Compare commits

..

10 commits

Author SHA1 Message Date
d297110d88 Add installation instructions to README 2024-08-11 17:32:02 -07:00
e2338df37c Release v0.1.1
- Add output_dir config option, defaults to "site" to keep original
  directory clean
- Add support for nested covers - if there are no images in a dir, pick
  the cover from the first child dir
- Show help text if no args are given
- Some HTML/CSS visual tweaks
- Start buildling standalone binaries using PEX
2024-08-11 17:26:31 -07:00
4d45aa5296 makefile tooling for building standalone binaries for various platforms and making a GH release 2024-08-11 17:25:54 -07:00
f7998cd708 Add output_dir config option, default to "site" to keep original directory clean 2024-08-11 12:13:27 -07:00
cd47a82ecf visual tweaks 2024-08-05 21:18:53 -07:00
6cd2e52408 Merge branch 'pex' 2024-08-05 20:00:28 -07:00
f60a14ae21 Add support for nested covers - if there are no images in a dir, pick the cover from the first child dir 2024-08-05 20:00:06 -07:00
c93f1997fc basic PEX tooling to build independent binaries 2024-08-05 19:29:40 -07:00
c08c5609ca Support running with no args - show help 2024-08-05 08:08:25 -07:00
acd736c970 Revert "mark py3.11 support"
This reverts commit 04586c8c45.

We actually need >= 3.12
2024-08-04 22:30:30 -07:00
11 changed files with 130 additions and 49 deletions

View file

@ -7,6 +7,9 @@ ci: init lint test
# Final pre-flight checks then deploy everywhere! # Final pre-flight checks then deploy everywhere!
shipit: all build staging prod 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: init:
poetry install poetry install
@ -33,11 +36,17 @@ test-fast:
test-watch: test-watch:
find . -name '*py' -or -name '*html' -or -name poetry.lock | entr -r -c make test-fast find . -name '*py' -or -name '*html' -or -name poetry.lock | entr -r -c make test-fast
run: clean:
podman-compose up rm -rv dist || true
build: docker:
podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD) podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD)
clean: dist:
podman-compose down --rmi all 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))

View file

@ -10,7 +10,16 @@ It's everything I need and nothing I don't.
## Getting Started ## 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-<version>-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 ### Initialization

View file

@ -1,4 +1,5 @@
import logging import logging
import sys
from argparse import ArgumentParser, Namespace from argparse import ArgumentParser, Namespace
from pathlib import Path from pathlib import Path
@ -15,16 +16,17 @@ def main() -> None:
setup_logging(args.logging) setup_logging(args.logging)
# Load config from file if it exists # Load config from file if it exists
conf_path = Path(args.album_path) / Path(args.config) if hasattr(args, "album_path"):
if conf_path.exists(): conf_path = Path(args.album_path) / Path(args.config)
logger.debug(f"Reading config from {conf_path}") if conf_path.exists():
config = Config.from_yaml(conf_path.read_bytes()) logger.debug(f"Reading config from {conf_path}")
elif args.action != "init": config = Config.from_yaml(conf_path.read_bytes())
logger.error( elif args.action != "init":
f"No config file found at {conf_path}. If this is a new photo directory, " logger.error(
"please run `photojawn init` in there first." f"No config file found at {conf_path}. If this is a new photo directory, "
) "please run `photojawn init` in there first."
return )
return
# Call the subcommand function # Call the subcommand function
match args.action: match args.action:
@ -98,7 +100,12 @@ def parse_args() -> Namespace:
help="Path to the main photos directory", 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
######################################## ########################################

View file

@ -16,6 +16,9 @@ class Config:
# Size of the image when looking at the standalone image page # Size of the image when looking at the standalone image page
view_size: tuple[int, int] = (1920, 1080) view_size: tuple[int, int] = (1920, 1080)
# Directory inside the photo directory to output the site to
output_dir: str = "site"
# Quick mode: # Quick mode:
# - Don't regenerate thumbnails if they already exist # - Don't regenerate thumbnails if they already exist
quick: bool = False quick: bool = False
@ -29,6 +32,8 @@ class Config:
for key, val in data.items(): for key, val in data.items():
match key: match key:
case "output_dir":
conf.output_dir = val
case "thumnail_size": case "thumnail_size":
conf.thumbnail_size = tuple(val) conf.thumbnail_size = tuple(val)
case "view_size": case "view_size":

