move python stuff out of the way

This commit is contained in:
Nick Pegg 2025-04-26 19:48:47 -07:00
parent 94a5e30a8f
commit ceb872590f
16 changed files with 0 additions and 0 deletions

0
py/photojawn/__init__.py Normal file
View file

0
py/photojawn/clean.py Normal file
View file

175
py/photojawn/cli.py Normal file
View file

@ -0,0 +1,175 @@
import logging
import sys
from argparse import ArgumentParser, Namespace
from pathlib import Path
from rich.logging import RichHandler
from photojawn.config import DEFAULT_CONFIG_PATH, Config
from photojawn.generate import generate
logger = logging.getLogger("photojawn.cli")
def main() -> None:
args = parse_args()
setup_logging(args.logging)
# Load config from file if it exists
if hasattr(args, "album_path"):
conf_path = Path(args.album_path) / Path(args.config)
if conf_path.exists():
logger.debug(f"Reading config from {conf_path}")
config = Config.from_yaml(conf_path.read_bytes())
elif args.action != "init":
logger.error(
f"No config file found at {conf_path}. If this is a new photo directory, "
"please run `photojawn init` in there first."
)
return
# Call the subcommand function
match args.action:
case "init":
cmd_init(args)
case "generate":
if args.quick:
config.quick = args.quick
cmd_generate(args, config)
case "clean":
cmd_clean(args, config)
def parse_args() -> Namespace:
parser = ArgumentParser()
parser.add_argument(
"--config",
"-c",
default=DEFAULT_CONFIG_PATH,
help="Path to photojawn.config.json for the album",
)
parser.add_argument(
"--logging",
default="warning",
choices=[level.lower() for level in logging.getLevelNamesMapping().keys()],
help="Log level",
)
subcommands = parser.add_subparsers(title="subcommands")
init_cmd = subcommands.add_parser(
"init",
help="Initialize an photo directory",
)
init_cmd.set_defaults(action="init")
init_cmd.add_argument(
"album_path",
nargs="?",
default=".",
help="Path to the main photos directory",
)
# Generate subcommand
generate_cmd = subcommands.add_parser(
"generate",
help="Generate the HTML photo album",
)
generate_cmd.set_defaults(action="generate")
generate_cmd.add_argument(
"--quick",
action="store_true",
help="Quick mode - don't regenerate thumbnails",
)
generate_cmd.add_argument(
"album_path",
nargs="?",
default=".",
help="Path to the main photos directory",
)
# Clean subcommand
clean_cmd = subcommands.add_parser(
"clean",
help="Remove all generated content from the photo album directory",
)
clean_cmd.set_defaults(action="clean")
clean_cmd.add_argument(
"album_path",
nargs="?",
default=".",
help="Path to the main photos directory",
)
args = parser.parse_args()
if not hasattr(args, "action"):
parser.print_help()
sys.exit(0)
return args
########################################
# Command functions
def cmd_init(args: Namespace) -> None:
"""
Generate a basic config and template files
"""
album_path = Path(args.album_path)
config_path = album_path / args.config
if config_path.exists():
logger.warning(
f"Looks like {album_path} is already set up. If you want to start over and "
f"overwrite any of your customizations, remove {config_path}"
)
return
skel_dir = Path(__file__).parent / "skel"
logger.debug(f"Skeleton dir: {skel_dir}")
skel_files = []
for parent_path, dirnames, filenames in skel_dir.walk():
for filename in filenames:
skel_file_path = parent_path / filename
rel_path = skel_file_path.relative_to(skel_dir)
album_file_path = album_path / rel_path
skel_files.append(album_file_path)
album_file_path.parent.mkdir(exist_ok=True)
album_file_path.write_bytes(skel_file_path.read_bytes())
logger.debug(f"Created skeleton file {album_file_path}")
print("Some basic files have been created for your album. Edit them as you need:")
for p in skel_files:
print(f" - {p}")
def cmd_generate(args: Namespace, config: Config) -> None:
logger.debug(f"Generating in {args.album_path}")
generate(config, Path(args.album_path))
def cmd_clean(args: Namespace, config: Config) -> None:
"""
Clean the photo album by all files that photojawn generated
"""
pass
########################################
# CLI Util functions
def setup_logging(level_str: str) -> None:
levels = logging.getLevelNamesMapping()
level = levels[level_str.upper()]
logging.basicConfig(
level=level,
format="[%(name)s] %(message)s",
handlers=[RichHandler(rich_tracebacks=True)],
)
# Override PIL logging because debug is really noisy
if level <= logging.DEBUG:
logging.getLogger("PIL").setLevel(logging.INFO)
if __name__ == "__main__":
main()

