Skip to content

Commit f298df1

Browse files
committed
Rename Admin Notes root to index and support labeled file links - PR_26156_186-admin-notes-index-and-custom-links
1 parent fe108c9 commit f298df1

10 files changed

Lines changed: 357 additions & 64 deletions

File tree

admin/notes.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ <h1>Admin Notes</h1>
4545
<div class="card-body content-stack">
4646
<div>
4747
<div class="kicker">Project Notes</div>
48-
<h2 id="admin-notes-title" data-admin-notes-title>note.txt</h2>
48+
<h2 id="admin-notes-title" data-admin-notes-title>index.txt</h2>
4949
</div>
50-
<a href="admin/notes.html" data-admin-notes-return hidden>Return to note.txt</a>
50+
<a href="admin/notes.html" data-admin-notes-return hidden>Return to index.txt</a>
5151
<div class="status" role="status" data-admin-notes-status>Loading admin notes.</div>
5252
<div class="content-stack" data-admin-notes-content aria-live="polite"></div>
5353
</div>

admin/notes.js

Lines changed: 187 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const NOTES_DIRECTORY = "docs_build/dev/admin-notes";
2-
const DEFAULT_NOTE = "note";
2+
const DEFAULT_NOTE = "index";
3+
const LINK_CLASS = "btn btn--compact primary";
34
const NOTE_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
5+
const STATUS_ICON_PATTERN = /^\[([ xX.!?])\]\s*(.*)$/;
46

