@onozaty/growi-uploader 1.0.0 → 1.2.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 +51 -16
  2. package/dist/index.mjs +387 -250
  3. package/package.json +9 -4
package/README.md CHANGED
@@ -137,9 +137,11 @@ Create a `growi-uploader.json` file in your project root:
137
137
 
138
138
  ## Attachment Files
139
139
 
140
- ### Naming Convention
140
+ Attachments are automatically detected using two methods:
141
141
 
142
- Attachment files must follow this naming pattern:
142
+ ### Method 1: Naming Convention
143
+
144
+ Files following this naming pattern are detected as attachments:
143
145
 
144
146
  ```
145
147
  <page-name>_attachment_<filename>
@@ -152,34 +154,66 @@ guide_attachment_image.png → Attached to /guide
152
154
  guide_attachment_document.pdf → Attached to /guide
153
155
  ```
154
156
 
155
- ### Automatic Link Replacement
157
+ ### Method 2: Link-Based Detection
158
+
159
+ Files referenced in markdown links are automatically detected as attachments:
156
160
 
157
- Markdown links to attachments are automatically converted to GROWI format.
161
+ **Local Directory:**
162
+ ```
163
+ guide.md
164
+ assets/
165
+ banner.png
166
+ images/
167
+ logo.png
168
+ screenshot.png
169
+ ```
158
170
 
159
- **Local Markdown (before upload):**
171
+ **guide.md Content:**
160
172
  ```markdown
161
- # User Guide
173
+ ![Logo](./images/logo.png)
174
+ ![Screenshot](images/screenshot.png)
175
+ ![Banner](/assets/banner.png)
176
+ ```
162
177
 
163
- ![Diagram](./guide_attachment_diagram.png)
178
+ All referenced files (`logo.png`, `screenshot.png`, and `banner.png`) will be uploaded as attachments to the `/guide` page, even though they don't follow the `_attachment_` naming convention.
164
179
 
165
- Download the [documentation](guide_attachment_document.pdf).
166
- ```
180
+ **Path resolution:**
181
+ - Relative paths (`./`, `../`, or no prefix): Resolved from the Markdown file's directory
182
+ - Absolute paths (starting with `/`): Resolved from the source directory root
183
+ - Example: `/assets/banner.png` → `<source-dir>/assets/banner.png`
184
+
185
+ **Excluded from detection:**
186
+ - `.md` files (treated as page links)
187
+ - External URLs (`http://`, `https://`)
188
+ - Non-existent files
189
+
190
+ ### Automatic Link Replacement
191
+
192
+ Markdown links to attachments are automatically converted to GROWI format (`/attachment/{id}`).
193
+
194
+ **Example (Naming Convention):**
167
195
 
168
- **GROWI Page (after upload):**
169
196
  ```markdown
170
- # User Guide
197
+ # Before upload
198
+ ![Diagram](./guide_attachment_diagram.png)
199
+ Download the [documentation](guide_attachment_document.pdf).
171
200
 
201
+ # After upload (on GROWI)
172
202
  ![Diagram](/attachment/68f3a41c794f665ad2c0d322)
173
-
174
203
  Download the [documentation](/attachment/68f3a3fa794f665ad2c0d2b3).
175
204
  ```
176
205
 
177
- ### Supported Link Formats
206
+ **Example (Link-Based):**
178
207
 
179
- Both formats are automatically detected and converted:
208
+ ```markdown
209
+ # Before upload
210
+ ![Logo](./images/logo.png)
211
+
212
+ # After upload (on GROWI)
213
+ ![Logo](/attachment/68f3a41c794f665ad2c0d322)
214
+ ```
180
215
 
181
- 1. **Filename only**: `![alt](guide_attachment_image.png)`
182
- 2. **Relative path**: `![alt](./guide_attachment_image.png)`
216
+ Both detection methods support multiple link formats (with or without `./`).
183
217
 
184
218
  ## Advanced Usage
185
219
 
@@ -231,6 +265,7 @@ Completed:
231
265
  - Attachments uploaded: 2
232
266
  - Attachments skipped: 0
233
267
  - Attachment errors: 0
