Foundation DB should write to glossary
This commit is contained in:
parent
563d09db89
commit
0df4dbf3ed
7 changed files with 360 additions and 20 deletions
|
|
@ -12,7 +12,8 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
|
|||
are translated per-name; refs and verify names are skipped.
|
||||
Harvests short name terms into vocab.txt.
|
||||
Step 4 Database - discover content layout; translate foundation DB sheets
|
||||
(items, skills, descriptions) before narrative custom sheets
|
||||
(items, skills, descriptions) before narrative custom sheets;
|
||||
harvests short label fields (map names, titles) into vocab.txt
|
||||
Step 5 Maps/Events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling
|
||||
Step 6 Precheck - dry-run selected JSON for safety-guard skips; edit those
|
||||
lines before writing binaries
|
||||
|
|
@ -1784,7 +1785,8 @@ class WolfWorkflowTab(QWidget):
|
|||
|
||||
note = self._desc(
|
||||
"You don't need to add item / skill / enemy value names here: Phase 0 translates "
|
||||
"WolfDawn safe entries from names.json and harvests them into vocab.txt "
|
||||
"WolfDawn safe entries from names.json and harvests them into vocab.txt. "
|
||||
"Foundation DB also merges short labels (map names, titles) after Step 4. "
|
||||
"Focus this glossary on characters and worldbuilding terms."
|
||||
)
|
||||
layout.addWidget(note)
|
||||
|
|
@ -1883,7 +1885,8 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addWidget(self._subheading("Translate database"))
|
||||
layout.addWidget(self._desc(
|
||||
"Run after Step 3 (names) so vocab.txt stays consistent. Only checked sheets "
|
||||
"are sent to the model. Re-running skips lines already translated."
|
||||
"are sent to the model. Re-running skips lines already translated. Short "
|
||||
"foundation labels (map names, titles) are merged into vocab.txt."
|
||||
))
|
||||
self._add_tl_mode_selector(layout)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ names.json safety: WolfDawn tags every value name with a static ``safety`` badge
|
|||
(``safe``, ``refs``, or ``verify``). For ``kind == "names"`` documents, only
|
||||
entries whose badge is ``safe`` are translated; ``refs`` and ``verify`` names and
|
||||
legacy entries without a badge keep ``text == source``. After translating
|
||||
names.json, translatable entries are harvested into ``vocab.txt`` (grouped by
|
||||
``note``, with bilingual headers) so later phases keep item / skill / term names
|
||||
consistent.
|
||||
names.json, short name-like entries are harvested into ``vocab.txt`` (grouped by
|
||||
``note``, with bilingual headers). After foundation DB sheets are translated,
|
||||
short label fields (map names, titles, etc.) are merged into the same glossary
|
||||
so later phases keep place / term names consistent without overwriting names.json.
|
||||
|
||||
Database sheet filter (Step 4): optional ``wolfDbIncludeGroups`` or
|
||||
``wolfDbIncludeTiers`` in ``.env`` limit which ``kind: db`` lines are collected.
|
||||
|
|
@ -353,6 +354,10 @@ def parseDocument(data, filename):
|
|||
# so a resume still seeds vocab. Mirrors the RPGMaker DB-first strategy.
|
||||
if is_names and not ESTIMATE:
|
||||
_harvest_names_to_vocab(data)
|
||||
# Foundation DB label fields (map names, titles) merge into vocab.txt without
|
||||
# replacing entries already seeded from names.json.
|
||||
elif data.get("kind") == "db" and not ESTIMATE:
|
||||
_harvest_db_to_vocab(data)
|
||||
|
||||
return [data, totalTokens, None]
|
||||
|
||||
|
|
@ -389,6 +394,25 @@ def _harvest_names_to_vocab(data):
|
|||
traceback.print_exc()
|
||||
|
||||
|
||||
def _harvest_db_to_vocab(data):
|
||||
"""Merge short foundation DB labels into vocab.txt, grouped by sheet.
|
||||
|
||||
Uses merge mode so names.json-seeded entries for the same section stay put.
|
||||
Descriptions and narrative dialogue are filtered out.
|
||||
"""
|
||||
try:
|
||||
include_tiers, include_groups = wolf_db.load_db_filter_config()
|
||||
by_sheet = wolf_db.collect_db_vocab_pairs(
|
||||
data,
|
||||
include_tiers=include_tiers,
|
||||
include_groups=include_groups,
|
||||
)
|
||||
for type_name, pairs in by_sheet.items():
|
||||
wolf_vocab.update_vocab_section(type_name, pairs, merge=True)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def getResultString(translatedData, translationTime, filename):
|
||||
"""Format the per-file / total cost + status line for the console log."""
|
||||
cost = calculateCost(translatedData[1][0], translatedData[1][1], MODEL)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,20 @@ class TestUpdateVocabSection(unittest.TestCase):
|
|||
self.assertIn("ヒール (Cure)", text)
|
||||
self.assertNotIn("ヒール (Heal)", text)
|
||||
|
||||
def test_merge_keeps_existing_and_adds_new(self):
|
||||
vocab.update_vocab_section("Map Setting · マップ設定", [("礼拝堂", "Chapel")])
|
||||
vocab.update_vocab_section(
|
||||
"Map Setting · マップ設定",
|
||||
[("礼拝堂", "Chapel Hall"), ("大通り", "Main Street")],
|
||||
merge=True,
|
||||
)
|
||||
|
||||
text = self.vocab_path.read_text(encoding="utf-8")
|
||||
self.assertIn("礼拝堂 (Chapel)", text)
|
||||
self.assertNotIn("礼拝堂 (Chapel Hall)", text)
|
||||
self.assertIn("大通り (Main Street)", text)
|
||||
self.assertEqual(text.count("# Map Setting · マップ設定"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -238,5 +238,93 @@ class TestDbProfile(unittest.TestCase):
|
|||
self.assertFalse(checks[groups[1].key])
|
||||
|
||||
|
||||
class TestDbVocabHarvest(unittest.TestCase):
|
||||
def test_map_name_is_candidate(self):
|
||||
line = {"fieldName": "マップ名", "source": "礼拝堂", "text": "Chapel"}
|
||||
self.assertTrue(
|
||||
wdb.is_db_vocab_harvest_candidate(
|
||||
line,
|
||||
type_name="Map Setting · マップ設定",
|
||||
tier=wdb.TIER_UNKNOWN,
|
||||
)
|
||||
)
|
||||
|
||||
def test_description_is_not_candidate(self):
|
||||
line = {
|
||||
"fieldName": "Description · 説明",
|
||||
"source": "味方一人のHPを回復",
|
||||
"text": "Restores one ally's HP",
|
||||
}
|
||||
self.assertFalse(
|
||||
wdb.is_db_vocab_harvest_candidate(
|
||||
line,
|
||||
type_name="Skill · 技能",
|
||||
tier=wdb.TIER_FOUNDATION,
|
||||
)
|
||||
)
|
||||
|
||||
def test_usage_message_with_name_placeholder_is_not_candidate(self):
|
||||
line = {
|
||||
"fieldName": "Usage Message [Battle] (Name~ · 使用時文章[戦闘](人名~",
|
||||
"source": "の攻撃!",
|
||||
"text": "'s attack!",
|
||||
}
|
||||
self.assertFalse(
|
||||
wdb.is_db_vocab_harvest_candidate(
|
||||
line,
|
||||
type_name="Skill · 技能",
|
||||
tier=wdb.TIER_FOUNDATION,
|
||||
)
|
||||
)
|
||||
|
||||
def test_narrative_sheet_is_not_candidate(self):
|
||||
line = {"fieldName": "マップ名", "source": "礼拝堂", "text": "Chapel"}
|
||||
self.assertFalse(
|
||||
wdb.is_db_vocab_harvest_candidate(
|
||||
line,
|
||||
type_name="■イベント(セルリア)",
|
||||
tier=wdb.TIER_NARRATIVE,
|
||||
)
|
||||
)
|
||||
|
||||
def test_collect_pairs_groups_by_sheet(self):
|
||||
doc = {
|
||||
"file": "SysDatabase.project",
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"typeName": "Map Setting · マップ設定",
|
||||
"lines": [
|
||||
{
|
||||
"fieldName": "マップ名",
|
||||
"source": "礼拝堂",
|
||||
"text": "Chapel",
|
||||
},
|
||||
{
|
||||
"fieldName": "Description · 説明",
|
||||
"source": "静かな礼拝堂",
|
||||
"text": "A quiet chapel",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"typeName": "■イベント(セルリア)",
|
||||
"lines": [
|
||||
{
|
||||
"fieldName": "現在の行動",
|
||||
"source": "礼拝堂で黙とう中",
|
||||
"text": "Praying in the chapel",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
pairs = wdb.collect_db_vocab_pairs(doc)
|
||||
self.assertEqual(
|
||||
pairs,
|
||||
{"Map Setting · マップ設定": [("礼拝堂", "Chapel")]},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -46,22 +46,22 @@ class _WolfTranslateHarness:
|
|||
self.captured.append(copy.deepcopy(text))
|
||||
return _mock_translate(text, history, history_ctx)
|
||||
|
||||
def capture_vocab(category, pairs):
|
||||
self.vocab_writes.append((category, list(pairs)))
|
||||
def capture_vocab(category, pairs, merge=False):
|
||||
self.vocab_writes.append((category, list(pairs), merge))
|
||||
|
||||
orig_t = wd.translateAI
|
||||
orig_estimate = wd.ESTIMATE
|
||||
orig_wrap = wd.WRAP
|
||||
orig_ignore = wd.IGNORETLTEXT
|
||||
orig_update = wd.wolf_vocab.update_vocab_section
|
||||
orig_labels = wd.wolf_names.derive_db_labels
|
||||
orig_db_filter = wd.wolf_db.load_db_filter_config
|
||||
wd.translateAI = translate
|
||||
wd.ESTIMATE = estimate
|
||||
wd.WRAP = False # keep write-back byte-faithful; wrapping tested separately
|
||||
wd.IGNORETLTEXT = ignore_tl_text
|
||||
# Never touch the real glossary / DB files during tests.
|
||||
wd.wolf_vocab.update_vocab_section = capture_vocab
|
||||
wd.wolf_names.derive_db_labels = lambda _p: {}
|
||||
wd.wolf_db.load_db_filter_config = lambda: (frozenset(), frozenset())
|
||||
try:
|
||||
data_copy = copy.deepcopy(data)
|
||||
result = wd.parseDocument(data_copy, filename)
|
||||
|
|
@ -69,10 +69,10 @@ class _WolfTranslateHarness:
|
|||
finally:
|
||||
wd.translateAI = orig_t
|
||||
wd.ESTIMATE = orig_estimate
|
||||
wd.WRAP = orig_wrap
|
||||
wd.IGNORETLTEXT = orig_ignore
|
||||
wd.wolf_vocab.update_vocab_section = orig_update
|
||||
wd.wolf_names.derive_db_labels = orig_labels
|
||||
wd.wolf_db.load_db_filter_config = orig_db_filter
|
||||
|
||||
|
||||
MAP_DOC = {
|
||||
|
|
@ -198,7 +198,7 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
harness.vocab_writes,
|
||||
[
|
||||
("Weapon · 武器", [("剣", "EN_剣")]),
|
||||
("Weapon · 武器", [("剣", "EN_剣")], False),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
finally:
|
||||
wd.wolf_vocab.remove_vocab_section = orig_remove
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("ダガー", "EN_ダガー")])])
|
||||
self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("ダガー", "EN_ダガー")], False)])
|
||||
self.assertEqual(harness.vocab_remove_writes, ["├■プロフィール"])
|
||||
|
||||
def test_txtdir_translates(self):
|
||||
|
|
@ -451,9 +451,94 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
self.assertEqual(captured, [])
|
||||
self.assertEqual(
|
||||
harness.vocab_writes,
|
||||
[("Weapon · 武器", [("剣", "Sword")])],
|
||||
[("Weapon · 武器", [("剣", "Sword")], False)],
|
||||
)
|
||||
|
||||
def test_db_foundation_labels_harvest_to_vocab(self):
|
||||
doc = {
|
||||
"file": "SysDatabase.project",
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 0,
|
||||
"typeName": "Map Setting · マップ設定",
|
||||
"lines": [
|
||||
{
|
||||
"row": 0,
|
||||
"field": 0,
|
||||
"fieldName": "マップ名",
|
||||
"source": "礼拝堂",
|
||||
"text": "礼拝堂",
|
||||
},
|
||||
{
|
||||
"row": 1,
|
||||
"field": 0,
|
||||
"fieldName": "マップ名",
|
||||
"source": "大通り",
|
||||
"text": "大通り",
|
||||
},
|
||||
{
|
||||
"row": 0,
|
||||
"field": 1,
|
||||
"fieldName": "Description · 説明",
|
||||
"source": "静かな礼拝堂",
|
||||
"text": "静かな礼拝堂",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
"typeName": "■イベント(セルリア)",
|
||||
"lines": [
|
||||
{
|
||||
"row": 0,
|
||||
"field": 0,
|
||||
"fieldName": "現在の行動",
|
||||
"source": "礼拝堂で黙とう中",
|
||||
"text": "礼拝堂で黙とう中",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
harness = _WolfTranslateHarness()
|
||||
(_data, _t, err), _c = harness.run(doc, "SysDatabase.project.json")
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(
|
||||
harness.vocab_writes,
|
||||
[
|
||||
(
|
||||
"Map Setting · マップ設定",
|
||||
[("礼拝堂", "EN_礼拝堂"), ("大通り", "EN_大通り")],
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def test_db_harvest_skipped_in_estimate(self):
|
||||
doc = {
|
||||
"file": "SysDatabase.project",
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 0,
|
||||
"typeName": "Map Setting · マップ設定",
|
||||
"lines": [
|
||||
{
|
||||
"row": 0,
|
||||
"field": 0,
|
||||
"fieldName": "マップ名",
|
||||
"source": "礼拝堂",
|
||||
"text": "礼拝堂",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
harness = _WolfTranslateHarness()
|
||||
harness.run(doc, "SysDatabase.project.json", estimate=True)
|
||||
self.assertEqual(harness.vocab_writes, [])
|
||||
|
||||
|
||||
import re # noqa: E402
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,26 @@ def _norm(s: str) -> str:
|
|||
return re.sub(r"\s+", " ", str(s)).strip().casefold()
|
||||
|
||||
|
||||
def update_vocab_section(category: str, pairs) -> None:
|
||||
_SECTION_PAIR_RE = re.compile(
|
||||
r"^(.+?)\s+\((.+)\)\s*$",
|
||||
)
|
||||
|
||||
|
||||
def _parse_section_pairs(section_body: str) -> dict[str, str]:
|
||||
"""Parse ``src (dst)`` lines from a vocab section body (no header)."""
|
||||
pairs: dict[str, str] = {}
|
||||
for raw in section_body.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = _SECTION_PAIR_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
pairs[m.group(1)] = m.group(2)
|
||||
return pairs
|
||||
|
||||
|
||||
def update_vocab_section(category: str, pairs, *, merge: bool = False) -> None:
|
||||
"""Insert or replace a ``# {category}`` section in the game-specific vocab.
|
||||
|
||||
Mirrors the RPGMaker auto-glossary behaviour (translated DB names feed
|
||||
|
|
@ -74,6 +93,8 @@ def update_vocab_section(category: str, pairs) -> None:
|
|||
- ``pairs``: iterable of ``(source, translated)``. Deduped by source (last
|
||||
wins); no-ops (empty translation or unchanged after normalisation) are
|
||||
dropped. When nothing survives filtering the file is left untouched.
|
||||
- ``merge``: when True, keep existing entries for this category and only add
|
||||
sources that are not already present (names.json stays authoritative).
|
||||
"""
|
||||
dedup: dict[str, str] = {}
|
||||
for src, dst in pairs:
|
||||
|
|
@ -97,16 +118,26 @@ def update_vocab_section(category: str, pairs) -> None:
|
|||
game_part = existing
|
||||
base_part = ""
|
||||
|
||||
block_lines = [f"{src} ({dst})" for src, dst in dedup.items()]
|
||||
new_block = f"# {category}\n" + "\n".join(block_lines) + "\n\n"
|
||||
|
||||
# Match this category's section up to the next '#' header or end of the
|
||||
# game portion. Handles '#Cat', '# Cat', '## Cat', etc.
|
||||
pattern = re.compile(
|
||||
rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)",
|
||||
rf"^([\t ]*#+\s*{re.escape(category)}\s*$\r?\n)(.*?)(?=^[\t ]*#|\Z)",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if pattern.search(game_part):
|
||||
match = pattern.search(game_part)
|
||||
if merge and match:
|
||||
merged = _parse_section_pairs(match.group(2))
|
||||
for src, dst in dedup.items():
|
||||
if src not in merged:
|
||||
merged[src] = dst
|
||||
dedup = merged
|
||||
if not dedup:
|
||||
return
|
||||
|
||||
block_lines = [f"{src} ({dst})" for src, dst in dedup.items()]
|
||||
new_block = f"# {category}\n" + "\n".join(block_lines) + "\n\n"
|
||||
|
||||
if match:
|
||||
new_game = pattern.sub(lambda _m: new_block, game_part, count=1)
|
||||
else:
|
||||
new_game = game_part.rstrip("\n")
|
||||
|
|
|
|||
|
|
@ -64,6 +64,28 @@ DIALOGUE_FIELDS_RE = re.compile(
|
|||
r"セリフ|コメント|相手|行為|台詞|会話|メッセージ_",
|
||||
)
|
||||
|
||||
# Foundation DB lines that are short display labels (map names, term titles),
|
||||
# not descriptions / battle messages / dialogue. Used when seeding vocab.txt.
|
||||
_DB_VOCAB_FIELD_RE = re.compile(
|
||||
r"マップ名|"
|
||||
r"属性名|"
|
||||
r"用語名|"
|
||||
r"コマンド名|"
|
||||
r"(?:^|·\s*)タイトル(?:$|[((\s])|"
|
||||
r"(?:^|·\s*)Name(?:\s*·|$)|"
|
||||
r"(?:^|·\s*)Title(?:\s*·|$)|"
|
||||
r"(?:^|·\s*)名称(?:$|[((\s·])|"
|
||||
r"(?:^|·\s*)称号(?!.*コメント)|"
|
||||
r"(?:^|·\s*)好きなもの(?!.*コメント)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DB_VOCAB_SKIP_FIELD_RE = re.compile(
|
||||
r"説明|Description|文章|メッセージ|Message|セリフ|コメント|Comment|"
|
||||
r"行為|相手|台詞|会話",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DB_VOCAB_HARVEST_MAX_LEN = 72
|
||||
|
||||
ARCHETYPE_CLASSIC = "classic_rpg"
|
||||
ARCHETYPE_DB_HEAVY = "simulation_db_heavy"
|
||||
ARCHETYPE_HYBRID = "hybrid"
|
||||
|
|
@ -458,6 +480,79 @@ def load_db_filter_config() -> tuple[frozenset[str], frozenset[str]]:
|
|||
)
|
||||
|
||||
|
||||
def is_db_vocab_harvest_candidate(
|
||||
line: dict[str, Any],
|
||||
*,
|
||||
type_name: str = "",
|
||||
tier: str | None = None,
|
||||
) -> bool:
|
||||
"""True when a foundation DB line should seed ``vocab.txt``.
|
||||
|
||||
Only short label-like fields on foundation/system/unknown sheets qualify.
|
||||
Descriptions, battle messages, and narrative dialogue stay out.
|
||||
"""
|
||||
resolved = tier
|
||||
if resolved is None:
|
||||
resolved = classify_group_tier(type_name, [line])
|
||||
if resolved not in FOUNDATION_TIERS:
|
||||
return False
|
||||
field_name = str(line.get("fieldName") or "")
|
||||
if _DB_VOCAB_SKIP_FIELD_RE.search(field_name):
|
||||
return False
|
||||
if not _DB_VOCAB_FIELD_RE.search(field_name):
|
||||
return False
|
||||
if DIALOGUE_FIELDS_RE.search(field_name):
|
||||
return False
|
||||
src = str(line.get("source") or "")
|
||||
if not src.strip():
|
||||
return False
|
||||
if "\n" in src or "\r" in src:
|
||||
return False
|
||||
if len(src) > _DB_VOCAB_HARVEST_MAX_LEN:
|
||||
return False
|
||||
if len(re.findall(r"[。!?]", src)) >= 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def collect_db_vocab_pairs(
|
||||
doc: dict[str, Any],
|
||||
*,
|
||||
include_tiers: frozenset[str] | None = None,
|
||||
include_groups: frozenset[str] | None = None,
|
||||
) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Return ``{typeName: [(source, text), ...]}`` for harvestable DB terms.
|
||||
|
||||
Respects the same sheet filter used for translation when *include_tiers* /
|
||||
*include_groups* are provided. Only foundation-tier sheets contribute.
|
||||
"""
|
||||
if doc.get("kind") != "db":
|
||||
return {}
|
||||
json_file = json_file_from_doc(doc)
|
||||
tiers = include_tiers if include_tiers is not None else frozenset()
|
||||
groups = include_groups if include_groups is not None else frozenset()
|
||||
by_sheet: dict[str, list[tuple[str, str]]] = {}
|
||||
for group in doc.get("groups") or []:
|
||||
type_name = str(group.get("typeName") or "")
|
||||
lines = group.get("lines") or []
|
||||
if not group_matches_filter(json_file, type_name, tiers, groups):
|
||||
continue
|
||||
tier = classify_group_tier(type_name, lines)
|
||||
if tier not in FOUNDATION_TIERS:
|
||||
continue
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for line in lines:
|
||||
if not is_db_vocab_harvest_candidate(line, type_name=type_name, tier=tier):
|
||||
continue
|
||||
src, dst = line.get("source"), line.get("text")
|
||||
if not isinstance(src, str) or not isinstance(dst, str):
|
||||
continue
|
||||
pairs.append((src, dst))
|
||||
if pairs:
|
||||
by_sheet[type_name] = pairs
|
||||
return by_sheet
|
||||
|
||||
|
||||
def group_matches_filter(
|
||||
json_file: str,
|
||||
type_name: str,
|
||||
|
|
|
|||
Loading…
Reference in a new issue