Skip to content

Commit 4d7ebb7

Browse files
committed
Feat: Local filesystem service + auxiliar methods for upload folder
1 parent 3915d10 commit 4d7ebb7

File tree

7 files changed

+310
-34
lines changed

7 files changed

+310
-34
lines changed

src/commands/upload-file.ts

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { DriveFileService } from '../services/drive/drive-file.service';
1111
import { CryptoService } from '../services/crypto.service';
1212
import { DownloadService } from '../services/network/download.service';
1313
import { ErrorUtils } from '../utils/errors.utils';
14-
import { NotValidDirectoryError, NotValidFolderUuidError } from '../types/command.types';
14+
import { NotValidDirectoryError } from '../types/command.types';
1515
import { ValidationService } from '../services/validation.service';
1616
import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types';
1717
import { ThumbnailService } from '../services/thumbnail.service';
@@ -58,11 +58,14 @@ export default class UploadFile extends Command {
5858
const fileInfo = path.parse(filePath);
5959
const fileType = fileInfo.ext.replaceAll('.', '');
6060

61-
let destinationFolderUuid = await this.getDestinationFolderUuid(flags['destination'], nonInteractive);
62-
if (destinationFolderUuid.trim().length === 0) {
63-
// destinationFolderUuid is empty from flags&prompt, which means we should use RootFolderUuid
64-
destinationFolderUuid = user.rootFolderId;
65-
}
61+
// If destinationFolderUuid is empty from flags&prompt, means we should use RootFolderUuid
62+
const destinationFolderUuid =
63+
(await CLIUtils.getDestinationFolderUuid({
64+
destinationFolderUuidFlag: flags['destination'],
65+
destinationFlagName: UploadFile.flags['destination'].name,
66+
nonInteractive,
67+
reporter: this.log.bind(this),
68+
})) ?? user.rootFolderId;
6669

6770
// 1. Prepare the network
6871
CLIUtils.doing('Preparing Network', flags['json']);
@@ -189,32 +192,6 @@ export default class UploadFile extends Command {
189192
this.exit(1);
190193
};
191194

192-
private getDestinationFolderUuid = async (
193-
destinationFolderUuidFlag: string | undefined,
194-
nonInteractive: boolean,
195-
): Promise<string> => {
196-
const destinationFolderUuid = await CLIUtils.getValueFromFlag(
197-
{
198-
value: destinationFolderUuidFlag,
199-
name: UploadFile.flags['destination'].name,
200-
},
201-
{
202-
nonInteractive,
203-
prompt: {
204-
message: 'What is the destination folder id? (leave empty for the root folder)',
205-
options: { type: 'input' },
206-
},
207-
},
208-
{
209-
validate: ValidationService.instance.validateUUIDv4,
210-
error: new NotValidFolderUuidError(),
211-
canBeEmpty: true,
212-
},
213-
this.log.bind(this),
214-
);
215-
return destinationFolderUuid;
216-
};
217-
218195
private getFilePath = async (fileFlag: string | undefined, nonInteractive: boolean): Promise<string> => {
219196
const filePath = await CLIUtils.getValueFromFlag(
220197
{
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { promises } from 'fs';
2+
import { basename, dirname, join, relative, parse } from 'path';
3+
import { FileSystemNode, ScanResult } from './local-filesystem.types';
4+
import { logger } from '../../utils/logger.utils';
5+
6+
export class LocalFilesystemService {
7+
static readonly instance = new LocalFilesystemService();
8+
9+
async scanLocalDirectory(path: string): Promise<ScanResult> {
10+
const folders: FileSystemNode[] = [];
11+
const files: FileSystemNode[] = [];
12+
13+
const parentPath = dirname(path);
14+
const totalBytes = await this.scanRecursive(path, parentPath, folders, files);
15+
return {
16+
folders,
17+
files,
18+
totalItems: folders.length + files.length,
19+
totalBytes,
20+
};
21+
}
22+
async scanRecursive(
23+
currentPath: string,
24+
parentPath: string,
25+
folders: FileSystemNode[],
26+
files: FileSystemNode[],
27+
): Promise<number> {
28+
try {
29+
const stats = await promises.stat(currentPath);
30+
const relativePath = relative(parentPath, currentPath);
31+
32+
if (stats.isFile()) {
33+
const fileInfo = parse(currentPath);
34+
files.push({
35+
type: 'file',
36+
name: fileInfo.name,
37+
absolutePath: currentPath,
38+
relativePath,
39+
size: stats.size,
40+
});
41+
return stats.size;
42+
}
43+
44+
if (stats.isDirectory()) {
45+
folders.push({
46+
type: 'folder',
47+
name: basename(currentPath),
48+
absolutePath: currentPath,
49+
relativePath,
50+
size: 0,
51+
});
52+
const entries = await promises.readdir(currentPath, { withFileTypes: true });
53+
const validEntries = entries.filter((e) => !e.isSymbolicLink());
54+
const bytesArray = await Promise.all(
55+
validEntries.map((e) => this.scanRecursive(join(currentPath, e.name), parentPath, folders, files)),
56+
);
57+
58+
return bytesArray.reduce((sum, bytes) => sum + bytes, 0);
59+
}
60+
61+
return 0;
62+
} catch (error: unknown) {
63+
logger.warn(`Error scanning path ${currentPath}: ${(error as Error).message} - skipping...`);
64+
return 0;
65+
}
66+
}
67+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface FileSystemNode {
2+
type: 'file' | 'folder';
3+
name: string;
4+
size: number;
5+
absolutePath: string;
6+
relativePath: string;
7+
}
8+
9+
export interface ScanResult {
10+
folders: FileSystemNode[];
11+
files: FileSystemNode[];
12+
totalItems: number;
13+
totalBytes: number;
14+
}

src/utils/cli.utils.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { ux, Flags } from '@oclif/core';
22
import cliProgress from 'cli-progress';
33
import Table, { Header } from 'tty-table';
4-
import { PromptOptions } from '../types/command.types';
4+
import { NotValidFolderUuidError, PromptOptions } from '../types/command.types';
55
import { InquirerUtils } from './inquirer.utils';
66
import { ErrorUtils } from './errors.utils';
7+
import { ValidationService } from '../services/validation.service';
78

89
export class CLIUtils {
910
static readonly clearPreviousLine = (jsonFlag?: boolean) => {
@@ -125,6 +126,43 @@ export class CLIUtils {
125126
}
126127
};
127128

129+
static readonly getDestinationFolderUuid = async ({
130+
destinationFolderUuidFlag,
131+
destinationFlagName,
132+
nonInteractive,
133+
reporter,
134+
}: {
135+
destinationFolderUuidFlag: string | undefined;
136+
destinationFlagName: string;
137+
nonInteractive: boolean;
138+
reporter: (message: string) => void;
139+
}): Promise<string | undefined> => {
140+
const destinationFolderUuid = await this.getValueFromFlag(
141+
{
142+
value: destinationFolderUuidFlag,
143+
name: destinationFlagName,
144+
},
145+
{
146+
nonInteractive,
147+
prompt: {
148+
message: 'What is the destination folder id? (leave empty for the root folder)',
149+
options: { type: 'input' },
150+
},
151+
},
152+
{
153+
validate: ValidationService.instance.validateUUIDv4,
154+
error: new NotValidFolderUuidError(),
155+
canBeEmpty: true,
156+
},
157+
reporter,
158+
);
159+
if (destinationFolderUuid.trim().length === 0) {
160+
return undefined;
161+
} else {
162+
return destinationFolderUuid;
163+
}
164+
};
165+
128166
private static readonly promptWithAttempts = async (
129167
prompt: { message: string; options: PromptOptions },
130168
maxAttempts: number,

src/utils/errors.utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ export function isError(error: unknown): error is Error {
55
return types.isNativeError(error);
66
}
77

8+
export function isAlreadyExistsError(error: unknown): error is Error {
9+
return (
10+
(isError(error) && error.message.includes('already exists')) ||
11+
(typeof error === 'object' && error !== null && 'status' in error && error.status === 409)
12+
);
13+
}
814
export class ErrorUtils {
915
static report(error: unknown, props: Record<string, unknown> = {}) {
1016
if (isError(error)) {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { beforeEach, describe, expect, it, vi, MockedFunction } from 'vitest';
2+
import { LocalFilesystemService } from '../../../src/services/local-filesystem/local-filesystem.service';
3+
import { Dirent, promises, Stats } from 'fs';
4+
import { logger } from '../../../src/utils/logger.utils';
5+
import { FileSystemNode } from '../../../src/services/local-filesystem/local-filesystem.types';
6+
7+
vi.mock('fs', () => ({
8+
promises: {
9+
stat: vi.fn(),
10+
readdir: vi.fn(),
11+
},
12+
}));
13+
14+
vi.mock('../../../src/utils/logger.utils', () => ({
15+
logger: {
16+
warn: vi.fn(),
17+
error: vi.fn(),
18+
info: vi.fn(),
19+
},
20+
}));
21+
22+
describe('Local Filesystem Service', () => {
23+
let service: LocalFilesystemService;
24+
const mockStat = vi.mocked(promises.stat);
25+
const mockReaddir = vi.mocked(promises.readdir) as unknown as MockedFunction<() => Promise<Dirent<string>[]>>;
26+
27+
const createMockStats = (isFile: boolean, size: number): Stats =>
28+
({
29+
isFile: () => isFile,
30+
isDirectory: () => !isFile,
31+
size,
32+
}) as Stats;
33+
34+
const createMockDirent = (name: string, isSymlink = false) =>
35+
({
36+
name,
37+
isSymbolicLink: () => isSymlink,
38+
isFile: () => false,
39+
isDirectory: () => false,
40+
}) as unknown as Dirent<string>;
41+
42+
beforeEach(() => {
43+
service = LocalFilesystemService.instance;
44+
vi.clearAllMocks();
45+
mockReaddir.mockResolvedValue([]);
46+
});
47+
48+
describe('scanRecursive', () => {
49+
it('should handle a single file', async () => {
50+
mockStat.mockResolvedValue(createMockStats(true, 100));
51+
52+
const folders: FileSystemNode[] = [];
53+
const files: FileSystemNode[] = [];
54+
const bytes = await service.scanRecursive('/path/file.txt', '/path', folders, files);
55+
56+
expect(bytes).toBe(100);
57+
expect(files).toHaveLength(1);
58+
expect(folders).toHaveLength(0);
59+
});
60+
61+
it('should handle an empty directory', async () => {
62+
mockStat.mockResolvedValue(createMockStats(false, 0));
63+
mockReaddir.mockResolvedValue([]);
64+
65+
const folders: FileSystemNode[] = [];
66+
const files: FileSystemNode[] = [];
67+
const bytes = await service.scanRecursive('/path/folder', '/path', folders, files);
68+
69+
expect(bytes).toBe(0);
70+
expect(folders).toHaveLength(1);
71+
expect(folders[0]).toMatchObject({
72+
type: 'folder',
73+
name: 'folder',
74+
});
75+
expect(files).toHaveLength(0);
76+
});
77+
78+
it('should handle a directory with files', async () => {
79+
mockStat
80+
.mockResolvedValueOnce(createMockStats(false, 0))
81+
.mockResolvedValueOnce(createMockStats(true, 50))
82+
.mockResolvedValueOnce(createMockStats(true, 75));
83+
84+
mockReaddir.mockResolvedValueOnce([createMockDirent('file1.txt', false), createMockDirent('file2.txt', false)]);
85+
86+
const folders: FileSystemNode[] = [];
87+
const files: FileSystemNode[] = [];
88+
const bytes = await service.scanRecursive('/path/folder', '/path', folders, files);
89+
90+
expect(bytes).toBe(125);
91+
expect(folders).toHaveLength(1);
92+
expect(files).toHaveLength(2);
93+
});
94+
95+
it('should skip symbolic links', async () => {
96+
mockStat.mockResolvedValueOnce(createMockStats(false, 0)).mockResolvedValueOnce(createMockStats(true, 100));
97+
mockReaddir.mockResolvedValueOnce([createMockDirent('symlink', true), createMockDirent('file.txt', false)]);
98+
99+
const folders: FileSystemNode[] = [];
100+
const files: FileSystemNode[] = [];
101+
await service.scanRecursive('/path/folder', '/path', folders, files);
102+
103+
expect(mockStat).toHaveBeenCalledTimes(2);
104+
expect(files).toHaveLength(1);
105+
});
106+
107+
it('should handle errors gracefully', async () => {
108+
mockStat.mockRejectedValue(new Error('Permission denied'));
109+
110+
const folders: FileSystemNode[] = [];
111+
const files: FileSystemNode[] = [];
112+
const bytes = await service.scanRecursive('/path/forbidden', '/path', folders, files);
113+
114+
expect(bytes).toBe(0);
115+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Permission denied'));
116+
});
117+
118+
it('should handle nested directories', async () => {
119+
mockStat
120+
.mockResolvedValueOnce(createMockStats(false, 0))
121+
.mockResolvedValueOnce(createMockStats(false, 0))
122+
.mockResolvedValueOnce(createMockStats(true, 200));
123+
124+
const subfolder = [createMockDirent('subfolder', false)];
125+
const file = [createMockDirent('file.txt', false)];
126+
127+
mockReaddir.mockResolvedValueOnce(subfolder).mockResolvedValueOnce(file);
128+
129+
const folders: FileSystemNode[] = [];
130+
const files: FileSystemNode[] = [];
131+
const bytes = await service.scanRecursive('/path/folder', '/path', folders, files);
132+
133+
expect(bytes).toBe(200);
134+
expect(folders).toHaveLength(2);
135+
expect(files).toHaveLength(1);
136+
});
137+
});
138+
describe('scanLocalDirectory', () => {
139+
it('should scan a directory and return complete results', async () => {
140+
mockStat.mockResolvedValueOnce(createMockStats(false, 0)).mockResolvedValueOnce(createMockStats(true, 100));
141+
142+
mockReaddir.mockResolvedValueOnce([createMockDirent('file.txt', false)]);
143+
144+
const result = await service.scanLocalDirectory('/test/folder');
145+
146+
expect(result).toMatchObject({
147+
totalItems: 2,
148+
totalBytes: 100,
149+
});
150+
expect(result.folders).toHaveLength(1);
151+
expect(result.files).toHaveLength(1);
152+
});
153+
});
154+
});

0 commit comments

Comments
 (0)