214 lines
13 KiB
Python
214 lines
13 KiB
Python
#!/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 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()
|