View file

@ -1,7 +1,8 @@
import logging import logging
import shutil
import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from pprint import pformat
from typing import Iterator, Optional from typing import Iterator, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
@ -17,7 +18,6 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class ImageDirectory: class ImageDirectory:
path: Path path: Path
rel_path: Path # Path relative to the root dir
children: list["ImageDirectory"] children: list["ImageDirectory"]
images: list["ImagePath"] images: list["ImagePath"]
is_root: bool = False is_root: bool = False
@ -75,13 +75,22 @@ def generate(config: Config, album_path: Path) -> None:
""" """
Main generation function Main generation function
""" """
root_dir = find_images(album_path) # Change the working directory to the album_path so that all paths are relative to
logger.debug(pformat(root_dir)) # it when we find images. We need to do this because all the paths in HTML need to
generate_thumbnails(config, root_dir) # 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) 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. 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 # image_dirs keeps track of all directories we find with images in them, so we can
# attach them as children to parent directories # attach them as children to parent directories
image_dirs: dict[Path, ImageDirectory] = { image_dirs: dict[Path, ImageDirectory] = {
root_path: ImageDirectory( root_path: ImageDirectory(path=root_path, children=[], images=[], is_root=True)
path=root_path, rel_path=Path("."), children=[], images=[], is_root=True
)
} }
for dirpath, dirnames, filenames in root_path.walk(top_down=False): 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 continue
image_dir = image_dirs.get( image_dir = image_dirs.get(
dirpath, dirpath,
ImageDirectory( ImageDirectory(
path=dirpath, path=dirpath,
rel_path=dirpath.relative_to(root_path),
children=[], children=[],
images=[], images=[],
), ),
@ -141,8 +147,14 @@ def find_images(root_path: Path) -> ImageDirectory:
image_dir.images.append(ip) image_dir.images.append(ip)
if image_dir.cover_path is None and len(image_dir.images) > 0: if image_dir.cover_path is None:
image_dir.cover_path = image_dir.images[0] 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 image_dirs[image_dir.path] = image_dir
return image_dirs[root_path] return image_dirs[root_path]
@ -159,20 +171,26 @@ def is_image(path: Path) -> bool:
return False 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 # Include cover images here because we want thumbnails for all of them
all_images = root_dir.image_paths() + root_dir.cover_image_paths() 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) orig_img = Image.open(image_path.path)
slides_path = image_path.path.parent / "slides" slides_path = config.output_dir / image_path.path.parent / "slides"
slides_path.mkdir(exist_ok=True) 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: if not thumb_path.exists() or not config.quick:
thumb_img = orig_img.copy() thumb_img = orig_img.copy()
thumb_img.thumbnail(config.thumbnail_size) 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}"' 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: if not screen_path.exists() or not config.quick:
screen_img = orig_img.copy() screen_img = orig_img.copy()
screen_img.thumbnail(config.view_size) 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(): for album_dir in root_dir.walk():
html_path = album_dir.path / "index.html" html_path = album_dir.path / "index.html"
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True) 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 = [] breadcrumbs = []
if not album_dir.is_root: if not album_dir.is_root:
crumb_pos = album_dir.path.parent 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() html_path = image_path.html_path()
root_path = root_dir.path.relative_to(html_path.parent, walk_up=True) 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) html_path.parent.mkdir(exist_ok=True)
prev_image = None prev_image = None

View file

