feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)
This commit is contained in:
1 parent
1a55a39425
commit
2303b12377
26 files changed
+3856
-83
No files matched your search
@@ -23,6 +23,7 @@ const props = withDefaults(
|
||||
stickyFooter?: boolean
|
||||
fullBleed?: boolean
|
||||
compactPadding?: boolean
|
||||
bleedOfflineBanner?: boolean
|
||||
renderWhileChecking?: boolean
|
||||
offlineMessage?: string
|
||||
offlineIcon?: string
|
||||
@@ -36,6 +37,7 @@ const props = withDefaults(
|
||||
offlineIcon: undefined,
|
||||
fullBleed: false,
|
||||
compactPadding: false,
|
||||
bleedOfflineBanner: false,
|
||||
renderWhileChecking: false,
|
||||
},
|
||||
)
|
||||
@@ -301,7 +303,8 @@ async function onLogout() {
|
||||
<OfflineBanner
|
||||
:message="offlineMessage"
|
||||
:icon="offlineIcon"
|
||||
:bleed="compactPadding"
|
||||
:bleed="compactPadding || bleedOfflineBanner"
|
||||
:roomy="!compactPadding && bleedOfflineBanner"
|
||||
/>
|
||||
|
||||
<AppBoot v-if="showBoot" />
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import { useMediaCleanupSettings } from '@/composables/useMediaCleanupSettings'
|
||||
import type { PurgeResult } from '@/composables/useMediaPurge'
|
||||
import {
|
||||
DEFAULT_INTERVAL_DAYS,
|
||||
REVIEW_DUPLICATES_PATH,
|
||||
lastPurgeLine,
|
||||
nextPurgeLine,
|
||||
parseIntervalDays,
|
||||
purgedToast,
|
||||
} from '@/lib/mediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
|
||||
const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
|
||||
const READ_ONLY_COPY = 'Only owners and admins can change this.'
|
||||
const SKELETON_BAR = 'rounded-none bg-accented'
|
||||
const OUTLINE_BUTTON =
|
||||
'h-11 justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:w-full md:h-8'
|
||||
|
||||
const session = useSessionStore()
|
||||
const toast = useLocalizedToast()
|
||||
const router = useRouter()
|
||||
const online = useOnline()
|
||||
|
||||
const isAdmin = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const readOnly = computed(() => !isAdmin.value)
|
||||
const offline = computed(() => !online.value)
|
||||
|
||||
const cleanup = useMediaCleanupSettings(() => {
|
||||
session.refresh()
|
||||
})
|
||||
const { settings, loadState, showSkeleton, saveStatus, saveFailure } = cleanup
|
||||
|
||||
const enabled = ref(false)
|
||||
const daysText = ref(String(DEFAULT_INTERVAL_DAYS))
|
||||
const committedDays = ref(DEFAULT_INTERVAL_DAYS)
|
||||
const daysInvalid = ref(false)
|
||||
const daysFocused = ref(false)
|
||||
const purgeOpen = ref(false)
|
||||
const purgeButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const loading = computed(() => !readOnly.value && loadState.value === 'loading')
|
||||
const skeletonVisible = computed(() => loading.value && showSkeleton.value)
|
||||
const loadFailed = computed(() => !readOnly.value && loadState.value === 'error')
|
||||
const showBody = computed(() => readOnly.value || loadState.value === 'ready')
|
||||
const controlsDisabled = computed(() => readOnly.value || offline.value)
|
||||
const switchChecked = computed(() => !readOnly.value && enabled.value)
|
||||
const showEvery = computed(() => readOnly.value || enabled.value)
|
||||
const lastLine = computed(() =>
|
||||
readOnly.value ? lastPurgeLine(null) : lastPurgeLine(settings.value),
|
||||
)
|
||||
const nextIso = computed(() =>
|
||||
!readOnly.value && enabled.value ? (settings.value?.next_purge_at ?? null) : null,
|
||||
)
|
||||
const daysError = computed(() => (daysInvalid.value ? t(INVALID_DAYS_COPY) : ''))
|
||||
const daysSaveError = computed(() => (saveFailure.value === 'days' ? t(SAVE_FAILED_COPY) : ''))
|
||||
const switchSaveError = computed(() => (saveFailure.value === 'enabled' ? t(SAVE_FAILED_COPY) : ''))
|
||||
const fieldError = computed(() => daysError.value || daysSaveError.value)
|
||||
|
||||
watch(settings, (next) => {
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
enabled.value = next.auto_purge_enabled
|
||||
committedDays.value = next.auto_purge_interval_days
|
||||
if (!daysInvalid.value && !daysFocused.value) {
|
||||
daysText.value = String(next.auto_purge_interval_days)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
isAdmin,
|
||||
(admin) => {
|
||||
if (admin) {
|
||||
cleanup.load()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function toggle(value: boolean) {
|
||||
if (controlsDisabled.value) {
|
||||
return
|
||||
}
|
||||
const previous = enabled.value
|
||||
enabled.value = value
|
||||
const saved = await cleanup.save({ auto_purge_enabled: value }, 'enabled')
|
||||
if (!saved) {
|
||||
enabled.value = previous
|
||||
}
|
||||
}
|
||||
|
||||
async function commitDays() {
|
||||
if (controlsDisabled.value) {
|
||||
return
|
||||
}
|
||||
const days = parseIntervalDays(daysText.value)
|
||||
if (days === null) {
|
||||
daysInvalid.value = true
|
||||
return
|
||||
}
|
||||
daysInvalid.value = false
|
||||
if (days === committedDays.value) {
|
||||
daysText.value = String(days)
|
||||
return
|
||||
}
|
||||
const previous = committedDays.value
|
||||
committedDays.value = days
|
||||
const saved = await cleanup.save({ auto_purge_interval_days: days }, 'days')
|
||||
if (!saved) {
|
||||
committedDays.value = previous
|
||||
daysText.value = String(previous)
|
||||
}
|
||||
}
|
||||
|
||||
function onDaysBlur() {
|
||||
daysFocused.value = false
|
||||
commitDays()
|
||||
}
|
||||
|
||||
function openPurge() {
|
||||
if (!offline.value) {
|
||||
purgeOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function closePurge() {
|
||||
purgeOpen.value = false
|
||||
await nextTick()
|
||||
purgeButton.value?.$el?.focus()
|
||||
}
|
||||
|
||||
function onPurged(result: PurgeResult) {
|
||||
toast.add(purgedToast(result))
|
||||
cleanup.refresh()
|
||||
}
|
||||
|
||||
function reviewDuplicates() {
|
||||
purgeOpen.value = false
|
||||
router.push(REVIEW_DUPLICATES_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
aria-labelledby="media-cleanup-h"
|
||||
data-testid="media-cleanup-card"
|
||||
:aria-busy="skeletonVisible ? 'true' : undefined"
|
||||
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<template v-if="skeletonVisible">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-4 w-35']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-9/10']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-11/20']" />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-30']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-45']" />
|
||||
</div>
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-5 w-9']" />
|
||||
</div>
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-3/5']" />
|
||||
<USeparator />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-25']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-55']" />
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
disabled
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h2 id="media-cleanup-h" class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Media cleanup') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
data-testid="cleanup-save-status"
|
||||
class="flex h-6 shrink-0 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="saveStatus === 'saving'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||
{{ t('Saving…') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="saveStatus === 'saved'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-success"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="size-3.5" />
|
||||
{{ t('Saved') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="loadFailed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="t(UNREACHABLE_COPY)"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="cleanup.load"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<template v-else-if="showBody">
|
||||
<UAlert
|
||||
v-if="readOnly"
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-info"
|
||||
:description="t(READ_ONLY_COPY)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span id="auto-purge-label" class="font-medium text-highlighted">{{
|
||||
t('Automatic purge')
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t('Runs in the background. Off by default.')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="switchSaveError"
|
||||
role="alert"
|
||||
data-testid="cleanup-switch-error"
|
||||
class="flex items-start gap-1.5 text-xs/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ switchSaveError }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center md:h-6 md:w-9">
|
||||
<USwitch
|
||||
:model-value="switchChecked"
|
||||
:disabled="controlsDisabled"
|
||||
aria-labelledby="auto-purge-label"
|
||||
:ui="{ base: 'data-[disabled]:opacity-40' }"
|
||||
@update:model-value="toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showEvery"
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<label for="auto-purge-days" class="font-medium text-highlighted">{{
|
||||
t('Every')
|
||||
}}</label>
|
||||
<span class="text-xs/5 text-muted">{{ t('Between 1 and 365 days.') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 md:items-end">
|
||||
<UInput
|
||||
id="auto-purge-days"
|
||||
v-model="daysText"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:disabled="controlsDisabled"
|
||||
:highlight="fieldError !== ''"
|
||||
:color="fieldError ? 'error' : 'neutral'"
|
||||
:aria-invalid="fieldError ? 'true' : undefined"
|
||||
class="w-full md:w-35"
|
||||
:ui="{
|
||||
base: 'h-11 pe-14 font-mono text-lg disabled:bg-default disabled:opacity-40 disabled:ring-(--ui-border-accented) md:h-10 md:text-sm',
|
||||
trailing: 'pe-3',
|
||||
}"
|
||||
@focus="daysFocused = true"
|
||||
@blur="onDaysBlur"
|
||||
@keydown.enter="commitDays"
|
||||
>
|
||||
<template #trailing>
|
||||
<span class="font-mono text-sm text-muted">{{ t('days') }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<span
|
||||
v-if="fieldError"
|
||||
role="alert"
|
||||
data-testid="cleanup-days-error"
|
||||
class="flex items-start gap-1.5 text-xs/5 text-error md:max-w-75 md:text-right"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ fieldError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-toned">
|
||||
<span data-testid="cleanup-last">{{ lastLine }}</span>
|
||||
<span v-if="nextIso" data-testid="cleanup-next">{{
|
||||
nextPurgeLine(nextIso)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!readOnly">
|
||||
<USeparator />
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="font-medium text-highlighted">{{ t('Purge now') }}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t(
|
||||
'Merge every group of duplicates that has an image in use, right away.',
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex md:shrink-0">
|
||||
<UButton
|
||||
ref="purgeButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
:disabled="offline"
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="openPurge"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<PurgeDuplicatesDialog
|
||||
v-if="purgeOpen"
|
||||
@close="closePurge"
|
||||
@purged="onPurged"
|
||||
@partial="cleanup.refresh"
|
||||
@review="reviewDuplicates"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -40,16 +40,22 @@ const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
controls: MediaControls
|
||||
counts: MediaCounts | null
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
controls: MediaControls
|
||||
counts: MediaCounts | null
|
||||
showPurge?: boolean
|
||||
purgeDisabled?: boolean
|
||||
}>(),
|
||||
{ showPurge: false, purgeDisabled: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:q': [value: string]
|
||||
'update:filter': [value: MediaFilter]
|
||||
'update:sort': [value: MediaSort]
|
||||
'update:view': [value: MediaView]
|
||||
purge: []
|
||||
}>()
|
||||
|
||||
const searchText = ref(props.controls.q)
|
||||
@@ -191,7 +197,9 @@ const sortItems = computed(() =>
|
||||
</template>
|
||||
</UInput>
|
||||
|
||||
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
|
||||
<div
|
||||
class="order-3 flex flex-wrap gap-x-2 gap-y-3 md:order-none md:mb-1.5 md:flex-nowrap md:gap-2"
|
||||
>
|
||||
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
|
||||
<UButton
|
||||
color="neutral"
|
||||
@@ -205,6 +213,20 @@ const sortItems = computed(() =>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
|
||||
<UButton
|
||||
v-if="showPurge"
|
||||
data-testid="purge-duplicates"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
:disabled="purgeDisabled"
|
||||
class="order-last h-11 w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:order-none md:h-8 md:w-auto"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="emit('purge')"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
|
||||
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
|
||||
<UButton
|
||||
v-for="option in VIEW_OPTIONS"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { translate } from '@/i18n'
|
||||
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const DEFAULT_MESSAGE =
|
||||
"You're offline. You can keep writing, but saving and publishing will wait until you're back online."
|
||||
|
||||
const ROOMY_BLEED_ROOT = 'w-auto -mx-4 -mt-5 mb-5 lg:-mx-8 lg:-mt-8 lg:mb-8'
|
||||
|
||||
const BLEED_UI = {
|
||||
root: 'w-auto -mx-4 -mt-4 mb-4 lg:-mx-6 lg:-mt-6 lg:mb-6',
|
||||
container: 'h-auto min-h-10 max-w-none justify-start gap-2.5 px-4 py-2.5 sm:px-4 lg:px-4',
|
||||
@@ -14,15 +16,18 @@ const BLEED_UI = {
|
||||
title: 'whitespace-normal font-normal text-wrap',
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
message?: string
|
||||
icon?: string
|
||||
bleed?: boolean
|
||||
roomy?: boolean
|
||||
}>(),
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false },
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false, roomy: false },
|
||||
)
|
||||
|
||||
const bleedUi = computed(() => (props.roomy ? { ...BLEED_UI, root: ROOMY_BLEED_ROOT } : BLEED_UI))
|
||||
|
||||
const isOffline = ref(!navigator.onLine)
|
||||
|
||||
function updateOnlineStatus() {
|
||||
@@ -46,6 +51,6 @@ onUnmounted(() => {
|
||||
color="neutral"
|
||||
:icon="icon"
|
||||
:title="translate(message)"
|
||||
:ui="bleed ? BLEED_UI : undefined"
|
||||
:ui="bleed ? bleedUi : undefined"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,375 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useMediaPurge, type PurgePreview, type PurgeResult } from '@/composables/useMediaPurge'
|
||||
import {
|
||||
PURGE_BUSY_COPY,
|
||||
PURGE_STALE_COPY,
|
||||
REVIEW_DUPLICATES_PATH,
|
||||
isForbidden,
|
||||
isHandledGlobally,
|
||||
keptLine,
|
||||
mergedCopies,
|
||||
moreGroupsLine,
|
||||
pluralGroups,
|
||||
previewSummary,
|
||||
thumbnailSource,
|
||||
} from '@/lib/mediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'purging' | 'nothing' | 'error' | 'partial' | 'conflict'
|
||||
type Notice = 'failed' | 'partial' | 'stale' | 'busy'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SKELETON_ROWS = 3
|
||||
const HTTP_CONFLICT = 409
|
||||
const FAILED_COPY = "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
const DESCRIPTION =
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
const NOTHING_COPY = 'No duplicate is used in an article, so there is nothing to merge.'
|
||||
const PARTIAL_COPY =
|
||||
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish."
|
||||
const BUTTON_BASE = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
|
||||
const MODAL_CONTENT =
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-md sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
purged: [result: PurgeResult]
|
||||
partial: [result: PurgeResult]
|
||||
review: []
|
||||
}>()
|
||||
|
||||
const { getPreview, purge } = useMediaPurge()
|
||||
const session = useSessionStore()
|
||||
|
||||
const open = ref(true)
|
||||
const status = ref<Status>('loading')
|
||||
const notice = ref<Notice | null>(null)
|
||||
const preview = ref<PurgePreview | null>(null)
|
||||
const partialCounts = ref({ purged: 0, total: 0 })
|
||||
const refreshing = ref(false)
|
||||
const showSkeleton = ref(false)
|
||||
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const purging = computed(() => status.value === 'purging')
|
||||
const busy = computed(() => purging.value || refreshing.value)
|
||||
const nothing = computed(() => status.value === 'nothing')
|
||||
const partial = computed(() => status.value === 'partial')
|
||||
const waitingForPreview = computed(() => status.value === 'loading')
|
||||
const canConfirm = computed(() => preview.value !== null && !busy.value)
|
||||
const confirmLabel = computed(() => {
|
||||
if (purging.value) {
|
||||
return t('Purging…')
|
||||
}
|
||||
return preview.value
|
||||
? t('Purge {0}', [pluralGroups(preview.value.groups)])
|
||||
: t('Purge duplicates')
|
||||
})
|
||||
const noticeText = computed(() => {
|
||||
if (notice.value === 'partial') {
|
||||
return t(PARTIAL_COPY, [partialCounts.value.purged, partialCounts.value.total])
|
||||
}
|
||||
return {
|
||||
failed: t(FAILED_COPY),
|
||||
stale: t(PURGE_STALE_COPY),
|
||||
busy: t(PURGE_BUSY_COPY),
|
||||
none: '',
|
||||
}[notice.value ?? 'none']
|
||||
})
|
||||
const noticeIsError = computed(() => notice.value === 'failed')
|
||||
const ui = computed(() => ({
|
||||
content: MODAL_CONTENT,
|
||||
header: nothing.value
|
||||
? 'min-h-0 justify-center px-6 pt-7 pb-0 text-center sm:px-6'
|
||||
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: nothing.value
|
||||
? 'mx-auto mt-3 max-w-85 text-sm text-muted text-pretty'
|
||||
: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
}))
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function giveUpAfterAuthFailure(error: unknown) {
|
||||
if (isForbidden(error)) {
|
||||
session.refresh()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreview(next: PurgePreview) {
|
||||
preview.value = next
|
||||
if (next.groups === 0) {
|
||||
status.value = 'nothing'
|
||||
notice.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
try {
|
||||
const first = await getPreview()
|
||||
applyPreview(first)
|
||||
if (first.groups > 0) {
|
||||
status.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
return
|
||||
}
|
||||
status.value = 'error'
|
||||
notice.value = 'failed'
|
||||
} finally {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
applyPreview(await getPreview())
|
||||
} catch (error) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
}
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function settle(result: PurgeResult) {
|
||||
if (result.failed_groups === 0) {
|
||||
emit('purged', result)
|
||||
close()
|
||||
return
|
||||
}
|
||||
partialCounts.value = { purged: result.groups_purged, total: result.groups_total }
|
||||
notice.value = 'partial'
|
||||
status.value = 'partial'
|
||||
emit('partial', result)
|
||||
await refreshPreview()
|
||||
}
|
||||
|
||||
async function fail(error: unknown) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
|
||||
notice.value = error.body === PURGE_BUSY_COPY ? 'busy' : 'stale'
|
||||
status.value = 'conflict'
|
||||
await refreshPreview()
|
||||
return
|
||||
}
|
||||
notice.value = 'failed'
|
||||
status.value = 'error'
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preview.value || busy.value) {
|
||||
return
|
||||
}
|
||||
const { fingerprint } = preview.value
|
||||
status.value = 'purging'
|
||||
try {
|
||||
await settle(await purge(fingerprint))
|
||||
} catch (error) {
|
||||
await fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && busy.value) {
|
||||
return
|
||||
}
|
||||
if (!value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (!busy.value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function onReview(event: MouseEvent) {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
emit('review')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadPreview()
|
||||
await nextTick()
|
||||
cancelButton.value?.$el?.focus()
|
||||
})
|
||||
|
||||
onBeforeUnmount(clearSkeletonTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="nothing ? t('Nothing to purge') : t('Purge duplicates?')"
|
||||
:description="nothing ? t(NOTHING_COPY) : t(DESCRIPTION)"
|
||||
:dismissible="!busy"
|
||||
:close="false"
|
||||
:content="{ role: 'alertdialog', 'aria-busy': busy ? 'true' : undefined }"
|
||||
:ui="ui"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template v-if="nothing" #title>
|
||||
<span class="flex flex-col items-center gap-3">
|
||||
<span
|
||||
class="flex size-11 items-center justify-center bg-(--rc-info-soft) text-info"
|
||||
>
|
||||
<UIcon name="i-lucide-copy" class="size-5" />
|
||||
</span>
|
||||
{{ t('Nothing to purge') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="!nothing" #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
v-if="notice && noticeText"
|
||||
:color="noticeIsError ? 'error' : 'warning'"
|
||||
variant="soft"
|
||||
:icon="noticeIsError ? 'i-lucide-circle-x' : 'i-lucide-triangle-alert'"
|
||||
:description="noticeText"
|
||||
:close="false"
|
||||
role="alert"
|
||||
/>
|
||||
|
||||
<template v-if="waitingForPreview && showSkeleton">
|
||||
<USkeleton
|
||||
data-testid="purge-skeleton"
|
||||
class="h-4 w-4/5 rounded-none bg-accented"
|
||||
/>
|
||||
<div class="border border-default">
|
||||
<div
|
||||
v-for="row in SKELETON_ROWS"
|
||||
:key="row"
|
||||
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
|
||||
>
|
||||
<USkeleton class="h-7.5 w-10 rounded-none bg-accented" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<USkeleton class="h-3.25 w-9/20 rounded-none bg-accented" />
|
||||
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="preview">
|
||||
<span data-testid="purge-summary" class="font-medium text-highlighted">{{
|
||||
previewSummary(preview)
|
||||
}}</span>
|
||||
<ul data-testid="purge-groups" class="border border-default">
|
||||
<li
|
||||
v-for="group in preview.list"
|
||||
:key="group.keep_id"
|
||||
data-testid="purge-group"
|
||||
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
|
||||
>
|
||||
<MediaThumb
|
||||
:item="thumbnailSource(group.url, group.filename)"
|
||||
size="sm"
|
||||
class="h-7.5 w-10 border border-default"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate font-medium text-highlighted">{{
|
||||
group.filename
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
keptLine(group.used_in)
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="text-xs whitespace-nowrap text-toned">{{
|
||||
mergedCopies(group.merged)
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="preview.more > 0"
|
||||
data-testid="purge-more"
|
||||
class="border-t border-muted px-3 py-2 text-muted"
|
||||
>
|
||||
{{ moreGroupsLine(preview.more) }}
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
:href="REVIEW_DUPLICATES_PATH"
|
||||
class="inline-flex min-h-11 items-center gap-1 self-start font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
@click="onReview"
|
||||
>
|
||||
{{ t('Review in Duplicates') }}
|
||||
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
ref="cancelButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:class="[
|
||||
BUTTON_BASE,
|
||||
'text-highlighted disabled:text-dimmed disabled:opacity-100',
|
||||
]"
|
||||
:disabled="busy"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ nothing || partial ? t('Close') : t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="partial"
|
||||
color="error"
|
||||
icon="i-lucide-rotate-cw"
|
||||
block
|
||||
:class="BUTTON_BASE"
|
||||
:disabled="!canConfirm"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ t('Retry') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else-if="!nothing"
|
||||
color="error"
|
||||
:loading="purging"
|
||||
block
|
||||
:class="BUTTON_BASE"
|
||||
:disabled="!canConfirm"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import {
|
||||
useMediaPurge,
|
||||
type PurgeSettings,
|
||||
type PurgeSettingsPatch,
|
||||
} from '@/composables/useMediaPurge'
|
||||
import { isForbidden, isHandledGlobally } from '@/lib/mediaPurge'
|
||||
|
||||
export type CleanupLoadState = 'loading' | 'ready' | 'error'
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved'
|
||||
export type SaveFailure = 'enabled' | 'days'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SAVED_VISIBLE_MS = 2000
|
||||
|
||||
export function useMediaCleanupSettings(onForbidden: () => void) {
|
||||
const { getSettings, updateSettings } = useMediaPurge()
|
||||
|
||||
const settings = ref<PurgeSettings | null>(null)
|
||||
const loadState = ref<CleanupLoadState>('loading')
|
||||
const showSkeleton = ref(false)
|
||||
const saveStatus = ref<SaveStatus>('idle')
|
||||
const saveFailure = ref<SaveFailure | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latestLoad = 0
|
||||
let pendingSaves = 0
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSavedTimer() {
|
||||
if (savedTimer !== null) {
|
||||
clearTimeout(savedTimer)
|
||||
savedTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
latestLoad += 1
|
||||
const ticket = latestLoad
|
||||
loadState.value = 'loading'
|
||||
showSkeleton.value = false
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
try {
|
||||
const loaded = await getSettings()
|
||||
if (ticket === latestLoad) {
|
||||
settings.value = loaded
|
||||
loadState.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (ticket !== latestLoad) {
|
||||
return
|
||||
}
|
||||
if (isHandledGlobally(error)) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
}
|
||||
return
|
||||
}
|
||||
loadState.value = 'error'
|
||||
} finally {
|
||||
if (ticket === latestLoad) {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
settings.value = await getSettings()
|
||||
} catch (error) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finishSaving(succeeded: boolean) {
|
||||
if (pendingSaves > 0) {
|
||||
return
|
||||
}
|
||||
if (!succeeded) {
|
||||
saveStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
saveStatus.value = 'saved'
|
||||
savedTimer = setTimeout(() => {
|
||||
saveStatus.value = 'idle'
|
||||
}, SAVED_VISIBLE_MS)
|
||||
}
|
||||
|
||||
async function save(patch: PurgeSettingsPatch, field: SaveFailure) {
|
||||
pendingSaves += 1
|
||||
clearSavedTimer()
|
||||
saveStatus.value = 'saving'
|
||||
let succeeded = false
|
||||
try {
|
||||
settings.value = await updateSettings(patch)
|
||||
saveFailure.value = null
|
||||
succeeded = true
|
||||
} catch (error) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
} else if (!isHandledGlobally(error)) {
|
||||
saveFailure.value = field
|
||||
}
|
||||
} finally {
|
||||
pendingSaves -= 1
|
||||
finishSaving(succeeded)
|
||||
}
|
||||
return succeeded
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSkeletonTimer()
|
||||
clearSavedTimer()
|
||||
})
|
||||
|
||||
return { settings, loadState, showSkeleton, saveStatus, saveFailure, load, refresh, save }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { bumpMediaRevision } from '@/lib/mediaRevision'
|
||||
|
||||
export interface PurgeSettings {
|
||||
auto_purge_enabled: boolean
|
||||
auto_purge_interval_days: number
|
||||
last_purge_at: string | null
|
||||
last_purge_merged: number
|
||||
last_purge_articles_updated: number
|
||||
enabled_at: string | null
|
||||
next_purge_at: string | null
|
||||
}
|
||||
|
||||
export type PurgeSettingsPatch =
|
||||
| { auto_purge_enabled: boolean }
|
||||
| { auto_purge_interval_days: number }
|
||||
|
||||
export interface PurgePreviewGroup {
|
||||
keep_id: number
|
||||
filename: string
|
||||
url: string
|
||||
used_in: number
|
||||
merged: number
|
||||
}
|
||||
|
||||
export interface PurgePreview {
|
||||
groups: number
|
||||
copies: number
|
||||
articles: number
|
||||
list: PurgePreviewGroup[]
|
||||
more: number
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface PurgeResult {
|
||||
groups_total: number
|
||||
groups_purged: number
|
||||
merged: number
|
||||
articles_updated: number
|
||||
failed_groups: number
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
const PREVIEW_PATH = '/v1/media/purge-preview'
|
||||
const PURGE_PATH = '/v1/media/purge'
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||
|
||||
export function useMediaPurge() {
|
||||
const { request } = useApi()
|
||||
|
||||
function getSettings() {
|
||||
return request<PurgeSettings>(SETTINGS_PATH)
|
||||
}
|
||||
|
||||
function updateSettings(patch: PurgeSettingsPatch) {
|
||||
return request<PurgeSettings>(SETTINGS_PATH, {
|
||||
method: 'PATCH',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
}
|
||||
|
||||
function getPreview() {
|
||||
return request<PurgePreview>(PREVIEW_PATH)
|
||||
}
|
||||
|
||||
async function purge(fingerprint: string) {
|
||||
const result = await request<PurgeResult>(PURGE_PATH, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ fingerprint }),
|
||||
})
|
||||
bumpMediaRevision()
|
||||
return result
|
||||
}
|
||||
|
||||
return { getSettings, updateSettings, getPreview, purge }
|
||||
}
|
||||
@@ -955,6 +955,55 @@ export const messages = {
|
||||
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.':
|
||||
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
|
||||
Meaning: 'Signification',
|
||||
'Media cleanup': 'Nettoyage des médias',
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.':
|
||||
'Fusionnez les images identiques pour garder votre médiathèque ordonnée. Les articles qui utilisent une copie passent à la plus utilisée.',
|
||||
'Automatic purge': 'Purge automatique',
|
||||
'Runs in the background. Off by default.': 'S’exécute en arrière-plan. Désactivée par défaut.',
|
||||
Every: 'Tous les',
|
||||
'Between 1 and 365 days.': 'Entre 1 et 365 jours.',
|
||||
days: 'jours',
|
||||
'Never run': 'Jamais exécutée',
|
||||
'Last purge: {0} · {1} merged · {2} updated':
|
||||
'Dernière purge : {0} · {1} fusionnées · {2} mis à jour',
|
||||
'Next purge: {0}': 'Prochaine purge : {0}',
|
||||
"Couldn't save. Try again in a moment.": 'Impossible d’enregistrer. Réessayez dans un instant.',
|
||||
'Enter a whole number of days between 1 and 365.':
|
||||
'Saisissez un nombre entier de jours entre 1 et 365.',
|
||||
'Only owners and admins can change this.':
|
||||
'Seuls les propriétaires et les administrateurs peuvent modifier ceci.',
|
||||
'Purge now': 'Purger maintenant',
|
||||
'Merge every group of duplicates that has an image in use, right away.':
|
||||
'Fusionnez tout de suite chaque groupe de doublons dont une image est utilisée.',
|
||||
'Purge duplicates': 'Purger les doublons',
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection.":
|
||||
'Vous êtes hors ligne. Modifier la purge automatique et purger les doublons nécessitent une connexion.',
|
||||
'Purge duplicates?': 'Purger les doublons ?',
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone.":
|
||||
'Les articles qui utilisent un doublon passeront à la copie la plus utilisée de cette image, et les autres copies seront supprimées. Cette action est irréversible.',
|
||||
'1 group': '1 groupe',
|
||||
'{0} groups': '{0} groupes',
|
||||
'{0} · {1} will be deleted · {2} will be updated':
|
||||
'{0} · {1} seront supprimées · {2} seront mis à jour',
|
||||
'Kept · used in {0}': 'Conservée · utilisée dans {0}',
|
||||
'{0} merged': '{0} fusionnées',
|
||||
'and 1 more group': 'et 1 autre groupe',
|
||||
'and {0} more groups': 'et {0} autres groupes',
|
||||
'Review in Duplicates': 'Voir dans Doublons',
|
||||
'Purge {0}': 'Purger {0}',
|
||||
'Purging…': 'Purge…',
|
||||
Close: 'Fermer',
|
||||
'Nothing to purge': 'Rien à purger',
|
||||
'No duplicate is used in an article, so there is nothing to merge.':
|
||||
'Aucun doublon n’est utilisé dans un article, il n’y a donc rien à fusionner.',
|
||||
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.":
|
||||
'Impossible de purger les doublons. Rien n’a été modifié. Réessayez dans un instant.',
|
||||
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish.":
|
||||
'Certains groupes n’ont pas pu être fusionnés. {0} groupes sur {1} ont été purgés. Réessayez pour terminer.',
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.':
|
||||
'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.',
|
||||
'A purge is already running.': 'Une purge est déjà en cours.',
|
||||
'Duplicates purged': 'Doublons purgés',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,107 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { PurgeSettings } from '@/composables/useMediaPurge'
|
||||
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
|
||||
|
||||
export const MIN_INTERVAL_DAYS = 1
|
||||
export const MAX_INTERVAL_DAYS = 365
|
||||
export const DEFAULT_INTERVAL_DAYS = 30
|
||||
export const PURGE_TOAST_DURATION_MS = 5000
|
||||
export const REVIEW_DUPLICATES_PATH = '/media?filter=duplicates'
|
||||
export const PURGE_STALE_COPY =
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.'
|
||||
export const PURGE_BUSY_COPY = 'A purge is already running.'
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401
|
||||
const HTTP_FORBIDDEN = 403
|
||||
const WHOLE_NUMBER = /^\d+$/
|
||||
const SVG_EXTENSION = /\.svg$/i
|
||||
const PNG_EXTENSION = /\.png$/i
|
||||
|
||||
export function isForbidden(error: unknown) {
|
||||
return error instanceof ApiError && error.status === HTTP_FORBIDDEN
|
||||
}
|
||||
|
||||
export function isHandledGlobally(error: unknown) {
|
||||
return error instanceof ApiError && (error.status === HTTP_UNAUTHORIZED || isForbidden(error))
|
||||
}
|
||||
|
||||
export function parseIntervalDays(text: string) {
|
||||
const trimmed = text.trim()
|
||||
if (!WHOLE_NUMBER.test(trimmed)) {
|
||||
return null
|
||||
}
|
||||
const days = Number.parseInt(trimmed, 10)
|
||||
return days >= MIN_INTERVAL_DAYS && days <= MAX_INTERVAL_DAYS ? days : null
|
||||
}
|
||||
|
||||
export function pluralCopies(count: number) {
|
||||
return t(count === 1 ? '1 copy' : '{0} copies', [count])
|
||||
}
|
||||
|
||||
export function pluralGroups(count: number) {
|
||||
return t(count === 1 ? '1 group' : '{0} groups', [count])
|
||||
}
|
||||
|
||||
export function mergedCopies(count: number) {
|
||||
return t('{0} merged', [pluralCopies(count)])
|
||||
}
|
||||
|
||||
export function lastPurgeLine(settings: PurgeSettings | null) {
|
||||
if (!settings?.last_purge_at) {
|
||||
return t('Never run')
|
||||
}
|
||||
return t('Last purge: {0} · {1} merged · {2} updated', [
|
||||
formatMediaDate(settings.last_purge_at),
|
||||
pluralCopies(settings.last_purge_merged ?? 0),
|
||||
pluralArticles(settings.last_purge_articles_updated ?? 0),
|
||||
])
|
||||
}
|
||||
|
||||
export function nextPurgeLine(iso: string) {
|
||||
return t('Next purge: {0}', [formatMediaDate(iso)])
|
||||
}
|
||||
|
||||
export function previewSummary(counts: { groups: number; copies: number; articles: number }) {
|
||||
return t('{0} · {1} will be deleted · {2} will be updated', [
|
||||
pluralGroups(counts.groups),
|
||||
pluralCopies(counts.copies),
|
||||
pluralArticles(counts.articles),
|
||||
])
|
||||
}
|
||||
|
||||
export function keptLine(usedIn: number) {
|
||||
return t('Kept · used in {0}', [pluralArticles(usedIn)])
|
||||
}
|
||||
|
||||
export function moreGroupsLine(more: number) {
|
||||
return t(more === 1 ? 'and 1 more group' : 'and {0} more groups', [more])
|
||||
}
|
||||
|
||||
export function thumbnailSource(url: string, filename: string) {
|
||||
let contentType = ''
|
||||
if (SVG_EXTENSION.test(filename)) {
|
||||
contentType = 'image/svg+xml'
|
||||
} else if (PNG_EXTENSION.test(filename)) {
|
||||
contentType = 'image/png'
|
||||
}
|
||||
return { url, content_type: contentType, sha256: '' }
|
||||
}
|
||||
|
||||
export function purgedToast(result: { merged: number; articles_updated: number }) {
|
||||
return {
|
||||
id: 'media-purged',
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Duplicates purged')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} merged. {1} updated.', [
|
||||
pluralCopies(result.merged),
|
||||
pluralArticles(result.articles_updated),
|
||||
])
|
||||
},
|
||||
duration: PURGE_TOAST_DURATION_MS,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import MediaTable from '@/components/MediaTable.vue'
|
||||
import MediaToolbar from '@/components/MediaToolbar.vue'
|
||||
import MediaUploadPanel from '@/components/MediaUploadPanel.vue'
|
||||
import MergeMediaDialog from '@/components/MergeMediaDialog.vue'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from '@/composables/useMedia'
|
||||
import { useMediaSelection } from '@/composables/useMediaSelection'
|
||||
import { useMediaUpload } from '@/composables/useMediaUpload'
|
||||
import type { PurgeResult } from '@/composables/useMediaPurge'
|
||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
|
||||
import { joinNames } from '@/lib/mediaFormat'
|
||||
@@ -41,7 +43,9 @@ import {
|
||||
toRouteQuery,
|
||||
type MediaControls,
|
||||
} from '@/lib/mediaQuery'
|
||||
import { purgedToast } from '@/lib/mediaPurge'
|
||||
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const TOAST_DURATION_MS = 5000
|
||||
@@ -71,6 +75,7 @@ const OFFLINE_COPY =
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const media = useMedia()
|
||||
const selection = useMediaSelection(media.items)
|
||||
const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
@@ -91,12 +96,14 @@ const deleteOpen = ref(false)
|
||||
const bulkDeleteOpen = ref(false)
|
||||
const bulkTargets = ref<MediaItem[]>([])
|
||||
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
|
||||
const purgeOpen = ref(false)
|
||||
const dragDepth = ref(0)
|
||||
const announcementSource = ref<() => string>(() => '')
|
||||
const announcement = computed(() => announcementSource.value())
|
||||
const usage = ref<MediaUsageItem[]>([])
|
||||
const copies = ref<MediaItem[]>([])
|
||||
let opener: HTMLElement | null = null
|
||||
let purgeChangedList = false
|
||||
let skipFocusRestore = false
|
||||
let pendingSelectId: number | null = null
|
||||
let bulkDeletedTotal = 0
|
||||
@@ -138,6 +145,12 @@ const filterLabel = computed(
|
||||
],
|
||||
)
|
||||
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
|
||||
const canPurge = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const showPurge = computed(
|
||||
() => canPurge.value && isDuplicates.value && showList.value && groups.value.length > 0,
|
||||
)
|
||||
const dragging = computed(() => dragDepth.value > 0)
|
||||
const uploadBlocked = computed(() => offline.value || upload.limited.value)
|
||||
const uploadLabel = computed(() =>
|
||||
@@ -150,6 +163,7 @@ const dialogOpen = computed(
|
||||
renameOpen.value ||
|
||||
deleteOpen.value ||
|
||||
bulkDeleteOpen.value ||
|
||||
purgeOpen.value ||
|
||||
mergeTarget.value !== null,
|
||||
)
|
||||
const hasSelection = computed(() => selection.count.value > 0)
|
||||
@@ -724,6 +738,33 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
|
||||
mergeTarget.value = { group, keep }
|
||||
}
|
||||
|
||||
function openPurge(event?: Event) {
|
||||
if (offline.value) {
|
||||
return
|
||||
}
|
||||
opener = (event?.currentTarget as HTMLElement | null) ?? null
|
||||
purgeChangedList = false
|
||||
purgeOpen.value = true
|
||||
}
|
||||
|
||||
function onPurged(result: PurgeResult) {
|
||||
toast.add(purgedToast(result))
|
||||
purgeChangedList = true
|
||||
}
|
||||
|
||||
function onPurgePartial() {
|
||||
purgeChangedList = true
|
||||
}
|
||||
|
||||
function closePurge() {
|
||||
purgeOpen.value = false
|
||||
if (purgeChangedList) {
|
||||
purgeChangedList = false
|
||||
refreshInBackground()
|
||||
}
|
||||
restoreFocusToOpener(null)
|
||||
}
|
||||
|
||||
function mergeText(merged: number, articlesUpdated: number) {
|
||||
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
|
||||
const articlesText =
|
||||
@@ -873,10 +914,13 @@ async function mergeAction() {
|
||||
<MediaToolbar
|
||||
:controls="controls"
|
||||
:counts="counts"
|
||||
:show-purge="showPurge"
|
||||
:purge-disabled="offline"
|
||||
@update:q="updateControls({ q: $event })"
|
||||
@update:filter="updateControls({ filter: $event })"
|
||||
@update:sort="updateControls({ sort: $event })"
|
||||
@update:view="updateControls({ view: $event })"
|
||||
@purge="openPurge"
|
||||
/>
|
||||
|
||||
<MediaUploadPanel
|
||||
@@ -1022,6 +1066,14 @@ async function mergeAction() {
|
||||
@close="bulkDeleteOpen = false"
|
||||
/>
|
||||
|
||||
<PurgeDuplicatesDialog
|
||||
v-if="purgeOpen"
|
||||
@close="closePurge"
|
||||
@purged="onPurged"
|
||||
@partial="onPurgePartial"
|
||||
@review="purgeOpen = false"
|
||||
/>
|
||||
|
||||
<MergeMediaDialog
|
||||
v-if="mergeTarget"
|
||||
:keep="mergeTarget.keep"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
import UpdatesCard from '@/components/UpdatesCard.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
@@ -19,6 +20,8 @@ import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const OFFLINE_COPY =
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
const RESTART_FAILED_COPY =
|
||||
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment."
|
||||
const RESTART_WARNING =
|
||||
@@ -372,7 +375,11 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<DashboardShell
|
||||
bleed-offline-banner
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
>
|
||||
<RestartOverlay
|
||||
v-if="update.restartRun"
|
||||
:kind="update.restartRun.kind"
|
||||
@@ -449,6 +456,8 @@ onBeforeUnmount(() => {
|
||||
@update="onUpdate"
|
||||
@restart="onRestart"
|
||||
/>
|
||||
|
||||
<MediaCleanupCard />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
callsTo,
|
||||
deferred,
|
||||
dialog,
|
||||
findButton,
|
||||
fixture,
|
||||
listBody,
|
||||
listCalls,
|
||||
mountPage,
|
||||
settle,
|
||||
textOf,
|
||||
unmountPage,
|
||||
type Handlers,
|
||||
} from './mediaTestKit'
|
||||
import { FAILED_COPY, previewOf, purgeResult } from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DUPLICATES = '/media?filter=duplicates'
|
||||
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
|
||||
const COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
||||
|
||||
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
|
||||
return (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(ids), { ...COUNTS, duplicates: ids.length ? 2 : 0 })
|
||||
: listBody(fixture())
|
||||
}
|
||||
|
||||
function purgeButton() {
|
||||
return document.body.querySelector<HTMLButtonElement>('[data-testid="purge-duplicates"]')
|
||||
}
|
||||
|
||||
async function openFromMedia(handlers: Handlers = {}) {
|
||||
await mountPage({ list: duplicatesList(), preview: () => previewOf(), ...handlers }, DUPLICATES)
|
||||
purgeButton()!.focus()
|
||||
purgeButton()!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('The Duplicates toolbar button', () => {
|
||||
it.each(['owner', 'admin'])('shows for %s on the Duplicates tab with groups', async (role) => {
|
||||
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, { role })
|
||||
|
||||
expect(purgeButton()).not.toBeNull()
|
||||
expect(textOf(purgeButton())).toBe('Purge duplicates')
|
||||
expect(purgeButton()!.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the filter is All', '/media', 'owner', duplicatesList()],
|
||||
['the filter is Unused', '/media?filter=unused', 'owner', duplicatesList()],
|
||||
['there is no group', DUPLICATES, 'owner', duplicatesList([])],
|
||||
['the role is not owner or admin', DUPLICATES, 'editor', duplicatesList()],
|
||||
])('is hidden when %s', async (_name, url, role, list) => {
|
||||
await mountPage({ list }, url, undefined, { role })
|
||||
|
||||
expect(purgeButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('is disabled offline, opens nothing and keeps the existing banner', async () => {
|
||||
await mountPage({ list: duplicatesList(), preview: () => previewOf() }, DUPLICATES)
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
purgeButton()!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(purgeButton()!.disabled).toBe(true)
|
||||
expect(document.body.textContent).toContain("You're offline.")
|
||||
expect(dialog()).toBeNull()
|
||||
expect(callsTo('GET', /purge-preview/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sits between the sort menu and the view toggle, full width and 44 px high when stacked', async () => {
|
||||
await mountPage({ list: duplicatesList() }, DUPLICATES, MOBILE_WIDTH)
|
||||
|
||||
const sort = document.body.querySelector('[aria-label="Sort"]')!
|
||||
const toggle = document.body.querySelector('[role="group"][aria-label="View"]')!
|
||||
const button = purgeButton()!
|
||||
expect(sort.compareDocumentPosition(button)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(button.compareDocumentPosition(toggle)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(button.className).toContain('h-11')
|
||||
expect(button.className).toContain('w-full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The purge dialog from Media', () => {
|
||||
it('opens in the loading state and sends the preview request', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await openFromMedia({ preview: () => pending.promise })
|
||||
|
||||
expect(callsTo('GET', /purge-preview/)).toHaveLength(1)
|
||||
expect(textOf(dialog())).toContain('Purge duplicates?')
|
||||
expect(findButton('Purge duplicates', dialog()!)!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('confirms with the preview fingerprint and shows the same summary', async () => {
|
||||
await openFromMedia()
|
||||
|
||||
expect(textOf(dialog()!.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'4 groups · 9 copies will be deleted · 12 articles will be updated',
|
||||
)
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await flushPromises()
|
||||
const [call] = callsTo('POST', /\/v1\/media\/purge$/)
|
||||
expect(JSON.parse(call![1].body)).toEqual({ fingerprint: 'ab12' })
|
||||
})
|
||||
|
||||
it('shows the error notice and keeps the dialog open on a failure', async () => {
|
||||
await openFromMedia({ purge: () => Promise.reject(new ApiError(500)) })
|
||||
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(FAILED_COPY)
|
||||
})
|
||||
|
||||
it('toasts, reloads the list and counts, and drops the button with the last group', async () => {
|
||||
let purged = false
|
||||
await openFromMedia({
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(purged ? [] : DUPLICATE_IDS), {
|
||||
all: 12,
|
||||
unused: 2,
|
||||
duplicates: purged ? 0 : 2,
|
||||
})
|
||||
: listBody(fixture()),
|
||||
purge: () => {
|
||||
purged = true
|
||||
return purgeResult()
|
||||
},
|
||||
})
|
||||
const reloads = listCalls().length
|
||||
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Duplicates purged',
|
||||
description: '9 copies merged. 12 articles updated.',
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(listCalls().length).toBe(reloads + 1)
|
||||
expect(document.body.textContent).toContain('No duplicates')
|
||||
expect(purgeButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('reloads the list when Close follows a partial result', async () => {
|
||||
await openFromMedia({
|
||||
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
|
||||
})
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
const reloads = listCalls().length
|
||||
|
||||
findButton('Close', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(listCalls().length).toBe(reloads + 1)
|
||||
})
|
||||
|
||||
it('only closes the dialog when Review in Duplicates is followed', async () => {
|
||||
await openFromMedia()
|
||||
const before = router.currentRoute.value.fullPath
|
||||
|
||||
dialog()!.querySelector('a')!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(router.currentRoute.value.fullPath).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Merge into this one stays', () => {
|
||||
it('still sends the merge request with the ids', async () => {
|
||||
const merge = vi.fn(() => ({ articles_updated: 5, merged: 2 }))
|
||||
await mountPage({ list: duplicatesList(), merge }, DUPLICATES)
|
||||
|
||||
const first = findButton('Merge into this one')!
|
||||
first.click()
|
||||
await flushPromises()
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(merge).toHaveBeenCalledWith({ keep_id: 12, merge_ids: [5, 6] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,529 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import {
|
||||
BUSY_COPY,
|
||||
FAILED_COPY,
|
||||
PREVIEW_PATH,
|
||||
PURGE_PATH,
|
||||
SETTINGS_PATH,
|
||||
STALE_COPY,
|
||||
bodyOf,
|
||||
callsTo,
|
||||
deferredValue,
|
||||
dialogEl,
|
||||
emptyPreview,
|
||||
enabledSettings,
|
||||
findButton,
|
||||
goOffline,
|
||||
groupRows,
|
||||
isBefore,
|
||||
lastLine,
|
||||
manyPreview,
|
||||
mountSettings,
|
||||
nextLine,
|
||||
openDialog,
|
||||
previewOf,
|
||||
pressEscape,
|
||||
purgeButton,
|
||||
purgeResult,
|
||||
singlePreview,
|
||||
textOf,
|
||||
unmountSettings,
|
||||
type PurgeMocks,
|
||||
} from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DESCRIPTION =
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
const SUMMARY = '4 groups · 9 copies will be deleted · 12 articles will be updated'
|
||||
const LAST_RUN_LINE = 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'
|
||||
const AFTER_PURGE_LINE = 'Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated'
|
||||
|
||||
async function opened(mocks: PurgeMocks = {}) {
|
||||
await mountSettings({ settings: () => enabledSettings(), ...mocks })
|
||||
return openDialog()
|
||||
}
|
||||
|
||||
function buttonLabels() {
|
||||
return Array.from(dialogEl()!.querySelectorAll('button')).map(textOf)
|
||||
}
|
||||
|
||||
function confirmButton(label: string) {
|
||||
return findButton(label, dialogEl()!)!
|
||||
}
|
||||
|
||||
function noticeText() {
|
||||
return textOf(dialogEl()!.querySelector('[role="alert"]'))
|
||||
}
|
||||
|
||||
function afterRefresh(settings = enabledSettings()) {
|
||||
let reads = 0
|
||||
return () => (reads++ === 0 ? enabledSettings() : settings)
|
||||
}
|
||||
|
||||
function purgedSettings(overrides = {}) {
|
||||
return enabledSettings({
|
||||
last_purge_at: '2026-10-05T09:00:00Z',
|
||||
last_purge_merged: 9,
|
||||
last_purge_articles_updated: 12,
|
||||
next_purge_at: '2026-11-04T09:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountSettings)
|
||||
|
||||
describe('Purge dialog: opening', () => {
|
||||
it('sends the preview request, shows the title, description, focuses Cancel and delays the skeleton', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const pending = deferredValue<unknown>()
|
||||
await opened({ preview: () => pending.promise })
|
||||
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(1)
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box)).toContain('Purge duplicates?')
|
||||
expect(textOf(box)).toContain(DESCRIPTION)
|
||||
expect(document.activeElement).toBe(findButton('Cancel', box))
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(box.querySelector('[data-testid="purge-skeleton"]')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(box.querySelector('[data-testid="purge-skeleton"]')).not.toBeNull()
|
||||
expect(box.querySelectorAll('.animate-pulse').length).toBeGreaterThan(3)
|
||||
expect(confirmButton('Purge duplicates').disabled).toBe(true)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge duplicates'])
|
||||
})
|
||||
|
||||
it('is not openable while offline', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
await goOffline()
|
||||
|
||||
purgeButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: the preview', () => {
|
||||
it('shows the summary, the rows, the link and both buttons', async () => {
|
||||
await opened()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
|
||||
const rows = groupRows()
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(
|
||||
rows.map((row) =>
|
||||
Array.from(row.querySelectorAll('span')).map(textOf).filter(Boolean).join(' '),
|
||||
),
|
||||
).toEqual([
|
||||
'floorboards.jpg Kept · used in 4 articles 2 copies merged',
|
||||
'IMG_4471.jpg Kept · used in 2 articles 1 copy merged',
|
||||
'kitchen-after.webp Kept · used in 3 articles 3 copies merged',
|
||||
'logo-renew.svg Kept · used in 5 articles 3 copies merged',
|
||||
])
|
||||
expect(rows[0]!.querySelector('img')!.getAttribute('src')).toBe('/media/floorboards.jpg')
|
||||
expect(box.querySelector('[data-testid="purge-more"]')).toBeNull()
|
||||
const link = box.querySelector('a')!
|
||||
expect(link.getAttribute('href')).toBe('/media?filter=duplicates')
|
||||
expect(textOf(link)).toBe('Review in Duplicates')
|
||||
expect(link.querySelector('svg')).not.toBeNull()
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 4 groups'])
|
||||
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[previewOf(), SUMMARY, 'Purge 4 groups', 'Kept · used in 4 articles', '2 copies merged'],
|
||||
[
|
||||
singlePreview(2, 1, 2),
|
||||
'1 group · 1 copy will be deleted · 2 articles will be updated',
|
||||
'Purge 1 group',
|
||||
'Kept · used in 2 articles',
|
||||
'1 copy merged',
|
||||
],
|
||||
[
|
||||
singlePreview(1, 1, 1),
|
||||
'1 group · 1 copy will be deleted · 1 article will be updated',
|
||||
'Purge 1 group',
|
||||
'Kept · used in 1 article',
|
||||
'1 copy merged',
|
||||
],
|
||||
])(
|
||||
'pluralises the summary, the confirm and the rows',
|
||||
async (preview, summary, confirm, kept, merged) => {
|
||||
await opened({ preview: () => preview })
|
||||
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-summary"]'))).toBe(summary)
|
||||
expect(buttonLabels()).toEqual(['Cancel', confirm])
|
||||
const row = textOf(groupRows()[0])
|
||||
expect(row).toContain(kept)
|
||||
expect(row).toContain(merged)
|
||||
},
|
||||
)
|
||||
|
||||
it('stops at 5 rows and says how many groups are left', async () => {
|
||||
await opened({ preview: manyPreview })
|
||||
|
||||
expect(groupRows()).toHaveLength(5)
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
|
||||
'and 3 more groups',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 8 groups'])
|
||||
})
|
||||
|
||||
it('says "and 1 more group" for one', async () => {
|
||||
await opened({ preview: () => ({ ...manyPreview(), more: 1 }) })
|
||||
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
|
||||
'and 1 more group',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Nothing to purge with a single Close button', async () => {
|
||||
await opened({ preview: emptyPreview })
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box)).toContain('Nothing to purge')
|
||||
expect(textOf(box)).toContain(
|
||||
'No duplicate is used in an article, so there is nothing to merge.',
|
||||
)
|
||||
expect(box.querySelector('svg')).not.toBeNull()
|
||||
expect(buttonLabels()).toEqual(['Close'])
|
||||
expect(box.querySelector('[data-testid="purge-summary"]')).toBeNull()
|
||||
expect(box.querySelector('a')).toBeNull()
|
||||
|
||||
findButton('Close', box)!.click()
|
||||
await flushPromises()
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('shows the error notice after %s and offers no purge', async (_name, preview) => {
|
||||
await opened({ preview })
|
||||
|
||||
expect(noticeText()).toBe(FAILED_COPY)
|
||||
expect(confirmButton('Purge duplicates').disabled).toBe(true)
|
||||
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(false)
|
||||
expect(dialogEl()!.querySelector('[data-testid="purge-summary"]')).toBeNull()
|
||||
confirmButton('Purge duplicates').click()
|
||||
await flushPromises()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: leaving', () => {
|
||||
it('closes with Cancel, sends nothing and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
findButton('Cancel', dialogEl()!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('closes with Escape, sends nothing and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
pressEscape(dialogEl())
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('closes with a click on the overlay and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
const overlay = document.body.querySelector('[data-slot="overlay"]')!
|
||||
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
|
||||
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('follows the review link to the Duplicates tab', async () => {
|
||||
await opened()
|
||||
|
||||
dialogEl()!.querySelector('a')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: purging', () => {
|
||||
it('sends exactly the fingerprint of the preview, once', async () => {
|
||||
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(bodyOf(calls[0]!)).toEqual({ fingerprint: 'ab12' })
|
||||
expect(Object.keys(bodyOf(calls[0]!))).toEqual(['fingerprint'])
|
||||
})
|
||||
|
||||
it('cannot be dismissed while the request runs', async () => {
|
||||
const pending = deferredValue<unknown>()
|
||||
await opened({ purge: () => pending.promise })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const confirm = confirmButton('Purging…')
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(true)
|
||||
pressEscape(dialogEl())
|
||||
document.body
|
||||
.querySelector('[data-slot="overlay"]')
|
||||
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(dialogEl()).not.toBeNull()
|
||||
|
||||
pending.resolve(purgeResult())
|
||||
await flushPromises()
|
||||
expect(dialogEl()).toBeNull()
|
||||
})
|
||||
|
||||
it('closes, shows the toast, refreshes Last purge in place and returns focus on success', async () => {
|
||||
const opener = await opened({ settings: afterRefresh(purgedSettings()) })
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(toasts.add).toHaveBeenCalledTimes(1)
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Duplicates purged',
|
||||
description: '9 copies merged. 12 articles updated.',
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('shows no Next purge when automatic purge is off', async () => {
|
||||
await opened({
|
||||
settings: afterRefresh(
|
||||
purgedSettings({ auto_purge_enabled: false, next_purge_at: null }),
|
||||
),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
expect(nextLine()).toBeNull()
|
||||
})
|
||||
|
||||
it('pluralises the toast', async () => {
|
||||
await opened({ purge: () => purgeResult({ merged: 1, articles_updated: 1 }) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: '1 copy merged. 1 article updated.' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits purged with the counts and then closes', async () => {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: vi.fn(async (path: string) =>
|
||||
path === PREVIEW_PATH ? previewOf() : purgeResult(),
|
||||
),
|
||||
})
|
||||
const wrapper = mount(PurgeDuplicatesDialog, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('purged')).toEqual([[purgeResult()]])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: failures', () => {
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network error', () => Promise.reject(new NetworkError())],
|
||||
])(
|
||||
'keeps the dialog open after %s and retries with the same fingerprint',
|
||||
async (_name, purge) => {
|
||||
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }), purge })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
const alert = box.querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toBe(FAILED_COPY)
|
||||
expect(alert.querySelector('svg')).not.toBeNull()
|
||||
expect(isBefore(alert, box.querySelector('[data-testid="purge-summary"]')!)).toBe(true)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
|
||||
expect(groupRows()).toHaveLength(4)
|
||||
expect(findButton('Cancel', box)!.disabled).toBe(false)
|
||||
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'ab12' })
|
||||
},
|
||||
)
|
||||
|
||||
it('warns about a partial result, refreshes the preview and the settings, and retries with the new fingerprint', async () => {
|
||||
let previews = 0
|
||||
await opened({
|
||||
settings: afterRefresh(purgedSettings()),
|
||||
preview: () => (previews++ === 0 ? previewOf() : singlePreview(2, 1, 2)),
|
||||
purge: () =>
|
||||
purgeResult({
|
||||
groups_total: 4,
|
||||
groups_purged: 3,
|
||||
merged: 8,
|
||||
articles_updated: 10,
|
||||
failed_groups: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(noticeText()).toBe(
|
||||
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
|
||||
)
|
||||
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'1 group · 1 copy will be deleted · 2 articles will be updated',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Close', 'Retry'])
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
|
||||
confirmButton('Retry').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'one' })
|
||||
})
|
||||
|
||||
it('shows Nothing to purge when a partial result leaves no group', async () => {
|
||||
let previews = 0
|
||||
await opened({
|
||||
preview: () => (previews++ === 0 ? previewOf() : emptyPreview()),
|
||||
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(dialogEl())).toContain('Nothing to purge')
|
||||
expect(buttonLabels()).toEqual(['Close'])
|
||||
})
|
||||
|
||||
it('refreshes the summary after the library changed and confirms with the new fingerprint', async () => {
|
||||
let previews = 0
|
||||
const refreshed = previewOf({
|
||||
groups: 3,
|
||||
copies: 7,
|
||||
articles: 10,
|
||||
list: previewOf().list.slice(0, 3),
|
||||
fingerprint: 'cd34',
|
||||
})
|
||||
await opened({
|
||||
preview: () => (previews++ === 0 ? previewOf({ fingerprint: 'ab12' }) : refreshed),
|
||||
purge: () => Promise.reject(new ApiError(409, undefined, STALE_COPY)),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(noticeText()).toBe(STALE_COPY)
|
||||
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'3 groups · 7 copies will be deleted · 10 articles will be updated',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 3 groups'])
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 3 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({ fingerprint: 'cd34' })
|
||||
})
|
||||
|
||||
it('shows the server text when a purge is already running', async () => {
|
||||
await opened({ purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(noticeText()).toBe(BUSY_COPY)
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(dialogEl()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
stubClipboard,
|
||||
upToDate,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -41,6 +43,9 @@ async function mountSettings(role: UserRole, status: UpdateStatus) {
|
||||
if (path === STATUS_PATH) {
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
INVALID_DAYS_COPY,
|
||||
OFFLINE_COPY,
|
||||
PREVIEW_PATH,
|
||||
SAVE_FAILED_COPY,
|
||||
SETTINGS_PATH,
|
||||
bodyOf,
|
||||
callsTo,
|
||||
cardEl,
|
||||
commitByBlur,
|
||||
commitByEnter,
|
||||
daysError,
|
||||
daysInput,
|
||||
deferredValue,
|
||||
enabledSettings,
|
||||
findButton,
|
||||
goOffline,
|
||||
lastLine,
|
||||
mountSettings,
|
||||
nextLine,
|
||||
purgeButton,
|
||||
purgeKit,
|
||||
purgeSettings,
|
||||
saveStatus,
|
||||
switchEl,
|
||||
switchError,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountSettings,
|
||||
type SettingsCall,
|
||||
} from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DESCRIPTION =
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.'
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
|
||||
function patching(onPatch: (call: SettingsCall) => unknown, read = () => enabledSettings()) {
|
||||
return (call: SettingsCall) => (call.method === 'PATCH' ? onPatch(call) : read())
|
||||
}
|
||||
|
||||
function savedInterval(call: SettingsCall) {
|
||||
const days = (call.body as { auto_purge_interval_days: number }).auto_purge_interval_days
|
||||
return enabledSettings({ auto_purge_interval_days: days })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountSettings)
|
||||
|
||||
describe('Media cleanup card: default and on', () => {
|
||||
it('Default: off, never run', async () => {
|
||||
await mountSettings({ settings: () => purgeSettings() })
|
||||
|
||||
const card = cardEl()
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(1)
|
||||
expect(textOf(card.querySelector('h2'))).toBe('Media cleanup')
|
||||
expect(textOf(card)).toContain(DESCRIPTION)
|
||||
expect(textOf(card)).toContain('Automatic purge')
|
||||
expect(textOf(card)).toContain('Runs in the background. Off by default.')
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(daysInput()).toBeNull()
|
||||
expect(textOf(card)).not.toContain('Every')
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(card.querySelector('[role="separator"]')).not.toBeNull()
|
||||
expect(textOf(card)).toContain('Purge now')
|
||||
expect(textOf(card)).toContain(
|
||||
'Merge every group of duplicates that has an image in use, right away.',
|
||||
)
|
||||
expect(purgeButton().disabled).toBe(false)
|
||||
expect(findButton('Save', card)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('On: 30 days, last run', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
|
||||
const card = cardEl()
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('true')
|
||||
expect(textOf(card)).toContain('Every')
|
||||
expect(textOf(card)).toContain('Between 1 and 365 days.')
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(textOf(card)).toContain('days')
|
||||
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[3, 5, 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'],
|
||||
[1, 1, 'Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated'],
|
||||
[0, 0, 'Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated'],
|
||||
])(
|
||||
'pluralises the Last purge line for %i merged and %i articles',
|
||||
async (merged, articles, line) => {
|
||||
await mountSettings({
|
||||
settings: () =>
|
||||
enabledSettings({
|
||||
last_purge_merged: merged,
|
||||
last_purge_articles_updated: articles,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(lastLine()).toBe(line)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Media cleanup card: the switch', () => {
|
||||
it('turning it on sends exactly the flag, shows Saving, then Saved for 2 s', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const patch = deferredValue<unknown>()
|
||||
await mountSettings({
|
||||
settings: patching(
|
||||
() => patch.promise,
|
||||
() => purgeSettings(),
|
||||
),
|
||||
})
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
const [call] = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(call![0]).toBe(SETTINGS_PATH)
|
||||
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: true })
|
||||
expect(Object.keys(bodyOf(call!))).toEqual(['auto_purge_enabled'])
|
||||
expect(saveStatus()).toBe('Saving…')
|
||||
expect(
|
||||
cardEl()
|
||||
.querySelector('[data-testid="cleanup-save-status"]')!
|
||||
.getAttribute('aria-live'),
|
||||
).toBe('polite')
|
||||
expect(switchEl().disabled).toBe(false)
|
||||
|
||||
patch.resolve(
|
||||
enabledSettings({ last_purge_at: null, next_purge_at: '2026-11-04T09:00:00Z' }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
|
||||
vi.advanceTimersByTime(1999)
|
||||
await flushPromises()
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('turning it off sends exactly the flag and hides the schedule but keeps Last purge', async () => {
|
||||
await mountSettings({
|
||||
settings: patching(() =>
|
||||
enabledSettings({ auto_purge_enabled: false, next_purge_at: null }),
|
||||
),
|
||||
})
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
const [call] = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: false })
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(daysInput()).toBeNull()
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: the days field', () => {
|
||||
it.each([
|
||||
['45', 'blur', 45],
|
||||
['45', 'Enter', 45],
|
||||
['1', 'Enter', 1],
|
||||
['365', 'blur', 365],
|
||||
])('saves %s once when it %s commits', async (typed, commit, sent) => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, typed)
|
||||
await (commit === 'blur' ? commitByBlur(input) : commitByEnter(input))
|
||||
|
||||
const calls = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(bodyOf(calls[0]!)).toEqual({ auto_purge_interval_days: sent })
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
|
||||
})
|
||||
|
||||
it('sends one request when Enter is followed by the blur', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends nothing for an unchanged value', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
input.focus()
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it.each(['', '0', '366', '400', '1.5'])(
|
||||
'rejects "%s" without sending anything and keeps the text',
|
||||
async (typed) => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, typed)
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(input.value).toBe(typed)
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
|
||||
expect(daysError()!.querySelector('svg')).not.toBeNull()
|
||||
expect(saveStatus()).toBe('')
|
||||
},
|
||||
)
|
||||
|
||||
it('also validates on Enter', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
|
||||
await typeInto(daysInput()!, '366')
|
||||
await commitByEnter(daysInput()!)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
|
||||
})
|
||||
|
||||
it('clears the validation error once the value is valid and sends it', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
await typeInto(input, '400')
|
||||
await commitByBlur(input)
|
||||
expect(daysError()).not.toBeNull()
|
||||
|
||||
await typeInto(input, '60')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(daysError()).toBeNull()
|
||||
expect(input.getAttribute('aria-invalid')).toBeNull()
|
||||
expect(bodyOf(callsTo('PATCH', SETTINGS_PATH)[0]!)).toEqual({
|
||||
auto_purge_interval_days: 60,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: save failures', () => {
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network error', () => Promise.reject(new NetworkError())],
|
||||
])('reverts the days field after %s and shows the message under it', async (_name, failure) => {
|
||||
await mountSettings({ settings: patching(failure) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(input.value).toBe('30')
|
||||
expect(textOf(daysError())).toBe(SAVE_FAILED_COPY)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('reverts the switch after a 500 and shows the message under Automatic purge', async () => {
|
||||
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(500))) })
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('true')
|
||||
expect(textOf(switchError())).toBe(SAVE_FAILED_COPY)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('removes the message on the next successful save', async () => {
|
||||
let failing = true
|
||||
await mountSettings({
|
||||
settings: patching((call) =>
|
||||
failing ? Promise.reject(new ApiError(500)) : savedInterval(call),
|
||||
),
|
||||
})
|
||||
const input = daysInput()!
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
expect(daysError()).not.toBeNull()
|
||||
|
||||
failing = false
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(daysError()).toBeNull()
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
})
|
||||
|
||||
it('reverts silently on a 403, refreshes the session and becomes read-only', async () => {
|
||||
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(403))) })
|
||||
const refresh = vi.spyOn(useSessionStore(), 'refresh')
|
||||
purgeKit.meRole = 'editor'
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
expect(switchError()).toBeNull()
|
||||
expect(textOf(cardEl())).toContain('Only owners and admins can change this.')
|
||||
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: read-only', () => {
|
||||
it('never reads the settings and shows placeholders, disabled, with the info alert', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() }, { role: 'editor' })
|
||||
|
||||
const card = cardEl()
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(daysInput()!.disabled).toBe(true)
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(textOf(card.querySelector('[role="status"]'))).toBe(
|
||||
'Only owners and admins can change this.',
|
||||
)
|
||||
expect(card.querySelector('[role="separator"]')).toBeNull()
|
||||
expect(textOf(card)).not.toContain('Purge now')
|
||||
expect(findButton('Purge duplicates', card)).toBeUndefined()
|
||||
|
||||
switchEl().click()
|
||||
await commitByBlur(daysInput()!)
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('treats a missing role the same way', async () => {
|
||||
await mountSettings({}, { role: undefined })
|
||||
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: loading and failure to load', () => {
|
||||
it('shows the skeleton only after 150 ms, with aria-busy and a disabled button', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const pending = deferredValue<unknown>()
|
||||
await mountSettings({ settings: () => pending.promise })
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
|
||||
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(cardEl().getAttribute('aria-busy')).toBe('true')
|
||||
expect(cardEl().querySelector('.animate-pulse')).not.toBeNull()
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
|
||||
pending.resolve(purgeSettings())
|
||||
await flushPromises()
|
||||
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
|
||||
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(purgeButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('shows the inline alert after %s and Try again loads it again', async (_name, failure) => {
|
||||
let failing = true
|
||||
await mountSettings({ settings: () => (failing ? failure() : purgeSettings()) })
|
||||
|
||||
const alert = cardEl().querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain(UNREACHABLE_COPY)
|
||||
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="settings-version"]')).not.toBeNull()
|
||||
|
||||
failing = false
|
||||
findButton('Try again', cardEl())!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: offline and 401', () => {
|
||||
it('shows the banner and disables the controls, sending nothing', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
await goOffline()
|
||||
|
||||
expect(document.body.textContent).toContain(OFFLINE_COPY)
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
expect(daysInput()!.disabled).toBe(true)
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
|
||||
switchEl().click()
|
||||
purgeButton().click()
|
||||
await flushPromises()
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('goes to the login page on a 401 and shows no copy of its own', async () => {
|
||||
await mountSettings(
|
||||
{ settings: () => Promise.reject(new ApiError(401)) },
|
||||
{ role: 'owner', realApi: true },
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
redirect: '/settings',
|
||||
reason: 'expired',
|
||||
})
|
||||
expect(textOf(cardEl())).not.toContain(UNREACHABLE_COPY)
|
||||
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no save failure for a 401 on a change', async () => {
|
||||
await mountSettings(
|
||||
{ settings: patching(() => Promise.reject(new ApiError(401))) },
|
||||
{ role: 'owner', realApi: true },
|
||||
)
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(textOf(cardEl())).not.toContain(SAVE_FAILED_COPY)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
updateSucceeded,
|
||||
verifying,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -135,6 +137,9 @@ async function mountSettings(options: MountOptions) {
|
||||
}
|
||||
const handler = handlers[path]
|
||||
if (!handler) {
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
}
|
||||
return handler()
|
||||
@@ -262,6 +267,9 @@ function silenceStatusReads() {
|
||||
if (path === STATUS_PATH) {
|
||||
return Promise.reject(new NetworkError())
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
}
|
||||
@@ -650,6 +658,9 @@ describe('Settings, the overlay ends when the site answers', () => {
|
||||
if (path === STATUS_PATH) {
|
||||
return answers()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
siteUp = healthy
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
startable,
|
||||
verifying,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -89,6 +91,9 @@ async function mountSettings(options: MountOptions) {
|
||||
if (path === DOWNLOAD_PATH) {
|
||||
return downloadHandler()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
stubClipboard,
|
||||
upToDate,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -77,6 +79,9 @@ async function mountSettings(options: MountOptions) {
|
||||
if (path === CHECK_PATH) {
|
||||
return checkHandler()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -6,6 +6,7 @@ import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -38,6 +39,9 @@ async function mountPage(settings: () => Promise<unknown>) {
|
||||
if (path === SETTINGS_PATH) {
|
||||
return settings()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
|
||||
@@ -236,6 +236,8 @@ export interface Handlers {
|
||||
hash?: (path: string) => unknown
|
||||
remove?: (id: number) => unknown
|
||||
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
|
||||
preview?: () => unknown
|
||||
purge?: (body: { fingerprint: string }) => unknown
|
||||
}
|
||||
|
||||
export function libraryHandlers(
|
||||
@@ -274,6 +276,12 @@ function dispatcher(handlers: Handlers) {
|
||||
if (method === 'GET' && path.startsWith('/v1/media/?')) {
|
||||
return (handlers.list ?? (() => listBody(fixture())))(path)
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/media/purge-preview') {
|
||||
return (handlers.preview ?? (() => undefined))()
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/media/purge') {
|
||||
return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body)))
|
||||
}
|
||||
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
|
||||
if (method === 'GET' && usage) {
|
||||
return (handlers.usage ?? (() => []))(Number(usage[1]))
|
||||
@@ -316,12 +324,18 @@ export async function mountPage(
|
||||
handlers: Handlers = {},
|
||||
url = '/media',
|
||||
width = DESKTOP_WIDTH,
|
||||
options: { realApi?: boolean } = {},
|
||||
options: { realApi?: boolean; role?: string } = {},
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = dispatcher(handlers)
|
||||
if (options.realApi) {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { PurgePreview, PurgeResult, PurgeSettings } from '@/composables/useMediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
|
||||
export const SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
export const PREVIEW_PATH = '/v1/media/purge-preview'
|
||||
export const PURGE_PATH = '/v1/media/purge'
|
||||
export const STALE_COPY =
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.'
|
||||
export const BUSY_COPY = 'A purge is already running.'
|
||||
export const FAILED_COPY =
|
||||
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
export const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
|
||||
export const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
|
||||
export const OFFLINE_COPY =
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
|
||||
export function purgeSettings(overrides: Partial<PurgeSettings> = {}): PurgeSettings {
|
||||
return {
|
||||
auto_purge_enabled: false,
|
||||
auto_purge_interval_days: 30,
|
||||
last_purge_at: null,
|
||||
last_purge_merged: 0,
|
||||
last_purge_articles_updated: 0,
|
||||
enabled_at: null,
|
||||
next_purge_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function enabledSettings(overrides: Partial<PurgeSettings> = {}) {
|
||||
return purgeSettings({
|
||||
auto_purge_enabled: true,
|
||||
last_purge_at: '2026-06-14T09:00:00Z',
|
||||
last_purge_merged: 3,
|
||||
last_purge_articles_updated: 5,
|
||||
enabled_at: '2026-06-14T09:00:00Z',
|
||||
next_purge_at: '2026-07-14T09:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
keep_id: 12,
|
||||
filename: 'floorboards.jpg',
|
||||
url: '/media/floorboards.jpg',
|
||||
used_in: 4,
|
||||
merged: 2,
|
||||
},
|
||||
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
|
||||
{
|
||||
keep_id: 2,
|
||||
filename: 'kitchen-after.webp',
|
||||
url: '/media/kitchen-after.webp',
|
||||
used_in: 3,
|
||||
merged: 3,
|
||||
},
|
||||
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
|
||||
{
|
||||
keep_id: 11,
|
||||
filename: 'lime-plaster.jpg',
|
||||
url: '/media/lime-plaster.jpg',
|
||||
used_in: 2,
|
||||
merged: 1,
|
||||
},
|
||||
]
|
||||
|
||||
export function previewOf(overrides: Partial<PurgePreview> = {}): PurgePreview {
|
||||
return {
|
||||
groups: 4,
|
||||
copies: 9,
|
||||
articles: 12,
|
||||
list: GROUPS.slice(0, 4),
|
||||
more: 0,
|
||||
fingerprint: 'ab12',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function manyPreview() {
|
||||
return previewOf({ groups: 8, copies: 15, articles: 24, list: GROUPS, more: 3 })
|
||||
}
|
||||
|
||||
export function emptyPreview() {
|
||||
return previewOf({ groups: 0, copies: 0, articles: 0, list: [], fingerprint: 'none' })
|
||||
}
|
||||
|
||||
export function singlePreview(usedIn: number, merged: number, articles: number, copies = merged) {
|
||||
return previewOf({
|
||||
groups: 1,
|
||||
copies,
|
||||
articles,
|
||||
list: [{ ...GROUPS[0]!, used_in: usedIn, merged }],
|
||||
fingerprint: 'one',
|
||||
})
|
||||
}
|
||||
|
||||
export function purgeResult(overrides: Partial<PurgeResult> = {}): PurgeResult {
|
||||
return {
|
||||
groups_total: 4,
|
||||
groups_purged: 4,
|
||||
merged: 9,
|
||||
articles_updated: 12,
|
||||
failed_groups: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsCall {
|
||||
method: string
|
||||
body: unknown
|
||||
}
|
||||
|
||||
export interface PurgeMocks {
|
||||
settings?: (call: SettingsCall) => unknown
|
||||
preview?: () => unknown
|
||||
purge?: (body: { fingerprint: string }) => unknown
|
||||
}
|
||||
|
||||
export const purgeKit = {
|
||||
requestMock: vi.fn(),
|
||||
wrapper: null as VueWrapper | null,
|
||||
meRole: 'owner' as string | undefined,
|
||||
getMe: vi.fn(),
|
||||
}
|
||||
|
||||
export function installPurgeApi(mocks: PurgeMocks) {
|
||||
return (path: string, options?: RequestInit) => {
|
||||
const method = options?.method ?? 'GET'
|
||||
if (path === SETTINGS_PATH) {
|
||||
const body = options?.body ? JSON.parse(String(options.body)) : undefined
|
||||
return (mocks.settings ?? (() => purgeSettings()))({ method, body })
|
||||
}
|
||||
if (path === PREVIEW_PATH && method === 'GET') {
|
||||
return (mocks.preview ?? (() => previewOf()))()
|
||||
}
|
||||
if (path === PURGE_PATH && method === 'POST') {
|
||||
return (mocks.purge ?? (() => purgeResult()))(JSON.parse(String(options?.body)))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknown>) {
|
||||
return vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const reply = (status: number, body?: unknown) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
text: async () => '',
|
||||
})
|
||||
try {
|
||||
const body = await request(path, init)
|
||||
return reply(body === undefined ? 204 : 200, body)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return reply(error.status)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function mountSettings(
|
||||
mocks: PurgeMocks = {},
|
||||
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
|
||||
) {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
purgeKit.meRole = options.role
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: options.role as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = installPurgeApi(mocks)
|
||||
purgeKit.requestMock = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/settings/') {
|
||||
return { version: 'v2.2.0-beta' }
|
||||
}
|
||||
if (path === '/v1/system/update') {
|
||||
return new Promise(() => undefined)
|
||||
}
|
||||
return dispatch(path, init)
|
||||
})
|
||||
purgeKit.getMe = vi.fn(async () => ({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: purgeKit.meRole,
|
||||
}))
|
||||
if (options.realApi) {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/composables/useApi')>('@/composables/useApi')
|
||||
vi.mocked(useApi).mockImplementation(actual.useApi)
|
||||
vi.stubGlobal('fetch', fetchFrom(purgeKit.requestMock))
|
||||
} else {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: purgeKit.getMe,
|
||||
request: purgeKit.requestMock,
|
||||
})
|
||||
}
|
||||
await router.push('/settings')
|
||||
purgeKit.wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return purgeKit.wrapper
|
||||
}
|
||||
|
||||
export async function unmountSettings() {
|
||||
purgeKit.wrapper?.unmount()
|
||||
purgeKit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function cardEl() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="media-cleanup-card"]')!
|
||||
}
|
||||
|
||||
export function textOf(element: Element | null | undefined) {
|
||||
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function findButton(label: string, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => textOf(button) === label,
|
||||
)
|
||||
}
|
||||
|
||||
export function purgeButton() {
|
||||
return findButton('Purge duplicates', cardEl())!
|
||||
}
|
||||
|
||||
export function switchEl() {
|
||||
return cardEl().querySelector<HTMLButtonElement>('[role="switch"]')!
|
||||
}
|
||||
|
||||
export function daysInput() {
|
||||
return cardEl().querySelector<HTMLInputElement>('#auto-purge-days')
|
||||
}
|
||||
|
||||
export function daysError() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-days-error"]')
|
||||
}
|
||||
|
||||
export function switchError() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-switch-error"]')
|
||||
}
|
||||
|
||||
export function saveStatus() {
|
||||
return textOf(cardEl().querySelector('[data-testid="cleanup-save-status"]'))
|
||||
}
|
||||
|
||||
export function lastLine() {
|
||||
return textOf(cardEl().querySelector('[data-testid="cleanup-last"]'))
|
||||
}
|
||||
|
||||
export function nextLine() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-next"]')
|
||||
}
|
||||
|
||||
export function dialogEl() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
||||
}
|
||||
|
||||
export function callsTo(method: string, path: string) {
|
||||
return purgeKit.requestMock.mock.calls.filter(
|
||||
([called, options]) => called === path && (options?.method ?? 'GET') === method,
|
||||
)
|
||||
}
|
||||
|
||||
export function bodyOf(call: unknown[]) {
|
||||
return JSON.parse(String((call[1] as RequestInit).body))
|
||||
}
|
||||
|
||||
export async function typeInto(input: HTMLInputElement, value: string) {
|
||||
input.focus()
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function commitByBlur(input: HTMLInputElement) {
|
||||
input.blur()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function commitByEnter(input: HTMLInputElement) {
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function goOffline() {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function pressEscape(target: Element | null) {
|
||||
;(target ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
export async function openDialog() {
|
||||
const opener = purgeButton()
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
return opener
|
||||
}
|
||||
|
||||
export function isBefore(first: Element, second: Element) {
|
||||
return first.compareDocumentPosition(second) === Node.DOCUMENT_POSITION_FOLLOWING
|
||||
}
|
||||
|
||||
export function groupRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="purge-group"]'))
|
||||
}
|
||||
|
||||
export function deferredValue<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
@@ -134,3 +134,15 @@ export function plainRestartDone(): UpdateStatus {
|
||||
finished_at: RESTART_FINISHED_AT,
|
||||
})
|
||||
}
|
||||
|
||||
export const PURGE_SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
|
||||
export const DEFAULT_PURGE_SETTINGS = {
|
||||
auto_purge_enabled: false,
|
||||
auto_purge_interval_days: 30,
|
||||
last_purge_at: null,
|
||||
last_purge_merged: 0,
|
||||
last_purge_articles_updated: 0,
|
||||
enabled_at: null,
|
||||
next_purge_at: null,
|
||||
}
|
||||
Reference in new issue
Block a user