Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
# yamllint requires LF line endings, including on Windows checkouts.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,7 @@ Available variables: `$date` `$performer` `$title` `$studio` `$height`
| `$date $performer - $title [$studio]` | `2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex].mp4` |

Notes:
- Illegal Windows filename charactersare stripped automatically. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Illegal Windows filename characters, leading/trailing ASCII spaces, and trailing periods are stripped automatically. Reserved device names (including `CON`, `NUL`, `COM1`–`COM9`, and `LPT1`–`LPT9`) block the plan. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Heights of 2160 and 4320 are shown as `4k` and `8k`; others as `<height>p` (e.g. `1080p`).
- If a scene has more than 3 performers, `$performer` is omitted. This applies before the optional `FEMALE_ONLY` filter.

Expand Down
11 changes: 5 additions & 6 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ This is the first `ROADMAP.md`, but it reconciles the June 2026 historical audit

### R0-3 — Complete Windows filename rules and normalization collision checks

- **Status:** Implemented 2026-08-23; pending Windows-runtime verification.
- **Status:** Completed 2026-08-25. Windows-runtime verification with Python 3.14 exercised temporary-file renames and confirmed filename sanitization for invalid/control characters, ASCII whitespace/period normalization, standard and superscript device names, and normalized destination collisions.
- **Source:** `BUG-001`, `TEST-002`
- **Action:** Centralize filename sanitization/validation for control characters, reserved device basenames, trailing periods/spaces, existing punctuation policy, and post-normalization collisions.
- **Reason / expected effect:** Turns predictable Windows rename failures into deterministic plan errors or safe normalized names.
Expand DownExpand Up@@ -275,7 +275,7 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e
|---|---|---|---|---|---|---|---|
| R0-1 | Authoritative validated plan/apply | `REL-001`, `ARCH-001`, `FEAT-001` | Complete | Medium | Current semantics regression tests | Phase 0 | Dry run and apply consume identical plan; all blocking conflicts detected before mutation |
| R0-2 | Privacy-safe local configuration | `SEC-3`, `FEAT-003` | Complete | Medium | Configuration precedence decision | Phase 0 | No private defaults tracked; clean setup works; missing config fails clearly |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Verification pending | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Complete | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R1-1 | SQLite/filesystem integration fixture | `TEST-002`, `REL-001` | Complete | Medium | Plan interface | Phase 1 | Real queries and multi-file/collision paths pass in CI |
| R1-2 | Versioned run manifest | `REL-002`, `FEAT-001` | Complete | Medium | R0-1 | Phase 1 | Every run has attributable operation states, recovery checkpoints, and a completion marker |
| R2-1 | Explicit planner/executor/DB boundaries | `ARCH-001` | Complete | Medium | R0-1, R1-1 | Phase 2 | No global cursor required by tests; behavior unchanged |
Expand DownExpand Up@@ -306,7 +306,6 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e

## Recommended Execution Order

1. **R0-3:** Run the existing filename rule suite on Windows before describing Windows runtime support as complete.
2. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
3. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
4. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
1. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
2. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
3. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
43 changes: 38 additions & 5 deletions rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
from datetime import UTC, datetime
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
Expand All@@ -21,6 +22,8 @@
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
INVALID_FILENAME_CHARS = re.compile(r'[\\/:"*?<>|#,\x00-\x1f]+')

Expand DownExpand Up@@ -60,13 +63,14 @@ def sanitize_filename(filename: str) -> str:
"""Return a Windows-safe filename component or raise ``ValueError``.

The existing punctuation policy also strips ``#`` and ``,``. Control
characters and trailing periods/spaces are removed. Reserved Windows
basenames are rejected rather than silently renamed to an unrelated file.
characters plus leading/trailing ASCII spaces and trailing periods are
removed. Reserved Windows basenames are rejected rather than silently
renamed to an unrelated file.
"""
cleaned = INVALID_FILENAME_CHARS.sub("", filename).rstrip(". ")
cleaned = INVALID_FILENAME_CHARS.sub("", filename).lstrip(" ").rstrip(". ")
if not cleaned or not any(character.isalnum() for character in cleaned):
raise ValueError("filename is empty after Windows normalization")
stem = cleaned.split(".", 1)[0].upper()
stem = cleaned.split(".", 1)[0].rstrip(" ").upper()
if stem in WINDOWS_RESERVED_NAMES:
raise ValueError("filename uses reserved Windows basename: {}".format(stem))
return cleaned
Expand DownExpand Up@@ -117,6 +121,24 @@ def read_plan(path: str | os.PathLike[str]) -> RenamePlan:
return plan


def _normalized_destination(destination: str) -> str:
"""Return the case-insensitive Windows comparison form for a destination."""
parent, filename = os.path.split(os.path.normpath(destination))
return ntpath.normcase(os.path.join(parent, filename.lstrip(" ").rstrip(". ")))


def _destination_filename_error(destination: str) -> str | None:
"""Return a safety error when a destination basename is not Windows-safe."""
filename = os.path.basename(destination)
try:
sanitized = sanitize_filename(filename)
except ValueError as error:
return str(error)
if sanitized != filename:
return "destination filename changes under Windows normalization"
return None


def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"""Validate filesystem and cross-operation safety without modifying files."""
issues: list[PlanIssue] = []
Expand All@@ -133,6 +155,17 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
)
)
continue
filename_error = _destination_filename_error(operation.destination)
if filename_error:
issues.append(
PlanIssue(
operation.scene_id,
operation.source,
operation.destination,
"invalid_destination",
filename_error,
)
)
if operation.source == operation.destination:
continue
source_directory = os.path.abspath(os.path.dirname(operation.source))
Expand DownExpand Up@@ -167,7 +200,7 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"destination already exists",
)
)
normalized = os.path.normpath(operation.destination).rstrip(". ").casefold()
normalized = _normalized_destination(operation.destination)
other = destinations.get(normalized)
if other is not None:
issues.append(
Expand Down
90 changes: 85 additions & 5 deletions tests/test_rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,11 +19,54 @@


class TestFilenameSanitization(unittest.TestCase):
def test_strips_control_characters_and_trailing_dot_space(self):
self.assertEqual(sanitize_filename("Title\x00 .mp4. "), "Title .mp4")

def test_rejects_reserved_windows_names_with_extensions(self):
for name in ("CON.mp4", "nul", "COM1.txt", "LPT9 "):
def test_normalizes_windows_unsafe_characters_and_whitespace(self):
"""Normalize forbidden characters and Windows-trimmed ASCII whitespace."""
cases = (
("Title\x00 .mp4. ", "Title .mp4"),
(" Title.mp4", "Title.mp4"),
('Title<>:"/\\|?*#, .mp4', "Title .mp4"),
)
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(sanitize_filename(source), expected)

def test_rejects_all_reserved_windows_names_with_extensions(self):
"""Reject standard and superscript Windows device basenames."""
reserved_names = (
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
)
for reserved_name in reserved_names:
for suffix in ("", ".mp4", " .txt"):
name = reserved_name + suffix
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)

@unittest.skipUnless(os.name == "nt", "Windows filesystem verification")
def test_sanitized_name_can_be_used_by_the_windows_filesystem(self):
"""Rename a temporary file to a sanitized Windows-safe destination."""
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "source.mp4")
destination = os.path.join(directory, sanitize_filename(" Title\x00 .mp4. "))
with open(source, "w", encoding="utf-8") as source_file:
source_file.write("test")
os.rename(source, destination)
self.assertTrue(os.path.isfile(destination))

def test_rejects_empty_filename_after_normalization(self):
"""Reject names with no usable characters after normalization."""
for name in (" . ", "\x00"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)
Expand DownExpand Up@@ -67,6 +110,43 @@ def test_normalized_duplicate_destinations_block_a_plan(self):
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_leading_space_normalization_collision_blocks_a_plan(self):
"""Block destinations that Windows treats as equal after trimming."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, self.destination),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, " new.mp4")),
)
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_windows_case_normalization_preserves_sharp_s_distinction(self):
"""Allow distinct NTFS names that Unicode case folding would merge."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, os.path.join(self.tempdir.name, "Straße.mp4")),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, "Strasse.mp4")),
)
)
self.assertNotIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_persisted_reserved_windows_destination_blocks_a_plan(self):
"""Reject a reserved device name after reading a digest-valid plan."""
plan = create_plan(
(RenameOperation("1", self.source, os.path.join(self.tempdir.name, "COM¹.mp4")),)
)
plan_path = os.path.join(self.tempdir.name, "plan.json")
write_plan(plan, plan_path)
issues = validate_plan(read_plan(plan_path))
self.assertEqual([issue.code for issue in issues], ["invalid_destination"])
self.assertIn("reserved Windows basename", issues[0].message)

def test_write_read_and_apply_require_an_unchanged_valid_plan(self):
plan = create_plan((RenameOperation("1", self.source, self.destination),))
plan_path = os.path.join(self.tempdir.name, "plan.json")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_renamer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ def test_discovery_can_stop_after_first(self):

