11#!/usr/bin/env node
22
3+ import { createHash } from "node:crypto" ;
34import { readFile , readdir } from "node:fs/promises" ;
45import { dirname , join , resolve } from "node:path" ;
56import { marked } from "marked" ;
@@ -42,6 +43,19 @@ const STORIES_SECTION = `<h2>Stories</h2>
4243
4344const CHILDREN_MACRO = `\n<ac:structured-macro ac:name="children" />` ;
4445
46+ // Bump this to force republish of all pages (e.g. after a connector change
47+ // that alters output but not source content — label changes, macro format, etc.)
48+ const CONNECTOR_VERSION = "1" ;
49+
50+ const HASH_PROPERTY_KEY = "aidos-content-hash" ;
51+
52+ function contentHash ( body ) {
53+ return createHash ( "sha256" )
54+ . update ( CONNECTOR_VERSION + body )
55+ . digest ( "hex" )
56+ . slice ( 0 , 16 ) ;
57+ }
58+
4559// Scale labels: epic (root files), feature (folder pages), story (files inside features)
4660// Passed explicitly through the recursion — not derived from depth.
4761
@@ -177,6 +191,65 @@ async function addLabels(baseUrl, pageId, labels) {
177191 ) ;
178192}
179193
194+ /** Read the stored content hash from a page property. Returns null if not set. */
195+ async function getStoredHash ( baseUrl , pageId ) {
196+ try {
197+ const data = await confluenceFetch (
198+ `${ baseUrl } /wiki/rest/api/content/${ pageId } /property/${ HASH_PROPERTY_KEY } ` ,
199+ { } ,
200+ `getHash ${ pageId } ` ,
201+ ) ;
202+ return data . value ?. hash ?? null ;
203+ } catch {
204+ return null ;
205+ }
206+ }
207+
208+ /** Write the content hash as a page property. Creates or updates. */
209+ async function setStoredHash ( baseUrl , pageId , hash ) {
210+ // Try to read existing property for its version number
211+ let version = null ;
212+ try {
213+ const data = await confluenceFetch (
214+ `${ baseUrl } /wiki/rest/api/content/${ pageId } /property/${ HASH_PROPERTY_KEY } ` ,
215+ { } ,
216+ `getHash ${ pageId } ` ,
217+ ) ;
218+ version = data . version ?. number ;
219+ } catch {
220+ // Property doesn't exist yet
221+ }
222+
223+ if ( version ) {
224+ // Update existing property
225+ await confluenceFetch (
226+ `${ baseUrl } /wiki/rest/api/content/${ pageId } /property/${ HASH_PROPERTY_KEY } ` ,
227+ {
228+ method : "PUT" ,
229+ body : JSON . stringify ( {
230+ key : HASH_PROPERTY_KEY ,
231+ value : { hash } ,
232+ version : { number : version + 1 } ,
233+ } ) ,
234+ } ,
235+ `setHash ${ pageId } ` ,
236+ ) ;
237+ } else {
238+ // Create new property
239+ await confluenceFetch (
240+ `${ baseUrl } /wiki/rest/api/content/${ pageId } /property` ,
241+ {
242+ method : "POST" ,
243+ body : JSON . stringify ( {
244+ key : HASH_PROPERTY_KEY ,
245+ value : { hash } ,
246+ } ) ,
247+ } ,
248+ `setHash ${ pageId } ` ,
249+ ) ;
250+ }
251+ }
252+
180253// ---------------------------------------------------------------------------
181254// Markdown parsing
182255// ---------------------------------------------------------------------------
@@ -357,6 +430,7 @@ function buildFeaturePageBody(markdown, meta) {
357430
358431async function publishPage ( ctx , parentId , childPages , title , body , labels ) {
359432 const { baseUrl, spaceKey, dryRun } = ctx ;
433+ const hash = contentHash ( body ) ;
360434
361435 if ( dryRun ) {
362436 const existing = childPages . get ( title ) ;
@@ -371,9 +445,18 @@ async function publishPage(ctx, parentId, childPages, title, body, labels) {
371445 }
372446
373447 if ( pageId ) {
448+ // Check if content has changed via stored hash
449+ const storedHash = await getStoredHash ( baseUrl , pageId ) ;
450+ if ( storedHash === hash ) {
451+ console . log ( " Unchanged: %s (page %s)" , title , pageId ) ;
452+ ctx . stats . unchanged ++ ;
453+ return pageId ;
454+ }
455+
374456 const page = await getPage ( baseUrl , pageId ) ;
375457 await updatePage ( baseUrl , pageId , title , body , page . version ) ;
376458 await addLabels ( baseUrl , pageId , labels ) ;
459+ await setStoredHash ( baseUrl , pageId , hash ) ;
377460 console . log ( " Updated: %s (page %s, v%d → v%d)" , title , pageId , page . version , page . version + 1 ) ;
378461 ctx . stats . updated ++ ;
379462 return pageId ;
@@ -384,6 +467,7 @@ async function publishPage(ctx, parentId, childPages, title, body, labels) {
384467 const created = await createPage ( baseUrl , spaceKey , parentId , title , "<p></p>" ) ;
385468 await updatePage ( baseUrl , created . id , title , body , 1 ) ;
386469 await addLabels ( baseUrl , created . id , labels ) ;
470+ await setStoredHash ( baseUrl , created . id , hash ) ;
387471 console . log ( " Created: %s (page %s)" , title , created . id ) ;
388472 ctx . stats . created ++ ;
389473 return created . id ;
@@ -605,7 +689,7 @@ async function main() {
605689 spaceKey : null ,
606690 rootPageId,
607691 dryRun,
608- stats : { created : 0 , updated : 0 } ,
692+ stats : { created : 0 , updated : 0 , unchanged : 0 } ,
609693 } ;
610694
611695 // Ensure dashboard and derive space key
@@ -615,9 +699,10 @@ async function main() {
615699 await publishDirectory ( ctx , rootPageId , aidosDir , 0 , null , "epic" ) ;
616700
617701 console . log (
618- "\nDone — created: %d, updated: %d" ,
702+ "\nDone — created: %d, updated: %d, unchanged: %d " ,
619703 ctx . stats . created ,
620704 ctx . stats . updated ,
705+ ctx . stats . unchanged ,
621706 ) ;
622707}
623708
0 commit comments