UNPKG

appwrite-utils-cli

Version:

Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.

311 lines (280 loc) 10.4 kB
import path from "path"; import fs from "fs"; import type { AttributeMappings, AppwriteConfig } from "appwrite-utils"; import type { ImportDataActions } from "../importDataActions.js"; import { logger } from "../../shared/logging.js"; import { RateLimitManager } from "./RateLimitManager.js"; /** * Service responsible for file handling during import. * Preserves all existing file handling capabilities including URL support. * Extracted from DataLoader to provide focused, testable file operations. */ export class FileHandlerService { private appwriteFolderPath: string; private config: AppwriteConfig; private importDataActions: ImportDataActions; private rateLimitManager: RateLimitManager; constructor( appwriteFolderPath: string, config: AppwriteConfig, importDataActions: ImportDataActions, rateLimitManager?: RateLimitManager ) { this.appwriteFolderPath = appwriteFolderPath; this.config = config; this.importDataActions = importDataActions; this.rateLimitManager = rateLimitManager || new RateLimitManager(); } /** * Generates attribute mappings with post-import actions based on the provided attribute mappings. * This method checks each mapping for a fileData attribute and adds a post-import action to create a file * and update the field with the file's ID if necessary. * * Preserves existing file handling logic from DataLoader. * * @param attributeMappings - The attribute mappings from the import definition. * @param context - The context object containing information about the database, collection, and document. * @param item - The item being imported, used for resolving template paths in fileData mappings. * @returns The attribute mappings updated with any necessary post-import actions. */ getAttributeMappingsWithFileActions( attributeMappings: AttributeMappings, context: any, item: any ): AttributeMappings { // Iterate over each attribute mapping to check for fileData attributes return attributeMappings.map((mapping) => { if (mapping.fileData) { // Resolve the file path using the provided template, context, and item let mappingFilePath = this.importDataActions.resolveTemplate( mapping.fileData.path, context, item ); // Ensure the file path is absolute if it doesn't start with "http" if (!mappingFilePath.toLowerCase().startsWith("http")) { mappingFilePath = this.resolveLocalFilePath(mappingFilePath); } // Define the after-import action to create a file and update the field const afterImportAction = { action: "createFileAndUpdateField", params: [ "{dbId}", "{collId}", "{docId}", mapping.targetKey, `${this.config!.documentBucketId}_${context.dbName .toLowerCase() .replace(" ", "")}`, // Bucket ID pattern mappingFilePath, mapping.fileData.name, ], }; // Add the after-import action to the mapping's postImportActions array const postImportActions = mapping.postImportActions ? [...mapping.postImportActions, afterImportAction] : [afterImportAction]; return { ...mapping, postImportActions }; } // Return the mapping unchanged if no fileData attribute is found return mapping; }); } /** * Resolves local file path, searching in subdirectories if needed. * Preserves existing file search logic from DataLoader. * * @param mappingFilePath - The relative file path from the mapping * @returns Resolved absolute file path */ private resolveLocalFilePath(mappingFilePath: string): string { // First try the direct path let fullPath = path.resolve(this.appwriteFolderPath, mappingFilePath); // If file doesn't exist, search in subdirectories if (!fs.existsSync(fullPath)) { const findFileInDir = (dir: string): string | null => { try { const files = fs.readdirSync(dir); for (const file of files) { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { // Recursively search subdirectories const found = findFileInDir(filePath); if (found) return found; } else if (file === path.basename(mappingFilePath)) { return filePath; } } } catch (error) { logger.warn(`Error reading directory ${dir}: ${error}`); } return null; }; const foundPath = findFileInDir(this.appwriteFolderPath); if (foundPath) { mappingFilePath = foundPath; } else { logger.warn( `File not found in any subdirectory: ${mappingFilePath}` ); // Keep the original resolved path as fallback mappingFilePath = fullPath; } } else { mappingFilePath = fullPath; } return mappingFilePath; } /** * Executes file-related post-import actions with rate limiting. * Wraps the existing createFileAndUpdateField action with proper error handling and rate limiting. * * @param context - The context containing document and collection information * @param postImportActions - The post-import actions to execute */ async executeFileActions( context: any, postImportActions: any[] ): Promise<void> { const fileActions = postImportActions.filter( action => action.action === "createFileAndUpdateField" ); if (fileActions.length === 0) { return; } // Execute file actions with rate limiting const filePromises = fileActions.map(action => this.rateLimitManager.fileUpload(() => this.executeFileAction(action, context)) ); try { await Promise.allSettled(filePromises); } catch (error) { logger.error(`Error executing file actions for context: ${JSON.stringify(context, null, 2)}`, error); } } /** * Executes a single file action with proper error handling. * * @param action - The file action to execute * @param context - The context for template resolution */ private async executeFileAction(action: any, context: any): Promise<void> { try { // Resolve parameters using existing template resolution const resolvedParams = action.params.map((param: any) => this.importDataActions.resolveTemplate(param, context, context) ); // Execute the createFileAndUpdateField action await this.importDataActions.executeAction( action.action, resolvedParams, context, context ); } catch (error) { logger.error( `Failed to execute file action '${action.action}' with params: ${JSON.stringify(action.params, null, 2)}`, error ); // Don't throw - we want to continue with other file operations } } /** * Validates that file paths exist before import begins. * Provides early validation to catch file issues before processing starts. * * @param attributeMappings - The attribute mappings to validate * @param context - The context for template resolution * @param item - The item for template resolution * @returns Array of validation errors (empty if all valid) */ validateFilePaths( attributeMappings: AttributeMappings, context: any, item: any ): string[] { const errors: string[] = []; for (const mapping of attributeMappings) { if (mapping.fileData) { try { let filePath = this.importDataActions.resolveTemplate( mapping.fileData.path, context, item ); // Skip URL validation (URLs are handled by the action itself) if (filePath.toLowerCase().startsWith("http")) { continue; } // Check if local file exists const resolvedPath = this.resolveLocalFilePath(filePath); if (!fs.existsSync(resolvedPath)) { errors.push( `File not found for mapping '${mapping.targetKey}': ${resolvedPath}` ); } } catch (error) { errors.push( `Error validating file path for mapping '${mapping.targetKey}': ${error}` ); } } } return errors; } /** * Gets file statistics for import planning. * Helps estimate import time and resource requirements. * * @param attributeMappings - The attribute mappings to analyze * @param items - The items that will be imported * @returns File statistics object */ getFileStatistics(attributeMappings: AttributeMappings, items: any[]): { totalFiles: number; urlFiles: number; localFiles: number; estimatedSize: number; } { let totalFiles = 0; let urlFiles = 0; let localFiles = 0; let estimatedSize = 0; const fileAttributeMappings = attributeMappings.filter(mapping => mapping.fileData); for (const item of items) { for (const mapping of fileAttributeMappings) { totalFiles++; try { const filePath = this.importDataActions.resolveTemplate( mapping.fileData!.path, {}, item ); if (filePath.toLowerCase().startsWith("http")) { urlFiles++; } else { localFiles++; try { const resolvedPath = this.resolveLocalFilePath(filePath); if (fs.existsSync(resolvedPath)) { const stats = fs.statSync(resolvedPath); estimatedSize += stats.size; } } catch (error) { // Ignore errors for statistics } } } catch (error) { // Ignore errors for statistics } } } return { totalFiles, urlFiles, localFiles, estimatedSize }; } }