feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { hashFile } from '../fileHash'
|
||||
|
||||
const SHA256_OF_ABC = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
|
||||
|
||||
function fileOf(bytes: number[]) {
|
||||
return new File([new Uint8Array(bytes)], 'a.png', { type: 'image/png' })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('hashFile', () => {
|
||||
it('resolves the lowercase 64-character hex of the digest', async () => {
|
||||
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
|
||||
expect(Array.from(new Uint8Array(data))).toEqual([97, 98, 99])
|
||||
return new Uint8Array(
|
||||
SHA256_OF_ABC.match(/../g)!.map((pair) => Number.parseInt(pair, 16)),
|
||||
).buffer
|
||||
})
|
||||
vi.stubGlobal('crypto', { subtle: { digest } })
|
||||
|
||||
const hex = await hashFile(fileOf([97, 98, 99]))
|
||||
|
||||
expect(digest).toHaveBeenCalledWith('SHA-256', expect.anything())
|
||||
expect(hex).toBe(SHA256_OF_ABC)
|
||||
expect(hex).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('resolves null when crypto.subtle is unavailable', async () => {
|
||||
vi.stubGlobal('crypto', { subtle: undefined })
|
||||
|
||||
expect(await hashFile(fileOf([1]))).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves null when the digest rejects', async () => {
|
||||
vi.stubGlobal('crypto', { subtle: { digest: vi.fn().mockRejectedValue(new Error('no')) } })
|
||||
|
||||
expect(await hashFile(fileOf([1]))).toBeNull()
|
||||
})
|
||||
|
||||
it('imports nothing from the Media page', async () => {
|
||||
const source = (await import('../fileHash?raw')).default
|
||||
expect(source).not.toMatch(/import .*Media/)
|
||||
expect(source).not.toMatch(/from '@\/(components|views|composables)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extensionMatchesType, validateImageFile } from '../imageUpload'
|
||||
|
||||
function fileOf(name: string, type: string, size = 100) {
|
||||
const file = new File(['x'], name, { type })
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
return file
|
||||
}
|
||||
|
||||
describe('validateImageFile', () => {
|
||||
it('checks the size first, 10 MB allowed and one byte more too large', () => {
|
||||
expect(validateImageFile(fileOf('a.png', 'image/png', 10485760))).toEqual({ ok: true })
|
||||
expect(validateImageFile(fileOf('a.png', 'image/png', 10485761))).toEqual({
|
||||
ok: false,
|
||||
kind: 'size',
|
||||
})
|
||||
expect(validateImageFile(fileOf('a.tif', 'image/tiff', 10485761))).toEqual({
|
||||
ok: false,
|
||||
kind: 'size',
|
||||
})
|
||||
})
|
||||
|
||||
it('needs an allowed MIME type and an allowed extension', () => {
|
||||
expect(validateImageFile(fileOf('a.gif', 'image/gif'))).toEqual({ ok: false, kind: 'type' })
|
||||
expect(validateImageFile(fileOf('a.jpg', 'text/plain'))).toEqual({
|
||||
ok: false,
|
||||
kind: 'type',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('extensionMatchesType', () => {
|
||||
it.each([
|
||||
['a.png', 'image/png', true],
|
||||
['a.JPG', 'image/jpeg', true],
|
||||
['a.jpeg', 'image/jpeg', true],
|
||||
['a.webp', 'image/webp', true],
|
||||
['a.svg', 'image/svg+xml', true],
|
||||
['pic.jpg', 'image/png', false],
|
||||
['a.svg', 'image/jpeg', false],
|
||||
['a.tif', 'image/tiff', false],
|
||||
])('%s as %s is %s', (name, type, expected) => {
|
||||
expect(extensionMatchesType(fileOf(name, type))).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { groupDuplicates, mergeArticleCount } from '../mediaDuplicates'
|
||||
|
||||
function copy(id: number, usedIn: number, createdAt: string, overrides: Partial<MediaItem> = {}) {
|
||||
return {
|
||||
id,
|
||||
filename: `copy-${id}.jpg`,
|
||||
url: `/media/copy-${id}.jpg`,
|
||||
size: 2516582,
|
||||
content_type: 'image/jpeg',
|
||||
width: 3600,
|
||||
height: 2400,
|
||||
sha256: 'aa',
|
||||
uploaded_by: 'Inès Duarte',
|
||||
created_at: createdAt,
|
||||
used_in: usedIn,
|
||||
duplicate: true,
|
||||
...overrides,
|
||||
} satisfies MediaItem
|
||||
}
|
||||
|
||||
describe('groupDuplicates', () => {
|
||||
it.each([
|
||||
[
|
||||
'the most used copy',
|
||||
[
|
||||
copy(1, 4, '2026-06-14T12:00:00Z'),
|
||||
copy(2, 3, '2026-09-02T12:00:00Z'),
|
||||
copy(3, 2, '2026-08-30T12:00:00Z'),
|
||||
],
|
||||
1,
|
||||
],
|
||||
[
|
||||
'the oldest copy at equal use',
|
||||
[copy(1, 2, '2026-08-29T12:00:00Z'), copy(2, 2, '2026-08-21T12:00:00Z')],
|
||||
2,
|
||||
],
|
||||
[
|
||||
'the oldest unused copy',
|
||||
[copy(1, 0, '2026-09-03T12:00:00Z'), copy(2, 0, '2026-09-01T12:00:00Z')],
|
||||
2,
|
||||
],
|
||||
[
|
||||
'the lowest id at equal use and date',
|
||||
[copy(9, 1, '2026-09-03T12:00:00Z'), copy(4, 1, '2026-09-03T12:00:00Z')],
|
||||
4,
|
||||
],
|
||||
])('suggests %s', (_name, copies, suggestedId) => {
|
||||
const [group] = groupDuplicates(copies)
|
||||
|
||||
expect(group!.suggested.id).toBe(suggestedId)
|
||||
expect(group!.copies[0]!.id).toBe(suggestedId)
|
||||
})
|
||||
|
||||
it('groups by sha256, orders copies and puts the oldest suggested copy first', () => {
|
||||
const groups = groupDuplicates([
|
||||
copy(7, 2, '2026-08-29T12:00:00Z', { sha256: 'bb', size: 3250585 }),
|
||||
copy(5, 3, '2026-09-02T12:00:00Z'),
|
||||
copy(9, 1, '2026-08-21T12:00:00Z', { sha256: 'bb', size: 3250585 }),
|
||||
copy(12, 4, '2026-06-14T12:00:00Z'),
|
||||
copy(6, 2, '2026-08-30T12:00:00Z'),
|
||||
])
|
||||
|
||||
expect(groups.map((group) => group.copies.map((item) => item.id))).toEqual([
|
||||
[12, 5, 6],
|
||||
[7, 9],
|
||||
])
|
||||
expect(groups.map((group) => group.header)).toEqual([
|
||||
'3 identical images · 2.4 MB each · 3600 × 2400',
|
||||
'2 identical images · 3.1 MB each · 3600 × 2400',
|
||||
])
|
||||
})
|
||||
|
||||
it('reads Vector for SVG groups and ignores single copies', () => {
|
||||
const groups = groupDuplicates([
|
||||
copy(1, 0, '2026-09-01T12:00:00Z', { width: null, height: null, size: 8192 }),
|
||||
copy(2, 0, '2026-09-02T12:00:00Z', { width: null, height: null, size: 8192 }),
|
||||
copy(3, 0, '2026-09-02T12:00:00Z', { sha256: 'zz' }),
|
||||
])
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]!.header).toBe('2 identical images · 8 KB each · Vector')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeArticleCount', () => {
|
||||
it('sums the use of every copy except the kept one', () => {
|
||||
const copies = [
|
||||
copy(12, 4, '2026-06-14T12:00:00Z'),
|
||||
copy(5, 3, '2026-09-02T12:00:00Z'),
|
||||
copy(6, 2, '2026-08-30T12:00:00Z'),
|
||||
]
|
||||
|
||||
expect(mergeArticleCount(copies, 12)).toBe(5)
|
||||
expect(mergeArticleCount(copies, 6)).toBe(7)
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ describe('parseMediaControls', () => {
|
||||
[{ q: 'wall' }, { q: 'wall' }],
|
||||
[{ view: 'list' }, { view: 'list' }],
|
||||
[{ filter: 'bogus', sort: 'zzz' }, {}],
|
||||
[{ filter: 'duplicates' }, {}],
|
||||
[{ filter: 'duplicates' }, { filter: 'duplicates' }],
|
||||
[{ view: 'grid' }, {}],
|
||||
[{ sort: 'newest' }, {}],
|
||||
])('maps %j', (query, expected) => {
|
||||
@@ -42,6 +42,7 @@ describe('toRequestQuery', () => {
|
||||
it.each([
|
||||
[{}, 'filter=all&sort=newest&limit=100'],
|
||||
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
|
||||
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&limit=100'],
|
||||
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'],
|
||||
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'],
|
||||
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&limit=100'],
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describeUpload,
|
||||
parseRetryAfter,
|
||||
rejectionText,
|
||||
rejectionTitle,
|
||||
summaryLine,
|
||||
type UploadRowStatus,
|
||||
} from '../mediaUploadRows'
|
||||
|
||||
function rows(...statuses: UploadRowStatus[]) {
|
||||
return statuses.map((status) => ({ status }))
|
||||
}
|
||||
|
||||
describe('summaryLine', () => {
|
||||
it.each([
|
||||
[2, 1, 2, '2 uploaded · 1 already in your library · 2 need attention'],
|
||||
[3, 0, 0, '3 uploaded'],
|
||||
[0, 1, 0, '1 already in your library'],
|
||||
[1, 0, 1, '1 uploaded · 1 need attention'],
|
||||
])('%i added, %i duplicate, %i attention reads "%s"', (added, duplicate, attention, line) => {
|
||||
expect(summaryLine({ added, duplicate, attention })).toBe(line)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeUpload', () => {
|
||||
it('counts settled rows while working', () => {
|
||||
const header = describeUpload(
|
||||
rows('added', 'duplicate', 'rejected', 'failed', 'uploading', 'waiting'),
|
||||
)
|
||||
|
||||
expect(header).toEqual({
|
||||
phase: 'working',
|
||||
title: 'Uploading 6 images',
|
||||
sub: '4 of 6 done',
|
||||
summary: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses singular forms', () => {
|
||||
expect(describeUpload(rows('uploading')).title).toBe('Uploading 1 image')
|
||||
expect(describeUpload(rows('added')).sub).toBe('1 image')
|
||||
})
|
||||
|
||||
it('settles with a summary', () => {
|
||||
const header = describeUpload(rows('added', 'added', 'duplicate', 'rejected', 'failed'))
|
||||
|
||||
expect(header).toEqual({
|
||||
phase: 'settled',
|
||||
title: 'Upload finished',
|
||||
sub: '5 images',
|
||||
summary: '2 uploaded · 1 already in your library · 2 need attention',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads Nothing was uploaded without a summary when nothing was added', () => {
|
||||
expect(describeUpload(rows('rejected', 'rejected', 'rejected'))).toEqual({
|
||||
phase: 'settled',
|
||||
title: 'Nothing was uploaded',
|
||||
sub: '3 files were rejected',
|
||||
summary: null,
|
||||
})
|
||||
expect(describeUpload(rows('rejected', 'failed'))).toMatchObject({
|
||||
title: 'Nothing was uploaded',
|
||||
sub: '2 files need attention',
|
||||
summary: null,
|
||||
})
|
||||
expect(describeUpload(rows('failed'))).toMatchObject({ sub: '1 file needs attention' })
|
||||
expect(describeUpload(rows('rejected'))).toMatchObject({ sub: '1 file was rejected' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rejection copy', () => {
|
||||
it.each([
|
||||
['size', 'scan.tif', 14.2 * 1024 * 1024, 'scan.tif is 14.2 MB'],
|
||||
['size', 'photo.png', 10.1 * 1024 * 1024, 'photo.png is 10.1 MB'],
|
||||
['type', 'notes.pdf', 215040, "notes.pdf isn't an image"],
|
||||
['unreadable', 'broken.png', 3145728, "broken.png couldn't be read"],
|
||||
] as const)('titles a %s rejection of %s', (kind, name, bytes, title) => {
|
||||
expect(rejectionTitle(kind, name, bytes)).toBe(title)
|
||||
})
|
||||
|
||||
it('picks the text per kind', () => {
|
||||
expect(rejectionText('size')).toBe(
|
||||
'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.',
|
||||
)
|
||||
expect(rejectionText('type')).toBe(rejectionText('size'))
|
||||
expect(rejectionText('unreadable')).toBe(
|
||||
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRetryAfter', () => {
|
||||
it('reads whole seconds and falls back to 60', () => {
|
||||
expect(parseRetryAfter('42')).toBe(42)
|
||||
expect(parseRetryAfter(null)).toBe(60)
|
||||
expect(parseRetryAfter('soon')).toBe(60)
|
||||
expect(parseRetryAfter('')).toBe(60)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
const HASH_ALGORITHM = 'SHA-256'
|
||||
const HEX_RADIX = 16
|
||||
const HEX_PAIR_WIDTH = 2
|
||||
|
||||
function readBytes(file: File): Promise<ArrayBuffer> {
|
||||
if (typeof file.arrayBuffer === 'function') {
|
||||
return file.arrayBuffer()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
function toHex(digest: ArrayBuffer) {
|
||||
return Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(HEX_RADIX).padStart(HEX_PAIR_WIDTH, '0'),
|
||||
).join('')
|
||||
}
|
||||
|
||||
export async function hashFile(file: File): Promise<string | null> {
|
||||
const subtle = globalThis.crypto?.subtle
|
||||
if (!subtle) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return toHex(await subtle.digest(HASH_ALGORITHM, await readBytes(file)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,17 @@ export function validateImageFile(file: File): ImageValidationResult {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const EXTENSIONS_BY_MIME_TYPE: Record<string, string[]> = {
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/webp': ['.webp'],
|
||||
'image/svg+xml': ['.svg'],
|
||||
}
|
||||
|
||||
export function extensionMatchesType(file: File) {
|
||||
return EXTENSIONS_BY_MIME_TYPE[file.type]?.includes(fileExtension(file.name)) ?? false
|
||||
}
|
||||
|
||||
export function formatMegabytes(bytes: number) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||
|
||||
export interface DuplicateGroup {
|
||||
hash: string
|
||||
copies: MediaItem[]
|
||||
suggested: MediaItem
|
||||
header: string
|
||||
}
|
||||
|
||||
function byAge(first: MediaItem, second: MediaItem) {
|
||||
return Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id
|
||||
}
|
||||
|
||||
function byPreference(first: MediaItem, second: MediaItem) {
|
||||
return second.used_in - first.used_in || byAge(first, second)
|
||||
}
|
||||
|
||||
export function groupHeader(copies: MediaItem[]) {
|
||||
const [first] = copies
|
||||
return `${copies.length} identical images · ${formatSize(first!.size)} each · ${formatDimensions(first!.width, first!.height)}`
|
||||
}
|
||||
|
||||
export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
|
||||
const byHash = new Map<string, MediaItem[]>()
|
||||
for (const item of items) {
|
||||
byHash.set(item.sha256, [...(byHash.get(item.sha256) ?? []), item])
|
||||
}
|
||||
return [...byHash.entries()]
|
||||
.filter(([, copies]) => copies.length > 1)
|
||||
.map(([hash, copies]) => {
|
||||
const sorted = [...copies].sort(byPreference)
|
||||
return { hash, copies: sorted, suggested: sorted[0]!, header: groupHeader(sorted) }
|
||||
})
|
||||
.sort((first, second) => byAge(first.suggested, second.suggested))
|
||||
}
|
||||
|
||||
export function mergeArticleCount(copies: MediaItem[], keepId: number) {
|
||||
return copies
|
||||
.filter((copy) => copy.id !== keepId)
|
||||
.reduce((total, copy) => total + copy.used_in, 0)
|
||||
}
|
||||
@@ -69,3 +69,10 @@ export function isValidFilenameStem(value: string) {
|
||||
LETTER_OR_NUMBER.test(stem)
|
||||
)
|
||||
}
|
||||
|
||||
export function joinNames(names: string[]) {
|
||||
if (names.length <= 1) {
|
||||
return names.join('')
|
||||
}
|
||||
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
export type MediaFilter = 'all' | 'unused'
|
||||
export type MediaFilter = 'all' | 'unused' | 'duplicates'
|
||||
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
|
||||
export type MediaView = 'grid' | 'list'
|
||||
|
||||
@@ -20,7 +20,7 @@ export const DEFAULT_CONTROLS: MediaControls = {
|
||||
view: 'grid',
|
||||
}
|
||||
|
||||
const FILTERS: MediaFilter[] = ['all', 'unused']
|
||||
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
|
||||
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
|
||||
const VIEWS: MediaView[] = ['grid', 'list']
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { formatMegabytes } from '@/lib/imageUpload'
|
||||
|
||||
export type UploadRowStatus =
|
||||
| 'waiting'
|
||||
| 'uploading'
|
||||
| 'added'
|
||||
| 'duplicate'
|
||||
| 'rejected'
|
||||
| 'failed'
|
||||
export type UploadRejection = 'size' | 'type' | 'unreadable'
|
||||
export type UploadPhase = 'working' | 'settled'
|
||||
|
||||
export interface UploadRowLike {
|
||||
status: UploadRowStatus
|
||||
}
|
||||
|
||||
export interface UploadHeader {
|
||||
phase: UploadPhase
|
||||
title: string
|
||||
sub: string
|
||||
summary: string | null
|
||||
}
|
||||
|
||||
export const REJECTION_TEXT = 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.'
|
||||
export const UNREADABLE_TEXT =
|
||||
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
|
||||
export const FAILED_TITLE = 'Upload failed'
|
||||
export const FAILED_TEXT = "The server didn't accept the file. Nothing was added to your library."
|
||||
export const ADDED_TEXT = 'Added to your library'
|
||||
export const DUPLICATE_TITLE = 'Already in your library'
|
||||
export const DROP_TITLE = 'Drop images to upload'
|
||||
export const DROP_TEXT = 'PNG, JPEG, WEBP, SVG · max 10 MB each'
|
||||
export const RATE_LIMITED_TITLE = 'Too many uploads'
|
||||
export const RATE_LIMITED_TEXT = 'For your security, wait a moment before uploading again.'
|
||||
export const DEFAULT_RETRY_AFTER_SECONDS = 60
|
||||
export const UPLOAD_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml'
|
||||
|
||||
const SETTLED: UploadRowStatus[] = ['added', 'duplicate', 'rejected', 'failed']
|
||||
|
||||
export function isSettled(status: UploadRowStatus) {
|
||||
return SETTLED.includes(status)
|
||||
}
|
||||
|
||||
function imageCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'image' : 'images'}`
|
||||
}
|
||||
|
||||
function fileCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'file' : 'files'}`
|
||||
}
|
||||
|
||||
export function rejectionTitle(kind: UploadRejection, filename: string, bytes: number) {
|
||||
if (kind === 'size') {
|
||||
return `${filename} is ${formatMegabytes(bytes)} MB`
|
||||
}
|
||||
if (kind === 'unreadable') {
|
||||
return `${filename} couldn't be read`
|
||||
}
|
||||
return `${filename} isn't an image`
|
||||
}
|
||||
|
||||
export function rejectionText(kind: UploadRejection) {
|
||||
return kind === 'unreadable' ? UNREADABLE_TEXT : REJECTION_TEXT
|
||||
}
|
||||
|
||||
export function duplicateText(
|
||||
existing: { filename: string; created_at: string; uploaded_by: string },
|
||||
formatDate: (iso: string) => string,
|
||||
) {
|
||||
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
|
||||
return `Same as "${existing.filename}", uploaded ${formatDate(existing.created_at)}${by}.`
|
||||
}
|
||||
|
||||
export function summaryLine(counts: { added: number; duplicate: number; attention: number }) {
|
||||
const parts = [
|
||||
...(counts.added > 0 ? [`${counts.added} uploaded`] : []),
|
||||
...(counts.duplicate > 0 ? [`${counts.duplicate} already in your library`] : []),
|
||||
...(counts.attention > 0 ? [`${counts.attention} need attention`] : []),
|
||||
]
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export function describeUpload(rows: UploadRowLike[]): UploadHeader {
|
||||
const total = rows.length
|
||||
const count = (status: UploadRowStatus) => rows.filter((row) => row.status === status).length
|
||||
const settled = rows.filter((row) => isSettled(row.status)).length
|
||||
if (settled < total) {
|
||||
return {
|
||||
phase: 'working',
|
||||
title: `Uploading ${imageCount(total)}`,
|
||||
sub: `${settled} of ${total} done`,
|
||||
summary: null,
|
||||
}
|
||||
}
|
||||
const added = count('added')
|
||||
const duplicate = count('duplicate')
|
||||
const attention = count('rejected') + count('failed')
|
||||
if (added === 0 && duplicate === 0) {
|
||||
const sub =
|
||||
count('rejected') === total
|
||||
? `${fileCount(total)} ${total === 1 ? 'was' : 'were'} rejected`
|
||||
: `${fileCount(total)} ${total === 1 ? 'needs' : 'need'} attention`
|
||||
return { phase: 'settled', title: 'Nothing was uploaded', sub, summary: null }
|
||||
}
|
||||
return {
|
||||
phase: 'settled',
|
||||
title: 'Upload finished',
|
||||
sub: imageCount(total),
|
||||
summary: summaryLine({ added, duplicate, attention }),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRetryAfter(header: string | null) {
|
||||
if (header === null || !/^\d+$/.test(header.trim())) {
|
||||
return DEFAULT_RETRY_AFTER_SECONDS
|
||||
}
|
||||
return Math.max(1, Number.parseInt(header, 10))
|
||||
}
|
||||
Reference in new issue
Block a user