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
-
-
-
-
-
-
-
-
-
-
-
- Categories:
-
- {% for cat in categories %}
-
- {% endfor %}
-
-
-
-
-
-
-
-
-
-
- | Slug |
- Categories |
- URL |
- Last updated |
- Status |
- |
-
-
-
- {% for m in mirrors %}
-
- |
-
- |
-
-
- {% 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 }}
-
- |
-
-
- |
-
- {% endfor %}
- {% if mirrors|length == 0 %}
-
- |
- No mirrors yet. Add one below.
- |
-
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
-
-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()