Skip to content

Commit a460488

Browse files
authored
Merge pull request #15 from AdaInTheLab/feat/sync-md-notes
SCMS [SCMS] Add admin endpoint to sync Lab Notes from filesystem 🔄
2 parents 6f52da6 + c1f40bf commit a460488

5 files changed

Lines changed: 244 additions & 4 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ SESSION_SECRET=your_session_secret_here
1717
ALLOWED_GITHUB_USERNAME=your_username_here
1818
ADMIN_DEV_BYPASS=true
1919

20+
LABNOTES_DIR=/home/humanpatternlab/lab-api/content/labnotes
21+
22+

package-lock.json

Lines changed: 68 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"express": "^5.2.1",
3333
"express-openapi-validator": "^5.6.0",
3434
"express-session": "^1.18.2",
35+
"gray-matter": "^4.0.3",
3536
"marked": "^17.0.1",
3637
"passport": "^0.7.0",
3738
"passport-github2": "^0.1.12"

src/routes/adminRoutes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Request, Response } from "express";
33
import type Database from "better-sqlite3";
44
import { randomUUID } from "node:crypto";
55
import passport, { requireAdmin, isGithubOAuthEnabled } from "../auth.js";
6+
import { syncLabNotesFromFs } from "../services/syncLabNotesFromFs.js";
67
import { normalizeLocale, sha256Hex } from "../lib/helpers.js";
78

89
export function registerAdminRoutes(app: any, db: Database.Database) {
@@ -398,6 +399,18 @@ export function registerAdminRoutes(app: any, db: Database.Database) {
398399
});
399400

400401

402+
// ---------------------------------------------------------------------------
403+
// Admin: Syncs MD Files to DB (protected)
404+
// ---------------------------------------------------------------------------
405+
app.post("/admin/notes/sync", requireAdmin, (req: any, res: { json: (arg0: { rootDir: string; locales: string[]; scanned: number; upserted: number; skipped: number; errors: Array<{ file: string; error: string; }>; ok: boolean; }) => void; status: (arg0: number) => { (): any; new(): any; json: { (arg0: { ok: boolean; error: any; }): void; new(): any; }; }; }) => {
406+
try {
407+
const result = syncLabNotesFromFs(db);
408+
res.json({ ok: true, ...result });
409+
} catch (e: any) {
410+
res.status(500).json({ ok: false, error: e?.message ?? String(e) });
411+
}
412+
});
413+
401414
// ---------------------------------------------------------------------------
402415
// Auth helpers (always available)
403416
// ---------------------------------------------------------------------------

