diff --git a/.gitignore b/.gitignore index 98b3e2a..3ec8cfc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ Packs_DB nonpacks/staticfiles/ nonpacks/media/ *.log +AGENTS/ diff --git a/nonpacks/library/migrations/0006_version_mods_manifest_version_pack_description_and_more.py b/nonpacks/library/migrations/0006_version_mods_manifest_version_pack_description_and_more.py new file mode 100644 index 0000000..b43c943 --- /dev/null +++ b/nonpacks/library/migrations/0006_version_mods_manifest_version_pack_description_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.3 on 2026-08-04 04:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0005_remove_version_file_versionfile'), + ] + + operations = [ + migrations.AddField( + model_name='version', + name='mods_manifest', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='version', + name='pack_description', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='version', + name='pack_format', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py index cf87d3a..c70fe78 100644 --- a/nonpacks/library/models.py +++ b/nonpacks/library/models.py @@ -206,6 +206,9 @@ class Version(models.Model): version_name = models.CharField(max_length=64) changelog = models.TextField(blank=True, default='') downloads = models.PositiveIntegerField(default=0) + pack_format = models.IntegerField(null=True, blank=True) + pack_description = models.TextField(blank=True, default='') + mods_manifest = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/nonpacks/library/tests.py b/nonpacks/library/tests.py index 068f986..06ebc19 100644 --- a/nonpacks/library/tests.py +++ b/nonpacks/library/tests.py @@ -1,7 +1,10 @@ import io +import json +import os import secrets import shutil import tempfile +import zipfile from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile @@ -964,3 +967,178 @@ class UGCApiTests(UGCMediaTestCase, UGCGatedTestCase): resp = self.client.get(reverse('library:api_users_autocomplete'), {'q': 'Ali'}) self.assertEqual(resp.status_code, 200) self.assertJSONEqual(resp.content, [{'username': 'Alice'}]) + + +def _zip_bytes(entries): + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + for name, content in entries.items(): + zf.writestr(name, content) + return buf.getvalue() + + +def _pack_zip(): + return _zip_bytes({ + 'pack.mcmeta': json.dumps({ + 'pack': {'pack_format': 64, 'description': 'A test pack'}, + 'animationframework': {'id': 'jakebreath:test'}, + }), + }) + + +class UGCZipParsingTests(TestCase): + def _write_zip(self, data, suffix='.zip'): + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f: + f.write(data) + return f.name + + def test_read_pack_mcmeta(self): + from library.zips import read_pack_mcmeta + path = self._write_zip(_pack_zip()) + try: + fmt, desc = read_pack_mcmeta(path) + finally: + os.unlink(path) + self.assertEqual(fmt, 64) + self.assertEqual(desc, 'A test pack') + + def test_read_pack_mcmeta_missing(self): + from library.zips import read_pack_mcmeta + path = self._write_zip(_zip_bytes({'assets/x.txt': 'x'})) + try: + fmt, desc = read_pack_mcmeta(path) + finally: + os.unlink(path) + self.assertIsNone(fmt) + self.assertEqual(desc, '') + + def test_parse_mods_folder(self): + from library.zips import parse_mods_manifest + path = self._write_zip(_zip_bytes({ + 'minecraft/mods/a.jar': 'x', 'minecraft/mods/b.jar': 'y', + 'pack.mcmeta': '{}', + })) + try: + manifest = parse_mods_manifest(path, 'pack.zip') + finally: + os.unlink(path) + self.assertEqual(manifest['source'], 'folder') + self.assertEqual([m['name'] for m in manifest['files']], ['a.jar', 'b.jar']) + + def test_parse_mods_mrpack(self): + from library.zips import parse_mods_manifest + path = self._write_zip(_zip_bytes({ + 'modrinth.index.json': json.dumps({ + 'dependencies': [ + {'project_id': 'abc', 'file_name': 'mod-a.jar', 'dependency_type': 'required'}, + ], + }), + }), suffix='.mrpack') + try: + manifest = parse_mods_manifest(path, 'pack.mrpack') + finally: + os.unlink(path) + self.assertEqual(manifest['source'], 'modrinth') + self.assertEqual(manifest['files'][0]['name'], 'mod-a.jar') + + def test_parse_mods_curseforge(self): + from library.zips import parse_mods_manifest + path = self._write_zip(_zip_bytes({ + 'manifest.json': json.dumps({ + 'files': [{'projectID': 123, 'fileID': 456, 'fileName': 'cool-mod.jar'}], + }), + })) + try: + manifest = parse_mods_manifest(path, 'pack.zip') + finally: + os.unlink(path) + self.assertEqual(manifest['source'], 'curseforge') + self.assertEqual(manifest['files'][0]['name'], 'cool-mod.jar') + + +class UGCCategoryTests(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 _upload_version(self, name, content, content_type='application/zip'): + 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=content_type)}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + self.assertEqual(resp.status_code, 200) + + def _upload_version_form(self, version_name='1.0.0'): + resp = self.client.post( + reverse('library:version_upload', args=[self.project.slug]), + {'version_name': version_name}, + ) + self.assertRedirects(resp, reverse('library:project_detail', args=[self.project.slug])) + + def test_non_pack_captures_pack_mcmeta(self): + self.project = Project.objects.create( + slug='non-pack', title='NoN Pack', category='non_pack', owner=self.alice, + ) + self._upload_version('pack.zip', _pack_zip()) + self._upload_version_form() + version = self.project.versions.first() + self.assertEqual(version.pack_format, 64) + self.assertEqual(version.pack_description, 'A test pack') + + def test_modpack_captures_mods_manifest(self): + self.project = Project.objects.create( + slug='modpack', title='Modpack', category='modpack', owner=self.alice, + ) + self._upload_version('pack.zip', _zip_bytes({'minecraft/mods/a.jar': 'x'})) + self._upload_version_form() + version = self.project.versions.first() + self.assertEqual(version.mods_manifest['source'], 'folder') + self.assertEqual(version.mods_manifest['files'][0]['name'], 'a.jar') + + def test_guide_uploads_md_and_zip_and_renders(self): + self.project = Project.objects.create( + slug='guide', title='Guide', category='guide', owner=self.alice, + ) + self._upload_version('README.md', b'# Title\n\nBody text.', content_type='text/markdown') + self._upload_version('assets.zip', _zip_bytes({'data/x.json': '{}'})) + self._upload_version_form() + version = self.project.versions.first() + self.assertEqual( + set(version.files.values_list('kind', flat=True)), {'markdown', 'release'} + ) + md_vf = version.files.get(kind='markdown') + # guide_doc endpoint renders the markdown server-side. + self.gate() + resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, md_vf.file.uuid])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, '

