@onozaty/growi-uploader 1.0.0 → 1.1.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 +43 -16
  2. package/dist/index.mjs +361 -249
  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,58 @@ 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
+ images/
165
+ logo.png
166
+ screenshot.png
167
+ ```
158
168
 
159
- **Local Markdown (before upload):**
169
+ **guide.md Content:**
160
170
  ```markdown
161
- # User Guide
171
+ ![Logo](./images/logo.png)
172
+ ![Screenshot](images/screenshot.png)
173
+ ```
162
174
 
163
- ![Diagram](./guide_attachment_diagram.png)
175
+ Both `logo.png` and `screenshot.png` will be uploaded as attachments to the `/guide` page, even though they don't follow the `_attachment_` naming convention.
164
176
 
165
- Download the [documentation](guide_attachment_document.pdf).
166
- ```
177
+ **Excluded from detection:**
178
+ - `.md` files (treated as page links)
179
+ - External URLs (`http://`, `https://`)
180
+ - Non-existent files
181
+
182
+ ### Automatic Link Replacement
183
+
184
+ Markdown links to attachments are automatically converted to GROWI format (`/attachment/{id}`).
185
+
186
+ **Example (Naming Convention):**
167
187
 
168
- **GROWI Page (after upload):**
169
188
  ```markdown
170
- # User Guide
189
+ # Before upload
190
+ ![Diagram](./guide_attachment_diagram.png)
191
+ Download the [documentation](guide_attachment_document.pdf).
171
192
 
193
+ # After upload (on GROWI)
172
194
  ![Diagram](/attachment/68f3a41c794f665ad2c0d322)
173
-
174
195
  Download the [documentation](/attachment/68f3a3fa794f665ad2c0d2b3).
175
196
  ```
176
197
 
177
- ### Supported Link Formats
198
+ **Example (Link-Based):**
199
+
200
+ ```markdown
201
+ # Before upload
202
+ ![Logo](./images/logo.png)
178
203
 
179
- Both formats are automatically detected and converted:
204
+ # After upload (on GROWI)
205
+ ![Logo](/attachment/68f3a41c794f665ad2c0d322)
206
+ ```
180
207
 
181
- 1. **Filename only**: `![alt](guide_attachment_image.png)`
182
- 2. **Relative path**: `![alt](./guide_attachment_image.png)`
208
+ Both detection methods support multiple link formats (with or without `./`).
183
209
 
184
210
  ## Advanced Usage
185
211
 
@@ -231,6 +257,7 @@ Completed:
231
257
  - Attachments uploaded: 2
232
258
  - Attachments skipped: 0
233
259
  - Attachment errors: 0
260
+ - Link replacement errors: 0
234
261
  ```
235
262
 
236
263
  ## 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.1.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,237 @@ 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 shouldUpdate If true, update existing pages; if false, skip existing pages
107
+ * @returns Result containing page ID, revision ID, and action taken
108
+ */
109
+ const createOrUpdatePage = async (file, shouldUpdate) => {
99
110
  try {
100
111
  const actualResponse = (await getPage({ path: file.growiPath })).data;
101
112
  if (actualResponse.page) {
102
113
  const pageId = actualResponse.page._id;
103
114
  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,
115
+ if (!shouldUpdate) return {
114
116
  pageId,
115
- revisionId
116
- })).data.page?.revision;
117
- console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (updated)`);
117
+ revisionId,
118
+ action: "skipped"
119
+ };
118
120
  return {
119
121
  pageId,
120
- revisionId: newRevisionId || revisionId,
122
+ revisionId: (await putPage({
123
+ body: file.content,
124
+ pageId,
125
+ revisionId
126
+ })).data.page?.revision,
121
127
  action: "updated"
122
128
  };
123
129
  }
124
130
  } 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
- }
131
+ if (!axios.isAxiosError(error) || error.response?.status !== 404) return {
132
+ pageId: void 0,
133
+ revisionId: void 0,
134
+ action: "error",
135
+ errorMessage: formatErrorMessage(error)
136
+ };
137
137
  }
