feat: implement path based operations

This commit is contained in:
2025-12-23 14:41:42 +01:00
parent a55b8813db
commit a31fa5b487
6 changed files with 4649 additions and 13 deletions

11
jest.config.js Normal file
View File

@@ -0,0 +1,11 @@
const { createDefaultPreset } = require("ts-jest");
const tsJestTransformCfg = createDefaultPreset().transform;
/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: "node",
transform: {
...tsJestTransformCfg,
},
};

4159
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,20 +5,27 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc && cp src/nodes/KDrive/kdrive.svg dist/nodes/KDrive/kdrive.svg",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "jest"
},
"keywords": ["n8n", "kdrive", "infomaniak"],
"keywords": [
"n8n",
"kdrive",
"infomaniak"
],
"author": "",
"license": "LGPL-3.0",
"dependencies": {
"@types/node": "^20.0.0",
"@types/request": "^2.48.12",
"n8n-workflow": "^1.0.0",
"request": "^2.88.2",
"@types/request": "^2.48.12"
"request": "^2.88.2"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/node": "^20.0.0"
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"jest": "^30.2.0",
"ts-jest": "^29.4.6",
"typescript": "^5.0.0"
},
"n8n": {
"nodes": [
@@ -28,4 +35,4 @@
"KDrive"
]
}
}
}

View File

@@ -8,6 +8,21 @@ import {
import * as request from 'request';
/**
* Interface for path resolution cache
*/
interface PathCacheEntry {
fileId: string;
fileName: string;
parentId: string;
timestamp: number;
}
/**
* Path cache to store resolved paths for performance
*/
const pathCache: Record<string, PathCacheEntry> = {};
/**
* Make an API request to kDrive API
*/
@@ -99,4 +114,180 @@ export function handleApiError(this: IExecuteFunctions, error: any): void {
});
throw new Error(`kDrive API Error: ${errorMessage}`);
}
/**
* Clear path cache
*/
export function clearPathCache(): void {
Object.keys(pathCache).forEach(key => delete pathCache[key]);
}
/**
* Normalize path - convert to absolute path and handle path separators
*/
export function normalizePath(path: string): string {
// Replace Windows-style backslashes with forward slashes
let normalized = path.replace(/\\/g, '/');
// Remove leading/trailing slashes
normalized = normalized.replace(/^\/+|\/+$/g, '');
// Handle empty path (root)
if (!normalized) {
return 'root';
}
// Collapse multiple consecutive slashes
normalized = normalized.replace(/\/+/g, '/');
return normalized;
}
/**
* Parse path into components
*/
export function parsePath(path: string): string[] {
const normalized = normalizePath(path);
if (normalized === 'root') {
return ['root'];
}
// Split by slash and filter out empty segments
return normalized.split('/').filter(component => component.length > 0);
}
/**
* Get file info by ID
*/
export async function getFileInfoById(
this: IExecuteFunctions,
fileId: string,
driveId: string,
credentials: IDataObject
): Promise<any> {
if (fileId === 'root') {
return {
id: 'root',
name: 'root',
type: 'directory',
parent_id: null
};
}
try {
const response = await kdriveApiRequest.call(
this,
'GET',
`/3/drive/${driveId}/files/${fileId}`,
{},
credentials
);
return response;
} catch (error) {
throw new Error(`Failed to get file info for ID ${fileId}: ${error}`);
}
}
/**
* Resolve path to file ID
*/
export async function resolvePathToId(
this: IExecuteFunctions,
path: string,
driveId: string,
credentials: IDataObject
): Promise<string> {
const normalizedPath = normalizePath(path);
// Check cache first
if (pathCache[normalizedPath]) {
return pathCache[normalizedPath].fileId;
}
// Handle root path
if (normalizedPath === 'root') {
return 'root';
}
const pathComponents = parsePath(normalizedPath);
let currentId = 'root';
// Traverse path component by component
for (const component of pathComponents) {
// Get contents of current directory
const endpoint = currentId === 'root'
? `/3/drive/${driveId}/files/1/files`
: `/3/drive/${driveId}/files/${currentId}/files`;
const response = await kdriveApiRequest.call(this, 'GET', endpoint, {}, credentials);
// Find the component in the current directory
const foundItem = response.items.find((item: any) =>
item.name === component && item.type === 'directory'
);
if (!foundItem) {
throw new Error(`Path component not found: ${component} in ${currentId}`);
}
currentId = foundItem.id;
}
// Cache the resolved path
pathCache[normalizedPath] = {
fileId: currentId,
fileName: pathComponents[pathComponents.length - 1],
parentId: pathComponents.length === 1 ? 'root' :
pathCache[parsePath(normalizedPath).slice(0, -1).join('/')]?.fileId || 'root',
timestamp: Date.now()
};
return currentId;
}
/**
* Resolve file ID to path
*/
export async function resolveIdToPath(
this: IExecuteFunctions,
fileId: string,
driveId: string,
credentials: IDataObject
): Promise<string> {
if (fileId === 'root') {
return 'root';
}
// Check if we have this in cache
const cachedEntry = Object.values(pathCache).find(entry => entry.fileId === fileId);
if (cachedEntry) {
return Object.keys(pathCache).find(key => pathCache[key].fileId === fileId) || fileId;
}
// Get file info
const fileInfo = await getFileInfoById.call(this, fileId, driveId, credentials);
if (!fileInfo.parent_id) {
return fileInfo.name; // Root level file
}
// Recursively get parent path
const parentPath = await resolveIdToPath.call(this, fileInfo.parent_id, driveId, credentials);
// Construct full path
const fullPath = parentPath === 'root'
? fileInfo.name
: `${parentPath}/${fileInfo.name}`;
// Cache the result
pathCache[fullPath] = {
fileId: fileInfo.id,
fileName: fileInfo.name,
parentId: fileInfo.parent_id,
timestamp: Date.now()
};
return fullPath;
}