@ -9,20 +9,25 @@
{% endfor %} {% endfor %}
/ {{album_dir.path.name}} / {{album_dir.path.name}}
</h1> </h1>
<hr>
{% endif %} {% endif %}
{% if album_dir.description %} {% if album_dir.description %}
<div class="caption">
{{ album_dir.description | safe }} {{ album_dir.description | safe }}
</div>
{% endif %} {% endif %}
{% if album_dir.children %} {% if album_dir.children %}
<h2>Albums</h2>
<div id="album-children"> <div id="album-children">
{% for child in album_dir.children %} {% for child in album_dir.children %}
<div class="album"> <div class="album">
<a href="{{child.path.name}}/"> <a href="{{child.path.name}}/">
<div> <div>
{% if child.cover_path %} {% if child.cover_path %}
<img src="{{child.path.name}}/slides/{{child.cover_path.thumbnail_filename()}}" /> <img
src="{{child.cover_path.path.parent.relative_to(album_dir.path)}}/slides/{{child.cover_path.thumbnail_filename()}}" />
{% endif %} {% endif %}
</div> </div>
<div> <div>

View file

@ -42,7 +42,7 @@
</div> </div>
</div> </div>
<div id="photo-description"> <div id="photo-description" class="caption">
{% if image_path.description %} {% if image_path.description %}
{{ image_path.description | safe }} {{ image_path.description | safe }}
{% endif %} {% endif %}

View file

@ -3,3 +3,7 @@ thumbnail_size: [256, 256]
# Max size of images when viewing a single one on screen # Max size of images when viewing a single one on screen
view_size: [1024, 768] 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

@ -22,15 +22,14 @@ a {
#content { #content {
text-align: center; text-align: center;
max-width: 1200px;
margin: 0.5em; margin: 0.5em;
margin-left: auto;
margin-right: auto;
} }
#content > * { #content > * {
width: fit-content;
max-width: 1500px;
margin-top: 1em; margin-top: 1em;
margin-left: auto;
margin-right: auto;
} }
ul { ul {
@ -47,6 +46,9 @@ ul {
margin: 1em; margin: 1em;
padding: 0.75em; padding: 0.75em;
background-color: lightgrey; background-color: lightgrey;
height: min-content;
border: thin solid black;
box-shadow: 0.25em 0.25em #ccc;
} }
#album-photos { #album-photos {
@ -81,6 +83,12 @@ ul {
height: auto; height: auto;
} }
.caption {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.arrow { .arrow {
border: solid black; border: solid black;
border-width: 0 3px 3px 0; border-width: 0 3px 3px 0;

18
poetry.lock generated
View file

@ -290,6 +290,20 @@ files = [
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, {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]] [[package]]
name = "pillow" name = "pillow"
version = "10.4.0" version = "10.4.0"
@ -590,5 +604,5 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12,<3.14"
content-hash = "3f22f947559bd9d54a838d51953828baa88e2485fb90c33931359511805cacc9" content-hash = "9ec27256e1d8988f06634a217388d357a211f6120d6ade104fdd87182b18279c"

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "photojawn" name = "photojawn"
version = "0.1.0" version = "0.1.1"
description = "A simple photo album static site generator" description = "A simple photo album static site generator"
authors = ["Nick Pegg <nick@nickpegg.com>"] authors = ["Nick Pegg <nick@nickpegg.com>"]
repository = "https://github.com/nickpegg/photojawn" repository = "https://github.com/nickpegg/photojawn"
@ -12,7 +12,7 @@ photojawn = 'photojawn.cli:main'
[tool.poetry.dependencies] [tool.poetry.dependencies]
# TODO: make sure we support >=3.10 via tests # TODO: make sure we support >=3.10 via tests
python = "<4.0,>=3.11" python = "^3.12,<3.14"
jinja2 = "^3.1.4" jinja2 = "^3.1.4"
pillow = "^10.4.0" pillow = "^10.4.0"
rich = "^13.7.1" rich = "^13.7.1"
@ -26,6 +26,7 @@ mypy = "*"
pytest-testmon = "*" pytest-testmon = "*"
types-pyyaml = "^6.0.12.20240724" types-pyyaml = "^6.0.12.20240724"
types-markdown = "^3.6.0.20240316" types-markdown = "^3.6.0.20240316"
pex = "^2.14.1"
[tool.mypy] [tool.mypy]