hazo_files 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/db_schema.mdc +0 -0
- package/.cursor/rules/design.mdc +0 -0
- package/.cursor/rules/general.mdc +0 -0
- package/CHANGE_LOG.md +341 -0
- package/CLAUDE.md +926 -0
- package/README.md +929 -0
- package/SETUP_CHECKLIST.md +931 -0
- package/TECHDOC.md +325 -0
- package/dist/index.d.mts +1031 -0
- package/dist/index.d.ts +1031 -0
- package/dist/index.js +2457 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2333 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ui/index.js +4054 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/index.mjs +3982 -0
- package/dist/ui/index.mjs.map +1 -0
- package/docs/ADDING_MODULES.md +964 -0
- package/hazo_files_config.ini +31 -0
- package/package.json +83 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
# Adding Custom Storage Modules
|
|
2
|
+
|
|
3
|
+
This guide walks you through creating custom storage modules for hazo_files. By implementing the `StorageModule` interface, you can integrate any storage backend (S3, Dropbox, OneDrive, WebDAV, etc.) with the unified hazo_files API.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
1. [Module Architecture](#module-architecture)
|
|
8
|
+
2. [Quick Start](#quick-start)
|
|
9
|
+
3. [Step-by-Step Guide](#step-by-step-guide)
|
|
10
|
+
4. [Interface Reference](#interface-reference)
|
|
11
|
+
5. [Best Practices](#best-practices)
|
|
12
|
+
6. [Testing](#testing)
|
|
13
|
+
7. [Examples](#examples)
|
|
14
|
+
|
|
15
|
+
## Module Architecture
|
|
16
|
+
|
|
17
|
+
### Overview
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Your Custom Module
|
|
21
|
+
│
|
|
22
|
+
├── Extends BaseStorageModule
|
|
23
|
+
│ ├── Common utilities (path, results)
|
|
24
|
+
│ ├── Configuration management
|
|
25
|
+
│ └── Tree building logic
|
|
26
|
+
│
|
|
27
|
+
├── Implements StorageModule interface
|
|
28
|
+
│ ├── All required operations
|
|
29
|
+
│ └── Provider-specific logic
|
|
30
|
+
│
|
|
31
|
+
└── Integrates with storage backend
|
|
32
|
+
└── S3, Dropbox, custom API, etc.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Key Components
|
|
36
|
+
|
|
37
|
+
1. **BaseStorageModule**: Abstract base class providing common functionality
|
|
38
|
+
2. **StorageModule Interface**: Contract defining all required operations
|
|
39
|
+
3. **Provider-Specific Logic**: Your implementation using the storage backend API
|
|
40
|
+
4. **Configuration**: Provider-specific config added to `HazoFilesConfig`
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
### Minimal Example
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { BaseStorageModule } from 'hazo_files';
|
|
48
|
+
import type { StorageProvider, HazoFilesConfig, OperationResult, FileItem } from 'hazo_files';
|
|
49
|
+
|
|
50
|
+
// 1. Define your storage provider type
|
|
51
|
+
export type CustomStorageProvider = 'my_storage';
|
|
52
|
+
|
|
53
|
+
// 2. Define configuration interface
|
|
54
|
+
export interface MyStorageConfig {
|
|
55
|
+
apiKey: string;
|
|
56
|
+
endpoint: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 3. Extend BaseStorageModule
|
|
60
|
+
export class MyStorageModule extends BaseStorageModule {
|
|
61
|
+
readonly provider: StorageProvider = 'my_storage' as StorageProvider;
|
|
62
|
+
private apiKey: string = '';
|
|
63
|
+
private endpoint: string = '';
|
|
64
|
+
|
|
65
|
+
async initialize(config: HazoFilesConfig): Promise<void> {
|
|
66
|
+
await super.initialize(config);
|
|
67
|
+
const myConfig = this.getProviderConfig<MyStorageConfig>();
|
|
68
|
+
this.apiKey = myConfig.apiKey;
|
|
69
|
+
this.endpoint = myConfig.endpoint;
|
|
70
|
+
// Initialize your storage client
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async uploadFile(source, remotePath, options?): Promise<OperationResult<FileItem>> {
|
|
74
|
+
this.ensureInitialized();
|
|
75
|
+
try {
|
|
76
|
+
// Implement upload logic
|
|
77
|
+
const fileItem = await this.performUpload(source, remotePath);
|
|
78
|
+
return this.successResult(fileItem);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return this.errorResult(`Upload failed: ${(error as Error).message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Implement other required methods...
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 4. Export factory function
|
|
88
|
+
export function createMyStorageModule(): MyStorageModule {
|
|
89
|
+
return new MyStorageModule();
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Registration
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { registerModule } from 'hazo_files';
|
|
97
|
+
import { createMyStorageModule } from './my-storage-module';
|
|
98
|
+
|
|
99
|
+
// Register your module
|
|
100
|
+
registerModule('my_storage', createMyStorageModule);
|
|
101
|
+
|
|
102
|
+
// Now you can use it
|
|
103
|
+
const fm = await createInitializedFileManager({
|
|
104
|
+
config: {
|
|
105
|
+
provider: 'my_storage',
|
|
106
|
+
my_storage: {
|
|
107
|
+
apiKey: 'your-api-key',
|
|
108
|
+
endpoint: 'https://api.example.com'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Step-by-Step Guide
|
|
115
|
+
|
|
116
|
+
### Step 1: Setup
|
|
117
|
+
|
|
118
|
+
Create a new file for your module:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
mkdir -p src/modules/my-storage
|
|
122
|
+
touch src/modules/my-storage/index.ts
|
|
123
|
+
touch src/modules/my-storage/client.ts
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Step 2: Define Types
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// src/modules/my-storage/types.ts
|
|
130
|
+
import type { StorageProvider } from 'hazo_files';
|
|
131
|
+
|
|
132
|
+
// Extend StorageProvider type
|
|
133
|
+
export type MyStorageProvider = 'my_storage';
|
|
134
|
+
|
|
135
|
+
// Configuration interface
|
|
136
|
+
export interface MyStorageConfig {
|
|
137
|
+
apiKey: string;
|
|
138
|
+
endpoint: string;
|
|
139
|
+
bucket?: string;
|
|
140
|
+
region?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Add to HazoFilesConfig
|
|
144
|
+
declare module 'hazo_files' {
|
|
145
|
+
interface HazoFilesConfig {
|
|
146
|
+
my_storage?: MyStorageConfig;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Step 3: Create Storage Client
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// src/modules/my-storage/client.ts
|
|
155
|
+
import axios from 'axios';
|
|
156
|
+
|
|
157
|
+
export class MyStorageClient {
|
|
158
|
+
private apiKey: string;
|
|
159
|
+
private endpoint: string;
|
|
160
|
+
|
|
161
|
+
constructor(apiKey: string, endpoint: string) {
|
|
162
|
+
this.apiKey = apiKey;
|
|
163
|
+
this.endpoint = endpoint;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async uploadFile(path: string, data: Buffer): Promise<any> {
|
|
167
|
+
const response = await axios.post(
|
|
168
|
+
`${this.endpoint}/files`,
|
|
169
|
+
data,
|
|
170
|
+
{
|
|
171
|
+
headers: {
|
|
172
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
173
|
+
'Content-Type': 'application/octet-stream',
|
|
174
|
+
'X-File-Path': path,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
return response.data;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async downloadFile(path: string): Promise<Buffer> {
|
|
182
|
+
const response = await axios.get(
|
|
183
|
+
`${this.endpoint}/files/${encodeURIComponent(path)}`,
|
|
184
|
+
{
|
|
185
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
186
|
+
responseType: 'arraybuffer',
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
return Buffer.from(response.data);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async listFiles(path: string): Promise<any[]> {
|
|
193
|
+
const response = await axios.get(
|
|
194
|
+
`${this.endpoint}/files`,
|
|
195
|
+
{
|
|
196
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
197
|
+
params: { path },
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
return response.data.files;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async deleteFile(path: string): Promise<void> {
|
|
204
|
+
await axios.delete(
|
|
205
|
+
`${this.endpoint}/files/${encodeURIComponent(path)}`,
|
|
206
|
+
{
|
|
207
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Add other methods as needed
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Step 4: Implement Module
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
// src/modules/my-storage/index.ts
|
|
220
|
+
import { BaseStorageModule } from '../../common/base-module';
|
|
221
|
+
import { FileNotFoundError, DirectoryNotFoundError } from '../../common/errors';
|
|
222
|
+
import { generateId, createFileItem, createFolderItem } from '../../common/utils';
|
|
223
|
+
import { getMimeType } from '../../common/mime-types';
|
|
224
|
+
import { MyStorageClient } from './client';
|
|
225
|
+
import type {
|
|
226
|
+
StorageProvider,
|
|
227
|
+
HazoFilesConfig,
|
|
228
|
+
FileItem,
|
|
229
|
+
FolderItem,
|
|
230
|
+
FileSystemItem,
|
|
231
|
+
OperationResult,
|
|
232
|
+
UploadOptions,
|
|
233
|
+
DownloadOptions,
|
|
234
|
+
MoveOptions,
|
|
235
|
+
RenameOptions,
|
|
236
|
+
ListOptions,
|
|
237
|
+
} from '../../types';
|
|
238
|
+
import type { MyStorageConfig } from './types';
|
|
239
|
+
|
|
240
|
+
export class MyStorageModule extends BaseStorageModule {
|
|
241
|
+
readonly provider: StorageProvider = 'my_storage' as StorageProvider;
|
|
242
|
+
private client: MyStorageClient | null = null;
|
|
243
|
+
|
|
244
|
+
async initialize(config: HazoFilesConfig): Promise<void> {
|
|
245
|
+
await super.initialize(config);
|
|
246
|
+
|
|
247
|
+
const myConfig = this.getProviderConfig<MyStorageConfig>();
|
|
248
|
+
this.client = new MyStorageClient(myConfig.apiKey, myConfig.endpoint);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async createDirectory(path: string): Promise<OperationResult<FolderItem>> {
|
|
252
|
+
this.ensureInitialized();
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const normalized = this.normalizePath(path);
|
|
256
|
+
|
|
257
|
+
// Call your storage API to create directory
|
|
258
|
+
await this.client!.createDirectory(normalized);
|
|
259
|
+
|
|
260
|
+
// Create FolderItem
|
|
261
|
+
const folder = createFolderItem({
|
|
262
|
+
id: generateId(),
|
|
263
|
+
name: this.getBaseName(normalized),
|
|
264
|
+
path: normalized,
|
|
265
|
+
createdAt: new Date(),
|
|
266
|
+
modifiedAt: new Date(),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return this.successResult(folder);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return this.errorResult(`Failed to create directory: ${(error as Error).message}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async removeDirectory(path: string, recursive = false): Promise<OperationResult> {
|
|
276
|
+
this.ensureInitialized();
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const normalized = this.normalizePath(path);
|
|
280
|
+
|
|
281
|
+
// Check if directory is empty (if not recursive)
|
|
282
|
+
if (!recursive) {
|
|
283
|
+
const contents = await this.client!.listFiles(normalized);
|
|
284
|
+
if (contents.length > 0) {
|
|
285
|
+
return this.errorResult('Directory is not empty');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await this.client!.deleteDirectory(normalized, recursive);
|
|
290
|
+
return this.successResult();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return this.errorResult(`Failed to remove directory: ${(error as Error).message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async uploadFile(
|
|
297
|
+
source: string | Buffer | ReadableStream,
|
|
298
|
+
remotePath: string,
|
|
299
|
+
options: UploadOptions = {}
|
|
300
|
+
): Promise<OperationResult<FileItem>> {
|
|
301
|
+
this.ensureInitialized();
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const normalized = this.normalizePath(remotePath);
|
|
305
|
+
const fileName = this.getBaseName(normalized);
|
|
306
|
+
|
|
307
|
+
// Convert source to Buffer
|
|
308
|
+
let buffer: Buffer;
|
|
309
|
+
if (typeof source === 'string') {
|
|
310
|
+
const fs = await import('fs');
|
|
311
|
+
buffer = await fs.promises.readFile(source);
|
|
312
|
+
} else if (Buffer.isBuffer(source)) {
|
|
313
|
+
buffer = source;
|
|
314
|
+
} else {
|
|
315
|
+
// ReadableStream to Buffer
|
|
316
|
+
const chunks: Uint8Array[] = [];
|
|
317
|
+
const reader = source.getReader();
|
|
318
|
+
while (true) {
|
|
319
|
+
const { done, value } = await reader.read();
|
|
320
|
+
if (done) break;
|
|
321
|
+
chunks.push(value);
|
|
322
|
+
}
|
|
323
|
+
buffer = Buffer.concat(chunks.map(c => Buffer.from(c)));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Upload to storage
|
|
327
|
+
const result = await this.client!.uploadFile(normalized, buffer);
|
|
328
|
+
|
|
329
|
+
// Track progress if callback provided
|
|
330
|
+
if (options.onProgress) {
|
|
331
|
+
options.onProgress(100, buffer.length, buffer.length);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Create FileItem
|
|
335
|
+
const fileItem = createFileItem({
|
|
336
|
+
id: result.id || generateId(),
|
|
337
|
+
name: fileName,
|
|
338
|
+
path: normalized,
|
|
339
|
+
size: buffer.length,
|
|
340
|
+
mimeType: getMimeType(fileName),
|
|
341
|
+
createdAt: new Date(result.created_at),
|
|
342
|
+
modifiedAt: new Date(result.modified_at),
|
|
343
|
+
metadata: result.metadata,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return this.successResult(fileItem);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return this.errorResult(`Upload failed: ${(error as Error).message}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async downloadFile(
|
|
353
|
+
remotePath: string,
|
|
354
|
+
localPath?: string,
|
|
355
|
+
options: DownloadOptions = {}
|
|
356
|
+
): Promise<OperationResult<Buffer | string>> {
|
|
357
|
+
this.ensureInitialized();
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const normalized = this.normalizePath(remotePath);
|
|
361
|
+
const buffer = await this.client!.downloadFile(normalized);
|
|
362
|
+
|
|
363
|
+
if (options.onProgress) {
|
|
364
|
+
options.onProgress(100, buffer.length, buffer.length);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (localPath) {
|
|
368
|
+
const fs = await import('fs');
|
|
369
|
+
const path = await import('path');
|
|
370
|
+
await fs.promises.mkdir(path.dirname(localPath), { recursive: true });
|
|
371
|
+
await fs.promises.writeFile(localPath, buffer);
|
|
372
|
+
return this.successResult(localPath);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return this.successResult(buffer);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
return this.errorResult(`Download failed: ${(error as Error).message}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async moveItem(
|
|
382
|
+
sourcePath: string,
|
|
383
|
+
destinationPath: string,
|
|
384
|
+
options: MoveOptions = {}
|
|
385
|
+
): Promise<OperationResult<FileSystemItem>> {
|
|
386
|
+
this.ensureInitialized();
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const normalizedSource = this.normalizePath(sourcePath);
|
|
390
|
+
const normalizedDest = this.normalizePath(destinationPath);
|
|
391
|
+
|
|
392
|
+
const result = await this.client!.moveFile(normalizedSource, normalizedDest);
|
|
393
|
+
|
|
394
|
+
// Convert result to FileSystemItem
|
|
395
|
+
const item = result.is_directory
|
|
396
|
+
? createFolderItem({
|
|
397
|
+
id: result.id,
|
|
398
|
+
name: this.getBaseName(normalizedDest),
|
|
399
|
+
path: normalizedDest,
|
|
400
|
+
createdAt: new Date(result.created_at),
|
|
401
|
+
modifiedAt: new Date(result.modified_at),
|
|
402
|
+
})
|
|
403
|
+
: createFileItem({
|
|
404
|
+
id: result.id,
|
|
405
|
+
name: this.getBaseName(normalizedDest),
|
|
406
|
+
path: normalizedDest,
|
|
407
|
+
size: result.size,
|
|
408
|
+
mimeType: result.mime_type,
|
|
409
|
+
createdAt: new Date(result.created_at),
|
|
410
|
+
modifiedAt: new Date(result.modified_at),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
return this.successResult(item);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
return this.errorResult(`Move failed: ${(error as Error).message}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async deleteFile(path: string): Promise<OperationResult> {
|
|
420
|
+
this.ensureInitialized();
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const normalized = this.normalizePath(path);
|
|
424
|
+
await this.client!.deleteFile(normalized);
|
|
425
|
+
return this.successResult();
|
|
426
|
+
} catch (error) {
|
|
427
|
+
return this.errorResult(`Delete failed: ${(error as Error).message}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async renameFile(
|
|
432
|
+
path: string,
|
|
433
|
+
newName: string,
|
|
434
|
+
options: RenameOptions = {}
|
|
435
|
+
): Promise<OperationResult<FileItem>> {
|
|
436
|
+
this.ensureInitialized();
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
const normalized = this.normalizePath(path);
|
|
440
|
+
const parentPath = this.getParentPath(normalized);
|
|
441
|
+
const newPath = this.joinPath(parentPath, newName);
|
|
442
|
+
|
|
443
|
+
const result = await this.client!.renameFile(normalized, newName);
|
|
444
|
+
|
|
445
|
+
const fileItem = createFileItem({
|
|
446
|
+
id: result.id,
|
|
447
|
+
name: newName,
|
|
448
|
+
path: newPath,
|
|
449
|
+
size: result.size,
|
|
450
|
+
mimeType: result.mime_type,
|
|
451
|
+
createdAt: new Date(result.created_at),
|
|
452
|
+
modifiedAt: new Date(result.modified_at),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
return this.successResult(fileItem);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
return this.errorResult(`Rename failed: ${(error as Error).message}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async renameFolder(
|
|
462
|
+
path: string,
|
|
463
|
+
newName: string,
|
|
464
|
+
options: RenameOptions = {}
|
|
465
|
+
): Promise<OperationResult<FolderItem>> {
|
|
466
|
+
this.ensureInitialized();
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const normalized = this.normalizePath(path);
|
|
470
|
+
const parentPath = this.getParentPath(normalized);
|
|
471
|
+
const newPath = this.joinPath(parentPath, newName);
|
|
472
|
+
|
|
473
|
+
const result = await this.client!.renameFolder(normalized, newName);
|
|
474
|
+
|
|
475
|
+
const folderItem = createFolderItem({
|
|
476
|
+
id: result.id,
|
|
477
|
+
name: newName,
|
|
478
|
+
path: newPath,
|
|
479
|
+
createdAt: new Date(result.created_at),
|
|
480
|
+
modifiedAt: new Date(result.modified_at),
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
return this.successResult(folderItem);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
return this.errorResult(`Rename failed: ${(error as Error).message}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async listDirectory(
|
|
490
|
+
path: string,
|
|
491
|
+
options: ListOptions = {}
|
|
492
|
+
): Promise<OperationResult<FileSystemItem[]>> {
|
|
493
|
+
this.ensureInitialized();
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const normalized = this.normalizePath(path);
|
|
497
|
+
const files = await this.client!.listFiles(normalized);
|
|
498
|
+
|
|
499
|
+
const items: FileSystemItem[] = files.map(file => {
|
|
500
|
+
const itemPath = this.joinPath(normalized, file.name);
|
|
501
|
+
|
|
502
|
+
if (file.is_directory) {
|
|
503
|
+
return createFolderItem({
|
|
504
|
+
id: file.id,
|
|
505
|
+
name: file.name,
|
|
506
|
+
path: itemPath,
|
|
507
|
+
createdAt: new Date(file.created_at),
|
|
508
|
+
modifiedAt: new Date(file.modified_at),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return createFileItem({
|
|
513
|
+
id: file.id,
|
|
514
|
+
name: file.name,
|
|
515
|
+
path: itemPath,
|
|
516
|
+
size: file.size,
|
|
517
|
+
mimeType: file.mime_type || getMimeType(file.name),
|
|
518
|
+
createdAt: new Date(file.created_at),
|
|
519
|
+
modifiedAt: new Date(file.modified_at),
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Apply filter if provided
|
|
524
|
+
const filtered = options.filter
|
|
525
|
+
? items.filter(options.filter)
|
|
526
|
+
: items;
|
|
527
|
+
|
|
528
|
+
return this.successResult(filtered);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
return this.errorResult(`List failed: ${(error as Error).message}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async getItem(path: string): Promise<OperationResult<FileSystemItem>> {
|
|
535
|
+
this.ensureInitialized();
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
const normalized = this.normalizePath(path);
|
|
539
|
+
const info = await this.client!.getFileInfo(normalized);
|
|
540
|
+
|
|
541
|
+
const item = info.is_directory
|
|
542
|
+
? createFolderItem({
|
|
543
|
+
id: info.id,
|
|
544
|
+
name: this.getBaseName(normalized),
|
|
545
|
+
path: normalized,
|
|
546
|
+
createdAt: new Date(info.created_at),
|
|
547
|
+
modifiedAt: new Date(info.modified_at),
|
|
548
|
+
})
|
|
549
|
+
: createFileItem({
|
|
550
|
+
id: info.id,
|
|
551
|
+
name: this.getBaseName(normalized),
|
|
552
|
+
path: normalized,
|
|
553
|
+
size: info.size,
|
|
554
|
+
mimeType: info.mime_type,
|
|
555
|
+
createdAt: new Date(info.created_at),
|
|
556
|
+
modifiedAt: new Date(info.modified_at),
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
return this.successResult(item);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
return this.errorResult(`Get item failed: ${(error as Error).message}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async exists(path: string): Promise<boolean> {
|
|
566
|
+
this.ensureInitialized();
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
const normalized = this.normalizePath(path);
|
|
570
|
+
return await this.client!.fileExists(normalized);
|
|
571
|
+
} catch {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Factory function
|
|
578
|
+
export function createMyStorageModule(): MyStorageModule {
|
|
579
|
+
return new MyStorageModule();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Export types
|
|
583
|
+
export type { MyStorageConfig } from './types';
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
### Step 5: Register Module
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
// src/modules/index.ts
|
|
590
|
+
import { createMyStorageModule } from './my-storage';
|
|
591
|
+
|
|
592
|
+
// Register module
|
|
593
|
+
registerModule('my_storage', createMyStorageModule);
|
|
594
|
+
|
|
595
|
+
// Export
|
|
596
|
+
export { MyStorageModule, createMyStorageModule } from './my-storage';
|
|
597
|
+
export type { MyStorageConfig } from './my-storage/types';
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### Step 6: Update Package Exports
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
// src/index.ts
|
|
604
|
+
export {
|
|
605
|
+
MyStorageModule,
|
|
606
|
+
createMyStorageModule,
|
|
607
|
+
} from './modules';
|
|
608
|
+
|
|
609
|
+
export type { MyStorageConfig } from './modules';
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
## Interface Reference
|
|
613
|
+
|
|
614
|
+
### Required Methods
|
|
615
|
+
|
|
616
|
+
Every storage module must implement these methods:
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
interface StorageModule {
|
|
620
|
+
// Lifecycle
|
|
621
|
+
readonly provider: StorageProvider;
|
|
622
|
+
initialize(config: HazoFilesConfig): Promise<void>;
|
|
623
|
+
|
|
624
|
+
// Directory operations
|
|
625
|
+
createDirectory(path: string): Promise<OperationResult<FolderItem>>;
|
|
626
|
+
removeDirectory(path: string, recursive?: boolean): Promise<OperationResult>;
|
|
627
|
+
|
|
628
|
+
// File operations
|
|
629
|
+
uploadFile(source, remotePath, options?): Promise<OperationResult<FileItem>>;
|
|
630
|
+
downloadFile(remotePath, localPath?, options?): Promise<OperationResult<Buffer | string>>;
|
|
631
|
+
moveItem(sourcePath, destinationPath, options?): Promise<OperationResult<FileSystemItem>>;
|
|
632
|
+
deleteFile(path: string): Promise<OperationResult>;
|
|
633
|
+
renameFile(path, newName, options?): Promise<OperationResult<FileItem>>;
|
|
634
|
+
renameFolder(path, newName, options?): Promise<OperationResult<FolderItem>>;
|
|
635
|
+
|
|
636
|
+
// Query operations
|
|
637
|
+
listDirectory(path, options?): Promise<OperationResult<FileSystemItem[]>>;
|
|
638
|
+
getItem(path: string): Promise<OperationResult<FileSystemItem>>;
|
|
639
|
+
exists(path: string): Promise<boolean>;
|
|
640
|
+
getFolderTree(path?, depth?): Promise<OperationResult<TreeNode[]>>;
|
|
641
|
+
}
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
### BaseStorageModule Utilities
|
|
645
|
+
|
|
646
|
+
Available protected methods from `BaseStorageModule`:
|
|
647
|
+
|
|
648
|
+
```typescript
|
|
649
|
+
// Configuration
|
|
650
|
+
protected ensureInitialized(): void
|
|
651
|
+
protected getProviderConfig<T>(): T
|
|
652
|
+
|
|
653
|
+
// Path utilities
|
|
654
|
+
protected normalizePath(path: string): string
|
|
655
|
+
protected joinPath(...segments: string[]): string
|
|
656
|
+
protected getBaseName(path: string): string
|
|
657
|
+
protected getParentPath(path: string): string
|
|
658
|
+
|
|
659
|
+
// Result helpers
|
|
660
|
+
protected successResult<T>(data?: T): OperationResult<T>
|
|
661
|
+
protected errorResult(error: string): OperationResult
|
|
662
|
+
|
|
663
|
+
// Tree building (can override for optimization)
|
|
664
|
+
protected async buildTree(path, maxDepth, currentDepth): Promise<TreeNode[]>
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
## Best Practices
|
|
668
|
+
|
|
669
|
+
### 1. Path Normalization
|
|
670
|
+
|
|
671
|
+
Always normalize paths at the start of each operation:
|
|
672
|
+
|
|
673
|
+
```typescript
|
|
674
|
+
async uploadFile(source, remotePath, options?): Promise<OperationResult<FileItem>> {
|
|
675
|
+
this.ensureInitialized();
|
|
676
|
+
const normalized = this.normalizePath(remotePath); // ← Always do this first
|
|
677
|
+
// Rest of implementation
|
|
678
|
+
}
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### 2. Error Handling
|
|
682
|
+
|
|
683
|
+
Use try-catch and return OperationResult:
|
|
684
|
+
|
|
685
|
+
```typescript
|
|
686
|
+
async uploadFile(...): Promise<OperationResult<FileItem>> {
|
|
687
|
+
try {
|
|
688
|
+
// Implementation
|
|
689
|
+
return this.successResult(fileItem);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (error instanceof MySpecificError) {
|
|
692
|
+
return this.errorResult(error.message);
|
|
693
|
+
}
|
|
694
|
+
return this.errorResult(`Unexpected error: ${(error as Error).message}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
### 3. Progress Tracking
|
|
700
|
+
|
|
701
|
+
Call progress callback if provided:
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
if (options?.onProgress) {
|
|
705
|
+
// During upload/download
|
|
706
|
+
options.onProgress(
|
|
707
|
+
(bytesTransferred / totalBytes) * 100,
|
|
708
|
+
bytesTransferred,
|
|
709
|
+
totalBytes
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
### 4. Source Type Handling
|
|
715
|
+
|
|
716
|
+
Handle all three source types in uploadFile:
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
async uploadFile(source, remotePath, options?): Promise<OperationResult<FileItem>> {
|
|
720
|
+
let buffer: Buffer;
|
|
721
|
+
|
|
722
|
+
if (typeof source === 'string') {
|
|
723
|
+
// File path
|
|
724
|
+
buffer = await fs.promises.readFile(source);
|
|
725
|
+
} else if (Buffer.isBuffer(source)) {
|
|
726
|
+
// Already a buffer
|
|
727
|
+
buffer = source;
|
|
728
|
+
} else {
|
|
729
|
+
// ReadableStream
|
|
730
|
+
buffer = await streamToBuffer(source);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Use buffer for upload
|
|
734
|
+
}
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### 5. Metadata Preservation
|
|
738
|
+
|
|
739
|
+
Include provider-specific metadata:
|
|
740
|
+
|
|
741
|
+
```typescript
|
|
742
|
+
const fileItem = createFileItem({
|
|
743
|
+
id: apiResult.id,
|
|
744
|
+
name: fileName,
|
|
745
|
+
path: normalized,
|
|
746
|
+
size: apiResult.size,
|
|
747
|
+
mimeType: apiResult.mimeType || getMimeType(fileName),
|
|
748
|
+
createdAt: new Date(apiResult.createdAt),
|
|
749
|
+
modifiedAt: new Date(apiResult.modifiedAt),
|
|
750
|
+
metadata: {
|
|
751
|
+
// Provider-specific metadata
|
|
752
|
+
storageClass: apiResult.storageClass,
|
|
753
|
+
etag: apiResult.etag,
|
|
754
|
+
versionId: apiResult.versionId,
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
### 6. Configuration Validation
|
|
760
|
+
|
|
761
|
+
Validate configuration in initialize():
|
|
762
|
+
|
|
763
|
+
```typescript
|
|
764
|
+
async initialize(config: HazoFilesConfig): Promise<void> {
|
|
765
|
+
await super.initialize(config);
|
|
766
|
+
|
|
767
|
+
const myConfig = this.getProviderConfig<MyStorageConfig>();
|
|
768
|
+
|
|
769
|
+
if (!myConfig.apiKey) {
|
|
770
|
+
throw new ConfigurationError('API key is required');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (!myConfig.endpoint) {
|
|
774
|
+
throw new ConfigurationError('Endpoint is required');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Initialize client
|
|
778
|
+
this.client = new MyStorageClient(myConfig);
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
## Testing
|
|
783
|
+
|
|
784
|
+
### Unit Tests
|
|
785
|
+
|
|
786
|
+
```typescript
|
|
787
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
788
|
+
import { MyStorageModule } from './my-storage';
|
|
789
|
+
|
|
790
|
+
describe('MyStorageModule', () => {
|
|
791
|
+
let module: MyStorageModule;
|
|
792
|
+
|
|
793
|
+
beforeEach(async () => {
|
|
794
|
+
module = new MyStorageModule();
|
|
795
|
+
await module.initialize({
|
|
796
|
+
provider: 'my_storage',
|
|
797
|
+
my_storage: {
|
|
798
|
+
apiKey: 'test-key',
|
|
799
|
+
endpoint: 'https://api.test.com',
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('should create directory', async () => {
|
|
805
|
+
const result = await module.createDirectory('/test');
|
|
806
|
+
expect(result.success).toBe(true);
|
|
807
|
+
expect(result.data?.name).toBe('test');
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('should upload file', async () => {
|
|
811
|
+
const buffer = Buffer.from('test content');
|
|
812
|
+
const result = await module.uploadFile(buffer, '/test.txt');
|
|
813
|
+
expect(result.success).toBe(true);
|
|
814
|
+
expect(result.data?.size).toBe(buffer.length);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// Add more tests...
|
|
818
|
+
});
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
### Integration Tests
|
|
822
|
+
|
|
823
|
+
```typescript
|
|
824
|
+
describe('MyStorageModule Integration', () => {
|
|
825
|
+
it('should upload and download file', async () => {
|
|
826
|
+
const module = new MyStorageModule();
|
|
827
|
+
await module.initialize({
|
|
828
|
+
provider: 'my_storage',
|
|
829
|
+
my_storage: {
|
|
830
|
+
apiKey: process.env.MY_STORAGE_API_KEY!,
|
|
831
|
+
endpoint: process.env.MY_STORAGE_ENDPOINT!,
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Upload
|
|
836
|
+
const content = Buffer.from('Hello, World!');
|
|
837
|
+
const uploadResult = await module.uploadFile(content, '/test.txt');
|
|
838
|
+
expect(uploadResult.success).toBe(true);
|
|
839
|
+
|
|
840
|
+
// Download
|
|
841
|
+
const downloadResult = await module.downloadFile('/test.txt');
|
|
842
|
+
expect(downloadResult.success).toBe(true);
|
|
843
|
+
expect(downloadResult.data).toEqual(content);
|
|
844
|
+
|
|
845
|
+
// Cleanup
|
|
846
|
+
await module.deleteFile('/test.txt');
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
## Examples
|
|
852
|
+
|
|
853
|
+
### Example 1: AWS S3 Module (Sketch)
|
|
854
|
+
|
|
855
|
+
```typescript
|
|
856
|
+
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
857
|
+
import { BaseStorageModule } from 'hazo_files';
|
|
858
|
+
|
|
859
|
+
export class S3StorageModule extends BaseStorageModule {
|
|
860
|
+
readonly provider = 's3' as StorageProvider;
|
|
861
|
+
private s3Client: S3Client | null = null;
|
|
862
|
+
private bucket: string = '';
|
|
863
|
+
|
|
864
|
+
async initialize(config: HazoFilesConfig): Promise<void> {
|
|
865
|
+
await super.initialize(config);
|
|
866
|
+
const s3Config = this.getProviderConfig<S3Config>();
|
|
867
|
+
|
|
868
|
+
this.s3Client = new S3Client({
|
|
869
|
+
region: s3Config.region,
|
|
870
|
+
credentials: {
|
|
871
|
+
accessKeyId: s3Config.accessKeyId,
|
|
872
|
+
secretAccessKey: s3Config.secretAccessKey,
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
this.bucket = s3Config.bucket;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async uploadFile(source, remotePath, options?): Promise<OperationResult<FileItem>> {
|
|
880
|
+
// Convert source to Buffer
|
|
881
|
+
const buffer = await this.sourceToBuffer(source);
|
|
882
|
+
|
|
883
|
+
// Upload to S3
|
|
884
|
+
await this.s3Client!.send(new PutObjectCommand({
|
|
885
|
+
Bucket: this.bucket,
|
|
886
|
+
Key: remotePath,
|
|
887
|
+
Body: buffer,
|
|
888
|
+
}));
|
|
889
|
+
|
|
890
|
+
// Create FileItem
|
|
891
|
+
// Return result
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Implement other methods...
|
|
895
|
+
}
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
### Example 2: Dropbox Module (Sketch)
|
|
899
|
+
|
|
900
|
+
```typescript
|
|
901
|
+
import { Dropbox } from 'dropbox';
|
|
902
|
+
import { BaseStorageModule } from 'hazo_files';
|
|
903
|
+
|
|
904
|
+
export class DropboxStorageModule extends BaseStorageModule {
|
|
905
|
+
readonly provider = 'dropbox' as StorageProvider;
|
|
906
|
+
private dbx: Dropbox | null = null;
|
|
907
|
+
|
|
908
|
+
async initialize(config: HazoFilesConfig): Promise<void> {
|
|
909
|
+
await super.initialize(config);
|
|
910
|
+
const dropboxConfig = this.getProviderConfig<DropboxConfig>();
|
|
911
|
+
|
|
912
|
+
this.dbx = new Dropbox({
|
|
913
|
+
accessToken: dropboxConfig.accessToken,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async uploadFile(source, remotePath, options?): Promise<OperationResult<FileItem>> {
|
|
918
|
+
const buffer = await this.sourceToBuffer(source);
|
|
919
|
+
|
|
920
|
+
const response = await this.dbx!.filesUpload({
|
|
921
|
+
path: remotePath,
|
|
922
|
+
contents: buffer,
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// Create FileItem from response
|
|
926
|
+
// Return result
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Implement other methods...
|
|
930
|
+
}
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
## Checklist
|
|
934
|
+
|
|
935
|
+
Use this checklist when creating a new module:
|
|
936
|
+
|
|
937
|
+
- [ ] Created module file structure
|
|
938
|
+
- [ ] Defined configuration interface
|
|
939
|
+
- [ ] Extended BaseStorageModule
|
|
940
|
+
- [ ] Implemented all required interface methods
|
|
941
|
+
- [ ] Added path normalization to all methods
|
|
942
|
+
- [ ] Implemented proper error handling with OperationResult
|
|
943
|
+
- [ ] Added progress tracking for upload/download
|
|
944
|
+
- [ ] Handled all source types (string, Buffer, ReadableStream)
|
|
945
|
+
- [ ] Created factory function
|
|
946
|
+
- [ ] Registered module
|
|
947
|
+
- [ ] Updated package exports
|
|
948
|
+
- [ ] Added TypeScript type definitions
|
|
949
|
+
- [ ] Wrote unit tests
|
|
950
|
+
- [ ] Wrote integration tests
|
|
951
|
+
- [ ] Updated documentation
|
|
952
|
+
- [ ] Added example usage
|
|
953
|
+
|
|
954
|
+
## Support
|
|
955
|
+
|
|
956
|
+
For questions or issues:
|
|
957
|
+
|
|
958
|
+
- Review existing modules: `src/modules/local` and `src/modules/google-drive`
|
|
959
|
+
- Check main documentation: [README.md](../README.md)
|
|
960
|
+
- Visit GitHub: [https://github.com/pub12/hazo_files](https://github.com/pub12/hazo_files)
|
|
961
|
+
|
|
962
|
+
---
|
|
963
|
+
|
|
964
|
+
Happy module development!
|