class TestCompatibilityRenderer(unittest.TestCase):
def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
"""Keep the compatibility renderer read-only and platform-neutral."""
manager = MagicMock()
database = manager.__enter__.return_value
database.cursor.fetchall.return_value = [
Expand All@@ -115,7 +116,9 @@ def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
finally:
os.chdir(original_cwd)
self.assertEqual(len(plan.operations), 1)
self.assertEqual(plan.operations[0].destination, "/fixture/Fixture Title.mp4")
self.assertEqual(
plan.operations[0].destination, os.path.join("/fixture", "Fixture Title.mp4")
)
manager.__enter__.assert_called_once_with()
manager.__exit__.assert_called_once()

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
# yamllint requires LF line endings, including on Windows checkouts.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,7 @@ Available variables: `$date` `$performer` `$title` `$studio` `$height`
| `$date $performer - $title [$studio]` | `2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex].mp4` |

Notes:
- Illegal Windows filename charactersare stripped automatically. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Illegal Windows filename characters, leading/trailing ASCII spaces, and trailing periods are stripped automatically. Reserved device names (including `CON`, `NUL`, `COM1`–`COM9`, and `LPT1`–`LPT9`) block the plan. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Heights of 2160 and 4320 are shown as `4k` and `8k`; others as `<height>p` (e.g. `1080p`).
- If a scene has more than 3 performers, `$performer` is omitted. This applies before the optional `FEMALE_ONLY` filter.

Expand Down
11 changes: 5 additions & 6 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ This is the first `ROADMAP.md`, but it reconciles the June 2026 historical audit

### R0-3 — Complete Windows filename rules and normalization collision checks

- **Status:** Implemented 2026-08-23; pending Windows-runtime verification.
- **Status:** Completed 2026-08-25. Windows-runtime verification with Python 3.14 exercised temporary-file renames and confirmed filename sanitization for invalid/control characters, ASCII whitespace/period normalization, standard and superscript device names, and normalized destination collisions.
- **Source:** `BUG-001`, `TEST-002`
- **Action:** Centralize filename sanitization/validation for control characters, reserved device basenames, trailing periods/spaces, existing punctuation policy, and post-normalization collisions.
- **Reason / expected effect:** Turns predictable Windows rename failures into deterministic plan errors or safe normalized names.
Expand DownExpand Up@@ -275,7 +275,7 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e
|---|---|---|---|---|---|---|---|
| R0-1 | Authoritative validated plan/apply | `REL-001`, `ARCH-001`, `FEAT-001` | Complete | Medium | Current semantics regression tests | Phase 0 | Dry run and apply consume identical plan; all blocking conflicts detected before mutation |
| R0-2 | Privacy-safe local configuration | `SEC-3`, `FEAT-003` | Complete | Medium | Configuration precedence decision | Phase 0 | No private defaults tracked; clean setup works; missing config fails clearly |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Verification pending | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Complete | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R1-1 | SQLite/filesystem integration fixture | `TEST-002`, `REL-001` | Complete | Medium | Plan interface | Phase 1 | Real queries and multi-file/collision paths pass in CI |
| R1-2 | Versioned run manifest | `REL-002`, `FEAT-001` | Complete | Medium | R0-1 | Phase 1 | Every run has attributable operation states, recovery checkpoints, and a completion marker |
| R2-1 | Explicit planner/executor/DB boundaries | `ARCH-001` | Complete | Medium | R0-1, R1-1 | Phase 2 | No global cursor required by tests; behavior unchanged |
Expand DownExpand Up@@ -306,7 +306,6 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e

## Recommended Execution Order

1. **R0-3:** Run the existing filename rule suite on Windows before describing Windows runtime support as complete.
2. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
3. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
4. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
1. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
2. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
3. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
43 changes: 38 additions & 5 deletions rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
from datetime import UTC, datetime
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
Expand All@@ -21,6 +22,8 @@
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
INVALID_FILENAME_CHARS = re.compile(r'[\\/:"*?<>|#,\x00-\x1f]+')

Expand DownExpand Up@@ -60,13 +63,14 @@ def sanitize_filename(filename: str) -> str:
"""Return a Windows-safe filename component or raise ``ValueError``.

The existing punctuation policy also strips ``#`` and ``,``. Control
characters and trailing periods/spaces are removed. Reserved Windows
basenames are rejected rather than silently renamed to an unrelated file.
characters plus leading/trailing ASCII spaces and trailing periods are
removed. Reserved Windows basenames are rejected rather than silently
renamed to an unrelated file.
"""
cleaned = INVALID_FILENAME_CHARS.sub("", filename).rstrip(". ")
cleaned = INVALID_FILENAME_CHARS.sub("", filename).lstrip(" ").rstrip(". ")
if not cleaned or not any(character.isalnum() for character in cleaned):
raise ValueError("filename is empty after Windows normalization")
stem = cleaned.split(".", 1)[0].upper()
stem = cleaned.split(".", 1)[0].rstrip(" ").upper()
if stem in WINDOWS_RESERVED_NAMES:
raise ValueError("filename uses reserved Windows basename: {}".format(stem))
return cleaned
Expand DownExpand Up@@ -117,6 +121,24 @@ def read_plan(path: str | os.PathLike[str]) -> RenamePlan:
return plan


def _normalized_destination(destination: str) -> str:
"""Return the case-insensitive Windows comparison form for a destination."""
parent, filename = os.path.split(os.path.normpath(destination))
return ntpath.normcase(os.path.join(parent, filename.lstrip(" ").rstrip(". ")))


def _destination_filename_error(destination: str) -> str | None:
"""Return a safety error when a destination basename is not Windows-safe."""
filename = os.path.basename(destination)
try:
sanitized = sanitize_filename(filename)
except ValueError as error:
return str(error)
if sanitized != filename:
return "destination filename changes under Windows normalization"
return None


def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"""Validate filesystem and cross-operation safety without modifying files."""
issues: list[PlanIssue] = []
Expand All@@ -133,6 +155,17 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
)
)
continue
filename_error = _destination_filename_error(operation.destination)
if filename_error:
issues.append(
PlanIssue(
operation.scene_id,
operation.source,
operation.destination,
"invalid_destination",
filename_error,
)
)
if operation.source == operation.destination:
continue
source_directory = os.path.abspath(os.path.dirname(operation.source))
Expand DownExpand Up@@ -167,7 +200,7 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"destination already exists",
)
)
normalized = os.path.normpath(operation.destination).rstrip(". ").casefold()
normalized = _normalized_destination(operation.destination)
other = destinations.get(normalized)
if other is not None:
issues.append(
Expand Down
90 changes: 85 additions & 5 deletions tests/test_rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,11 +19,54 @@


class TestFilenameSanitization(unittest.TestCase):
def test_strips_control_characters_and_trailing_dot_space(self):
self.assertEqual(sanitize_filename("Title\x00 .mp4. "), "Title .mp4")

def test_rejects_reserved_windows_names_with_extensions(self):
for name in ("CON.mp4", "nul", "COM1.txt", "LPT9 "):
def test_normalizes_windows_unsafe_characters_and_whitespace(self):
"""Normalize forbidden characters and Windows-trimmed ASCII whitespace."""
cases = (
("Title\x00 .mp4. ", "Title .mp4"),
(" Title.mp4", "Title.mp4"),
('Title<>:"/\\|?*#, .mp4', "Title .mp4"),
)
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(sanitize_filename(source), expected)

def test_rejects_all_reserved_windows_names_with_extensions(self):
"""Reject standard and superscript Windows device basenames."""
reserved_names = (
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
)
for reserved_name in reserved_names:
for suffix in ("", ".mp4", " .txt"):
name = reserved_name + suffix
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)

@unittest.skipUnless(os.name == "nt", "Windows filesystem verification")
def test_sanitized_name_can_be_used_by_the_windows_filesystem(self):
"""Rename a temporary file to a sanitized Windows-safe destination."""
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "source.mp4")
destination = os.path.join(directory, sanitize_filename(" Title\x00 .mp4. "))
with open(source, "w", encoding="utf-8") as source_file:
source_file.write("test")
os.rename(source, destination)
self.assertTrue(os.path.isfile(destination))

def test_rejects_empty_filename_after_normalization(self):
"""Reject names with no usable characters after normalization."""
for name in (" . ", "\x00"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)
Expand DownExpand Up@@ -67,6 +110,43 @@ def test_normalized_duplicate_destinations_block_a_plan(self):
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_leading_space_normalization_collision_blocks_a_plan(self):
"""Block destinations that Windows treats as equal after trimming."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, self.destination),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, " new.mp4")),
)
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_windows_case_normalization_preserves_sharp_s_distinction(self):
"""Allow distinct NTFS names that Unicode case folding would merge."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, os.path.join(self.tempdir.name, "Straße.mp4")),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, "Strasse.mp4")),
)
)
self.assertNotIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_persisted_reserved_windows_destination_blocks_a_plan(self):
"""Reject a reserved device name after reading a digest-valid plan."""
plan = create_plan(
(RenameOperation("1", self.source, os.path.join(self.tempdir.name, "COM¹.mp4")),)
)
plan_path = os.path.join(self.tempdir.name, "plan.json")
write_plan(plan, plan_path)
issues = validate_plan(read_plan(plan_path))
self.assertEqual([issue.code for issue in issues], ["invalid_destination"])
self.assertIn("reserved Windows basename", issues[0].message)

def test_write_read_and_apply_require_an_unchanged_valid_plan(self):
plan = create_plan((RenameOperation("1", self.source, self.destination),))
plan_path = os.path.join(self.tempdir.name, "plan.json")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_renamer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ def test_discovery_can_stop_after_first(self):

