3 Commits

Author SHA1 Message Date
a52f171946 fix(folders): translate BasePath for create/rename/delete to fix "invalid path"
The sidebar shows user-relative paths (BasePath stripped) but the sidecar
operates on originals-relative paths. Folder create/rename/delete passed the
stripped path straight through, so a BasePath user's ops resolved to the wrong
directory and the sidecar returned "invalid path". Wrap outgoing paths with
toOriginalsPath and map returned paths back with toUserPath, matching the move
flow. Identity for admin accounts (empty BasePath).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
e124809ad5 fix(move): move every originals file of a photo, not just the primary
Videos, Live Photos, and RAW+JPG pairs keep several files under Root "/". The
old movePhotoFiles moved only the primary (often the poster JPG), orphaning
the .mov: PhotoPrism then saw the photo as moved (dropped from the grid) while
the video stayed behind and broke. Move the whole originals group under one
shared stem (new uniqueStem helper) so siblings re-stack after reindex; fail
the photo and report it if any sibling can't move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
ad6e733622 fix(move): close the dialog when a move starts so header progress shows
The move dialog held its full-screen overlay open for the whole operation,
hiding exactly the header reindex/status pill the user waits on. Snapshot the
draft state, closeMove() up front, and run the move in the background with a
toast.loading→success/error — mirrors the archive flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
4 changed files with 192 additions and 81 deletions

View File

