Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
when the timeline is scoped to root; the photo grid post-filters to
`Path === ''` because PhotoPrism's `path:` operator can't express an
exact-root match. Collapsible chevron, persisted to its own
localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
folder in parallel via `listFolderCounts`; root count derived from
`config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.
Timeline interaction
- Single click on a tile selects only that tile (clears others); the
preview now lives on dblclick. Modifier clicks still go through
`gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
silently drops the Archived field, so we route through
/batch/photos/{archive,restore} the same way the BulkActionBar
already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
photo into view via `forcedExpand`+`scrollTileIntoView` so it
actually mounts (selection ring would otherwise stay invisible when
the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
view is a folder view (no heap / search / non-default section), so
label clicks / heap views / favorites no longer drop subfolder
photos.
Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
matching the Toolbar's visual language. Right sidebar stays full
height — the bar only spans the timeline width.
- Approve action wired for the review pile.
Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
styling, single-click-selects + dblclick-previews, and `setOrder`
for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
`view.thumbnailSize` (XS–XL) so the timeline's size selector now
reaches into all four grids.
Settings
- General-settings dialog merges Appearance into UI and switches free
text inputs to selects for the PhotoPrism theme / language / start
page / map style (the value-from-server prepends if it's outside
the curated list so we never silently rewrite a custom value). Time
zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.
Sidecar
- Heap convert runs reindex synchronously per source path so the
client's invalidate-and-refetch sees the moved files.
Inbox
- New /inbox route stub for the upcoming import workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
263 lines
7.6 KiB
Go
263 lines
7.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type heapConvertBody struct {
|
|
TargetFolder string `json:"targetFolder"`
|
|
Mode string `json:"mode"` // "move" or "copy"
|
|
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
|
|
DeleteHeap bool `json:"deleteHeap"`
|
|
}
|
|
|
|
type heapPhoto struct {
|
|
UID string `json:"UID"`
|
|
Files []ppFile `json:"Files"`
|
|
}
|
|
|
|
type heapErr struct {
|
|
UID string `json:"uid"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// copyFile is the os.Rename fallback for cross-device moves and the
|
|
// primary path for "copy" mode. Streams so a 4GB video doesn't pin
|
|
// memory; preserves mode bits, sets the modification time to now (we're
|
|
// creating a new inode either way).
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
st, err := in.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := ctxToken(c)
|
|
albumUID := c.Param("uid")
|
|
|
|
var body heapConvertBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
|
return
|
|
}
|
|
mode := body.Mode
|
|
if mode != "copy" {
|
|
mode = "move"
|
|
}
|
|
deleteHeap := mode == "move" && body.DeleteHeap
|
|
|
|
var subfolder string
|
|
if body.Subfolder != "" {
|
|
s, ok := sanitizeFilename(body.Subfolder)
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
|
|
return
|
|
}
|
|
subfolder = s
|
|
}
|
|
|
|
// Resolve destination. resolveUnderRoot ensures the target lives
|
|
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
|
// Empty / "/" / "." are valid here — they mean "drop these into
|
|
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
|
|
// rejects those for safety, so handle the root case explicitly.
|
|
var targetAbs string
|
|
trimmed := strings.Trim(body.TargetFolder, "/")
|
|
if trimmed == "" || trimmed == "." {
|
|
targetAbs = cfg.OriginalsRoot
|
|
} else {
|
|
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
|
return
|
|
}
|
|
targetAbs = abs
|
|
}
|
|
destAbs := targetAbs
|
|
if subfolder != "" {
|
|
destAbs = filepath.Join(targetAbs, subfolder)
|
|
if err := os.MkdirAll(destAbs, 0o755); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Pull the heap's photos via the q=album:UID query. count=1000 covers
|
|
// every realistic heap; merged=true expands stacked variants so we
|
|
// move the JPG/HEIC sibling alongside the primary.
|
|
q := url.QueryEscape("album:" + albumUID)
|
|
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
|
|
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if !resp.OK {
|
|
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
|
|
return
|
|
}
|
|
var photos []heapPhoto
|
|
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
|
|
return
|
|
}
|
|
|
|
sourceParents := map[string]struct{}{}
|
|
errs := []heapErr{}
|
|
moved, copied := 0, 0
|
|
|
|
for _, photo := range photos {
|
|
// Pick the file to physically move. PhotoPrism's "primary" file
|
|
// for a HEIC photo is the generated `.HEIC.jpg` preview that
|
|
// lives in storage/sidecar (Root=="sidecar"), not in originals
|
|
// — moving that path would fail "file missing on disk" every
|
|
// time. Prefer the primary that lives in originals (Root=="/")
|
|
// and fall back to the first originals-rooted file. PhotoPrism
|
|
// regenerates sidecars on reindex, so they don't need to follow.
|
|
var file ppFile
|
|
found := false
|
|
for _, f := range photo.Files {
|
|
if f.Root == "/" && f.Primary {
|
|
file, found = f, true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
for _, f := range photo.Files {
|
|
if f.Root == "/" {
|
|
file, found = f, true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
|
continue
|
|
}
|
|
srcRel := file.Name
|
|
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
|
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
|
continue
|
|
}
|
|
st, err := os.Stat(srcAbs)
|
|
if err != nil || !st.Mode().IsRegular() {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
|
continue
|
|
}
|
|
if filepath.Dir(srcAbs) == destAbs {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
|
continue
|
|
}
|
|
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
|
|
if !ok {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
|
continue
|
|
}
|
|
dstAbs := filepath.Join(destAbs, name)
|
|
if mode == "move" {
|
|
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
|
// Cross-device renames fail with EXDEV — fall back to
|
|
// copy+remove so a library that spans filesystems still
|
|
// works.
|
|
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
|
continue
|
|
}
|
|
if err2 := os.Remove(srcAbs); err2 != nil {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
|
|
continue
|
|
}
|
|
}
|
|
moved++
|
|
} else {
|
|
if err := copyFile(srcAbs, dstAbs); err != nil {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
|
continue
|
|
}
|
|
copied++
|
|
}
|
|
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
|
}
|
|
|
|
// Reindex the destination + every source parent so PhotoPrism's
|
|
// DB catches up. We block on these so the response only goes out
|
|
// after the index reflects the move — callers (the frontend's
|
|
// invalidateQueries refetch in particular) need the next /photos
|
|
// fetch to return the moved files, otherwise the folder view
|
|
// looks unchanged. PhotoPrism's index endpoint serialises calls
|
|
// internally; running them sequentially matches that contract
|
|
// without surprising the server.
|
|
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
|
paths := map[string]struct{}{destRel: {}}
|
|
for p := range sourceParents {
|
|
paths[p] = struct{}{}
|
|
}
|
|
if subfolder != "" {
|
|
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
|
|
paths[parent] = struct{}{}
|
|
}
|
|
for p := range paths {
|
|
reindex := "/"
|
|
if p != "" && p != "." {
|
|
reindex = "/" + p
|
|
}
|
|
fireReindex(cfg, pp, token, reindex)
|
|
}
|
|
|
|
heapDeleted := false
|
|
if deleteHeap {
|
|
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
|
|
if err == nil && r.OK {
|
|
heapDeleted = true
|
|
} else if err != nil {
|
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
|
|
} else {
|
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
|
|
}
|
|
}
|
|
|
|
slog.Info("heap.convert",
|
|
"album", albumUID,
|
|
"mode", mode,
|
|
"moved", moved,
|
|
"copied", copied,
|
|
"errors", len(errs),
|
|
"heap_deleted", heapDeleted,
|
|
)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"moved": moved,
|
|
"copied": copied,
|
|
"errors": errs,
|
|
"heap_deleted": heapDeleted,
|
|
})
|
|
}
|
|
}
|