1
1
#!/usr/bin/env node
2
2
/**
3
- * 将 GitHub Discussions 标题补上 [ docId: <id>] ,用于从 pathname->docId 的 Giscus 迁移。
3
+ * 将 GitHub Discussions 标题统一重写为 docId,用于从 pathname->docId 的 Giscus 迁移。
4
4
*
5
5
* 两种输入来源:
6
6
* A) DB 模式(推荐):读取 Postgres(docs/path_current + doc_paths)获得每个 docId 的所有历史路径
21
21
* # 用映射文件(不连 DB)
22
22
* node scripts/migrate-giscus-add-docid.mjs --map=tmp/discussion-map.json --apply=true
23
23
*
24
+ * # 仅处理部分 doc,支持多次传参或逗号/换行分隔
25
+ * node scripts/migrate-giscus-add-docid.mjs --doc=abcd123 --doc=efg456 --apply=true
26
+ * node scripts/migrate-giscus-add-docid.mjs --doc-path=app/docs/foo/bar.mdx --doc-path=docs/foo/bar --apply=true
27
+ * node scripts/migrate-giscus-add-docid.mjs --doc-paths="app/docs/foo/bar.mdx,docs/foo/bar" --apply=true
28
+ * GISCUS_DOC_PATHS="app/docs/foo/bar.mdx\napp/docs/baz.mdx" node scripts/migrate-giscus-add-docid.mjs --apply=true
29
+ *
24
30
* 映射文件格式(示例):
25
31
* {
26
32
* "i0xmpsk...xls": ["app/docs/foo/bar.mdx", "/docs/foo/bar"],
@@ -52,6 +58,17 @@ const REPO =
52
58
const MAP = getArg ( "map" ) || process . env . GISCUS_DISCUSSION_MAP || "" ; // JSON 文件(映射文件模式)
53
59
const APPLY = ( getArg ( "apply" ) || "false" ) . toLowerCase ( ) === "true" ; // 是否真的更新标题
54
60
61
+ const DOC_FILTERS = getArgList ( "doc" ) ;
62
+ const DOC_PATH_FILTERS = [
63
+ ...getArgList ( "doc-path" ) ,
64
+ ...getArgList ( "doc-paths" ) ,
65
+ ...( process . env . GISCUS_DOC_PATHS
66
+ ? process . env . GISCUS_DOC_PATHS . split ( / [ , \n ] / )
67
+ . map ( ( v ) => v . trim ( ) )
68
+ . filter ( Boolean )
69
+ : [ ] ) ,
70
+ ] ;
71
+
55
72
if ( ! GH_TOKEN ) {
56
73
console . error ( "[migrate-giscus] Missing GH_TOKEN/GITHUB_TOKEN." ) ;
57
74
process . exit ( 1 ) ;
@@ -62,6 +79,21 @@ function getArg(k) {
62
79
return arg ? arg . split ( "=" ) [ 1 ] : null ;
63
80
}
64
81
82
+ function getArgList ( k ) {
83
+ const matches = process . argv
84
+ . slice ( 2 )
85
+ . filter ( ( s ) => s . startsWith ( `--${ k } =` ) )
86
+ . map ( ( s ) => s . split ( "=" ) [ 1 ] ) ;
87
+ if ( matches . length === 0 ) {
88
+ const single = getArg ( k ) ;
89
+ if ( single ) matches . push ( single ) ;
90
+ }
91
+ return matches
92
+ . flatMap ( ( value ) => ( value ?? "" ) . split ( / [ , \n ] / ) )
93
+ . map ( ( value ) => value . trim ( ) )
94
+ . filter ( Boolean ) ;
95
+ }
96
+
65
97
const GQL = "https://api.github.com/graphql" ;
66
98
const ghHeaders = {
67
99
"Content-Type" : "application/json" ,
@@ -126,21 +158,21 @@ async function loadDocIdTerms() {
126
158
select : {
127
159
id : true ,
128
160
path_current : true ,
161
+ title : true ,
129
162
doc_paths : { select : { path : true } } ,
130
163
} ,
131
164
} ) ;
132
- const map = new Map ( ) ; // docId -> Set<term>
165
+ const map = new Map ( ) ; // docId -> { title: string|null, terms: Set }
133
166
for ( const d of docs ) {
134
- const set = map . get ( d . id ) ?? new Set ( ) ;
135
- if ( d . path_current ) set . add ( d . path_current ) ;
136
- for ( const p of d . doc_paths ) if ( p ?. path ) set . add ( p . path ) ;
137
- // 兼容站点实际的 pathname(可选添加去掉扩展名、加前缀)
138
- for ( const p of Array . from ( set ) ) {
139
- const noExt = p . replace ( / \. ( m d | m d x | m a r k d o w n ) $ / i, "" ) ;
140
- set . add ( noExt ) ;
141
- set . add ( `/${ noExt } ` ) ; // 常见 pathname 形态
142
- }
143
- map . set ( d . id , set ) ;
167
+ const entry = map . get ( d . id ) ?? {
168
+ title : d . title ?? null ,
169
+ terms : new Set ( ) ,
170
+ } ;
171
+ if ( ! entry . title && d . title ) entry . title = d . title ;
172
+ if ( d . path_current ) registerPathVariants ( entry . terms , d . path_current ) ;
173
+ for ( const p of d . doc_paths )
174
+ if ( p ?. path ) registerPathVariants ( entry . terms , p . path ) ;
175
+ map . set ( d . id , entry ) ;
144
176
}
145
177
return map ;
146
178
}
@@ -151,17 +183,26 @@ async function loadDocIdTerms() {
151
183
const raw = await fs . readFile ( abs , "utf8" ) ;
152
184
const obj = JSON . parse ( raw ) ;
153
185
const map = new Map ( ) ;
154
- for ( const [ docId , arr ] of Object . entries ( obj ) ) {
155
- const set = new Set ( ) ;
156
- ( arr || [ ] ) . forEach ( ( t ) => {
157
- if ( typeof t === "string" && t . trim ( ) ) {
158
- set . add ( t . trim ( ) ) ;
159
- const noExt = t . replace ( / \. ( m d | m d x | m a r k d o w n ) $ / i, "" ) ;
160
- set . add ( noExt ) ;
161
- set . add ( `/${ noExt } ` ) ;
186
+ for ( const [ docId , rawValue ] of Object . entries ( obj ) ) {
187
+ const entry = { title : null , terms : new Set ( ) } ;
188
+
189
+ if ( Array . isArray ( rawValue ) ) {
190
+ rawValue . forEach ( ( t ) => registerPathVariants ( entry . terms , t ) ) ;
191
+ } else if ( rawValue && typeof rawValue === "object" ) {
192
+ if ( typeof rawValue . title === "string" && rawValue . title . trim ( ) ) {
193
+ entry . title = rawValue . title . trim ( ) ;
194
+ }
195
+ const termsSource = Array . isArray ( rawValue . terms )
196
+ ? rawValue . terms
197
+ : rawValue . paths ;
198
+ if ( Array . isArray ( termsSource ) ) {
199
+ termsSource . forEach ( ( t ) => registerPathVariants ( entry . terms , t ) ) ;
162
200
}
163
- } ) ;
164
- map . set ( docId , set ) ;
201
+ } else if ( typeof rawValue === "string" ) {
202
+ registerPathVariants ( entry . terms , rawValue ) ;
203
+ }
204
+
205
+ map . set ( docId , entry ) ;
165
206
}
166
207
return map ;
167
208
}
@@ -183,18 +224,90 @@ async function searchDiscussionByTerm(term) {
183
224
) ;
184
225
}
185
226
186
- // 如果标题中已经包含 [ docId: xxx],就跳过
187
- function alreadyHasDocIdTag ( title , docId ) {
188
- const tag = `[docId: ${ docId } ]` ;
189
- return title . includes ( tag ) ;
227
+ function titleAlreadyNormalized ( title , docId ) {
228
+ const normalized = docId . trim ( ) ;
229
+ if ( ! normalized ) return false ;
230
+ return title . trim ( ) === normalized ;
190
231
}
191
232
192
- // 生成新标题(在末尾追加,如已含则不变)
193
- function appendDocIdTag ( title , docId ) {
194
- const tag = `[docId:${ docId } ]` ;
195
- if ( title . includes ( tag ) ) return title ;
196
- // 避免标题太挤,加个空格
197
- return `${ title . trim ( ) } ${ tag } ` ;
233
+ function normalizeTitleToDocId ( currentTitle , docId ) {
234
+ const normalized = docId . trim ( ) ;
235
+ if ( ! normalized ) return currentTitle . trim ( ) ;
236
+ return normalized ;
237
+ }
238
+
239
+ function registerPathVariants ( targetSet , rawPath ) {
240
+ if ( typeof rawPath !== "string" ) return ;
241
+ const trimmed = rawPath . trim ( ) ;
242
+ if ( ! trimmed ) return ;
243
+
244
+ const variants = new Set ( ) ;
245
+ const candidates = [ trimmed ] ;
246
+
247
+ const withoutExt = trimmed . replace ( / \. ( m d | m d x | m a r k d o w n ) $ / i, "" ) ;
248
+ candidates . push ( withoutExt ) ;
249
+
250
+ const leadingSlash = trimmed . startsWith ( "/" ) ? trimmed : `/${ trimmed } ` ;
251
+ candidates . push ( leadingSlash ) ;
252
+
253
+ const withoutExtLeadingSlash = withoutExt . startsWith ( "/" )
254
+ ? withoutExt
255
+ : `/${ withoutExt } ` ;
256
+ candidates . push ( withoutExtLeadingSlash ) ;
257
+
258
+ const withoutApp = trimmed . replace ( / ^ a p p \/ / i, "" ) ;
259
+ if ( withoutApp && withoutApp !== trimmed ) {
260
+ candidates . push ( withoutApp ) ;
261
+ const withoutAppNoExt = withoutApp . replace ( / \. ( m d | m d x | m a r k d o w n ) $ / i, "" ) ;
262
+ candidates . push ( withoutAppNoExt ) ;
263
+ candidates . push ( withoutApp . startsWith ( "/" ) ? withoutApp : `/${ withoutApp } ` ) ;
264
+ candidates . push (
265
+ withoutAppNoExt . startsWith ( "/" ) ? withoutAppNoExt : `/${ withoutAppNoExt } ` ,
266
+ ) ;
267
+ }
268
+
269
+ for ( const candidate of candidates ) {
270
+ const value = typeof candidate === "string" ? candidate . trim ( ) : "" ;
271
+ if ( value ) variants . add ( value ) ;
272
+ }
273
+
274
+ for ( const value of variants ) targetSet . add ( value ) ;
275
+ }
276
+
277
+ function applyFilters ( docIdMap ) {
278
+ const docIdFilterSet = new Set ( DOC_FILTERS ) ;
279
+ const hasDocIdFilter = docIdFilterSet . size > 0 ;
280
+
281
+ const pathFilterVariants = new Set ( ) ;
282
+ for ( const path of DOC_PATH_FILTERS ) {
283
+ registerPathVariants ( pathFilterVariants , path ) ;
284
+ }
285
+ const hasPathFilter = pathFilterVariants . size > 0 ;
286
+
287
+ if ( ! hasDocIdFilter && ! hasPathFilter ) {
288
+ return ;
289
+ }
290
+
291
+ for ( const [ docId , info ] of Array . from ( docIdMap . entries ( ) ) ) {
292
+ let keep = true ;
293
+
294
+ if ( keep && hasDocIdFilter ) {
295
+ keep = docIdFilterSet . has ( docId ) ;
296
+ }
297
+
298
+ if ( keep && hasPathFilter ) {
299
+ const terms = Array . from ( info ?. terms ?? [ ] ) ;
300
+ keep = terms . some ( ( term ) => pathFilterVariants . has ( term ) ) ;
301
+ }
302
+
303
+ if ( ! keep ) {
304
+ docIdMap . delete ( docId ) ;
305
+ }
306
+ }
307
+
308
+ if ( docIdMap . size === 0 ) {
309
+ log ( "⚠️ 未找到符合过滤条件的 docId,本次执行不会更新任何讨论。" ) ;
310
+ }
198
311
}
199
312
200
313
async function main ( ) {
@@ -203,13 +316,20 @@ async function main() {
203
316
) ;
204
317
const docIdToTerms = await loadDocIdTerms ( ) ;
205
318
319
+ applyFilters ( docIdToTerms ) ;
320
+
321
+ if ( docIdToTerms . size === 0 ) {
322
+ if ( prisma ) await prisma . $disconnect ( ) ;
323
+ return ;
324
+ }
325
+
206
326
let updated = 0 ,
207
327
skipped = 0 ,
208
328
notFound = 0 ,
209
329
examined = 0 ;
210
330
211
- for ( const [ docId , termsSet ] of docIdToTerms ) {
212
- const terms = Array . from ( termsSet ) ;
331
+ for ( const [ docId , info ] of docIdToTerms ) {
332
+ const terms = Array . from ( info ?. terms ?? [ ] ) ;
213
333
let matched = null ;
214
334
215
335
// 尝试每个 term,直到命中一个讨论
@@ -235,13 +355,13 @@ async function main() {
235
355
examined += 1 ;
236
356
237
357
const oldTitle = matched . title ;
238
- if ( alreadyHasDocIdTag ( oldTitle , docId ) ) {
358
+ if ( titleAlreadyNormalized ( oldTitle , docId ) ) {
239
359
skipped += 1 ;
240
360
log ( `⏭ #${ matched . number } 已包含 docId:${ matched . url } ` ) ;
241
361
continue ;
242
362
}
243
363
244
- const newTitle = appendDocIdTag ( oldTitle , docId ) ;
364
+ const newTitle = normalizeTitleToDocId ( oldTitle , docId ) ;
245
365
log (
246
366
`${ APPLY ? "✏️ 更新" : "👀 预览" } #${ matched . number } "${ oldTitle } " → "${ newTitle } "` ,
247
367
) ;
0 commit comments