Title

', html=True) + # Detail page exposes the guide switcher. + resp = self.client.get(reverse('library:project_detail', args=['guide'])) + self.assertContains(resp, 'guide-switcher') + self.assertContains(resp, 'README.md') + + def test_guide_doc_rejects_non_markdown(self): + self.project = Project.objects.create( + slug='guide', title='Guide', category='guide', owner=self.alice, + ) + self._upload_version('pack.zip', _zip_bytes({'data/x.json': '{}'})) + self._upload_version_form() + version = self.project.versions.first() + vf = version.files.get(kind='release') + self.gate() + resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid])) + self.assertEqual(resp.status_code, 404) + + def test_regular_category_has_no_extra_metadata(self): + self.project = Project.objects.create( + slug='mod', title='Mod', category='mod', owner=self.alice, + ) + self._upload_version('pack.zip', _pack_zip()) + self._upload_version_form() + version = self.project.versions.first() + self.assertIsNone(version.pack_format) + self.assertEqual(version.mods_manifest, {}) diff --git a/nonpacks/library/urls.py b/nonpacks/library/urls.py index 0705fba..d28e5af 100644 --- a/nonpacks/library/urls.py +++ b/nonpacks/library/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('packs//versions/upload/', views.version_upload, name='version_upload'), path('packs//versions//download/', views.version_download, name='version_download'), path('packs//versions//files//download/', views.version_file_download, name='version_file_download'), + path('packs//guide///', views.guide_doc, name='guide_doc'), path('packs//gallery/upload/', views.asset_upload, name='asset_upload'), path('packs//gallery//delete/', views.asset_delete, name='asset_delete'), path('packs//contributors/', views.contributors, name='contributors'), diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index d9c8170..42fa4e2 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -13,6 +13,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.text import slugify +from common.markdown import render_markdown from .forms import ContributorForm, ProjectForm, VersionForm from .models import ( FileIndex, @@ -29,6 +30,7 @@ from .models import ( slugify_tag, ) from .storage import delete_file_index, move_file_index, store_temp_file +from .zips import parse_mods_manifest, read_pack_mcmeta def _open_indexed_file(file_index): @@ -312,16 +314,43 @@ def project_detail(request, slug): assets = project.assets.select_related('file').all() tags = project.tag_links.select_related('tag__category').all() can_edit = project.can_edit(request.user) + + guide_docs = [] + if project.category == 'guide': + latest = versions.first() + if latest is not None: + guide_docs = [vf for vf in latest.files.all() if vf.is_markdown] + context = { 'project': project, 'versions': versions, 'assets': assets, 'tags': tags, 'can_edit': can_edit, + 'guide_docs': guide_docs, } return render(request, 'library/project_detail.html', context) +def guide_doc(request, slug, version_id, file_uuid): + """Render one markdown guide document server-side (used by the switcher).""" + version = get_object_or_404( + Version.objects.select_related('project'), + pk=version_id, project__slug=slug, + ) + vf = get_object_or_404( + version.files.select_related('file'), file__uuid=file_uuid, + ) + if not vf.is_markdown: + raise Http404 + fh = _open_indexed_file(vf.file) + try: + text = fh.read().decode('utf-8', 'replace') + finally: + fh.close() + return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8') + + DRAFT_KEYS = ( 'title', 'summary', 'category', 'description', 'tags', 'version_name', 'changelog', @@ -376,13 +405,23 @@ def _classify_version_file(file_index, category): return 'release' +def _stored_path(file_index): + """Resolve an indexed file's stored_path to an absolute filesystem path.""" + return Path(settings.MEDIA_ROOT) / file_index.stored_path + + def _finalize_version(project, version_name, changelog, temp_uploads): - """Adopt pending 'version' temp uploads into a new Version as VersionFiles.""" + """Adopt pending 'version' temp uploads into a new Version as VersionFiles, + capturing category-specific metadata (pack.mcmeta / mods manifest).""" version = Version.objects.create( project=project, version_name=version_name, changelog=changelog, ) + pack_format = None + pack_description = '' + mods_manifest = {} + for temp in temp_uploads: index = _adopt_temp(temp, project.pk, 'versions', 'version') VersionFile.objects.create( @@ -392,6 +431,28 @@ def _finalize_version(project, version_name, changelog, temp_uploads): ) temp.status = 'used' temp.save(update_fields=['status']) + + is_zip = (index.original_filename or '').lower().endswith('.zip') + if project.category == 'non_pack' and is_zip and pack_format is None: + fmt, desc = read_pack_mcmeta(_stored_path(index)) + if fmt is not None: + pack_format = fmt + pack_description = desc + elif project.category == 'modpack' and is_zip and not mods_manifest: + manifest = parse_mods_manifest(_stored_path(index), index.original_filename) + if manifest.get('files'): + mods_manifest = manifest + + update_fields = [] + if pack_format is not None: + version.pack_format = pack_format + version.pack_description = pack_description + update_fields += ['pack_format', 'pack_description'] + if mods_manifest: + version.mods_manifest = mods_manifest + update_fields.append('mods_manifest') + if update_fields: + version.save(update_fields=update_fields) return version diff --git a/nonpacks/library/zips.py b/nonpacks/library/zips.py new file mode 100644 index 0000000..e9ae3d8 --- /dev/null +++ b/nonpacks/library/zips.py @@ -0,0 +1,100 @@ +"""Zip introspection helpers for category-specific version metadata. + +Parsed once at upload time and stored on the Version row, so the detail page +never re-reads large archives. All functions take the on-disk path of an +adopted file (under MEDIA_ROOT) and return plain dicts/values. +""" + +import json +import zipfile + + +def _open_zip(path): + """Return a ZipFile for the path (None when it's not a zip).""" + try: + return zipfile.ZipFile(path) + except (zipfile.BadZipFile, OSError): + return None + + +def read_pack_mcmeta(path): + """Extract the NoN-relevant data from a datapack's pack.mcmeta. + + Returns (pack_format, description) or (None, ''). + """ + zf = _open_zip(path) + if zf is None: + return None, '' + try: + try: + data = json.loads(zf.read('pack.mcmeta').decode('utf-8', 'replace')) + except (KeyError, json.JSONDecodeError): + return None, '' + pack = data.get('pack') or {} + try: + pack_format = int(pack.get('pack_format')) + except (TypeError, ValueError): + pack_format = None + description = pack.get('description', '') or '' + if isinstance(description, (dict, list)): + description = json.dumps(description) + return pack_format, str(description) + finally: + zf.close() + + +def parse_mods_manifest(path, filename=''): + """Inspect a modpack archive and build a manifest for the Mods tab. + + - Modrinth .mrpack: modrinth.index.json → dependencies + - Curseforge modpack: manifest.json → files array + - Classic folder layout: minecraft/mods/*.jar + Returns a dict: {source, files: [...]}. + """ + zf = _open_zip(path) + if zf is None: + return {'source': 'unknown', 'files': []} + + try: + names = set(zf.namelist()) + + if filename.lower().endswith('.mrpack') or 'modrinth.index.json' in names: + try: + index = json.loads(zf.read('modrinth.index.json').decode('utf-8', 'replace')) + except (KeyError, json.JSONDecodeError): + index = {} + deps = [] + for dep in index.get('dependencies') or []: + deps.append({ + 'name': dep.get('file_name') or dep.get('project_id', 'unknown'), + 'version_id': dep.get('version_id'), + 'type': dep.get('dependency_type', ''), + }) + return {'source': 'modrinth', 'files': deps} + + if 'manifest.json' in names: + try: + manifest = json.loads(zf.read('manifest.json').decode('utf-8', 'replace')) + except (KeyError, json.JSONDecodeError): + manifest = {} + files = [] + for entry in manifest.get('files') or []: + files.append({ + 'name': entry.get('fileName', entry.get('projectID', 'unknown')), + 'project_id': entry.get('projectID'), + 'file_id': entry.get('fileID'), + }) + if files: + return {'source': 'curseforge', 'files': files} + + mods = [] + prefix = 'minecraft/mods/' + for name in sorted(names): + if name.startswith(prefix) and not name.endswith('/'): + mods.append({'name': name[len(prefix):]}) + if mods: + return {'source': 'folder', 'files': mods} + + return {'source': 'unknown', 'files': []} + finally: + zf.close() diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index fb895bc..ead99f1 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -2773,3 +2773,89 @@ a.deletelink { .file-caption::placeholder { color: rgba(255, 255, 255, 0.7); } + +/* ========== Category-specific (Phase 2) ========== */ +.pack-format-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + font-size: 0.7rem; + font-weight: 600; +} +.pack-description { + margin: 6px 0 0; + font-size: 0.85rem; + color: var(--md-sys-color-on-surface-variant); +} + +/* Guide switcher */ +.guide-switcher { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 16px; + border-bottom: 1px solid var(--md-sys-color-outline); + padding-bottom: 10px; +} +.guide-switch-btn { + background: none; + border: 1px solid var(--md-sys-color-outline); + border-radius: 999px; + color: var(--md-sys-color-on-surface-variant); + padding: 5px 12px; + font-size: 0.8rem; + cursor: pointer; +} +.guide-switch-btn:hover { + color: var(--md-sys-color-primary); + border-color: var(--md-sys-color-primary); +} +.guide-switch-btn.active { + background: var(--md-sys-color-primary); + border-color: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); +} +.guide-content { + max-height: 70vh; + overflow-y: auto; +} + +/* Mods tab */ +.mods-version { + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--md-sys-color-outline-variant); +} +.mods-version h3 { + margin: 0 0 8px; + font-size: 0.95rem; +} +.mods-source-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: var(--md-sys-color-surface-variant); + color: var(--md-sys-color-on-surface-variant); + font-size: 0.7rem; +} +.mods-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 6px; +} +.mods-list li { + font-size: 0.8rem; + padding: 4px 8px; + border-radius: 6px; + background: var(--md-sys-color-surface-variant); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/nonpacks/templates/library/project_create.html b/nonpacks/templates/library/project_create.html index 06231a3..a91b099 100644 --- a/nonpacks/templates/library/project_create.html +++ b/nonpacks/templates/library/project_create.html @@ -27,11 +27,11 @@ {{ project_form.category }} -
+
{{ project_form.description.errors }} {{ project_form.description }} -

