diff --git a/package.json b/package.json index 94c1283..2e3b1d3 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test:search": "node --import tsx --test src/test/SearchService.test.ts" }, "dependencies": { - "better-sqlite3": "^9.4.5", + "better-sqlite3": "^11.0.0", "csv-parse": "^5.5.5", "fast-glob": "^3.3.2", "fuse.js": "^6.6.2", @@ -41,7 +41,7 @@ "@vitejs/plugin-react": "^4.3.3", "concurrently": "^8.2.2", "cross-env": "^7.0.3", - "electron": "^29.4.4", + "electron": "^32.0.0", "electron-builder": "^26.0.12", "tree-kill": "^1.2.2", "tsx": "^4.15.7", diff --git a/src/main/MainApp.ts b/src/main/MainApp.ts index 481b178..a0f60a0 100644 --- a/src/main/MainApp.ts +++ b/src/main/MainApp.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { app, BrowserWindow, dialog, ipcMain, nativeTheme, Menu } from 'electron'; import type { IpcMainInvokeEvent } from 'electron'; import { IPC_CHANNELS } from '../shared/ipc'; -import { TagUpdatePayload } from '../shared/models'; +import { SplitSegmentRequest, TagUpdatePayload } from '../shared/models'; import { DatabaseService } from './services/DatabaseService'; import { LibraryService } from './services/LibraryService'; import { SearchService } from './services/SearchService'; @@ -141,6 +141,7 @@ export class MainApp { ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata); ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions); ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata); + ipcMain.removeHandler(IPC_CHANNELS.librarySplit); ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview); ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate); ipcMain.removeHandler(IPC_CHANNELS.categoriesList); @@ -241,7 +242,11 @@ export class MainApp { ipcMain.handle( IPC_CHANNELS.libraryOrganize, - async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) => + async ( + _event: IpcMainInvokeEvent, + fileId: number, + metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number } + ) => this.requireLibrary().organizeFile(fileId, metadata) ); @@ -259,6 +264,12 @@ export class MainApp { this.requireLibrary().deleteFiles(fileIds) ); + ipcMain.handle( + IPC_CHANNELS.librarySplit, + async (_event: IpcMainInvokeEvent, fileId: number, segments: SplitSegmentRequest[]) => + this.requireLibrary().splitFile(fileId, segments) + ); + ipcMain.handle(IPC_CHANNELS.libraryBuffer, async (_event: IpcMainInvokeEvent, fileId: number) => this.requireLibrary().getAudioBuffer(fileId) ); @@ -285,7 +296,11 @@ export class MainApp { ipcMain.handle( IPC_CHANNELS.libraryUpdateMetadata, - async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) => + async ( + _event: IpcMainInvokeEvent, + fileId: number, + metadata: { author?: string | null; copyright?: string | null; rating?: number } + ) => this.requireLibrary().updateFileMetadata(fileId, metadata) ); } diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index bdce8e5..7eb347d 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -33,6 +33,8 @@ export interface FileRecordInput { tags?: string[]; /** Optional category payload (stored as JSON string). */ categories?: string[]; + /** Optional parent file reference when generated from another file. */ + parentFileId?: number | null; } export interface FileRecordRow extends AudioFileSummary {} @@ -89,7 +91,8 @@ export class DatabaseService { bit_depth, checksum, tags_json, - categories_json + categories_json, + parent_file_id ) VALUES ( @absolutePath, @relativePath, @@ -103,7 +106,8 @@ export class DatabaseService { @bitDepth, @checksum, @tagsJson, - @categoriesJson + @categoriesJson, + @parentFileId ) ON CONFLICT(absolute_path) DO UPDATE SET library_relative_path = excluded.library_relative_path, @@ -117,7 +121,8 @@ export class DatabaseService { bit_depth = excluded.bit_depth, checksum = excluded.checksum, tags_json = CASE WHEN files.tags_json = '[]' THEN excluded.tags_json ELSE files.tags_json END, - categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END + categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END, + parent_file_id = CASE WHEN excluded.parent_file_id IS NOT NULL THEN excluded.parent_file_id ELSE files.parent_file_id END RETURNING *` ); @@ -132,9 +137,10 @@ export class DatabaseService { durationMs: record.durationMs, sampleRate: record.sampleRate, bitDepth: record.bitDepth, - checksum: record.checksum, + checksum: record.checksum, tagsJson: JSON.stringify(record.tags ?? []), - categoriesJson: JSON.stringify(record.categories ?? []) + categoriesJson: JSON.stringify(record.categories ?? []), + parentFileId: record.parentFileId ?? null }) as DbRow | undefined; if (!row) { @@ -145,22 +151,35 @@ export class DatabaseService { } /** - * Updates the stored tags and categories for a file. + * Updates the stored tags and/or categories for a file. + * When a field is omitted it remains unchanged. */ - public updateTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary { + public updateTagging(fileId: number, tags?: string[], categories?: string[]): AudioFileSummary { const connection = this.requireDb(); + const updates: string[] = []; + const parameters: Record = { id: fileId }; + + if (tags !== undefined) { + updates.push('tags_json = @tags'); + parameters.tags = JSON.stringify(tags); + } + + if (categories !== undefined) { + updates.push('categories_json = @categories'); + parameters.categories = JSON.stringify(categories); + } + + if (updates.length === 0) { + return this.getFileById(fileId); + } + const statement = connection.prepare( `UPDATE files - SET tags_json = @tags, - categories_json = @categories + SET ${updates.join(', ')} WHERE id = @id RETURNING *` ); - const row = statement.get({ - id: fileId, - tags: JSON.stringify(tags), - categories: JSON.stringify(categories) - }) as DbRow | undefined; + const row = statement.get(parameters) as DbRow | undefined; if (!row) { throw new Error(`File with id ${fileId} not found`); } @@ -408,6 +427,7 @@ export class DatabaseService { sampleRate: row.sample_rate === null ? null : (row.sample_rate as number), bitDepth: row.bit_depth === null ? null : (row.bit_depth as number), checksum: typeof row.checksum === 'string' ? (row.checksum as string) : null, + parentFileId: typeof row.parent_file_id === 'number' ? (row.parent_file_id as number) : null, tags: this.parseJsonArray(row.tags_json), categories: this.parseJsonArray(row.categories_json), customName: typeof row.custom_name === 'string' ? (row.custom_name as string) : null @@ -444,7 +464,8 @@ export class DatabaseService { bit_depth INTEGER, checksum TEXT, tags_json TEXT NOT NULL DEFAULT '[]', - categories_json TEXT NOT NULL DEFAULT '[]' + categories_json TEXT NOT NULL DEFAULT '[]', + parent_file_id INTEGER REFERENCES files(id) ); CREATE INDEX IF NOT EXISTS idx_files_display_name ON files(display_name); CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified_at); @@ -478,6 +499,7 @@ export class DatabaseService { this.addColumnIfMissing(connection, 'files', 'created_at', 'INTEGER'); this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT'); this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT'); + this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER'); // Create checksum index after ensuring column exists connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)'); diff --git a/src/main/services/LibraryService.ts b/src/main/services/LibraryService.ts index 39ccb15..18e45ea 100644 --- a/src/main/services/LibraryService.ts +++ b/src/main/services/LibraryService.ts @@ -31,7 +31,6 @@ export class LibraryService { private readonly organization: OrganizationService; private metadataSuggestionCache: { authors: Set } | null = null; private readonly waveformPreviewCache = new Map(); - public constructor( private readonly database: DatabaseService, private readonly settings: SettingsService, @@ -49,10 +48,10 @@ export class LibraryService { return; } const fileContent = await fs.readFile(csvAbsolutePath, 'utf-8'); - const rows = parse(fileContent, { - columns: true, - skip_empty_lines: true - }) as CsvCategoryRow[]; + const rows = parse(fileContent, { + columns: true, + skip_empty_lines: true + }) as CsvCategoryRow[]; for (const row of rows) { const category: CategoryRecord = { id: row.CatID, @@ -87,8 +86,9 @@ export class LibraryService { const cleanedTempFiles = await this.cleanupTempFiles(); const libraryRoot = this.settings.ensureLibraryPath(); const existing = this.database.listFiles(); - const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const)); - const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const)); + const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const)); + const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const)); + const discoveredByPath = new Map(); const pattern = ['**/*.wav', '**/*.wave']; const absolutePaths = await fg(pattern, { @@ -117,9 +117,29 @@ export class LibraryService { const knownFile = knownByPath ?? knownByChecksum ?? null; const wasKnown = knownFile !== null; - // Read embedded WAV metadata (author, copyright, rating, title) + // Read embedded WAV metadata (author, copyright, rating, title, parentId) const embeddedMetadata = this.tagService.readMetadata(absolutePath); + let parentFileId = knownFile?.parentFileId ?? null; + + // First try embedded metadata parentId + if (parentFileId === null && embeddedMetadata.parentId !== undefined) { + parentFileId = embeddedMetadata.parentId; + } + + // Fall back to filename pattern matching for segments + if (parentFileId === null) { + const segmentMatch = fileName.match(/^(.*)_segment\d+(\.[^.]+)$/i); + if (segmentMatch) { + const parentFileName = `${segmentMatch[1]}${segmentMatch[2]}`; + const parentAbsolutePath = path.join(path.dirname(absolutePath), parentFileName); + const parentRecord = existingByPath.get(parentAbsolutePath) ?? discoveredByPath.get(parentAbsolutePath) ?? null; + if (parentRecord) { + parentFileId = parentRecord.id; + } + } + } + const record: FileRecordInput = { absolutePath, relativePath, @@ -133,14 +153,36 @@ export class LibraryService { bitDepth: metadata.bitDepth, checksum, tags: metadata.tags.length > 0 ? metadata.tags : (knownFile?.tags ?? []), - categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? []) + categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? []), + parentFileId }; const upserted = this.database.upsertFile(record); // Update custom name from embedded title if present, otherwise keep existing const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null; - if (customName !== upserted.customName) { - this.database.updateCustomName(upserted.id, customName); + const finalRecord = customName !== upserted.customName + ? this.database.updateCustomName(upserted.id, customName) + : upserted; + + existingByPath.set(absolutePath, finalRecord); + if (checksum) { + existingByChecksum.set(checksum, finalRecord); + } + discoveredByPath.set(absolutePath, finalRecord); + + if (parentFileId !== null) { + const embeddedParent = embeddedMetadata.parentId ?? null; + if (embeddedParent === null || embeddedParent !== parentFileId) { + this.tagService.writeMetadataOnly(absolutePath, { + tags: finalRecord.tags, + categories: finalRecord.categories, + title: customName ?? embeddedMetadata.title ?? finalRecord.displayName, + author: embeddedMetadata.author ?? null, + rating: embeddedMetadata.rating, + copyright: embeddedMetadata.copyright ?? null, + parentId: parentFileId + }); + } } if (wasKnown) { @@ -510,7 +552,9 @@ export class LibraryService { categories: updatedRecord.categories, title: effectiveCustomName, author: mergedAuthor, - rating: mergedRating + rating: mergedRating, + copyright: existing.copyright ?? null, + parentId: updatedRecord.parentFileId ?? null }); // Update suggestions cache for any metadata that was provided @@ -604,7 +648,9 @@ export class LibraryService { categories: updated.categories, title: effectiveCustomName, author: mergedAuthor, - rating: mergedRating + rating: mergedRating, + copyright: existing.copyright ?? null, + parentId: updated.parentFileId ?? null }); // Update suggestions cache for any metadata that was provided @@ -683,13 +729,14 @@ export class LibraryService { })(); const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF'; - let originalMetadata: { author?: string | null; rating?: number; title?: string | null } = {}; + let originalMetadata: { author?: string | null; rating?: number; title?: string | null; copyright?: string | null } = {}; try { const metadata = this.tagService.readMetadata(record.absolutePath); originalMetadata = { author: metadata.author ?? null, rating: metadata.rating ?? undefined, - title: metadata.title ?? null + title: metadata.title ?? null, + copyright: metadata.copyright ?? null }; } catch (error) { console.warn('Failed to read original metadata before splitting', error); @@ -739,12 +786,33 @@ export class LibraryService { (nextWave as WaveFile & { container?: string }).container = container; const segmentBytes = Buffer.from(nextWave.toBuffer()); + // Determine the segment name part - use label if available, otherwise use sequence + let segmentNamePart = ''; + if (segment.label) { + const sanitized = this.organization.sanitizeCustomName(segment.label); + if (sanitized) { + segmentNamePart = sanitized; + } + } + let segmentFileName = ''; let segmentAbsolutePath = ''; for (let attempt = 0; attempt < 1000; attempt += 1) { - const suffix = this.organization.formatSequenceNumber(sequence); - sequence += 1; - const candidateName = `${baseName}_segment${suffix}.wav`; + let candidateName: string; + if (segmentNamePart) { + // Use label-based name, with optional suffix for duplicates + if (attempt === 0) { + candidateName = `${baseName}_${segmentNamePart}.wav`; + } else { + candidateName = `${baseName}_${segmentNamePart}_${this.organization.formatSequenceNumber(attempt)}.wav`; + } + } else { + // Fallback to sequential numbering + const suffix = this.organization.formatSequenceNumber(sequence); + sequence += 1; + candidateName = `${baseName}_segment${suffix}.wav`; + } + if (usedNames.has(candidateName)) { continue; } @@ -789,12 +857,15 @@ export class LibraryService { bitDepth: bitDepthNumeric, checksum, tags: resolvedTags, - categories: resolvedCategories + categories: resolvedCategories, + parentFileId: record.id }); const resolvedCustomName = segment.metadata?.customName !== undefined ? this.normaliseMetadataInput(segment.metadata.customName) - : record.customName ?? null; + : segment.label !== undefined + ? this.normaliseMetadataInput(segment.label) + : record.customName ?? null; const updatedRecord = resolvedCustomName !== fileRecord.customName ? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null) : fileRecord; @@ -811,7 +882,9 @@ export class LibraryService { categories: resolvedCategories, title: resolvedCustomName ?? updatedRecord.displayName, author: resolvedAuthor ?? undefined, - rating: resolvedRating + rating: resolvedRating, + copyright: originalMetadata.copyright ?? null, + parentId: record.id }); if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) { @@ -872,7 +945,9 @@ export class LibraryService { categories: record.categories, title: record.customName ?? existing.title, author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null, - rating: metadata.rating !== undefined ? metadata.rating : existing.rating + rating: metadata.rating !== undefined ? metadata.rating : existing.rating, + copyright: existing.copyright ?? null, + parentId: record.parentFileId ?? null }; // Write merged metadata to the WAV file diff --git a/src/main/services/TagService.ts b/src/main/services/TagService.ts index d8daf09..4e2ba8b 100644 --- a/src/main/services/TagService.ts +++ b/src/main/services/TagService.ts @@ -10,23 +10,25 @@ export class TagService { public constructor(private readonly database: DatabaseService) {} /** - * Applies tags and categories to a file record and embeds the metadata into the WAV container. + * Applies category updates (and optional tag overrides) then embeds metadata into the WAV container. * Preserves existing author, title, and rating fields. */ - public applyTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary { - const normalisedTags = this.normaliseValues(tags); + public applyTagging(fileId: number, tags: string[] | undefined, categories: string[]): AudioFileSummary { const normalisedCategories = this.normaliseValues(categories); + const normalisedTags = Array.isArray(tags) ? this.normaliseValues(tags) : undefined; const updated = this.database.updateTagging(fileId, normalisedTags, normalisedCategories); // Read existing metadata to preserve author, title, and rating const existing = this.readMetadata(updated.absolutePath); this.writeWaveMetadata(updated.absolutePath, { - tags: normalisedTags, - categories: normalisedCategories, + tags: normalisedTags ?? updated.tags, + categories: normalisedCategories, title: existing.title, author: existing.author, - rating: existing.rating + rating: existing.rating, + copyright: existing.copyright, + parentId: existing.parentId ?? updated.parentFileId ?? null }); return updated; } @@ -52,6 +54,8 @@ export class TagService { author?: string; title?: string; rating?: number; + copyright?: string; + parentId?: number; } { try { const buffer = fs.readFileSync(filePath); @@ -63,10 +67,44 @@ export class TagService { rating = Math.floor(ratingValue / 2); } + const copyright = tags.ICOP?.trim() || undefined; + + // Try to read parentId from JSON comment first + let parentId: number | undefined; + const comment = tags.ICMT?.trim(); + if (comment) { + try { + const parsed = JSON.parse(comment); + if (parsed && typeof parsed.parentId === 'number') { + parentId = parsed.parentId; + } + } catch { + // Not JSON or invalid, try fallback to IPAR + const parentRaw = tags.IPAR?.trim(); + if (parentRaw && parentRaw.length > 0) { + const parsedNum = Number.parseInt(parentRaw, 10); + if (Number.isFinite(parsedNum)) { + parentId = parsedNum; + } + } + } + } else { + // No comment, try fallback to IPAR + const parentRaw = tags.IPAR?.trim(); + if (parentRaw && parentRaw.length > 0) { + const parsedNum = Number.parseInt(parentRaw, 10); + if (Number.isFinite(parsedNum)) { + parentId = parsedNum; + } + } + } + return { author: tags.IART?.trim() || undefined, title: tags.INAM?.trim() || undefined, - rating + rating, + copyright, + parentId }; } catch (error) { // eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis. @@ -84,9 +122,11 @@ export class TagService { metadata: { tags: string[]; categories: string[]; - title: string | null | undefined; - author: string | null | undefined; - rating: number | undefined; + title?: string | null; + author?: string | null; + rating?: number; + copyright?: string | null; + parentId?: number | null; } ): void { this.writeWaveMetadata(filePath, metadata); @@ -101,9 +141,11 @@ export class TagService { metadata: { tags: string[]; categories: string[]; - title: string | null | undefined; - author: string | null | undefined; - rating: number | undefined; + title?: string | null; + author?: string | null; + rating?: number; + copyright?: string | null; + parentId?: number | null; } ): void { try { @@ -114,22 +156,47 @@ export class TagService { listInfoTags?: Record; }; - const tagValuesList = metadata.tags.map((entry) => entry.trim()).filter((entry) => entry.length > 0); - const categoryValuesList = metadata.categories.map((entry) => entry.trim()).filter((entry) => entry.length > 0); + const tagValuesList = (metadata.tags ?? []) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + const categoryValuesList = (metadata.categories ?? []) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); const tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null; const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null; const primaryCategory = categoryValuesList.at(0) ?? null; + const trimmedTitle = metadata.title?.toString().trim(); + const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null; + const trimmedAuthor = metadata.author?.toString().trim(); + const effectiveAuthor = trimmedAuthor && trimmedAuthor.length > 0 ? trimmedAuthor : null; + const trimmedCopyright = metadata.copyright?.toString().trim(); + const effectiveCopyright = trimmedCopyright && trimmedCopyright.length > 0 ? trimmedCopyright : null; + const parentText = metadata.parentId !== undefined && metadata.parentId !== null + ? String(metadata.parentId) + : null; + const commentPayload = { + version: 1, + tags: tagValuesList, + categories: categoryValuesList, + parentId: metadata.parentId ?? null, + title: effectiveTitle, + author: effectiveAuthor, + rating: metadata.rating ?? null + } satisfies Record; + const commentText = JSON.stringify(commentPayload); const tagValues: Record = { IKEY: tagText, - ICMT: tagText, + ICMT: commentText, ISBJ: categoryText, ISUB: primaryCategory, IGNR: null, - INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null, - IART: metadata.author?.trim()?.length ? metadata.author.trim() : null, + INAM: effectiveTitle, + IART: effectiveAuthor, IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null, + ICOP: effectiveCopyright, + IPAR: parentText, ISFT: 'AudioSort' }; @@ -139,11 +206,13 @@ export class TagService { fs.writeFileSync(filePath, updatedBuffer); console.log(`Wrote metadata to ${filePath}:`, { - tags: metadata.tags.join(', '), + tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '', categories: metadata.categories.join(', '), - title: metadata.title, - author: metadata.author, - rating: metadata.rating + title: effectiveTitle, + author: effectiveAuthor, + copyright: effectiveCopyright, + rating: metadata.rating, + parentId: metadata.parentId ?? null }); } catch (error) { // eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis. diff --git a/src/preload/index.ts b/src/preload/index.ts index c266bbe..ba63ac5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import type { AudioFileSummary, CategoryRecord, LibraryScanSummary, + SplitSegmentRequest, TagUpdatePayload } from '../shared/models'; @@ -37,7 +38,10 @@ const api: RendererApi = { async moveFile(fileId: number, targetRelativeDirectory: string): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryMove, fileId, targetRelativeDirectory); }, - async organizeFile(fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise { + async organizeFile( + fileId: number, + metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number } + ): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryOrganize, fileId, metadata); }, async updateCustomName(fileId: number, customName: string | null): Promise { @@ -49,6 +53,9 @@ const api: RendererApi = { async deleteFiles(fileIds: number[]): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryDelete, fileIds); }, + async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise { + return ipcRenderer.invoke(IPC_CHANNELS.librarySplit, fileId, segments); + }, async getAudioBuffer(fileId: number): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryBuffer, fileId); }, @@ -70,7 +77,10 @@ const api: RendererApi = { async listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }> { return ipcRenderer.invoke(IPC_CHANNELS.libraryMetadataSuggestions); }, - async updateFileMetadata(fileId: number, metadata: { author?: string; copyright?: string; rating?: number }): Promise { + async updateFileMetadata( + fileId: number, + metadata: { author?: string | null; copyright?: string | null; rating?: number } + ): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata); }, onMenuAction(channel: string, callback: () => void): () => void { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b3b1044..4750339 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -69,6 +69,14 @@ function App(): JSX.Element { [library.files, library.selectedFileId] ); + const parentFile = useMemo(() => { + const parentId = selectedFile?.parentFileId ?? null; + if (parentId === null) { + return null; + } + return library.files.find((file) => file.id === parentId) ?? null; + }, [library.files, selectedFile?.parentFileId]); + const selectedFiles = useMemo( () => library.files.filter((file) => library.selectedFileIds.has(file.id)), [library.files, library.selectedFileIds] @@ -172,15 +180,20 @@ function App(): JSX.Element { await libraryStore.updateCustomName(selectedFile.id, customName); }; - const handleTagUpdate = async (data: { tags: string[]; categories: string[] }) => { + const handleTagUpdate = async (categories: string[]) => { if (!selectedFile) { return; } - await libraryStore.updateTagging({ fileId: selectedFile.id, ...data }); + await libraryStore.updateTagging({ fileId: selectedFile.id, categories }); }; - const handleMultiFileTagUpdate = async (fileId: number, data: { tags: string[]; categories: string[] }) => { - await libraryStore.updateTagging({ fileId, ...data }); + const handleOpenParent = (parentId: number) => { + void libraryStore.focusOnFile(parentId); + setActiveTab('listen'); + }; + + const handleMultiFileTagUpdate = async (fileId: number, categories: string[]) => { + await libraryStore.updateTagging({ fileId, categories }); }; const handleMultiFileCustomName = async (fileId: number, customName: string | null) => { @@ -204,10 +217,9 @@ function App(): JSX.Element { if (existingCategories.includes(categoryId)) continue; const newCategories = [...existingCategories, categoryId]; - await libraryStore.updateTagging({ - fileId, - tags: file.tags, - categories: newCategories + await libraryStore.updateTagging({ + fileId, + categories: newCategories }); } }; @@ -287,12 +299,14 @@ function App(): JSX.Element { <> diff --git a/src/renderer/src/audio/scrubWorklet.ts b/src/renderer/src/audio/scrubWorklet.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/renderer/src/components/FileDetailPanel.tsx b/src/renderer/src/components/FileDetailPanel.tsx index 216b88f..d21e934 100644 --- a/src/renderer/src/components/FileDetailPanel.tsx +++ b/src/renderer/src/components/FileDetailPanel.tsx @@ -2,25 +2,36 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty import type { AudioFileSummary, CategoryRecord } from '../../../shared/models'; import { TagEditor } from './TagEditor'; +/** + * Normalizes path separators to backslashes for consistent display on Windows. + */ +function normalizePathDisplay(path: string): string { + return path.replace(/\//g, '\\'); +} + /** * Props for FileDetailPanel component. */ export interface FileDetailPanelProps { file: AudioFileSummary | null; + /** Optional summary of the parent file when this entry was generated from another file. */ + parentFile: AudioFileSummary | null; categories: CategoryRecord[]; onRename(newName: string): Promise; onMove(targetRelativeDirectory: string): Promise; /** Organizes a file with optional metadata fields (customName, author, copyright, rating 1-5). */ onOrganize(metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise; - onUpdateTags(data: { tags: string[]; categories: string[] }): Promise; + onUpdateTags(categories: string[]): Promise; onUpdateCustomName(customName: string | null): Promise; + /** Invoked when the user requests to open the parent file. */ + onOpenParent?(parentId: number): void; metadataSuggestionsVersion: number; } /** * Displays metadata for the selected file with rename, move, and tagging controls. */ -export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize, onUpdateTags, onUpdateCustomName, metadataSuggestionsVersion }: FileDetailPanelProps): JSX.Element { +export function FileDetailPanel({ file, parentFile, categories, onRename, onMove, onOrganize, onUpdateTags, onUpdateCustomName, onOpenParent, metadataSuggestionsVersion }: FileDetailPanelProps): JSX.Element { const [isEditingCustomName, setIsEditingCustomName] = useState(false); const [moveDraft, setMoveDraft] = useState(''); const [customNameDraft, setCustomNameDraft] = useState(''); @@ -192,7 +203,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize } }; - const triggerOrganize = async (overrides?: { author?: string; rating?: number; customName?: string | null }) => { + const triggerOrganize = async (overrides?: { author?: string; rating?: number; customName?: string | null }, force = false) => { if (!file) { return; } @@ -213,6 +224,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize const previous = initialMetadataRef.current; if ( + !force && previous && previous.author === comparisonState.author && previous.copyright === comparisonState.copyright && @@ -250,10 +262,12 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize } }; - const handleTagSave = async (data: { tags: string[]; categories: string[] }) => { + const handleCategorySave = async (selectedCategories: string[]) => { setBusy(true); try { - await onUpdateTags(data); + await onUpdateTags(selectedCategories); + } catch (error) { + console.error('Failed to update categories', error); } finally { setBusy(false); } @@ -268,6 +282,13 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize } }; + const handleOpenParent = () => { + if (!file || file.parentFileId === null || !onOpenParent) { + return; + } + onOpenParent(file.parentFileId); + }; + const toggleTagSection = () => { setIsTagSectionExpanded((value) => !value); }; @@ -292,15 +313,65 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize {file.customName || file.displayName} )} -