268
+ - Link replacement errors: 0
234
269
  ```
235
270
 
236
271
  ## Requirements
package/dist/index.mjs CHANGED
@@ -1,23 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { basename, dirname, join, resolve } from "node:path";
4
- import { readFileSync } from "node:fs";
5
- import { glob } from "glob";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { existsSync, readFileSync } from "node:fs";
6
5
  import * as axios$1 from "axios";
7
6
  import axios from "axios";
8
7
  import { lookup } from "mime-types";
8
+ import { glob } from "glob";
9
+
10
+ //#region package.json
11
+ var version = "1.2.0";
9
12
 
13
+ //#endregion
10
14
  //#region src/config.ts
15
+ /**
16
+ * Load configuration from JSON file
17
+ *
18
+ * @param configPath - Path to the configuration file
19
+ * @returns Configuration object with defaults applied:
20
+ * - basePath defaults to "/" if not specified or empty
21
+ * - update defaults to false if not specified
22
+ * @throws Error if config file is not found or required fields (url, token) are missing
23
+ */
11
24
  const loadConfig = (configPath) => {
12
25
  const fullPath = resolve(configPath);
13
26
  try {
14
27
  const content = readFileSync(fullPath, "utf-8");
15
- const config = JSON.parse(content);
16
- if (!config.url) throw new Error("Missing required field: url");
17
- if (!config.token) throw new Error("Missing required field: token");
18
- if (config.basePath === void 0) config.basePath = "/";
19
- if (config.update === void 0) config.update = false;
20
- return config;
28
+ const input = JSON.parse(content);
29
+ if (!input.url) throw new Error("Missing required field: url");
30
+ if (!input.token) throw new Error("Missing required field: token");
31
+ return {
32
+ url: input.url,
33
+ token: input.token,
34
+ basePath: input.basePath || "/",
35
+ update: input.update ?? false
36
+ };
21
37
  } catch (error) {
22
38
  if (error.code === "ENOENT") throw new Error(`Config file not found: ${fullPath}`);
23
39
  throw error;
@@ -25,36 +41,7 @@ const loadConfig = (configPath) => {
25
41
  };
26
42
 
27
43
  //#endregion
28
- //#region src/scanner.ts
29
- const scanMarkdownFiles = async (sourceDir, basePath = "/") => {
30
- const mdFiles = await glob("**/*.md", {
31
- cwd: sourceDir,
32
- absolute: false
33
- });
34
- mdFiles.sort();
35
- return await Promise.all(mdFiles.map(async (file) => {
36
- const content = readFileSync(join(sourceDir, file), "utf-8");
37
- const growiPath = join(basePath, file.replace(/\.md$/, "")).replace(/\\/g, "/");
38
- const dir = dirname(file);
39
- const pageName = basename(file, ".md");
40
- const attachments = (await glob(dir === "." ? `${pageName}_attachment_*` : `${dir}/${pageName}_attachment_*`, {
41
- cwd: sourceDir,
42
- absolute: false
43
- })).map((attachFile) => ({
44
- localPath: attachFile,
45
- fileName: basename(attachFile).replace(`${pageName}_attachment_`, "")
46
- }));
47
- return {
48
- localPath: file,
49
- growiPath: growiPath.startsWith("/") ? growiPath : `/${growiPath}`,
50
- content,
51
- attachments
52
- };
53
- }));
54
- };
55
-
56
- //#endregion
57
- //#region src/growi.ts
44
+ //#region src/generated/growi.ts
58
45
  /**
59
46
  * Add attachment to the page
60
47
  * @summary /attachment
@@ -90,83 +77,255 @@ const putPage = (putPageBody, options) => {
90
77
  };
91
78
 
92
79
  //#endregion
93
- //#region src/uploader.ts
80
+ //#region src/growi-client.ts
81
+ /**
82
+ * Configure axios with GROWI API endpoint and authentication
83
+ *
84
+ * @param growiUrl GROWI base URL (e.g., "https://example.com")
85
+ * @param token API token for authentication
86
+ */
94
87
  const configureAxios = (growiUrl, token) => {
95
88
  axios.defaults.baseURL = `${growiUrl}/_api/v3`;
96
89
  axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
97
90
  };
98
- const createOrUpdatePage = async (file, config) => {
91
+ /**
92
+ * Format error into a human-readable error message
93
+ *
94
+ * @param error Error object from catch block
95
+ * @returns Formatted error message string
96
+ */
97
+ const formatErrorMessage = (error) => {
98
+ if (axios.isAxiosError(error)) return `${error.response?.status} ${error.response?.data?.message || error.message}`;
99
+ if (error instanceof Error) return error.message;
100
+ return String(error);
101
+ };
102
+ /**
103
+ * Create a new page or update an existing page in GROWI
104
+ *
105
+ * @param file Markdown file to upload
106
+ * @param content Markdown content
107
+ * @param shouldUpdate If true, update existing pages; if false, skip existing pages
108
+ * @returns Result containing page ID, revision ID, and action taken
109
+ */
110
+ const createOrUpdatePage = async (file, content, shouldUpdate) => {
99
111
  try {
100
112
  const actualResponse = (await getPage({ path: file.growiPath })).data;
101
113
  if (actualResponse.page) {
102
114
  const pageId = actualResponse.page._id;
103
115
  const revisionId = actualResponse.page.revision?._id;
104
- if (!config.update) {
105
- console.log(`[SKIP] ${file.localPath} → ${file.growiPath} (page already exists)`);
106
- return {
107
- pageId,
108
- revisionId,
109
- action: "skipped"
110
- };
111
- }
112
- const newRevisionId = (await putPage({
113
- body: file.content,
116
+ if (!shouldUpdate) return {
114
117
  pageId,
115
- revisionId
116
- })).data.page?.revision;
117
- console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (updated)`);
118
+ revisionId,
119
+ action: "skipped"
120
+ };
118
121
  return {
119
122
  pageId,
120
- revisionId: newRevisionId || revisionId,
123
+ revisionId: (await putPage({
124
+ body: content,
125
+ pageId,
126
+ revisionId
127
+ })).data.page?.revision,
121
128
  action: "updated"
122
129
  };
