simplegals 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
simplegals/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
simplegals/cli.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .core.config import init_project, load_project_config
8
+ from .core.gallery import build, clean, validate
9
+ from .workers.progress import ProgressState, format_cli_progress
10
+
11
+
12
+ def _resolve_config(args: argparse.Namespace) -> Path:
13
+ return Path(args.config) if args.config else Path.cwd() / "simpleGal.json"
14
+
15
+
16
+ def cmd_init(args: argparse.Namespace) -> int:
17
+ config_path = Path(args.config) if args.config else None
18
+ result = init_project(Path.cwd(), config_path=config_path)
19
+ print(f"Initialized: {result}")
20
+ return 0
21
+
22
+
23
+ def cmd_validate(args: argparse.Namespace) -> int:
24
+ config_path = _resolve_config(args)
25
+ try:
26
+ config = load_project_config(config_path)
27
+ except FileNotFoundError:
28
+ print(f"Config not found: {config_path}. Run 'simpleGals init' first.", file=sys.stderr)
29
+ return 1
30
+ errors = validate(Path.cwd(), config)
31
+ if errors:
32
+ for e in errors:
33
+ print(f" error: {e}", file=sys.stderr)
34
+ return 1
35
+ print("Validation passed.")
36
+ return 0
37
+
38
+
39
+ def cmd_build(args: argparse.Namespace) -> int:
40
+ config_path = _resolve_config(args)
41
+ try:
42
+ config = load_project_config(config_path)
43
+ except FileNotFoundError:
44
+ print(f"Config not found: {config_path}. Run 'simpleGals init' first.", file=sys.stderr)
45
+ return 1
46
+
47
+ _first = [True]
48
+
49
+ def _on_progress(state: ProgressState) -> None:
50
+ text = format_cli_progress(state)
51
+ if _first[0]:
52
+ sys.stderr.write(text + "\n")
53
+ _first[0] = False
54
+ else:
55
+ sys.stderr.write(f"\x1b[2A\r{text}\n")
56
+ sys.stderr.flush()
57
+
58
+ log_path, had_errors = build(Path.cwd(), config, progress_callback=_on_progress, force=args.force)
59
+
60
+ if had_errors:
61
+ print(f"\nBuild completed with errors. Log: {log_path}", file=sys.stderr)
62
+ lines = log_path.read_text(encoding="utf-8").splitlines()
63
+ tail = lines[-20:] if len(lines) > 20 else lines
64
+ print("\n--- last 20 lines of build log ---", file=sys.stderr)
65
+ print("\n".join(tail), file=sys.stderr)
66
+ return 1
67
+
68
+ print(f"Build complete. Log: {log_path}")
69
+ return 0
70
+
71
+
72
+ def cmd_clean(args: argparse.Namespace) -> int:
73
+ clean(Path.cwd())
74
+ print("Cleaned .meta/")
75
+ return 0
76
+
77
+
78
+ def main() -> None:
79
+ parser = argparse.ArgumentParser(
80
+ prog="simpleGals",
81
+ description="Static HTML image gallery generator",
82
+ )
83
+ parser.add_argument(
84
+ "-c", "--config",
85
+ metavar="PATH",
86
+ help="Path to simpleGal.json (default: ./simpleGal.json)",
87
+ )
88
+
89
+ sub = parser.add_subparsers(dest="command", required=True)
90
+ sub.add_parser("init", help="Create a stub simpleGal.json in the current directory")
91
+ sub.add_parser("validate", help="Validate config and input images")
92
+ build_parser = sub.add_parser("build", help="Build the gallery")
93
+ build_parser.add_argument(
94
+ "--force", "-f",
95
+ action="store_true",
96
+ help="Rebuild all output files, ignoring the cache",
97
+ )
98
+ sub.add_parser("clean", help="Remove all cached metadata from .meta/")
99
+
100
+ args = parser.parse_args()
101
+
102
+ handlers = {
103
+ "init": cmd_init,
104
+ "validate": cmd_validate,
105
+ "build": cmd_build,
106
+ "clean": cmd_clean,
107
+ }
108
+ sys.exit(handlers[args.command](args))
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
File without changes
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import platform
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class GlobalConfig:
12
+ file_panel_width: int | str = 30
13
+ scroll_rate: float = 2.0
14
+ preview_delay: int = 125
15
+
16
+
17
+ @dataclass
18
+ class Layout:
19
+ columns: int = 4
20
+ rows: int = 5
21
+
22
+
23
+ @dataclass
24
+ class ProjectConfig:
25
+ title: str = "Gallery"
26
+ description: str = ""
27
+ layout: Layout = field(default_factory=Layout)
28
+ quality: int = 90
29
+ copyright: str = ""
30
+ template: str | None = None
31
+ images: dict = field(default_factory=dict)
32
+
33
+
34
+ def global_config_path() -> Path:
35
+ system = platform.system()
36
+ if system == "Darwin":
37
+ return Path.home() / "Library" / "Application Support" / "simplegals" / "config.json"
38
+ if system == "Windows":
39
+ import os
40
+ appdata = os.environ.get("APPDATA", str(Path.home()))
41
+ return Path(appdata) / "simplegals" / "config.json"
42
+ import os
43
+ xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
44
+ return Path(xdg) / "simplegals" / "config.json"
45
+
46
+
47
+ def load_global_config(path: Path | None = None) -> GlobalConfig:
48
+ p = path or global_config_path()
49
+ if not p.exists():
50
+ return GlobalConfig()
51
+ data = json.loads(p.read_text(encoding="utf-8"))
52
+ return GlobalConfig(**{k: v for k, v in data.items() if k in GlobalConfig.__dataclass_fields__})
53
+
54
+
55
+ def save_global_config(config: GlobalConfig, path: Path | None = None) -> None:
56
+ p = path or global_config_path()
57
+ p.parent.mkdir(parents=True, exist_ok=True)
58
+ p.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
59
+
60
+
61
+ def load_project_config(path: Path) -> ProjectConfig:
62
+ if not path.exists():
63
+ raise FileNotFoundError(f"Config not found: {path}")
64
+ data = json.loads(path.read_text(encoding="utf-8"))
65
+ layout_data = data.pop("layout", {})
66
+ layout = Layout(**{k: v for k, v in layout_data.items() if k in Layout.__dataclass_fields__})
67
+ fields = {k: v for k, v in data.items() if k in ProjectConfig.__dataclass_fields__}
68
+ fields["layout"] = layout
69
+ return ProjectConfig(**fields)
70
+
71
+
72
+ def save_project_config(config: ProjectConfig, path: Path) -> None:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ path.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
75
+
76
+
77
+ def init_project(project_dir: Path, config_path: Path | None = None) -> Path:
78
+ target = config_path or (project_dir / "simpleGal.json")
79
+ if not target.exists():
80
+ save_project_config(ProjectConfig(), target)
81
+ return target
82
+
83
+
84
+ def settings_hash(config: ProjectConfig) -> str:
85
+ data = json.dumps(
86
+ {"copyright": config.copyright, "quality": config.quality, "template": config.template},
87
+ sort_keys=True,
88
+ )
89
+ return hashlib.sha256(data.encode()).hexdigest()[:16]
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import multiprocessing
5
+ import threading
6
+ from dataclasses import asdict
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import bitmath
11
+
12
+ from .config import ProjectConfig, settings_hash
13
+ from .metadata import (
14
+ ImageSidecar,
15
+ OutputMeta,
16
+ ThumbMeta,
17
+ check_staleness,
18
+ file_sha256,
19
+ load_sidecar,
20
+ now_rfc3339,
21
+ save_sidecar,
22
+ )
23
+ from .template import render_gallery
24
+ from ..workers.pool import dispatch
25
+ from ..workers.progress import ProgressState, drain_queue, format_cli_progress
26
+
27
+ SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png"})
28
+
29
+
30
+ def ensure_project_dirs(project_dir: Path) -> tuple[Path, Path, Path]:
31
+ in_dir = project_dir / "in"
32
+ out_dir = project_dir / "out"
33
+ meta_dir = project_dir / ".meta"
34
+ for d in (in_dir, out_dir, meta_dir):
35
+ d.mkdir(parents=True, exist_ok=True)
36
+ return in_dir, out_dir, meta_dir
37
+
38
+
39
+ def scan_sources(in_dir: Path) -> list[Path]:
40
+ return sorted(
41
+ p for p in in_dir.iterdir()
42
+ if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
43
+ )
44
+
45
+
46
+ def validate(project_dir: Path, config: ProjectConfig) -> list[str]:
47
+ errors: list[str] = []
48
+ in_dir = project_dir / "in"
49
+ if not in_dir.exists():
50
+ errors.append(f"Missing input directory: {in_dir}")
51
+ return errors
52
+ source_names = {p.name for p in scan_sources(in_dir)}
53
+ for name in config.images:
54
+ if name not in source_names:
55
+ errors.append(f"Image in config not found in in/: {name}")
56
+ return errors
57
+
58
+
59
+ def clean(project_dir: Path) -> None:
60
+ meta_dir = project_dir / ".meta"
61
+ if meta_dir.exists():
62
+ for p in meta_dir.iterdir():
63
+ if p.is_file():
64
+ p.unlink()
65
+
66
+
67
+ def prune_removed_sources(
68
+ out_dir: Path,
69
+ meta_dir: Path,
70
+ source_names: set[str],
71
+ ) -> int:
72
+ """Delete out/ and .meta/ artifacts for sources no longer present in in/.
73
+
74
+ Returns the number of source entries pruned.
75
+ """
76
+ removed = 0
77
+ for sidecar in meta_dir.glob("*.json"):
78
+ name = sidecar.stem # e.g. "DSC_8297.jpg" (strip trailing .json)
79
+ if name in source_names:
80
+ continue
81
+ stem = Path(name).stem
82
+ ext = Path(name).suffix
83
+ sidecar.unlink(missing_ok=True)
84
+ (meta_dir / f"{stem}_thumb{ext}").unlink(missing_ok=True)
85
+ (out_dir / name).unlink(missing_ok=True)
86
+ (out_dir / f"{stem}_thumb{ext}").unlink(missing_ok=True)
87
+ (out_dir / f"{stem}_item.html").unlink(missing_ok=True)
88
+ removed += 1
89
+ return removed
90
+
91
+
92
+ def build(
93
+ project_dir: Path,
94
+ config: ProjectConfig,
95
+ progress_callback=None,
96
+ force: bool = False,
97
+ ) -> tuple[Path, bool]:
98
+ """Run a full gallery build. Returns (log_path, had_errors)."""
99
+ in_dir, out_dir, meta_dir = ensure_project_dirs(project_dir)
100
+
101
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
102
+ log_path = meta_dir / f"build-{ts}.log"
103
+ log_lines: list[str] = []
104
+
105
+ def log(msg: str) -> None:
106
+ log_lines.append(msg)
107
+
108
+ sources = scan_sources(in_dir)
109
+ log(f"Found {len(sources)} source image(s) in {in_dir}")
110
+
111
+ source_names = {s.name for s in sources}
112
+ pruned = prune_removed_sources(out_dir, meta_dir, source_names)
113
+ if pruned:
114
+ log(f"Pruned {pruned} removed source(s) from out/ and .meta/")
115
+
116
+ if force:
117
+ log("Force rebuild requested — skipping cache.")
118
+
119
+ thumb_tasks: list[tuple] = []
120
+ output_tasks: list[tuple] = []
121
+
122
+ for source in sources:
123
+ if force:
124
+ thumb_stale, output_stale = True, True
125
+ else:
126
+ thumb_stale, output_stale = check_staleness(source, meta_dir, config)
127
+ if thumb_stale:
128
+ thumb_tasks.append((str(source), str(meta_dir), None))
129
+ if output_stale:
130
+ output_tasks.append((str(source), str(out_dir), {
131
+ "quality": config.quality,
132
+ "copyright": config.copyright,
133
+ "template": config.template,
134
+ }))
135
+
136
+ log(f"Tasks: {len(thumb_tasks)} thumb, {len(output_tasks)} output")
137
+
138
+ had_errors = False
139
+ if thumb_tasks or output_tasks:
140
+ q: multiprocessing.Queue = multiprocessing.Queue()
141
+ state = ProgressState(
142
+ thumb_total=len(thumb_tasks),
143
+ output_total=len(output_tasks),
144
+ )
145
+ _done = threading.Event()
146
+
147
+ def _run_dispatch() -> None:
148
+ dispatch(thumb_tasks, output_tasks, q)
149
+ _done.set()
150
+
151
+ dispatch_thread = threading.Thread(target=_run_dispatch, daemon=True)
152
+ dispatch_thread.start()
153
+
154
+ while not _done.wait(timeout=0.05):
155
+ state = drain_queue(q, state, timeout=0.02)
156
+ if progress_callback:
157
+ progress_callback(state)
158
+
159
+ # final drain after all workers finish
160
+ state = drain_queue(q, state, timeout=0.1)
161
+ if progress_callback:
162
+ progress_callback(state)
163
+
164
+ dispatch_thread.join()
165
+ log(format_cli_progress(state))
166
+ if state.thumb_failed or state.output_failed:
167
+ had_errors = True
168
+ log(f"Errors: {state.thumb_failed} thumb, {state.output_failed} output")
169
+
170
+ s_hash = settings_hash(config)
171
+ for source in sources:
172
+ sidecar = load_sidecar(meta_dir, source.name) or ImageSidecar(
173
+ source=source.name,
174
+ mtime=now_rfc3339(),
175
+ sha256=file_sha256(source),
176
+ settings_hash=s_hash,
177
+ )
178
+ thumb_path = meta_dir / f"{source.stem}_thumb{source.suffix}"
179
+ if thumb_path.exists():
180
+ sidecar.thumb = ThumbMeta(path=str(thumb_path), generated_at=now_rfc3339())
181
+ output_path = out_dir / source.name
182
+ if output_path.exists():
183
+ sidecar.output = OutputMeta(
184
+ path=str(output_path),
185
+ thumb_path=str(out_dir / f"{source.stem}_thumb{source.suffix}"),
186
+ generated_at=now_rfc3339(),
187
+ )
188
+ sidecar.settings_hash = s_hash
189
+ save_sidecar(meta_dir, sidecar)
190
+
191
+ raw_records = []
192
+ for source in sources:
193
+ img_config = config.images.get(source.name, {})
194
+ size_str = bitmath.getsize(str(source), bestprefix=True).format("{value:.2f} {unit}")
195
+ mtime_dt = datetime.fromtimestamp(source.stat().st_mtime, tz=timezone.utc)
196
+ raw_records.append({
197
+ "filename": source.name,
198
+ "output_path": source.name,
199
+ "thumb_path": f"{source.stem}_thumb{source.suffix}",
200
+ "caption": img_config.get("caption", ""),
201
+ "alt": img_config.get("alt", ""),
202
+ "include": img_config.get("include", True),
203
+ "date": mtime_dt.strftime("%Y-%m-%d"),
204
+ "size": size_str,
205
+ "item_page": f"{source.stem}_item.html",
206
+ })
207
+
208
+ log("Rendering HTML templates...")
209
+ render_gallery(out_dir, config, raw_records)
210
+ log("Build complete.")
211
+
212
+ log_path.write_text("\n".join(log_lines) + "\n", encoding="utf-8")
213
+
214
+ jsonl_path = meta_dir / "build.jsonl"
215
+ entry = {
216
+ "ts": now_rfc3339(),
217
+ "sources": len(sources),
218
+ "thumb_tasks": len(thumb_tasks),
219
+ "output_tasks": len(output_tasks),
220
+ "had_errors": had_errors,
221
+ "log": str(log_path),
222
+ }
223
+ with open(jsonl_path, "a", encoding="utf-8") as f:
224
+ f.write(json.dumps(entry) + "\n")
225
+
226
+ return log_path, had_errors
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import asdict, dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from .config import ProjectConfig, settings_hash as compute_settings_hash
10
+
11
+
12
+ @dataclass
13
+ class ThumbMeta:
14
+ path: str
15
+ generated_at: str
16
+
17
+
18
+ @dataclass
19
+ class OutputMeta:
20
+ path: str
21
+ thumb_path: str
22
+ generated_at: str
23
+
24
+
25
+ @dataclass
26
+ class ImageSidecar:
27
+ source: str
28
+ mtime: str
29
+ sha256: str
30
+ settings_hash: str
31
+ thumb: ThumbMeta | None = None
32
+ output: OutputMeta | None = None
33
+
34
+
35
+ def sidecar_path(meta_dir: Path, source_name: str) -> Path:
36
+ return meta_dir / f"{source_name}.json"
37
+
38
+
39
+ def load_sidecar(meta_dir: Path, source_name: str) -> ImageSidecar | None:
40
+ p = sidecar_path(meta_dir, source_name)
41
+ if not p.exists():
42
+ return None
43
+ data = json.loads(p.read_text(encoding="utf-8"))
44
+ thumb = ThumbMeta(**data["thumb"]) if data.get("thumb") else None
45
+ output = OutputMeta(**data["output"]) if data.get("output") else None
46
+ return ImageSidecar(
47
+ source=data["source"],
48
+ mtime=data["mtime"],
49
+ sha256=data["sha256"],
50
+ settings_hash=data["settings_hash"],
51
+ thumb=thumb,
52
+ output=output,
53
+ )
54
+
55
+
56
+ def save_sidecar(meta_dir: Path, sidecar: ImageSidecar) -> None:
57
+ meta_dir.mkdir(parents=True, exist_ok=True)
58
+ p = sidecar_path(meta_dir, sidecar.source)
59
+ p.write_text(json.dumps(asdict(sidecar), indent=2), encoding="utf-8")
60
+
61
+
62
+ def file_sha256(path: Path) -> str:
63
+ h = hashlib.sha256()
64
+ with open(path, "rb") as f:
65
+ for chunk in iter(lambda: f.read(65536), b""):
66
+ h.update(chunk)
67
+ return h.hexdigest()
68
+
69
+
70
+ def now_rfc3339() -> str:
71
+ return datetime.now(timezone.utc).isoformat()
72
+
73
+
74
+ def _mtime_str(path: Path) -> str:
75
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
76
+
77
+
78
+ def check_staleness(
79
+ source: Path,
80
+ meta_dir: Path,
81
+ config: ProjectConfig,
82
+ ) -> tuple[bool, bool]:
83
+ """Return (thumb_needs_regen, output_needs_regen)."""
84
+ sidecar = load_sidecar(meta_dir, source.name)
85
+ if sidecar is None:
86
+ return True, True
87
+
88
+ current_s_hash = compute_settings_hash(config)
89
+ current_mtime = _mtime_str(source)
90
+
91
+ def _artifacts_exist() -> tuple[bool, bool]:
92
+ thumb_missing = sidecar.thumb is None or not Path(sidecar.thumb.path).exists()
93
+ output_missing = sidecar.output is None or not Path(sidecar.output.path).exists()
94
+ return thumb_missing, output_missing
95
+
96
+ if current_mtime == sidecar.mtime:
97
+ thumb_missing, output_missing = _artifacts_exist()
98
+ return thumb_missing, output_missing or current_s_hash != sidecar.settings_hash
99
+
100
+ current_sha = file_sha256(source)
101
+ if current_sha == sidecar.sha256:
102
+ # touched but unchanged — refresh stored mtime
103
+ sidecar.mtime = current_mtime
104
+ save_sidecar(meta_dir, sidecar)
105
+ thumb_missing, output_missing = _artifacts_exist()
106
+ return thumb_missing, output_missing or current_s_hash != sidecar.settings_hash
107
+
108
+ return True, True
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import piexif
6
+ from PIL import Image
7
+
8
+ from .config import ProjectConfig
9
+
10
+ SGUI_THUMB_MAX: tuple[int, int] = (300, 300)
11
+ HTML_THUMB_MAX: tuple[int, int] = (600, 450)
12
+
13
+
14
+ def _thumb_name(source: Path) -> str:
15
+ return f"{source.stem}_thumb{source.suffix}"
16
+
17
+
18
+ def generate_sgui_thumb(source: Path, meta_dir: Path) -> Path:
19
+ """Generate .meta/<stem>_thumb<ext> for sgui preview. Not subject to publishing settings."""
20
+ dest = meta_dir / _thumb_name(source)
21
+ with Image.open(source) as img:
22
+ img = img.copy()
23
+ img.thumbnail(SGUI_THUMB_MAX, Image.LANCZOS)
24
+ img.save(dest)
25
+ return dest
26
+
27
+
28
+ def generate_output(
29
+ source: Path,
30
+ out_dir: Path,
31
+ config: ProjectConfig,
32
+ ) -> tuple[Path, Path]:
33
+ """Generate out/<filename>.<ext> and out/<stem>_thumb<ext>. Returns (output_path, thumb_path)."""
34
+ output_path = out_dir / source.name
35
+ thumb_path = out_dir / _thumb_name(source)
36
+
37
+ with Image.open(source) as img:
38
+ img = img.copy()
39
+
40
+ if config.copyright:
41
+ img = _inject_copyright(img, config.copyright, source)
42
+
43
+ save_kwargs: dict = {"quality": config.quality}
44
+ img.save(output_path, **_format_save_kwargs(source, save_kwargs))
45
+
46
+ img.thumbnail(HTML_THUMB_MAX, Image.LANCZOS)
47
+ img.save(thumb_path, **_format_save_kwargs(source, save_kwargs))
48
+
49
+ return output_path, thumb_path
50
+
51
+
52
+ def _format_save_kwargs(source: Path, kwargs: dict) -> dict:
53
+ ext = source.suffix.lower()
54
+ if ext in (".jpg", ".jpeg"):
55
+ return kwargs
56
+ if ext == ".png":
57
+ # Pillow maps quality 0-100 → compress_level 0-9
58
+ compress = max(0, min(9, round((100 - kwargs.get("quality", 90)) / 11)))
59
+ return {"compress_level": compress}
60
+ return {}
61
+
62
+
63
+ def _inject_copyright(img: Image.Image, copyright: str, source: Path) -> Image.Image:
64
+ ext = source.suffix.lower()
65
+ if ext not in (".jpg", ".jpeg"):
66
+ return img
67
+ try:
68
+ exif_bytes = img.info.get("exif", b"")
69
+ if exif_bytes:
70
+ exif_dict = piexif.load(exif_bytes)
71
+ else:
72
+ exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}}
73
+ exif_dict["0th"][piexif.ImageIFD.Copyright] = copyright.encode("utf-8")
74
+ img.info["exif"] = piexif.dump(exif_dict)
75
+ except Exception:
76
+ pass
77
+ return img