Compare commits
2 Commits
f9f276a986
...
79a9ef49d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a9ef49d4 | |||
| bd39d310ab |
@@ -64,7 +64,11 @@ export function BulkTakenAtEditor({
|
|||||||
|
|
||||||
const handleApplyUniform = () => {
|
const handleApplyUniform = () => {
|
||||||
if (!uniformDraft) return
|
if (!uniformDraft) return
|
||||||
const parsed = new Date(uniformDraft)
|
// uniformDraft is "YYYY-MM-DD" from a date input; anchor to local
|
||||||
|
// midnight so the stored ISO matches the day the user picked when
|
||||||
|
// viewed in their own timezone.
|
||||||
|
const [year, month, day] = uniformDraft.split('-').map(Number)
|
||||||
|
const parsed = new Date(year, month - 1, day)
|
||||||
if (Number.isNaN(parsed.getTime())) return
|
if (Number.isNaN(parsed.getTime())) return
|
||||||
onApplyUniform(parsed.toISOString())
|
onApplyUniform(parsed.toISOString())
|
||||||
}
|
}
|
||||||
@@ -74,7 +78,7 @@ export function BulkTakenAtEditor({
|
|||||||
{/* Apply-one row */}
|
{/* Apply-one row */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Input
|
<Input
|
||||||
type="datetime-local"
|
type="date"
|
||||||
value={uniformDraft}
|
value={uniformDraft}
|
||||||
onChange={(e) => setUniformDraft(e.target.value)}
|
onChange={(e) => setUniformDraft(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
COLOR_LABEL_OPTIONS,
|
COLOR_LABEL_OPTIONS,
|
||||||
type ColorLabel,
|
type ColorLabel,
|
||||||
} from '../../constants/colorLabels'
|
} from '../../constants/colorLabels'
|
||||||
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
|
import { toDateInputValue } from '../../lib/guessDateFromPath'
|
||||||
import { TagsEditor } from './TagsEditor'
|
import { TagsEditor } from './TagsEditor'
|
||||||
import { TakenAtEditor } from './TakenAtEditor'
|
import { TakenAtEditor } from './TakenAtEditor'
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
setFilenameDraft(photo?.filename ?? '')
|
setFilenameDraft(photo?.filename ?? '')
|
||||||
setNotesDraft(photo?.user_notes ?? '')
|
setNotesDraft(photo?.user_notes ?? '')
|
||||||
setTakenAtDraft(
|
setTakenAtDraft(
|
||||||
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
|
photo?.taken_at ? toDateInputValue(new Date(photo.taken_at)) : ''
|
||||||
)
|
)
|
||||||
}, [
|
}, [
|
||||||
photo?.id,
|
photo?.id,
|
||||||
@@ -353,26 +353,32 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
updateMutation.mutate({ user_notes: next || null })
|
updateMutation.mutate({ user_notes: next || null })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Commit a datetime-local draft back to the server. The backend also
|
/** Commit a date-only draft back to the server. The backend also
|
||||||
* rewrites EXIF on disk, so a failure here rolls the draft back to the
|
* rewrites EXIF on disk, so a failure here rolls the draft back to the
|
||||||
* server value — we never want the UI to silently disagree with the
|
* server value — we never want the UI to silently disagree with the
|
||||||
* file. An empty string is a no-op because the input's `required` is
|
* file. An empty string is a no-op because the input's `required` is
|
||||||
* off and we don't yet have a "clear date" affordance. */
|
* off and we don't yet have a "clear date" affordance.
|
||||||
|
*
|
||||||
|
* Comparison is YYYY-MM-DD vs YYYY-MM-DD (not full ISO) so a blur with
|
||||||
|
* no edits doesn't clobber the stored time-of-day with a new local
|
||||||
|
* midnight. */
|
||||||
const commitTakenAt = (rawValue?: string) => {
|
const commitTakenAt = (rawValue?: string) => {
|
||||||
const source = rawValue ?? takenAtDraft
|
const source = rawValue ?? takenAtDraft
|
||||||
if (!source) return
|
if (!source) return
|
||||||
const parsed = new Date(source)
|
const [y, m, d] = source.split('-').map((n) => Number(n))
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) {
|
||||||
toast.error('Invalid date', 'Could not parse the value')
|
toast.error('Invalid date', 'Could not parse the value')
|
||||||
setTakenAtDraft(
|
setTakenAtDraft(
|
||||||
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
|
photo?.taken_at ? toDateInputValue(new Date(photo.taken_at)) : ''
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const current = photo?.taken_at
|
||||||
|
? toDateInputValue(new Date(photo.taken_at))
|
||||||
|
: ''
|
||||||
|
if (current === source) return
|
||||||
|
const parsed = new Date(y, m - 1, d)
|
||||||
const iso = parsed.toISOString()
|
const iso = parsed.toISOString()
|
||||||
if (photo?.taken_at && new Date(photo.taken_at).toISOString() === iso) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ taken_at: iso },
|
{ taken_at: iso },
|
||||||
{
|
{
|
||||||
@@ -383,7 +389,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
)
|
)
|
||||||
setTakenAtDraft(
|
setTakenAtDraft(
|
||||||
photo?.taken_at
|
photo?.taken_at
|
||||||
? toDatetimeLocalValue(new Date(photo.taken_at))
|
? toDateInputValue(new Date(photo.taken_at))
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -455,22 +461,6 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Filepath: full-width mono so it wraps cleanly instead of
|
|
||||||
* stretching the two-column grid. */}
|
|
||||||
<div className="text-xs">
|
|
||||||
<span className="text-text-muted">Path</span>
|
|
||||||
<p
|
|
||||||
className="mt-0.5 break-all font-mono text-[11px] text-text"
|
|
||||||
title={photo.filepath}
|
|
||||||
>
|
|
||||||
{photo.filepath
|
|
||||||
? photo.filepath.replace(
|
|
||||||
/^\/?nextcloud-users\/[^/]+\/files\//,
|
|
||||||
'…/',
|
|
||||||
)
|
|
||||||
: '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{hasGps && (
|
{hasGps && (
|
||||||
<a
|
<a
|
||||||
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
|
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
|
||||||
@@ -513,6 +503,21 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-text-muted">Path</span>
|
||||||
|
<p
|
||||||
|
className="mt-0.5 break-all font-mono text-[11px] text-text"
|
||||||
|
title={photo.filepath}
|
||||||
|
>
|
||||||
|
{photo.filepath
|
||||||
|
? photo.filepath.replace(
|
||||||
|
/^\/?nextcloud-users\/[^/]+\/files\//,
|
||||||
|
'…/',
|
||||||
|
)
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TakenAtEditor
|
<TakenAtEditor
|
||||||
photo={photo}
|
photo={photo}
|
||||||
draft={takenAtDraft}
|
draft={takenAtDraft}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { format } from 'date-fns'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
guessDateFromPath,
|
guessDateFromPath,
|
||||||
toDatetimeLocalValue,
|
toDateInputValue,
|
||||||
} from '../../lib/guessDateFromPath'
|
} from '../../lib/guessDateFromPath'
|
||||||
import type { PhotoDetails } from './PhotoInfoPanel'
|
import type { PhotoDetails } from './PhotoInfoPanel'
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ export function TakenAtEditor({
|
|||||||
<label className="mb-1 block text-text-muted">Date Taken</label>
|
<label className="mb-1 block text-text-muted">Date Taken</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="date"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => onDraftChange(e.target.value)}
|
onChange={(e) => onDraftChange(e.target.value)}
|
||||||
onBlur={() => onCommit()}
|
onBlur={() => onCommit()}
|
||||||
@@ -88,7 +88,7 @@ export function TakenAtEditor({
|
|||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
onDraftChange(
|
onDraftChange(
|
||||||
photo.taken_at
|
photo.taken_at
|
||||||
? toDatetimeLocalValue(new Date(photo.taken_at))
|
? toDateInputValue(new Date(photo.taken_at))
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
e.currentTarget.blur()
|
e.currentTarget.blur()
|
||||||
@@ -108,7 +108,7 @@ export function TakenAtEditor({
|
|||||||
{showSuggestion && guess && (
|
{showSuggestion && guess && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = toDatetimeLocalValue(guess.date)
|
const next = toDateInputValue(guess.date)
|
||||||
onDraftChange(next)
|
onDraftChange(next)
|
||||||
onCommit(next)
|
onCommit(next)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -269,11 +269,9 @@ export function guessDateFromPath(filepath: string): DateGuess | null {
|
|||||||
return bestFolder
|
return bestFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a Date as the `value` of an `<input type="datetime-local">`. */
|
/** Format a Date as the `value` of an `<input type="date">` — local-time
|
||||||
export function toDatetimeLocalValue(d: Date): string {
|
* YYYY-MM-DD (ISO 8601 date). */
|
||||||
|
export function toDateInputValue(d: Date): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return (
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
||||||
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
type PpFolder
|
type PpFolder
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { filters, setSection } from '$lib/stores/filters.svelte';
|
import { filters, setSection } from '$lib/stores/filters.svelte';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
|
||||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -95,10 +95,13 @@
|
|||||||
// pickedPath === '' is the root selection; falsy check would
|
// pickedPath === '' is the root selection; falsy check would
|
||||||
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
|
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
|
||||||
if (!heap || pickedPath === null) return;
|
if (!heap || pickedPath === null) return;
|
||||||
|
// pickedPath is user-relative (listFolders strips BasePath). The
|
||||||
|
// sidecar moves files on disk so it needs a server-absolute path —
|
||||||
|
// translate before submitting.
|
||||||
convertMut.mutate({
|
convertMut.mutate({
|
||||||
uid: heap.UID,
|
uid: heap.UID,
|
||||||
body: {
|
body: {
|
||||||
targetFolder: pickedPath,
|
targetFolder: toOriginalsPath(pickedPath),
|
||||||
mode,
|
mode,
|
||||||
subfolder: subfolder.trim() || null,
|
subfolder: subfolder.trim() || null,
|
||||||
deleteHeap: mode === 'move' && deleteHeap
|
deleteHeap: mode === 'move' && deleteHeap
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
setSection,
|
setSection,
|
||||||
type Section
|
type Section
|
||||||
} from '$lib/stores/filters.svelte';
|
} from '$lib/stores/filters.svelte';
|
||||||
import { isAuthenticated, session } from '$lib/stores/session.svelte';
|
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
|
||||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||||
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
|
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
|
||||||
import HeapConvertDialog from './HeapConvertDialog.svelte';
|
import HeapConvertDialog from './HeapConvertDialog.svelte';
|
||||||
@@ -158,15 +158,24 @@
|
|||||||
}));
|
}));
|
||||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||||
|
|
||||||
// Root entry shows the whole library — see applyFolderScope() on /
|
// Root entry shows "the user's library" — for admins without a
|
||||||
// timeline. PhotoPrism's `count.all` from /api/v1/config is the
|
// BasePath that's still the whole library, served cheaply from
|
||||||
// authoritative library total (kept in sync server-side), so use it
|
// /api/v1/config's `count.all`. For any user with a non-empty
|
||||||
// directly. Earlier this subtracted Σ(folderCounts) from total, which
|
// BasePath the precomputed total is wrong (it's library-wide), so we
|
||||||
// worked when folderCounts were direct-child only; now that the
|
// ask the sidecar for a recursive count rooted at the user's
|
||||||
// sidecar fan-out recurses (see handlers_folders.go), every photo
|
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
|
||||||
// gets summed once per ancestor — the subtraction would double-count
|
// resolves to the BasePath itself, and the sidecar fan-out recurses.
|
||||||
// and drive rootCount to 0.
|
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
|
||||||
const rootCount = $derived(configQuery.data?.count?.all ?? 0);
|
queryKey: ['photos', 'root-count', userBasePath()],
|
||||||
|
queryFn: () => listFolderCounts(['']),
|
||||||
|
enabled: isAuthenticated() && userBasePath() !== '',
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
const rootCount = $derived(
|
||||||
|
userBasePath() === ''
|
||||||
|
? (configQuery.data?.count?.all ?? 0)
|
||||||
|
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
const createMut = createMutation(() => ({
|
const createMut = createMutation(() => ({
|
||||||
mutationFn: (title: string) => createHeap(title),
|
mutationFn: (title: string) => createHeap(title),
|
||||||
@@ -565,18 +574,6 @@
|
|||||||
counts={folderCounts}
|
counts={folderCounts}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if filters.folderPath && filters.folderPath !== '/'}
|
|
||||||
<button
|
|
||||||
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
onclick={() => {
|
|
||||||
setFolderPath(null);
|
|
||||||
void goto('/', { keepFocus: true, noScroll: true });
|
|
||||||
}}
|
|
||||||
title="Clear folder filter"
|
|
||||||
>
|
|
||||||
<span class="truncate">✕ {filters.folderPath}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Views — everyday browse entries (section + route mixed) under
|
<!-- Views — everyday browse entries (section + route mixed) under
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
type PpLogEntry,
|
type PpLogEntry,
|
||||||
type PpSettings
|
type PpSettings
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
|
import { userBasePath } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -71,7 +72,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Index tab ─────────────────────────────────────────────────────────
|
// ── Index tab ─────────────────────────────────────────────────────────
|
||||||
let indexForm = $state<IndexBody>({ path: '/', rescan: false, cleanup: false });
|
// Default the reindex path to the user's BasePath when scoping is on,
|
||||||
|
// so non-admins (and admins-with-BasePath) only rescan their own
|
||||||
|
// subtree. PhotoPrism's /index expects originals-relative paths with
|
||||||
|
// a leading slash; `'/'` means the whole library.
|
||||||
|
const _bp = userBasePath();
|
||||||
|
let indexForm = $state<IndexBody>({
|
||||||
|
path: _bp === '' ? '/' : `/${_bp}`,
|
||||||
|
rescan: false,
|
||||||
|
cleanup: false
|
||||||
|
});
|
||||||
const startIndexMut = createMutation(() => ({
|
const startIndexMut = createMutation(() => ({
|
||||||
mutationFn: (b: IndexBody) => startIndex(b),
|
mutationFn: (b: IndexBody) => startIndex(b),
|
||||||
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
|
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte';
|
import {
|
||||||
|
adoptSession,
|
||||||
|
clearSession,
|
||||||
|
session,
|
||||||
|
toOriginalsPath,
|
||||||
|
toUserPath,
|
||||||
|
userBasePath
|
||||||
|
} from '$lib/stores/session.svelte';
|
||||||
import { primaryFile } from '$lib/types/photoprism';
|
import { primaryFile } from '$lib/types/photoprism';
|
||||||
import type {
|
import type {
|
||||||
PpClientConfig,
|
PpClientConfig,
|
||||||
@@ -325,13 +332,26 @@ export interface PpFolder {
|
|||||||
* Recursive list of subfolders under originals/. `uncached=true` because
|
* Recursive list of subfolders under originals/. `uncached=true` because
|
||||||
* PhotoPrism's folder cache lags new folders by a noticeable interval and
|
* PhotoPrism's folder cache lags new folders by a noticeable interval and
|
||||||
* mule-image's folder tree expects to surface mutations immediately.
|
* mule-image's folder tree expects to surface mutations immediately.
|
||||||
|
*
|
||||||
|
* Scoped to the signed-in user's `BasePath` on the way out: server-absolute
|
||||||
|
* `Path` values get rewritten to user-relative (e.g. `users/alice/2024/01`
|
||||||
|
* → `2024/01`) so every downstream consumer (FolderTree, sidebar, heap
|
||||||
|
* convert picker) sees folders relative to the user's root. The BasePath
|
||||||
|
* row itself is dropped — the sidebar synthesises the root entry. When
|
||||||
|
* BasePath is empty (today's admin default) this is a no-op.
|
||||||
*/
|
*/
|
||||||
export async function listFolders(): Promise<PpFolder[]> {
|
export async function listFolders(): Promise<PpFolder[]> {
|
||||||
const { data } = await http.get<{ folders?: PpFolder[] }>(
|
const { data } = await http.get<{ folders?: PpFolder[] }>(
|
||||||
'/folders/originals',
|
'/folders/originals',
|
||||||
{ params: { recursive: true, uncached: true, files: false } }
|
{ params: { recursive: true, uncached: true, files: false } }
|
||||||
);
|
);
|
||||||
return data.folders ?? [];
|
const bp = userBasePath();
|
||||||
|
const folders = data.folders ?? [];
|
||||||
|
if (bp === '') return folders;
|
||||||
|
return folders
|
||||||
|
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||||
|
.map((f) => ({ ...f, Path: toUserPath(f.Path) }))
|
||||||
|
.filter((f) => f.Path !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,6 +360,11 @@ export async function listFolders(): Promise<PpFolder[]> {
|
|||||||
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
|
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
|
||||||
* `/folders/originals`, but the photo counts come from `X-Files` and
|
* `/folders/originals`, but the photo counts come from `X-Files` and
|
||||||
* `X-Folders` response headers since the body only lists subfolders.
|
* `X-Folders` response headers since the body only lists subfolders.
|
||||||
|
*
|
||||||
|
* BasePath does NOT apply: `/folders/import` is a separate root from
|
||||||
|
* originals (PhotoPrism's `import.path`, not under originals/), so we
|
||||||
|
* don't filter the result by the signed-in user's BasePath. If/when
|
||||||
|
* per-user inbox isolation is needed, that's a PhotoPrism-side feature.
|
||||||
*/
|
*/
|
||||||
export interface ImportInfo {
|
export interface ImportInfo {
|
||||||
files: number;
|
files: number;
|
||||||
@@ -385,11 +410,21 @@ export async function getImportInfo(): Promise<ImportInfo> {
|
|||||||
*/
|
*/
|
||||||
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
|
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
|
||||||
if (paths.length === 0) return {};
|
if (paths.length === 0) return {};
|
||||||
const data = (await sidecar('POST', '/folders/counts', { paths })) as Record<
|
// The sidecar walks the real filesystem and queries PhotoPrism with
|
||||||
|
// server-absolute paths, but callers hand us user-relative paths
|
||||||
|
// (because that's what `listFolders` returns post-scoping). Translate
|
||||||
|
// on the way out, then re-key the response back to user-relative on
|
||||||
|
// the way in so callers' map keys line up with their input array.
|
||||||
|
const serverPaths = paths.map((p) => toOriginalsPath(p));
|
||||||
|
const data = (await sidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
|
||||||
string,
|
string,
|
||||||
number
|
number
|
||||||
>;
|
>;
|
||||||
return data;
|
const out: Record<string, number> = {};
|
||||||
|
for (let i = 0; i < paths.length; i += 1) {
|
||||||
|
out[paths[i]] = data[serverPaths[i]] ?? 0;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Geo ──────────────────────────────────────────────────────────────────────
|
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* filter shape (favorites → `favorite:true`, archive → `archived:true`,
|
* filter shape (favorites → `favorite:true`, archive → `archived:true`,
|
||||||
* etc.), and the search box on top stacks an additional `q` term.
|
* etc.), and the search box on top stacks an additional `q` term.
|
||||||
*/
|
*/
|
||||||
|
import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
export type Section =
|
export type Section =
|
||||||
| 'all-photos'
|
| 'all-photos'
|
||||||
@@ -93,24 +94,30 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// `/` is the root-folder sentinel. PhotoPrism's `path:` operator can't
|
// `path:` clause. `folderPath` stays user-relative throughout the store
|
||||||
// express "exact root match" (path:"" / path:/ both fall back to "no
|
// (URLs, sidebar, click handlers); we resolve it to a server-absolute
|
||||||
// filter"), so we leave the server query unfiltered and let the
|
// path here via `toOriginalsPath`, which prefixes the user's BasePath
|
||||||
// timeline post-filter to `Path === ''` client-side.
|
// when one is set.
|
||||||
//
|
//
|
||||||
// For any non-root folder we want EVERY photo under that subtree, not
|
// Cases:
|
||||||
// just photos whose `photo_path` is an exact match. PhotoPrism's
|
// bp="", folderPath="/" → no `path:` term (whole library)
|
||||||
// `path:` operator is exact-by-default but supports a trailing `*`
|
// bp="", folderPath="2024" → path:"2024*"
|
||||||
// wildcard:
|
// bp="u/a", folderPath="/" → path:"u/a*" (user's root)
|
||||||
// path:"2024" → matches only photos directly at `2024/` (none,
|
// bp="u/a", folderPath="2024" → path:"u/a/2024*"
|
||||||
// if all files live in date-stamped sub-folders)
|
//
|
||||||
// path:"2024*" → matches `2024`, `2024/01`, `2024/02/...`, etc.
|
// PhotoPrism's `path:` operator is exact-by-default but accepts a
|
||||||
// path:"2024/01*" → matches `2024/01` plus descendants — still
|
// trailing `*` wildcard. We always append `*` so internal tree nodes
|
||||||
// correct for a leaf folder.
|
// return the union of all descendant photos and leaves keep returning
|
||||||
// Always append `*` so internal tree nodes return the union of all
|
// their direct contents. The pre-BasePath comment about the root
|
||||||
// descendant photos and leaves keep returning their direct contents.
|
// sentinel still applies for admins without BasePath: `/` collapses
|
||||||
if (f.folderPath && f.folderPath !== '/') {
|
// to "no filter" so the server returns the full library.
|
||||||
parts.push(`path:${quoteIfNeeded(f.folderPath + '*')}`);
|
const bp = userBasePath();
|
||||||
|
const isRoot = !f.folderPath || f.folderPath === '/';
|
||||||
|
if (!(isRoot && bp === '')) {
|
||||||
|
const serverPath = toOriginalsPath(f.folderPath);
|
||||||
|
if (serverPath) {
|
||||||
|
parts.push(`path:${quoteIfNeeded(serverPath + '*')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
|
|||||||
@@ -147,3 +147,47 @@ export function videoUrl(hash: string, format = 'avc'): string {
|
|||||||
if (!session.previewToken) return '';
|
if (!session.previewToken) return '';
|
||||||
return `/api/v1/videos/${hash}/${session.previewToken}/${format}`;
|
return `/api/v1/videos/${hash}/${session.previewToken}/${format}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in user's library root, originals-relative, no leading/trailing
|
||||||
|
* slash. `""` means "whole library" — used today by admin accounts whose
|
||||||
|
* BasePath isn't configured in PhotoPrism. Non-empty values gate every place
|
||||||
|
* that crosses the user↔server seam (sidebar tree, timeline `path:` filter,
|
||||||
|
* folder counts, heap convert) so each user sees only their own subtree.
|
||||||
|
*/
|
||||||
|
export function userBasePath(): string {
|
||||||
|
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a user-relative path (what the sidebar and URL deal in) to a
|
||||||
|
* server-absolute, originals-relative path (what PhotoPrism's `path:`
|
||||||
|
* operator and the sidecar's filesystem ops want).
|
||||||
|
*
|
||||||
|
* "" or "/" → BasePath (user's root)
|
||||||
|
* "2024/01" → "<basePath>/2024/01"
|
||||||
|
* null → "" (caller decides to omit the filter entirely)
|
||||||
|
*/
|
||||||
|
export function toOriginalsPath(uiPath: string | null): string {
|
||||||
|
if (uiPath === null) return '';
|
||||||
|
const bp = userBasePath();
|
||||||
|
const rel = uiPath.replace(/^\/+|\/+$/g, '');
|
||||||
|
if (rel === '') return bp;
|
||||||
|
return bp === '' ? rel : `${bp}/${rel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of `toOriginalsPath` — strips the user's BasePath prefix so the
|
||||||
|
* UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
|
||||||
|
* are equal to the BasePath collapse to `""` (the user's root sentinel).
|
||||||
|
* Paths outside the BasePath are returned as-is, but callers should
|
||||||
|
* already have filtered those out via `listFolders`'s post-filter.
|
||||||
|
*/
|
||||||
|
export function toUserPath(serverPath: string): string {
|
||||||
|
const bp = userBasePath();
|
||||||
|
const sp = serverPath.replace(/^\/+|\/+$/g, '');
|
||||||
|
if (bp === '') return sp;
|
||||||
|
if (sp === bp) return '';
|
||||||
|
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1);
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export interface PpUser {
|
|||||||
DisplayName?: string;
|
DisplayName?: string;
|
||||||
Email?: string;
|
Email?: string;
|
||||||
Role: PpRole;
|
Role: PpRole;
|
||||||
|
// Per-user library scoping. Originals-relative paths (no leading or
|
||||||
|
// trailing slash). `BasePath === ""` means the user sees the whole
|
||||||
|
// library (today's admin default). Non-empty values drive client-side
|
||||||
|
// scoping of the sidebar tree and timeline `path:` filter so users
|
||||||
|
// only see their own subtree. PhotoPrism's server-side `acl` filter
|
||||||
|
// already scopes non-admins to BasePath; the client mirrors that so
|
||||||
|
// admins-with-a-BasePath behave the same way.
|
||||||
|
BasePath?: string;
|
||||||
|
UploadPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PpClientConfig {
|
export interface PpClientConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user