feat(romhacks): serve library over internal Caddy file_server; notes embed art + guide links
Deploy to valhalla / deploy (push) Has been cancelled
Deploy to valhalla / deploy (push) Has been cancelled
- proxy: read-only bind /storage1/labdata/romhacks/library -> /srv/romhacks - Caddyfile: romhacks-files.ginnoir.com (internal_only) file_server + browse - render_catalog_notes.py + handoff.py: embed box art and link guides/ spreadsheets via https://romhacks-files.ginnoir.com/<slug>/<file> (handoff art/guides; guide filter drops buried game-data txt, caps at 30) - regenerate bookmarks
This commit is contained in:
@@ -32,6 +32,10 @@ services:
|
||||
- /config/caddy/data:/data
|
||||
- /config/caddy/config:/config
|
||||
- /storage1/Books:/srv/Books
|
||||
# Pokémon ROM-hack library (box art + guides + spreadsheets), served
|
||||
# read-only and LAN-only by the romhacks-files.ginnoir.com site so the
|
||||
# Obsidian catalog notes can embed art and link guides.
|
||||
- /storage1/labdata/romhacks/library:/srv/romhacks:ro
|
||||
|
||||
networks:
|
||||
edge:
|
||||
|
||||
@@ -24,6 +24,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
INCOMING = pathlib.Path(os.environ.get("INCOMING_DIR", "/incoming"))
|
||||
LIBRARY = pathlib.Path(os.environ.get("LIBRARY_DIR", "/library"))
|
||||
@@ -41,6 +42,51 @@ PATCH_EXTS = {".ips", ".bps", ".ups", ".xdelta"}
|
||||
INPROGRESS = (".part", ".jdownload", ".jdownload.cfg", ".tmp", ".crdownload")
|
||||
FANGAME_HINTS = ("game.exe", "game.ini", ".rxdata", ".rvdata", ".rvdata2", ".rgssad", ".rgss2a", ".rgss3a")
|
||||
ART_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif")
|
||||
# Internal-only Caddy file_server over the library (romhacks-files.ginnoir.com →
|
||||
# /srv/romhacks). Lets the Obsidian notes embed art / link guides off /storage1.
|
||||
FILE_BASE = os.environ.get("FILE_BASE", "https://romhacks-files.ginnoir.com")
|
||||
# Document/spreadsheet extensions that make a genuine guide (a subset of
|
||||
# GUIDE_EXTS — bare game-data .txt under PBS/Graphics/Plugins is not a guide).
|
||||
DOC_EXTS = {".html", ".htm", ".pdf", ".xlsx", ".xls", ".ods", ".csv",
|
||||
".doc", ".docx", ".rtf"}
|
||||
GAME_DATA_DIRS = {"pbs", "graphics", "plugins", "audio", "data", "fonts",
|
||||
"scripts", "optional", "se", "bgm", "tilesets", "sound",
|
||||
"movies", "backup"}
|
||||
MAX_GUIDES = 30
|
||||
|
||||
|
||||
def file_url(slug_name, rel):
|
||||
"""URL into the served library, encoding each path segment."""
|
||||
parts = [quote(slug_name)] + [quote(seg) for seg in str(rel).split("/")]
|
||||
return FILE_BASE + "/" + "/".join(parts)
|
||||
|
||||
|
||||
def pick_guide_rels(guide_rels, artifact_name):
|
||||
"""Filter raw guide rel-paths to linkable docs/spreadsheets/extras."""
|
||||
art_l = (artifact_name or "").lower()
|
||||
scored = []
|
||||
for rel in guide_rels:
|
||||
low = rel.lower()
|
||||
parts = low.split("/")
|
||||
bn = parts[-1]
|
||||
if bn in ("note.md", "handoff.json"):
|
||||
continue
|
||||
ext = os.path.splitext(bn)[1]
|
||||
in_gamedata = any(seg in GAME_DATA_DIRS for seg in parts[:-1])
|
||||
if ext in DOC_EXTS:
|
||||
if in_gamedata:
|
||||
continue
|
||||
scored.append((0, rel))
|
||||
elif ext in (".txt", ".md"):
|
||||
if in_gamedata or len(parts) > 2:
|
||||
continue
|
||||
scored.append((1, rel))
|
||||
elif ext in ARCHIVE_EXTS:
|
||||
if bn == art_l:
|
||||
continue
|
||||
scored.append((2, rel))
|
||||
scored.sort(key=lambda t: (t[0], t[1].lower()))
|
||||
return [rel for _, rel in scored]
|
||||
# Document / extra files worth keeping (guides, spreadsheets, changelogs, and
|
||||
# supplementary or alternate-version archives). Deliberately excludes raw game
|
||||
# assets (png/audio/scripts) so an extracted tree isn't counted as "guides".
|
||||
@@ -127,7 +173,7 @@ def collect_guides(folder, primary):
|
||||
return guides
|
||||
|
||||
|
||||
def render_note(meta, kind, platform, primary, guides, art, folder):
|
||||
def render_note(meta, kind, platform, primary, guides, art, folder, slug_name):
|
||||
name = meta.get("name", folder.name)
|
||||
lines = [
|
||||
"---",
|
||||
@@ -140,8 +186,10 @@ def render_note(meta, kind, platform, primary, guides, art, folder):
|
||||
f"# {name}",
|
||||
"",
|
||||
]
|
||||
# Box art — art is copied into the library folder root, so <slug>/<name>
|
||||
# resolves on the file-server.
|
||||
for a in art:
|
||||
lines.append(f"![[{a.name}]]")
|
||||
lines.append(f"})")
|
||||
if art:
|
||||
lines.append("")
|
||||
desc = (meta.get("description") or "").strip()
|
||||
@@ -152,12 +200,19 @@ def render_note(meta, kind, platform, primary, guides, art, folder):
|
||||
lines += ["## Download links", ""]
|
||||
lines += [f"- {l}" for l in links]
|
||||
lines.append("")
|
||||
guide_rels = [str(g.relative_to(folder)) for g in guides]
|
||||
picked = pick_guide_rels(guide_rels, primary.name if primary else None)
|
||||
if picked:
|
||||
lines += ["## Guides & extras", ""]
|
||||
for rel in picked[:MAX_GUIDES]:
|
||||
lines.append(f"- [{rel}]({file_url(slug_name, rel)})")
|
||||
if len(picked) > MAX_GUIDES:
|
||||
lines.append(f"- …and {len(picked) - MAX_GUIDES} more "
|
||||
f"(browse the [library folder]({file_url(slug_name, '')}))")
|
||||
lines.append("")
|
||||
lines += ["## Files", "",
|
||||
f"- **Artifact:** `{primary.name if primary else 'n/a'}` ({kind}/{platform})"]
|
||||
if guides:
|
||||
lines.append(f"- **Guides / extras ({len(guides)}):**")
|
||||
for g in sorted(guides):
|
||||
lines.append(f" - `{g.relative_to(folder)}`")
|
||||
f"- **Artifact:** `{primary.name if primary else 'n/a'}` ({kind}/{platform})",
|
||||
f"- **Library:** [browse files]({file_url(slug_name, '')})"]
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -197,7 +252,7 @@ def process(folder, handled):
|
||||
|
||||
kind, platform, primary = classify(folder)
|
||||
guides = collect_guides(folder, primary)
|
||||
note = render_note(meta, kind, platform, primary, guides, art, folder)
|
||||
note = render_note(meta, kind, platform, primary, guides, art, folder, slug_name)
|
||||
|
||||
print(f"[handoff] {slug_name}: kind={kind} platform={platform} "
|
||||
f"artifact={primary.name if primary else 'NONE'} guides={len(guides)} art={len(art)}")
|
||||
|
||||
@@ -1,10 +1,74 @@
|
||||
import json, re, pathlib, datetime
|
||||
import json, os, re, pathlib, datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
rows = json.load(open("/tmp/catalog_new.json", encoding="utf-8"))
|
||||
META = pathlib.Path("/storage1/labdata/romhacks/metadata")
|
||||
LIBRARY = pathlib.Path("/storage1/labdata/romhacks/library")
|
||||
OUT = pathlib.Path("/tmp/catalog_notes"); OUT.mkdir(exist_ok=True)
|
||||
TODAY = "2026-06-08"
|
||||
|
||||
# Internal-only Caddy file_server over /storage1/labdata/romhacks/library
|
||||
# (romhacks-files.ginnoir.com → /srv/romhacks, import internal_only). Lets the
|
||||
# notes embed box art and link guides that Obsidian can't load off /storage1.
|
||||
FILE_BASE = "https://romhacks-files.ginnoir.com"
|
||||
|
||||
# Genuine guide/spreadsheet documents worth linking. Bare game-data text files
|
||||
# (PBS/, Graphics/, Plugins/ trees of an RPG-Maker fangame) are NOT guides.
|
||||
DOC_EXTS = {".html", ".htm", ".pdf", ".xlsx", ".xls", ".ods", ".csv",
|
||||
".doc", ".docx", ".rtf"}
|
||||
ARCHIVE_EXTS = {".zip", ".rar", ".7z"}
|
||||
GAME_DATA_DIRS = {"pbs", "graphics", "plugins", "audio", "data", "fonts",
|
||||
"scripts", "optional", "se", "bgm", "tilesets", "sound",
|
||||
"movies", "backup"}
|
||||
MAX_GUIDES = 30
|
||||
|
||||
|
||||
def file_url(slug, rel):
|
||||
"""URL into the served library, encoding each path segment (spaces, etc.)."""
|
||||
parts = [quote(slug)] + [quote(seg) for seg in rel.split("/")]
|
||||
return FILE_BASE + "/" + "/".join(parts)
|
||||
|
||||
|
||||
def load_handoff(slug):
|
||||
hj = LIBRARY / slug / "handoff.json"
|
||||
if hj.exists():
|
||||
try:
|
||||
return json.loads(hj.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def pick_guides(guides, artifact):
|
||||
"""Filter the raw handoff guide list to linkable docs/spreadsheets/extras.
|
||||
|
||||
Returns rel paths, docs first, capped at MAX_GUIDES (caller notes overflow).
|
||||
"""
|
||||
art_l = (artifact or "").lower()
|
||||
scored = []
|
||||
for rel in guides:
|
||||
low = rel.lower()
|
||||
parts = low.split("/")
|
||||
bn = parts[-1]
|
||||
if bn in ("note.md", "handoff.json"):
|
||||
continue
|
||||
ext = os.path.splitext(bn)[1]
|
||||
in_gamedata = any(seg in GAME_DATA_DIRS for seg in parts[:-1])
|
||||
if ext in DOC_EXTS:
|
||||
if in_gamedata:
|
||||
continue # buried html/csv inside a game tree = noise
|
||||
scored.append((0, rel))
|
||||
elif ext in (".txt", ".md"):
|
||||
if in_gamedata or len(parts) > 2:
|
||||
continue # only top-level readme/changelog/credits
|
||||
scored.append((1, rel))
|
||||
elif ext in ARCHIVE_EXTS:
|
||||
if rel == artifact or bn == art_l:
|
||||
continue # that's the rom/game itself, linked elsewhere
|
||||
scored.append((2, rel))
|
||||
scored.sort(key=lambda t: (t[0], t[1].lower()))
|
||||
return [rel for _, rel in scored]
|
||||
|
||||
BASES = ["HeartGold","SoulSilver","FireRed","LeafGreen","Omega Ruby","Alpha Sapphire",
|
||||
"Black 2","White 2","Emerald","Crystal","Platinum","Diamond","Pearl",
|
||||
"Ruby","Sapphire","Gold","Silver","Black","White","Red","Blue","Yellow",
|
||||
@@ -85,6 +149,17 @@ def render(r):
|
||||
"patch":"Patch file — apply to a base ROM.","unknown":"Imported."}.get(r["kind"],"Imported.")
|
||||
fm.append(f"> [!info] Auto-imported from the Discord catalog ({r['generation']}) on {TODAY}. {kindline}")
|
||||
fm.append("")
|
||||
# Box art — embed from the internal Caddy file-server. Fall back to art
|
||||
# filenames in the metadata folder if the handoff record is missing them.
|
||||
ho = load_handoff(r["slug"])
|
||||
art = ho.get("art") or []
|
||||
if not art and (META / r["slug"]).exists():
|
||||
art = sorted(p.name for p in (META / r["slug"]).glob("*")
|
||||
if p.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp", ".gif"))
|
||||
for a in art:
|
||||
fm.append(f"})")
|
||||
if art:
|
||||
fm.append("")
|
||||
cd = clean_desc(desc)
|
||||
if cd:
|
||||
fm += ["## Summary", "", cd, ""]
|
||||
@@ -95,10 +170,20 @@ def render(r):
|
||||
except Exception: pass
|
||||
if links:
|
||||
fm += ["## Download", ""] + [f"- {l}" for l in links] + [""]
|
||||
# Guides / spreadsheets / extras — link each to the served file.
|
||||
guides = pick_guides(ho.get("guides", []), r["artifact"])
|
||||
if guides:
|
||||
fm += ["## Guides & extras", ""]
|
||||
for rel in guides[:MAX_GUIDES]:
|
||||
fm.append(f"- [{rel}]({file_url(r['slug'], rel)})")
|
||||
if len(guides) > MAX_GUIDES:
|
||||
fm.append(f"- …and {len(guides) - MAX_GUIDES} more "
|
||||
f"(browse the [library folder]({file_url(r['slug'], '')}))")
|
||||
fm.append("")
|
||||
fm += ["## Files", "",
|
||||
f"- **Artifact:** `{r['artifact'] or 'n/a'}`",
|
||||
f"- **Library:** `/storage1/labdata/romhacks/library/{r['slug']}/`"
|
||||
+ (f" — {r['n_guides']} guide/extra file(s)" if r['n_guides'] else "")]
|
||||
f"- **Library:** [browse files]({file_url(r['slug'], '')}) — "
|
||||
f"`/storage1/labdata/romhacks/library/{r['slug']}/`"]
|
||||
fm.append("")
|
||||
return safe_filename(name), "\n".join(fm)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user