diff --git a/web/src/lib/components/StepIndicator.svelte b/web/src/lib/components/StepIndicator.svelte new file mode 100644 index 0000000..e421469 --- /dev/null +++ b/web/src/lib/components/StepIndicator.svelte @@ -0,0 +1,102 @@ + + + + + diff --git a/web/src/lib/components/steps/AuthStep.svelte b/web/src/lib/components/steps/AuthStep.svelte new file mode 100644 index 0000000..7cbab51 --- /dev/null +++ b/web/src/lib/components/steps/AuthStep.svelte @@ -0,0 +1,129 @@ + + +
+ +

Sign in to ATProto

+

+ Use your Bluesky handle and an + app password. +

+ +
+ + + + + + + {#if showAdvanced} + + {/if} + + {#if authError} +
{authError}
+ {/if} + + +
+
+ + diff --git a/web/src/lib/components/steps/FilesStep.svelte b/web/src/lib/components/steps/FilesStep.svelte new file mode 100644 index 0000000..c73f1d7 --- /dev/null +++ b/web/src/lib/components/steps/FilesStep.svelte @@ -0,0 +1,180 @@ + + +
+ +

+ Upload your export{needs.lastfm && needs.spotify ? 's' : ''} +

+ +
+ {#if needs.lastfm} +
0} + role="button" + tabindex="0" + aria-label="Upload Last.fm CSV file" + ondragover={(e) => { e.preventDefault(); lfDragging = true; }} + ondragleave={() => (lfDragging = false)} + ondrop={(e) => handleDrop(e, 'lf')} + onclick={() => document.getElementById('lfInput')?.click()} + onkeydown={(e) => e.key === 'Enter' && document.getElementById('lfInput')?.click()} + > + { lastfmFiles = Array.from((e.target as HTMLInputElement).files ?? []); }} + /> + {#if lastfmFiles.length > 0} + + {lastfmFiles[0].name} + {(lastfmFiles[0].size / 1024).toFixed(0)} KB · CSV + {:else} + + Last.fm CSV + Drag & drop or click to select + {/if} +
+ {/if} + + {#if needs.spotify} +
0} + role="button" + tabindex="0" + aria-label="Upload Spotify JSON files" + ondragover={(e) => { e.preventDefault(); spDragging = true; }} + ondragleave={() => (spDragging = false)} + ondrop={(e) => handleDrop(e, 'sp')} + onclick={() => document.getElementById('spInput')?.click()} + onkeydown={(e) => e.key === 'Enter' && document.getElementById('spInput')?.click()} + > + { spotifyFiles = Array.from((e.target as HTMLInputElement).files ?? []); }} + /> + {#if spotifyFiles.length > 0} + + + {spotifyFiles.length === 1 ? spotifyFiles[0].name : `${spotifyFiles.length} files selected`} + + + {spotifyFiles.length === 1 + ? `${(spotifyFiles[0].size / 1024).toFixed(0)} KB · JSON` + : 'JSON · Streaming_History_Audio_*'} + + {:else} + + Spotify JSON + Select one or more JSON files + {/if} +
+ {/if} +
+ +
+ {#if needs.lastfm} +
+ How to export from Last.fm +

+ Go to + last.fm/settings/dataexporter + , request a data export, and download the CSV once ready. +

+
+ {/if} + {#if needs.spotify} +
+ How to export from Spotify +

+ Go to + spotify.com/account/privacy + , request "Extended streaming history", and upload all + Streaming_History_Audio_*.json files. +

+
+ {/if} +
+ + +
+ + diff --git a/web/src/lib/components/steps/ModeStep.svelte b/web/src/lib/components/steps/ModeStep.svelte new file mode 100644 index 0000000..7b553e7 --- /dev/null +++ b/web/src/lib/components/steps/ModeStep.svelte @@ -0,0 +1,59 @@ + + +
+

What would you like to do?

+
+ {#each MODES as m} + + {/each} +
+
+ + diff --git a/web/src/lib/components/steps/OptionsStep.svelte b/web/src/lib/components/steps/OptionsStep.svelte new file mode 100644 index 0000000..814cc15 --- /dev/null +++ b/web/src/lib/components/steps/OptionsStep.svelte @@ -0,0 +1,137 @@ + + +
+ +

Import options

+ +
+
+
+ Dry run + Preview what would be imported without making changes +
+ +
+ + {#if mode !== 'deduplicate'} +
+
+ Reverse order + Process newest records first (default: oldest first) +
+ +
+ +
+
+ Fresh start + Re-fetch existing records instead of using the session cache +
+ +
+ {/if} +
+ + {#if dryRun} +
Dry run enabled — no records will be written to Teal.
+ {/if} + + +
+ + diff --git a/web/src/lib/components/steps/RunStep.svelte b/web/src/lib/components/steps/RunStep.svelte new file mode 100644 index 0000000..f6eb8f0 --- /dev/null +++ b/web/src/lib/components/steps/RunStep.svelte @@ -0,0 +1,209 @@ + + +
+
+

{runTitle}

+ {#if isRunning && !result} + + {/if} +
+ + {#if progress && !result} +
+
+
+

+ {progress.recordsProcessed.toLocaleString()} / {progress.totalRecords.toLocaleString()} records +  ·  batch {progress.batchIndex} · {progress.currentBatchSize} per batch +

+ {/if} + +
+ {#each logs as entry} +
+ {#if entry.level === 'section'} + {entry.message} + {:else} + {new Date(entry.timestamp).toTimeString().slice(0, 8)} + {entry.message} + {/if} +
+ {/each} + {#if isRunning && !result && logs.length === 0} +
Initialising…
+ {/if} +
+ + {#if result} +
+ {#if importError} +

Error

+

{importError}

+ {:else if result.cancelled} +

Stopped

+

+ {#if isDedup} + {result.success.toLocaleString()} duplicate(s) removed before stopping. + {:else} + {result.success.toLocaleString()} record(s) published before stopping. + {/if} +

+ {:else} +

{dryRun ? 'Preview complete' : '✓ Done'}

+

+ {#if isDedup && dryRun} + {result.success.toLocaleString()} duplicate(s) would be removed. + {:else if isDedup} + {result.success.toLocaleString()} duplicate(s) removed. + {:else if dryRun} + {result.success.toLocaleString()} record(s) would be imported. + {:else} + {result.success.toLocaleString()} record(s) published successfully. + {#if result.errors > 0} {result.errors} failed.{/if} + {/if} +

+ {/if} +
+ + {/if} +
+ + diff --git a/web/src/lib/core/import.ts b/web/src/lib/core/import.ts new file mode 100644 index 0000000..9274659 --- /dev/null +++ b/web/src/lib/core/import.ts @@ -0,0 +1,161 @@ +/** + * Import orchestration logic — pure TypeScript, no Svelte deps. + * Handles all five ImportMode flows with progress + cancellation callbacks. + */ + +import type { AtpAgent } from '@atproto/api'; +import type { ImportMode, LogEntry, PlayRecord } from '../types.js'; +import { parseLastFmFile, convertToPlayRecord } from './csv.js'; +import { parseSpotifyFiles, convertSpotifyToPlayRecord } from './spotify.js'; +import { mergePlayRecords, deduplicateInputRecords, sortRecords } from './merge.js'; +import { + fetchExistingRecords, + filterNewRecords, + fetchAllRecordsForDedup, + findDuplicateGroups, + removeDuplicateRecords, +} from './sync.js'; +import { publishRecords, type PublishProgress } from './publisher.js'; + +export type { PublishProgress }; + +export interface ImportOptions { + dryRun: boolean; + reverseOrder: boolean; + fresh: boolean; +} + +export interface ImportResult { + success: number; + errors: number; + cancelled: boolean; +} + +export interface ImportCallbacks { + onLog: (level: LogEntry['level'], message: string) => void; + onProgress: (p: PublishProgress) => void; + isCancelled: () => boolean; +} + +export async function runImport( + agent: AtpAgent, + mode: ImportMode, + lastfmFiles: File[], + spotifyFiles: File[], + { dryRun, reverseOrder, fresh }: ImportOptions, + { onLog, onProgress, isCancelled }: ImportCallbacks, +): Promise { + // Single AbortController for every network call in this run. + // A 50 ms poll drives it from isCancelled so all awaited fetches are cut off + // immediately — even mid-page-fetch — without callers needing to know about it. + const ac = new AbortController(); + const poll = setInterval(() => { if (isCancelled()) ac.abort(); }, 50); + const sig = ac.signal; + + // Wrap every abort error into a clean cancelled result. + const run = async (): Promise => { + // ── Deduplicate mode ───────────────────────────────────────────────────── + if (mode === 'deduplicate') { + onLog('section', '── Deduplication ──────────────────────────────────'); + onLog('info', 'Fetching existing records from Teal…'); + const all = await fetchAllRecordsForDedup( + agent, + (n) => onLog('progress', ` Fetched ${n.toLocaleString()} records…`), + sig, + ); + onLog('success', `Fetched ${all.length.toLocaleString()} records`); + + const groups = findDuplicateGroups(all); + const totalDups = groups.reduce((s, g) => s + g.records.length - 1, 0); + + if (totalDups === 0) { + onLog('success', 'No duplicates found — your records are clean.'); + return { success: 0, errors: 0, cancelled: false }; + } + onLog('warn', `Found ${totalDups.toLocaleString()} duplicate(s) across ${groups.length} groups`); + + if (dryRun) { + onLog('info', `[DRY RUN] Would remove ${totalDups} duplicate record(s).`); + return { success: totalDups, errors: 0, cancelled: false }; + } + + onLog('info', 'Removing duplicates…'); + const removed = await removeDuplicateRecords( + agent, + groups, + (n) => onLog('progress', ` Removed ${n}/${totalDups}…`), + sig, + ); + onLog('success', `Removed ${removed.toLocaleString()} duplicate(s)`); + return { success: removed, errors: 0, cancelled: false }; + } + + // ── Load records ───────────────────────────────────────────────────────── + onLog('section', '── Loading Records ─────────────────────────────────'); + let records: PlayRecord[] = []; + + if (mode === 'combined') { + const lfRaw = await parseLastFmFile(lastfmFiles[0]); + onLog('info', `Last.fm: ${lfRaw.length.toLocaleString()} scrobbles`); + const spRaw = await parseSpotifyFiles(spotifyFiles); + onLog('info', `Spotify: ${spRaw.length.toLocaleString()} tracks`); + const { merged, stats } = mergePlayRecords( + lfRaw.map((r) => convertToPlayRecord(r)), + spRaw.map((r) => convertSpotifyToPlayRecord(r)), + ); + records = merged; + onLog('success', `Merged: ${stats.mergedTotal.toLocaleString()} unique records (${stats.duplicatesRemoved} removed)`); + } else if (mode === 'spotify') { + const spRaw = await parseSpotifyFiles(spotifyFiles); + records = spRaw.map((r) => convertSpotifyToPlayRecord(r)); + onLog('success', `Loaded ${records.length.toLocaleString()} Spotify records`); + } else { + const lfRaw = await parseLastFmFile(lastfmFiles[0]); + records = lfRaw.map((r) => convertToPlayRecord(r)); + onLog('success', `Loaded ${records.length.toLocaleString()} Last.fm records`); + } + + const { unique, duplicates: inputDups } = deduplicateInputRecords(records); + records = unique; + if (inputDups > 0) onLog('warn', `Removed ${inputDups.toLocaleString()} duplicate(s) from input`); + + // ── Sync check ─────────────────────────────────────────────────────────── + onLog('section', '── Sync Check ───────────────────────────────────────'); + onLog('info', 'Fetching existing records from Teal…'); + const existing = await fetchExistingRecords( + agent, + (n) => onLog('progress', ` Fetched ${n.toLocaleString()} existing records…`), + fresh, + sig, + ); + const before = records.length; + records = filterNewRecords(records, existing); + const skipped = before - records.length; + if (skipped > 0) onLog('info', `Skipped ${skipped.toLocaleString()} already-imported record(s)`); + onLog('success', `${records.length.toLocaleString()} new record(s) to import`); + + if (records.length === 0) { + onLog('success', '✓ Nothing to import — all records already exist in Teal!'); + return { success: 0, errors: 0, cancelled: false }; + } + if (mode !== 'combined') records = sortRecords(records, reverseOrder); + + // ── Publish ────────────────────────────────────────────────────────────── + onLog('section', '── Publishing ───────────────────────────────────────'); + const res = await publishRecords(agent, records, dryRun, { + onProgress, + onLog: (level, msg) => onLog(level as LogEntry['level'], msg), + isCancelled, + }); + return { success: res.successCount, errors: res.errorCount, cancelled: res.cancelled }; + }; + + try { + return await run(); + } catch (err: any) { + if (ac.signal.aborted) return { success: 0, errors: 0, cancelled: true }; + throw err; + } finally { + clearInterval(poll); + } +} diff --git a/web/src/lib/core/publisher.ts b/web/src/lib/core/publisher.ts index 80b35f1..a8c1c03 100644 --- a/web/src/lib/core/publisher.ts +++ b/web/src/lib/core/publisher.ts @@ -28,6 +28,18 @@ export interface PublisherCallbacks { isCancelled: () => boolean; } +/** Sleep for up to `ms` milliseconds, waking early if `isCancelled()` becomes true. */ +function cancellableSleep(ms: number, isCancelled: () => boolean): Promise { + return new Promise((resolve) => { + const end = Date.now() + ms; + const tick = () => { + if (isCancelled() || Date.now() >= end) { resolve(); return; } + setTimeout(tick, Math.min(50, end - Date.now())); + }; + tick(); + }); +} + function normalizeResponseHeaders(response: any): Record { const headers: Record = {}; if (response?.headers) { @@ -47,6 +59,10 @@ export async function publishRecords( callbacks: PublisherCallbacks ): Promise<{ successCount: number; errorCount: number; cancelled: boolean }> { const { onProgress, onLog, isCancelled } = callbacks; + + // Abort controller so we can cut off any in-flight fetch the instant Stop is pressed. + const ac = new AbortController(); + const cancelPoll = setInterval(() => { if (isCancelled()) ac.abort(); }, 50); const total = records.length; if (dryRun) { @@ -69,7 +85,7 @@ export async function publishRecords( const startTime = Date.now(); onLog('info', `Publishing ${total.toLocaleString()} records to ATProto…`); - onLog('warn', 'Do not close this tab during import.'); + onLog('warn', 'Do not close this tab while publishing.'); while (i < total) { if (isCancelled()) { @@ -102,13 +118,18 @@ export async function publishRecords( ); const batchPoints = batch.length * POINTS_PER_RECORD; - await rl.waitForPermit(batchPoints); + await rl.waitForPermit(batchPoints, isCancelled); + if (isCancelled()) { + clearInterval(cancelPoll); + onLog('warn', 'Import cancelled by user.'); + return { successCount, errorCount, cancelled: true }; + } try { - const response = await agent.com.atproto.repo.applyWrites({ - repo: agent.session?.did ?? '', - writes: writes as any - }); + const response = await agent.com.atproto.repo.applyWrites( + { repo: agent.session?.did ?? '', writes: writes as any }, + { signal: ac.signal } + ); successCount += response.data.results?.length ?? batch.length; @@ -142,6 +163,11 @@ export async function publishRecords( i += batch.length; } catch (err: any) { + if (ac.signal.aborted || isCancelled()) { + clearInterval(cancelPoll); + onLog('warn', 'Import cancelled by user.'); + return { successCount, errorCount, cancelled: true }; + } if (isRateLimitError(err)) { onLog('warn', '⚠️ Rate limit hit — waiting for quota reset…'); // Extract headers from error if present @@ -160,9 +186,10 @@ export async function publishRecords( } if (i < total) { - await new Promise((r) => setTimeout(r, currentDelay)); + await cancellableSleep(currentDelay, isCancelled); } } + clearInterval(cancelPoll); return { successCount, errorCount, cancelled: false }; } diff --git a/web/src/lib/core/rate-limiter.ts b/web/src/lib/core/rate-limiter.ts index 653a8ce..bf640ac 100644 --- a/web/src/lib/core/rate-limiter.ts +++ b/web/src/lib/core/rate-limiter.ts @@ -59,7 +59,7 @@ export class BrowserRateLimiter { return this.state !== null && this.state.limit > 0; } - async waitForPermit(pointsNeeded: number): Promise { + async waitForPermit(pointsNeeded: number, isCancelled?: () => boolean): Promise { if (!this.state) return; // no state yet — let first request probe const now = Math.floor(Date.now() / 1000); @@ -72,10 +72,17 @@ export class BrowserRateLimiter { const effective = this.state.remaining - headroomPts; if (effective < pointsNeeded) { - // Wait until the window resets - const waitMs = Math.max(0, (this.state.resetAt - Math.floor(Date.now() / 1000)) + 1) * 1000; - await new Promise((r) => setTimeout(r, waitMs)); - if (this.state) { + // Wait until the window resets, but poll for cancellation every 50 ms. + const resetMs = Math.max(0, (this.state.resetAt - Math.floor(Date.now() / 1000)) + 1) * 1000; + const end = Date.now() + resetMs; + await new Promise((resolve) => { + const tick = () => { + if ((isCancelled?.()) || Date.now() >= end) { resolve(); return; } + setTimeout(tick, Math.min(50, end - Date.now())); + }; + tick(); + }); + if (this.state && !isCancelled?.()) { this.state.remaining = this.state.limit; this.state.resetAt = Math.floor(Date.now() / 1000) + this.state.windowSeconds; } diff --git a/web/src/lib/core/sync.ts b/web/src/lib/core/sync.ts index 046189d..9a140b9 100644 --- a/web/src/lib/core/sync.ts +++ b/web/src/lib/core/sync.ts @@ -24,7 +24,8 @@ const sessionCache = new Map>(); export async function fetchExistingRecords( agent: AtpAgent, onProgress?: (fetched: number) => void, - forceRefresh = false + forceRefresh = false, + signal?: AbortSignal ): Promise> { const did = agent.session?.did; if (!did) throw new Error('No authenticated session'); @@ -39,12 +40,11 @@ export async function fetchExistingRecords( let batchSize = 50; do { - const res = await agent.com.atproto.repo.listRecords({ - repo: did, - collection: RECORD_TYPE, - limit: batchSize, - cursor - }); + signal?.throwIfAborted(); + const res = await agent.com.atproto.repo.listRecords( + { repo: did, collection: RECORD_TYPE, limit: batchSize, cursor }, + { signal } + ); for (const rec of res.data.records) { const value = rec.value as unknown as PlayRecord; @@ -56,7 +56,6 @@ export async function fetchExistingRecords( cursor = res.data.cursor; onProgress?.(total); - // Simple adaptive sizing if (res.data.records.length === batchSize && batchSize < 100) { batchSize = Math.min(100, batchSize * 2); } @@ -75,7 +74,8 @@ export function filterNewRecords( export async function fetchAllRecordsForDedup( agent: AtpAgent, - onProgress?: (fetched: number) => void + onProgress?: (fetched: number) => void, + signal?: AbortSignal ): Promise { const did = agent.session?.did; if (!did) throw new Error('No authenticated session'); @@ -85,12 +85,11 @@ export async function fetchAllRecordsForDedup( let batchSize = 50; do { - const res = await agent.com.atproto.repo.listRecords({ - repo: did, - collection: RECORD_TYPE, - limit: batchSize, - cursor - }); + signal?.throwIfAborted(); + const res = await agent.com.atproto.repo.listRecords( + { repo: did, collection: RECORD_TYPE, limit: batchSize, cursor }, + { signal } + ); for (const rec of res.data.records) { const value = rec.value as unknown as PlayRecord; @@ -130,22 +129,28 @@ export function findDuplicateGroups(records: ExistingRecord[]): DedupGroup[] { export async function removeDuplicateRecords( agent: AtpAgent, groups: DedupGroup[], - onProgress?: (removed: number) => void + onProgress?: (removed: number) => void, + signal?: AbortSignal ): Promise { let removed = 0; for (const group of groups) { for (const rec of group.records.slice(1)) { + signal?.throwIfAborted(); try { - await agent.com.atproto.repo.deleteRecord({ - repo: agent.session?.did ?? '', - collection: RECORD_TYPE, - rkey: rec.uri.split('/').pop()! - }); + await agent.com.atproto.repo.deleteRecord( + { repo: agent.session?.did ?? '', collection: RECORD_TYPE, rkey: rec.uri.split('/').pop()! }, + { signal } + ); removed++; onProgress?.(removed); - await new Promise((r) => setTimeout(r, 100)); - } catch { - // continue on individual failures + // Short delay — cancellable via the signal + await new Promise((resolve, reject) => { + const t = setTimeout(resolve, 100); + signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason); }, { once: true }); + }); + } catch (err: any) { + if (signal?.aborted) throw err; // propagate abort + // otherwise continue on individual failures } } } diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts index 34bea06..fb8c421 100644 --- a/web/src/lib/index.ts +++ b/web/src/lib/index.ts @@ -2,3 +2,5 @@ export type { ImportMode, LogEntry, LogLevel, PlayRecord } from './types.js'; export { MODES, modeNeeds, stepLabelsFor } from './modes.js'; export type { ModeConfig, IconComponent } from './modes.js'; +export { runImport } from './core/import.js'; +export type { ImportOptions, ImportResult, ImportCallbacks } from './core/import.js'; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index ce20533..eae909f 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,29 +1,26 @@ @@ -227,31 +126,10 @@

Import Last.fm & Spotify history to ATProto / Teal

- - {#if step > 0 && step < 5} - + {#if step < 5} + {/if} -
{#key step}
- - {#if step === 0} -
-

What would you like to do?

-
- {#each MODES as m} - - {/each} -
- -
- - + + {:else if step === 1} -
- -

Sign in to ATProto

-

- Use your Bluesky handle and an - app password. -

-
- - - - {#if showAdvanced} - - {/if} - {#if authError} -
{authError}
- {/if} - -
-
- - + + {:else if step === 2} -
- -

Upload your export{needs.lastfm && needs.spotify ? 's' : ''}

-
- {#if needs.lastfm} -
0} - role="button" tabindex="0" aria-label="Upload Last.fm CSV file" - ondragover={e => { e.preventDefault(); lfDragging = true; }} - ondragleave={() => (lfDragging = false)} - ondrop={e => handleDrop(e, 'lf')} - onclick={() => document.getElementById('lfInput')?.click()} - onkeydown={e => e.key === 'Enter' && document.getElementById('lfInput')?.click()} - > - { lastfmFiles = Array.from((e.target as HTMLInputElement).files ?? []); }} /> - {#if lastfmFiles.length > 0} - - {lastfmFiles[0].name} - {(lastfmFiles[0].size / 1024).toFixed(0)} KB · CSV - {:else} - - Last.fm CSV - Drag & drop or click to select - {/if} -
- {/if} - {#if needs.spotify} -
0} - role="button" tabindex="0" aria-label="Upload Spotify JSON files" - ondragover={e => { e.preventDefault(); spDragging = true; }} - ondragleave={() => (spDragging = false)} - ondrop={e => handleDrop(e, 'sp')} - onclick={() => document.getElementById('spInput')?.click()} - onkeydown={e => e.key === 'Enter' && document.getElementById('spInput')?.click()} - > - { spotifyFiles = Array.from((e.target as HTMLInputElement).files ?? []); }} /> - {#if spotifyFiles.length > 0} - - {spotifyFiles.length === 1 ? spotifyFiles[0].name : `${spotifyFiles.length} files selected`} - {spotifyFiles.length === 1 ? `${(spotifyFiles[0].size / 1024).toFixed(0)} KB · JSON` : 'JSON · Streaming_History_Audio_*'} - {:else} - - Spotify JSON - Select one or more JSON files - {/if} -
- {/if} -
-
- {#if needs.lastfm} -
- How to export from Last.fm -

Go to last.fm/settings/dataexporter, request a data export, and download the CSV once ready.

-
- {/if} - {#if needs.spotify} -
- How to export from Spotify -

Go to spotify.com/account/privacy, request "Extended streaming history", and upload all Streaming_History_Audio_*.json files.

-
- {/if} -
- -
- - + goTo(3)} + onback={handleBack} + /> + {:else if step === 3} -
- -

Import options

-
-
-
- Dry run - Preview what would be imported without making changes -
- -
- {#if mode !== 'deduplicate'} -
-
- Reverse order - Process newest records first (default: oldest first) -
- -
-
-
- Fresh start - Re-fetch existing records instead of using the session cache -
- -
- {/if} -
- {#if dryRun} -
Dry run enabled — no records will be written to Teal.
- {/if} - -
- - + + {:else if step === 4} -
-
-

{runTitle}

- {#if isRunning && !result} - - {/if} -
- {#if progress && !result} -
-
-
-

- {progress.recordsProcessed.toLocaleString()} / {progress.totalRecords.toLocaleString()} records -  ·  batch {progress.batchIndex} · {progress.currentBatchSize} per batch -

- {/if} -
- {#each logs as entry} -
- {#if entry.level === 'section'} - {entry.message} - {:else} - {new Date(entry.timestamp).toTimeString().slice(0, 8)} - {entry.message} - {/if} -
- {/each} - {#if isRunning && !result && logs.length === 0} -
Initialising…
- {/if} -
- {#if result} -
- {#if importError} -

Error

-

{importError}

- {:else if result.cancelled} -

Stopped

-

{result.success.toLocaleString()} record(s) published before stopping.

- {:else} -

{dryRun ? 'Preview complete' : '✓ Done'}

-

- {#if dryRun}{result.success.toLocaleString()} record(s) would be imported. - {:else}{result.success.toLocaleString()} record(s) published successfully.{#if result.errors > 0} {result.errors} failed.{/if} - {/if} -

- {/if} -
- - {/if} -
+ { cancelled = true; stopping = true; }} + onreset={handleReset} + /> {/if} -
{/key}
+