webdav-backup
Version:
A simple, fast, and efficient tool for backing up files to a WebDAV server
322 lines (270 loc) • 11.8 kB
text/typescript
import { FileInfo, ScanResult, UploadState } from '../interfaces/file-scanner';
import { Verbosity } from '../interfaces/logger';
import { WebDAVConnectivityOptions, WebDAVServiceOptions, WebDAVClientOptions, UploadResult, DirectoryResult } from '../interfaces/webdav';
/**
* WebDAV Uploader
* Handles file uploads to the WebDAV server with improved modularity
*/
import path from 'path';
import os from 'os';
import * as logger from '../../utils/logger';
import WebDAVService from '../webdav/webdav-service';
import { HashCache } from './hash-cache';
import { ProgressTracker } from './progress-tracker';
import { FileUploadManager } from './file-upload-manager';
/**
* WebDAV Uploader class with improved modularity
*/
export default class Uploader {
/**
* Create a new WebDAV Uploader
* @param {string} webdavUrl - The WebDAV server URL
* @param {number} concurrentUploads - Number of concurrent uploads
* @param {string} targetDir - The target directory on the WebDAV server
* @param {number} verbosity - Verbosity level
*/
constructor(webdavUrl, concurrentUploads, targetDir = '', verbosity = logger.Verbosity.Normal) {
this.webdavUrl = webdavUrl;
this.targetDir = targetDir.trim().replace(/^\/+|\/+$/g, '');
this.verbosity = verbosity;
// Initialize services
this.webdavService = new WebDAVService(webdavUrl, verbosity);
this.hashCache = new HashCache(
path.join(os.tmpdir(), 'webdav-backup-hash-cache.json'),
verbosity
);
this.progressTracker = new ProgressTracker(verbosity);
this.uploadManager = new FileUploadManager(
concurrentUploads,
this.handleFileUpload.bind(this),
verbosity
);
// Load hash cache on construction
this.hashCache.load();
// Initialize state
this.fileScanner = null;
// Track successfully uploaded files to avoid duplicate messages
this.uploadedFiles = new Set();
// Store normalized path info for all files
this.normalizedPaths = new Map();
// Track directories that have already been created in this session
this.createdDirectories = new Set();
}
/**
* Set the file scanner to use for recording uploaded files
* @param {FileScanner} scanner - The file scanner instance
*/
setFileScanner(scanner) {
this.fileScanner = scanner;
logger.verbose('File scanner set', this.verbosity);
}
/**
* Create directory structure if needed and track which directories have been created
* @param {string} directory - Directory to create
* @returns {Promise<boolean>} True if successful
*/
async ensureDirectoryExists(directory) {
// Skip if no directory or empty
if (!directory) return true;
// Skip if we've already created this directory in this session
if (this.createdDirectories.has(directory)) {
logger.verbose(`Directory already created in this session: ${directory}`, this.verbosity);
return true;
}
// Create the directory structure
const result = await this.webdavService.createDirectoryStructure(directory);
// If successful, add to our tracking set
if (result) {
this.createdDirectories.add(directory);
}
return result;
}
/**
* Handle the upload of a single file
* @param {Object} fileInfo - File information object
* @returns {Promise<{success: boolean, filePath: string}>} Upload result
*/
async handleFileUpload(fileInfo) {
try {
// Check if we've already uploaded this file in this session
if (this.uploadedFiles.has(fileInfo.relativePath)) {
logger.verbose(`File ${fileInfo.relativePath} already uploaded in this session, skipping`, this.verbosity);
return { success: true, filePath: fileInfo.relativePath };
}
// Check if file has changed - use flag from file scanner if available
// The scanner already checked using the same hash cache
if (fileInfo.hasChanged === false) {
logger.verbose(`File ${fileInfo.relativePath} has not changed, skipping upload`, this.verbosity);
this.progressTracker.recordSuccess();
return { success: true, filePath: fileInfo.relativePath };
}
// For files not pre-checked, use the hash cache
if (fileInfo.hasChanged === null) {
const hasChanged = await this.hashCache.hasChanged(fileInfo.absolutePath);
if (!hasChanged) {
logger.verbose(`File ${fileInfo.relativePath} has not changed, skipping upload`, this.verbosity);
this.progressTracker.recordSuccess();
return { success: true, filePath: fileInfo.relativePath };
}
}
logger.verbose(`File ${fileInfo.relativePath} has changed, uploading...`, this.verbosity);
// Create target directory if it doesn't exist
if (this.targetDir) {
await this.ensureDirectoryExists(this.targetDir);
}
// Get or create normalized path info
let pathInfo = this.normalizedPaths.get(fileInfo.relativePath);
if (!pathInfo) {
// Normalize the relative path to use forward slashes
const normalizedPath = fileInfo.relativePath.replace(/\\/g, '/');
// Extract directory from the relative path
const lastSlashIndex = normalizedPath.lastIndexOf('/');
const directory = lastSlashIndex > 0 ? normalizedPath.substring(0, lastSlashIndex) : '';
// Construct the target path
const targetPath = this.targetDir
? `${this.targetDir}/${normalizedPath}`
: normalizedPath;
// Create full directory path
const fullDirectoryPath = directory
? (this.targetDir ? `${this.targetDir}/${directory}` : directory)
: this.targetDir;
// Store all the path info to avoid recalculating
pathInfo = {
normalizedPath,
directory,
targetPath,
fullDirectoryPath
};
// Cache the normalized path info
this.normalizedPaths.set(fileInfo.relativePath, pathInfo);
}
// Create directory structure if needed
if (pathInfo.directory) {
logger.verbose(`Ensuring directory structure exists for file: ${pathInfo.directory}`, this.verbosity);
await this.ensureDirectoryExists(pathInfo.fullDirectoryPath);
}
// Upload the file
const result = await this.webdavService.uploadFile(fileInfo.absolutePath, pathInfo.targetPath);
if (result.success) {
// Track that we've uploaded this file to avoid duplicate messages
this.uploadedFiles.add(fileInfo.relativePath);
// Log success
logger.success(`Successfully uploaded ${fileInfo.relativePath}`, this.verbosity);
// Update file scanner if available
if (this.fileScanner) {
this.fileScanner.updateFileState(fileInfo.relativePath, fileInfo.checksum);
}
this.progressTracker.recordSuccess();
return { success: true, filePath: fileInfo.relativePath };
} else {
logger.error(`Failed to upload ${fileInfo.relativePath}: ${result.output}`);
this.progressTracker.recordFailure();
return { success: false, filePath: fileInfo.relativePath };
}
} catch (error) {
logger.error(`Error uploading file ${fileInfo.relativePath}: ${error.message}`);
this.progressTracker.recordFailure();
return { success: false, filePath: fileInfo.relativePath };
}
}
/**
* Start the upload process
* @param {Array} filesToUpload - Array of files to upload
* @returns {Promise<void>}
*/
async startUpload(filesToUpload) {
if (!this.webdavUrl) {
logger.error("WebDAV URL is not available. Setup may have failed.");
return;
}
// Check connectivity first
const connected = await this.webdavService.checkConnectivity();
if (!connected) {
logger.error("Failed to connect to WebDAV server. Upload cannot proceed.");
return;
}
// Create the target directory structure if needed
if (this.targetDir) {
const dirResult = await this.ensureDirectoryExists(this.targetDir);
logger.verbose(`Target directory result: ${dirResult ? 'success' : 'failed'}`, this.verbosity);
}
if (filesToUpload.length === 0) {
logger.success("All files are up to date.", this.verbosity);
return;
}
// Reset tracking sets for new upload session
this.uploadedFiles.clear();
this.createdDirectories.clear();
// Extract and pre-create all unique directories
if (filesToUpload.length > 1) {
const uniqueDirectories = new Set();
// Analyze files and collect unique directories
for (const fileInfo of filesToUpload) {
let pathInfo = this.normalizedPaths.get(fileInfo.relativePath);
if (!pathInfo) {
// Normalize the relative path
const normalizedPath = fileInfo.relativePath.replace(/\\/g, '/');
// Extract directory from the relative path
const lastSlashIndex = normalizedPath.lastIndexOf('/');
const directory = lastSlashIndex > 0 ? normalizedPath.substring(0, lastSlashIndex) : '';
// Create full directory path
const fullDirectoryPath = directory
? (this.targetDir ? `${this.targetDir}/${directory}` : directory)
: this.targetDir;
if (directory) {
uniqueDirectories.add(fullDirectoryPath);
}
// Store all the path info to avoid recalculating
pathInfo = {
normalizedPath,
directory,
targetPath: this.targetDir ? `${this.targetDir}/${normalizedPath}` : normalizedPath,
fullDirectoryPath
};
// Cache the normalized path info
this.normalizedPaths.set(fileInfo.relativePath, pathInfo);
} else if (pathInfo.directory) {
uniqueDirectories.add(pathInfo.fullDirectoryPath);
}
}
// Create all unique directories first
logger.verbose(`Pre-creating ${uniqueDirectories.size} unique directories...`, this.verbosity);
const directories = Array.from(uniqueDirectories);
for (const dir of directories) {
await this.ensureDirectoryExists(dir);
}
}
// Show starting message before initializing progress tracker
// This ensures it appears on its own line
logger.info(`Starting parallel upload with ${this.uploadManager.maxConcurrency} concurrent uploads...`, this.verbosity);
// Small delay to ensure the message is displayed before progress bar
await new Promise(resolve => setTimeout(resolve, 50));
// Initialize progress tracker
this.progressTracker.initialize(filesToUpload.length);
this.progressTracker.startProgressUpdates();
// Set up upload manager
this.uploadManager.setQueue(filesToUpload);
try {
// Start upload and wait for completion
await new Promise((resolve) => {
this.uploadManager.start(resolve);
});
// Final update to state file if we have a file scanner
if (this.fileScanner) {
this.fileScanner.recordCompletion();
await this.fileScanner.saveState();
}
// Show result summary
this.progressTracker.displaySummary();
} catch (error) {
logger.error(`\nUpload process failed: ${error}`);
// Save current state if possible
if (this.fileScanner) {
await this.fileScanner.saveState();
}
} finally {
// Stop progress updates
this.progressTracker.stopProgressUpdates();
}
}
}