8 Commits

15 changed files with 195 additions and 57 deletions

View File

@@ -1 +1 @@
2.3.4
2.4.0

View File

@@ -44,20 +44,20 @@ The script supports several command-line options to customize its behavior. Belo
### Options
- `-h, --help`: Show the help message and exit.
- `-p ROOT, --root-directory ROOT`: Specify the root folder where the images are stored. This option is required.
- `-w URL, --web-root-url URL`: Specify the base URL for the web root of the image hosting site. This option is required.
- `-t TITLE, --site-title TITLE`: Specify the title of the image hosting site. This option is required.
- `-r, --regenerate-thumbnails`: Regenerate thumbnails even if they already exist.
- `-n, --non-interactive-mode`: Run in non-interactive mode, disabling progress bars.
- `-l LICENSE, --license-type LICENSE`: Specify the license type for the images. Choices are `cc-zero`, `cc-by`, `cc-by-sa`, `cc-by-nd`, `cc-by-nc`, `cc-by-nc-sa`, and `cc-by-nc-nd`.
- `-a AUTHOR, --author-name AUTHOR`: Specify the name of the author of the images. Default is "Author".
- `-e EXTENSION, --file-extensions EXTENSION`: Specify the file extensions to include. This option can be specified multiple times.
- `-l LICENSE, --license-type LICENSE`: Specify the license type for the images. Choices are `cc-zero`, `cc-by`, `cc-by-sa`, `cc-by-nd`, `cc-by-nc`, `cc-by-nc-sa`, and `cc-by-nc-nd`.
- `-m, --web-manifest`: Generate a web manifest file.
- `-n, --non-interactive-mode`: Run in non-interactive mode, disabling progress bars.
- `-p ROOT, --root-directory ROOT`: Specify the root folder where the images are stored. **(This option is required)**.
- `-t TITLE, --site-title TITLE`: Specify the title of the image hosting site. **(This option is required)**.
- `-w URL, --web-root-url URL`: Specify the base URL for the web root of the image hosting site. **(This option is required)**.
- `--exclude-folder FOLDER`: Specify folders to exclude from processing. This option can be specified multiple times.
- `--ignore-other-files`: Ignore files that do not match the specified extensions.
- `--regenerate-thumbnails`: Regenerate thumbnails even if they already exist.
- `--reread-metadata`: Reread image metadata if it already exists.
- `--theme-path PATH`: Specify the path to the CSS theme file. Default is the provided default theme.
- `--use-fancy-folders`: Enable fancy folder view instead of the default Apache directory listing.
- `--ignore-other-files`: Ignore files that do not match the specified extensions.
- `--exclude-folder FOLDER`: Specify folders to exclude from processing. This option can be specified multiple times.
- `-m, --web-manifest`: Generate a web manifest file.
### Examples

View File

@@ -39,7 +39,8 @@
"cc-by-nc-sa",
"-n",
"-m",
"-r"
"--regenerate-thumbnails",
"--reread-metadata",
],
"console": "integratedTerminal",
"name": "Testfolder",
@@ -57,12 +58,13 @@
"-t",
"Pictures",
"--theme",
"themes/catpuccin.css",
"themes/default.css",
"--use-fancy-folders",
"--web-manifest",
"-n",
"-m",
"-r",
// "--regenerate-thumbnails",
// "--reread-metadata",
],
"console": "integratedTerminal",
"name": "woek",
@@ -147,6 +149,9 @@
"python.analysis.inlayHints.callArgumentNames": "off",
"python.analysis.inlayHints.functionReturnTypes": false,
"python.analysis.inlayHints.variableTypes": false,
"yaml.schemas": {
"https://raw.githubusercontent.com/pamburus/hl/master/schema/json/config.schema.json": "file:///home/user/git/github.com/greflm13/StaticGalleryBuilder/hl_config.yaml"
},
},
"tasks": {
"version": "2.0.0",
@@ -218,6 +223,21 @@
"clear": true
},
"group": "build"
},
{
"command": "LESS=-SR hl logs/latest.jsonl --config hl_config.yaml",
"isBackground": false,
"label": "View Latest Log",
"problemMatcher": [],
"type": "shell",
"presentation": {
"echo": false,
"reveal": "always",
"focus": true,
"panel": "dedicated",
"showReuseMessage": false,
"clear": true
}
}
],
},

View File

