Compare commits
3 Commits
6d9b236ef6
...
a52f171946
| Author | SHA1 | Date | |
|---|---|---|---|
| a52f171946 | |||
| e124809ad5 | |||
| ad6e733622 |
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user