class TestCompatibilityRenderer(unittest.TestCase):
def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
"""Keep the compatibility renderer read-only and platform-neutral."""
manager = MagicMock()
database = manager.__enter__.return_value
database.cursor.fetchall.return_value = [
Expand All@@ -115,7 +116,9 @@ def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
finally:
os.chdir(original_cwd)
self.assertEqual(len(plan.operations), 1)
self.assertEqual(plan.operations[0].destination, "/fixture/Fixture Title.mp4")
self.assertEqual(
plan.operations[0].destination, os.path.join("/fixture", "Fixture Title.mp4")
)
manager.__enter__.assert_called_once_with()
manager.__exit__.assert_called_once()

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
# yamllint requires LF line endings, including on Windows checkouts.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,7 @@ Available variables: `$date` `$performer` `$title` `$studio` `$height`
| `$date $performer - $title [$studio]` | `2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex].mp4` |

Notes:
- Illegal Windows filename charactersare stripped automatically. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Illegal Windows filename characters, leading/trailing ASCII spaces, and trailing periods are stripped automatically. Reserved device names (including `CON`, `NUL`, `COM1`–`COM9`, and `LPT1`–`LPT9`) block the plan. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Heights of 2160 and 4320 are shown as `4k` and `8k`; others as `<height>p` (e.g. `1080p`).
- If a scene has more than 3 performers, `$performer` is omitted. This applies before the optional `FEMALE_ONLY` filter.

Expand Down
11 changes: 5 additions & 6 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ This is the first `ROADMAP.md`, but it reconciles the June 2026 historical audit

### R0-3 — Complete Windows filename rules and normalization collision checks

- **Status:** Implemented 2026-08-23; pending Windows-runtime verification.
- **Status:** Completed 2026-08-25. Windows-runtime verification with Python 3.14 exercised temporary-file renames and confirmed filename sanitization for invalid/control characters, ASCII whitespace/period normalization, standard and superscript device names, and normalized destination collisions.
- **Source:** `BUG-001`, `TEST-002`
- **Action:** Centralize filename sanitization/validation for control characters, reserved device basenames, trailing periods/spaces, existing punctuation policy, and post-normalization collisions.
- **Reason / expected effect:** Turns predictable Windows rename failures into deterministic plan errors or safe normalized names.
Expand DownExpand Up@@ -275,7 +275,7 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e
|---|---|---|---|---|---|---|---|
| R0-1 | Authoritative validated plan/apply | `REL-001`, `ARCH-001`, `FEAT-001` | Complete | Medium | Current semantics regression tests | Phase 0 | Dry run and apply consume identical plan; all blocking conflicts detected before mutation |
| R0-2 | Privacy-safe local configuration | `SEC-3`, `FEAT-003` | Complete | Medium | Configuration precedence decision | Phase 0 | No private defaults tracked; clean setup works; missing config fails clearly |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Verification pending | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Complete | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R1-1 | SQLite/filesystem integration fixture | `TEST-002`, `REL-001` | Complete | Medium | Plan interface | Phase 1 | Real queries and multi-file/collision paths pass in CI |
| R1-2 | Versioned run manifest | `REL-002`, `FEAT-001` | Complete | Medium | R0-1 | Phase 1 | Every run has attributable operation states, recovery checkpoints, and a completion marker |
| R2-1 | Explicit planner/executor/DB boundaries | `ARCH-001` | Complete | Medium | R0-1, R1-1 | Phase 2 | No global cursor required by tests; behavior unchanged |
Expand DownExpand Up@@ -306,7 +306,6 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e

## Recommended Execution Order

1. **R0-3:** Run the existing filename rule suite on Windows before describing Windows runtime support as complete.
2. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
3. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
4. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
1. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
2. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
3. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
43 changes: 38 additions & 5 deletions rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
from datetime import UTC, datetime
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
Expand All@@ -21,6 +22,8 @@
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
INVALID_FILENAME_CHARS = re.compile(r'[\\/:"*?<>|#,\x00-\x1f]+')

Expand DownExpand Up@@ -60,13 +63,14 @@ def sanitize_filename(filename: str) -> str:
"""Return a Windows-safe filename component or raise ``ValueError``.

The existing punctuation policy also strips ``#`` and ``,``. Control
characters and trailing periods/spaces are removed. Reserved Windows
basenames are rejected rather than silently renamed to an unrelated file.
characters plus leading/trailing ASCII spaces and trailing periods are
removed. Reserved Windows basenames are rejected rather than silently
renamed to an unrelated file.
"""
cleaned = INVALID_FILENAME_CHARS.sub("", filename).rstrip(". ")
cleaned = INVALID_FILENAME_CHARS.sub("", filename).lstrip(" ").rstrip(". ")
if not cleaned or not any(character.isalnum() for character in cleaned):
raise ValueError("filename is empty after Windows normalization")
stem = cleaned.split(".", 1)[0].upper()
stem = cleaned.split(".", 1)[0].rstrip(" ").upper()
if stem in WINDOWS_RESERVED_NAMES:
raise ValueError("filename uses reserved Windows basename: {}".format(stem))
return cleaned
Expand DownExpand Up@@ -117,6 +121,24 @@ def read_plan(path: str | os.PathLike[str]) -> RenamePlan:
return plan


def _normalized_destination(destination: str) -> str:
"""Return the case-insensitive Windows comparison form for a destination."""
parent, filename = os.path.split(os.path.normpath(destination))
return ntpath.normcase(os.path.join(parent, filename.lstrip(" ").rstrip(". ")))


def _destination_filename_error(destination: str) -> str | None:
"""Return a safety error when a destination basename is not Windows-safe."""
filename = os.path.basename(destination)
try:
sanitized = sanitize_filename(filename)
except ValueError as error:
return str(error)
if sanitized != filename:
return "destination filename changes under Windows normalization"
return None


def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"""Validate filesystem and cross-operation safety without modifying files."""
issues: list[PlanIssue] = []
Expand All@@ -133,6 +155,17 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
)
)
continue
filename_error = _destination_filename_error(operation.destination)
if filename_error:
issues.append(
PlanIssue(
operation.scene_id,
operation.source,
operation.destination,
"invalid_destination",
filename_error,
)
)
if operation.source == operation.destination:
continue
source_directory = os.path.abspath(os.path.dirname(operation.source))
Expand DownExpand Up@@ -167,7 +200,7 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"destination already exists",
)
)
normalized = os.path.normpath(operation.destination).rstrip(". ").casefold()
normalized = _normalized_destination(operation.destination)
other = destinations.get(normalized)
if other is not None:
issues.append(
Expand Down
90 changes: 85 additions & 5 deletions tests/test_rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,11 +19,54 @@


class TestFilenameSanitization(unittest.TestCase):
def test_strips_control_characters_and_trailing_dot_space(self):
self.assertEqual(sanitize_filename("Title\x00 .mp4. "), "Title .mp4")

def test_rejects_reserved_windows_names_with_extensions(self):
for name in ("CON.mp4", "nul", "COM1.txt", "LPT9 "):
def test_normalizes_windows_unsafe_characters_and_whitespace(self):
"""Normalize forbidden characters and Windows-trimmed ASCII whitespace."""
cases = (
("Title\x00 .mp4. ", "Title .mp4"),
(" Title.mp4", "Title.mp4"),
('Title<>:"/\\|?*#, .mp4', "Title .mp4"),
)
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(sanitize_filename(source), expected)

def test_rejects_all_reserved_windows_names_with_extensions(self):
"""Reject standard and superscript Windows device basenames."""
reserved_names = (
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
)
for reserved_name in reserved_names:
for suffix in ("", ".mp4", " .txt"):
name = reserved_name + suffix
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)

@unittest.skipUnless(os.name == "nt", "Windows filesystem verification")
def test_sanitized_name_can_be_used_by_the_windows_filesystem(self):
"""Rename a temporary file to a sanitized Windows-safe destination."""
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "source.mp4")
destination = os.path.join(directory, sanitize_filename(" Title\x00 .mp4. "))
with open(source, "w", encoding="utf-8") as source_file:
source_file.write("test")
os.rename(source, destination)
self.assertTrue(os.path.isfile(destination))

def test_rejects_empty_filename_after_normalization(self):
"""Reject names with no usable characters after normalization."""
for name in (" . ", "\x00"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)
Expand DownExpand Up@@ -67,6 +110,43 @@ def test_normalized_duplicate_destinations_block_a_plan(self):
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_leading_space_normalization_collision_blocks_a_plan(self):
"""Block destinations that Windows treats as equal after trimming."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, self.destination),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, " new.mp4")),
)
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_windows_case_normalization_preserves_sharp_s_distinction(self):
"""Allow distinct NTFS names that Unicode case folding would merge."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, os.path.join(self.tempdir.name, "Straße.mp4")),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, "Strasse.mp4")),
)
)
self.assertNotIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_persisted_reserved_windows_destination_blocks_a_plan(self):
"""Reject a reserved device name after reading a digest-valid plan."""
plan = create_plan(
(RenameOperation("1", self.source, os.path.join(self.tempdir.name, "COM¹.mp4")),)
)
plan_path = os.path.join(self.tempdir.name, "plan.json")
write_plan(plan, plan_path)
issues = validate_plan(read_plan(plan_path))
self.assertEqual([issue.code for issue in issues], ["invalid_destination"])
self.assertIn("reserved Windows basename", issues[0].message)

def test_write_read_and_apply_require_an_unchanged_valid_plan(self):
plan = create_plan((RenameOperation("1", self.source, self.destination),))
plan_path = os.path.join(self.tempdir.name, "plan.json")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_renamer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ def test_discovery_can_stop_after_first(self):