@@ -196,11 +196,13 @@ def main() -> None:
lock_file = os.path.join(args.root_directory, ".lock")
if os.path.exists(lock_file):
print("Another instance of this program is running.")
logger.info("nother instance of this program is running")
logger.error("another instance of this program is running")
exit()
try:
Path(lock_file).touch()
if args.reread_metadata:
logger.warning("reread metadata flag is set to true, all image metadata will be reread")
if args.regenerate_thumbnails:
logger.warning("regenerate thumbnails flag is set to true, all thumbnails will be regenerated")
if os.path.exists(os.path.join(args.root_directory, ".thumbnails")):
@@ -212,7 +214,6 @@ def main() -> None:
icons(args)
if args.generate_webmanifest:
logger.info("generating webmanifest")
print("Generating webmanifest...")
webmanifest(args)

View File

@@ -90,7 +90,7 @@ figure {
.footer a img {
height: 22px !important;
margin-left: 3px;
vertical-align: text-bottom
vertical-align: text-bottom;
}
.navbar {
@@ -157,12 +157,12 @@ figure {
opacity: 1;
}
/* Create eight equal columns that sits next to each other */
.column {
-ms-flex: 12.5%;
flex: 12.5%;
max-width: 12.5%;
padding: 0 4px;
display: inline-block;
}
.column img {
@@ -187,7 +187,6 @@ figure {
border-style: none;
}
/* Responsive layout - makes a four column-layout instead of eight columns */
@media screen and (max-width: 1000px) {
.column {
-ms-flex: 25%;
@@ -209,7 +208,6 @@ figure {
}
}
/* Responsive layout - makes a two column-layout instead of four columns */
@media screen and (max-width: 800px) {
.column {
-ms-flex: 50%;
@@ -239,7 +237,6 @@ figure {
}
}
/* Responsive layout - makes the two columns stack on top of each other instead of next to each other */
@media screen and (max-width: 600px) {
.column {
-ms-flex: 100%;

View File

@@ -272,13 +272,14 @@ a.pswp__share--download:hover {
color: #BBB; }
.pswp__caption__center {
text-align: left;
text-align: center;
max-width: 420px;
margin: 0 auto;
font-size: 13px;
padding: 10px;
line-height: 20px;
color: #CCC; }
color: #CCC;
font-weight: bold; }
.pswp__caption--empty {
display: none; }

73
hl_config.yaml Normal file
View File

@@ -0,0 +1,73 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/pamburus/hl/master/schema/json/config.schema.json
$schema: https://raw.githubusercontent.com/pamburus/hl/master/schema/json/config.schema.json
# Time format, see https://man7.org/linux/man-pages/man1/date.1.html for details.
time-format: "%b %d %T.%3N"
# Time zone name, see column "TZ identifier" at
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones page.
time-zone: "Europe/Vienna"
# Settings for fields processing.
fields:
# Configuration of the predefined set of fields.
predefined:
time:
show: auto
names: ["asctime"]
logger:
names: ["defaultlogger", "consolelogger"]
level:
show: auto
variants:
- names: ["levelname"]
values:
debug: ["DEBUG"]
info: ["INFO"]
warning: ["WARNING", "WARN"]
error: ["ERROR", "FATAL", "CRITICAL"]
- names: ["levelno"]
values:
debug: [10]
info: [20]
warning: [30]
error: [40, 50]
message:
names: ["message"]
caller:
names: ["funcName"]
caller-file:
names: ["filename"]
caller-line:
names: ["lineno"]
# List of wildcard field names to ignore.
ignore: ["_*"]
# List of exact field names to hide.
hide: ["pathname", "created", "levelno", "taskname", "relativeCreated", "thread", "process", "msecs"]
# Formatting settings.
formatting:
flatten: always
punctuation:
logger-name-separator: ":"
field-key-value-separator: "="
string-opening-quote: "'"
string-closing-quote: "'"
source-location-separator: "→ "
hidden-fields-indicator: " ..."
level-left-separator: "│"
level-right-separator: "│"
input-number-prefix: "#"
input-number-left-separator: ""
input-number-right-separator: " │ "
input-name-left-separator: ""
input-name-right-separator: " │ "
input-name-clipping: "··"
input-name-common-part: "··"
array-separator: " "
# Number of processing threads, configured automatically based on CPU count if not specified.
concurrency: ~
# Currently selected theme.
theme: "neutral"

View File

@@ -2,7 +2,14 @@ from dataclasses import dataclass
from typing import List, Optional
import os
import argparse
from rich_argparse import RichHelpFormatter, HelpPreviewAction
try:
from rich_argparse import RichHelpFormatter, HelpPreviewAction
RICH = True
except ModuleNotFoundError:
RICH = False
from modules.logger import logger
@@ -58,6 +65,7 @@ class Args:
license_type: Optional[str]
non_interactive_mode: bool
regenerate_thumbnails: bool
reread_metadata: bool
root_directory: str
site_title: str
theme_path: str
@@ -75,6 +83,7 @@ class Args:
result["license_type"] = self.license_type
result["non_interactive_mode"] = self.non_interactive_mode
result["regenerate_thumbnails"] = self.regenerate_thumbnails
result["reread_metadata"] = self.reread_metadata
result["root_directory"] = self.root_directory
result["site_title"] = self.site_title
result["theme_path"] = self.theme_path
@@ -98,19 +107,24 @@ def parse_arguments(version: str) -> Args:
An instance of the Args class containing the parsed arguments.
"""
# fmt: off
parser = argparse.ArgumentParser(description="Generate HTML files for a static image hosting website.", formatter_class=RichHelpFormatter)
if RICH:
parser = argparse.ArgumentParser(description="Generate HTML files for a static image hosting website.", formatter_class=RichHelpFormatter)
else:
parser = argparse.ArgumentParser(description="Generate HTML files for a static image hosting website.")
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("-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("-m", "--web-manifest", help="Generate a web manifest file.", action="store_true", default=False, dest="generate_webmanifest")
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("-p", "--root-directory", help="Root directory containing the images.", required=True, type=str, dest="root_directory", metavar="ROOT")
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("-t", "--site-title", help="Title of the image hosting site.", required=True, type=str, dest="site_title", metavar="TITLE")
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("--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("--generate-help-preview", action=HelpPreviewAction, path="help.svg", )
if RICH:
parser.add_argument("--generate-help-preview", action=HelpPreviewAction, path="help.svg", )
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("--regenerate-thumbnails", help="Regenerate thumbnails even if they already exist.", action="store_true", default=False, dest="regenerate_thumbnails")
parser.add_argument("--reread-metadata", help="Reread image metadata", action="store_true", default=False, dest="reread_metadata")
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("--version", action="version", version=f"%(prog)s {version}")
@@ -125,6 +139,7 @@ def parse_arguments(version: str) -> Args:
license_type=parsed_args.license_type,
non_interactive_mode=parsed_args.non_interactive_mode,
regenerate_thumbnails=parsed_args.regenerate_thumbnails,
reread_metadata=parsed_args.reread_metadata,
root_directory=parsed_args.root_directory,
site_title=parsed_args.site_title,
theme_path=parsed_args.theme_path,

View File

@@ -1,8 +1,10 @@
import os
import re
import urllib.parse
import fnmatch
import json
from typing import Any, Dict, List, Tuple
from datetime import datetime
import numpy as np
from tqdm.auto import tqdm
@@ -89,11 +91,13 @@ def get_image_info(item: str, folder: str) -> Dict[str, Any]:
Returns:
Dict[str, Any]: A dictionary containing image width, height, and EXIF data.
"""
with Image.open(os.path.join(folder, item)) as img:
logger.info("extracting image information", extra={"file": item})
file = os.path.join(folder, item)
with Image.open(file) as img:
logger.info("extracting image information", extra={"file": file})
exif = img.getexif()
width, height = img.size
if exif:
logger.info("extracting EXIF data", extra={"file": file})
ifd = exif.get_ifd(ExifTags.IFD.Exif)
exifdatas = dict(exif.items()) | ifd
exifdata = {}
@@ -101,7 +105,7 @@ def get_image_info(item: str, folder: str) -> Dict[str, Any]:
tag = ExifTags.TAGS.get(tag_id, tag_id)
content = exifdatas.get(tag_id)
if isinstance(content, bytes):
content = content.hex(" ")
content = "0x" + content.hex()
if isinstance(content, TiffImagePlugin.IFDRational):
content = content.limit_rational(1000000)
if isinstance(content, tuple):
@@ -111,15 +115,22 @@ def get_image_info(item: str, folder: str) -> Dict[str, Any]:
newtuple = newtuple + (i.limit_rational(1000000),)
if newtuple:
content = newtuple
if tag in ["DateTime", "DateTimeOriginal", "DateTimeDigitized"]:
epr = r'\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}'
if re.match(epr, content):
content = datetime.strptime(content, "%Y:%m:%d %H:%M:%S").strftime("%Y-%m-%d %H:%M:%S")
else:
content = None
exifdata[tag] = content
if "Orientation" in exifdata and exifdata["Orientation"] in [6, 8]:
logger.info("image is rotated", extra={"file": file})
width, height = height, width
for key in ["PrintImageMatching", "UserComment", "MakerNote"]:
if key in exifdata:
del exifdata[key]
return {"width": width, "height": height, "exifdata": exifdata}
else:
return {"width": width, "height": height}
return {"width": width, "height": height, "exifdata": None}
def process_image(item: str, folder: str, _args: Args, baseurl: str, sizelist: Dict[str, Dict[str, int]], raw: List[str]) -> Dict[str, Any]:
@@ -138,7 +149,7 @@ def process_image(item: str, folder: str, _args: Args, baseurl: str, sizelist: D
Dict[str, Any]: Dictionary containing image details for HTML rendering.
"""
extsplit = os.path.splitext(item)
if item not in sizelist or _args.regenerate_thumbnails:
if item not in sizelist or _args.reread_metadata:
sizelist[item] = get_image_info(item, folder)
image = {
@@ -147,6 +158,7 @@ def process_image(item: str, folder: str, _args: Args, baseurl: str, sizelist: D
"name": item,
"width": sizelist[item]["width"],
"height": sizelist[item]["height"],
"exifdata": sizelist[item]["exifdata"],
}
path = os.path.join(_args.root_directory, ".thumbnails", baseurl, item + ".jpg")
if not os.path.exists(path) or _args.regenerate_thumbnails:
@@ -155,12 +167,15 @@ def process_image(item: str, folder: str, _args: Args, baseurl: str, sizelist: D
thumbnails.append((folder, item, _args.root_directory))
for _raw in raw:
if os.path.exists(os.path.join(folder, extsplit[0] + _raw)):
url = urllib.parse.quote(extsplit[0]) + _raw
file = os.path.join(folder, extsplit[0] + _raw)
if os.path.exists(file):
url = f"{_args.web_root_url}{baseurl}{urllib.parse.quote(extsplit[0])}{_raw}"
if _raw in (".tif", ".tiff"):
image["tiff"] = f"{_args.web_root_url}{baseurl}{url}"
logger.info("tiff file found", extra={"file": file})
image["tiff"] = url
else:
image["raw"] = f"{_args.web_root_url}{baseurl}{url}"
logger.info("raw file found", extra={"file": file, "extension": _raw})
image["raw"] = url
return image
@@ -299,7 +314,6 @@ def create_html_file(folder: str, title: str, foldername: str, images: List[Dict
"""
html_file = os.path.join(folder, "index.html")
logger.info("generating html file with jinja2", extra={"path": html_file})
image_chunks = np.array_split(images, 8) if images else []
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] + '/'))}"
if parent and _args.web_root_url.startswith("file://"):
@@ -331,7 +345,7 @@ def create_html_file(folder: str, title: str, foldername: str, images: List[Dict
header=header,
license=license_info,
subdirectories=subfolders,
images=image_chunks,
images=images,
info=_info,
allimages=images,
webmanifest=_args.generate_webmanifest,

View File

@@ -48,14 +48,14 @@ def log_format(keys):
def rotate_log_file(compress=False):
"""
Renames the existing 'latest.jsonl' file to a timestamped file based on the first log entry's asctime.
Optionally compresses the old log file using gzip.
Truncates the 'latest.jsonl' file after optionally compressing its contents to a timestamped file.
The 'latest.jsonl' file is not deleted or moved, just emptied.
Args:
compress (bool): If True, compress the old log file using gzip.
"""
if os.path.exists(LATEST_LOG_FILE):
with open(LATEST_LOG_FILE, "r", encoding="utf-8") as f:
with open(LATEST_LOG_FILE, "r+", encoding="utf-8") as f:
first_line = f.readline()
try:
first_log = json.loads(first_line)
@@ -64,16 +64,23 @@ def rotate_log_file(compress=False):
except (json.JSONDecodeError, KeyError):
first_timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
safe_timestamp = first_timestamp.replace(":", "-").replace(" ", "_")
old_log_filename = os.path.join(LOG_DIR, f"{safe_timestamp}.jsonl")
safe_timestamp = first_timestamp.replace(":", "-").replace(" ", "_")
old_log_filename = os.path.join(LOG_DIR, f"{safe_timestamp}.jsonl")
os.rename(LATEST_LOG_FILE, old_log_filename)
# Write contents to the new file
with open(old_log_filename, "w", encoding="utf-8") as old_log_file:
f.seek(0) # Go back to the beginning of the file
shutil.copyfileobj(f, old_log_file)
if compress:
with open(old_log_filename, "rb") as f_in:
with gzip.open(f"{old_log_filename}.gz", "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
os.remove(old_log_filename)
if compress:
with open(old_log_filename, "rb") as f_in:
with gzip.open(f"{old_log_filename}.gz", "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
os.remove(old_log_filename)
# Truncate the original file
f.seek(0)
f.truncate()
def setup_logger(level=logging.INFO):

View File

@@ -178,8 +178,8 @@ def create_icons_from_svg(files: List[str], iconspath: str, _args: Args) -> List
List[Icon]
List of icons created from the SVG file.
"""
logger.info("creating icons for web application", extra={"iconspath": iconspath})
svg = [file for file in files if file.endswith(".svg")][0]
logger.info("creating icons for web application", extra={"iconspath": iconspath, "svg": svg})
icon_list = [
{"src": f"{_args.web_root_url}.static/icons/{svg}", "type": "image/svg+xml", "sizes": "512x512", "purpose": "maskable"},
{"src": f"{_args.web_root_url}.static/icons/{svg}", "type": "image/svg+xml", "sizes": "512x512", "purpose": "any"},
@@ -188,7 +188,7 @@ def create_icons_from_svg(files: List[str], iconspath: str, _args: Args) -> List
tmpimg = BytesIO()
sizes = size.split("x")
iconpath = os.path.join(iconspath, os.path.splitext(svg)[0] + "-" + size + ".png")
logger.info("converting svg to png", extra={"iconpath": iconpath, "size": size})
logger.info("converting svg to png", extra={"svg": svg, "size": size})
cairosvg.svg2png(
url=os.path.join(iconspath, svg),
write_to=tmpimg,
@@ -240,7 +240,7 @@ def create_icons_from_png(iconspath: str, web_root_url: str) -> List[Icon]:
continue
with Image.open(os.path.join(iconspath, icon)) as iconfile:
iconsize = f"{iconfile.size[0]}x{iconfile.size[1]}"
logger.info("using icon", extra={"icon": icon, "size": iconsize})
logger.info("using icon", extra={"iconspath": iconspath, "icon": icon, "size": iconsize})
icon_list.append({"src": f"{web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "maskable"})
icon_list.append({"src": f"{web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "any"})
return icon_list
@@ -255,6 +255,8 @@ def webmanifest(_args: Args) -> None:
_args : Args
Parsed command-line arguments.
"""
logger.info("generating webmanifest")
iconspath = os.path.join(_args.root_directory, ".static", "icons")
files = os.listdir(iconspath)
icon_list = create_icons_from_svg(files, iconspath, _args) if SVGSUPPORT and any(file.endswith(".svg") for file in files) else create_icons_from_png(iconspath, _args.web_root_url)

View File

@@ -56,9 +56,8 @@
{% if images %}
{%- set ns = namespace(count = 0) -%}
<div class="row">
{%- for imageblock in images %}
{%- for image in images %}
<div class="column">
{%- for image in imageblock %}
<figure>
<img src="{{ image.thumbnail }}" alt="{{ image.name }}" onclick="openSwipe({{ ns.count }})" />
{%- set ns.count = ns.count + 1 %}
@@ -71,7 +70,6 @@
{%- endif %}
</figcaption>
</figure>
{%- endfor %}
</div>
{%- endfor %}
</div>
@@ -147,7 +145,11 @@
var pswpElement = document.querySelectorAll('.pswp')[0];
var items = [
{%- for image in allimages %}
{%- if image.exifdata.DateTime %}
{ src: "{{ image.url }}", w: {{ image.width }}, h: {{ image.height }}, msrc: "{{ image.thumbnail }}", title: "Captured: {{ image.exifdata.DateTime }}" },
{%- else %}
{ src: "{{ image.url }}", w: {{ image.width }}, h: {{ image.height }}, msrc: "{{ image.thumbnail }}" },
{%- endif %}
{%- endfor %}
];
var re = /pid=(\d+)/;
@@ -178,8 +180,7 @@
}
function topFunction() {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
{%- endif %}

BIN
test/example/DSC03508.ARW Executable file

Binary file not shown.

4
view-latest-log.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
LESS=-SR hl logs/latest.jsonl --config hl_config.yaml

3
view-logs.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
LESS=-SR hl $(ls -tr logs/*.{jsonl,jsonl.gz}) --config hl_config.yaml