11#!/usr/bin/env node
22/**
3- * 将 GitHub Discussions 标题补上 [ docId: <id>] ,用于从 pathname->docId 的 Giscus 迁移。
3+ * 将 GitHub Discussions 标题统一重写为 docId,用于从 pathname->docId 的 Giscus 迁移。
44 *
55 * 两种输入来源:
66 * A) DB 模式(推荐):读取 Postgres(docs/path_current + doc_paths)获得每个 docId 的所有历史路径
2121 * # 用映射文件(不连 DB)
2222 * node scripts/migrate-giscus-add-docid.mjs --map=tmp/discussion-map.json --apply=true
2323 *
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+ *
2430 * 映射文件格式(示例):
2531 * {
2632 * "i0xmpsk...xls": ["app/docs/foo/bar.mdx", "/docs/foo/bar"],
@@ -52,6 +58,17 @@ const REPO =
5258const MAP = getArg ( "map" ) || process . env . GISCUS_DISCUSSION_MAP || "" ; // JSON 文件(映射文件模式)
5359const APPLY = ( getArg ( "apply" ) || "false" ) . toLowerCase ( ) === "true" ; // 是否真的更新标题
5460
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+
5572if ( ! GH_TOKEN ) {
5673 console . error ( "[migrate-giscus] Missing GH_TOKEN/GITHUB_TOKEN." ) ;
5774 process . exit ( 1 ) ;
@@ -62,6 +79,21 @@ function getArg(k) {
6279 return arg ? arg . split ( "=" ) [ 1 ] : null ;
6380}
6481
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+
6597const GQL = "https://api.github.com/graphql" ;
6698const ghHeaders = {
6799 "Content-Type" : "application/json" ,
@@ -126,21 +158,21 @@ async function loadDocIdTerms() {
126158 select : {
127159 id : true ,
128160 path_current : true ,
161+ title : true ,
129162 doc_paths : { select : { path : true } } ,
130163 } ,
131164 } ) ;
132- const map = new Map ( ) ; // docId -> Set<term>
165+ const map = new Map ( ) ; // docId -> { title: string|null, terms: Set }
133166 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 ) ;
144176 }
145177 return map ;
146178 }
@@ -151,17 +183,26 @@ async function loadDocIdTerms() {
151183 const raw = await fs . readFile ( abs , "utf8" ) ;
152184 const obj = JSON . parse ( raw ) ;
153185 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 ) ) ;
162200 }
163- } ) ;
164- map . set ( docId , set ) ;
201+ } else if ( typeof rawValue === "string" ) {
202+ registerPathVariants ( entry . terms , rawValue ) ;
203+ }
204+
205+ map . set ( docId , entry ) ;
165206 }
166207 return map ;
167208 }
@@ -183,18 +224,90 @@ async function searchDiscussionByTerm(term) {
183224 ) ;
184225}
185226
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 ;
190231}
191232
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+ }
198311}
199312
200313async function main ( ) {
@@ -203,13 +316,20 @@ async function main() {
203316 ) ;
204317 const docIdToTerms = await loadDocIdTerms ( ) ;
205318
319+ applyFilters ( docIdToTerms ) ;
320+
321+ if ( docIdToTerms . size === 0 ) {
322+ if ( prisma ) await prisma . $disconnect ( ) ;
323+ return ;
324+ }
325+
206326 let updated = 0 ,
207327 skipped = 0 ,
208328 notFound = 0 ,
209329 examined = 0 ;
210330
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 ?? [ ] ) ;
213333 let matched = null ;
214334
215335 // 尝试每个 term,直到命中一个讨论
@@ -235,13 +355,13 @@ async function main() {
235355 examined += 1 ;
236356
237357 const oldTitle = matched . title ;
238- if ( alreadyHasDocIdTag ( oldTitle , docId ) ) {
358+ if ( titleAlreadyNormalized ( oldTitle , docId ) ) {
239359 skipped += 1 ;
240360 log ( `⏭ #${ matched . number } 已包含 docId:${ matched . url } ` ) ;
241361 continue ;
242362 }
243363
244- const newTitle = appendDocIdTag ( oldTitle , docId ) ;
364+ const newTitle = normalizeTitleToDocId ( oldTitle , docId ) ;
245365 log (
246366 `${ APPLY ? "✏️ 更新" : "👀 预览" } #${ matched . number } "${ oldTitle } " → "${ newTitle } "` ,
247367 ) ;
0 commit comments