diff --git a/.gitignore b/.gitignore index 60aaf5d..42f20c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,3 @@ -index.html* -/mirrors -/node_modules -/data - # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/add_mirror.sh b/add_mirror.sh deleted file mode 100755 index 35c50ae..0000000 --- a/add_mirror.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BASE="/srv/www" -URL_LIST="$BASE/mirrors.txt" - -if [ $# -lt 1 ]; then - echo "Usage: $0 URL [slug]" - exit 1 -fi - -url="$1" -if [ $# -ge 2 ]; then - slug="$2" -else - # crude slugify: strip scheme, replace non alnum with underscores - slug="$(echo "$url" | sed 's#https\?://##; s#[^a-zA-Z0-9._-]#_#g')" -fi - -# Check if URL already exists -if grep -q " $url\$" "$URL_LIST" 2>/dev/null; then - echo "URL already in list. Not adding again." -else - echo "$slug $url" >> "$URL_LIST" - echo "Added: $slug $url" -fi - -# Run update for just this slug -"$BASE/update_mirrors.sh" "$slug" diff --git a/app.py b/app.py deleted file mode 100755 index f13fc37..0000000 --- a/app.py +++ /dev/null @@ -1,639 +0,0 @@ -#!/usr/bin/env python3 -from mirror_manager import ( - load_mirrors, - add_mirror, - update_mirror, - MIRROR_ROOT, - LOG_ROOT, -) -import re -import html -import subprocess -import threading -from pathlib import Path -from flask import ( - Flask, - request, - redirect, - url_for, - jsonify, - send_from_directory, - render_template_string -) - - -BASE = Path("/srv/www") -STATIC_DIR = BASE / "static" -STATIC_DIR.mkdir(exist_ok=True) - -app = Flask(__name__) - - -def _run_update_in_background(slug: str): - th = threading.Thread(target=update_mirror, args=(slug,), daemon=True) - th.start() - - -# -------------------- TEMPLATES -------------------- -INDEX_TEMPLATE = r""" - - - - - Mirror Manager - - - -
-
-
-
-

Mirror Manager

-

Local offline mirrors of external sites, grouped by category.

-
-
- - - Running locally - -
-
-
- -
- -
-
-
- Categories: - - {% for cat in categories %} - - {% endfor %} -
-
- -
-
- -
- - - - - - - - - - - - - {% for m in mirrors %} - - - - - - - - - {% endfor %} - {% if mirrors|length == 0 %} - - - - {% endif %} - -
SlugCategoriesURLLast updatedStatus
- - -
- {% for c in m.categories %} - {{ c }} - {% endfor %} -
-
- {{ m.url }} - - {% if m.last_updated %} - {{ m.last_updated }} - {% else %} - never - {% endif %} - - {% set st = m.status or 'idle' %} -
- - {{ st }} -
-
-
- -
-
- No mirrors yet. Add one below. -
-
-
- - -
-

Add mirror

-
-
- - -
-
- - -
-
- - -
-
- - -
- {% if error %} -

{{ error }}

- {% endif %} - -

- New mirrors are cloned in the background. Status will show as updating until done. -

-
-
- - -
-

Content search

-
- - -
-
-
-
-
- - - - -""" - - -LOG_TEMPLATE = r""" - - - - - Log: {{ slug }} - - - -
-
-
-

Log for {{ slug }}

-

Live tail of wget output (auto-refreshing).

-
- Open mirror -
-
-

