feat(images): store images' own categories in the library database (#332)

One new table, image_categories, and its category index, created empty
by a forward-only migration that reads and writes only its own objects.
Startup checks the structure; restore also checks the values. The store
sets, clears and reads own categories by file key (the deepest output
source holding the file and the path below it), and copies a removed
duplicate's category to a kept copy that has none.
This commit is contained in:
Vito Sansevero
2026-10-27 18:15:47 -07:00
parent 44177974cb
commit cd573ccf1a
12 changed files with 818 additions and 2 deletions
+43
View File
@@ -800,6 +800,49 @@ def _favorite_root_key(root) -> str:
return _favorite_relative_key(os.path.realpath(str(root)))
def _source_depth(source: str) -> int:
return len([part for part in re.split(r"[\\/]+", source) if part])
def deepest_source(sources, absolute_path):
"""``(source, POSIX path below it)`` of the deepest source holding the path.
*sources* are compared by their real path (they are stored resolved); the
path is compared lexically after ``normpath`` and never resolved, so a
symlinked subfolder keeps its listed spelling. Containment folds case
where the OS does. None when no source contains the path.
"""
path = os.path.normpath(str(absolute_path))
folded = os.path.normcase(path)
best = None
for source in sources:
real = os.path.normpath(os.path.realpath(str(source)))
prefix = os.path.join(os.path.normcase(real), "")
if not folded.startswith(prefix):
continue
if best is None or _source_depth(real) > _source_depth(best):
best = real
if best is None:
return None
relative = os.path.relpath(path, best)
return best, relative.replace(os.sep, "/")
def image_category_key(sources, absolute_path):
"""The file key of an image's own category (#332), or None.
``(root key, relative key)``: the deepest output source that contains the
file and the path below it, keyed with the favorites helpers. Without
nested sources it equals the file's favorite key. A path no source
contains has no key (and so no own category).
"""
found = deepest_source(sources, absolute_path)
if found is None:
return None
source, relative = found
return _favorite_root_key(source), _favorite_relative_key(relative)
def _name_suffix(name):
"""``Path(name).suffix`` as Python 3.10-3.13 compute it.