class TestCompatibilityRenderer(unittest.TestCase):
def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
"""Keep the compatibility renderer read-only and platform-neutral."""
manager = MagicMock()
database = manager.__enter__.return_value
database.cursor.fetchall.return_value = [
Expand All@@ -115,7 +116,9 @@ def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
finally:
os.chdir(original_cwd)
self.assertEqual(len(plan.operations), 1)
self.assertEqual(plan.operations[0].destination, "/fixture/Fixture Title.mp4")
self.assertEqual(
plan.operations[0].destination, os.path.join("/fixture", "Fixture Title.mp4")
)
manager.__enter__.assert_called_once_with()
manager.__exit__.assert_called_once()

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
# yamllint requires LF line endings, including on Windows checkouts.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,7 @@ Available variables: `$date` `$performer` `$title` `$studio` `$height`
| `$date $performer - $title [$studio]` | `2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex].mp4` |

Notes:
- Illegal Windows filename charactersare stripped automatically. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Illegal Windows filename characters, leading/trailing ASCII spaces, and trailing periods are stripped automatically. Reserved device names (including `CON`, `NUL`, `COM1`–`COM9`, and `LPT1`–`LPT9`) block the plan. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Heights of 2160 and 4320 are shown as `4k` and `8k`; others as `<height>p` (e.g. `1080p`).
- If a scene has more than 3 performers, `$performer` is omitted. This applies before the optional `FEMALE_ONLY` filter.

Expand Down
11 changes: 5 additions & 6 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ This is the first `ROADMAP.md`, but it reconciles the June 2026 historical audit

### R0-3 — Complete Windows filename rules and normalization collision checks

- **Status:** Implemented 2026-08-23; pending Windows-runtime verification.
- **Status:** Completed 2026-08-25. Windows-runtime verification with Python 3.14 exercised temporary-file renames and confirmed filename sanitization for invalid/control characters, ASCII whitespace/period normalization, standard and superscript device names, and normalized destination collisions.
- **Source:** `BUG-001`, `TEST-002`
- **Action:** Centralize filename sanitization/validation for control characters, reserved device basenames, trailing periods/spaces, existing punctuation policy, and post-normalization collisions.
- **Reason / expected effect:** Turns predictable Windows rename failures into deterministic plan errors or safe normalized names.
Expand DownExpand Up@@ -275,7 +275,7 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e
|---|---|---|---|---|---|---|---|
| R0-1 | Authoritative validated plan/apply | `REL-001`, `ARCH-001`, `FEAT-001` | Complete | Medium | Current semantics regression tests | Phase 0 | Dry run and apply consume identical plan; all blocking conflicts detected before mutation |
| R0-2 | Privacy-safe local configuration | `SEC-3`, `FEAT-003` | Complete | Medium | Configuration precedence decision | Phase 0 | No private defaults tracked; clean setup works; missing config fails clearly |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Verification pending | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Complete | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R1-1 | SQLite/filesystem integration fixture | `TEST-002`, `REL-001` | Complete | Medium | Plan interface | Phase 1 | Real queries and multi-file/collision paths pass in CI |
| R1-2 | Versioned run manifest | `REL-002`, `FEAT-001` | Complete | Medium | R0-1 | Phase 1 | Every run has attributable operation states, recovery checkpoints, and a completion marker |
| R2-1 | Explicit planner/executor/DB boundaries | `ARCH-001` | Complete | Medium | R0-1, R1-1 | Phase 2 | No global cursor required by tests; behavior unchanged |
Expand DownExpand Up@@ -306,7 +306,6 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e

## Recommended Execution Order

1. **R0-3:** Run the existing filename rule suite on Windows before describing Windows runtime support as complete.
2. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
3. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
4. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
1. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
2. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
3. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
43 changes: 38 additions & 5 deletions rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
from datetime import UTC, datetime
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
Expand All@@ -21,6 +22,8 @@
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
INVALID_FILENAME_CHARS = re.compile(r'[\\/:"*?<>|#,\x00-\x1f]+')

Expand DownExpand Up@@ -60,13 +63,14 @@ def sanitize_filename(filename: str) -> str:
"""Return a Windows-safe filename component or raise ``ValueError``.

The existing punctuation policy also strips ``#`` and ``,``. Control
characters and trailing periods/spaces are removed. Reserved Windows
basenames are rejected rather than silently renamed to an unrelated file.
characters plus leading/trailing ASCII spaces and trailing periods are
removed. Reserved Windows basenames are rejected rather than silently
renamed to an unrelated file.
"""
cleaned = INVALID_FILENAME_CHARS.sub("", filename).rstrip(". ")
cleaned = INVALID_FILENAME_CHARS.sub("", filename).lstrip(" ").rstrip(". ")
if not cleaned or not any(character.isalnum() for character in cleaned):
raise ValueError("filename is empty after Windows normalization")
stem = cleaned.split(".", 1)[0].upper()
stem = cleaned.split(".", 1)[0].rstrip(" ").upper()
if stem in WINDOWS_RESERVED_NAMES:
raise ValueError("filename uses reserved Windows basename: {}".format(stem))
return cleaned
Expand DownExpand Up@@ -117,6 +121,24 @@ def read_plan(path: str | os.PathLike[str]) -> RenamePlan:
return plan


def _normalized_destination(destination: str) -> str:
"""Return the case-insensitive Windows comparison form for a destination."""
parent, filename = os.path.split(os.path.normpath(destination))
return ntpath.normcase(os.path.join(parent, filename.lstrip(" ").rstrip(". ")))


def _destination_filename_error(destination: str) -> str | None:
"""Return a safety error when a destination basename is not Windows-safe."""
filename = os.path.basename(destination)
try:
sanitized = sanitize_filename(filename)
except ValueError as error:
return str(error)
if sanitized != filename:
return "destination filename changes under Windows normalization"
return None


def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"""Validate filesystem and cross-operation safety without modifying files."""
issues: list[PlanIssue] = []
Expand All@@ -133,6 +155,17 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
)
)
continue
filename_error = _destination_filename_error(operation.destination)
if filename_error:
issues.append(
PlanIssue(
operation.scene_id,
operation.source,
operation.destination,
"invalid_destination",
filename_error,
)
)
if operation.source == operation.destination:
continue
source_directory = os.path.abspath(os.path.dirname(operation.source))
Expand DownExpand Up@@ -167,7 +200,7 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"destination already exists",
)
)
normalized = os.path.normpath(operation.destination).rstrip(". ").casefold()
normalized = _normalized_destination(operation.destination)
other = destinations.get(normalized)
if other is not None:
issues.append(
Expand Down
90 changes: 85 additions & 5 deletions tests/test_rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,11 +19,54 @@


class TestFilenameSanitization(unittest.TestCase):
def test_strips_control_characters_and_trailing_dot_space(self):
self.assertEqual(sanitize_filename("Title\x00 .mp4. "), "Title .mp4")

def test_rejects_reserved_windows_names_with_extensions(self):
for name in ("CON.mp4", "nul", "COM1.txt", "LPT9 "):
def test_normalizes_windows_unsafe_characters_and_whitespace(self):
"""Normalize forbidden characters and Windows-trimmed ASCII whitespace."""
cases = (
("Title\x00 .mp4. ", "Title .mp4"),
(" Title.mp4", "Title.mp4"),
('Title<>:"/\\|?*#, .mp4', "Title .mp4"),
)
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(sanitize_filename(source), expected)

def test_rejects_all_reserved_windows_names_with_extensions(self):
"""Reject standard and superscript Windows device basenames."""
reserved_names = (
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
)
for reserved_name in reserved_names:
for suffix in ("", ".mp4", " .txt"):
name = reserved_name + suffix
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)

@unittest.skipUnless(os.name == "nt", "Windows filesystem verification")
def test_sanitized_name_can_be_used_by_the_windows_filesystem(self):
"""Rename a temporary file to a sanitized Windows-safe destination."""
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "source.mp4")
destination = os.path.join(directory, sanitize_filename(" Title\x00 .mp4. "))
with open(source, "w", encoding="utf-8") as source_file:
source_file.write("test")
os.rename(source, destination)
self.assertTrue(os.path.isfile(destination))

def test_rejects_empty_filename_after_normalization(self):
"""Reject names with no usable characters after normalization."""
for name in (" . ", "\x00"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)
Expand DownExpand Up@@ -67,6 +110,43 @@ def test_normalized_duplicate_destinations_block_a_plan(self):
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_leading_space_normalization_collision_blocks_a_plan(self):
"""Block destinations that Windows treats as equal after trimming."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, self.destination),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, " new.mp4")),
)
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_windows_case_normalization_preserves_sharp_s_distinction(self):
"""Allow distinct NTFS names that Unicode case folding would merge."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, os.path.join(self.tempdir.name, "Straße.mp4")),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, "Strasse.mp4")),
)
)
self.assertNotIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_persisted_reserved_windows_destination_blocks_a_plan(self):
"""Reject a reserved device name after reading a digest-valid plan."""
plan = create_plan(
(RenameOperation("1", self.source, os.path.join(self.tempdir.name, "COM¹.mp4")),)
)
plan_path = os.path.join(self.tempdir.name, "plan.json")
write_plan(plan, plan_path)
issues = validate_plan(read_plan(plan_path))
self.assertEqual([issue.code for issue in issues], ["invalid_destination"])
self.assertIn("reserved Windows basename", issues[0].message)

def test_write_read_and_apply_require_an_unchanged_valid_plan(self):
plan = create_plan((RenameOperation("1", self.source, self.destination),))
plan_path = os.path.join(self.tempdir.name, "plan.json")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_renamer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ def test_discovery_can_stop_after_first(self):

