Last active
July 7, 2026 01:44
-
-
Save ssokolow/0fa340fa0a6f6c53cb2dac346641ad95 to your computer and use it in GitHub Desktop.
A generator for listing the mods in a zipped up Stardew Valley/SMAPI Mods folder
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """Generate a mod listing from the manifest.json files in a zipped up SMAPI | |
| Mods folder. | |
| Requires: | |
| - jinja2 (`python3-jinja2` on Debian-family Linux distros) for the HHTML output | |
| - json5 (`python3-json5` on Debian-family Linux distros) for JSONC parsing | |
| - natsort (`python3-natsort` on Debian-family Linux distros) | |
| """ | |
| __author__ = "Stephan Sokolow (deitarion/SSokolow)" | |
| __appname__ = "Generate Modlists" | |
| __version__ = "0.1" | |
| __license__ = "MIT" | |
| import html | |
| import logging | |
| import os | |
| import re | |
| import time | |
| import zipfile | |
| from jinja2 import Environment, DictLoader | |
| import json5 as json # type: ignore | |
| import natsort | |
| from typing import Dict, List, Mapping, Union | |
| log = logging.getLogger(__name__) | |
| re_nexus_updatekey = re.compile(r'Nexus:(\d+)') | |
| BASE_TMPL = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <meta name="referrer" content="never"> | |
| <meta name="referrer" content="no-referrer"> | |
| <title>Mods in {{zipname}}</title> | |
| <style> | |
| body { | |
| font-family: sans-serif; | |
| margin: 1em | |
| } | |
| h1 small { font-size: 60%; } | |
| dl { margin-left: 3ex; } | |
| dt { | |
| font-weight: bold; | |
| margin-bottom: 0.5ex; | |
| } | |
| dd + dt { margin-top: 2ex; } | |
| dd { margin-bottom: 1ex; } | |
| dd.ver:has(+ dd.ver) { margin-bottom: 0.5ex; } | |
| dd.ver { | |
| font-weight: bold; | |
| font-size: 80%; | |
| } | |
| .note { | |
| margin: 1em; | |
| padding: 1em; | |
| border: 1px solid #aaaaff; | |
| background: #eeeeff; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1> Mods in <a href="{{zipname}}">{{zipname}}</a> | |
| <small>({{zipsize_pretty}}, Updated: {{zipdate}})</small></h1> | |
| <p class='note'><strong>NOTE:</strong> This list is generated from the metadata inside the mods and cannot fully account for cases like <a href="https://www.nexusmods.com/stardewvalley/mods/5787">East Scarp</a> and <a href="https://www.nexusmods.com/stardewvalley/mods/3753">Stardew Valley Expanded</a> where the mod is a single download but multiple mods internally.</p> | |
| {%- macro render_mod(mod) -%} | |
| <dt>{% if mod.url -%} | |
| <a href="{{mod.url}}">{{mod.name}}</a> | |
| {%- else -%} | |
| {{- mod.name -}} | |
| {%- endif %} v{{mod.version}} by {{mod.author}}</dt> | |
| {% if mod.minimum_sdv %}<dd class='ver'>Requires Stardew Valley version | |
| {{ mod.minimum_sdv }} or newer{% endif %} | |
| {% if mod.minimum_smapi %}<dd class='ver'>Requires SMAPI version | |
| {{ mod.minimum_smapi }} or newer{% endif %} | |
| <dd>{{mod.description}}</dd> | |
| {%- endmacro -%} | |
| <dl> | |
| {% for mod in mods %} | |
| {% if mod.submods %} | |
| <dt>{{mod.name}}</dt> | |
| <dd><dl> | |
| {% for submod in mod.submods %} | |
| {{ render_mod(submod) }} | |
| {% endfor %} | |
| </dl></dd> | |
| {% else %} | |
| {{ render_mod(mod) }} | |
| {% endif %} | |
| {% endfor %} | |
| </dl> | |
| </body> | |
| </html> | |
| """ | |
| def sizeof_fmt(num, suffix="B"): | |
| """Source: https://stackoverflow.com/a/1094933""" | |
| for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"): | |
| if abs(num) < 1024.0: | |
| return f"{num:3.1f}{unit}{suffix}" | |
| num /= 1024.0 | |
| return f"{num:.1f}Yi{suffix}" | |
| def render_html(mods: List[Mapping[str, Union[str, List[Dict[str, str]]]]], | |
| zip_path: str): | |
| """Render the prepared mod data into an HTML file""" | |
| env = Environment( | |
| loader=DictLoader({'index.html': BASE_TMPL}), | |
| autoescape=True, | |
| trim_blocks=True, | |
| lstrip_blocks=True, | |
| ) | |
| template = env.get_template('index.html') | |
| fbase, fext = os.path.splitext(zip_path) | |
| with open(fbase + '.info.html', 'w') as fobj: | |
| zipstat = os.stat(zip_path) | |
| fobj.write(template.render( | |
| zipname=html.escape(os.path.basename(zip_path)), | |
| zipdate=time.strftime('%Y-%m-%d', time.gmtime(zipstat.st_mtime)), | |
| zipsize_pretty=sizeof_fmt(zipstat.st_size), | |
| mods=mods | |
| )) | |
| def process_manifest(contents: bytes) -> Dict[str, str]: | |
| """Parse relevant fields from a SMAPI manifest.json | |
| Does not add the subpath key that ``group_mods`` requires. | |
| """ | |
| data = json.loads(contents) | |
| result = { | |
| 'name': data.get('Name', '(untitled)'), | |
| 'author': data.get('Author', '(unspecified)'), | |
| 'version': data.get('Version', 'v?.?.?'), | |
| 'description': data.get('Description', '(no description)'), | |
| 'minimum_smapi': data.get('MinimumApiVersion'), | |
| 'minimum_sdv': data.get('MinimumGameVersion'), | |
| 'url': None, | |
| } | |
| for key in data.get('UpdateKeys', []): | |
| match_obj = re_nexus_updatekey.match(key) | |
| if match_obj: | |
| result['url'] = ('https://www.nexusmods.com/stardewvalley/mods/' + | |
| match_obj.group(1)) | |
| return result | |
| def group_mods(mods: List[Dict[str, str]]) -> List[ | |
| Mapping[str, Union[str, List[Dict[str, str]]]]]: | |
| """Group mods into ``submods`` keys based on their paths""" | |
| def _inner(mods: List[Dict[str, str]]) -> Dict[str, List[Dict[str, str]]]: | |
| output: Dict[str, List[Dict[str, str]]] = {} | |
| for mod in mods: | |
| subpath = mod.get('subpath') | |
| if subpath: | |
| if '/' in subpath: | |
| head, tail = subpath.split('/', 1) | |
| else: | |
| head, tail = subpath, None | |
| output.setdefault(head, []).append(mod) | |
| if tail: | |
| mod['subpath'] = tail | |
| else: | |
| del mod['subpath'] | |
| while len(output) == 1: | |
| output = _inner(list(output.values())[0]) | |
| return output | |
| grouped = _inner(mods) | |
| result: List[Mapping[str, Union[str, List[Dict[str, str]]]]] = [] | |
| for key, value in grouped.items(): | |
| if len(value) == 1: | |
| result.append(value[0]) | |
| else: | |
| result.append({ | |
| 'name': key, | |
| 'submods': value | |
| }) | |
| return result | |
| def process_file(path: str) -> None: | |
| """Process a single zipped mod pack and write a matching .info.html | |
| Return without doing anything if ``path`` is not a zip. | |
| """ | |
| if not zipfile.is_zipfile(path): | |
| log.debug("Skipping file (not a Zip): %s", path) | |
| return | |
| mods = [] | |
| with zipfile.ZipFile(path, 'r') as zobj: | |
| for zpath in zobj.namelist(): | |
| if os.path.basename(zpath).lower() == 'manifest.json': | |
| try: | |
| mod = process_manifest(zobj.read(zpath)) | |
| mod['subpath'] = os.path.dirname(zpath) | |
| mods.append(mod) | |
| except Exception as err: | |
| log.error("Could not parse %r in %r: %s", zpath, path, err) | |
| mods.sort(key=natsort.natsort_keygen(key=lambda o: o.get('name'))) | |
| grouped_mods = group_mods(mods) | |
| grouped_mods.sort(key=natsort.natsort_keygen(key=lambda o: o.get('name'))) | |
| render_html(grouped_mods, path) | |
| def process_arg(path: str) -> None: | |
| """Recurse the given paths, dispatching to process_file""" | |
| if os.path.isdir(path): | |
| for parent, dirs, files in os.walk(path): | |
| dirs.sort() | |
| files.sort() | |
| for fname in files: | |
| process_file(os.path.join(parent, fname)) | |
| else: | |
| process_file(path) | |
| def main() -> None: | |
| """The main entry point, compatible with setuptools entry points.""" | |
| from argparse import ArgumentParser, RawDescriptionHelpFormatter | |
| parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter, | |
| description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0]) | |
| parser.add_argument('-V', '--version', action='version', | |
| version="%%(prog)s v%s" % __version__) | |
| parser.add_argument('-v', '--verbose', action="count", | |
| default=2, help="Increase the verbosity. Use twice for extra effect.") | |
| parser.add_argument('-q', '--quiet', action="count", | |
| default=0, help="Decrease the verbosity. Use twice for extra effect.") | |
| parser.add_argument('path', action="store", nargs="+", | |
| help="Path to operate on") | |
| # Reminder: %(default)s can be used in help strings. | |
| args = parser.parse_args() | |
| # Set up clean logging to stderr | |
| log_levels = [logging.CRITICAL, logging.ERROR, logging.WARNING, | |
| logging.INFO, logging.DEBUG] | |
| args.verbose = min(args.verbose - args.quiet, len(log_levels) - 1) | |
| args.verbose = max(args.verbose, 0) | |
| logging.basicConfig(level=log_levels[args.verbose], | |
| format='%(levelname)s: %(message)s') | |
| for path in args.path: | |
| process_arg(path) | |
| if __name__ == '__main__': # pragma: nocover | |
| main() | |
| # vim: set sw=4 sts=4 expandtab : |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
