#!/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 = ['', '', 'Archives Sanctuary', '

Archives Sanctuary

', '

Collection ' + escape(identifier) + ' · références pour le site et le Galactium.

', '', '

La maquette contient des données fictives. Les images de recherche et les étapes intermédiaires ne décrivent pas toutes le jeu livré.

', '

Atlas de livraison complets

Documents et sources

Images et planches

') for group, entries in sorted(groups.items()): parts.append(f'
{escape(group)} · {len(entries)} fichiers
') for row in entries: url = link(row['path']) image = '' if row['path'].endswith('.pdf') else f'{escape(Path(row[' parts.append(f'
{image}
{escape(row["path"])}
{escape(row["status"])}
') parts.append('
') return '\n'.join(parts) + '\n\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 l’archive 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 n’est inclus.\n\n' 'Cette collection est privée et n’est 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()