dalux-build-api 1.1.3 → 2.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.
Files changed (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +404 -365
  3. package/package.json +47 -46
  4. package/src/api/CompaniesApi.js +67 -60
  5. package/src/api/CompanyCatalogApi.js +129 -108
  6. package/src/api/FileAreasApi.js +57 -37
  7. package/src/api/FileRevisionsApi.js +31 -31
  8. package/src/api/FileUploadApi.js +64 -64
  9. package/src/api/FilesApi.js +701 -297
  10. package/src/api/FoldersApi.js +283 -58
  11. package/src/api/FormsApi.js +53 -48
  12. package/src/api/InspectionPlansApi.js +66 -62
  13. package/src/api/ProjectTemplatesApi.js +25 -25
  14. package/src/api/ProjectsApi.js +127 -106
  15. package/src/api/TasksApi.js +193 -161
  16. package/src/api/TestPlansApi.js +63 -59
  17. package/src/api/UsersApi.js +52 -47
  18. package/src/api/VersionSetsApi.js +75 -67
  19. package/src/api/WorkPackagesApi.js +30 -26
  20. package/src/apiClient.js +139 -75
  21. package/src/configuration.js +39 -24
  22. package/src/index.js +133 -92
  23. package/src/models/common.js +22 -0
  24. package/src/models/companies/index.js +14 -0
  25. package/src/models/companyCatalog/index.js +19 -0
  26. package/src/models/convert.js +44 -0
  27. package/src/models/fileAreas/index.js +20 -0
  28. package/src/models/fileRevisions/index.js +12 -0
  29. package/src/models/fileUpload/index.js +12 -0
  30. package/src/models/files/index.js +97 -0
  31. package/src/models/folders/index.js +20 -0
  32. package/src/models/forms/index.js +19 -0
  33. package/src/models/helpers.js +62 -0
  34. package/src/models/index.js +148 -0
  35. package/src/models/inspectionPlans/index.js +18 -0
  36. package/src/models/projectTemplates/index.js +11 -0
  37. package/src/models/projects/index.js +57 -0
  38. package/src/models/tasks/index.js +105 -0
  39. package/src/models/testPlans/index.js +17 -0
  40. package/src/models/users/index.js +29 -0
  41. package/src/models/versionSets/index.js +22 -0
  42. package/src/models/workPackages/index.js +19 -0
  43. package/src/utils/errors.js +45 -0
  44. package/src/utils/index.js +26 -0
  45. package/src/utils/pagination.js +82 -0
  46. package/src/utils/pathResolver.js +124 -0
  47. package/src/utils/search.js +61 -0
  48. package/src/utils/validation.js +36 -0
@@ -1,297 +1,701 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const axios = require('axios');
6
-
7
- /**
8
- * API methods for files within a file area.
9
- */
10
- class FilesApi {
11
- /**
12
- * @param {import('../apiClient')} apiClient
13
- */
14
- constructor(apiClient) {
15
- this._client = apiClient;
16
- }
17
-
18
- /**
19
- * Browse all files on the given project and file area.
20
- * GET /6.1/projects/{projectId}/file_areas/{fileAreaId}/files
21
- * @param {string} projectId
22
- * @param {string} fileAreaId
23
- * @param {object} [params] - Optional params (e.g. folderId, updatedAfter, includeProperties). The files endpoint does not support OData $filter.
24
- * @returns {Promise<object>}
25
- */
26
- listFiles(projectId, fileAreaId, params = {}) {
27
- return this._client.get(
28
- `/6.1/projects/${projectId}/file_areas/${fileAreaId}/files`,
29
- params,
30
- );
31
- }
32
-
33
- /**
34
- * Retrieve all files by following bookmark pagination (metadata.totalRemainingItems).
35
- * @param {string} projectId
36
- * @param {string} fileAreaId
37
- * @param {object} [params]
38
- * @param {boolean} [verbose=false]
39
- * @returns {Promise<object[]>}
40
- */
41
- async getAllFiles(projectId, fileAreaId, params = {}, verbose = false) {
42
- const allItems = [];
43
- let currentParams = { ...params };
44
- let hasNextPage = true;
45
- const urlPath = `/6.1/projects/${projectId}/file_areas/${fileAreaId}/files`;
46
-
47
- while (hasNextPage) {
48
- const response = await this._client.get(urlPath, currentParams);
49
- const items = response && response.items;
50
- if (items && items.length) {
51
- allItems.push(...items);
52
- }
53
- const remaining = ((response && response.metadata) || {}).totalRemainingItems ?? 0;
54
- if (verbose) {
55
- console.log(`Retrieved ${allItems.length} files so far, ${remaining} remaining...`);
56
- }
57
- if (!items || !items.length || remaining === 0) {
58
- hasNextPage = false;
59
- } else {
60
- const nextLink = (response.links || []).find((l) => l.rel === 'nextPage');
61
- if (nextLink) {
62
- const bookmark = new URL(nextLink.href).searchParams.get('bookmark');
63
- currentParams = { ...params, bookmark };
64
- } else {
65
- hasNextPage = false;
66
- }
67
- }
68
- }
69
- if (verbose) {
70
- console.log(`Done. Total files retrieved: ${allItems.length}`);
71
- }
72
- return allItems;
73
- }
74
-
75
- /**
76
- * All files in a folder (filters getAllFiles by data.folderId).
77
- * @param {string} projectId
78
- * @param {string} fileAreaId
79
- * @param {string} folderId
80
- * @param {object} [params]
81
- * @param {boolean} [verbose=false]
82
- * @returns {Promise<object[]>}
83
- */
84
- async getAllFilesInFolder(projectId, fileAreaId, folderId, params = {}, verbose = false) {
85
- const allFiles = await this.getAllFiles(projectId, fileAreaId, params, verbose);
86
- const filtered = allFiles.filter((f) => ((f.data) || {}).folderId === folderId);
87
- if (verbose) {
88
- console.log(`Files matching folder '${folderId}': ${filtered.length}`);
89
- }
90
- return filtered;
91
- }
92
-
93
- /**
94
- * Download a file from a direct download URL using X-API-KEY (same as Python client).
95
- * @param {string} downloadLink
96
- * @param {string} fileName
97
- * @param {string} [savePath] - Directory to save into (default: current working directory)
98
- * @returns {Promise<string>} Absolute path to saved file
99
- */
100
- async downloadFileFromLink(downloadLink, fileName, savePath) {
101
- const apiKey = this._client.configuration.apiKey;
102
- const dir = savePath || '.';
103
- await fs.promises.mkdir(dir, { recursive: true });
104
- const filePath = path.join(dir, fileName);
105
-
106
- const response = await axios.get(downloadLink, {
107
- headers: { 'X-API-KEY': apiKey },
108
- responseType: 'stream',
109
- validateStatus: () => true,
110
- });
111
- if (response.status !== 200) {
112
- throw new Error(`Failed to download file. Status code: ${response.status}`);
113
- }
114
- await new Promise((resolve, reject) => {
115
- const ws = fs.createWriteStream(filePath);
116
- response.data.pipe(ws);
117
- response.data.on('error', reject);
118
- ws.on('finish', () => resolve());
119
- ws.on('error', reject);
120
- });
121
- return path.resolve(filePath);
122
- }
123
-
124
- /**
125
- * GET /5.0/projects/{projectId}/file_areas/{fileAreaId}/files/{fileId}
126
- * @param {string} projectId
127
- * @param {string} fileAreaId
128
- * @param {string} fileId
129
- * @param {{ download?: boolean, savePath?: string }} [options] - If download is true, saves file and sets downloadedFilePath on the returned object
130
- * @returns {Promise<object>}
131
- */
132
- async getFile(projectId, fileAreaId, fileId, options = {}) {
133
- const fileInfo = await this._client.get(
134
- `/5.0/projects/${projectId}/file_areas/${fileAreaId}/files/${fileId}`,
135
- );
136
- if (options.download && fileInfo && fileInfo.data && fileInfo.data.downloadLink) {
137
- const name = fileInfo.data.fileName || fileId;
138
- const downloadedPath = await this.downloadFileFromLink(
139
- fileInfo.data.downloadLink,
140
- name,
141
- options.savePath,
142
- );
143
- fileInfo.downloadedFilePath = downloadedPath;
144
- }
145
- return fileInfo;
146
- }
147
-
148
- /**
149
- * Download all files in a folder, with optional keyword / extension filters (Python parity).
150
- * @param {string} projectId
151
- * @param {string} fileAreaId
152
- * @param {string} folderId
153
- * @param {string} [savePath]
154
- * @param {object} [opts] - filenameKeywords, filenameKeywordsMatch ('any'|'all'), filenameExtensions, params, verbose
155
- * @returns {Promise<Array<{ fileName: string, downloadedFilePath: string }>>}
156
- */
157
- async bulkDownloadFolder(projectId, fileAreaId, folderId, savePath, opts = {}) {
158
- if (typeof savePath === 'object' && savePath !== null && !Array.isArray(savePath)) {
159
- opts = savePath;
160
- savePath = undefined;
161
- }
162
- const {
163
- filenameKeywords = null,
164
- filenameKeywordsMatch = 'any',
165
- filenameExtensions = null,
166
- params = {},
167
- verbose = false,
168
- } = opts;
169
-
170
- let files = await this.getAllFilesInFolder(projectId, fileAreaId, folderId, params, verbose);
171
-
172
- if (filenameKeywords && filenameKeywords.length) {
173
- const kws = filenameKeywords.map((kw) => String(kw).toLowerCase());
174
- const matchFn =
175
- filenameKeywordsMatch === 'all'
176
- ? (name) => kws.every((kw) => name.includes(kw))
177
- : (name) => kws.some((kw) => name.includes(kw));
178
- files = files.filter((f) => {
179
- const name = (((f.data) || {}).fileName) || '';
180
- return matchFn(name.toLowerCase());
181
- });
182
- if (verbose) {
183
- console.log(
184
- `Files matching fileName keywords ${JSON.stringify(filenameKeywords)} (${filenameKeywordsMatch}): ${files.length}`,
185
- );
186
- }
187
- }
188
-
189
- if (filenameExtensions && filenameExtensions.length) {
190
- const normExts = filenameExtensions.map((ext) =>
191
- (ext.startsWith('.') ? ext : `.${ext}`).toLowerCase(),
192
- );
193
- const before = files.length;
194
- files = files.filter((f) => {
195
- const n = (((f.data) || {}).fileName) || ''.toLowerCase();
196
- return normExts.some((ext) => n.endsWith(ext));
197
- });
198
- if (verbose) {
199
- console.log(`Files matching fileName extensions ${JSON.stringify(normExts)}: ${files.length} / ${before}`);
200
- }
201
- }
202
-
203
- const results = [];
204
- for (let i = 0; i < files.length; i += 1) {
205
- const f = files[i];
206
- const data = f.data || {};
207
- const fileName = data.fileName || data.fileId || `file_${i + 1}`;
208
- const downloadLink = data.downloadLink;
209
- if (!downloadLink) {
210
- if (verbose) {
211
- console.log(` [${i + 1}/${files.length}] Skipping '${fileName}' (no downloadLink)`);
212
- }
213
- continue;
214
- }
215
- if (verbose) {
216
- console.log(` [${i + 1}/${files.length}] Downloading '${fileName}'...`);
217
- }
218
- const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
219
- results.push({ fileName, downloadedFilePath });
220
- }
221
- if (verbose) {
222
- console.log(`Bulk download complete. ${results.length} file(s) downloaded.`);
223
- }
224
- return results;
225
- }
226
-
227
- /**
228
- * Download files by id (fetches metadata per id then streams from downloadLink).
229
- * @param {string} projectId
230
- * @param {string} fileAreaId
231
- * @param {string[]} fileIds
232
- * @param {string} [savePath]
233
- * @param {{ verbose?: boolean }} [options]
234
- * @returns {Promise<Array<{ fileId: string, fileName: string, downloadedFilePath: string }>>}
235
- */
236
- async bulkDownloadByIds(projectId, fileAreaId, fileIds, savePath, options = {}) {
237
- if (typeof savePath === 'object' && savePath !== null) {
238
- options = savePath;
239
- savePath = undefined;
240
- }
241
- const { verbose = false } = options;
242
- const results = [];
243
- const total = fileIds.length;
244
- for (let i = 0; i < fileIds.length; i += 1) {
245
- const fileId = fileIds[i];
246
- const fileInfo = await this.getFile(projectId, fileAreaId, fileId);
247
- const data = (fileInfo && fileInfo.data) || {};
248
- const fileName = data.fileName || fileId;
249
- const downloadLink = data.downloadLink;
250
- if (!downloadLink) {
251
- if (verbose) {
252
- console.log(` [${i + 1}/${total}] Skipping '${fileName}' (no downloadLink)`);
253
- }
254
- continue;
255
- }
256
- if (verbose) {
257
- console.log(` [${i + 1}/${total}] Downloading '${fileName}'...`);
258
- }
259
- const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
260
- results.push({ fileId, fileName, downloadedFilePath });
261
- }
262
- if (verbose) {
263
- console.log(`Done. ${results.length}/${total} file(s) downloaded.`);
264
- }
265
- return results;
266
- }
267
-
268
- /**
269
- * Retrieve properties mapping for a specific file.
270
- * GET /1.0/projects/{projectId}/file_areas/{fileAreaId}/files/{fileId}/properties/1.0/mappings
271
- * @param {string} projectId
272
- * @param {string} fileAreaId
273
- * @param {string} fileId
274
- * @returns {Promise<object>}
275
- */
276
- getFilePropertiesMapping(projectId, fileAreaId, fileId) {
277
- return this._client.get(
278
- `/1.0/projects/${projectId}/file_areas/${fileAreaId}/files/${fileId}/properties/1.0/mappings`,
279
- );
280
- }
281
-
282
- /**
283
- * Retrieve valid property values for a specific file property mapping.
284
- * GET /1.0/projects/{projectId}/file_areas/{fileAreaId}/files/properties/1.0/mappings/{filePropertyId}/values
285
- * @param {string} projectId
286
- * @param {string} fileAreaId
287
- * @param {string} filePropertyId
288
- * @returns {Promise<object>}
289
- */
290
- getFilePropertyMappingValues(projectId, fileAreaId, filePropertyId) {
291
- return this._client.get(
292
- `/1.0/projects/${projectId}/file_areas/${fileAreaId}/files/properties/1.0/mappings/${filePropertyId}/values`,
293
- );
294
- }
295
- }
296
-
297
- module.exports = FilesApi;
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const axios = require('axios');
7
+ const { findByField, findAllByField } = require('../utils/search');
8
+ const { resolveFolderIdFromNamedPath } = require('../utils/pathResolver');
9
+ const { validateProjectId, validateFileAreaId, validateFolderId } = require('../utils/validation');
10
+ const { convertToModel, convertToModelList } = require('../models/convert');
11
+ const { FileSchema, FileResponseSchema, FilesListResponseSchema } = require('../models/files');
12
+
13
+ /**
14
+ * API methods for files within a file area.
15
+ */
16
+ class FilesApi {
17
+ /**
18
+ * @param {import('../apiClient')} apiClient
19
+ */
20
+ constructor(apiClient) {
21
+ this._client = apiClient;
22
+ }
23
+
24
+ /**
25
+ * Browse all files on the given project and file area.
26
+ * GET /6.1/projects/{projectId}/file_areas/{fileAreaId}/files
27
+ * @param {string} projectId
28
+ * @param {string} fileAreaId
29
+ * @param {object} [params] - Optional params (e.g. folderId, updatedAfter, includeProperties). The files endpoint does not support OData $filter.
30
+ * @returns {Promise<object>}
31
+ */
32
+ async listFiles(projectId, fileAreaId, params = {}) {
33
+ const response = await this._client.get(
34
+ `/6.1/projects/${projectId}/file_areas/${fileAreaId}/files`,
35
+ params,
36
+ );
37
+ return convertToModel(response, FilesListResponseSchema, 'FilesListResponse');
38
+ }
39
+
40
+ /**
41
+ * Retrieve all files by following bookmark pagination (metadata.totalRemainingItems).
42
+ * @param {string} projectId
43
+ * @param {string} fileAreaId
44
+ * @param {object} [params]
45
+ * @param {boolean} [verbose=false]
46
+ * @returns {Promise<object[]>}
47
+ */
48
+ async getAllFiles(projectId, fileAreaId, params = {}, verbose = false) {
49
+ validateProjectId(projectId);
50
+ validateFileAreaId(fileAreaId);
51
+ const allItems = [];
52
+ let currentParams = { ...params };
53
+ let hasNextPage = true;
54
+ const urlPath = `/6.1/projects/${projectId}/file_areas/${fileAreaId}/files`;
55
+
56
+ while (hasNextPage) {
57
+ const response = await this._client.get(urlPath, currentParams);
58
+ const items = response && response.items;
59
+ if (items && items.length) {
60
+ allItems.push(...items);
61
+ }
62
+ const remaining = ((response && response.metadata) || {}).totalRemainingItems ?? 0;
63
+ if (verbose) {
64
+ console.log(`Retrieved ${allItems.length} files so far, ${remaining} remaining...`);
65
+ }
66
+ if (!items || !items.length || remaining === 0) {
67
+ hasNextPage = false;
68
+ } else {
69
+ const nextLink = (response.links || []).find((l) => l.rel === 'nextPage');
70
+ if (nextLink) {
71
+ const bookmark = new URL(nextLink.href).searchParams.get('bookmark');
72
+ currentParams = { ...params, bookmark };
73
+ } else {
74
+ hasNextPage = false;
75
+ }
76
+ }
77
+ }
78
+ if (verbose) {
79
+ console.log(`Done. Total files retrieved: ${allItems.length}`);
80
+ }
81
+ return convertToModelList(allItems, FileSchema, 'File');
82
+ }
83
+
84
+ /**
85
+ * All files in a folder.
86
+ *
87
+ * Supports two call styles matching the Python client:
88
+ * - `getAllFilesInFolder(projectId, fileAreaId, folderId, params?, verbose?)` — explicit IDs
89
+ * - `getAllFilesInFolder(projectId, fileAreaIdOrPath, null, params?, verbose?)` — full path
90
+ * (e.g. ``"Files/4_Design/C07_Geometry"``)
91
+ *
92
+ * @param {string} projectId
93
+ * @param {string} fileAreaIdOrPath - File area ID, OR a full path starting with the file area name.
94
+ * @param {string|null} [folderId=null] - Folder ID. When null, fileAreaIdOrPath is treated as a path.
95
+ * @param {object} [params]
96
+ * @param {boolean} [verbose=false]
97
+ * @returns {Promise<object[]>}
98
+ */
99
+ async getAllFilesInFolder(projectId, fileAreaIdOrPath, folderId = null, params = {}, verbose = false) {
100
+ validateProjectId(projectId);
101
+
102
+ let fileAreaId;
103
+ let resolvedFolderId;
104
+
105
+ if (folderId == null) {
106
+ const resolved = await resolveFolderIdFromNamedPath(
107
+ this._client, projectId, fileAreaIdOrPath, { verbose },
108
+ );
109
+ if (!resolved.fileAreaId || !resolved.folderId) {
110
+ if (verbose) console.log(`Could not resolve folder path: ${fileAreaIdOrPath}`);
111
+ return [];
112
+ }
113
+ fileAreaId = resolved.fileAreaId;
114
+ resolvedFolderId = resolved.folderId;
115
+ } else {
116
+ fileAreaId = fileAreaIdOrPath;
117
+ resolvedFolderId = folderId;
118
+ validateFileAreaId(fileAreaId);
119
+ validateFolderId(resolvedFolderId);
120
+ }
121
+
122
+ const allFiles = await this.getAllFiles(projectId, fileAreaId, params, verbose);
123
+ const filtered = allFiles.filter((f) => {
124
+ const data = f.data || f;
125
+ return data.folderId === resolvedFolderId;
126
+ });
127
+ if (verbose) {
128
+ console.log(`Files matching folder '${resolvedFolderId}': ${filtered.length}`);
129
+ }
130
+ return filtered;
131
+ }
132
+
133
+ /**
134
+ * Download a file from a direct download URL using X-API-KEY (same as Python client).
135
+ * @param {string} downloadLink
136
+ * @param {string} fileName
137
+ * @param {string} [savePath] - Directory to save into (default: current working directory)
138
+ * @returns {Promise<string>} Absolute path to saved file
139
+ */
140
+ async downloadFileFromLink(downloadLink, fileName, savePath) {
141
+ const apiKey = this._client.configuration.apiKey;
142
+ const dir = savePath || '.';
143
+ await fs.promises.mkdir(dir, { recursive: true });
144
+ const filePath = path.join(dir, fileName);
145
+
146
+ const response = await axios.get(downloadLink, {
147
+ headers: { 'X-API-KEY': apiKey },
148
+ responseType: 'stream',
149
+ validateStatus: () => true,
150
+ });
151
+ if (response.status !== 200) {
152
+ throw new Error(`Failed to download file. Status code: ${response.status}`);
153
+ }
154
+ await new Promise((resolve, reject) => {
155
+ const ws = fs.createWriteStream(filePath);
156
+ response.data.pipe(ws);
157
+ response.data.on('error', reject);
158
+ ws.on('finish', () => resolve());
159
+ ws.on('error', reject);
160
+ });
161
+ return path.resolve(filePath);
162
+ }
163
+
164
+ /**
165
+ * Get a file by IDs or by full path.
166
+ *
167
+ * Supports two call styles matching the Python client:
168
+ * - `getFile(projectId, fileAreaId, fileId, options?)` — explicit IDs
169
+ * - `getFile(projectId, fullPath, null, options?)` — full path including file name
170
+ * (e.g. ``"Files/folder/.../file.ifc"``)
171
+ *
172
+ * @param {string} projectId
173
+ * @param {string} fileAreaIdOrPath - File area ID, OR a full path.
174
+ * @param {string|null} [fileId=null] - File ID. When null, fileAreaIdOrPath is treated as a path.
175
+ * @param {{ download?: boolean, savePath?: string, verbose?: boolean, params?: object }} [options]
176
+ * @returns {Promise<object|string>}
177
+ */
178
+ async getFile(projectId, fileAreaIdOrPath, fileId = null, options = {}) {
179
+ validateProjectId(projectId);
180
+
181
+ if (fileId == null) {
182
+ // Path-based lookup
183
+ const { download = false, savePath, verbose = false, params } = options;
184
+ const pathStr = fileAreaIdOrPath;
185
+ const notFound = `File does not exist: ${pathStr}`;
186
+ const parts = pathStr.split('/').map((p) => p.trim()).filter(Boolean);
187
+ if (parts.length < 3) {
188
+ throw new Error('path must include file area name, folder path, and file name');
189
+ }
190
+ const resolved = await resolveFolderIdFromNamedPath(
191
+ this._client, projectId, parts.slice(0, -1).join('/'), { verbose },
192
+ );
193
+ if (!resolved.fileAreaId || !resolved.folderId) return notFound;
194
+ const files = await this.getAllFilesInFolder(
195
+ projectId, resolved.fileAreaId, resolved.folderId, params, verbose,
196
+ );
197
+ const candidateFileName = parts[parts.length - 1];
198
+ const fileMatch = findByField(files, 'fileName', candidateFileName, (x) => x.data || x)
199
+ || findByField(files, 'file_name', candidateFileName, (x) => x.data || x);
200
+ if (!fileMatch) return notFound;
201
+
202
+ if (download) {
203
+ const data = fileMatch.data || fileMatch;
204
+ if (data.downloadLink) {
205
+ const name = data.fileName || data.file_name || candidateFileName;
206
+ const downloadedFilePath = await this.downloadFileFromLink(data.downloadLink, name, savePath);
207
+ return { ...fileMatch, downloadedFilePath };
208
+ }
209
+ }
210
+ return fileMatch;
211
+ }
212
+
213
+ // ID-based lookup (original behaviour)
214
+ const rawResponse = await this._client.get(
215
+ `/5.0/projects/${projectId}/file_areas/${fileAreaIdOrPath}/files/${fileId}`,
216
+ );
217
+ const fileInfo = convertToModel(rawResponse, FileResponseSchema, 'FileResponse');
218
+ if (options.download && fileInfo && fileInfo.data && fileInfo.data.downloadLink) {
219
+ const name = fileInfo.data.fileName || fileId;
220
+ const downloadedPath = await this.downloadFileFromLink(
221
+ fileInfo.data.downloadLink,
222
+ name,
223
+ options.savePath,
224
+ );
225
+ fileInfo.downloadedFilePath = downloadedPath;
226
+ }
227
+ return fileInfo;
228
+ }
229
+
230
+ /**
231
+ * Apply file name filters (contains, startsWith, endsWith, extensions).
232
+ * @param {object[]} files
233
+ * @param {object} filters
234
+ * @returns {object[]}
235
+ */
236
+ _applyFileNameFilters(files, filters = {}) {
237
+ const {
238
+ contains = null,
239
+ containsMatch = 'any',
240
+ notContains = null,
241
+ startsWith = null,
242
+ notStartsWith = null,
243
+ endsWith = null,
244
+ notEndsWith = null,
245
+ extensions = null,
246
+ notExtensions = null,
247
+ verbose = false,
248
+ } = filters;
249
+
250
+ function getName(f) {
251
+ return ((f.data || f).fileName || (f.data || f).file_name || '').toLowerCase();
252
+ }
253
+ function norm(vals) {
254
+ return (vals || []).map((v) => String(v).toLowerCase()).filter(Boolean);
255
+ }
256
+ function normExts(vals) {
257
+ return (vals || []).map((v) => (v.startsWith('.') ? v : `.${v}`).toLowerCase()).filter(Boolean);
258
+ }
259
+
260
+ let filtered = files;
261
+
262
+ const containsVals = norm(contains);
263
+ if (containsVals.length) {
264
+ const before = filtered.length;
265
+ const matchFn = containsMatch === 'all'
266
+ ? (name) => containsVals.every((v) => name.includes(v))
267
+ : (name) => containsVals.some((v) => name.includes(v));
268
+ filtered = filtered.filter((f) => matchFn(getName(f)));
269
+ if (verbose) console.log(`Files matching contains ${JSON.stringify(containsVals)} (${containsMatch}): ${filtered.length} / ${before}`);
270
+ }
271
+
272
+ const notContainsVals = norm(notContains);
273
+ if (notContainsVals.length) {
274
+ const before = filtered.length;
275
+ filtered = filtered.filter((f) => !notContainsVals.some((v) => getName(f).includes(v)));
276
+ if (verbose) console.log(`Files excluding contains ${JSON.stringify(notContainsVals)}: ${filtered.length} / ${before}`);
277
+ }
278
+
279
+ const startsWithVals = norm(startsWith);
280
+ if (startsWithVals.length) {
281
+ const before = filtered.length;
282
+ filtered = filtered.filter((f) => startsWithVals.some((v) => getName(f).startsWith(v)));
283
+ if (verbose) console.log(`Files matching startsWith ${JSON.stringify(startsWithVals)}: ${filtered.length} / ${before}`);
284
+ }
285
+
286
+ const notStartsWithVals = norm(notStartsWith);
287
+ if (notStartsWithVals.length) {
288
+ const before = filtered.length;
289
+ filtered = filtered.filter((f) => !notStartsWithVals.some((v) => getName(f).startsWith(v)));
290
+ if (verbose) console.log(`Files excluding startsWith ${JSON.stringify(notStartsWithVals)}: ${filtered.length} / ${before}`);
291
+ }
292
+
293
+ const endsWithVals = norm(endsWith);
294
+ if (endsWithVals.length) {
295
+ const before = filtered.length;
296
+ filtered = filtered.filter((f) => endsWithVals.some((v) => getName(f).endsWith(v)));
297
+ if (verbose) console.log(`Files matching endsWith ${JSON.stringify(endsWithVals)}: ${filtered.length} / ${before}`);
298
+ }
299
+
300
+ const notEndsWithVals = norm(notEndsWith);
301
+ if (notEndsWithVals.length) {
302
+ const before = filtered.length;
303
+ filtered = filtered.filter((f) => !notEndsWithVals.some((v) => getName(f).endsWith(v)));
304
+ if (verbose) console.log(`Files excluding endsWith ${JSON.stringify(notEndsWithVals)}: ${filtered.length} / ${before}`);
305
+ }
306
+
307
+ const extVals = normExts(extensions);
308
+ if (extVals.length) {
309
+ const before = filtered.length;
310
+ filtered = filtered.filter((f) => extVals.some((v) => getName(f).endsWith(v)));
311
+ if (verbose) console.log(`Files matching extensions ${JSON.stringify(extVals)}: ${filtered.length} / ${before}`);
312
+ }
313
+
314
+ const notExtVals = normExts(notExtensions);
315
+ if (notExtVals.length) {
316
+ const before = filtered.length;
317
+ filtered = filtered.filter((f) => !notExtVals.some((v) => getName(f).endsWith(v)));
318
+ if (verbose) console.log(`Files excluding extensions ${JSON.stringify(notExtVals)}: ${filtered.length} / ${before}`);
319
+ }
320
+
321
+ return filtered;
322
+ }
323
+
324
+ /**
325
+ * Download all files in a folder with optional file name filters.
326
+ *
327
+ * Supports two call styles matching the Python client:
328
+ * - `bulkDownloadFolder(projectId, fileAreaId, folderId, savePath?, opts?)` — explicit IDs
329
+ * - `bulkDownloadFolder(projectId, fullPath, null, savePath?, opts?)` — full path
330
+ *
331
+ * @param {string} projectId
332
+ * @param {string} fileAreaIdOrPath - File area ID or full path starting with file area name.
333
+ * @param {string|null} [folderId=null] - Folder ID; when null, fileAreaIdOrPath is a full path.
334
+ * @param {string} [savePath]
335
+ * @param {object} [opts]
336
+ * @param {string[]} [opts.contains]
337
+ * @param {string} [opts.containsMatch='any']
338
+ * @param {string[]} [opts.notContains]
339
+ * @param {string[]} [opts.startsWith]
340
+ * @param {string[]} [opts.notStartsWith]
341
+ * @param {string[]} [opts.endsWith]
342
+ * @param {string[]} [opts.notEndsWith]
343
+ * @param {string[]} [opts.extensions]
344
+ * @param {string[]} [opts.notExtensions]
345
+ * @param {object} [opts.params]
346
+ * @param {boolean} [opts.verbose=false]
347
+ * @returns {Promise<Array<{ fileName: string, downloadedFilePath: string }>>}
348
+ */
349
+ async bulkDownloadFolder(projectId, fileAreaIdOrPath, folderId = null, savePath, opts = {}) {
350
+ if (typeof savePath === 'object' && savePath !== null && !Array.isArray(savePath)) {
351
+ opts = savePath;
352
+ savePath = undefined;
353
+ }
354
+ const { params = {}, verbose = false, ...filterOpts } = opts;
355
+
356
+ let files = await this.getAllFilesInFolder(
357
+ projectId, fileAreaIdOrPath, folderId, params, verbose,
358
+ );
359
+
360
+ // Legacy keyword/extension filter aliases for backward compatibility
361
+ if (opts.filenameKeywords && opts.filenameKeywords.length) {
362
+ filterOpts.contains = opts.filenameKeywords;
363
+ filterOpts.containsMatch = opts.filenameKeywordsMatch || 'any';
364
+ }
365
+ if (opts.filenameExtensions && opts.filenameExtensions.length) {
366
+ filterOpts.extensions = opts.filenameExtensions;
367
+ }
368
+
369
+ files = this._applyFileNameFilters(files, { ...filterOpts, verbose });
370
+
371
+ const results = [];
372
+ for (let i = 0; i < files.length; i += 1) {
373
+ const f = files[i];
374
+ const data = f.data || f;
375
+ const fileName = data.fileName || data.file_name || data.fileId || `file_${i + 1}`;
376
+ const downloadLink = data.downloadLink;
377
+ if (!downloadLink) {
378
+ if (verbose) console.log(` [${i + 1}/${files.length}] Skipping '${fileName}' (no downloadLink)`);
379
+ continue;
380
+ }
381
+ if (verbose) console.log(` [${i + 1}/${files.length}] Downloading '${fileName}'...`);
382
+ const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
383
+ results.push({ fileName, downloadedFilePath });
384
+ }
385
+ if (verbose) console.log(`Bulk download complete. ${results.length} file(s) downloaded.`);
386
+ return results;
387
+ }
388
+
389
+ /**
390
+ * Download a list of files by IDs or full paths.
391
+ *
392
+ * Matches Python's ``bulk_download_files``:
393
+ * - When *fileAreaId* is ``null``, each item in *files* is treated as a full path
394
+ * (e.g. ``"Files/folder/.../file.ext"``).
395
+ * - When *fileAreaId* is provided, items are treated as file IDs.
396
+ *
397
+ * @param {string} projectId
398
+ * @param {string[]} files - List of file IDs or full paths.
399
+ * @param {string|null} [fileAreaId=null] - Required when items are file IDs; null for paths.
400
+ * @param {string} [savePath]
401
+ * @param {object} [opts]
402
+ * @param {object} [opts.params]
403
+ * @param {boolean} [opts.verbose=false]
404
+ * @returns {Promise<Array<{ fileName: string, downloadedFilePath: string }>>}
405
+ */
406
+ async bulkDownloadFiles(projectId, files, fileAreaId = null, savePath, opts = {}) {
407
+ if (typeof savePath === 'object' && savePath !== null) {
408
+ opts = savePath;
409
+ savePath = undefined;
410
+ }
411
+ const { params = {}, verbose = false } = opts;
412
+
413
+ const results = [];
414
+ const total = files.length;
415
+ const fileAreasCache = {};
416
+ const foldersCache = {};
417
+ const resolvedPathsCache = {};
418
+ const allFilesCache = {};
419
+
420
+ for (let i = 0; i < files.length; i += 1) {
421
+ const item = files[i];
422
+ let fileData = null;
423
+
424
+ if (fileAreaId == null) {
425
+ // Path-based: "FileArea/Folder/.../file.ext"
426
+ const parts = item.split('/').map((p) => p.trim()).filter(Boolean);
427
+ if (parts.length < 3) {
428
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${item}' (invalid path)`);
429
+ continue;
430
+ }
431
+ const { fileAreaId: resolvedFaId, folderId } = await resolveFolderIdFromNamedPath(
432
+ this._client, projectId, parts.slice(0, -1).join('/'),
433
+ { verbose, fileAreasCache, foldersCache, resolvedPathsCache },
434
+ );
435
+ if (!resolvedFaId || !folderId) {
436
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${item}' (File does not exist: ${item})`);
437
+ continue;
438
+ }
439
+ if (!allFilesCache[resolvedFaId]) {
440
+ allFilesCache[resolvedFaId] = await this.getAllFiles(projectId, resolvedFaId, params, verbose);
441
+ }
442
+ const folderFiles = (allFilesCache[resolvedFaId] || []).filter((f) => {
443
+ const d = f.data || f;
444
+ return d.folderId === folderId;
445
+ });
446
+ const candidateName = parts[parts.length - 1];
447
+ const match = findByField(folderFiles, 'fileName', candidateName, (x) => x.data || x)
448
+ || findByField(folderFiles, 'file_name', candidateName, (x) => x.data || x);
449
+ if (!match) {
450
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${item}' (File does not exist: ${item})`);
451
+ continue;
452
+ }
453
+ fileData = match.data || match;
454
+ } else {
455
+ // ID-based
456
+ const fileInfo = await this.getFile(projectId, fileAreaId, item);
457
+ if (!fileInfo || typeof fileInfo === 'string') {
458
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${item}' (${fileInfo || 'not found'})`);
459
+ continue;
460
+ }
461
+ fileData = fileInfo.data || fileInfo;
462
+ }
463
+
464
+ const fileName = fileData.fileName || fileData.file_name || fileData.fileId || item;
465
+ const downloadLink = fileData.downloadLink;
466
+ if (!downloadLink) {
467
+ if (verbose) console.log(` [${i + 1}/${total}] Skipping '${fileName}' (no downloadLink)`);
468
+ continue;
469
+ }
470
+ if (verbose) console.log(` [${i + 1}/${total}] Downloading '${fileName}'...`);
471
+ const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
472
+ results.push({ fileName, downloadedFilePath });
473
+ }
474
+ if (verbose) console.log(`Done. ${results.length}/${total} file(s) downloaded.`);
475
+ return results;
476
+ }
477
+
478
+ /**
479
+ * Download files by id (fetches metadata per id then streams from downloadLink).
480
+ * @param {string} projectId
481
+ * @param {string} fileAreaId
482
+ * @param {string[]} fileIds
483
+ * @param {string} [savePath]
484
+ * @param {{ verbose?: boolean }} [options]
485
+ * @returns {Promise<Array<{ fileId: string, fileName: string, downloadedFilePath: string }>>}
486
+ */
487
+ async bulkDownloadByIds(projectId, fileAreaId, fileIds, savePath, options = {}) {
488
+ if (typeof savePath === 'object' && savePath !== null) {
489
+ options = savePath;
490
+ savePath = undefined;
491
+ }
492
+ const { verbose = false } = options;
493
+ const results = [];
494
+ const total = fileIds.length;
495
+ for (let i = 0; i < fileIds.length; i += 1) {
496
+ const fileId = fileIds[i];
497
+ const fileInfo = await this.getFile(projectId, fileAreaId, fileId);
498
+ const data = (fileInfo && fileInfo.data) || {};
499
+ const fileName = data.fileName || fileId;
500
+ const downloadLink = data.downloadLink;
501
+ if (!downloadLink) {
502
+ if (verbose) {
503
+ console.log(` [${i + 1}/${total}] Skipping '${fileName}' (no downloadLink)`);
504
+ }
505
+ continue;
506
+ }
507
+ if (verbose) {
508
+ console.log(` [${i + 1}/${total}] Downloading '${fileName}'...`);
509
+ }
510
+ const downloadedFilePath = await this.downloadFileFromLink(downloadLink, fileName, savePath);
511
+ results.push({ fileId, fileName, downloadedFilePath });
512
+ }
513
+ if (verbose) {
514
+ console.log(`Done. ${results.length}/${total} file(s) downloaded.`);
515
+ }
516
+ return results;
517
+ }
518
+
519
+ /**
520
+ * Interactively browse the folder tree level-by-level and select files.
521
+ *
522
+ * Navigation (type into stdin):
523
+ * - A **folder number** (single token) → enter that folder.
524
+ * - ``b`` / ``back`` → go up one level.
525
+ * - **File numbers / ranges** (e.g. ``1``, ``3-5``) → toggle selection.
526
+ * - ``d`` / ``done`` → finish and return selected file IDs.
527
+ *
528
+ * @param {string} projectId
529
+ * @param {string} fileAreaId
530
+ * @param {object|null} [tree] - Pre-built tree from FoldersApi.getFileAreaTree.
531
+ * If not provided, *foldersApi* must be supplied.
532
+ * @param {object|null} [foldersApi] - FoldersApi instance to build the tree automatically.
533
+ * @returns {Promise<string[]>} Ordered list of selected file IDs.
534
+ */
535
+ async selectFilesInteractive(projectId, fileAreaId, tree = null, foldersApi = null) {
536
+ if (!tree) {
537
+ if (!foldersApi) throw new Error("Either 'tree' or 'foldersApi' must be provided.");
538
+ tree = await foldersApi.getFileAreaTree(projectId, fileAreaId, this);
539
+ }
540
+
541
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
542
+ const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
543
+
544
+ function getFid(f) {
545
+ const d = f.data || f || {};
546
+ return d.id || d.fileId || null;
547
+ }
548
+ function getFname(f) {
549
+ return (f.data || f || {}).fileName || '<unknown>';
550
+ }
551
+ function hasContent(node) {
552
+ if (node.files.length) return true;
553
+ return node.children.some(hasContent);
554
+ }
555
+ function countFiles(node) {
556
+ return node.files.length + node.children.reduce((s, c) => s + countFiles(c), 0);
557
+ }
558
+ function parseTokens(raw, maxIdx) {
559
+ const chosen = new Set();
560
+ for (const token of raw.split(/\s+/)) {
561
+ if (token.includes('-')) {
562
+ const [lo, hi] = token.split('-').map(Number);
563
+ if (!isNaN(lo) && !isNaN(hi)) {
564
+ for (let n = lo; n <= hi; n++) chosen.add(n);
565
+ }
566
+ } else {
567
+ const n = parseInt(token, 10);
568
+ if (!isNaN(n)) chosen.add(n);
569
+ }
570
+ }
571
+ return new Set([...chosen].filter((n) => n >= 1 && n <= maxIdx));
572
+ }
573
+
574
+ const selectedIds = [];
575
+ const selectedSet = new Set();
576
+ const stack = [tree];
577
+
578
+ try {
579
+ while (true) {
580
+ const node = stack[stack.length - 1];
581
+ const folders = node.children
582
+ .filter(hasContent)
583
+ .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
584
+ const files = [...node.files].sort((a, b) =>
585
+ getFname(a).toLowerCase().localeCompare(getFname(b).toLowerCase()),
586
+ );
587
+
588
+ console.log();
589
+ const header = node.path || `[root] ${node.name}`;
590
+ console.log(` ${'='.repeat(60)}`);
591
+ console.log(` ${header}`);
592
+ console.log(` ${'='.repeat(60)}`);
593
+ const navHints = [];
594
+ if (stack.length > 1) navHints.push('[b] back');
595
+ navHints.push(`[d] done (${selectedIds.length} selected)`);
596
+ console.log(` ${navHints.join(' | ')}`);
597
+
598
+ if (folders.length) {
599
+ console.log('\n Folders:');
600
+ folders.forEach((child, i) => {
601
+ const total = countFiles(child);
602
+ console.log(` [${String(i + 1).padStart(3)}] ${child.name}/ (${total} file(s))`);
603
+ });
604
+ } else {
605
+ console.log('\n Folders: (none)');
606
+ }
607
+
608
+ const fileOffset = folders.length;
609
+ if (files.length) {
610
+ console.log('\n Files:');
611
+ files.forEach((f, j) => {
612
+ const idx = fileOffset + j + 1;
613
+ const mark = selectedSet.has(getFid(f)) ? '✓ ' : ' ';
614
+ console.log(` [${String(idx).padStart(3)}] ${mark}${getFname(f)}`);
615
+ });
616
+ } else {
617
+ console.log('\n Files: (none)');
618
+ }
619
+
620
+ console.log();
621
+ const raw = (await question(' > ')).trim();
622
+ const cmd = raw.toLowerCase();
623
+
624
+ if (cmd === 'd' || cmd === 'done') break;
625
+ if (cmd === '') continue;
626
+ if (cmd === 'b' || cmd === 'back') {
627
+ if (stack.length > 1) stack.pop();
628
+ continue;
629
+ }
630
+
631
+ // Single folder navigation
632
+ const tokens = raw.split(/\s+/);
633
+ if (tokens.length === 1 && /^\d+$/.test(tokens[0])) {
634
+ const idx = parseInt(tokens[0], 10);
635
+ if (idx >= 1 && idx <= folders.length) {
636
+ stack.push(folders[idx - 1]);
637
+ continue;
638
+ }
639
+ }
640
+
641
+ // File toggles
642
+ const chosen = parseTokens(raw, fileOffset + files.length);
643
+ let toggled = 0;
644
+ for (const idx of [...chosen].sort((a, b) => a - b)) {
645
+ if (idx <= folders.length) continue;
646
+ const fileIdx = idx - fileOffset - 1;
647
+ if (fileIdx >= 0 && fileIdx < files.length) {
648
+ const f = files[fileIdx];
649
+ const fid = getFid(f);
650
+ if (!fid) continue;
651
+ if (selectedSet.has(fid)) {
652
+ selectedSet.delete(fid);
653
+ const pos = selectedIds.indexOf(fid);
654
+ if (pos !== -1) selectedIds.splice(pos, 1);
655
+ } else {
656
+ selectedSet.add(fid);
657
+ selectedIds.push(fid);
658
+ }
659
+ toggled++;
660
+ }
661
+ }
662
+ if (toggled) console.log(` → ${selectedIds.length} file(s) selected total.`);
663
+ }
664
+ } finally {
665
+ rl.close();
666
+ }
667
+
668
+ console.log(`\n Done. ${selectedIds.length} file(s) selected.`);
669
+ return selectedIds;
670
+ }
671
+
672
+ /**
673
+ * Retrieve properties mapping for a specific file.
674
+ * GET /1.0/projects/{projectId}/file_areas/{fileAreaId}/files/{fileId}/properties/1.0/mappings
675
+ * @param {string} projectId
676
+ * @param {string} fileAreaId
677
+ * @param {string} fileId
678
+ * @returns {Promise<object>}
679
+ */
680
+ getFilePropertiesMapping(projectId, fileAreaId, fileId) {
681
+ return this._client.get(
682
+ `/1.0/projects/${projectId}/file_areas/${fileAreaId}/files/${fileId}/properties/1.0/mappings`,
683
+ );
684
+ }
685
+
686
+ /**
687
+ * Retrieve valid property values for a specific file property mapping.
688
+ * GET /1.0/projects/{projectId}/file_areas/{fileAreaId}/files/properties/1.0/mappings/{filePropertyId}/values
689
+ * @param {string} projectId
690
+ * @param {string} fileAreaId
691
+ * @param {string} filePropertyId
692
+ * @returns {Promise<object>}
693
+ */
694
+ getFilePropertyMappingValues(projectId, fileAreaId, filePropertyId) {
695
+ return this._client.get(
696
+ `/1.0/projects/${projectId}/file_areas/${fileAreaId}/files/properties/1.0/mappings/${filePropertyId}/values`,
697
+ );
698
+ }
699
+ }
700
+
701
+ module.exports = FilesApi;