fix(notes): page PhotoPrism server-side so /notes shows every captioned photo
Client-side paging of listPhotosWithNotes stopped early for BasePath users: the sidecar post-filters each page by BasePath, so a full upstream page can arrive short, tripping the `length < PAGE` end condition before the library is exhausted — hiding notes past the first slice. Add GET /api/sidecar/notes: the sidecar pages /api/v1/photos to completion (keying the loop off the raw upstream page length), filters to non-empty Caption under the caller's BasePath, dedupes by UID, and returns the set. listPhotosWithNotes now calls this single endpoint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -70,4 +71,71 @@ func handlePhotos(pp *ppClient) gin.HandlerFunc {
|
|||||||
c.Header("X-Count", itoa(len(filtered)))
|
c.Header("X-Count", itoa(len(filtered)))
|
||||||
c.JSON(http.StatusOK, filtered)
|
c.JSON(http.StatusOK, filtered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleNotes pages PhotoPrism's photo list to completion and returns only
|
||||||
|
// photos carrying a non-empty Caption (mule-image's "Note"), scoped to the
|
||||||
|
// caller's BasePath. Paging server-side is what makes this correct: the
|
||||||
|
// client can't tell when the *BasePath-filtered* list is exhausted (a full
|
||||||
|
// upstream page can filter down to a short — or empty — slice), but here we
|
||||||
|
// can key the loop off the raw upstream page length.
|
||||||
|
//
|
||||||
|
// Route: GET /api/sidecar/notes (behind requireSession)
|
||||||
|
func handleNotes(pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
basePath := ctxBasePath(c)
|
||||||
|
prefix := basePath + "/"
|
||||||
|
|
||||||
|
const pageSize = 1000
|
||||||
|
out := make([]map[string]any, 0, 64)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
path := fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=true&order=newest", pageSize, offset)
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet, path, token, nil)
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var photos []map[string]any
|
||||||
|
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected photos response"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawLen := len(photos)
|
||||||
|
|
||||||
|
for _, ph := range photos {
|
||||||
|
// BasePath scope — same rule as handlePhotos.
|
||||||
|
if basePath != "" {
|
||||||
|
pathStr, _ := ph["FileName"].(string)
|
||||||
|
if pathStr != basePath && !strings.HasPrefix(pathStr, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-empty caption only.
|
||||||
|
caption, _ := ph["Caption"].(string)
|
||||||
|
if strings.TrimSpace(caption) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Dedupe by UID — `merged` can still repeat a photo at a page seam.
|
||||||
|
uid, _ := ph["UID"].(string)
|
||||||
|
if uid != "" {
|
||||||
|
if _, ok := seen[uid]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[uid] = struct{}{}
|
||||||
|
}
|
||||||
|
out = append(out, ph)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short upstream page means PhotoPrism has no more rows.
|
||||||
|
if rawLen < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ func main() {
|
|||||||
// tabs only show photos the user owns.
|
// tabs only show photos the user owns.
|
||||||
auth.GET("/timeline", handlePhotos(pp))
|
auth.GET("/timeline", handlePhotos(pp))
|
||||||
|
|
||||||
|
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
||||||
|
// the /notes view isn't capped to the newest slice.
|
||||||
|
auth.GET("/notes", handleNotes(pp))
|
||||||
|
|
||||||
// User-scoped folders — post-filters the folder tree by BasePath
|
// User-scoped folders — post-filters the folder tree by BasePath
|
||||||
// so the sidebar shows only folders under the user's library root.
|
// so the sidebar shows only folders under the user's library root.
|
||||||
auth.GET("/folders", handleFoldersProxy(pp))
|
auth.GET("/folders", handleFoldersProxy(pp))
|
||||||
|
|||||||
@@ -639,25 +639,16 @@ export interface PhotoWithNote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
|
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
|
||||||
// PhotoPrism has no "caption is not empty" search filter, so we page the
|
// The sidecar pages PhotoPrism to completion server-side and returns only
|
||||||
// whole library and keep the captioned rows. Paging (rather than a single
|
// captioned, BasePath-scoped photos — paging client-side would stop early
|
||||||
// count:1000 fetch) means notes on photos older than the newest 1000 still
|
// because each page is BasePath-filtered before we see it (a full upstream
|
||||||
// surface — the previous cap silently hid them.
|
// page can arrive short), silently hiding notes past the first slice.
|
||||||
|
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/notes');
|
||||||
const out: PhotoWithNote[] = [];
|
const out: PhotoWithNote[] = [];
|
||||||
const seen = new Set<string>();
|
for (const p of data) {
|
||||||
const PAGE = 1000;
|
const note = p.Caption?.trim();
|
||||||
for (let offset = 0; ; offset += PAGE) {
|
if (!note) continue;
|
||||||
const list = await listPhotos({ count: PAGE, offset, order: 'newest', merged: true });
|
out.push({ photo: p, note });
|
||||||
for (const p of list) {
|
|
||||||
// `merged: true` can repeat a photo across file-rows; dedupe by UID
|
|
||||||
// so the same tile doesn't render twice.
|
|
||||||
if (seen.has(p.UID)) continue;
|
|
||||||
seen.add(p.UID);
|
|
||||||
const note = p.Caption?.trim();
|
|
||||||
if (!note) continue;
|
|
||||||
out.push({ photo: p, note });
|
|
||||||
}
|
|
||||||
if (list.length < PAGE) break;
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user