scriptable-testlab
Version:
A lightweight, efficient tool designed to manage and update scripts for Scriptable.
1,023 lines (889 loc) • 29.6 kB
text/typescript
import * as fs from 'fs';
import * as path from 'path';
import {AbsFileManager} from 'scriptable-abstract';
import {FILE_MANAGER_ERROR_CODES, FILE_MANAGER_ERROR_MESSAGES, FileManagerError} from '../../types/errors';
import {
BookmarkSource,
DirectoryNode,
FileManagerInstance,
FileManagerOptions,
FileManagerResetOptions,
FileManagerState,
FileNode,
FileSystemEvent,
FileSystemNode,
isDirectoryNode,
isFileNode,
} from '../../types/file';
import {DEFAULT_BASE_DIRECTORIES, DEFAULT_ROOT_PATH, FileManagerType, FileUtils} from '../../utils/paths';
import {MockData} from '../data';
import {MockImage} from '../media';
/**
* Mock implementation of Scriptable's FileManager.
* Provides a virtual file system implementation for testing.
* @implements FileManager
*/
export class MockFileManager extends AbsFileManager<FileManagerState> implements FileManagerInstance {
private bookmarks: Map<string, {path: string; source: BookmarkSource}> = new Map();
private static localInstance: MockFileManager | null = null;
private static iCloudInstance: MockFileManager | null = null;
private eventListeners: ((event: FileSystemEvent) => void)[] = [];
/**
* @inheritdoc
* Gets or creates a local file manager instance
*/
static local(): FileManager {
if (!this.localInstance) {
this.localInstance = MockFileManager.create(FileManagerType.LOCAL, {
rootPath: path.join(DEFAULT_ROOT_PATH, 'local'),
});
}
return this.localInstance;
}
/**
* @inheritdoc
* Gets or creates an iCloud file manager instance
*/
static iCloud(): FileManager {
if (!this.iCloudInstance) {
this.iCloudInstance = MockFileManager.create(FileManagerType.ICLOUD, {
rootPath: path.join(DEFAULT_ROOT_PATH, 'icloud'),
});
}
return this.iCloudInstance;
}
/**
* @additional
* Creates a new file manager instance
*/
static create(type: FileManagerType, options: FileManagerOptions = {}): MockFileManager {
const instance = new MockFileManager(type, options);
instance.initializeBaseDirectories();
return instance;
}
/**
* @additional
* Resets both local and iCloud instances
*/
static reset(options: FileManagerResetOptions = {}): void {
if (this.localInstance) {
this.localInstance.resetInstance(options);
}
if (this.iCloudInstance) {
this.iCloudInstance.resetInstance(options);
}
if (!options.preserveInstances) {
this.localInstance = null;
this.iCloudInstance = null;
}
}
/**
* @additional
* Reset this instance
*/
resetInstance(options: FileManagerResetOptions = {}): void {
if (!options.preserveBookmarks) {
this.bookmarks.clear();
}
const baseDirectories = options.preserveBaseDirectories
? this.state.baseDirectories
: {...DEFAULT_BASE_DIRECTORIES};
this.setState({
store: {},
rootPath: this.state.rootPath,
baseDirectories,
type: this.state.type,
fileSystem: {
type: 'directory',
children: new Map(),
metadata: {
creationDate: new Date(),
modificationDate: new Date(),
size: 0,
},
},
});
if (!options.preserveBaseDirectories) {
this.initializeBaseDirectories();
}
}
constructor(type: FileManagerType, options: FileManagerOptions = {}) {
super();
const rootPath = options.rootPath ?? DEFAULT_ROOT_PATH;
const normalizedRootPath = FileUtils.normalizePath(rootPath);
const baseDirectories = {...DEFAULT_BASE_DIRECTORIES};
Object.entries(options.baseDirectories ?? {}).forEach(([key, value]) => {
if (typeof value === 'string' && key in DEFAULT_BASE_DIRECTORIES) {
(baseDirectories as Record<string, string>)[key] = FileUtils.joinPaths(normalizedRootPath, value);
}
});
this.setState({
store: {},
rootPath: normalizedRootPath,
baseDirectories,
type: type === FileManagerType.LOCAL ? 'local' : 'icloud',
fileSystem: {
type: 'directory',
children: new Map(),
metadata: {
creationDate: new Date(),
modificationDate: new Date(),
size: 0,
},
},
});
}
// Core file operations
/**
* @inheritdoc
* Read data from a file
* @throws {FileManagerError} If file does not exist or is a directory
*/
read(filePath: string): Data {
try {
const content = this.readString(filePath);
return MockData.fromString(content);
} catch (error) {
throw this.wrapError(error, FILE_MANAGER_ERROR_CODES.IO_ERROR, filePath);
}
}
/**
* @inheritdoc
* Write data to a file
* @throws {FileManagerError} If parent directory does not exist
*/
write(filePath: string, content: Data): void {
try {
this.writeString(filePath, content.toRawString());
} catch (error) {
throw this.wrapError(error, FILE_MANAGER_ERROR_CODES.IO_ERROR, filePath);
}
}
/**
* @inheritdoc
* Read string content from a file
* @throws {FileManagerError} If file does not exist or is a directory
*/
readString(filePath: string): string {
const normalizedPath = this.resolvePath(filePath);
const node = this.getNode(normalizedPath);
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
filePath,
);
}
if (node.type !== 'file') {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_A_FILE],
FILE_MANAGER_ERROR_CODES.NOT_A_FILE,
filePath,
);
}
return node.content;
}
/**
* @inheritdoc
* Write string content to a file
* @throws {FileManagerError} If parent directory does not exist
*/
writeString(filePath: string, content: string): void {
const normalizedPath = this.resolvePath(filePath);
const parentDir = this.ensureParentDirectory(normalizedPath);
const fileName = path.basename(normalizedPath);
const now = new Date();
const fileNode: FileNode = {
type: 'file',
content,
metadata: {
creationDate: now,
modificationDate: now,
size: content.length,
isInCloud: this.state.type === 'icloud',
isDownloaded: this.state.type === 'local',
},
};
const existingNode = parentDir.children.get(fileName);
if (existingNode?.type === 'file') {
parentDir.metadata.size -= existingNode.metadata.size;
}
parentDir.children.set(fileName, fileNode);
parentDir.metadata.modificationDate = now;
parentDir.metadata.size += content.length;
this.emitEvent({
type: existingNode ? 'modify' : 'create',
path: normalizedPath,
timestamp: now,
metadata: fileNode.metadata,
});
}
// Image operations
/**
* @inheritdoc
* Read image from a file
* @throws {FileManagerError} If file does not exist or is a directory
*/
readImage(filePath: string): Image {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
filePath,
);
}
if (!isFileNode(node)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_A_FILE],
FILE_MANAGER_ERROR_CODES.NOT_A_FILE,
filePath,
);
}
return new MockImage();
}
/**
* @inheritdoc
* Write image to a file
* @throws {FileManagerError} If parent directory does not exist
*/
writeImage(filePath: string, _image: Image): void {
this.write(filePath, new MockData());
}
// File system operations
/**
* @inheritdoc
* Remove a file or directory
* @throws {FileManagerError} If path does not exist
*/
remove(filePath: string): void {
const normalizedPath = this.resolvePath(filePath);
const parentPath = path.dirname(normalizedPath);
const fileName = path.basename(normalizedPath);
const parentNode = this.getNode(parentPath);
if (!parentNode || !isDirectoryNode(parentNode)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
parentPath,
);
}
const node = parentNode.children.get(fileName);
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
filePath,
);
}
// 递归删除目录内容
if (isDirectoryNode(node)) {
for (const childName of node.children.keys()) {
this.remove(path.join(normalizedPath, childName));
}
}
// 删除节点
parentNode.children.delete(fileName);
// 更新父目录元数据
parentNode.metadata.modificationDate = new Date();
if (isFileNode(node)) {
parentNode.metadata.size -= node.metadata.size;
}
// 发送删除事件
this.emitEvent({
type: 'delete',
path: normalizedPath,
timestamp: new Date(),
metadata: node.metadata,
});
}
fileExists(filePath: string): boolean {
return this.getNode(this.resolvePath(filePath)) !== undefined;
}
isDirectory(filePath: string): boolean {
const node = this.getNode(this.resolvePath(filePath));
return node?.type === 'directory';
}
/**
* @inheritdoc
* Create a directory
* @throws {FileManagerError} If parent directory does not exist and createParents is false
*/
createDirectory(dirPath: string, createParents: boolean = false): void {
// Normalize the path first
const normalizedPath = this.resolvePath(dirPath);
const rootPath = FileUtils.normalizePath(this.state.rootPath);
// Early return for root path
if (normalizedPath === rootPath) {
return;
}
// Get parent path and check if it exists
const parentPath = path.dirname(normalizedPath);
const parentExists = this.fileExists(parentPath);
// If parent doesn't exist and createParents is false, throw error
if (!parentExists && !createParents) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.PARENT_DIRECTORY_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.PARENT_DIRECTORY_NOT_FOUND,
parentPath,
);
}
// Create parent directories if needed
if (!parentExists && createParents) {
this.createDirectory(parentPath, true);
}
// Get parent node
const parentNode = this.getNode(parentPath);
if (!parentNode || !isDirectoryNode(parentNode)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY],
FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY,
parentPath,
);
}
// Get directory name and check if it already exists
const dirName = path.basename(normalizedPath);
const existingNode = parentNode.children.get(dirName);
// If exists but is not a directory, throw error
if (existingNode && !isDirectoryNode(existingNode)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY],
FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY,
normalizedPath,
);
}
// If directory already exists, return
if (existingNode) {
return;
}
// Create new directory node
const now = new Date();
const newNode: DirectoryNode = {
type: 'directory',
children: new Map(),
metadata: {
creationDate: now,
modificationDate: now,
size: 0,
isInCloud: this.state.type === 'icloud',
isDownloaded: this.state.type === 'local',
},
};
// Add to parent
parentNode.children.set(dirName, newNode);
parentNode.metadata.modificationDate = now;
// Emit create event
this.emitEvent({
type: 'create',
path: normalizedPath,
timestamp: now,
metadata: newNode.metadata,
});
}
/**
* @inheritdoc
* List contents of a directory
* @throws {FileManagerError} If directory does not exist
*/
listContents(directoryPath: string): string[] {
const node = this.getNode(this.resolvePath(directoryPath));
if (!node || !isDirectoryNode(node)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY],
FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY,
directoryPath,
);
}
return Array.from(node.children.keys());
}
// Directory paths
documentsDirectory(): string {
return this.state.baseDirectories.documents;
}
libraryDirectory(): string {
return this.state.baseDirectories.library;
}
cacheDirectory(): string {
return this.state.baseDirectories.cache;
}
temporaryDirectory(): string {
return this.state.baseDirectories.temporary;
}
// Path operations
joinPath(lhs: string, rhs: string): string {
return FileUtils.joinPaths(lhs, rhs);
}
// File operations
/**
* @inheritdoc
* Move a file or directory
* @throws {FileManagerError} If source or destination parent directory does not exist
*/
move(sourceFile: string, destinationFile: string): void {
const normalizedSourcePath = this.resolvePath(sourceFile);
const normalizedDestPath = this.resolvePath(destinationFile);
const sourceParentPath = path.dirname(normalizedSourcePath);
const sourceFileName = path.basename(normalizedSourcePath);
const destParentPath = path.dirname(normalizedDestPath);
const destFileName = path.basename(normalizedDestPath);
const sourceParentNode = this.getNode(sourceParentPath);
if (!sourceParentNode || !isDirectoryNode(sourceParentNode)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
sourceParentPath,
);
}
const sourceNode = sourceParentNode.children.get(sourceFileName);
if (!sourceNode) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
sourceFile,
);
}
const destParentNode = this.getNode(destParentPath);
if (!destParentNode || !isDirectoryNode(destParentNode)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
destParentPath,
);
}
sourceParentNode.children.delete(sourceFileName);
destParentNode.children.set(destFileName, sourceNode);
const now = new Date();
sourceParentNode.metadata.modificationDate = now;
destParentNode.metadata.modificationDate = now;
if (isFileNode(sourceNode)) {
sourceParentNode.metadata.size -= sourceNode.metadata.size;
destParentNode.metadata.size += sourceNode.metadata.size;
}
}
/**
* @inheritdoc
* Copy a file or directory
* @throws {FileManagerError} If source file does not exist or destination parent directory does not exist
*/
copy(sourceFile: string, destinationFile: string): void {
const normalizedSourcePath = this.resolvePath(sourceFile);
const normalizedDestPath = this.resolvePath(destinationFile);
const sourceNode = this.getNode(normalizedSourcePath);
if (!sourceNode) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
sourceFile,
);
}
const clonedNode: FileSystemNode = isDirectoryNode(sourceNode)
? {
type: 'directory',
children: new Map(sourceNode.children),
metadata: {
...sourceNode.metadata,
creationDate: new Date(),
modificationDate: new Date(),
},
}
: {
type: 'file',
content: sourceNode.content,
metadata: {
...sourceNode.metadata,
creationDate: new Date(),
modificationDate: new Date(),
},
};
this.createNode(normalizedDestPath, clonedNode);
}
// Bookmark operations
bookmarkedPath(name: string): string {
const bookmark = this.bookmarks.get(name);
if (!bookmark) {
throw new Error('Bookmark not found');
}
return bookmark.path;
}
bookmarkExists(name: string): boolean {
return this.bookmarks.has(name);
}
createBookmark(name: string, path: string): void {
this.bookmarks.set(name, {
path,
source: this.state.type === 'local' ? 'local' : 'icloud',
});
}
removeBookmark(name: string): void {
this.bookmarks.delete(name);
}
allFileBookmarks(): Array<{name: string; path: string; source: BookmarkSource}> {
return Array.from(this.bookmarks.entries()).map(([name, data]) => ({
name,
path: data.path,
source: data.source,
}));
}
// Cloud operations
downloadFileFromiCloud(filePath: string): Promise<void> {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new Error('File not found');
}
node.metadata.isDownloaded = true;
return Promise.resolve();
}
isFileDownloaded(filePath: string): boolean {
const node = this.getNode(filePath);
return node?.metadata.isDownloaded ?? false;
}
isFileStoredIniCloud(filePath: string): boolean {
const node = this.getNode(this.resolvePath(filePath));
return node?.metadata.isInCloud ?? false;
}
// Metadata operations
modificationDate(filePath: string): Date {
const node = this.getNode(filePath);
if (!node) {
throw new Error('File not found');
}
return node.metadata.modificationDate;
}
creationDate(filePath: string): Date {
const node = this.getNode(filePath);
if (!node) {
throw new Error('File not found');
}
return node.metadata.creationDate;
}
fileSize(filePath: string): number {
const node = this.getNode(filePath);
if (!node) {
throw new Error('File not found');
}
return node.metadata.size;
}
// File information
fileName(filePath: string, includeFileExtension: boolean = true): string {
const name = FileUtils.getFileName(filePath);
if (!includeFileExtension) {
const extIndex = name.lastIndexOf('.');
return extIndex > 0 ? name.substring(0, extIndex) : name;
}
return name;
}
fileExtension(filePath: string): string {
return FileUtils.getFileExtension(filePath).slice(1);
}
getUTI(filePath: string): string {
const extension = this.fileExtension(filePath);
return FileUtils.getUTI(extension);
}
// Extended Attributes operations
writeExtendedAttribute(filePath: string, attributeName: string, value: string): void {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND,
filePath,
);
}
if (!node.metadata.extendedAttributes) {
node.metadata.extendedAttributes = new Map();
}
node.metadata.extendedAttributes.set(attributeName, value);
}
readExtendedAttribute(filePath: string, attributeName: string): string {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND,
filePath,
);
}
const value = node.metadata.extendedAttributes?.get(attributeName);
if (value === undefined) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.EXTENDED_ATTRIBUTE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.EXTENDED_ATTRIBUTE_NOT_FOUND,
);
}
return value;
}
allExtendedAttributes(filePath: string): string[] {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND,
filePath,
);
}
return Array.from(node.metadata.extendedAttributes?.keys() ?? []);
}
removeExtendedAttribute(filePath: string, attributeName: string): void {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND,
filePath,
);
}
if (!node.metadata.extendedAttributes?.delete(attributeName)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.EXTENDED_ATTRIBUTE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.EXTENDED_ATTRIBUTE_NOT_FOUND,
);
}
}
// Tags operations
addTag(filePath: string, tag: string): void {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND,
filePath,
);
}
if (!node.metadata.tags) {
node.metadata.tags = new Set();
}
node.metadata.tags.add(tag);
}
removeTag(filePath: string, tag: string): void {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND,
filePath,
);
}
if (!node.metadata.tags?.delete(tag)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.TAG_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.TAG_NOT_FOUND,
);
}
}
allTags(filePath: string): string[] {
const node = this.getNode(this.resolvePath(filePath));
if (!node) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND],
FILE_MANAGER_ERROR_CODES.FILE_NOT_FOUND,
filePath,
);
}
return Array.from(node.metadata.tags ?? []);
}
// Event handling
/**
* @additional
* Add a file system event listener
*/
addEventListener(listener: (event: FileSystemEvent) => void): void {
this.eventListeners.push(listener);
}
/**
* @additional
* Remove a file system event listener
*/
removeEventListener(listener: (event: FileSystemEvent) => void): void {
const index = this.eventListeners.indexOf(listener);
if (index !== -1) {
this.eventListeners.splice(index, 1);
}
}
/**
* @additional
* Emit a file system event
*/
private emitEvent(event: FileSystemEvent): void {
this.eventListeners.forEach(listener => listener(event));
}
// Error handling
/**
* @additional
* Wrap an error in a FileManagerError
*/
private wrapError(error: unknown, code: keyof typeof FILE_MANAGER_ERROR_CODES, path?: string): FileManagerError {
if (error instanceof FileManagerError) {
return error;
}
const message = FILE_MANAGER_ERROR_MESSAGES[code];
return new FileManagerError(message, code, path, error instanceof Error ? error : undefined);
}
// Private helper methods
/**
* @additional
* Initialize base directories
*/
private initializeBaseDirectories(): void {
if (!fs.existsSync(this.state.rootPath)) {
fs.mkdirSync(this.state.rootPath, {recursive: true});
}
Object.values(this.state.baseDirectories).forEach(dirPath => {
const normalizedPath = FileUtils.normalizePath(dirPath);
if (!fs.existsSync(normalizedPath)) {
fs.mkdirSync(normalizedPath, {recursive: true});
}
const parts = normalizedPath.split('/').filter(Boolean);
let current: DirectoryNode = this.state.fileSystem;
for (const part of parts) {
if (!current.children.has(part)) {
const newNode: DirectoryNode = {
type: 'directory',
children: new Map(),
metadata: {
creationDate: new Date(),
modificationDate: new Date(),
size: 0,
},
};
current.children.set(part, newNode);
}
const nextNode = current.children.get(part);
if (!nextNode || !isDirectoryNode(nextNode)) {
throw new Error(`Path component ${part} exists but is not a directory`);
}
current = nextNode;
}
});
}
/**
* @additional
* Resolve a path to its normalized form
*/
private resolvePath(filePath: string): string {
// Normalize both paths using platform-specific handling
const normalizedPath = FileUtils.normalizePath(filePath);
const rootPath = FileUtils.normalizePath(this.state.rootPath);
// If the path is already the root path, return it directly
if (normalizedPath === rootPath) {
return rootPath;
}
// Handle absolute paths
if (path.isAbsolute(normalizedPath)) {
// Check if it's a base directory
const isBaseDir = Object.values(this.state.baseDirectories).some(
dir => FileUtils.normalizePath(dir) === normalizedPath,
);
// If it's a base directory, return as is
if (isBaseDir) {
return normalizedPath;
}
// Check if path is outside root
const relativePath = path.relative(rootPath, normalizedPath);
if (relativePath.startsWith('..')) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.OUTSIDE_ROOT],
FILE_MANAGER_ERROR_CODES.OUTSIDE_ROOT,
);
}
// Prevent path duplication by getting relative path if it's under root
if (normalizedPath.startsWith(rootPath)) {
const relPath = path.relative(rootPath, normalizedPath);
return path.join(rootPath, relPath).replace(/\\/g, '/');
}
return normalizedPath;
}
// Handle relative paths
const resolvedPath = path.join(rootPath, normalizedPath);
return FileUtils.normalizePath(resolvedPath);
}
/**
* @additional
* Get a node from the file system
*/
private getNode(filePath: string): FileSystemNode | undefined {
const normalizedPath = FileUtils.normalizePath(filePath);
const rootPath = FileUtils.normalizePath(this.state.rootPath);
if (normalizedPath === rootPath) {
return this.state.fileSystem;
}
// Get the path relative to root if it's a subdirectory of root
let relativePath: string;
if (normalizedPath.startsWith(rootPath)) {
relativePath = path.relative(rootPath, normalizedPath);
} else {
relativePath = path.relative(rootPath, path.resolve(rootPath, normalizedPath));
}
const parts = relativePath.split(/[/\\]/).filter(Boolean);
let current: DirectoryNode = this.state.fileSystem;
for (const part of parts) {
const next = current.children.get(part);
if (!next) return undefined;
// If we're not at the last part, the node must be a directory
if (part !== parts[parts.length - 1] && !isDirectoryNode(next)) {
return undefined;
}
if (isDirectoryNode(next)) {
current = next;
} else {
return next;
}
}
return current;
}
/**
* @additional
* Ensure a parent directory exists
*/
private ensureParentDirectory(filePath: string): DirectoryNode {
const normalizedPath = this.resolvePath(filePath);
const parentPath = path.dirname(normalizedPath);
if (!this.fileExists(parentPath)) {
this.createDirectory(parentPath, true);
}
const node = this.getNode(parentPath);
if (!node || !isDirectoryNode(node)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY],
FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY,
parentPath,
);
}
return node;
}
private createNode(filePath: string, node: FileSystemNode, createParents: boolean = true): void {
const normalizedPath = this.resolvePath(filePath);
const parentPath = path.dirname(normalizedPath);
const fileName = path.basename(normalizedPath);
if (parentPath !== this.state.rootPath && !this.fileExists(parentPath)) {
if (createParents) {
this.createDirectory(parentPath, true);
} else {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_FOUND],
FILE_MANAGER_ERROR_CODES.NOT_FOUND,
parentPath,
);
}
}
const parentNode = this.getNode(parentPath);
if (!parentNode || !isDirectoryNode(parentNode)) {
throw new FileManagerError(
FILE_MANAGER_ERROR_MESSAGES[FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY],
FILE_MANAGER_ERROR_CODES.NOT_A_DIRECTORY,
parentPath,
);
}
if (this.state.type === 'icloud') {
node.metadata = {
...node.metadata,
isInCloud: true,
isDownloaded: false,
};
}
parentNode.children.set(fileName, node);
parentNode.metadata.modificationDate = new Date();
if (isFileNode(node)) {
parentNode.metadata.size += node.metadata.size;
}
}
}