diff --git a/src/main/MainApp.ts b/src/main/MainApp.ts index 9e8240f..d4cfeb5 100644 --- a/src/main/MainApp.ts +++ b/src/main/MainApp.ts @@ -47,16 +47,22 @@ export class MainApp { this.searchService.rebuildIndex(); this.registerIpcHandlers(); - this.createMenu(); + await this.createMenu(); this.createWindow(); } /** * Creates the application menu. */ - private createMenu(): void { + private async createMenu(): Promise { const isMac = process.platform === 'darwin'; + const drives = await this.listAvailableDrives(); + const driveSubmenu: Electron.MenuItemConstructorOptions[] = drives.map((drive) => ({ + label: drive.label, + click: () => this.mainWindow?.webContents.send('import-from-drive', drive.path) + })); + const template: Electron.MenuItemConstructorOptions[] = [ ...(isMac ? [{ label: app.name, @@ -79,6 +85,16 @@ export class MainApp { { label: 'File', submenu: [ + { + label: 'Import From Folder...', + accelerator: isMac ? 'Cmd+Shift+I' : 'Ctrl+Shift+I', + click: () => this.mainWindow?.webContents.send('import-from-folder') + }, + { + label: 'Import From Drive', + submenu: driveSubmenu.length > 0 ? driveSubmenu : [{ label: 'No drives available', enabled: false }] + }, + { type: 'separator' as const }, { label: 'Rescan Library', accelerator: isMac ? 'Cmd+R' : 'Ctrl+R', @@ -132,6 +148,8 @@ export class MainApp { ipcMain.removeHandler(IPC_CHANNELS.settingsGet); ipcMain.removeHandler(IPC_CHANNELS.settingsSetLibrary); ipcMain.removeHandler(IPC_CHANNELS.dialogSelectLibrary); + ipcMain.removeHandler(IPC_CHANNELS.dialogSelectImportFolder); + ipcMain.removeHandler(IPC_CHANNELS.systemListDrives); ipcMain.removeHandler(IPC_CHANNELS.libraryScan); ipcMain.removeHandler(IPC_CHANNELS.libraryList); ipcMain.removeHandler(IPC_CHANNELS.libraryRename); @@ -143,6 +161,7 @@ export class MainApp { ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata); ipcMain.removeHandler(IPC_CHANNELS.librarySplit); ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview); + ipcMain.removeHandler(IPC_CHANNELS.libraryImport); ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate); ipcMain.removeHandler(IPC_CHANNELS.categoriesList); ipcMain.removeHandler(IPC_CHANNELS.searchQuery); @@ -215,7 +234,7 @@ export class MainApp { }); ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => { - const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] }; + const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] }; const targetWindow = this.mainWindow; const result = targetWindow ? await dialog.showOpenDialog(targetWindow, options) @@ -223,6 +242,17 @@ export class MainApp { return result.canceled ? null : result.filePaths[0] ?? null; }); + ipcMain.handle(IPC_CHANNELS.dialogSelectImportFolder, async () => { + const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] }; + const targetWindow = this.mainWindow; + const result = targetWindow + ? await dialog.showOpenDialog(targetWindow, options) + : await dialog.showOpenDialog(options); + return result.canceled ? null : result.filePaths[0] ?? null; + }); + + ipcMain.handle(IPC_CHANNELS.systemListDrives, async () => this.listAvailableDrives()); + ipcMain.handle(IPC_CHANNELS.libraryScan, async () => this.requireLibrary().scanLibrary()); ipcMain.handle(IPC_CHANNELS.libraryList, async () => this.requireLibrary().listFiles()); ipcMain.handle(IPC_CHANNELS.libraryGetById, async (_event: IpcMainInvokeEvent, fileId: number) => @@ -306,6 +336,19 @@ export class MainApp { ) => this.requireLibrary().updateFileMetadata(fileId, metadata) ); + + ipcMain.handle(IPC_CHANNELS.libraryImport, async (_event: IpcMainInvokeEvent, payload: unknown) => { + if (!Array.isArray(payload)) { + throw new Error('Import request must provide an array of source paths.'); + } + const sources = payload + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry) => entry.length > 0); + if (sources.length === 0) { + return { imported: [], skipped: [], failed: [] }; + } + return this.requireLibrary().importExternalSources(sources); + }); } /** @@ -360,6 +403,86 @@ export class MainApp { return app.getAppPath(); } + private async listAvailableDrives(): Promise> { + if (process.platform === 'win32') { + try { + const { exec } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execAsync = promisify(exec); + + // Query Windows Management Instrumentation for drive info + const { stdout } = await execAsync('wmic logicaldisk get deviceid,drivetype', { + timeout: 5000, + windowsHide: true + }); + + const lines = stdout.trim().split('\n').slice(1); // Skip header + const drives: Array<{ path: string; type: string }> = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Parse "C: 3" format (DeviceID and DriveType) + const match = trimmed.match(/^([A-Z]:)\s+(\d+)$/); + if (!match) continue; + + const [, deviceId, driveType] = match; + const drivePath = `${deviceId}\\`; + + // Verify drive is actually accessible before including it + try { + await fs.promises.access(drivePath); + } catch { + continue; // Skip if not accessible (unplugged/ejected) + } + + // DriveType: 2=Removable, 3=Local Fixed, 4=Network, 5=CD-ROM, 6=RAM Disk + let label = drivePath; + if (driveType === '2') { + label = `${drivePath} (Removable)`; + } + + drives.push({ path: drivePath, type: label }); + } + + return drives.sort((a, b) => a.path.localeCompare(b.path)).map((d) => ({ path: d.path, label: d.type })); + } catch (error) { + console.warn('Failed to query drive types via wmic, falling back to simple enumeration', error); + // Fallback to basic enumeration + const drives: { path: string; label: string }[] = []; + const probes: Promise[] = []; + for (let code = 65; code <= 90; code += 1) { + const letter = String.fromCharCode(code); + const drivePath = `${letter}:\\`; + const probe = fs.promises + .access(drivePath) + .then(() => { + drives.push({ path: drivePath, label: drivePath }); + }) + .catch(() => undefined); + probes.push(probe); + } + await Promise.all(probes); + return drives.sort((a, b) => a.path.localeCompare(b.path)); + } + } + + if (process.platform === 'darwin') { + try { + const entries = await fs.promises.readdir('/Volumes', { withFileTypes: true }); + const volumes = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ path: path.join('/Volumes', entry.name), label: path.join('/Volumes', entry.name) })); + return volumes.length > 0 ? volumes : [{ path: '/', label: '/' }]; + } catch { + return [{ path: '/', label: '/' }]; + } + } + + return [{ path: '/', label: '/' }]; + } + private buildSearchBases(): string[] { const bases = new Set(); const appPath = app.getAppPath(); diff --git a/src/main/services/LibraryService.ts b/src/main/services/LibraryService.ts index 5ce0d42..33fe30e 100644 --- a/src/main/services/LibraryService.ts +++ b/src/main/services/LibraryService.ts @@ -4,7 +4,18 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import fg from 'fast-glob'; import { parse } from 'csv-parse/sync'; -import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, SplitSegmentRequest, TagUpdatePayload } from '../../shared/models'; +import { + AppSettings, + AudioBufferPayload, + AudioFileSummary, + CategoryRecord, + ImportFailureEntry, + ImportSkipEntry, + LibraryImportResult, + LibraryScanSummary, + SplitSegmentRequest, + TagUpdatePayload +} from '../../shared/models'; import { DatabaseService, FileRecordInput } from './DatabaseService'; import { SettingsService } from './SettingsService'; import { TagService } from './TagService'; @@ -292,6 +303,138 @@ export class LibraryService { return cleanedCount; } + /** + * Imports external WAV files from the provided sources, copying them into the library while + * skipping duplicates based on the audio checksum. Imported files land under `_Imports/`. + */ + public async importExternalSources(sourcePaths: string[]): Promise { + // eslint-disable-next-line no-console -- Log import start for debugging. + console.log('Starting import from:', sourcePaths); + + if (!Array.isArray(sourcePaths) || sourcePaths.length === 0) { + return { imported: [], skipped: [], failed: [] }; + } + + const libraryRoot = this.settings.ensureLibraryPath(); + const normalisedLibraryRoot = this.normalizeAbsolutePath(libraryRoot); + const libraryRootWithSlash = normalisedLibraryRoot.endsWith('/') + ? normalisedLibraryRoot + : `${normalisedLibraryRoot}/`; + + const existingFiles = this.database.listFiles(); + const knownChecksums = new Set(); + const knownPaths = new Set(); + for (const file of existingFiles) { + if (typeof file.checksum === 'string' && file.checksum.length > 0) { + knownChecksums.add(file.checksum); + } + knownPaths.add(this.normalizeAbsolutePath(file.absolutePath)); + } + + const importFolderRelativeBase = path.join('_Imports', new Date().toISOString().slice(0, 10)); + const importFolderAbsolute = path.join(libraryRoot, importFolderRelativeBase); + await fs.mkdir(importFolderAbsolute, { recursive: true }); + + const { files: candidateFiles, failures } = await this.collectImportCandidates(sourcePaths); + + // eslint-disable-next-line no-console -- Log discovered files for debugging. + console.log(`Found ${candidateFiles.length} candidate files, ${failures.length} collection failures`); + if (failures.length > 0) { + // eslint-disable-next-line no-console -- Log collection failures for debugging. + console.log('Collection failures:', failures); + } + + const imported: AudioFileSummary[] = []; + const skipped: ImportSkipEntry[] = []; + const failed: ImportFailureEntry[] = [...failures]; + const usedNames = new Set(); + const allowedExtensions = new Set(['.wav', '.wave']); + + const folderRelativeNormalised = this.normalizeRelativePath( + path.relative(libraryRoot, importFolderAbsolute) + ); + const folderForJoin = + folderRelativeNormalised === '.' || folderRelativeNormalised.length === 0 + ? '' + : folderRelativeNormalised; + + for (const candidate of candidateFiles) { + const extension = path.extname(candidate).toLowerCase(); + if (!allowedExtensions.has(extension)) { + // eslint-disable-next-line no-console -- Log skip reason for debugging. + console.log(`Skipping ${candidate}: unsupported extension ${extension}`); + skipped.push({ path: candidate, reason: 'unsupported' }); + continue; + } + + const normalisedCandidate = this.normalizeAbsolutePath(candidate); + if ( + normalisedCandidate === normalisedLibraryRoot || + normalisedCandidate.startsWith(libraryRootWithSlash) + ) { + // eslint-disable-next-line no-console -- Log skip reason for debugging. + console.log(`Skipping ${candidate}: already inside library`); + skipped.push({ path: candidate, reason: 'inside-library' }); + continue; + } + + if (knownPaths.has(normalisedCandidate)) { + // eslint-disable-next-line no-console -- Log skip reason for debugging. + console.log(`Skipping ${candidate}: duplicate path`); + skipped.push({ path: candidate, reason: 'duplicate' }); + continue; + } + + // eslint-disable-next-line no-console -- Log checksum computation for debugging. + console.log(`Computing checksum for ${candidate}...`); + const checksum = await this.computeFileChecksum(candidate); + if (!checksum) { + // eslint-disable-next-line no-console -- Log skip reason for debugging. + console.log(`Skipping ${candidate}: checksum computation failed`); + skipped.push({ path: candidate, reason: 'checksum' }); + continue; + } + + if (knownChecksums.has(checksum)) { + // eslint-disable-next-line no-console -- Log skip reason for debugging. + console.log(`Skipping ${candidate}: duplicate checksum ${checksum}`); + skipped.push({ path: candidate, reason: 'duplicate' }); + continue; + } + + // eslint-disable-next-line no-console -- Log import attempt for debugging. + console.log(`Attempting to import ${candidate} with checksum ${checksum}...`); + try { + const record = await this.copyAndRegisterImportedFile({ + sourcePath: candidate, + checksum, + importFolderAbsolute, + folderForJoin, + libraryRoot, + usedNames + }); + imported.push(record); + knownChecksums.add(checksum); + knownPaths.add(this.normalizeAbsolutePath(record.absolutePath)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + // eslint-disable-next-line no-console -- Log import failures for debugging. + console.error(`Failed to import ${candidate}:`, errorMessage); + failed.push({ + path: candidate, + message: errorMessage + }); + } + } + + if (imported.length > 0) { + this.resetMetadataSuggestionsCache(); + this.search.rebuildIndex(); + } + + return { imported, skipped, failed }; + } + /** * Renames a file while keeping it in the current directory. */ @@ -1352,6 +1495,142 @@ export class LibraryService { return value.replace(/\\/g, '/'); } + /** + * Gathers candidate audio files from the provided sources, handling both folders and individual files. + */ + private async collectImportCandidates(sourcePaths: string[]): Promise<{ + files: string[]; + failures: ImportFailureEntry[]; + }> { + const discovered = new Set(); + const failures: ImportFailureEntry[] = []; + const uniqueSources = Array.from( + new Set((sourcePaths ?? []).map((entry) => entry?.trim()).filter((entry): entry is string => Boolean(entry))) + ); + + for (const rawSource of uniqueSources) { + const absoluteSource = path.resolve(rawSource); + try { + const stats = await fs.stat(absoluteSource); + if (stats.isDirectory()) { + try { + const matches = await fg(['**/*.wav', '**/*.wave'], { + cwd: absoluteSource, + absolute: true, + onlyFiles: true, + suppressErrors: true, + caseSensitiveMatch: false + }); + for (const match of matches) { + discovered.add(path.resolve(match)); + } + } catch (error) { + failures.push({ + path: absoluteSource, + message: error instanceof Error ? error.message : String(error) + }); + } + } else if (stats.isFile()) { + discovered.add(absoluteSource); + } else { + failures.push({ path: absoluteSource, message: 'Unsupported file system entry.' }); + } + } catch (error) { + failures.push({ + path: absoluteSource, + message: error instanceof Error ? error.message : String(error) + }); + } + } + + const files = Array.from(discovered).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + return { files, failures }; + } + + /** + * Copies a single source file into the library and registers it in the database, returning the stored record. + */ + private async copyAndRegisterImportedFile(options: { + sourcePath: string; + checksum: string; + importFolderAbsolute: string; + folderForJoin: string; + libraryRoot: string; + usedNames: Set; + }): Promise { + const { sourcePath, checksum, importFolderAbsolute, folderForJoin, libraryRoot, usedNames } = options; + const extension = path.extname(sourcePath); + const baseName = path.basename(sourcePath, extension); + let sanitizedBase = this.organization.sanitizeCustomName(baseName); + if (!sanitizedBase) { + sanitizedBase = 'Imported'; + } + + let attempt = 0; + let candidateName: string = ''; + let candidateAbsolutePath: string = ''; + while (true) { + if (attempt === 0) { + candidateName = this.normaliseFileName(`${sanitizedBase}.wav`); + } else { + const suffix = this.organization.formatSequenceNumber(attempt); + candidateName = this.normaliseFileName(`${sanitizedBase}_${suffix}.wav`); + } + + if (!usedNames.has(candidateName)) { + candidateAbsolutePath = path.join(importFolderAbsolute, candidateName); + const exists = await this.pathExists(candidateAbsolutePath); + if (!exists) { + break; + } + } + + attempt += 1; + if (attempt > 9999) { + throw new Error('Unable to allocate a unique filename for the imported file.'); + } + } + + usedNames.add(candidateName); + this.assertWithinLibrary(libraryRoot, candidateAbsolutePath); + + await fs.copyFile(sourcePath, candidateAbsolutePath); + + try { + const stats = await fs.stat(candidateAbsolutePath); + const metadata = await this.extractAudioMetadata(candidateAbsolutePath); + const relativePath = this.toLibraryRelativePath(folderForJoin, candidateName); + const record = this.database.upsertFile({ + absolutePath: candidateAbsolutePath, + relativePath, + fileName: candidateName, + displayName: path.basename(candidateName, path.extname(candidateName)), + modifiedAt: stats.mtimeMs, + createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs, + size: stats.size, + durationMs: metadata.durationMs, + sampleRate: metadata.sampleRate, + bitDepth: metadata.bitDepth, + checksum, + tags: metadata.tags, + categories: metadata.categories + }); + + try { + const embedded = this.tagService.readMetadata(candidateAbsolutePath); + this.updateMetadataSuggestionsCache(embedded.author ?? null); + } catch (metadataError) { + // eslint-disable-next-line no-console -- Import should continue even if metadata read fails. + console.warn('Failed to read metadata from imported file', metadataError); + } + + return record; + } catch (error) { + await fs.rm(candidateAbsolutePath, { force: true }).catch(() => undefined); + throw error; + } + } + /** * Resolves and caches the music-metadata parseFile implementation. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 66af901..b143a10 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import type { AudioBufferPayload, AudioFileSummary, CategoryRecord, + LibraryImportResult, LibraryScanSummary, SplitSegmentRequest, TagUpdatePayload @@ -26,9 +27,18 @@ const api: RendererApi = { async rescanLibrary(): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryScan); }, + async selectImportFolder(): Promise { + return ipcRenderer.invoke(IPC_CHANNELS.dialogSelectImportFolder); + }, + async listSystemDrives(): Promise { + return ipcRenderer.invoke(IPC_CHANNELS.systemListDrives); + }, async listAudioFiles(): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryList); }, + async importExternalSources(paths: string[]): Promise { + return ipcRenderer.invoke(IPC_CHANNELS.libraryImport, paths); + }, /** Retrieves a single audio file summary by id, returning null when the record no longer exists. */ async getAudioFileById(fileId: number): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryGetById, fileId); @@ -87,8 +97,8 @@ const api: RendererApi = { ): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata); }, - onMenuAction(channel: string, callback: () => void): () => void { - const listener = () => callback(); + onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void { + const listener = (_event: Electron.IpcRendererEvent, payload?: unknown) => callback(payload); ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener(channel, listener); } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9e82964..584ed72 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type JSX } from 'react'; +import { useCallback, useEffect, useMemo, useState, type JSX } from 'react'; import FileList from './components/FileList'; import FileDetailPanel from './components/FileDetailPanel'; import MultiFileEditor from './components/MultiFileEditor'; @@ -10,7 +10,7 @@ import EditModePanel from './components/edit/EditModePanel'; import { useLibrarySnapshot } from './hooks/useLibrarySnapshot'; import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot'; import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore'; -import type { AudioFileSummary } from '../../shared/models'; +import type { AudioFileSummary, LibraryImportResult } from '../../shared/models'; type RightPanelTab = 'listen' | 'edit'; @@ -25,14 +25,18 @@ function App(): JSX.Element { const [showStatusMessage, setShowStatusMessage] = useState(false); const [statusFadingOut, setStatusFadingOut] = useState(false); const [activeTab, setActiveTab] = useState('listen'); + const [importMessage, setImportMessage] = useState(null); const statusMessage = useMemo(() => { + if (importMessage) { + return importMessage; + } if (!library.lastScan) { return null; } const { added, updated, removed } = library.lastScan; return `Scan complete: +${added} updated ${updated} removed ${removed}`; - }, [library.lastScan]); + }, [importMessage, library.lastScan]); useEffect(() => { if (statusMessage) { @@ -44,6 +48,7 @@ function App(): JSX.Element { const hideTimer = setTimeout(() => { setShowStatusMessage(false); setStatusFadingOut(false); + setImportMessage((current) => (current === statusMessage ? null : current)); }, 5000); return () => { clearTimeout(fadeTimer); @@ -52,18 +57,6 @@ function App(): JSX.Element { } }, [statusMessage]); - useEffect(() => { - const cleanup1 = window.api.onMenuAction('open-settings', () => setSettingsOpen(true)); - const cleanup2 = window.api.onMenuAction('rescan-library', handleRescan); - const cleanup3 = window.api.onMenuAction('find-duplicates', handleFindDuplicates); - - return () => { - cleanup1(); - cleanup2(); - cleanup3(); - }; - }, []); - const selectedFile = useMemo( () => library.files.find((file) => file.id === library.selectedFileId) ?? null, [library.files, library.selectedFileId] @@ -155,14 +148,82 @@ function App(): JSX.Element { void libraryStore.search(value); }; - const handleRescan = async () => { + const handleRescan = useCallback(async () => { await libraryStore.rescan(); - }; + }, []); - const handleFindDuplicates = async () => { + const handleFindDuplicates = useCallback(async () => { const duplicates = await window.api.listDuplicates(); setDuplicateGroups(duplicates); - }; + }, []); + + const summariseImportResult = useCallback((result: LibraryImportResult): string => { + const parts: string[] = [`${result.imported.length} added`]; + if (result.skipped.length > 0) { + parts.push(`${result.skipped.length} skipped`); + } + if (result.failed.length > 0) { + parts.push(`${result.failed.length} failed`); + } + return `Import complete: ${parts.join(', ')}`; + }, []); + + const notifyImportSuccess = useCallback((result: LibraryImportResult) => { + setImportMessage(summariseImportResult(result)); + }, [summariseImportResult]); + + const notifyImportFailure = useCallback(() => { + setImportMessage('Import failed. Check logs for details.'); + }, []); + + const handleImportFromFolder = useCallback(async () => { + try { + const result = await libraryStore.importFromFolder(); + if (!result) { + return; + } + notifyImportSuccess(result); + } catch (error) { + console.error('Import failed', error); + notifyImportFailure(); + } + }, [notifyImportFailure, notifyImportSuccess]); + + const handleImportFromDrive = useCallback(async (drive: string) => { + try { + const result = await libraryStore.importFromDrive(drive); + notifyImportSuccess(result); + } catch (error) { + console.error('Import failed', error); + notifyImportFailure(); + } + }, [notifyImportFailure, notifyImportSuccess]); + + useEffect(() => { + const cleanup1 = window.api.onMenuAction('open-settings', () => setSettingsOpen(true)); + const cleanup2 = window.api.onMenuAction('rescan-library', () => { + void handleRescan(); + }); + const cleanup3 = window.api.onMenuAction('find-duplicates', () => { + void handleFindDuplicates(); + }); + const cleanup4 = window.api.onMenuAction('import-from-folder', () => { + void handleImportFromFolder(); + }); + const cleanup5 = window.api.onMenuAction('import-from-drive', (drive) => { + if (typeof drive === 'string') { + void handleImportFromDrive(drive); + } + }); + + return () => { + cleanup1(); + cleanup2(); + cleanup3(); + cleanup4(); + cleanup5(); + }; + }, [handleFindDuplicates, handleImportFromFolder, handleImportFromDrive, handleRescan]); const handleKeepDuplicate = async (fileIdToKeep: number, fileIdsToDelete: number[]) => { await window.api.deleteFiles(fileIdsToDelete); @@ -211,7 +272,7 @@ function App(): JSX.Element { if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } - }, 100); + }, 5000); }; const handleUpdateCustomName = async (customName: string | null) => { diff --git a/src/renderer/src/components/DrivePickerDialog.tsx b/src/renderer/src/components/DrivePickerDialog.tsx new file mode 100644 index 0000000..8d88414 --- /dev/null +++ b/src/renderer/src/components/DrivePickerDialog.tsx @@ -0,0 +1,78 @@ +import type { JSX } from 'react'; + +export interface DrivePickerDialogProps { + drives: string[]; + loading: boolean; + importing: boolean; + error: string | null; + onRefresh(): void; + onSelect(drivePath: string): void; + onClose(): void; +} + +/** + * Modal dialog that lists available system drives for import selection. + */ +export function DrivePickerDialog({ + drives, + loading, + importing, + error, + onRefresh, + onSelect, + onClose +}: DrivePickerDialogProps): JSX.Element { + const handleBackdropClick = () => { + if (!importing && !loading) { + onClose(); + } + }; + + return ( +
+
event.stopPropagation()}> +
+

