diff --git a/nonpacks/library/migrations/0012_taglist_auto.py b/nonpacks/library/migrations/0012_taglist_auto.py new file mode 100644 index 0000000..7b8e5de --- /dev/null +++ b/nonpacks/library/migrations/0012_taglist_auto.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-08-05 15:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0011_version_mod_manifest'), + ] + + operations = [ + migrations.AddField( + model_name='taglist', + name='auto', + field=models.BooleanField(default=False), + ), + ] diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py index 8403ea7..9e5bb8b 100644 --- a/nonpacks/library/models.py +++ b/nonpacks/library/models.py @@ -189,6 +189,7 @@ class TagList(models.Model): added_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+', ) + auto = models.BooleanField(default=False) added_at = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index ba54a8d..d780698 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -39,11 +39,12 @@ from .zips import ( read_animation_manifest, read_logical_manifest, read_models_manifest, + read_mod_version_manifest, read_pack_meta, read_pack_mcmeta, read_plugins_manifest, read_skins_manifest, - read_mod_version_manifest, + _mc_from_filename, ) @@ -543,19 +544,120 @@ ANIMATION_API_WARNING = ( ) -def _apply_content_tags(project, tag_names, actor): - """Auto-create a content: for every distinct animation content tag.""" - content_cat = TagCategory.objects.filter(slug='content').first() - if content_cat is None: - return - for name in tag_names: +def _clean_mc_version(value): + """Extract the first X.Y from an MC version/range ('>=26.2 <26.3' -> '26.2').""" + m = re.search(r'(\d+\.\d+)', str(value or '')) + return m.group(1) if m else '' + + +def _vanilla_entities(): + """Set of real Minecraft entity names from the bundled vanilla index. + + Excludes renderer aliases (slime_size_*, player_slim) that are model + variants rather than Minecraft entities. + """ + aliases = {'slime_size_0', 'slime_size_1', 'slime_size_3', 'player_slim'} + try: + with open(settings.BASE_DIR / 'static' / 'vanilla' / 'entity_index.json') as f: + return set(json.load(f).keys()) - aliases + except (OSError, ValueError): + return set() + + +def _species_entity(entity): + """Map model-variant names to their real Minecraft entity.""" + return { + 'player_slim': 'player', + 'slime_size_0': 'slime', + 'slime_size_1': 'slime', + 'slime_size_3': 'slime', + }.get(entity, entity) + + +def _auto_tag_specs(project, version): + """Deduped list of (category_slug, tag_name) auto-tags for a version.""" + specs = [] + + def add(cat, name): name = slugify_tag(name) - if not name: + if name and (cat, name) not in specs: + specs.append((cat, name)) + + if project.category == 'mod': + for m in (version.mod_manifest or {}).get('mods', []): + if m.get('modloader'): + add('loader', m['modloader']) + mc = _clean_mc_version(m.get('minecraft')) + if not mc: + mc = _mc_from_filename(m.get('file_name') or '') + if mc: + add('version', mc) + elif project.category == 'modpack': + for loader in (version.mods_manifest or {}).get('modloaders', []): + add('loader', loader) + mc = (version.mods_manifest or {}).get('minecraft') + if mc: + add('version', mc) + elif project.category == 'model': + vanilla = _vanilla_entities() + for m in (version.models_manifest or {}).get('models', []): + ext = (m.get('ext') or '').lstrip('.') + if ext == 'geo.json': + ext = 'geo' + if ext: + add('format', ext) + entity = _species_entity(m.get('entity') or '') + if entity and entity in vanilla: + add('species', entity) + elif project.category == 'skin': + add('general', 'skin') + elif project.category == 'guide': + add('general', 'guide') + elif project.category == 'non_pack': + # NoN packs may carry custom/modded species — no vanilla whitelist. + # Only skip placeholder model names (pregnancy-egg stubs, etc.). + placeholders = {'default', 'minecraft_spider'} + for tag in (version.animation_manifest or {}).get('content_tags', []): + add('content', tag) + for m in (version.models_manifest or {}).get('models', []): + entity = _species_entity(m.get('entity') or '') + if entity and entity not in placeholders: + add('species', entity) + return specs + + +def _sync_auto_tags(project, actor=None, version=None): + """Apply auto tags (loader/version/format/species/general/content) from the + given version's manifests. Marks auto links, cleans stale ones, and never + touches manual links (arbitrary tags like creator:LiZo are preserved).""" + actor = actor or project.owner + version = version or project.versions.first() + if version is None: + return + + TagCategory.objects.get_or_create(slug='format', defaults={'name': 'Format', 'color': '#0e7490'}) + specs = _auto_tag_specs(project, version) + spec_set = set(specs) + + cats = {} + for cat_slug, name in specs: + cat = cats.get(cat_slug) or TagCategory.objects.filter(slug=cat_slug).first() + if cat is None: continue + cats[cat_slug] = cat tag, _ = Tag.objects.get_or_create( - name=name, category=content_cat, defaults={'created_by': actor}, + name=name, category=cat, defaults={'created_by': actor}, ) - TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor}) + link, created = TagList.objects.get_or_create( + project=project, tag=tag, defaults={'added_by': actor, 'auto': True}, + ) + if not created and not link.auto: + link.auto = True + link.save(update_fields=['auto']) + + for link in project.tag_links.select_related('tag__category').filter(auto=True).all(): + if (link.tag.category.slug, link.tag.name) not in spec_set: + link.delete() def _established_animation_id(project): @@ -689,6 +791,7 @@ def _reparse_version(version): version.pack_format = fmt version.pack_description = desc version.save() + _sync_auto_tags(version.project, version.project.owner, version.project.versions.first()) def _finalize_version(project, version_name, changelog, temp_uploads, actor): @@ -792,10 +895,7 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor): if update_fields: version.save(update_fields=update_fields) - if animation_manifest is not None: - _apply_content_tags( - project, animation_manifest.get('content_tags') or [], actor, - ) + _sync_auto_tags(project, actor, version) return version, missing_animation_id @@ -912,6 +1012,7 @@ def project_edit(request, slug): project.save() _apply_tags(project, data['tags'], request.user) project.sync_creator_tags(actor=request.user) + _sync_auto_tags(project, request.user) messages.success(request, 'Project updated.') return redirect('library:project_detail', slug=project.slug) else: