Delete scripts directory
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
This script is used to format the pchtxt heirarchy appropriately for the following repository:
|
||||
|
||||
https://github.com/Fl4sh9174/Switch-Ultrawide-Mods
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import zipfile
|
||||
import shutil
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
# ----- Normalization and Capitalization Helpers -----
|
||||
|
||||
def sanitize_name(name):
|
||||
"""
|
||||
Remove accents and unwanted characters, and replace ' - ' with a single space.
|
||||
"""
|
||||
normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii')
|
||||
cleaned = normalized.replace("'", "").replace("’", "").replace("`", "").replace('"', "")
|
||||
cleaned = cleaned.replace(" - ", " ") # Merge any " - " into a single space
|
||||
cleaned = ' '.join(cleaned.split()) # Collapse multiple spaces
|
||||
return cleaned.strip()
|
||||
|
||||
def capitalize_hyphenated(word):
|
||||
"""
|
||||
Capitalize both parts of a hyphenated word.
|
||||
E.g. "yooka-laylee" → "Yooka-Laylee"
|
||||
"""
|
||||
parts = word.split('-')
|
||||
capitalized_parts = []
|
||||
for part in parts:
|
||||
if part:
|
||||
capitalized_parts.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper())
|
||||
else:
|
||||
capitalized_parts.append('')
|
||||
return '-'.join(capitalized_parts)
|
||||
|
||||
# Regex for Roman numerals (supports up to 3999: I, II, III, IV, …, XIII, …, MMMCMXCIX, etc.)
|
||||
ROMAN_NUMERAL_PATTERN = re.compile(
|
||||
r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Known acronyms to force‐uppercase exactly
|
||||
ACRONYMS = {
|
||||
"HD", "2D", "3D", "4K", "VR", "AI", "API", "USB", "CPU", "GPU", "DVD", "CD",
|
||||
"RPG", "FPS", "MMO", "MMORPG", "LAN", "GUI", "NPC",
|
||||
"FFVII", "FFVIII", "FFIX", "FFX", "FFXII",
|
||||
"FX", "2K", "5K", "8K", "V1", "V2", "V3", "V4", "DOF"
|
||||
}
|
||||
|
||||
def is_roman_numeral(word):
|
||||
"""
|
||||
Return True if the word is a valid Roman numeral (case-insensitive).
|
||||
"""
|
||||
return bool(ROMAN_NUMERAL_PATTERN.match(word))
|
||||
|
||||
def title_case_preserve_numbers(name):
|
||||
"""
|
||||
Title-case with these rules:
|
||||
• Fully uppercase acronyms remain unchanged (e.g. HD, 2D, 3D, FFVII, etc.).
|
||||
• Roman numerals are fully uppercase (e.g. 'iii' → 'III', 'xI' → 'XI').
|
||||
• Hyphenated words are capitalized on both sides (→ 'Yooka-Laylee').
|
||||
• Conjoined Roman numerals with &, +, or | become fully uppercase (e.g. 'I&ii' → 'I&II').
|
||||
• Small filler words (a, an, and, the, of, in, etc.) become lowercase only if:
|
||||
– they appear in the middle (not first or last),
|
||||
– and they are not immediately after a subtitle marker.
|
||||
• After a subtitle marker (":", "~", "–", "—", or "-"), force capitalization on all subsequent words
|
||||
until the next subtitle marker or end-of-title.
|
||||
"""
|
||||
lowercase_exceptions = {
|
||||
"a", "an", "and", "as", "at", "but", "by", "for", "from",
|
||||
"in", "nor", "of", "on", "or", "so", "the", "to", "with", "yet"
|
||||
}
|
||||
subtitle_markers = {":", "~", "-", "–", "—"}
|
||||
|
||||
words = name.split()
|
||||
capitalized_words = []
|
||||
force_capitalize = False
|
||||
|
||||
for idx, raw_word in enumerate(words):
|
||||
# Check if this word contains a subtitle marker
|
||||
contains_marker = any(marker in raw_word for marker in subtitle_markers)
|
||||
|
||||
# Break on subtitle markers, but keep them in the split list
|
||||
split_parts = re.split(r'([:~\-–—])', raw_word)
|
||||
rebuilt = []
|
||||
|
||||
for part in split_parts:
|
||||
if part in subtitle_markers:
|
||||
# Keep the marker as-is, then force the next real segment to capitalize
|
||||
rebuilt.append(part)
|
||||
force_capitalize = True
|
||||
continue
|
||||
|
||||
lower_part = part.lower()
|
||||
is_first = (idx == 0)
|
||||
is_last = (idx == len(words) - 1)
|
||||
|
||||
def capitalize_special(subword):
|
||||
# 1) If subword (uppercased) is in ACRONYMS, return it unchanged
|
||||
if subword.upper() in ACRONYMS:
|
||||
return subword.upper()
|
||||
# 2) If subword is a standalone Roman numeral, uppercase it
|
||||
if is_roman_numeral(subword):
|
||||
return subword.upper()
|
||||
# 3) Check for compound Roman numerals (e.g. "I&ii")
|
||||
for sep in ['&', '+', '|']:
|
||||
if sep in subword:
|
||||
pieces = subword.split(sep)
|
||||
if all(is_roman_numeral(p) for p in pieces):
|
||||
return sep.join(p.upper() for p in pieces)
|
||||
# 4) Otherwise, capitalize hyphens normally
|
||||
return capitalize_hyphenated(subword)
|
||||
|
||||
# Determine whether to capitalize or lowercase this segment
|
||||
if force_capitalize or is_first or is_last or (lower_part not in lowercase_exceptions):
|
||||
sub_hyphens = part.split('-')
|
||||
rebuilt.append('-'.join(capitalize_special(p) for p in sub_hyphens))
|
||||
else:
|
||||
# It's a filler word in the middle → keep lowercase
|
||||
rebuilt.append(lower_part)
|
||||
|
||||
capitalized_words.append(''.join(rebuilt))
|
||||
|
||||
# If this raw_word did NOT contain a marker, stop forcing capitalization on the next word
|
||||
if not contains_marker:
|
||||
force_capitalize = False
|
||||
|
||||
# Finally, ensure the first and last words are definitely capitalized with special rules:
|
||||
if capitalized_words:
|
||||
# First word:
|
||||
first_split = capitalized_words[0].split('-')
|
||||
capitalized_words[0] = '-'.join(
|
||||
p.upper() if (p.upper() in ACRONYMS or is_roman_numeral(p)) else capitalize_hyphenated(p)
|
||||
for p in first_split
|
||||
)
|
||||
# Last word:
|
||||
last_split = capitalized_words[-1].split('-')
|
||||
capitalized_words[-1] = '-'.join(
|
||||
p.upper() if (p.upper() in ACRONYMS or is_roman_numeral(p)) else capitalize_hyphenated(p)
|
||||
for p in last_split
|
||||
)
|
||||
|
||||
return ' '.join(capitalized_words)
|
||||
|
||||
def clean_title(name):
|
||||
"""
|
||||
Convenience function to run both sanitize_name → title_case_preserve_numbers
|
||||
in one call.
|
||||
"""
|
||||
return title_case_preserve_numbers(sanitize_name(name))
|
||||
|
||||
# ----- Unzipping and Formatting for this Repo -----
|
||||
|
||||
def unzip_files(folder_path):
|
||||
print("Unzipping files...\n")
|
||||
for item in os.listdir(folder_path):
|
||||
if item.lower().endswith('.zip'):
|
||||
file_path = os.path.join(folder_path, item)
|
||||
# Remove any bracketed tags (e.g. "[something]") then strip “.zip”
|
||||
raw_game_name = re.sub(r'\[.*?\]', '', item).replace('.zip', '').strip()
|
||||
cleaned_game_name = clean_title(raw_game_name)
|
||||
extract_to = os.path.join(folder_path, cleaned_game_name)
|
||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(extract_to)
|
||||
print(f"✅ Unzipped: {file_path} → {extract_to}")
|
||||
|
||||
def create_formatted_structure(folder_path):
|
||||
formatted_path = os.path.join(folder_path, 'formatted')
|
||||
os.makedirs(formatted_path, exist_ok=True)
|
||||
print(f"\nOrganizing into: {formatted_path}\n")
|
||||
|
||||
for game_dir in os.listdir(folder_path):
|
||||
game_dir_path = os.path.join(folder_path, game_dir)
|
||||
# Skip the "formatted" folder itself
|
||||
if not os.path.isdir(game_dir_path) or game_dir == 'formatted':
|
||||
continue
|
||||
|
||||
# Compute the cleaned, title-cased game name once:
|
||||
cleaned_game_name = clean_title(game_dir)
|
||||
|
||||
for root, dirs, files in os.walk(game_dir_path):
|
||||
for file in files:
|
||||
if file.lower().endswith('.pchtxt'):
|
||||
# Look for a [mod_name vX.Y] segment in the path
|
||||
relative_path = os.path.relpath(root, folder_path)
|
||||
mod_match = re.search(r'\[(.*?)\]', relative_path)
|
||||
if not mod_match:
|
||||
continue
|
||||
|
||||
raw_mod_name = mod_match.group(1)
|
||||
# Strip off any trailing " v<digits>" from the bracketed part
|
||||
mod_name_no_version = re.sub(r' v[0-9.]+$', '', raw_mod_name).strip()
|
||||
|
||||
cleaned_mod_name = clean_title(mod_name_no_version)
|
||||
version = file[:-len('.pchtxt')].strip()
|
||||
|
||||
target_dir = os.path.join(
|
||||
formatted_path,
|
||||
f"{cleaned_game_name} - {cleaned_mod_name}"
|
||||
)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
source_file = os.path.join(root, file)
|
||||
dest_file = os.path.join(target_dir, f"{version}.pchtxt")
|
||||
shutil.move(source_file, dest_file)
|
||||
print(f"📦 Moved {file} → {os.path.join(target_dir, f'{version}.pchtxt')}")
|
||||
|
||||
# Once done walking this game’s directory, remove it entirely
|
||||
shutil.rmtree(game_dir_path)
|
||||
print(f"🗑️ Removed temporary folder: {game_dir_path}")
|
||||
|
||||
print("\n✅ All files organized successfully.")
|
||||
|
||||
def main(folder_path):
|
||||
unzip_files(folder_path)
|
||||
create_formatted_structure(folder_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python format_repo.py /path/to/folder/of/zips/")
|
||||
sys.exit(1)
|
||||
|
||||
folder_path = sys.argv[1]
|
||||
main(folder_path)
|
||||
@@ -1,3 +0,0 @@
|
||||
This script is used to format the pchtxt heirarchy appropriately for the following repository:
|
||||
|
||||
https://github.com/KeatonTheBot/switch-pchtxt-mods
|
||||
@@ -1,264 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
# ----- Normalization & Title‐Casing Helpers (identical to other repos) -----
|
||||
|
||||
def sanitize_name(name):
|
||||
"""
|
||||
Remove accents and unwanted characters, and replace ' - ' with a single space.
|
||||
"""
|
||||
normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii')
|
||||
cleaned = normalized.replace("'", "").replace("’", "").replace("`", "").replace('"', "")
|
||||
# Merge any " - " into a single space, collapse extra spaces
|
||||
cleaned = cleaned.replace(" - ", " ")
|
||||
cleaned = ' '.join(cleaned.split())
|
||||
return cleaned.strip()
|
||||
|
||||
def capitalize_hyphenated(word):
|
||||
"""
|
||||
Capitalize both parts of a hyphenated word. E.g. "yooka-laylee" → "Yooka-Laylee".
|
||||
"""
|
||||
parts = word.split('-')
|
||||
capitalized_parts = []
|
||||
for part in parts:
|
||||
if part:
|
||||
capitalized_parts.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper())
|
||||
else:
|
||||
capitalized_parts.append('')
|
||||
return '-'.join(capitalized_parts)
|
||||
|
||||
# Regex for Roman numerals up to 3999
|
||||
ROMAN_NUMERAL_PATTERN = re.compile(
|
||||
r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Known acronyms to force‐uppercase exactly
|
||||
ACRONYMS = {
|
||||
"HD", "2D", "3D", "4K", "VR", "AI", "API", "USB", "CPU", "GPU", "DVD", "CD",
|
||||
"RPG", "FPS", "MMO", "MMORPG", "LAN", "GUI", "NPC",
|
||||
"FFVII", "FFVIII", "FFIX", "FFX", "FFXII",
|
||||
"FX", "2K", "5K", "8K", "V1", "V2", "V3", "V4"
|
||||
}
|
||||
|
||||
def is_roman_numeral(word):
|
||||
"""
|
||||
Return True if `word` is a valid Roman numeral (case‐insensitive).
|
||||
"""
|
||||
return bool(ROMAN_NUMERAL_PATTERN.match(word))
|
||||
|
||||
def title_case_preserve_numbers(name):
|
||||
"""
|
||||
Title‐case with these rules:
|
||||
• Fully uppercase acronyms remain unchanged (e.g. HD, 2D, 3D, FFVII, etc.).
|
||||
• Roman numerals become fully uppercase (e.g. 'iii' → 'III', 'xI' → 'XI').
|
||||
• Hyphenated words are capitalized on both sides (→ 'Yooka-Laylee').
|
||||
• Small filler words (a, an, and, the, of, in, etc.) become lowercase
|
||||
only if they appear in the middle and are not immediately after a subtitle marker,
|
||||
except the first and last words (which always get capitalized).
|
||||
• After a subtitle marker (":", "~", "–", "—", or "-"), force capitalization
|
||||
on all subsequent words (until the next subtitle marker or end).
|
||||
• Compound Roman numerals joined by &, +, or | become fully uppercase (e.g. "I&ii" → "I&II").
|
||||
"""
|
||||
lowercase_exceptions = {
|
||||
"a", "an", "and", "as", "at", "but", "by", "for", "from",
|
||||
"in", "nor", "of", "on", "or", "so", "the", "to", "with", "yet"
|
||||
}
|
||||
subtitle_markers = {":", "~", "-", "–", "—"}
|
||||
|
||||
words = name.split()
|
||||
result = []
|
||||
force_capitalize_mode = False
|
||||
|
||||
for idx, raw_word in enumerate(words):
|
||||
# Check if this word contains any subtitle marker
|
||||
contains_marker = any(marker in raw_word for marker in subtitle_markers)
|
||||
|
||||
# Split on subtitle markers (keeping them in the list)
|
||||
split_parts = re.split(r'([:~\-–—])', raw_word)
|
||||
rebuilt_parts = []
|
||||
|
||||
for part in split_parts:
|
||||
if part in subtitle_markers:
|
||||
# Keep marker, then force next segments to capitalize
|
||||
rebuilt_parts.append(part)
|
||||
force_capitalize_mode = True
|
||||
continue
|
||||
|
||||
lower_part = part.lower()
|
||||
is_first = (idx == 0)
|
||||
is_last = (idx == len(words) - 1)
|
||||
|
||||
def capitalize_special(subword):
|
||||
# 1) If subword uppercase is in ACRONYMS → uppercase it
|
||||
if subword.upper() in ACRONYMS:
|
||||
return subword.upper()
|
||||
# 2) If subword is a Roman numeral → uppercase it
|
||||
if is_roman_numeral(subword):
|
||||
return subword.upper()
|
||||
# 3) Check for compound Roman numerals joined by &, +, |
|
||||
for sep in ('&', '+', '|'):
|
||||
if sep in subword:
|
||||
pieces = subword.split(sep)
|
||||
if all(is_roman_numeral(p) for p in pieces):
|
||||
return sep.join(p.upper() for p in pieces)
|
||||
# 4) Otherwise → just hyphen‐capitalize normally
|
||||
return capitalize_hyphenated(subword)
|
||||
|
||||
if force_capitalize_mode or is_first or is_last or (lower_part not in lowercase_exceptions):
|
||||
sub_parts = part.split('-')
|
||||
rebuilt_parts.append('-'.join(capitalize_special(p) for p in sub_parts))
|
||||
else:
|
||||
# In-middle filler word → keep lowercase
|
||||
rebuilt_parts.append(lower_part)
|
||||
|
||||
result.append(''.join(rebuilt_parts))
|
||||
|
||||
# If this raw_word did NOT contain a marker, stop forcing capitalization on the next
|
||||
if not contains_marker:
|
||||
force_capitalize_mode = False
|
||||
|
||||
# Finally, ensure the first and last words are capitalized with special logic:
|
||||
if result:
|
||||
# First word:
|
||||
first_split = result[0].split('-')
|
||||
new_first = []
|
||||
for p in first_split:
|
||||
if p.upper() in ACRONYMS or is_roman_numeral(p):
|
||||
new_first.append(p.upper())
|
||||
else:
|
||||
new_first.append(capitalize_hyphenated(p))
|
||||
result[0] = '-'.join(new_first)
|
||||
|
||||
# Last word:
|
||||
last_split = result[-1].split('-')
|
||||
new_last = []
|
||||
for p in last_split:
|
||||
if p.upper() in ACRONYMS or is_roman_numeral(p):
|
||||
new_last.append(p.upper())
|
||||
else:
|
||||
new_last.append(capitalize_hyphenated(p))
|
||||
result[-1] = '-'.join(new_last)
|
||||
|
||||
return ' '.join(result)
|
||||
|
||||
def clean_title(name):
|
||||
"""
|
||||
Convenience: run sanitize_name → title_case_preserve_numbers in one shot.
|
||||
"""
|
||||
return title_case_preserve_numbers(sanitize_name(name))
|
||||
|
||||
# ----- Original Repo Logic, Now Injecting Our Title‐Casing -----
|
||||
|
||||
def transform_game_name(game_name):
|
||||
"""
|
||||
1) Move ", The" to front, if present.
|
||||
2) Remove any " - " substring.
|
||||
"""
|
||||
if ', The' in game_name:
|
||||
parts = game_name.split(', The')
|
||||
# e.g., "Zelda, The" → "The Zelda"
|
||||
game_name = f"The {parts[0]}{parts[1]}"
|
||||
# Remove " - " exactly
|
||||
game_name = game_name.replace(' - ', ' ')
|
||||
return game_name
|
||||
|
||||
def get_game_name_and_mod_name(path, root_dir):
|
||||
"""
|
||||
Given `path` (where a .pchtxt file lives) and the `root_dir`,
|
||||
derive:
|
||||
• game_name (with country if present, then sanitized+title‐cased)
|
||||
• mod_name (sanitized+title‐cased), handling Aspect Ratio and version suffix.
|
||||
"""
|
||||
relative_path = os.path.relpath(path, root_dir)
|
||||
parts = relative_path.split(os.sep)
|
||||
|
||||
# The first part is the raw game folder name
|
||||
raw_game = parts[0]
|
||||
# Strip out any bracketed tags, then transform
|
||||
raw_game = re.sub(r'\[.*?\]', '', raw_game).strip()
|
||||
raw_game = transform_game_name(raw_game)
|
||||
|
||||
# Check for country‐specific folders (look for something like "[USA]" or "[JP]" etc.)
|
||||
country = None
|
||||
for part in parts[1:]:
|
||||
if re.search(r'\[.*?\]', part):
|
||||
country = re.sub(r'\[.*?\]', '', part).strip()
|
||||
break
|
||||
|
||||
if country:
|
||||
raw_game = f"{raw_game} ({country})"
|
||||
|
||||
# Now sanitize + title‐case the game name exactly as in other repos:
|
||||
game_name = clean_title(raw_game)
|
||||
|
||||
# Determine mod_name
|
||||
# If path contains "Aspect Ratio", then:
|
||||
if 'Aspect Ratio' in relative_path:
|
||||
# e.g. "<...>/Aspect Ratio/16:9/[files]"
|
||||
aspect_ratio = os.path.basename(os.path.dirname(path)).replace("'", ".")
|
||||
raw_mod = f"Aspect Ratio {aspect_ratio}"
|
||||
else:
|
||||
# If the last folder has a version suffix " v\d+", attach it to the previous part
|
||||
last_part = parts[-1]
|
||||
if re.search(r' v\d+', last_part):
|
||||
# e.g. .../SomeMod/Disable Fog v1/file.pchtxt → mod = "Disable Fog v1"
|
||||
raw_mod = parts[-2] + " " + last_part
|
||||
else:
|
||||
# Otherwise just take the immediate parent folder name
|
||||
raw_mod = parts[-2]
|
||||
|
||||
# Sanitize + title‐case mod_name as well
|
||||
mod_name = clean_title(raw_mod)
|
||||
|
||||
return game_name, mod_name
|
||||
|
||||
def create_formatted_structure(folder_path):
|
||||
"""
|
||||
Walk `folder_path` for all .pchtxt files. For each one:
|
||||
1) Derive (game_name, mod_name) via get_game_name_and_mod_name.
|
||||
2) Sanitize and title‐case those exactly the same as other repos.
|
||||
3) Copy <…>.pchtxt into formatted/<Game Name> - <Mod Name>/<version>.pchtxt.
|
||||
"""
|
||||
formatted_path = os.path.join(folder_path, 'formatted')
|
||||
os.makedirs(formatted_path, exist_ok=True)
|
||||
print(f"Creating formatted structure at: {formatted_path}\n")
|
||||
|
||||
for root, dirs, files in os.walk(folder_path):
|
||||
# Skip the “formatted” directory itself
|
||||
if 'formatted' in root.split(os.sep):
|
||||
continue
|
||||
|
||||
for file in files:
|
||||
if not file.lower().endswith('.pchtxt'):
|
||||
continue
|
||||
|
||||
# Derive game_name and mod_name
|
||||
game_name, mod_name = get_game_name_and_mod_name(root, folder_path)
|
||||
|
||||
version = file[:-len('.pchtxt')].strip() # strip the ".pchtxt"
|
||||
new_dir = os.path.join(formatted_path, f"{game_name} - {mod_name}")
|
||||
os.makedirs(new_dir, exist_ok=True)
|
||||
|
||||
src = os.path.join(root, file)
|
||||
dst = os.path.join(new_dir, f"{version}.pchtxt")
|
||||
shutil.copy(src, dst)
|
||||
print(f"Copied {src} → {dst}")
|
||||
|
||||
print("\nDone!\n")
|
||||
|
||||
def main(folder_path):
|
||||
create_formatted_structure(folder_path)
|
||||
print("All files have been organized successfully.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python format_repo_3.py /path/to/folder/")
|
||||
sys.exit(1)
|
||||
|
||||
folder_path = sys.argv[1]
|
||||
main(folder_path)
|
||||
@@ -1,3 +0,0 @@
|
||||
This script is used to format the pchtxt heirarchy appropriately for the following repository:
|
||||
|
||||
https://github.com/StevensND/switch-port-mods
|
||||
@@ -1,210 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
# ----- Normalization & Title‐Casing Helpers -----
|
||||
|
||||
def sanitize_name(name):
|
||||
normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii')
|
||||
cleaned = normalized.replace("'", "").replace("’", "").replace("`", "").replace('"', "")
|
||||
cleaned = cleaned.replace(" - ", " ")
|
||||
cleaned = ' '.join(cleaned.split())
|
||||
return cleaned.strip()
|
||||
|
||||
def capitalize_hyphenated(word):
|
||||
parts = word.split('-')
|
||||
capitalized_parts = []
|
||||
for part in parts:
|
||||
if part:
|
||||
capitalized_parts.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper())
|
||||
else:
|
||||
capitalized_parts.append('')
|
||||
return '-'.join(capitalized_parts)
|
||||
|
||||
ROMAN_NUMERAL_PATTERN = re.compile(
|
||||
r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Known acronyms to force‐uppercase exactly
|
||||
ACRONYMS = {
|
||||
"HD", "2D", "3D", "4K", "VR", "AI", "API", "USB", "CPU", "GPU", "DVD", "CD",
|
||||
"RPG", "FPS", "MMO", "MMORPG", "LAN", "GUI", "NPC",
|
||||
"FFVII", "FFVIII", "FFIX", "FFX", "FFXII",
|
||||
"FX", "2K", "5K", "8K", "V1", "V2", "V3", "V4", "DOF"
|
||||
}
|
||||
|
||||
def is_roman_numeral(word):
|
||||
return bool(ROMAN_NUMERAL_PATTERN.match(word))
|
||||
|
||||
def title_case_preserve_numbers(name):
|
||||
lowercase_exceptions = {
|
||||
"a", "an", "and", "as", "at", "but", "by", "for", "from",
|
||||
"in", "nor", "of", "on", "or", "so", "the", "to", "with", "yet"
|
||||
}
|
||||
subtitle_markers = {":", "~", "-", "–", "—"}
|
||||
words = name.split()
|
||||
result = []
|
||||
force_capitalize_mode = False
|
||||
|
||||
for idx, raw_word in enumerate(words):
|
||||
contains_marker = any(marker in raw_word for marker in subtitle_markers)
|
||||
split_parts = re.split(r'([:~\-–—])', raw_word)
|
||||
rebuilt_parts = []
|
||||
|
||||
for part in split_parts:
|
||||
if part in subtitle_markers:
|
||||
rebuilt_parts.append(part)
|
||||
force_capitalize_mode = True
|
||||
continue
|
||||
|
||||
lower_part = part.lower()
|
||||
is_first = (idx == 0)
|
||||
is_last = (idx == len(words) - 1)
|
||||
|
||||
def capitalize_special(subword):
|
||||
if subword.upper() in ACRONYMS:
|
||||
return subword.upper()
|
||||
if is_roman_numeral(subword):
|
||||
return subword.upper()
|
||||
for sep in ('&', '+', '|'):
|
||||
if sep in subword:
|
||||
pieces = subword.split(sep)
|
||||
if all(is_roman_numeral(p) for p in pieces):
|
||||
return sep.join(p.upper() for p in pieces)
|
||||
return capitalize_hyphenated(subword)
|
||||
|
||||
if force_capitalize_mode or is_first or is_last or (lower_part not in lowercase_exceptions):
|
||||
sub_parts = part.split('-')
|
||||
rebuilt_parts.append('-'.join(capitalize_special(s) for s in sub_parts))
|
||||
else:
|
||||
rebuilt_parts.append(lower_part)
|
||||
|
||||
result.append(''.join(rebuilt_parts))
|
||||
if not contains_marker:
|
||||
force_capitalize_mode = False
|
||||
|
||||
if result:
|
||||
first_split = result[0].split('-')
|
||||
result[0] = "-".join(capitalize_hyphenated(p) if not is_roman_numeral(p) else p.upper() for p in first_split)
|
||||
|
||||
last_split = result[-1].split('-')
|
||||
result[-1] = "-".join(capitalize_hyphenated(p) if not is_roman_numeral(p) else p.upper() for p in last_split)
|
||||
|
||||
return " ".join(result)
|
||||
|
||||
def clean_title(name):
|
||||
return title_case_preserve_numbers(sanitize_name(name))
|
||||
|
||||
|
||||
# ----- Game & Mod Name Logic -----
|
||||
|
||||
def strip_versions(text):
|
||||
"""
|
||||
Remove any substrings that look like version numbers, e.g.:
|
||||
- 1.0, 1.2.3
|
||||
- v1.0, v2.3.4
|
||||
"""
|
||||
return re.sub(r'\b(v?\d+(?:\.\d+){1,2})\b', '', text, flags=re.IGNORECASE).strip()
|
||||
|
||||
|
||||
def get_game_name_and_mod_name(path, root_dir):
|
||||
relative_path = os.path.relpath(path, root_dir)
|
||||
parts = relative_path.split(os.sep)
|
||||
|
||||
raw_game = parts[0]
|
||||
raw_game = re.sub(r'\[.*?\]', '', raw_game).strip()
|
||||
if ", The" in raw_game:
|
||||
p = raw_game.split(", The")
|
||||
raw_game = f"The {p[0]}{p[1]}"
|
||||
raw_game = raw_game.replace(" - ", " ")
|
||||
|
||||
country = None
|
||||
for p in parts[1:]:
|
||||
if re.search(r'\[.*?\]', p):
|
||||
country = re.sub(r'\[.*?\]', '', p).strip()
|
||||
break
|
||||
if country:
|
||||
raw_game = f"{raw_game} ({country})"
|
||||
game_name = clean_title(raw_game)
|
||||
|
||||
sub_folders = [ re.sub(r'\[.*?\]', '', p).strip() for p in parts[1:] ]
|
||||
sub_folders = [sf for sf in sub_folders if sf.lower() != "pchtxt"]
|
||||
|
||||
if "Aspect Ratio" in relative_path:
|
||||
aspect_folder = os.path.basename(path)
|
||||
raw_mod = f"Aspect Ratio {aspect_folder}"
|
||||
else:
|
||||
if sub_folders:
|
||||
m = re.match(r'^([0-9]+(?:\.[0-9]+)*)\s*(.*)$', sub_folders[0])
|
||||
if m:
|
||||
trailing = m.group(2).strip()
|
||||
if trailing:
|
||||
sub_folders[0] = trailing
|
||||
else:
|
||||
sub_folders = sub_folders[1:]
|
||||
|
||||
if country and sub_folders:
|
||||
prefix = country.lower()
|
||||
candidate = sub_folders[0].lower()
|
||||
if candidate.startswith(prefix):
|
||||
sub_folders[0] = sub_folders[0][len(country):].lstrip()
|
||||
|
||||
if sub_folders:
|
||||
raw_mod = " ".join(sub_folders).strip()
|
||||
else:
|
||||
raw_mod = ""
|
||||
|
||||
raw_mod = strip_versions(raw_mod)
|
||||
m2 = re.match(r'^(.*)\s+v[0-9.]+$', raw_mod, re.IGNORECASE)
|
||||
if m2:
|
||||
raw_mod = m2.group(1).strip()
|
||||
|
||||
mod_name = clean_title(raw_mod) if raw_mod else ""
|
||||
return game_name, mod_name
|
||||
|
||||
|
||||
# ----- File Structure Logic -----
|
||||
|
||||
def create_formatted_structure(folder_path):
|
||||
formatted_path = os.path.join(folder_path, "formatted")
|
||||
os.makedirs(formatted_path, exist_ok=True)
|
||||
print(f"Creating formatted structure at: {formatted_path}\n")
|
||||
|
||||
for root, dirs, files in os.walk(folder_path):
|
||||
if "formatted" in root.split(os.sep):
|
||||
continue
|
||||
|
||||
for filename in files:
|
||||
if not filename.lower().endswith(".pchtxt"):
|
||||
continue
|
||||
|
||||
game_name, mod_name = get_game_name_and_mod_name(root, folder_path)
|
||||
version = filename[:-len(".pchtxt")].strip()
|
||||
combined_dir = f"{game_name} - {mod_name}".rstrip()
|
||||
new_dir = os.path.join(formatted_path, combined_dir)
|
||||
os.makedirs(new_dir, exist_ok=True)
|
||||
|
||||
src = os.path.join(root, filename)
|
||||
dst = os.path.join(new_dir, f"{version}.pchtxt")
|
||||
shutil.copy(src, dst)
|
||||
print(f"Copied {src} → {dst}")
|
||||
|
||||
print("\nDone!\n")
|
||||
|
||||
|
||||
def main(folder_path):
|
||||
create_formatted_structure(folder_path)
|
||||
print("All files have been organized successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python format_repo_2.py /path/to/folder/")
|
||||
sys.exit(1)
|
||||
|
||||
folder_path = sys.argv[1]
|
||||
main(folder_path)
|
||||
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
This script is used to format the pchtxt heirarchy appropriately for the following repository:
|
||||
|
||||
https://github.com/cucholix/NX-IPS-romfs-compilation
|
||||
@@ -1,286 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless extractor using unar/7z (no Keka). Designed for the NX-IPS-romfs-compilation repo.
|
||||
Installs required: `brew install unar p7zip`
|
||||
Set KEEP_TMP=1 to preserve temp extraction folders for debugging.
|
||||
"""
|
||||
import os, re, sys, shutil, tempfile, subprocess, unicodedata, urllib.request, zipfile
|
||||
|
||||
# ---------------- Config ----------------
|
||||
TMP_ROOT = os.path.expanduser("~/keka_extract_tmp") # safe, in HOME
|
||||
KEEP_TMP = os.environ.get("KEEP_TMP", "") != ""
|
||||
# ---------------- Helpers (naming/title) ----------------
|
||||
def sanitize_name(name):
|
||||
n = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
|
||||
for ch in ("'", "’", "`", '"'):
|
||||
n = n.replace(ch, "")
|
||||
n = n.replace(" - ", " ")
|
||||
return " ".join(n.split()).strip()
|
||||
|
||||
def capitalize_hyphenated(word):
|
||||
parts = word.split("-")
|
||||
out = []
|
||||
for p in parts:
|
||||
if not p:
|
||||
out.append("")
|
||||
elif len(p) == 1:
|
||||
out.append(p.upper())
|
||||
else:
|
||||
out.append(p[0].upper() + p[1:].lower())
|
||||
return "-".join(out)
|
||||
|
||||
ROMAN_NUMERAL_PATTERN = re.compile(r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$", re.IGNORECASE)
|
||||
ACRONYMS = {"HD","2D","3D","4K","VR","AI","API","USB","CPU","GPU","DVD","CD",
|
||||
"RPG","FPS","MMO","MMORPG","LAN","GUI","NPC",
|
||||
"FFVII","FFVIII","FFIX","FFX","FFXII","FX","2K","5K","8K","V1","V2","V3","V4","DOF"}
|
||||
|
||||
def is_roman_numeral(w): return bool(ROMAN_NUMERAL_PATTERN.match(w))
|
||||
|
||||
def title_case_preserve_numbers(name):
|
||||
lowercase_exceptions = {"a","an","and","as","at","but","by","for","from","in","nor","of","on","or","so","the","to","with","yet"}
|
||||
subtitle_markers = {":","~","-","–","—"}
|
||||
words = name.split()
|
||||
result = []
|
||||
force_cap = False
|
||||
for idx, word in enumerate(words):
|
||||
contains_marker = any(m in word for m in subtitle_markers)
|
||||
parts = re.split(r'([:~\-–—])', word)
|
||||
out_parts = []
|
||||
for part in parts:
|
||||
if part in subtitle_markers:
|
||||
out_parts.append(part)
|
||||
force_cap = True
|
||||
continue
|
||||
lp = part.lower()
|
||||
is_first = idx == 0
|
||||
is_last = idx == len(words) - 1
|
||||
def cap_special(w):
|
||||
if w.upper() in ACRONYMS: return w.upper()
|
||||
if is_roman_numeral(w): return w.upper()
|
||||
for sep in ('&','+','|'):
|
||||
if sep in w:
|
||||
sub = w.split(sep)
|
||||
if all(is_roman_numeral(x) for x in sub):
|
||||
return sep.join(x.upper() for x in sub)
|
||||
return capitalize_hyphenated(w)
|
||||
if force_cap or is_first or is_last or (lp not in lowercase_exceptions):
|
||||
out_parts.append('-'.join(cap_special(sp) for sp in part.split('-')))
|
||||
else:
|
||||
out_parts.append(lp)
|
||||
result.append(''.join(out_parts))
|
||||
if not contains_marker:
|
||||
force_cap = False
|
||||
if result:
|
||||
result[0] = '-'.join(sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp) for sp in result[0].split('-'))
|
||||
result[-1] = '-'.join(sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp) for sp in result[-1].split('-'))
|
||||
return ' '.join(result)
|
||||
|
||||
def find_title_path(path):
|
||||
hex_re = re.compile(r'^[0-9A-Fa-f]{16}$')
|
||||
for root, dirs, _ in os.walk(path):
|
||||
for d in dirs:
|
||||
if hex_re.match(d):
|
||||
return os.path.join(root, d)
|
||||
return None
|
||||
|
||||
# ---------------- Extraction functions ----------------
|
||||
def which_exec(names):
|
||||
for n in names:
|
||||
p = shutil.which(n)
|
||||
if p:
|
||||
return p
|
||||
return None
|
||||
|
||||
UNAR = which_exec(["unar"])
|
||||
SEVENZ = which_exec(["7z", "7zz"]) # prefer 7z if present
|
||||
|
||||
def extract_with_unar(archive_path, outdir):
|
||||
if UNAR is None:
|
||||
return False, "unar not installed"
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
cmd = [UNAR, "-o", outdir, "-f", archive_path] # -f overwrite if needed
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
out = proc.stdout.decode(errors="replace")
|
||||
err = proc.stderr.decode(errors="replace")
|
||||
ok = (proc.returncode == 0)
|
||||
return ok, f"rc={proc.returncode} stdout={out[:1000]!r} stderr={err[:1000]!r}"
|
||||
|
||||
def extract_with_7z(archive_path, outdir):
|
||||
if SEVENZ is None:
|
||||
return False, "7z not installed"
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
# 7z expects -o<dir> (no space) — but passing as single arg works too for many wrappers; use concatenated to be safe
|
||||
cmd = [SEVENZ, "x", archive_path, f"-o{outdir}", "-y"]
|
||||
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
out = proc.stdout.decode(errors="replace")
|
||||
err = proc.stderr.decode(errors="replace")
|
||||
ok = (proc.returncode == 0)
|
||||
return ok, f"rc={proc.returncode} stdout={out[:1000]!r} stderr={err[:1000]!r}"
|
||||
|
||||
def extract_archive_headless(archive_path, outdir):
|
||||
"""
|
||||
Try best extractor for extension:
|
||||
.rar -> unar then 7z
|
||||
.7z -> 7z then unar
|
||||
Returns (True, debugstr) or (False, debugstr)
|
||||
"""
|
||||
ext = archive_path.lower().rsplit(".",1)[-1]
|
||||
if ext == "rar":
|
||||
# prefer unar for rar
|
||||
if UNAR:
|
||||
ok, dbg = extract_with_unar(archive_path, outdir)
|
||||
if ok: return True, "unar: " + dbg
|
||||
# else try 7z
|
||||
if SEVENZ:
|
||||
ok, dbg = extract_with_7z(archive_path, outdir)
|
||||
if ok: return True, "7z: " + dbg
|
||||
return False, "no suitable extractor succeeded; tried unar and 7z. " + (dbg if 'dbg' in locals() else "")
|
||||
elif ext == "7z":
|
||||
if SEVENZ:
|
||||
ok, dbg = extract_with_7z(archive_path, outdir)
|
||||
if ok: return True, "7z: " + dbg
|
||||
if UNAR:
|
||||
ok, dbg = extract_with_unar(archive_path, outdir)
|
||||
if ok: return True, "unar: " + dbg
|
||||
return False, "no suitable extractor succeeded for .7z"
|
||||
else:
|
||||
# generic try unar then 7z
|
||||
if UNAR:
|
||||
ok, dbg = extract_with_unar(archive_path, outdir)
|
||||
if ok: return True, "unar: " + dbg
|
||||
if SEVENZ:
|
||||
ok, dbg = extract_with_7z(archive_path, outdir)
|
||||
if ok: return True, "7z: " + dbg
|
||||
return False, "no extractor available for extension: " + ext
|
||||
|
||||
# ---------------- Processing per archive ----------------
|
||||
def process_rar(root_folder, rar_relpath, output_root):
|
||||
subdir, filename = os.path.split(rar_relpath)
|
||||
version_match = re.match(r"release_(.+?)(?:\.part\d+)?\.(rar|7z)$", filename, re.IGNORECASE)
|
||||
if not version_match:
|
||||
print(f"❌ Invalid release name: {filename}")
|
||||
return
|
||||
version = version_match.group(1)
|
||||
raw_game_name = os.path.basename(subdir)
|
||||
cleaned_name = sanitize_name(raw_game_name)
|
||||
game_name = title_case_preserve_numbers(cleaned_name)
|
||||
pack_label = f"{game_name} - Graphics Pack"
|
||||
|
||||
rar_path = os.path.join(root_folder, rar_relpath)
|
||||
|
||||
os.makedirs(TMP_ROOT, exist_ok=True)
|
||||
tmpdir = tempfile.mkdtemp(prefix="extract_", dir=TMP_ROOT)
|
||||
try:
|
||||
ok, dbg = extract_archive_headless(rar_path, tmpdir)
|
||||
if not ok:
|
||||
print(f"❌ Extraction error (headless) on {rar_relpath}: {dbg}")
|
||||
if KEEP_TMP:
|
||||
print("Temp dir kept at:", tmpdir)
|
||||
return
|
||||
|
||||
title_path = find_title_path(tmpdir)
|
||||
if not title_path:
|
||||
# maybe files extracted but no TitleID folder, show debugging
|
||||
any_files = False
|
||||
for _r,_d,files in os.walk(tmpdir):
|
||||
if files:
|
||||
any_files = True
|
||||
break
|
||||
if any_files:
|
||||
print(f"❌ Extracted but no TitleID found for {rar_relpath}. Inspect: {tmpdir} — dbg: {dbg}")
|
||||
else:
|
||||
print(f"❌ No files extracted for {rar_relpath}. dbg: {dbg}")
|
||||
if KEEP_TMP:
|
||||
print("Temp dir kept at:", tmpdir)
|
||||
return
|
||||
|
||||
title_id = os.path.basename(title_path)
|
||||
version_dir = os.path.join(output_root, pack_label, version)
|
||||
os.makedirs(version_dir, exist_ok=True)
|
||||
dst = os.path.join(version_dir, title_id)
|
||||
try:
|
||||
shutil.copytree(title_path, dst, dirs_exist_ok=True)
|
||||
print(f"✅ {rar_relpath} → {os.path.join(pack_label, version, title_id)}")
|
||||
except Exception as e:
|
||||
print(f"❌ Copy failed for {rar_relpath}: {e}")
|
||||
|
||||
finally:
|
||||
if KEEP_TMP:
|
||||
print("Kept tmpdir for debugging:", tmpdir)
|
||||
else:
|
||||
try:
|
||||
shutil.rmtree(tmpdir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------- pchtxt processing (unchanged) ----------------
|
||||
def process_pchtxt(root_folder, output_path):
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
print(f"Creating formatted pchtxt structure at: {output_path}\n")
|
||||
for current_root, dirs, files in os.walk(root_folder):
|
||||
if 'contents_formatted' in current_root or 'pchtxt_formatted' in current_root:
|
||||
continue
|
||||
for file in files:
|
||||
if not file.lower().endswith('.pchtxt'):
|
||||
continue
|
||||
version = file[:-len('.pchtxt')].strip()
|
||||
parent_dir = os.path.basename(current_root)
|
||||
game_name = sanitize_name(parent_dir)
|
||||
if game_name.endswith("Graphics"):
|
||||
game_name = game_name[:-len("Graphics")].strip()
|
||||
game_name = title_case_preserve_numbers(game_name)
|
||||
mod_name = "Graphics Mods"
|
||||
target_dir = os.path.join(output_path, f"{game_name} - {mod_name}")
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
source_path = os.path.join(current_root, file)
|
||||
dest_path = os.path.join(target_dir, f"{version}.pchtxt")
|
||||
shutil.copy2(source_path, dest_path)
|
||||
print(f"Copied {source_path} → {dest_path}")
|
||||
print("\nDone processing pchtxt!")
|
||||
|
||||
# ---------------- main ----------------
|
||||
def main():
|
||||
# refuse to run as root — running as root was causing /var/root/ issues earlier
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
print("Refusing to run as root. Run this script as your normal user (no sudo).")
|
||||
sys.exit(1)
|
||||
|
||||
zip_url = "https://github.com/cucholix/NX-IPS-romfs-compilation/archive/refs/heads/main.zip"
|
||||
zip_path = "NX-IPS-romfs-compilation-main.zip"
|
||||
unzip_dir = "NX-IPS-romfs-compilation-main"
|
||||
|
||||
if not os.path.exists(unzip_dir):
|
||||
if not os.path.exists(zip_path):
|
||||
print("Downloading repo zip...")
|
||||
urllib.request.urlretrieve(zip_url, zip_path)
|
||||
print("Download complete.")
|
||||
print("Unzipping repo...")
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(".")
|
||||
print("Unzip complete.")
|
||||
|
||||
root = unzip_dir
|
||||
output_contents = os.path.join(".", "contents_formatted")
|
||||
os.makedirs(output_contents, exist_ok=True)
|
||||
|
||||
tasks = []
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for fn in files:
|
||||
if re.match(r"release_(.+?)(?:\.part\d+)?\.(rar|7z)$", fn, re.IGNORECASE):
|
||||
if fn.lower().endswith(".part01.rar") or (fn.lower().endswith(".rar") and ".part" not in fn.lower()) or fn.lower().endswith(".7z"):
|
||||
rel = os.path.relpath(os.path.join(dirpath, fn), root)
|
||||
tasks.append(rel)
|
||||
|
||||
if not UNAR and not SEVENZ:
|
||||
print("ERROR: neither 'unar' nor '7z' found. Install with: brew install unar p7zip")
|
||||
sys.exit(1)
|
||||
|
||||
for relpath in tasks:
|
||||
process_rar(root, relpath, output_contents)
|
||||
|
||||
print("✅ Done processing content mods.")
|
||||
output_pchtxt = os.path.join(".", "pchtxt_formatted")
|
||||
process_pchtxt(root, output_pchtxt)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,247 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
import unicodedata
|
||||
|
||||
def sanitize_name(name):
|
||||
"""Normalize game names: remove symbols, accents, and replace ' - ' with space."""
|
||||
n = unicodedata.normalize('NFKD', name).encode('ascii','ignore').decode('ascii')
|
||||
n = n.replace("'", "").replace("’", "").replace("`", "").replace('"', "")
|
||||
n = n.replace(" - ", " ") # Replace problematic ' - ' only in the game name
|
||||
return ' '.join(n.split()).strip()
|
||||
|
||||
def capitalize_hyphenated(word):
|
||||
"""
|
||||
Capitalize both parts of a hyphenated word. E.g. "yooka-laylee" → "Yooka-Laylee".
|
||||
"""
|
||||
parts = word.split('-')
|
||||
capitalized = []
|
||||
for part in parts:
|
||||
if part:
|
||||
capitalized.append(
|
||||
part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper()
|
||||
)
|
||||
else:
|
||||
capitalized.append('')
|
||||
return '-'.join(capitalized)
|
||||
|
||||
# Regex for Roman numerals (supports up to 3999): I, II, III, IV, V, etc.
|
||||
ROMAN_NUMERAL_PATTERN = re.compile(
|
||||
r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# A set of known acronyms that should remain uppercase exactly as is.
|
||||
ACRONYMS = {
|
||||
"HD", "2D", "3D", "4K", "VR", "AI", "API", "USB", "CPU", "GPU", "DVD", "CD",
|
||||
"RPG", "FPS", "MMO", "MMORPG", "LAN", "GUI", "NPC",
|
||||
"FFVII", "FFVIII", "FFIX", "FFX", "FFXII",
|
||||
"FX", "2K", "5K", "8K", "V1", "V2", "V3", "V4", "DOF"
|
||||
}
|
||||
|
||||
def is_roman_numeral(word):
|
||||
"""Return True if the word is a valid Roman numeral (e.g. I, ii, Xx, etc.)."""
|
||||
return bool(ROMAN_NUMERAL_PATTERN.match(word))
|
||||
|
||||
def title_case_preserve_numbers(name):
|
||||
"""
|
||||
Title-case with these rules:
|
||||
• Fully uppercase acronyms remain unchanged.
|
||||
• Roman numerals are fully uppercase.
|
||||
• Hyphenated words are capitalized on both sides.
|
||||
• Conjoined Roman numerals with '&', '+', or '|' become fully uppercase.
|
||||
• Small filler words (a, an, and, the, of, in, etc.) become lowercase
|
||||
only if they are in the middle of the title (not first, not last, not
|
||||
immediately after a subtitle marker).
|
||||
• After a subtitle marker ( :, ~, –, —, - ), force capitalization on all
|
||||
subsequent words until the next subtitle marker or end-of-title.
|
||||
"""
|
||||
lowercase_exceptions = {
|
||||
"a", "an", "and", "as", "at", "but", "by", "for", "from",
|
||||
"in", "nor", "of", "on", "or", "so", "the", "to", "with", "yet"
|
||||
}
|
||||
subtitle_markers = {":", "~", "-", "–", "—"}
|
||||
|
||||
words = name.split()
|
||||
result = []
|
||||
force_capitalize_mode = False # Once True, it stays True until next subtitle marker
|
||||
|
||||
for idx, word in enumerate(words):
|
||||
# Detect if this word contains any subtitle marker character
|
||||
contains_marker = any(marker in word for marker in subtitle_markers)
|
||||
|
||||
# Split on subtitle markers but keep them in the list
|
||||
split_parts = re.split(r'([:~\-–—])', word)
|
||||
capitalized_parts = []
|
||||
|
||||
for part in split_parts:
|
||||
if part in subtitle_markers:
|
||||
# Keep the subtitle marker, then force subsequent parts to capitalize
|
||||
capitalized_parts.append(part)
|
||||
force_capitalize_mode = True
|
||||
continue
|
||||
|
||||
lower_part = part.lower()
|
||||
is_first = (idx == 0)
|
||||
is_last = (idx == len(words) - 1)
|
||||
|
||||
# Helper: capitalize a sub-word with special rules
|
||||
def capitalize_special(w):
|
||||
if w.upper() in ACRONYMS:
|
||||
return w.upper()
|
||||
if is_roman_numeral(w):
|
||||
return w.upper()
|
||||
for sep in ['&', '+', '|']:
|
||||
if sep in w:
|
||||
sub = w.split(sep)
|
||||
if all(is_roman_numeral(x) for x in sub):
|
||||
return sep.join(x.upper() for x in sub)
|
||||
# Otherwise, capitalize hyphenated words normally
|
||||
return capitalize_hyphenated(w)
|
||||
|
||||
# Decide how to capitalize this segment:
|
||||
if (
|
||||
force_capitalize_mode or
|
||||
is_first or
|
||||
is_last or
|
||||
(lower_part not in lowercase_exceptions)
|
||||
):
|
||||
sub_parts = part.split('-')
|
||||
capitalized_sub_parts = [capitalize_special(sp) for sp in sub_parts]
|
||||
capitalized_parts.append('-'.join(capitalized_sub_parts))
|
||||
else:
|
||||
capitalized_parts.append(lower_part)
|
||||
|
||||
result.append(''.join(capitalized_parts))
|
||||
|
||||
# If this word did not contain a subtitle marker, stop forcing next capitalization
|
||||
if not contains_marker:
|
||||
force_capitalize_mode = False
|
||||
|
||||
# Always re-capitalize FIRST and LAST words fully using the same special rules
|
||||
if result:
|
||||
first_word_parts = result[0].split('-')
|
||||
result[0] = '-'.join(
|
||||
sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp)
|
||||
for sp in first_word_parts
|
||||
)
|
||||
last_word_parts = result[-1].split('-')
|
||||
result[-1] = '-'.join(
|
||||
sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp)
|
||||
for sp in last_word_parts
|
||||
)
|
||||
|
||||
return ' '.join(result)
|
||||
|
||||
def find_title_id(path):
|
||||
"""Look for a 16-character Title ID under any 'contents/' directory in path."""
|
||||
for root, dirs, _ in os.walk(path):
|
||||
if os.path.basename(root).lower() == 'contents':
|
||||
for d in dirs:
|
||||
if re.fullmatch(r'[0-9A-Fa-f]{16}', d):
|
||||
return d
|
||||
return None
|
||||
|
||||
def extract_with_7z(rar_path, tmpdir):
|
||||
"""
|
||||
Extract only atmosphere/contents/* using 7z.
|
||||
Return True if extraction succeeded (7z return code 0 OR
|
||||
we already found a Title ID folder inside tmpdir).
|
||||
"""
|
||||
cmd = [
|
||||
"7z", "x", "-y",
|
||||
rar_path,
|
||||
f"-o{tmpdir}",
|
||||
"atmosphere/contents/*"
|
||||
]
|
||||
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return (proc.returncode == 0) or (find_title_id(tmpdir) is not None)
|
||||
|
||||
def process_rar(root_folder, rar_relpath, output_root):
|
||||
"""
|
||||
1) Parse “release_<version>.rar” or “release_<version>.part01.rar” → version string.
|
||||
2) Sanitize + title-case the game folder name.
|
||||
3) Extract with 7z into a temp dir.
|
||||
4) Find Title ID under atmosphere/contents/<16hex>/.
|
||||
5) Copy that content into output/<GameName>/version/<TitleID>/.
|
||||
"""
|
||||
subdir, filename = os.path.split(rar_relpath)
|
||||
|
||||
# New regex: capture base version, ignoring any “.partXX”
|
||||
version_match = re.match(r"release_(.+?)(?:\.part\d+)?\.rar$", filename, re.IGNORECASE)
|
||||
if not version_match:
|
||||
print(f"❌ Invalid release name: {filename}")
|
||||
return
|
||||
version = version_match.group(1) # e.g. “1.2.4” even if filename was “release_1.2.4.part01.rar”
|
||||
raw_game_name = os.path.basename(subdir)
|
||||
|
||||
# 2) Clean & normalize game name
|
||||
cleaned_name = sanitize_name(raw_game_name)
|
||||
game_name = title_case_preserve_numbers(cleaned_name)
|
||||
pack_label = f"{game_name} - Graphics Pack"
|
||||
|
||||
rar_path = os.path.join(root_folder, rar_relpath)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ok = extract_with_7z(rar_path, tmp)
|
||||
if not ok:
|
||||
print(f"❌ Extraction error (7z) on {rar_relpath}")
|
||||
return
|
||||
|
||||
title_id = find_title_id(tmp)
|
||||
if not title_id:
|
||||
print(f"❌ No Title ID found in {rar_relpath}")
|
||||
return
|
||||
|
||||
version_dir = os.path.join(output_root, pack_label, version)
|
||||
os.makedirs(version_dir, exist_ok=True)
|
||||
|
||||
src = os.path.join(tmp, "atmosphere", "contents", title_id)
|
||||
dst = os.path.join(version_dir, title_id)
|
||||
try:
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
print(f"✅ {rar_relpath} → {os.path.join(pack_label, version, title_id)}")
|
||||
except Exception as e:
|
||||
print(f"❌ Copy failed for {rar_relpath}: {e}")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python collect_content_mods.py /path/to/root")
|
||||
sys.exit(1)
|
||||
|
||||
root = sys.argv[1]
|
||||
output = os.path.join(root, "format2")
|
||||
os.makedirs(output, exist_ok=True)
|
||||
|
||||
tasks = []
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for fn in files:
|
||||
# 1) Only consider RARs that are either:
|
||||
# • single-part: “release_<version>.rar”
|
||||
# • first part of multi-part: “release_<version>.part01.rar”
|
||||
#
|
||||
# Regex explanation:
|
||||
# release_ → literal prefix
|
||||
# (.+?) → capture “version” (non-greedy)
|
||||
# (?:\.part\d+)? → optionally “.partNN” (where NN = digits)
|
||||
# \.rar$ → end with “.rar”
|
||||
m = re.match(r"release_(.+?)(?:\.part\d+)?\.rar$", fn, re.IGNORECASE)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
# If it really is a “.partNN.rar” (some NN > 1), skip it.
|
||||
# We only want “.part01” or no “.part” at all.
|
||||
if fn.lower().endswith(".part01.rar") or fn.lower().endswith(".rar") and ".part" not in fn.lower():
|
||||
rel = os.path.relpath(os.path.join(dirpath, fn), root)
|
||||
tasks.append(rel)
|
||||
|
||||
for relpath in tasks:
|
||||
process_rar(root, relpath, output)
|
||||
|
||||
print("✅ Done.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import shutil
|
||||
import unicodedata
|
||||
import sys
|
||||
import re
|
||||
|
||||
def sanitize_name(name):
|
||||
"""
|
||||
Remove accents and unwanted characters, and replace ' - ' with a single space.
|
||||
"""
|
||||
normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii')
|
||||
cleaned = normalized.replace("'", "").replace("’", "").replace("`", "").replace('"', '')
|
||||
cleaned = cleaned.replace(" - ", " ") # Remove " - " to avoid duplication
|
||||
cleaned = ' '.join(cleaned.split()) # Collapse multiple spaces
|
||||
return cleaned.strip()
|
||||
|
||||
def capitalize_hyphenated(word):
|
||||
"""
|
||||
Capitalize both parts of a hyphenated word. E.g. "yooka-laylee" → "Yooka-Laylee".
|
||||
"""
|
||||
parts = word.split('-')
|
||||
capitalized_parts = []
|
||||
for part in parts:
|
||||
if part:
|
||||
capitalized_parts.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper())
|
||||
else:
|
||||
capitalized_parts.append('')
|
||||
return '-'.join(capitalized_parts)
|
||||
|
||||
# Regex for Roman numerals (supports up to 3999): I, II, III, IV, V, VI, VII, VIII, IX, X, XI, etc.
|
||||
ROMAN_NUMERAL_PATTERN = re.compile(
|
||||
r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Known acronyms to force‐uppercase exactly
|
||||
ACRONYMS = {
|
||||
"HD", "2D", "3D", "4K", "VR", "AI", "API", "USB", "CPU", "GPU", "DVD", "CD",
|
||||
"RPG", "FPS", "MMO", "MMORPG", "LAN", "GUI", "NPC",
|
||||
"FFVII", "FFVIII", "FFIX", "FFX", "FFXII",
|
||||
"FX", "2K", "5K", "8K", "V1", "V2", "V3", "V4", "DOF"
|
||||
}
|
||||
|
||||
def is_roman_numeral(word):
|
||||
"""
|
||||
Return True if the word is a valid Roman numeral (case-insensitive).
|
||||
"""
|
||||
return bool(ROMAN_NUMERAL_PATTERN.match(word))
|
||||
|
||||
def title_case_preserve_numbers(name):
|
||||
"""
|
||||
Title-case with these rules:
|
||||
• Fully uppercase acronyms remain unchanged (e.g. HD, 2D, 3D, FFVII, etc.).
|
||||
• Roman numerals become fully uppercase (e.g. iIi → III, xI → XI).
|
||||
• Hyphenated words are capitalized on both sides (→ Yooka-Laylee).
|
||||
• Conjoined Roman numerals with '&', '+', or '|' become fully uppercase (e.g. I&ii → I&II).
|
||||
• Small filler words (a, an, and, the, of, in, etc.) become lowercase
|
||||
only if they appear in the middle and are not immediately after a subtitle marker,
|
||||
except the first and last words (which always capitalized).
|
||||
• After a subtitle marker (":", "~", "–", "—", or "-"), force capitalization
|
||||
on all subsequent words (until the next subtitle marker or end).
|
||||
"""
|
||||
lowercase_exceptions = {
|
||||
"a", "an", "and", "as", "at", "but", "by", "for", "from",
|
||||
"in", "nor", "of", "on", "or", "so", "the", "to", "with", "yet"
|
||||
}
|
||||
subtitle_markers = {":", "~", "-", "–", "—"}
|
||||
|
||||
words = name.split()
|
||||
result = []
|
||||
force_capitalize_mode = False # Once True, stays True until next subtitle marker
|
||||
|
||||
for idx, word in enumerate(words):
|
||||
# Detect if this word contains any subtitle marker character
|
||||
contains_marker = any(marker in word for marker in subtitle_markers)
|
||||
|
||||
# Split on subtitle markers but keep them in the list
|
||||
split_parts = re.split(r'([:~\-–—])', word)
|
||||
capitalized_parts = []
|
||||
|
||||
for part in split_parts:
|
||||
if part in subtitle_markers:
|
||||
# Append the marker itself, then force-capitalize subsequent parts
|
||||
capitalized_parts.append(part)
|
||||
force_capitalize_mode = True
|
||||
continue
|
||||
|
||||
lower_part = part.lower()
|
||||
is_first = (idx == 0)
|
||||
is_last = (idx == len(words) - 1)
|
||||
|
||||
# Helper: capitalize a sub-word with special rules for acronyms, roman numerals, and conjoined numerals
|
||||
def capitalize_special(w):
|
||||
# If w (case-insensitive) is in our ACRONYMS set, uppercase it fully.
|
||||
if w.upper() in ACRONYMS:
|
||||
return w.upper()
|
||||
# If w alone is a Roman numeral, uppercase it fully.
|
||||
if is_roman_numeral(w):
|
||||
return w.upper()
|
||||
# Handle compound Roman numerals separated by &, +, or |
|
||||
for sep in ['&', '+', '|']:
|
||||
if sep in w:
|
||||
parts = w.split(sep)
|
||||
if all(is_roman_numeral(p) for p in parts):
|
||||
return sep.join(p.upper() for p in parts)
|
||||
# Otherwise, capitalize hyphenated words normally.
|
||||
return capitalize_hyphenated(w)
|
||||
|
||||
# Decide how to capitalize this segment:
|
||||
if force_capitalize_mode or is_first or is_last or (lower_part not in lowercase_exceptions):
|
||||
# Split any hyphens, apply capitalize_special to each half
|
||||
sub_parts = part.split('-')
|
||||
capitalized_sub = [capitalize_special(sp) for sp in sub_parts]
|
||||
capitalized_parts.append('-'.join(capitalized_sub))
|
||||
else:
|
||||
# In-the-middle filler word: keep lowercase
|
||||
capitalized_parts.append(lower_part)
|
||||
|
||||
result.append(''.join(capitalized_parts))
|
||||
|
||||
# If this word did not contain a subtitle marker, stop forcing next capitalization
|
||||
if not contains_marker:
|
||||
force_capitalize_mode = False
|
||||
|
||||
# Always capitalize the FIRST and LAST words (using the same special rules):
|
||||
if result:
|
||||
first_parts = result[0].split('-')
|
||||
result[0] = '-'.join(
|
||||
sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp)
|
||||
for sp in first_parts
|
||||
)
|
||||
|
||||
last_parts = result[-1].split('-')
|
||||
result[-1] = '-'.join(
|
||||
sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp)
|
||||
for sp in last_parts
|
||||
)
|
||||
|
||||
return ' '.join(result)
|
||||
|
||||
def create_formatted_structure(root_folder):
|
||||
"""
|
||||
Walk root_folder for all .pchtxt files. For each one:
|
||||
1. Extract folder name → raw game name.
|
||||
2. sanitize_name() → remove weird characters.
|
||||
3. Remove trailing "Graphics" if present.
|
||||
4. title_case_preserve_numbers() to get final Game Name.
|
||||
5. Create folder: formatted/<Game Name> - Graphics Mods/
|
||||
6. Copy each .pchtxt into that folder as <version>.pchtxt.
|
||||
"""
|
||||
formatted_path = os.path.join(root_folder, 'formatted')
|
||||
os.makedirs(formatted_path, exist_ok=True)
|
||||
print(f"Creating formatted structure at: {formatted_path}\n")
|
||||
|
||||
for current_root, dirs, files in os.walk(root_folder):
|
||||
for file in files:
|
||||
if not file.lower().endswith('.pchtxt'):
|
||||
continue
|
||||
|
||||
version = file[:-len('.pchtxt')].strip()
|
||||
parent_dir = os.path.basename(current_root)
|
||||
game_name = sanitize_name(parent_dir)
|
||||
|
||||
# Remove trailing "Graphics" if it exists (exact match at end)
|
||||
if game_name.endswith("Graphics"):
|
||||
game_name = game_name[:-len("Graphics")].strip()
|
||||
|
||||
# Title-case with acronyms, roman-numeral, and compound-numeral logic
|
||||
game_name = title_case_preserve_numbers(game_name)
|
||||
|
||||
mod_name = "Graphics Mods"
|
||||
target_dir = os.path.join(formatted_path, f"{game_name} - {mod_name}")
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
source_path = os.path.join(current_root, file)
|
||||
dest_path = os.path.join(target_dir, f"{version}.pchtxt")
|
||||
|
||||
shutil.copy2(source_path, dest_path)
|
||||
print(f"Copied {source_path} → {dest_path}")
|
||||
|
||||
print("\nDone!")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python collect_graphics_mods.py /path/to/root/folder")
|
||||
sys.exit(1)
|
||||
|
||||
folder_path = sys.argv[1]
|
||||
create_formatted_structure(folder_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,99 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from difflib import get_close_matches
|
||||
|
||||
# Load title DB
|
||||
with open("EE.en.json", "r", encoding="utf-8") as f:
|
||||
title_db = json.load(f)
|
||||
|
||||
# Normalize title names for matching
|
||||
def normalize_title(text):
|
||||
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii') # remove accents
|
||||
text = re.sub(r'[^a-zA-Z0-9 ]', '', text).lower() # remove punctuation and lowercase
|
||||
return ' '.join(text.split()) # normalize whitespace
|
||||
|
||||
# Create a normalized mapping of game name -> title ID
|
||||
title_map = {}
|
||||
normalized_title_map = {}
|
||||
for entry in title_db.values():
|
||||
name = entry.get("name")
|
||||
tid = entry.get("id")
|
||||
if name and tid:
|
||||
title_map[name.strip()] = tid.strip()
|
||||
normalized_title_map[normalize_title(name)] = tid.strip()
|
||||
|
||||
# Fuzzy match game name to title ID using normalized names
|
||||
def get_title_id(game_name):
|
||||
if not game_name:
|
||||
return None
|
||||
|
||||
norm_query = normalize_title(game_name)
|
||||
|
||||
# Exact match
|
||||
if norm_query in normalized_title_map:
|
||||
return normalized_title_map[norm_query]
|
||||
|
||||
# Substring match (e.g., "Atelier Sophie 2" in "Atelier Sophie 2: The Alchemist of the Mysterious Dream")
|
||||
for title_key in normalized_title_map.keys():
|
||||
if norm_query in title_key:
|
||||
return normalized_title_map[title_key]
|
||||
|
||||
# Fuzzy fallback
|
||||
matches = get_close_matches(norm_query, normalized_title_map.keys(), n=1, cutoff=0.5)
|
||||
if matches:
|
||||
return normalized_title_map[matches[0]]
|
||||
|
||||
return None
|
||||
|
||||
# Update all .pchtxt files in formatted/
|
||||
def update_pchtxt_headers(formatted_dir):
|
||||
formatted_path = Path(formatted_dir)
|
||||
if not formatted_path.exists():
|
||||
print(f"❌ Directory not found: {formatted_dir}")
|
||||
return
|
||||
|
||||
for game_folder in formatted_path.iterdir():
|
||||
if not game_folder.is_dir() or " - Graphics Mods" not in game_folder.name:
|
||||
continue
|
||||
|
||||
game_name = game_folder.name.replace(" - Graphics Mods", "").strip()
|
||||
if not game_name:
|
||||
print(f"⚠️ Could not extract game name from folder: {game_folder.name}")
|
||||
continue
|
||||
|
||||
title_id = get_title_id(game_name)
|
||||
if not title_id:
|
||||
print(f"❌ Title ID not found for: {game_name}")
|
||||
continue
|
||||
|
||||
for pchtxt in game_folder.glob("*.pchtxt"):
|
||||
try:
|
||||
with open(pchtxt, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Replace or insert title ID in header line
|
||||
updated = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("#"):
|
||||
if "[" in line and "]" in line:
|
||||
lines[i] = f"# {game_name} [{title_id}]\n"
|
||||
else:
|
||||
lines[i] = line.strip() + f" [{title_id}]\n"
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
lines.insert(1, f"# {game_name} [{title_id}]\n")
|
||||
|
||||
with open(pchtxt, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print(f"✅ Updated: {pchtxt.name} with [{title_id}]")
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing {pchtxt.name}: {e}")
|
||||
|
||||
# Run it
|
||||
update_pchtxt_headers("formatted")
|
||||
@@ -1,409 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from difflib import get_close_matches
|
||||
|
||||
def load_title_database(db_path="US.en.json"):
|
||||
"""Load the title database from JSON file."""
|
||||
try:
|
||||
with open(db_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"❌ Title database not found: {db_path}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError:
|
||||
print(f"❌ Invalid JSON in title database: {db_path}")
|
||||
sys.exit(1)
|
||||
|
||||
def load_cnmts_database(cnmts_path="cnmts.json"):
|
||||
"""Load the cnmts database from JSON file."""
|
||||
try:
|
||||
with open(cnmts_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"❌ CNMTS database not found: {cnmts_path}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError:
|
||||
print(f"❌ Invalid JSON in CNMTS database: {cnmts_path}")
|
||||
sys.exit(1)
|
||||
|
||||
def sanitize_name(name):
|
||||
"""Remove accents and unwanted characters, and replace ' - ' with a single space."""
|
||||
normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii')
|
||||
cleaned = normalized.replace("'", "").replace("'", "").replace("`", "").replace('"', '')
|
||||
cleaned = cleaned.replace(" - ", " ")
|
||||
cleaned = ' '.join(cleaned.split())
|
||||
return cleaned.strip()
|
||||
|
||||
def capitalize_hyphenated(word):
|
||||
"""Capitalize both parts of a hyphenated word."""
|
||||
parts = word.split('-')
|
||||
capitalized_parts = []
|
||||
for part in parts:
|
||||
if part:
|
||||
capitalized_parts.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper())
|
||||
else:
|
||||
capitalized_parts.append('')
|
||||
return '-'.join(capitalized_parts)
|
||||
|
||||
ROMAN_NUMERAL_PATTERN = re.compile(
|
||||
r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
ACRONYMS = {
|
||||
"HD", "2D", "3D", "4K", "VR", "AI", "API", "USB", "CPU", "GPU", "DVD", "CD",
|
||||
"RPG", "FPS", "MMO", "MMORPG", "LAN", "GUI", "NPC",
|
||||
"FFVII", "FFVIII", "FFIX", "FFX", "FFXII",
|
||||
"FX", "2K", "5K", "8K", "V1", "V2", "V3", "V4", "DOF"
|
||||
}
|
||||
|
||||
def is_roman_numeral(word):
|
||||
"""Return True if the word is a valid Roman numeral."""
|
||||
return bool(ROMAN_NUMERAL_PATTERN.match(word))
|
||||
|
||||
def title_case_preserve_numbers(name):
|
||||
"""Title-case with special rules for acronyms, Roman numerals, etc."""
|
||||
lowercase_exceptions = {
|
||||
"a", "an", "and", "as", "at", "but", "by", "for", "from",
|
||||
"in", "nor", "of", "on", "or", "so", "the", "to", "with", "yet"
|
||||
}
|
||||
subtitle_markers = {":", "~", "-", "–", "—"}
|
||||
|
||||
words = name.split()
|
||||
result = []
|
||||
force_capitalize_mode = False
|
||||
|
||||
for idx, word in enumerate(words):
|
||||
contains_marker = any(marker in word for marker in subtitle_markers)
|
||||
split_parts = re.split(r'([:~\-–—])', word)
|
||||
capitalized_parts = []
|
||||
|
||||
for part in split_parts:
|
||||
if part in subtitle_markers:
|
||||
capitalized_parts.append(part)
|
||||
force_capitalize_mode = True
|
||||
continue
|
||||
|
||||
lower_part = part.lower()
|
||||
is_first = (idx == 0)
|
||||
is_last = (idx == len(words) - 1)
|
||||
|
||||
def capitalize_special(w):
|
||||
if w.upper() in ACRONYMS:
|
||||
return w.upper()
|
||||
if is_roman_numeral(w):
|
||||
return w.upper()
|
||||
for sep in ['&', '+', '|']:
|
||||
if sep in w:
|
||||
parts = w.split(sep)
|
||||
if all(is_roman_numeral(p) for p in parts):
|
||||
return sep.join(p.upper() for p in parts)
|
||||
return capitalize_hyphenated(w)
|
||||
|
||||
if force_capitalize_mode or is_first or is_last or (lower_part not in lowercase_exceptions):
|
||||
sub_parts = part.split('-')
|
||||
capitalized_sub = [capitalize_special(sp) for sp in sub_parts]
|
||||
capitalized_parts.append('-'.join(capitalized_sub))
|
||||
else:
|
||||
capitalized_parts.append(lower_part)
|
||||
|
||||
result.append(''.join(capitalized_parts))
|
||||
|
||||
if not contains_marker:
|
||||
force_capitalize_mode = False
|
||||
|
||||
if result:
|
||||
first_parts = result[0].split('-')
|
||||
result[0] = '-'.join(
|
||||
sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp)
|
||||
for sp in first_parts
|
||||
)
|
||||
|
||||
last_parts = result[-1].split('-')
|
||||
result[-1] = '-'.join(
|
||||
sp.upper() if (sp.upper() in ACRONYMS or is_roman_numeral(sp)) else capitalize_hyphenated(sp)
|
||||
for sp in last_parts
|
||||
)
|
||||
|
||||
return ' '.join(result)
|
||||
|
||||
def normalize_title(text):
|
||||
"""Normalize title names for matching."""
|
||||
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii')
|
||||
text = re.sub(r'[^a-zA-Z0-9 ]', '', text).lower()
|
||||
return ' '.join(text.split())
|
||||
|
||||
def create_title_mapping(title_db):
|
||||
"""Create normalized mapping of game name -> title ID."""
|
||||
title_map = {}
|
||||
normalized_title_map = {}
|
||||
|
||||
for entry in title_db.values():
|
||||
name = entry.get("name")
|
||||
tid = entry.get("id")
|
||||
if name and tid:
|
||||
title_map[name.strip()] = tid.strip()
|
||||
normalized_title_map[normalize_title(name)] = tid.strip()
|
||||
|
||||
return title_map, normalized_title_map
|
||||
|
||||
def find_title_id_by_build_id(build_id, cnmts_db, debug=False):
|
||||
"""Find title ID by matching build ID prefix against full build IDs in cnmts.json."""
|
||||
if not build_id:
|
||||
return None, None
|
||||
|
||||
build_id = build_id.upper()
|
||||
|
||||
for title_id, versions in cnmts_db.items():
|
||||
for version_num, version_data in versions.items():
|
||||
content_entries = version_data.get("contentEntries", [])
|
||||
for entry in content_entries:
|
||||
full_build_id = entry.get("buildId")
|
||||
if full_build_id and entry.get("type") == 1:
|
||||
# Check if the build ID from .pchtxt matches the start of the full build ID
|
||||
if full_build_id.upper().startswith(build_id):
|
||||
if debug:
|
||||
return title_id.upper(), full_build_id
|
||||
return title_id.upper(), None
|
||||
|
||||
return None, None
|
||||
|
||||
def get_title_id_from_name(game_name, normalized_title_map):
|
||||
"""Fuzzy match game name to title ID using normalized names."""
|
||||
if not game_name:
|
||||
return None
|
||||
|
||||
norm_query = normalize_title(game_name)
|
||||
|
||||
# Exact match
|
||||
if norm_query in normalized_title_map:
|
||||
return normalized_title_map[norm_query]
|
||||
|
||||
# Substring match
|
||||
for title_key in normalized_title_map.keys():
|
||||
if norm_query in title_key:
|
||||
return normalized_title_map[title_key]
|
||||
|
||||
# Fuzzy fallback
|
||||
matches = get_close_matches(norm_query, normalized_title_map.keys(), n=1, cutoff=0.5)
|
||||
if matches:
|
||||
return normalized_title_map[matches[0]]
|
||||
|
||||
return None
|
||||
|
||||
def extract_game_name_from_folder(folder_name):
|
||||
"""Extract and format game name from folder name."""
|
||||
game_name = sanitize_name(folder_name)
|
||||
|
||||
# Remove trailing "Graphics" if it exists
|
||||
if game_name.endswith("Graphics"):
|
||||
game_name = game_name[:-len("Graphics")].strip()
|
||||
|
||||
# Apply title case formatting
|
||||
game_name = title_case_preserve_numbers(game_name)
|
||||
|
||||
return game_name
|
||||
|
||||
def extract_nsobid_from_pchtxt(file_path):
|
||||
"""Extract the nsobid (build ID) from a .pchtxt file."""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith("@nsobid-"):
|
||||
# Extract the build ID after @nsobid-
|
||||
build_id = line[8:] # Remove "@nsobid-"
|
||||
return build_id.upper()
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ Error reading {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def extract_region_from_filename(filename):
|
||||
"""Extract region code from filename like '1.0.3_US.pchtxt' -> 'US'."""
|
||||
# Remove .pchtxt extension
|
||||
name_without_ext = filename.replace('.pchtxt', '')
|
||||
|
||||
# Look for pattern like _XX where XX is 2-3 letter region code
|
||||
region_match = re.search(r'_([A-Z]{2,3})$', name_without_ext)
|
||||
if region_match:
|
||||
return region_match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def patch_pchtxt_file(file_path, game_name, title_id):
|
||||
"""Patch a single .pchtxt file with the title ID."""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find and update the header line (starts with #)
|
||||
updated = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("#"):
|
||||
# Check if it already has a title ID in brackets
|
||||
if "[" in line and "]" in line:
|
||||
# Replace existing title ID
|
||||
lines[i] = f"# {game_name} [{title_id}]\n"
|
||||
else:
|
||||
# Add title ID to existing header
|
||||
lines[i] = line.strip() + f" [{title_id}]\n"
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
# Find the line after @nsobid to insert header
|
||||
nsobid_index = -1
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("@nsobid-"):
|
||||
nsobid_index = i
|
||||
break
|
||||
|
||||
if nsobid_index >= 0:
|
||||
# Insert after @nsobid line, but before any empty line
|
||||
insert_index = nsobid_index + 1
|
||||
if insert_index < len(lines) and lines[insert_index].strip() == "":
|
||||
insert_index += 1
|
||||
lines.insert(insert_index, f"# {game_name} [{title_id}]\n")
|
||||
else:
|
||||
# Insert at the beginning if no @nsobid found
|
||||
lines.insert(0, f"# {game_name} [{title_id}]\n")
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def patch_original_files_with_buildid(root_folder, title_db_path="US.en.json", cnmts_db_path="cnmts.json"):
|
||||
"""
|
||||
Walk through the original folder structure and patch all .pchtxt files
|
||||
with their corresponding title IDs using build ID mapping.
|
||||
"""
|
||||
# Load databases
|
||||
title_db = load_title_database(title_db_path)
|
||||
cnmts_db = load_cnmts_database(cnmts_db_path)
|
||||
|
||||
title_map, normalized_title_map = create_title_mapping(title_db)
|
||||
|
||||
print(f"Loaded {len(normalized_title_map)} titles from title database")
|
||||
print(f"Loaded CNMTS database with {len(cnmts_db)} title entries")
|
||||
print(f"Scanning folder: {root_folder}\n")
|
||||
|
||||
stats = {
|
||||
'processed': 0,
|
||||
'patched_by_buildid': 0,
|
||||
'patched_by_name': 0,
|
||||
'no_buildid': 0,
|
||||
'no_title_id': 0,
|
||||
'errors': 0,
|
||||
'regional_variants': 0
|
||||
}
|
||||
|
||||
for current_root, dirs, files in os.walk(root_folder):
|
||||
# Skip the formatted directory if it exists
|
||||
if 'formatted' in current_root:
|
||||
continue
|
||||
|
||||
pchtxt_files = [f for f in files if f.lower().endswith('.pchtxt')]
|
||||
if not pchtxt_files:
|
||||
continue
|
||||
|
||||
# Extract game name from folder
|
||||
folder_name = os.path.basename(current_root)
|
||||
game_name = extract_game_name_from_folder(folder_name)
|
||||
|
||||
print(f"📁 {folder_name}")
|
||||
print(f" Game: {game_name}")
|
||||
|
||||
# Group files by region to show regional variants
|
||||
regional_files = {}
|
||||
for file in pchtxt_files:
|
||||
region = extract_region_from_filename(file)
|
||||
if region:
|
||||
regional_files[region] = file
|
||||
stats['regional_variants'] += 1
|
||||
|
||||
if regional_files:
|
||||
print(f" 🌍 Regional variants detected: {', '.join(regional_files.keys())}")
|
||||
|
||||
# Process each .pchtxt file in this folder
|
||||
for file in pchtxt_files:
|
||||
file_path = os.path.join(current_root, file)
|
||||
stats['processed'] += 1
|
||||
|
||||
region = extract_region_from_filename(file)
|
||||
region_suffix = f" ({region})" if region else ""
|
||||
|
||||
# First, try to get title ID from build ID
|
||||
build_id = extract_nsobid_from_pchtxt(file_path)
|
||||
title_id = None
|
||||
method = None
|
||||
|
||||
if build_id:
|
||||
title_id, full_build_id = find_title_id_by_build_id(build_id, cnmts_db, debug=True)
|
||||
if title_id:
|
||||
method = "build_id"
|
||||
stats['patched_by_buildid'] += 1
|
||||
print(f" 🔍 Build ID match: {build_id} -> {full_build_id}")
|
||||
else:
|
||||
print(f" ⚠️ Build ID {build_id} not found in CNMTS database")
|
||||
else:
|
||||
stats['no_buildid'] += 1
|
||||
print(f" ⚠️ No build ID found in {file}")
|
||||
|
||||
# If build ID lookup failed, try name-based lookup
|
||||
if not title_id:
|
||||
title_id = get_title_id_from_name(game_name, normalized_title_map)
|
||||
if title_id:
|
||||
method = "name"
|
||||
stats['patched_by_name'] += 1
|
||||
|
||||
if title_id:
|
||||
if patch_pchtxt_file(file_path, game_name, title_id):
|
||||
print(f" ✅ Patched: {file}{region_suffix} with [{title_id}] (via {method})")
|
||||
else:
|
||||
stats['errors'] += 1
|
||||
else:
|
||||
print(f" ❌ No title ID found for: {file}{region_suffix}")
|
||||
stats['no_title_id'] += 1
|
||||
|
||||
print() # Empty line for readability
|
||||
|
||||
# Print summary
|
||||
print("=" * 60)
|
||||
print("SUMMARY:")
|
||||
print(f"Files processed: {stats['processed']}")
|
||||
print(f"Files patched via build ID: {stats['patched_by_buildid']}")
|
||||
print(f"Files patched via name matching: {stats['patched_by_name']}")
|
||||
print(f"Regional variant files detected: {stats['regional_variants']}")
|
||||
print(f"Files with no build ID: {stats['no_buildid']}")
|
||||
print(f"Files with no title ID found: {stats['no_title_id']}")
|
||||
print(f"Errors: {stats['errors']}")
|
||||
print("=" * 60)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) not in [2, 3, 4]:
|
||||
print("Usage: python patch_buildid_titleids.py /path/to/root/folder [path/to/US.en.json] [path/to/cnmts.json]")
|
||||
print("If database paths are not provided, it will look for 'US.en.json' and 'cnmts.json' in the current directory.")
|
||||
sys.exit(1)
|
||||
|
||||
root_folder = sys.argv[1]
|
||||
title_db_path = sys.argv[2] if len(sys.argv) >= 3 else "US.en.json"
|
||||
cnmts_db_path = sys.argv[3] if len(sys.argv) == 4 else "cnmts.json"
|
||||
|
||||
if not os.path.exists(root_folder):
|
||||
print(f"❌ Root folder not found: {root_folder}")
|
||||
sys.exit(1)
|
||||
|
||||
patch_original_files_with_buildid(root_folder, title_db_path, cnmts_db_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +0,0 @@
|
||||
This script is used to format the pchtxt heirarchy appropriately for the following repository:
|
||||
|
||||
https://github.com/theboy181/switch-ptchtxt-mods
|
||||
@@ -1,283 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
import sys
|
||||
import rarfile
|
||||
import unicodedata
|
||||
|
||||
# ----- Normalization & Title‐Casing Helpers -----
|
||||
|
||||
def sanitize_name(name):
|
||||
"""
|
||||
Remove accents and unwanted characters, replace ' - ' with a single space,
|
||||
remove extra quotes/apostrophes, collapse multiple spaces.
|
||||
"""
|
||||
normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii')
|
||||
cleaned = normalized.replace("'", "").replace("’", "").replace("`", "").replace('"', "")
|
||||
cleaned = cleaned.replace(" - ", " ")
|
||||
cleaned = ' '.join(cleaned.split())
|
||||
return cleaned.strip()
|
||||
|
||||
def capitalize_hyphenated(word):
|
||||
"""
|
||||
Capitalize both parts of a hyphenated word. E.g. "yooka-laylee" → "Yooka-Laylee".
|
||||
"""
|
||||
parts = word.split('-')
|
||||
capitalized = []
|
||||
for part in parts:
|
||||
if part:
|
||||
capitalized.append(part[0].upper() + part[1:].lower() if len(part) > 1 else part.upper())
|
||||
else:
|
||||
capitalized.append('')
|
||||
return '-'.join(capitalized)
|
||||
|
||||
# Regex for Roman numerals (supports up to 3999)
|
||||
ROMAN_NUMERAL_PATTERN = re.compile(
|
||||
r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Known acronyms to force‐uppercase exactly
|
||||
ACRONYMS = {
|
||||
"HD", "2D", "3D", "4K", "VR", "AI", "API", "USB", "CPU", "GPU", "DVD", "CD",
|
||||
"RPG", "FPS", "MMO", "MMORPG", "LAN", "GUI", "NPC",
|
||||
"FFVII", "FFVIII", "FFIX", "FFX", "FFXII",
|
||||
"FX", "2K", "5K", "8K", "V1", "V2", "V3", "V4", "DOF"
|
||||
}
|
||||
|
||||
def is_roman_numeral(word):
|
||||
"""
|
||||
Return True if `word` is a valid Roman numeral (case‐insensitive).
|
||||
"""
|
||||
return bool(ROMAN_NUMERAL_PATTERN.match(word))
|
||||
|
||||
def title_case_preserve_numbers(name):
|
||||
"""
|
||||
Title‐case `name` with these rules:
|
||||
• Fully uppercase acronyms remain unchanged (e.g. HD, 2D, 3D, FFVII, etc.).
|
||||
• Roman numerals become fully uppercase (e.g. 'iii' → 'III', 'xI' → 'XI').
|
||||
• Hyphenated words are capitalized on both sides (→ 'Yooka-Laylee').
|
||||
• Small filler words (a, an, and, the, of, in, etc.) become lowercase
|
||||
only if they appear in the middle and are not immediately after a subtitle marker,
|
||||
except the first and last words (always capitalized).
|
||||
• After a subtitle marker (":", "~", "–", "—", or "-"), force capitalization
|
||||
on all subsequent words until the next subtitle marker or the end.
|
||||
• Compound Roman numerals joined by "&", "+", or "|" become fully uppercase
|
||||
(e.g. "I&ii" → "I&II").
|
||||
"""
|
||||
lowercase_exceptions = {
|
||||
"a", "an", "and", "as", "at", "but", "by", "for", "from",
|
||||
"in", "nor", "of", "on", "or", "so", "the", "to", "with", "yet"
|
||||
}
|
||||
subtitle_markers = {":", "~", "-", "–", "—"}
|
||||
|
||||
words = name.split()
|
||||
result = []
|
||||
force_capitalize_mode = False
|
||||
|
||||
for idx, raw_word in enumerate(words):
|
||||
# Check if this raw_word contains any subtitle marker (to force‐caps afterward)
|
||||
contains_marker = any(marker in raw_word for marker in subtitle_markers)
|
||||
|
||||
# Split on any subtitle marker but keep the markers themselves
|
||||
split_parts = re.split(r'([:~\-–—])', raw_word)
|
||||
compounded = []
|
||||
|
||||
for part in split_parts:
|
||||
if part in subtitle_markers:
|
||||
# Keep the marker, then force‐capitalize subsequent parts
|
||||
compounded.append(part)
|
||||
force_capitalize_mode = True
|
||||
continue
|
||||
|
||||
lower_part = part.lower()
|
||||
is_first = (idx == 0)
|
||||
is_last = (idx == len(words) - 1)
|
||||
|
||||
def cap_one(subword):
|
||||
# If it's an acronym, uppercase it
|
||||
if subword.upper() in ACRONYMS:
|
||||
return subword.upper()
|
||||
# If it's a Roman numeral, uppercase it
|
||||
if is_roman_numeral(subword):
|
||||
return subword.upper()
|
||||
# If it’s a compound Roman numeral joined by &, +, or |
|
||||
for sep in ("&", "+", "|"):
|
||||
if sep in subword:
|
||||
pieces = subword.split(sep)
|
||||
if all(is_roman_numeral(p) for p in pieces):
|
||||
return sep.join(p.upper() for p in pieces)
|
||||
# Otherwise just capitalize hyphenated segments
|
||||
return capitalize_hyphenated(subword)
|
||||
|
||||
if force_capitalize_mode or is_first or is_last or (lower_part not in lowercase_exceptions):
|
||||
# Split hyphens, apply cap_one to each
|
||||
subs = part.split('-')
|
||||
compounded.append("-".join(cap_one(s) for s in subs))
|
||||
else:
|
||||
# Middle filler word → keep lowercase
|
||||
compounded.append(lower_part)
|
||||
|
||||
result.append("".join(compounded))
|
||||
# If this raw_word did not contain a subtitle marker, exit force‐capitalize
|
||||
if not contains_marker:
|
||||
force_capitalize_mode = False
|
||||
|
||||
# Finally, ALWAYS capitalize the very first and very last words (same rules)
|
||||
if result:
|
||||
first_split = result[0].split("-")
|
||||
new_first = []
|
||||
for p in first_split:
|
||||
if p.upper() in ACRONYMS or is_roman_numeral(p):
|
||||
new_first.append(p.upper())
|
||||
else:
|
||||
new_first.append(capitalize_hyphenated(p))
|
||||
result[0] = "-".join(new_first)
|
||||
|
||||
last_split = result[-1].split("-")
|
||||
new_last = []
|
||||
for p in last_split:
|
||||
if p.upper() in ACRONYMS or is_roman_numeral(p):
|
||||
new_last.append(p.upper())
|
||||
else:
|
||||
new_last.append(capitalize_hyphenated(p))
|
||||
result[-1] = "-".join(new_last)
|
||||
|
||||
return " ".join(result)
|
||||
|
||||
def clean_title(name):
|
||||
"""
|
||||
Combine sanitize_name() + title_case_preserve_numbers() into one call.
|
||||
"""
|
||||
return title_case_preserve_numbers(sanitize_name(name))
|
||||
|
||||
def transform_game_name_raw(raw_game):
|
||||
"""
|
||||
Move ", The" to the front and remove any stray colons:
|
||||
e.g. "Skyrim, The" → "The Skyrim"
|
||||
"""
|
||||
if ", The" in raw_game:
|
||||
parts = raw_game.split(", The")
|
||||
raw_game = f"The {parts[0]}{parts[1]}"
|
||||
raw_game = raw_game.replace(":", "") # strip out colons
|
||||
raw_game = raw_game.replace(" - ", " ") # remove literal “ - ”
|
||||
return raw_game
|
||||
|
||||
def extract_rar_files(folder_path):
|
||||
"""
|
||||
Only extract top‐level archives matching "release_*.rar" in the root folder_path.
|
||||
Do NOT dive into subfolders (so we skip those tiny per‐mod RARs).
|
||||
"""
|
||||
# We look at *only* the immediate children of `folder_path`.
|
||||
for item in os.listdir(folder_path):
|
||||
full = os.path.join(folder_path, item)
|
||||
if not os.path.isfile(full):
|
||||
continue
|
||||
|
||||
# Only process those .rar that match "release_*.rar" at the top level
|
||||
if item.lower().startswith("release_") and item.lower().endswith(".rar"):
|
||||
try:
|
||||
with rarfile.RarFile(full) as rf:
|
||||
rf.extractall(folder_path)
|
||||
print(f"Extracted top‐level archive: {full}")
|
||||
except rarfile.Error as e:
|
||||
print(f"❌ Failed to extract {full}: {e}")
|
||||
|
||||
def get_game_name_and_mod_name(path, root_dir):
|
||||
"""
|
||||
Given a folder `path` containing a .pchtxt, return (game_name, mod_name).
|
||||
1) game_name ← first‐level folder under root_dir, strip bracketed tags, move ", The",
|
||||
remove colons, possibly append "(Country)", then run clean_title(...).
|
||||
2) mod_name ← if 'Aspect Ratio' in path → "Aspect Ratio <foldername>";
|
||||
else if last folder ends in " v<digits>" → "<parent> <lastFolder>";
|
||||
else immediate parent folder.
|
||||
Afterwards, replace ' / ` → ".", "21-9" → "21.9", remove colons,
|
||||
handle "Trailblazers" → "4K", then run clean_title(...).
|
||||
"""
|
||||
relative = os.path.relpath(path, root_dir)
|
||||
parts = relative.split(os.sep)
|
||||
|
||||
# --- raw_game_name logic ---
|
||||
raw_game = parts[0]
|
||||
raw_game = re.sub(r'\[.*?\]', '', raw_game).strip()
|
||||
raw_game = transform_game_name_raw(raw_game)
|
||||
|
||||
# check for country code deeper in path
|
||||
country = None
|
||||
for p in parts[1:]:
|
||||
if re.search(r'\[.*?\]', p):
|
||||
country = re.sub(r'\[.*?\]', '', p).strip()
|
||||
break
|
||||
if country:
|
||||
raw_game = f"{raw_game} ({country})"
|
||||
|
||||
game_name = clean_title(raw_game)
|
||||
|
||||
# --- raw_mod_name logic ---
|
||||
if "Aspect Ratio" in relative:
|
||||
aspect_folder = os.path.basename(path)
|
||||
raw_mod = f"Aspect Ratio {aspect_folder}"
|
||||
else:
|
||||
last_folder = parts[-1]
|
||||
if re.search(r' v\d+', last_folder):
|
||||
parent_folder = parts[-2]
|
||||
raw_mod = f"{parent_folder} {last_folder}"
|
||||
else:
|
||||
raw_mod = parts[-2] if len(parts) > 1 else ""
|
||||
|
||||
raw_mod = raw_mod.strip()
|
||||
raw_mod = raw_mod.replace("'", ".").replace("`", ".")
|
||||
raw_mod = raw_mod.replace("21-9", "21.9")
|
||||
raw_mod = raw_mod.replace(":", "")
|
||||
if raw_mod == "Trailblazers":
|
||||
raw_mod = "4K"
|
||||
|
||||
mod_name = clean_title(raw_mod) if raw_mod else ""
|
||||
return game_name, mod_name
|
||||
|
||||
def create_formatted_structure(folder_path):
|
||||
"""
|
||||
1) extract_rar_files(folder_path) # only top‐level releases
|
||||
2) walk every subfolder for .pchtxt
|
||||
3) for each .pchtxt, compute (game_name, mod_name) with get_game_name_and_mod_name
|
||||
4) copy into formatted/"<Game Name> - <Mod Name>"/"<version>.pchtxt"
|
||||
"""
|
||||
extract_rar_files(folder_path)
|
||||
|
||||
formatted_path = os.path.join(folder_path, "formatted")
|
||||
os.makedirs(formatted_path, exist_ok=True)
|
||||
|
||||
for root, dirs, files in os.walk(folder_path):
|
||||
# Skip anything already under “formatted”
|
||||
if "formatted" in root.split(os.sep):
|
||||
continue
|
||||
|
||||
for f in files:
|
||||
if not f.lower().endswith(".pchtxt"):
|
||||
continue
|
||||
|
||||
game_name, mod_name = get_game_name_and_mod_name(root, folder_path)
|
||||
version = f[:-len(".pchtxt")].strip()
|
||||
|
||||
combined = f"{game_name} - {mod_name}".strip()
|
||||
target_dir = os.path.join(formatted_path, combined)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
src = os.path.join(root, f)
|
||||
dst = os.path.join(target_dir, f"{version}.pchtxt")
|
||||
shutil.copy(src, dst)
|
||||
print(f"Copied {src} → {dst}")
|
||||
|
||||
print("\nAll files have been organized successfully.")
|
||||
|
||||
def main(folder_path):
|
||||
create_formatted_structure(folder_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python format_repo_4.py /path/to/folder/")
|
||||
sys.exit(1)
|
||||
|
||||
folder_path = sys.argv[1]
|
||||
main(folder_path)
|
||||
Reference in New Issue
Block a user