@@ -108,6 +108,37 @@ func uniqueName(destDir, basename string) (abs, name string, ok bool) {
return "", "", false
}
// uniqueStem finds a base name (extension stripped) that is free for *every*
// extension in `exts` under destDir, appending `-1`, `-2`, … on collision —
// the multi-file analogue of uniqueName. Moving a photo's originals siblings
// (e.g. IMG_1234.JPG + IMG_1234.MOV) under a single shared stem keeps
// PhotoPrism stacking them as one photo after reindex; picking the stem once
// for the whole group is what stops the video from being orphaned under a
// differently-suffixed name than its poster. Caps at 1000 attempts to match
// uniqueName. The passed extensions keep their on-disk case (we compare
// case-sensitively via os.Stat, which is correct on the case-sensitive
// volumes PhotoPrism targets).
func uniqueStem(destDir, primaryBase string, exts []string) (stem string, ok bool) {
base := strings.TrimSuffix(primaryBase, filepath.Ext(primaryBase))
for i := 0; i < 1000; i++ {
candidate := base
if i > 0 {
candidate = base + "-" + itoa(i)
}
free := true
for _, ext := range exts {
if _, err := os.Stat(filepath.Join(destDir, candidate+ext)); !errors.Is(err, os.ErrNotExist) {
free = false
break
}
}
if free {
return candidate, true
}
}
return "", false
}
// itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string {
if n == 0 {

View File

@@ -165,77 +165,124 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
errs = []heapErr{}
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
// Gather *every* originals-rooted file of the photo, not just the
// primary. A video, Live Photo, or RAW+JPG pair keeps several files
// under Root "/" (e.g. the poster IMG.JPG and its IMG.MOV), and they
// must travel together — moving only the primary orphans the rest, so
// the photo looks "moved" in PhotoPrism (the poster defines its path)
// while the actual video is left behind and silently breaks. Sidecar-
// rooted files (Root=="sidecar": HEIC previews, .json) are regenerated
// on reindex and intentionally skipped. Pick the stem from the primary
// (or the first originals file) so the siblings re-stack under one name.
var group []ppFile
var primary ppFile
havePrimary := false
for _, f := range photo.Files {
if f.Root == "/" && f.Primary {
file, found = f, true
break
if f.Root != "/" {
continue
}
group = append(group, f)
if f.Primary && !havePrimary {
primary, havePrimary = f, true
}
}
if !found {
for _, f := range photo.Files {
if f.Root == "/" {
file, found = f, true
break
}
}
}
if !found {
if len(group) == 0 {
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
if !havePrimary {
primary = group[0]
}
st, statErr := os.Stat(srcAbs)
if statErr != nil || !st.Mode().IsRegular() {
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
continue
// Choose one collision-free stem for the whole group up front, so the
// siblings land as `<stem>.JPG`, `<stem>.MOV`, … and stay stacked.
exts := make([]string, 0, len(group))
extSeen := map[string]struct{}{}
for _, f := range group {
ext := filepath.Ext(f.Name)
if _, dup := extSeen[ext]; !dup {
extSeen[ext] = struct{}{}
exts = append(exts, ext)
}
}
if filepath.Dir(srcAbs) == destAbs {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue
}
dstAbs := filepath.Join(destAbs, name)
if mode == "move" {
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != 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: mvErr.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
}
// Move/copy each sibling. A failure on any one fails the whole photo
// (surfaced in errs) rather than leaving a half-moved stack unreported.
var failure string
movedAny := false
usedNames := map[string]struct{}{}
for _, f := range group {
srcRel := f.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
failure = "path escapes originals"
break
}
moved++
} else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()})
st, statErr := os.Stat(srcAbs)
if statErr != nil || !st.Mode().IsRegular() {
failure = "file missing on disk"
break
}
if filepath.Dir(srcAbs) == destAbs {
// Already in the target folder — nothing to do for this sibling,
// but the photo isn't an error just because one file is in place.
continue
}
name := stem + filepath.Ext(srcAbs)
// Two originals files sharing an extension (rare) would collide on
// the shared stem; keep the extra one's own unique name so neither
// overwrites the other.
if _, clash := usedNames[name]; clash {
_, n, uok := uniqueName(destAbs, filepath.Base(srcAbs))
if !uok {
failure = "too many collisions"
break
}
name = n
}
usedNames[name] = struct{}{}
dstAbs := filepath.Join(destAbs, name)
if mode == "move" {
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != 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 {
failure = mvErr.Error()
break
}
if err2 := os.Remove(srcAbs); err2 != nil {
failure = "rename ok, source remove failed: " + err2.Error()
break
}
}
} else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
failure = cpErr.Error()
break
}
}
movedAny = true
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
if failure != "" {
errs = append(errs, heapErr{UID: photo.UID, Reason: failure})
continue
}
if !movedAny {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
if mode == "move" {
moved++
} else {
copied++
}
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
// Reindex the destination + every source parent so PhotoPrism's DB

View File

@@ -43,7 +43,13 @@
type Section,
type TagCategory
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import {
isAuthenticated,
session,
userBasePath,
toOriginalsPath,
toUserPath
} from '$lib/stores/session.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte';
import { indexer } from '$lib/stores/indexer.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
@@ -285,10 +291,15 @@
}
const createFolderMut = createMutation(() => ({
mutationFn: (relPath: string) => createFolder(relPath),
// The sidebar deals in user-relative paths (BasePath stripped); the
// sidecar operates on originals-relative paths. Translate on the way
// out (toOriginalsPath) and back for display (toUserPath), exactly like
// the move flow — otherwise a BasePath user's folder ops resolve to the
// wrong directory and the sidecar returns "invalid path".
mutationFn: (relPath: string) => createFolder(toOriginalsPath(relPath)),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(`Folder created: ${r.path}`);
toast.success(`Folder created: ${toUserPath(r.path)}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create folder')
@@ -296,31 +307,36 @@
const renameFolderMut = createMutation(() => ({
mutationFn: (args: { rel: string; newName: string }) =>
renameFolder(args.rel, args.newName),
renameFolder(toOriginalsPath(args.rel), args.newName),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] });
// Handler returns originals-relative paths; map back to the UI's
// user-relative space before comparing/navigating.
const oldUi = toUserPath(r.oldPath);
const newUi = toUserPath(r.newPath);
// If the active folder filter was on this folder, follow the rename.
if (filters.folderPath === r.oldPath) {
setFolderPath(r.newPath);
const params = new URLSearchParams({ folder: r.newPath });
if (filters.folderPath === oldUi) {
setFolderPath(newUi);
const params = new URLSearchParams({ folder: newUi });
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
toast.success(`Renamed: ${r.oldPath}${r.newPath}`);
toast.success(`Renamed: ${oldUi}${newUi}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Rename failed')
}));
const deleteFolderMut = createMutation(() => ({
mutationFn: (rel: string) => deleteFolder(rel),
mutationFn: (rel: string) => deleteFolder(toOriginalsPath(rel)),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
if (filters.folderPath && filters.folderPath.startsWith(r.path)) {
const ui = toUserPath(r.path);
if (filters.folderPath && filters.folderPath.startsWith(ui)) {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}
toast.success(`Folder deleted: ${r.path}`);
toast.success(`Folder deleted: ${ui}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Delete failed')

View File

@@ -120,19 +120,38 @@
// (nothing picked) so a falsy check doesn't wrongly block root.
if (!s || pickedPath === null || submitting) return;
submitting = true;
// Snapshot the draft before closing — closeMove() nulls the subject,
// which the reset effect uses to wipe pickedPath/mode/subfolder.
const dest = pickedPath;
const opMode = mode;
const sub = subfolder.trim() || null;
const delHeap = mode === 'move' && deleteHeap;
const labelName = folderName;
// Close the dialog immediately and run the move in the background. The
// move can be slow (a folder/heap with many files triggers a real
// disk move + reindex) and its progress surfaces in the header pill;
// keeping the modal + overlay up would hide exactly the feedback the
// user is waiting on. Mirrors the archive flow (toast + header pill).
closeMove();
const verbing = opMode === 'copy' ? 'Copying' : 'Moving';
const tid = toast.loading(`${verbing}…`);
try {
if (s.kind === 'heap') {
const r = await convertHeap(s.heap.UID, {
targetFolder: toOriginalsPath(pickedPath),
mode,
subfolder: subfolder.trim() || null,
deleteHeap: mode === 'move' && deleteHeap
targetFolder: toOriginalsPath(dest),
mode: opMode,
subfolder: sub,
deleteHeap: delHeap
});
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success(
moveSummary(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length)
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid }
);
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
setSection('all-photos');
@@ -141,32 +160,30 @@
} else if (s.kind === 'photos') {
const r = await movePhotosToFolder({
uids: s.uids,
targetFolder: toOriginalsPath(pickedPath),
mode,
subfolder: subfolder.trim() || null
targetFolder: toOriginalsPath(dest),
mode: opMode,
subfolder: sub
});
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(
moveSummary(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length)
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid }
);
} else {
// Folder reparent (move only). Translate both the folder's own
// path and the destination parent to originals-relative for the
// sidecar, which moves real directories on disk.
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(pickedPath));
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
const newUiPath = pickedPath === '' ? folderName : `${pickedPath}/${folderName}`;
toast.success(`Moved ${folderName}${pickedPath === '' ? '/' : pickedPath}`);
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
toast.success(`Moved ${labelName}${dest === '' ? '/' : dest}`, { id: tid });
// If we just moved the folder the timeline is showing, follow it.
if (filters.folderPath === s.path) setFolderPath(newUiPath);
}
closeMove();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed');
} finally {
submitting = false;
toast.error(err instanceof Error ? err.message : 'Move failed', { id: tid });
}
}
</script>