Phase 2.9 complete
This commit is contained in:
@@ -92,6 +92,24 @@ def move_file_index(file_index, new_stored_path, kind=None):
|
||||
return file_index
|
||||
|
||||
|
||||
def refresh_file_index(file_index):
|
||||
"""Recompute size + md5 from the stored file. Needed after an in-place
|
||||
rewrite (e.g. injection of animationframework metadata) so downloads keep
|
||||
a correct Content-Length."""
|
||||
if not file_index.stored_path or not default_storage.exists(file_index.stored_path):
|
||||
return file_index
|
||||
with default_storage.open(file_index.stored_path, 'rb') as src:
|
||||
md5 = hashlib.md5()
|
||||
size = 0
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
md5.update(chunk)
|
||||
size += len(chunk)
|
||||
file_index.size = size
|
||||
file_index.md5 = md5.hexdigest()
|
||||
file_index.save(update_fields=['size', 'md5'])
|
||||
return file_index
|
||||
|
||||
|
||||
def delete_file_index(file_index):
|
||||
"""Remove an indexed file from disk and DB (safe no-op when None)."""
|
||||
if file_index is None:
|
||||
|
||||
+195
-5
@@ -1277,7 +1277,7 @@ class UGCAnimationTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
self.assertIn('missionary', names)
|
||||
self.assertIn('bugged', names)
|
||||
|
||||
def test_version_upload_warns_without_animation_id(self):
|
||||
def test_animation_pack_without_id_gets_injected(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
@@ -1285,11 +1285,9 @@ class UGCAnimationTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']),
|
||||
fetch_redirect_response=False)
|
||||
version = self.project.versions.first()
|
||||
self.assertEqual(version.animation_id, '')
|
||||
# The missing id is auto-assigned instead of warning.
|
||||
self.assertEqual(version.animation_id, f'alice:project_{self.project.pk}')
|
||||
self.assertTrue(version.animation_manifest)
|
||||
# The warning banner renders on the redirect target (before it's consumed).
|
||||
resp = self.client.get(reverse('library:project_detail', args=['anim-pack']))
|
||||
self.assertContains(resp, 'incompatible with the update-check API')
|
||||
|
||||
def test_latest_api_returns_version_and_url(self):
|
||||
self.project = Project.objects.create(
|
||||
@@ -1574,3 +1572,195 @@ class UGCThumbnailTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
self.gate()
|
||||
resp = self.client.get(reverse('library:project_detail', args=['media']))
|
||||
self.assertContains(resp, '/thumb/')
|
||||
|
||||
|
||||
def _stored_mcmeta(version):
|
||||
from django.core.files.storage import default_storage
|
||||
release = version.files.filter(kind='release').first()
|
||||
with default_storage.open(release.file.stored_path, 'rb') as f:
|
||||
with zipfile.ZipFile(f) as zf:
|
||||
return json.loads(zf.read('pack.mcmeta').decode('utf-8'))
|
||||
|
||||
|
||||
class UGCAnimationIdTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.alice = User.objects.create_user(username='Alice', password='pw')
|
||||
UserProfile.objects.get_or_create(user=self.alice)
|
||||
|
||||
def _write_zip(self, data):
|
||||
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f:
|
||||
f.write(data)
|
||||
return f.name
|
||||
|
||||
def _upload_version(self, slug, name, content):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
resp = self.client.post(
|
||||
reverse('library:api_upload_temp'),
|
||||
{'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type='application/zip')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return self.client.post(
|
||||
reverse('library:version_upload', args=[slug]),
|
||||
{'version_name': '1.0.0'},
|
||||
)
|
||||
|
||||
def test_inject_merge_and_create(self):
|
||||
from library.zips import inject_animationframework
|
||||
path = self._write_zip(_logical_zip())
|
||||
try:
|
||||
ok = inject_animationframework(path, {'id': 'alice:project_1', 'name': 'X',
|
||||
'author': 'Alice', 'version': '1.0', 'description': 'y'})
|
||||
self.assertTrue(ok)
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
mc = json.loads(zf.read('pack.mcmeta').decode('utf-8'))
|
||||
self.assertEqual(mc['animationframework']['id'], 'alice:project_1')
|
||||
self.assertEqual(mc['pack']['pack_format'], 61) # existing block preserved
|
||||
|
||||
path2 = self._write_zip(_zip_bytes({'data/x.txt': 'x'}))
|
||||
inject_animationframework(path2, {'id': 'a:b'})
|
||||
with zipfile.ZipFile(path2) as zf:
|
||||
mc2 = json.loads(zf.read('pack.mcmeta').decode('utf-8'))
|
||||
self.assertEqual(mc2['pack']['pack_format'], 64)
|
||||
self.assertEqual(mc2['pack']['supported_formats'], [64, 81])
|
||||
self.assertEqual(mc2['pack']['min_format'], 64)
|
||||
self.assertEqual(mc2['pack']['max_format'], 81)
|
||||
self.assertEqual(mc2['animationframework']['id'], 'a:b')
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_version_upload_injects_defaults(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
resp = self._upload_version('np', 'pack.zip', _logical_zip())
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['np']))
|
||||
version = self.project.versions.first()
|
||||
expected = f'alice:project_{self.project.pk}'
|
||||
self.assertEqual(version.animation_id, expected)
|
||||
mc = _stored_mcmeta(version)
|
||||
self.assertEqual(mc['animationframework']['id'], expected)
|
||||
self.assertEqual(mc['animationframework']['name'], 'NoN Pack')
|
||||
# latest API works for the injected id.
|
||||
self.gate()
|
||||
resp = self.client.get(reverse('library:api_packs_latest', args=['alice', f'project_{self.project.pk}']))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()['version'], '1.0.0')
|
||||
# Download is not truncated: FileIndex size refreshed after injection.
|
||||
release = version.files.first()
|
||||
resp = self.client.get(reverse('library:file_request', args=[release.file.uuid]))
|
||||
served = b''.join(resp.streaming_content)
|
||||
self.assertEqual(int(resp['Content-Length']), release.file.size)
|
||||
self.assertEqual(len(served), release.file.size)
|
||||
with zipfile.ZipFile(io.BytesIO(served)) as zf:
|
||||
self.assertIsNone(zf.testzip())
|
||||
|
||||
def test_version_upload_establishes_from_pack(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
self._upload_version('np', 'pack.zip', _animation_pack_zip())
|
||||
version = self.project.versions.first()
|
||||
self.assertEqual(version.animation_id, 'jakebreath:testpack')
|
||||
|
||||
def test_version_upload_reuses_project_id(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
self._upload_version('np', 'pack.zip', _animation_pack_zip()) # establishes jakebreath:testpack
|
||||
self._upload_version('np', 'pack2.zip', _logical_zip()) # no id → reuse
|
||||
versions = list(self.project.versions.order_by('created_at'))
|
||||
self.assertEqual(versions[-1].animation_id, 'jakebreath:testpack')
|
||||
|
||||
def test_different_id_requires_confirm(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
self._upload_version('np', 'pack.zip', _animation_pack_zip()) # jakebreath:testpack
|
||||
other = _zip_bytes({
|
||||
'pack.mcmeta': json.dumps({'pack': {'pack_format': 64},
|
||||
'animationframework': {'id': 'other:pack'}}),
|
||||
'data/other/afw_animdefs/x.json': '{}',
|
||||
})
|
||||
# Without confirmation → form error, no new version.
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
self.client.post(reverse('library:api_upload_temp'),
|
||||
{'kind': 'version', 'file': SimpleUploadedFile('other.zip', other, content_type='application/zip')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
resp = self.client.post(reverse('library:version_upload', args=['np']), {'version_name': '2.0.0'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(self.project.versions.count(), 1)
|
||||
# With confirmation → accepted.
|
||||
resp = self.client.post(reverse('library:version_upload', args=['np']),
|
||||
{'version_name': '2.0.0', 'confirm_replace_identity': '1'})
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['np']))
|
||||
self.assertEqual(self.project.versions.count(), 2)
|
||||
self.assertEqual(self.project.versions.first().animation_id, 'other:pack')
|
||||
|
||||
def test_non_non_pack_not_injected(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='mod', title='A Mod', category='mod', owner=self.alice,
|
||||
)
|
||||
self._upload_version('mod', 'pack.zip', _logical_zip())
|
||||
version = self.project.versions.first()
|
||||
self.assertEqual(version.animation_id, '')
|
||||
self.assertNotIn('animationframework', _stored_mcmeta(version))
|
||||
|
||||
def test_regenerate_metadata(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
self._upload_version('np', 'pack.zip', _logical_zip())
|
||||
version = self.project.versions.first()
|
||||
self.assertTrue(version.animation_id) # auto-injected at upload
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
resp = self.client.post(reverse('library:regenerate_metadata', args=['np']),
|
||||
{'af_namespace': 'foo', 'af_pack_id': 'bar'})
|
||||
self.assertRedirects(resp, reverse('library:project_edit', args=['np']))
|
||||
version.refresh_from_db()
|
||||
self.assertEqual(version.animation_id, 'foo:bar')
|
||||
mc = _stored_mcmeta(version)
|
||||
self.assertEqual(mc['animationframework']['id'], 'foo:bar')
|
||||
resp = self.client.get(reverse('library:api_packs_latest', args=['foo', 'bar']))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()['version'], '1.0.0')
|
||||
# Regenerated download is intact (FileIndex size refreshed).
|
||||
release = version.files.first()
|
||||
resp = self.client.get(reverse('library:file_request', args=[release.file.uuid]))
|
||||
served = b''.join(resp.streaming_content)
|
||||
self.assertEqual(int(resp['Content-Length']), release.file.size)
|
||||
self.assertEqual(len(served), release.file.size)
|
||||
with zipfile.ZipFile(io.BytesIO(served)) as zf:
|
||||
self.assertIsNone(zf.testzip())
|
||||
|
||||
def test_regenerate_validates_id(self):
|
||||
self.project = Project.objects.create(
|
||||
slug='np', title='NoN Pack', category='non_pack', owner=self.alice,
|
||||
)
|
||||
self._upload_version('np', 'pack.zip', _logical_zip())
|
||||
before = self.project.versions.first().animation_id
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
resp = self.client.post(reverse('library:regenerate_metadata', args=['np']),
|
||||
{'af_namespace': 'bad id!', 'af_pack_id': 'bar'})
|
||||
self.assertRedirects(resp, reverse('library:project_edit', args=['np']))
|
||||
self.project.versions.first().refresh_from_db()
|
||||
self.assertEqual(self.project.versions.first().animation_id, before)
|
||||
|
||||
def test_create_injects_defaults(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
self.client.post(reverse('library:api_upload_temp'),
|
||||
{'kind': 'version', 'file': SimpleUploadedFile('pack.zip', _logical_zip(), content_type='application/zip')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
resp = self.client.post(reverse('library:project_create'), {
|
||||
'title': 'New Pack', 'category': 'non_pack', 'version_name': '1.0.0',
|
||||
})
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['new-pack']))
|
||||
project = Project.objects.get(slug='new-pack')
|
||||
version = project.versions.first()
|
||||
self.assertEqual(version.animation_id, f'alice:project_{project.pk}')
|
||||
|
||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
||||
path('packs/create/', views.project_create, name='project_create'),
|
||||
path('packs/<slug:slug>/', views.project_detail, name='project_detail'),
|
||||
path('packs/<slug:slug>/edit/', views.project_edit, name='project_edit'),
|
||||
path('packs/<slug:slug>/edit/regenerate/', views.regenerate_metadata, name='regenerate_metadata'),
|
||||
path('packs/<slug:slug>/delete/', views.project_delete, name='project_delete'),
|
||||
path('packs/<slug:slug>/versions/upload/', views.version_upload, name='version_upload'),
|
||||
path('packs/<slug:slug>/versions/<int:version_id>/download/', views.version_download, name='version_download'),
|
||||
|
||||
+188
-1
@@ -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,6 +922,12 @@ def version_upload(request, slug):
|
||||
if not version_files:
|
||||
form.add_error('version_name', 'Upload at least one version file before uploading.')
|
||||
else:
|
||||
errors, _injected = _inject_animation_metadata(
|
||||
request, project, form.cleaned_data['version_name'], version_files,
|
||||
)
|
||||
for error in errors:
|
||||
form.add_error(None, error)
|
||||
if not errors:
|
||||
_, missing_animation_id = _finalize_version(
|
||||
project,
|
||||
form.cleaned_data['version_name'],
|
||||
@@ -805,6 +945,7 @@ def version_upload(request, slug):
|
||||
'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(
|
||||
|
||||
@@ -6,6 +6,7 @@ adopted file (under MEDIA_ROOT) and return plain dicts/values.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
|
||||
@@ -25,6 +26,57 @@ def _open_zip(path):
|
||||
return None
|
||||
|
||||
|
||||
DEFAULT_PACK_BLOCK = {
|
||||
'pack_format': 64,
|
||||
'supported_formats': [64, 81],
|
||||
'min_format': 64,
|
||||
'max_format': 81,
|
||||
}
|
||||
|
||||
|
||||
def inject_animationframework(path, fields, create_pack_block=None):
|
||||
"""Rewrite the zip at ``path`` in place so pack.mcmeta carries the
|
||||
animationframework block (id/name/author/version/description).
|
||||
|
||||
When the archive already has pack.mcmeta, only the animationframework key
|
||||
is added/updated and every other field is preserved. When it has none, a
|
||||
whole pack.mcmeta is created (pack block from ``create_pack_block``, which
|
||||
defaults to the current NoN pack format). Returns True on success.
|
||||
"""
|
||||
tmp = f'{path}.nfnorm'
|
||||
try:
|
||||
with zipfile.ZipFile(path, 'r') as zin:
|
||||
names = set(zin.namelist())
|
||||
if 'pack.mcmeta' in names:
|
||||
try:
|
||||
mcmeta = _decode(zin.read('pack.mcmeta'))
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
mcmeta = {}
|
||||
if not isinstance(mcmeta.get('pack'), dict):
|
||||
mcmeta['pack'] = dict(create_pack_block or DEFAULT_PACK_BLOCK)
|
||||
else:
|
||||
block = dict(create_pack_block or DEFAULT_PACK_BLOCK)
|
||||
mcmeta = {'pack': block}
|
||||
|
||||
mcmeta['animationframework'] = fields
|
||||
|
||||
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename == 'pack.mcmeta':
|
||||
continue
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
zout.writestr('pack.mcmeta', json.dumps(mcmeta, indent=2))
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
try:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def read_pack_mcmeta(path):
|
||||
"""Extract the NoN-relevant data from a datapack's pack.mcmeta.
|
||||
|
||||
|
||||
@@ -66,6 +66,38 @@
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-check"></i> Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if project.category == 'non_pack' %}
|
||||
<div class="card" style="margin-top: 20px;">
|
||||
<h2><i class="fas fa-film"></i> Animation Director ID</h2>
|
||||
<p class="bio-help">The <code>animationframework.id</code> that applications use to check for updates.</p>
|
||||
<form method="post" action="{% url 'library:regenerate_metadata' project.slug %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit_af_namespace">Namespace</label>
|
||||
<input type="text" id="edit_af_namespace" name="af_namespace" value="{{ anim_edit.namespace }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit_af_pack_id">Pack ID</label>
|
||||
<input type="text" id="edit_af_pack_id" name="af_pack_id" value="{{ anim_edit.pack_id }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="bio-help">
|
||||
Current ID:
|
||||
{% if anim_edit.current %}
|
||||
<code>{{ anim_edit.current }}</code>
|
||||
{% else %}
|
||||
<em>none</em>
|
||||
{% endif %}
|
||||
· Full ID: <code id="edit-af-preview">{{ anim_edit.namespace }}:{{ anim_edit.pack_id }}</code>
|
||||
</p>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-secondary"><i class="fas fa-sync"></i> Regenerate metadata</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -78,10 +110,19 @@
|
||||
(function () {
|
||||
const cat = document.getElementById('id_category');
|
||||
const group = document.getElementById('description-group');
|
||||
if (!cat || !group) return;
|
||||
if (cat && group) {
|
||||
function toggle() { group.style.display = cat.value === 'guide' ? 'none' : ''; }
|
||||
cat.addEventListener('change', toggle);
|
||||
toggle();
|
||||
}
|
||||
const ns = document.getElementById('edit_af_namespace');
|
||||
const pid = document.getElementById('edit_af_pack_id');
|
||||
const preview = document.getElementById('edit-af-preview');
|
||||
if (ns && pid && preview) {
|
||||
function update() { preview.textContent = ns.value + ':' + pid.value; }
|
||||
ns.addEventListener('input', update);
|
||||
pid.addEventListener('input', update);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -38,6 +38,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if anim_meta.show_fields %}
|
||||
<div class="form-group">
|
||||
<label>Animation Director ID</label>
|
||||
<p class="anim-warning"><i class="fas fa-exclamation-triangle"></i> This pack has no <code>animationframework.id</code>. One will be added to the downloadable pack so applications can check for updates.</p>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="af_namespace">Namespace</label>
|
||||
<input type="text" id="af_namespace" name="af_namespace" value="{{ anim_meta.namespace }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="af_pack_id">Pack ID</label>
|
||||
<input type="text" id="af_pack_id" name="af_pack_id" value="{{ anim_meta.pack_id }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p class="bio-help">Full ID: <code id="af-preview">{{ anim_meta.namespace }}:{{ anim_meta.pack_id }}</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if anim_meta.show_confirm %}
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="confirm_replace_identity" value="1">
|
||||
Replace pack identity (the uploaded pack has a different <code>animationframework.id</code> than this project)
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
{{ version_form.changelog.errors }}
|
||||
<label for="{{ version_form.changelog.id_for_label }}">{{ version_form.changelog.label }}</label>
|
||||
@@ -55,4 +82,17 @@
|
||||
{{ pending_uploads|json_script:'packs-pending-uploads' }}
|
||||
<script src="{% static 'js/uploads.js' %}"></script>
|
||||
<script src="{% static 'js/version_upload.js' %}"></script>
|
||||
{% if anim_meta.show_fields %}
|
||||
<script>
|
||||
(function () {
|
||||
const ns = document.getElementById('af_namespace');
|
||||
const pid = document.getElementById('af_pack_id');
|
||||
const preview = document.getElementById('af-preview');
|
||||
if (!ns || !pid || !preview) return;
|
||||
function update() { preview.textContent = ns.value + ':' + pid.value; }
|
||||
ns.addEventListener('input', update);
|
||||
pid.addEventListener('input', update);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user