diff --git a/Makefile b/Makefile index 1d96c95..2b99c41 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ ci: init lint test # Final pre-flight checks then deploy everywhere! shipit: all build staging prod +version := $(shell yq -p toml .tool.poetry.version < pyproject.toml) +scie_platforms := linux-aarch64 linux-x86_64 macos-aarch64 macos-x86_64 + init: poetry install @@ -33,11 +36,17 @@ test-fast: test-watch: find . -name '*py' -or -name '*html' -or -name poetry.lock | entr -r -c make test-fast -run: - podman-compose up +clean: + rm -rv dist || true -build: +docker: podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD) -clean: - podman-compose down --rmi all +dist: + poetry build + poetry run pex --project . -o dist/photojawn -c photojawn --scie eager $(foreach plat,$(scie_platforms), --scie-platform $(plat)) + +release: dist + git push --tags + gh release create --verify-tag v$(version) + gh release upload v$(version) dist/photojawn-$(version)-*whl $(foreach plat,$(scie_platforms),dist/photojawn-$(plat)) diff --git a/README.md b/README.md index b9fbd5a..fd7d2a1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,16 @@ It's everything I need and nothing I don't. ## Getting Started -TODO: Installation instructions lol +### Installation + +Photojawn requires at least Python 3.12. If you don't know what version you +have, you can check by running `python -V`. + +1. Head on over to the [releases](https://github.com/nickpegg/photojawn/releases) page +2. If you have Python >=3.12, you can install using the wheel (`.whl` file) + a. To install, run `pip install photojawn--py3-none-any.whl` +3. If you don't have Python >=3.12, you can download one of the standalone binaries depending on your OS and architecture, e.g. `photojawn-linux-x86_64`. + ### Initialization diff --git a/photojawn/cli.py b/photojawn/cli.py index 9375151..f28c97b 100644 --- a/photojawn/cli.py +++ b/photojawn/cli.py @@ -1,4 +1,5 @@ import logging +import sys from argparse import ArgumentParser, Namespace from pathlib import Path @@ -15,16 +16,17 @@ def main() -> None: setup_logging(args.logging) # Load config from file if it exists - 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 + 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: @@ -98,7 +100,12 @@ def parse_args() -> Namespace: help="Path to the main photos directory", ) - return parser.parse_args() + args = parser.parse_args() + if not hasattr(args, "action"): + parser.print_help() + sys.exit(0) + + return args ######################################## diff --git a/photojawn/config.py b/photojawn/config.py index 1c075bd..fb208e2 100644 --- a/photojawn/config.py +++ b/photojawn/config.py @@ -16,6 +16,9 @@ class Config: # 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 @@ -29,6 +32,8 @@ class Config: 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": diff --git a/photojawn/generate.py b/photojawn/generate.py index f145b5e..6a45b31 100644 --- a/photojawn/generate.py +++ b/photojawn/generate.py @@ -1,7 +1,8 @@ import logging +import shutil +import os from dataclasses import dataclass from pathlib import Path -from pprint import pformat from typing import Iterator, Optional from jinja2 import Environment, FileSystemLoader, select_autoescape @@ -17,7 +18,6 @@ logger = logging.getLogger(__name__) @dataclass class ImageDirectory: path: Path - rel_path: Path # Path relative to the root dir children: list["ImageDirectory"] images: list["ImagePath"] is_root: bool = False @@ -75,13 +75,22 @@ def generate(config: Config, album_path: Path) -> None: """ Main generation function """ - root_dir = find_images(album_path) - logger.debug(pformat(root_dir)) - generate_thumbnails(config, root_dir) + # 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(root_path: Path) -> ImageDirectory: +def find_images(config: Config, root_path: Path) -> ImageDirectory: """ Build up an ImageDirectory to track all of the directories and their images. @@ -91,20 +100,17 @@ def find_images(root_path: Path) -> ImageDirectory: # 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, rel_path=Path("."), children=[], images=[], is_root=True - ) + 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"}: + if dirpath.name in {"slides", "_templates", "static", config.output_dir}: continue image_dir = image_dirs.get( dirpath, ImageDirectory( path=dirpath, - rel_path=dirpath.relative_to(root_path), children=[], images=[], ), @@ -141,8 +147,14 @@ def find_images(root_path: Path) -> ImageDirectory: image_dir.images.append(ip) - if image_dir.cover_path is None and len(image_dir.images) > 0: - image_dir.cover_path = image_dir.images[0] + 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] @@ -159,20 +171,26 @@ def is_image(path: Path) -> bool: return False -def generate_thumbnails(config: Config, root_dir: ImageDirectory) -> None: +def generate_images(config: Config, root_dir: ImageDirectory) -> None: """ - Find all of the images and generate thumbnails and on-screen versions + 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 thumbnails..."): + + for image_path in track(all_images, description="Making smaller images..."): orig_img = Image.open(image_path.path) - slides_path = image_path.path.parent / "slides" - slides_path.mkdir(exist_ok=True) + slides_path = config.output_dir / image_path.path.parent / "slides" + slides_path.mkdir(exist_ok=True, parents=True) - thumb_path = image_path.thumbnail_path() + # 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) @@ -181,7 +199,7 @@ def generate_thumbnails(config: Config, root_dir: ImageDirectory) -> None: f'Generated thumbnail size "{image_path.path}" -> "{thumb_path}"' ) - screen_path = image_path.display_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) @@ -207,8 +225,8 @@ def generate_html(config: Config, root_dir: ImageDirectory) -> None: 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 - # TODO build breadcrumbs here, (href, name) breadcrumbs = [] if not album_dir.is_root: crumb_pos = album_dir.path.parent @@ -240,6 +258,7 @@ def generate_html(config: Config, root_dir: ImageDirectory) -> None: 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 diff --git a/photojawn/skel/_templates/album.html b/photojawn/skel/_templates/album.html index 97102bb..cf4a112 100644 --- a/photojawn/skel/_templates/album.html +++ b/photojawn/skel/_templates/album.html @@ -9,20 +9,25 @@ {% endfor %} / {{album_dir.path.name}} +
{% endif %} {% if album_dir.description %} +
{{ album_dir.description | safe }} +
{% endif %} {% if album_dir.children %} +