41
py/photojawn/config.py Normal file
View file

@ -0,0 +1,41 @@
import logging
from dataclasses import dataclass
import yaml
DEFAULT_CONFIG_PATH = "photojawn.conf.yml"
logger = logging.getLogger(__name__)
@dataclass
class Config:
# Size of thumbnails when looking at a folder page
thumbnail_size: tuple[int, int] = (256, 256)
# Size of the image when looking at the standalone image page
view_size: tuple[int, int] = (1920, 1080)
# Directory inside the photo directory to output the site to
output_dir: str = "site"
# Quick mode:
# - Don't regenerate thumbnails if they already exist
quick: bool = False
@classmethod
def from_yaml(cls, contents: bytes) -> "Config":
conf = cls()
data = yaml.safe_load(contents)
if data is None:
return conf
for key, val in data.items():
match key:
case "output_dir":
conf.output_dir = val
case "thumnail_size":
conf.thumbnail_size = tuple(val)
case "view_size":
conf.view_size = tuple(val)
return conf

282
py/photojawn/generate.py Normal file
View file

@ -0,0 +1,282 @@
import logging
import os
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
from markdown import markdown
from PIL import Image, UnidentifiedImageError
from rich.progress import Progress, track
from photojawn.config import Config
logger = logging.getLogger(__name__)
@dataclass
class ImageDirectory:
path: Path
children: list["ImageDirectory"]
images: list["ImagePath"]
is_root: bool = False
description: str = ""
cover_path: Optional["ImagePath"] = None
def walk(self) -> Iterator["ImageDirectory"]:
yield self
for child in self.children:
yield from child.walk()
def image_paths(self) -> list["ImagePath"]:
"""
Iterate through all images in this dir and children
"""
images = []
for image_dir in self.walk():
images += image_dir.images
return images
def cover_image_paths(self) -> list["ImagePath"]:
images = []
for image_dir in self.walk():
if image_dir.cover_path is not None:
images.append(image_dir.cover_path)
return images
@dataclass
class ImagePath:
path: Path
description: str = ""
def thumbnail_filename(self) -> str:
return self.path.stem + ".thumb" + self.path.suffix
def thumbnail_path(self) -> Path:
return self.path.parent / "slides" / self.thumbnail_filename()
def display_filename(self) -> str:
return self.path.stem + ".screen" + self.path.suffix
def display_path(self) -> Path:
return self.path.parent / "slides" / self.display_filename()
def html_filename(self) -> str:
return self.path.with_suffix(".html").name
def html_path(self) -> Path:
return self.path.parent / "slides" / self.html_filename()
def generate(config: Config, album_path: Path) -> None:
"""
Main generation function
"""
# Change the working directory to the album_path so that all paths are relative to
# it when we find images. We need to do this because all the paths in HTML need to
# be relative to it and we don't want to have to do a bunch of path gymnastics to
# re-relative all those paths.
orig_wd = Path.cwd()
os.chdir(album_path)
root_dir = find_images(config, Path("."))
generate_images(config, root_dir)
generate_html(config, root_dir)
shutil.copytree("static", Path(config.output_dir) / "static")
os.chdir(orig_wd)
def find_images(config: Config, root_path: Path) -> ImageDirectory:
"""
Build up an ImageDirectory to track all of the directories and their images.
A directory with no images nor childern with images will not be tracked, since we
don't want to render an album page for those.
"""
# image_dirs keeps track of all directories we find with images in them, so we can
# attach them as children to parent directories
image_dirs: dict[Path, ImageDirectory] = {
root_path: ImageDirectory(path=root_path, children=[], images=[], is_root=True)
}
for dirpath, dirnames, filenames in root_path.walk(top_down=False):
if dirpath.name in {"slides", "_templates", "static", config.output_dir}:
continue
image_dir = image_dirs.get(
dirpath,
ImageDirectory(
path=dirpath,
children=[],
images=[],
),
)
for dirname in sorted(dirnames):
child_path = dirpath / dirname
if child_path in image_dirs:
image_dir.children.append(image_dirs[child_path])
for filename in sorted(filenames):
file_path = dirpath / filename
if filename == "description.txt":
image_dir.description = file_path.read_text()
elif filename == "description.md":
image_dir.description = markdown(file_path.read_text())
elif is_image(file_path):
ip = ImagePath(file_path)
# Set a cover image for the album. Use "cover.jpg" if one exists,
# otherwise use the first image we find.
if file_path.stem == "cover":
image_dir.cover_path = ip
# Don't add the cover image to the list of images, we want to handle
# that separately
continue
# If there's an associated .txt or .md file, read it in as the image's
# description
if file_path.with_suffix(".md").exists():
ip.description = markdown(file_path.with_suffix(".md").read_text())
elif file_path.with_suffix(".txt").exists():
ip.description = file_path.with_suffix(".txt").read_text()
image_dir.images.append(ip)
if image_dir.cover_path is None:
if len(image_dir.images) > 0:
image_dir.cover_path = image_dir.images[0]
elif len(image_dir.children) > 0:
cover = image_dir.children[0].cover_path
logger.debug(f"nested cover path for {image_dir.path.name}: {cover}")
image_dir.cover_path = cover
image_dirs[image_dir.path] = image_dir
return image_dirs[root_path]
def is_image(path: Path) -> bool:
"""
Returns True if PIL thinks the file is an image
"""
try:
Image.open(path)
return True
except UnidentifiedImageError:
return False
def generate_images(config: Config, root_dir: ImageDirectory) -> None:
"""
Find all of the images and generate various image sizes
"""
# Include cover images here because we want thumbnails for all of them
all_images = root_dir.image_paths() + root_dir.cover_image_paths()
for image_path in track(all_images, description="Making smaller images..."):
orig_img = Image.open(image_path.path)
slides_path = config.output_dir / image_path.path.parent / "slides"
slides_path.mkdir(exist_ok=True, parents=True)
# Copy the original image in
orig_new_path = config.output_dir / image_path.path
if not orig_new_path.exists() or not config.quick:
logger.info(f"Copying original image to {orig_new_path}")
orig_img.save(orig_new_path)
thumb_path = config.output_dir / image_path.thumbnail_path()
if not thumb_path.exists() or not config.quick:
thumb_img = orig_img.copy()
thumb_img.thumbnail(config.thumbnail_size)
thumb_img.save(thumb_path)
logger.info(
f'Generated thumbnail size "{image_path.path}" -> "{thumb_path}"'
)
screen_path = config.output_dir / image_path.display_path()
if not screen_path.exists() or not config.quick:
screen_img = orig_img.copy()
screen_img.thumbnail(config.view_size)
screen_img.save(screen_path)
logger.info(f'Generated screen size "{image_path.path}" -> "{screen_path}"')
def generate_html(config: Config, root_dir: ImageDirectory) -> None:
"""
Recursively generate HTML files for this directory and all children
"""
jinja_env = Environment(
loader=FileSystemLoader(root_dir.path / "_templates"),
autoescape=select_autoescape(),
)
album_tmpl = jinja_env.get_template("album.html")
photo_tmpl = jinja_env.get_template("photo.html")
with Progress() as progress:
task = progress.add_task("Rendering HTML...", total=len(root_dir.image_paths()))
for album_dir in root_dir.walk():
html_path = album_dir.path / "index.html"
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True)
html_path = config.output_dir / html_path
breadcrumbs = []
if not album_dir.is_root:
crumb_pos = album_dir.path.parent
while crumb_pos != root_dir.path:
breadcrumbs.append(
(
str(crumb_pos.relative_to(album_dir.path, walk_up=True)),
crumb_pos.name,
)
)
crumb_pos = crumb_pos.parent
breadcrumbs.reverse()
logger.debug(f"Rendering {html_path}")
with html_path.open("w") as f:
f.write(
album_tmpl.render(
root_path=root_path,
album_dir=album_dir,
breadcrumbs=breadcrumbs,
)
)
for pos, image_path in enumerate(album_dir.images):
# TODO: If a file with a matching name but .txt or .md, add that as the
# description for the image
if image_path.path.stem == "cover":
continue
html_path = image_path.html_path()
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True)
html_path = config.output_dir / html_path
html_path.parent.mkdir(exist_ok=True)
prev_image = None
next_image = None
if pos != 0:
prev_image = album_dir.images[pos - 1]
if pos < len(album_dir.images) - 1:
next_image = album_dir.images[pos + 1]
logger.debug(f"Rendering {html_path}")
with html_path.open("w") as f:
f.write(
photo_tmpl.render(
root_path=root_path,
image_path=image_path,
prev_image=prev_image,
next_image=next_image,
)
)
progress.update(task, advance=1)

