Moved some files around and made cleanup better

This commit is contained in:
2026-09-14 19:01:08 -07:00
parent 50b234104a
commit 97ed7f05c8
866 changed files with 404 additions and 1612 deletions

View File

@@ -54,10 +54,13 @@ def resolve_folder_name(card_id):
def revision_candidates(name,revision):
candidates=[REPO/'in-progress/cards'/name/'revisions'/revision,
REPO/'artifacts/cards'/name,
REPO/'in-progress/cards'/name/'history'/f'accepted-{revision}']
REPO/'artifacts/cards'/name]
return [path for path in candidates if (path/'manifest.json').is_file() and read(path/'manifest.json')['revision']==revision]
def historical_revision_paths(name,revision):
base=REPO/'in-progress/cards'/name/'history'
return [base/'revisions'/revision,base/f'accepted-{revision}']
def remap_content(directory,source,destination):
path=directory/'card.json'
content=read(path)
@@ -87,6 +90,96 @@ def rebase_linked_inputs(directory,source,destination):
if changed:tree.write(path,encoding='unicode')
remap_content(directory,source,destination)
def internalize_linked_inputs(directory,source):
"""Copy live inputs out of a package that is about to become compact history."""
source=source.resolve();replacements={};changed=False
def is_source_file(path):
path=path.resolve()
return path.is_file() and path.is_relative_to(source)
for path in sorted(directory.rglob('*')):
if not path.is_symlink():continue
target=path.resolve()
if not is_source_file(target):continue
path.unlink();shutil.copy2(target,path);replacements[target]=path;changed=True
def local_copy(target):
nonlocal changed
target=target.resolve()
if target in replacements:return replacements[target]
preferred=directory/'source'/target.name
if preferred.exists() and preferred.is_file() and digest(preferred)==digest(target):
replacements[target]=preferred;return preferred
if preferred.exists():
source_revision=read(source/'manifest.json')['revision']
preferred=directory/'source'/'retained'/source_revision/target.relative_to(source)
preferred.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(target,preferred)
replacements[target]=preferred;changed=True;return preferred
content_path=directory/'card.json';content=read(content_path);content_changed=False
for key in ['art','previousNormal']:
value=content.get(key)
if not value:continue
target=(REPO/value).resolve()
if is_source_file(target):
content[key]=str(local_copy(target).relative_to(REPO));content_changed=True
if content_changed:write(content_path,content)
for path in sorted(directory.rglob('*.svg')):
tree=ET.parse(path);svg_changed=False
for e in tree.getroot().iter():
for attr in ['href','{http://www.w3.org/1999/xlink}href']:
value=e.get(attr)
if not value or value.startswith(('data:','#')):continue
target=(path.parent/value).resolve()
if is_source_file(target):
e.set(attr,os.path.relpath(local_copy(target),path.parent));svg_changed=True
if svg_changed:
ET.register_namespace('','http://www.w3.org/2000/svg');ET.register_namespace('xlink','http://www.w3.org/1999/xlink')
tree.write(path,encoding='unicode');changed=True
return changed
def compact_revision(directory,reason):
"""Retain text provenance and one low reference face per printing."""
manifest_path=directory/'manifest.json'
if not manifest_path.is_file():raise ValueError(f'Cannot compact a revision without a manifest: {directory}')
manifest=read(manifest_path);full_manifest=directory/'full-manifest.json'
if full_manifest.exists():raise ValueError(f'Revision is already compact: {directory}')
references=sorted((directory/'low').glob('*/card.png'))
if not references:
art=directory/'source/art-master.png'
if art.is_file():
preview=directory/'review/art-reference.png';preview.parent.mkdir(parents=True,exist_ok=True)
with Image.open(art) as image:
image=image.convert('RGBA');image.thumbnail(RESOLUTIONS['low'],Image.Resampling.LANCZOS)
image.save(preview,optimize=True)
references=[preview]
manifest_path.replace(full_manifest)
for resolution in ['med','high']:
path=directory/resolution
if path.exists():shutil.rmtree(path)
low=directory/'low'
if low.exists():
for path in sorted(low.rglob('*'),reverse=True):
if path.is_file() or path.is_symlink():
if not (path.name=='card.png' and path.parent.name in PRINTINGS):path.unlink()
elif path.is_dir() and not any(path.iterdir()):path.rmdir()
visual_suffixes={'.png','.jpg','.jpeg','.webp','.tif','.tiff','.gif','.bmp','.svg','.psd','.xcf'}
for area in ['source','review']:
root=directory/area
if not root.exists():continue
for path in sorted(root.rglob('*'),reverse=True):
if (path.is_file() or path.is_symlink()) and path.suffix.lower() in visual_suffixes and path not in references:path.unlink()
elif path.is_dir() and not any(path.iterdir()):path.rmdir()
retained_references=[str(path.relative_to(directory)) for path in references if path.is_file()]
record={'schemaVersion':1,'kind':'compact-card-revision','cardId':manifest['cardId'],
'folderName':manifest.get('folderName'),'revision':manifest['revision'],'originalStage':manifest['stage'],
'approval':manifest.get('approval'),'reason':reason,'originalManifest':'full-manifest.json',
'originalManifestSHA256':digest(full_manifest),'referenceResolution':'low','referenceImages':retained_references,
'retention':'Text provenance plus one 500 x 700 card face per available printing; full current acceptance lives in artifacts/cards.'}
write(directory/'record.json',record);return record
def add_history_entry(profile,revision,path,status):
base=REPO/'in-progress/cards'/profile['folderName'];relative=str(path.relative_to(base))
if not any(entry.get('revision')==revision and entry.get('path')==relative for entry in profile.setdefault('history',[])):
profile['history'].append({'revision':revision,'path':relative,'status':status})
def resolve_revision(card_id,revision):
safe_card_id(card_id);safe_name(revision);name=resolve_folder_name(card_id)
found=revision_candidates(name,revision)
@@ -101,7 +194,8 @@ def scaffold(card_id,title,rarity,revision):
existing=any(read(path)['cardId']==card_id for root in roots for path in profile_paths(root))
name=resolve_folder_name(card_id) if existing else f'{card_id}-{title_slug(title)}'
base=roots[0]/name;target=base/'revisions'/revision
if target.exists() or revision_candidates(name,revision):raise ValueError('Revision already exists; choose a new revision.')
if target.exists() or revision_candidates(name,revision) or any(path.exists() for path in historical_revision_paths(name,revision)):
raise ValueError('Revision already exists; choose a new revision.')
for folder in ['source','review']:(target/folder).mkdir(parents=True,exist_ok=True)
for resolution in RESOLUTIONS:
for printing in PRINTINGS:(target/resolution/printing).mkdir(parents=True)
@@ -209,33 +303,71 @@ def relocate_revision(source,destination,aliases):
def approve(card_id,revision,note):
if not note.strip():raise ValueError('Record the user’s explicit approval wording')
name=resolve_folder_name(card_id)
profile_path=REPO/'in-progress/cards'/name/'card.json'
name=resolve_folder_name(card_id);base=REPO/'in-progress/cards'/name
profile_path=base/'card.json'
if read(profile_path)['selectedRevision']!=revision:raise ValueError('Select this exact revision in the card profile before approving it')
source=resolve_revision(card_id,revision);manifest=validate(source)
if manifest['stage']!='assembly-review':raise ValueError('Only an assembly-review revision can be approved')
aliases=manifest.get('compatibilityAliases',[])
for alias in aliases:
if not (REPO/alias['alias']).is_symlink():raise ValueError('Compatibility alias no longer exists')
target=REPO/'artifacts/cards'/name
siblings=sorted(path for path in (base/'revisions').glob('*') if path.is_dir() and path!=source)
sibling_archives=[base/'history/revisions'/path.name for path in siblings]
if any(path.exists() for path in sibling_archives):raise ValueError('Superseded revision history destination already exists')
target=REPO/'artifacts/cards'/name;previous=None;archive=None
if target.exists():
previous=validate(target)
if previous['stage']!='approved':raise ValueError('Existing artifact is not an approved card')
archive=REPO/'in-progress/cards'/name/'history'/f"accepted-{previous['revision']}"
archive=base/'history'/f"accepted-{previous['revision']}"
if archive.exists():raise ValueError('Previous acceptance archive already exists')
# Pin incoming references to the old accepted artwork before replacing its location.
rebase_linked_inputs(source,target,archive)
changed=False
for dependency in siblings+([target] if target.exists() else []):
changed=internalize_linked_inputs(source,dependency) or changed
if changed:
manifest=refresh(source);validate(source)
if target.exists():
relocate_revision(target,archive,previous.get('compatibilityAliases',[]))
refresh(archive,allow_approved=True)
refresh(archive,allow_approved=True);compact_revision(archive,'replaced by a newer accepted revision')
target=relocate_revision(source,target,aliases)
manifest['stage']='approved';manifest['approval']={'by':'user','note':note};write(target/'manifest.json',manifest)
base=REPO/'in-progress/cards'/name;profile=read(base/'card.json');profile.update({'selectedRevision':revision,'stage':'approved','approval':manifest['approval']});write(base/'card.json',profile)
profile=read(profile_path);profile.update({'selectedRevision':revision,'stage':'approved','approval':manifest['approval']})
if archive is not None:add_history_entry(profile,previous['revision'],archive,'superseded-acceptance-record')
for sibling,destination in zip(siblings,sibling_archives):
destination.parent.mkdir(parents=True,exist_ok=True);shutil.move(str(sibling),str(destination))
compact_revision(destination,f'superseded when {revision} was accepted')
add_history_entry(profile,read(destination/'record.json')['revision'],destination,'superseded-revision-record')
write(profile_path,profile)
content=read(target/'card.json')
for key in ['schemaVersion','selectedRevision','stage','approval','rarity']:
if key in profile:content[key]=profile[key]
write(target/'card.json',content)
refresh(target,allow_approved=True);update_index();return target
def compact_card_history(card_id):
"""Apply compact retention to history for a card that is already approved."""
name=resolve_folder_name(card_id);base=REPO/'in-progress/cards'/name;profile_path=base/'card.json'
profile=read(profile_path);target=REPO/'artifacts/cards'/name
manifest=validate(target)
if manifest['stage']!='approved' or profile.get('stage')!='approved':raise ValueError('History cleanup requires an approved card')
accepted=sorted(path for path in (base/'history').glob('accepted-*') if (path/'manifest.json').is_file())
working=sorted(path for path in (base/'revisions').glob('*') if (path/'manifest.json').is_file())
destinations=[base/'history/revisions'/path.name for path in working]
if any(path.exists() for path in destinations):raise ValueError('Superseded revision history destination already exists')
changed=False
for dependency in accepted+working:changed=internalize_linked_inputs(target,dependency) or changed
if changed:refresh(target,allow_approved=True)
validate(target)
records=[]
for archive in accepted:
record=compact_revision(archive,'replaced by the current accepted revision');records.append(record)
add_history_entry(profile,record['revision'],archive,'superseded-acceptance-record')
for source,destination in zip(working,destinations):
destination.parent.mkdir(parents=True,exist_ok=True);shutil.move(str(source),str(destination))
record=compact_revision(destination,f"superseded when {manifest['revision']} was accepted");records.append(record)
add_history_entry(profile,record['revision'],destination,'superseded-revision-record')
write(profile_path,profile);update_index()
return {'cardId':card_id,'acceptedRevision':manifest['revision'],'compacted':[record['revision'] for record in records]}
def update_index():
profiles={}
# Working selection takes precedence when an older approved release also exists.
@@ -264,12 +396,14 @@ if __name__=='__main__':
p=sub.add_parser(command);p.add_argument('--card',required=True);p.add_argument('--revision',required=True)
if command=='new':p.add_argument('--title',required=True);p.add_argument('--rarity',choices=['Common','Uncommon','Rare','Extraordinary','Legendary'],required=True)
if command=='approve':p.add_argument('--approval-note',required=True)
compact_parser=sub.add_parser('compact');compact_parser.add_argument('--card',required=True)
sub.add_parser('status');args=parser.parse_args()
try:
if args.command=='status':
for card in update_index():print(f'{card["cardId"]} · {card["title"]}: {card["stage"]} · {card["selectedRevision"]} · {card["path"]}')
elif args.command=='new':print(scaffold(args.card,args.title,args.rarity,args.revision))
elif args.command=='approve':print(approve(args.card,args.revision,args.approval_note))
elif args.command=='compact':print(json.dumps(compact_card_history(args.card),indent=2))
elif args.command=='refresh':print(refresh(resolve_revision(args.card,args.revision))['stage']);update_index()
else:print(validate(resolve_revision(args.card,args.revision))['stage'])
except (ValueError,FileNotFoundError,KeyError) as error:parser.exit(1,f'{error}\n')