@dyno181cm.nexsoft/zentao_mcp 1.4.3 → 1.5.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.
- package/build/formatters/actionFormatter.js +13 -30
- package/build/formatters/imageLocalizer.js +2 -0
- package/build/tools/bugTool.js +1 -1
- package/build/tools/commentTool.js +1 -1
- package/build/tools/statusTool.js +2 -2
- package/build/tools/taskTool.js +1 -1
- package/build/utils/markdownUtils.js +6 -2
- package/build/utils/mcpResponse.js +11 -10
- package/build/zentaoClient.js +104 -96
- package/package.json +1 -1
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { htmlToMarkdown, parseFiles } from "../utils/markdownUtils.js";
|
|
2
2
|
import { localizeImages } from "./imageLocalizer.js";
|
|
3
|
-
import {
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import * as os from "os";
|
|
6
|
-
import fs from "fs";
|
|
3
|
+
import { renderAttachments } from "./attachmentRenderer.js";
|
|
7
4
|
/**
|
|
8
5
|
* Normalizes and formats the history/comments/actions list into a clean Markdown block.
|
|
9
6
|
*
|
|
@@ -47,36 +44,22 @@ export async function renderHistoryAndComments(actions, client, downloadedImages
|
|
|
47
44
|
commentStr = `\n > ${commentMd.replace(/\n/g, '\n > ')}`;
|
|
48
45
|
}
|
|
49
46
|
}
|
|
50
|
-
// 3. Format files if present in action
|
|
47
|
+
// 3. Format files if present in action — delegated to renderAttachments
|
|
51
48
|
let filesStr = "";
|
|
52
|
-
if (act.files
|
|
49
|
+
if (act.files) {
|
|
53
50
|
const parsedFiles = parseFiles(act.files);
|
|
54
51
|
if (parsedFiles.length > 0) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
downloadedImages.push(localPath);
|
|
66
|
-
}
|
|
52
|
+
// renderAttachments returns a full "## Files\n..." block; for inline action
|
|
53
|
+
// files we extract only the list items to keep the timeline compact.
|
|
54
|
+
const attachmentBlock = await renderAttachments(parsedFiles, client, downloadedImages);
|
|
55
|
+
if (attachmentBlock) {
|
|
56
|
+
// Strip the section header and trim, then indent as a sub-item.
|
|
57
|
+
const listContent = attachmentBlock
|
|
58
|
+
.replace(/^\s*## Files\s*\n/, '')
|
|
59
|
+
.trim();
|
|
60
|
+
if (listContent) {
|
|
61
|
+
filesStr = `\n - *Attachments:* ${listContent.replace(/^- /gm, '').replace(/\n- /g, ', ')}`;
|
|
67
62
|
}
|
|
68
|
-
catch {
|
|
69
|
-
// ignore download error
|
|
70
|
-
}
|
|
71
|
-
if (localPath) {
|
|
72
|
-
fileLinks.push(`[${f.title}](${toFileUrl(localPath)}) *(Size: ${formatSize(f.size)})*`);
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
fileLinks.push(`[${f.title}] *(download failed)*`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (fileLinks.length > 0) {
|
|
79
|
-
filesStr = `\n - *Attachments:* ${fileLinks.join(", ")}`;
|
|
80
63
|
}
|
|
81
64
|
}
|
|
82
65
|
}
|
|
@@ -71,6 +71,8 @@ export async function localizeImages(html, client, downloadedImages) {
|
|
|
71
71
|
}
|
|
72
72
|
catch {
|
|
73
73
|
// Keep original tag if download fails.
|
|
74
|
+
// Ensure in-flight entry is cleared even on unexpected errors.
|
|
75
|
+
inFlightImageDownloads.delete(remoteUrl);
|
|
74
76
|
}
|
|
75
77
|
}));
|
|
76
78
|
// Phase 2: Apply all replacements sequentially on the original string.
|
package/build/tools/bugTool.js
CHANGED
|
@@ -17,6 +17,6 @@ export function registerBugTool(server, client) {
|
|
|
17
17
|
const data = await client.getBugDetails(bugId);
|
|
18
18
|
const downloadedImages = [];
|
|
19
19
|
const markdown = await bugToMarkdown(data, client, downloadedImages);
|
|
20
|
-
return buildMcpResponse(markdown, downloadedImages);
|
|
20
|
+
return await buildMcpResponse(markdown, downloadedImages);
|
|
21
21
|
});
|
|
22
22
|
}
|
|
@@ -29,7 +29,7 @@ export function registerCommentTool(server, client) {
|
|
|
29
29
|
`# History & Comments for ${title}`,
|
|
30
30
|
markdown.trim() || "*No history or comments found.*"
|
|
31
31
|
].join("\n\n");
|
|
32
|
-
return buildMcpResponse(output, downloadedImages);
|
|
32
|
+
return await buildMcpResponse(output, downloadedImages);
|
|
33
33
|
});
|
|
34
34
|
server.registerTool("zentao_add_comment", {
|
|
35
35
|
description: "Add a comment/remark to a task or a bug",
|
|
@@ -54,7 +54,7 @@ export function registerStatusTools(server, client) {
|
|
|
54
54
|
const downloadedImages = [];
|
|
55
55
|
const markdown = await taskToMarkdown(updatedTask, client, downloadedImages);
|
|
56
56
|
const output = `### Successfully updated task status to '${updatedTask.status || "updated"}'!\n\n${markdown}`;
|
|
57
|
-
return buildMcpResponse(output, downloadedImages);
|
|
57
|
+
return await buildMcpResponse(output, downloadedImages);
|
|
58
58
|
});
|
|
59
59
|
// ─── Bug Status Update Tool ────────────────────────────────────────────────
|
|
60
60
|
server.registerTool("zentao_update_bug_status", {
|
|
@@ -90,6 +90,6 @@ export function registerStatusTools(server, client) {
|
|
|
90
90
|
const downloadedImages = [];
|
|
91
91
|
const markdown = await bugToMarkdown(updatedBug, client, downloadedImages);
|
|
92
92
|
const output = `### Successfully updated bug status to '${updatedBug.status || "updated"}'!\n\n${markdown}`;
|
|
93
|
-
return buildMcpResponse(output, downloadedImages);
|
|
93
|
+
return await buildMcpResponse(output, downloadedImages);
|
|
94
94
|
});
|
|
95
95
|
}
|
package/build/tools/taskTool.js
CHANGED
|
@@ -17,6 +17,6 @@ export function registerTaskTool(server, client) {
|
|
|
17
17
|
const data = await client.getTaskDetails(taskId);
|
|
18
18
|
const downloadedImages = [];
|
|
19
19
|
const markdown = await taskToMarkdown(data, client, downloadedImages);
|
|
20
|
-
return buildMcpResponse(markdown, downloadedImages);
|
|
20
|
+
return await buildMcpResponse(markdown, downloadedImages);
|
|
21
21
|
});
|
|
22
22
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import TurndownService from "turndown";
|
|
2
|
-
const turndownService = new TurndownService(
|
|
2
|
+
const turndownService = new TurndownService({
|
|
3
|
+
headingStyle: 'atx', // Use `#` headings instead of underline style
|
|
4
|
+
bulletListMarker: '-', // Consistent `-` for unordered lists
|
|
5
|
+
codeBlockStyle: 'fenced', // Use ``` for code blocks
|
|
6
|
+
});
|
|
3
7
|
/** Convert HTML to Markdown for better AI readability. */
|
|
4
8
|
export function htmlToMarkdown(html) {
|
|
5
9
|
if (!html)
|
|
@@ -19,7 +23,7 @@ export function parseFiles(files) {
|
|
|
19
23
|
size: f.size,
|
|
20
24
|
});
|
|
21
25
|
if (Array.isArray(files)) {
|
|
22
|
-
return files.filter(Boolean).map(mapper); //
|
|
26
|
+
return files.filter(Boolean).map(mapper); // filter null/undefined items
|
|
23
27
|
}
|
|
24
28
|
if (typeof files === "object") {
|
|
25
29
|
return Object.values(files).filter(Boolean).map(mapper);
|
|
@@ -10,28 +10,29 @@ export function mcpText(text) {
|
|
|
10
10
|
*
|
|
11
11
|
* Images are deduplicated and limited to the first 5 to stay within
|
|
12
12
|
* JSON-RPC / token limits. Individual images larger than 2 MB are skipped.
|
|
13
|
+
*
|
|
14
|
+
* Uses async file I/O (`fs.promises`) to avoid blocking the event loop
|
|
15
|
+
* while reading image data.
|
|
13
16
|
*/
|
|
14
|
-
export function buildMcpResponse(markdown, imagePaths) {
|
|
17
|
+
export async function buildMcpResponse(markdown, imagePaths) {
|
|
15
18
|
const content = [{ type: "text", text: markdown }];
|
|
16
19
|
// Deduplicate and cap at 5 images
|
|
17
20
|
const uniquePaths = Array.from(new Set(imagePaths)).slice(0, 5);
|
|
18
|
-
|
|
21
|
+
await Promise.all(uniquePaths.map(async (imgPath) => {
|
|
19
22
|
try {
|
|
20
|
-
if (!fs.existsSync(imgPath))
|
|
21
|
-
continue;
|
|
22
23
|
const mimeType = getMimeType(imgPath);
|
|
23
24
|
if (!mimeType)
|
|
24
|
-
|
|
25
|
-
const stats = fs.
|
|
25
|
+
return;
|
|
26
|
+
const stats = await fs.promises.stat(imgPath);
|
|
26
27
|
// Skip images > 2 MB to avoid hitting JSON-RPC / token limits
|
|
27
28
|
if (stats.size > 2 * 1024 * 1024)
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
content.push({ type: "image", data, mimeType });
|
|
29
|
+
return;
|
|
30
|
+
const buffer = await fs.promises.readFile(imgPath);
|
|
31
|
+
content.push({ type: "image", data: buffer.toString("base64"), mimeType });
|
|
31
32
|
}
|
|
32
33
|
catch {
|
|
33
34
|
// Ignore read errors for individual images
|
|
34
35
|
}
|
|
35
|
-
}
|
|
36
|
+
}));
|
|
36
37
|
return { content };
|
|
37
38
|
}
|
package/build/zentaoClient.js
CHANGED
|
@@ -2,6 +2,29 @@ import axios from 'axios';
|
|
|
2
2
|
import * as dotenv from 'dotenv';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
dotenv.config();
|
|
5
|
+
/**
|
|
6
|
+
* Resolution label map for bug actions.
|
|
7
|
+
* Defined at module level to avoid re-allocating the object on every call to
|
|
8
|
+
* `generateActionDesc`.
|
|
9
|
+
*/
|
|
10
|
+
const RESOLUTION_LABEL_MAP = {
|
|
11
|
+
fixed: '已解决',
|
|
12
|
+
design: '设计如此',
|
|
13
|
+
duplicate: '重复Bug',
|
|
14
|
+
external: '外部原因',
|
|
15
|
+
notrepro: '无法重现',
|
|
16
|
+
postponed: '延期处理',
|
|
17
|
+
willnotfix: '不予解决',
|
|
18
|
+
tostory: '转为需求',
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* User fields that may be stored as a plain account string in the classic API
|
|
22
|
+
* and need to be normalised to `{ account, realname }` objects.
|
|
23
|
+
*/
|
|
24
|
+
const USER_FIELDS = [
|
|
25
|
+
'openedBy', 'assignedTo', 'resolvedBy',
|
|
26
|
+
'closedBy', 'finishedBy', 'canceledBy', 'lastEditedBy',
|
|
27
|
+
];
|
|
5
28
|
/**
|
|
6
29
|
* Client for communicating with the Zentao API.
|
|
7
30
|
* Handles authentication, automatic token injection, session expiration retries,
|
|
@@ -15,8 +38,21 @@ export class ZentaoClient {
|
|
|
15
38
|
baseUrl = process.env.ZENTAO_BASE_URL || '';
|
|
16
39
|
getCache = new Map();
|
|
17
40
|
cacheTtlMs = 2 * 60 * 1000; // 2 minutes TTL
|
|
18
|
-
/**
|
|
41
|
+
/** In-flight request map prevents cache stampede under concurrent calls. */
|
|
19
42
|
inFlightRequests = new Map();
|
|
43
|
+
// ─── Computed properties ─────────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Returns the web base URL (without the API path suffix).
|
|
46
|
+
* Used by both `getFallbackClassicData` and `addComment` to avoid duplicating
|
|
47
|
+
* the same string-split logic in two places.
|
|
48
|
+
*/
|
|
49
|
+
get webBaseUrl() {
|
|
50
|
+
if (this.baseUrl.includes('/api.php/v1')) {
|
|
51
|
+
return this.baseUrl.split('/api.php/v1')[0];
|
|
52
|
+
}
|
|
53
|
+
return this.baseUrl.split('/api/v1')[0];
|
|
54
|
+
}
|
|
55
|
+
// ─── Cache helpers ────────────────────────────────────────────────────────────
|
|
20
56
|
/**
|
|
21
57
|
* Clears the in-memory cache for GET requests. Helpful for testing.
|
|
22
58
|
*/
|
|
@@ -35,7 +71,7 @@ export class ZentaoClient {
|
|
|
35
71
|
if (cached && now - cached.timestamp < this.cacheTtlMs) {
|
|
36
72
|
return cached.data;
|
|
37
73
|
}
|
|
38
|
-
//
|
|
74
|
+
// If a request for this URL is already in-flight, reuse its promise
|
|
39
75
|
// instead of firing a duplicate HTTP request (prevents cache stampede).
|
|
40
76
|
if (this.inFlightRequests.has(url)) {
|
|
41
77
|
return this.inFlightRequests.get(url);
|
|
@@ -52,6 +88,7 @@ export class ZentaoClient {
|
|
|
52
88
|
this.inFlightRequests.set(url, requestPromise);
|
|
53
89
|
return requestPromise;
|
|
54
90
|
}
|
|
91
|
+
// ─── Constructor & interceptors ───────────────────────────────────────────────
|
|
55
92
|
/**
|
|
56
93
|
* Initializes the Axios client with base configurations and registers interceptors
|
|
57
94
|
* to automatically inject token headers and handle token expiration (401 errors).
|
|
@@ -92,6 +129,7 @@ export class ZentaoClient {
|
|
|
92
129
|
return Promise.reject(error);
|
|
93
130
|
});
|
|
94
131
|
}
|
|
132
|
+
// ─── Authentication ───────────────────────────────────────────────────────────
|
|
95
133
|
/**
|
|
96
134
|
* Performs authentication request to Zentao API to obtain a token.
|
|
97
135
|
* Updates the internal token state upon successful login.
|
|
@@ -113,6 +151,7 @@ export class ZentaoClient {
|
|
|
113
151
|
throw new Error('Login failed: Token not found in response');
|
|
114
152
|
}
|
|
115
153
|
}
|
|
154
|
+
// ─── Private helpers ──────────────────────────────────────────────────────────
|
|
116
155
|
/**
|
|
117
156
|
* Helper to generate standard action descriptions when they are not present in classic API.
|
|
118
157
|
*/
|
|
@@ -122,20 +161,8 @@ export class ZentaoClient {
|
|
|
122
161
|
if (userMap[extraName]) {
|
|
123
162
|
extraName = userMap[extraName];
|
|
124
163
|
}
|
|
125
|
-
else {
|
|
126
|
-
|
|
127
|
-
'fixed': '已解决',
|
|
128
|
-
'design': '设计如此',
|
|
129
|
-
'duplicate': '重复Bug',
|
|
130
|
-
'external': '外部原因',
|
|
131
|
-
'notrepro': '无法重现',
|
|
132
|
-
'postponed': '延期处理',
|
|
133
|
-
'willnotfix': '不予解决',
|
|
134
|
-
'tostory': '转为需求',
|
|
135
|
-
};
|
|
136
|
-
if (resMap[extraName]) {
|
|
137
|
-
extraName = resMap[extraName];
|
|
138
|
-
}
|
|
164
|
+
else if (RESOLUTION_LABEL_MAP[extraName]) {
|
|
165
|
+
extraName = RESOLUTION_LABEL_MAP[extraName];
|
|
139
166
|
}
|
|
140
167
|
switch (act.action) {
|
|
141
168
|
case 'opened':
|
|
@@ -175,10 +202,7 @@ export class ZentaoClient {
|
|
|
175
202
|
* Fetches classic JSON API fallback data when REST API returns empty/errors.
|
|
176
203
|
*/
|
|
177
204
|
async getFallbackClassicData(type, id) {
|
|
178
|
-
const
|
|
179
|
-
? this.baseUrl.split('/api.php/v1')[0]
|
|
180
|
-
: this.baseUrl.split('/api/v1')[0];
|
|
181
|
-
const url = `${webBaseUrl}/${type}-view-${id}.json`;
|
|
205
|
+
const url = `${this.webBaseUrl}/${type}-view-${id}.json`;
|
|
182
206
|
const res = await this.client.get(url, {
|
|
183
207
|
baseURL: '', // override baseURL
|
|
184
208
|
});
|
|
@@ -190,8 +214,7 @@ export class ZentaoClient {
|
|
|
190
214
|
if (entity) {
|
|
191
215
|
const userMap = detailData.users || {};
|
|
192
216
|
// Normalize user fields
|
|
193
|
-
const
|
|
194
|
-
for (const field of userFields) {
|
|
217
|
+
for (const field of USER_FIELDS) {
|
|
195
218
|
const val = entity[field];
|
|
196
219
|
if (val && typeof val === 'string') {
|
|
197
220
|
entity[field] = {
|
|
@@ -227,6 +250,60 @@ export class ZentaoClient {
|
|
|
227
250
|
}
|
|
228
251
|
throw new Error(`Failed to fetch ${type} details from classic fallback API for ID ${id}`);
|
|
229
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Generic helper that fetches entity details from the REST API and falls back
|
|
255
|
+
* to the classic JSON API when the REST response is empty or throws an error.
|
|
256
|
+
* Caches the fallback result under the REST API key so subsequent calls are fast.
|
|
257
|
+
*
|
|
258
|
+
* @param restUrl The REST API path (e.g. `/tasks/1`).
|
|
259
|
+
* @param type Entity type passed to `getFallbackClassicData`.
|
|
260
|
+
* @param id Entity ID passed to `getFallbackClassicData`.
|
|
261
|
+
*/
|
|
262
|
+
async getWithFallback(restUrl, type, id) {
|
|
263
|
+
const runFallback = async () => {
|
|
264
|
+
const data = await this.getFallbackClassicData(type, id);
|
|
265
|
+
// Cache the fallback result under the REST key so repeated calls are fast.
|
|
266
|
+
this.getCache.set(restUrl, { data, timestamp: Date.now() });
|
|
267
|
+
return data;
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
const data = await this.get(restUrl);
|
|
271
|
+
if (!data || data === '') {
|
|
272
|
+
return runFallback();
|
|
273
|
+
}
|
|
274
|
+
return data;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return runFallback();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Shared stream-to-file helper used by `downloadFile` and `downloadImageToLocal`.
|
|
282
|
+
* Pipes a readable stream to a local file path, removing the partial file on error.
|
|
283
|
+
*
|
|
284
|
+
* @param readable A Node.js Readable stream (axios response body).
|
|
285
|
+
* @param targetPath The local file path to write to.
|
|
286
|
+
* @returns Resolves with `targetPath` on success.
|
|
287
|
+
*/
|
|
288
|
+
pipeStreamToFile(readable, targetPath) {
|
|
289
|
+
const writer = fs.createWriteStream(targetPath);
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
readable.pipe(writer);
|
|
292
|
+
let writeError = null;
|
|
293
|
+
writer.on('error', (err) => {
|
|
294
|
+
writeError = err;
|
|
295
|
+
writer.close();
|
|
296
|
+
// Remove the partial/corrupt file so future calls don't mistake it for a valid download.
|
|
297
|
+
fs.unlink(targetPath, () => { });
|
|
298
|
+
reject(err);
|
|
299
|
+
});
|
|
300
|
+
writer.on('close', () => {
|
|
301
|
+
if (!writeError)
|
|
302
|
+
resolve(targetPath);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
230
307
|
/**
|
|
231
308
|
* Retrieves details of a specific task.
|
|
232
309
|
* Checks the cache first, otherwise fetches from API.
|
|
@@ -236,25 +313,7 @@ export class ZentaoClient {
|
|
|
236
313
|
* @returns Resolves with the task details.
|
|
237
314
|
*/
|
|
238
315
|
async getTaskDetails(taskId) {
|
|
239
|
-
|
|
240
|
-
const task = await this.get(`/tasks/${taskId}`);
|
|
241
|
-
if (!task || task === "") {
|
|
242
|
-
const fallbackTask = await this.getFallbackClassicData('task', taskId);
|
|
243
|
-
this.getCache.set(`/tasks/${taskId}`, { data: fallbackTask, timestamp: Date.now() });
|
|
244
|
-
return fallbackTask;
|
|
245
|
-
}
|
|
246
|
-
return task;
|
|
247
|
-
}
|
|
248
|
-
catch (error) {
|
|
249
|
-
try {
|
|
250
|
-
const fallbackTask = await this.getFallbackClassicData('task', taskId);
|
|
251
|
-
this.getCache.set(`/tasks/${taskId}`, { data: fallbackTask, timestamp: Date.now() });
|
|
252
|
-
return fallbackTask;
|
|
253
|
-
}
|
|
254
|
-
catch (fallbackError) {
|
|
255
|
-
throw error; // throw original error if fallback also fails
|
|
256
|
-
}
|
|
257
|
-
}
|
|
316
|
+
return this.getWithFallback(`/tasks/${taskId}`, 'task', taskId);
|
|
258
317
|
}
|
|
259
318
|
/**
|
|
260
319
|
* Retrieves details of a specific bug.
|
|
@@ -265,25 +324,7 @@ export class ZentaoClient {
|
|
|
265
324
|
* @returns Resolves with the bug details.
|
|
266
325
|
*/
|
|
267
326
|
async getBugDetails(bugId) {
|
|
268
|
-
|
|
269
|
-
const bug = await this.get(`/bugs/${bugId}`);
|
|
270
|
-
if (!bug || bug === "") {
|
|
271
|
-
const fallbackBug = await this.getFallbackClassicData('bug', bugId);
|
|
272
|
-
this.getCache.set(`/bugs/${bugId}`, { data: fallbackBug, timestamp: Date.now() });
|
|
273
|
-
return fallbackBug;
|
|
274
|
-
}
|
|
275
|
-
return bug;
|
|
276
|
-
}
|
|
277
|
-
catch (error) {
|
|
278
|
-
try {
|
|
279
|
-
const fallbackBug = await this.getFallbackClassicData('bug', bugId);
|
|
280
|
-
this.getCache.set(`/bugs/${bugId}`, { data: fallbackBug, timestamp: Date.now() });
|
|
281
|
-
return fallbackBug;
|
|
282
|
-
}
|
|
283
|
-
catch (fallbackError) {
|
|
284
|
-
throw error; // throw original error if fallback also fails
|
|
285
|
-
}
|
|
286
|
-
}
|
|
327
|
+
return this.getWithFallback(`/bugs/${bugId}`, 'bug', bugId);
|
|
287
328
|
}
|
|
288
329
|
/**
|
|
289
330
|
* Downloads a file from Zentao API and pipes it to a local file path.
|
|
@@ -293,25 +334,10 @@ export class ZentaoClient {
|
|
|
293
334
|
* @returns A promise that resolves to the targetPath upon success, or rejects with an error.
|
|
294
335
|
*/
|
|
295
336
|
async downloadFile(fileId, targetPath) {
|
|
296
|
-
const writer = fs.createWriteStream(targetPath);
|
|
297
337
|
const res = await this.client.get(`/files/${fileId}`, {
|
|
298
338
|
responseType: 'stream',
|
|
299
339
|
});
|
|
300
|
-
return
|
|
301
|
-
res.data.pipe(writer);
|
|
302
|
-
let error = null;
|
|
303
|
-
writer.on('error', (err) => {
|
|
304
|
-
error = err;
|
|
305
|
-
writer.close();
|
|
306
|
-
// Fix #4: remove the partial/corrupt file so future calls don't mistake it for a valid download.
|
|
307
|
-
fs.unlink(targetPath, () => { });
|
|
308
|
-
reject(err);
|
|
309
|
-
});
|
|
310
|
-
writer.on('close', () => {
|
|
311
|
-
if (!error)
|
|
312
|
-
resolve(targetPath);
|
|
313
|
-
});
|
|
314
|
-
});
|
|
340
|
+
return this.pipeStreamToFile(res.data, targetPath);
|
|
315
341
|
}
|
|
316
342
|
/**
|
|
317
343
|
* Downloads an image from a full authenticated URL and saves it to a local path.
|
|
@@ -322,26 +348,11 @@ export class ZentaoClient {
|
|
|
322
348
|
* @returns A promise that resolves to the targetPath upon success, or rejects with an error.
|
|
323
349
|
*/
|
|
324
350
|
async downloadImageToLocal(imageUrl, targetPath) {
|
|
325
|
-
const writer = fs.createWriteStream(targetPath);
|
|
326
351
|
const res = await this.client.get(imageUrl, {
|
|
327
352
|
baseURL: '', // override baseURL so the full URL is used as-is
|
|
328
353
|
responseType: 'stream',
|
|
329
354
|
});
|
|
330
|
-
return
|
|
331
|
-
res.data.pipe(writer);
|
|
332
|
-
let error = null;
|
|
333
|
-
writer.on('error', (err) => {
|
|
334
|
-
error = err;
|
|
335
|
-
writer.close();
|
|
336
|
-
// Fix #4: remove the partial/corrupt file so future calls don't mistake it for a valid download.
|
|
337
|
-
fs.unlink(targetPath, () => { });
|
|
338
|
-
reject(err);
|
|
339
|
-
});
|
|
340
|
-
writer.on('close', () => {
|
|
341
|
-
if (!error)
|
|
342
|
-
resolve(targetPath);
|
|
343
|
-
});
|
|
344
|
-
});
|
|
355
|
+
return this.pipeStreamToFile(res.data, targetPath);
|
|
345
356
|
}
|
|
346
357
|
/**
|
|
347
358
|
* Helper method to perform POST requests.
|
|
@@ -387,10 +398,7 @@ export class ZentaoClient {
|
|
|
387
398
|
if (!this.token) {
|
|
388
399
|
await this.login();
|
|
389
400
|
}
|
|
390
|
-
const
|
|
391
|
-
? this.baseUrl.split('/api.php/v1')[0]
|
|
392
|
-
: this.baseUrl.split('/api/v1')[0];
|
|
393
|
-
const url = `${webBaseUrl}/action-comment-${type}-${id}.json?zentaosid=${this.token}`;
|
|
401
|
+
const url = `${this.webBaseUrl}/action-comment-${type}-${id}.json?zentaosid=${this.token}`;
|
|
394
402
|
// Clear the cache so subsequent fetches get the new comment
|
|
395
403
|
this.clearCache();
|
|
396
404
|
const params = new URLSearchParams();
|