fix(review): refresh the recorded spelling when a file is seen under a new one

A case-only rename on Windows or macOS keeps size and mtime_ns, so the
rescan skipped the file and the item kept the old spelling. Its target was
then formatted from a name the directory no longer lists, the capture
refused it, and the item stayed pending with no way to apply it. The skip
path and a duplicate insert now record the current spelling for every item
at the locator and move the review index revision; item revisions do not
change, so an open decision stays valid. Tested under case-folding
semantics on every platform.
This commit is contained in:
Vito Sansevero
2026-10-22 17:37:00 -07:00
parent fe5a0017a9
commit b84e6ea111
3 changed files with 156 additions and 4 deletions
+30 -1
View File
@@ -50,6 +50,13 @@ _BUMP_REVIEW_INDEX = (
f"UPDATE {METADATA_TABLE} SET " # nosec B608
"review_index_revision=review_index_revision+1 WHERE singleton_id=1"
)
# A later spelling of the same locator (a case-only rename on a case-folding
# filesystem) replaces the recorded one, so targets keep formatting.
_REFRESH_SPELLING = (
f"UPDATE {ITEMS_TABLE} SET spelled_path=? " # nosec B608
f"WHERE spelled_path<>? AND byte_id IN (SELECT b.id FROM {BYTES_TABLE} b "
f"JOIN {FILES_TABLE} f ON f.id=b.file_id WHERE f.locator=?)"
)
_REVIEW_INDEX = (
f"SELECT review_index_revision FROM {METADATA_TABLE} " # nosec B608
"WHERE singleton_id=1"
@@ -276,10 +283,28 @@ class ReviewOperations:
),
)
inserted = cursor.rowcount > 0
if inserted:
refreshed = _refresh_spelling(conn, locator, spelled_path)
if inserted or refreshed:
conn.execute(_BUMP_REVIEW_INDEX)
return inserted
def refresh_review_spelling(self, locator: str, spelled_path: str) -> bool:
"""Record ``spelled_path`` as the spelling of every item at ``locator``.
The rescan's skip calls it for a file it does not copy again: a
case-only rename keeps size and ``mtime_ns``, and a target formatted
from the old spelling would be refused. True when a spelling changed
(the review index revision moves; item revisions do not, so an open
decision stays valid).
"""
_validate_locator(locator)
_validate_spelling(spelled_path, locator)
with self._pair_transaction() as conn:
refreshed = _refresh_spelling(conn, locator, spelled_path)
if refreshed:
conn.execute(_BUMP_REVIEW_INDEX)
return refreshed
def review_index_revision(self) -> int:
"""The queue's revision (moved by every queue change)."""
row = self.model.get_connection().execute(_REVIEW_INDEX).fetchone()
@@ -388,6 +413,10 @@ class ReviewOperations:
return found
def _refresh_spelling(conn: sqlite3.Connection, locator: str, spelled: str) -> bool:
return conn.execute(_REFRESH_SPELLING, (spelled, spelled, locator)).rowcount > 0
def _queue_row(row, columns) -> dict:
item = dict(zip(columns, tuple(row)))
item["limited"] = bool(item["limited"])
+118
View File
@@ -11,6 +11,7 @@ import asyncio
import hashlib
import json
import logging
import contextlib
import os
import sqlite3
import unittest
@@ -774,3 +775,120 @@ class TestMonitorRecording(ReviewTestCase):
if __name__ == "__main__":
unittest.main()
class _FoldingPath:
"""``os.path`` of a case-folding filesystem (Windows, macOS APFS)."""
def __getattr__(self, name):
return getattr(os.path, name)
@staticmethod
def normcase(value):
return os.path.normcase(value).lower()
class _FoldingOs:
path = _FoldingPath()
def __getattr__(self, name):
return getattr(os, name)
def _case_folding():
"""Fold case where locators are formed: recording, the store, the database."""
import database.review as review_module
import utils.media_capture as capture_module
stack = contextlib.ExitStack()
for module in (review_module, capture_module, review_capture):
stack.enter_context(patch.object(module, "os", _FoldingOs()))
return stack
class TestRecordedSpelling(ReviewTestCase):
"""A case-only rename refreshes the recorded spelling (I5)."""
def _spelled(self):
conn = self.api.db.model.get_connection()
return [
row[0]
for row in conn.execute("SELECT spelled_path FROM media_review_items")
]
async def test_case_only_rename_refreshes_the_spelling_on_the_skip_path(self):
self._require_module()
self._roots()
upper = self.output_dir / "Two.PNG"
write_png(upper, prompt=ambiguous_graph("cat", "dog"))
with _case_folding():
self.assertEqual(self.api.record_review_file(str(upper)), "recorded")
(before,) = self._items()
revision = self._review_revision()
lower = self.output_dir / "two.png"
os.rename(upper, lower) # keeps size and mtime_ns
self.assertEqual(self.api.record_review_file(str(lower)), "duplicate")
(after,) = self._items()
self.assertEqual(self._spelled(), [str(lower)])
self.assertEqual(self._review_revision(), revision + 1)
# The item itself is unchanged: an open decision stays valid.
self.assertEqual(
{k: v for k, v in after.items() if k != "spelled_path"},
{k: v for k, v in before.items() if k != "spelled_path"},
)
from py.api.review import format_review_target
real_root = os.path.realpath(str(self.output_dir))
self.assertEqual(
format_review_target(
after["spelled_path"],
after["locator"],
[(0, real_root)],
pathmod=_FoldingPath(),
),
(0, "two.png"),
)
# The new spelling opens; the recorded one would be refused.
capture = self.api._captures.capture(
self.output_dir, "two.png", epoch="e", scope="s"
)
self.assertEqual(capture.locator, after["locator"])
self.api._captures.release(capture.token)
# Seeing the same spelling again writes nothing.
self.assertEqual(self.api.record_review_file(str(lower)), "duplicate")
self.assertEqual(self._review_revision(), revision + 1)
async def test_duplicate_insert_refreshes_the_spelling(self):
self._require_module()
db = self.api.db
root = os.path.realpath(str(self.output_dir))
upper, lower = os.path.join(root, "Two.PNG"), os.path.join(root, "two.png")
facts = dict(
sha256="a" * 64,
size=10,
mtime_ns=1,
resolver_version=1,
evidence={"reason": "", "candidates": []},
candidate_count=0,
limited=True,
)
with _case_folding():
locator = _FoldingPath.normcase(upper)
epoch = db.get_execution_epoch()
self.assertTrue(
db.record_review_item(
locator=locator, spelled_path=upper, epoch=epoch, **facts
)
)
revision = self._review_revision()
self.assertFalse(
db.record_review_item(
locator=locator, spelled_path=lower, epoch=epoch, **facts
)
)
self.assertEqual(self._spelled(), [lower])
self.assertEqual(self._review_revision(), revision + 1)
self.assertFalse(db.refresh_review_spelling(locator, lower))
self.assertEqual(self._review_revision(), revision + 1)
with self.assertRaises(ValueError):
db.refresh_review_spelling(locator, os.path.join(root, "other.png"))
+8 -3
View File
@@ -113,17 +113,22 @@ def _queued_spelling(db, root, relative: str) -> str | None:
with the listed relative path), so it folds to the capture's locator. A
file whose size and ``mtime_ns`` equal the verified fingerprint of bytes
an item already cites is not copied or hashed again (maintainer ruling:
the rescan stays cheap and the file stays in the default scope). A stat
failure leaves the decision to the capture.
the rescan stays cheap and the file stays in the default scope); its
items take the current spelling. A stat failure leaves the decision to
the capture.
"""
spelled = os.path.join(os.path.realpath(os.fspath(root)), *relative.split("/"))
try:
facts = os.lstat(spelled)
except OSError:
return spelled
locator = os.path.normcase(spelled)
if db.review_fingerprint_queued(
os.path.normcase(spelled), facts.st_size, facts.st_mtime_ns, RESOLVER_VERSION
locator, facts.st_size, facts.st_mtime_ns, RESOLVER_VERSION
):
# Not copied again, but a case-only rename keeps the stat: record
# the current spelling so the item's target still opens.
db.refresh_review_spelling(locator, spelled)
return None
return spelled