123
130
  }
124
131
  } catch (error) {
125
- if (!axios.isAxiosError(error) || error.response?.status !== 404) {
126
- if (axios.isAxiosError(error)) {
127
- const status = error.response?.status;
128
- const message = error.response?.data?.message || error.message;
129
- console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (${status} ${message})`);
130
- } else console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (${error})`);
131
- return {
132
- pageId: void 0,
133
- revisionId: void 0,
134
- action: "error"
135
- };
136
- }
132
+ if (!axios.isAxiosError(error) || error.response?.status !== 404) return {
133
+ pageId: void 0,
134
+ revisionId: void 0,
135
+ action: "error",
136
+ errorMessage: formatErrorMessage(error)
137
+ };
137
138
  }
138
139
  try {
139
140
  const response = await postPage({
140
141
  path: file.growiPath,
141
- body: file.content
142
+ body: content
142
143
  });
143
- const pageId = response.data.page?._id;
144
- const revisionId = response.data.page?.revision;
145
- console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (created)`);
146
144
  return {
147
- pageId,
148
- revisionId,
145
+ pageId: response.data.page?._id,
146
+ revisionId: response.data.page?.revision,
149
147
  action: "created"
150
148
  };
151
149
  } catch (error) {
152
- if (axios.isAxiosError(error)) {
153
- const status = error.response?.status;
154
- const message = error.response?.data?.message || error.message;
155
- console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (${status} ${message})`);
156
- } else console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (${error})`);
157
150
  return {
158
151
  pageId: void 0,
159
152
  revisionId: void 0,
160
- action: "error"
153
+ action: "error",
154
+ errorMessage: formatErrorMessage(error)
155
+ };
156
+ }
157
+ };
158
+ /**
159
+ * Update page content with new Markdown content
160
+ *
161
+ * @param pageId Page ID
162
+ * @param revisionId Current revision ID
163
+ * @param content New Markdown content
164
+ * @returns Result containing success status, new revision ID, or error message
165
+ */
166
+ const updatePageContent = async (pageId, revisionId, content) => {
167
+ try {
168
+ const newRevisionId = (await putPage({
169
+ body: content,
170
+ pageId,
171
+ revisionId
172
+ })).data.page?.revision;
173
+ return {
174
+ success: true,
175
+ ...newRevisionId && { revisionId: newRevisionId }
161
176
  };
177
+ } catch (error) {
178
+ return {
179
+ success: false,
180
+ errorMessage: formatErrorMessage(error)
181
+ };
182
+ }
183
+ };
184
+ /**
185
+ * Upload an attachment file to a GROWI page
186
+ *
187
+ * @param attachment Attachment file to upload
188
+ * @param pageId Page ID to attach the file to
189
+ * @param sourceDir Source directory containing the attachment file
190
+ * @returns Result containing attachment ID, revision ID, and success status
191
+ */
192
+ const uploadAttachment = async (attachment, pageId, sourceDir) => {
193
+ try {
194
+ const fileBuffer = readFileSync(join(sourceDir, attachment.localPath));
195
+ const mimeType = lookup(attachment.fileName) || "application/octet-stream";
196
+ const formData = new FormData();
197
+ formData.append("page_id", pageId);
198
+ formData.append("file", new Blob([fileBuffer], { type: mimeType }), attachment.fileName);
199
+ const response = await postAttachment(formData);
200
+ const attachmentId = response.data.attachment?._id;
201
+ const revisionId = typeof response.data.revision === "string" ? response.data.revision : void 0;
202
+ return {
203
+ success: true,
204
+ ...attachmentId && { attachmentId },
205
+ ...revisionId && { revisionId }
206
+ };
207
+ } catch (error) {
208
+ return {
209
+ success: false,
210
+ errorMessage: formatErrorMessage(error)
211
+ };
212
+ }
213
+ };
214
+
215
+ //#endregion
216
+ //#region src/scanner.ts
217
+ /**
218
+ * Extract attachment files from markdown links
219
+ *
220
+ * Scans markdown content for image and link references, resolves their paths,
221
+ * and returns those that exist as files (excluding .md files and external URLs).
222
+ *
223
+ * @param content Markdown content
224
+ * @param markdownFilePath Path to the markdown file (relative to sourceDir)
225
+ * @param sourceDir Source directory (absolute path)
226
+ * @returns Array of attachment files found via links
227
+ */
228
+ const extractLinkedAttachments = (content, markdownFilePath, sourceDir) => {
229
+ const attachments = [];
230
+ const linkRegex = new RegExp([
231
+ "!?",
232
+ "\\[(?<alt>[^\\]]*)\\]",
233
+ "\\(",
234
+ "(?:",
235
+ "<(?<anglePath>[^>]+)>",
236
+ "|",
237
+ "(?<regularPath>(?:[^)\\\\]+|\\\\.)*)",
238
+ ")",
239
+ "\\)"
240
+ ].join(""), "g");
241
+ let match;
242
+ while ((match = linkRegex.exec(content)) !== null) {
243
+ const { anglePath, regularPath } = match.groups;
244
+ const linkPath = anglePath || regularPath;
245
+ 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 absolutePath;
251
+ if (linkPath.startsWith("/")) absolutePath = resolve(sourceDir, unescapedPath.slice(1));
252
+ else absolutePath = resolve(dirname(join(sourceDir, markdownFilePath)), unescapedPath);
253
+ if (!existsSync(absolutePath)) continue;
254
+ const normalizedPath = relative(sourceDir, absolutePath).replace(/\\/g, "/");
255
+ attachments.push({
256
+ localPath: normalizedPath,
257
+ fileName: basename(normalizedPath),
258
+ detectionPattern: "link",
259
+ originalLinkPaths: [originalLinkPath]
260
+ });
162
261
  }
262
+ return attachments;
263
+ };
264
+ /**
265
+ * Merge attachments from naming pattern and link pattern, removing duplicates
266
+ *
267
+ * If the same file is detected by both patterns, it will be kept as a single
268
+ * attachment with merged originalLinkPaths.
269
+ *
270
+ * @param namingAttachments Attachments detected by naming pattern
271
+ * @param linkAttachments Attachments detected by link pattern
272
+ * @returns Merged array with duplicates removed
273
+ */
274
+ const mergeAttachments = (namingAttachments, linkAttachments) => {
275
+ const map = /* @__PURE__ */ new Map();
276
+ for (const att of namingAttachments) map.set(att.localPath, att);
277
+ for (const att of linkAttachments) {
278
+ const existing = map.get(att.localPath);
279
+ if (existing) {
280
+ if (!existing.originalLinkPaths) existing.originalLinkPaths = [];
281
+ if (att.originalLinkPaths) existing.originalLinkPaths.push(...att.originalLinkPaths);
282
+ } else map.set(att.localPath, att);
283
+ }
284
+ return Array.from(map.values());
285
+ };
286
+ const scanMarkdownFiles = async (sourceDir, basePath = "/") => {
287
+ const pageFiles = (await glob("**/*.md", {
288
+ cwd: sourceDir,
289
+ absolute: false
290
+ })).filter((file) => !basename(file).includes("_attachment_"));
291
+ pageFiles.sort();
292
+ return await Promise.all(pageFiles.map(async (file) => {
293
+ const content = readFileSync(join(sourceDir, file), "utf-8");
294
+ const growiPath = join(basePath, file.replace(/\.md$/, "")).replace(/\\/g, "/");
295
+ const dir = dirname(file);
296
+ const pageName = basename(file, ".md");
297
+ const attachments = mergeAttachments((await glob(dir === "." ? `${pageName}_attachment_*` : `${dir}/${pageName}_attachment_*`, {
298
+ cwd: sourceDir,
299
+ absolute: false
300
+ })).map((attachFile) => ({
301
+ localPath: attachFile,
302
+ fileName: basename(attachFile).replace(`${pageName}_attachment_`, ""),
303
+ detectionPattern: "naming"
304
+ })), extractLinkedAttachments(content, file, sourceDir));
305
+ return {
306
+ localPath: file,
307
+ growiPath: growiPath.startsWith("/") ? growiPath : `/${growiPath}`,
308
+ attachments
309
+ };
310
+ }));
311
+ };
312
+
313
+ //#endregion
314
+ //#region src/markdown.ts
315
+ /**
316
+ * Escape special regex characters for safe use in RegExp
317
+ * Escapes: . * + ? ^ $ { } ( ) | [ ] \ < >
318
+ * Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
319
+ */
320
+ const escapeRegex = (str) => {
321
+ return str.replace(/[.*+?^${}()|[\]\\<>]/g, "\\$&");
163
322
  };
164
323
  /**
165
324
  * Replace attachment links in Markdown content with GROWI format
166
325
  *
167
- * Supports:
168
- * - Filename only: guide_attachment_file.png
169
- * - Relative path: ./guide_attachment_file.png
326
+ * Supports two detection patterns:
327
+ * 1. Naming pattern: guide_attachment_file.png, ./guide_attachment_file.png
328
+ * 2. Link pattern: ./images/photo.jpg, images/photo.jpg (as found in markdown)
170
329
  *
171
330
  * @param markdown Original Markdown content
172
331
  * @param attachments List of attachments with their IDs
@@ -178,10 +337,20 @@ const replaceAttachmentLinks = (markdown, attachments, pageName) => {
178
337
  let replaced = false;
179
338
  for (const attachment of attachments) {
180
339
  if (!attachment.attachmentId) continue;
181
- const localFileName = `${pageName}_attachment_${attachment.fileName}`;
182
340
  const growiPath = `/attachment/${attachment.attachmentId}`;
183
- const escapedFileName = localFileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
184
- const patterns = [escapedFileName, `\\./${escapedFileName}`];
341
+ const patterns = [];
342
+ if (attachment.detectionPattern === "naming") {
343
+ const escapedFileName = escapeRegex(`${pageName}_attachment_${attachment.fileName}`);
344
+ patterns.push(escapedFileName, `\\./${escapedFileName}`);
345
+ }
346
+ if (attachment.detectionPattern === "link" && attachment.originalLinkPaths) for (const linkPath of attachment.originalLinkPaths) {
347
+ const escapedPath = escapeRegex(linkPath);
348
+ patterns.push(escapedPath);
349
+ if (linkPath.startsWith("./")) {
350
+ const escapedWithoutDot = escapeRegex(linkPath.substring(2));
351
+ patterns.push(escapedWithoutDot);
352
+ } else if (!linkPath.startsWith("../")) patterns.push(`\\./${escapedPath}`);
353
+ }
185
354
  for (const pattern of patterns) {
186
355
  const imgRegex = new RegExp(`!\\[([^\\]]*)\\]\\(${pattern}\\)`, "g");
187
356
  if (imgRegex.test(result)) {
@@ -201,197 +370,165 @@ const replaceAttachmentLinks = (markdown, attachments, pageName) => {
201
370
  };
202
371
  };
203
372
  /**
204
- * Update page content only (for re-updating after attachment link replacement)
373
+ * Replace .md extension in page links with GROWI format
205
374
  *
206
- * @param pageId Page ID
207
- * @param revisionId Current revision ID
208
- * @param content New Markdown content
209
- * @param growiPath GROWI page path (for logging)
210
- * @returns True if update succeeded
375
+ * Converts Markdown page links to GROWI-compatible format by removing .md extension.
376
+ * For absolute paths (starting with /), prepends basePath to the link.
377
+ * External URLs (http://, https://) are excluded from replacement.
378
+ *
379
+ * Supported patterns:
380
+ * - Relative path: [text](./page.md) → [text](./page)
381
+ * - Filename only: [text](page.md) → [text](page)
382
+ * - Absolute path: [text](/docs/page.md) → [text](/basePath/docs/page)
383
+ * - With anchor: [text](./page.md#section) → [text](./page#section)
384
+ *
385
+ * @param markdown Original Markdown content
386
+ * @param basePath Base path for GROWI pages (default: "/")
387
+ * @returns Object with replaced content and whether any replacement occurred
211
388
  */
212
- const updatePageContent = async (pageId, revisionId, content, growiPath) => {
213
- try {
214
- await putPage({
215
- body: content,
216
- pageId,
217
- revisionId
218
- });
219
- return true;
220
- } catch (error) {
221
- if (axios.isAxiosError(error)) {
222
- const status = error.response?.status;
223
- const message = error.response?.data?.message || error.message;
224
- console.error(`[WARN] Failed to update attachment links for ${growiPath} (${status} ${message})`);
225
- } else console.error(`[WARN] Failed to update attachment links for ${growiPath}`);
226
- return false;
227
- }
228
- };
229
- const uploadAttachment = async (attachment, pageId, growiPath, sourceDir) => {
230
- try {
231
- const fileBuffer = readFileSync(join(sourceDir, attachment.localPath));
232
- const mimeType = lookup(attachment.fileName) || "application/octet-stream";
233
- const formData = new FormData();
234
- formData.append("page_id", pageId);
235
- formData.append("file", new Blob([fileBuffer], { type: mimeType }), attachment.fileName);
236
- const response = await postAttachment(formData);
237
- const attachmentId = response.data.attachment?._id;
238
- const revisionId = typeof response.data.revision === "string" ? response.data.revision : void 0;
239
- console.log(`[SUCCESS] ${attachment.localPath} → ${growiPath} (attachment)`);
240
- if (attachmentId && revisionId) return {
241
- success: true,
242
- attachmentId,
243
- revisionId
244
- };
245
- else if (attachmentId) return {
246
- success: true,
247
- attachmentId
248
- };
249
- else if (revisionId) return {
250
- success: true,
251
- revisionId
252
- };
253
- else return { success: true };
254
- } catch (error) {
255
- if (axios.isAxiosError(error)) {
256
- const status = error.response?.status;
257
- const message = error.response?.data?.message || error.message;
258
- console.error(`[ERROR] ${attachment.localPath} → ${growiPath} (${status} ${message})`);
259
- } else console.error(`[ERROR] ${attachment.localPath} → ${growiPath} (${error})`);
260
- return { success: false };
261
- }
262
- };
263
-
264
- //#endregion
265
- //#region package.json
266
- var name = "@onozaty/growi-uploader";
267
- var version = "1.0.0";
268
- var description = "A content uploader for GROWI";
269
- var type = "module";
270
- var bin = { "growi-uploader": "./dist/index.mjs" };
271
- var files = ["dist/**/*"];
272
- var scripts = {
273
- "build": "pnpm gen && tsdown",
274
- "prepublishOnly": "pnpm run build",
275
- "dev": "tsx src/index.ts",
276
- "gen": "orval",
277
- "lint": "eslint .",
278
- "typecheck": "tsc --noEmit",
279
- "check": "pnpm run lint && pnpm run typecheck",
280
- "format": "prettier --write src/"
281
- };
282
- var repository = {
283
- "type": "git",
284
- "url": "git+https://github.com/onozaty/growi-uploader.git"
285
- };
286
- var keywords = ["growi", "uploader"];
287
- var author = "onozaty";
288
- var license = "MIT";
289
- var bugs = { "url": "https://github.com/onozaty/growi-uploader/issues" };
290
- var homepage = "https://github.com/onozaty/growi-uploader#readme";
291
- var packageManager = "pnpm@10.12.1";
292
- var devDependencies = {
293
- "@eslint/js": "^9.37.0",
294
- "@types/mime-types": "^3.0.1",
295
- "@types/node": "^24.7.2",
296
- "eslint": "^9.37.0",
297
- "globals": "^16.4.0",
298
- "jiti": "^2.6.1",
299
- "orval": "^7.13.2",
300
- "prettier": "^3.6.2",
301
- "tsdown": "^0.15.7",
302
- "tsx": "^4.20.6",
303
- "typescript": "^5.9.3",
304
- "typescript-eslint": "^8.46.1"
305
- };
306
- var dependencies = {
307
- "axios": "^1.12.2",
308
- "commander": "^14.0.1",
309
- "glob": "^11.0.3",
310
- "mime-types": "^3.0.1"
311
- };
312
- var package_default = {
313
- name,
314
- version,
315
- description,
316
- type,
317
- bin,
318
- files,
319
- scripts,
320
- repository,
321
- keywords,
322
- author,
323
- license,
324
- bugs,
325
- homepage,
326
- packageManager,
327
- devDependencies,
328
- dependencies
389
+ const replaceMarkdownExtension = (markdown, basePath = "/") => {
390
+ const result = markdown.replace(/(\[[^\]]*\]\((?!https?:\/\/))([^)]*?)\.md((?:#[^)]*)?\))/g, (match, prefix, path, suffix) => {
391
+ if (path.startsWith("/") && basePath !== "/") return `${prefix}${basePath.endsWith("/") ? basePath.slice(0, -1) : basePath}${path}${suffix}`;
392
+ return `${prefix}${path}${suffix}`;
393
+ });
394
+ return {
395
+ content: result,
396
+ replaced: result !== markdown
397
+ };
329
398
  };
330
399
 
331
400
  //#endregion
332
- //#region src/index.ts
333
- const program = new Command();
334
- program.name("growi-uploader").description("A content uploader for GROWI").version(package_default.version).argument("<source-dir>", "Source directory containing Markdown files").option("-c, --config <path>", "Path to config file", "growi-uploader.json").action(async (sourceDir, options) => {
335
- try {
336
- const config = loadConfig(options.config);
337
- const sourceDirPath = resolve(sourceDir);
338
- configureAxios(config.url, config.token);
339
- const files$1 = await scanMarkdownFiles(sourceDirPath, config.basePath);
340
- const totalAttachments = files$1.reduce((sum, file) => sum + file.attachments.length, 0);
341
- console.log(`Found ${files$1.length} Markdown file(s) and ${totalAttachments} attachment(s)\n`);
342
- let pagesCreated = 0;
343
- let pagesUpdated = 0;
344
- let pagesSkipped = 0;
345
- let pageErrors = 0;
346
- let attachmentsUploaded = 0;
347
- let attachmentsSkipped = 0;
348
- let attachmentErrors = 0;
349
- for (const file of files$1) {
350
- const result = await createOrUpdatePage(file, config);
351
- if (result.action === "created") pagesCreated++;
352
- else if (result.action === "updated") pagesUpdated++;
353
- else if (result.action === "skipped") pagesSkipped++;
354
- else if (result.action === "error") pageErrors++;
355
- if (result.pageId && (result.action === "created" || result.action === "updated") && file.attachments.length > 0) {
401
+ //#region src/uploader.ts
402
+ /**
403
+ * Upload Markdown files and their attachments to GROWI
404
+ *
405
+ * This function orchestrates a 4-stage upload process:
406
+ * 1. Create or update page with original Markdown content
407
+ * 2. Upload attachments
408
+ * 3. Replace attachment links in Markdown and update page
409
+ * 4. Replace .md extension in page links and update page
410
+ *
411
+ * @param files List of Markdown files to upload
412
+ * @param sourceDir Source directory containing files
413
+ * @param config Configuration (only update flag is used)
414
+ * @returns Upload statistics
415
+ */
416
+ const uploadFiles = async (files, sourceDir, config) => {
417
+ const stats = {
418
+ pagesCreated: 0,
419
+ pagesUpdated: 0,
420
+ pagesSkipped: 0,
421
+ pageErrors: 0,
422
+ attachmentsUploaded: 0,
423
+ attachmentsSkipped: 0,
424
+ attachmentErrors: 0,
425
+ linkReplacementErrors: 0
426
+ };
427
+ for (const file of files) {
428
+ const content = readFileSync(join(sourceDir, file.localPath), "utf-8");
429
+ const result = await createOrUpdatePage(file, content, config.update);
430
+ if (result.action === "created") {
431
+ stats.pagesCreated++;
432
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (created)`);
433
+ } else if (result.action === "updated") {
434
+ stats.pagesUpdated++;
435
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (updated)`);
436
+ } else if (result.action === "skipped") {
437
+ stats.pagesSkipped++;
438
+ console.log(`[SKIP] ${file.localPath} → ${file.growiPath} (page already exists)`);
439
+ } else if (result.action === "error") {
440
+ stats.pageErrors++;
441
+ console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (${result.errorMessage || "unknown error"})`);
442
+ }
443
+ if (result.pageId && (result.action === "created" || result.action === "updated")) {
444
+ let currentContent = content;
445
+ let currentRevisionId = result.revisionId;
446
+ if (file.attachments.length > 0) {
356
447
  let hasAttachments = false;
357
448
  let latestRevisionId = result.revisionId;
358
449
  for (const attachment of file.attachments) {
359
- const attachmentResult = await uploadAttachment(attachment, result.pageId, file.growiPath, sourceDirPath);
450
+ const attachmentResult = await uploadAttachment(attachment, result.pageId, sourceDir);
360
451
  if (attachmentResult.success) {
361
- attachmentsUploaded++;
452
+ stats.attachmentsUploaded++;
453
+ console.log(`[SUCCESS] ${attachment.localPath} → ${file.growiPath} (attachment)`);
362
454
  if (attachmentResult.attachmentId) {
363
455
  attachment.attachmentId = attachmentResult.attachmentId;
364
456
  hasAttachments = true;
365
457
  }
366
458
  if (attachmentResult.revisionId) latestRevisionId = attachmentResult.revisionId;
367
- } else attachmentErrors++;
459
+ } else {
460
+ stats.attachmentErrors++;
461
+ console.error(`[ERROR] ${attachment.localPath} → ${file.growiPath} (${attachmentResult.errorMessage || "failed to upload attachment"})`);
462
+ }
368
463
  }
369
464
  if (hasAttachments && latestRevisionId) {
465
+ currentRevisionId = latestRevisionId;
370
466
  const pageName = basename(file.localPath, ".md");
371
- const { content: replacedContent, replaced } = replaceAttachmentLinks(file.content, file.attachments, pageName);
467
+ const { content: replacedContent, replaced } = replaceAttachmentLinks(currentContent, file.attachments, pageName);
372
468
  if (replaced) {
373
- if (await updatePageContent(result.pageId, latestRevisionId, replacedContent, file.growiPath)) console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (attachment links replaced)`);
469
+ const updateResult = await updatePageContent(result.pageId, currentRevisionId, replacedContent);
470
+ if (updateResult.success && updateResult.revisionId) {
471
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (attachment links replaced)`);
472
+ currentContent = replacedContent;
473
+ currentRevisionId = updateResult.revisionId;
474
+ } else {
475
+ console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update attachment links: ${updateResult.errorMessage || "unknown error"})`);
476
+ stats.linkReplacementErrors++;
477
+ }
478
+ }
479
+ }
480
+ }
481
+ if (currentRevisionId) {
482
+ const { content: linkedContent, replaced: linkReplaced } = replaceMarkdownExtension(currentContent, config.basePath);
483
+ if (linkReplaced) {
484
+ const updateResult = await updatePageContent(result.pageId, currentRevisionId, linkedContent);
485
+ if (updateResult.success && updateResult.revisionId) {
486
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (page links replaced)`);
487
+ currentRevisionId = updateResult.revisionId;
488
+ } else {
489
+ console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update page links: ${updateResult.errorMessage || "unknown error"})`);
490
+ stats.linkReplacementErrors++;
374
491
  }
375
492
  }
376
- } else if (file.attachments.length > 0) for (const attachment of file.attachments) {
377
- console.log(`[SKIP] ${attachment.localPath} → ${file.growiPath} (attachment skipped)`);
378
- attachmentsSkipped++;
379
493
  }
494
+ } else if (file.attachments.length > 0) for (const attachment of file.attachments) {
495
+ console.log(`[SKIP] ${attachment.localPath} → ${file.growiPath} (attachment skipped)`);
496
+ stats.attachmentsSkipped++;
380
497
  }
381
- console.log("\nCompleted:");
382
- console.log(`- Pages created: ${pagesCreated}`);
383
- console.log(`- Pages updated: ${pagesUpdated}`);
384
- console.log(`- Pages skipped: ${pagesSkipped}`);
385
- console.log(`- Page errors: ${pageErrors}`);
386
- console.log(`- Attachments uploaded: ${attachmentsUploaded}`);
387
- console.log(`- Attachments skipped: ${attachmentsSkipped}`);
388
- console.log(`- Attachment errors: ${attachmentErrors}`);
498
+ }
499
+ return stats;
500
+ };
501
+
502
+ //#endregion
503
+ //#region src/index.ts
504
+ const program = new Command();
505
+ const main = async (sourceDir, configPath) => {
506
+ const config = loadConfig(configPath);
507
+ const sourceDirPath = resolve(sourceDir);
508
+ configureAxios(config.url, config.token);
509
+ const files = await scanMarkdownFiles(sourceDirPath, config.basePath);
510
+ const totalAttachments = files.reduce((sum, file) => sum + file.attachments.length, 0);
511
+ console.log(`Found ${files.length} Markdown file(s) and ${totalAttachments} attachment(s)\n`);
512
+ const stats = await uploadFiles(files, sourceDirPath, config);
513
+ console.log("\nCompleted:");
514
+ console.log(`- Pages created: ${stats.pagesCreated}`);
515
+ console.log(`- Pages updated: ${stats.pagesUpdated}`);
516
+ console.log(`- Pages skipped: ${stats.pagesSkipped}`);
517
+ console.log(`- Page errors: ${stats.pageErrors}`);
518
+ console.log(`- Attachments uploaded: ${stats.attachmentsUploaded}`);
519
+ console.log(`- Attachments skipped: ${stats.attachmentsSkipped}`);
520
+ console.log(`- Attachment errors: ${stats.attachmentErrors}`);
521
+ console.log(`- Link replacement errors: ${stats.linkReplacementErrors}`);
522
+ };
523
+ 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) => {
524
+ try {
525
+ await main(sourceDir, options.config);
389
526
  } catch (error) {
390
527
  console.error("Error:", error instanceof Error ? error.message : error);
391
528
  process.exit(1);
392
529
  }
393
530
  });
394
- program.parse();
531
+ if (process.env.NODE_ENV !== "test" && !process.env.VITEST) program.parse();
395
532
 
396
533
  //#endregion
397
- export { };
534
+ export { main };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onozaty/growi-uploader",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "A content uploader for GROWI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,9 @@
17
17
  "lint": "eslint .",
18
18
  "typecheck": "tsc --noEmit",
19
19
  "check": "pnpm run lint && pnpm run typecheck",
20
- "format": "prettier --write src/"
20
+ "format": "prettier --write src/",
21
+ "test": "vitest run",
22
+ "test:cov": "vitest run --coverage"
21
23
  },
22
24
  "repository": {
23
25
  "type": "git",
@@ -38,15 +40,18 @@
38
40
  "@eslint/js": "^9.37.0",
39
41
  "@types/mime-types": "^3.0.1",
40
42
  "@types/node": "^24.7.2",
43
+ "@vitest/coverage-v8": "^3.2.4",
44
+ "@vitest/eslint-plugin": "^1.3.23",
41
45
  "eslint": "^9.37.0",
42
46
  "globals": "^16.4.0",
43
47
  "jiti": "^2.6.1",
44
48
  "orval": "^7.13.2",
45
49
  "prettier": "^3.6.2",
46
- "tsdown": "^0.15.7",
50
+ "tsdown": "^0.15.9",
47
51
  "tsx": "^4.20.6",
48
52
  "typescript": "^5.9.3",
49
- "typescript-eslint": "^8.46.1"
53
+ "typescript-eslint": "^8.46.1",
54
+ "vitest": "^3.2.4"
50
55
  },
51
56
  "dependencies": {
52
57
  "axios": "^1.12.2",