473 lines
15 KiB
Python
Executable File
473 lines
15 KiB
Python
Executable File
import configparser
|
|
import os
|
|
import sys
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import random
|
|
import urllib.parse
|
|
from argparse import ArgumentParser, HelpFormatter
|
|
|
|
from mirva import get_version
|
|
from tqdm import tqdm
|
|
|
|
|
|
class Mirva:
|
|
def __init__(self):
|
|
self.resource_src = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "resources"
|
|
)
|
|
self.resource_dir = ".mirva"
|
|
self.medium_dir = os.path.join(self.resource_dir, "med")
|
|
self.config_file = os.path.join(self.resource_dir, "config.cfg")
|
|
self.config_backup = os.path.join(self.resource_dir, "config.cfg.bkp")
|
|
self.image_match = re.compile(
|
|
".*\.jpg$|.*\.jpeg$|.*\.png$|.*\.gif$|.*\.tif$", re.I
|
|
)
|
|
self.video_match = re.compile(".*\.mp4$", re.I)
|
|
self.site_defaults = {
|
|
"title": {"default": "", "help": "Title of the site"},
|
|
"sub_title": {
|
|
"default": "",
|
|
"help": "Subtitle of the site, shown under the title",
|
|
},
|
|
"intro": {
|
|
"default": "",
|
|
"help": "Intro text shown before first image",
|
|
},
|
|
"image_size": {
|
|
"default": "1920",
|
|
"help": "Resize images for faster loading. Use 'link' for symbolic links without resizing.",
|
|
},
|
|
"scroll": {
|
|
"default": "smooth",
|
|
"help": "Transition to next image with keyboard: smooth or auto",
|
|
},
|
|
}
|
|
|
|
## Init ##
|
|
self.get_options()
|
|
os.chdir(self.options.folder)
|
|
self.file_list = self.get_files()
|
|
|
|
if self.run_commands["config"]:
|
|
self.write_resources()
|
|
updated = self.create_config()
|
|
if updated:
|
|
print(
|
|
"Config created or updated: Check config contents: {}".format(
|
|
self.config_file
|
|
)
|
|
)
|
|
|
|
if self.run_commands["build"]:
|
|
self.get_config()
|
|
self.create_posts()
|
|
self.write_index()
|
|
self.write_mediums()
|
|
print("Gallery written.")
|
|
|
|
def create_config(self):
|
|
self.config = configparser.RawConfigParser()
|
|
self.config.read(self.config_file)
|
|
config_changed = False
|
|
if not "SITE" in self.config:
|
|
self.config["SITE"] = {}
|
|
config_changed = True
|
|
|
|
for key in self.site_defaults:
|
|
if not key in self.config["SITE"]:
|
|
config_changed = True
|
|
self.config["SITE"][key] = self.site_defaults[key]["default"]
|
|
|
|
if self.options.set:
|
|
for key, value in self.options.set:
|
|
if key not in self.site_defaults:
|
|
raise KeyError("Key '{}' is not a config keyword".format(key))
|
|
self.config["SITE"][key] = value
|
|
config_changed = True
|
|
|
|
for f in self.file_list:
|
|
if not f in self.config:
|
|
title, _ = os.path.splitext(f)
|
|
title = title.replace("_", " ")
|
|
self.config[f] = {"title": title, "description": ""}
|
|
config_changed = True
|
|
print("Added {}".format(f))
|
|
|
|
if self.options.purge:
|
|
unnecessary = []
|
|
for f in self.config:
|
|
if f in ("SITE", "DEFAULT"):
|
|
continue
|
|
if f not in self.file_list:
|
|
print("{} not found in files".format(f))
|
|
unnecessary.append(f)
|
|
for f in unnecessary:
|
|
del self.config[f]
|
|
config_changed = True
|
|
|
|
if self.options.exif:
|
|
self.append_exif()
|
|
config_changed = True
|
|
|
|
if config_changed:
|
|
self.write_config()
|
|
return config_changed
|
|
|
|
def create_posts(self):
|
|
self.posts = []
|
|
for c in self.config:
|
|
if c in self.file_list:
|
|
post = self.get_post(
|
|
c, self.config[c]["title"], self.config[c]["description"]
|
|
)
|
|
self.posts.append(post)
|
|
|
|
def get_config(self):
|
|
self.config = configparser.RawConfigParser()
|
|
self.config.read(self.config_file)
|
|
|
|
def write_config(self):
|
|
print(
|
|
"Modified config: {}".format(
|
|
os.path.join(self.options.folder, self.config_file)
|
|
)
|
|
)
|
|
if os.path.exists(self.config_file):
|
|
with open(self.config_file, "rt") as reader:
|
|
with open(self.config_backup, "wt") as writer:
|
|
writer.write(reader.read())
|
|
|
|
with open(self.config_file, "wt") as fp:
|
|
self.config.write(fp)
|
|
|
|
def get_files(self):
|
|
files = []
|
|
for f in sorted(os.listdir(".")):
|
|
if f.startswith("."):
|
|
continue
|
|
if not (self.image_match.match(f) or self.video_match.match(f)):
|
|
continue
|
|
files.append(f)
|
|
return files
|
|
|
|
def get_options(self):
|
|
parser = ArgumentParser(prog="mirva", formatter_class=SmartFormatter)
|
|
parser.add_argument(
|
|
"--version",
|
|
action="version",
|
|
version="%(prog)s {version}".format(version=get_version()),
|
|
)
|
|
parser.add_argument(
|
|
"--folder",
|
|
type=str,
|
|
default=".",
|
|
help="Folder for gallery. Default current folder.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--purge",
|
|
default=False,
|
|
action="store_true",
|
|
help="Remove non-existent files in image list. Required if images are removed.",
|
|
)
|
|
parser.add_argument(
|
|
"--exif",
|
|
default=False,
|
|
action="store_true",
|
|
help="Append EXIF information to image descriptions",
|
|
)
|
|
parser.add_argument(
|
|
"--set",
|
|
"-s",
|
|
default=None,
|
|
nargs=2,
|
|
action="append",
|
|
metavar=("key", "value"),
|
|
help="smart|Configurable options: \n"
|
|
+ "\n".join(
|
|
["{}: {}".format(k, v["help"]) for k, v in self.site_defaults.items()]
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--force",
|
|
default=False,
|
|
action="store_true",
|
|
help="Force regeneration of middle sized images",
|
|
)
|
|
parser.add_argument(
|
|
default="all",
|
|
dest="command",
|
|
action="store",
|
|
nargs="?",
|
|
choices=["all", "config", "build"],
|
|
help="Run only part of the process. Defaults to all. Config is run always if it doesn't exist.",
|
|
)
|
|
|
|
self.options = parser.parse_args()
|
|
self.run_commands = {
|
|
"config": self.options.command == "config"
|
|
or self.options.command == "all"
|
|
or not os.path.exists(self.config_file),
|
|
"build": self.options.command == "build" or self.options.command == "all",
|
|
}
|
|
|
|
def get_index(self, posts):
|
|
return """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
|
<!--
|
|
Design by TEMPLATED
|
|
http://templated.co
|
|
Released for free under the Creative Commons Attribution License
|
|
|
|
Name : Green Forest
|
|
Description: A two-column, fixed-width design with dark color scheme.
|
|
Version : 1.0
|
|
Released : 20110306
|
|
|
|
-->
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head>
|
|
<meta name="generator" content="Mirva" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
|
<link rel="shortcut icon" href="{resource}/mirva.ico"/>
|
|
<title>{page_title}</title>
|
|
<link href="{resource}/mirva.css" rel="stylesheet" type="text/css" media="screen" />
|
|
<script src="{resource}/mirva.js"></script>
|
|
</head>
|
|
<body data-scroll="{scroll}">
|
|
<div id="wrapper">
|
|
<div id="header">
|
|
<div id="logo">
|
|
<div id="page_title">
|
|
<h1>{page_title}</h1>
|
|
<p>{sub_title}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- end #header -->
|
|
<div id="page">
|
|
<div id="page-bgtop">
|
|
<div id="page-bgbtm">
|
|
<div id="content">
|
|
<div class="post" id="post_intro">
|
|
<div class="entry intro">
|
|
{intro}
|
|
</div>
|
|
</div>
|
|
{posts}
|
|
|
|
<div style="clear: both;"> </div>
|
|
</div>
|
|
<!-- end #content -->
|
|
<div style="clear: both;"> </div>
|
|
</div>
|
|
</div>
|
|
<!-- end #page -->
|
|
</div>
|
|
</body>
|
|
</html>""".format(
|
|
page_title=self.config["SITE"]["title"],
|
|
sub_title=self.config["SITE"]["sub_title"],
|
|
intro=self.config["SITE"]["intro"],
|
|
scroll=self.config["SITE"]["scroll"],
|
|
posts="\n".join(posts),
|
|
resource=self.resource_dir,
|
|
)
|
|
|
|
def get_post(self, image, title, content):
|
|
image = urllib.parse.quote(image)
|
|
if self.video_match.match(image):
|
|
return """
|
|
<div class="post" id="post_{image}">
|
|
<div class="navigation"> </div>
|
|
<div class="image_wrapper center"><a href="{image}">
|
|
<video class=post_image controls preload="metadata">
|
|
<source src="{image}" type="video/mp4" >
|
|
</video>
|
|
</a></div>
|
|
<div class="meta"><div class="post_title" id="title_{image}">{title}</div></div>
|
|
<div style="clear: both;"> </div>
|
|
<div class="entry">{content}</div>
|
|
</div>""".format(
|
|
image=image, title=title, content=content
|
|
)
|
|
|
|
return """
|
|
<div class="post" id="post_{image}">
|
|
<div class="navigation"> </div>
|
|
<div class="image_wrapper center"><a href="{image}">
|
|
<img loading=lazy class=post_image src="{med_dir}/{image}.jpg">
|
|
</a></div>
|
|
<div class="meta"><div class="post_title" id="title_{image}">{title}</div></div>
|
|
<div style="clear: both;"> </div>
|
|
<div class="entry">{content}</div>
|
|
</div>""".format(
|
|
image=image, title=title, content=content, med_dir=self.medium_dir
|
|
)
|
|
|
|
def is_created_with_mirva(self):
|
|
with open("index.html", "rt") as fp:
|
|
for line in fp.readlines():
|
|
if line.strip() == '<meta name="generator" content="Mirva" />':
|
|
return True
|
|
return False
|
|
|
|
def write_index(self):
|
|
if os.path.exists("index.html"):
|
|
if not self.is_created_with_mirva():
|
|
print(
|
|
"index.html exists, and it's not written with Mirva. Not overwriting."
|
|
)
|
|
sys.exit(1)
|
|
|
|
with open("index.html", "wt") as fp:
|
|
fp.write(self.get_index(self.posts))
|
|
|
|
def write_resources(self):
|
|
try:
|
|
os.makedirs(self.resource_dir)
|
|
except Exception:
|
|
pass
|
|
|
|
for f in (
|
|
"mirva.css",
|
|
"arrow_up.png",
|
|
"arrow_down.png",
|
|
"mirva.ico",
|
|
"mirva.js",
|
|
):
|
|
if os.path.exists(os.path.join(self.resource_dir, f)):
|
|
continue
|
|
shutil.copy(
|
|
os.path.join(self.resource_src, f),
|
|
os.path.join(self.resource_dir, f),
|
|
)
|
|
|
|
if not os.path.exists(os.path.join(self.resource_dir, "banner.jpg")):
|
|
try:
|
|
f = random.choice(self.file_list)
|
|
outfile = os.path.join(self.resource_dir, "banner.jpg")
|
|
res = "1920x"
|
|
convargs = [
|
|
"convert",
|
|
"{}[0]".format(f),
|
|
"-background",
|
|
"white",
|
|
"-flatten",
|
|
"-resize",
|
|
res,
|
|
"+contrast",
|
|
"+contrast",
|
|
"+contrast",
|
|
"-quality",
|
|
"85",
|
|
outfile,
|
|
]
|
|
subprocess.run(convargs)
|
|
print("Random banner written to {}".format(outfile))
|
|
except Exception:
|
|
shutil.copy(
|
|
os.path.join(self.resource_src, "banner.jpg"),
|
|
os.path.join(self.resource_dir, "banner.jpg"),
|
|
)
|
|
|
|
def write_mediums(self):
|
|
try:
|
|
os.makedirs(self.medium_dir)
|
|
except Exception:
|
|
pass
|
|
|
|
r = self.config["SITE"].get("image_size", 1920)
|
|
link = str(r).lower() == "link"
|
|
if link:
|
|
r = 0
|
|
|
|
res = "{:d}x{:d}>".format(int(r), int(r))
|
|
force = self.options.force
|
|
for f in tqdm(self.file_list):
|
|
if self.video_match.match(f):
|
|
continue
|
|
outfile = os.path.join(self.medium_dir, "{}.jpg".format(f))
|
|
if force:
|
|
try:
|
|
os.remove(outfile)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
if not os.path.exists(outfile):
|
|
if link:
|
|
os.symlink("../../{}".format(f), outfile)
|
|
continue
|
|
|
|
convargs = [
|
|
"convert",
|
|
"{}[0]".format(f),
|
|
"-background",
|
|
"white",
|
|
"-flatten",
|
|
"-resize",
|
|
res,
|
|
"-quality",
|
|
"85",
|
|
outfile,
|
|
]
|
|
subprocess.run(convargs)
|
|
sys.stdout.write("\n")
|
|
|
|
def append_exif(self):
|
|
exif_format = """
|
|
<ul>
|
|
<li>Date: %[EXIF:DateTimeOriginal]
|
|
<li>Camera: %[EXIF:Make] %[EXIF:Model]
|
|
<li>Parameters: %[EXIF:ExposureTime]s / F%[EXIF:FNumber] / Focal %[EXIF:FocalLength]
|
|
<li>Size: %w x %h / {size}
|
|
</ul>
|
|
"""
|
|
|
|
for f in self.config:
|
|
if f in self.file_list:
|
|
sys.stdout.write(".")
|
|
sys.stdout.flush()
|
|
file_size = human_size(f)
|
|
p = subprocess.run(
|
|
[
|
|
"identify",
|
|
"-format",
|
|
exif_format.format(size=file_size),
|
|
"{}[0]".format(f),
|
|
],
|
|
capture_output=True,
|
|
)
|
|
|
|
self.config[f]["description"] += p.stdout.decode("utf-8")
|
|
sys.stdout.write("\n")
|
|
|
|
|
|
def human_size(file_name, precision=1):
|
|
size = os.path.getsize(file_name)
|
|
|
|
if size == None:
|
|
return "nan"
|
|
sign = ""
|
|
if size < 0:
|
|
sign = "-"
|
|
size = -size
|
|
suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"]
|
|
suffixIndex = 0
|
|
defPrecision = 0
|
|
while size > 1024:
|
|
suffixIndex += 1
|
|
size = size / 1024.0
|
|
defPrecision = precision
|
|
return "%s%.*f%s" % (sign, defPrecision, size, suffixes[suffixIndex])
|
|
|
|
|
|
class SmartFormatter(HelpFormatter):
|
|
def _split_lines(self, help, width):
|
|
if help.startswith("smart|"):
|
|
return help[6:].splitlines()
|
|
return HelpFormatter._split_lines(self, help, width)
|