UNPKG

9.98 kBPlain TextView Raw
1import * as Path from 'path';
2import { mkdirp, glob } from 'fs-extra-plus';
3import { Readable, Writable } from 'stream';
4import * as mime from 'mime-types';
5
6export interface BucketFile {
7 bucket: Bucket;
8 path: string;
9 size?: number;
10 updated?: string;
11 contentType?: string;
12 local?: string; // optional local file path
13}
14
15export type BucketFileDeleted = BucketFile & { deleted: boolean };
16
17// Note: right now use generic default with F (file) any
18export interface Bucket<F = any> {
19 type: string;
20 name: string;
21
22 /** Return the path of a cloud "file object" */
23 getPath(obj: F): string;
24
25 exists(path: string): Promise<boolean>;
26
27 /**
28 * Get and return a BucketFile for a given path (will do a cloud bucket query).
29 * Returns null if not found. Throw exception if any other exception than notfound.
30 */
31 getFile(path: String): Promise<BucketFile | null>;
32
33 list(prefixOrGlob?: String): Promise<BucketFile[]>;
34
35 /**
36 * Will copy one or more file to a destination file (then require single match, i.e. no glob),
37 * or to a destination folder.
38 * @param prefixOrGlob full file name, or prefix or glob. If multiple match, to need dest must be a dir (end with '/')
39 * @param dest can be dir path (copying multiple file and preserving filename and relative dir structure), or full file name (require single match)
40 */
41 copy(prefixOrGlob: string, dest: string | BucketFile): Promise<void>;
42
43 /**
44 * Download one or more remote bucket file to a local file or a folder structure.
45 * @param prefixOrGlob
46 * @param localDir
47 * If end with '/' then all files from the prefixOrGlob will be downloaded with their originial filename (and relative folder structure).
48 * Otherwise, if full file name, then, make sure there is onyl one matching bucket source, and copy to this file destination (to rename on download)
49 */
50 download(prefixOrGlob: string, localPath: string): Promise<BucketFile[]>
51
52 downloadAsText(path: string): Promise<string>
53
54 upload(localPath: string, path: string): Promise<BucketFile[]>;
55
56 uploadContent(path: string, content: string): Promise<void>;
57
58 createReadStream(path: string): Promise<Readable>;
59
60 createWriteStream(path: string): Promise<Writable>;
61
62 /**
63 * Delete a single file.
64 * @param path
65 */
66 delete(path: string): Promise<boolean>
67
68 deleteAll(files: BucketFile[]): Promise<BucketFileDeleted[]>
69
70}
71
72
73//#region ---------- Common Bucket Utils ----------
74/**
75 * Build the full destination path from the local path name and the destPath
76 * - If `destPath` ends with `/`, then baseName of `localPath` is concatenated.
77 * - Otherwise, `destPath` is the fullDestPath.
78 *
79 * @throws exception if destPath is not present.
80 */
81export function buildFullDestPath(localPath: string, destPath: string) {
82
83 // we do not have a dest path, throw error
84 if (!destPath) {
85 throw new Error('No depthPath');
86 }
87
88 let fullDestPath: string;
89
90 // if it is a folder, we just concatinate the base name
91 if (destPath.endsWith('/')) {
92 const srcBaseName = Path.basename(localPath);
93 fullDestPath = destPath + srcBaseName;
94 }
95 // if the destPath is not a folder, assume it is the new file name.
96 else {
97 fullDestPath = destPath;
98 }
99
100 return fullDestPath;
101}
102
103/**
104 * Return a clean prefix and glob when defined in the string. Clean prefix, meaning, glob less one,
105 * that can be passed to most cloud storage api.
106 *
107 * @param prefixOrGlob undefined, null, e.g., 'some-prefix', 'folder/', 'folder/glob-pattern.*'
108 * @returns {prefix, glob, baseDir}
109 * - prefix is the first characters unitl the first glob character ('*')
110 * - glob is prefixOrGlob value if it is a glob, otherwise undefined.
111 * - baseDir is the eventual longest directory path without any glob char (ending with '/')
112 */
113export function parsePrefixOrGlob(prefixOrGlob?: string) {
114 let glob: string | undefined;
115 let prefix: string | undefined;
116 let baseDir: string | undefined;
117
118 if (prefixOrGlob && prefixOrGlob.length > 0) {
119 const firstWildIdx = prefixOrGlob.indexOf('*');
120 // if it has a '*' then it is a pattern
121 if (firstWildIdx > 0) {
122 glob = prefixOrGlob;
123 prefix = prefixOrGlob.substring(0, firstWildIdx);
124 }
125 // otherwise, it is just a
126 else {
127 prefix = prefixOrGlob;
128 }
129 }
130
131 if (prefix) {
132 const lastSlashIdx = prefix.lastIndexOf('/');
133 if (lastSlashIdx > -1) {
134 baseDir = prefix.substring(0, lastSlashIdx + 1);
135 }
136 }
137
138 return { prefix, glob, baseDir };
139}
140
141//// Common DOWNLOAD
142
143type ItemDownloadFn<F> = (object: F, localPath: string) => Promise<void>;
144
145export async function commonBucketDownload<F>(bucket: Bucket, cloudFiles: F[],
146 pathOrGlob: string, localPath: string,
147 downloadr: ItemDownloadFn<F>): Promise<BucketFile[]> {
148
149 const isLocalPathDir = localPath.endsWith('/');
150
151 // If not a local directory, make sure we have only one file.
152 // TODO: might check if the pathOrGlob is a glob as well to prevent it (in case there is only one match)
153 if (!isLocalPathDir && cloudFiles.length > 1) {
154 throw new Error(`Cannot copy multiple files ${pathOrGlob} to the same local file ${localPath}. Download to a directory (end with '/') to download multipel file.`);
155 }
156 const files: BucketFile[] = [];
157 const { baseDir } = parsePrefixOrGlob(pathOrGlob);
158
159 for (let cf of cloudFiles) {
160 const remotePath = bucket.getPath(cf);
161
162 const localFilePath = (isLocalPathDir) ? getDestPath(baseDir, remotePath, localPath) : localPath;
163
164 const localPathDir = Path.dirname(localFilePath);
165 await mkdirp(localPathDir);
166 process.stdout.write(`Downloading ${bucket.type}://${bucket.name}/${remotePath} to ${localFilePath}`);
167
168 try {
169 await downloadr(cf, localFilePath);
170 process.stdout.write(` - DONE\n`);
171 const file = { bucket, path: remotePath, size: -1, local: localFilePath };
172 files.push(file);
173 } catch (ex) {
174 process.stdout.write(` - FAIL - ABORT - Cause: ${ex}\n`);
175 throw ex;
176 }
177 }
178
179 return files;
180}
181
182//// COMMON UPLOAD
183type ItemUploadFn = (localFilePath: string, remoteFilePath: string, contentType?: string) => Promise<BucketFile>;
184
185export async function commonBucketUpload<F>(bucket: Bucket, localFileOrDirOrGlob: string,
186 remotePath: string,
187 uploadr: ItemUploadFn): Promise<BucketFile[]> {
188
189 const bucketFiles: BucketFile[] = [];
190
191 if (localFileOrDirOrGlob.endsWith('/')) {
192 localFileOrDirOrGlob = localFileOrDirOrGlob + '**/*.*';
193 }
194 const isLocalGlob = localFileOrDirOrGlob.includes('*');
195
196 const { baseDir } = parsePrefixOrGlob(localFileOrDirOrGlob);
197
198 const localFiles = await glob(localFileOrDirOrGlob);
199
200 for (const localPath of localFiles) {
201 // if we have an localFileExpression (globs), then, we build the fullDestPath relative to the baseDir of the glob (last / before the first *)
202 const fullDestPath = (isLocalGlob) ? getDestPath(baseDir, localPath, remotePath) : buildFullDestPath(localPath, remotePath);
203 const contentType = getContentType(fullDestPath);
204 process.stdout.write(`Uploading file ${localPath} to ${bucket.type}://${bucket.name}/${fullDestPath}`);
205 try {
206 const bucketFile = await uploadr(localPath, fullDestPath, contentType);
207 bucketFiles.push(bucketFile);
208 process.stdout.write(` - DONE\n`);
209 } catch (ex) {
210 process.stdout.write(` - FAIL - ABORT - Cause: ${ex}\n`);
211 throw ex;
212 }
213 }
214
215
216 return bucketFiles;
217}
218
219//// COMMON COPY
220
221type ItemCopyFn<F> = (Object: F, destDir: BucketFile) => Promise<void>;
222
223export async function commonBucketCopy<F>(bucket: Bucket, cloudFiles: F[], pathOrGlob: string, dest: string | BucketFile,
224 copier: ItemCopyFn<F>) {
225 const destBucket = ((typeof dest === 'string') ? bucket : dest.bucket);
226 const destPath = (typeof dest === 'string') ? dest : dest.path;
227
228 const isDestPathDir = destPath.endsWith('/');
229
230 // If not a local directory, make sure we have only one file.
231 // TODO: might check if the pathOrGlob is a glob as well to prevent it (in case there is only one match)
232 if (!isDestPathDir && cloudFiles.length > 1) {
233 throw new Error(`Cannot copy multiple files ${pathOrGlob} to the same bucket file ${destPath}. Download to a directory (end with '/') to download multipel file.`);
234 }
235
236 const { baseDir } = parsePrefixOrGlob(pathOrGlob);
237 const files: BucketFile[] = [];
238
239 for (let cf of cloudFiles) {
240 const remotePath = bucket.getPath(cf);
241 const destFilePath = (isDestPathDir) ? getDestPath(baseDir, remotePath, destPath) : destPath;
242
243 process.stdout.write(`Copying ${bucket.type}://${bucket.name}/${remotePath} to ${bucket.type}://${destBucket.name}/${destFilePath}`);
244
245 try {
246 await copier(cf, { bucket: destBucket, path: destFilePath });
247 process.stdout.write(` - DONE\n`);
248
249 } catch (ex) {
250 process.stdout.write(` - FAIL - ABORT - Cause: ${ex}\n`);
251 throw ex;
252 }
253 }
254 return files;
255}
256
257export async function commonDeleteAll(bucket: Bucket, files: BucketFile[]): Promise<BucketFileDeleted[]> {
258 const filesInfo: BucketFileDeleted[] = [];
259
260 // validate that all files are same bucket
261 for (const file of files) {
262 // check if same bucket
263 if (file.bucket !== bucket) {
264 throw new Error(`Cannot delete file from another bucket ${bucket.name} should match file bucket ${file.bucket.name}`);
265 }
266 }
267
268 for (const file of files) {
269 const deleted = await bucket.delete(file.path);
270 filesInfo.push({ ...file, deleted })
271 }
272
273 return filesInfo;
274}
275//#endregion ---------- /Common Bucket Utils ----------
276
277function getDestPath(baseDir: string | undefined, remotePath: string, destPathDir: string) {
278 const baseName = Path.basename(remotePath);
279 const filePath = (baseDir) ? Path.relative(baseDir, remotePath) : baseName;
280 const destPath = `${destPathDir}${filePath}`;
281 return destPath;
282}
283
284export function getContentType(path: string) {
285 let ct = mime.contentType(path);
286 let contentType = (ct) ? ct : undefined;
287 return contentType;
288
289}
290
291
292/**
293 * NOT IMPLEMENTED YET
294 * Can be set in bucket constructor, or overrided per call.
295 */
296interface Options {
297 log: boolean | string; // (default true) true to log, string to log with a prefix
298 skipOnFatal: boolean | Function;
299 beforeAction: Function;
300 afterAction: Function;
301}