Skip to content

Commit e716cfb

Browse files
committed
feat: return Libre Graph driveItem from write ops
Drop the invented {success, resource, operation, spaceId, path, name} envelope from folder:create, file:upload, file/folder:copy/move, and file:download. Resolve the resulting item via Graph after each WebDAV write and return the driveItem directly, so write outputs match the read outputs (folder:list, share, user) that already return raw Graph types. file/folder:delete returns {}. webUrl on the returned driveItem stays empty until opencloud-eu/opencloud#2744 lands server-side; once it does, the field arrives populated without any node-side change. Closes #7.
1 parent b7d3a73 commit e716cfb

2 files changed

Lines changed: 88 additions & 64 deletions

File tree

nodes/OpenCloud/OpenCloud.node.ts

Lines changed: 39 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
} from 'n8n-workflow';
1212
import { NodeApiError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
1313

14-
import type { DriveItemsResponse, DrivesResponse } from './GenericFunctions';
14+
import type { DriveItem, DriveItemsResponse, DrivesResponse } from './GenericFunctions';
1515
import { openCloudApiRequest } from './GenericFunctions';
1616

1717
function driveChildrenUrl(driveId: string, itemId: string): string {
@@ -36,14 +36,37 @@ function lastSegment(path: string): string {
3636
return segments.length === 0 ? '' : segments[segments.length - 1];
3737
}
3838

39+
/**
40+
* Returns the item id at `rawPath` within `driveId`. The drive root is a
41+
* special case: OpenCloud's root item id equals the drive id, so an empty
42+
* path resolves without a Graph call. Subpaths delegate to resolvePathToItem.
43+
*/
3944
async function resolvePathToItemId(
4045
context: IExecuteFunctions,
4146
driveId: string,
4247
rawPath: string,
4348
itemIndex: number,
4449
): Promise<string> {
50+
if (splitPath(rawPath).length === 0) return driveId;
51+
return (await resolvePathToItem(context, driveId, rawPath, itemIndex)).id!;
52+
}
53+
54+
async function resolvePathToItem(
55+
context: IExecuteFunctions,
56+
driveId: string,
57+
rawPath: string,
58+
itemIndex: number,
59+
): Promise<DriveItem> {
4560
const segments = splitPath(rawPath);
61+
if (segments.length === 0) {
62+
throw new NodeOperationError(
63+
context.getNode(),
64+
'Cannot resolve an empty path to a drive item',
65+
{ itemIndex },
66+
);
67+
}
4668
let currentItemId = driveId;
69+
let lastMatch: DriveItem | undefined;
4770
for (let i = 0; i < segments.length; i++) {
4871
const segment = segments[i];
4972
const isLast = i === segments.length - 1;
@@ -69,8 +92,9 @@ async function resolvePathToItemId(
6992
);
7093
}
7194
currentItemId = match.id;
95+
lastMatch = match;
7296
}
73-
return currentItemId;
97+
return lastMatch!;
7498
}
7599

76100
export class OpenCloud implements INodeType {
@@ -814,7 +838,6 @@ export class OpenCloud implements INodeType {
814838
const nameSegments = splitPath(name);
815839
const parentSegments = splitPath(parentPath);
816840
const finalPath = '/' + [...parentSegments, ...nameSegments].join('/');
817-
const finalName = nameSegments[nameSegments.length - 1];
818841

819842
for (let j = 0; j < nameSegments.length; j++) {
820843
const segPath = '/' + [...parentSegments, ...nameSegments.slice(0, j + 1)].join('/');
@@ -839,15 +862,9 @@ export class OpenCloud implements INodeType {
839862
}
840863
}
841864

865+
const createdItem = await resolvePathToItem(this, driveId, finalPath, i);
842866
returnData.push({
843-
json: {
844-
success: true,
845-
resource: 'folder',
846-
operation: 'create',
847-
spaceId: driveId,
848-
path: finalPath,
849-
name: finalName,
850-
},
867+
json: createdItem as unknown as IDataObject,
851868
pairedItem: { item: i },
852869
});
853870
} else if (resource === 'file' && operation === 'upload') {
@@ -887,15 +904,9 @@ export class OpenCloud implements INodeType {
887904
false,
888905
);
889906

907+
const uploadedItem = await resolvePathToItem(this, driveId, filePath, i);
890908
returnData.push({
891-
json: {
892-
success: true,
893-
resource: 'file',
894-
operation: 'upload',
895-
spaceId: driveId,
896-
path: filePath,
897-
name,
898-
},
909+
json: uploadedItem as unknown as IDataObject,
899910
pairedItem: { item: i },
900911
});
901912
} else if (resource === 'file' && operation === 'download') {
@@ -924,21 +935,14 @@ export class OpenCloud implements INodeType {
924935

925936
const fileName = lastSegment(rawPath);
926937
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data as unknown as string);
927-
const newItem: INodeExecutionData = {
928-
json: {
929-
success: true,
930-
resource: 'file',
931-
operation: 'download',
932-
spaceId: driveId,
933-
path: rawPath,
934-
name: fileName,
935-
},
938+
const downloadedItem = await resolvePathToItem(this, driveId, rawPath, i);
939+
returnData.push({
940+
json: downloadedItem as unknown as IDataObject,
936941
binary: {
937942
[outputProp]: await this.helpers.prepareBinaryData(buffer, fileName),
938943
},
939944
pairedItem: { item: i },
940-
};
941-
returnData.push(newItem);
945+
});
942946
} else if (
943947
(operation === 'copy' || operation === 'move') &&
944948
(resource === 'folder' || resource === 'file')
@@ -998,17 +1002,9 @@ export class OpenCloud implements INodeType {
9981002
throw error;
9991003
}
10001004

1005+
const movedItem = await resolvePathToItem(this, destDriveId, destFullPath, i);
10011006
returnData.push({
1002-
json: {
1003-
success: true,
1004-
resource,
1005-
operation,
1006-
sourceSpaceId: driveId,
1007-
sourcePath: srcPath,
1008-
destinationSpaceId: destDriveId,
1009-
destinationPath: destFullPath,
1010-
name: destName,
1011-
},
1007+
json: movedItem as unknown as IDataObject,
10121008
pairedItem: { item: i },
10131009
});
10141010
} else if (operation === 'share' && (resource === 'folder' || resource === 'file')) {
@@ -1099,14 +1095,10 @@ export class OpenCloud implements INodeType {
10991095

11001096
await openCloudApiRequest.call(this, 'DELETE', url, '', {}, false);
11011097

1098+
// Item no longer exists, nothing meaningful to return. Empty object
1099+
// keeps the per-input-item output slot wired for pairedItem.
11021100
returnData.push({
1103-
json: {
1104-
success: true,
1105-
resource,
1106-
operation: 'delete',
1107-
spaceId: driveId,
1108-
path: rawPath,
1109-
},
1101+
json: {},
11101102
pairedItem: { item: i },
11111103
});
11121104
}

nodes/OpenCloud/__tests__/OpenCloud.node.test.ts

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -77,30 +77,31 @@ describe('OpenCloud node', () => {
7777
it('create → list → delete a folder under tmp root', async () => {
7878
const folderName = `folder-${Date.now()}`;
7979
const folderPath = tmpPath(folderName);
80+
const parentItemId = `${driveId}!parent`;
81+
const subItemId = `${driveId}!sub`;
8082

8183
// Create
8284
if (!IS_INTEGRATION) {
8385
nock(TEST_SERVER)
84-
.intercept(`/dav/spaces/${driveIdEnc}${encodePath(TMP_ROOT)}`, 'MKCOL')
85-
.optionally()
86-
.reply(201)
8786
.intercept(`/dav/spaces/${driveIdEnc}${encodePath(folderPath)}`, 'MKCOL')
8887
.reply(201);
88+
// Post-MKCOL path-walk resolves the new folder to a driveItem.
89+
nockChildrenWalk(
90+
[{ id: parentItemId, name: TMP_ROOT.slice(1), folder: {} }],
91+
[{ id: subItemId, name: folderName, folder: {} }],
92+
);
8993
}
90-
// Make sure the parent exists in integration mode (idempotent).
9194
if (IS_INTEGRATION) await ensureFolder(driveId, TMP_ROOT);
92-
const created = await runCreate(driveId, '', TMP_ROOT.slice(1)).catch(() => null); // ok if exists
93-
void created;
9495
const createdSub = await runCreate(driveId, TMP_ROOT, folderName);
95-
expect(createdSub).toMatchObject({ success: true, name: folderName });
96+
expect(createdSub).toMatchObject({ name: folderName, folder: {} });
97+
expect(typeof createdSub.id).toBe('string');
9698

9799
// List parent — folder should appear
98100
if (!IS_INTEGRATION) {
99-
// Mock the path-walk + children listing
100-
nockChildrenWalk([{ id: `${driveId}!parent`, name: TMP_ROOT.slice(1), folder: {} }]);
101+
nockChildrenWalk([{ id: parentItemId, name: TMP_ROOT.slice(1), folder: {} }]);
101102
nock(TEST_SERVER)
102-
.get(`/graph/v1.0/drives/${driveIdEnc}/items/${encodeURIComponent(`${driveId}!parent`)}/children`)
103-
.reply(200, { value: [{ id: 'child', name: folderName, folder: {} }] });
103+
.get(`/graph/v1.0/drives/${driveIdEnc}/items/${encodeURIComponent(parentItemId)}/children`)
104+
.reply(200, { value: [{ id: subItemId, name: folderName, folder: {} }] });
104105
}
105106
const { fns: listFns } = makeExecuteFunctions({
106107
parameters: { resource: 'folder', operation: 'list', space: driveId, path: TMP_ROOT },
@@ -117,7 +118,7 @@ describe('OpenCloud node', () => {
117118
parameters: { resource: 'folder', operation: 'delete', space: driveId, path: folderPath },
118119
});
119120
const deleted = await node.execute.call(delFns);
120-
expect(deleted[0][0].json).toMatchObject({ success: true, path: folderPath });
121+
expect(deleted[0][0].json).toEqual({});
121122
});
122123
});
123124

@@ -130,11 +131,17 @@ describe('OpenCloud node', () => {
130131
if (IS_INTEGRATION) await ensureFolder(driveId, TMP_ROOT);
131132

132133
// Upload
134+
const parentItemId = `${driveId}!parent`;
135+
const fileItemId = `${driveId}!file`;
133136
if (!IS_INTEGRATION) {
134137
nock(TEST_SERVER)
135138
.put(`/dav/spaces/${driveIdEnc}${encodePath(filePath)}`, content)
136139
.matchHeader('Content-Type', 'text/plain; charset=utf-8')
137140
.reply(201);
141+
nockChildrenWalk(
142+
[{ id: parentItemId, name: TMP_ROOT.slice(1), folder: {} }],
143+
[{ id: fileItemId, name: fileName, file: { mimeType: 'text/plain' } }],
144+
);
138145
}
139146
const uploaded = await runOnceJson({
140147
resource: 'file',
@@ -145,13 +152,18 @@ describe('OpenCloud node', () => {
145152
binaryDataUpload: false,
146153
fileContent: content,
147154
});
148-
expect(uploaded).toMatchObject({ success: true, name: fileName });
155+
expect(uploaded).toMatchObject({ name: fileName, file: { mimeType: 'text/plain' } });
156+
expect(typeof uploaded.id).toBe('string');
149157

150158
// Download
151159
if (!IS_INTEGRATION) {
152160
nock(TEST_SERVER)
153161
.get(`/dav/spaces/${driveIdEnc}${encodePath(filePath)}`)
154162
.reply(200, Buffer.from(content), { 'Content-Type': 'text/plain' });
163+
nockChildrenWalk(
164+
[{ id: parentItemId, name: TMP_ROOT.slice(1), folder: {} }],
165+
[{ id: fileItemId, name: fileName, file: { mimeType: 'text/plain' } }],
166+
);
155167
}
156168
const { fns: dlFns } = makeExecuteFunctions({
157169
parameters: {
@@ -165,6 +177,7 @@ describe('OpenCloud node', () => {
165177
const downloaded = await node.execute.call(dlFns);
166178
const binary = downloaded[0][0].binary as Record<string, { data: string }>;
167179
expect(Buffer.from(binary.data.data, 'base64').toString('utf8')).toBe(content);
180+
expect(downloaded[0][0].json).toMatchObject({ name: fileName, file: { mimeType: 'text/plain' } });
168181

169182
// Delete
170183
if (!IS_INTEGRATION) {
@@ -191,6 +204,10 @@ describe('OpenCloud node', () => {
191204
.put(`/dav/spaces/${driveIdEnc}${encodePath(filePath)}`)
192205
.matchHeader('Content-Type', 'application/octet-stream')
193206
.reply(201);
207+
nockChildrenWalk(
208+
[{ id: `${driveId}!parent`, name: TMP_ROOT.slice(1), folder: {} }],
209+
[{ id: `${driveId}!img`, name: fileName, file: { mimeType: 'image/png' } }],
210+
);
194211
}
195212
const { fns } = makeExecuteFunctions({
196213
parameters: {
@@ -234,17 +251,26 @@ describe('OpenCloud node', () => {
234251
.intercept(`/dav/spaces/${driveIdEnc}${encodePath(srcPath)}`, 'COPY')
235252
.matchHeader('Destination', `${TEST_SERVER}/dav/spaces/${driveIdEnc}${encodePath(copyPath)}`)
236253
.reply(201);
254+
nockChildrenWalk(
255+
[{ id: `${driveId}!parent`, name: TMP_ROOT.slice(1), folder: {} }],
256+
[{ id: `${driveId}!copy`, name: 'copy.txt', file: { mimeType: 'text/plain' } }],
257+
);
237258
}
238-
await runOnceJson({
259+
const copied = await runOnceJson({
239260
resource: 'file', operation: 'copy', space: driveId,
240261
path: srcPath, destSpace: '', destParentPath: TMP_ROOT, destName: 'copy.txt',
241262
});
263+
expect(copied).toMatchObject({ name: 'copy.txt', file: { mimeType: 'text/plain' } });
242264

243265
if (!IS_INTEGRATION) {
244266
nock(TEST_SERVER)
245267
.intercept(`/dav/spaces/${driveIdEnc}${encodePath(copyPath)}`, 'MOVE')
246268
.matchHeader('Destination', `${TEST_SERVER}/dav/spaces/${driveIdEnc}${encodePath(renamedPath)}`)
247269
.reply(201);
270+
nockChildrenWalk(
271+
[{ id: `${driveId}!parent`, name: TMP_ROOT.slice(1), folder: {} }],
272+
[{ id: `${driveId}!ren`, name: 'renamed.txt', file: { mimeType: 'text/plain' } }],
273+
);
248274
}
249275
await runOnceJson({
250276
resource: 'file', operation: 'move', space: driveId,
@@ -451,15 +477,21 @@ describe('OpenCloud node', () => {
451477
});
452478

453479
mockOnly.it('cross-space folder COPY (mock — depends on a second drive)', async () => {
454-
const otherDriveEnc = encodeURIComponent('storage-users-2$other-space-id');
480+
const otherDriveId = 'storage-users-2$other-space-id';
481+
const otherDriveEnc = encodeURIComponent(otherDriveId);
455482
nock(TEST_SERVER)
456483
.intercept(`/dav/spaces/${driveIdEnc}/Documents/Reports`, 'COPY')
457484
.matchHeader('Destination', `${TEST_SERVER}/dav/spaces/${otherDriveEnc}/Shared/Reports`)
458-
.reply(201);
485+
.reply(201)
486+
// Post-COPY path-walk on the destination drive.
487+
.get(`/graph/v1.0/drives/${otherDriveEnc}/items/${otherDriveEnc}/children`)
488+
.reply(200, { value: [{ id: `${otherDriveId}!shared`, name: 'Shared', folder: {} }] })
489+
.get(`/graph/v1.0/drives/${otherDriveEnc}/items/${encodeURIComponent(`${otherDriveId}!shared`)}/children`)
490+
.reply(200, { value: [{ id: `${otherDriveId}!reports`, name: 'Reports', folder: {} }] });
459491
await runOnceJson({
460492
resource: 'folder', operation: 'copy', space: driveId,
461493
path: '/Documents/Reports',
462-
destSpace: 'storage-users-2$other-space-id',
494+
destSpace: otherDriveId,
463495
destParentPath: '/Shared', destName: '',
464496
});
465497
});

0 commit comments

Comments
 (0)