57
class AdminNotesViewer {
68
constructor(documentRef = document, historyRef = window.history) {
@@ -15,11 +17,15 @@ class AdminNotesViewer {
1517

1618
bindEvents() {
1719
this.content?.addEventListener("click", (event) => {
18-
const link = event.target.closest("[data-admin-note-link]");
19-
if (!link) {
20+
const link = event.target.closest("[data-admin-note-link], [data-admin-note-file]");
21+
if (!link || !this.content.contains(link)) {
2022
return;
2123
}
2224
event.preventDefault();
25+
if (link.dataset.adminNoteFile) {
26+
this.openFile(link.dataset.adminNoteFile, true);
27+
return;
28+
}
2329
this.openNote(link.dataset.adminNoteLink || DEFAULT_NOTE, true);
2430
});
2531

@@ -30,30 +36,43 @@ class AdminNotesViewer {
3036
}
3137

3238
start() {
33-
this.openNote(this.noteNameFromLocation(window.location), false);
39+
this.openFromLocation(window.location, false);
3440
}
3541

36-
noteNameFromLocation(locationRef) {
42+
openFromLocation(locationRef, pushHistory) {
3743
const params = new URLSearchParams(locationRef.search);
38-
return this.noteNameFromValue(params.get("note") || DEFAULT_NOTE);
44+
const filePath = params.get("file");
45+
if (filePath) {
46+
this.openFile(filePath, pushHistory);
47+
return;
48+
}
49+
this.openNote(this.noteNameFromValue(params.get("note") || DEFAULT_NOTE), pushHistory);
3950
}
4051

4152
noteNameFromValue(value) {
42-
return String(value || DEFAULT_NOTE).replace(/\.txt$/i, "");
53+
const normalized = String(value || DEFAULT_NOTE)
54+
.trim()
55+
.replace(/\\/g, "/")
56+
.replace(/\/index(?:\.txt)?$/i, "")
57+
.replace(/\.txt$/i, "");
58+
return normalized || DEFAULT_NOTE;
4359
}
4460

4561
validNoteName(noteName) {
4662
return NOTE_NAME_PATTERN.test(noteName);
4763
}
4864

4965
notePath(noteName) {
50-
return `${NOTES_DIRECTORY}/${noteName}.txt`;
66+
return noteName === DEFAULT_NOTE
67+
? `${NOTES_DIRECTORY}/index.txt`
68+
: `${NOTES_DIRECTORY}/${noteName}/index.txt`;
5169
}
5270

5371
async openNote(noteName, pushHistory) {
5472
const normalizedNoteName = this.noteNameFromValue(noteName);
5573
this.clearContent();
56-
this.setTitle(`${normalizedNoteName || DEFAULT_NOTE}.txt`);
74+
const title = normalizedNoteName === DEFAULT_NOTE ? "index.txt" : `${normalizedNoteName}/index.txt`;
75+
this.setTitle(title);
5776
this.setReturnVisible(normalizedNoteName !== DEFAULT_NOTE);
5877

5978
if (!this.validNoteName(normalizedNoteName)) {
@@ -64,27 +83,79 @@ class AdminNotesViewer {
6483
}
6584

6685
const notePath = this.notePath(normalizedNoteName);
86+
await this.loadTextFile({
87+
filePath: notePath,
88+
title,
89+
missingMessage: `Missing note file ${notePath}. Add this file under ${NOTES_DIRECTORY}/ or return to index.txt.`,
90+
errorMessage: `Unable to load ${notePath}. Check the note file and reload the page.`,
91+
pushHistory,
92+
pushUrl: this.hrefForNote(normalizedNoteName)
93+
});
94+
}
95+
96+
async openFile(filePath, pushHistory) {
97+
const normalizedFilePath = this.rootRelativeTextPathFromValue(filePath);
98+
this.clearContent();
99+
this.setTitle(normalizedFilePath || "linked file");
100+
this.setReturnVisible(true);
101+
102+
if (!normalizedFilePath) {
103+
this.renderError(
104+
`Rejected linked file path "${filePath}". Use a repository-root text file path without traversal.`
105+
);
106+
return;
107+
}
108+
109+
await this.loadTextFile({
110+
filePath: normalizedFilePath,
111+
title: normalizedFilePath,
112+
missingMessage: `Missing linked file ${normalizedFilePath}. Add this text file under the repository root or return to index.txt.`,
113+
errorMessage: `Unable to load ${normalizedFilePath}. Check the linked text file and reload the page.`,
114+
pushHistory,
115+
pushUrl: this.hrefForFile(normalizedFilePath)
116+
});
117+
}
118+
119+
async loadTextFile({ filePath, title, missingMessage, errorMessage, pushHistory, pushUrl }) {
67120
try {
68-
const response = await fetch(notePath, { cache: "no-store" });
121+
const response = await fetch(filePath, { cache: "no-store" });
69122
if (!response.ok) {
70-
this.renderError(`Missing note file ${notePath}. Add this file under ${NOTES_DIRECTORY}/ or return to note.txt.`);
123+
this.renderError(missingMessage);
71124
return;
72125
}
73126
const text = await response.text();
74-
this.renderNote(text, normalizedNoteName);
75-
this.setStatus(`Loaded ${notePath}.`);
127+
this.renderNote(text, title);
128+
this.setStatus(`Loaded ${filePath}.`);
76129
if (pushHistory) {
77-
this.historyRef.pushState({}, "", this.hrefForNote(normalizedNoteName));
130+
this.historyRef.pushState({}, "", pushUrl);
78131
}
79132
} catch {
80-
this.renderError(`Unable to load ${notePath}. Check the note file and reload the page.`);
133+
this.renderError(errorMessage);
81134
}
82135
}
83136

84137
hrefForNote(noteName) {
85138
return noteName === DEFAULT_NOTE ? "admin/notes.html" : `admin/notes.html?note=${encodeURIComponent(noteName)}`;
86139
}
87140

141+
hrefForFile(filePath) {
142+
return `admin/notes.html?file=${encodeURIComponent(filePath)}`;
143+
}
144+
145+
rootRelativeTextPathFromValue(value) {
146+
const rawValue = String(value || "").trim();
147+
if (/^[A-Za-z]:/.test(rawValue) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawValue)) {
148+
return null;
149+
}
150+
const normalizedPath = rawValue.replace(/\\/g, "/").replace(/^\/+/, "");
151+
const segments = normalizedPath.split("/");
152+
const invalidSegments = segments.some((segment) => !segment || segment === "." || segment === "..");
153+
if (!normalizedPath || invalidSegments || !normalizedPath.toLowerCase().endsWith(".txt")) {
154+
return null;
155+
}
156+
return normalizedPath;
157+
}
158+
88159
clearContent() {
89160
this.content?.replaceChildren();
90161
}
@@ -116,26 +187,104 @@ class AdminNotesViewer {
116187
this.content?.append(errorMessage);
117188
}
118189

119-
renderNote(text, noteName) {
120-
this.setTitle(`${noteName}.txt`);
190+
renderNote(text, title) {
191+
this.setTitle(title);
121192
const lines = text.split(/\r?\n/);
193+
let currentList = null;
194+
let lastTopLevelItem = null;
195+
let nestedList = null;
196+
122197
lines.forEach((line) => {
123198
const trimmed = line.trim();
124199
if (!trimmed) {
200+
currentList = null;
201+
lastTopLevelItem = null;
202+
nestedList = null;
203+
return;
204+
}
205+
const bullet = line.match(/^(\s*)-\s+(.*)$/);
206+
if (bullet) {
207+
const level = bullet[1].length >= 2 ? 1 : 0;
208+
if (level === 0) {
209+
currentList = currentList || this.documentRef.createElement("ul");
210+
if (!currentList.parentNode) {
211+
this.content?.append(currentList);
212+
}
213+
const item = this.documentRef.createElement("li");
214+
this.appendRichText(item, bullet[2].trim());
215+
currentList.append(item);
216+
lastTopLevelItem = item;
217+
nestedList = null;
218+
return;
219+
}
220+
if (!lastTopLevelItem) {
221+
currentList = currentList || this.documentRef.createElement("ul");
222+
if (!currentList.parentNode) {
223+
this.content?.append(currentList);
224+
}
225+
lastTopLevelItem = this.documentRef.createElement("li");
226+
currentList.append(lastTopLevelItem);
227+
}
228+
nestedList = nestedList || this.documentRef.createElement("ul");
229+
if (!nestedList.parentNode) {
230+
lastTopLevelItem.append(nestedList);
231+
}
232+
const item = this.documentRef.createElement("li");
233+
this.appendRichText(item, bullet[2].trim());
234+
nestedList.append(item);
125235
return;
126236
}
237+
127238
const node = this.sectionHeading(trimmed)
128239
? this.documentRef.createElement("h3")
129240
: this.documentRef.createElement("p");
130-
this.appendLinkedText(node, trimmed);
241+
this.appendRichText(node, trimmed);
131242
this.content?.append(node);
243+
currentList = null;
244+
lastTopLevelItem = null;
245+
nestedList = null;
132246
});
133247
}
134248

135249
sectionHeading(text) {
136250
return ["Ideas", "Things to Fix", "Undecided Questions"].includes(text);
137251
}
138252

253+
appendRichText(parent, text) {
254+
const statusMatch = text.match(STATUS_ICON_PATTERN);
255+
if (statusMatch) {
256+
parent.append(this.statusIcon(statusMatch[1]));
257+
parent.append(this.documentRef.createTextNode(" "));
258+
this.appendLinkedText(parent, statusMatch[2]);
259+
return;
260+
}
261+
this.appendLinkedText(parent, text);
262+
}
263+
264+
statusIcon(rawStatus) {
265+
const icon = this.documentRef.createElement("strong");
266+
icon.dataset.adminNotesStatusIcon = this.statusIconName(rawStatus);
267+
icon.textContent = `[${rawStatus}]`;
268+
return icon;
269+
}
270+
271+
statusIconName(rawStatus) {
272+
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";
286+
}
287+
139288
appendLinkedText(parent, text) {
140289
const bracketPattern = /\[([^\]]+)\]/g;
141290
let cursor = 0;
@@ -149,15 +298,31 @@ class AdminNotesViewer {
149298
parent.append(this.documentRef.createTextNode(text.slice(cursor)));
150299
}
151300

152-
linkOrText(noteName) {
153-
const normalizedNoteName = this.noteNameFromValue(noteName);
301+
linkOrText(linkText) {
302+
const commaIndex = linkText.indexOf(",");
303+
if (commaIndex !== -1) {
304+
const label = linkText.slice(0, commaIndex).trim();
305+
const filePath = this.rootRelativeTextPathFromValue(linkText.slice(commaIndex + 1));
306+
if (!label || !filePath) {
307+
return this.documentRef.createTextNode(`[${linkText}]`);
308+
}
309+
const link = this.documentRef.createElement("a");
310+
link.href = this.hrefForFile(filePath);
311+
link.className = LINK_CLASS;
312+
link.dataset.adminNoteFile = filePath;
313+
link.textContent = label;
314+
return link;
315+
}
316+
317+
const normalizedNoteName = this.noteNameFromValue(linkText);
154318
if (!this.validNoteName(normalizedNoteName)) {
155-
return this.documentRef.createTextNode(`[${noteName}]`);
319+
return this.documentRef.createTextNode(`[${linkText}]`);
156320
}
157321
const link = this.documentRef.createElement("a");
158322
link.href = this.hrefForNote(normalizedNoteName);
323+
link.className = LINK_CLASS;
159324
link.dataset.adminNoteLink = normalizedNoteName;
160-
link.textContent = `[${noteName}]`;
325+
link.textContent = `[${linkText}]`;
161326
return link;
162327
}
163328
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
Ideas
2+
- [ ] Capture admin-only project ideas here.
3+
- [.] Keep nested ideas visible while they are still taking shape.
4+
- Use bracket links for connected notes, such as [other, /admin/notes/other/index.txt].
5+
- Review generated achievement prompts at [HERE, docs_build\tools-images-generated\achievements.txt].
6+
- Review the same generated prompts with a forward slash path at [HERE-FWD, docs_build/tools-images-generated/achievements.txt].
7+
8+
Things to Fix
9+
- [!] Replace placeholder notes with concrete admin follow-up items.
10+
- Track fixes that are not ready for a dedicated BUILD_PR yet.
11+
12+
Undecided Questions
13+
- [?] Decide which project questions need their own subnote files.
14+
- Keep open decisions visible until they become scoped work.

docs_build/dev/admin-notes/note.txt

Lines changed: 0 additions & 11 deletions
This file was deleted.

docs_build/dev/admin-notes/other.txt

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Other
2+
- [x] This is a sample linked admin subnote.
3+
- Return to index.txt goes back to the main admin note.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# PR_26156_186 Admin Notes Index And Custom Links Report
2+
3+
## Result
4+
PASS
5+
6+
## Summary
7+
- Changed the Admin Notes root file from `docs_build/dev/admin-notes/note.txt` to `docs_build/dev/admin-notes/index.txt`.
8+
- Changed simple subnote routing so `[other]` loads `docs_build/dev/admin-notes/other/index.txt`.
9+
- Updated return navigation and loaded-file messaging to use `index.txt`.
10+
- Added custom bracket links with labels and repository-root text file paths, including backslash and forward slash path normalization.
11+
- Preserved simple `[other]` subnote links while adding status marker, bullet, and sub-bullet parsing.
12+
13+
## Scope Controls
14+
- No archived V1/V2 files changed.
15+
- No `start_of_day` files changed.
16+
- No page-local CSS, inline styles, `<style>` blocks, inline event handlers, or inline `<script>` blocks added.
17+
- No new CSS was added; note links use existing Theme V2 button classes for the orange treatment.
18+
- No full samples smoke was run.
19+
20+
## PR_26156_185 Context
21+
- No local `PR_26156_185` delta ZIP, report, or commit was found in the workspace.
22+
- The current tracked Admin Notes implementation was used as the available context.
23+
- The parser now covers the PR_26156_185 behaviors named in the BUILD: status markers and bullet/sub-bullet parsing.
24+
25+
## Runtime Behavior Verified
26+
- Root page loads `docs_build/dev/admin-notes/index.txt`: PASS.
27+
- `[other]` opens `docs_build/dev/admin-notes/other/index.txt`: PASS.
28+
- Subnote return link says `Return to index.txt` and returns to the root index: PASS.
29+
- `[HERE, docs_build\tools-images-generated\achievements.txt]` displays `HERE` and opens the target file: PASS.
30+
- Forward slash and backslash custom paths both resolve to `docs_build/tools-images-generated/achievements.txt`: PASS.
31+
- Simple subnote traversal attempts are rejected before fetch: PASS.
32+
- Root-relative traversal and non-text paths are rejected before fetch: PASS.
33+
- Missing note and linked text files show visible actionable errors: PASS.
34+
- Status markers and bullet/sub-bullet parsing still render: PASS.
35+
36+
## Notes
37+
- Missing-file validation intentionally triggers browser 404 fetches and verifies the visible actionable error messages.
38+
- Repository-root custom links are limited to normalized relative `.txt` paths without empty, current-directory, or parent-directory path segments.

0 commit comments

Comments
 (0)