Albums

{% for child in album_dir.children %} -
+
{% if image_path.description %} {{ image_path.description | safe }} {% endif %} diff --git a/photojawn/skel/photojawn.conf.yml b/photojawn/skel/photojawn.conf.yml index 9df48b9..91b8b0b 100644 --- a/photojawn/skel/photojawn.conf.yml +++ b/photojawn/skel/photojawn.conf.yml @@ -3,3 +3,7 @@ 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" diff --git a/photojawn/skel/static/index.css b/photojawn/skel/static/index.css index c15e4a5..f476fe1 100644 --- a/photojawn/skel/static/index.css +++ b/photojawn/skel/static/index.css @@ -22,15 +22,14 @@ a { #content { text-align: center; + max-width: 1200px; margin: 0.5em; + margin-left: auto; + margin-right: auto; } #content > * { - width: fit-content; - max-width: 1500px; margin-top: 1em; - margin-left: auto; - margin-right: auto; } ul { @@ -47,6 +46,9 @@ ul { margin: 1em; padding: 0.75em; background-color: lightgrey; + height: min-content; + border: thin solid black; + box-shadow: 0.25em 0.25em #ccc; } #album-photos { @@ -81,6 +83,12 @@ ul { height: auto; } +.caption { + max-width: 700px; + margin-left: auto; + margin-right: auto; +} + .arrow { border: solid black; border-width: 0 3px 3px 0; diff --git a/poetry.lock b/poetry.lock index ebf1e08..5c9194d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -290,6 +290,20 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pex" +version = "2.14.1" +description = "The PEX packaging toolchain." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<3.14,>=2.7" +files = [ + {file = "pex-2.14.1-py2.py3-none-any.whl", hash = "sha256:43210f64e5461d91ad6c99d80724e83f5010bfa82a1664bf44ffdb14e2defb58"}, + {file = "pex-2.14.1.tar.gz", hash = "sha256:e71296873101732deffa5341d7dc9a9d8ed9e58413acc07579f7b39998c273a2"}, +] + +[package.extras] +subprocess = ["subprocess32 (>=3.2.7)"] + [[package]] name = "pillow" version = "10.4.0" @@ -590,5 +604,5 @@ files = [ [metadata] lock-version = "2.0" -python-versions = "^3.12" -content-hash = "3f22f947559bd9d54a838d51953828baa88e2485fb90c33931359511805cacc9" +python-versions = "^3.12,<3.14" +content-hash = "9ec27256e1d8988f06634a217388d357a211f6120d6ade104fdd87182b18279c" diff --git a/pyproject.toml b/pyproject.toml index 32e17a4..600e9e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "photojawn" -version = "0.1.0" +version = "0.1.1" description = "A simple photo album static site generator" authors = ["Nick Pegg "] repository = "https://github.com/nickpegg/photojawn" @@ -12,7 +12,7 @@ photojawn = 'photojawn.cli:main' [tool.poetry.dependencies] # TODO: make sure we support >=3.10 via tests -python = "<4.0,>=3.11" +python = "^3.12,<3.14" jinja2 = "^3.1.4" pillow = "^10.4.0" rich = "^13.7.1" @@ -26,6 +26,7 @@ mypy = "*" pytest-testmon = "*" types-pyyaml = "^6.0.12.20240724" types-markdown = "^3.6.0.20240316" +pex = "^2.14.1" [tool.mypy]