@onozaty/growi-uploader 1.3.0 → 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.
Files changed (3) hide show
  1. package/README.md +9 -1
  2. package/dist/index.mjs +116 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -69,6 +69,7 @@ growi-uploader <source-dir> [options]
69
69
 
70
70
  **Options:**
71
71
  - `-c, --config <path>`: Path to config file (default: `growi-uploader.json`)
72
+ - `-v, --verbose`: Enable verbose error output with detailed information
72
73
  - `-V, --version`: Output the version number
73
74
  - `-h, --help`: Display help information
74
75
 
@@ -80,6 +81,9 @@ npx @onozaty/growi-uploader ./docs
80
81
 
81
82
  # Upload with custom config file
82
83
  npx @onozaty/growi-uploader ./docs -c my-config.json
84
+
85
+ # Upload with verbose error output
86
+ npx @onozaty/growi-uploader ./docs --verbose
83
87
  ```
84
88
 
85
89
  ## Directory Structure Example
@@ -117,7 +121,8 @@ Create a `growi-uploader.json` file in your project root:
117
121
  "url": "https://your-growi-instance.com",
118
122
  "token": "your-api-token",
119
123
  "basePath": "/imported",
120
- "update": true
124
+ "update": true,
125
+ "verbose": false
121
126
  }
122
127
  ```
123
128
 
@@ -129,6 +134,7 @@ Create a `growi-uploader.json` file in your project root:
129
134
  | `token` | string | ✅ | - | GROWI API access token |
130
135
  | `basePath` | string | ❌ | `/` | Base path for imported pages |
131
136
  | `update` | boolean | ❌ | `false` | Update existing pages if true, skip if false |
137
+ | `verbose` | boolean | ❌ | `false` | Enable verbose error output with detailed information |
132
138
 
133
139
  ### Getting an API Token
134
140
 
@@ -194,6 +200,8 @@ All referenced files (`logo.png`, `screenshot.png`, and `banner.png`) will be up
194
200
  [File](./docs/my%20file.pdf) # URL-encoded space
195
201
  [File](<./path/file (1).png>) # Special chars with angle brackets
196
202
  ![Image](./path/file\\(1\\).png) # Special chars with escaping