class TestCompatibilityRenderer(unittest.TestCase):
def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
"""Keep the compatibility renderer read-only and platform-neutral."""
manager = MagicMock()
database = manager.__enter__.return_value
database.cursor.fetchall.return_value = [
Expand All@@ -115,7 +116,9 @@ def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
finally:
os.chdir(original_cwd)
self.assertEqual(len(plan.operations), 1)
self.assertEqual(plan.operations[0].destination, "/fixture/Fixture Title.mp4")
self.assertEqual(
plan.operations[0].destination, os.path.join("/fixture", "Fixture Title.mp4")
)
manager.__enter__.assert_called_once_with()
manager.__exit__.assert_called_once()

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
# yamllint requires LF line endings, including on Windows checkouts.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,7 @@ Available variables: `$date` `$performer` `$title` `$studio` `$height`
| `$date $performer - $title [$studio]` | `2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex].mp4` |

Notes:
- Illegal Windows filename charactersare stripped automatically. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Illegal Windows filename characters, leading/trailing ASCII spaces, and trailing periods are stripped automatically. Reserved device names (including `CON`, `NUL`, `COM1`–`COM9`, and `LPT1`–`LPT9`) block the plan. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Heights of 2160 and 4320 are shown as `4k` and `8k`; others as `<height>p` (e.g. `1080p`).
- If a scene has more than 3 performers, `$performer` is omitted. This applies before the optional `FEMALE_ONLY` filter.

Expand Down
11 changes: 5 additions & 6 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ This is the first `ROADMAP.md`, but it reconciles the June 2026 historical audit

### R0-3 — Complete Windows filename rules and normalization collision checks

- **Status:** Implemented 2026-08-23; pending Windows-runtime verification.
- **Status:** Completed 2026-08-25. Windows-runtime verification with Python 3.14 exercised temporary-file renames and confirmed filename sanitization for invalid/control characters, ASCII whitespace/period normalization, standard and superscript device names, and normalized destination collisions.
- **Source:** `BUG-001`, `TEST-002`
- **Action:** Centralize filename sanitization/validation for control characters, reserved device basenames, trailing periods/spaces, existing punctuation policy, and post-normalization collisions.
- **Reason / expected effect:** Turns predictable Windows rename failures into deterministic plan errors or safe normalized names.
Expand DownExpand Up@@ -275,7 +275,7 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e
|---|---|---|---|---|---|---|---|
| R0-1 | Authoritative validated plan/apply | `REL-001`, `ARCH-001`, `FEAT-001` | Complete | Medium | Current semantics regression tests | Phase 0 | Dry run and apply consume identical plan; all blocking conflicts detected before mutation |
| R0-2 | Privacy-safe local configuration | `SEC-3`, `FEAT-003` | Complete | Medium | Configuration precedence decision | Phase 0 | No private defaults tracked; clean setup works; missing config fails clearly |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Verification pending | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Complete | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R1-1 | SQLite/filesystem integration fixture | `TEST-002`, `REL-001` | Complete | Medium | Plan interface | Phase 1 | Real queries and multi-file/collision paths pass in CI |
| R1-2 | Versioned run manifest | `REL-002`, `FEAT-001` | Complete | Medium | R0-1 | Phase 1 | Every run has attributable operation states, recovery checkpoints, and a completion marker |
| R2-1 | Explicit planner/executor/DB boundaries | `ARCH-001` | Complete | Medium | R0-1, R1-1 | Phase 2 | No global cursor required by tests; behavior unchanged |
Expand DownExpand Up@@ -306,7 +306,6 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e

## Recommended Execution Order

1. **R0-3:** Run the existing filename rule suite on Windows before describing Windows runtime support as complete.
2. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
3. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
4. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
1. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
2. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
3. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
43 changes: 38 additions & 5 deletions rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
from datetime import UTC, datetime
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
Expand All@@ -21,6 +22,8 @@
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
INVALID_FILENAME_CHARS = re.compile(r'[\\/:"*?<>|#,\x00-\x1f]+')

Expand DownExpand Up@@ -60,13 +63,14 @@ def sanitize_filename(filename: str) -> str:
"""Return a Windows-safe filename component or raise ``ValueError``.

The existing punctuation policy also strips ``#`` and ``,``. Control
characters and trailing periods/spaces are removed. Reserved Windows
basenames are rejected rather than silently renamed to an unrelated file.
characters plus leading/trailing ASCII spaces and trailing periods are
removed. Reserved Windows basenames are rejected rather than silently
renamed to an unrelated file.
"""
cleaned = INVALID_FILENAME_CHARS.sub("", filename).rstrip(". ")
cleaned = INVALID_FILENAME_CHARS.sub("", filename).lstrip(" ").rstrip(". ")
if not cleaned or not any(character.isalnum() for character in cleaned):
raise ValueError("filename is empty after Windows normalization")
stem = cleaned.split(".", 1)[0].upper()
stem = cleaned.split(".", 1)[0].rstrip(" ").upper()
if stem in WINDOWS_RESERVED_NAMES:
raise ValueError("filename uses reserved Windows basename: {}".format(stem))
return cleaned
Expand DownExpand Up@@ -117,6 +121,24 @@ def read_plan(path: str | os.PathLike[str]) -> RenamePlan:
return plan


def _normalized_destination(destination: str) -> str:
"""Return the case-insensitive Windows comparison form for a destination."""
parent, filename = os.path.split(os.path.normpath(destination))
return ntpath.normcase(os.path.join(parent, filename.lstrip(" ").rstrip(". ")))


def _destination_filename_error(destination: str) -> str | None:
"""Return a safety error when a destination basename is not Windows-safe."""
filename = os.path.basename(destination)
try:
sanitized = sanitize_filename(filename)
except ValueError as error:
return str(error)
if sanitized != filename:
return "destination filename changes under Windows normalization"
return None


def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"""Validate filesystem and cross-operation safety without modifying files."""
issues: list[PlanIssue] = []
Expand All@@ -133,6 +155,17 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
)
)
continue
filename_error = _destination_filename_error(operation.destination)
if filename_error:
issues.append(
PlanIssue(
operation.scene_id,
operation.source,
operation.destination,
"invalid_destination",
filename_error,
)
)
if operation.source == operation.destination:
continue
source_directory = os.path.abspath(os.path.dirname(operation.source))
Expand DownExpand Up@@ -167,7 +200,7 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"destination already exists",
)
)
normalized = os.path.normpath(operation.destination).rstrip(". ").casefold()
normalized = _normalized_destination(operation.destination)
other = destinations.get(normalized)
if other is not None:
issues.append(
Expand Down
90 changes: 85 additions & 5 deletions tests/test_rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,11 +19,54 @@


class TestFilenameSanitization(unittest.TestCase):
def test_strips_control_characters_and_trailing_dot_space(self):
self.assertEqual(sanitize_filename("Title\x00 .mp4. "), "Title .mp4")

def test_rejects_reserved_windows_names_with_extensions(self):
for name in ("CON.mp4", "nul", "COM1.txt", "LPT9 "):
def test_normalizes_windows_unsafe_characters_and_whitespace(self):
"""Normalize forbidden characters and Windows-trimmed ASCII whitespace."""
cases = (
("Title\x00 .mp4. ", "Title .mp4"),
(" Title.mp4", "Title.mp4"),
('Title<>:"/\\|?*#, .mp4', "Title .mp4"),
)
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(sanitize_filename(source), expected)

def test_rejects_all_reserved_windows_names_with_extensions(self):
"""Reject standard and superscript Windows device basenames."""
reserved_names = (
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
)
for reserved_name in reserved_names:
for suffix in ("", ".mp4", " .txt"):
name = reserved_name + suffix
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)

@unittest.skipUnless(os.name == "nt", "Windows filesystem verification")
def test_sanitized_name_can_be_used_by_the_windows_filesystem(self):
"""Rename a temporary file to a sanitized Windows-safe destination."""
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "source.mp4")
destination = os.path.join(directory, sanitize_filename(" Title\x00 .mp4. "))
with open(source, "w", encoding="utf-8") as source_file:
source_file.write("test")
os.rename(source, destination)
self.assertTrue(os.path.isfile(destination))

def test_rejects_empty_filename_after_normalization(self):
"""Reject names with no usable characters after normalization."""
for name in (" . ", "\x00"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)
Expand DownExpand Up@@ -67,6 +110,43 @@ def test_normalized_duplicate_destinations_block_a_plan(self):
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_leading_space_normalization_collision_blocks_a_plan(self):
"""Block destinations that Windows treats as equal after trimming."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, self.destination),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, " new.mp4")),
)
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_windows_case_normalization_preserves_sharp_s_distinction(self):
"""Allow distinct NTFS names that Unicode case folding would merge."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, os.path.join(self.tempdir.name, "Straße.mp4")),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, "Strasse.mp4")),
)
)
self.assertNotIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_persisted_reserved_windows_destination_blocks_a_plan(self):
"""Reject a reserved device name after reading a digest-valid plan."""
plan = create_plan(
(RenameOperation("1", self.source, os.path.join(self.tempdir.name, "COM¹.mp4")),)
)
plan_path = os.path.join(self.tempdir.name, "plan.json")
write_plan(plan, plan_path)
issues = validate_plan(read_plan(plan_path))
self.assertEqual([issue.code for issue in issues], ["invalid_destination"])
self.assertIn("reserved Windows basename", issues[0].message)

def test_write_read_and_apply_require_an_unchanged_valid_plan(self):
plan = create_plan((RenameOperation("1", self.source, self.destination),))
plan_path = os.path.join(self.tempdir.name, "plan.json")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_renamer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ def test_discovery_can_stop_after_first(self):

