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
+18
View File
@@ -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
View File
@@ -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}')
+1
View File
@@ -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'),
+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(
+52
View File
@@ -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.
+45 -4
View File
@@ -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;
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);
}
})();
</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 %}