@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.
- package/README.md +43 -16
- package/dist/index.mjs +361 -249
- 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,58 @@ 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
|
+
images/
|
|
165
|
+
logo.png
|
|
166
|
+
screenshot.png
|
|
167
|
+
```
|
|
158
168
|
|
|
159
|
-
**
|
|
169
|
+
**guide.md Content:**
|
|
160
170
|
```markdown
|
|
161
|
-
|
|
171
|
+

|
|
172
|
+

|
|
173
|
+
```
|
|
162
174
|
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
189
|
+
# Before upload
|
|
190
|
+

|
|
191
|
+
Download the [documentation](guide_attachment_document.pdf).
|
|
171
192
|
|
|
193
|
+
# After upload (on GROWI)
|
|
172
194
|

|
|
173
|
-
|
|
174
195
|
Download the [documentation](/attachment/68f3a3fa794f665ad2c0d2b3).
|
|
175
196
|
```
|
|
176
197
|
|
|
177
|
-
|
|
198
|
+
**Example (Link-Based):**
|
|
199
|
+
|
|
200
|
+
```markdown
|
|
201
|
+
# Before upload
|
|
202
|
+

|
|
178
203
|
|
|
179
|
-
|
|
204
|
+
# After upload (on GROWI)
|
|
205
|
+

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