Compare commits
6 Commits
claude/inf
...
a52f171946
| Author | SHA1 | Date | |
|---|---|---|---|
| a52f171946 | |||
| e124809ad5 | |||
| ad6e733622 | |||
| 6d9b236ef6 | |||
| 669e5fde33 | |||
| 277fdc5a53 |
@@ -108,6 +108,37 @@ func uniqueName(destDir, basename string) (abs, name string, ok bool) {
|
|||||||
return "", "", false
|
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.
|
// itoa is the tiny stdlib-free formatter we use inside hot loops.
|
||||||
func itoa(n int) string {
|
func itoa(n int) string {
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
|||||||
@@ -165,53 +165,87 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
|
|||||||
errs = []heapErr{}
|
errs = []heapErr{}
|
||||||
|
|
||||||
for _, photo := range photos {
|
for _, photo := range photos {
|
||||||
// Pick the file to physically move. PhotoPrism's "primary" file
|
// Gather *every* originals-rooted file of the photo, not just the
|
||||||
// for a HEIC photo is the generated `.HEIC.jpg` preview that
|
// primary. A video, Live Photo, or RAW+JPG pair keeps several files
|
||||||
// lives in storage/sidecar (Root=="sidecar"), not in originals
|
// under Root "/" (e.g. the poster IMG.JPG and its IMG.MOV), and they
|
||||||
// — moving that path would fail "file missing on disk" every
|
// must travel together — moving only the primary orphans the rest, so
|
||||||
// time. Prefer the primary that lives in originals (Root=="/")
|
// the photo looks "moved" in PhotoPrism (the poster defines its path)
|
||||||
// and fall back to the first originals-rooted file. PhotoPrism
|
// while the actual video is left behind and silently breaks. Sidecar-
|
||||||
// regenerates sidecars on reindex, so they don't need to follow.
|
// rooted files (Root=="sidecar": HEIC previews, .json) are regenerated
|
||||||
var file ppFile
|
// on reindex and intentionally skipped. Pick the stem from the primary
|
||||||
found := false
|
// (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 {
|
for _, f := range photo.Files {
|
||||||
if f.Root == "/" && f.Primary {
|
if f.Root != "/" {
|
||||||
file, found = f, true
|
continue
|
||||||
break
|
}
|
||||||
|
group = append(group, f)
|
||||||
|
if f.Primary && !havePrimary {
|
||||||
|
primary, havePrimary = f, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if len(group) == 0 {
|
||||||
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"})
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
srcRel := file.Name
|
if !havePrimary {
|
||||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
primary = group[0]
|
||||||
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
st, statErr := os.Stat(srcAbs)
|
|
||||||
if statErr != nil || !st.Mode().IsRegular() {
|
// Choose one collision-free stem for the whole group up front, so the
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
// siblings land as `<stem>.JPG`, `<stem>.MOV`, … and stay stacked.
|
||||||
continue
|
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 {
|
if !ok {
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
||||||
continue
|
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
|
||||||
|
}
|
||||||
|
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)
|
dstAbs := filepath.Join(destAbs, name)
|
||||||
if mode == "move" {
|
if mode == "move" {
|
||||||
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
|
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
|
||||||
@@ -219,23 +253,36 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
|
|||||||
// copy+remove so a library that spans filesystems still
|
// copy+remove so a library that spans filesystems still
|
||||||
// works.
|
// works.
|
||||||
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.Error()})
|
failure = mvErr.Error()
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
if err2 := os.Remove(srcAbs); err2 != nil {
|
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
|
failure = "rename ok, source remove failed: " + err2.Error()
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
moved++
|
|
||||||
} else {
|
} else {
|
||||||
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()})
|
failure = cpErr.Error()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
movedAny = true
|
||||||
|
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||||
|
}
|
||||||
|
if failure != "" {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: failure})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !movedAny {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mode == "move" {
|
||||||
|
moved++
|
||||||
|
} else {
|
||||||
copied++
|
copied++
|
||||||
}
|
}
|
||||||
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reindex the destination + every source parent so PhotoPrism's DB
|
// Reindex the destination + every source parent so PhotoPrism's DB
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ import {
|
|||||||
removedBulk,
|
removedBulk,
|
||||||
failBulk,
|
failBulk,
|
||||||
setDetail,
|
setDetail,
|
||||||
markRemoved,
|
markRemoved
|
||||||
clearRemoved
|
|
||||||
} from '$lib/stores/bulkAction.svelte';
|
} from '$lib/stores/bulkAction.svelte';
|
||||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
@@ -207,17 +206,19 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
if (target) {
|
if (target) {
|
||||||
// Destructive removal: flash a red cross, then pull the tiles out of
|
// Destructive removal: flash a red cross, then pull the tiles out of
|
||||||
// the grid immediately (markRemoved) rather than waiting on the slow
|
// the grid immediately (markRemoved) rather than waiting on the slow
|
||||||
// server-reconcile refetch. clearRemoved once the refetch settles so
|
// server-reconcile refetch. The grid reconciles `removedIds` against
|
||||||
// the archived-filtered page replaces the optimistic hide.
|
// the cache and drops each id once the archived-filtered page has
|
||||||
|
// actually replaced it (see +page.svelte), so we don't clear here —
|
||||||
|
// clearing on this action's own settle raced other in-flight archives
|
||||||
|
// and flashed photos back in.
|
||||||
removedBulk(doneLabel, ids);
|
removedBulk(doneLabel, ids);
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
await delay(500);
|
await delay(500);
|
||||||
markRemoved(ids);
|
markRemoved(ids);
|
||||||
invalidatePhotos(ids);
|
invalidatePhotos(ids);
|
||||||
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||||
void settled.then(() => clearRemoved(ids));
|
|
||||||
} else {
|
} else {
|
||||||
doneBulk(doneLabel, ids);
|
doneBulk(doneLabel, ids);
|
||||||
focusAfter(ids);
|
focusAfter(ids);
|
||||||
@@ -267,9 +268,10 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
await delay(500);
|
await delay(500);
|
||||||
markRemoved(ids);
|
markRemoved(ids);
|
||||||
invalidatePhotos(ids);
|
invalidatePhotos(ids);
|
||||||
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
|
// removedIds is reconciled against the cache in +page.svelte; no
|
||||||
|
// settle-driven clear here (see toggleArchive note above).
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||||
void settled.then(() => clearRemoved(ids));
|
|
||||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
import { ChevronRight, FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
||||||
import Self from './FolderTree.svelte';
|
import Self from './FolderTree.svelte';
|
||||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||||
|
|
||||||
@@ -164,19 +164,21 @@
|
|||||||
>
|
>
|
||||||
{#if hasChildren}
|
{#if hasChildren}
|
||||||
<button
|
<button
|
||||||
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
|
class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
|
||||||
class:text-muted-foreground={!active}
|
class:text-muted-foreground={!active}
|
||||||
onclick={() => toggle(node.path)}
|
onclick={() => toggle(node.path)}
|
||||||
title={open ? 'Collapse' : 'Expand'}
|
title={open ? 'Collapse' : 'Expand'}
|
||||||
aria-label={open ? 'Collapse' : 'Expand'}
|
aria-label={open ? 'Collapse' : 'Expand'}
|
||||||
>
|
>
|
||||||
{open ? '▾' : '▸'}
|
<ChevronRight
|
||||||
|
class="h-4 w-4 transition-transform duration-150 {open ? 'rotate-90' : ''}"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Spacer keeps childless siblings aligned with their chevroned
|
<!-- Spacer keeps childless siblings aligned with their chevroned
|
||||||
peers at every depth, so labels share a common left edge
|
peers at every depth, so labels share a common left edge
|
||||||
across the sidebar (folders, heaps, views, manage). -->
|
across the sidebar (folders, heaps, views, manage). -->
|
||||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
<span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<!--
|
<!--
|
||||||
Count badge lives INSIDE the button so the entire row (label
|
Count badge lives INSIDE the button so the entire row (label
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
renameFolder,
|
renameFolder,
|
||||||
renameHeap,
|
renameHeap,
|
||||||
scanCrossFolderDuplicates,
|
scanCrossFolderDuplicates,
|
||||||
|
startIndex,
|
||||||
triggerDownload,
|
triggerDownload,
|
||||||
type CrossFolderScanResult,
|
type CrossFolderScanResult,
|
||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
@@ -42,14 +43,22 @@
|
|||||||
type Section,
|
type Section,
|
||||||
type TagCategory
|
type TagCategory
|
||||||
} from '$lib/stores/filters.svelte';
|
} 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 { openMove } from '$lib/stores/moveDialog.svelte';
|
||||||
|
import { indexer } from '$lib/stores/indexer.svelte';
|
||||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||||
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
|
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
|
||||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||||
import SettingsDialog from './SettingsDialog.svelte';
|
import SettingsDialog from './SettingsDialog.svelte';
|
||||||
import UsersDialog from './UsersDialog.svelte';
|
import UsersDialog from './UsersDialog.svelte';
|
||||||
import {
|
import {
|
||||||
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
@@ -59,6 +68,7 @@
|
|||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -281,10 +291,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createFolderMut = createMutation(() => ({
|
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) => {
|
onSuccess: (r) => {
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
toast.success(`Folder created: ${r.path}`);
|
toast.success(`Folder created: ${toUserPath(r.path)}`);
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
toast.error(err instanceof Error ? err.message : 'Could not create folder')
|
toast.error(err instanceof Error ? err.message : 'Could not create folder')
|
||||||
@@ -292,36 +307,57 @@
|
|||||||
|
|
||||||
const renameFolderMut = createMutation(() => ({
|
const renameFolderMut = createMutation(() => ({
|
||||||
mutationFn: (args: { rel: string; newName: string }) =>
|
mutationFn: (args: { rel: string; newName: string }) =>
|
||||||
renameFolder(args.rel, args.newName),
|
renameFolder(toOriginalsPath(args.rel), args.newName),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
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 the active folder filter was on this folder, follow the rename.
|
||||||
if (filters.folderPath === r.oldPath) {
|
if (filters.folderPath === oldUi) {
|
||||||
setFolderPath(r.newPath);
|
setFolderPath(newUi);
|
||||||
const params = new URLSearchParams({ folder: r.newPath });
|
const params = new URLSearchParams({ folder: newUi });
|
||||||
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
toast.success(`Renamed: ${r.oldPath} → ${r.newPath}`);
|
toast.success(`Renamed: ${oldUi} → ${newUi}`);
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
toast.error(err instanceof Error ? err.message : 'Rename failed')
|
toast.error(err instanceof Error ? err.message : 'Rename failed')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const deleteFolderMut = createMutation(() => ({
|
const deleteFolderMut = createMutation(() => ({
|
||||||
mutationFn: (rel: string) => deleteFolder(rel),
|
mutationFn: (rel: string) => deleteFolder(toOriginalsPath(rel)),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
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);
|
setFolderPath(null);
|
||||||
void goto('/', { keepFocus: true, noScroll: true });
|
void goto('/', { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
toast.success(`Folder deleted: ${r.path}`);
|
toast.success(`Folder deleted: ${ui}`);
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
toast.error(err instanceof Error ? err.message : 'Delete failed')
|
toast.error(err instanceof Error ? err.message : 'Delete failed')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// One-click "reindex new files": kicks off a scan of the whole library
|
||||||
|
// with rescan off, so PhotoPrism only picks up files it hasn't indexed
|
||||||
|
// yet. Progress streams in via the WebSocket indexer pill, and the grid
|
||||||
|
// auto-refreshes as new tiles land (see indexer store). Guarded against
|
||||||
|
// double-trigger while a scan is already running.
|
||||||
|
async function onReindex() {
|
||||||
|
if (indexer.active) return;
|
||||||
|
const tid = toast.loading('Starting reindex…');
|
||||||
|
try {
|
||||||
|
await startIndex({ path: '/', rescan: false, cleanup: false });
|
||||||
|
toast.success('Reindex started — new files will appear as they’re found', { id: tid });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onCreateFolder(parent: string | null = null) {
|
function onCreateFolder(parent: string | null = null) {
|
||||||
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
|
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -484,6 +520,17 @@
|
|||||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Library
|
Library
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||||
|
class:opacity-0={!indexer.active}
|
||||||
|
class:opacity-100={indexer.active}
|
||||||
|
onclick={onReindex}
|
||||||
|
disabled={indexer.active}
|
||||||
|
title="Reindex new files"
|
||||||
|
aria-label="Reindex new files"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-3 w-3 {indexer.active ? 'animate-spin' : ''}" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||||
onclick={() => (settingsOpen = true)}
|
onclick={() => (settingsOpen = true)}
|
||||||
@@ -518,18 +565,20 @@
|
|||||||
{#if hasSubfolders}
|
{#if hasSubfolders}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
|
class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
|
||||||
class:text-muted-foreground={!rootActive}
|
class:text-muted-foreground={!rootActive}
|
||||||
onclick={toggleRoot}
|
onclick={toggleRoot}
|
||||||
title={rootExpanded ? 'Collapse' : 'Expand'}
|
title={rootExpanded ? 'Collapse' : 'Expand'}
|
||||||
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
|
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
|
||||||
>
|
>
|
||||||
{rootExpanded ? '▾' : '▸'}
|
<ChevronRight
|
||||||
|
class="h-4 w-4 transition-transform duration-150 {rootExpanded ? 'rotate-90' : ''}"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Spacer keeps chevronless rows aligned with their chevroned
|
<!-- Spacer keeps chevronless rows aligned with their chevroned
|
||||||
peers, so labels share a common left edge across the sidebar. -->
|
peers, so labels share a common left edge across the sidebar. -->
|
||||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
<span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -698,9 +747,11 @@
|
|||||||
aria-expanded={tagsExpanded}
|
aria-expanded={tagsExpanded}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{tagsExpanded ? '▾' : '▸'}
|
<ChevronRight
|
||||||
|
class="h-4 w-4 transition-transform duration-150 {tagsExpanded ? 'rotate-90' : ''}"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||||
<span class="truncate">Tags</span>
|
<span class="truncate">Tags</span>
|
||||||
@@ -766,9 +817,11 @@
|
|||||||
aria-expanded={reviewExpanded}
|
aria-expanded={reviewExpanded}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{reviewExpanded ? '▾' : '▸'}
|
<ChevronRight
|
||||||
|
class="h-4 w-4 transition-transform duration-150 {reviewExpanded ? 'rotate-90' : ''}"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||||
<span class="truncate">Review</span>
|
<span class="truncate">Review</span>
|
||||||
|
|||||||
@@ -120,19 +120,38 @@
|
|||||||
// (nothing picked) so a falsy check doesn't wrongly block root.
|
// (nothing picked) so a falsy check doesn't wrongly block root.
|
||||||
if (!s || pickedPath === null || submitting) return;
|
if (!s || pickedPath === null || submitting) return;
|
||||||
submitting = true;
|
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 {
|
try {
|
||||||
if (s.kind === 'heap') {
|
if (s.kind === 'heap') {
|
||||||
const r = await convertHeap(s.heap.UID, {
|
const r = await convertHeap(s.heap.UID, {
|
||||||
targetFolder: toOriginalsPath(pickedPath),
|
targetFolder: toOriginalsPath(dest),
|
||||||
mode,
|
mode: opMode,
|
||||||
subfolder: subfolder.trim() || null,
|
subfolder: sub,
|
||||||
deleteHeap: mode === 'move' && deleteHeap
|
deleteHeap: delHeap
|
||||||
});
|
});
|
||||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
toast.success(
|
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) {
|
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
|
||||||
setSection('all-photos');
|
setSection('all-photos');
|
||||||
@@ -141,32 +160,30 @@
|
|||||||
} else if (s.kind === 'photos') {
|
} else if (s.kind === 'photos') {
|
||||||
const r = await movePhotosToFolder({
|
const r = await movePhotosToFolder({
|
||||||
uids: s.uids,
|
uids: s.uids,
|
||||||
targetFolder: toOriginalsPath(pickedPath),
|
targetFolder: toOriginalsPath(dest),
|
||||||
mode,
|
mode: opMode,
|
||||||
subfolder: subfolder.trim() || null
|
subfolder: sub
|
||||||
});
|
});
|
||||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
toast.success(
|
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 {
|
} else {
|
||||||
// Folder reparent (move only). Translate both the folder's own
|
// Folder reparent (move only). Translate both the folder's own
|
||||||
// path and the destination parent to originals-relative for the
|
// path and the destination parent to originals-relative for the
|
||||||
// sidecar, which moves real directories on disk.
|
// 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: ['photos'] });
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
const newUiPath = pickedPath === '' ? folderName : `${pickedPath}/${folderName}`;
|
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
|
||||||
toast.success(`Moved ${folderName} → ${pickedPath === '' ? '/' : pickedPath}`);
|
toast.success(`Moved ${labelName} → ${dest === '' ? '/' : dest}`, { id: tid });
|
||||||
// If we just moved the folder the timeline is showing, follow it.
|
// If we just moved the folder the timeline is showing, follow it.
|
||||||
if (filters.folderPath === s.path) setFolderPath(newUiPath);
|
if (filters.folderPath === s.path) setFolderPath(newUiPath);
|
||||||
}
|
}
|
||||||
closeMove();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Move failed');
|
toast.error(err instanceof Error ? err.message : 'Move failed', { id: tid });
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -33,8 +33,7 @@
|
|||||||
doneBulk,
|
doneBulk,
|
||||||
removedBulk,
|
removedBulk,
|
||||||
failBulk,
|
failBulk,
|
||||||
markRemoved,
|
markRemoved
|
||||||
clearRemoved
|
|
||||||
} from '$lib/stores/bulkAction.svelte';
|
} from '$lib/stores/bulkAction.svelte';
|
||||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
import { Layers } from 'lucide-svelte';
|
import { Layers } from 'lucide-svelte';
|
||||||
@@ -156,15 +155,14 @@
|
|||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
const settled = Promise.all([
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
qc.invalidateQueries({ queryKey: ['photos'] }),
|
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||||
qc.invalidateQueries({ queryKey: ['marks'] }),
|
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
qc.invalidateQueries({ queryKey: ['review-groups'] })
|
// The optimistic-removal overlay (removedIds) is reconciled against
|
||||||
]);
|
// the cache in +page.svelte — each id drops once the fresh, archived-
|
||||||
// Clear the optimistic-removal overlay only once the refetch has
|
// filtered page has actually replaced it. Clearing here off this
|
||||||
// landed, so tiles never flash back in before the fresh (archived-
|
// action's own settle raced other in-flight removals and flashed
|
||||||
// filtered) page replaces the old one.
|
// tiles back in.
|
||||||
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { isAuthenticated, session } from './session.svelte';
|
import { isAuthenticated, session } from './session.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +45,27 @@ let lastFileUpdateAt = 0;
|
|||||||
let pendingFileTimer: ReturnType<typeof setTimeout> | null = null;
|
let pendingFileTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pendingFileName: string | undefined;
|
let pendingFileName: string | undefined;
|
||||||
|
|
||||||
|
// Newly indexed photos sort newest-first, so they land at the top of the
|
||||||
|
// timeline. Refetch the photos query as files stream in so the user watches
|
||||||
|
// new tiles arrive without a manual reload — but on a much coarser cadence
|
||||||
|
// than the per-file pill throttle, since a timeline refetch is far heavier
|
||||||
|
// than a label swap. Tracked independently of `lastFileUpdateAt` so the two
|
||||||
|
// throttles don't interfere.
|
||||||
|
const PHOTOS_REFETCH_THROTTLE_MS = 2000;
|
||||||
|
let lastPhotosInvalidateAt = 0;
|
||||||
|
|
||||||
|
function invalidatePhotosGrid(): void {
|
||||||
|
if (!browser || !isAuthenticated()) return;
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidatePhotosGridThrottled(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastPhotosInvalidateAt < PHOTOS_REFETCH_THROTTLE_MS) return;
|
||||||
|
lastPhotosInvalidateAt = now;
|
||||||
|
invalidatePhotosGrid();
|
||||||
|
}
|
||||||
|
|
||||||
function url(): string {
|
function url(): string {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
return `${proto}//${location.host}/api/v1/ws`;
|
return `${proto}//${location.host}/api/v1/ws`;
|
||||||
@@ -134,6 +156,8 @@ function handleMessage(raw: string): void {
|
|||||||
const fileName =
|
const fileName =
|
||||||
(data.fileName as string | undefined) ?? (data.baseName as string | undefined);
|
(data.fileName as string | undefined) ?? (data.baseName as string | undefined);
|
||||||
setActiveThrottled('Indexing', fileName);
|
setActiveThrottled('Indexing', fileName);
|
||||||
|
// Stream newly indexed files into the grid as the scan runs.
|
||||||
|
invalidatePhotosGridThrottled();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'index.updating': {
|
case 'index.updating': {
|
||||||
@@ -148,6 +172,8 @@ function handleMessage(raw: string): void {
|
|||||||
case 'index.completed': {
|
case 'index.completed': {
|
||||||
const seconds = typeof data.seconds === 'number' ? data.seconds : undefined;
|
const seconds = typeof data.seconds === 'number' ? data.seconds : undefined;
|
||||||
setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete');
|
setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete');
|
||||||
|
// Final refetch so the grid lands on the fully-indexed result.
|
||||||
|
invalidatePhotosGrid();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
setFocused,
|
setFocused,
|
||||||
setOrder,
|
setOrder,
|
||||||
} from "$lib/stores/selection.svelte";
|
} from "$lib/stores/selection.svelte";
|
||||||
import { removedIds } from "$lib/stores/bulkAction.svelte";
|
import { removedIds, clearRemoved } from "$lib/stores/bulkAction.svelte";
|
||||||
import {
|
import {
|
||||||
openPreview,
|
openPreview,
|
||||||
setRightSidebarWidth,
|
setRightSidebarWidth,
|
||||||
@@ -283,6 +283,20 @@
|
|||||||
}
|
}
|
||||||
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
||||||
|
|
||||||
|
// Reconcile the optimistic-removal overlay against the actual cache.
|
||||||
|
// `removedIds` hides a tile while its photo is still present in a loaded
|
||||||
|
// page; we drop an id from the set only once it has genuinely left the
|
||||||
|
// freshly-deduped cache (i.e. every page that held it has refetched
|
||||||
|
// without it). Driving the clear from the data — rather than from each
|
||||||
|
// archive action's invalidation promise — removes the race where settling
|
||||||
|
// one action's refetch un-hid a photo that other, still-stale pages
|
||||||
|
// continued to carry, making archived tiles flash back into the grid.
|
||||||
|
$effect(() => {
|
||||||
|
const present = new Set(dedupedAll.map((p) => p.UID));
|
||||||
|
const gone = [...removedIds].filter((id) => !present.has(id));
|
||||||
|
if (gone.length) clearRemoved(gone);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
setOrder(photos.map((p) => p.UID));
|
setOrder(photos.map((p) => p.UID));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user