584 lines
20 KiB
Python
Executable File
584 lines
20 KiB
Python
Executable File
import configparser
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
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.json")
|
|
self.config_file_old = os.path.join(self.resource_dir, "config.cfg")
|
|
self.config_backup = os.path.join(self.resource_dir, "config.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)
|
|
if os.path.basename(os.getcwd()) == self.resource_dir:
|
|
os.chdir("..")
|
|
self.site_defaults["title"]["default"] = os.path.basename(os.getcwd())
|
|
self.file_list = self.get_files()
|
|
self.folder_list = self.get_folders()
|
|
|
|
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.create_folders()
|
|
self.write_index()
|
|
self.write_mediums()
|
|
print("Gallery written.")
|
|
|
|
def create_config(self):
|
|
self.get_config()
|
|
config_changed = False
|
|
|
|
## SITE
|
|
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
|
|
|
|
## FOLDERS
|
|
if not "FOLDERS" in self.config:
|
|
self.config["FOLDERS"] = []
|
|
config_changed = True
|
|
|
|
path_list = [i["path"] for i in self.config["FOLDERS"]]
|
|
if self.options.add_back:
|
|
if not ".." in path_list:
|
|
self.config["FOLDERS"].insert(0, {"path": "..", "title": "Back", "thumb": "[guess]"})
|
|
config_changed = True
|
|
if self.options.add_folders:
|
|
for d in self.folder_list:
|
|
if not d in path_list:
|
|
self.config["FOLDERS"].append({"path": d, "title": d, "thumb": "[guess]"})
|
|
config_changed = True
|
|
|
|
## IMAGES
|
|
if not "IMAGES" in self.config:
|
|
self.config["IMAGES"] = []
|
|
config_changed = True
|
|
|
|
path_list = [i["path"] for i in self.config["IMAGES"]]
|
|
for f in self.file_list:
|
|
if not f in path_list:
|
|
title, _ = os.path.splitext(f)
|
|
title = title.replace("_", " ")
|
|
self.config["IMAGES"].append({"path": f, "title": title, "description": ""})
|
|
config_changed = True
|
|
print("Added {}".format(f))
|
|
|
|
if self.options.purge:
|
|
unnecessary_files = []
|
|
unnecessary_folders = []
|
|
for i, f in enumerate(self.config["IMAGES"]):
|
|
if f["path"] not in self.file_list:
|
|
print("{} not found in files".format(f["path"]))
|
|
unnecessary_files.append(i)
|
|
for i, d in enumerate(self.config["FOLDERS"]):
|
|
if d["path"] == "..":
|
|
continue
|
|
if d["path"] not in self.folder_list:
|
|
unnecessary_folders.append(i)
|
|
if len(unnecessary_files) > 0:
|
|
config_changed = True
|
|
if len(unnecessary_folders) > 0:
|
|
config_changed = True
|
|
|
|
self.config["IMAGES"] = [d for i, d in enumerate(self.config["IMAGES"]) if i not in unnecessary_files]
|
|
self.config["FOLDERS"] = [d for i, d in enumerate(self.config["FOLDERS"]) if i not in unnecessary_folders]
|
|
|
|
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["IMAGES"]:
|
|
if c["path"] in self.file_list:
|
|
post = self.get_post(c["path"], c["title"], c["description"])
|
|
self.posts.append(post)
|
|
|
|
def create_folders(self):
|
|
self.folders = []
|
|
for c in self.config["FOLDERS"]:
|
|
self.folders.append(self.get_folder(c["path"], c["title"], c["thumb"]))
|
|
|
|
def get_config(self):
|
|
if os.path.exists(self.config_file_old):
|
|
# Migration from old style config
|
|
self.config_old = configparser.RawConfigParser()
|
|
self.config_old.read(self.config_file_old)
|
|
self.config = {}
|
|
self.config["SITE"] = dict(self.config_old.items("SITE"))
|
|
self.config["IMAGES"] = []
|
|
|
|
for f in self.config_old.sections():
|
|
if f in ("IMAGES", "SITE"):
|
|
continue
|
|
self.config["IMAGES"].append(
|
|
{
|
|
"path": f,
|
|
"title": self.config_old[f]["title"],
|
|
"description": self.config_old[f]["description"],
|
|
}
|
|
)
|
|
os.remove(self.config_file_old)
|
|
return
|
|
if os.path.exists(self.config_file):
|
|
with open(self.config_file, "rt") as fp:
|
|
self.config = json.load(fp)
|
|
return
|
|
self.config = {}
|
|
|
|
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:
|
|
json.dump(self.config, fp, indent=2)
|
|
|
|
def get_files(self):
|
|
files = []
|
|
for f in sorted(os.listdir(".")):
|
|
if f.startswith("."):
|
|
continue
|
|
if not os.path.isfile(f):
|
|
continue
|
|
if not (self.image_match.match(f) or self.video_match.match(f)):
|
|
continue
|
|
files.append(f)
|
|
return files
|
|
|
|
def get_folders(self):
|
|
folders = []
|
|
for f in sorted(os.listdir(".")):
|
|
if f.startswith("."):
|
|
continue
|
|
if not os.path.isdir(f):
|
|
continue
|
|
folders.append(f)
|
|
return folders
|
|
|
|
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(
|
|
"--add-back",
|
|
default=False,
|
|
action="store_true",
|
|
help="Add 'Back' link to parent folder.",
|
|
)
|
|
parser.add_argument(
|
|
"--add-folders",
|
|
default=False,
|
|
action="store_true",
|
|
help="Add links to subfolders.",
|
|
)
|
|
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):
|
|
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" />
|
|
<link href="{resource}/user.css" rel="stylesheet" type="text/css" media="screen" />
|
|
<script src="{resource}/mirva.js"></script>
|
|
<script src="{resource}/user.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 id="post_folders">
|
|
<div class="entry folders">
|
|
{folders}
|
|
</div>
|
|
</div>
|
|
<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"],
|
|
folders="\n".join(self.folders),
|
|
posts="\n".join(self.posts),
|
|
resource=self.resource_dir,
|
|
)
|
|
|
|
def get_post(self, image, title, content):
|
|
image = urllib.parse.quote(image)
|
|
if self.video_match.match(image):
|
|
insert = """
|
|
<video class=post_image controls preload="metadata">
|
|
<source src="{image}" type="video/mp4" >
|
|
</video>
|
|
"""
|
|
else:
|
|
insert = """
|
|
<img loading=lazy class=post_image src="{med_dir}/{image}.jpg">
|
|
"""
|
|
return (
|
|
"""
|
|
<div class="post" id="post_{image}">
|
|
<div class="navigation"> </div>
|
|
<div class="image_wrapper center"><a href="{image}">
|
|
"""
|
|
+ insert
|
|
+ """
|
|
</a></div>
|
|
<div class="meta">
|
|
<div class="post_share"></div>
|
|
<div class="post_title">{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 get_folder(self, path, title, thumb):
|
|
if thumb == "[guess]":
|
|
try:
|
|
img = (
|
|
"style=\"background-image: url('"
|
|
+ urllib.parse.quote(
|
|
os.path.join(
|
|
path,
|
|
self.resource_dir,
|
|
"med",
|
|
sorted(
|
|
[
|
|
f
|
|
for f in os.listdir(os.path.join(path, self.resource_dir, "med"))
|
|
if f.endswith(".jpg")
|
|
]
|
|
)[0],
|
|
)
|
|
)
|
|
+ "')\""
|
|
)
|
|
except Exception:
|
|
img = ""
|
|
else:
|
|
img = "style=\"background-image: url('{}')\"".format(thumb)
|
|
return '<div class="folder_wrapper" {img}><a href="{path}" title="{title}"><span class="folder_title"><p>{title}</p></span></a></div>'.format(
|
|
path=urllib.parse.quote(path), title=title, img=img
|
|
)
|
|
|
|
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())
|
|
|
|
def write_resources(self):
|
|
try:
|
|
os.makedirs(self.resource_dir)
|
|
except Exception:
|
|
pass
|
|
|
|
# Force overwrite
|
|
for f in (
|
|
"mirva.css",
|
|
"mirva.js",
|
|
):
|
|
shutil.copy(
|
|
os.path.join(self.resource_src, f),
|
|
os.path.join(self.resource_dir, f),
|
|
)
|
|
|
|
# Coppy only if not exist
|
|
for f in ("arrow_up.png", "arrow_down.png", "mirva.ico", "share.svg", "user.css", "user.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["IMAGES"]:
|
|
if f["path"] in self.file_list:
|
|
sys.stdout.write(".")
|
|
sys.stdout.flush()
|
|
file_size = human_size(f["path"])
|
|
p = subprocess.run(
|
|
[
|
|
"identify",
|
|
"-format",
|
|
exif_format.format(size=file_size),
|
|
"{}[0]".format(f["path"]),
|
|
],
|
|
capture_output=True,
|
|
)
|
|
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)
|