Compare commits
No commits in common. "d297110d889bd4cbbf03738ae81e35f380bf5022" and "04586c8c45cda57fbec44b822296ddb9b7f66ad5" have entirely different histories.
d297110d88
...
04586c8c45
11 changed files with 49 additions and 130 deletions
19
Makefile
19
Makefile
|
@ -7,9 +7,6 @@ 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
|
||||||
|
@ -36,17 +33,11 @@ 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
|
||||||
|
|
||||||
clean:
|
run:
|
||||||
rm -rv dist || true
|
podman-compose up
|
||||||
|
|
||||||
docker:
|
build:
|
||||||
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)
|
||||||
|
|
||||||
dist:
|
clean:
|
||||||
poetry build
|
podman-compose down --rmi all
|
||||||
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))
|
|
||||||
|
|
11
README.md
11
README.md
|
@ -10,16 +10,7 @@ It's everything I need and nothing I don't.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Installation
|
TODO: Installation instructions lol
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
from argparse import ArgumentParser, Namespace
|
from argparse import ArgumentParser, Namespace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
@ -16,17 +15,16 @@ 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
|
||||||
if hasattr(args, "album_path"):
|
conf_path = Path(args.album_path) / Path(args.config)
|
||||||
conf_path = Path(args.album_path) / Path(args.config)
|
if conf_path.exists():
|
||||||
if conf_path.exists():
|
logger.debug(f"Reading config from {conf_path}")
|
||||||
logger.debug(f"Reading config from {conf_path}")
|
config = Config.from_yaml(conf_path.read_bytes())
|
||||||
config = Config.from_yaml(conf_path.read_bytes())
|
elif args.action != "init":
|
||||||
elif args.action != "init":
|
logger.error(
|
||||||
logger.error(
|
f"No config file found at {conf_path}. If this is a new photo directory, "
|
||||||
f"No config file found at {conf_path}. If this is a new photo directory, "
|
"please run `photojawn init` in there first."
|
||||||
"please run `photojawn init` in there first."
|
)
|
||||||
)
|
return
|
||||||
return
|
|
||||||
|
|
||||||
# Call the subcommand function
|
# Call the subcommand function
|
||||||
match args.action:
|
match args.action:
|
||||||
|
@ -100,12 +98,7 @@ def parse_args() -> Namespace:
|
||||||
help="Path to the main photos directory",
|
help="Path to the main photos directory",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
return parser.parse_args()
|
||||||
if not hasattr(args, "action"):
|
|
||||||
parser.print_help()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
|
|
|
@ -16,9 +16,6 @@ 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
|
||||||
|
@ -32,8 +29,6 @@ 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":
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
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
|
||||||
|
@ -18,6 +17,7 @@ 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,22 +75,13 @@ def generate(config: Config, album_path: Path) -> None:
|
||||||
"""
|
"""
|
||||||
Main generation function
|
Main generation function
|
||||||
"""
|
"""
|
||||||
# Change the working directory to the album_path so that all paths are relative to
|
root_dir = find_images(album_path)
|
||||||
# it when we find images. We need to do this because all the paths in HTML need to
|
logger.debug(pformat(root_dir))
|
||||||
# be relative to it and we don't want to have to do a bunch of path gymnastics to
|
generate_thumbnails(config, root_dir)
|
||||||
# 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(config: Config, root_path: Path) -> ImageDirectory:
|
def find_images(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.
|
||||||
|
|
||||||
|
@ -100,17 +91,20 @@ def find_images(config: Config, 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(path=root_path, children=[], images=[], is_root=True)
|
root_path: ImageDirectory(
|
||||||
|
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", config.output_dir}:
|
if dirpath.name in {"slides", "_templates", "static"}:
|
||||||
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=[],
|
||||||
),
|
),
|
||||||
|
@ -147,14 +141,8 @@ def find_images(config: Config, root_path: Path) -> ImageDirectory:
|
||||||
|
|
||||||
image_dir.images.append(ip)
|
image_dir.images.append(ip)
|
||||||
|
|
||||||
if image_dir.cover_path is None:
|
if image_dir.cover_path is None and len(image_dir.images) > 0:
|
||||||
if len(image_dir.images) > 0:
|
image_dir.cover_path = 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]
|
||||||
|
@ -171,26 +159,20 @@ def is_image(path: Path) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def generate_images(config: Config, root_dir: ImageDirectory) -> None:
|
def generate_thumbnails(config: Config, root_dir: ImageDirectory) -> None:
|
||||||
"""
|
"""
|
||||||
Find all of the images and generate various image sizes
|
Find all of the images and generate thumbnails and on-screen versions
|
||||||
"""
|
"""
|
||||||
# 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()
|
|
||||||
|
|
||||||
for image_path in track(all_images, description="Making smaller images..."):
|
all_images = root_dir.image_paths() + root_dir.cover_image_paths()
|
||||||
|
for image_path in track(all_images, description="Making thumbnails..."):
|
||||||
orig_img = Image.open(image_path.path)
|
orig_img = Image.open(image_path.path)
|
||||||
|
|
||||||
slides_path = config.output_dir / image_path.path.parent / "slides"
|
slides_path = image_path.path.parent / "slides"
|
||||||
slides_path.mkdir(exist_ok=True, parents=True)
|
slides_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
# Copy the original image in
|
thumb_path = image_path.thumbnail_path()
|
||||||
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)
|
||||||
|
@ -199,7 +181,7 @@ def generate_images(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 = config.output_dir / image_path.display_path()
|
screen_path = 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)
|
||||||
|
@ -225,8 +207,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
|
||||||
|
@ -258,7 +240,6 @@ 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
|
||||||
|
|
|
@ -9,25 +9,20 @@
|
||||||
{% 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
|
<img src="{{child.path.name}}/slides/{{child.cover_path.thumbnail_filename()}}" />
|
||||||
src="{{child.cover_path.path.parent.relative_to(album_dir.path)}}/slides/{{child.cover_path.thumbnail_filename()}}" />
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="photo-description" class="caption">
|
<div id="photo-description">
|
||||||
{% if image_path.description %}
|
{% if image_path.description %}
|
||||||
{{ image_path.description | safe }}
|
{{ image_path.description | safe }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -3,7 +3,3 @@ 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"
|
|
||||||
|
|
|
@ -22,14 +22,15 @@ 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 {
|
||||||
|
@ -46,9 +47,6 @@ 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 {
|
||||||
|
@ -83,12 +81,6 @@ 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
18
poetry.lock
generated
|
@ -290,20 +290,6 @@ 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"
|
||||||
|
@ -604,5 +590,5 @@ files = [
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12,<3.14"
|
python-versions = "^3.12"
|
||||||
content-hash = "9ec27256e1d8988f06634a217388d357a211f6120d6ade104fdd87182b18279c"
|
content-hash = "3f22f947559bd9d54a838d51953828baa88e2485fb90c33931359511805cacc9"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "photojawn"
|
name = "photojawn"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
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 = "^3.12,<3.14"
|
python = "<4.0,>=3.11"
|
||||||
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,7 +26,6 @@ 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]
|
||||||
|
|
Loading…
Add table
Reference in a new issue