feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)

This commit is contained in:
fsylvain committed 2026-10-05 23:57:50 +02:00
1 parent 1a55a39425
commit 2303b12377
26 files changed
+3856 -83

No files matched your search

+107
View File
@@ -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,
}
}