Files
sanctuary-beta/scripts/archive_web_assets.py
T
2026-09-15 09:29:11 +02:00

214 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Preserve local Sanctuary web references outside build, with a portable index."""
import argparse
from collections import Counter, defaultdict
import hashlib
import html
import json
from pathlib import Path
import re
import shutil
import subprocess
from urllib.parse import quote
import zipfile
ROOT = Path(__file__).resolve().parents[1]
ARCHIVES = ROOT / 'archives/web'
MEDIA = {'.png', '.jpg', '.jpeg', '.svg', '.webp', '.pdf'}
SKIP = {'node_modules', 'site-packages', '.git', '.gradle', 'source', 'sources', 'src'}
def sha(path):
digest = hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(block)
return digest.hexdigest()
def version_hint(path):
match = re.search(r'alpha\.(\d+(?:\.\d+)?)', str(path))
if match:
return 'alpha.' + match.group(1)
match = re.search(r'(?:alpha|atlas)(\d+)', str(path))
if match:
value = match.group(1)
return 'alpha.' + (value[:2] + '.' + value[2:] if len(value) == 3 and value.startswith('30') else value)
match = re.search(r'beta(\d+)', str(path))
return 'beta.' + match.group(1) if match else None
def collect():
selected = {}
def add(path, destination, category, status):
if path.is_symlink() or not path.is_file():
return
if destination in selected:
raise ValueError('Duplicate archive path: ' + destination)
selected[destination] = (path, category, status)
for folder in sorted((ROOT / 'build').iterdir()):
if not folder.is_dir() or 'isolated' in folder.name:
continue
historic = re.match(r'^(alpha\d+|atlas30[567])(?:\D|$)', folder.name)
current = folder.name in {'beta003-preview', 'beta004-preview'}
if not historic and not current:
continue
for path in sorted(folder.rglob('*')):
relative = path.relative_to(folder)
if set(relative.parts) & SKIP or any('venv' in part for part in relative.parts):
continue
is_atlas_data = folder.name in {'atlas305', 'atlas306', 'atlas307'} and path.suffix == '.json'
if path.suffix.lower() not in MEDIA and not is_atlas_data:
continue
destination = 'assets/' + path.relative_to(ROOT / 'build').as_posix()
category = 'beta-previews' if current else 'alpha-atlas' if folder.name.startswith('atlas') else 'alpha-research'
add(path, destination, category, 'development-output')
for path in sorted((ROOT / 'build').iterdir()):
if path.is_file() and path.suffix.lower() in MEDIA and re.match(r'^(Sanctuary-.*alpha\.|alpha\d+)', path.name):
add(path, 'assets/' + path.name, 'alpha-figures', 'release-named' if path.name.startswith('Sanctuary-') else 'development-output')
for path in sorted((ROOT / 'build').glob('Sanctuary-Atlas-0.1.0-alpha.30.[567].zip')):
with zipfile.ZipFile(path) as archive:
if archive.testzip() is not None:
raise ValueError('Invalid atlas: ' + path.name)
manifest = json.loads(archive.read('manifest.json'))
for name, expected in manifest['files_sha256'].items():
if hashlib.sha256(archive.read(name)).hexdigest() != expected:
raise ValueError('Atlas manifest mismatch: ' + name)
add(path, 'atlases/' + path.name, 'alpha-release-atlas', 'original-manifest-verified')
for path in sorted((ROOT / 'docs').rglob('*')):
if path.suffix.lower() in MEDIA | {'.md', '.json'}:
add(path, 'references/' + path.relative_to(ROOT).as_posix(), 'documentation', 'snapshot-at-archive-date')
for name in ['README.md', 'CHANGELOG.md', 'THIRD_PARTY_NOTICES.md', 'LICENSE']:
add(ROOT / name, 'references/' + name, 'documentation', 'snapshot-at-archive-date')
for name in ['terrain_atlas.py', 'structure_atlas.py', 'finishing_atlas.py', 'archive_terrain_atlas.py', 'terrain-atlas-requirements.txt', 'archive_web_assets.py']:
add(ROOT / 'scripts' / name, 'references/scripts/' + name, 'reproduction-script', 'snapshot-at-archive-date')
for path in sorted((ARCHIVES / 'blocodex-navigation-v1').iterdir()):
add(path, 'blocodex-navigation/' + path.name, 'blocodex-concept', 'approved-concept-fictional-data')
for path in sorted((ARCHIVES / 'references').glob('*.json')):
add(path, 'references/integration/' + path.name, 'integration-intake', 'metadata-only')
for path in sorted((ROOT / 'mods/sanctuary/src/main/resources/assets/sanctuary/textures/gui/progression').glob('*.png')):
add(path, 'assets/progression-icons/' + path.name, 'creator-icons', 'original-user-asset')
title = ROOT / 'ressources-pack/helloworld/assets/minecraft_title.png'
add(title, 'assets/title/minecraft_title.png', 'title-reference', 'project-reference')
return selected
def gallery(rows, identifier):
groups = defaultdict(list)
for row in rows:
if Path(row['path']).suffix.lower() in MEDIA:
groups[row['category'] + ' / ' + (row['version_hint'] or 'référence')].append(row)
escape = html.escape
link = lambda path: quote(path, safe='/')
parts = ['<!doctype html><html lang="fr"><meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<title>Archives Sanctuary</title><style>',
'body{font:16px/1.5 system-ui,sans-serif;max-width:1200px;margin:32px auto;padding:0 20px;background:#f3f0e9;color:#252c25}',
'a{color:#275c50}details{margin:16px 0;border-top:1px solid #b2b9ac;padding-top:12px}summary{cursor:pointer;font-weight:600}',
'.images{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));gap:16px}',
'figure{margin:12px 0;padding:10px;background:#fff}img{max-width:100%;height:180px;object-fit:contain;display:block;margin:auto}figcaption{overflow-wrap:anywhere;font-size:13px}',
'li{margin-bottom:6px}.links{display:flex;gap:18px;flex-wrap:wrap}</style>',
'<h1>Archives Sanctuary</h1>',
'<p>Collection ' + escape(identifier) + ' · références pour le site et le Galactium.</p>',
'<div class="links"><a href="blocodex-navigation/index.html">Ouvrir le Blocodex interactif</a>',
'<a href="manifest.json">Inventaire et empreintes</a><a href="README.md">Lire la notice</a></div>',
'<p>La maquette contient des données fictives. Les images de recherche et les étapes intermédiaires ne décrivent pas toutes le jeu livré.</p>',
'<h2>Atlas de livraison complets</h2><ul>']
for row in rows:
if row['category'] == 'alpha-release-atlas':
parts.append(f'<li><a href="{link(row["path"])}">{escape(Path(row["path"]).name)}</a> · original vérifié</li>')
parts.append('</ul><details><summary>Documents et sources</summary><ul>')
for row in rows:
if row['category'] in {'documentation', 'reproduction-script', 'integration-intake'} and Path(row['path']).suffix.lower() not in MEDIA:
parts.append(f'<li><a href="{link(row["path"])}">{escape(row["path"])}</a></li>')
parts.append('</ul></details><h2>Images et planches</h2>')
for group, entries in sorted(groups.items()):
parts.append(f'<details><summary>{escape(group)} · {len(entries)} fichiers</summary><div class="images">')
for row in entries:
url = link(row['path'])
image = '' if row['path'].endswith('.pdf') else f'<img loading="lazy" src="{url}" alt="{escape(Path(row["path"]).stem)}">'
parts.append(f'<figure><a href="{url}">{image}<figcaption>{escape(row["path"])}<br>{escape(row["status"])}</figcaption></a></figure>')
parts.append('</div></details>')
return '\n'.join(parts) + '\n</html>\n'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--id', required=True)
args = parser.parse_args()
if not re.fullmatch(r'\d{4}-\d{2}-\d{2}-web-v\d+', args.id):
parser.error('Expected YYYY-MM-DD-web-vN')
output = ARCHIVES / 'snapshots' / args.id
bundle = output.parent / ('Sanctuary-Web-Archive-' + args.id.replace('-web-', '-') + '.zip')
catalog = ARCHIVES / ('catalog-' + args.id + '.json')
if any(path.exists() for path in [output, bundle, catalog]):
raise SystemExit('Refusing to overwrite an existing immutable collection')
files = collect()
if sum(category == 'alpha-release-atlas' for _, category, _ in files.values()) != 3:
raise SystemExit('Expected all three alpha.30.5/30.6/30.7 atlas archives')
output.mkdir(parents=True)
rows = []
for destination, (source, category, status) in sorted(files.items()):
target = output / destination
target.parent.mkdir(parents=True, exist_ok=True)
expected = sha(source)
shutil.copy2(source, target)
if sha(target) != expected:
raise ValueError('Copy differs: ' + destination)
rows.append({'path': destination, 'source': source.relative_to(ROOT).as_posix(),
'category': category, 'status': status, 'version_hint': version_hint(destination),
'bytes': target.stat().st_size, 'sha256': expected})
(output / 'index.html').write_text(gallery(rows, args.id), encoding='utf-8')
(output / 'README.md').write_text(
'# Sanctuary — archive pour le site\n\nOuvrir `index.html` pour parcourir la collection.\n'
'La maquette autonome est dans `blocodex-navigation/index.html`.\n\n'
'Les images et PDF retrouvés dans les sorties alpha du dépôt jusqu’à alpha.30.7 sont copiés avec leurs chemins. '
'Les trois atlas de livraison sont conservés sans modification, données et documentation historique incluses. '
'Les aperçus beta.003/004, icônes et maquette sont séparés du fonds alpha.\n\n'
'Le manifeste décrit chaque fichier : source relative au dépôt Sanctuary, catégorie, statut, version déduite du nom, taille et SHA-256. '
'Une version déduite est une indication de classement, pas une validation de génération. '
'Les documents hors atlas sont une photographie au jour de larchive et contiennent des intentions futures.\n\n'
'Les liens des documents historiques peuvent pointer vers des builds non inclus ou des releases en ligne. '
'Le catalogue et la maquette fonctionnent hors ligne. Aucun JAR, MRpack, monde, sauvegarde, cache de dépendance ou journal personnel nest inclus.\n\n'
'Cette collection est privée et nest pas un déploiement du site. Elle conserve les crédits et licences existants ; '
'voir `references/THIRD_PARTY_NOTICES.md` et `references/LICENSE`.\n', encoding='utf-8')
for name in ['index.html', 'README.md']:
path = output / name
rows.append({'path': name, 'source': 'generated-by:scripts/archive_web_assets.py',
'category': 'archive-index', 'status': 'archive-navigation', 'version_hint': None,
'bytes': path.stat().st_size, 'sha256': sha(path)})
manifest = {'id': args.id, 'schema': 1, 'scope': 'local-alpha-media-through-30.7-plus-blocodex',
'source_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
'source_worktree_dirty': bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT, text=True).strip()),
'counts': dict(Counter(row['category'] for row in rows)), 'files': rows}
encoded = json.dumps(manifest, ensure_ascii=False, indent=2) + '\n'
(output / 'manifest.json').write_text(encoded, encoding='utf-8')
catalog.write_text(encoded, encoding='utf-8')
with zipfile.ZipFile(bundle, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for path in sorted(output.rglob('*')):
if not path.is_file():
continue
info = zipfile.ZipInfo(args.id + '/' + path.relative_to(output).as_posix(), date_time=(2000, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_STORED if path.suffix == '.zip' else zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
archive.writestr(info, path.read_bytes())
with zipfile.ZipFile(bundle) as archive:
assert archive.testzip() is None
for row in rows:
assert hashlib.sha256(archive.read(args.id + '/' + row['path'])).hexdigest() == row['sha256']
assert archive.read(args.id + '/manifest.json').decode() == encoded
receipt = {'id': args.id, 'zip': bundle.relative_to(ARCHIVES).as_posix(), 'zip_bytes': bundle.stat().st_size,
'zip_sha256': sha(bundle), 'manifest_sha256': sha(catalog), 'file_count': len(rows) + 1,
'counts': manifest['counts'], 'checks': ['copies_sha256', 'three_original_atlas_manifests', 'zip_crc', 'zip_members_sha256']}
(ARCHIVES / ('receipt-' + args.id + '.json')).write_text(json.dumps(receipt, indent=2) + '\n')
bundle.with_suffix('.zip.sha256').write_text(receipt['zip_sha256'] + ' ' + bundle.name + '\n')
print(json.dumps(receipt, indent=2))
if __name__ == '__main__':
main()