harper-knowledge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +276 -0
- package/config.yaml +17 -0
- package/dist/core/embeddings.d.ts +29 -0
- package/dist/core/embeddings.js +199 -0
- package/dist/core/entries.d.ts +85 -0
- package/dist/core/entries.js +235 -0
- package/dist/core/history.d.ts +30 -0
- package/dist/core/history.js +119 -0
- package/dist/core/search.d.ts +23 -0
- package/dist/core/search.js +306 -0
- package/dist/core/tags.d.ts +32 -0
- package/dist/core/tags.js +76 -0
- package/dist/core/triage.d.ts +55 -0
- package/dist/core/triage.js +126 -0
- package/dist/http-utils.d.ts +37 -0
- package/dist/http-utils.js +132 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +76 -0
- package/dist/mcp/server.d.ts +24 -0
- package/dist/mcp/server.js +124 -0
- package/dist/mcp/tools.d.ts +13 -0
- package/dist/mcp/tools.js +497 -0
- package/dist/oauth/authorize.d.ts +27 -0
- package/dist/oauth/authorize.js +438 -0
- package/dist/oauth/github.d.ts +28 -0
- package/dist/oauth/github.js +62 -0
- package/dist/oauth/keys.d.ts +33 -0
- package/dist/oauth/keys.js +100 -0
- package/dist/oauth/metadata.d.ts +21 -0
- package/dist/oauth/metadata.js +55 -0
- package/dist/oauth/middleware.d.ts +22 -0
- package/dist/oauth/middleware.js +64 -0
- package/dist/oauth/register.d.ts +14 -0
- package/dist/oauth/register.js +83 -0
- package/dist/oauth/token.d.ts +15 -0
- package/dist/oauth/token.js +178 -0
- package/dist/oauth/validate.d.ts +30 -0
- package/dist/oauth/validate.js +52 -0
- package/dist/resources/HistoryResource.d.ts +38 -0
- package/dist/resources/HistoryResource.js +38 -0
- package/dist/resources/KnowledgeEntryResource.d.ts +64 -0
- package/dist/resources/KnowledgeEntryResource.js +157 -0
- package/dist/resources/QueryLogResource.d.ts +20 -0
- package/dist/resources/QueryLogResource.js +57 -0
- package/dist/resources/ServiceKeyResource.d.ts +51 -0
- package/dist/resources/ServiceKeyResource.js +132 -0
- package/dist/resources/TagResource.d.ts +25 -0
- package/dist/resources/TagResource.js +32 -0
- package/dist/resources/TriageResource.d.ts +51 -0
- package/dist/resources/TriageResource.js +107 -0
- package/dist/types.d.ts +317 -0
- package/dist/types.js +7 -0
- package/dist/webhooks/datadog.d.ts +26 -0
- package/dist/webhooks/datadog.js +120 -0
- package/dist/webhooks/github.d.ts +24 -0
- package/dist/webhooks/github.js +167 -0
- package/dist/webhooks/middleware.d.ts +14 -0
- package/dist/webhooks/middleware.js +161 -0
- package/dist/webhooks/types.d.ts +17 -0
- package/dist/webhooks/types.js +4 -0
- package/package.json +72 -0
- package/schema/knowledge.graphql +134 -0
- package/web/index.html +735 -0
- package/web/js/app.js +461 -0
- package/web/js/detail.js +223 -0
- package/web/js/editor.js +303 -0
- package/web/js/search.js +238 -0
- package/web/js/triage.js +305 -0
package/web/js/app.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harper Knowledge Base — Main Application Module
|
|
3
|
+
*
|
|
4
|
+
* Hash-based router, API helpers, auth state management,
|
|
5
|
+
* and page rendering dispatcher.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// API Helper
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
const api = {
|
|
13
|
+
_token: null,
|
|
14
|
+
|
|
15
|
+
/** Set the auth token (Basic or Bearer) */
|
|
16
|
+
setToken(token) {
|
|
17
|
+
this._token = token;
|
|
18
|
+
sessionStorage.setItem("kb-auth-token", token);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
/** Load saved token from storage */
|
|
22
|
+
loadToken() {
|
|
23
|
+
this._token = sessionStorage.getItem("kb-auth-token");
|
|
24
|
+
return this._token;
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
clearToken() {
|
|
28
|
+
this._token = null;
|
|
29
|
+
sessionStorage.removeItem("kb-auth-token");
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/** Build headers including auth if available */
|
|
33
|
+
_headers(extra = {}) {
|
|
34
|
+
const headers = { "Content-Type": "application/json", ...extra };
|
|
35
|
+
if (this._token) {
|
|
36
|
+
headers["Authorization"] = this._token;
|
|
37
|
+
}
|
|
38
|
+
return headers;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async _request(method, path, body) {
|
|
42
|
+
const opts = {
|
|
43
|
+
method,
|
|
44
|
+
headers: this._headers(),
|
|
45
|
+
};
|
|
46
|
+
if (body !== undefined) {
|
|
47
|
+
opts.body = JSON.stringify(body);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const response = await fetch(path, opts);
|
|
51
|
+
|
|
52
|
+
// If the response is 204 or has no content, return null
|
|
53
|
+
if (response.status === 204) return null;
|
|
54
|
+
|
|
55
|
+
const contentType = response.headers.get("content-type") || "";
|
|
56
|
+
if (!contentType.includes("application/json")) {
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new ApiError(response.status, await response.text());
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const data = await response.json();
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new ApiError(
|
|
67
|
+
response.status,
|
|
68
|
+
data?.error || data?.data?.error || response.statusText,
|
|
69
|
+
data,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return data;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
get(path) {
|
|
77
|
+
return this._request("GET", path);
|
|
78
|
+
},
|
|
79
|
+
post(path, data) {
|
|
80
|
+
return this._request("POST", path, data);
|
|
81
|
+
},
|
|
82
|
+
put(path, data) {
|
|
83
|
+
return this._request("PUT", path, data);
|
|
84
|
+
},
|
|
85
|
+
delete(path) {
|
|
86
|
+
return this._request("DELETE", path);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
class ApiError extends Error {
|
|
91
|
+
constructor(status, message, data) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.status = status;
|
|
94
|
+
this.data = data;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Auth State
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
const auth = {
|
|
103
|
+
user: null,
|
|
104
|
+
authenticated: false,
|
|
105
|
+
|
|
106
|
+
async check() {
|
|
107
|
+
// Check for GitHub session via @harperfast/oauth
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch("/oauth/github/user");
|
|
110
|
+
if (res.ok) {
|
|
111
|
+
const data = await res.json();
|
|
112
|
+
if (data && (data.username || data.login)) {
|
|
113
|
+
this.user = data.username || data.login;
|
|
114
|
+
this.authenticated = true;
|
|
115
|
+
updateAuthUI();
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// No GitHub session or endpoint unavailable
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check for stored Harper credentials (Basic auth)
|
|
124
|
+
api.loadToken();
|
|
125
|
+
if (api._token) {
|
|
126
|
+
this.user = sessionStorage.getItem("kb-auth-user");
|
|
127
|
+
this.authenticated = true;
|
|
128
|
+
updateAuthUI();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.authenticated = false;
|
|
133
|
+
this.user = null;
|
|
134
|
+
updateAuthUI();
|
|
135
|
+
return false;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async login(username, password) {
|
|
139
|
+
const token = "Basic " + btoa(username + ":" + password);
|
|
140
|
+
api.setToken(token);
|
|
141
|
+
|
|
142
|
+
// Validate by trying a request that requires auth
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch("/Triage/", { headers: api._headers() });
|
|
145
|
+
if (res.status === 401) {
|
|
146
|
+
api.clearToken();
|
|
147
|
+
throw new Error("Invalid credentials");
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.message === "Invalid credentials") throw err;
|
|
151
|
+
// Network error — let it through, will fail on actual use
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
sessionStorage.setItem("kb-auth-user", username);
|
|
155
|
+
this.user = username;
|
|
156
|
+
this.authenticated = true;
|
|
157
|
+
updateAuthUI();
|
|
158
|
+
return true;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async logout() {
|
|
162
|
+
api.clearToken();
|
|
163
|
+
sessionStorage.removeItem("kb-auth-user");
|
|
164
|
+
this.authenticated = false;
|
|
165
|
+
this.user = null;
|
|
166
|
+
updateAuthUI();
|
|
167
|
+
|
|
168
|
+
// Clear @harperfast/oauth session
|
|
169
|
+
try {
|
|
170
|
+
await fetch("/oauth/logout", { method: "POST" });
|
|
171
|
+
} catch {
|
|
172
|
+
// Ignore — may not have a GitHub session
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
navigate("search");
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function updateAuthUI() {
|
|
180
|
+
const el = document.getElementById("auth-status");
|
|
181
|
+
if (!el) return;
|
|
182
|
+
if (auth.authenticated) {
|
|
183
|
+
const name = auth.user ? escapeHtml(auth.user) : "authenticated";
|
|
184
|
+
el.innerHTML = `<span style="color: var(--green);">${name}</span> <a href="#" id="logout-btn" style="color: var(--text-dim); margin-left: 8px;">[logout]</a>`;
|
|
185
|
+
const logoutBtn = document.getElementById("logout-btn");
|
|
186
|
+
if (logoutBtn) {
|
|
187
|
+
logoutBtn.addEventListener("click", (e) => {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
auth.logout();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
el.innerHTML = `<a href="#" id="login-btn" style="color: var(--text-dim);">[login]</a>`;
|
|
194
|
+
const loginBtn = document.getElementById("login-btn");
|
|
195
|
+
if (loginBtn) {
|
|
196
|
+
loginBtn.addEventListener("click", (e) => {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
showLoginModal();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function showLoginModal() {
|
|
205
|
+
const returnUrl = encodeURIComponent(
|
|
206
|
+
window.location.pathname + window.location.hash,
|
|
207
|
+
);
|
|
208
|
+
const githubLoginUrl = `/oauth/github/login?redirect=${returnUrl}`;
|
|
209
|
+
|
|
210
|
+
const overlay = document.createElement("div");
|
|
211
|
+
overlay.className = "modal-overlay";
|
|
212
|
+
overlay.innerHTML = `
|
|
213
|
+
<div class="modal">
|
|
214
|
+
<h2>Log In</h2>
|
|
215
|
+
<div id="login-error"></div>
|
|
216
|
+
<a href="${githubLoginUrl}" class="btn-github">
|
|
217
|
+
<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
218
|
+
Sign in with GitHub
|
|
219
|
+
</a>
|
|
220
|
+
<div class="login-divider"><span>or</span></div>
|
|
221
|
+
<button type="button" id="login-cred-toggle" class="login-cred-toggle">Sign in with Harper credentials</button>
|
|
222
|
+
<div id="login-cred-form" class="login-cred-form">
|
|
223
|
+
<div class="form-group">
|
|
224
|
+
<label for="login-user">Username</label>
|
|
225
|
+
<input type="text" id="login-user" autocomplete="username">
|
|
226
|
+
</div>
|
|
227
|
+
<div class="form-group">
|
|
228
|
+
<label for="login-pass">Password</label>
|
|
229
|
+
<input type="password" id="login-pass" autocomplete="current-password">
|
|
230
|
+
</div>
|
|
231
|
+
<div class="btn-row">
|
|
232
|
+
<button id="login-cancel">Cancel</button>
|
|
233
|
+
<button id="login-submit" class="primary">Sign in</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
document.body.appendChild(overlay);
|
|
240
|
+
|
|
241
|
+
const credToggle = overlay.querySelector("#login-cred-toggle");
|
|
242
|
+
const credForm = overlay.querySelector("#login-cred-form");
|
|
243
|
+
const userInput = overlay.querySelector("#login-user");
|
|
244
|
+
const passInput = overlay.querySelector("#login-pass");
|
|
245
|
+
const submitBtn = overlay.querySelector("#login-submit");
|
|
246
|
+
const cancelBtn = overlay.querySelector("#login-cancel");
|
|
247
|
+
const errorDiv = overlay.querySelector("#login-error");
|
|
248
|
+
|
|
249
|
+
credToggle.addEventListener("click", () => {
|
|
250
|
+
credForm.classList.toggle("visible");
|
|
251
|
+
credToggle.style.display = "none";
|
|
252
|
+
userInput.focus();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const doLogin = async () => {
|
|
256
|
+
const username = userInput.value.trim();
|
|
257
|
+
const password = passInput.value;
|
|
258
|
+
if (!username || !password) return;
|
|
259
|
+
|
|
260
|
+
submitBtn.disabled = true;
|
|
261
|
+
submitBtn.textContent = "Signing in...";
|
|
262
|
+
errorDiv.innerHTML = "";
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await auth.login(username, password);
|
|
266
|
+
overlay.remove();
|
|
267
|
+
route();
|
|
268
|
+
} catch {
|
|
269
|
+
errorDiv.innerHTML =
|
|
270
|
+
'<div class="status-message error">Invalid credentials. Try again.</div>';
|
|
271
|
+
submitBtn.disabled = false;
|
|
272
|
+
submitBtn.textContent = "Sign in";
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
submitBtn.addEventListener("click", doLogin);
|
|
277
|
+
passInput.addEventListener("keydown", (e) => {
|
|
278
|
+
if (e.key === "Enter") doLogin();
|
|
279
|
+
});
|
|
280
|
+
userInput.addEventListener("keydown", (e) => {
|
|
281
|
+
if (e.key === "Enter") passInput.focus();
|
|
282
|
+
});
|
|
283
|
+
cancelBtn.addEventListener("click", () => overlay.remove());
|
|
284
|
+
overlay.addEventListener("click", (e) => {
|
|
285
|
+
if (e.target === overlay) overlay.remove();
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// Router
|
|
291
|
+
// ============================================================================
|
|
292
|
+
|
|
293
|
+
function getRoute() {
|
|
294
|
+
const hash = location.hash.slice(1) || "search";
|
|
295
|
+
const parts = hash.split("/");
|
|
296
|
+
return { page: parts[0], id: parts.slice(1).join("/") };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function navigate(page, id) {
|
|
300
|
+
location.hash = id ? `${page}/${id}` : page;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function updateNav() {
|
|
304
|
+
const { page } = getRoute();
|
|
305
|
+
document.querySelectorAll("nav a").forEach((a) => {
|
|
306
|
+
const nav = a.dataset.nav;
|
|
307
|
+
a.classList.toggle("active", nav === page);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============================================================================
|
|
312
|
+
// Page Modules — loaded dynamically
|
|
313
|
+
// ============================================================================
|
|
314
|
+
|
|
315
|
+
let modules = {};
|
|
316
|
+
|
|
317
|
+
async function loadModule(name) {
|
|
318
|
+
if (!modules[name]) {
|
|
319
|
+
modules[name] = await import(`./${name}.js`);
|
|
320
|
+
}
|
|
321
|
+
return modules[name];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ============================================================================
|
|
325
|
+
// Route Dispatcher
|
|
326
|
+
// ============================================================================
|
|
327
|
+
|
|
328
|
+
async function route() {
|
|
329
|
+
const { page, id } = getRoute();
|
|
330
|
+
const main = document.getElementById("main");
|
|
331
|
+
updateNav();
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
switch (page) {
|
|
335
|
+
case "search":
|
|
336
|
+
case "browse": {
|
|
337
|
+
const mod = await loadModule("search");
|
|
338
|
+
mod.render(main, page === "browse");
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case "entry": {
|
|
342
|
+
const mod = await loadModule("detail");
|
|
343
|
+
mod.render(main, id);
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case "add": {
|
|
347
|
+
const mod = await loadModule("editor");
|
|
348
|
+
mod.render(main, null);
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
case "edit": {
|
|
352
|
+
const mod = await loadModule("editor");
|
|
353
|
+
mod.render(main, id);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case "triage": {
|
|
357
|
+
const mod = await loadModule("triage");
|
|
358
|
+
mod.render(main);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
default:
|
|
362
|
+
main.innerHTML = `<div class="empty-state"><h2>Not Found</h2><p>Unknown route: ${escapeHtml(page)}</p></div>`;
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
console.error("Route error:", err);
|
|
366
|
+
main.innerHTML = `<div class="status-message error">Error loading page: ${escapeHtml(err.message)}</div>`;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ============================================================================
|
|
371
|
+
// Utility exports
|
|
372
|
+
// ============================================================================
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Escape HTML special characters to prevent XSS.
|
|
376
|
+
*/
|
|
377
|
+
function escapeHtml(str) {
|
|
378
|
+
if (str == null) return "";
|
|
379
|
+
return String(str)
|
|
380
|
+
.replace(/&/g, "&")
|
|
381
|
+
.replace(/</g, "<")
|
|
382
|
+
.replace(/>/g, ">")
|
|
383
|
+
.replace(/"/g, """)
|
|
384
|
+
.replace(/'/g, "'");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Return a confidence badge HTML string.
|
|
389
|
+
*/
|
|
390
|
+
function confidenceBadge(confidence) {
|
|
391
|
+
const cls =
|
|
392
|
+
confidence === "verified"
|
|
393
|
+
? "badge-verified"
|
|
394
|
+
: confidence === "reviewed"
|
|
395
|
+
? "badge-reviewed"
|
|
396
|
+
: "badge-ai-generated";
|
|
397
|
+
return `<span class="badge ${cls}">${escapeHtml(confidence)}</span>`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Return tag chip HTML string(s).
|
|
402
|
+
*/
|
|
403
|
+
function tagChips(tags, activeTags = []) {
|
|
404
|
+
if (!tags || !tags.length) return "";
|
|
405
|
+
return tags
|
|
406
|
+
.map((t) => {
|
|
407
|
+
const active = activeTags.includes(t) ? " active" : "";
|
|
408
|
+
return `<span class="tag${active}" data-tag="${escapeHtml(t)}">${escapeHtml(t)}</span>`;
|
|
409
|
+
})
|
|
410
|
+
.join("");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Format a date value for display.
|
|
415
|
+
*/
|
|
416
|
+
function formatDate(d) {
|
|
417
|
+
if (!d) return "--";
|
|
418
|
+
const date = new Date(d);
|
|
419
|
+
if (isNaN(date.getTime())) return String(d);
|
|
420
|
+
return date.toLocaleDateString("en-US", {
|
|
421
|
+
year: "numeric",
|
|
422
|
+
month: "short",
|
|
423
|
+
day: "numeric",
|
|
424
|
+
hour: "2-digit",
|
|
425
|
+
minute: "2-digit",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Truncate text to a maximum length.
|
|
431
|
+
*/
|
|
432
|
+
function truncate(text, max = 150) {
|
|
433
|
+
if (!text) return "";
|
|
434
|
+
if (text.length <= max) return text;
|
|
435
|
+
return text.slice(0, max) + "...";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Export utilities for use by other modules
|
|
439
|
+
export {
|
|
440
|
+
api,
|
|
441
|
+
auth,
|
|
442
|
+
navigate,
|
|
443
|
+
escapeHtml,
|
|
444
|
+
confidenceBadge,
|
|
445
|
+
tagChips,
|
|
446
|
+
formatDate,
|
|
447
|
+
truncate,
|
|
448
|
+
showLoginModal,
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// ============================================================================
|
|
452
|
+
// Init
|
|
453
|
+
// ============================================================================
|
|
454
|
+
|
|
455
|
+
window.addEventListener("hashchange", route);
|
|
456
|
+
|
|
457
|
+
// Initial load
|
|
458
|
+
(async () => {
|
|
459
|
+
await auth.check();
|
|
460
|
+
route();
|
|
461
|
+
})();
|
package/web/js/detail.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harper Knowledge Base — Entry Detail View
|
|
3
|
+
*
|
|
4
|
+
* Full content display, metadata, relationships,
|
|
5
|
+
* and edit navigation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
api,
|
|
10
|
+
auth,
|
|
11
|
+
escapeHtml,
|
|
12
|
+
confidenceBadge,
|
|
13
|
+
tagChips,
|
|
14
|
+
formatDate,
|
|
15
|
+
showLoginModal,
|
|
16
|
+
} from "./app.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render the entry detail page.
|
|
20
|
+
* @param {HTMLElement} container
|
|
21
|
+
* @param {string} id - Entry ID
|
|
22
|
+
*/
|
|
23
|
+
export async function render(container, id) {
|
|
24
|
+
if (!id) {
|
|
25
|
+
container.innerHTML = `<div class="empty-state"><h2>No entry selected</h2><p>Select an entry from search results.</p></div>`;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
container.innerHTML = '<div class="loading">Loading entry...</div>';
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const entry = await api.get(`/Knowledge/${encodeURIComponent(id)}`);
|
|
33
|
+
|
|
34
|
+
if (!entry || entry.error) {
|
|
35
|
+
container.innerHTML = `<div class="empty-state"><h2>Entry not found</h2><p>${escapeHtml(entry?.error || "The requested entry does not exist.")}</p></div>`;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
container.innerHTML = renderEntry(entry);
|
|
40
|
+
|
|
41
|
+
// Wire up events
|
|
42
|
+
wireEvents(container, entry);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error("Detail error:", err);
|
|
45
|
+
if (err.status === 404) {
|
|
46
|
+
container.innerHTML = `<div class="empty-state"><h2>Entry not found</h2><p>No entry exists with ID: ${escapeHtml(id)}</p></div>`;
|
|
47
|
+
} else {
|
|
48
|
+
container.innerHTML = `<div class="status-message error">Failed to load entry: ${escapeHtml(err.message)}</div>`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderEntry(entry) {
|
|
54
|
+
const deprecatedBanner = entry.deprecated
|
|
55
|
+
? `<div class="status-message error" style="margin-bottom: 16px;">This entry has been deprecated.</div>`
|
|
56
|
+
: "";
|
|
57
|
+
|
|
58
|
+
return `
|
|
59
|
+
${deprecatedBanner}
|
|
60
|
+
<div class="detail-header">
|
|
61
|
+
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;">
|
|
62
|
+
<div>
|
|
63
|
+
<div class="detail-title">${escapeHtml(entry.title)}</div>
|
|
64
|
+
<div class="card-meta" style="margin-top: 6px;">
|
|
65
|
+
${confidenceBadge(entry.confidence)}
|
|
66
|
+
${entry.tags?.length ? tagChips(entry.tags) : ""}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div style="display: flex; gap: 8px; flex-shrink: 0;">
|
|
70
|
+
<a href="#edit/${escapeHtml(entry.id)}" class="btn">Edit</a>
|
|
71
|
+
<button id="deprecate-btn" class="danger" style="display: ${entry.deprecated ? "none" : "inline-flex"};">Deprecate</button>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="detail-content">${escapeHtml(entry.content)}</div>
|
|
77
|
+
|
|
78
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
|
79
|
+
<div class="detail-section">
|
|
80
|
+
<h3>Metadata</h3>
|
|
81
|
+
<dl class="meta-grid">
|
|
82
|
+
<dt>ID</dt>
|
|
83
|
+
<dd style="font-family: var(--font-mono); font-size: 12px;">${escapeHtml(entry.id)}</dd>
|
|
84
|
+
<dt>Source</dt>
|
|
85
|
+
<dd>${
|
|
86
|
+
entry.sourceUrl
|
|
87
|
+
? isSafeUrl(entry.sourceUrl)
|
|
88
|
+
? `<a href="${escapeHtml(entry.sourceUrl)}" target="_blank" rel="noopener">${escapeHtml(entry.source || entry.sourceUrl)}</a>`
|
|
89
|
+
: escapeHtml(entry.source || entry.sourceUrl)
|
|
90
|
+
: escapeHtml(entry.source || "--")
|
|
91
|
+
}</dd>
|
|
92
|
+
<dt>Added By</dt>
|
|
93
|
+
<dd>${escapeHtml(entry.addedBy || "--")}</dd>
|
|
94
|
+
<dt>Reviewed By</dt>
|
|
95
|
+
<dd>${escapeHtml(entry.reviewedBy || "--")}</dd>
|
|
96
|
+
<dt>Created</dt>
|
|
97
|
+
<dd>${formatDate(entry.createdAt)}</dd>
|
|
98
|
+
<dt>Updated</dt>
|
|
99
|
+
<dd>${formatDate(entry.updatedAt)}</dd>
|
|
100
|
+
</dl>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div>
|
|
104
|
+
${renderApplicability(entry.appliesTo)}
|
|
105
|
+
${renderRelationships(entry)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderApplicability(appliesTo) {
|
|
112
|
+
if (!appliesTo) return "";
|
|
113
|
+
|
|
114
|
+
const fields = [
|
|
115
|
+
["Harper", appliesTo.harper],
|
|
116
|
+
["Storage Engine", appliesTo.storageEngine],
|
|
117
|
+
["Node.js", appliesTo.node],
|
|
118
|
+
["Platform", appliesTo.platform],
|
|
119
|
+
].filter(([, v]) => v);
|
|
120
|
+
|
|
121
|
+
if (fields.length === 0) return "";
|
|
122
|
+
|
|
123
|
+
return `
|
|
124
|
+
<div class="detail-section">
|
|
125
|
+
<h3>Applies To</h3>
|
|
126
|
+
<dl class="meta-grid">
|
|
127
|
+
${fields.map(([label, value]) => `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`).join("")}
|
|
128
|
+
</dl>
|
|
129
|
+
</div>
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderRelationships(entry) {
|
|
134
|
+
const sections = [];
|
|
135
|
+
|
|
136
|
+
if (entry.supersedesId) {
|
|
137
|
+
sections.push(`
|
|
138
|
+
<div style="margin-bottom: 8px;">
|
|
139
|
+
<span style="color: var(--text-dim); font-size: 12px;">Supersedes:</span>
|
|
140
|
+
<a href="#entry/${escapeHtml(entry.supersedesId)}" class="entry-link">${escapeHtml(entry.supersedesId)}</a>
|
|
141
|
+
</div>
|
|
142
|
+
`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (entry.supersededById) {
|
|
146
|
+
sections.push(`
|
|
147
|
+
<div style="margin-bottom: 8px;">
|
|
148
|
+
<span style="color: var(--text-dim); font-size: 12px;">Superseded by:</span>
|
|
149
|
+
<a href="#entry/${escapeHtml(entry.supersededById)}" class="entry-link">${escapeHtml(entry.supersededById)}</a>
|
|
150
|
+
</div>
|
|
151
|
+
`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (entry.siblingIds?.length) {
|
|
155
|
+
sections.push(`
|
|
156
|
+
<div style="margin-bottom: 8px;">
|
|
157
|
+
<span style="color: var(--text-dim); font-size: 12px;">Siblings:</span>
|
|
158
|
+
<div class="entry-links" style="margin-top: 4px;">
|
|
159
|
+
${entry.siblingIds.map((id) => `<a href="#entry/${escapeHtml(id)}" class="entry-link">${escapeHtml(id)}</a>`).join("")}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (entry.relatedIds?.length) {
|
|
166
|
+
sections.push(`
|
|
167
|
+
<div style="margin-bottom: 8px;">
|
|
168
|
+
<span style="color: var(--text-dim); font-size: 12px;">Related:</span>
|
|
169
|
+
<div class="entry-links" style="margin-top: 4px;">
|
|
170
|
+
${entry.relatedIds.map((id) => `<a href="#entry/${escapeHtml(id)}" class="entry-link">${escapeHtml(id)}</a>`).join("")}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (sections.length === 0) return "";
|
|
177
|
+
|
|
178
|
+
return `
|
|
179
|
+
<div class="detail-section">
|
|
180
|
+
<h3>Relationships</h3>
|
|
181
|
+
${sections.join("")}
|
|
182
|
+
</div>
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Only render http/https URLs as clickable links. */
|
|
187
|
+
function isSafeUrl(url) {
|
|
188
|
+
try {
|
|
189
|
+
const parsed = new URL(url);
|
|
190
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function wireEvents(container, entry) {
|
|
197
|
+
const deprecateBtn = container.querySelector("#deprecate-btn");
|
|
198
|
+
if (deprecateBtn) {
|
|
199
|
+
deprecateBtn.addEventListener("click", async () => {
|
|
200
|
+
if (!auth.authenticated) {
|
|
201
|
+
showLoginModal();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!confirm(`Deprecate "${entry.title}"? This cannot be undone easily.`))
|
|
206
|
+
return;
|
|
207
|
+
|
|
208
|
+
deprecateBtn.disabled = true;
|
|
209
|
+
deprecateBtn.textContent = "Deprecating...";
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await api.delete(`/Knowledge/${encodeURIComponent(entry.id)}`);
|
|
213
|
+
// Re-render the page to show deprecated state
|
|
214
|
+
render(container, entry.id);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error("Deprecate error:", err);
|
|
217
|
+
alert(`Failed to deprecate: ${err.message}`);
|
|
218
|
+
deprecateBtn.disabled = false;
|
|
219
|
+
deprecateBtn.textContent = "Deprecate";
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|