-    
-
- - - -""" - -# -------------------- ROUTES -------------------- - - -@app.route("/static/") -def static_file(filename): - return send_from_directory(STATIC_DIR, filename) - - -@app.route("/", methods=["GET"]) -def index(): - mirrors = load_mirrors() - cats = set() - rows = [] - for m in mirrors: - categories = m.get("categories") or [] - for c in categories: - cats.add(c) - raw = m.get("last_updated") - disp = raw.replace("T", " ").replace("Z", " UTC") if raw else None - rows.append({ - "slug": m["slug"], - "categories": categories, - "categories_joined": ", ".join(categories), - "url": m["url"], - "status": m.get("status") or "idle", - "last_updated_raw": raw, - "last_updated": disp, - }) - return render_template_string(INDEX_TEMPLATE, mirrors=rows, categories=sorted(cats), error=None) - - -@app.route("/add", methods=["POST"]) -def add_mirror_route(): - slug = (request.form.get("slug") or "").strip() - categories = (request.form.get("categories") or "").strip() - url = (request.form.get("url") or "").strip() - ignore_robots = bool(request.form.get("ignore_robots")) - - error = None - if not slug or not categories or not url: - error = "Slug, categories, and URL are required." - elif " " in slug: - error = "Slug cannot contain spaces." - - if error: - # re-render with error - mirrors = load_mirrors() - cats = set() - rows = [] - for m in mirrors: - cs = m.get("categories") or [] - for c in cs: - cats.add(c) - raw = m.get("last_updated") - disp = raw.replace("T", " ").replace("Z", " UTC") if raw else None - rows.append({ - "slug": m["slug"], - "categories": cs, - "categories_joined": ", ".join(cs), - "url": m["url"], - "status": m.get("status") or "idle", - "last_updated_raw": raw, - "last_updated": disp, - }) - return render_template_string(INDEX_TEMPLATE, mirrors=rows, categories=sorted(cats), error=error), 400 - - try: - add_mirror(slug, categories, url, ignore_robots=ignore_robots) - except Exception as e: - mirrors = load_mirrors() - cats = set() - rows = [] - for m in mirrors: - cs = m.get("categories") or [] - for c in cs: - cats.add(c) - raw = m.get("last_updated") - disp = raw.replace("T", " ").replace("Z", " UTC") if raw else None - rows.append({ - "slug": m["slug"], - "categories": cs, - "categories_joined": ", ".join(cs), - "url": m["url"], - "status": m.get("status") or "idle", - "last_updated_raw": raw, - "last_updated": disp, - }) - return render_template_string(INDEX_TEMPLATE, mirrors=rows, categories=sorted(cats), error=str(e)), 400 - - _run_update_in_background(slug) - return redirect(url_for("index")) - - -@app.route("/update/", methods=["POST"]) -def trigger_update(slug): - _run_update_in_background(slug) - return redirect(url_for("index")) - - -@app.route("/status", methods=["GET"]) -def status(): - mirrors = load_mirrors() - out = [] - for m in mirrors: - raw = m.get("last_updated") - disp = raw.replace("T", " ").replace("Z", " UTC") if raw else None - out.append({ - "slug": m["slug"], - "categories": m.get("categories") or [], - "url": m["url"], - "status": m.get("status") or "idle", - "last_updated": raw, - "last_updated_display": disp or "", - }) - return jsonify({"mirrors": out}) - - -@app.route("/logs/") -def log_view(slug): - log_path = LOG_ROOT / f"{slug}.log" - if not log_path.exists(): - log_path.touch() - return render_template_string(LOG_TEMPLATE, slug=slug) - - -@app.route("/logs//tail") -def log_tail(slug): - log_path = LOG_ROOT / f"{slug}.log" - if not log_path.exists(): - return "", 200 - try: - with log_path.open("rb") as f: - f.seek(0, 2) - size = f.tell() - block = 65536 - if size <= block: - f.seek(0) - data = f.read() - else: - f.seek(-block, 2) - data = f.read() - return data.decode("utf-8", errors="replace") - except OSError: - return "", 200 - - -def strip_html(text: str) -> str: - # Remove script and style blocks first - text = re.sub( - r")<[^<]*)*", - " ", - text, - flags=re.IGNORECASE, - ) - text = re.sub( - r")<[^<]*)*", - " ", - text, - flags=re.IGNORECASE, - ) - # Strip all remaining tags - text = re.sub(r"<[^>]+>", " ", text) - # Unescape HTML entities (& → &, etc.) - text = html.unescape(text) - # Collapse whitespace - text = re.sub(r"\s+", " ", text).strip() - return text - - -def make_snippet(text: str, - query: str, - radius: int = 80, - max_len: int = 240) -> str: - if not text: - return "" - lower = text.lower() - qlower = query.lower() - idx = lower.find(qlower) - if idx == -1: - snippet = text[:max_len] - if len(text) > max_len: - snippet += "…" - return snippet - start = max(0, idx - radius) - end = min(len(text), idx + len(query) + radius) - snippet = text[start:end] - if start > 0: - snippet = "…" + snippet - if end < len(text): - snippet += "…" - return snippet - - -@app.route("/search", methods=["GET"]) -def content_search(): - q = (request.args.get("q") or "").strip() - if not q: - return jsonify({"results": []}) - - try: - proc = subprocess.run( - [ - "rg", - "--line-number", - "--no-heading", - "--color", "never", - "--max-count", "5", # per file - "--type-add", "page:*.{html,htm,md,markdown,txt}", - "-tpage", - q, - str(MIRROR_ROOT), - ], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - timeout=10, - ) - except FileNotFoundError: - return jsonify({ - "results": [{ - "path": "(error)", - "line": 0, - "url": "", - "snippet": "ripgrep (rg) is not installed." - }] - }) - except subprocess.TimeoutExpired: - return jsonify({ - "results": [{ - "path": "(error)", - "line": 0, - "url": "", - "snippet": "rg timed out." - }] - }) - - results = [] - for line in proc.stdout.splitlines(): - parts = line.split(":", 2) - if len(parts) != 3: - continue - path, lineno, raw_content = parts - - # Strip HTML/JS/CSS markup from this line before making a snippet - text_content = strip_html(raw_content) - if not text_content: - continue - - snippet = make_snippet(text_content, q) - - try: - rel_path = str(Path(path).relative_to(MIRROR_ROOT)) - except ValueError: - rel_path = path - - url = "/mirrors/" + rel_path.replace("\\", "/") - - results.append({ - "path": rel_path, - "line": int(lineno), - "url": url, - "snippet": snippet, - }) - - if len(results) >= 50: - break - - return jsonify({"results": results}) - - -if __name__ == "__main__": - app.run(host="127.0.0.1", port=5000, debug=False) diff --git a/generate_index.py b/generate_index.py deleted file mode 100755 index 9775bd1..0000000 --- a/generate_index.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -import pathlib -import html - -BASE = pathlib.Path("/srv/www") -URL_LIST = BASE / "mirrors.txt" -OUTDIR = BASE / "mirrors" -INDEX = BASE / "index.html" - -entries = [] - -if URL_LIST.exists(): - for line in URL_LIST.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - parts = line.split(None, 1) - if len(parts) != 2: - continue - slug, url = parts - mirror_dir = OUTDIR / slug - if not mirror_dir.exists(): - # not mirrored yet, but still list it - status = " (not downloaded yet)" - else: - status = "" - entries.append((slug, url, status)) - -items_html = [] -for slug, url, status in entries: - slug_esc = html.escape(slug) - url_esc = html.escape(url) - status_esc = html.escape(status) - # Link goes to the directory; nginx autoindex or an index file will handle it - items_html.append( - f'
  • {slug_esc}' - f' – {url_esc}{status_esc}
  • ' - ) - -html_doc = f""" - - - - My Tutorial Mirrors - - - -

    Nytegear Mirrors

    -

    This page is generated automatically from mirrors.txt.

    -
      - {''.join(items_html)} -
    - - -""" - -INDEX.write_text(html_doc, encoding="utf-8") diff --git a/mirage/__init__.py b/mirage/__init__.py new file mode 100644 index 0000000..13abfe8 --- /dev/null +++ b/mirage/__init__.py @@ -0,0 +1,3 @@ +"""Mirage core package.""" + +__all__ = [] diff --git a/mirage/cli.py b/mirage/cli.py new file mode 100644 index 0000000..f83e1c2 --- /dev/null +++ b/mirage/cli.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import typer + +from .commands import mirrors_app, misc_app +from .daemon import run_daemon + +app = typer.Typer( + help=( + "Mirage - mirror management that's too good to be true.\n\n" + "Manage local mirrors of websites for offline use.\n" + "Use `mirage mirrors ...` to add/list/update/search.\n" + "Run `mirage daemon` (e.g. under systemd) to process update jobs." + ) +) + +app.add_typer(mirrors_app, name="mirrors") +app.add_typer(misc_app, name="misc") + + +@app.command("daemon") +def daemon_cmd( + poll_interval: float = typer.Option( + 1.0, + "--poll-interval", + help="Seconds between job queue polls.", + ), +): + """ + Run the Mirage mirror daemon. + + This is intended to be managed by systemd: + - `ExecStart=/usr/bin/mirage daemon` + """ + run_daemon(poll_interval=poll_interval) + + +def app_main(): + app() + + +if __name__ == "__main__": + app_main() + diff --git a/mirage/commands/__init__.py b/mirage/commands/__init__.py new file mode 100644 index 0000000..da0a503 --- /dev/null +++ b/mirage/commands/__init__.py @@ -0,0 +1,4 @@ +from .mirrors import mirrors_app +from .misc import misc_app + +__all__ = ["mirrors_app", "misc_app"] diff --git a/mirage/commands/mirrors.py b/mirage/commands/mirrors.py new file mode 100644 index 0000000..c02ac40 --- /dev/null +++ b/mirage/commands/mirrors.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import os +import time +from typing import List, Optional + +import typer + +from .. import storage, jobs +from ..models import Mirror +from ..updater import log_path_for + +mirrors_app = typer.Typer( + help="Manage mirrors (add, list, update, search, status, watch).") + + +@mirrors_app.command("list") +def list_mirrors_cmd(): + """ + List all configured mirrors. + """ + mirrors = storage.list_mirrors() + if not mirrors: + typer.echo("No mirrors configured.") + raise typer.Exit(0) + + for m in mirrors: + cats = ", ".join(m.categories) if m.categories else "-" + status = m.status or "idle" + lu = m.last_updated.isoformat( + sep=" ", timespec="seconds") if m.last_updated else "never" + typer.echo(f"{m.slug:20} [{status:8}] {cats:25} {lu}") + typer.echo(f" {m.url}") + + +@mirrors_app.command("add") +def add_mirror_cmd( + slug: str = typer.Argument(..., + help="Local slug for the mirror (unique)."), + url: str = typer.Argument(..., help="Source URL to mirror."), + category: List[str] = typer.Option( + None, + "--category", + "-c", + help="Category tag(s) to apply. Can be passed multiple times.", + ), + ignore_robots: bool = typer.Option( + False, + "--ignore-robots", + help="Ignore robots.txt when mirroring (wget robots=off).", + ), + no_update: bool = typer.Option( + False, + "--no-update", + help="Do not enqueue an initial update job.", + ), +): + """ + Add a new mirror definition. + + By default, this queues an initial update job and returns immediately. + The actual mirroring is handled by the mirage daemon. + """ + existing = storage.get_mirror(slug) + if existing: + typer.echo(f"Error: mirror with slug { + slug!r} already exists.", err=True) + raise typer.Exit(1) + + m = Mirror( + slug=slug, + url=url, + categories=category or [], + ignore_robots=ignore_robots, + ) + storage.upsert_mirror(m) + typer.echo(f"Added mirror {slug!r} -> {url}") + + if no_update: + typer.echo("Initial update NOT queued (per --no-update).") + return + + jobs.enqueue_update(slug) + typer.echo("Initial update job queued.") + typer.echo("Run `mirage mirrors status` or `mirage mirrors watch` to monitor.") + + +@mirrors_app.command("edit") +def edit_mirror_cmd( + slug: str = typer.Argument(..., help="Mirror slug to edit."), + new_slug: Optional[str] = typer.Option( + None, + "--slug", + help="Rename the mirror to this slug.", + ), + url: Optional[str] = typer.Option( + None, + "--url", + help="Update the source URL.", + ), + category: List[str] = typer.Option( + None, + "--category", + "-c", + help="Replace categories with these (can be passed multiple times).", + ), + add_category: List[str] = typer.Option( + None, + "--add-category", + help="Add category tag(s) without removing existing ones.", + ), + remove_category: List[str] = typer.Option( + None, + "--remove-category", + help="Remove these category tag(s) from the mirror.", + ), + ignore_robots: Optional[bool] = typer.Option( + None, + "--ignore-robots/--respect-robots", + help="Toggle ignoring robots.txt.", + ), +): + """ + Modify properties of an existing mirror (URL, categories, ignore_robots, slug). + """ + m = storage.get_mirror(slug) + if not m: + typer.echo(f"No such mirror: {slug!r}", err=True) + raise typer.Exit(1) + + original_slug = m.slug + + if url is not None: + m.url = url + + if category: + m.categories = list(category) + + if add_category: + for c in add_category: + if c not in m.categories: + m.categories.append(c) + + if remove_category: + m.categories = [c for c in m.categories if c not in remove_category] + + if ignore_robots is not None: + m.ignore_robots = ignore_robots + + if new_slug is not None and new_slug != original_slug: + # Simple rename: remove old entry, reinsert with new slug + m.slug = new_slug + # Save under new slug + storage.upsert_mirror(m) + # Delete old slug + if original_slug != new_slug: + storage.delete_mirror(original_slug) + typer.echo(f"Mirror {original_slug!r} renamed to {new_slug!r}.") + else: + storage.upsert_mirror(m) + typer.echo(f"Mirror {slug!r} updated.") + + +@mirrors_app.command("remove") +def remove_mirror_cmd( + slug: str = typer.Argument(..., help="Mirror slug to remove."), +): + """ + Remove a mirror definition (does not delete files on disk). + """ + ok = storage.delete_mirror(slug) + if not ok: + typer.echo(f"No such mirror: {slug!r}", err=True) + raise typer.Exit(1) + typer.echo(f"Removed mirror {slug!r} from metadata.") + typer.echo("NOTE: this does not delete the mirrored files on disk.") + + +@mirrors_app.command("update") +def update_mirror_cmd( + slug: str = typer.Argument(..., help="Mirror slug to update."), +): + """ + Enqueue an update job for a single mirror (non-blocking). + """ + m = storage.get_mirror(slug) + if not m: + typer.echo(f"No such mirror: {slug!r}", err=True) + raise typer.Exit(1) + + jobs.enqueue_update(slug) + typer.echo(f"Update job queued for {slug!r}.") + + +@mirrors_app.command("update-all") +def update_all_cmd(): + """ + Enqueue update jobs for all mirrors (non-blocking). + """ + all_mirrors = storage.list_mirrors() + if not all_mirrors: + typer.echo("No mirrors configured.") + raise typer.Exit(0) + + count = 0 + for m in all_mirrors: + # Avoid spamming duplicates if already queued/updating + if m.status in ("queued", "updating"): + continue + jobs.enqueue_update(m.slug) + count += 1 + + typer.echo(f"Queued update jobs for {count} mirror(s).") + typer.echo("Daemon will process them in the background.") + + +@mirrors_app.command("status") +def status_cmd( + slug: Optional[str] = typer.Argument( + None, + help="Optional mirror slug. If omitted, show status for all mirrors.", + ), +): + """ + Show current status for mirrors. + """ + if slug is None: + mirrors = storage.list_mirrors() + if not mirrors: + typer.echo("No mirrors configured.") + raise typer.Exit(0) + + for m in mirrors: + cats = ", ".join(m.categories) if m.categories else "-" + status = m.status or "idle" + lu = m.last_updated.isoformat( + sep=" ", timespec="seconds") if m.last_updated else "never" + typer.echo(f"{m.slug:20} [{status:8}] {cats:25} {lu}") + if m.last_error: + typer.echo(f" last_error: {m.last_error}") + else: + m = storage.get_mirror(slug) + if not m: + typer.echo(f"No such mirror: {slug!r}", err=True) + raise typer.Exit(1) + typer.echo(f"slug : {m.slug}") + typer.echo(f"url : {m.url}") + typer.echo(f"categories : {', '.join( + m.categories) if m.categories else '-'}") + typer.echo(f"ignore_robots: {m.ignore_robots}") + typer.echo(f"status : {m.status or 'idle'}") + lu = m.last_updated.isoformat( + sep=" ", timespec="seconds") if m.last_updated else "never" + typer.echo(f"last_updated : {lu}") + if m.last_error: + typer.echo(f"last_error : {m.last_error}") + + +@mirrors_app.command("watch") +def watch_cmd( + slug: str = typer.Argument(..., help="Mirror slug to watch log for."), + lines: int = typer.Option( + 40, + "--lines", + "-n", + help="Show this many trailing lines before following.", + ), +): + """ + Tail the wget log for a mirror (like `tail -f`). + + Ctrl-C exits the watch without stopping the update job. + """ + log_path = log_path_for(slug) + if not log_path.exists(): + typer.echo(f"No log file yet for {slug!r}: {log_path}") + raise typer.Exit(1) + + typer.echo(f"Watching log: {log_path}") + try: + with log_path.open("r", encoding="utf-8") as f: + # show last N lines + all_lines = f.readlines() + tail = all_lines[-lines:] if lines > 0 else all_lines + for line in tail: + typer.echo(line.rstrip("\n")) + + # now follow + with log_path.open("r", encoding="utf-8") as f: + f.seek(0, os.SEEK_END) + while True: + where = f.tell() + line = f.readline() + if not line: + time.sleep(0.5) + f.seek(where) + else: + typer.echo(line.rstrip("\n")) + except KeyboardInterrupt: + typer.echo("\n[watch] Detaching from log.") diff --git a/mirage/commands/misc.py b/mirage/commands/misc.py new file mode 100644 index 0000000..f72f527 --- /dev/null +++ b/mirage/commands/misc.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import typer + +# type: ignore[attr-defined] +from ..config import load_config, _default_config_path + +misc_app = typer.Typer(help="Miscellaneous commands (config, info).") + + +@misc_app.command("config-path") +def config_path_cmd(): + """ + Show where the active config file is located (or would be created). + """ + # Slight hack: default path; real path reading is in load_config() + p = _default_config_path() + typer.echo(str(p)) + + +@misc_app.command("config-show") +def config_show_cmd(): + """ + Print the current configuration values. + """ + cfg = load_config() + typer.echo(f"mirror_root = {cfg.mirror_root}") + typer.echo(f"data_dir = {cfg.data_dir}") + typer.echo(f"log_dir = {cfg.log_dir}") + typer.echo(f"db_path = {cfg.db_path}") + typer.echo(f"max_concurrent_updates = {cfg.max_concurrent_updates}") + typer.echo(f"wget_bin = {cfg.wget_bin}") + typer.echo(f"rg_bin = {cfg.rg_bin}") diff --git a/mirage/config.py b/mirage/config.py new file mode 100644 index 0000000..0f83a9a --- /dev/null +++ b/mirage/config.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import os +import tomllib # Python 3.11+; on 3.10 use 'tomli' instead +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +DEFAULT_MIRROR_ROOT = Path("/srv/www/mirrors") +DEFAULT_DATA_DIR = Path("~/.local/share/mirrorctl").expanduser() +DEFAULT_MAX_CONCURRENT_UPDATES = 4 +DEFAULT_WGET_BIN = "wget" +DEFAULT_RG_BIN = "rg" + + +@dataclass +class Config: + mirror_root: Path + data_dir: Path + max_concurrent_updates: int + wget_bin: str + rg_bin: str + + @property + def log_dir(self) -> Path: + d = self.data_dir / "logs" + d.mkdir(parents=True, exist_ok=True) + return d + + @property + def db_path(self) -> Path: + self.data_dir.mkdir(parents=True, exist_ok=True) + return self.data_dir / "mirrors.json" + + @property + def config_dir(self) -> Path: + # For future use (e.g. storing per-mirror configs) + d = self.data_dir / "config" + d.mkdir(parents=True, exist_ok=True) + return d + + +def default_config() -> Config: + return Config( + mirror_root=DEFAULT_MIRROR_ROOT, + data_dir=DEFAULT_DATA_DIR, + max_concurrent_updates=DEFAULT_MAX_CONCURRENT_UPDATES, + wget_bin=DEFAULT_WGET_BIN, + rg_bin=DEFAULT_RG_BIN, + ) + + +def _default_config_path() -> Path: + xdg = os.getenv("XDG_CONFIG_HOME") + if xdg: + base = Path(xdg) + else: + base = Path("~/.config").expanduser() + return base / "mirrorctl" / "config.toml" + + +def _search_config_paths() -> list[Path]: + env = os.getenv("MIRRORCTL_CONFIG") + paths: list[Path] = [] + if env: + paths.append(Path(env)) + + # user config + paths.append(_default_config_path()) + + # system config + paths.append(Path("/etc/mirrorctl/config.toml")) + + return paths + + +def _ensure_default_config_file(path: Path, cfg: Config) -> None: + if path.exists(): + return + path.parent.mkdir(parents=True, exist_ok=True) + content = f"""# mirrorctl configuration + +# Directory where mirrors will be stored +mirror_root = "{cfg.mirror_root}" + +# Directory for mirrorctl metadata (db, logs, etc.) +data_dir = "{cfg.data_dir}" + +# Max parallel mirror updates +max_concurrent_updates = {cfg.max_concurrent_updates} + +# Path to wget binary +wget_bin = "{cfg.wget_bin}" + +# Path to ripgrep (rg) binary +rg_bin = "{cfg.rg_bin}" +""" + path.write_text(content, encoding="utf-8") + + +def load_config() -> Config: + """ + Load configuration from MIRRORCTL_CONFIG, XDG config, or /etc. + If no config exists, create a default one in + ~/.config/mirrorctl/config.toml. + """ + cfg = default_config() + + paths = _search_config_paths() + used_path: Optional[Path] = None + + for p in paths: + if p.is_file(): + used_path = p + break + + if used_path is None: + # create default user config and read it back + user_path = _default_config_path() + _ensure_default_config_file(user_path, cfg) + used_path = user_path + + data = {} + try: + raw = used_path.read_bytes() + data = tomllib.loads(raw.decode("utf-8")) + except Exception: + # Fall back to defaults if config is unreadable + data = {} + + # Apply overrides from file + mirror_root = Path(data.get("mirror_root", cfg.mirror_root)) + data_dir = Path(data.get("data_dir", cfg.data_dir)) + max_concurrent = int( + data.get("max_concurrent_updates", cfg.max_concurrent_updates)) + wget_bin = str(data.get("wget_bin", cfg.wget_bin)) + rg_bin = str(data.get("rg_bin", cfg.rg_bin)) + + return Config( + mirror_root=mirror_root, + data_dir=data_dir, + max_concurrent_updates=max_concurrent, + wget_bin=wget_bin, + rg_bin=rg_bin, + ) diff --git a/mirage/daemon.py b/mirage/daemon.py new file mode 100644 index 0000000..b3453af --- /dev/null +++ b/mirage/daemon.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor, Future +from datetime import datetime +from typing import Dict, Tuple + +from .config import load_config +from . import jobs +from . import storage +from .updater import update_mirror + + +def run_daemon(poll_interval: float = 1.0) -> None: + """ + Simple job-processing daemon. + + - Watches jobs/pending for new update jobs. + - Moves them to jobs/running. + - Runs wget via update_mirror() with concurrency. + """ + cfg = load_config() + max_workers = max(1, cfg.max_concurrent_updates) + executor = ThreadPoolExecutor(max_workers=max_workers) + + # Map Future -> (job_path, slug) + running: Dict[Future, Tuple[str, str]] = {} + + print(f"[mirage-daemon] starting with max_workers={max_workers}") + print(f"[mirage-daemon] jobs dir: {cfg.data_dir / 'jobs'}") + + try: + while True: + # 1. Collect finished jobs + finished = [f for f in running if f.done()] + for f in finished: + job_path, slug = running.pop(f) + # Remove job file from running + from pathlib import Path + try: + # type: ignore[arg-type] + Path(job_path).unlink(missing_ok=True) + except TypeError: + Path(job_path).unlink(missing_ok=True) + + try: + f.result() + except Exception as e: # noqa: BLE001 + # Internal failure => mark mirror as error + m = storage.get_mirror(slug) + if m: + m.status = "error" + m.last_error = f"Internal error: {e!r}" + m.last_updated = datetime.now() + storage.upsert_mirror(m) + + # 2. If we have capacity, pull jobs from pending + capacity = max_workers - len(running) + if capacity > 0: + pending = jobs.list_pending_jobs() + if pending: + for pending_path, job in pending[:capacity]: + running_path = jobs.move_to_running(pending_path) + # mark mirror as updating early + m = storage.get_mirror(job.slug) + if m: + m.status = "updating" + m.last_error = None + storage.upsert_mirror(m) + + fut = executor.submit(update_mirror, job.slug) + running[fut] = (str(running_path), job.slug) + + time.sleep(poll_interval) + except KeyboardInterrupt: + print("[mirage-daemon] shutting down (KeyboardInterrupt)") + finally: + executor.shutdown(wait=False) diff --git a/mirage/jobs.py b/mirage/jobs.py new file mode 100644 index 0000000..fb1a61f --- /dev/null +++ b/mirage/jobs.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import List, Tuple + +from .config import load_config +from . import storage + + +@dataclass +class Job: + id: str + slug: str + type: str # currently only "update" + queued_at: datetime + + def to_dict(self) -> dict: + return { + "id": self.id, + "slug": self.slug, + "type": self.type, + "queued_at": self.queued_at.isoformat(), + } + + @classmethod + def from_dict(cls, data: dict) -> "Job": + return cls( + id=data["id"], + slug=data["slug"], + type=data["type"], + queued_at=datetime.fromisoformat(data["queued_at"]), + ) + + +def _jobs_root() -> Path: + cfg = load_config() + root = cfg.data_dir / "jobs" + root.mkdir(parents=True, exist_ok=True) + (root / "pending").mkdir(exist_ok=True) + (root / "running").mkdir(exist_ok=True) + return root + + +def pending_dir() -> Path: + return _jobs_root() / "pending" + + +def running_dir() -> Path: + return _jobs_root() / "running" + + +def enqueue_update(slug: str) -> Path: + """ + Enqueue an update job for the given slug. + Mark mirror status as 'queued' (unless it's already queued/updating). + """ + job_id = uuid.uuid4().hex + job = Job( + id=job_id, + slug=slug, + type="update", + queued_at=datetime.now(), + ) + pdir = pending_dir() + path = pdir / f"{job_id}.json" + with path.open("w", encoding="utf-8") as f: + json.dump(job.to_dict(), f) + + m = storage.get_mirror(slug) + if m and m.status not in ("queued", "updating"): + m.status = "queued" + m.last_error = None + storage.upsert_mirror(m) + + return path + + +def list_pending_jobs() -> List[Tuple[Path, Job]]: + jobs: List[Tuple[Path, Job]] = [] + pdir = pending_dir() + for path in sorted(pdir.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + job = Job.from_dict(data) + except Exception: + continue + jobs.append((path, job)) + return jobs + + +def load_job(path: Path) -> Job: + data = json.loads(path.read_text(encoding="utf-8")) + return Job.from_dict(data) + + +def move_to_running(pending_path: Path) -> Path: + """ + Move a pending job file into the running directory. + """ + rdir = running_dir() + dest = rdir / pending_path.name + pending_path.replace(dest) + return dest + diff --git a/mirage/models/__init__.py b/mirage/models/__init__.py new file mode 100644 index 0000000..d7badf0 --- /dev/null +++ b/mirage/models/__init__.py @@ -0,0 +1,3 @@ +from .mirror import Mirror + +__all__ = ["Mirror"] diff --git a/mirage/models/mirror.py b/mirage/models/mirror.py new file mode 100644 index 0000000..9d6c0f6 --- /dev/null +++ b/mirage/models/mirror.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional + + +@dataclass +class Mirror: + """ + Representation of a single mirrored site. + + Attributes: + slug: Local identifier (and directory name under mirror_root). + url: Source URL to mirror. + categories: Arbitrary tags/categories (strings). + ignore_robots: Whether to disable robots.txt (wget robots=off). + status: Current status: "idle", "updating", "warning", "error". + last_updated: Last successful/attempted update timestamp. + last_error: Text description of last error/warning. + """ + slug: str + url: str + categories: List[str] = field(default_factory=list) + ignore_robots: bool = False + status: str = "idle" + last_updated: Optional[datetime] = None + last_error: Optional[str] = None + + def to_dict(self) -> dict: + if self.last_updated: + update = self.last_updated.isoformat() + else: + update = None + + return { + "slug": self.slug, + "url": self.url, + "categories": self.categories, + "ignore_robots": self.ignore_robots, + "status": self.status, + "last_updated": update, + "last_error": self.last_error, + } + + @classmethod + def from_dict(cls, data: dict) -> "Mirror": + lu = data.get("last_updated") + last_updated = datetime.fromisoformat(lu) if lu else None + return cls( + slug=data["slug"], + url=data["url"], + categories=data.get("categories", []), + ignore_robots=data.get("ignore_robots", False), + status=data.get("status", "idle"), + last_updated=last_updated, + last_error=data.get("last_error"), + ) diff --git a/mirage/search.py b/mirage/search.py new file mode 100644 index 0000000..f467fa6 --- /dev/null +++ b/mirage/search.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import html +import re +import subprocess +from pathlib import Path +from typing import List, Dict + +from .config import load_config + + +def strip_html(text: str) -> str: + # Remove script and style blocks + text = re.sub( + r")<[^<]*)*", + " ", + text, + flags=re.IGNORECASE, + ) + text = re.sub( + r")<[^<]*)*", + " ", + text, + flags=re.IGNORECASE, + ) + # Strip all remaining tags + text = re.sub(r"<[^>]+>", " ", text) + # Unescape HTML entities (& -> &) + text = html.unescape(text) + # Collapse whitespace + text = re.sub(r"\s+", " ", text).strip() + return text + + +def make_snippet(text: str, + query: str, + radius: int = 80, + max_len: int = 240) -> str: + if not text: + return "" + lower = text.lower() + qlower = query.lower() + idx = lower.find(qlower) + if idx == -1: + snippet = text[:max_len] + if len(text) > max_len: + snippet += "…" + return snippet + start = max(0, idx - radius) + end = min(len(text), idx + len(query) + radius) + snippet = text[start:end] + if start > 0: + snippet = "…" + snippet + if end < len(text): + snippet += "…" + return snippet + + +def search_content(query: str, limit: int = 50) -> List[Dict]: + """ + Run ripgrep across all mirrors and return text snippets around matches. + + Returns list of dicts: + { + "path": "slug/host/path/file.html", + "line": 42, + "snippet": "text around the query ..." + } + """ + query = (query or "").strip() + if not query: + return [] + + cfg = load_config() + root = cfg.mirror_root + + try: + proc = subprocess.run( + [ + cfg.rg_bin, + "--line-number", + "--no-heading", + "--color", + "never", + "--max-count", + "5", # per file + "--type-add", + "page:*.{html,htm,md,markdown,txt}", + "-tpage", + query, + str(root), + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=10, + ) + except FileNotFoundError: + return [{ + "path": "(error)", + "line": 0, + "snippet": f"ripgrep binary not found: {cfg.rg_bin!r}", + }] + except subprocess.TimeoutExpired: + return [{ + "path": "(error)", + "line": 0, + "snippet": "rg timed out.", + }] + + results: List[Dict] = [] + + for line in proc.stdout.splitlines(): + parts = line.split(":", 2) + if len(parts) != 3: + continue + path_str, lineno_str, raw_content = parts + + text_content = strip_html(raw_content) + if not text_content: + continue + + snippet = make_snippet(text_content, query) + + try: + rel_path = str(Path(path_str).relative_to(root)) + except ValueError: + rel_path = path_str + + try: + lineno = int(lineno_str) + except ValueError: + lineno = 0 + + results.append({ + "path": rel_path, + "line": lineno, + "snippet": snippet, + }) + + if len(results) >= limit: + break + + return results diff --git a/mirage/storage.py b/mirage/storage.py new file mode 100644 index 0000000..7e34543 --- /dev/null +++ b/mirage/storage.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +from threading import RLock +from typing import Dict, List, Optional + +from .config import load_config +from .models import Mirror + +_lock = RLock() + + +def _load_raw(path) -> Dict[str, dict]: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def _save_raw(path, data: Dict[str, dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + with tmp.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2, sort_keys=True) + tmp.replace(path) + + +def list_mirrors() -> List[Mirror]: + cfg = load_config() + with _lock: + data = _load_raw(cfg.db_path) + return [Mirror.from_dict(v) for v in data.values()] + + +def get_mirror(slug: str) -> Optional[Mirror]: + cfg = load_config() + with _lock: + data = _load_raw(cfg.db_path) + if slug not in data: + return None + return Mirror.from_dict(data[slug]) + + +def upsert_mirror(m: Mirror) -> None: + cfg = load_config() + with _lock: + data = _load_raw(cfg.db_path) + data[m.slug] = m.to_dict() + _save_raw(cfg.db_path, data) + + +def delete_mirror(slug: str) -> bool: + cfg = load_config() + with _lock: + data = _load_raw(cfg.db_path) + if slug not in data: + return False + del data[slug] + _save_raw(cfg.db_path, data) + return True diff --git a/mirage/updater.py b/mirage/updater.py new file mode 100644 index 0000000..96c8eb1 --- /dev/null +++ b/mirage/updater.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import subprocess +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path +from typing import Iterable, Tuple, Dict + +from .config import load_config +from .models import Mirror +from . import storage + + +def mirror_dir_for(slug: str) -> Path: + cfg = load_config() + d = cfg.mirror_root / slug + d.mkdir(parents=True, exist_ok=True) + return d + + +def log_path_for(slug: str) -> Path: + cfg = load_config() + return cfg.log_dir / f"{slug}.log" + + +def _write_log_header(log_path: Path, cmd: list[str]) -> None: + now = datetime.now().isoformat() + with log_path.open("a", encoding="utf-8") as log: + log.write(f"\n=== {now} Running: {' '.join(cmd)}\n") + log.flush() + + +def run_wget(mirror: Mirror) -> Tuple[int, Path]: + """ + Run wget for a single mirror, appending logs to its log file. + + Returns: + (exit_code, log_path) + """ + cfg = load_config() + target_dir = mirror_dir_for(mirror.slug) + log_path = log_path_for(mirror.slug) + + cmd = [ + cfg.wget_bin, + "--mirror", + "--convert-links", + "--page-requisites", + "--no-parent", + "--adjust-extension", + f"--execute=robots={'off' if mirror.ignore_robots else 'on'}", + "--directory-prefix", + str(target_dir), + mirror.url, + ] + + _write_log_header(log_path, cmd) + + with log_path.open("a", encoding="utf-8") as log: + proc = subprocess.run( + cmd, + stdout=log, + stderr=log, + text=True, + ) + + return proc.returncode, log_path + + +def update_mirror(slug: str) -> Mirror: + """ + Update a single mirror by slug and persist its status. + + Returns: + Updated Mirror instance. + """ + m = storage.get_mirror(slug) + if not m: + raise ValueError(f"Unknown mirror: {slug!r}") + + # Mark as updating + m.status = "updating" + m.last_error = None + storage.upsert_mirror(m) + + code, log_path = run_wget(m) + + # Reload to avoid overwriting concurrent changes + m = storage.get_mirror(slug) or m + + if code == 0: + m.status = "idle" + elif code == 4: + # network issues -> warning + m.status = "warning" + m.last_error = f"wget exited with code {code}, see {log_path}" + else: + m.status = "error" + m.last_error = f"wget exited with code {code}, see {log_path}" + + m.last_updated = datetime.now() + storage.upsert_mirror(m) + return m + + +def update_all_concurrent( + slugs: Iterable[str] | None = None) -> Dict[str, Mirror]: + """ + Update multiple mirrors concurrently. + + Args: + slugs: Iterable of slugs to update. If None, update all mirrors. + + Returns: + Mapping slug -> updated Mirror. + """ + cfg = load_config() + if slugs is None: + slugs = [m.slug for m in storage.list_mirrors()] + + slugs = list(slugs) + results: Dict[str, Mirror] = {} + + if not slugs: + return results + + max_workers = max(1, cfg.max_concurrent_updates) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_slug = {executor.submit( + update_mirror, slug): slug for slug in slugs} + + for future in as_completed(future_to_slug): + slug = future_to_slug[future] + try: + m = future.result() + results[slug] = m + except Exception as e: # noqa: BLE001 + # If update fails badly, mark error + m = storage.get_mirror(slug) + if m: + m.status = "error" + m.last_error = f"Internal error: {e!r}" + m.last_updated = datetime.now() + storage.upsert_mirror(m) + results[slug] = m + + return results diff --git a/mirror_manager.py b/mirror_manager.py deleted file mode 100755 index b958726..0000000 --- a/mirror_manager.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -import json -import subprocess -import datetime as dt -from pathlib import Path -import threading -from concurrent.futures import ThreadPoolExecutor, as_completed - -BASE = Path("/srv/www") -DATA_FILE = BASE / "data" / "mirrors.json" -MIRROR_ROOT = BASE / "mirrors" -LOG_ROOT = BASE / "logs" - -MIRROR_ROOT.mkdir(parents=True, exist_ok=True) -LOG_ROOT.mkdir(parents=True, exist_ok=True) -DATA_FILE.parent.mkdir(parents=True, exist_ok=True) - -_LOCK = threading.Lock() - - -def _now_iso() -> str: - return dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" - - -def load_mirrors() -> list[dict]: - with _LOCK: - if not DATA_FILE.exists(): - return [] - with DATA_FILE.open("r", encoding="utf-8") as f: - return json.load(f) - - -def save_mirrors(mirrors: list[dict]) -> None: - with _LOCK: - tmp = DATA_FILE.with_suffix(".tmp") - with tmp.open("w", encoding="utf-8") as f: - json.dump(mirrors, f, indent=2) - tmp.replace(DATA_FILE) - - -def get_mirror(mirrors: list[dict], slug: str) -> dict | None: - for m in mirrors: - if m["slug"] == slug: - return m - return None - - -def _normalise_categories(raw: str) -> list[str]: - # "tutorials, wgpu, rust" -> ["tutorials","wgpu","rust"] - parts = [p.strip() for p in raw.split(",")] - return [p for p in parts if p] - - -def add_mirror(slug: str, - categories: str, - url: str, - ignore_robots: bool = False) -> dict: - mirrors = load_mirrors() - if get_mirror(mirrors, slug) is not None: - raise ValueError(f"Mirror with slug '{slug}' already exists") - - cats = _normalise_categories(categories) - if not cats: - raise ValueError("At least one category is required") - - m = { - "slug": slug, - "categories": cats, - "url": url, - "ignore_robots": bool(ignore_robots), - "created_at": _now_iso(), - "last_updated": None, - "status": "queued", # idle | updating | queued | warning | error - "last_error": None, - } - mirrors.append(m) - save_mirrors(mirrors) - return m - - -def _set_status(slug: str, *, - status: str, - last_error: str | None = None, - last_updated: str | None = None): - mirrors = load_mirrors() - m = get_mirror(mirrors, slug) - if m is None: - return - m["status"] = status - if last_error is not None: - m["last_error"] = last_error - if last_updated is not None: - m["last_updated"] = last_updated - save_mirrors(mirrors) - - -def update_mirror(slug: str) -> None: - """Run wget mirror for a single slug (blocking in this thread).""" - mirrors = load_mirrors() - m = get_mirror(mirrors, slug) - if m is None: - raise ValueError(f"No such mirror: {slug}") - - _set_status(slug, status="updating", last_error=None) - - target_dir = MIRROR_ROOT / slug - target_dir.mkdir(parents=True, exist_ok=True) - log_file = LOG_ROOT / f"{slug}.log" - - robots_setting = "off" if m.get("ignore_robots") else "on" - - cmd = [ - "wget", - "--mirror", # recurse, keep timestamps - "--convert-links", - "--adjust-extension", - "--page-requisites", - "--no-parent", - "--wait=0.5", - "--random-wait", - "--limit-rate=50m", - "--tries=3", - "--retry-connrefused", - f"--execute=robots={robots_setting}", - "-P", - str(target_dir), - m["url"], - ] - - try: - with log_file.open("a", encoding="utf-8") as lf: - lf.write(f"\n=== {_now_iso()} : Starting mirror of { - m['url']} ===\n") - lf.flush() - proc = subprocess.run( - cmd, - stdout=lf, - stderr=subprocess.STDOUT, - ) - lf.write(f"=== {_now_iso()} : wget exited with code { - proc.returncode} ===\n") - lf.flush() - - # Classify result - if proc.returncode == 0: - _set_status(slug, status="idle", - last_updated=_now_iso(), last_error=None) - else: - # If we see FINISHED in the log and the directory has content, - # treat this as a partial/ok-with-warnings case. - text = log_file.read_text(encoding="utf-8", errors="ignore") - has_finished = "FINISHED --" in text - has_files = any(target_dir.rglob("*")) - if has_finished and has_files: - _set_status( - slug, - status="warning", - last_updated=_now_iso(), - last_error=f"wget exited with { - proc.returncode} (partial; see log)", - ) - else: - _set_status( - slug, - status="error", - last_error=f"wget exited with {proc.returncode}", - ) - except Exception as e: - _set_status( - slug, - status="error", - last_error=f"{type(e).__name__}: {e}", - ) - - -def update_all_mirrors(max_workers: int = 3) -> None: - mirrors = load_mirrors() - slugs = [m["slug"] for m in mirrors] - if not slugs: - return - # Run several in parallel - with ThreadPoolExecutor(max_workers=max_workers) as pool: - futures = {pool.submit(update_mirror, slug): slug for slug in slugs} - for fut in as_completed(futures): - slug = futures[fut] - try: - fut.result() - except Exception as e: - _set_status(slug, status="error", last_error=f"{ - type(e).__name__}: {e}") diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 6e7ca6a..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1067 +0,0 @@ -{ - "name": "www", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "www", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@tailwindcss/cli": "^4.1.17" - }, - "devDependencies": { - "tailwindcss": "^4.1.17" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/cli": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.17.tgz", - "integrity": "sha512-jUIxcyUNlCC2aNPnyPEWU/L2/ik3pB4fF3auKGXr8AvN3T3OFESVctFKOBoPZQaZJIeUpPn1uCLp0MRxuek8gg==", - "license": "MIT", - "dependencies": { - "@parcel/watcher": "^2.5.1", - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "enhanced-resolve": "^5.18.3", - "mri": "^1.2.0", - "picocolors": "^1.1.1", - "tailwindcss": "4.1.17" - }, - "bin": { - "tailwindcss": "dist/index.mjs" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss/node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index ca9b531..0000000 --- a/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "devDependencies": { - "tailwindcss": "^4.1.17" - }, - "name": "www", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "repository": { - "type": "git", - "url": "https://git.nytegear.com/aargonian/nytegear-mirror-websites.git" - }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "commonjs", - "dependencies": { - "@tailwindcss/cli": "^4.1.17" - } -} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d5445b4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "mirage" +version = "0.1.0" +description = "Mirror management that's too good to be true." +authors = [{ name = "Aaron Gorodetzky", email = "aaron@nytegear.com" }] +requires-python = ">=3.10" +dependencies = [ + "typer[all]>=0.12.0", +] + +[project.scripts] +mirage = "mirage.cli:app" + +[tool.setuptools] +packages = ["mirage"] diff --git a/static/tailwind.css b/static/tailwind.css deleted file mode 100644 index f8d89fc..0000000 --- a/static/tailwind.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-yellow-400:oklch(85.2% .199 91.936);--color-emerald-400:oklch(76.5% .177 163.223);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-900:oklch(41% .159 10.272);--color-rose-950:oklch(27.1% .105 12.094);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-5xl:64rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.relative{position:relative}.static{position:static}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-2{margin-top:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.block{display:block}.flex{display:flex}.inline{display:inline}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing)*2)}.h-full{height:100%}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[75vh\]{max-height:75vh}.min-h-full{min-height:100%}.w-2{width:calc(var(--spacing)*2)}.w-full{width:100%}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-full{min-width:100%}.flex-1{flex:1}.animate-pulse{animation:var(--animate-pulse)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-900\/80>:not(:last-child)){border-color:#0f172bcc}@supports (color:color-mix(in lab, red, red)){:where(.divide-slate-900\/80>:not(:last-child)){border-color:color-mix(in oklab,var(--color-slate-900)80%,transparent)}}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-rose-900{border-color:var(--color-rose-900)}.border-sky-500{border-color:var(--color-sky-500)}.border-slate-600{border-color:var(--color-slate-600)}.border-slate-700{border-color:var(--color-slate-700)}.border-slate-800{border-color:var(--color-slate-800)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-rose-400{background-color:var(--color-rose-400)}.bg-rose-950\/60{background-color:#4d021899}@supports (color:color-mix(in lab, red, red)){.bg-rose-950\/60{background-color:color-mix(in oklab,var(--color-rose-950)60%,transparent)}}.bg-slate-800\/80{background-color:#1d293dcc}@supports (color:color-mix(in lab, red, red)){.bg-slate-800\/80{background-color:color-mix(in oklab,var(--color-slate-800)80%,transparent)}}.bg-slate-900{background-color:var(--color-slate-900)}.bg-slate-900\/70{background-color:#0f172bb3}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/70{background-color:color-mix(in oklab,var(--color-slate-900)70%,transparent)}}.bg-slate-950{background-color:var(--color-slate-950)}.bg-slate-950\/80{background-color:#020618cc}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/80{background-color:color-mix(in oklab,var(--color-slate-950)80%,transparent)}}.bg-slate-950\/90{background-color:#020618e6}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/90{background-color:color-mix(in oklab,var(--color-slate-950)90%,transparent)}}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-sky-500{--tw-gradient-from:var(--color-sky-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-indigo-500{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.7rem\]{font-size:.7rem}.text-\[0\.65rem\]{font-size:.65rem}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-300{color:var(--color-amber-300)}.text-rose-300{color:var(--color-rose-300)}.text-sky-300{color:var(--color-sky-300)}.text-sky-400{color:var(--color-sky-400)}.text-sky-500{color:var(--color-sky-500)}.text-slate-100{color:var(--color-slate-100)}.text-slate-200{color:var(--color-slate-200)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-black\/40{--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.shadow-black\/40{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-black)40%,transparent)var(--tw-shadow-alpha),transparent)}}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.placeholder\:text-slate-500::placeholder{color:var(--color-slate-500)}@media (hover:hover){.hover\:border-sky-500:hover{border-color:var(--color-sky-500)}.hover\:bg-slate-900\/80:hover{background-color:#0f172bcc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-900\/80:hover{background-color:color-mix(in oklab,var(--color-slate-900)80%,transparent)}}.hover\:from-sky-400:hover{--tw-gradient-from:var(--color-sky-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.hover\:to-indigo-400:hover{--tw-gradient-to:var(--color-indigo-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.hover\:text-sky-100:hover{color:var(--color-sky-100)}.hover\:text-sky-200:hover{color:var(--color-sky-200)}.hover\:text-sky-300:hover{color:var(--color-sky-300)}.hover\:text-slate-100:hover{color:var(--color-slate-100)}.hover\:text-slate-200:hover{color:var(--color-slate-200)}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:w-64{width:calc(var(--spacing)*64)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}}@media (min-width:48rem){.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}}@media (min-width:64rem){.lg\:w-80{width:calc(var(--spacing)*80)}.lg\:flex-row{flex-direction:row}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/systemd/mirage-update.service b/systemd/mirage-update.service new file mode 100644 index 0000000..0f5e06b --- /dev/null +++ b/systemd/mirage-update.service @@ -0,0 +1,6 @@ +[Unit] +Description=Enqueue periodic updates for Mirage mirrors + +[Service] +Type=oneshot +ExecStart=/usr/bin/mirage mirrors update-all diff --git a/systemd/update-mirrors.timer b/systemd/mirage-update.timer similarity index 50% rename from systemd/update-mirrors.timer rename to systemd/mirage-update.timer index ac707a2..0c0803b 100644 --- a/systemd/update-mirrors.timer +++ b/systemd/mirage-update.timer @@ -1,10 +1,11 @@ +# systemd/mirage-update.timer [Unit] -Description=Daily update of offline mirrors +Description=Run Mirage mirror updates periodically [Timer] OnCalendar=03:00 Persistent=true -Unit=update-mirrors.service [Install] WantedBy=timers.target + diff --git a/systemd/mirage.service b/systemd/mirage.service new file mode 100644 index 0000000..d255de3 --- /dev/null +++ b/systemd/mirage.service @@ -0,0 +1,15 @@ +[Unit] +Description=Mirage mirror daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=mirage +Group=mirage +ExecStart=/usr/bin/mirage daemon +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/mirror-manager.service b/systemd/mirror-manager.service deleted file mode 100644 index 427e9d6..0000000 --- a/systemd/mirror-manager.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=Mirror Manager Flask App -After=network.target - -[Service] -User=aargonian -Group=aargonian -WorkingDirectory=/srv/www -Environment="FLASK_ENV=production" -ExecStart=/usr/bin/python3 /srv/www/app.py -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/systemd/update-mirrors.service b/systemd/update-mirrors.service deleted file mode 100644 index 9ec4d41..0000000 --- a/systemd/update-mirrors.service +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=Update Offline Website Mirrors - -[Service] -Type=oneshot -User=aargonian -Group=aargonian -WorkingDirectory=/srv/www -ExecStart=/usr/bin/python3 /srv/www/update_mirrors.py diff --git a/tailwind-input.css b/tailwind-input.css deleted file mode 100644 index f1d8c73..0000000 --- a/tailwind-input.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/update_mirrors.py b/update_mirrors.py deleted file mode 100755 index 382de42..0000000 --- a/update_mirrors.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -import sys -from mirror_manager import update_all_mirrors, update_mirror - - -def main(): - if len(sys.argv) == 2: - slug = sys.argv[1] - update_mirror(slug) - else: - # bump max_workers if you're feeling brave / bandwidth-rich - update_all_mirrors(max_workers=8) - - -if __name__ == "__main__": - main()