View file

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block content %}
{% if not album_dir.is_root %}
<h1>
<a href="{{root_path}}">Home</a>
{% for href, name in breadcrumbs %}
/ <a href="{{href}}">{{name}}</a>
{% endfor %}
/ {{album_dir.path.name}}
</h1>
<hr>
{% endif %}
{% if album_dir.description %}
<div class="caption">
{{ album_dir.description | safe }}
</div>
{% endif %}
{% if album_dir.children %}
<h2>Albums</h2>
<div id="album-children">
{% for child in album_dir.children %}
<div class="album">
<a href="{{child.path.name}}/">
<div>
{% if child.cover_path %}
<img
src="{{child.cover_path.path.parent.relative_to(album_dir.path)}}/slides/{{child.cover_path.thumbnail_filename()}}" />
{% endif %}
</div>
<div>
{{child.path.name}}
</div>
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% if album_dir.images %}
{% if album_dir.children %}
<h2>Photos</h2>
{% endif %}
<div id="album-photos">
{% for image in album_dir.images %}
<div class="thumbnail">
<a href="slides/{{image.html_filename()}}">
<img src="slides/{{image.thumbnail_filename()}}" />
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Photos</title>
<link rel="stylesheet" href="{{ root_path }}/static/index.css" type="text/css">
</head>
<body>
<div id="header">
<a href="{{ root_path }}">
<h1>My Photos</h1>
</a>
</div>
<div id="content">
{% block content %}
{% endblock %}
</div>
<script type="text/javascript">
{% block js %}
{% endblock %}
</script>
</body>
</html>

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block js %}
// Do left/right navigation on keypresses
document.onkeydown = function(event) {
if (event.key == "ArrowLeft") {
{% if prev_image %}
location.href = "{{prev_image.html_filename()}}";
{% endif %}
} else if (event.key == "ArrowRight") {
{% if next_image %}
location.href = "{{next_image.html_filename()}}";
{% endif %}
}
}
{% endblock %}
{% block content %}
<div id="photo">
<img src="{{image_path.display_filename()}}" />
</div>
<div id="nav">
<div>
{% if prev_image %}
<a href="{{prev_image.html_filename()}}">
<i class="arrow arrow-left"></i>
</a>
{% endif %}
</div>
<div>
<a href="..">
<i class="arrow arrow-up"></i>
</a>
</div>
<div>
{% if next_image %}
<a href="{{next_image.html_filename()}}">
<i class="arrow arrow-right"></i>
</a>
{% endif %}
</div>
</div>
<div id="photo-description" class="caption">
{% if image_path.description %}
{{ image_path.description | safe }}
{% endif %}
</div>
<div id="download">
<a href="../{{image_path.path.name}}">view full size</a>
</div>
{% endblock %}

