- warhammer_data.py: GW naming throughout - Space Marine chapters are
their own factions under a 'Space Marines' group, systems match the
shop (Age of Sigmar, The Horus Heresy, Other Games, The Old World,
Middle-Earth), classes are GW shop categories (Battleline, Infantry,
Vehicle, Walker, Flyer, Monster, Mounted, Hero, Titanic), group map
extended to AoS grand alliances / Other Games / Old World / Middle-Earth
- gwshop.py: hierarchy remap - GW depth-4 paths are system/group/faction/
class (the shop's last level is the class layer), depth-5 sources keep
a subfaction before it; catalog snapshot regenerated accordingly
- sorter.py: catalog entry is hierarchy-authoritative (group, class, and
faction when GW moved a unit or keyword tables are stale); generic GW
branches ('Unit Type') and a Battleline default never degrade a
confident keyword classification; whole-catalog retry when the faction
pool has no confident match (handles units GW moved between factions);
whole-catalog matching also runs when keyword classification found no
faction; invalid (HTML) items never fuzzy-match; default catalog is
now data/gwshop_catalog.json
- catalogue.py: --apply moves include the group layer, same structure
as the sorter
- docs/SORTING.md: hierarchy rewritten to GW naming with examples
- tests: expectations updated to GW naming (chapters, unit types)
- verified live: 4/4 real files in the test batch now catalog-match,
e.g. The Twin Lance -> 40k/Xenos Armies/T'au Empire/Epic Hero (GW's
current placement) instead of the stale Drukhari keyword
103 lines
3.6 KiB
Python
103 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Sorter CLI: fuzzysort collection files into the library (plan/apply).
|
|
|
|
# plan (default, touches nothing)
|
|
python3 sorter.py <dir> --library /path/to/library
|
|
|
|
# apply: hardlink files into the library (originals stay!)
|
|
python3 sorter.py <dir> --library /path/to/library --apply
|
|
|
|
# widen/narrow fuzzy matching
|
|
python3 sorter.py <dir> --library ... --threshold 0.5
|
|
|
|
# sort a single archive
|
|
python3 sorter.py "Thunderwolf Cavalry.7z" --library ...
|
|
|
|
Flags:
|
|
--catalog FILE default data/library_catalog.json
|
|
--audit FILE default <library>/sorter_applied.jsonl
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from dlphelper.sorter import (
|
|
format_plan,
|
|
load_catalog,
|
|
sort_plan,
|
|
apply_plan,
|
|
)
|
|
|
|
log = logging.getLogger("sorter")
|
|
|
|
DEFAULT_CATALOG = Path(__file__).resolve().parent / "data" / "gwshop_catalog.json"
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Sort files into the library structure (dry-run by default)."
|
|
)
|
|
parser.add_argument("source", type=Path,
|
|
help="a directory to sort or a single file")
|
|
parser.add_argument("--library", type=Path, required=True, metavar="DIR",
|
|
help="library root the files will be linked into")
|
|
parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG, metavar="FILE")
|
|
parser.add_argument("--threshold", type=float, default=0.62,
|
|
help="fuzzy match threshold (default 0.62)")
|
|
parser.add_argument("--apply", action="store_true",
|
|
help="create the targets (hardlink, fallback copy)")
|
|
parser.add_argument("--audit", type=Path, default=None, metavar="FILE")
|
|
parser.add_argument("--json", action="store_true",
|
|
help="print the plan as JSON instead of text")
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(level=logging.INFO,
|
|
format="%(asctime)s %(levelname)-7s %(message)s",
|
|
datefmt="%H:%M:%S")
|
|
|
|
source = args.source
|
|
files = [source] if source.is_file() else sorted(source.rglob("*"))
|
|
catalog = load_catalog(args.catalog)
|
|
audit = args.audit or (args.library / "sorter_applied.jsonl")
|
|
|
|
items = sort_plan(files, args.library, catalog, threshold=args.threshold)
|
|
|
|
if args.json:
|
|
print(json.dumps([
|
|
{
|
|
"source": str(i.path), "name": i.name, "size": i.size,
|
|
"system": i.system, "group": i.group, "faction": i.faction,
|
|
"subfaction": i.subfaction, "class": i.klass, "unit": i.unit,
|
|
"score": round(i.match_score, 3), "kind": i.match_kind,
|
|
"inner_count": len(i.inner_names),
|
|
"target": str(i.target),
|
|
}
|
|
for i in items
|
|
], indent=2, ensure_ascii=False))
|
|
else:
|
|
print(format_plan(items))
|
|
|
|
n_catalog = sum(1 for i in items if i.match_kind == "catalog")
|
|
n_implicit = sum(1 for i in items if i.match_kind == "implicit")
|
|
n_none = sum(1 for i in items if i.match_kind == "none")
|
|
log.info("Plan: %d files -> %d catalog matches [+] / %d implicit [~] / "
|
|
"%d unclassifiable [?]",
|
|
len(items), n_catalog, n_implicit, n_none)
|
|
|
|
if args.apply:
|
|
linked, copied, skipped = apply_plan(items, audit)
|
|
log.info("Applied: %d hardlinked, %d copied, %d skipped (audit: %s)",
|
|
linked, copied, skipped, audit)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|