src/services/syncLabNotesFromFs.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// src/services/syncLabNotesFromFs.ts
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import matter from "gray-matter";
5+
import { marked } from "marked";
6+
import type Database from "better-sqlite3";
7+
8+
type SyncCounts = {
9+
rootDir: string;
10+
locales: string[];
11+
scanned: number;
12+
upserted: number;
13+
skipped: number;
14+
errors: Array<{ file: string; error: string }>;
15+
};
16+
17+
function listMarkdownFiles(dir: string): string[] {
18+
if (!fs.existsSync(dir)) return [];
19+
return fs
20+
.readdirSync(dir)
21+
.filter((f) => f.toLowerCase().endsWith(".md"))
22+
.map((f) => path.join(dir, f));
23+
}
24+
25+
function slugFromFilename(filePath: string): string {
26+
return path.basename(filePath, path.extname(filePath));
27+
}
28+
29+
export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
30+
const rootDir = String(process.env.LABNOTES_DIR || "").trim();
31+
if (!rootDir) {
32+
throw new Error("LABNOTES_DIR is not set");
33+
}
34+
if (!fs.existsSync(rootDir)) {
35+
throw new Error(`LABNOTES_DIR not found: ${rootDir}`);
36+
}
37+
38+
const localeDirs = fs
39+
.readdirSync(rootDir, { withFileTypes: true })
40+
.filter((d) => d.isDirectory())
41+
.map((d) => d.name);
42+
43+
// Fallback: if root contains .md directly, treat as "en"
44+
const rootMd = listMarkdownFiles(rootDir);
45+
const locales = localeDirs.length ? localeDirs : ["en"];
46+
47+
const counts: SyncCounts = {
48+
rootDir,
49+
locales,
50+
scanned: 0,
51+
upserted: 0,
52+
skipped: 0,
53+
errors: [],
54+
};
55+
56+
const upsert = db.prepare(`
57+
INSERT INTO lab_notes (
58+
id, slug, title, excerpt, content_html, locale,
59+
category, department_id,
60+
shadow_density, coherence_score, safer_landing, read_time_minutes,
61+
published_at
62+
)
63+
VALUES (
64+
coalesce(?, lower(hex(randomblob(16)))),
65+
?, ?, ?, ?, ?,
66+
?, ?,
67+
?, ?, ?, ?,
68+
?
69+
)
70+
ON CONFLICT(slug, locale) DO UPDATE SET
71+
title=excluded.title,
72+
excerpt=excluded.excerpt,
73+
content_html=excluded.content_html,
74+
category=excluded.category,
75+
department_id=excluded.department_id,
76+
shadow_density=excluded.shadow_density,
77+
coherence_score=excluded.coherence_score,
78+
safer_landing=excluded.safer_landing,
79+
read_time_minutes=excluded.read_time_minutes,
80+
published_at=excluded.published_at,
81+
updated_at=CURRENT_TIMESTAMP
82+
`);
83+
84+
const selectExisting = db.prepare(`
85+
SELECT content_html, title, excerpt, category, department_id
86+
FROM lab_notes
87+
WHERE slug = ? AND locale = ?
88+
LIMIT 1
89+
`);
90+
91+
const processFile = (filePath: string, locale: string) => {
92+
counts.scanned += 1;
93+
94+
try {
95+
const raw = fs.readFileSync(filePath, "utf8");
96+
const parsed = matter(raw);
97+
98+
const slug = String(parsed.data.slug || slugFromFilename(filePath)).trim();
99+
const title = String(parsed.data.title || slug).trim();
100+
101+
const excerpt = String(parsed.data.excerpt || "").trim();
102+
const category = parsed.data.category ? String(parsed.data.category) : null;
103+
const departmentId = parsed.data.department_id ? String(parsed.data.department_id) : null;
104+
105+
const shadowDensity = parsed.data.shadow_density ?? null;
106+
const coherenceScore = parsed.data.coherence_score ?? null;
107+
const saferLanding = parsed.data.safer_landing ?? null;
108+
const readTimeMinutes = parsed.data.read_time_minutes ?? null;
109+
const publishedAt = parsed.data.published_at ? String(parsed.data.published_at) : null;
110+
111+
const contentHtml = marked.parse(String(parsed.content || ""));
112+
113+
// Skip if nothing meaningfully changed (basic check)
114+
const existing = selectExisting.get(slug, locale) as any;
115+
if (
116+
existing &&
117+
existing.content_html === contentHtml &&
118+
existing.title === title &&
119+
existing.excerpt === excerpt &&
120+
(existing.category ?? null) === (category ?? null) &&
121+
(existing.department_id ?? null) === (departmentId ?? null)
122+
) {
123+
counts.skipped += 1;
124+
return;
125+
}
126+
127+
upsert.run(
128+
null, // id (optional)
129+
slug,
130+
title,
131+
excerpt || null,
132+
contentHtml,
133+
locale,
134+
category,
135+
departmentId,
136+
shadowDensity,
137+
coherenceScore,
138+
saferLanding,
139+
readTimeMinutes,
140+
publishedAt
141+
);
142+
143+
counts.upserted += 1;
144+
} catch (e: any) {
145+
counts.errors.push({ file: filePath, error: e?.message ?? String(e) });
146+
}
147+
};
148+
149+
if (localeDirs.length) {
150+
for (const loc of localeDirs) {
151+
const files = listMarkdownFiles(path.join(rootDir, loc));
152+
for (const f of files) processFile(f, loc);
153+
}
154+
} else {
155+
for (const f of rootMd) processFile(f, "en");
156+
}
157+
158+
return counts;
159+
}

0 commit comments

Comments
 (0)