@rsweeten/dropbox-sync
Version:
Reusable Dropbox synchronization module with framework adapters
504 lines (435 loc) • 17.8 kB
text/typescript
import path from 'path'
import fs from 'fs'
import { Dropbox } from 'dropbox'
import type {
DropboxCredentials,
SyncMethods,
SyncOptions,
SyncResult,
SyncProgress,
ProgressCallback,
SocketMethods,
} from './types'
// Define interface for Dropbox file entries
interface DropboxFileEntry {
'.tag': string
path_display?: string
[key: string]: any
}
// Define interface for Dropbox API responses
interface DropboxListFolderResponse {
result: {
entries: DropboxFileEntry[]
has_more: boolean
cursor: string
}
}
interface DropboxDownloadResponse {
result: {
fileBlob?: Blob
fileBinary?: string
[key: string]: any
}
}
export function createSyncMethods(
getClient: () => Dropbox,
credentials: DropboxCredentials,
socket: SocketMethods
): SyncMethods {
let syncCancelled = false
/**
* Normalize path for consistent comparison between local and Dropbox paths
*/
function normalizePath(filePath: string): string {
// Remove any leading slashes for consistency, then add a single leading slash
return '/' + filePath.replace(/^\/+/, '').toLowerCase()
}
/**
* Convert blob/buffer types to Buffer
*/
async function blobToBuffer(data: any): Promise<Buffer> {
// If it's already a Buffer, return it directly
if (Buffer.isBuffer(data)) {
return data
}
// If it's a Blob, convert it to a Buffer
if (typeof Blob !== 'undefined' && data instanceof Blob) {
// In browser or when Blob has arrayBuffer method
if (data.arrayBuffer && typeof data.arrayBuffer === 'function') {
const arrayBuffer = await data.arrayBuffer()
return Buffer.from(arrayBuffer)
}
// Fallback for environments where arrayBuffer might not exist
return Buffer.from(String(data))
}
// If it's a plain ArrayBuffer
if (data instanceof ArrayBuffer) {
return Buffer.from(data)
}
// If it's a string
if (typeof data === 'string') {
return Buffer.from(data)
}
// For Jest mock objects that mimic Blob behavior
if (
data &&
typeof data === 'object' &&
'arrayBuffer' in data &&
typeof data.arrayBuffer === 'function'
) {
const arrayBuffer = await data.arrayBuffer()
return Buffer.from(arrayBuffer)
}
// If we don't know what it is, throw an error
throw new Error(`Unsupported data type: ${typeof data}`)
}
/**
* Recursively scan local directory for files to sync
*/
async function scanLocalDirectory(
dir: string,
baseDir = ''
): Promise<string[]> {
const files: string[] = []
try {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
const relativePath = path.join(baseDir, entry.name)
if (entry.isDirectory()) {
files.push(
...(await scanLocalDirectory(fullPath, relativePath))
)
} else if (
/\.(jpg|jpeg|png|gif|bmp|webp|svg|json)$/i.test(entry.name)
) {
// Only include image files by default
files.push('/' + relativePath.replace(/\\/g, '/'))
}
}
} catch (error) {
console.error(`Error scanning directory ${dir}:`, error)
}
return files
}
/**
* Recursively scan Dropbox folder for files
*/
async function scanDropboxFolder(
dropboxPath: string = ''
): Promise<string[]> {
const dropbox = getClient()
const files: string[] = []
async function fetchFolderContents(path: string) {
try {
const response = (await dropbox.filesListFolder({
path,
recursive: true,
})) as unknown as DropboxListFolderResponse
for (const entry of response.result.entries) {
if (
entry['.tag'] === 'file' &&
entry.path_display &&
/\.(jpg|jpeg|png|gif|bmp|webp|svg|json)$/i.test(
entry.path_display
)
) {
files.push(entry.path_display)
}
}
if (response.result.has_more) {
await continueListing(response.result.cursor)
}
} catch (error) {
console.error('Error fetching Dropbox folder contents:', error)
throw error
}
}
async function continueListing(cursor: string) {
const response = (await dropbox.filesListFolderContinue({
cursor: cursor,
})) as unknown as DropboxListFolderResponse
for (const entry of response.result.entries) {
if (
entry['.tag'] === 'file' &&
entry.path_display &&
/\.(jpg|jpeg|png|gif|bmp|webp|svg|json)$/i.test(
entry.path_display
)
) {
files.push(entry.path_display)
}
}
if (response.result.has_more) {
await continueListing(response.result.cursor)
}
}
await fetchFolderContents(dropboxPath)
return files
}
return {
/**
* Scan local directory for files
*/
async scanLocalFiles(dir?: string): Promise<string[]> {
if (!dir && typeof window === 'undefined') {
throw new Error(
'Local directory must be provided in Node.js environment'
)
}
const localDir = dir || path.join(process.cwd(), 'public', 'img')
return scanLocalDirectory(localDir)
},
/**
* Scan Dropbox folder for files
*/
async scanDropboxFiles(dir?: string): Promise<string[]> {
const dropboxDir = dir || ''
return scanDropboxFolder(dropboxDir)
},
/**
* Compare local and Dropbox files and create a sync queue
*/
async createSyncQueue(
options?: Partial<SyncOptions>
): Promise<{ uploadQueue: string[]; downloadQueue: string[] }> {
const localDir =
options?.localDir || path.join(process.cwd(), 'public', 'img')
const dropboxDir = options?.dropboxDir || ''
// Use the public API methods instead of internal functions
const localFiles = await this.scanLocalFiles(localDir)
const dropboxFiles = await this.scanDropboxFiles(dropboxDir)
// Normalize paths for comparison
const normalizedDropboxFiles = dropboxFiles.map(normalizePath)
const normalizedLocalFiles = localFiles.map(normalizePath)
// Files to upload (in local but not in Dropbox)
const uploadQueue = localFiles.filter((localFile) => {
const normalizedLocalFile = normalizePath(localFile)
return !normalizedDropboxFiles.includes(normalizedLocalFile)
})
// Files to download (in Dropbox but not in local)
const downloadQueue = dropboxFiles.filter((dropboxFile) => {
const normalizedDropboxFile = normalizePath(dropboxFile)
return !normalizedLocalFiles.includes(normalizedDropboxFile)
})
return { uploadQueue, downloadQueue }
},
/**
* Synchronize files between local filesystem and Dropbox
*/
async syncFiles(options?: Partial<SyncOptions>): Promise<SyncResult> {
const localDir =
options?.localDir || path.join(process.cwd(), 'public', 'img')
const dropboxDir = options?.dropboxDir || ''
const progressCallback = options?.progressCallback
syncCancelled = false
const result: SyncResult = {
uploaded: [],
downloaded: [],
errors: [],
}
// Report initial progress
const reportProgress = (progress: SyncProgress) => {
if (progressCallback) {
progressCallback(progress)
}
// Also emit via Socket.IO if available
if (progress.progress !== undefined && progress.message) {
socket.emit('sync:progress', {
type: progress.type || 'progress',
message: progress.message,
progress: progress.progress,
socketId: 'dropbox-sync-module',
})
}
}
reportProgress({
stage: 'init',
progress: 0,
message: 'Starting Dropbox synchronization',
})
try {
// Get the Dropbox client
const dropbox = getClient()
// Get files to sync
reportProgress({
stage: 'listing',
message: 'Analyzing files to sync...',
progress: 0,
})
const { uploadQueue, downloadQueue } =
await this.createSyncQueue({ localDir, dropboxDir })
const totalTasks = uploadQueue.length + downloadQueue.length
// Socket notification about queue
socket.emit('sync:queue', {
total: totalTasks,
totalUploads: uploadQueue.length,
totalDownloads: downloadQueue.length,
})
if (totalTasks === 0) {
reportProgress({
stage: 'complete',
progress: 100,
message: 'All files are already in sync',
})
socket.emit('sync:complete', {
message: 'All files are already in sync',
})
return result
}
let completedTasks = 0
// Upload files to Dropbox
for (const file of uploadQueue) {
if (syncCancelled) {
break
}
try {
reportProgress({
stage: 'processing',
type: 'upload',
current: completedTasks + 1,
total: totalTasks,
progress: Math.round(
(completedTasks / totalTasks) * 100
),
message: `Uploading: ${file}`,
item: file,
})
// Upload logic
const localFilePath = path.join(
localDir,
file.replace(/^\/+/, '')
)
const dropboxFilePath = '/' + file.replace(/^\/+/, '')
const fileContent = fs.readFileSync(localFilePath)
await dropbox.filesUpload({
path: dropboxFilePath,
contents: fileContent,
mode: { '.tag': 'overwrite' },
})
result.uploaded.push(file)
completedTasks++
} catch (error) {
result.errors.push({ file, error: error as Error })
console.error(`Error uploading file ${file}:`, error)
}
}
// Download files from Dropbox
for (const file of downloadQueue) {
if (syncCancelled) {
break
}
try {
reportProgress({
stage: 'processing',
type: 'download',
current: completedTasks + 1,
total: totalTasks,
progress: Math.round(
(completedTasks / totalTasks) * 100
),
message: `Downloading: ${file}`,
item: file,
})
// Download logic
const dropboxFilePath = file
const localRelativePath = file.replace(/^\/+/, '')
const localFilePath = path.join(
localDir,
localRelativePath
)
// Download the file from Dropbox with proper error handling
const response = (await dropbox.filesDownload({
path: dropboxFilePath,
})) as unknown as DropboxDownloadResponse
// Get file content - handle different possible response formats
let fileContent: Buffer
const responseResult = response.result
if (responseResult.fileBlob) {
fileContent = await blobToBuffer(
responseResult.fileBlob
)
} else if (responseResult.fileBinary) {
fileContent = Buffer.from(
responseResult.fileBinary,
'binary'
)
} else {
// For Node.js environment, Dropbox might return content in different properties
const contentKey = Object.keys(responseResult).find(
(key) =>
[
'content',
'fileContent',
'file',
'data',
].includes(key)
)
if (contentKey) {
fileContent = Buffer.from(
responseResult[contentKey]
)
} else {
throw new Error(
`Could not find file content in Dropbox response for ${dropboxFilePath}`
)
}
}
// Create directory if it doesn't exist
const dirPath = path.dirname(localFilePath)
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
// Write the file to disk
fs.writeFileSync(localFilePath, fileContent)
result.downloaded.push(file)
completedTasks++
} catch (error) {
result.errors.push({ file, error: error as Error })
console.error(`Error downloading file ${file}:`, error)
// Emit error but continue with the next file
socket.emit('sync:error', {
message: `Error downloading ${file}: ${
(error as Error).message
}`,
continue: true,
})
}
}
// Report completion
reportProgress({
stage: 'complete',
progress: 100,
message: 'Synchronization completed successfully',
})
// Sync complete notification
socket.emit('sync:complete', {
message: 'Synchronization completed successfully',
stats: {
uploaded: result.uploaded.length,
downloaded: result.downloaded.length,
},
})
} catch (error) {
reportProgress({
stage: 'error',
message: `Error: ${(error as Error).message}`,
})
// Emit error notification
socket.emit('sync:error', {
message: (error as Error).message,
})
// Re-throw the error for handling by the caller
throw error
}
return result
},
/**
* Cancel an ongoing synchronization
*/
cancelSync() {
syncCancelled = true
socket.emit('sync:cancel', {})
},
}
}