mirror of
https://github.com/greflm13/StaticGalleryBuilder.git
synced 2026-02-05 02:59:27 +00:00
428 lines
20 KiB
Python
Executable File
428 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os
|
|
import argparse
|
|
import urllib.parse
|
|
import shutil
|
|
import fnmatch
|
|
import json
|
|
from multiprocessing import Pool
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
import numpy as np
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from tqdm.auto import tqdm
|
|
from PIL import Image, ImageOps, ExifTags
|
|
from rich_argparse import RichHelpFormatter, HelpPreviewAction
|
|
|
|
try:
|
|
import cairosvg
|
|
from io import BytesIO
|
|
|
|
svgsupport = True
|
|
except:
|
|
svgsupport = False
|
|
|
|
import cclicense
|
|
|
|
# fmt: off
|
|
# Constants
|
|
STATIC_FILES_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "files")
|
|
FAVICON_PATH = ".static/favicon.ico"
|
|
GLOBAL_CSS_PATH = ".static/global.css"
|
|
DEFAULT_THEME_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), "themes", "default.css")
|
|
DEFAULT_AUTHOR = "Author"
|
|
VERSION = "1.9.10"
|
|
RAW_EXTENSIONS = [".3fr", ".ari", ".arw", ".bay", ".braw", ".crw", ".cr2", ".cr3", ".cap", ".data", ".dcs", ".dcr", ".dng", ".drf", ".eip", ".erf", ".fff", ".gpr", ".iiq", ".k25", ".kdc", ".mdc", ".mef", ".mos", ".mrw", ".nef", ".nrw", ".obm", ".orf", ".pef", ".ptx", ".pxn", ".r3d", ".raf", ".raw", ".rwl", ".rw2", ".rwz", ".sr2", ".srf", ".srw", ".tif", ".tiff", ".x3f"]
|
|
IMG_EXTENSIONS = [".jpg", ".jpeg"]
|
|
EXCLUDES = [".lock", "index.html", "manifest.json", ".sizelist.json", ".thumbnails", ".static"]
|
|
NOT_LIST = ["*/Galleries/*", "Archives"]
|
|
ICON_SIZES = ["36x36", "48x48", "72x72", "96x96", "144x144", "192x192", "512x512"]
|
|
# fmt: on
|
|
|
|
# Initialize Jinja2 environment
|
|
env = Environment(loader=FileSystemLoader(os.path.join(os.path.abspath(os.path.dirname(__file__)), "templates")))
|
|
thumbnails: List[Tuple[str, str]] = []
|
|
info: Dict[str, str] = {}
|
|
pbardict: Dict[str, tqdm] = {}
|
|
|
|
|
|
class Icon:
|
|
src: str
|
|
type: str
|
|
sizes: str
|
|
purpose: str
|
|
|
|
|
|
class Args:
|
|
author_name: str
|
|
exclude_folders: List[str]
|
|
file_extensions: List[str]
|
|
generate_webmanifest: bool
|
|
ignore_other_files: bool
|
|
license_type: Optional[str]
|
|
non_interactive_mode: bool
|
|
regenerate_thumbnails: bool
|
|
root_directory: str
|
|
site_title: str
|
|
theme_path: str
|
|
use_fancy_folders: bool
|
|
web_root_url: str
|
|
|
|
|
|
# fmt: off
|
|
def parse_arguments() -> Args:
|
|
parser = argparse.ArgumentParser(description="Generate HTML files for a static image hosting website.", formatter_class=RichHelpFormatter)
|
|
parser.add_argument("-p", "--root-directory", help="Root directory containing the images.", required=True, type=str, dest="root_directory", metavar="ROOT")
|
|
parser.add_argument("-w", "--web-root-url", help="Base URL of the web root for the image hosting site.", required=True, type=str, dest="web_root_url", metavar="URL")
|
|
parser.add_argument("-t", "--site-title", help="Title of the image hosting site.", required=True, type=str, dest="site_title", metavar="TITLE")
|
|
parser.add_argument("-r", "--regenerate-thumbnails", help="Regenerate thumbnails even if they already exist.", action="store_true", default=False, dest="regenerate_thumbnails")
|
|
parser.add_argument("-n", "--non-interactive-mode", help="Run in non-interactive mode, disabling progress bars.", action="store_true", default=False, dest="non_interactive_mode")
|
|
parser.add_argument("-l", "--license-type", help="Specify the license type for the images.", choices=["cc-zero", "cc-by", "cc-by-sa", "cc-by-nd", "cc-by-nc", "cc-by-nc-sa", "cc-by-nc-nd"], default=None, dest="license_type", metavar="LICENSE")
|
|
parser.add_argument("-a", "--author-name", help="Name of the author of the images.", default=DEFAULT_AUTHOR, type=str, dest="author_name", metavar="AUTHOR")
|
|
parser.add_argument("-e", "--file-extensions", help="File extensions to include (can be specified multiple times).", action="append", dest="file_extensions", metavar="EXTENSION")
|
|
parser.add_argument("-m", "--web-manifest", help="Generate a web manifest file.", action="store_true", default=False, dest="generate_webmanifest")
|
|
parser.add_argument("--theme-path", help="Path to the CSS theme file.", default=DEFAULT_THEME_PATH, type=str, dest="theme_path", metavar="PATH")
|
|
parser.add_argument("--use-fancy-folders", help="Enable fancy folder view instead of the default Apache directory listing.", action="store_true", default=False, dest="use_fancy_folders")
|
|
parser.add_argument("--ignore-other-files", help="Ignore files that do not match the specified extensions.", action="store_true", default=False, dest="ignore_other_files")
|
|
parser.add_argument("--exclude-folder", help="Folders to exclude from processing, globs supported (can be specified multiple times).", action="append", dest="exclude_folders", metavar="FOLDER")
|
|
parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
|
|
parser.add_argument("--generate-help-preview", action=HelpPreviewAction, path="help.svg")
|
|
parsed_args = parser.parse_args()
|
|
_args = Args()
|
|
_args.author_name = parsed_args.author_name
|
|
_args.exclude_folders = parsed_args.exclude_folders
|
|
_args.file_extensions = parsed_args.file_extensions
|
|
_args.generate_webmanifest = parsed_args.generate_webmanifest
|
|
_args.ignore_other_files = parsed_args.ignore_other_files
|
|
_args.license_type = parsed_args.license_type
|
|
_args.non_interactive_mode = parsed_args.non_interactive_mode
|
|
_args.regenerate_thumbnails = parsed_args.regenerate_thumbnails
|
|
_args.root_directory = parsed_args.root_directory
|
|
_args.site_title = parsed_args.site_title
|
|
_args.theme_path = parsed_args.theme_path
|
|
_args.use_fancy_folders = parsed_args.use_fancy_folders
|
|
_args.web_root_url = parsed_args.web_root_url
|
|
return _args
|
|
# fmt: on
|
|
|
|
|
|
def init_globals(_args: Args) -> Args:
|
|
global RAW_EXTENSIONS
|
|
if not _args.file_extensions:
|
|
_args.file_extensions = IMG_EXTENSIONS
|
|
if not _args.exclude_folders:
|
|
_args.exclude_folders = NOT_LIST
|
|
_args.root_directory = _args.root_directory.rstrip("/") + "/"
|
|
_args.web_root_url = _args.web_root_url.rstrip("/") + "/"
|
|
|
|
RAW_EXTENSIONS = [ext.lower() for ext in RAW_EXTENSIONS] + [ext.upper() for ext in RAW_EXTENSIONS]
|
|
|
|
return _args
|
|
|
|
|
|
def copy_static_files(_args: Args) -> None:
|
|
if os.path.exists(os.path.join(_args.root_directory, ".static")):
|
|
print("Removing existing .static folder...")
|
|
shutil.rmtree(os.path.join(_args.root_directory, ".static"))
|
|
if not os.path.exists(os.path.join(_args.root_directory, ".static")):
|
|
print("Copying static files...")
|
|
shutil.copytree(STATIC_FILES_DIR, os.path.join(_args.root_directory, ".static"), dirs_exist_ok=True)
|
|
shutil.copyfile(_args.theme_path, os.path.join(_args.root_directory, ".static", "theme.css"))
|
|
|
|
|
|
def webmanifest(_args: Args) -> None:
|
|
icons: List[Icon] = []
|
|
files = os.listdir(os.path.join(STATIC_FILES_DIR, "icons"))
|
|
if svgsupport and any(file.endswith(".svg") for file in files):
|
|
svg = [file for file in files if file.endswith(".svg")][0]
|
|
icons.append(
|
|
{"src": f"{_args.web_root_url}.static/icons/{svg}", "type": "image/svg+xml", "sizes": "512x512", "purpose": "maskable"}
|
|
)
|
|
icons.append({"src": f"{_args.web_root_url}.static/icons/{svg}", "type": "image/svg+xml", "sizes": "512x512", "purpose": "any"})
|
|
for size in ICON_SIZES:
|
|
tmpimg = BytesIO()
|
|
sizes = size.split("x")
|
|
iconpath = os.path.join(_args.root_directory, ".static", "icons", os.path.splitext(svg)[0] + "-" + size + ".png")
|
|
cairosvg.svg2png(
|
|
url=os.path.join(STATIC_FILES_DIR, "icons", svg),
|
|
write_to=tmpimg,
|
|
output_width=int(sizes[0]),
|
|
output_height=int(sizes[1]),
|
|
scale=1,
|
|
)
|
|
with Image.open(tmpimg) as iconfile:
|
|
iconfile.save(iconpath, format="PNG")
|
|
icons.append(
|
|
{
|
|
"src": f"{_args.web_root_url}.static/icons/{os.path.splitext(svg)[0]}-{size}.png",
|
|
"sizes": size,
|
|
"type": "image/png",
|
|
"purpose": "maskable",
|
|
}
|
|
)
|
|
icons.append(
|
|
{
|
|
"src": f"{_args.web_root_url}.static/icons/{os.path.splitext(svg)[0]}-{size}.png",
|
|
"sizes": size,
|
|
"type": "image/png",
|
|
"purpose": "any",
|
|
}
|
|
)
|
|
else:
|
|
for icon in os.listdir(os.path.join(STATIC_FILES_DIR, "icons")):
|
|
if not icon.endswith(".png"):
|
|
continue
|
|
with Image.open(os.path.join(STATIC_FILES_DIR, "icons", icon)) as iconfile:
|
|
iconsize = f"{iconfile.size[0]}x{iconfile.size[1]}"
|
|
icons.append(
|
|
{"src": f"{_args.web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "maskable"}
|
|
)
|
|
icons.append({"src": f"{_args.web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "any"})
|
|
if len(icons) == 0:
|
|
print("No icons found in the static/icons folder!")
|
|
return
|
|
|
|
with open(os.path.join(_args.root_directory, ".static", "theme.css")) as f:
|
|
content = f.read()
|
|
background_color = (
|
|
content.replace("body{", "body {").split("body {")[1].split("}")[0].split("background-color:")[1].split(";")[0].strip()
|
|
)
|
|
theme_color = (
|
|
content.replace(".navbar{", "navbar {").split(".navbar {")[1].split("}")[0].split("background-color:")[1].split(";")[0].strip()
|
|
)
|
|
with open(os.path.join(_args.root_directory, ".static", "manifest.json"), "w", encoding="utf-8") as f:
|
|
manifest = env.get_template("manifest.json.j2")
|
|
content = manifest.render(
|
|
name=_args.web_root_url.replace("https://", "").replace("http://", "").replace("/", ""),
|
|
short_name=_args.site_title,
|
|
icons=icons,
|
|
background_color=background_color,
|
|
theme_color=theme_color,
|
|
)
|
|
f.write(content)
|
|
|
|
|
|
def generate_thumbnail(arguments: Tuple[str, str]) -> None:
|
|
folder, item = arguments
|
|
path = os.path.join(args.root_directory, ".thumbnails", folder.removeprefix(args.root_directory), os.path.splitext(item)[0]) + ".jpg"
|
|
if not os.path.exists(path) or args.regenerate_thumbnails:
|
|
try:
|
|
with Image.open(os.path.join(folder, item)) as imgfile:
|
|
img = ImageOps.exif_transpose(imgfile)
|
|
img.thumbnail((512, 512))
|
|
img.save(path, "JPEG", quality=75, optimize=True)
|
|
except OSError:
|
|
print(f"Failed to generate thumbnail for {os.path.join(folder, item)}")
|
|
|
|
|
|
def get_total_folders(folder: str, _total: int = 0) -> int:
|
|
|
|
_total += 1
|
|
|
|
pbar.desc = f"Traversing filesystem - {folder}"
|
|
pbar.update(1)
|
|
|
|
items = os.listdir(folder)
|
|
items.sort()
|
|
for item in items:
|
|
if item not in EXCLUDES:
|
|
if os.path.isdir(os.path.join(folder, item)):
|
|
if item not in args.exclude_folders:
|
|
skip = False
|
|
for exclude in args.exclude_folders:
|
|
if fnmatch.fnmatchcase(os.path.join(folder, item), exclude):
|
|
skip = True
|
|
if not skip:
|
|
_total = get_total_folders(os.path.join(folder, item), _total)
|
|
return _total
|
|
|
|
|
|
def list_folder(folder: str, title: str) -> None:
|
|
sizelist: Dict[Dict[str, int], Dict[str, int]] = {}
|
|
if not os.path.exists(os.path.join(folder, ".sizelist.json")):
|
|
sizelistfile = open(os.path.join(folder, ".sizelist.json"), "x", encoding="utf-8")
|
|
sizelistfile.write("{}")
|
|
sizelistfile.close()
|
|
with open(os.path.join(folder, ".sizelist.json"), "r+", encoding="utf-8") as sizelistfile:
|
|
sizelist = json.loads(sizelistfile.read())
|
|
items = os.listdir(folder)
|
|
items.sort()
|
|
images: List[Dict[str, Any]] = []
|
|
subfolders: List[Dict[str, str]] = []
|
|
foldername = folder.removeprefix(args.root_directory)
|
|
foldername = f"{foldername}/" if foldername else ""
|
|
baseurl = urllib.parse.quote(foldername)
|
|
if not os.path.exists(os.path.join(args.root_directory, ".thumbnails", foldername)):
|
|
os.mkdir(os.path.join(args.root_directory, ".thumbnails", foldername))
|
|
contains_files = False
|
|
if not args.non_interactive_mode:
|
|
pbardict[folder] = tqdm(total=len(items), desc=f"Getting image infos - {folder}", unit="files", ascii=True, dynamic_ncols=True)
|
|
for item in items:
|
|
if item not in EXCLUDES:
|
|
if os.path.isdir(os.path.join(folder, item)):
|
|
subfolder = {"url": f"{args.web_root_url}{baseurl}{urllib.parse.quote(item)}", "name": item}
|
|
subfolders.append(subfolder)
|
|
if item not in args.exclude_folders:
|
|
skip = False
|
|
for exclude in args.exclude_folders:
|
|
if fnmatch.fnmatchcase(os.path.join(folder, item), exclude):
|
|
skip = True
|
|
if not skip:
|
|
list_folder(os.path.join(folder, item), os.path.join(folder, item).removeprefix(args.root_directory))
|
|
else:
|
|
extsplit = os.path.splitext(item)
|
|
contains_files = True
|
|
if extsplit[1].lower() in args.file_extensions:
|
|
if not sizelist.get(item) or args.regenerate_thumbnails:
|
|
exifdata = {}
|
|
with Image.open(os.path.join(folder, item)) as img:
|
|
exif = img.getexif()
|
|
width, height = img.size
|
|
|
|
for key, val in exif.items():
|
|
if key in ExifTags.TAGS:
|
|
exifdata[ExifTags.TAGS[key]] = val
|
|
else:
|
|
exifdata[key] = val
|
|
if "Orientation" in exifdata and (exifdata["Orientation"] == 6 or exifdata["Orientation"] == 8):
|
|
sizelist[item] = {"width": height, "height": width}
|
|
else:
|
|
sizelist[item] = {"width": width, "height": height}
|
|
|
|
image = {
|
|
"url": f"{args.web_root_url}{baseurl}{urllib.parse.quote(item)}",
|
|
"thumbnail": f"{args.web_root_url}.thumbnails/{baseurl}{urllib.parse.quote(extsplit[0])}.jpg",
|
|
"name": item,
|
|
"width": sizelist[item]["width"],
|
|
"height": sizelist[item]["height"],
|
|
}
|
|
if not os.path.exists(os.path.join(args.root_directory, ".thumbnails", foldername, item)):
|
|
thumbnails.append((folder, item))
|
|
for raw in RAW_EXTENSIONS:
|
|
if os.path.exists(os.path.join(folder, extsplit[0] + raw)):
|
|
url = urllib.parse.quote(extsplit[0]) + raw
|
|
if raw in (".tif", ".tiff"):
|
|
image["tiff"] = f"{args.web_root_url}{baseurl}{url}"
|
|
else:
|
|
image["raw"] = f"{args.web_root_url}{baseurl}{url}"
|
|
images.append(image)
|
|
if item == "info":
|
|
with open(os.path.join(folder, item), encoding="utf-8") as f:
|
|
_info = f.read()
|
|
info[urllib.parse.quote(folder)] = _info
|
|
if not args.non_interactive_mode:
|
|
pbardict[folder].update(1)
|
|
pbar.update(0)
|
|
if not args.non_interactive_mode:
|
|
pbardict[folder].close()
|
|
sizelistfile.seek(0)
|
|
sizelistfile.write(json.dumps(sizelist, indent=4))
|
|
sizelistfile.truncate()
|
|
if not contains_files and not args.use_fancy_folders:
|
|
return
|
|
if images or (args.use_fancy_folders and not contains_files) or (args.use_fancy_folders and args.ignore_other_files):
|
|
image_chunks = np.array_split(images, 8) if images else []
|
|
with open(os.path.join(folder, "index.html"), "w", encoding="utf-8") as f:
|
|
_info: List[str] = None
|
|
header = os.path.basename(folder) or title
|
|
parent = (
|
|
None
|
|
if not foldername
|
|
else f"{args.web_root_url}{urllib.parse.quote(foldername.removesuffix(folder.split('/')[-1] + '/'))}"
|
|
)
|
|
license_info: cclicense.License = (
|
|
{
|
|
"project": args.site_title,
|
|
"author": args.author_name,
|
|
"type": cclicense.licensenameswitch(args.license_type),
|
|
"url": cclicense.licenseurlswitch(args.license_type),
|
|
"pics": cclicense.licensepicswitch(args.license_type),
|
|
}
|
|
if args.license_type
|
|
else None
|
|
)
|
|
if urllib.parse.quote(folder) in info:
|
|
_info = []
|
|
_infolst = info[urllib.parse.quote(folder)].split("\n")
|
|
for i in _infolst:
|
|
if len(i) > 1:
|
|
_info.append(i)
|
|
html = env.get_template("index.html.j2")
|
|
content = html.render(
|
|
title=title,
|
|
favicon=f"{args.web_root_url}{FAVICON_PATH}",
|
|
stylesheet=f"{args.web_root_url}{GLOBAL_CSS_PATH}",
|
|
theme=f"{args.web_root_url}.static/theme.css",
|
|
root=args.web_root_url,
|
|
parent=parent,
|
|
header=header,
|
|
license=license_info,
|
|
subdirectories=subfolders,
|
|
images=image_chunks,
|
|
info=_info,
|
|
allimages=images,
|
|
webmanifest=args.generate_webmanifest,
|
|
)
|
|
f.write(content)
|
|
else:
|
|
if os.path.exists(os.path.join(folder, "index.html")):
|
|
os.remove(os.path.join(folder, "index.html"))
|
|
if not args.non_interactive_mode:
|
|
pbar.update(1)
|
|
|
|
|
|
def main() -> None:
|
|
global args, pbar
|
|
|
|
args = parse_arguments()
|
|
args = init_globals(args)
|
|
|
|
if os.path.exists(os.path.join(args.root_directory, ".lock")):
|
|
print("Another instance of this program is running.")
|
|
exit()
|
|
|
|
try:
|
|
Path(os.path.join(args.root_directory, ".lock")).touch()
|
|
if not os.path.exists(os.path.join(args.root_directory, ".thumbnails")):
|
|
os.mkdir(os.path.join(args.root_directory, ".thumbnails"))
|
|
|
|
copy_static_files(args)
|
|
|
|
if args.generate_webmanifest:
|
|
print("Generating webmanifest...")
|
|
webmanifest(args)
|
|
|
|
if args.non_interactive_mode:
|
|
print("Generating HTML files...")
|
|
list_folder(args.root_directory, args.site_title)
|
|
with Pool(os.cpu_count()) as pool:
|
|
print("Generating thumbnails...")
|
|
pool.map(generate_thumbnail, thumbnails)
|
|
else:
|
|
pbar = tqdm(desc="Traversing filesystem", unit="folders", ascii=True, dynamic_ncols=True)
|
|
total = get_total_folders(args.root_directory)
|
|
pbar.desc = "Traversing filesystem"
|
|
pbar.update(0)
|
|
pbar.close()
|
|
|
|
pbar = tqdm(total=total, desc="Generating HTML files", unit="folders", ascii=True, dynamic_ncols=True)
|
|
list_folder(args.root_directory, args.site_title)
|
|
pbar.close()
|
|
|
|
with Pool(os.cpu_count()) as pool:
|
|
for _ in tqdm(
|
|
pool.imap_unordered(generate_thumbnail, thumbnails),
|
|
total=len(thumbnails),
|
|
desc="Generating thumbnails",
|
|
unit="files",
|
|
ascii=True,
|
|
dynamic_ncols=True,
|
|
):
|
|
pass
|
|
finally:
|
|
os.remove(os.path.join(args.root_directory, ".lock"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|