Import From Drive

+

Select a drive to copy new WAV files into your library.

+
+ + {error ?
{error}
: null} + +
+ {loading ? ( +

Loading available drives…

+ ) : drives.length === 0 ? ( +

No drives were detected.

+ ) : ( +
    + {drives.map((drive) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ + +
+
+
+ ); +} + +export default DrivePickerDialog; diff --git a/src/renderer/src/stores/LibraryStore.ts b/src/renderer/src/stores/LibraryStore.ts index d9412d8..5dfe11f 100644 --- a/src/renderer/src/stores/LibraryStore.ts +++ b/src/renderer/src/stores/LibraryStore.ts @@ -2,6 +2,7 @@ import type { AppSettings, AudioFileSummary, CategoryRecord, + LibraryImportResult, LibraryScanSummary, SplitSegmentRequest, TagUpdatePayload @@ -60,8 +61,8 @@ export class LibraryStore extends EventTarget { window.api.listCategories(), window.api.listAudioFiles() ]); - const firstFileId = files.at(0)?.id ?? null; - const firstFile = files.at(0) ?? null; + const firstFileId = files[0]?.id ?? null; + const firstFile = files[0] ?? null; const nextVersion = this.snapshot.metadataSuggestionsVersion + 1; this.snapshot = { initialized: true, @@ -252,7 +253,7 @@ export class LibraryStore extends EventTarget { ...this.snapshot, files: results, searchQuery: query, - selectedFileId: results.at(0)?.id ?? null, + selectedFileId: results[0]?.id ?? null, focusedFile: this.snapshot.focusedFile }; this.refreshVisibleFiles(this.snapshot.selectedFileId ?? null); @@ -299,7 +300,7 @@ export class LibraryStore extends EventTarget { this.snapshot = { ...this.snapshot, files, - selectedFileId: files.at(0)?.id ?? null, + selectedFileId: files[0]?.id ?? null, lastScan: summary, focusedFile: this.snapshot.focusedFile, metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1 @@ -308,6 +309,44 @@ export class LibraryStore extends EventTarget { return summary; } + /** + * Prompts the user to select a folder and imports audio files from it. + */ + public async importFromFolder(): Promise { + const folder = await window.api.selectImportFolder(); + if (!folder) { + return null; + } + return this.executeImport([folder]); + } + + /** + * Imports from a drive path, refreshing local caches when new files are added. + */ + public async importFromDrive(drivePath: string): Promise { + return this.executeImport([drivePath]); + } + + private async executeImport(sources: string[]): Promise { + const trimmed = sources.map((entry) => entry.trim()).filter((entry) => entry.length > 0); + if (trimmed.length === 0) { + return { imported: [], skipped: [], failed: [] }; + } + + const result = await window.api.importExternalSources(trimmed); + if (result.imported.length > 0) { + const files = await window.api.listAudioFiles(); + const preferredId = result.imported[0]?.id ?? this.snapshot.selectedFileId ?? null; + this.snapshot = { + ...this.snapshot, + files, + metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1 + }; + this.refreshVisibleFiles(preferredId ?? undefined); + } + return result; + } + /** * Applies tag mutations to both the backend and the cached snapshot. */ @@ -415,7 +454,7 @@ export class LibraryStore extends EventTarget { .map((id) => fileLookup.get(id) ?? created.find((record) => record.id === id) ?? null) .filter((record): record is AudioFileSummary => record !== null); - const primaryId = justSplitRecords.at(0)?.id ?? null; + const primaryId = justSplitRecords[0]?.id ?? null; const nextSelection = new Set(justSplitRecords.map((record) => record.id)); if (primaryId !== null) { nextSelection.add(primaryId); @@ -430,7 +469,7 @@ export class LibraryStore extends EventTarget { justSplitFileIds: justSplitRecords.map((record) => record.id), selectedFileId: primaryId, selectedFileIds: nextSelection, - focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords.at(0) ?? null : null + focusedFile: primaryId !== null ? fileLookup.get(primaryId) ?? justSplitRecords[0] ?? null : null }; this.emitChange(); return created; @@ -446,7 +485,7 @@ export class LibraryStore extends EventTarget { ...this.snapshot, settings, files, - selectedFileId: files.at(0)?.id ?? null, + selectedFileId: files[0]?.id ?? null, categoryFilter: null, focusedFile: this.snapshot.focusedFile, metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1 @@ -542,7 +581,7 @@ export class LibraryStore extends EventTarget { } } if (enforceVisible) { - return visible.at(0)?.id ?? null; + return visible[0]?.id ?? null; } return desiredId; } diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index dd848ce..8ba6679 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -174,6 +174,95 @@ body { color: inherit; } +.drive-picker { + width: min(28rem, 90vw); + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.drive-picker__header h2 { + margin: 0 0 0.35rem 0; + font-size: 1.4rem; +} + +.drive-picker__header p { + margin: 0; + opacity: 0.75; + font-size: 0.95rem; +} + +.drive-picker__error { + padding: 0.75rem 1rem; + border-radius: 0.6rem; + background: rgba(220, 97, 97, 0.12); + border: 1px solid rgba(220, 97, 97, 0.35); + color: #f5d6d6; + font-size: 0.9rem; +} + +.drive-picker__body { + min-height: 6rem; +} + +.drive-picker__status { + margin: 0; + opacity: 0.75; +} + +.drive-picker__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.drive-picker__drive { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.3rem; + padding: 0.7rem 0.9rem; + border-radius: 0.6rem; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: inherit; + font: inherit; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} + +.drive-picker__drive:hover:not(:disabled), +.drive-picker__drive:focus-visible { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.3); +} + +.drive-picker__drive:disabled { + opacity: 0.6; + cursor: default; +} + +.drive-picker__drive-label { + font-weight: 600; + font-size: 1rem; +} + +.drive-picker__drive-hint { + font-size: 0.8rem; + opacity: 0.7; +} + +.drive-picker__footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + .status-banner { text-align: center; padding: 0.25rem 1rem; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 7bfa275..5fcea86 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -3,6 +3,8 @@ export const IPC_CHANNELS = { settingsGet: 'settings:get', settingsSetLibrary: 'settings:set-library', dialogSelectLibrary: 'dialog:select-library', + dialogSelectImportFolder: 'dialog:select-import-folder', + systemListDrives: 'system:list-drives', libraryScan: 'library:scan', libraryList: 'library:list', libraryGetById: 'library:get-by-id', @@ -19,6 +21,7 @@ export const IPC_CHANNELS = { libraryMetadataSuggestions: 'library:metadata-suggestions', libraryUpdateMetadata: 'library:update-metadata', libraryWaveformPreview: 'library:waveform-preview', + libraryImport: 'library:import', tagsUpdate: 'tags:update', categoriesList: 'categories:list', searchQuery: 'search:query' @@ -38,8 +41,14 @@ export interface RendererApi { setLibraryPath(path: string): Promise; /** Triggers a manual rescan of the library. */ rescanLibrary(): Promise; + /** Opens a dialog to pick a folder to import audio from. */ + selectImportFolder(): Promise; + /** Lists available system drives for drive-level imports. */ + listSystemDrives(): Promise; /** Fetches the current list of audio files. */ listAudioFiles(): Promise; + /** Imports external audio files into the library. */ + importExternalSources(paths: string[]): Promise; /** Retrieves a single audio file summary by id, or null if it cannot be resolved. */ getAudioFileById(fileId: number): Promise; /** Fetches groups of duplicate files based on checksum. */ @@ -81,7 +90,7 @@ export interface RendererApi { metadata: { author?: string | null; copyright?: string | null; rating?: number } ): Promise; /** Listens for menu actions from the main process. Returns a cleanup function. */ - onMenuAction(channel: string, callback: () => void): () => void; + onMenuAction(channel: string, callback: (payload?: unknown) => void): () => void; } declare global { diff --git a/src/shared/models.ts b/src/shared/models.ts index 3525590..8962961 100644 --- a/src/shared/models.ts +++ b/src/shared/models.ts @@ -78,6 +78,35 @@ export interface LibraryScanSummary { total: number; } +/** Enumerates reasons why an import candidate might be skipped. */ +export type ImportSkipReason = 'duplicate' | 'checksum' | 'unsupported' | 'inside-library'; + +/** Describes a source entry that was skipped during an import run. */ +export interface ImportSkipEntry { + /** Absolute path of the skipped file. */ + path: string; + /** Reason the file did not qualify for import. */ + reason: ImportSkipReason; +} + +/** Records a failure encountered while processing an import candidate. */ +export interface ImportFailureEntry { + /** Absolute path of the problematic file or directory. */ + path: string; + /** Human-readable message explaining the failure. */ + message: string; +} + +/** Summary returned after importing external audio sources. */ +export interface LibraryImportResult { + /** Files successfully copied into the library. */ + imported: AudioFileSummary[]; + /** Candidates that were skipped with a known reason. */ + skipped: ImportSkipEntry[]; + /** Candidates that failed due to unexpected errors. */ + failed: ImportFailureEntry[]; +} + /** * Shape of persisted application settings. */