Files
mirva/mirva/mirva.py
2023-10-14 11:28:26 +03:00

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;">&nbsp;</div>
</div>
<!-- end #content -->
<div style="clear: both;">&nbsp;</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">&nbsp;</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;">&nbsp;</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)