class TestCompatibilityRenderer(unittest.TestCase):
def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
"""Keep the compatibility renderer read-only and platform-neutral."""
manager = MagicMock()
database = manager.__enter__.return_value
database.cursor.fetchall.return_value = [
Expand All@@ -115,7 +116,9 @@ def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
finally:
os.chdir(original_cwd)
self.assertEqual(len(plan.operations), 1)
self.assertEqual(plan.operations[0].destination, "/fixture/Fixture Title.mp4")
self.assertEqual(
plan.operations[0].destination, os.path.join("/fixture", "Fixture Title.mp4")
)
manager.__enter__.assert_called_once_with()
manager.__exit__.assert_called_once()

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
# yamllint requires LF line endings, including on Windows checkouts.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,7 @@ Available variables: `$date` `$performer` `$title` `$studio` `$height`
| `$date $performer - $title [$studio]` | `2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex].mp4` |

Notes:
- Illegal Windows filename charactersare stripped automatically. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Illegal Windows filename characters, leading/trailing ASCII spaces, and trailing periods are stripped automatically. Reserved device names (including `CON`, `NUL`, `COM1`–`COM9`, and `LPT1`–`LPT9`) block the plan. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Heights of 2160 and 4320 are shown as `4k` and `8k`; others as `<height>p` (e.g. `1080p`).
- If a scene has more than 3 performers, `$performer` is omitted. This applies before the optional `FEMALE_ONLY` filter.

Expand Down
11 changes: 5 additions & 6 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ This is the first `ROADMAP.md`, but it reconciles the June 2026 historical audit

### R0-3 — Complete Windows filename rules and normalization collision checks

- **Status:** Implemented 2026-08-23; pending Windows-runtime verification.
- **Status:** Completed 2026-08-25. Windows-runtime verification with Python 3.14 exercised temporary-file renames and confirmed filename sanitization for invalid/control characters, ASCII whitespace/period normalization, standard and superscript device names, and normalized destination collisions.
- **Source:** `BUG-001`, `TEST-002`
- **Action:** Centralize filename sanitization/validation for control characters, reserved device basenames, trailing periods/spaces, existing punctuation policy, and post-normalization collisions.
- **Reason / expected effect:** Turns predictable Windows rename failures into deterministic plan errors or safe normalized names.
Expand DownExpand Up@@ -275,7 +275,7 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e
|---|---|---|---|---|---|---|---|
| R0-1 | Authoritative validated plan/apply | `REL-001`, `ARCH-001`, `FEAT-001` | Complete | Medium | Current semantics regression tests | Phase 0 | Dry run and apply consume identical plan; all blocking conflicts detected before mutation |
| R0-2 | Privacy-safe local configuration | `SEC-3`, `FEAT-003` | Complete | Medium | Configuration precedence decision | Phase 0 | No private defaults tracked; clean setup works; missing config fails clearly |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Verification pending | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Complete | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R1-1 | SQLite/filesystem integration fixture | `TEST-002`, `REL-001` | Complete | Medium | Plan interface | Phase 1 | Real queries and multi-file/collision paths pass in CI |
| R1-2 | Versioned run manifest | `REL-002`, `FEAT-001` | Complete | Medium | R0-1 | Phase 1 | Every run has attributable operation states, recovery checkpoints, and a completion marker |
| R2-1 | Explicit planner/executor/DB boundaries | `ARCH-001` | Complete | Medium | R0-1, R1-1 | Phase 2 | No global cursor required by tests; behavior unchanged |
Expand DownExpand Up@@ -306,7 +306,6 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e

## Recommended Execution Order

1. **R0-3:** Run the existing filename rule suite on Windows before describing Windows runtime support as complete.
2. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
3. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
4. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
1. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
2. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
3. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
43 changes: 38 additions & 5 deletions rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
from datetime import UTC, datetime
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
Expand All@@ -21,6 +22,8 @@
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
INVALID_FILENAME_CHARS = re.compile(r'[\\/:"*?<>|#,\x00-\x1f]+')

Expand DownExpand Up@@ -60,13 +63,14 @@ def sanitize_filename(filename: str) -> str:
"""Return a Windows-safe filename component or raise ``ValueError``.

The existing punctuation policy also strips ``#`` and ``,``. Control
characters and trailing periods/spaces are removed. Reserved Windows
basenames are rejected rather than silently renamed to an unrelated file.
characters plus leading/trailing ASCII spaces and trailing periods are
removed. Reserved Windows basenames are rejected rather than silently
renamed to an unrelated file.
"""
cleaned = INVALID_FILENAME_CHARS.sub("", filename).rstrip(". ")
cleaned = INVALID_FILENAME_CHARS.sub("", filename).lstrip(" ").rstrip(". ")
if not cleaned or not any(character.isalnum() for character in cleaned):
raise ValueError("filename is empty after Windows normalization")
stem = cleaned.split(".", 1)[0].upper()
stem = cleaned.split(".", 1)[0].rstrip(" ").upper()
if stem in WINDOWS_RESERVED_NAMES:
raise ValueError("filename uses reserved Windows basename: {}".format(stem))
return cleaned
Expand DownExpand Up@@ -117,6 +121,24 @@ def read_plan(path: str | os.PathLike[str]) -> RenamePlan:
return plan


def _normalized_destination(destination: str) -> str:
"""Return the case-insensitive Windows comparison form for a destination."""
parent, filename = os.path.split(os.path.normpath(destination))
return ntpath.normcase(os.path.join(parent, filename.lstrip(" ").rstrip(". ")))


def _destination_filename_error(destination: str) -> str | None:
"""Return a safety error when a destination basename is not Windows-safe."""
filename = os.path.basename(destination)
try:
sanitized = sanitize_filename(filename)
except ValueError as error:
return str(error)
if sanitized != filename:
return "destination filename changes under Windows normalization"
return None


def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"""Validate filesystem and cross-operation safety without modifying files."""
issues: list[PlanIssue] = []
Expand All@@ -133,6 +155,17 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
)
)
continue
filename_error = _destination_filename_error(operation.destination)
if filename_error:
issues.append(
PlanIssue(
operation.scene_id,
operation.source,
operation.destination,
"invalid_destination",
filename_error,
)
)
if operation.source == operation.destination:
continue
source_directory = os.path.abspath(os.path.dirname(operation.source))
Expand DownExpand Up@@ -167,7 +200,7 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"destination already exists",
)
)
normalized = os.path.normpath(operation.destination).rstrip(". ").casefold()
normalized = _normalized_destination(operation.destination)
other = destinations.get(normalized)
if other is not None:
issues.append(
Expand Down
90 changes: 85 additions & 5 deletions tests/test_rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,11 +19,54 @@


class TestFilenameSanitization(unittest.TestCase):
def test_strips_control_characters_and_trailing_dot_space(self):
self.assertEqual(sanitize_filename("Title\x00 .mp4. "), "Title .mp4")

def test_rejects_reserved_windows_names_with_extensions(self):
for name in ("CON.mp4", "nul", "COM1.txt", "LPT9 "):
def test_normalizes_windows_unsafe_characters_and_whitespace(self):
"""Normalize forbidden characters and Windows-trimmed ASCII whitespace."""
cases = (
("Title\x00 .mp4. ", "Title .mp4"),
(" Title.mp4", "Title.mp4"),
('Title<>:"/\\|?*#, .mp4', "Title .mp4"),
)
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(sanitize_filename(source), expected)

def test_rejects_all_reserved_windows_names_with_extensions(self):
"""Reject standard and superscript Windows device basenames."""
reserved_names = (
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
)
for reserved_name in reserved_names:
for suffix in ("", ".mp4", " .txt"):
name = reserved_name + suffix
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)

@unittest.skipUnless(os.name == "nt", "Windows filesystem verification")
def test_sanitized_name_can_be_used_by_the_windows_filesystem(self):
"""Rename a temporary file to a sanitized Windows-safe destination."""
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "source.mp4")
destination = os.path.join(directory, sanitize_filename(" Title\x00 .mp4. "))
with open(source, "w", encoding="utf-8") as source_file:
source_file.write("test")
os.rename(source, destination)
self.assertTrue(os.path.isfile(destination))

def test_rejects_empty_filename_after_normalization(self):
"""Reject names with no usable characters after normalization."""
for name in (" . ", "\x00"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)
Expand DownExpand Up@@ -67,6 +110,43 @@ def test_normalized_duplicate_destinations_block_a_plan(self):
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_leading_space_normalization_collision_blocks_a_plan(self):
"""Block destinations that Windows treats as equal after trimming."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, self.destination),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, " new.mp4")),
)
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_windows_case_normalization_preserves_sharp_s_distinction(self):
"""Allow distinct NTFS names that Unicode case folding would merge."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, os.path.join(self.tempdir.name, "Straße.mp4")),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, "Strasse.mp4")),
)
)
self.assertNotIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_persisted_reserved_windows_destination_blocks_a_plan(self):
"""Reject a reserved device name after reading a digest-valid plan."""
plan = create_plan(
(RenameOperation("1", self.source, os.path.join(self.tempdir.name, "COM¹.mp4")),)
)
plan_path = os.path.join(self.tempdir.name, "plan.json")
write_plan(plan, plan_path)
issues = validate_plan(read_plan(plan_path))
self.assertEqual([issue.code for issue in issues], ["invalid_destination"])
self.assertIn("reserved Windows basename", issues[0].message)

def test_write_read_and_apply_require_an_unchanged_valid_plan(self):
plan = create_plan((RenameOperation("1", self.source, self.destination),))
plan_path = os.path.join(self.tempdir.name, "plan.json")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_renamer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ def test_discovery_can_stop_after_first(self):

