11const NOTES_DIRECTORY = "docs_build/dev/admin-notes" ;
2+ const DIRECTORY_INDEX_PATH = `${ NOTES_DIRECTORY } /directory-index.json` ;
23const DEFAULT_NOTE = "index" ;
34const LINK_CLASS = "btn btn--compact primary" ;
45const NOTE_NAME_PATTERN = / ^ [ A - Z a - z 0 - 9 _ - ] + $ / ;
56const STATUS_ICON_PATTERN = / ^ \[ ( [ x X . ! ? ] ) \] \s * ( .* ) $ / ;
7+ const NOTE_INDEX_FILE = "index.txt" ;
8+ const STATUS_MARKERS = {
9+ " " : {
10+ icon : "⬜" ,
11+ label : "Not Started" ,
12+ name : "not-started" ,
13+ title : "Not Started"
14+ } ,
15+ "." : {
16+ icon : "🟡" ,
17+ label : "In Progress" ,
18+ name : "in-progress" ,
19+ title : "In Progress"
20+ } ,
21+ x : {
22+ icon : "✅" ,
23+ label : "Complete" ,
24+ name : "complete" ,
25+ title : "Complete"
26+ } ,
27+ "!" : {
28+ icon : "⛔" ,
29+ label : "Blocker" ,
30+ name : "blocker" ,
31+ title : "Blocker"
32+ } ,
33+ "?" : {
34+ icon : "❓" ,
35+ label : "Decide" ,
36+ name : "decide" ,
37+ title : "Decide which project questions need their own subnote files."
38+ }
39+ } ;
640
741class AdminNotesViewer {
842 constructor ( documentRef = document , historyRef = window . history ) {
943 this . documentRef = documentRef ;
1044 this . historyRef = historyRef ;
45+ this . surface = documentRef . querySelector ( "[data-admin-notes-viewer]" ) ;
1146 this . title = documentRef . querySelector ( "[data-admin-notes-title]" ) ;
1247 this . returnLink = documentRef . querySelector ( "[data-admin-notes-return]" ) ;
1348 this . status = documentRef . querySelector ( "[data-admin-notes-status]" ) ;
1449 this . content = documentRef . querySelector ( "[data-admin-notes-content]" ) ;
50+ this . directory = documentRef . querySelector ( "[data-admin-notes-directory]" ) ;
51+ this . directoryLinks = documentRef . querySelector ( "[data-admin-notes-directory-links]" ) ;
52+ this . legendList = documentRef . querySelector ( "[data-admin-notes-legend-list]" ) ;
53+ this . directoryIndex = null ;
1554 this . bindEvents ( ) ;
1655 }
1756
1857 bindEvents ( ) {
19- this . content ?. addEventListener ( "click" , ( event ) => {
58+ this . surface ?. addEventListener ( "click" , ( event ) => {
2059 const link = event . target . closest ( "[data-admin-note-link], [data-admin-note-file]" ) ;
21- if ( ! link || ! this . content . contains ( link ) ) {
60+ if ( ! link || ! this . surface . contains ( link ) ) {
2261 return ;
2362 }
2463 event . preventDefault ( ) ;
@@ -36,6 +75,7 @@ class AdminNotesViewer {
3675 }
3776
3877 start ( ) {
78+ this . renderLegend ( ) ;
3979 this . openFromLocation ( window . location , false ) ;
4080 }
4181
@@ -64,14 +104,14 @@ class AdminNotesViewer {
64104
65105 notePath ( noteName ) {
66106 return noteName === DEFAULT_NOTE
67- ? `${ NOTES_DIRECTORY } /index.txt `
68- : `${ NOTES_DIRECTORY } /${ noteName } /index.txt ` ;
107+ ? `${ NOTES_DIRECTORY } /${ NOTE_INDEX_FILE } `
108+ : `${ NOTES_DIRECTORY } /${ noteName } /${ NOTE_INDEX_FILE } ` ;
69109 }
70110
71111 async openNote ( noteName , pushHistory ) {
72112 const normalizedNoteName = this . noteNameFromValue ( noteName ) ;
73113 this . clearContent ( ) ;
74- const title = normalizedNoteName === DEFAULT_NOTE ? "index.txt" : ` ${ normalizedNoteName } /index.txt` ;
114+ const title = NOTE_INDEX_FILE ;
75115 this . setTitle ( title ) ;
76116 this . setReturnVisible ( normalizedNoteName !== DEFAULT_NOTE ) ;
77117
@@ -86,6 +126,7 @@ class AdminNotesViewer {
86126 await this . loadTextFile ( {
87127 filePath : notePath ,
88128 title,
129+ currentFilePath : notePath ,
89130 missingMessage : `Missing note file ${ notePath } . Add this file under ${ NOTES_DIRECTORY } / or return to index.txt.` ,
90131 errorMessage : `Unable to load ${ notePath } . Check the note file and reload the page.` ,
91132 pushHistory,
@@ -96,7 +137,7 @@ class AdminNotesViewer {
96137 async openFile ( filePath , pushHistory ) {
97138 const normalizedFilePath = this . rootRelativeTextPathFromValue ( filePath ) ;
98139 this . clearContent ( ) ;
99- this . setTitle ( normalizedFilePath || "linked file" ) ;
140+ this . setTitle ( normalizedFilePath ? this . fileNameForPath ( normalizedFilePath ) : "linked file" ) ;
100141 this . setReturnVisible ( true ) ;
101142
102143 if ( ! normalizedFilePath ) {
@@ -108,15 +149,16 @@ class AdminNotesViewer {
108149
109150 await this . loadTextFile ( {
110151 filePath : normalizedFilePath ,
111- title : normalizedFilePath ,
152+ title : this . fileNameForPath ( normalizedFilePath ) ,
153+ currentFilePath : normalizedFilePath ,
112154 missingMessage : `Missing linked file ${ normalizedFilePath } . Add this text file under the repository root or return to index.txt.` ,
113155 errorMessage : `Unable to load ${ normalizedFilePath } . Check the linked text file and reload the page.` ,
114156 pushHistory,
115157 pushUrl : this . hrefForFile ( normalizedFilePath )
116158 } ) ;
117159 }
118160
119- async loadTextFile ( { filePath, title, missingMessage, errorMessage, pushHistory, pushUrl } ) {
161+ async loadTextFile ( { filePath, title, currentFilePath , missingMessage, errorMessage, pushHistory, pushUrl } ) {
120162 try {
121163 const response = await fetch ( filePath , { cache : "no-store" } ) ;
122164 if ( ! response . ok ) {
@@ -126,6 +168,7 @@ class AdminNotesViewer {
126168 const text = await response . text ( ) ;
127169 this . renderNote ( text , title ) ;
128170 this . setStatus ( `Loaded ${ filePath } .` ) ;
171+ await this . renderDirectoryLinks ( this . folderPathForFile ( currentFilePath ) , currentFilePath ) ;
129172 if ( pushHistory ) {
130173 this . historyRef . pushState ( { } , "" , pushUrl ) ;
131174 }
@@ -156,8 +199,36 @@ class AdminNotesViewer {
156199 return normalizedPath ;
157200 }
158201
202+ rootRelativeFolderPathFromValue ( value ) {
203+ const rawValue = String ( value || "" ) . trim ( ) ;
204+ if ( / ^ [ A - Z a - z ] : / . test ( rawValue ) || / ^ [ A - Z a - z ] [ A - Z a - z 0 - 9 + . - ] * : / . test ( rawValue ) ) {
205+ return null ;
206+ }
207+ const normalizedPath = rawValue . replace ( / \\ / g, "/" ) . replace ( / ^ \/ + | \/ + $ / g, "" ) ;
208+ const segments = normalizedPath . split ( "/" ) ;
209+ const invalidSegments = segments . some ( ( segment ) => ! segment || segment === "." || segment === ".." ) ;
210+ if ( ! normalizedPath || invalidSegments ) {
211+ return null ;
212+ }
213+ return normalizedPath ;
214+ }
215+
216+ fileNameForPath ( filePath ) {
217+ return String ( filePath || "" ) . split ( "/" ) . pop ( ) || NOTE_INDEX_FILE ;
218+ }
219+
220+ folderPathForFile ( filePath ) {
221+ const segments = String ( filePath || "" ) . split ( "/" ) ;
222+ segments . pop ( ) ;
223+ return segments . join ( "/" ) ;
224+ }
225+
159226 clearContent ( ) {
160227 this . content ?. replaceChildren ( ) ;
228+ this . directoryLinks ?. replaceChildren ( ) ;
229+ if ( this . directory ) {
230+ this . directory . hidden = true ;
231+ }
161232 }
162233
163234 setTitle ( value ) {
@@ -187,6 +258,112 @@ class AdminNotesViewer {
187258 this . content ?. append ( errorMessage ) ;
188259 }
189260
261+ async renderDirectoryLinks ( folderPath , currentFilePath ) {
262+ const entries = await this . directoryEntriesForFolder ( folderPath ) ;
263+ const safeEntries = entries
264+ . map ( ( entry ) => this . safeDirectoryEntry ( entry ) )
265+ . filter ( Boolean )
266+ . filter ( ( entry ) => ! ( entry . path === currentFilePath && this . fileNameForPath ( entry . path ) === NOTE_INDEX_FILE ) )
267+ . sort ( ( left , right ) => left . label . localeCompare ( right . label ) ) ;
268+
269+ this . directoryLinks ?. replaceChildren ( ) ;
270+ if ( ! safeEntries . length ) {
271+ if ( this . directory ) {
272+ this . directory . hidden = true ;
273+ }
274+ return ;
275+ }
276+
277+ safeEntries . forEach ( ( entry ) => {
278+ this . directoryLinks ?. append ( this . directoryLink ( entry ) ) ;
279+ } ) ;
280+ if ( this . directory ) {
281+ this . directory . hidden = false ;
282+ }
283+ }
284+
285+ async directoryEntriesForFolder ( folderPath ) {
286+ const safeFolderPath = this . rootRelativeFolderPathFromValue ( folderPath ) ;
287+ if ( ! safeFolderPath ) {
288+ return [ ] ;
289+ }
290+ const directoryIndex = await this . loadDirectoryIndex ( ) ;
291+ const entries = directoryIndex . folders ?. [ safeFolderPath ] ;
292+ return Array . isArray ( entries ) ? entries : [ ] ;
293+ }
294+
295+ async loadDirectoryIndex ( ) {
296+ if ( this . directoryIndex ) {
297+ return this . directoryIndex ;
298+ }
299+ try {
300+ const response = await fetch ( DIRECTORY_INDEX_PATH , { cache : "no-store" } ) ;
301+ this . directoryIndex = response . ok ? await response . json ( ) : { folders : { } } ;
302+ } catch {
303+ this . directoryIndex = { folders : { } } ;
304+ }
305+ return this . directoryIndex ;
306+ }
307+
308+ safeDirectoryEntry ( entry ) {
309+ const label = String ( entry ?. label || "" ) . trim ( ) ;
310+ const path = this . rootRelativeTextPathFromValue ( entry ?. path ) ;
311+ if ( ! label || ! path ) {
312+ return null ;
313+ }
314+ if ( entry . type === "folder" ) {
315+ if ( ! path . endsWith ( `/${ NOTE_INDEX_FILE } ` ) ) {
316+ return null ;
317+ }
318+ const folderPath = this . folderPathForFile ( path ) ;
319+ const noteName = this . noteNameForDirectoryPath ( folderPath ) ;
320+ return { label, noteName, path, type : "folder" } ;
321+ }
322+ if ( entry . type === "file" ) {
323+ return { label, path, type : "file" } ;
324+ }
325+ return null ;
326+ }
327+
328+ noteNameForDirectoryPath ( folderPath ) {
329+ const prefix = `${ NOTES_DIRECTORY } /` ;
330+ if ( ! folderPath . startsWith ( prefix ) ) {
331+ return null ;
332+ }
333+ const noteName = folderPath . slice ( prefix . length ) ;
334+ return this . validNoteName ( noteName ) ? noteName : null ;
335+ }
336+
337+ directoryLink ( entry ) {
338+ const link = this . documentRef . createElement ( "a" ) ;
339+ link . className = LINK_CLASS ;
340+ link . textContent = entry . label ;
341+ if ( entry . type === "folder" && entry . noteName ) {
342+ link . href = this . hrefForNote ( entry . noteName ) ;
343+ link . dataset . adminNoteLink = entry . noteName ;
344+ } else {
345+ link . href = this . hrefForFile ( entry . path ) ;
346+ link . dataset . adminNoteFile = entry . path ;
347+ }
348+ link . dataset . adminNotesDirectoryLink = entry . type ;
349+ return link ;
350+ }
351+
352+ renderLegend ( ) {
353+ this . legendList ?. replaceChildren ( ) ;
354+ Object . values ( STATUS_MARKERS ) . forEach ( ( marker ) => {
355+ const item = this . documentRef . createElement ( "li" ) ;
356+ item . title = marker . title ;
357+ const icon = this . documentRef . createElement ( "span" ) ;
358+ icon . dataset . adminNotesLegendIcon = marker . name ;
359+ icon . title = marker . title ;
360+ icon . textContent = marker . icon ;
361+ item . append ( icon ) ;
362+ item . append ( this . documentRef . createTextNode ( ` ${ marker . label } ` ) ) ;
363+ this . legendList ?. append ( item ) ;
364+ } ) ;
365+ }
366+
190367 renderNote ( text , title ) {
191368 this . setTitle ( title ) ;
192369 const lines = text . split ( / \r ? \n / ) ;
@@ -262,27 +439,18 @@ class AdminNotesViewer {
262439 }
263440
264441 statusIcon ( rawStatus ) {
265- const icon = this . documentRef . createElement ( "strong" ) ;
266- icon . dataset . adminNotesStatusIcon = this . statusIconName ( rawStatus ) ;
267- icon . textContent = `[${ rawStatus } ]` ;
442+ const marker = this . statusMarker ( rawStatus ) ;
443+ const icon = this . documentRef . createElement ( "span" ) ;
444+ icon . dataset . adminNotesStatusIcon = marker . name ;
445+ icon . title = marker . title ;
446+ icon . setAttribute ( "aria-label" , marker . label ) ;
447+ icon . textContent = marker . icon ;
268448 return icon ;
269449 }
270450
271- statusIconName ( rawStatus ) {
451+ statusMarker ( rawStatus ) {
272452 const normalizedStatus = rawStatus . toLowerCase ( ) ;
273- if ( normalizedStatus === "x" ) {
274- return "done" ;
275- }
276- if ( normalizedStatus === "." ) {
277- return "active" ;
278- }
279- if ( normalizedStatus === "!" ) {
280- return "blocked" ;
281- }
282- if ( normalizedStatus === "?" ) {
283- return "question" ;
284- }
285- return "open" ;
453+ return STATUS_MARKERS [ normalizedStatus ] || STATUS_MARKERS [ " " ] ;
286454 }
287455
288456 appendLinkedText ( parent , text ) {
0 commit comments