diff --git a/.version b/.version index cae9add..642c63c 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.8.2 \ No newline at end of file +2.8.3 \ No newline at end of file diff --git a/builder.py b/builder.py index 2d6b992..5ccd448 100755 --- a/builder.py +++ b/builder.py @@ -11,6 +11,7 @@ from pathlib import Path from tqdm.auto import tqdm from PIL import Image, ImageOps +from jsmin import jsmin from modules.argumentparser import parse_arguments, Args @@ -123,6 +124,10 @@ def copy_static_files(_args: Args) -> None: with open(os.path.join(static_dir, "theme.css"), "x", encoding="utf-8") as f: logger.info("writing theme file") f.write(themehead + '\n.foldericon {\n content: url("data:image/svg+xml,' + svg + '");\n}\n' + themetail) + logger.info("minifying javascript") + with open(os.path.join(SCRIPTDIR, "templates", "functionality.js"), "r", encoding="utf-8") as js_file: + with open(os.path.join(static_dir, "functionality.min.js"), "w+", encoding="utf-8") as min_file: + min_file.write(jsmin(js_file.read())) def generate_thumbnail(arguments: tuple[str, str, str]) -> None: diff --git a/files/global.css b/files/global.css index 2cb0e17..dfe59f9 100644 --- a/files/global.css +++ b/files/global.css @@ -208,7 +208,7 @@ input { } .tooltiptext.tagdropdown.show { - max-height: 286px; + max-height: 80vh; overflow-y: scroll; opacity: 1; } @@ -232,7 +232,7 @@ input { } .tagentryparent.show { - max-height: 286px; + max-height: 80vh; opacity: 1; overflow-y: scroll; } diff --git a/modules/generate_html.py b/modules/generate_html.py index 201583d..6fe4cd1 100644 --- a/modules/generate_html.py +++ b/modules/generate_html.py @@ -3,6 +3,7 @@ import re import urllib.parse import fnmatch import json +import html from typing import Any from datetime import datetime from collections import defaultdict @@ -11,6 +12,7 @@ from tqdm.auto import tqdm from PIL import Image, ExifTags, TiffImagePlugin, UnidentifiedImageError from jinja2 import Environment, FileSystemLoader from defusedxml import ElementTree +from bs4 import BeautifulSoup from modules.logger import logger from modules import cclicense @@ -516,17 +518,18 @@ def process_subfolder(item: str, folder: str, baseurl: str, subfolders: list[dic def process_license(folder: str, item: str) -> None: """ - Processes a LICENSE file. + Processes a LICENSE file, preserving formatting in HTML. Args: - folder (str): The folder containing the info file. - item (str): The licenses file name. + folder (str): The folder containing the LICENSE file. + item (str): The LICENSE file name. """ - with open(os.path.join(folder, item), encoding="utf-8") as f: - logger.info("processing LICENSE", extra={"path": os.path.join(folder, item)}) - licens[urllib.parse.quote(folder)] = ( - f.read().replace("\n", "
\n").replace(" ", " ").replace(" ", " ").replace("sp; ", "sp; ").replace("  ", " ") - ) + path = os.path.join(folder, item) + with open(path, encoding="utf-8") as f: + logger.info("processing LICENSE", extra={"path": path}) + raw_text = f.read() + escaped_text = html.escape(raw_text) + licens[urllib.parse.quote(folder)] = f"
{escaped_text}
" def process_info_file(folder: str, item: str) -> None: @@ -556,6 +559,11 @@ def should_generate_html(images: list[dict[str, Any]], contains_files, _args: Ar return images or (_args.use_fancy_folders and not contains_files) or (_args.use_fancy_folders and _args.ignore_other_files) +def format_html(html: str) -> str: + soup = BeautifulSoup(html, "html5lib") + return soup.prettify() + + def create_html_file( folder: str, title: str, foldername: str, images: list[dict[str, Any]], subfolders: list[dict[str, str]], _args: Args, version: str, logo: str, subfoldertags: list[str] ) -> list[str]: @@ -624,7 +632,7 @@ def create_html_file( logo=logo, licensefile=folder_license, ) - f.write(content) + f.write(format_html(content)) html = env.get_template("index.html.j2") content = html.render( @@ -646,8 +654,8 @@ def create_html_file( ) with open(html_file, "w", encoding="utf-8") as f: - logger.info("writing html file", extra={"path": html_file}) - f.write(content) + logger.info("writing formatted html file", extra={"path": html_file}) + f.write(format_html(content)) return sorted(alltags) diff --git a/modules/svg_handling.py b/modules/svg_handling.py index 27729aa..f18e30d 100644 --- a/modules/svg_handling.py +++ b/modules/svg_handling.py @@ -146,7 +146,7 @@ def render_manifest_json(_args: Args, icon_list: list[Icon], colors: dict[str, s colors : dict[str, str] dictionary containing color scheme and theme color. """ - manifest = env.get_template("manifest.json.j2") + manifest = env.get_template("manifest.webmanifest.j2") content = manifest.render( name=_args.web_root_url.replace("https://", "").replace("http://", "").replace("/", ""), short_name=_args.site_title, @@ -154,8 +154,8 @@ def render_manifest_json(_args: Args, icon_list: list[Icon], colors: dict[str, s background_color=colors["bcolor1"], theme_color=colors["theme_color"], ) - with open(os.path.join(_args.root_directory, ".static", "manifest.json"), "w", encoding="utf-8") as f: - logger.info("rendering manifest.json", extra={"path": os.path.join(_args.root_directory, ".static", "manifest.json")}) + with open(os.path.join(_args.root_directory, ".static", "manifest.webmanifest"), "w", encoding="utf-8") as f: + logger.info("rendering manifest.webmanifest", extra={"path": os.path.join(_args.root_directory, ".static", "manifest.webmanifest")}) f.write(content) diff --git a/requirements.txt b/requirements.txt index 01e43a1..55c60e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ CairoSVG==2.7.1 defusedxml==0.7.1 +html5lib==1.1 Jinja2==3.1.5 +jsmin==3.0.1 Pillow==11.1.0 pyinstaller==6.11.1 python_json_logger==2.0.7 diff --git a/templates/functionality.js b/templates/functionality.js new file mode 100644 index 0000000..61d4a85 --- /dev/null +++ b/templates/functionality.js @@ -0,0 +1,410 @@ +class PhotoGallery { + constructor() { + this.pswpElement = document.querySelector(".pswp"); + this.re = /pid=(\d+)/; + this.filterRe = /#(.*)/; + this.recursiveRe = /\?recursive/; + this.items = []; + this.shown = []; + this.subfolders = []; + this.controllers = {}; + this.tagDropdownShown = false; + + this.debounce = this.debounce.bind(this); + this.openSwipe = this.openSwipe.bind(this); + this.prefetch = this.prefetch.bind(this); + this.cancel = this.cancel.bind(this); + this.reset = this.reset.bind(this); + this.recursive = this.recursive.bind(this); + this.requestMetadata = this.requestMetadata.bind(this); + this.filter = this.filter.bind(this); + this.updateImageList = this.updateImageList.bind(this); + this.setFilter = this.setFilter.bind(this); + this.toggleTag = this.toggleTag.bind(this); + this.setupDropdownToggle = this.setupDropdownToggle.bind(this); + this.setupTagHandlers = this.setupTagHandlers.bind(this); + this.setupClickHandlers = this.setupClickHandlers.bind(this); + this.scrollFunction = this.scrollFunction.bind(this); + this.topFunction = this.topFunction.bind(this); + this.onLoad = this.onLoad.bind(this); + + this.init(); + } + + debounce(fn, delay) { + let timeoutId; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn.apply(this, args), delay); + }; + } + + openSwipe(imgIndex) { + const options = { index: imgIndex }; + const gallery = new PhotoSwipe( + this.pswpElement, + PhotoSwipeUI_Default, + this.shown, + options + ); + gallery.init(); + } + + prefetch(imgIndex) { + if (this.controllers[imgIndex]) { + this.cancel(imgIndex); + } + const controller = new AbortController(); + const signal = controller.signal; + this.controllers[imgIndex] = controller; + const urlToFetch = this.items[imgIndex]?.src; + if (urlToFetch) { + fetch(urlToFetch, { method: "GET", signal }).catch(() => {}); + } + } + + cancel(imgIndex) { + if (this.controllers[imgIndex]) { + this.controllers[imgIndex].abort(); + delete this.controllers[imgIndex]; + } + } + + reset() { + const curr = window.location.href.split("#"); + const content = document.documentElement.innerHTML; + const title = document.title; + const folders = document.querySelector(".folders"); + if (folders) folders.style.display = ""; + document + .querySelectorAll("#tagdropdown input.tagcheckbox:checked") + .forEach((checkbox) => (checkbox.checked = false)); + window.history.replaceState( + { html: content, pageTitle: title }, + "", + curr[0].split("?")[0] + "#" + ); + this.requestMetadata(); + } + + async recursive() { + const curr = window.location.href.split("#"); + const content = document.documentElement.innerHTML; + const title = document.title; + const isChecked = document.getElementById("recursive")?.checked; + const folders = document.querySelector(".folders"); + + if (!isChecked) { + if (folders) folders.style.display = ""; + window.history.replaceState( + { html: content, pageTitle: title }, + "", + curr[0].split("?")[0] + "#" + (curr[1] || "") + ); + this.requestMetadata(); + return; + } + + if (folders) folders.style.display = "none"; + window.history.replaceState( + { html: content, pageTitle: title }, + "", + curr[0].split("?")[0] + "?recursive#" + (curr[1] || "") + ); + + const visited = new Set(); + const existingItems = new Set(); + const newItems = []; + + try { + const response = await fetch(".metadata.json"); + if (!response.ok) throw new Error("Failed to fetch metadata"); + const data = await response.json(); + + this.items = []; + this.subfolders = data.subfolders || []; + + for (const image of Object.values(data.images || {})) { + newItems.push(image); + existingItems.add(image.src); + } + } catch { + return; + } + + const fetchFoldersRecursively = async (folderList) => { + if (!Array.isArray(folderList)) return; + const nextLevel = []; + await Promise.all( + folderList.map(async (folder) => { + if (!folder || !folder.metadata || visited.has(folder.url)) return; + visited.add(folder.url); + try { + const response = await fetch(folder.metadata); + if (!response.ok) throw new Error(); + const data = await response.json(); + for (const image of Object.values(data.images || {})) { + if (!existingItems.has(image.src)) { + newItems.push(image); + existingItems.add(image.src); + } + } + if (Array.isArray(data.subfolders)) + nextLevel.push(...data.subfolders); + } catch {} + }) + ); + if (nextLevel.length > 0) await fetchFoldersRecursively(nextLevel); + }; + + await fetchFoldersRecursively(this.subfolders); + this.items = [...newItems]; + this.filter(); + } + + requestMetadata() { + fetch(".metadata.json") + .then((response) => { + if (!response.ok) throw new Error("Failed to fetch metadata"); + return response.json(); + }) + .then((data) => { + this.items = Object.values(data.images || {}); + this.subfolders = data.subfolders || []; + + if (this.filterRe.test(window.location.href)) { + const selected = window.location.href + .match(this.filterRe)[1] + .split(","); + this.setFilter(selected); + } + if (this.recursiveRe.test(window.location.href)) { + const recChk = document.getElementById("recursive"); + if (recChk) recChk.checked = true; + this.recursive(); + } else { + this.filter(); + } + if (this.re.test(window.location.href)) { + const pid = window.location.href.match(this.re)[1]; + this.openSwipe(parseInt(pid)); + } + }) + .catch(() => {}); + } + + filter() { + this.shown = []; + const curr = window.location.href.split("#")[0] + "#"; + const path = decodeURIComponent( + window.location.href.split("#")[0].replace("index.html", "") + ); + const selectedTags = []; + + document + .querySelectorAll("#tagdropdown input.tagcheckbox:checked") + .forEach((checkbox) => { + let tag = checkbox.parentElement.id.trim().substring(1); + if (checkbox.parentElement.parentElement.children.length > 1) + tag += "|"; + selectedTags.push(tag); + }); + + const urltags = selectedTags.join(","); + + let isRecursiveChecked = false; + try { + isRecursiveChecked = + document.getElementById("recursive")?.checked || false; + } catch {} + + for (const item of this.items) { + const tags = item.tags || []; + const include = selectedTags.every((selected) => { + const isParent = selected.endsWith("|"); + return isParent + ? tags.some((t) => t.startsWith(selected)) + : tags.includes(selected); + }); + + if (include || selectedTags.length === 0) { + if (!isRecursiveChecked) { + if (decodeURIComponent(item.src.replace(item.name, "")) === path) { + this.shown.push(item); + } + } else { + this.shown.push(item); + } + } + } + this.updateImageList(); + window.location.href = curr + urltags; + } + + updateImageList() { + const imagelist = document.getElementById("imagelist"); + if (!imagelist) return; + let str = ""; + this.shown.forEach((item, index) => { + str += `
${item.name}`; + if (item.tiff) str += ` TIFF`; + if (item.raw) str += ` RAW`; + str += "
"; + }); + imagelist.innerHTML = str; + } + + setFilter(selected) { + document + .querySelectorAll("#tagdropdown input.tagcheckbox") + .forEach((checkbox) => { + selected.forEach((tag) => { + if ( + checkbox.parentElement.id + .trim() + .substring(1) + .replace(" ", "%20") === tag + ) { + checkbox.checked = true; + } + }); + }); + } + + toggleTag(tagid) { + const tag = document.getElementById(tagid); + const ol = tag?.closest(".tagentry")?.querySelector(".tagentryparent"); + const svg = tag?.parentElement.querySelector(".tagtoggle svg"); + if (!ol || !svg) return; + ol.classList.toggle("show"); + svg.style.transform = ol.classList.contains("show") + ? "rotate(180deg)" + : "rotate(0deg)"; + } + + setupDropdownToggle() { + const toggleLink = document.getElementById("tagtogglelink"); + const dropdown = document.getElementById("tagdropdown"); + if (!toggleLink) return; + + toggleLink.addEventListener("click", (event) => { + event.stopPropagation(); + const svg = toggleLink.querySelector("svg"); + dropdown.classList.toggle("show"); + if (svg) + svg.style.transform = dropdown.classList.contains("show") + ? "rotate(180deg)" + : "rotate(0deg)"; + this.tagDropdownShown = dropdown.classList.contains("show"); + }); + + document.addEventListener("click", (event) => { + if ( + !dropdown.contains(event.target) && + !toggleLink.contains(event.target) + ) { + dropdown.classList.remove("show"); + this.tagDropdownShown = false; + const svg = toggleLink.querySelector("svg"); + if (svg) svg.style.transform = "rotate(0deg)"; + } + }); + } + + setupTagHandlers() { + const tagContainer = document.getElementById("tagdropdown"); + if (!tagContainer) return; + + const debouncedFilter = this.debounce(this.filter, 150); + tagContainer.addEventListener("change", debouncedFilter); + + tagContainer.addEventListener("click", (event) => { + const toggle = event.target.closest(".tagtoggle"); + if (toggle) { + event.stopPropagation(); + const tagid = toggle.dataset.toggleid; + this.toggleTag(tagid); + } + }); + } + + setupClickHandlers() { + const resetEl = document + .getElementById("reset-filter") + ?.querySelector("label"); + if (resetEl) resetEl.addEventListener("click", this.reset); + + const recurseEl = document.getElementById("recursive"); + if (recurseEl) + recurseEl.addEventListener("change", this.debounce(this.recursive, 150)); + + const totop = document.getElementById("totop"); + if (totop) totop.addEventListener("click", this.topFunction); + + const imagelist = document.getElementById("imagelist"); + if (imagelist) { + imagelist.addEventListener("click", (event) => { + const img = event.target.closest("img"); + if (!img || !img.dataset.index) return; + const index = parseInt(img.dataset.index); + if (!isNaN(index)) this.openSwipe(index); + }); + + imagelist.addEventListener("mouseover", (event) => { + const img = event.target.closest("img"); + if (!img || !img.dataset.index) return; + const index = parseInt(img.dataset.index); + if (!isNaN(index)) this.prefetch(index); + }); + + imagelist.addEventListener("mouseleave", (event) => { + const img = event.target.closest("img"); + if (!img || !img.dataset.index) return; + const index = parseInt(img.dataset.index); + if (!isNaN(index)) this.cancel(index); + }); + } + } + + scrollFunction() { + const totopbutton = document.getElementById("totop"); + if (!totopbutton) return; + if ( + document.body.scrollTop > 20 || + document.documentElement.scrollTop > 20 + ) { + totopbutton.style.display = "block"; + } else { + totopbutton.style.display = "none"; + } + } + + topFunction() { + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + onLoad() { + document.querySelectorAll(".tagtoggle").forEach((toggle) => { + toggle.addEventListener("mouseup", (event) => { + event.stopPropagation(); + const tagid = toggle.getAttribute("data-tagid"); + this.toggleTag(tagid); + }); + }); + + this.requestMetadata(); + this.setupDropdownToggle(); + this.setupTagHandlers(); + this.setupClickHandlers(); + + window.addEventListener("scroll", this.scrollFunction); + } + + init() { + if (window.addEventListener) { + window.addEventListener("load", this.onLoad, false); + } else if (window.attachEvent) { + window.attachEvent("onload", this.onLoad); + } + } +} diff --git a/templates/index.html.j2 b/templates/index.html.j2 index eef003b..7a8f1f6 100644 --- a/templates/index.html.j2 +++ b/templates/index.html.j2 @@ -28,7 +28,7 @@ {{ title }} {%- if webmanifest %} - + {%- endif %} {%- if theme %} @@ -43,10 +43,12 @@ + + @@ -78,8 +80,9 @@
    + - @@ -130,14 +133,14 @@ {%- endif %} Made with StaticGalleryBuilder {{ version }} by {{ logo }}. - + {%- endif %} {%- else %} {%- endif %} - + \ No newline at end of file diff --git a/templates/license.html.j2 b/templates/license.html.j2 index 397b57a..1f6bbfd 100644 --- a/templates/license.html.j2 +++ b/templates/license.html.j2 @@ -21,13 +21,16 @@
    - +
    {%- if licensefile %}
    diff --git a/templates/manifest.json.j2 b/templates/manifest.webmanifest.j2 similarity index 100% rename from templates/manifest.json.j2 rename to templates/manifest.webmanifest.j2