From e4096e5c264e2b3b9721ba5fee782615d1f78375 Mon Sep 17 00:00:00 2001 From: Ilham Deneri Date: Thu, 5 Nov 2026 23:32:41 +0100 Subject: [PATCH 01/11] feat(ui): add exhaustive refusal copy and keep the recovery status WIP: one switch over every IPC error class gives a what-to-do line per surface (row, confirm step, banner) after the engine's why; errorCopy and rowErrorCopy now use it. The recovery store keeps the whole status and can forget a pending change. Signed-off-by: Ilham Deneri --- src/lib/ipc/errors.test.ts | 56 ++++++--- src/lib/ipc/errors.ts | 34 ++---- src/lib/ipc/refusalCopy.test.ts | 138 +++++++++++++++++++++ src/lib/ipc/refusalCopy.ts | 205 ++++++++++++++++++++++++++++++++ src/state/recovery.ts | 77 +++++++++++- 5 files changed, 470 insertions(+), 40 deletions(-) create mode 100644 src/lib/ipc/refusalCopy.test.ts create mode 100644 src/lib/ipc/refusalCopy.ts diff --git a/src/lib/ipc/errors.test.ts b/src/lib/ipc/errors.test.ts index 44df426..e95c462 100644 --- a/src/lib/ipc/errors.test.ts +++ b/src/lib/ipc/errors.test.ts @@ -8,25 +8,53 @@ describe('rejected commands', () => { it('never turns anything into [object Object]', () => { expect(ipcErrorOf({ unexpected: true }).message).toBe('The command failed.'); - expect(ipcErrorOf(new Error('boom'))).toEqual({ kind: 'internal', message: 'boom', fileId: null }); + expect(ipcErrorOf(new Error('boom'))).toEqual({ + kind: 'internal', + message: 'boom', + fileId: null, + }); expect(ipcErrorOf('plain text').message).toBe('plain text'); expect(ipcErrorOf(undefined).message).not.toContain('[object'); }); - it('words the timing refusals so the user can act on them', () => { - const busy = errorCopy({ kind: 'busy', message: 'raw', fileId: null }); - expect(busy).toMatch(/another job/i); - expect(busy).not.toContain('raw'); - const recovery = errorCopy({ kind: 'recoveryRunning', message: 'raw', fileId: null }); - expect(recovery).toMatch(/crash interrupted/); - expect(recovery).not.toContain('raw'); - expect(errorCopy({ kind: 'corrupt', message: 'truncated', fileId: 1 })).toBe('Truncated'); - expect(errorCopy({ kind: 'settingsChanged', message: 'raw', fileId: null })).toMatch(/Press Export again/); + it('words the timing refusals so the user can act on them, after the engine says why', () => { + const busy = errorCopy({ + kind: 'busy', + message: '/m/a.wav is being exported; try again when that has finished', + fileId: 1, + }); + expect(busy).toBe('/m/a.wav is being exported. Wait for it to finish, then try again.'); + const recovery = errorCopy({ + kind: 'recoveryRunning', + message: 'the check for changes a crash interrupted is still running; nothing was done', + fileId: null, + }); + expect(recovery).toBe( + 'The check for changes a crash interrupted is still running. Nothing was changed. Try again in a moment.', + ); + expect(errorCopy({ kind: 'corrupt', message: 'truncated', fileId: 1 })).toMatch( + /^Truncated\. /, + ); + expect( + errorCopy({ + kind: 'settingsChanged', + message: 'the loudness settings changed after this plan; nothing was done', + fileId: null, + }), + ).toBe( + 'The loudness settings changed after this plan. Nothing was written. Press Export again to see the new numbers.', + ); }); - it('on a file\'s own row, says it of this file rather than its long path', () => { - const error = { kind: 'fileChanged' as const, message: '/music/a.wav changed since you confirmed the export', fileId: 1 }; - expect(rowErrorCopy(error, '/music/a.wav')).toBe('This file changed since you confirmed the export'); - expect(rowErrorCopy(error, '/other.wav')).toBe('/music/a.wav changed since you confirmed the export'); + it("on a file's own row, says it of this file rather than its long path", () => { + const error = { + kind: 'fileChanged' as const, + message: '/music/a.wav changed since you confirmed the export', + fileId: 1, + }; + expect(rowErrorCopy(error, '/music/a.wav')).toBe( + 'This file changed since you confirmed the export. Press Analyse to read it again.', + ); + expect(rowErrorCopy(error, '/other.wav')).toMatch(/^\/music\/a\.wav changed since/); }); }); diff --git a/src/lib/ipc/errors.ts b/src/lib/ipc/errors.ts index 896cc13..a82164f 100644 --- a/src/lib/ipc/errors.ts +++ b/src/lib/ipc/errors.ts @@ -1,4 +1,8 @@ import type { IpcError } from './generated/IpcError'; +import { refusal, type Surface } from './refusalCopy'; + +export { refusal, sentence, SURFACES, surfacesOf, todoFor } from './refusalCopy'; +export type { Refusal, Surface } from './refusalCopy'; function isIpcError(err: unknown): err is IpcError { return ( @@ -12,31 +16,19 @@ function isIpcError(err: unknown): err is IpcError { /** What a rejected command rejected with, as the typed error every Rust command sends. */ export function ipcErrorOf(err: unknown): IpcError { if (isIpcError(err)) return { kind: err.kind, message: err.message, fileId: err.fileId ?? null }; - const message = err instanceof Error ? err.message : typeof err === 'string' ? err : 'The command failed.'; + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'The command failed.'; return { kind: 'internal', message, fileId: null }; } -/** Text from the engine, which words its reasons to follow a colon, as a sentence of its own. */ -export function sentence(text: string): string { - return text.charAt(0).toLocaleUpperCase() + text.slice(1); -} - -/** The refusals that are about timing rather than the file, in words the user can act on. */ -export function errorCopy(error: IpcError): string { - switch (error.kind) { - case 'busy': - return 'Another job is working on one of these files. Wait for it to finish, then try again.'; - case 'recoveryRunning': - return 'SoundCheck is still checking for changes a crash interrupted. Nothing was changed; try again in a moment.'; - case 'settingsChanged': - return 'The loudness settings changed after this summary, so nothing was written. Press Export again to see the new numbers.'; - default: - return sentence(error.message); - } +/** Why, then what to do, as one text: for a line with room for both (`banner` unless said). */ +export function errorCopy(error: IpcError, surface: Surface = 'banner'): string { + const { why, todo } = refusal(error, surface); + return `${why}. ${todo}`; } /** {@link errorCopy} on a file's own row, where its long path would push out what it says. */ -export function rowErrorCopy(error: IpcError, path: string): string { - const copy = errorCopy(error); - return copy.startsWith(path) ? `This file${copy.slice(path.length)}` : copy; +export function rowErrorCopy(error: IpcError, path: string, surface: Surface = 'row'): string { + const { why, todo } = refusal(error, surface, path); + return `${why}. ${todo}`; } diff --git a/src/lib/ipc/refusalCopy.test.ts b/src/lib/ipc/refusalCopy.test.ts new file mode 100644 index 0000000..1651a2e --- /dev/null +++ b/src/lib/ipc/refusalCopy.test.ts @@ -0,0 +1,138 @@ +import type { IpcErrorKind } from './generated/IpcErrorKind'; +import { refusal, SURFACES, surfacesOf, todoFor } from './refusalCopy'; + +/** + * Every class the engine sends, by name. A `Record` over the type, so a class added in Rust (and + * regenerated) fails to compile here until it is listed, and the walk below then covers it. + */ +const KINDS: Record = { + unsupportedFormat: true, + unsupportedChannels: true, + corrupt: true, + io: true, + cancelled: true, + notDjSafe: true, + drmProtected: true, + wouldClip: true, + modelUnavailable: true, + invalidArgument: true, + internal: true, + rekordboxUsbExport: true, + inPlaceRefused: true, + noSpace: true, + verifyFailed: true, + fileChanged: true, + nothingToUndo: true, + alreadyExists: true, + listedTwice: true, + sameOutputName: true, + recoveryRunning: true, + busy: true, + settingsChanged: true, +}; +const ALL = Object.keys(KINDS) as IpcErrorKind[]; + +/** + * Where each class can reach the user: the row of a file an export or a revert failed or refused, + * the confirm step (a file the plan refuses or cannot plan, or the plan itself), and the banner + * (a job that could not start, the XML, a setting refused, the start-up recovery and Forget). + */ +const USED_ON: Record = { + unsupportedFormat: ['row', 'confirm'], + unsupportedChannels: ['row', 'confirm'], + corrupt: ['row', 'confirm', 'banner'], + io: ['row', 'confirm', 'banner'], + cancelled: ['row', 'banner'], + notDjSafe: ['row', 'confirm'], + drmProtected: ['row', 'confirm'], + wouldClip: ['row', 'confirm'], + modelUnavailable: ['row', 'banner'], + invalidArgument: ['row', 'confirm', 'banner'], + internal: ['row', 'confirm', 'banner'], + rekordboxUsbExport: ['row', 'confirm', 'banner'], + inPlaceRefused: ['row', 'confirm'], + noSpace: ['row', 'confirm', 'banner'], + verifyFailed: ['row', 'banner'], + fileChanged: ['row', 'confirm', 'banner'], + nothingToUndo: ['row'], + alreadyExists: ['row', 'confirm', 'banner'], + listedTwice: ['row', 'confirm'], + sameOutputName: ['row', 'confirm'], + recoveryRunning: ['row', 'confirm', 'banner'], + busy: ['row', 'confirm', 'banner'], + settingsChanged: ['confirm', 'banner'], +}; + +describe('refusal copy', () => { + it.each(ALL)('%s has a what-to-do line on every surface it reaches', (kind) => { + const has = surfacesOf(kind); + for (const surface of USED_ON[kind]) { + expect(has, `${kind} on ${surface}`).toContain(surface); + const line = todoFor(kind)[surface] ?? ''; + expect(line.trim().length, `${kind} on ${surface}`).toBeGreaterThan(0); + // A sentence of its own, in sentence case, never shouting or apologising. + expect(line).toMatch(/^[A-Z].*\.$/); + expect(line).not.toMatch(/sorry|oops|please/i); + } + }); + + it.each(ALL)("%s keeps the engine's why and adds its what-to-do, on every surface", (kind) => { + for (const surface of SURFACES) { + const r = refusal({ kind, message: 'something about the file', fileId: 1 }, surface); + expect(r.why).toBe('Something about the file'); + expect(r.todo.length).toBeGreaterThan(0); + } + }); + + it('says it of this file on its own row', () => { + const r = refusal( + { kind: 'verifyFailed', message: '/m/a.aiff did not verify', fileId: 1 }, + 'row', + '/m/a.aiff', + ); + expect(r.why).toBe('This file did not verify'); + }); + + it("takes the engine's own advice where it words one, which is more exact", () => { + const locked = refusal( + { + kind: 'inPlaceRefused', + message: + '/m/a.wav cannot be changed in place: it is locked (Finder: Get Info > Locked). Unlock it, or write a copy to a folder', + fileId: 1, + }, + 'row', + '/m/a.wav', + ); + expect(locked).toEqual({ + why: 'This file cannot be changed in place: it is locked (Finder: Get Info > Locked)', + todo: 'Unlock it, or write a copy to a folder.', + }); + // Without it, the line here. + const bare = refusal( + { kind: 'inPlaceRefused', message: '/m/a.wav cannot be changed in place', fileId: 1 }, + 'confirm', + ); + expect(bare.todo).toBe(todoFor('inPlaceRefused').confirm); + }); + + it('moves the advice out of the why, so it is said once', () => { + const space = refusal( + { + kind: 'noSpace', + message: + 'not enough free space on /Volumes/Stick: 90 bytes needed, 10 free. Free some space or choose another folder', + fileId: null, + }, + 'banner', + ); + expect(space.why).toBe('Not enough free space on /Volumes/Stick: 90 bytes needed, 10 free'); + expect(space.todo).toBe('Free some space or choose another folder.'); + }); + + it('never leaves the why empty', () => { + expect(refusal({ kind: 'internal', message: '', fileId: null }, 'banner').why).toBe( + 'The command failed', + ); + }); +}); diff --git a/src/lib/ipc/refusalCopy.ts b/src/lib/ipc/refusalCopy.ts new file mode 100644 index 0000000..692e545 --- /dev/null +++ b/src/lib/ipc/refusalCopy.ts @@ -0,0 +1,205 @@ +import type { IpcError } from './generated/IpcError'; +import type { IpcErrorKind } from './generated/IpcErrorKind'; + +/** + * What to do about a refused or failed command, for every error class the engine sends and every + * place it can be shown. The engine words what happened and why (`IpcError.message`); this adds + * the step that gets the user going again, worded for where it is read: + * + * - `row`: a file's own row (its Action cell and the details under the table), after an export + * or a revert of that one file; + * - `confirm`: the confirm step, before anything is written, for a file or the whole plan; + * - `banner`: the line above the table, for a job that could not start, the rekordbox XML, a + * setting the engine refused, or the start-up recovery. + */ +export type Surface = 'row' | 'confirm' | 'banner'; + +export const SURFACES: readonly Surface[] = ['row', 'confirm', 'banner']; + +type Todo = Partial>; + +const RETRY = 'Try again; if it keeps happening, restart SoundCheck.'; +const RECONVERT = 'Convert it to AIFF, WAV or FLAC in another app, then add that file.'; + +/** + * The what-to-do line of each class, per surface; a surface missing from a class is one it never + * reaches. One `switch`, so a class added on the Rust side fails to compile here until it has + * its words. + */ +export function todoFor(kind: IpcErrorKind): Todo { + switch (kind) { + case 'unsupportedFormat': + return { row: RECONVERT, confirm: RECONVERT, banner: RECONVERT }; + case 'unsupportedChannels': { + const stereo = 'Bounce a stereo or mono version, then add that file.'; + return { row: stereo, confirm: stereo, banner: stereo }; + } + case 'corrupt': { + const fresh = + 'Get a fresh copy of the file (download or bounce it again), then add that one.'; + return { + row: fresh, + confirm: fresh, + banner: 'Move that file away, or choose another folder.', + }; + } + case 'io': + return { + row: 'Check that the drive is connected and the file opens, then try again.', + confirm: 'Check that the drive is connected, then press Export again.', + banner: + 'Check that the drive is connected and the folder can be written to, then try again.', + }; + case 'cancelled': + return { + row: 'Export it again when you are ready.', + confirm: 'Press Export again when you are ready.', + banner: 'Start it again when you are ready.', + }; + case 'notDjSafe': { + const safe = 'Bounce it at 44.1 or 48 kHz, 16 or 24 bit, then add that file.'; + return { row: safe, confirm: safe, banner: safe }; + } + case 'drmProtected': { + const free = 'Use a copy without DRM, such as the download from the store.'; + return { row: free, confirm: free, banner: free }; + } + case 'wouldClip': + return { + row: 'Lower the target or raise the ceiling, then export it again.', + confirm: 'Lower the target or raise the ceiling, then press Export again.', + banner: 'Lower the target or raise the ceiling, then try again.', + }; + case 'modelUnavailable': { + const reinstall = 'Install SoundCheck again; the beat model comes with it.'; + return { row: reinstall, confirm: reinstall, banner: reinstall }; + } + case 'invalidArgument': + return { row: RETRY, confirm: 'Close this and press Export again.', banner: RETRY }; + case 'internal': + return { row: RETRY, confirm: RETRY, banner: RETRY }; + case 'rekordboxUsbExport': { + const library = 'Process the library copy, then export the USB again from rekordbox.'; + return { row: library, confirm: library, banner: library }; + } + case 'inPlaceRefused': + return { + row: 'Fix that and export it again, or export it To folder.', + confirm: 'Fix that and press Export again, or choose To folder.', + }; + case 'noSpace': { + const room = 'Free some space on that drive, or choose another folder.'; + return { row: room, confirm: room, banner: room }; + } + case 'verifyFailed': + return { + row: 'Export it again; if it fails again, check the drive in Disk Utility.', + confirm: 'Press Export again; if it fails again, check the drive in Disk Utility.', + banner: 'Try again; if it fails again, check the drive in Disk Utility.', + }; + case 'fileChanged': + return { + row: 'Press Analyse to read it again.', + confirm: 'Press Analyse for it, then press Export again.', + banner: 'Press Analyse for the files that changed, then try again.', + }; + case 'nothingToUndo': { + const none = 'Nothing to do: the file is as SoundCheck found it.'; + return { row: none, banner: none }; + } + case 'alreadyExists': { + const other = 'Rename or move that file, or choose another folder.'; + return { row: other, confirm: other, banner: other }; + } + case 'listedTwice': { + const once = 'Nothing to do: it is exported once.'; + return { row: once, confirm: once }; + } + case 'sameOutputName': { + const rename = 'Rename one of them, or choose another folder.'; + return { row: rename, confirm: rename }; + } + case 'recoveryRunning': + return { + row: 'Nothing was changed. Try again once the line above the table has gone.', + confirm: 'Nothing was changed. Press Export again once the line above the table has gone.', + banner: 'Nothing was changed. Try again in a moment.', + }; + case 'busy': + return { + row: 'Wait for it to finish, then try again.', + confirm: 'Wait for it to finish, then press Export again.', + banner: 'Wait for it to finish, then try again.', + }; + case 'settingsChanged': { + const again = 'Nothing was written. Press Export again to see the new numbers.'; + return { confirm: again, banner: again, row: again }; + } + default: { + const unknown: never = kind; + return unknown; + } + } +} + +/** The surfaces a class can be shown on: those it has words for. */ +export function surfacesOf(kind: IpcErrorKind): Surface[] { + const todo = todoFor(kind); + return SURFACES.filter((s) => todo[s] !== undefined); +} + +/** + * The engine's own advice at the end of its message, for the classes that word one: a refusal in + * place says what to change about the file ("Unlock it, or write a copy to a folder"), which is + * more exact than any line written here. It is the last sentence and never names a path. + */ +const ADVISED: ReadonlySet = new Set([ + 'inPlaceRefused', + 'rekordboxUsbExport', + 'noSpace', +]); +const ADVICE = /\.\s+([A-Z][^/]*)$/; + +/** Endings the engine adds that a line here says better, per class. */ +const SAID_HERE: Partial> = { + busy: /;\s*try again when that has finished\.?$/, + recoveryRunning: /;\s*nothing was done\.?$/, + settingsChanged: /;\s*nothing was done\.?$/, +}; + +/** Text from the engine, which words its reasons to follow a colon, as a sentence of its own. */ +export function sentence(text: string): string { + return text.charAt(0).toLocaleUpperCase() + text.slice(1); +} + +function stop(text: string): string { + const t = text.trim(); + return /[.!?…]$/.test(t) ? t : `${t}.`; +} + +export interface Refusal { + /** Why, in the engine's words; a file's own path reads "This file" on its row. */ + why: string; + /** What to do, for this surface. */ + todo: string; +} + +/** The why and the what-to-do of `error` on `surface`; `path`: the row's file. */ +export function refusal(error: IpcError, surface: Surface, path?: string): Refusal { + let why = error.message.trim(); + let advice: string | null = null; + const said = SAID_HERE[error.kind]; + if (said) why = why.replace(said, ''); + if (ADVISED.has(error.kind)) { + const m = ADVICE.exec(why); + if (m?.[1]) { + advice = stop(m[1]); + why = why.slice(0, m.index + 1); + } + } + if (path && why.startsWith(path)) why = `This file${why.slice(path.length)}`; + const todo = todoFor(error.kind); + // A surface a class is not expected on still gets a way forward, never an empty line. + const line = advice ?? todo[surface] ?? todo.banner ?? todo.confirm ?? todo.row ?? RETRY; + return { why: why ? sentence(why).replace(/\.$/, '') : 'The command failed', todo: line }; +} diff --git a/src/state/recovery.ts b/src/state/recovery.ts index 2c45d29..2321413 100644 --- a/src/state/recovery.ts +++ b/src/state/recovery.ts @@ -1,5 +1,12 @@ import { create } from 'zustand'; -import { recoveryStatus } from '@/lib/ipc'; +import { + forgetPending, + ipcErrorOf, + recoveryStatus, + type ForgetResult, + type IpcError, + type RecoveryStatus, +} from '@/lib/ipc'; /** How often the state of the start-up recovery is read while it runs. */ const POLL_MS = 1000; @@ -7,9 +14,37 @@ const POLL_MS = 1000; interface RecoveryState { /** The check for changes a crash interrupted is running: export, Revert and XML wait for it. */ running: boolean; + /** What it found, as the engine last said; `null` until it has said. */ + status: RecoveryStatus | null; + /** The banner line about finished changes, or a failed check, was closed. */ + dismissed: boolean; + /** The change being forgotten now, by journal id. */ + forgetting: string | null; + /** Why the last Forget was refused, with the change it was for. */ + forgetError: { txn: string; error: IpcError } | null; + /** What the last Forget kept, for the line that says where it is. */ + forgotten: ForgetResult | null; } -export const useRecovery = create()(() => ({ running: false })); +const INITIAL: RecoveryState = { + running: false, + status: null, + dismissed: false, + forgetting: null, + forgetError: null, + forgotten: null, +}; + +export const useRecovery = create()(() => INITIAL); + +/** Back to before the app asked (tests). */ +export function resetRecovery(): void { + useRecovery.setState(INITIAL, true); +} + +function setStatus(status: RecoveryStatus): void { + useRecovery.setState({ status, running: status.state === 'running' }); +} /** Follows the start-up recovery until it has ended. Returns a stop function. */ export function startRecoveryWatch(): () => void { @@ -19,9 +54,13 @@ export function startRecoveryWatch(): () => void { recoveryStatus() .then((status) => { if (stopped) return; - const running = status?.state === 'running'; - useRecovery.setState({ running }); - if (running) timer = setTimeout(read, POLL_MS); + // Nothing known yet (an engine that does not answer this): as if it ended clean. + if (!status) { + useRecovery.setState({ running: false }); + return; + } + setStatus(status); + if (status.state === 'running') timer = setTimeout(read, POLL_MS); }) .catch(() => { // Not known: the engine still refuses what it must, with its own words. @@ -34,3 +73,31 @@ export function startRecoveryWatch(): () => void { clearTimeout(timer); }; } + +/** + * Gives up on a change recovery left pending: the engine ends it in its journal, deletes nothing, + * and answers with the report without it, so its row goes. + */ +export async function forgetChange(txn: string): Promise { + if (useRecovery.getState().forgetting !== null) return; + useRecovery.setState({ forgetting: txn, forgetError: null }); + try { + const result = await forgetPending(txn); + setStatus(result.status); + useRecovery.setState({ forgotten: result }); + } catch (err) { + useRecovery.setState({ forgetError: { txn, error: ipcErrorOf(err) } }); + } finally { + useRecovery.setState({ forgetting: null }); + } +} + +/** Closes the line about finished changes or a failed check. */ +export function dismissRecovery(): void { + useRecovery.setState({ dismissed: true, forgotten: null }); +} + +/** Closes the line about what a Forget kept. */ +export function dismissForgotten(): void { + useRecovery.setState({ forgotten: null }); +} From 0b3e57657d00b6f27a898122b0d38b5ad7c6462f Mon Sep 17 00:00:00 2001 From: Ilham Deneri Date: Thu, 5 Nov 2026 23:47:53 +0100 Subject: [PATCH 02/11] feat(ui): notices and recovery banner (work in progress) Stale loudness tags, Serato Autotags and FLAC re-analysis notices on written rows, the confirm step and the footer; the start-up recovery banner with a Forget confirm step. Signed-off-by: Ilham Deneri --- src/App.tsx | 10 +- .../export/ExportConfirm.notices.test.tsx | 86 ++++++++ src/features/export/ExportConfirm.test.tsx | 7 +- src/features/export/ExportConfirm.tsx | 14 +- src/features/export/ExportDetail.test.tsx | 75 ++++++- src/features/export/ExportDetail.tsx | 27 ++- src/features/export/cells.test.tsx | 8 +- src/features/export/flow.ts | 2 +- src/features/export/notices.test.ts | 93 +++++++++ src/features/export/notices.ts | 151 ++++++++++++++ src/features/export/text.ts | 6 + src/features/library/Footer.test.tsx | 23 ++- src/features/library/Footer.tsx | 7 +- src/features/library/cells.tsx | 24 ++- src/features/recovery/ForgetConfirm.tsx | 45 ++++ src/features/recovery/RecoveryBanner.tsx | 193 ++++++++++++++++++ src/features/recovery/text.ts | 84 ++++++++ 17 files changed, 824 insertions(+), 31 deletions(-) create mode 100644 src/features/export/ExportConfirm.notices.test.tsx create mode 100644 src/features/export/notices.test.ts create mode 100644 src/features/export/notices.ts create mode 100644 src/features/recovery/ForgetConfirm.tsx create mode 100644 src/features/recovery/RecoveryBanner.tsx create mode 100644 src/features/recovery/text.ts diff --git a/src/App.tsx b/src/App.tsx index bd4114a..1d1283c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,8 @@ import { startSettingsSync } from '@/features/settings/sync'; import { ExportConfirm } from '@/features/export/ExportConfirm'; import { ExportDetail } from '@/features/export/ExportDetail'; import { useExportFlow } from '@/features/export/flow'; -import { startRecoveryWatch } from '@/state/recovery'; +import { startRecoveryWatch, useRecovery } from '@/state/recovery'; +import { RecoveryBanner } from '@/features/recovery/RecoveryBanner'; /** Keys for the review loop: arrows move, N jumps to the next row that needs a look. */ @@ -34,6 +35,8 @@ function useKeys() { if (useTrack.getState().fileId !== null) return; // The confirm step, while open, has the keys to itself. if (useExportFlow.getState().phase !== 'closed') return; + // So does the step that asks before forgetting a change. + if (useRecovery.getState().asking) return; const panels = usePanels.getState(); if (e.metaKey && e.key === ',') { // Settings: the drawer in a narrow window, else the rail's first control. @@ -154,6 +157,7 @@ function Library() {
+ {notice && (
{notice} @@ -161,12 +165,12 @@ function Library() { )} {aborted && (
- {abortedKind === 'export' ? 'Export' : 'Analysis'} could not start: {errorCopy(aborted)} + {abortedKind === 'export' ? 'Export' : 'Analysis'} could not start: {errorCopy(aborted, 'banner')}
)} {artefactsFailed && (
- The rekordbox XML and the grid report could not be written: {errorCopy(artefactsFailed)} + The rekordbox XML and the grid report could not be written: {errorCopy(artefactsFailed, 'banner')} {exported && ' The exported files are fine.'}
)} diff --git a/src/features/export/ExportConfirm.notices.test.tsx b/src/features/export/ExportConfirm.notices.test.tsx new file mode 100644 index 0000000..be7703c --- /dev/null +++ b/src/features/export/ExportConfirm.notices.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import type { ExportPreview, FileEntry } from '@/lib/ipc'; +import { pipeline } from '@/features/pipeline/actions'; +import { useLibrary } from '@/state/library'; +import { resetRecovery } from '@/state/recovery'; +import { useSettings } from '@/state/settings'; +import { analysed, entry, exportPreview, exportRow, previewRow } from '@/test/fixtures'; +import { ExportConfirm } from './ExportConfirm'; +import { openExportConfirm, useExportFlow } from './flow'; + +const initialLibrary = useLibrary.getState(); +const initialSettings = useSettings.getState(); + +function wav(fileId: number): FileEntry { + const e = entry(fileId); + return { ...e, path: `/music/Track ${fileId}.wav`, info: { ...e.info, codec: 'wav' } }; +} + +function add(...entries: FileEntry[]) { + useLibrary.getState().add(entries); + for (const e of entries) useLibrary.getState().applyEvent(analysed(e.fileId)); +} + +function backend(answer: ExportPreview) { + mockIPC((cmd) => (cmd === 'plan_export' ? answer : undefined)); +} + +const xmlOnly = exportRow({ + stage: 'skipped', + action: { type: 'xmlOnly', reason: { type: 'gridOnlyFlac' } }, + positionsShift: false, + notes: ['grid only on FLAC'], +}); + +beforeEach(() => { + useLibrary.setState(initialLibrary, true); + useSettings.setState({ ...initialSettings, place: 'inPlace' }, true); + resetRecovery(); + useExportFlow.setState({ + phase: 'closed', + request: null, + preview: null, + error: null, + leftOut: 0, + differing: [], + along: [], + planned: [], + again: false, + stays: '', + offer: '', + }); + pipeline().send({ type: 'FINISHED' }); +}); + +describe('the confirm step, about rekordbox and FLAC', () => { + it('says once, for the batch, that written FLAC files need analysing in rekordbox', async () => { + add(entry(1), entry(2), wav(3)); + backend( + exportPreview([ + previewRow(1, exportRow()), + previewRow(2, exportRow()), + previewRow(3, exportRow()), + ]), + ); + render(); + void openExportConfirm(); + const said = await screen.findAllByText( + /in rekordbox once they are written, before playing them from a CDJ/, + ); + expect(said).toHaveLength(1); + expect(said[0]).toHaveTextContent(/^Analyse the 2 FLAC files/); + expect(said[0]).toHaveTextContent( + /can put rekordbox's own beat grid in place of the exported one/, + ); + }); + + it('says nothing about it when no FLAC is written', async () => { + add(entry(1), wav(2)); + backend(exportPreview([previewRow(1, xmlOnly), previewRow(2, exportRow())])); + render(); + void openExportConfirm(); + await screen.findByRole('button', { name: 'Write 1 file' }); + expect(screen.queryByText(/FLAC files? in rekordbox/)).toBeNull(); + }); +}); diff --git a/src/features/export/ExportConfirm.test.tsx b/src/features/export/ExportConfirm.test.tsx index 91969c1..afcbf38 100644 --- a/src/features/export/ExportConfirm.test.tsx +++ b/src/features/export/ExportConfirm.test.tsx @@ -260,11 +260,14 @@ describe('the confirm step', () => { it('a refusal shows the real copy, not [object Object], with no way to export', async () => { add(1); - backend({ throws: { kind: 'busy', message: 'raw', fileId: 1 } }); + const message = '/music/Track 1.flac is being reverted; try again when that has finished'; + backend({ throws: { kind: 'busy', message, fileId: 1 } }); render(); void openExportConfirm(); const alert = await screen.findByRole('alert'); - expect(alert).toHaveTextContent('Another job is working on one of these files'); + expect(alert).toHaveTextContent( + '/music/Track 1.flac is being reverted. Wait for it to finish, then press Export again.', + ); expect(alert).not.toHaveTextContent('[object'); expect(screen.queryByRole('button', { name: /^(Write|Export)/ })).toBeNull(); await userEvent.click(screen.getByRole('button', { name: 'Close' })); diff --git a/src/features/export/ExportConfirm.tsx b/src/features/export/ExportConfirm.tsx index ef390cd..f2b9b1f 100644 --- a/src/features/export/ExportConfirm.tsx +++ b/src/features/export/ExportConfirm.tsx @@ -1,12 +1,13 @@ import { Loader2 } from 'lucide-react'; import { useSelector } from '@xstate/react'; import { Modal } from '@/components/Modal'; -import { rowErrorCopy, sentence, type DecideSettings, type ExportPreviewRow, type ExportRequest } from '@/lib/ipc'; +import { errorCopy, rowErrorCopy, sentence, type DecideSettings, type ExportPreviewRow, type ExportRequest } from '@/lib/ipc'; import { displayTitle, level } from '@/lib/format'; import { pipeline } from '@/features/pipeline/actions'; import { Toggle } from '@/features/settings/controls'; import { useLibrary } from '@/state/library'; import { confirmExport, dismissExportConfirm, setExportAgain, useExportFlow } from './flow'; +import { flacConfirmText, flacWrites } from './notices'; import { countPreview, isRefusal, shortPath, summaryLine, type ExportCounts } from './summary'; const buttonClass = @@ -44,6 +45,14 @@ function CutWarning({ counts }: { counts: ExportCounts }) { ); } +/** Said once for the batch: rekordbox analyses a written FLAC again, before a CDJ plays it well. */ +function FlacNotice({ rows: planned }: { rows: ExportPreviewRow[] }) { + const count = useLibrary((l) => flacWrites(planned, l.rows)); + const text = flacConfirmText(count); + if (!text) return null; + return

{text}

; +} + /** Each file the step has something to say about: why it is skipped, or what to know about it. */ function Particulars({ rows: planned }: { rows: ExportPreviewRow[] }) { const rows = useLibrary((l) => l.rows); @@ -51,7 +60,7 @@ function Particulars({ rows: planned }: { rows: ExportPreviewRow[] }) { const entry = rows[fileId]?.entry; const name = entry ? displayTitle(entry.info, entry.path) : `Track ${fileId}`; if (error) { - const text = entry ? rowErrorCopy(error, entry.path) : sentence(error.message); + const text = entry ? rowErrorCopy(error, entry.path, 'confirm') : errorCopy(error, 'confirm'); return [{ fileId, name, head: isRefusal(error) ? 'Refused' : 'Cannot be planned', text }]; } if (!row) return []; @@ -116,6 +125,7 @@ export function ExportConfirm() {

{settingsLine(request, preview.decide)}

+ {counts.replacing > 0 && (

{counts.replacing === 1 diff --git a/src/features/export/ExportDetail.test.tsx b/src/features/export/ExportDetail.test.tsx index 9cbe9dd..2aaa962 100644 --- a/src/features/export/ExportDetail.test.tsx +++ b/src/features/export/ExportDetail.test.tsx @@ -3,11 +3,19 @@ import userEvent from '@testing-library/user-event'; import { mockIPC } from '@tauri-apps/api/mocks'; import { useLibrary, type Row } from '@/state/library'; import { useRecovery } from '@/state/recovery'; -import { analysis, doneRow, entry, exportRow, plan } from '@/test/fixtures'; +import type { Codec } from '@/lib/ipc'; +import type { SeratoTag } from '@/lib/ipc/generated/SeratoTag'; +import { analysis, doneRow, entry, exportRow, plan, verification } from '@/test/fixtures'; import { ExportDetail } from './ExportDetail'; const initial = useLibrary.getState(); +/** Track 1 as another format, with Serato data when given. */ +function track(codec: Codec, seratoTags: SeratoTag[] = []) { + const e = entry(1); + return { ...e, path: `/music/Track 1.${codec}`, info: { ...e.info, codec, serato: seratoTags.length > 0, seratoTags } }; +} + function show(overrides: Partial) { useLibrary.setState({ rows: { @@ -60,11 +68,66 @@ describe('the row details', () => { expect(screen.getByText(/no loudness to align/)).toBeInTheDocument(); }); - it('a failure: what, why, and that the file was not changed', () => { - show({ state: 'failed', exportError: { kind: 'busy', message: 'raw', fileId: 1 } }); + it("a failure in three lines: what happened, why in the engine's words, what to do", () => { + const message = '/music/Track 1.flac is being reverted; try again when that has finished'; + show({ state: 'failed', exportError: { kind: 'busy', message, fileId: 1 } }); + expect(screen.getByText('Failed: the file was not changed')).toBeInTheDocument(); + expect(screen.getByText('This file is being reverted.')).toBeInTheDocument(); + expect(screen.getByText('Wait for it to finish, then try again.')).toBeInTheDocument(); + }); + + it("a refusal says so, with the engine's own advice", () => { + const message = + '/music/Track 1.flac cannot be changed in place: it is locked (Finder: Get Info > Locked). Unlock it, or write a copy to a folder'; + show({ state: 'failed', exportError: { kind: 'inPlaceRefused', message, fileId: 1 } }); + expect(screen.getByText('Refused: the file was not changed')).toBeInTheDocument(); + expect(screen.getByText(/^This file cannot be changed in place: it is locked/)).toBeInTheDocument(); + expect(screen.getByText('Unlock it, or write a copy to a folder.')).toBeInTheDocument(); + }); + + it('a file that changed on disk: what to do is Analyse', () => { + const message = '/music/Track 1.flac changed since you confirmed the export'; + show({ state: 'failed', changedOnDisk: true, exportError: { kind: 'fileChanged', message, fileId: 1 } }); expect(screen.getByText('Failed')).toBeInTheDocument(); - expect(screen.getByText(/Another job is working/)).toBeInTheDocument(); - expect(screen.getByText('The file was not changed.')).toBeInTheDocument(); + expect(screen.getByText('Press Analyse to read it again.')).toBeInTheDocument(); + }); + + it('names the loudness tags a gain left at the old level, in plain words', () => { + const stale = ['TXXX:REPLAYGAIN_ALBUM_GAIN', 'TXXX:REPLAYGAIN_ALBUM_PEAK', 'RVA2', 'COMM:iTunNORM']; + show({ entry: track('aiff'), export: doneRow({ verification: verification({ staleLoudnessTags: stale }) }) }); + expect( + screen.getByText( + 'Album ReplayGain, RVA2 and iTunNORM were left as they are; players that use them will play this file at the old level.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/Serato's Auto Gain/)).toBeNull(); + expect(screen.queryByText(/Analyse this FLAC/)).toBeNull(); + }); + + it("says Serato's Auto Gain is out of date only when the file has Autotags and its gain changed", () => { + const tagged = track('wav', ['markers', 'autotags']); + const stale = verification({ staleLoudnessTags: ['GEOB:Serato Autotags'] }); + const { unmount } = show({ entry: tagged, export: doneRow({ verification: stale }) }); + expect(screen.getByText(/^Serato's Auto Gain for this file was measured at the old level/)).toBeInTheDocument(); + // Said on a line of its own, not among the other tags. + expect(screen.queryByText(/were left as they are|was left as it is/)).toBeNull(); + unmount(); + // No gain change: the engine lists nothing, and nothing is said. + const same = doneRow({ action: { type: 'write', gainDb: 0, cut: { type: 'library' } } }); + const { unmount: gone } = show({ entry: tagged, export: same }); + expect(screen.queryByText(/Serato's Auto Gain/)).toBeNull(); + gone(); + // No Autotags in the file: nothing either. + show({ entry: track('wav', ['markers']), export: doneRow() }); + expect(screen.queryByText(/Serato's Auto Gain/)).toBeNull(); + }); + + it('tells a written FLAC to be analysed in rekordbox, and only once it is written', () => { + const { unmount } = show({ export: doneRow() }); + expect(screen.getByText(/^Analyse this FLAC in rekordbox before playing it from a CDJ/)).toBeInTheDocument(); + unmount(); + show({ state: 'exportQueued', export: exportRow() }); + expect(screen.queryByText(/Analyse this FLAC/)).toBeNull(); }); it('every note the row carries', () => { @@ -83,7 +146,7 @@ describe('the row details', () => { revertible: true, revertError: { kind: 'fileChanged', message: 'it changed since it was written', fileId: 1 }, }); - expect(screen.getByText('It changed since it was written')).toHaveClass('text-err'); + expect(screen.getByText(/^It changed since it was written\. Press Analyse/)).toHaveClass('text-err'); }); it('Revert is disabled for a file in the running batch, with why', () => { diff --git a/src/features/export/ExportDetail.tsx b/src/features/export/ExportDetail.tsx index d55f24e..ceb41ca 100644 --- a/src/features/export/ExportDetail.tsx +++ b/src/features/export/ExportDetail.tsx @@ -1,9 +1,11 @@ import { useLibrary, isLocked, type Row } from '@/state/library'; -import { rowErrorCopy, sentence } from '@/lib/ipc'; +import { refusal, rowErrorCopy, sentence } from '@/lib/ipc'; import { useRecovery } from '@/state/recovery'; import { revert, revertBlock } from './actions'; import { GridCheckBadge } from './ExportCells'; import { gridCheckChip } from './gridCheck'; +import { isRefusal } from './summary'; +import { AUTOTAGS_TEXT, autotagsStale, FLAC_ROW_TEXT, flacWritten, rowStaleTagsText } from './notices'; import { exportActionText, startText, verifiedText } from './text'; import { exportDifference, useExportNow, type ExportNow } from './work'; @@ -16,11 +18,15 @@ function Told({ row, now }: { row: Row; now: ExportNow }) { const shown = row.export; const different = exportDifference(row, now)?.text; if (row.state === 'failed' && row.exportError) { + // What happened, why (the engine's words), what to do. + const { why, todo } = refusal(row.exportError, 'row', row.entry.path); return ( <> - Failed - {rowErrorCopy(row.exportError, row.entry.path)} - {row.changedOnDisk ? 'Press Analyse to read it again.' : 'The file was not changed.'} + + {`${isRefusal(row.exportError) ? 'Refused' : 'Failed'}${row.changedOnDisk ? '' : ': the file was not changed'}`} + + {`${why}.`} + {todo} ); } @@ -54,6 +60,19 @@ function Told({ row, now }: { row: Row; now: ExportNow }) { {shown.notes.map((note) => ( {note} ))} + + + ); +} + +/** What to know about the written file in the DJ apps: tags at the old level, Serato, rekordbox. */ +function Notices({ row }: { row: Row }) { + const stale = rowStaleTagsText(row); + return ( + <> + {stale && {stale}} + {autotagsStale(row) && {AUTOTAGS_TEXT}} + {flacWritten(row) && {FLAC_ROW_TEXT}} ); } diff --git a/src/features/export/cells.test.tsx b/src/features/export/cells.test.tsx index 229280b..0214d16 100644 --- a/src/features/export/cells.test.tsx +++ b/src/features/export/cells.test.tsx @@ -110,7 +110,7 @@ describe('the Action column of an export', () => { } }); - it('a failed export says why and that the file was not changed, in red', () => { + it('a failed export says why, in red, and what to do', () => { render( { />, ); expect(screen.getByText('No space left')).toHaveClass('text-err'); - expect(screen.getByText('The file was not changed')).toBeInTheDocument(); + expect(screen.getByText('Free some space on that drive, or choose another folder')).toBeInTheDocument(); }); it('a row an export in place would refuse says why, once the confirm step is closed', () => { @@ -128,8 +128,8 @@ describe('the Action column of an export', () => { ]); const refused = useLibrary.getState().rows[1] as Row; render(); - expect(screen.getByText(/is a symbolic link/)).toBeInTheDocument(); - expect(screen.getByText('The file was not changed')).toBeInTheDocument(); + expect(screen.getByText('/music/Track 1.wav is a symbolic link')).toBeInTheDocument(); + expect(screen.getByText('Fix that and export it again, or export it To folder')).toBeInTheDocument(); }); it('before any export it still shows the plan', () => { diff --git a/src/features/export/flow.ts b/src/features/export/flow.ts index 7ffc402..335bbc7 100644 --- a/src/features/export/flow.ts +++ b/src/features/export/flow.ts @@ -163,7 +163,7 @@ async function planRows(mine: number): Promise { const refused = rows.flatMap(({ fileId, error }) => (error && isRefusal(error) ? [{ fileId, error }] : [])); if (refused.length > 0) useLibrary.getState().markRefused(refused); } catch (err) { - if (mine === attempt) useExportFlow.setState({ phase: 'failed', error: errorCopy(ipcErrorOf(err)) }); + if (mine === attempt) useExportFlow.setState({ phase: 'failed', error: errorCopy(ipcErrorOf(err), 'confirm') }); } } diff --git a/src/features/export/notices.test.ts b/src/features/export/notices.test.ts new file mode 100644 index 0000000..2fb7f1e --- /dev/null +++ b/src/features/export/notices.test.ts @@ -0,0 +1,93 @@ +import type { Row } from '@/state/library'; +import { analysis, doneRow, entry, exportRow, plan, verification } from '@/test/fixtures'; +import { autotagsStale, flacConfirmText, hasStaleTags, staleTags, staleTagsText } from './notices'; + +function row(overrides: Partial): Row { + return { + entry: entry(1), + state: 'done', + progress: 1, + analysis: analysis(), + plan: plan(), + ...overrides, + }; +} + +describe('loudness tags left at the old level', () => { + it('names each kind once, as a DJ knows it, in the order of the file', () => { + const labels = [ + 'TXXX:REPLAYGAIN_ALBUM_GAIN', + 'TXXX:REPLAYGAIN_ALBUM_PEAK', + 'RVA2', + 'COMM:iTunNORM', + 'replaygain_track_gain', + 'R128_ALBUM_GAIN', + 'ITUNNORM', + ]; + expect(staleTags(labels).names).toEqual([ + 'Album ReplayGain', + 'RVA2', + 'iTunNORM', + 'Track ReplayGain', + 'Album R128 gain', + ]); + }); + + it('reads one tag in the singular', () => { + expect(staleTagsText(['RVA2'])).toBe( + 'RVA2 was left as it is; players that use it will play this file at the old level.', + ); + }); + + it("keeps Serato's for its own notice", () => { + expect(staleTags(['GEOB:Serato Autotags']).autotags).toBe(true); + expect(staleTags(['SERATO_AUTOGAIN']).autotags).toBe(true); + expect(staleTagsText(['GEOB:Serato Autotags', 'SERATO_AUTOTAGS'])).toBeNull(); + }); + + it('says there are more when the engine cut the list short', () => { + expect(staleTagsText(['RVA2', 'TXXX:REPLAYGAIN_AL...'])).toBe( + 'RVA2 and other loudness tags were left as they are; players that use them will play this file at the old level.', + ); + }); + + it('says nothing when there are none', () => { + expect(staleTagsText([])).toBeNull(); + }); + + it('counts a finished export only, for the footer', () => { + const stale = verification({ staleLoudnessTags: ['RVA2'] }); + expect(hasStaleTags(row({ export: doneRow({ verification: stale }) }))).toBe(true); + expect(hasStaleTags(row({ export: doneRow() }))).toBe(false); + expect( + hasStaleTags(row({ export: exportRow({ stage: 'writing', verification: stale }) })), + ).toBe(false); + }); +}); + +describe("Serato's auto gain", () => { + const tagged = { + ...entry(1), + info: { ...entry(1).info, serato: true, seratoTags: ['autotags' as const] }, + }; + + it('is out of date once a gain was written to a file that has Autotags', () => { + expect(autotagsStale(row({ entry: tagged, export: doneRow() }))).toBe(true); + }); + + it('is not, before the file is written, without a gain, or without Autotags', () => { + expect(autotagsStale(row({ entry: tagged, export: exportRow() }))).toBe(false); + const same = doneRow({ action: { type: 'write', gainDb: 0, cut: { type: 'library' } } }); + expect(autotagsStale(row({ entry: tagged, export: same }))).toBe(false); + expect(autotagsStale(row({ export: doneRow() }))).toBe(false); + }); +}); + +describe('the FLAC notice of the confirm step', () => { + it('counts the files, and is not there for none', () => { + expect(flacConfirmText(0)).toBeNull(); + expect(flacConfirmText(1)).toMatch( + /^Analyse the 1 FLAC file in rekordbox once it is written, before playing it/, + ); + }); +}); diff --git a/src/features/export/notices.ts b/src/features/export/notices.ts new file mode 100644 index 0000000..0d01ab6 --- /dev/null +++ b/src/features/export/notices.ts @@ -0,0 +1,151 @@ +import type { ExportPreviewRow } from '@/lib/ipc'; +import type { Row } from '@/state/library'; + +/** + * What a DJ should know about a file once it is written, beyond what changed in it: loudness tags + * the gain left at the old level, Serato's auto gain, and rekordbox analysing a FLAC again. All of + * it comes from what the engine sent for the row (the verified write, its stale tags) and what it + * read of the file when it was added (its codec and Serato data). + */ + +/** How the engine marks a list item it had to cut short to stay within the row's budget. */ +const CUT_SHORT = '...'; + +/** A tag label as the engine reports it (`TXXX:REPLAYGAIN_ALBUM_GAIN`, `RVA2`, `SERATO_AUTOGAIN`). */ +function bare(label: string): string { + const colon = label.indexOf(':'); + return (colon >= 0 && colon <= 4 ? label.slice(colon + 1) : label).toUpperCase(); +} + +function isAutotags(label: string): boolean { + const name = bare(label); + return name === 'SERATO AUTOTAGS' || name === 'SERATO_AUTOTAGS' || name === 'SERATO_AUTOGAIN'; +} + +/** The name a DJ knows a loudness tag by; `null` for Serato's, which has a notice of its own. */ +function plainName(label: string): string | null { + if (isAutotags(label)) return null; + const name = bare(label); + if (name.startsWith('REPLAYGAIN_TRACK_')) return 'Track ReplayGain'; + if (name.startsWith('REPLAYGAIN_ALBUM_')) return 'Album ReplayGain'; + if (name.startsWith('REPLAYGAIN_')) return 'ReplayGain'; + if (name.startsWith('R128_TRACK_')) return 'Track R128 gain'; + if (name.startsWith('R128_ALBUM_')) return 'Album R128 gain'; + if (name.startsWith('R128_')) return 'R128 gain'; + if (name === 'RVA2') return 'RVA2'; + if (name === 'ITUNNORM') return 'iTunNORM'; + return label; +} + +function list(names: string[]): string { + if (names.length <= 1) return names[0] ?? ''; + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1] ?? ''}`; +} + +export interface StaleTags { + /** Plain names, each once, in the file's order. */ + names: string[]; + /** The engine cut the list short: there are more than named. */ + more: boolean; + /** Serato Autotags is among them. */ + autotags: boolean; +} + +/** The loudness tags a written file kept at the old level, by name. */ +export function staleTags(labels: readonly string[]): StaleTags { + const names: string[] = []; + let more = false; + let autotags = false; + for (const label of labels) { + if (label.endsWith(CUT_SHORT)) { + more = true; + continue; + } + if (isAutotags(label)) autotags = true; + const name = plainName(label); + if (name && !names.includes(name)) names.push(name); + } + return { names, more, autotags }; +} + +/** + * "Album ReplayGain, RVA2 and iTunNORM were left as they are; players that use them will play this + * file at the old level." `null` when there is none (Serato's has its own line). + */ +export function staleTagsText(labels: readonly string[]): string | null { + const { names, more } = staleTags(labels); + if (names.length === 0 && !more) return null; + const named = more + ? [...names, names.length > 0 ? 'other loudness tags' : 'some loudness tags'] + : names; + const many = named.length > 1 || more; + return `${list(named)} ${many ? 'were left as they are' : 'was left as it is'}; players that use ${many ? 'them' : 'it'} will play this file at the old level.`; +} + +/** Words for Serato's auto gain gone stale, as Serato names its settings. */ +export const AUTOTAGS_TEXT = + "Serato's Auto Gain for this file was measured at the old level: in Serato, analyse it again with Set Auto Gain on, or turn Auto Gain off."; + +/** The written file's change, when it was written: the gain the engine applied. */ +function writtenGain(row: Row): number | null { + const shown = row.export; + if (!shown?.verification || shown.action.type !== 'write') return null; + return shown.action.gainDb; +} + +/** + * The file holds Serato Autotags and its gain changed: Serato's auto gain now describes the old + * level. The engine lists the Autotags among the stale tags of exactly such a file; the Serato data + * read when the file was added says the same when that list was cut short. + */ +export function autotagsStale(row: Row): boolean { + const gain = writtenGain(row); + if (gain === null) return false; + const stale = row.export?.verification?.staleLoudnessTags ?? []; + if (staleTags(stale).autotags) return true; + return gain !== 0 && row.entry.info.seratoTags.includes('autotags'); +} + +/** The stale tags line of a written row, Serato's aside. */ +export function rowStaleTagsText(row: Row): string | null { + return writtenGain(row) === null + ? null + : staleTagsText(row.export?.verification?.staleLoudnessTags ?? []); +} + +/** Whether the row's finished export kept loudness tags at the old level, Serato's included. */ +export function hasStaleTags(row: Row): boolean { + if (row.export?.stage !== 'done') return false; + return rowStaleTagsText(row) !== null || autotagsStale(row); +} + +/** + * rekordbox keeps a seek index of each FLAC it has analysed, mapping positions to where they sit in + * the file's frames; a written FLAC's frames are new, so the index no longer matches until + * rekordbox analyses the file again, and that analysis makes its own beat grid. + */ +export const FLAC_ROW_TEXT = + "Analyse this FLAC in rekordbox before playing it from a CDJ: rekordbox keeps an index of where each part of a FLAC sits, and writing it moved them, so until then the track can skip. That analysis can put rekordbox's own beat grid in place of the one written here."; + +/** The row holds a FLAC SoundCheck wrote. */ +export function flacWritten(row: Row): boolean { + return row.entry.info.codec === 'flac' && row.export?.verification != null; +} + +/** The confirm step's line when the batch writes FLAC files; `null` when it writes none. */ +export function flacConfirmText(count: number): string | null { + if (count === 0) return null; + const files = count === 1 ? '1 FLAC file' : `${count} FLAC files`; + const them = count === 1 ? 'it' : 'them'; + return `Analyse the ${files} in rekordbox once ${count === 1 ? 'it is' : 'they are'} written, before playing ${them} from a CDJ: rekordbox keeps an index of where each part of a FLAC sits, and writing moves them, so until then a track can skip. That analysis can put rekordbox's own beat grid in place of the exported one.`; +} + +/** How many of the planned rows write a FLAC. */ +export function flacWrites( + planned: readonly ExportPreviewRow[], + rows: Record, +): number { + return planned.filter( + (p) => p.row?.action.type === 'write' && rows[p.fileId]?.entry.info.codec === 'flac', + ).length; +} diff --git a/src/features/export/text.ts b/src/features/export/text.ts index 151ef9b..c2b3d11 100644 --- a/src/features/export/text.ts +++ b/src/features/export/text.ts @@ -94,6 +94,12 @@ export function lastExportText(last: LastExport): string { return `Last export: ${parts.join(', ')}${time}${end}`; } +/** The footer's word on files that kept loudness tags at the old level; `null` for none. */ +export function staleTagsSummary(files: number): string | null { + if (files === 0) return null; + return `${files} ${files === 1 ? 'keeps' : 'keep'} loudness tags at the old level`; +} + /** What the XML holds, said plainly: an empty one still is a file on disk. */ export function xmlListedText(artefacts: ExportArtefacts, leftOut: number): string { if (artefacts.listed === 0) return 'The rekordbox XML lists no tracks: none has a grid it may carry'; diff --git a/src/features/library/Footer.test.tsx b/src/features/library/Footer.test.tsx index ed4170e..3a65dc3 100644 --- a/src/features/library/Footer.test.tsx +++ b/src/features/library/Footer.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { mockIPC } from '@tauri-apps/api/mocks'; import { useLibrary } from '@/state/library'; import { useRecovery } from '@/state/recovery'; -import { analysed, artefacts, doneRow, entry } from '@/test/fixtures'; +import { analysed, artefacts, doneRow, entry, verification } from '@/test/fixtures'; import { Footer } from './Footer'; const initial = useLibrary.getState(); @@ -64,6 +64,27 @@ describe('the footer', () => { expect(JSON.stringify(shown[0])).toContain('/exports/2026-11-05'); }); + it('adds to the last export how many files kept loudness tags at the old level', () => { + lib().add([entry(1), entry(2)]); + lib().applyEvent(analysed(1)); + lib().applyEvent(analysed(2)); + lib().beginExport([1, 2], []); + const stale = doneRow({ verification: verification({ staleLoudnessTags: ['REPLAYGAIN_ALBUM_GAIN'] }) }); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 1, row: stale, inPlace: false, update: null }); + lib().applyEvent({ type: 'exportDone', jobId: 3, fileId: 2, row: doneRow(), inPlace: false, update: null }); + lib().applyEvent({ type: 'finished', jobId: 3, cancelled: false }); + render(