diff --git a/nonpacks/library/storage.py b/nonpacks/library/storage.py index e37c120..8b9475e 100644 --- a/nonpacks/library/storage.py +++ b/nonpacks/library/storage.py @@ -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: diff --git a/nonpacks/library/tests.py b/nonpacks/library/tests.py index 295f95f..cd252f0 100644 --- a/nonpacks/library/tests.py +++ b/nonpacks/library/tests.py @@ -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}') diff --git a/nonpacks/library/urls.py b/nonpacks/library/urls.py index 0e220d5..6aaefe9 100644 --- a/nonpacks/library/urls.py +++ b/nonpacks/library/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('packs/create/', views.project_create, name='project_create'), path('packs//', views.project_detail, name='project_detail'), path('packs//edit/', views.project_edit, name='project_edit'), + path('packs//edit/regenerate/', views.regenerate_metadata, name='regenerate_metadata'), path('packs//delete/', views.project_delete, name='project_delete'), path('packs//versions/upload/', views.version_upload, name='version_upload'), path('packs//versions//download/', views.version_download, name='version_download'), diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index 7d9d7c7..5c6e9bc 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -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( diff --git a/nonpacks/library/zips.py b/nonpacks/library/zips.py index 576c1f7..03e3d23 100644 --- a/nonpacks/library/zips.py +++ b/nonpacks/library/zips.py @@ -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. diff --git a/nonpacks/templates/library/project_edit.html b/nonpacks/templates/library/project_edit.html index 828889b..8045772 100644 --- a/nonpacks/templates/library/project_edit.html +++ b/nonpacks/templates/library/project_edit.html @@ -66,6 +66,38 @@ + +{% if project.category == 'non_pack' %} +
+

Animation Director ID

+

The animationframework.id that applications use to check for updates.

+
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+

+ Current ID: + {% if anim_edit.current %} + {{ anim_edit.current }} + {% else %} + none + {% endif %} + ยท Full ID: {{ anim_edit.namespace }}:{{ anim_edit.pack_id }} +

+
+ +
+
+
+{% 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; - function toggle() { group.style.display = cat.value === 'guide' ? 'none' : ''; } - cat.addEventListener('change', toggle); - toggle(); + 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); + } })(); {% endblock %} diff --git a/nonpacks/templates/library/version_upload.html b/nonpacks/templates/library/version_upload.html index 0842cdf..90082d0 100644 --- a/nonpacks/templates/library/version_upload.html +++ b/nonpacks/templates/library/version_upload.html @@ -38,6 +38,33 @@ + {% if anim_meta.show_fields %} +
+ +

This pack has no animationframework.id. One will be added to the downloadable pack so applications can check for updates.

+
+
+ + +
+
+ + +
+
+

Full ID: {{ anim_meta.namespace }}:{{ anim_meta.pack_id }}

+
+ {% endif %} + + {% if anim_meta.show_confirm %} +
+ +
+ {% endif %} +
{{ version_form.changelog.errors }} @@ -55,4 +82,17 @@ {{ pending_uploads|json_script:'packs-pending-uploads' }} +{% if anim_meta.show_fields %} + +{% endif %} {% endblock %}