e.currentTarget.style.opacity = '1'} - onMouseLeave={(e) => e.currentTarget.style.opacity = '0.7'} - title="Click to open in folder" +

{ + const btn = e.currentTarget.querySelector('.file-regenerate-name') as HTMLElement; + if (btn) btn.style.opacity = '1'; + }} + onMouseLeave={(e) => { + const btn = e.currentTarget.querySelector('.file-regenerate-name') as HTMLElement; + if (btn) btn.style.opacity = '0'; + }} > - {file.relativePath} -

+

e.currentTarget.style.opacity = '1'} + onMouseLeave={(e) => e.currentTarget.style.opacity = '0.7'} + title="Click to open in folder" + > + {normalizePathDisplay(file.relativePath)} +

+ +
+ {file.parentFileId !== null && onOpenParent ? ( + + ) : null}
{formatBytes(file.size)}
@@ -374,17 +445,16 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize onClick={toggleTagSection} aria-expanded={isTagSectionExpanded} > - Tags + Categories
diff --git a/src/renderer/src/components/MultiFileEditor.tsx b/src/renderer/src/components/MultiFileEditor.tsx index e9b7f97..09ab2b0 100644 --- a/src/renderer/src/components/MultiFileEditor.tsx +++ b/src/renderer/src/components/MultiFileEditor.tsx @@ -6,7 +6,7 @@ import { TagEditor } from './TagEditor'; export interface MultiFileEditorProps { files: AudioFileSummary[]; categories: CategoryRecord[]; - onUpdateTags(fileId: number, data: { tags: string[]; categories: string[] }): Promise; + onUpdateTags(fileId: number, categories: string[]): Promise; onUpdateCustomName(fileId: number, customName: string | null): Promise; onOrganize(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise; onUpdateMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise; @@ -14,7 +14,7 @@ export interface MultiFileEditorProps { } /** - * Multi-file tag editor that shows aggregated metadata and allows batch editing. + * Multi-file metadata editor that focuses on shared categories and attributes. */ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCustomName, onOrganize, onUpdateMetadata, metadataSuggestionsVersion }: MultiFileEditorProps): JSX.Element { const [sharedCustomName, setSharedCustomName] = useState(''); @@ -23,20 +23,6 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] }); const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true); - // Compute aggregated tags and categories for TagEditor - const aggregatedTags = useMemo(() => { - const tagCounts = new Map(); - for (const file of files) { - for (const tag of file.tags) { - tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); - } - } - // Only show tags that appear in all files - return Array.from(tagCounts.entries()) - .filter(([, count]) => count === files.length) - .map(([tag]) => tag); - }, [files]); - const aggregatedCategories = useMemo(() => { const categoryCounts = new Map(); for (const file of files) { @@ -199,22 +185,22 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto } }; - const handleTagSave = async (data: { tags: string[]; categories: string[] }) => { + const handleCategorySave = async (selectedCategories: string[]) => { try { let failureCount = 0; for (const file of files) { try { - await onUpdateTags(file.id, data); + await onUpdateTags(file.id, selectedCategories); } catch (error) { failureCount += 1; - console.error(`Failed to update tags for file ${file.id} (${file.fileName}):`, error); + console.error(`Failed to update categories for file ${file.id} (${file.fileName}):`, error); } } if (failureCount > 0) { - console.error(`${failureCount} file(s) failed to update tags`); + console.error(`${failureCount} file(s) failed to update categories`); } } catch (error) { - console.error('Failed to update tags:', error); + console.error('Failed to update categories:', error); } }; @@ -309,17 +295,16 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto onClick={toggleTagSection} aria-expanded={isTagSectionExpanded} > - Tags + Categories
diff --git a/src/renderer/src/components/TagEditor.tsx b/src/renderer/src/components/TagEditor.tsx index fc41228..ef3aa01 100644 --- a/src/renderer/src/components/TagEditor.tsx +++ b/src/renderer/src/components/TagEditor.tsx @@ -1,13 +1,4 @@ -import { - useEffect, - useMemo, - useState, - useRef, - useLayoutEffect, - type ChangeEvent, - type UIEvent, - type CSSProperties -} from 'react'; +import { useEffect, useMemo, useState, useRef, useLayoutEffect, type UIEvent } from 'react'; import type { CategoryRecord } from '../../../shared/models'; import { getCollapsedGroups, @@ -24,19 +15,17 @@ import { import type { CategorySwatch } from '../utils/categoryColors'; export interface TagEditorProps { - tags: string[]; categories: string[]; availableCategories: CategoryRecord[]; - onSave(data: { tags: string[]; categories: string[] }): void; + onSave(categories: string[]): void | Promise; /** If false the internal heading is omitted. Defaults to true. */ showHeading?: boolean; } /** - * Allows editing free-form tags and selecting UCS categories. + * Provides category selection controls without exposing free-form tag editing. */ -export function TagEditor({ tags, categories, availableCategories, onSave, showHeading = true }: TagEditorProps): JSX.Element { - const [tagDraft, setTagDraft] = useState(tags.join(', ')); +export function TagEditor({ categories, availableCategories, onSave, showHeading = true }: TagEditorProps): JSX.Element { const [categoryFilter, setCategoryFilter] = useState(''); const [selectedCategories, setSelectedCategories] = useState(new Set(categories)); const [isCategoryListExpanded, setIsCategoryListExpanded] = useState(false); @@ -52,7 +41,6 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH const isFirstRender = useRef(true); useEffect(() => { - setTagDraft(tags.join(', ')); setSelectedCategories(new Set(categories)); if (!isFirstRender.current) { @@ -61,7 +49,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH } else { isFirstRender.current = false; } - }, [tags, categories]); + }, [categories]); const { groupedCategories, filteredResults } = useMemo(() => { const filter = categoryFilter.trim().toLowerCase(); @@ -146,20 +134,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH next.add(categoryId); } setSelectedCategories(next); - - const parsedTags = tagDraft - .split(',') - .map((value: string) => value.trim()) - .filter((value: string) => value.length > 0); - onSave({ tags: parsedTags, categories: Array.from(next) }); - }; - - const handleTagBlur = () => { - const parsedTags = tagDraft - .split(',') - .map((value: string) => value.trim()) - .filter((value: string) => value.length > 0); - onSave({ tags: parsedTags, categories: Array.from(selectedCategories) }); + onSave(Array.from(next)); }; const handleRemoveCategory = (categoryId: string) => { @@ -174,11 +149,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH return; } setSelectedCategories(new Set()); - const parsedTags = tagDraft - .split(',') - .map((value: string) => value.trim()) - .filter((value: string) => value.length > 0); - onSave({ tags: parsedTags, categories: [] }); + onSave([]); }; const toggleCategoryList = () => { @@ -187,18 +158,11 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH return (
- {showHeading &&

Tags

} -