View File

@@ -9,6 +9,9 @@ import {
import {
kdriveApiRequest,
handleApiError,
resolvePathToId,
parsePath,
normalizePath,
} from './GenericFunctions';
export class KDrive implements INodeType {
@@ -80,7 +83,7 @@ export class KDrive implements INodeType {
resource: ['file'],
},
},
options: [
options: [
{
name: 'List Files',
value: 'listFiles',
@@ -91,21 +94,41 @@ export class KDrive implements INodeType {
value: 'getFileInfo',
description: 'Get information about a file',
},
{
name: 'Get File Info by Path',
value: 'getFileInfoByPath',
description: 'Get information about a file using path',
},
{
name: 'Upload File',
value: 'uploadFile',
description: 'Upload a file',
},
{
name: 'Upload File by Path',
value: 'uploadFileByPath',
description: 'Upload a file to specific path',
},
{
name: 'Download File',
value: 'downloadFile',
description: 'Download a file',
},
{
name: 'Download File by Path',
value: 'downloadFileByPath',
description: 'Download a file using path',
},
{
name: 'Delete File',
value: 'deleteFile',
description: 'Delete a file (move to trash)',
},
{
name: 'Delete File by Path',
value: 'deleteFileByPath',
description: 'Delete a file using path',
},
{
name: 'Search Files',
value: 'searchFiles',
@@ -116,6 +139,11 @@ export class KDrive implements INodeType {
value: 'getFileVersions',
description: 'Get file versions',
},
{
name: 'Get File Versions by Path',
value: 'getFileVersionsByPath',
description: 'Get file versions using path',
},
],
default: 'listFiles',
},
@@ -166,12 +194,52 @@ export class KDrive implements INodeType {
default: 'root',
displayOptions: {
show: {
resource: ['file'],
operation: ['listFiles'],
resource: ['directory'],
operation: ['createDirectory', 'createFile'],
},
},
description: 'The ID of the parent directory (use "root" for root directory)',
},
// Path-based parameters for file operations
{
displayName: 'File Path',
name: 'filePath',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['getFileInfoByPath', 'downloadFileByPath', 'deleteFileByPath', 'getFileVersionsByPath'],
},
},
description: 'The path to the file (e.g., "documents/report.pdf")',
},
{
displayName: 'Directory Path',
name: 'directoryPath',
type: 'string',
default: 'root',
displayOptions: {
show: {
resource: ['file'],
operation: ['listFiles'],
},
},
description: 'The path to the directory (use "root" for root, e.g., "documents/project")',
},
{
displayName: 'Upload Path',
name: 'uploadPath',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['file'],
operation: ['uploadFileByPath'],
},
},
description: 'The full path where to upload the file (e.g., "documents/report.pdf")',
},
// Directory ID for directory operations
{
displayName: 'Parent Directory ID',
@@ -322,9 +390,18 @@ export class KDrive implements INodeType {
const driveId = credentials.driveId as string;
if (operation === 'listFiles') {
const parentDirectoryId = this.getNodeParameter('parentDirectoryId', i) as string;
let parentDirectoryId = this.getNodeParameter('parentDirectoryId', i) as string;
const directoryPath = this.getNodeParameter('directoryPath', i) as string;
// If directoryPath is provided, resolve it to an ID
if (directoryPath && directoryPath !== 'root') {
parentDirectoryId = await resolvePathToId.call(this, directoryPath, driveId, credentials);
} else if (directoryPath === 'root') {
parentDirectoryId = 'root';
}
const endpoint = parentDirectoryId === 'root'
? `/3/drive/${driveId}/files/root/files`
? `/3/drive/${driveId}/files/1/files`
: `/3/drive/${driveId}/files/${parentDirectoryId}/files`;
const response = await kdriveApiRequest.call(this, 'GET', endpoint, {}, credentials);
returnData.push({ json: response });
@@ -364,10 +441,48 @@ export class KDrive implements INodeType {
const response = await kdriveApiRequest.call(this, 'GET', `/3/drive/${driveId}/files/search`, params, credentials);
returnData.push({ json: response });
} else if (operation === 'getFileVersions') {
} else if (operation === 'getFileVersions') {
const fileId = this.getNodeParameter('fileId', i) as string;
const response = await kdriveApiRequest.call(this, 'GET', `/3/drive/${driveId}/files/${fileId}/versions`, {}, credentials);
returnData.push({ json: response });
} else if (operation === 'getFileInfoByPath') {
const filePath = this.getNodeParameter('filePath', i) as string;
const fileId = await resolvePathToId.call(this, filePath, driveId, credentials);
const response = await kdriveApiRequest.call(this, 'GET', `/3/drive/${driveId}/files/${fileId}`, {}, credentials);
returnData.push({ json: response });
} else if (operation === 'uploadFileByPath') {
const fileData = this.getNodeParameter('fileData', i) as string;
const uploadPath = this.getNodeParameter('uploadPath', i) as string;
// Parse path to get directory and filename
const pathComponents = parsePath(uploadPath);
const fileName = pathComponents[pathComponents.length - 1];
const parentPath = pathComponents.slice(0, -1).join('/') || 'root';
const parentDirectoryId = await resolvePathToId.call(this, parentPath, driveId, credentials);
const formData: IDataObject = {
file: fileData,
filename: fileName,
parent_id: parentDirectoryId === 'root' ? 'root' : parentDirectoryId,
};
const response = await kdriveApiRequest.call(this, 'POST', `/3/drive/${driveId}/upload`, formData, credentials);
returnData.push({ json: response });
} else if (operation === 'downloadFileByPath') {
const filePath = this.getNodeParameter('filePath', i) as string;
const fileId = await resolvePathToId.call(this, filePath, driveId, credentials);
const response = await kdriveApiRequest.call(this, 'GET', `/2/drive/${driveId}/files/${fileId}/download`, {}, credentials, true);
returnData.push({ json: {}, binary: { data: { mimeType: 'application/octet-stream', data: response } } });
} else if (operation === 'deleteFileByPath') {
const filePath = this.getNodeParameter('filePath', i) as string;
const fileId = await resolvePathToId.call(this, filePath, driveId, credentials);
const response = await kdriveApiRequest.call(this, 'DELETE', `/2/drive/${driveId}/files/${fileId}`, {}, credentials);
returnData.push({ json: response });
} else if (operation === 'getFileVersionsByPath') {
const filePath = this.getNodeParameter('filePath', i) as string;
const fileId = await resolvePathToId.call(this, filePath, driveId, credentials);
const response = await kdriveApiRequest.call(this, 'GET', `/3/drive/${driveId}/files/${fileId}/versions`, {}, credentials);
returnData.push({ json: response });
}
} else if (resource === 'directory') {
const driveId = credentials.driveId as string;

153
tests/KDrive.node.test.ts Normal file
View File

@@ -0,0 +1,153 @@
import { KDrive } from '../src/nodes/KDrive/KDrive.node';
import {
kdriveApiRequest,
handleApiError,
resolvePathToId,
parsePath,
normalizePath,
resolveIdToPath,
clearPathCache
} from '../src/nodes/KDrive/GenericFunctions';
describe('KDrive Node', () => {
let kdriveNode: KDrive;
beforeEach(() => {
kdriveNode = new KDrive();
});
describe('Node Description', () => {
it('should have correct node description', () => {
expect(kdriveNode.description.displayName).toBe('kDrive');
expect(kdriveNode.description.name).toBe('kDrive');
expect(kdriveNode.description.group).toContain('transform');
expect(kdriveNode.description.version).toBe(1);
});
it('should have required credentials', () => {
expect(kdriveNode.description.credentials).toBeDefined();
if (kdriveNode.description.credentials) {
expect(kdriveNode.description.credentials.length).toBe(1);
expect(kdriveNode.description.credentials[0].name).toBe('kDriveApi');
}
});
});
describe('Node Properties', () => {
it('should have authentication property', () => {
const authProperty = kdriveNode.description.properties.find(p => p.name === 'authentication');
expect(authProperty).toBeDefined();
if (authProperty) {
expect(authProperty.type).toBe('options');
}
});
it('should have resource property', () => {
const resourceProperty = kdriveNode.description.properties.find(p => p.name === 'resource');
expect(resourceProperty).toBeDefined();
if (resourceProperty) {
expect(resourceProperty.type).toBe('options');
}
});
it('should have operation property', () => {
const operationProperty = kdriveNode.description.properties.find(p => p.name === 'operation');
expect(operationProperty).toBeDefined();
if (operationProperty) {
expect(operationProperty.type).toBe('options');
}
});
it('should have path-based operation options', () => {
const operationProperty = kdriveNode.description.properties.find(p => p.name === 'operation') as any;
expect(operationProperty).toBeDefined();
if (operationProperty && operationProperty.options) {
const operationValues = operationProperty.options.map((opt: any) => opt.value);
expect(operationValues).toContain('getFileInfoByPath');
expect(operationValues).toContain('uploadFileByPath');
expect(operationValues).toContain('downloadFileByPath');
expect(operationValues).toContain('deleteFileByPath');
expect(operationValues).toContain('getFileVersionsByPath');
}
});
it('should have path-based parameters', () => {
const filePathParam = kdriveNode.description.properties.find(p => p.name === 'filePath');
expect(filePathParam).toBeDefined();
const directoryPathParam = kdriveNode.description.properties.find(p => p.name === 'directoryPath');
expect(directoryPathParam).toBeDefined();
const uploadPathParam = kdriveNode.description.properties.find(p => p.name === 'uploadPath');
expect(uploadPathParam).toBeDefined();
});
});
});
describe('Generic Functions', () => {
describe('kdriveApiRequest', () => {
it('should be a function', () => {
expect(typeof kdriveApiRequest).toBe('function');
});
});
describe('handleApiError', () => {
it('should be a function', () => {
expect(typeof handleApiError).toBe('function');
});
});
describe('Path Resolution Functions', () => {
describe('normalizePath', () => {
it('should normalize Windows paths', () => {
expect(normalizePath('documents\\file.txt')).toBe('documents/file.txt');
});
it('should handle leading/trailing slashes', () => {
expect(normalizePath('/documents/file.txt/')).toBe('documents/file.txt');
expect(normalizePath('///documents///file.txt///')).toBe('documents/file.txt');
});
it('should handle empty path as root', () => {
expect(normalizePath('')).toBe('root');
expect(normalizePath('/')).toBe('root');
expect(normalizePath('///')).toBe('root');
});
it('should handle root path', () => {
expect(normalizePath('root')).toBe('root');
});
});
describe('parsePath', () => {
it('should parse simple paths', () => {
expect(parsePath('file.txt')).toEqual(['file.txt']);
});
it('should parse nested paths', () => {
expect(parsePath('documents/project/file.txt')).toEqual(['documents', 'project', 'file.txt']);
});
it('should handle root path', () => {
expect(parsePath('root')).toEqual(['root']);
});
it('should normalize before parsing', () => {
expect(parsePath('/documents//project///file.txt/')).toEqual(['documents', 'project', 'file.txt']);
});
});
describe('clearPathCache', () => {
it('should be a function', () => {
expect(typeof clearPathCache).toBe('function');
});
});
describe('resolvePathToId and resolveIdToPath', () => {
it('should be functions', () => {
expect(typeof resolvePathToId).toBe('function');
expect(typeof resolveIdToPath).toBe('function');
});
});
});
});