View file

@ -0,0 +1,9 @@
# Max size of thumbnails when viewing an album
thumbnail_size: [256, 256]
# Max size of images when viewing a single one on screen
view_size: [1024, 768]
# Directory where the generated site will be created in. All original images will be
# copied in to here with the same directory structure.
output_dir: "site"

View file

@ -0,0 +1,112 @@
body {
margin: 0;
}
a {
color: #ba1200;
}
#header {
background-color: #eee;
}
#header * {
margin-top: 0;
text-decoration: inherit;
color: inherit;
}
#header h1 {
padding: 0.5em;
}
#content {
text-align: center;
max-width: 1200px;
margin: 0.5em;
margin-left: auto;
margin-right: auto;
}
#content > * {
margin-top: 1em;
}
ul {
padding-left: 1.5em;
}
#album-children {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
#album-children > * {
margin: 1em;
padding: 0.75em;
background-color: lightgrey;
height: min-content;
border: thin solid black;
box-shadow: 0.25em 0.25em #ccc;
}
#album-photos {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.thumbnail {
margin: 1em;
display: flex;
align-items: center;
}
.thumbnail * {
max-width: 100%;
}
#nav {
width: 150px;
margin: auto;
display: flex;
padding: 0.5em;
}
#nav div {
width: 50px;
}
#photo img {
max-width: 100%;
height: auto;
}
.caption {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.arrow {
border: solid black;
border-width: 0 3px 3px 0;
display: inline-block;
padding: 3px;
}
.arrow-right {
transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
}
.arrow-left {
transform: rotate(135deg);
-webkit-transform: rotate(135deg);
}
.arrow-up {
transform: rotate(-135deg);
-webkit-transform: rotate(-135deg);
}