class TestCompatibilityRenderer(unittest.TestCase):
def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
"""Keep the compatibility renderer read-only and platform-neutral."""
manager = MagicMock()
database = manager.__enter__.return_value
database.cursor.fetchall.return_value = [
Expand All@@ -115,7 +116,9 @@ def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
finally:
os.chdir(original_cwd)
self.assertEqual(len(plan.operations), 1)
self.assertEqual(plan.operations[0].destination, "/fixture/Fixture Title.mp4")
self.assertEqual(
plan.operations[0].destination, os.path.join("/fixture", "Fixture Title.mp4")
)
manager.__enter__.assert_called_once_with()
manager.__exit__.assert_called_once()

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
# yamllint requires LF line endings, including on Windows checkouts.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,7 @@ Available variables: `$date` `$performer` `$title` `$studio` `$height`
| `$date $performer - $title [$studio]` | `2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex].mp4` |

Notes:
- Illegal Windows filename charactersare stripped automatically. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Illegal Windows filename characters, leading/trailing ASCII spaces, and trailing periods are stripped automatically. Reserved device names (including `CON`, `NUL`, `COM1`–`COM9`, and `LPT1`–`LPT9`) block the plan. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Heights of 2160 and 4320 are shown as `4k` and `8k`; others as `<height>p` (e.g. `1080p`).
- If a scene has more than 3 performers, `$performer` is omitted. This applies before the optional `FEMALE_ONLY` filter.

Expand Down
11 changes: 5 additions & 6 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ This is the first `ROADMAP.md`, but it reconciles the June 2026 historical audit

### R0-3 — Complete Windows filename rules and normalization collision checks

- **Status:** Implemented 2026-08-23; pending Windows-runtime verification.
- **Status:** Completed 2026-08-25. Windows-runtime verification with Python 3.14 exercised temporary-file renames and confirmed filename sanitization for invalid/control characters, ASCII whitespace/period normalization, standard and superscript device names, and normalized destination collisions.
- **Source:** `BUG-001`, `TEST-002`
- **Action:** Centralize filename sanitization/validation for control characters, reserved device basenames, trailing periods/spaces, existing punctuation policy, and post-normalization collisions.
- **Reason / expected effect:** Turns predictable Windows rename failures into deterministic plan errors or safe normalized names.
Expand DownExpand Up@@ -275,7 +275,7 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e
|---|---|---|---|---|---|---|---|
| R0-1 | Authoritative validated plan/apply | `REL-001`, `ARCH-001`, `FEAT-001` | Complete | Medium | Current semantics regression tests | Phase 0 | Dry run and apply consume identical plan; all blocking conflicts detected before mutation |
| R0-2 | Privacy-safe local configuration | `SEC-3`, `FEAT-003` | Complete | Medium | Configuration precedence decision | Phase 0 | No private defaults tracked; clean setup works; missing config fails clearly |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Verification pending | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Complete | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R1-1 | SQLite/filesystem integration fixture | `TEST-002`, `REL-001` | Complete | Medium | Plan interface | Phase 1 | Real queries and multi-file/collision paths pass in CI |
| R1-2 | Versioned run manifest | `REL-002`, `FEAT-001` | Complete | Medium | R0-1 | Phase 1 | Every run has attributable operation states, recovery checkpoints, and a completion marker |
| R2-1 | Explicit planner/executor/DB boundaries | `ARCH-001` | Complete | Medium | R0-1, R1-1 | Phase 2 | No global cursor required by tests; behavior unchanged |
Expand DownExpand Up@@ -306,7 +306,6 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e

## Recommended Execution Order

1. **R0-3:** Run the existing filename rule suite on Windows before describing Windows runtime support as complete.
2. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
3. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
4. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
1. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
2. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
3. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
43 changes: 38 additions & 5 deletions rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
from datetime import UTC, datetime
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
Expand All@@ -21,6 +22,8 @@
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
INVALID_FILENAME_CHARS = re.compile(r'[\\/:"*?<>|#,\x00-\x1f]+')

Expand DownExpand Up@@ -60,13 +63,14 @@ def sanitize_filename(filename: str) -> str:
"""Return a Windows-safe filename component or raise ``ValueError``.

The existing punctuation policy also strips ``#`` and ``,``. Control
characters and trailing periods/spaces are removed. Reserved Windows
basenames are rejected rather than silently renamed to an unrelated file.
characters plus leading/trailing ASCII spaces and trailing periods are
removed. Reserved Windows basenames are rejected rather than silently
renamed to an unrelated file.
"""
cleaned = INVALID_FILENAME_CHARS.sub("", filename).rstrip(". ")
cleaned = INVALID_FILENAME_CHARS.sub("", filename).lstrip(" ").rstrip(". ")
if not cleaned or not any(character.isalnum() for character in cleaned):
raise ValueError("filename is empty after Windows normalization")
stem = cleaned.split(".", 1)[0].upper()
stem = cleaned.split(".", 1)[0].rstrip(" ").upper()
if stem in WINDOWS_RESERVED_NAMES:
raise ValueError("filename uses reserved Windows basename: {}".format(stem))
return cleaned
Expand DownExpand Up@@ -117,6 +121,24 @@ def read_plan(path: str | os.PathLike[str]) -> RenamePlan:
return plan


def _normalized_destination(destination: str) -> str:
"""Return the case-insensitive Windows comparison form for a destination."""
parent, filename = os.path.split(os.path.normpath(destination))
return ntpath.normcase(os.path.join(parent, filename.lstrip(" ").rstrip(". ")))


def _destination_filename_error(destination: str) -> str | None:
"""Return a safety error when a destination basename is not Windows-safe."""
filename = os.path.basename(destination)
try:
sanitized = sanitize_filename(filename)
except ValueError as error:
return str(error)
if sanitized != filename:
return "destination filename changes under Windows normalization"
return None


def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"""Validate filesystem and cross-operation safety without modifying files."""
issues: list[PlanIssue] = []
Expand All@@ -133,6 +155,17 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
)
)
continue
filename_error = _destination_filename_error(operation.destination)
if filename_error:
issues.append(
PlanIssue(
operation.scene_id,
operation.source,
operation.destination,
"invalid_destination",
filename_error,
)
)
if operation.source == operation.destination:
continue
source_directory = os.path.abspath(os.path.dirname(operation.source))
Expand DownExpand Up@@ -167,7 +200,7 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"destination already exists",
)
)
normalized = os.path.normpath(operation.destination).rstrip(". ").casefold()
normalized = _normalized_destination(operation.destination)
other = destinations.get(normalized)
if other is not None:
issues.append(
Expand Down
90 changes: 85 additions & 5 deletions tests/test_rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,11 +19,54 @@


class TestFilenameSanitization(unittest.TestCase):
def test_strips_control_characters_and_trailing_dot_space(self):
self.assertEqual(sanitize_filename("Title\x00 .mp4. "), "Title .mp4")

def test_rejects_reserved_windows_names_with_extensions(self):
for name in ("CON.mp4", "nul", "COM1.txt", "LPT9 "):
def test_normalizes_windows_unsafe_characters_and_whitespace(self):
"""Normalize forbidden characters and Windows-trimmed ASCII whitespace."""
cases = (
("Title\x00 .mp4. ", "Title .mp4"),
(" Title.mp4", "Title.mp4"),
('Title<>:"/\\|?*#, .mp4', "Title .mp4"),
)
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(sanitize_filename(source), expected)

def test_rejects_all_reserved_windows_names_with_extensions(self):
"""Reject standard and superscript Windows device basenames."""
reserved_names = (
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
)
for reserved_name in reserved_names:
for suffix in ("", ".mp4", " .txt"):
name = reserved_name + suffix
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)

@unittest.skipUnless(os.name == "nt", "Windows filesystem verification")
def test_sanitized_name_can_be_used_by_the_windows_filesystem(self):
"""Rename a temporary file to a sanitized Windows-safe destination."""
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "source.mp4")
destination = os.path.join(directory, sanitize_filename(" Title\x00 .mp4. "))
with open(source, "w", encoding="utf-8") as source_file:
source_file.write("test")
os.rename(source, destination)
self.assertTrue(os.path.isfile(destination))

def test_rejects_empty_filename_after_normalization(self):
"""Reject names with no usable characters after normalization."""
for name in (" . ", "\x00"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)
Expand DownExpand Up@@ -67,6 +110,43 @@ def test_normalized_duplicate_destinations_block_a_plan(self):
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_leading_space_normalization_collision_blocks_a_plan(self):
"""Block destinations that Windows treats as equal after trimming."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, self.destination),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, " new.mp4")),
)
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_windows_case_normalization_preserves_sharp_s_distinction(self):
"""Allow distinct NTFS names that Unicode case folding would merge."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, os.path.join(self.tempdir.name, "Straße.mp4")),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, "Strasse.mp4")),
)
)
self.assertNotIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_persisted_reserved_windows_destination_blocks_a_plan(self):
"""Reject a reserved device name after reading a digest-valid plan."""
plan = create_plan(
(RenameOperation("1", self.source, os.path.join(self.tempdir.name, "COM¹.mp4")),)
)
plan_path = os.path.join(self.tempdir.name, "plan.json")
write_plan(plan, plan_path)
issues = validate_plan(read_plan(plan_path))
self.assertEqual([issue.code for issue in issues], ["invalid_destination"])
self.assertIn("reserved Windows basename", issues[0].message)

def test_write_read_and_apply_require_an_unchanged_valid_plan(self):
plan = create_plan((RenameOperation("1", self.source, self.destination),))
plan_path = os.path.join(self.tempdir.name, "plan.json")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_renamer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ def test_discovery_can_stop_after_first(self):

