O3BSR2XYDGTG6POI5RVLNYKQSFEJURM5BMQC5WXQOMVMYBTL74PQC HU4V2UA4CZNNWQ4RRMFYEKD44QRMLTRJHZMIONWIZGLTJU7CYU4AC O3JTKE2JLRNBU2ULVFRH4BWADTJK3BADHPMLBNEPDW2FOIRDKS6AC OBXY6BHNROIV7W4O3MMQ6GTQXU2XUKF5A4DCJYDLUEUHV72E7BNAC ETAXJ5YIOUH75V6ZKHUGQWTRWIRZVJAUG5LYXYZEJDS7SXPNEZSAC EQKLVT45KJNHQCSI56WCHTB4UKMLOIOXBXXEJ6JH46GME2RKTSWQC T4EU44HLZGB4GRJWFZYR72H2FACCMDPD2R3PCBAUFKCCIOAQRJWAC M3JUJ2WWZGCVMBITKRM5FUJMHFYL2QRMXJUVRUE4AC2RF74AOL5AC YX7LU4WRAUDMWS3DEDXZDSF6DXBHLYDWVSMSRK6KIW3MO6GRXSVQC QPZE42LFURFXW6QNTVXY6AF7DZI7KTOLHMNSJHLFCRZMNMGQ6EVQC PMPFBBM5FF3CYEQ6P5UK5KXSZXD6SGN26LAZ5PVG6FNI3ISJPIMQC // Check if user has ADMIN or CURATOR permission for this datasetconst hasAdminPermission = await checkUserPermission(db, userId, datasetId, 'ADMIN');const hasCuratorPermission = await checkUserPermission(db, userId, datasetId, 'CURATOR');
// Check if user has EDIT permission for this datasetconst hasEditPermission = await checkUserPermission(db, userId, datasetId, 'EDIT');
/*** Protected API route to validate selection files against database files** @route POST /api/files/validate-selection-files* @authentication Required* @param {string} clusterId - Required body parameter specifying the cluster to check against* @param {string[]} baseFileNames - Array of base file names from selections to validate* @returns {Object} Response containing:* - data: Array of matching file objects with id, fileName, and baseName* @error 400 - If clusterId or baseFileNames are missing/invalid* @error 403 - If user doesn't have permission to access the cluster* @error 500 - If database operation fails* @description Efficiently validates that selection base file names exist in the database* for the specified cluster. Uses SQL IN clause for optimal performance with large datasets.** Request body:* {* "clusterId": "cluster123",* "baseFileNames": ["20241201_214500", "20241201_214600", ...]* }** Response includes only files that match the provided base names:* {* "data": [* { "id": "file123", "fileName": "20241201_214500.wav", "baseName": "20241201_214500" },* { "id": "file124", "fileName": "folder/20241201_214600.wav", "baseName": "20241201_214600" }* ]* }*/files.post("/validate-selection-files", authenticate, async (c) => {try {const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;const userId = jwtPayload.sub;// Parse request bodyconst body = await c.req.json();const { clusterId, baseFileNames } = body;// Validate required parametersif (!clusterId) {return c.json({error: "Missing required parameter: clusterId"}, 400);}if (!Array.isArray(baseFileNames) || baseFileNames.length === 0) {return c.json({error: "Missing or invalid parameter: baseFileNames must be a non-empty array"}, 400);}// Validate baseFileNames are stringsif (!baseFileNames.every(name => typeof name === 'string')) {return c.json({error: "Invalid parameter: all baseFileNames must be strings"}, 400);}// Connect to the databaseconst db = createDatabase(c.env);// Get the datasetId for this cluster to check permissionsconst clusterResult = await db.select({ datasetId: cluster.datasetId }).from(cluster).where(eq(cluster.id, clusterId)).limit(1);if (clusterResult.length === 0) {return c.json({error: "Access denied"}, 403);}const datasetId = clusterResult[0].datasetId;// Check if user has READ permission for this datasetconst hasPermission = await checkUserPermission(db, userId, datasetId, 'READ');if (!hasPermission) {return c.json({error: "Access denied: No READ permission for this dataset"}, 403);}// Query all files for this clusterconst filesResult = await db.select({id: file.id,fileName: file.fileName,}).from(file).where(and(eq(file.clusterId, clusterId),eq(file.active, true)));// Process files and match against base namesconst matchingFiles: Array<{ id: string; fileName: string; baseName: string }> = [];const baseFileNamesSet = new Set(baseFileNames);for (const dbFile of filesResult) {// Skip hidden files (starting with .)if (dbFile.fileName.startsWith('.')) {continue;}// Extract base name from database file name (remove extension and path)let dbFileName = dbFile.fileName;// Remove path if present (take last part after /)if (dbFileName.includes('/')) {dbFileName = dbFileName.split('/').pop() || dbFileName;}// Remove extension (.wav or .WAV)const baseName = dbFileName.replace(/\.(wav|WAV)$/i, '');// Check if this base name matches any selection base nameif (baseFileNamesSet.has(baseName)) {matchingFiles.push({id: dbFile.id,fileName: dbFile.fileName,baseName: baseName});}}return c.json({data: matchingFiles});} catch (error) {return c.json(standardErrorResponse(error, "validating selection files"), 500);}});
// First, validate the filter if we have a filter name from the folderif (filterName) {console.log('Validating filter:', filterName);const filterResponse = await fetch('/api/filters/validate', {method: 'POST',headers: {Authorization: `Bearer ${accessToken}`,'Content-Type': 'application/json'},body: JSON.stringify({ name: filterName })});if (!filterResponse.ok) {const errorText = await filterResponse.text();console.error('Filter validation failed:', filterResponse.status, errorText);throw new Error(`Filter '${filterName}' not found. Please ensure the filter exists in the database.`);}const filterData = await filterResponse.json();const validatedFilterId = filterData.data.id;setFilterId(validatedFilterId);console.log('Filter validation successful:', filterName, '->', validatedFilterId);} else {throw new Error('No filter name could be parsed from the folder name. Please ensure the folder follows the pattern: Clips_{filterName}_{date}');}
// Fetch files for this cluster to map base names to file IDsconsole.log('Fetching files for clusterId:', clusterId);const filesResponse = await fetch(`/api/files?clusterId=${encodeURIComponent(clusterId)}&pageSize=500`, {headers: { Authorization: `Bearer ${accessToken}` },
// Validate selection files against database using new efficient API endpoint// This replaces the inefficient pagination approach with a targeted validationconsole.log('Validating selection files for clusterId:', clusterId, 'with', fileBaseNames.length, 'base names');console.log('Selection base names to validate:', fileBaseNames.slice(0, 10), `... (${fileBaseNames.length} total)`);const filesResponse = await fetch('/api/files/validate-selection-files', {method: 'POST',headers: {Authorization: `Bearer ${accessToken}`,'Content-Type': 'application/json'},body: JSON.stringify({clusterId: clusterId,baseFileNames: fileBaseNames})
console.error('Files API error:', filesResponse.status, errorText);throw new Error(`Failed to fetch cluster files: ${filesResponse.status} ${errorText}`);
console.error('File validation API error:', filesResponse.status, errorText);throw new Error(`Failed to validate selection files: ${filesResponse.status} ${errorText}`);
for (const dbFile of availableFiles) {// Skip hidden files (starting with .)if (dbFile.fileName.startsWith('.')) {continue;}// Extract base name from database file name (remove extension and path)let dbFileName = dbFile.fileName;// Remove path if present (take last part after /)if (dbFileName.includes('/')) {dbFileName = dbFileName.split('/').pop() || dbFileName;}// Remove extension (.wav or .WAV)dbFileName = dbFileName.replace(/\.(wav|WAV)$/i, '');dbFileMap.set(dbFileName, dbFile);
// Create mapping from validated files// The API already did the heavy lifting of matching base names to database filesfor (const matchedFile of matchingFiles) {fileMap.set(matchedFile.baseName, matchedFile.id);console.log(`✓ Validated: ${matchedFile.baseName} -> ${matchedFile.id} (${matchedFile.fileName})`);
console.log('Database files available:', Array.from(dbFileMap.keys()).slice(0, 10), `... (${dbFileMap.size} total)`);console.log('Selection base names to match:', fileBaseNames.slice(0, 10), `... (${fileBaseNames.length} total)`);// Show first few examples for comparisonconsole.log('First 5 database files:', Array.from(dbFileMap.keys()).slice(0, 5));console.log('First 5 selection base names:', fileBaseNames.slice(0, 5));
// Map base file names from selections to file IDsfor (const baseName of fileBaseNames) {const dbFile = dbFileMap.get(baseName);if (dbFile) {fileMap.set(baseName, dbFile.id);console.log(`✓ Mapped: ${baseName} -> ${dbFile.id} (${dbFile.fileName})`);} else {console.log(`✗ No match found for: ${baseName}`);}
// Log any missing files for debuggingconst foundBaseNames = new Set(matchingFiles.map((f: { baseName: string }) => f.baseName));const missingBaseNames = fileBaseNames.filter(baseName => !foundBaseNames.has(baseName));if (missingBaseNames.length > 0) {console.log(`✗ Missing files (${missingBaseNames.length}):`, missingBaseNames.slice(0, 10), missingBaseNames.length > 10 ? '...' : '');
const parseFilterNameFromFolder = (folderName: string): string | null => {// Parse filter name from folder pattern: Clips_{filterName}_{date}// Example: "Clips_opensoundscape-kiwi-1.2_2025-06-28" -> "opensoundscape-kiwi-1.2"const clipsPrefix = 'Clips_';if (!folderName.startsWith(clipsPrefix)) {console.warn('Folder name does not start with expected "Clips_" prefix:', folderName);return null;}// Remove the "Clips_" prefixconst withoutPrefix = folderName.substring(clipsPrefix.length);// Split by underscore and get all parts except the last one (which should be the date)const parts = withoutPrefix.split('_');if (parts.length < 2) {console.warn('Folder name does not follow expected pattern Clips_{filterName}_{date}:', folderName);return null;}// Join all parts except the last one to reconstruct the filter name// This handles filter names that might contain underscoresconst filterName = parts.slice(0, -1).join('_');if (!filterName) {console.warn('Could not extract filter name from folder:', folderName);return null;}console.log('Parsed filter name:', filterName, 'from folder:', folderName);return filterName;};
// Remove extension
// Parse selection filename to extract base name and timing information// Expected format: "basename-startTime-endTime.ext"// e.g., "20241201_214500-105.5-133.2.wav" -> baseName="20241201_214500", start=105.5, end=133.2// Remove file extension (.png or .wav, case insensitive)
// Get base name (everything except last two parts)
// Reconstruct base name from all parts except the last two// This handles cases where the original filename contains dashes// e.g., "recording-site-01-20241201_214500-105-133" -> baseName="recording-site-01-20241201_214500"
baseName: parsed.baseName,startTime: parsed.startTime,endTime: parsed.endTime,wavFile: fileEntry.wav,pngFile: fileEntry.png,species: fileEntry.species,callType: fileEntry.callType || undefined,folderPath: fileEntry.folderPath
baseName: parsed.baseName, // Base audio file name (without timing suffix)startTime: parsed.startTime, // Selection start time in secondsendTime: parsed.endTime, // Selection end time in secondswavFile: fileEntry.wav, // Handle to WAV filepngFile: fileEntry.png, // Handle to PNG filespecies: fileEntry.species, // Species name from folder structurecallType: fileEntry.callType || undefined, // Call type from folder structure (optional)folderPath: fileEntry.folderPath // Full folder path for reference
validFiles,invalidFiles,species: Array.from(species),callTypes: Array.from(callTypes),totalFiles: validFiles.length + invalidFiles.length
validFiles, // Array of complete, valid file pairs with parsed timinginvalidFiles, // Array of files with issues (missing pair or invalid format)species: Array.from(species), // Unique species found in folder structurecallTypes: Array.from(callTypes), // Unique call types found in folder structuretotalFiles: validFiles.length + invalidFiles.length // Total processed files
// Fetch filters to get a valid filter IDconst filtersResponse = await fetch('/api/filters', {headers: { Authorization: `Bearer ${accessToken}` },});
if (!filterId) {throw new Error('No valid filter ID available. Filter validation may have failed.');}
let filterId = 'default-filter-id'; // fallbackif (filtersResponse.ok) {const filtersData = await filtersResponse.json();const filters = filtersData.data || [];if (filters.length > 0) {filterId = filters[0].id; // Use first available filter}}
console.log('Using validated filter ID for import:', filterId);