203
+ <img src="./images/logo.png" alt="Logo"> # HTML img tag (double quotes)
204
+ <img src='./images/logo.png' alt='Logo'> # HTML img tag (single quotes)
197
205
  ```
198
206
 
199
207
  **Excluded from detection:**
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import { lookup } from "mime-types";
8
8
  import { glob } from "glob";
9
9
 
10
10
  //#region package.json
11
- var version = "1.3.0";
11
+ var version = "1.5.0";
12
12
 
13
13
  //#endregion
14
14
  //#region src/config.ts
@@ -19,6 +19,7 @@ var version = "1.3.0";
19
19
  * @returns Configuration object with defaults applied:
20
20
  * - basePath defaults to "/" if not specified or empty
21
21
  * - update defaults to false if not specified
22
+ * - verbose defaults to false if not specified
22
23
  * @throws Error if config file is not found or required fields (url, token) are missing
23
24
  */
24
25
  const loadConfig = (configPath) => {
@@ -32,7 +33,8 @@ const loadConfig = (configPath) => {
32
33
  url: input.url,
33
34
  token: input.token,
34
35
  basePath: input.basePath || "/",
35
- update: input.update ?? false
36
+ update: input.update ?? false,
37
+ verbose: input.verbose ?? false
36
38
  };
37
39
  } catch (error) {
38
40
  if (error.code === "ENOENT") throw new Error(`Config file not found: ${fullPath}`);
@@ -85,7 +87,8 @@ const putPage = (putPageBody, options) => {
85
87
  * @param token API token for authentication
86
88
  */
87
89
  const configureAxios = (growiUrl, token) => {
88
- axios.defaults.baseURL = `${growiUrl}/_api/v3`;
90
+ const baseUrl = growiUrl.replace(/\/+$/, "");
91
+ axios.defaults.baseURL = `${baseUrl}/_api/v3`;
89
92
  axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
90
93
  };
91
94
  /**
@@ -95,11 +98,59 @@ const configureAxios = (growiUrl, token) => {
95
98
  * @returns Formatted error message string
96
99
  */
97
100
  const formatErrorMessage = (error) => {
98
- if (axios.isAxiosError(error)) return `${error.response?.status} ${error.response?.data?.message || error.message}`;
101
+ if (axios.isAxiosError(error)) {
102
+ const status = error.response?.status;
103
+ const statusText = error.response?.statusText;
104
+ const data = error.response?.data;
105
+ const parts = [];
106
+ if (status) parts.push(`HTTP ${status}${statusText ? ` ${statusText}` : ""}`);
107
+ if (data && typeof data === "object") {
108
+ const message = data.message || data.error || data.errors;
109
+ if (message) parts.push(message);
110
+ else if (Object.keys(data).length > 0) parts.push(JSON.stringify(data));
111
+ }
112
+ if (error.code) parts.push(`(${error.code})`);
113
+ if (parts.length === 0) return error.message;
114
+ return parts.join(" ");
115
+ }
99
116
  if (error instanceof Error) return error.message;
100
117
  return String(error);
101
118
  };
102
119
  /**
120
+ * Format error into a detailed error message for verbose mode
121
+ *
122
+ * @param error Error object from catch block
123
+ * @returns Detailed multi-line error message with all available information
124
+ */
125
+ const formatDetailedError = (error) => {
126
+ const lines = [];
127
+ if (axios.isAxiosError(error)) {
128
+ const status = error.response?.status;
129
+ const statusText = error.response?.statusText;
130
+ const data = error.response?.data;
131
+ if (status) lines.push(` HTTP Status: ${status}${statusText ? ` ${statusText}` : ""}`);
132
+ if (data) try {
133
+ const responseStr = typeof data === "object" ? JSON.stringify(data, null, 2).split("\n").map((line) => ` ${line}`).join("\n") : String(data);
134
+ lines.push(` Response Body:\n${responseStr}`);
135
+ } catch {
136
+ lines.push(` Response Body: ${String(data)}`);
137
+ }
138
+ if (error.code) lines.push(` Error Code: ${error.code}`);
139
+ if (error.config) lines.push(` Request: ${error.config.method?.toUpperCase()} ${error.config.url}`);
140
+ if (error.stack) {
141
+ const stackLines = error.stack.split("\n").slice(0, 5);
142
+ lines.push(` Stack Trace:\n${stackLines.map((line) => ` ${line}`).join("\n")}`);
143
+ }
144
+ } else if (error instanceof Error) {
145
+ lines.push(` Error: ${error.message}`);
146
+ if (error.stack) {
147
+ const stackLines = error.stack.split("\n").slice(0, 5);
148
+ lines.push(` Stack Trace:\n${stackLines.map((line) => ` ${line}`).join("\n")}`);
149
+ }
150
+ } else lines.push(` Error: ${String(error)}`);
151
+ return lines.length > 0 ? ` Details:\n${lines.join("\n")}` : "";
152
+ };
153
+ /**
103
154
  * Create a new page or update an existing page in GROWI
104
155
  *
105
156
  * @param file Markdown file to upload
@@ -133,7 +184,8 @@ const createOrUpdatePage = async (file, content, shouldUpdate) => {
133
184
  pageId: void 0,
134
185
  revisionId: void 0,
135
186
  action: "error",
136
- errorMessage: formatErrorMessage(error)
187
+ errorMessage: formatErrorMessage(error),
188
+ error
137
189
  };
138
190
  }
139
191
  try {
@@ -151,7 +203,8 @@ const createOrUpdatePage = async (file, content, shouldUpdate) => {
151
203
  pageId: void 0,
152
204
  revisionId: void 0,
153
205
  action: "error",
154
- errorMessage: formatErrorMessage(error)
206
+ errorMessage: formatErrorMessage(error),
207
+ error
155
208
  };
156
209
  }
157
210
  };
@@ -177,7 +230,8 @@ const updatePageContent = async (pageId, revisionId, content) => {
177
230
  } catch (error) {
178
231
  return {
179
232
  success: false,
180
- errorMessage: formatErrorMessage(error)
233
+ errorMessage: formatErrorMessage(error),
234
+ error
181
235
  };
182
236
  }
183
237
  };
@@ -207,7 +261,8 @@ const uploadAttachment = async (attachment, pageId, sourceDir) => {
207
261
  } catch (error) {
208
262
  return {
209
263
  success: false,
210
- errorMessage: formatErrorMessage(error)
264
+ errorMessage: formatErrorMessage(error),
265
+ error
211
266
  };
212
267
  }
213
268
  };
@@ -215,6 +270,37 @@ const uploadAttachment = async (attachment, pageId, sourceDir) => {
215
270
  //#endregion
216
271
  //#region src/scanner.ts
217
272
  /**
273
+ * Process a link path and convert it to an AttachmentFile if it exists
274
+ *
275
+ * @param linkPath The link path from markdown
276
+ * @param originalLinkPath The original format of the link (for replacement)
277
+ * @param markdownFilePath Path to the markdown file (relative to sourceDir)
278
+ * @param sourceDir Source directory (absolute path)
279
+ * @returns AttachmentFile if the path resolves to an existing file, null otherwise
280
+ */
281
+ const processLinkPath = (linkPath, originalLinkPath, markdownFilePath, sourceDir) => {
282
+ if (linkPath.startsWith("http://") || linkPath.startsWith("https://")) return null;
283
+ if (linkPath.endsWith(".md")) return null;
284
+ const unescapedPath = linkPath.replace(/\\([\\`*_{}[\]()#+\-.!])/g, "$1");
285
+ let decodedPath;
286
+ try {
287
+ decodedPath = decodeURIComponent(unescapedPath);
288
+ } catch {
289
+ decodedPath = unescapedPath;
290
+ }
291
+ let absolutePath;
292
+ if (linkPath.startsWith("/")) absolutePath = resolve(sourceDir, decodedPath.slice(1));
293
+ else absolutePath = resolve(dirname(join(sourceDir, markdownFilePath)), decodedPath);
294
+ if (!existsSync(absolutePath)) return null;
295
+ const normalizedPath = relative(sourceDir, absolutePath).replace(/\\/g, "/");
296
+ return {
297
+ localPath: normalizedPath,
298
+ fileName: basename(normalizedPath),
299
+ detectionPattern: "link",
300
+ originalLinkPaths: [originalLinkPath]
301
+ };
302
+ };
303
+ /**
218
304
  * Extract attachment files from markdown links
219
305
  *
220
306
  * Scans markdown content for image and link references, resolves their paths,
@@ -243,27 +329,15 @@ const extractLinkedAttachments = (content, markdownFilePath, sourceDir) => {
243
329
  const { anglePath, regularPath } = match.groups;
244
330
  const linkPath = anglePath || regularPath;
245
331
  if (!linkPath) continue;
246
- const originalLinkPath = anglePath ? `<${anglePath}>` : regularPath;
247
- if (linkPath.startsWith("http://") || linkPath.startsWith("https://")) continue;
248
- if (linkPath.endsWith(".md")) continue;
249
- const unescapedPath = linkPath.replace(/\\([\\`*_{}[\]()#+\-.!])/g, "$1");
250
- let decodedPath;
251
- try {
252
- decodedPath = decodeURIComponent(unescapedPath);
253
- } catch {
254
- decodedPath = unescapedPath;
255
- }
256
- let absolutePath;
257
- if (linkPath.startsWith("/")) absolutePath = resolve(sourceDir, decodedPath.slice(1));
258
- else absolutePath = resolve(dirname(join(sourceDir, markdownFilePath)), decodedPath);
259
- if (!existsSync(absolutePath)) continue;
260
- const normalizedPath = relative(sourceDir, absolutePath).replace(/\\/g, "/");
261
- attachments.push({
262
- localPath: normalizedPath,
263
- fileName: basename(normalizedPath),
264
- detectionPattern: "link",
265
- originalLinkPaths: [originalLinkPath]
266
- });
332
+ const attachment = processLinkPath(linkPath, anglePath ? `<${anglePath}>` : regularPath, markdownFilePath, sourceDir);
333
+ if (attachment) attachments.push(attachment);
334
+ }
335
+ const imgTagRegex = /<img\s+[^>]*src=(?<quote>["'])(?<src>.*?)\k<quote>[^>]*>/gi;
336
+ while ((match = imgTagRegex.exec(content)) !== null) {
337
+ const { src } = match.groups;
338
+ if (!src) continue;
339
+ const attachment = processLinkPath(src, src, markdownFilePath, sourceDir);
340
+ if (attachment) attachments.push(attachment);
267
341
  }
268
342
  return attachments;
269
343
  };
@@ -368,6 +442,11 @@ const replaceAttachmentLinks = (markdown, attachments, pageName) => {
368
442
  replaced = true;
369
443
  result = result.replace(linkRegex, `[$1](${growiPath})`);
370
444
  }
445
+ const imgTagRegex = new RegExp(`(<img\\s+[^>]*src=)(["'])${pattern}\\2([^>]*>)`, "gi");
446
+ if (imgTagRegex.test(result)) {
447
+ replaced = true;
448
+ result = result.replace(imgTagRegex, `$1$2${growiPath}$2$3`);
449
+ }
371
450
  }
372
451
  }
373
452
  return {
@@ -445,6 +524,7 @@ const uploadFiles = async (files, sourceDir, config) => {
445
524
  } else if (result.action === "error") {
446
525
  stats.pageErrors++;
447
526
  console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (${result.errorMessage || "unknown error"})`);
527
+ if (config.verbose && result.error) console.error(formatDetailedError(result.error));
448
528
  }
449
529
  if (result.pageId && (result.action === "created" || result.action === "updated")) {
450
530
  let currentContent = content;
@@ -465,6 +545,7 @@ const uploadFiles = async (files, sourceDir, config) => {
465
545
  } else {
466
546
  stats.attachmentErrors++;
467
547
  console.error(`[ERROR] ${attachment.localPath} → ${file.growiPath} (${attachmentResult.errorMessage || "failed to upload attachment"})`);
548
+ if (config.verbose && attachmentResult.error) console.error(formatDetailedError(attachmentResult.error));
468
549
  }
469
550
  }
470
551
  if (hasAttachments && latestRevisionId) {
@@ -479,6 +560,7 @@ const uploadFiles = async (files, sourceDir, config) => {
479
560
  currentRevisionId = updateResult.revisionId;
480
561
  } else {
481
562
  console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update attachment links: ${updateResult.errorMessage || "unknown error"})`);
563
+ if (config.verbose && updateResult.error) console.error(formatDetailedError(updateResult.error));
482
564
  stats.linkReplacementErrors++;
483
565
  }
484
566
  }
@@ -493,6 +575,7 @@ const uploadFiles = async (files, sourceDir, config) => {
493
575
  currentRevisionId = updateResult.revisionId;
494
576
  } else {
495
577
  console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update page links: ${updateResult.errorMessage || "unknown error"})`);
578
+ if (config.verbose && updateResult.error) console.error(formatDetailedError(updateResult.error));
496
579
  stats.linkReplacementErrors++;
497
580
  }
498
581
  }
@@ -508,8 +591,9 @@ const uploadFiles = async (files, sourceDir, config) => {
508
591
  //#endregion
509
592
  //#region src/index.ts
510
593
  const program = new Command();
511
- const main = async (sourceDir, configPath) => {
594
+ const main = async (sourceDir, configPath, verboseOverride) => {
512
595
  const config = loadConfig(configPath);
596
+ if (verboseOverride !== void 0) config.verbose = verboseOverride;
513
597
  const sourceDirPath = resolve(sourceDir);
514
598
  configureAxios(config.url, config.token);
515
599
  const files = await scanMarkdownFiles(sourceDirPath, config.basePath);
@@ -526,9 +610,9 @@ const main = async (sourceDir, configPath) => {
526
610
  console.log(`- Attachment errors: ${stats.attachmentErrors}`);
527
611
  console.log(`- Link replacement errors: ${stats.linkReplacementErrors}`);
528
612
  };
529
- program.name("growi-uploader").description("A content uploader for GROWI").version(version).argument("<source-dir>", "Source directory containing Markdown files").option("-c, --config <path>", "Path to config file", "growi-uploader.json").action(async (sourceDir, options) => {
613
+ program.name("growi-uploader").description("A content uploader for GROWI").version(version).argument("<source-dir>", "Source directory containing Markdown files").option("-c, --config <path>", "Path to config file", "growi-uploader.json").option("-v, --verbose", "Enable verbose error output with detailed information").action(async (sourceDir, options) => {
530
614
  try {
531
- await main(sourceDir, options.config);
615
+ await main(sourceDir, options.config, options.verbose);
532
616
  } catch (error) {
533
617
  console.error("Error:", error instanceof Error ? error.message : error);
534
618
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onozaty/growi-uploader",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "A content uploader for GROWI",
5
5
  "type": "module",
6
6
  "bin": {