Add --keep-logs and Windows 10/11 support

- --keep-logs N sets how many results logs are kept (default 5,
  0 = keep all); negative values are rejected.
- OS check: IS_WINDOWS/OS shown at start; Python < 3.8 exits clearly.
- Windows fixes (no change in behaviour on Linux):
  * console output never crashes on ™/Ψ/Japanese titles (legacy code
    page or redirected output)
  * \\?\ long-path prefix for file operations over 260 chars
  * case-insensitive path matching for undo/migration (C:\Games ==
    c:\games) and case-only renames no longer reported as collisions
    (also fixes this on exFAT under Linux)
  * reserved device names (CON, NUL, COM1...) prefixed with "_"
- Control characters stripped from titles (tab/newline -> space) so
  titles can't corrupt the tab-separated undo log.
- install_prereqs.ps1: winget-based prerequisite installer for Windows.
- .gitattributes keeps LF line endings (CRLF for .ps1) so Windows
  clones still run on Linux.
- README: Windows usage notes and --keep-logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bsgonzalezqandClaude Opus 5.5 committed 2026-09-25 11:16:20 -05:00
1 parent 298568a156
commit ce38089d5f
4 files changed
+157 -30

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Keep LF everywhere so a clone made on Windows still runs on Linux (shebangs, bash);
# Python on Windows reads LF files fine. PowerShell scripts get CRLF.
* text=auto eol=lf
*.ps1 text eol=crlf
+19 -7
View File
@@ -27,7 +27,7 @@ CUSA11253_base.pkg (loose in root) Dead Cells/
| git | Clones and updates the repo |
| Internet access | Only needed for the English-name lookup in `--build-db`. Use `--offline` to skip it |
The prerequisites are also listed in [`requirements.txt`](requirements.txt), which names no packages, and checked by [`install_prereqs.sh`](install_prereqs.sh).
The prerequisites are also listed in [`requirements.txt`](requirements.txt), which names no packages, and checked by [`install_prereqs.sh`](install_prereqs.sh) (Linux/macOS) or [`install_prereqs.ps1`](install_prereqs.ps1) (Windows).
## Installation
@@ -60,15 +60,26 @@ If you'd rather install by hand:
### Windows
```powershell
winget install -e --id Python.Python.3.12
winget install -e --id Git.Git
# reopen the terminal so both are on PATH, then:
winget install -e --id Git.Git # skip if git is installed
# reopen the terminal so git is on PATH, then:
git clone https://github.com/bsgonzalezq/ps4-title-renamer.git
cd ps4-title-renamer
python ps4_rename.py --help
powershell -ExecutionPolicy Bypass -File .\install_prereqs.ps1 # installs / checks Python 3.8+ and git
py ps4_rename.py --help
```
On Windows, use `python` where this README shows `python3`.
`install_prereqs.ps1` works like `install_prereqs.sh`: it installs only what's missing (through `winget`), checks the Python version, and checks the script runs.
**Using the script on Windows:**
- **Commands:** use `py` or `python` where this README shows `python3`, and `\` in paths, e.g. `py ps4-title-renamer\ps4_rename.py D:\PS4 --apply`.
- **Everything else works as on Linux:** a drive root like `D:\` works as `PATH`, and the script checks the OS when it starts. It handles Windows differences automatically:
- **Long paths:** paths longer than 260 characters work even if Windows' long-path support is off.
- **Case:** Windows ignores letter case in names and paths (`C:\Games` = `c:\games`), and so does the script, including renames that only change letter case.
- **Names:** file names that Windows can't use are made safe. That covers `:` and similar characters, the reserved names `CON`, `NUL` and `COM1`, and trailing dots.
- **Console:** titles with characters like `™`, `Ψ` or Japanese text never crash the output, even when it's redirected to a file.
- **Same drive on both systems:** a drive renamed on Linux can be used on Windows and the other way round. Names are already safe for exFAT/NTFS, and the db and logs are plain UTF-8 text.
- **`rename_undo.log`:** records full paths, so run `--undo` on the same OS and drive letter or mount point you used for `--apply`.
### Updating
@@ -147,6 +158,7 @@ Re-run the link command if you move the script.
| `--no-auto-db` | Don't build the db automatically when IDs are missing |
| `--db FILE` | Title db to use (see below) |
| `--log FILE` | Where to write the results log (default: the script's folder) |
| `--keep-logs N` | Number of results logs to keep; older ones are deleted. Default 5, and `0` keeps all |
| `--clean-logs` | Delete the results logs; `rename_undo.log` is kept |
| `-h`, `--help` | Show help |
@@ -216,7 +228,7 @@ Running the script again on renamed items doesn't rename them twice:
## Logs
All logs are kept in the script's folder, not in `PATH`. In a git clone, `.gitignore` excludes them. Every rename or undo run writes `rename_results_<dryrun|apply|undo>_<timestamp>.log`. Only the 5 newest are kept, and older ones are deleted automatically after each run. To change the limit, edit `MAX_LOGS` at the top of the script. `rename_undo.log` is never rotated.
All logs are kept in the script's folder, not in `PATH`. In a git clone, `.gitignore` excludes them. Every rename or undo run writes `rename_results_<dryrun|apply|undo>_<timestamp>.log`. Only the 5 newest are kept, and older ones are deleted automatically after each run. Use `--keep-logs N` to change the limit, or `--keep-logs 0` to keep every log. `rename_undo.log` is never rotated.
```
=== CHANGED (143) ===
+49
View File
@@ -0,0 +1,49 @@
# Install / verify prerequisites for ps4-title-renamer on Windows 10/11: Python 3.8+ and git.
# Run from the repo folder: powershell -ExecutionPolicy Bypass -File .\install_prereqs.ps1
$ErrorActionPreference = 'Stop'
Set-Location -Path $PSScriptRoot
$MinPy = [version]'3.8'
function Find-Python {
# "py" is the Python launcher from python.org; "python" may be the Microsoft Store stub
foreach ($cmd in @('py', 'python')) {
if (Get-Command $cmd -ErrorAction SilentlyContinue) {
$v = & $cmd -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>$null
if ($LASTEXITCODE -eq 0 -and $v) { return @{ Cmd = $cmd; Version = [version]$v } }
}
}
return $null
}
function Install-WithWinget($id) {
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
throw "winget not found. Install $id manually (https://www.python.org / https://git-scm.com)."
}
Write-Host "Installing $id ..."
winget install -e --id $id --accept-source-agreements --accept-package-agreements
# pick up PATH changes made by the installer in this session
$env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' +
[Environment]::GetEnvironmentVariable('Path', 'User')
}
$py = Find-Python
if (-not $py) {
Install-WithWinget 'Python.Python.3.12'
$py = Find-Python
if (-not $py) { throw 'Python installed, but not on PATH yet: open a new terminal and run this script again.' }
}
if ($py.Version -lt $MinPy) { throw "Python $MinPy+ required, found $($py.Version). Please upgrade Python." }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Install-WithWinget 'Git.Git'
}
# No third-party packages today; this keeps working if any are added later
if ((Test-Path requirements.txt) -and (Select-String -Path requirements.txt -Pattern '^\s*[^#\s]' -Quiet)) {
& $py.Cmd -m pip install --user -r requirements.txt
}
Write-Host ''
Write-Host "OK: Python $($py.Version) ($($py.Cmd)), $(git --version)"
& $py.Cmd ps4_rename.py --help | Out-Null
if ($LASTEXITCODE -eq 0) { Write-Host "OK: ps4_rename.py runs. Try: $($py.Cmd) ps4_rename.py --help" }
+85 -23
View File
@@ -15,8 +15,11 @@ IDs missing from the db are added automatically (as --build-db) before renaming.
Logs are kept in the script's folder: every rename/undo run writes
rename_results_<action>_<timestamp>.log (CHANGED, NOT CHANGED + reason, ERRORS),
and --apply records renames in rename_undo.log for --undo. Only the 5 newest results
logs are kept; older ones are deleted automatically (rename_undo.log is never deleted).
and --apply records renames in rename_undo.log for --undo. Only the newest results
logs are kept (5, or --keep-logs N); older ones are deleted automatically
(rename_undo.log is never deleted).
Works on Linux, macOS and Windows 10/11 (use `python` or `py` instead of `python3`).
ps4_rename.py [PATH] --clean-logs delete rename_results_*.log (keeps rename_undo.log)
The script keeps itself, its db (ps4_titles.db) and its logs in a folder named
@@ -24,21 +27,71 @@ ps4-title-renamer; run from anywhere else, it creates ./ps4-title-renamer and mo
DB format (plain text, one per line, '#' comments): ID|Title|Region
"""
import argparse, json, os, re, shutil, struct, subprocess, sys, time, unicodedata
import argparse, json, os, platform, re, shutil, struct, subprocess, sys, time, unicodedata
import urllib.error, urllib.parse, urllib.request
from datetime import datetime
if sys.version_info < (3, 8):
sys.exit(f'Python 3.8 or newer is required (found {platform.python_version()})')
IS_WINDOWS = os.name == 'nt'
OS_NAME = f'{platform.system()} {platform.release()}'.strip()
# Titles contain characters like ™, Ψ or Japanese text: never crash printing them, e.g. on a
# Windows console with a legacy code page or when output is redirected to a file.
for _stream in (sys.stdout, sys.stderr):
try:
if IS_WINDOWS and not _stream.isatty():
_stream.reconfigure(encoding='utf-8', errors='replace')
else:
_stream.reconfigure(errors='replace')
except (AttributeError, ValueError):
pass
HERE = os.path.dirname(os.path.realpath(__file__)) # real script folder, even when run via a symlink
UNDO_LOG = os.path.join(HERE, 'rename_undo.log')
DB_FILE = os.path.join(HERE, 'ps4_titles.db')
TOOL_DIR = 'ps4-title-renamer' # folder the script (with its db and logs) always lives in
MAX_LOGS = 5 # results logs kept in the script folder; older ones are rotated out
MAX_LOGS = 5 # default number of results logs kept (--keep-logs)
ID_RE = re.compile(r'(?<![A-Z])([A-Z]{4}\d{5})(?!\d)')
SKIP = {'System Volume Information', '$RECYCLE.BIN', '.Trash-1000', '.git', 'ps4-title-renamer'}
REGIONS = {'UP': 'USA', 'EP': 'EUR', 'JP': 'JPN', 'HP': 'ASIA', 'KP': 'KOR'}
# chars not allowed on exFAT/NTFS
BAD = str.maketrans({':': ' - ', '/': '-', '\\': '-', '*': '', '?': '', '"': "'",
'<': '', '>': '', '|': '-', '・': '-', '™': '', '®': '', '©': ''})
BAD.update({i: ' ' if i in (9, 10, 13) else None for i in range(32)}) # control chars (tab is the undo-log separator)
RESERVED = {'CON', 'PRN', 'AUX', 'NUL', *(f'COM{i}' for i in range(1, 10)), *(f'LPT{i}' for i in range(1, 10))}
def lp(path):
"""Long-path-safe form of path for file operations on Windows (> 260 chars); unchanged elsewhere."""
if not IS_WINDOWS:
return path
p = os.path.abspath(path)
if len(p) < 240 or p.startswith('\\\\?\\'):
return path
return '\\\\?\\UNC\\' + p[2:] if p.startswith('\\\\') else '\\\\?\\' + p
def same_path(a, b):
"""Compare paths the way the OS does (case-insensitive on Windows)."""
return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
def same_file(a, b):
"""True when a and b are the same file, e.g. names differing only in case on NTFS/exFAT."""
try:
return os.path.samefile(lp(a), lp(b))
except OSError:
return False
def windows_safe(name):
"""Avoid names Windows can't use: reserved device names and trailing dots/spaces."""
name = name.rstrip(' .')
if name.split('.')[0].strip().upper() in RESERVED:
name = '_' + name
return name
def load_db(path):
@@ -55,7 +108,7 @@ def load_db(path):
def read_sfo(pkg):
"""Return (content_id, {key: value}) from a PS4 PKG's param.sfo, or None."""
with open(pkg, 'rb') as f:
with open(lp(pkg), 'rb') as f:
h = f.read(0x100)
if h[:4] != b'\x7fCNT':
return None
@@ -279,7 +332,7 @@ def new_name(name, db, keep_id, unknown):
return gid
t = safe_title(db, gid)
return f'{t} [{gid}]' if keep_id else t
return BARE_ID_RE.sub(sub, TAG_RE.sub(tag, name)).rstrip(' .')
return windows_safe(BARE_ID_RE.sub(sub, TAG_RE.sub(tag, name)))
PKG_KINDS = {'gd': 'base', 'gde': 'base', 'gp': 'patch', 'ac': 'dlc'} # param.sfo CATEGORY
@@ -402,16 +455,16 @@ def rename_all(root, db, keep_id, apply, undo_log, log):
continue
src, dst = os.path.join(dp, name), os.path.join(dp, subdir, nn)
target = os.path.join(subdir, nn) if subdir else nn
if os.path.exists(dst):
if os.path.exists(lp(dst)) and not same_file(src, dst): # same file = case-only rename
log.error(relpath, f'target already exists: {target}')
continue
print(f'{relpath} -> {target}' + (f' (new folder "{subdir}")' if create else ''))
if apply:
try:
if create:
os.mkdir(os.path.join(root, subdir))
os.mkdir(lp(os.path.join(root, subdir)))
done.append(('MKDIR', os.path.join(root, subdir)))
os.rename(src, dst)
os.rename(lp(src), lp(dst))
except OSError as e:
log.error(relpath, f'rename to "{target}" failed: {e.strerror}')
continue
@@ -430,13 +483,14 @@ def rename_all(root, db, keep_id, apply, undo_log, log):
def _under(path, root):
return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
path, root = os.path.normcase(path), os.path.normcase(root)
return path == root or path.startswith(os.path.join(root, '')) # join adds the OS separator
def migrate_undo_log(root):
"""Move a rename_undo.log left in PATH by older versions into the script folder."""
old = os.path.join(root, 'rename_undo.log')
if os.path.abspath(old) == UNDO_LOG or not os.path.isfile(old):
if same_path(old, UNDO_LOG) or not os.path.isfile(old):
return
with open(old, encoding='utf-8') as f:
data = f.read()
@@ -461,20 +515,20 @@ def undo(root, undo_log, log):
for src, dst in reversed(mine):
if src == 'MKDIR': # folder created for a loose pkg: remove it once empty again
try:
os.rmdir(dst)
os.rmdir(lp(dst))
print(f'removed folder {dst}')
log.change(dst, '(folder removed)')
except OSError as e:
log.keep(dst, f'created folder not removed: {e.strerror}')
continue
if not os.path.exists(dst):
if not os.path.exists(lp(dst)):
log.error(dst, 'renamed item no longer exists')
failed.append((src, dst))
elif os.path.exists(src):
elif os.path.exists(lp(src)) and not same_file(src, dst):
log.keep(dst, f'original name already in use: {src}')
else:
try:
os.rename(dst, src)
os.rename(lp(dst), lp(src))
except OSError as e:
log.error(dst, f'restore failed: {e.strerror}')
failed.append((src, dst))
@@ -493,12 +547,15 @@ def undo(root, undo_log, log):
os.remove(undo_log)
def rotate_logs():
"""Keep only the MAX_LOGS newest rename_results_*.log in the script folder (never rename_undo.log)."""
def rotate_logs(keep=MAX_LOGS):
"""Keep only the `keep` newest rename_results_*.log in the script folder (never rename_undo.log).
keep=0 keeps them all."""
if keep <= 0:
return
logs = [os.path.join(HERE, fn) for fn in os.listdir(HERE)
if fn.startswith('rename_results_') and fn.endswith('.log')]
logs.sort(key=lambda p: (os.path.getmtime(p), p), reverse=True)
for p in logs[MAX_LOGS:]:
for p in logs[keep:]:
try:
os.remove(p)
print(f'Rotated out old log {os.path.basename(p)}')
@@ -536,13 +593,13 @@ def relocate():
"""Keep the script in a folder named ps4-title-renamer: if it isn't in one, create
./ps4-title-renamer under the current directory, move the script plus its db and logs
there, and re-run from the new location. Returns False if it stayed where it is."""
if os.path.basename(HERE) == TOOL_DIR:
if os.path.normcase(os.path.basename(HERE)) == os.path.normcase(TOOL_DIR):
return False
if os.path.isdir(os.path.join(HERE, '.git')):
print(f'Note: script folder "{HERE}" is a git clone, not moving it into {TOOL_DIR}/\n')
return False
cwd = os.getcwd()
target = cwd if os.path.basename(cwd) == TOOL_DIR else os.path.join(cwd, TOOL_DIR)
target = cwd if os.path.normcase(os.path.basename(cwd)) == os.path.normcase(TOOL_DIR) else os.path.join(cwd, TOOL_DIR)
script = os.path.join(target, 'ps4_rename.py')
if os.path.exists(script):
print(f'Note: {script} already exists, not replacing it; running from {HERE}\n')
@@ -578,7 +635,7 @@ def relocate():
def migrate_db(root, db_path):
"""Merge a ps4_titles.db left in PATH by older versions into the db next to the script."""
old = os.path.join(root, 'ps4_titles.db')
if os.path.isfile(old) and os.path.abspath(old) != os.path.abspath(db_path):
if os.path.isfile(old) and not same_path(old, db_path):
n = merge_db(old, db_path)
print(f'Merged {old} into {db_path} ({n} new entries)\n')
@@ -598,9 +655,13 @@ def main():
ap.add_argument('--db', metavar='FILE', help='db file (default: ps4_titles.db next to the script)')
ap.add_argument('--log', metavar='FILE',
help='results log path (default: rename_results_<action>_<timestamp>.log in the script folder)')
ap.add_argument('--keep-logs', type=int, default=MAX_LOGS, metavar='N',
help=f'number of results logs to keep, oldest are deleted (default: {MAX_LOGS}, 0 = keep all)')
ap.add_argument('--clean-logs', action='store_true',
help='delete rename_results_*.log files (rename_undo.log is kept) and exit')
a = ap.parse_args()
if a.keep_logs < 0:
ap.error('--keep-logs must be 0 or more')
a.root = os.path.abspath(a.root)
if not os.path.isdir(a.root):
ap.error(f'not a directory: {a.root}')
@@ -608,7 +669,8 @@ def main():
if not a.db:
a.db = DB_FILE
migrate_db(a.root, a.db)
print(f'Directory: {a.root}\nScript folder: {HERE}\nDB: {a.db}\n')
print(f'OS: {OS_NAME} (Python {platform.python_version()})\n'
f'Directory: {a.root}\nScript folder: {HERE}\nDB: {a.db}\n')
undo_log = UNDO_LOG
if a.clean_logs:
clean_logs(a.root)
@@ -641,7 +703,7 @@ def main():
rename_all(a.root, db, a.keep_id, a.apply, undo_log, log)
finally:
log.write()
rotate_logs()
rotate_logs(a.keep_logs)
if __name__ == '__main__':