@aigne/doc-smith 0.8.13 → 0.8.14-beta.1
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/CHANGELOG.md +20 -0
- package/agents/publish/publish-docs.mjs +72 -69
- package/agents/utils/update-branding.mjs +22 -8
- package/package.json +2 -2
- package/utils/deploy.mjs +0 -5
- package/utils/file-utils.mjs +185 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.14-beta.1](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.14-beta...v0.8.14-beta.1) (2025-10-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* doc review and update ([#199](https://github.com/AIGNE-io/aigne-doc-smith/issues/199)) ([0061323](https://github.com/AIGNE-io/aigne-doc-smith/commit/006132345a17a7fae10a803656b731144ecc54c9))
|
|
9
|
+
* respect env vars for publish and improve reliability ([#204](https://github.com/AIGNE-io/aigne-doc-smith/issues/204)) ([bd14c2a](https://github.com/AIGNE-io/aigne-doc-smith/commit/bd14c2af54ebca89c8edb5861e3b1a6e3f76e92e))
|
|
10
|
+
|
|
11
|
+
## [0.8.14-beta](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.13...v0.8.14-beta) (2025-10-16)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* **file-utils:** add comprehensive file type detection and image upload functionality ([#200](https://github.com/AIGNE-io/aigne-doc-smith/issues/200)) ([ab7aae3](https://github.com/AIGNE-io/aigne-doc-smith/commit/ab7aae33ed9b5209e824ba91fbf9c94d37187d83))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* use `output` field present tool result to enhance LLM understanding ([#202](https://github.com/AIGNE-io/aigne-doc-smith/issues/202)) ([511178d](https://github.com/AIGNE-io/aigne-doc-smith/commit/511178de7a88fdecf2136f90035d87957e23b23f))
|
|
22
|
+
|
|
3
23
|
## [0.8.13](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.13-beta...v0.8.13) (2025-10-16)
|
|
4
24
|
|
|
5
25
|
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { basename,
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
2
|
import { publishDocs as publishDocsFn } from "@aigne/publish-docs";
|
|
3
3
|
import { BrokerClient } from "@blocklet/payment-broker-client/node";
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import fs from "fs-extra";
|
|
6
|
-
import slugify from "slugify";
|
|
7
6
|
|
|
8
7
|
import { getAccessToken, getOfficialAccessToken } from "../../utils/auth-utils.mjs";
|
|
9
8
|
import {
|
|
@@ -15,8 +14,6 @@ import {
|
|
|
15
14
|
} from "../../utils/constants/index.mjs";
|
|
16
15
|
import { beforePublishHook, ensureTmpDir } from "../../utils/d2-utils.mjs";
|
|
17
16
|
import { deploy } from "../../utils/deploy.mjs";
|
|
18
|
-
import { getExtnameFromContentType } from "../../utils/file-utils.mjs";
|
|
19
|
-
import { uploadFiles } from "../../utils/upload-files.mjs";
|
|
20
17
|
import {
|
|
21
18
|
getGithubRepoUrl,
|
|
22
19
|
isHttp,
|
|
@@ -24,6 +21,7 @@ import {
|
|
|
24
21
|
saveValueToConfig,
|
|
25
22
|
} from "../../utils/utils.mjs";
|
|
26
23
|
import updateBranding from "../utils/update-branding.mjs";
|
|
24
|
+
import { downloadAndUploadImage } from "../../utils/file-utils.mjs";
|
|
27
25
|
|
|
28
26
|
const BASE_URL = process.env.DOC_SMITH_BASE_URL || CLOUD_SERVICE_URL_PROD;
|
|
29
27
|
|
|
@@ -59,29 +57,27 @@ export default async function publishDocs(
|
|
|
59
57
|
|
|
60
58
|
// ----------------- main publish process flow -----------------------------
|
|
61
59
|
// Check if DOC_DISCUSS_KIT_URL is set in environment variables
|
|
62
|
-
const
|
|
63
|
-
const useEnvAppUrl = !!envAppUrl;
|
|
64
|
-
|
|
65
|
-
// Use environment variable if available, otherwise use the provided appUrl
|
|
66
|
-
if (useEnvAppUrl) {
|
|
67
|
-
appUrl = envAppUrl;
|
|
68
|
-
}
|
|
60
|
+
const useEnvAppUrl = !!(process.env.DOC_DISCUSS_KIT_URL || appUrl);
|
|
69
61
|
|
|
70
62
|
// Check if appUrl is default and not saved in config (only when not using env variable)
|
|
71
63
|
const config = await loadConfigFromFile();
|
|
72
|
-
|
|
73
|
-
const
|
|
64
|
+
appUrl = process.env.DOC_DISCUSS_KIT_URL || appUrl || config?.appUrl;
|
|
65
|
+
const hasInputAppUrl = !!appUrl;
|
|
74
66
|
|
|
67
|
+
let shouldSyncBranding = void 0;
|
|
75
68
|
let token = "";
|
|
69
|
+
let client = null;
|
|
70
|
+
let authToken = null;
|
|
71
|
+
let sessionId = null;
|
|
76
72
|
|
|
77
|
-
if (!
|
|
78
|
-
|
|
73
|
+
if (!hasInputAppUrl) {
|
|
74
|
+
authToken = await getOfficialAccessToken(BASE_URL, false);
|
|
79
75
|
|
|
80
|
-
|
|
76
|
+
sessionId = "";
|
|
81
77
|
let paymentLink = "";
|
|
82
78
|
|
|
83
79
|
if (authToken) {
|
|
84
|
-
|
|
80
|
+
client = new BrokerClient({ baseUrl: BASE_URL, authToken });
|
|
85
81
|
const info = await client.checkCacheSession({
|
|
86
82
|
needShortUrl: true,
|
|
87
83
|
sessionId: config?.checkoutId,
|
|
@@ -137,29 +133,60 @@ export default async function publishDocs(
|
|
|
137
133
|
// Ensure appUrl has protocol
|
|
138
134
|
appUrl = userInput.includes("://") ? userInput : `https://${userInput}`;
|
|
139
135
|
} else if (["new-instance", "new-instance-continue"].includes(choice)) {
|
|
140
|
-
|
|
141
|
-
shouldWithBranding = await options.prompts.confirm({
|
|
142
|
-
message: "Would you like to update the project branding (title, description, logo)?",
|
|
143
|
-
default: true,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
// Deploy a new Discuss Kit service
|
|
147
|
-
let id = "";
|
|
148
|
-
let paymentUrl = "";
|
|
136
|
+
// resume previous website setup
|
|
149
137
|
if (choice === "new-instance-continue") {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
console.log(`\nCreating a new website for your documentation...`);
|
|
138
|
+
shouldSyncBranding = config?.shouldSyncBranding ?? void 0;
|
|
139
|
+
if (shouldSyncBranding !== void 0) {
|
|
140
|
+
shouldWithBranding = shouldWithBranding ?? shouldSyncBranding;
|
|
141
|
+
}
|
|
155
142
|
}
|
|
156
|
-
const { appUrl: homeUrl, token: ltToken } = (await deploy(id, paymentUrl)) || {};
|
|
157
143
|
|
|
158
|
-
|
|
159
|
-
|
|
144
|
+
if (options?.prompts?.confirm) {
|
|
145
|
+
if (shouldSyncBranding === void 0) {
|
|
146
|
+
shouldSyncBranding = await options.prompts.confirm({
|
|
147
|
+
message: "Would you like to update the project branding (title, description, logo)?",
|
|
148
|
+
default: true,
|
|
149
|
+
});
|
|
150
|
+
await saveValueToConfig(
|
|
151
|
+
"shouldSyncBranding",
|
|
152
|
+
shouldSyncBranding,
|
|
153
|
+
"Should sync branding for documentation",
|
|
154
|
+
);
|
|
155
|
+
shouldWithBranding = shouldSyncBranding;
|
|
156
|
+
} else {
|
|
157
|
+
console.log(
|
|
158
|
+
`Would you like to update the project branding (title, description, logo)? ${chalk.cyan(shouldSyncBranding ? "Yes" : "No")}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
let id = "";
|
|
165
|
+
if (choice === "new-instance-continue") {
|
|
166
|
+
id = sessionId;
|
|
167
|
+
console.log(`\nResuming your previous website setup...`);
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`\nCreating a new website for your documentation...`);
|
|
170
|
+
}
|
|
171
|
+
const { appUrl: homeUrl, token: ltToken } = (await deploy(id, paymentLink)) || {};
|
|
172
|
+
|
|
173
|
+
appUrl = homeUrl;
|
|
174
|
+
token = ltToken;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const errorMsg = error?.message || "Unknown error occurred";
|
|
177
|
+
return { message: `${chalk.red("❌ Failed to create website:")} ${errorMsg}` };
|
|
178
|
+
}
|
|
160
179
|
}
|
|
161
180
|
}
|
|
162
181
|
|
|
182
|
+
if (sessionId) {
|
|
183
|
+
authToken = await getOfficialAccessToken(BASE_URL, false);
|
|
184
|
+
client = client || new BrokerClient({ baseUrl: BASE_URL, authToken });
|
|
185
|
+
|
|
186
|
+
const { vendors } = await client.getSessionDetail(sessionId, false);
|
|
187
|
+
token = vendors?.find((vendor) => vendor.vendorType === "launcher" && vendor.token)?.token;
|
|
188
|
+
}
|
|
189
|
+
|
|
163
190
|
console.log(`\nPublishing your documentation to ${chalk.cyan(appUrl)}\n`);
|
|
164
191
|
|
|
165
192
|
const accessToken = await getAccessToken(appUrl, token);
|
|
@@ -175,46 +202,22 @@ export default async function publishDocs(
|
|
|
175
202
|
description: projectDesc || config?.projectDesc || "",
|
|
176
203
|
icon: projectLogo || config?.projectLogo || "",
|
|
177
204
|
};
|
|
205
|
+
let finalPath = null;
|
|
178
206
|
|
|
179
207
|
// Handle project logo download if it's a URL
|
|
180
208
|
if (projectInfo.icon && isHttp(projectInfo.icon)) {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const buffer = Buffer.from(arrayBuffer);
|
|
190
|
-
|
|
191
|
-
if (response.ok) {
|
|
192
|
-
if (!ext) {
|
|
193
|
-
const contentType = response.headers.get("content-type");
|
|
194
|
-
ext = getExtnameFromContentType(contentType);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const finalPath = ext ? `${tempFilePath}.${ext}` : tempFilePath;
|
|
198
|
-
fs.writeFileSync(finalPath, buffer);
|
|
199
|
-
|
|
200
|
-
const filePath = join(process.cwd(), finalPath);
|
|
201
|
-
const { results: uploadResults } = await uploadFiles({
|
|
202
|
-
appUrl,
|
|
203
|
-
filePaths: [filePath],
|
|
204
|
-
accessToken,
|
|
205
|
-
concurrency: 1,
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// FIXME: 暂时保持使用绝对路径,需要修改为相对路径 @pengfei
|
|
209
|
-
projectInfo.icon = uploadResults?.[0]?.url || projectInfo.icon;
|
|
210
|
-
}
|
|
211
|
-
} catch (error) {
|
|
212
|
-
console.warn(`Failed to download project logo from ${projectInfo.icon}: ${error.message}`);
|
|
213
|
-
}
|
|
209
|
+
const { url: uploadedImageUrl, downloadFinalPath } = await downloadAndUploadImage(
|
|
210
|
+
projectInfo.icon,
|
|
211
|
+
docsDir,
|
|
212
|
+
appUrl,
|
|
213
|
+
accessToken,
|
|
214
|
+
);
|
|
215
|
+
projectInfo.icon = uploadedImageUrl;
|
|
216
|
+
finalPath = downloadFinalPath;
|
|
214
217
|
}
|
|
215
218
|
|
|
216
219
|
if (shouldWithBranding) {
|
|
217
|
-
updateBranding({ appUrl, projectInfo, accessToken });
|
|
220
|
+
updateBranding({ appUrl, projectInfo, accessToken, finalPath });
|
|
218
221
|
}
|
|
219
222
|
|
|
220
223
|
// Construct boardMeta object
|
|
@@ -263,6 +266,7 @@ export default async function publishDocs(
|
|
|
263
266
|
}
|
|
264
267
|
message = `✅ Documentation published successfully!`;
|
|
265
268
|
await saveValueToConfig("checkoutId", "", "Checkout ID for document deployment service");
|
|
269
|
+
await saveValueToConfig("shouldSyncBranding", "", "Should sync branding for documentation");
|
|
266
270
|
} else {
|
|
267
271
|
// If the error is 401 or 403, it means the access token is invalid
|
|
268
272
|
if (error?.includes("401") || error?.includes("403")) {
|
|
@@ -297,7 +301,6 @@ publishDocs.input_schema = {
|
|
|
297
301
|
appUrl: {
|
|
298
302
|
type: "string",
|
|
299
303
|
description: "The URL of the app.",
|
|
300
|
-
default: CLOUD_SERVICE_URL_PROD,
|
|
301
304
|
},
|
|
302
305
|
boardId: {
|
|
303
306
|
type: "string",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
2
|
import chalk from "chalk";
|
|
4
3
|
import { joinURL } from "ufo";
|
|
5
4
|
|
|
@@ -8,12 +7,11 @@ import {
|
|
|
8
7
|
CLOUD_SERVICE_URL_PROD,
|
|
9
8
|
CLOUD_SERVICE_URL_STAGING,
|
|
10
9
|
DISCUSS_KIT_DID,
|
|
11
|
-
DOC_SMITH_DIR,
|
|
12
10
|
} from "../../utils/constants/index.mjs";
|
|
13
11
|
import { requestWithAuthToken } from "../../utils/request.mjs";
|
|
14
12
|
import { uploadFiles } from "../../utils/upload-files.mjs";
|
|
15
13
|
|
|
16
|
-
export default async function updateBranding({ appUrl, projectInfo, accessToken }) {
|
|
14
|
+
export default async function updateBranding({ appUrl, projectInfo, accessToken, finalPath }) {
|
|
17
15
|
try {
|
|
18
16
|
const origin = new URL(appUrl).origin;
|
|
19
17
|
if ([CLOUD_SERVICE_URL_PROD, CLOUD_SERVICE_URL_STAGING].includes(origin)) {
|
|
@@ -27,6 +25,18 @@ export default async function updateBranding({ appUrl, projectInfo, accessToken
|
|
|
27
25
|
const componentInfo = await getComponentInfoWithMountPoint(origin, DISCUSS_KIT_DID);
|
|
28
26
|
const mountPoint = componentInfo.mountPoint || "/";
|
|
29
27
|
|
|
28
|
+
if (projectInfo.name.length > 40) {
|
|
29
|
+
console.warn(
|
|
30
|
+
`⚠️ Name is too long, it should be less than 40 characters\nWill be truncated to 40 characters`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (projectInfo.description.length > 160) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`⚠️ Description is too long, it should be less than 160 characters\nWill be truncated to 160 characters`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
const res = await requestWithAuthToken(
|
|
31
41
|
joinURL(origin, mountPoint, "/api/branding"),
|
|
32
42
|
{
|
|
@@ -35,23 +45,27 @@ export default async function updateBranding({ appUrl, projectInfo, accessToken
|
|
|
35
45
|
"Content-Type": "application/json",
|
|
36
46
|
},
|
|
37
47
|
body: JSON.stringify({
|
|
38
|
-
appName: projectInfo.name,
|
|
39
|
-
appDescription: projectInfo.description,
|
|
48
|
+
appName: projectInfo.name.slice(0, 40),
|
|
49
|
+
appDescription: projectInfo.description.slice(0, 160),
|
|
40
50
|
}),
|
|
41
51
|
},
|
|
42
52
|
accessToken,
|
|
43
53
|
);
|
|
44
54
|
|
|
45
55
|
if (res.success) {
|
|
56
|
+
if (!finalPath) {
|
|
57
|
+
console.warn("\n🔄 Skipped updating branding for missing logo file\n");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
try {
|
|
47
|
-
const
|
|
48
|
-
const projectLogoStat = await stat(projectLogoPath);
|
|
62
|
+
const projectLogoStat = await stat(finalPath);
|
|
49
63
|
|
|
50
64
|
if (projectLogoStat.isFile()) {
|
|
51
65
|
// Upload to blocklet logo endpoint
|
|
52
66
|
await uploadFiles({
|
|
53
67
|
appUrl: origin,
|
|
54
|
-
filePaths: [
|
|
68
|
+
filePaths: [finalPath],
|
|
55
69
|
accessToken,
|
|
56
70
|
concurrency: 1,
|
|
57
71
|
endpoint: `${origin}/.well-known/service/blocklet/logo/upload/square/${componentInfo.did}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aigne/doc-smith",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.14-beta.1",
|
|
4
4
|
"description": "AI-driven documentation generation tool built on the AIGNE Framework",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"debug": "^4.4.1",
|
|
39
39
|
"diff": "^8.0.2",
|
|
40
40
|
"dompurify": "^3.2.6",
|
|
41
|
+
"file-type": "^21.0.0",
|
|
41
42
|
"fs-extra": "^11.3.1",
|
|
42
43
|
"glob": "^11.0.3",
|
|
43
44
|
"gpt-tokenizer": "^3.2.0",
|
|
@@ -54,7 +55,6 @@
|
|
|
54
55
|
"remark-gfm": "^4.0.1",
|
|
55
56
|
"remark-lint": "^10.0.1",
|
|
56
57
|
"remark-parse": "^11.0.0",
|
|
57
|
-
"slugify": "^1.6.6",
|
|
58
58
|
"terminal-link": "^4.0.0",
|
|
59
59
|
"ufo": "^1.6.1",
|
|
60
60
|
"unified": "^11.0.5",
|
package/utils/deploy.mjs
CHANGED
|
@@ -42,11 +42,6 @@ export async function deploy(id, cachedUrl) {
|
|
|
42
42
|
sessionId,
|
|
43
43
|
"Checkout ID for document deployment website",
|
|
44
44
|
);
|
|
45
|
-
await saveValueToConfig(
|
|
46
|
-
"paymentUrl",
|
|
47
|
-
paymentUrl,
|
|
48
|
-
"Payment URL for document deployment website",
|
|
49
|
-
);
|
|
50
45
|
|
|
51
46
|
if (!isResuming) {
|
|
52
47
|
await open(paymentUrl);
|
package/utils/file-utils.mjs
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { access, readFile, stat } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
+
import path, { join } from "node:path";
|
|
4
5
|
import { glob } from "glob";
|
|
6
|
+
import fs from "fs-extra";
|
|
5
7
|
import { isBinaryFile } from "isbinaryfile";
|
|
6
8
|
import { encode } from "gpt-tokenizer";
|
|
9
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
10
|
+
import { gunzipSync } from "node:zlib";
|
|
7
11
|
import { isGlobPattern } from "./utils.mjs";
|
|
8
12
|
import { INTELLIGENT_SUGGESTION_TOKEN_THRESHOLD } from "./constants/index.mjs";
|
|
13
|
+
import { uploadFiles } from "./upload-files.mjs";
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* Check if a directory is inside a git repository using git command
|
|
@@ -644,3 +649,182 @@ export function getMediaDescriptionCachePath() {
|
|
|
644
649
|
const cwd = process.cwd();
|
|
645
650
|
return path.join(cwd, ".aigne", "doc-smith", "media-description.yaml");
|
|
646
651
|
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Detect file type from buffer with comprehensive fallback strategy
|
|
655
|
+
* @param {Buffer} buffer - File buffer
|
|
656
|
+
* @param {string} contentType - HTTP Content-Type header
|
|
657
|
+
* @param {string} url - Original URL (for fallback)
|
|
658
|
+
* @returns {Promise<{ext: string, mime: string}>} File extension and MIME type
|
|
659
|
+
*/
|
|
660
|
+
export async function detectFileType(buffer, contentType, url = "") {
|
|
661
|
+
// 1. Try file-type for binary images (PNG, JPG, WebP, GIF, etc.)
|
|
662
|
+
try {
|
|
663
|
+
const fileType = await fileTypeFromBuffer(buffer);
|
|
664
|
+
if (fileType) {
|
|
665
|
+
return {
|
|
666
|
+
ext: fileType.ext,
|
|
667
|
+
mime: fileType.mime,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
} catch (error) {
|
|
671
|
+
console.debug("file-type detection failed:", error.message);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// 2. Check for SVG/SVGZ
|
|
675
|
+
const svgResult = await detectSvgType(buffer, contentType);
|
|
676
|
+
if (svgResult) {
|
|
677
|
+
return svgResult;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 3. Fallback to Content-Type
|
|
681
|
+
if (contentType) {
|
|
682
|
+
const ext = getExtnameFromContentType(contentType);
|
|
683
|
+
if (ext) {
|
|
684
|
+
return {
|
|
685
|
+
ext,
|
|
686
|
+
mime: contentType.split(";")[0].trim(),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// 4. Fallback to URL extension
|
|
692
|
+
if (url) {
|
|
693
|
+
const urlExt = path.extname(url).toLowerCase();
|
|
694
|
+
if (urlExt) {
|
|
695
|
+
return {
|
|
696
|
+
ext: urlExt.slice(1), // Remove leading dot
|
|
697
|
+
mime: getMimeType(url),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// 5. Default fallback
|
|
703
|
+
return {
|
|
704
|
+
ext: "bin",
|
|
705
|
+
mime: "application/octet-stream",
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Detect SVG/SVGZ file type
|
|
711
|
+
* @param {Buffer} buffer - File buffer
|
|
712
|
+
* @param {string} contentType - HTTP Content-Type header
|
|
713
|
+
* @returns {Promise<{ext: string, mime: string} | null>} SVG info or null
|
|
714
|
+
*/
|
|
715
|
+
async function detectSvgType(buffer, contentType) {
|
|
716
|
+
// Check Content-Type first
|
|
717
|
+
if (contentType?.includes("image/svg+xml")) {
|
|
718
|
+
return {
|
|
719
|
+
ext: "svg",
|
|
720
|
+
mime: "image/svg+xml",
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
let text = "";
|
|
726
|
+
|
|
727
|
+
// Check if it's gzipped (SVGZ)
|
|
728
|
+
if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
729
|
+
try {
|
|
730
|
+
const decompressed = gunzipSync(buffer);
|
|
731
|
+
text = decompressed.toString("utf8");
|
|
732
|
+
if (isSvgContent(text)) {
|
|
733
|
+
return {
|
|
734
|
+
ext: "svgz",
|
|
735
|
+
mime: "image/svg+xml",
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
} catch {
|
|
739
|
+
// Not gzipped, continue with regular text detection
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Try as regular text
|
|
744
|
+
if (!text) {
|
|
745
|
+
text = buffer.toString("utf8");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (isSvgContent(text)) {
|
|
749
|
+
return {
|
|
750
|
+
ext: "svg",
|
|
751
|
+
mime: "image/svg+xml",
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
} catch (error) {
|
|
755
|
+
console.debug("SVG detection failed:", error.message);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Check if text content is SVG
|
|
763
|
+
* @param {string} text - Text content
|
|
764
|
+
* @returns {boolean} True if SVG
|
|
765
|
+
*/
|
|
766
|
+
function isSvgContent(text) {
|
|
767
|
+
if (!text || typeof text !== "string") return false;
|
|
768
|
+
|
|
769
|
+
// Remove BOM and trim
|
|
770
|
+
const cleanText = text.replace(/^\uFEFF/, "").trim();
|
|
771
|
+
|
|
772
|
+
// Check for SVG root element
|
|
773
|
+
return /^<\?xml\s+[^>]*>\s*<svg/i.test(cleanText) || /^<svg/i.test(cleanText);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Download and upload a remote image URL
|
|
778
|
+
* @param {string} imageUrl - The remote image URL
|
|
779
|
+
* @param {string} docsDir - Directory to save temporary files
|
|
780
|
+
* @param {string} appUrl - Application URL for upload
|
|
781
|
+
* @param {string} accessToken - Access token for upload
|
|
782
|
+
* @returns {Promise<{url: string, downloadFinalPath: string | null}>} The uploaded image URL and final path if failed
|
|
783
|
+
*/
|
|
784
|
+
export async function downloadAndUploadImage(imageUrl, docsDir, appUrl, accessToken) {
|
|
785
|
+
let downloadFinalPath = null;
|
|
786
|
+
|
|
787
|
+
try {
|
|
788
|
+
// 1. Download with timeout control
|
|
789
|
+
const controller = new AbortController();
|
|
790
|
+
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
|
|
791
|
+
|
|
792
|
+
const response = await fetch(imageUrl, {
|
|
793
|
+
signal: controller.signal,
|
|
794
|
+
});
|
|
795
|
+
clearTimeout(timeoutId);
|
|
796
|
+
|
|
797
|
+
if (!response.ok) {
|
|
798
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// 2. Convert to Buffer for file type detection
|
|
802
|
+
const blob = await response.blob();
|
|
803
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
804
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
805
|
+
|
|
806
|
+
// 3. Detect file type with comprehensive fallback
|
|
807
|
+
const contentType = response.headers.get("content-type");
|
|
808
|
+
const { ext } = await detectFileType(buffer, contentType, imageUrl);
|
|
809
|
+
|
|
810
|
+
// 4. Generate random filename and save file
|
|
811
|
+
const randomId = randomBytes(16).toString("hex");
|
|
812
|
+
const tempFilePath = join(docsDir, `temp-logo-${randomId}`);
|
|
813
|
+
downloadFinalPath = ext ? `${tempFilePath}.${ext}` : tempFilePath;
|
|
814
|
+
fs.writeFileSync(downloadFinalPath, buffer);
|
|
815
|
+
|
|
816
|
+
// 5. Upload and get URL
|
|
817
|
+
const { results: uploadResults } = await uploadFiles({
|
|
818
|
+
appUrl,
|
|
819
|
+
filePaths: [downloadFinalPath],
|
|
820
|
+
accessToken,
|
|
821
|
+
concurrency: 1,
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// 6. Return uploaded URL
|
|
825
|
+
return { url: uploadResults?.[0]?.url || imageUrl, downloadFinalPath };
|
|
826
|
+
} catch (error) {
|
|
827
|
+
console.warn(`Failed to download and upload image from ${imageUrl}: ${error.message}`);
|
|
828
|
+
return { url: imageUrl, downloadFinalPath: null };
|
|
829
|
+
}
|
|
830
|
+
}
|