138
138
  try {
139
139
  const response = await postPage({
140
140
  path: file.growiPath,
141
141
  body: file.content
142
142
  });
143
- const pageId = response.data.page?._id;
144
- const revisionId = response.data.page?.revision;
145
- console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (created)`);
146
143
  return {
147
- pageId,
148
- revisionId,
144
+ pageId: response.data.page?._id,
145
+ revisionId: response.data.page?.revision,
149
146
  action: "created"
150
147
  };
151
148
  } 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
149
  return {
158
150
  pageId: void 0,
159
151
  revisionId: void 0,
160
- action: "error"
152
+ action: "error",
153
+ errorMessage: formatErrorMessage(error)
154
+ };
155
+ }
156
+ };
157
+ /**
158
+ * Update page content with new Markdown content
159
+ *
160
+ * @param pageId Page ID
161
+ * @param revisionId Current revision ID
162
+ * @param content New Markdown content
163
+ * @returns Result containing success status, new revision ID, or error message
164
+ */
165
+ const updatePageContent = async (pageId, revisionId, content) => {
166
+ try {
167
+ const newRevisionId = (await putPage({
168
+ body: content,
169
+ pageId,
170
+ revisionId
171
+ })).data.page?.revision;
172
+ return {
173
+ success: true,
174
+ ...newRevisionId && { revisionId: newRevisionId }
175
+ };
176
+ } catch (error) {
177
+ return {
178
+ success: false,
179
+ errorMessage: formatErrorMessage(error)
180
+ };
181
+ }
182
+ };
183
+ /**
184
+ * Upload an attachment file to a GROWI page
185
+ *
186
+ * @param attachment Attachment file to upload
187
+ * @param pageId Page ID to attach the file to
188
+ * @param sourceDir Source directory containing the attachment file
189
+ * @returns Result containing attachment ID, revision ID, and success status
190
+ */
191
+ const uploadAttachment = async (attachment, pageId, sourceDir) => {
192
+ try {
193
+ const fileBuffer = readFileSync(join(sourceDir, attachment.localPath));
194
+ const mimeType = lookup(attachment.fileName) || "application/octet-stream";
195
+ const formData = new FormData();
196
+ formData.append("page_id", pageId);
197
+ formData.append("file", new Blob([fileBuffer], { type: mimeType }), attachment.fileName);
198
+ const response = await postAttachment(formData);
199
+ const attachmentId = response.data.attachment?._id;
200
+ const revisionId = typeof response.data.revision === "string" ? response.data.revision : void 0;
201
+ return {
202
+ success: true,
203
+ ...attachmentId && { attachmentId },
204
+ ...revisionId && { revisionId }
205
+ };
206
+ } catch (error) {
207
+ return {
208
+ success: false,
209
+ errorMessage: formatErrorMessage(error)
161
210
  };
162
211
  }
163
212
  };
213
+
214
+ //#endregion
215
+ //#region src/scanner.ts
216
+ /**
217
+ * Extract attachment files from markdown links
218
+ *
219
+ * Scans markdown content for image and link references, resolves their paths,
220
+ * and returns those that exist as files (excluding .md files and external URLs).
221
+ *
222
+ * @param content Markdown content
223
+ * @param markdownFilePath Path to the markdown file (relative to sourceDir)
224
+ * @param sourceDir Source directory (absolute path)
225
+ * @returns Array of attachment files found via links
226
+ */
227
+ const extractLinkedAttachments = (content, markdownFilePath, sourceDir) => {
228
+ const attachments = [];
229
+ const linkRegex = /!?\[([^\]]*)\]\((?:<([^>]+)>|((?:[^)\\]+|\\.)*))\)/g;
230
+ let match;
231
+ while ((match = linkRegex.exec(content)) !== null) {
232
+ const angleBracketPath = match[2];
233
+ const regularPath = match[3];
234
+ const linkPath = angleBracketPath || regularPath;
235
+ if (!linkPath) continue;
236
+ const originalLinkPath = angleBracketPath ? `<${angleBracketPath}>` : regularPath;
237
+ if (linkPath.startsWith("http://") || linkPath.startsWith("https://")) continue;
238
+ if (linkPath.endsWith(".md")) continue;
239
+ if (linkPath.startsWith("/")) continue;
240
+ const unescapedPath = linkPath.replace(/\\([\\`*_{}[\]()#+\-.!])/g, "$1");
241
+ const absolutePath = resolve(dirname(join(sourceDir, markdownFilePath)), unescapedPath);
242
+ if (!existsSync(absolutePath)) continue;
243
+ const normalizedPath = relative(sourceDir, absolutePath).replace(/\\/g, "/");
244
+ attachments.push({
245
+ localPath: normalizedPath,
246
+ fileName: basename(normalizedPath),
247
+ detectionPattern: "link",
248
+ originalLinkPaths: [originalLinkPath]
249
+ });
250
+ }
251
+ return attachments;
252
+ };
253
+ /**
254
+ * Merge attachments from naming pattern and link pattern, removing duplicates
255
+ *
256
+ * If the same file is detected by both patterns, it will be kept as a single
257
+ * attachment with merged originalLinkPaths.
258
+ *
259
+ * @param namingAttachments Attachments detected by naming pattern
260
+ * @param linkAttachments Attachments detected by link pattern
261
+ * @returns Merged array with duplicates removed
262
+ */
263
+ const mergeAttachments = (namingAttachments, linkAttachments) => {
264
+ const map = /* @__PURE__ */ new Map();
265
+ for (const att of namingAttachments) map.set(att.localPath, att);
266
+ for (const att of linkAttachments) {
267
+ const existing = map.get(att.localPath);
268
+ if (existing) {
269
+ if (!existing.originalLinkPaths) existing.originalLinkPaths = [];
270
+ if (att.originalLinkPaths) existing.originalLinkPaths.push(...att.originalLinkPaths);
271
+ } else map.set(att.localPath, att);
272
+ }
273
+ return Array.from(map.values());
274
+ };
275
+ const scanMarkdownFiles = async (sourceDir, basePath = "/") => {
276
+ const pageFiles = (await glob("**/*.md", {
277
+ cwd: sourceDir,
278
+ absolute: false
279
+ })).filter((file) => !basename(file).includes("_attachment_"));
280
+ pageFiles.sort();
281
+ return await Promise.all(pageFiles.map(async (file) => {
282
+ const content = readFileSync(join(sourceDir, file), "utf-8");
283
+ const growiPath = join(basePath, file.replace(/\.md$/, "")).replace(/\\/g, "/");
284
+ const dir = dirname(file);
285
+ const pageName = basename(file, ".md");
286
+ const attachments = mergeAttachments((await glob(dir === "." ? `${pageName}_attachment_*` : `${dir}/${pageName}_attachment_*`, {
287
+ cwd: sourceDir,
288
+ absolute: false
289
+ })).map((attachFile) => ({
290
+ localPath: attachFile,
291
+ fileName: basename(attachFile).replace(`${pageName}_attachment_`, ""),
292
+ detectionPattern: "naming"
293
+ })), extractLinkedAttachments(content, file, sourceDir));
294
+ return {
295
+ localPath: file,
296
+ growiPath: growiPath.startsWith("/") ? growiPath : `/${growiPath}`,
297
+ content,
298
+ attachments
299
+ };
300
+ }));
301
+ };
302
+
303
+ //#endregion
304
+ //#region src/markdown.ts
164
305
  /**
165
306
  * Replace attachment links in Markdown content with GROWI format
166
307
  *
167
- * Supports:
168
- * - Filename only: guide_attachment_file.png
169
- * - Relative path: ./guide_attachment_file.png
308
+ * Supports two detection patterns:
309
+ * 1. Naming pattern: guide_attachment_file.png, ./guide_attachment_file.png
310
+ * 2. Link pattern: ./images/photo.jpg, images/photo.jpg (as found in markdown)
170
311
  *
171
312
  * @param markdown Original Markdown content
172
313
  * @param attachments List of attachments with their IDs
@@ -178,10 +319,20 @@ const replaceAttachmentLinks = (markdown, attachments, pageName) => {
178
319
  let replaced = false;
179
320
  for (const attachment of attachments) {
180
321
  if (!attachment.attachmentId) continue;
181
- const localFileName = `${pageName}_attachment_${attachment.fileName}`;
182
322
  const growiPath = `/attachment/${attachment.attachmentId}`;
183
- const escapedFileName = localFileName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
184
- const patterns = [escapedFileName, `\\./${escapedFileName}`];
323
+ const patterns = [];
324
+ if (attachment.detectionPattern === "naming") {
325
+ const escapedFileName = `${pageName}_attachment_${attachment.fileName}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
326
+ patterns.push(escapedFileName, `\\./${escapedFileName}`);
327
+ }
328
+ if (attachment.detectionPattern === "link" && attachment.originalLinkPaths) for (const linkPath of attachment.originalLinkPaths) {
329
+ const escapedPath = linkPath.replace(/[.*+?^${}()|[\]\\<>]/g, "\\$&");
330
+ patterns.push(escapedPath);
331
+ if (linkPath.startsWith("./")) {
332
+ const escapedWithoutDot = linkPath.substring(2).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
333
+ patterns.push(escapedWithoutDot);
334
+ } else if (!linkPath.startsWith("../")) patterns.push(`\\./${escapedPath}`);
335
+ }
185
336
  for (const pattern of patterns) {
186
337
  const imgRegex = new RegExp(`!\\[([^\\]]*)\\]\\(${pattern}\\)`, "g");
187
338
  if (imgRegex.test(result)) {
@@ -201,197 +352,158 @@ const replaceAttachmentLinks = (markdown, attachments, pageName) => {
201
352
  };
202
353
  };
203
354
  /**
204
- * Update page content only (for re-updating after attachment link replacement)
355
+ * Replace .md extension in page links with GROWI format
205
356
  *
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
357
+ * Converts Markdown page links to GROWI-compatible format by removing .md extension.
358
+ * External URLs (http://, https://) are excluded from replacement.
359
+ *
360
+ * Supported patterns:
361
+ * - Relative path: [text](./page.md) → [text](./page)
362
+ * - Filename only: [text](page.md) → [text](page)
363
+ * - With anchor: [text](./page.md#section) → [text](./page#section)
364
+ *
365
+ * @param markdown Original Markdown content
366
+ * @returns Object with replaced content and whether any replacement occurred
211
367
  */
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
368
+ const replaceMarkdownExtension = (markdown) => {
369
+ const result = markdown.replace(/(\[[^\]]*\]\((?!https?:\/\/)[^)]*?)\.md((?:#[^)]*)?\))/g, "$1$2");
370
+ return {
371
+ content: result,
372
+ replaced: result !== markdown
373
+ };
329
374
  };
330
375
 
331
376
  //#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) {
377
+ //#region src/uploader.ts
378
+ /**
379
+ * Upload Markdown files and their attachments to GROWI
380
+ *
381
+ * This function orchestrates a 4-stage upload process:
382
+ * 1. Create or update page with original Markdown content
383
+ * 2. Upload attachments
384
+ * 3. Replace attachment links in Markdown and update page
385
+ * 4. Replace .md extension in page links and update page
386
+ *
387
+ * @param files List of Markdown files to upload
388
+ * @param sourceDir Source directory containing files
389
+ * @param config Configuration (only update flag is used)
390
+ * @returns Upload statistics
391
+ */
392
+ const uploadFiles = async (files, sourceDir, config) => {
393
+ const stats = {
394
+ pagesCreated: 0,
395
+ pagesUpdated: 0,
396
+ pagesSkipped: 0,
397
+ pageErrors: 0,
398
+ attachmentsUploaded: 0,
399
+ attachmentsSkipped: 0,
400
+ attachmentErrors: 0,
401
+ linkReplacementErrors: 0
402
+ };
403
+ for (const file of files) {
404
+ const result = await createOrUpdatePage(file, config.update);
405
+ if (result.action === "created") {
406
+ stats.pagesCreated++;
407
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (created)`);
408
+ } else if (result.action === "updated") {
409
+ stats.pagesUpdated++;
410
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (updated)`);
411
+ } else if (result.action === "skipped") {
412
+ stats.pagesSkipped++;
413
+ console.log(`[SKIP] ${file.localPath} → ${file.growiPath} (page already exists)`);
414
+ } else if (result.action === "error") {
415
+ stats.pageErrors++;
416
+ console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (${result.errorMessage || "unknown error"})`);
417
+ }
418
+ if (result.pageId && (result.action === "created" || result.action === "updated")) {
419
+ let currentContent = file.content;
420
+ let currentRevisionId = result.revisionId;
421
+ if (file.attachments.length > 0) {
356
422
  let hasAttachments = false;
357
423
  let latestRevisionId = result.revisionId;
358
424
  for (const attachment of file.attachments) {
359
- const attachmentResult = await uploadAttachment(attachment, result.pageId, file.growiPath, sourceDirPath);
425
+ const attachmentResult = await uploadAttachment(attachment, result.pageId, sourceDir);
360
426
  if (attachmentResult.success) {
361
- attachmentsUploaded++;
427
+ stats.attachmentsUploaded++;
428
+ console.log(`[SUCCESS] ${attachment.localPath} → ${file.growiPath} (attachment)`);
362
429
  if (attachmentResult.attachmentId) {
363
430
  attachment.attachmentId = attachmentResult.attachmentId;
364
431
  hasAttachments = true;
365
432
  }
366
433
  if (attachmentResult.revisionId) latestRevisionId = attachmentResult.revisionId;
367
- } else attachmentErrors++;
434
+ } else {
435
+ stats.attachmentErrors++;
436
+ console.error(`[ERROR] ${attachment.localPath} → ${file.growiPath} (${attachmentResult.errorMessage || "failed to upload attachment"})`);
437
+ }
368
438
  }
369
439
  if (hasAttachments && latestRevisionId) {
440
+ currentRevisionId = latestRevisionId;
370
441
  const pageName = basename(file.localPath, ".md");
371
- const { content: replacedContent, replaced } = replaceAttachmentLinks(file.content, file.attachments, pageName);
442
+ const { content: replacedContent, replaced } = replaceAttachmentLinks(currentContent, file.attachments, pageName);
372
443
  if (replaced) {
373
- if (await updatePageContent(result.pageId, latestRevisionId, replacedContent, file.growiPath)) console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (attachment links replaced)`);
444
+ const updateResult = await updatePageContent(result.pageId, currentRevisionId, replacedContent);
445
+ if (updateResult.success && updateResult.revisionId) {
446
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (attachment links replaced)`);
447
+ currentContent = replacedContent;
448
+ currentRevisionId = updateResult.revisionId;
449
+ } else {
450
+ console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update attachment links: ${updateResult.errorMessage || "unknown error"})`);
451
+ stats.linkReplacementErrors++;
452
+ }
453
+ }
454
+ }
455
+ }
456
+ if (currentRevisionId) {
457
+ const { content: linkedContent, replaced: linkReplaced } = replaceMarkdownExtension(currentContent);
458
+ if (linkReplaced) {
459
+ const updateResult = await updatePageContent(result.pageId, currentRevisionId, linkedContent);
460
+ if (updateResult.success && updateResult.revisionId) {
461
+ console.log(`[SUCCESS] ${file.localPath} → ${file.growiPath} (page links replaced)`);
462
+ currentRevisionId = updateResult.revisionId;
463
+ } else {
464
+ console.error(`[ERROR] ${file.localPath} → ${file.growiPath} (failed to update page links: ${updateResult.errorMessage || "unknown error"})`);
465
+ stats.linkReplacementErrors++;
374
466
  }
375
467
  }
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
468
  }
