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
19 changes: 14 additions & 5 deletions photomap/backend/embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,13 @@ def _open_npz_file(embeddings_path: Path) -> dict[str, Any]:
else (int(embeddings.shape[1]) if embeddings.ndim == 2 and embeddings.size else 512)
)

# Pre-compute sorted order
sorted_indices = np.argsort(modification_times)
# Pre-compute sorted order. ``np.lexsort`` is stable and uses ``filenames``
# as a deterministic tiebreaker when modtimes collide (common: EXIF dates
# are 1-second resolution, so bursts and batch copies tie). Plain
# ``argsort`` defaults to quicksort, which is unstable — same data sorted
# twice could yield different orders, silently invalidating any global
# index a caller (bookmarks, back-stack, deletion) has held onto.
sorted_indices = np.lexsort((filenames, modification_times))
sorted_filenames = filenames[sorted_indices]
filename_map = {fname: idx for idx, fname in enumerate(sorted_filenames)}

Expand Down Expand Up @@ -1493,8 +1498,10 @@ def remove_image_from_embeddings(self, index: int) -> None:
embeddings = data["embeddings"].copy()
modtimes = data["modification_times"].copy()
metadata = data["metadata"].copy()
# Reconstruct sorting locally to find correct index
sorted_indices = np.argsort(modtimes)
# Reconstruct sorting locally to find correct index. Must match
# the (modtime, filename) lexsort used in ``_open_npz_file`` or
# we'd find the wrong file to delete.
sorted_indices = np.lexsort((filenames, modtimes))
sorted_filenames = filenames[sorted_indices]

current_filename = sorted_filenames[index]
Expand Down Expand Up @@ -1548,7 +1555,9 @@ def update_image_path(self, index: int, new_path: Path) -> None:
embeddings = data["embeddings"].copy()
modtimes = data["modification_times"].copy()
metadata = data["metadata"].copy()
sorted_indices = np.argsort(modtimes)
# Match the (modtime, filename) lexsort used elsewhere — see
# ``_open_npz_file`` for the rationale.
sorted_indices = np.lexsort((filenames, modtimes))
sorted_filenames = filenames[sorted_indices]

current_filename = sorted_filenames[index]
Expand Down
69 changes: 55 additions & 14 deletions photomap/frontend/static/javascript/bookmarks.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,19 @@ class BookmarkManager {
}

setupEventListeners() {
// Listen for album changes to load bookmarks for the new album
window.addEventListener("albumChanged", () => {
// ``albumChanged`` covers both album switches and intra-album deletions.
// Switches reload the new album's bookmark set from localStorage;
// deletions adjust the existing in-memory set to match the backend's
// post-delete renumbering (subsequent indices shift down). Without the
// deletion branch, bookmarks would silently point at the wrong images
// after a single-image delete from control-panel.js.
window.addEventListener("albumChanged", (e) => {
const detail = e.detail || {};
if (detail.changeType === "deletion" && Array.isArray(detail.deletedIndices)) {
this.handleDeletion(detail.deletedIndices);
this.updateAllBookmarkIcons();
return;
}
this.loadBookmarks();
this.isShowingBookmarks = false;
this.previousSearchResults = null;
Expand Down Expand Up @@ -161,6 +172,18 @@ class BookmarkManager {
const indices = Array.from(this.bookmarks);
localStorage.setItem(key, JSON.stringify(indices));
} catch (e) {
// Quota exceeded or private-mode write rejection — the in-memory set
// is still correct for this session, but won't persist across reload.
// Tell the user once so they're not surprised when bookmarks "vanish"
// after refresh; suppress subsequent alerts so a rapid sequence of
// toggles doesn't spam.
const isQuota = e && (e.name === "QuotaExceededError" || e.code === 22);
if (isQuota && !this._quotaAlerted) {
this._quotaAlerted = true;
alert(
"Bookmark save failed: browser storage is full. Bookmarks won't persist after reload until storage is freed."
);
}
console.warn("Failed to save bookmarks:", e);
}

Expand Down Expand Up @@ -242,23 +265,41 @@ class BookmarkManager {
}

/**
* Remove a bookmark (used when image is deleted)
* Reconcile bookmark indices after one or more images were deleted from
* the album. Bookmarks pointing at deleted images are removed; bookmarks
* pointing at later images shift down by the count of deleted indices
* below them — same renumbering the backend performs on delete.
*
* Called from the ``albumChanged`` listener with ``changeType: "deletion"``
* — single-image deletes (control-panel.js) and multi-image deletes
* (deleteBookmarkedImages, which then clears the lot) both flow through
* the same event.
*/
removeBookmark(globalIndex) {
if (this.bookmarks.has(globalIndex)) {
this.bookmarks.delete(globalIndex);
// Adjust indices for images after the deleted one
const newBookmarks = new Set();
for (const idx of this.bookmarks) {
if (idx > globalIndex) {
newBookmarks.add(idx - 1);
handleDeletion(deletedIndices) {
if (!deletedIndices || deletedIndices.length === 0 || this.bookmarks.size === 0) {
return;
}
const deletedSet = new Set(deletedIndices);
const sortedDeleted = [...deletedIndices].sort((a, b) => a - b);
const newBookmarks = new Set();
for (const idx of this.bookmarks) {
if (deletedSet.has(idx)) {
continue; // bookmark itself was deleted
}
// Count deletions strictly below ``idx`` — small N on both sides, so a
// linear scan beats the bookkeeping of binary search here.
let shift = 0;
for (const d of sortedDeleted) {
if (d < idx) {
shift += 1;
} else {
newBookmarks.add(idx);
break;
}
}
this.bookmarks = newBookmarks;
this.saveBookmarks();
newBookmarks.add(idx - shift);
}
this.bookmarks = newBookmarks;
this.saveBookmarks();
}

/**
Expand Down
15 changes: 15 additions & 0 deletions photomap/frontend/static/javascript/control-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ async function handleSuccessfulDelete(globalIndex, searchIndex) {
const metadata = await getIndexMetadata(state.album);
slideState.totalAlbumImages = metadata?.filename_count || 0;

// Tell the rest of the app (bookmarks, back-stack, grid view) which index
// the backend just renumbered out from under them. The multi-delete path in
// bookmarks.js fires the same event with multiple indices, so listeners
// only need to handle one shape.
window.dispatchEvent(
new CustomEvent("albumChanged", {
detail: {
album: state.album,
totalImages: slideState.totalAlbumImages,
changeType: "deletion",
deletedIndices: [globalIndex],
},
})
);

// Drop the deleted entry from search results and decrement subsequent global indices.
// Stay on the same search position so the next result fills the slot; clamp at the end.
if (slideState.isSearchMode && slideState.searchResults?.length > 0) {
Expand Down
Loading