class TestCompatibilityRenderer(unittest.TestCase):
def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
"""Keep the compatibility renderer read-only and platform-neutral."""
manager = MagicMock()
database = manager.__enter__.return_value
database.cursor.fetchall.return_value = [
Expand All@@ -115,7 +116,9 @@ def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
finally:
os.chdir(original_cwd)
self.assertEqual(len(plan.operations), 1)
self.assertEqual(plan.operations[0].destination, "/fixture/Fixture Title.mp4")
self.assertEqual(
plan.operations[0].destination, os.path.join("/fixture", "Fixture Title.mp4")
)
manager.__enter__.assert_called_once_with()
manager.__exit__.assert_called_once()

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
# yamllint requires LF line endings, including on Windows checkouts.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,7 +79,7 @@ Available variables: `$date` `$performer` `$title` `$studio` `$height`
| `$date $performer - $title [$studio]` | `2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex].mp4` |

Notes:
- Illegal Windows filename charactersare stripped automatically. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Illegal Windows filename characters, leading/trailing ASCII spaces, and trailing periods are stripped automatically. Reserved device names (including `CON`, `NUL`, `COM1`–`COM9`, and `LPT1`–`LPT9`) block the plan. `#` and `,` are also stripped even though they are legal on Windows — edit the character-stripping regex in the script to preserve them.
- Heights of 2160 and 4320 are shown as `4k` and `8k`; others as `<height>p` (e.g. `1080p`).
- If a scene has more than 3 performers, `$performer` is omitted. This applies before the optional `FEMALE_ONLY` filter.

Expand Down
11 changes: 5 additions & 6 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,7 +43,7 @@ This is the first `ROADMAP.md`, but it reconciles the June 2026 historical audit

### R0-3 — Complete Windows filename rules and normalization collision checks

- **Status:** Implemented 2026-08-23; pending Windows-runtime verification.
- **Status:** Completed 2026-08-25. Windows-runtime verification with Python 3.14 exercised temporary-file renames and confirmed filename sanitization for invalid/control characters, ASCII whitespace/period normalization, standard and superscript device names, and normalized destination collisions.
- **Source:** `BUG-001`, `TEST-002`
- **Action:** Centralize filename sanitization/validation for control characters, reserved device basenames, trailing periods/spaces, existing punctuation policy, and post-normalization collisions.
- **Reason / expected effect:** Turns predictable Windows rename failures into deterministic plan errors or safe normalized names.
Expand DownExpand Up@@ -275,7 +275,7 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e
|---|---|---|---|---|---|---|---|
| R0-1 | Authoritative validated plan/apply | `REL-001`, `ARCH-001`, `FEAT-001` | Complete | Medium | Current semantics regression tests | Phase 0 | Dry run and apply consume identical plan; all blocking conflicts detected before mutation |
| R0-2 | Privacy-safe local configuration | `SEC-3`, `FEAT-003` | Complete | Medium | Configuration precedence decision | Phase 0 | No private defaults tracked; clean setup works; missing config fails clearly |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Verification pending | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R0-3 | Complete Windows filename rules | `BUG-001`, `TEST-002` | Complete | Medium | Normalization policy | Phase 0 | Platform rule suite passes; normalization collisions block safely |
| R1-1 | SQLite/filesystem integration fixture | `TEST-002`, `REL-001` | Complete | Medium | Plan interface | Phase 1 | Real queries and multi-file/collision paths pass in CI |
| R1-2 | Versioned run manifest | `REL-002`, `FEAT-001` | Complete | Medium | R0-1 | Phase 1 | Every run has attributable operation states, recovery checkpoints, and a completion marker |
| R2-1 | Explicit planner/executor/DB boundaries | `ARCH-001` | Complete | Medium | R0-1, R1-1 | Phase 2 | No global cursor required by tests; behavior unchanged |
Expand DownExpand Up@@ -306,7 +306,6 @@ Any future branch deletion requires refreshed branch/PR/worktree/unique-commit e

## Recommended Execution Order

1. **R0-3:** Run the existing filename rule suite on Windows before describing Windows runtime support as complete.
2. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
3. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
4. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
1. **R3-2:** Prepare a release candidate; publish, tag, or release only after explicit approval and clean matrix/security checks.
2. **G2:** Update the GitHub homepage only after a separately approved public destination decision.
3. **X-1:** Separately validate Stash API/plugin demand and constraints; promote only with evidence.
43 changes: 38 additions & 5 deletions rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@
from datetime import UTC, datetime
import hashlib
import json
import ntpath
import os
from pathlib import Path
import re
Expand All@@ -21,6 +22,8 @@
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
*(f"COM{number}" for number in "¹²³"),
*(f"LPT{number}" for number in "¹²³"),
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
INVALID_FILENAME_CHARS = re.compile(r'[\\/:"*?<>|#,\x00-\x1f]+')

Expand DownExpand Up@@ -60,13 +63,14 @@ def sanitize_filename(filename: str) -> str:
"""Return a Windows-safe filename component or raise ``ValueError``.

The existing punctuation policy also strips ``#`` and ``,``. Control
characters and trailing periods/spaces are removed. Reserved Windows
basenames are rejected rather than silently renamed to an unrelated file.
characters plus leading/trailing ASCII spaces and trailing periods are
removed. Reserved Windows basenames are rejected rather than silently
renamed to an unrelated file.
"""
cleaned = INVALID_FILENAME_CHARS.sub("", filename).rstrip(". ")
cleaned = INVALID_FILENAME_CHARS.sub("", filename).lstrip(" ").rstrip(". ")
if not cleaned or not any(character.isalnum() for character in cleaned):
raise ValueError("filename is empty after Windows normalization")
stem = cleaned.split(".", 1)[0].upper()
stem = cleaned.split(".", 1)[0].rstrip(" ").upper()
if stem in WINDOWS_RESERVED_NAMES:
raise ValueError("filename uses reserved Windows basename: {}".format(stem))
return cleaned
Expand DownExpand Up@@ -117,6 +121,24 @@ def read_plan(path: str | os.PathLike[str]) -> RenamePlan:
return plan


def _normalized_destination(destination: str) -> str:
"""Return the case-insensitive Windows comparison form for a destination."""
parent, filename = os.path.split(os.path.normpath(destination))
return ntpath.normcase(os.path.join(parent, filename.lstrip(" ").rstrip(". ")))


def _destination_filename_error(destination: str) -> str | None:
"""Return a safety error when a destination basename is not Windows-safe."""
filename = os.path.basename(destination)
try:
sanitized = sanitize_filename(filename)
except ValueError as error:
return str(error)
if sanitized != filename:
return "destination filename changes under Windows normalization"
return None


def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"""Validate filesystem and cross-operation safety without modifying files."""
issues: list[PlanIssue] = []
Expand All@@ -133,6 +155,17 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
)
)
continue
filename_error = _destination_filename_error(operation.destination)
if filename_error:
issues.append(
PlanIssue(
operation.scene_id,
operation.source,
operation.destination,
"invalid_destination",
filename_error,
)
)
if operation.source == operation.destination:
continue
source_directory = os.path.abspath(os.path.dirname(operation.source))
Expand DownExpand Up@@ -167,7 +200,7 @@ def validate_plan(plan: RenamePlan) -> tuple[PlanIssue, ...]:
"destination already exists",
)
)
normalized = os.path.normpath(operation.destination).rstrip(". ").casefold()
normalized = _normalized_destination(operation.destination)
other = destinations.get(normalized)
if other is not None:
issues.append(
Expand Down
90 changes: 85 additions & 5 deletions tests/test_rename_plan.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,11 +19,54 @@


class TestFilenameSanitization(unittest.TestCase):
def test_strips_control_characters_and_trailing_dot_space(self):
self.assertEqual(sanitize_filename("Title\x00 .mp4. "), "Title .mp4")

def test_rejects_reserved_windows_names_with_extensions(self):
for name in ("CON.mp4", "nul", "COM1.txt", "LPT9 "):
def test_normalizes_windows_unsafe_characters_and_whitespace(self):
"""Normalize forbidden characters and Windows-trimmed ASCII whitespace."""
cases = (
("Title\x00 .mp4. ", "Title .mp4"),
(" Title.mp4", "Title.mp4"),
('Title<>:"/\\|?*#, .mp4', "Title .mp4"),
)
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(sanitize_filename(source), expected)

def test_rejects_all_reserved_windows_names_with_extensions(self):
"""Reject standard and superscript Windows device basenames."""
reserved_names = (
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{number}" for number in range(1, 10)),
*(f"LPT{number}" for number in range(1, 10)),
"COM¹",
"COM²",
"COM³",
"LPT¹",
"LPT²",
"LPT³",
)
for reserved_name in reserved_names:
for suffix in ("", ".mp4", " .txt"):
name = reserved_name + suffix
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)

@unittest.skipUnless(os.name == "nt", "Windows filesystem verification")
def test_sanitized_name_can_be_used_by_the_windows_filesystem(self):
"""Rename a temporary file to a sanitized Windows-safe destination."""
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "source.mp4")
destination = os.path.join(directory, sanitize_filename(" Title\x00 .mp4. "))
with open(source, "w", encoding="utf-8") as source_file:
source_file.write("test")
os.rename(source, destination)
self.assertTrue(os.path.isfile(destination))

def test_rejects_empty_filename_after_normalization(self):
"""Reject names with no usable characters after normalization."""
for name in (" . ", "\x00"):
with self.subTest(name=name):
with self.assertRaises(ValueError):
sanitize_filename(name)
Expand DownExpand Up@@ -67,6 +110,43 @@ def test_normalized_duplicate_destinations_block_a_plan(self):
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_leading_space_normalization_collision_blocks_a_plan(self):
"""Block destinations that Windows treats as equal after trimming."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, self.destination),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, " new.mp4")),
)
)
self.assertIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_windows_case_normalization_preserves_sharp_s_distinction(self):
"""Allow distinct NTFS names that Unicode case folding would merge."""
other_source = os.path.join(self.tempdir.name, "other.mp4")
with open(other_source, "w", encoding="utf-8") as other_file:
other_file.write("test")
plan = create_plan(
(
RenameOperation("1", self.source, os.path.join(self.tempdir.name, "Straße.mp4")),
RenameOperation("2", other_source, os.path.join(self.tempdir.name, "Strasse.mp4")),
)
)
self.assertNotIn("duplicate_destination", {issue.code for issue in validate_plan(plan)})

def test_persisted_reserved_windows_destination_blocks_a_plan(self):
"""Reject a reserved device name after reading a digest-valid plan."""
plan = create_plan(
(RenameOperation("1", self.source, os.path.join(self.tempdir.name, "COM¹.mp4")),)
)
plan_path = os.path.join(self.tempdir.name, "plan.json")
write_plan(plan, plan_path)
issues = validate_plan(read_plan(plan_path))
self.assertEqual([issue.code for issue in issues], ["invalid_destination"])
self.assertIn("reserved Windows basename", issues[0].message)

def test_write_read_and_apply_require_an_unchanged_valid_plan(self):
plan = create_plan((RenameOperation("1", self.source, self.destination),))
plan_path = os.path.join(self.tempdir.name, "plan.json")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_renamer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,7 @@ def test_discovery_can_stop_after_first(self):

class TestCompatibilityRenderer(unittest.TestCase):
def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
"""Keep the compatibility renderer read-only and platform-neutral."""
manager = MagicMock()
database = manager.__enter__.return_value
database.cursor.fetchall.return_value = [
Expand All@@ -115,7 +116,9 @@ def test_edit_db_uses_a_short_lived_handle_and_never_applies(self):
finally:
os.chdir(original_cwd)
self.assertEqual(len(plan.operations), 1)
self.assertEqual(plan.operations[0].destination, "/fixture/Fixture Title.mp4")
self.assertEqual(
plan.operations[0].destination, os.path.join("/fixture", "Fixture Title.mp4")
)
manager.__enter__.assert_called_once_with()
manager.__exit__.assert_called_once()

Expand Down
Loading