469
+ } else if (file.attachments.length > 0) for (const attachment of file.attachments) {
470
+ console.log(`[SKIP] ${attachment.localPath} → ${file.growiPath} (attachment skipped)`);
471
+ stats.attachmentsSkipped++;
380
472
  }
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}`);
473
+ }
474
+ return stats;
475
+ };
476
+
477
+ //#endregion
478
+ //#region src/index.ts
479
+ const program = new Command();
480
+ const main = async (sourceDir, configPath) => {
481
+ const config = loadConfig(configPath);
482
+ const sourceDirPath = resolve(sourceDir);
483
+ configureAxios(config.url, config.token);
484
+ const files = await scanMarkdownFiles(sourceDirPath, config.basePath);
485
+ const totalAttachments = files.reduce((sum, file) => sum + file.attachments.length, 0);
486
+ console.log(`Found ${files.length} Markdown file(s) and ${totalAttachments} attachment(s)\n`);
487
+ const stats = await uploadFiles(files, sourceDirPath, config);
488
+ console.log("\nCompleted:");
489
+ console.log(`- Pages created: ${stats.pagesCreated}`);
490
+ console.log(`- Pages updated: ${stats.pagesUpdated}`);
491
+ console.log(`- Pages skipped: ${stats.pagesSkipped}`);
492
+ console.log(`- Page errors: ${stats.pageErrors}`);
493
+ console.log(`- Attachments uploaded: ${stats.attachmentsUploaded}`);
494
+ console.log(`- Attachments skipped: ${stats.attachmentsSkipped}`);
495
+ console.log(`- Attachment errors: ${stats.attachmentErrors}`);
496
+ console.log(`- Link replacement errors: ${stats.linkReplacementErrors}`);
497
+ };
498
+ 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) => {
499
+ try {
500
+ await main(sourceDir, options.config);
389
501
  } catch (error) {
390
502
  console.error("Error:", error instanceof Error ? error.message : error);
391
503
  process.exit(1);
392
504
  }
393
505
  });
394
- program.parse();
506
+ if (process.env.NODE_ENV !== "test" && !process.env.VITEST) program.parse();
395
507
 
396
508
  //#endregion
397
- export { };
509
+ 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.1.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",