Supports Markdown — headings, lists, links, code, tables and more.

+

Supports Markdown — headings, lists, links, code, tables and more. Guides don't use this field.

@@ -116,4 +116,14 @@ + {% endblock %} diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index dff63b0..35f9617 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -60,14 +60,33 @@ {% endif %}
- + + {% if project.category == 'modpack' %}{% endif %}
-
+
- {% if project.description %} + {% if project.category == 'guide' %} +
+ {% if guide_docs %} +
+ {% for doc in guide_docs %} + + {% endfor %} +
+
+

Loading…

+
+ {% else %} +

No guide documents uploaded yet.

+ {% endif %} +
+ {% elif project.description %}
{{ project.description|markdown }}
{% else %}

No description yet.

@@ -84,10 +103,14 @@
{{ version.version_name }} {% if forloop.first %}latest{% endif %} + {% if version.pack_format %}pack format {{ version.pack_format }}{% endif %}
{{ version.created_at|date:"M j, Y" }} {{ version.downloads }}
+ {% if version.pack_description %} +

{{ version.pack_description }}

+ {% endif %} {% if version.changelog %}
{{ version.changelog|markdown }}
{% endif %} @@ -96,6 +119,8 @@
{% if vf.is_png %} {{ vf.filename }} + {% elif vf.is_markdown %} + {% else %} {% endif %} @@ -113,6 +138,28 @@
+{% if project.category == 'modpack' %} +
+
+

Mods

+ {% for version in versions %} + {% if version.mods_manifest.files %} +
+

{{ version.version_name }} {{ version.mods_manifest.source }}

+
    + {% for mod in version.mods_manifest.files %} +
  • {{ mod.name }}
  • + {% endfor %} +
+
+ {% endif %} + {% empty %} +

No mods detected in the uploaded packs.

+ {% endfor %} +
+
+{% endif %} + @@ -74,4 +74,14 @@ + {% endblock %}