From ad2e77ebd0796e33ed621d6f9de01eb750730d6d Mon Sep 17 00:00:00 2001
From: Anton Sarukhanov <code@ant.sr>
Date: Sun, 19 Apr 2020 22:00:25 -0400
Subject: [PATCH] WIP: Photo Gallery

---
 content/Photos/ukraine-2019.md |   5 +
 my_plugins/photos/photos.py    | 585 +++++++++++++++++++++++++++++++++
 2 files changed, 590 insertions(+)
 create mode 100644 content/Photos/ukraine-2019.md
 create mode 100644 my_plugins/photos/photos.py

diff --git a/content/Photos/ukraine-2019.md b/content/Photos/ukraine-2019.md
new file mode 100644
index 0000000..5842185
--- /dev/null
+++ b/content/Photos/ukraine-2019.md
@@ -0,0 +1,5 @@
+Title: Ukraine
+Description: Kyiv, Turbiv, Vinnitsa
+gallery: {photo}/ukraine-2019
+
+test
diff --git a/my_plugins/photos/photos.py b/my_plugins/photos/photos.py
new file mode 100644
index 0000000..5692d3c
--- /dev/null
+++ b/my_plugins/photos/photos.py
@@ -0,0 +1,585 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import itertools
+import logging
+import multiprocessing
+import os
+import pprint
+import re
+import shutil
+
+from pelican.generators import ArticlesGenerator
+from pelican.generators import PagesGenerator
+from pelican.settings import DEFAULT_CONFIG
+from pelican import signals
+from pelican.utils import pelican_open
+
+logger = logging.getLogger(__name__)
+
+try:
+    from PIL import Image
+    from PIL import ImageOps
+except ImportError:
+    logger.error('PIL/Pillow not found')
+
+try:
+    import piexif
+except ImportError:
+    ispiexif = False
+    logger.warning('piexif not found! Cannot use exif manipulation features')
+else:
+    ispiexif = True
+    logger.debug('piexif found.')
+
+
+def initialized(pelican):
+    """Initialize the plugin.
+
+    This should be triggered on Pelican's `initialized` signal."""
+
+    p = os.path.expanduser('~/Pictures')
+
+    DEFAULT_CONFIG.setdefault('PHOTO_LIBRARY', p)
+    DEFAULT_CONFIG.setdefault('PHOTO_GALLERY', (1024, 768, 80))
+    DEFAULT_CONFIG.setdefault('PHOTO_ARTICLE', (760, 506, 80))
+    DEFAULT_CONFIG.setdefault('PHOTO_THUMB', (192, 144, 60))
+    DEFAULT_CONFIG.setdefault('PHOTO_SQUARE_THUMB', False)
+    DEFAULT_CONFIG.setdefault('PHOTO_GALLERY_TITLE', '')
+    DEFAULT_CONFIG.setdefault('PHOTO_RESIZE_JOBS', 1)
+    DEFAULT_CONFIG.setdefault('VIDEO_COPY_JOBS', 1)
+    DEFAULT_CONFIG.setdefault('PHOTO_EXIF_KEEP', False)
+    DEFAULT_CONFIG.setdefault('PHOTO_EXIF_REMOVE_GPS', False)
+    DEFAULT_CONFIG.setdefault('PHOTO_EXIF_AUTOROTATE', True)
+    DEFAULT_CONFIG.setdefault('PHOTO_EXIF_COPYRIGHT', False)
+    DEFAULT_CONFIG.setdefault('PHOTO_EXIF_COPYRIGHT_AUTHOR', DEFAULT_CONFIG['SITENAME'])
+    DEFAULT_CONFIG.setdefault('PHOTO_LIGHTBOX_GALLERY_ATTR', 'data-lightbox')
+    DEFAULT_CONFIG.setdefault('PHOTO_LIGHTBOX_CAPTION_ATTR', 'data-title')
+
+    DEFAULT_CONFIG['queue_resize'] = {}
+    DEFAULT_CONFIG['queue_copy'] = {}
+    DEFAULT_CONFIG['created_galleries'] = {}
+    DEFAULT_CONFIG['plugin_dir'] = os.path.dirname(os.path.realpath(__file__))
+
+    if pelican:
+        pelican.settings.setdefault('PHOTO_LIBRARY', p)
+        pelican.settings.setdefault('PHOTO_GALLERY', (1024, 768, 80))
+        pelican.settings.setdefault('PHOTO_ARTICLE', (760, 506, 80))
+        pelican.settings.setdefault('PHOTO_THUMB', (192, 144, 60))
+        pelican.settings.setdefault('PHOTO_SQUARE_THUMB', False)
+        pelican.settings.setdefault('PHOTO_GALLERY_TITLE', '')
+        pelican.settings.setdefault('PHOTO_RESIZE_JOBS', 1)
+        pelican.settings.setdefault('VIDEO_COPY_JOBS', 1)
+        pelican.settings.setdefault('PHOTO_EXIF_KEEP', False)
+        pelican.settings.setdefault('PHOTO_EXIF_REMOVE_GPS', False)
+        pelican.settings.setdefault('PHOTO_EXIF_AUTOROTATE', True)
+        pelican.settings.setdefault('PHOTO_EXIF_COPYRIGHT', False)
+        pelican.settings.setdefault('PHOTO_EXIF_COPYRIGHT_AUTHOR', pelican.settings['AUTHOR'])
+        pelican.settings.setdefault('PHOTO_LIGHTBOX_GALLERY_ATTR', 'data-lightbox')
+        pelican.settings.setdefault('PHOTO_LIGHTBOX_CAPTION_ATTR', 'data-title')
+
+
+def read_notes(filename, msg=None):
+    notes = {}
+    try:
+        with pelican_open(filename) as text:
+            for line in text.splitlines():
+                if line.startswith('#'):
+                    continue
+
+                m = line.split(':', 1)
+                if len(m) > 1:
+                    item = m[0].strip()
+                    note = m[1].strip()
+                    if item and note:
+                        notes[item] = note
+                else:
+                    notes[line] = ''
+    except Exception as e:
+        if msg:
+            logger.info('{} at file {}'.format(msg, filename))
+        logger.debug('read_notes issue: {} at file {}. Debug message:{}'.format(msg, filename, e))
+    return notes
+
+
+def enqueue_resize(orig, resized, spec=(640, 480, 80)):
+    logger.debug('photos: enqueue_resize({}, {}, {}'
+                 .format(orig, resized, spec))
+    if resized not in DEFAULT_CONFIG['queue_resize']:
+        DEFAULT_CONFIG['queue_resize'][resized] = (orig, spec)
+    elif DEFAULT_CONFIG['queue_resize'][resized] != (orig, spec):
+        logger.error('photos: resize conflict for {}, {}-{} is not {}-{}'
+                     .format(resized,
+                             DEFAULT_CONFIG['queue_resize'][resized][0],
+                             DEFAULT_CONFIG['queue_resize'][resized][1],
+                             orig,
+                             spec))
+
+
+def enqueue_copy(orig, copied):
+    logger.debug('photos: enqueue_copy({}, {}'.format(orig, copied))
+    if copied not in DEFAULT_CONFIG['queue_copy']:
+        DEFAULT_CONFIG['queue_copy'][copied] = orig
+    elif DEFAULT_CONFIG['queue_copy'][copied] != orig:
+        logger.error('photos: copy conflict for {}, {} is not {}'
+                     .format(copied, DEFAULT_CONFIG['queue_copy'][copied],
+                             orig))
+
+
+def rotate_image(img, exif_dict):
+
+    if "exif" in img.info and piexif.ImageIFD.Orientation in exif_dict["0th"]:
+        orientation = exif_dict["0th"].pop(piexif.ImageIFD.Orientation)
+        if orientation == 2:
+            img = img.transpose(Image.FLIP_LEFT_RIGHT)
+        elif orientation == 3:
+            img = img.rotate(180)
+        elif orientation == 4:
+            img = img.rotate(180).transpose(Image.FLIP_LEFT_RIGHT)
+        elif orientation == 5:
+            img = img.rotate(-90).transpose(Image.FLIP_LEFT_RIGHT)
+        elif orientation == 6:
+            img = img.rotate(-90, expand=True)
+        elif orientation == 7:
+            img = img.rotate(90).transpose(Image.FLIP_LEFT_RIGHT)
+        elif orientation == 8:
+            img = img.rotate(90)
+
+    return (img, exif_dict)
+
+
+def manipulate_exif(img, settings):
+
+    try:
+        exif = piexif.load(img.info['exif'])
+    except Exception:
+        logger.debug('EXIF information not found')
+        exif = {}
+
+    if settings['PHOTO_EXIF_AUTOROTATE']:
+        img, exif = rotate_image(img, exif)
+
+    if settings['PHOTO_EXIF_REMOVE_GPS']:
+        exif.pop('GPS')
+
+    return (img, piexif.dump(exif))
+
+
+def resize_worker(orig, resized, spec, settings):
+
+    logger.info('photos: make photo {} -> {}'.format(orig, resized))
+    im = Image.open(orig)
+
+    if ispiexif and settings['PHOTO_EXIF_KEEP'] and im.format == 'JPEG':
+        # Only works with JPEG exif for sure.
+        try:
+            im, exif_copy = manipulate_exif(im, settings)
+        except Exception:
+            logger.info('photos: no EXIF or EXIF error in {}'.format(orig))
+            exif_copy = b''
+    else:
+        exif_copy = b''
+
+    icc_profile = im.info.get("icc_profile", None)
+
+    if settings['PHOTO_SQUARE_THUMB'] and spec == settings['PHOTO_THUMB']:
+        im = ImageOps.fit(im, (spec[0], spec[1]), Image.ANTIALIAS)
+
+    im.thumbnail((spec[0], spec[1]), Image.ANTIALIAS)
+    directory = os.path.split(resized)[0]
+
+    if not os.path.exists(directory):
+        try:
+            os.makedirs(directory)
+        except Exception:
+            logger.exception('Could not create {}'.format(directory))
+    else:
+        logger.debug('Directory already exists at {}'.format(os.path.split(resized)[0]))
+
+    im.save(resized, 'JPEG', quality=spec[2], icc_profile=icc_profile, exif=exif_copy)
+
+
+def copy_worker(orig, resized, settings):
+    logger.info('photos: copy video {} -> {}'.format(orig, resized))
+    directory = os.path.split(resized)[0]
+
+    if not os.path.exists(directory):
+        try:
+            os.makedirs(directory)
+        except Exception:
+            logger.exception('Could not create {}'.format(directory))
+    else:
+        logger.debug('Directory already exists at {}'.format(os.path.split(resized)[0]))
+
+    shutil.copyfile(orig, resized)
+
+
+def resize_photos(generator, writer):
+    if generator.settings['PHOTO_RESIZE_JOBS'] == -1:
+        debug = True
+        generator.settings['PHOTO_RESIZE_JOBS'] = 1
+    else:
+        debug = False
+
+    pool = multiprocessing.Pool(generator.settings['PHOTO_RESIZE_JOBS'])
+    logger.debug('Debug Status: {}'.format(debug))
+    for resized, what in DEFAULT_CONFIG['queue_resize'].items():
+        resized = os.path.join(generator.output_path, resized)
+        orig, spec = what
+        if (not os.path.isfile(resized) or os.path.getmtime(orig) > os.path.getmtime(resized)):
+            if debug:
+                resize_worker(orig, resized, spec, generator.settings)
+            else:
+                pool.apply_async(resize_worker, (orig, resized, spec, generator.settings))
+
+    pool.close()
+    pool.join()
+
+
+def copy_videos(generator, writer):
+    if generator.settings['VIDEO_COPY_JOBS'] == -1:
+        debug = True
+        generator.settings['VIDEO_COPY_JOBS'] = 1
+    else:
+        debug = False
+    pool = multiprocessing.Pool(generator.settings['VIDEO_COPY_JOBS'])
+    logger.debug('Debug Status: {}'.format(debug))
+    for copied, orig in DEFAULT_CONFIG['queue_copy'].items():
+        copied = os.path.join(generator.output_path, copied)
+        if (not os.path.isfile(copied) or os.path.getmtime(orig) > os.path.getmtime(copied)):
+            if debug:
+                copy_worker(orig, copied, generator.settings)
+            else:
+                pool.apply_async(resize_worker, (orig, copied, generator.settings))
+
+    pool.close()
+    pool.join()
+
+
+def detect_content(content):
+    """
+
+
+    Triggered by Pelican's `content_object_init` signal."""
+
+    hrefs = None
+
+    def replacer(m):
+        what = m.group('what')
+        value = m.group('value')
+        tag = m.group('tag')
+        output = m.group(0)
+
+        if what in ('photo', 'lightbox'):
+            if value.startswith('/'):
+                value = value[1:]
+
+            path = os.path.join(
+                os.path.expanduser(settings['PHOTO_LIBRARY']),
+                value
+            )
+
+            if os.path.isfile(path):
+                photo_prefix = os.path.splitext(value)[0].lower()
+
+                if what == 'photo':
+                    photo_article = photo_prefix + 'a.jpg'
+                    enqueue_resize(
+                        path,
+                        os.path.join('photos', photo_article),
+                        settings['PHOTO_ARTICLE']
+                    )
+
+                    output = ''.join((
+                        '<',
+                        m.group('tag'),
+                        m.group('attrs_before'),
+                        m.group('src'),
+                        '=',
+                        m.group('quote'),
+                        os.path.join(settings['SITEURL'], 'photos', photo_article),
+                        m.group('quote'),
+                        m.group('attrs_after'),
+                    ))
+
+                elif what == 'lightbox' and tag == 'img':
+                    photo_gallery = photo_prefix + '.jpg'
+                    enqueue_resize(
+                        path,
+                        os.path.join('photos', photo_gallery),
+                        settings['PHOTO_GALLERY']
+                    )
+
+                    photo_thumb = photo_prefix + 't.jpg'
+                    enqueue_resize(
+                        path,
+                        os.path.join('photos', photo_thumb),
+                        settings['PHOTO_THUMB']
+                    )
+
+                    lightbox_attr_list = ['']
+
+                    gallery_name = value.split('/')[0]
+                    lightbox_attr_list.append('{}="{}"'.format(
+                        settings['PHOTO_LIGHTBOX_GALLERY_ATTR'],
+                        gallery_name
+                    ))
+
+                    captions = read_notes(
+                        os.path.join(os.path.dirname(path), 'captions.txt'),
+                        msg='photos: No captions for gallery'
+                    )
+                    caption = captions.get(os.path.basename(path)) if captions else None
+                    if caption:
+                        lightbox_attr_list.append('{}="{}"'.format(
+                            settings['PHOTO_LIGHTBOX_CAPTION_ATTR'],
+                            caption
+                        ))
+
+                    lightbox_attrs = ' '.join(lightbox_attr_list)
+
+                    output = ''.join((
+                        '<a href=',
+                        m.group('quote'),
+                        os.path.join(settings['SITEURL'], 'photos', photo_gallery),
+                        m.group('quote'),
+                        lightbox_attrs,
+                        '><img',
+                        m.group('attrs_before'),
+                        'src=',
+                        m.group('quote'),
+                        os.path.join(settings['SITEURL'], 'photos', photo_thumb),
+                        m.group('quote'),
+                        m.group('attrs_after'),
+                        '</a>'
+                    ))
+
+            else:
+                logger.error('photos: No photo %s', path)
+
+        return output
+
+    if hrefs is None:
+        regex = r"""
+            <\s*
+            (?P<tag>[^\s\>]+)  # detect the tag
+            (?P<attrs_before>[^\>]*)
+            (?P<src>href|src)  # match tag with src and href attr
+            \s*=
+            (?P<quote>["\'])  # require value to be quoted
+            (?P<path>{0}(?P<value>.*?))  # the url value
+            (?P=quote)
+            (?P<attrs_after>[^\>]*>)
+        """.format(
+            content.settings['INTRASITE_LINK_REGEX']
+        )
+        hrefs = re.compile(regex, re.X)
+
+    if content._content and ('{photo}' in content._content or '{lightbox}' in content._content):
+        settings = content.settings
+        content._content = hrefs.sub(replacer, content._content)
+
+
+def galleries_string_decompose(gallery_string):
+    splitter_regex = re.compile(r'[\s,]*?({photo}|{filename})')
+    title_regex = re.compile(r'{(.+)}')
+    galleries = map(str.strip, filter(None, splitter_regex.split(gallery_string)))
+    galleries = [gallery[1:] if gallery.startswith('/') else gallery for gallery in galleries]
+    if len(galleries) % 2 == 0 and ' ' not in galleries:
+        galleries = zip(zip(['type'] * len(galleries[0::2]), galleries[0::2]), zip(['location'] * len(galleries[0::2]), galleries[1::2]))
+        galleries = [dict(gallery) for gallery in galleries]
+        for gallery in galleries:
+            title = re.search(title_regex, gallery['location'])
+            if title:
+                gallery['title'] = title.group(1)
+                gallery['location'] = re.sub(title_regex, '', gallery['location']).strip()
+            else:
+                gallery['title'] = DEFAULT_CONFIG['PHOTO_GALLERY_TITLE']
+        return galleries
+    else:
+        logger.error('Unexpected gallery location format! \n{}'.format(pprint.pformat(galleries)))
+
+
+def process_gallery(generator, content, location):
+
+    content.photo_gallery = []
+
+    galleries = galleries_string_decompose(location)
+
+    for gallery in galleries:
+
+        if gallery['location'] in DEFAULT_CONFIG['created_galleries']:
+            content.photo_gallery.append(
+                (gallery['location'],
+                 DEFAULT_CONFIG['created_galleries'][gallery]))
+            continue
+
+        if gallery['type'] == '{photo}':
+            dir_gallery = os.path.join(
+                os.path.expanduser(generator.settings['PHOTO_LIBRARY']),
+                gallery['location'])
+            rel_gallery = gallery['location']
+        elif gallery['type'] == '{filename}':
+            base_path = os.path.join(generator.path, content.relative_dir)
+            dir_gallery = os.path.join(base_path, gallery['location'])
+            rel_gallery = os.path.join(content.relative_dir,
+                                       gallery['location'])
+
+        if os.path.isdir(dir_gallery):
+            logger.info('photos: Gallery detected: {}'.format(rel_gallery))
+            dir_photo = os.path.join('photos', rel_gallery.lower())
+            dir_thumb = os.path.join('photos', rel_gallery.lower())
+            exifs = read_notes(os.path.join(dir_gallery, 'exif.txt'),
+                               msg='photos: No EXIF for gallery')
+            captions = read_notes(os.path.join(dir_gallery, 'captions.txt'),
+                                  msg='photos: No captions for gallery')
+            blacklist = read_notes(os.path.join(dir_gallery, 'blacklist.txt'),
+                                   msg='photos: No blacklist for gallery')
+            content_gallery = []
+
+            title = gallery['title']
+            for item in sorted(os.listdir(dir_gallery)):
+                if item.startswith('.'):
+                    continue
+                if item.endswith('.txt'):
+                    continue
+                if item in blacklist:
+                    continue
+                if item.endswith('.mp4.jpg'):
+                    if os.path.isfile(
+                         os.path.join(dir_gallery, os.path.splitext(item)[0])):
+                        continue
+                thumb = os.path.splitext(item)[0] + 't.jpg'
+                if item.endswith('.jpg'):
+                    enqueue_resize(
+                        os.path.join(dir_gallery, item),
+                        os.path.join(dir_photo, item),
+                        generator.settings['PHOTO_GALLERY'])
+                    enqueue_resize(
+                        os.path.join(dir_gallery, item),
+                        os.path.join(dir_thumb, thumb),
+                        generator.settings['PHOTO_THUMB'])
+                elif item.endswith('.mp4'):
+                    enqueue_copy(
+                        os.path.join(dir_gallery, item),
+                        os.path.join(generator.output_path, dir_photo, item),
+                    )
+                    still = item + '.jpg'
+                    if os.path.isfile(os.path.join(dir_gallery, still)):
+                        still = os.path.join(dir_gallery, still)
+                    else:
+                        here = os.path.dirname(__file__)
+                        still = os.path.join(here, 'images/video-icon.png')
+                    enqueue_resize(
+                        still,
+                        os.path.join(dir_thumb, thumb),
+                        generator.settings['PHOTO_THUMB'])
+                content_gallery.append((
+                    item,
+                    os.path.join(dir_photo, item),
+                    os.path.join(dir_thumb, thumb),
+                    exifs.get(item, ''),
+                    captions.get(item, '')))
+
+            # DEBUG TODO
+            # for c in content_gallery:
+            #     raise(Exception(c))
+
+            content.photo_gallery.append((title, content_gallery))
+            logger.debug('Gallery Data: '
+                         .format(pprint.pformat(content.photo_gallery)))
+            DEFAULT_CONFIG['created_galleries']['gallery'] = content_gallery
+        else:
+            logger.error('photos: Gallery does not exist: {} at {}'
+                         .format(gallery['location'], dir_gallery))
+
+
+def detect_gallery(generator, content):
+    if 'gallery' in content.metadata:
+        gallery = content.metadata.get('gallery')
+        if gallery.startswith('{photo}') or gallery.startswith('{filename}'):
+            process_gallery(generator, content, gallery)
+        elif gallery:
+            logger.error('photos: Gallery tag not recognized: {}'
+                         .format(gallery))
+
+
+def image_clipper(x):
+    return x[8:] if x[8] == '/' else x[7:]
+
+
+def file_clipper(x):
+    return x[11:] if x[10] == '/' else x[10:]
+
+
+def process_image(generator, content, image):
+
+    if image.startswith('{photo}'):
+        path = os.path.join(
+            os.path.expanduser(generator.settings['PHOTO_LIBRARY']),
+            image_clipper(image))
+        image = image_clipper(image)
+    elif image.startswith('{filename}'):
+        path = os.path.join(content.relative_dir, file_clipper(image))
+        image = file_clipper(image)
+
+    if os.path.isfile(path):
+        photo = os.path.splitext(image)[0].lower() + 'a.jpg'
+        thumb = os.path.splitext(image)[0].lower() + 't.jpg'
+        content.photo_image = (
+            os.path.basename(image).lower(),
+            os.path.join('photos', photo),
+            os.path.join('photos', thumb))
+        enqueue_resize(
+            path,
+            os.path.join('photos', photo),
+            generator.settings['PHOTO_ARTICLE'])
+        enqueue_resize(
+            path,
+            os.path.join('photos', thumb),
+            generator.settings['PHOTO_THUMB'])
+    else:
+        logger.error('photo: No photo for {} at {}'
+                     .format(content.source_path, path))
+
+
+def detect_image(generator, content):
+    image = content.metadata.get('image', None)
+    if image:
+        if image.startswith('{photo}') or image.startswith('{filename}'):
+            process_image(generator, content, image)
+        else:
+            logger.error('photos: Image tag not recognized: {}'.format(image))
+
+
+def detect_images_and_galleries(generators):
+    """Runs generator on both pages and articles."""
+    for generator in generators:
+        if isinstance(generator, ArticlesGenerator):
+            for article in itertools.chain(generator.articles,
+                                           generator.translations,
+                                           generator.drafts):
+                detect_image(generator, article)
+                detect_gallery(generator, article)
+        elif isinstance(generator, PagesGenerator):
+            for page in itertools.chain(generator.pages,
+                                        generator.translations,
+                                        generator.hidden_pages):
+                detect_image(generator, page)
+                detect_gallery(generator, page)
+
+
+def register():
+    """Map signals to plugin logic.
+
+    Required by Pelican.
+    Uses the new style of registration based on GitHub Pelican issue #314."""
+    signals.initialized.connect(initialized)
+    try:
+        signals.content_object_init.connect(detect_content)
+        signals.all_generators_finalized.connect(detect_images_and_galleries)
+        signals.article_writer_finalized.connect(resize_photos)
+        signals.article_writer_finalized.connect(copy_videos)
+    except Exception as e:
+        logger.exception('Plugin failed to execute: {}'
+                         .format(pprint.pformat(e)))
-- 
GitLab