@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.
- package/README.md +51 -16
- package/dist/index.mjs +387 -250
- 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
|
-
|
|
140
|
+
Attachments are automatically detected using two methods:
|
|
141
141
|
|
|
142
|
-
|
|
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
|
-
###
|
|
157
|
+
### Method 2: Link-Based Detection
|
|
158
|
+
|
|
159
|
+
Files referenced in markdown links are automatically detected as attachments:
|
|
156
160
|
|
|
157
|
-
|
|
161
|
+
**Local Directory:**
|
|
162
|
+
```
|
|
163
|
+
guide.md
|
|
164
|
+
assets/
|
|
165
|
+
banner.png
|
|
166
|
+
images/
|
|
167
|
+
logo.png
|
|
168
|
+
screenshot.png
|
|
169
|
+
```
|
|
158
170
|
|
|
159
|
-
**
|
|
171
|
+
**guide.md Content:**
|
|
160
172
|
```markdown
|
|
161
|
-
|
|
173
|
+

|
|
174
|
+

|
|
175
|
+

|
|
176
|
+
```
|
|
162
177
|
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
197
|
+
# Before upload
|
|
198
|
+

|
|
199
|
+
Download the [documentation](guide_attachment_document.pdf).
|
|
171
200
|
|
|
201
|
+
# After upload (on GROWI)
|
|
172
202
|

|
|
173
|
-
|
|
174
203
|
Download the [documentation](/attachment/68f3a3fa794f665ad2c0d2b3).
|
|
175
204
|
```
|
|
176
205
|
|
|
177
|
-
|
|
206
|
+
**Example (Link-Based):**
|
|
178
207
|
|
|
179
|
-
|
|
208
|
+
```markdown
|
|
209
|
+
# Before upload
|
|
210
|
+

|
|
211
|
+
|
|
212
|
+
# After upload (on GROWI)
|
|
213
|
+

|
|
214
|
+
```
|
|
180
215
|
|
|
181
|
-
|
|
182
|
-
2. **Relative path**: ``
|
|
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
|
|
16
|
-
if (!
|
|
17
|
-
if (!
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
117
|
-
|
|
118
|
+
revisionId,
|
|
119
|
+
action: "skipped"
|
|
120
|
+
};
|
|
118
121
|
return {
|
|
119
122
|
pageId,
|
|
120
|
-
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
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
|
-
*
|
|
169
|
-
*
|
|
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
|
|
184
|
-
|
|
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
|
-
*
|
|
373
|
+
* Replace .md extension in page links with GROWI format
|
|
205
374
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
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/
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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,
|
|
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
|
|
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(
|
|
467
|
+
const { content: replacedContent, replaced } = replaceAttachmentLinks(currentContent, file.attachments, pageName);
|
|
372
468
|
if (replaced) {
|
|
373
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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.
|
|
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.
|
|
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",
|