Phase 2.9 complete

This commit is contained in:
2026-08-04 12:05:45 -05:00
parent b7b21ab5e0
commit a45b686f6c
7 changed files with 549 additions and 20 deletions
+198 -11
View File
@@ -1,6 +1,7 @@
import json
import mimetypes
import posixpath
import re
import zipfile
from pathlib import Path
@@ -31,8 +32,9 @@ from .models import (
VersionFile,
slugify_tag,
)
from .storage import delete_file_index, move_file_index, store_temp_file
from .storage import delete_file_index, move_file_index, refresh_file_index, store_temp_file
from .zips import (
inject_animationframework,
parse_mods_manifest,
read_animation_manifest,
read_logical_manifest,
@@ -523,6 +525,126 @@ def _apply_content_tags(project, tag_names, actor):
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})
def _established_animation_id(project):
"""The animationframework.id of the project's newest version (its identity)."""
latest = project.versions.first()
return latest.animation_id if latest is not None else ''
def _valid_id_component(value):
return bool(re.match(r'^[a-zA-Z0-9_.-]+$', value or ''))
def _animation_fields(project, version_name, animation_id):
return {
'id': animation_id,
'name': project.title,
'author': project.owner.username,
'version': version_name,
'description': project.summary or project.title,
}
def _inject_animation_metadata(request, project, version_name, temp_uploads):
"""For NoN-pack zips missing an animationframework.id, rewrite the temp zip
to add the block. Returns (errors, injected_count)."""
if project.category != 'non_pack':
return [], 0
established = _established_animation_id(project)
default_ns = slugify(project.owner.username) or 'site'
default_pid = f'project_{project.pk}'
ns = (request.POST.get('af_namespace') or default_ns).strip()
pid = (request.POST.get('af_pack_id') or default_pid).strip()
create_pack = {
'pack_format': 64,
'supported_formats': [64, 81],
'min_format': 64,
'max_format': 81,
'description': project.title,
}
errors = []
injected = 0
for temp in temp_uploads:
if not (temp.file.original_filename or '').lower().endswith('.zip'):
continue
path = _stored_path(temp.file)
meta = read_pack_meta(path)
current = meta.get('animation_id') if meta else None
if current:
if established and current != established and not request.POST.get('confirm_replace_identity'):
errors.append(
'The uploaded pack has a different animationframework.id than this project. '
'Check "replace pack identity" to accept it.'
)
continue
desired = established or f'{ns}:{pid}'
if not _valid_id_component(ns) or not _valid_id_component(pid):
errors.append('Animation ID must only use letters, numbers, underscores, dots or dashes.')
break
if inject_animationframework(
path, _animation_fields(project, version_name, desired), create_pack,
):
refresh_file_index(temp.file)
injected += 1
return errors, injected
def _animation_meta_for_upload(request, project):
"""Render hints for the version-upload page's Animation ID section."""
if project.category != 'non_pack':
return {'show': False}
established = _established_animation_id(project)
default_ns = slugify(project.owner.username) or 'site'
default_pid = f'project_{project.pk}'
show_fields = False
show_confirm = False
for temp in _pending_kind(request.user, 'version'):
if not (temp.file.original_filename or '').lower().endswith('.zip'):
continue
meta = read_pack_meta(_stored_path(temp.file))
current = meta.get('animation_id') if meta else None
if not current and not established:
show_fields = True
if current and established and current != established:
show_confirm = True
if established:
ns, _, pid = established.partition(':')
default_ns = ns or default_ns
default_pid = pid or default_pid
return {
'show': show_fields or show_confirm,
'show_fields': show_fields,
'show_confirm': show_confirm,
'established': established,
'namespace': default_ns,
'pack_id': default_pid,
}
def _reparse_version(version):
"""Re-derive a version's metadata (id + manifests) from its release zip."""
release = version.files.filter(kind='release').first()
if release is None:
return
path = _stored_path(release.file)
meta = read_pack_meta(path)
if meta and meta.get('animation_id'):
version.animation_id = meta['animation_id']
else:
version.animation_id = ''
version.animation_manifest = read_animation_manifest(path) or {}
version.models_manifest = read_models_manifest(path) or {}
version.logical_manifest = read_logical_manifest(path) or {}
fmt, desc = read_pack_mcmeta(path)
if fmt is not None:
version.pack_format = fmt
version.pack_description = desc
version.save()
def _finalize_version(project, version_name, changelog, temp_uploads, actor):
"""Adopt pending 'version' temp uploads into a new Version as VersionFiles,
capturing category/content-specific metadata (pack.mcmeta, mods, animation,
@@ -648,6 +770,9 @@ def project_create(request):
thumb.save(update_fields=['status'])
project.save(update_fields=['thumbnail'])
_inject_animation_metadata(
request, project, version_form.cleaned_data['version_name'], version_files,
)
_, missing_animation_id = _finalize_version(
project,
version_form.cleaned_data['version_name'],
@@ -746,11 +871,20 @@ def project_edit(request, slug):
else:
pending_uploads = []
established = _established_animation_id(project)
ns, _, pid = established.partition(':')
anim_edit = {
'current': established,
'namespace': ns or (slugify(project.owner.username) or 'site'),
'pack_id': pid or f'project_{project.pk}',
}
return render(request, 'library/project_edit.html', {
'project': project,
'project_form': form,
'pending_uploads': pending_uploads,
'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')),
'anim_edit': anim_edit,
})
@@ -788,23 +922,30 @@ def version_upload(request, slug):
if not version_files:
form.add_error('version_name', 'Upload at least one version file before uploading.')
else:
_, missing_animation_id = _finalize_version(
project,
form.cleaned_data['version_name'],
form.cleaned_data['changelog'],
version_files,
request.user,
errors, _injected = _inject_animation_metadata(
request, project, form.cleaned_data['version_name'], version_files,
)
if missing_animation_id:
messages.warning(request, ANIMATION_API_WARNING)
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
return redirect('library:project_detail', slug=project.slug)
for error in errors:
form.add_error(None, error)
if not errors:
_, missing_animation_id = _finalize_version(
project,
form.cleaned_data['version_name'],
form.cleaned_data['changelog'],
version_files,
request.user,
)
if missing_animation_id:
messages.warning(request, ANIMATION_API_WARNING)
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
return redirect('library:project_detail', slug=project.slug)
version_files = _pending_kind(request.user, 'version')
return render(request, 'library/version_upload.html', {
'project': project,
'version_form': form,
'pending_uploads': [_serialize_temp(u) for u in version_files],
'anim_meta': _animation_meta_for_upload(request, project),
})
@@ -815,6 +956,52 @@ def _bump_and_redirect(request, version, file_index):
)
@login_required
def regenerate_metadata(request, slug):
"""Rewrite the latest version's release zip so pack.mcmeta carries the
chosen animationframework.id, then re-parse the version's metadata."""
project = get_object_or_404(Project, slug=slug)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to edit this project.')
if request.method != 'POST':
return HttpResponse(status=405)
latest = project.versions.first()
if latest is None:
messages.error(request, 'No versions to regenerate.')
return redirect('library:project_edit', slug=slug)
release = latest.files.filter(kind='release').first()
if release is None:
messages.error(request, 'No release file to regenerate.')
return redirect('library:project_edit', slug=slug)
ns = request.POST.get('af_namespace', '').strip()
pid = request.POST.get('af_pack_id', '').strip()
if not _valid_id_component(ns) or not _valid_id_component(pid):
messages.error(request, 'Animation ID must only use letters, numbers, underscores, dots or dashes.')
return redirect('library:project_edit', slug=slug)
animation_id = f'{ns}:{pid}'
create_pack = {
'pack_format': 64,
'supported_formats': [64, 81],
'min_format': 64,
'max_format': 81,
'description': project.title,
}
path = _stored_path(release.file)
if not inject_animationframework(
path, _animation_fields(project, latest.version_name, animation_id), create_pack,
):
messages.error(request, 'Could not regenerate pack metadata.')
return redirect('library:project_edit', slug=slug)
refresh_file_index(release.file)
_reparse_version(latest)
messages.success(request, f'Pack metadata regenerated with id {animation_id}.')
return redirect('library:project_edit', slug=slug)
def version_download(request, slug, version_id):
"""Download the first release file of a version (kept for API/mods)."""
version = get_object_or_404(