Compare commits
12 commits
Author | SHA1 | Date | |
---|---|---|---|
9ae778bb79 | |||
5ff3338b30 | |||
b1d66d7e9f | |||
aba9fa4025 | |||
37581ee6a0 | |||
aa57c0d092 | |||
4ebaee95cc | |||
9945b9eb7f | |||
94a5e30a8f | |||
24aeba0221 | |||
a8eb1df562 | |||
d297110d88 |
37
.github/workflows/rust.yml
vendored
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
name: Rust
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Rust version
|
||||||
|
run: rustup --version
|
||||||
|
- name: Check
|
||||||
|
run: cargo check
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy
|
||||||
|
- name: Build
|
||||||
|
run: cargo build
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test
|
5
.gitignore
vendored
|
@ -3,7 +3,10 @@ __pycache__
|
||||||
.testmondata
|
.testmondata
|
||||||
dist
|
dist
|
||||||
|
|
||||||
|
# Rust stuff
|
||||||
|
/target
|
||||||
|
|
||||||
# Project specific files
|
# Project specific files
|
||||||
test_album
|
/test_album*
|
||||||
DESIGN.md
|
DESIGN.md
|
||||||
TODO.md
|
TODO.md
|
||||||
|
|
1891
Cargo.lock
generated
Normal file
28
Cargo.toml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
[package]
|
||||||
|
name = "photojawn"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "A static site generator for photo albums"
|
||||||
|
authors = ["Nick Pegg <nick@nickpegg.com>"]
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/nickpegg/photojawn"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
env_logger = "0.11.8"
|
||||||
|
fs_extra = "1.3"
|
||||||
|
image = "0.25.6"
|
||||||
|
indicatif = "0.17.11"
|
||||||
|
kamadak-exif = "0.6.1"
|
||||||
|
log = "0.4.27"
|
||||||
|
pulldown-cmark = "0.13.0"
|
||||||
|
rayon = "1.10"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_yml = "0.0.12"
|
||||||
|
tera = { version = "1.20", default-features = false }
|
||||||
|
thiserror = "2.0"
|
||||||
|
time = { version = "0.3.41", features = ["formatting", "macros", "parsing"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
mktemp = "0.5.1"
|
42
Dockerfile
|
@ -1,42 +0,0 @@
|
||||||
# Build stage
|
|
||||||
FROM python:3.12-slim AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN pip install --upgrade pip &&\
|
|
||||||
pip install --no-cache-dir poetry &&\
|
|
||||||
poetry config virtualenvs.create false &&\
|
|
||||||
apt update && apt install -y build-essential
|
|
||||||
|
|
||||||
COPY pyproject.toml poetry.lock ./
|
|
||||||
RUN poetry install --without=dev
|
|
||||||
|
|
||||||
# Clean up apt stuff
|
|
||||||
RUN apt remove -y build-essential &&\
|
|
||||||
apt autoremove -y &&\
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
|
|
||||||
# Run stage
|
|
||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED 1
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE 1
|
|
||||||
|
|
||||||
# # Default port, overridable by setting an outside env var, which Heroku does
|
|
||||||
# ENV PORT 8000
|
|
||||||
# ENV NUM_WORKERS 8
|
|
||||||
# EXPOSE ${PORT}
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy all the built libs from the build container
|
|
||||||
COPY --from=build / /
|
|
||||||
|
|
||||||
ARG GIT_COMMIT=unknown
|
|
||||||
LABEL git_commit=$GIT_COMMIT
|
|
||||||
ENV GIT_COMMIT $GIT_COMMIT
|
|
||||||
|
|
||||||
CMD TODO
|
|
47
Makefile
|
@ -2,51 +2,46 @@
|
||||||
all: fmt lint test
|
all: fmt lint test
|
||||||
|
|
||||||
# What to have CI systems run
|
# What to have CI systems run
|
||||||
ci: init lint test
|
ci: lint test
|
||||||
|
|
||||||
# Final pre-flight checks then deploy everywhere!
|
# Final pre-flight checks then deploy everywhere!
|
||||||
shipit: all build staging prod
|
# TODO
|
||||||
|
# shipit: all build staging prod
|
||||||
|
|
||||||
version := $(shell yq -p toml .tool.poetry.version < pyproject.toml)
|
# 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
|
||||||
|
|
||||||
# Everything to get the dev env set up
|
# Everything to get the dev env set up
|
||||||
dev: init
|
# dev: init
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
poetry run ruff check --select I --fix # import sorting
|
cargo fmt
|
||||||
poetry run ruff format
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
poetry run ruff check --fix
|
cargo clippy
|
||||||
|
|
||||||
test:
|
test:
|
||||||
poetry run mypy .
|
RUST_BACKTRACE=1 cargo test
|
||||||
poetry run pytest
|
|
||||||
|
|
||||||
# Faster tests, only running what's changed
|
|
||||||
test-fast:
|
|
||||||
poetry run mypy .
|
|
||||||
poetry run pytest --testmon
|
|
||||||
|
|
||||||
test-watch:
|
test-watch:
|
||||||
find . -name '*py' -or -name '*html' -or -name poetry.lock | entr -r -c make test-fast
|
find . -name '*rs' -or -name '*html' -or -name Cargo.lock | entr -r -c make test
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rv dist || true
|
cargo clean
|
||||||
|
|
||||||
docker:
|
# TODO?
|
||||||
podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD)
|
# docker:
|
||||||
|
# podman build -t nickpegg/photojawn . --build-arg GIT_COMMIT=$(shell git rev-parse --short HEAD)
|
||||||
|
|
||||||
dist:
|
dist:
|
||||||
poetry build
|
cargo build --release
|
||||||
poetry run pex --project . -o dist/photojawn -c photojawn --scie eager $(foreach plat,$(scie_platforms), --scie-platform $(plat))
|
|
||||||
|
|
||||||
release: dist
|
# TODO
|
||||||
git push --tags
|
# release: dist
|
||||||
gh release create --verify-tag v$(version)
|
# git push --tags
|
||||||
gh release upload v$(version) dist/photojawn-$(version)-*whl $(foreach plat,$(scie_platforms),dist/photojawn-$(plat))
|
# gh release create --verify-tag v$(version)
|
||||||
|
# gh release upload v$(version) dist/photojawn-$(version)-*whl $(foreach plat,$(scie_platforms),dist/photojawn-$(plat))
|
||||||
|
|
38
README.md
|
@ -1,16 +1,18 @@
|
||||||
# photojawn
|
# photojawn
|
||||||
|
|
||||||
This is a super-simple photo album static site generator. You feed it a
|
This is a super-simple photo album static site generator. You feed it a directory of photos (which
|
||||||
directory of photos (which can contain directories of photos, etc. etc.) and
|
can contain directories of photos, etc. etc.) and it'll generate a basic HTML photo album for you.
|
||||||
it'll generate a basic HTML photo album for you. You can then host the
|
You can then host the directory with a webserver of your choice or upload it to an S3 bucket.
|
||||||
directory with a webserver of your choice or upload it to an S3 bucket.
|
|
||||||
|
|
||||||
It's everything I need and nothing I don't.
|
It's everything I need and nothing I don't.
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
TODO: Installation instructions lol
|
### Installation
|
||||||
|
|
||||||
|
1. Head on over to the [releases](https://github.com/nickpegg/photojawn/releases) page
|
||||||
|
2. Download the binary for your OS/arch
|
||||||
|
|
||||||
### Initialization
|
### Initialization
|
||||||
|
|
||||||
|
@ -19,14 +21,13 @@ Then inside your photo directory, run:
|
||||||
photojawn init
|
photojawn init
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create a config file, some [jinja2](https://jinja.palletsprojects.com/en/latest/templates/)
|
This will create a config file, some [Tera](https://keats.github.io/tera/docs/#templates) (similar
|
||||||
HTML templates, and a CSS file. Edit them to your heart's content to make your
|
to Jinja) HTML templates, and a CSS file. Edit them to your heart's content to make your photo
|
||||||
photo album website purdy.
|
album website purdy.
|
||||||
|
|
||||||
### Generating the site
|
### Generating the site
|
||||||
|
|
||||||
To generate the HTML files and various image sizes, inside your photo
|
To generate the HTML files and various image sizes, inside your photo directory, run:
|
||||||
directory, run:
|
|
||||||
```
|
```
|
||||||
photojawn generate
|
photojawn generate
|
||||||
```
|
```
|
||||||
|
@ -34,17 +35,18 @@ photojawn generate
|
||||||
|
|
||||||
## Special features
|
## Special features
|
||||||
|
|
||||||
- HTML templates are written using [jinja2](https://jinja.palletsprojects.com/en/latest/templates/)
|
- HTML templates are written using [Tera](https://keats.github.io/tera/docs/#templates), which is
|
||||||
|
very similar to [Jinja](https://jinja.palletsprojects.com/en/stable/templates/)
|
||||||
- If you have a `description.txt` or `description.md` file in a directory with
|
- If you have a `description.txt` or `description.md` file in a directory with
|
||||||
images, its contents will be used as the album description. `.md` files will
|
images, its contents will be used as the album description. `.md` files will be rendered as
|
||||||
be rendered as Markdown.
|
Markdown.
|
||||||
- If an image file (e.g. `IMG_1234.jpg`) has a corresponding `.txt` or `.md`
|
- If an image file (e.g. `IMG_1234.jpg`) has a corresponding `.txt` or `.md`
|
||||||
file (e.g. `IMG_1234.md`) then it'll be used as the image's caption. `.md`
|
file (e.g. `IMG_1234.md`) then it'll be used as the image's caption. `.md` files will be
|
||||||
files will be rendered as Markdown.
|
rendered as Markdown.
|
||||||
- If you have an image in a directory called `cover.jpg` (or a symlink
|
- If you have an image in a directory called `cover.jpg` (or a symlink
|
||||||
to another image named that), then it'll be used as the cover image for the
|
to another image named that), then it'll be used as the cover image for the album. If one
|
||||||
album. If one doesn't exist, the first image in the directory will be used as
|
doesn't exist, the first image in the directory will be used as the cover image. If a directory
|
||||||
the cover image.
|
has no images itself, it'll use the first sub-directory's cover as its cover image
|
||||||
|
|
||||||
|
|
||||||
## y tho
|
## y tho
|
||||||
|
|
175
photojawn/cli.py
|
@ -1,175 +0,0 @@
|
||||||
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()
|
|
|
@ -1,41 +0,0 @@
|
||||||
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
|
|
|
@ -1,282 +0,0 @@
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import os
|
|
||||||
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)
|
|
|
@ -1,55 +0,0 @@
|
||||||
{% 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 %}
|
|
608
poetry.lock
generated
|
@ -1,608 +0,0 @@
|
||||||
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorama"
|
|
||||||
version = "0.4.6"
|
|
||||||
description = "Cross-platform colored terminal text."
|
|
||||||
optional = false
|
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
|
||||||
files = [
|
|
||||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
|
||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "coverage"
|
|
||||||
version = "7.6.0"
|
|
||||||
description = "Code coverage measurement for Python"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"},
|
|
||||||
{file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"},
|
|
||||||
{file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"},
|
|
||||||
{file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"},
|
|
||||||
{file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"},
|
|
||||||
{file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"},
|
|
||||||
{file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"},
|
|
||||||
{file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
toml = ["tomli"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iniconfig"
|
|
||||||
version = "2.0.0"
|
|
||||||
description = "brain-dead simple config-ini parsing"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
files = [
|
|
||||||
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
|
||||||
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jinja2"
|
|
||||||
version = "3.1.4"
|
|
||||||
description = "A very fast and expressive template engine."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
files = [
|
|
||||||
{file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
|
|
||||||
{file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
MarkupSafe = ">=2.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
i18n = ["Babel (>=2.7)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markdown"
|
|
||||||
version = "3.6"
|
|
||||||
description = "Python implementation of John Gruber's Markdown."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f"},
|
|
||||||
{file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
|
|
||||||
testing = ["coverage", "pyyaml"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markdown-it-py"
|
|
||||||
version = "3.0.0"
|
|
||||||
description = "Python port of markdown-it. Markdown parsing, done right!"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
|
|
||||||
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
mdurl = ">=0.1,<1.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
|
|
||||||
code-style = ["pre-commit (>=3.0,<4.0)"]
|
|
||||||
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
|
|
||||||
linkify = ["linkify-it-py (>=1,<3)"]
|
|
||||||
plugins = ["mdit-py-plugins"]
|
|
||||||
profiling = ["gprof2dot"]
|
|
||||||
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
|
|
||||||
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markupsafe"
|
|
||||||
version = "2.1.5"
|
|
||||||
description = "Safely add untrusted strings to HTML/XML markup."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
files = [
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
|
|
||||||
{file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
|
|
||||||
{file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mdurl"
|
|
||||||
version = "0.1.2"
|
|
||||||
description = "Markdown URL utilities"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
files = [
|
|
||||||
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
|
|
||||||
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mypy"
|
|
||||||
version = "1.11.1"
|
|
||||||
description = "Optional static typing for Python"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"},
|
|
||||||
{file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"},
|
|
||||||
{file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"},
|
|
||||||
{file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"},
|
|
||||||
{file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"},
|
|
||||||
{file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"},
|
|
||||||
{file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"},
|
|
||||||
{file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"},
|
|
||||||
{file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"},
|
|
||||||
{file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"},
|
|
||||||
{file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"},
|
|
||||||
{file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"},
|
|
||||||
{file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"},
|
|
||||||
{file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"},
|
|
||||||
{file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"},
|
|
||||||
{file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"},
|
|
||||||
{file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"},
|
|
||||||
{file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"},
|
|
||||||
{file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"},
|
|
||||||
{file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"},
|
|
||||||
{file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"},
|
|
||||||
{file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"},
|
|
||||||
{file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"},
|
|
||||||
{file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"},
|
|
||||||
{file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"},
|
|
||||||
{file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"},
|
|
||||||
{file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
mypy-extensions = ">=1.0.0"
|
|
||||||
typing-extensions = ">=4.6.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dmypy = ["psutil (>=4.0)"]
|
|
||||||
install-types = ["pip"]
|
|
||||||
mypyc = ["setuptools (>=50)"]
|
|
||||||
reports = ["lxml"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mypy-extensions"
|
|
||||||
version = "1.0.0"
|
|
||||||
description = "Type system extensions for programs checked with the mypy type checker."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
files = [
|
|
||||||
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
|
|
||||||
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "packaging"
|
|
||||||
version = "24.1"
|
|
||||||
description = "Core utilities for Python packages"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
|
|
||||||
{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"
|
|
||||||
description = "Python Imaging Library (Fork)"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"},
|
|
||||||
{file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"},
|
|
||||||
{file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"},
|
|
||||||
{file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"},
|
|
||||||
{file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"},
|
|
||||||
{file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"},
|
|
||||||
{file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"},
|
|
||||||
{file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"},
|
|
||||||
{file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"},
|
|
||||||
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"},
|
|
||||||
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"},
|
|
||||||
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"},
|
|
||||||
{file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"},
|
|
||||||
{file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"},
|
|
||||||
{file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"},
|
|
||||||
{file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"},
|
|
||||||
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"},
|
|
||||||
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"},
|
|
||||||
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"},
|
|
||||||
{file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"},
|
|
||||||
{file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"},
|
|
||||||
{file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
|
|
||||||
fpx = ["olefile"]
|
|
||||||
mic = ["olefile"]
|
|
||||||
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
|
|
||||||
typing = ["typing-extensions"]
|
|
||||||
xmp = ["defusedxml"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pluggy"
|
|
||||||
version = "1.5.0"
|
|
||||||
description = "plugin and hook calling mechanisms for python"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
|
|
||||||
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dev = ["pre-commit", "tox"]
|
|
||||||
testing = ["pytest", "pytest-benchmark"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pygments"
|
|
||||||
version = "2.18.0"
|
|
||||||
description = "Pygments is a syntax highlighting package written in Python."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
|
|
||||||
{file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
windows-terminal = ["colorama (>=0.4.6)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest"
|
|
||||||
version = "8.3.2"
|
|
||||||
description = "pytest: simple powerful testing with Python"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
|
|
||||||
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
|
||||||
iniconfig = "*"
|
|
||||||
packaging = "*"
|
|
||||||
pluggy = ">=1.5,<2"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest-testmon"
|
|
||||||
version = "2.1.1"
|
|
||||||
description = "selects tests affected by changed files and methods"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "pytest-testmon-2.1.1.tar.gz", hash = "sha256:8ebe2c3de42d99306ee54cd4536fed0fc48346a954420da904b18e8d59b5da98"},
|
|
||||||
{file = "pytest_testmon-2.1.1-py3-none-any.whl", hash = "sha256:8271ca47bc8c80760c4fc7fd7895ea786b111bbb31f13eeea879a6fd11fe2226"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
coverage = ">=6,<8"
|
|
||||||
pytest = ">=5,<9"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyyaml"
|
|
||||||
version = "6.0.1"
|
|
||||||
description = "YAML parser and emitter for Python"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
files = [
|
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
|
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
|
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
|
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
|
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
|
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
|
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
|
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
|
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
|
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
|
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
|
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
|
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
|
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
|
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
|
||||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
|
|
||||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
|
|
||||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
|
|
||||||
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
|
|
||||||
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
|
|
||||||
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
|
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
|
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
|
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
|
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
|
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
|
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
|
|
||||||
{file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
|
|
||||||
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
|
|
||||||
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
|
|
||||||
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
|
|
||||||
{file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
|
|
||||||
{file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
|
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
|
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
|
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
|
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
|
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
|
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
|
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
|
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
|
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
|
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
|
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
|
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
|
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
|
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
|
||||||
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rich"
|
|
||||||
version = "13.7.1"
|
|
||||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7.0"
|
|
||||||
files = [
|
|
||||||
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
|
|
||||||
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
markdown-it-py = ">=2.2.0"
|
|
||||||
pygments = ">=2.13.0,<3.0.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ruff"
|
|
||||||
version = "0.5.6"
|
|
||||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
files = [
|
|
||||||
{file = "ruff-0.5.6-py3-none-linux_armv6l.whl", hash = "sha256:a0ef5930799a05522985b9cec8290b185952f3fcd86c1772c3bdbd732667fdcd"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b652dc14f6ef5d1552821e006f747802cc32d98d5509349e168f6bf0ee9f8f42"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:80521b88d26a45e871f31e4b88938fd87db7011bb961d8afd2664982dfc3641a"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9bc8f328a9f1309ae80e4d392836e7dbc77303b38ed4a7112699e63d3b066ab"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d394940f61f7720ad371ddedf14722ee1d6250fd8d020f5ea5a86e7be217daf"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111a99cdb02f69ddb2571e2756e017a1496c2c3a2aeefe7b988ddab38b416d36"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e395daba77a79f6dc0d07311f94cc0560375ca20c06f354c7c99af3bf4560c5d"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c476acb43c3c51e3c614a2e878ee1589655fa02dab19fe2db0423a06d6a5b1b6"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2ff8003f5252fd68425fd53d27c1f08b201d7ed714bb31a55c9ac1d4c13e2eb"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c94e084ba3eaa80c2172918c2ca2eb2230c3f15925f4ed8b6297260c6ef179ad"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f77c1c3aa0669fb230b06fb24ffa3e879391a3ba3f15e3d633a752da5a3e670"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f908148c93c02873210a52cad75a6eda856b2cbb72250370ce3afef6fb99b1ed"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:563a7ae61ad284187d3071d9041c08019975693ff655438d8d4be26e492760bd"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:94fe60869bfbf0521e04fd62b74cbca21cbc5beb67cbb75ab33fe8c174f54414"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-win32.whl", hash = "sha256:e6a584c1de6f8591c2570e171cc7ce482bb983d49c70ddf014393cd39e9dfaed"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-win_amd64.whl", hash = "sha256:d7fe7dccb1a89dc66785d7aa0ac283b2269712d8ed19c63af908fdccca5ccc1a"},
|
|
||||||
{file = "ruff-0.5.6-py3-none-win_arm64.whl", hash = "sha256:57c6c0dd997b31b536bff49b9eee5ed3194d60605a4427f735eeb1f9c1b8d264"},
|
|
||||||
{file = "ruff-0.5.6.tar.gz", hash = "sha256:07c9e3c2a8e1fe377dd460371c3462671a728c981c3205a5217291422209f642"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-markdown"
|
|
||||||
version = "3.6.0.20240316"
|
|
||||||
description = "Typing stubs for Markdown"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "types-Markdown-3.6.0.20240316.tar.gz", hash = "sha256:de9fb84860b55b647b170ca576895fcca61b934a6ecdc65c31932c6795b440b8"},
|
|
||||||
{file = "types_Markdown-3.6.0.20240316-py3-none-any.whl", hash = "sha256:d3ecd26a940781787c7b57a0e3c9d77c150db64e12989ef687059edc83dfd78a"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-pyyaml"
|
|
||||||
version = "6.0.12.20240724"
|
|
||||||
description = "Typing stubs for PyYAML"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "types-PyYAML-6.0.12.20240724.tar.gz", hash = "sha256:cf7b31ae67e0c5b2919c703d2affc415485099d3fe6666a6912f040fd05cb67f"},
|
|
||||||
{file = "types_PyYAML-6.0.12.20240724-py3-none-any.whl", hash = "sha256:e5becec598f3aa3a2ddf671de4a75fa1c6856fbf73b2840286c9d50fae2d5d48"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.12.2"
|
|
||||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
files = [
|
|
||||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
|
||||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[metadata]
|
|
||||||
lock-version = "2.0"
|
|
||||||
python-versions = "^3.12,<3.14"
|
|
||||||
content-hash = "9ec27256e1d8988f06634a217388d357a211f6120d6ade104fdd87182b18279c"
|
|
|
@ -1,47 +0,0 @@
|
||||||
[tool.poetry]
|
|
||||||
name = "photojawn"
|
|
||||||
version = "0.1.1"
|
|
||||||
description = "A simple photo album static site generator"
|
|
||||||
authors = ["Nick Pegg <nick@nickpegg.com>"]
|
|
||||||
repository = "https://github.com/nickpegg/photojawn"
|
|
||||||
readme = "README.md"
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
|
||||||
photojawn = 'photojawn.cli:main'
|
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
|
||||||
# TODO: make sure we support >=3.10 via tests
|
|
||||||
python = "^3.12,<3.14"
|
|
||||||
jinja2 = "^3.1.4"
|
|
||||||
pillow = "^10.4.0"
|
|
||||||
rich = "^13.7.1"
|
|
||||||
pyyaml = "^6.0.1"
|
|
||||||
markdown = "^3.6"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
|
||||||
pytest = "*"
|
|
||||||
ruff = "*"
|
|
||||||
mypy = "*"
|
|
||||||
pytest-testmon = "*"
|
|
||||||
types-pyyaml = "^6.0.12.20240724"
|
|
||||||
types-markdown = "^3.6.0.20240316"
|
|
||||||
pex = "^2.14.1"
|
|
||||||
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
strict = true
|
|
||||||
|
|
||||||
# Allow untyped decorators. This is mostly for @pytest.fixture
|
|
||||||
disallow_untyped_decorators = false
|
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
|
||||||
# Various packages which don't have stubs available
|
|
||||||
module = [
|
|
||||||
]
|
|
||||||
ignore_missing_imports = true
|
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry-core"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
52
resources/skel/_templates/album.html
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if root_path %}
|
||||||
|
<h1>
|
||||||
|
{% for crumb in breadcrumbs %}
|
||||||
|
<a href="{{crumb.path}}">{{crumb.name}}</a> /
|
||||||
|
{% endfor %}
|
||||||
|
{{name}}
|
||||||
|
</h1>
|
||||||
|
<hr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if description %}
|
||||||
|
<div class="caption">
|
||||||
|
{{ description | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if children %}
|
||||||
|
<h2>Albums</h2>
|
||||||
|
<div id="album-children">
|
||||||
|
{% for child in children %}
|
||||||
|
<div class="album">
|
||||||
|
<a href="{{child.name}}/">
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src="{{child.cover_thumbnail_path}}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{child.name}}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if images %}
|
||||||
|
{% if children %}
|
||||||
|
<h2>Photos</h2>
|
||||||
|
{% endif %}
|
||||||
|
<div id="album-photos">
|
||||||
|
{% for image in images %}
|
||||||
|
<div class="thumbnail">
|
||||||
|
<a href="slides/{{image.html_filename}}">
|
||||||
|
<img src="slides/{{image.thumb_filename}}" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>My Photos</title>
|
<title>My Photos</title>
|
||||||
<link rel="stylesheet" href="{{ root_path }}/static/index.css" type="text/css">
|
<link rel="stylesheet" href="{%if root_path %}{{ root_path }}/{%endif%}static/index.css" type="text/css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="header">
|
<div id="header">
|
|
@ -5,11 +5,11 @@
|
||||||
document.onkeydown = function(event) {
|
document.onkeydown = function(event) {
|
||||||
if (event.key == "ArrowLeft") {
|
if (event.key == "ArrowLeft") {
|
||||||
{% if prev_image %}
|
{% if prev_image %}
|
||||||
location.href = "{{prev_image.html_filename()}}";
|
location.href = "{{prev_image.html_filename}}";
|
||||||
{% endif %}
|
{% endif %}
|
||||||
} else if (event.key == "ArrowRight") {
|
} else if (event.key == "ArrowRight") {
|
||||||
{% if next_image %}
|
{% if next_image %}
|
||||||
location.href = "{{next_image.html_filename()}}";
|
location.href = "{{next_image.html_filename}}";
|
||||||
{% endif %}
|
{% endif %}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,13 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="photo">
|
<div id="photo">
|
||||||
<img src="{{image_path.display_filename()}}" />
|
<img src="{{image.screen_filename}}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="nav">
|
<div id="nav">
|
||||||
<div>
|
<div>
|
||||||
{% if prev_image %}
|
{% if prev_image %}
|
||||||
<a href="{{prev_image.html_filename()}}">
|
<a href="{{prev_image.html_filename}}">
|
||||||
<i class="arrow arrow-left"></i>
|
<i class="arrow arrow-left"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -35,7 +35,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{% if next_image %}
|
{% if next_image %}
|
||||||
<a href="{{next_image.html_filename()}}">
|
<a href="{{next_image.html_filename}}">
|
||||||
<i class="arrow arrow-right"></i>
|
<i class="arrow arrow-right"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -43,12 +43,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="photo-description" class="caption">
|
<div id="photo-description" class="caption">
|
||||||
{% if image_path.description %}
|
{% if image.description %}
|
||||||
{{ image_path.description | safe }}
|
{{ image.description | safe }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="download">
|
<div id="download">
|
||||||
<a href="../{{image_path.path.name}}">view full size</a>
|
<a href="../{{image.filename}}">view full size</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
BIN
resources/test_album/moon.jpg
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
resources/test_album/mountains.jpg
Normal file
After Width: | Height: | Size: 70 KiB |
1
resources/test_album/nested1/description.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
nested album
|
BIN
resources/test_album/nested1/moon.jpg
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
resources/test_album/nested1/mountains.jpg
Normal file
After Width: | Height: | Size: 70 KiB |
1
resources/test_album/nested1/nested2/description.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
deeply nested album
|
BIN
resources/test_album/nested1/nested2/moon.jpg
Normal file
After Width: | Height: | Size: 50 KiB |
1
resources/test_album/with_cover/cover.jpg
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
mountains.jpg
|
BIN
resources/test_album/with_cover/moon.jpg
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
resources/test_album/with_cover/mountains.jpg
Normal file
After Width: | Height: | Size: 70 KiB |
1
resources/test_album/with_description/description.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
album with a description
|
BIN
resources/test_album/with_description/moon.jpg
Normal file
After Width: | Height: | Size: 50 KiB |
1
resources/test_album/with_description/moon.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
this is a moon
|
BIN
resources/test_album/with_description/mountains.jpg
Normal file
After Width: | Height: | Size: 70 KiB |
77
src/config.rs
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Tuple of how big thumbnails should be - (width, height)
|
||||||
|
pub thumbnail_size: (u32, u32),
|
||||||
|
/// Tuple of how big thumbnails should be - (width, height)
|
||||||
|
pub view_size: (u32, u32),
|
||||||
|
/// Directory inside the album that the site should be output to
|
||||||
|
pub output_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_album(path: PathBuf) -> anyhow::Result<Config> {
|
||||||
|
let config_path = path.join("photojawn.conf.yml");
|
||||||
|
let content = fs::read(&config_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to read config from {}. Is this an album directory?",
|
||||||
|
config_path.display(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let cfg = serde_yml::from_slice(&content)
|
||||||
|
.with_context(|| format!("Failed to parse config from {}", config_path.display()))?;
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
thumbnail_size: (256, 256),
|
||||||
|
view_size: (1024, 768),
|
||||||
|
output_dir: PathBuf::from("site"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use crate::skel::make_skeleton;
|
||||||
|
use mktemp::Temp;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default() {
|
||||||
|
let c = Config::default();
|
||||||
|
assert_eq!(c.thumbnail_size, (256, 256));
|
||||||
|
assert_eq!(c.output_dir, PathBuf::from("site"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_yaml() {
|
||||||
|
// Empty YAML gives full default values
|
||||||
|
let default_cfg = Config::default();
|
||||||
|
let cfg: Config = serde_yml::from_str("").unwrap();
|
||||||
|
assert_eq!(cfg, default_cfg);
|
||||||
|
|
||||||
|
// Default values for any unspecified fields
|
||||||
|
let cfg: Config = serde_yml::from_str("thumbnail_size: [1, 1]").unwrap();
|
||||||
|
assert_ne!(cfg, default_cfg);
|
||||||
|
assert_eq!(cfg.thumbnail_size, (1, 1));
|
||||||
|
assert_eq!(cfg.view_size, default_cfg.view_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_base_album() {
|
||||||
|
let tmpdir = Temp::new_dir().unwrap();
|
||||||
|
make_skeleton(&tmpdir).unwrap();
|
||||||
|
|
||||||
|
let cfg = Config::from_album(tmpdir.to_path_buf()).unwrap();
|
||||||
|
assert_eq!(cfg, Config::default());
|
||||||
|
}
|
||||||
|
}
|
412
src/generate.rs
Normal file
|
@ -0,0 +1,412 @@
|
||||||
|
pub mod album_dir;
|
||||||
|
mod image;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::generate::image::Image;
|
||||||
|
use album_dir::AlbumDir;
|
||||||
|
use anyhow::{Context, anyhow};
|
||||||
|
use indicatif::ProgressBar;
|
||||||
|
use rayon::prelude::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::env;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tera::Tera;
|
||||||
|
|
||||||
|
const IMG_RESIZE_FILTER: ::image::imageops::FilterType = ::image::imageops::FilterType::Lanczos3;
|
||||||
|
/// Generate an album
|
||||||
|
///
|
||||||
|
/// `root_path` is a path to the root directory of the album. `full` if true will regenerate
|
||||||
|
/// everything, including images that already exist.
|
||||||
|
pub fn generate(root_path: &PathBuf, full: bool) -> anyhow::Result<PathBuf> {
|
||||||
|
log::debug!("Generating album in {}", root_path.display());
|
||||||
|
let config = Config::from_album(root_path.to_path_buf())?;
|
||||||
|
let orig_path = env::current_dir()?;
|
||||||
|
|
||||||
|
// Jump into the root path so that all paths are relative to the root of the album
|
||||||
|
env::set_current_dir(root_path)?;
|
||||||
|
let album = AlbumDir::try_from(root_path)?;
|
||||||
|
|
||||||
|
fs::create_dir_all(&config.output_dir)?;
|
||||||
|
copy_static(&config)?;
|
||||||
|
generate_images(&config, &album, full)?;
|
||||||
|
generate_html(&config, &album)?;
|
||||||
|
|
||||||
|
env::set_current_dir(orig_path)?;
|
||||||
|
Ok(root_path.join(config.output_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_static(config: &Config) -> anyhow::Result<()> {
|
||||||
|
let dst = &config.output_dir.join("static");
|
||||||
|
log::info!("Copying static files from _static to {}", dst.display());
|
||||||
|
fs_extra::dir::copy(
|
||||||
|
"_static",
|
||||||
|
dst,
|
||||||
|
&fs_extra::dir::CopyOptions::new()
|
||||||
|
.content_only(true)
|
||||||
|
.overwrite(true),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_images(config: &Config, album: &AlbumDir, full: bool) -> anyhow::Result<()> {
|
||||||
|
let output_path = album.path.join(&config.output_dir);
|
||||||
|
// HashSet is to avoid dupliates, which could happen since we add covers in later
|
||||||
|
let mut all_images: HashSet<&Image> = album.iter_all_images().collect();
|
||||||
|
|
||||||
|
// also resize cover images, since we didn't count those as part of the image collections
|
||||||
|
let mut album_queue: VecDeque<&AlbumDir> = VecDeque::from([album]);
|
||||||
|
while let Some(album) = album_queue.pop_front() {
|
||||||
|
all_images.insert(&album.cover);
|
||||||
|
album_queue.extend(&album.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_locks: HashMap<&PathBuf, Mutex<&PathBuf>> = all_images
|
||||||
|
.iter()
|
||||||
|
.map(|i| (&i.path, Mutex::new(&i.path)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
println!("Generating images...");
|
||||||
|
let progress = ProgressBar::new(all_images.len() as u64);
|
||||||
|
let result = all_images.par_iter().try_for_each(|img| {
|
||||||
|
// Get the lock on the path to make sure two threads don't try to generate the same image
|
||||||
|
// on disk.
|
||||||
|
let _path_lock = path_locks.get(&img.path).unwrap().lock().unwrap();
|
||||||
|
|
||||||
|
let full_size_path = output_path.join(&img.path);
|
||||||
|
if !full
|
||||||
|
&& full_size_path.exists()
|
||||||
|
&& fs::read(&full_size_path)? == fs::read(&full_size_path)?
|
||||||
|
{
|
||||||
|
log::info!("Skipping {}, already generated", img.path.display());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Copying original {} -> {}",
|
||||||
|
img.path.display(),
|
||||||
|
full_size_path.display()
|
||||||
|
);
|
||||||
|
fs::create_dir_all(full_size_path.parent().unwrap_or(Path::new("")))?;
|
||||||
|
if full_size_path.exists() {
|
||||||
|
fs::remove_file(&full_size_path)?;
|
||||||
|
}
|
||||||
|
fs::hard_link(&img.path, &full_size_path)
|
||||||
|
.with_context(|| format!("Error creating hard link at {}", full_size_path.display()))?;
|
||||||
|
|
||||||
|
let orig_image = ::image::open(&img.path)
|
||||||
|
.with_context(|| format!("Failed to read image {}", &img.path.display()))?;
|
||||||
|
let thumb_path = output_path.join(&img.thumb_path);
|
||||||
|
log::info!(
|
||||||
|
"Resizing {} -> {}",
|
||||||
|
img.path.display(),
|
||||||
|
thumb_path.display()
|
||||||
|
);
|
||||||
|
fs::create_dir_all(thumb_path.parent().unwrap_or(Path::new("")))?;
|
||||||
|
orig_image
|
||||||
|
.resize(
|
||||||
|
config.thumbnail_size.0,
|
||||||
|
config.thumbnail_size.1,
|
||||||
|
IMG_RESIZE_FILTER,
|
||||||
|
)
|
||||||
|
.save(&thumb_path)
|
||||||
|
.with_context(|| format!("Error saving {}", thumb_path.display()))?;
|
||||||
|
|
||||||
|
let screen_path = output_path.join(&img.screen_path);
|
||||||
|
log::info!(
|
||||||
|
"Resizing {} -> {}",
|
||||||
|
img.path.display(),
|
||||||
|
screen_path.display()
|
||||||
|
);
|
||||||
|
fs::create_dir_all(thumb_path.parent().unwrap_or(Path::new("")))?;
|
||||||
|
orig_image
|
||||||
|
.resize(config.view_size.0, config.view_size.1, IMG_RESIZE_FILTER)
|
||||||
|
.save(&screen_path)
|
||||||
|
.with_context(|| format!("Error saving {}", screen_path.display()))?;
|
||||||
|
|
||||||
|
progress.inc(1);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
progress.finish();
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_html(config: &Config, album: &AlbumDir) -> anyhow::Result<()> {
|
||||||
|
let output_path = album.path.join(&config.output_dir);
|
||||||
|
let tera = Tera::new(
|
||||||
|
album
|
||||||
|
.path
|
||||||
|
.join("_templates/*.html")
|
||||||
|
.to_str()
|
||||||
|
.ok_or(anyhow!("Album path {} is invalid", album.path.display()))?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!("Generating HTML...");
|
||||||
|
|
||||||
|
let mut dir_queue: VecDeque<&AlbumDir> = VecDeque::from([album]);
|
||||||
|
while let Some(album) = dir_queue.pop_front() {
|
||||||
|
let html_path = output_path.join(&album.path).join("index.html");
|
||||||
|
log::info!("Rendering album page {}", html_path.display());
|
||||||
|
let ctx = AlbumContext::try_from(album)?;
|
||||||
|
log::debug!("Album context: {ctx:?}");
|
||||||
|
fs::write(
|
||||||
|
output_path.join(&album.path).join("index.html"),
|
||||||
|
tera.render("album.html", &tera::Context::from_serialize(&ctx)?)?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for child in album.children.iter() {
|
||||||
|
dir_queue.push_back(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pos, img) in album.images.iter().enumerate() {
|
||||||
|
let prev_image: Option<&Image> = match pos {
|
||||||
|
0 => None,
|
||||||
|
n => Some(&album.images[n - 1]),
|
||||||
|
};
|
||||||
|
let next_image: Option<&Image> = album.images.get(pos + 1);
|
||||||
|
|
||||||
|
// Find the path to the root by counting the parts of the path
|
||||||
|
// Start with 1 .. to get out of the slides dir
|
||||||
|
let mut path_to_root = PathBuf::from("..");
|
||||||
|
if let Some(parent) = img.path.parent() {
|
||||||
|
let mut parent = parent.to_path_buf();
|
||||||
|
while parent.pop() {
|
||||||
|
path_to_root.push("..");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Rendering image page {}", img.html_path.display());
|
||||||
|
let ctx = SlideContext {
|
||||||
|
root_path: path_to_root,
|
||||||
|
image: img.clone(),
|
||||||
|
prev_image: prev_image.cloned(),
|
||||||
|
next_image: next_image.cloned(),
|
||||||
|
};
|
||||||
|
log::debug!("Image context: {ctx:?}");
|
||||||
|
fs::write(
|
||||||
|
output_path.join(&img.html_path),
|
||||||
|
tera.render("photo.html", &tera::Context::from_serialize(&ctx)?)?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
struct Breadcrumb {
|
||||||
|
path: PathBuf,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Tera context for album pages
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
struct AlbumContext {
|
||||||
|
/// Path required to get back to the root album
|
||||||
|
root_path: PathBuf,
|
||||||
|
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
|
||||||
|
images: Vec<Image>,
|
||||||
|
|
||||||
|
/// Path to the cover image thumbnail within /slides/, relative to the album dir. Used when
|
||||||
|
/// linking to an album from a parent album
|
||||||
|
cover_thumbnail_path: PathBuf,
|
||||||
|
|
||||||
|
/// list of:
|
||||||
|
/// - relative dir to walk up to root, e.g. "../../.."
|
||||||
|
/// - dir name
|
||||||
|
breadcrumbs: Vec<Breadcrumb>,
|
||||||
|
|
||||||
|
/// Immediate children of this album
|
||||||
|
children: Vec<AlbumContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&AlbumDir> for AlbumContext {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(album: &AlbumDir) -> anyhow::Result<Self> {
|
||||||
|
let name: String = match album.path.file_name() {
|
||||||
|
Some(n) => n.to_string_lossy().to_string(),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build breadcrumbs
|
||||||
|
let mut breadcrumbs: Vec<Breadcrumb> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut album_path = album.path.clone();
|
||||||
|
let mut relpath: PathBuf = PathBuf::new();
|
||||||
|
while album_path.pop() {
|
||||||
|
let filename: &OsStr = album_path.file_name().unwrap_or(OsStr::new("Home"));
|
||||||
|
relpath.push("..");
|
||||||
|
breadcrumbs.push(Breadcrumb {
|
||||||
|
path: relpath.clone(),
|
||||||
|
name: filename.to_string_lossy().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
breadcrumbs.reverse();
|
||||||
|
log::debug!("Crumbs for {}: {breadcrumbs:?}", album.path.display());
|
||||||
|
|
||||||
|
// The first breadcrumb path is the relative path to the root album
|
||||||
|
let root_path = match breadcrumbs.is_empty() {
|
||||||
|
false => breadcrumbs[0].path.clone(),
|
||||||
|
true => PathBuf::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make the path to the thumbnail relative to the album that we're in
|
||||||
|
let cover_thumbnail_path: PathBuf;
|
||||||
|
if let Some(parent) = album.path.parent() {
|
||||||
|
cover_thumbnail_path = album.cover.thumb_path.strip_prefix(parent)?.to_path_buf()
|
||||||
|
} else {
|
||||||
|
cover_thumbnail_path = album
|
||||||
|
.cover
|
||||||
|
.thumb_path
|
||||||
|
.strip_prefix(&album.path)?
|
||||||
|
.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
let children: Vec<AlbumContext> = album
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(AlbumContext::try_from)
|
||||||
|
.collect::<anyhow::Result<Vec<AlbumContext>>>()?;
|
||||||
|
let images: Vec<Image> = album.images.clone();
|
||||||
|
|
||||||
|
Ok(AlbumContext {
|
||||||
|
root_path,
|
||||||
|
name,
|
||||||
|
description: album.description.clone(),
|
||||||
|
breadcrumbs,
|
||||||
|
images,
|
||||||
|
children,
|
||||||
|
cover_thumbnail_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Tera context for slide (individual image) pages
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
struct SlideContext {
|
||||||
|
// Path required to get back to the root album
|
||||||
|
root_path: PathBuf,
|
||||||
|
image: Image,
|
||||||
|
prev_image: Option<Image>,
|
||||||
|
next_image: Option<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::generate;
|
||||||
|
use std::collections::{HashSet, VecDeque};
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::test_util::{init, make_test_album};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Test that the generate function creates a rendered site as we expect it
|
||||||
|
fn test_generate() {
|
||||||
|
init();
|
||||||
|
let album_path = make_test_album();
|
||||||
|
let output_path = generate(&album_path.to_path_buf(), false).unwrap();
|
||||||
|
|
||||||
|
check_album(output_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does basic sanity checks on an output album
|
||||||
|
fn check_album(root_path: PathBuf) -> anyhow::Result<()> {
|
||||||
|
log::debug!("Checking album dir {}", root_path.display());
|
||||||
|
|
||||||
|
// The _static dir should have gotten copied into <output>/static
|
||||||
|
assert!(root_path.join("static/index.css").exists());
|
||||||
|
|
||||||
|
let mut dirs: VecDeque<PathBuf> = VecDeque::from([root_path.clone()]);
|
||||||
|
while let Some(dir) = dirs.pop_front() {
|
||||||
|
let mut files: Vec<PathBuf> = Vec::new();
|
||||||
|
for entry in dir.read_dir().unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.is_dir()
|
||||||
|
&& !path.ends_with(Path::new("slides"))
|
||||||
|
&& path.file_name() != Some(OsStr::new("static"))
|
||||||
|
{
|
||||||
|
dirs.push_back(path.clone());
|
||||||
|
} else if path.is_file() {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// There should be an index.html
|
||||||
|
let index_path = dir.join("index.html");
|
||||||
|
assert!(
|
||||||
|
index_path.exists(),
|
||||||
|
"Expected {} to exist",
|
||||||
|
index_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
// There should be a slides dir
|
||||||
|
let slides_path = dir.join("slides");
|
||||||
|
assert!(
|
||||||
|
slides_path.is_dir(),
|
||||||
|
"Expected {} to be a dir",
|
||||||
|
slides_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
// No two images should have the same path
|
||||||
|
let image_set: HashSet<&PathBuf> = files.iter().collect();
|
||||||
|
assert_eq!(image_set.len(), files.len());
|
||||||
|
|
||||||
|
// For each image in the album (including the cover), in slides there should be a:
|
||||||
|
// - <image>.html
|
||||||
|
// - <image>.screen.<ext>
|
||||||
|
// - <image>.thumb.<ext>
|
||||||
|
for file in &files {
|
||||||
|
if let Some(ext) = file.extension() {
|
||||||
|
if ext != "jpg" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::debug!("Checking associated files for {}", file.display());
|
||||||
|
|
||||||
|
if !file
|
||||||
|
.file_name()
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("cover")
|
||||||
|
{
|
||||||
|
let html_path =
|
||||||
|
slides_path.join(&file.with_extension("html").file_name().unwrap());
|
||||||
|
assert!(
|
||||||
|
html_path.exists(),
|
||||||
|
"Expected {} to exist",
|
||||||
|
html_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for ext in ["screen.jpg", "thumb.jpg"] {
|
||||||
|
let img_path = slides_path.join(file.with_extension(ext).file_name().unwrap());
|
||||||
|
assert!(
|
||||||
|
img_path.exists(),
|
||||||
|
"Expected {} to exist",
|
||||||
|
img_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// There shouldn't be any .txt or .md files hanging around
|
||||||
|
for file in &files {
|
||||||
|
if let Some(ext) = file.extension() {
|
||||||
|
assert_ne!(ext, "md");
|
||||||
|
assert_ne!(ext, "txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
230
src/generate/album_dir.rs
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
use crate::generate::image::Image;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use image::ImageReader;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::slice::Iter;
|
||||||
|
|
||||||
|
/// An album directory, which has images and possibly child albums
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct AlbumDir {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub images: Vec<Image>,
|
||||||
|
pub cover: Image,
|
||||||
|
pub children: Vec<AlbumDir>,
|
||||||
|
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumDir {
|
||||||
|
/// Returns an iterator over all images in the album and subalbums
|
||||||
|
pub fn iter_all_images(&self) -> AlbumImageIter {
|
||||||
|
AlbumImageIter::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an AlbumDir recursively from a path. The root path is so that we can make every path
|
||||||
|
/// relative to the root.
|
||||||
|
fn from_path(p: &Path, root: &Path) -> anyhow::Result<Self> {
|
||||||
|
let mut images = vec![];
|
||||||
|
let mut cover: Option<Image> = None;
|
||||||
|
let mut children = vec![];
|
||||||
|
let mut description = String::new();
|
||||||
|
|
||||||
|
for entry in p.read_dir()? {
|
||||||
|
let entry_path = entry?.path().to_path_buf();
|
||||||
|
|
||||||
|
if entry_path.is_file() {
|
||||||
|
if let Some(filename) = entry_path.file_name() {
|
||||||
|
if filename == "description.txt" {
|
||||||
|
description = fs::read_to_string(entry_path)?;
|
||||||
|
} else if filename == "description.md" {
|
||||||
|
log::debug!("Loading Markdown from {}", entry_path.display());
|
||||||
|
let contents = fs::read_to_string(&entry_path)?;
|
||||||
|
let parser = pulldown_cmark::Parser::new(&contents);
|
||||||
|
pulldown_cmark::html::push_html(&mut description, parser);
|
||||||
|
} else {
|
||||||
|
if filename.to_string_lossy().starts_with("cover") {
|
||||||
|
log::debug!("Found explicit cover for {}", p.display());
|
||||||
|
cover = Some(Image::new(
|
||||||
|
entry_path.strip_prefix(root)?.to_path_buf(),
|
||||||
|
String::new(),
|
||||||
|
)?);
|
||||||
|
// Don't include the cover in the set of images
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader = ImageReader::open(&entry_path)?.with_guessed_format()?;
|
||||||
|
if reader.format().is_some() {
|
||||||
|
// Found an image
|
||||||
|
let mut description = String::new();
|
||||||
|
|
||||||
|
// Read in any associated description file
|
||||||
|
if entry_path.with_extension("txt").exists() {
|
||||||
|
description = fs::read_to_string(entry_path.with_extension("txt"))?;
|
||||||
|
} else if entry_path.with_extension("md").exists() {
|
||||||
|
log::debug!(
|
||||||
|
"Loading Markdown from {}",
|
||||||
|
entry_path.with_extension("md").display()
|
||||||
|
);
|
||||||
|
let contents = fs::read_to_string(entry_path.with_extension("md"))?;
|
||||||
|
let parser = pulldown_cmark::Parser::new(&contents);
|
||||||
|
pulldown_cmark::html::push_html(&mut description, parser);
|
||||||
|
}
|
||||||
|
|
||||||
|
images.push(Image::new(
|
||||||
|
entry_path.strip_prefix(root)?.to_path_buf(),
|
||||||
|
description,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if entry_path.is_dir() {
|
||||||
|
if let Some(dirname) = entry_path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
if dirname.starts_with("_") {
|
||||||
|
// Likely a templates or static dir
|
||||||
|
continue;
|
||||||
|
} else if dirname == "site" {
|
||||||
|
// Is a generated site dir, don't descend into it
|
||||||
|
continue;
|
||||||
|
} else if dirname == "slides" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(AlbumDir::from_path(&entry_path, root)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
children.sort_by_key(|c| c.path.clone());
|
||||||
|
images.sort_by_key(|i| i.path.clone());
|
||||||
|
|
||||||
|
// Find a cover image if we didn't have an explicit one. Either the first image, or the
|
||||||
|
// first image from the first album that has a cover.
|
||||||
|
if cover.is_none() {
|
||||||
|
if !images.is_empty() {
|
||||||
|
cover = Some(images[0].clone());
|
||||||
|
} else {
|
||||||
|
// Find a cover image from one of the children
|
||||||
|
if !children.is_empty() {
|
||||||
|
cover = Some(children[0].cover.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cover = cover.ok_or(anyhow!("Could not find a cover image for {}", p.display()))?;
|
||||||
|
log::debug!("Cover for {} is {}", p.display(), cover.path.display());
|
||||||
|
|
||||||
|
Ok(AlbumDir {
|
||||||
|
path: p.strip_prefix(root)?.to_path_buf(),
|
||||||
|
images,
|
||||||
|
cover,
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&PathBuf> for AlbumDir {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(p: &PathBuf) -> anyhow::Result<AlbumDir> {
|
||||||
|
AlbumDir::from_path(p, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An iterator which walks through all of the images in an album, and its sub-albums
|
||||||
|
pub struct AlbumImageIter<'a> {
|
||||||
|
image_iter: Box<dyn Iterator<Item = &'a Image> + 'a>,
|
||||||
|
children_iter: Iter<'a, AlbumDir>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> AlbumImageIter<'a> {
|
||||||
|
fn new(ad: &'a AlbumDir) -> Self {
|
||||||
|
Self {
|
||||||
|
image_iter: Box::new(ad.images.iter()),
|
||||||
|
children_iter: ad.children.iter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Iterator for AlbumImageIter<'a> {
|
||||||
|
type Item = &'a Image;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
if let Some(img) = self.image_iter.next() {
|
||||||
|
return Some(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
for album in self.children_iter.by_ref() {
|
||||||
|
// Set the child album as the current image iterator
|
||||||
|
self.image_iter = Box::new(album.iter_all_images());
|
||||||
|
// If we found a child album with an image, return the image. Otherwise we'll keep
|
||||||
|
// iterating over children.
|
||||||
|
if let Some(i) = self.image_iter.next() {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn basic_album_iter() {
|
||||||
|
let mut ad = AlbumDir {
|
||||||
|
path: "".into(),
|
||||||
|
description: "".to_string(),
|
||||||
|
cover: Image::new("foo".into(), "".to_string()).unwrap(),
|
||||||
|
images: vec![
|
||||||
|
Image::new("foo".into(), "".to_string()).unwrap(),
|
||||||
|
Image::new("bar".into(), "".to_string()).unwrap(),
|
||||||
|
],
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
// A child album with some images
|
||||||
|
ad.children.push(AlbumDir {
|
||||||
|
path: "subdir".into(),
|
||||||
|
description: "".to_string(),
|
||||||
|
cover: Image::new("subdir/foo".into(), "".to_string()).unwrap(),
|
||||||
|
images: vec![
|
||||||
|
Image::new("subdir/foo".into(), "".to_string()).unwrap(),
|
||||||
|
Image::new("subdir/bar".into(), "".to_string()).unwrap(),
|
||||||
|
],
|
||||||
|
children: vec![AlbumDir {
|
||||||
|
path: "subdir/deeper_subdir".into(),
|
||||||
|
description: "".to_string(),
|
||||||
|
cover: Image::new("subdir/deeper_subdir/image.jpg".into(), "".to_string()).unwrap(),
|
||||||
|
images: vec![
|
||||||
|
Image::new("subdir/deeper_subdir/image.jpg".into(), String::new()).unwrap(),
|
||||||
|
],
|
||||||
|
children: vec![],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
// A child album with no images
|
||||||
|
ad.children.push(AlbumDir {
|
||||||
|
description: "".to_string(),
|
||||||
|
cover: Image::new("blah".into(), "".to_string()).unwrap(),
|
||||||
|
path: "another_subdir".into(),
|
||||||
|
images: vec![],
|
||||||
|
children: vec![],
|
||||||
|
});
|
||||||
|
|
||||||
|
let imgs: HashSet<&str> = ad
|
||||||
|
.iter_all_images()
|
||||||
|
.map(|i| i.path.to_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
let expected: HashSet<&str> = HashSet::from([
|
||||||
|
"foo",
|
||||||
|
"bar",
|
||||||
|
"subdir/foo",
|
||||||
|
"subdir/bar",
|
||||||
|
"subdir/deeper_subdir/image.jpg",
|
||||||
|
]);
|
||||||
|
assert_eq!(imgs, expected);
|
||||||
|
}
|
||||||
|
}
|
108
src/generate/image.rs
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct Image {
|
||||||
|
/// Path to the image, relative to the root album
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub filename: String,
|
||||||
|
|
||||||
|
/// Text description of the image which is displayed below it on the HTML page
|
||||||
|
pub description: String,
|
||||||
|
|
||||||
|
pub thumb_filename: String,
|
||||||
|
pub thumb_path: PathBuf,
|
||||||
|
pub screen_filename: String,
|
||||||
|
pub screen_path: PathBuf,
|
||||||
|
pub html_filename: String,
|
||||||
|
pub html_path: PathBuf,
|
||||||
|
}
|
||||||
|
impl Image {
|
||||||
|
pub fn new(path: PathBuf, description: String) -> anyhow::Result<Self> {
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or(anyhow!(
|
||||||
|
"Image path {} is missing a filename",
|
||||||
|
path.display()
|
||||||
|
))?
|
||||||
|
.to_str()
|
||||||
|
.ok_or(anyhow!("Cannot convert {} to a string", path.display()))?
|
||||||
|
.to_string();
|
||||||
|
let thumb_filename = Self::slide_filename(&path, "thumb", true)?;
|
||||||
|
let thumb_path = Self::slide_path(&path, &thumb_filename);
|
||||||
|
let screen_filename = Self::slide_filename(&path, "screen", true)?;
|
||||||
|
let screen_path = Self::slide_path(&path, &screen_filename);
|
||||||
|
let html_filename = Self::slide_filename(&path, "html", false)?;
|
||||||
|
let html_path = Self::slide_path(&path, &html_filename);
|
||||||
|
|
||||||
|
Ok(Image {
|
||||||
|
path,
|
||||||
|
description,
|
||||||
|
filename,
|
||||||
|
thumb_filename,
|
||||||
|
thumb_path,
|
||||||
|
screen_filename,
|
||||||
|
screen_path,
|
||||||
|
html_filename,
|
||||||
|
html_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the filename for a given slide type. For example if ext = "thumb" and the current
|
||||||
|
/// filename is "blah.jpg" this will return "blah.thumb.jpg". If keep_ext if false, it would
|
||||||
|
/// return "blah.thumb"
|
||||||
|
fn slide_filename(path: &Path, ext: &str, keep_ext: bool) -> anyhow::Result<String> {
|
||||||
|
let mut new_ext: OsString = ext.into();
|
||||||
|
if keep_ext {
|
||||||
|
if let Some(e) = path.extension() {
|
||||||
|
new_ext = OsString::from(
|
||||||
|
ext.to_string()
|
||||||
|
+ "."
|
||||||
|
+ e.to_str().ok_or(anyhow!(
|
||||||
|
"Image {} extension is not valid UTF-8",
|
||||||
|
path.display()
|
||||||
|
))?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_path = path.with_extension(new_ext);
|
||||||
|
let new_name = new_path
|
||||||
|
.file_name()
|
||||||
|
.ok_or(anyhow!("Image {} missing a file name", path.display()))?
|
||||||
|
.to_str()
|
||||||
|
.ok_or(anyhow!("Unable to convert {} to a string", path.display()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(new_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the file in the slides dir given the path to the original image
|
||||||
|
fn slide_path(path: &Path, file_name: &str) -> PathBuf {
|
||||||
|
let mut new_path = path.to_path_buf();
|
||||||
|
new_path.pop();
|
||||||
|
new_path.push("slides");
|
||||||
|
new_path.push(file_name);
|
||||||
|
new_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_paths() {
|
||||||
|
let img = Image::new(PathBuf::from("foo/bar/image.jpg"), String::new()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
img.thumb_path,
|
||||||
|
PathBuf::from("foo/bar/slides/image.thumb.jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
img.screen_path,
|
||||||
|
PathBuf::from("foo/bar/slides/image.screen.jpg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
7
src/lib.rs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
pub(crate) mod config;
|
||||||
|
pub mod generate;
|
||||||
|
pub mod reorganize;
|
||||||
|
pub mod skel;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_util;
|
64
src/main.rs
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use photojawn::generate::generate;
|
||||||
|
use photojawn::reorganize::reorganize;
|
||||||
|
use photojawn::skel::make_skeleton;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let album_path = Path::new(&cli.album_path).canonicalize()?;
|
||||||
|
|
||||||
|
match cli.subcommand {
|
||||||
|
Commands::Init {} => {
|
||||||
|
make_skeleton(&album_path)?;
|
||||||
|
println!("Album created in {}", album_path.display());
|
||||||
|
}
|
||||||
|
Commands::Generate { full } => {
|
||||||
|
let path = generate(&album_path.to_path_buf(), full)?;
|
||||||
|
println!("Album site generated in {}", path.display());
|
||||||
|
}
|
||||||
|
Commands::Reorganize { path, dry_run } => {
|
||||||
|
reorganize(Path::new(&path), dry_run)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to the album
|
||||||
|
#[arg(long, default_value = ".")]
|
||||||
|
album_path: String,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
subcommand: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Initialize a new Photojawn album directory
|
||||||
|
Init {},
|
||||||
|
/// Generates a photo album
|
||||||
|
Generate {
|
||||||
|
/// Regenerate everything, including images that have already been generated
|
||||||
|
#[arg(long)]
|
||||||
|
full: bool,
|
||||||
|
},
|
||||||
|
/// Reorganize photos in an album by date
|
||||||
|
Reorganize {
|
||||||
|
/// Directory of images you want to reorganize. Only image files will be moved.
|
||||||
|
///
|
||||||
|
/// The new image filenames will be the date and time taken, followed by the original
|
||||||
|
/// filename. For example:
|
||||||
|
/// original_filename.jpg -> YYYYMMDD_HHSS_original_filename.jpg
|
||||||
|
#[arg()]
|
||||||
|
path: String,
|
||||||
|
/// Don't actually reorganize, just say what renames would happen
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
|
},
|
||||||
|
}
|
243
src/reorganize.rs
Normal file
|
@ -0,0 +1,243 @@
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use image::ImageReader;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fs::{rename, File};
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::str::from_utf8;
|
||||||
|
use thiserror::Error;
|
||||||
|
use time::macros::format_description;
|
||||||
|
use time::{OffsetDateTime, PrimitiveDateTime, UtcDateTime};
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum OrganizeError {
|
||||||
|
#[error("These files are not supported, unable to parse EXIF data: {0:?}")]
|
||||||
|
ExifNotSupported(Vec<PathBuf>),
|
||||||
|
#[error("File {0} is missing an EXIF DateTimeOriginal field")]
|
||||||
|
ExifNoDateTime(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> {
|
||||||
|
let renames = get_renames(dir)?;
|
||||||
|
|
||||||
|
if renames.is_empty() {
|
||||||
|
println!("Nothing to rename");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either do the renames, or if dry-run print what the names would be
|
||||||
|
if dry_run {
|
||||||
|
for (src, dst) in renames {
|
||||||
|
println!("{} -> {}", src.display(), dst.display());
|
||||||
|
}
|
||||||
|
println!("Would have renamed the above files");
|
||||||
|
} else {
|
||||||
|
for (src, dst) in renames {
|
||||||
|
println!("{} -> {}", src.display(), dst.display());
|
||||||
|
rename(&src, &dst).with_context(|| {
|
||||||
|
format!("Failed to rename {} to {}", src.display(), dst.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a vec of tuples of all the renames that need to happen in a directory
|
||||||
|
fn get_renames(dir: &Path) -> anyhow::Result<Vec<(PathBuf, PathBuf)>> {
|
||||||
|
let mut renames: Vec<(PathBuf, PathBuf)> = Vec::new();
|
||||||
|
|
||||||
|
// Run through all the images and figure out new names for them
|
||||||
|
for entry in dir.read_dir()? {
|
||||||
|
let entry = entry?;
|
||||||
|
|
||||||
|
if !entry.path().is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only bother with image files, because those are the only hope for EXIF
|
||||||
|
let is_image: bool = ImageReader::open(entry.path())?
|
||||||
|
.with_guessed_format()?
|
||||||
|
.format()
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
let is_cover: bool = entry
|
||||||
|
.path()
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|n| n.to_string_lossy().starts_with("cover"));
|
||||||
|
|
||||||
|
if is_image && !is_cover {
|
||||||
|
// TODO: Should we just skip over images with no EXIF data? Find datetime some other
|
||||||
|
// way?
|
||||||
|
let Ok(dt) = get_exif_datetime(entry.path()) else {
|
||||||
|
log::warn!(
|
||||||
|
"Unable to read datetime from EXIF for {}",
|
||||||
|
entry.path().display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let orig_filename = entry
|
||||||
|
.path()
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or(OsStr::new(""))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
|
||||||
|
let ext = entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.ok_or(anyhow!(
|
||||||
|
"{} is missing an extension",
|
||||||
|
entry.path().display()
|
||||||
|
))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let new_filename_base = dt.format(format_description!(
|
||||||
|
"[year][month][day]_[hour][minute][second]_"
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// Renaming an already-renamed file should be a no-op
|
||||||
|
if orig_filename.starts_with(&new_filename_base) {
|
||||||
|
log::info!("{orig_filename} looks like it was already renamed, skiping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_path = entry
|
||||||
|
.path()
|
||||||
|
.with_file_name(new_filename_base + &orig_filename)
|
||||||
|
.with_extension(ext);
|
||||||
|
|
||||||
|
renames.push((entry.path(), new_path.clone()));
|
||||||
|
|
||||||
|
// Check for files associated with this image and set them up to be renamed too, like
|
||||||
|
// description files that end with .txt or .md
|
||||||
|
for ext in ["txt", "md"] {
|
||||||
|
let side_file_path = entry.path().with_extension(ext);
|
||||||
|
if side_file_path.exists() {
|
||||||
|
let new_side_file_path = new_path.with_extension(ext);
|
||||||
|
renames.push((side_file_path, new_side_file_path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort renames by the destination
|
||||||
|
renames.sort_by_key(|(_, dst)| dst.clone());
|
||||||
|
|
||||||
|
Ok(renames)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries to figure out the datetime that the image was created from EXIF metadata
|
||||||
|
fn get_exif_datetime(path: PathBuf) -> anyhow::Result<UtcDateTime> {
|
||||||
|
let format_with_offset = format_description!(
|
||||||
|
"[year]:[month]:[day] [hour]:[minute]:[second][offset_hour]:[offset_minute]"
|
||||||
|
);
|
||||||
|
let format_without_offset =
|
||||||
|
format_description!(version = 2, "[year]:[month]:[day] [hour]:[minute]:[second]");
|
||||||
|
|
||||||
|
let file = File::open(&path).with_context(|| format!("Couldn't open {}", path.display()))?;
|
||||||
|
let mut bufreader = BufReader::new(file);
|
||||||
|
let exif = exif::Reader::new()
|
||||||
|
.read_from_container(&mut bufreader)
|
||||||
|
.with_context(|| format!("Couldn't read EXIF data from {}", path.display()))?;
|
||||||
|
let field = exif
|
||||||
|
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
|
||||||
|
.ok_or(OrganizeError::ExifNoDateTime(path.clone()))?;
|
||||||
|
|
||||||
|
let dt: UtcDateTime = match &field.value {
|
||||||
|
exif::Value::Ascii(v) => {
|
||||||
|
let s = from_utf8(&v[0])?;
|
||||||
|
log::debug!("Date string from file: {s}");
|
||||||
|
|
||||||
|
match OffsetDateTime::parse(s, format_with_offset) {
|
||||||
|
Ok(v) => v.to_utc(),
|
||||||
|
Err(_) => PrimitiveDateTime::parse(s, format_without_offset)?.as_utc(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(OrganizeError::ExifNoDateTime(path).into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(dt)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::test_util::{init, make_test_album};
|
||||||
|
use time::{Date, Month, Time};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Make sure we can get the datetime from one of our test photos
|
||||||
|
fn basic_datetime_read() {
|
||||||
|
init();
|
||||||
|
let dt = get_exif_datetime("resources/test_album/moon.jpg".into()).unwrap();
|
||||||
|
log::info!("Got dt: {dt}");
|
||||||
|
assert_eq!(
|
||||||
|
dt,
|
||||||
|
UtcDateTime::new(
|
||||||
|
Date::from_calendar_date(1970, Month::January, 1).unwrap(),
|
||||||
|
Time::from_hms(13, 37, 0).unwrap(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exif_datetime_missing() {
|
||||||
|
init();
|
||||||
|
let result = get_exif_datetime("resources/test_album/mountains.jpg".into());
|
||||||
|
assert!(result.is_err());
|
||||||
|
//result.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_basic_renames() {
|
||||||
|
init();
|
||||||
|
let tmp_album_dir = make_test_album();
|
||||||
|
let dir = tmp_album_dir.join("with_description");
|
||||||
|
|
||||||
|
log::debug!("Getting renames for {}", dir.display());
|
||||||
|
let renames = get_renames(&dir).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
renames,
|
||||||
|
vec![
|
||||||
|
(dir.join("moon.jpg"), dir.join("19700102_133700_moon.jpg")),
|
||||||
|
(dir.join("moon.txt"), dir.join("19700102_133700_moon.txt")),
|
||||||
|
(
|
||||||
|
dir.join("mountains.jpg"),
|
||||||
|
dir.join("19700103_133700_mountains.jpg")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// get_renames() should ignore other stuff in the directory
|
||||||
|
fn test_other_junk() {
|
||||||
|
init();
|
||||||
|
let tmp_album_dir = make_test_album();
|
||||||
|
|
||||||
|
let renames = get_renames(&tmp_album_dir).unwrap();
|
||||||
|
// No mountain.jpg since it doesn't have EXIF data
|
||||||
|
assert_eq!(
|
||||||
|
renames,
|
||||||
|
vec![(
|
||||||
|
tmp_album_dir.join("moon.jpg"),
|
||||||
|
tmp_album_dir.join("19700101_133700_moon.jpg")
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// The rename function will prepend date and time to the original filenames. If we do it a
|
||||||
|
/// second time, it should be a no-op instead of continuing to prepend date and time.
|
||||||
|
fn test_rerename() {
|
||||||
|
let tmp_album_dir = make_test_album();
|
||||||
|
let dir = tmp_album_dir.join("with_description");
|
||||||
|
reorganize(&dir, false).unwrap();
|
||||||
|
|
||||||
|
let renames = get_renames(&dir).unwrap();
|
||||||
|
assert_eq!(renames, Vec::new());
|
||||||
|
}
|
||||||
|
}
|
92
src/skel.rs
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum InitError {
|
||||||
|
#[error("Album directory already initialized - contains a {0}")]
|
||||||
|
AlreadyInitialized(PathBuf),
|
||||||
|
#[error(transparent)]
|
||||||
|
IoError(#[from] io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new album directory and creates basic versions
|
||||||
|
pub fn make_skeleton(album_path: &Path) -> Result<(), InitError> {
|
||||||
|
let files = HashMap::from([
|
||||||
|
(
|
||||||
|
album_path.join("photojawn.conf.yml"),
|
||||||
|
include_bytes!("../resources/skel/photojawn.conf.yml").as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
album_path.join("_static/index.css"),
|
||||||
|
include_bytes!("../resources/skel/_static/index.css").as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
album_path.join("_templates/base.html"),
|
||||||
|
include_bytes!("../resources/skel/_templates/base.html").as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
album_path.join("_templates/album.html"),
|
||||||
|
include_bytes!("../resources/skel/_templates/album.html").as_slice(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
album_path.join("_templates/photo.html"),
|
||||||
|
include_bytes!("../resources/skel/_templates/photo.html").as_slice(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Bail if any of the files we would create exist
|
||||||
|
for path in files.keys() {
|
||||||
|
if path.exists() {
|
||||||
|
return Err(InitError::AlreadyInitialized(path.to_path_buf()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(album_path)?;
|
||||||
|
for (path, contents) in files.iter() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(path, contents)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use mktemp::Temp;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_exist() {
|
||||||
|
let tmpdir = Temp::new_dir().unwrap();
|
||||||
|
make_skeleton(&tmpdir).unwrap();
|
||||||
|
assert!(tmpdir.join("photojawn.conf.yml").exists());
|
||||||
|
assert!(tmpdir.join("_static/index.css").exists());
|
||||||
|
assert!(tmpdir.join("_templates/base.html").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_exists() {
|
||||||
|
let tmpdir = Temp::new_dir().unwrap();
|
||||||
|
fs::write(tmpdir.join("photojawn.conf.yml"), "some: config").unwrap();
|
||||||
|
let res = make_skeleton(&tmpdir);
|
||||||
|
assert!(res.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dir_exists_no_config() {
|
||||||
|
let tmpdir = Temp::new_dir().unwrap();
|
||||||
|
fs::create_dir(tmpdir.join("_templates")).unwrap();
|
||||||
|
fs::write(tmpdir.join("_templates/base.html"), "some template").unwrap();
|
||||||
|
let res = make_skeleton(&tmpdir);
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
// Make sure it didn't clobber our template
|
||||||
|
let contents = fs::read(tmpdir.join("_templates/base.html")).unwrap();
|
||||||
|
assert_eq!(contents, "some template".as_bytes());
|
||||||
|
}
|
||||||
|
}
|
25
src/test_util.rs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
use crate::skel::make_skeleton;
|
||||||
|
use mktemp::Temp;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
let _ = env_logger::builder().is_test(true).try_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the test album to a tempdir and returns the path to it. Returns a Temp object which
|
||||||
|
/// cleans up the directory on drop, so make sure to persist the variable until you're done with it
|
||||||
|
pub fn make_test_album() -> Temp {
|
||||||
|
let tmpdir = Temp::new_dir().unwrap();
|
||||||
|
let source_path = Path::new("resources/test_album").canonicalize().unwrap();
|
||||||
|
|
||||||
|
log::info!("Creating test album in {}", tmpdir.display());
|
||||||
|
make_skeleton(&tmpdir.to_path_buf()).unwrap();
|
||||||
|
fs_extra::dir::copy(
|
||||||
|
&source_path,
|
||||||
|
&tmpdir,
|
||||||
|
&fs_extra::dir::CopyOptions::new().content_only(true),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tmpdir
|
||||||
|
}
|