Start standalone OKF plugin
This commit is contained in:
parent
1173b8ac63
commit
076f7a042a
39
TODO.md
39
TODO.md
@ -4,6 +4,43 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
|
||||
|
||||
## OKF Knowledge System
|
||||
|
||||
Current state on `experimental-okf` as of 2026-06-18: first basic standalone OKF plugin pass is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants, workflow actions, and version history. File-backed OKF directories, indexing, AI prompt integration, and feedback-to-correction flow remain for later passes.
|
||||
|
||||
### Implemented Locally
|
||||
|
||||
- Added standalone `plugins/okf` plugin with WebUI route mounting and navigation registration.
|
||||
- Added plugin-owned SQLite tables for OKF entries, versions, and per-user OKF permission grants.
|
||||
- Added sanitized Markdown rendering for OKF answers/details without unsafe raw HTML.
|
||||
- Added logged-in OKF/Q&A index, search, category/tag filtering, and entry detail pages.
|
||||
- Enforced server-side visibility:
|
||||
- anonymous users blocked
|
||||
- normal users see published user-visible entries
|
||||
- moderators see published user/mod entries and support details
|
||||
- admins see all OKF fields and workflow state
|
||||
- Added admin/editor management UI for create/edit, workflow actions, and version history.
|
||||
- Added OKF-specific permission grants independent of normal roles:
|
||||
- `edit`
|
||||
- `edit_review`
|
||||
- `edit_review_implement`
|
||||
- Added workflow actions for review, publish, archive, restore-as-draft, and soft delete.
|
||||
- Added version snapshots for create/update/workflow changes.
|
||||
- Added a local `global.lumiFrameworks.okf.search(...)` integration point for later AI context provider wiring.
|
||||
- Added `plugins/okf/tests/verify.js`.
|
||||
|
||||
### Remaining Work
|
||||
|
||||
- Wire OKF into Lumi AI context retrieval through a generic context provider path with role-aware filtering.
|
||||
- Add file-backed OKF Markdown/frontmatter directories for generated core/plugin/community/corrections knowledge.
|
||||
- Add Markdown/frontmatter parsing with stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps.
|
||||
- Add OKF indexing/chunking with path/id/heading/score/excerpt source metadata.
|
||||
- Add changed-file reindexing where practical.
|
||||
- Enforce retrieval priority for corrections, community OKF, plugin OKF, and core OKF.
|
||||
- Add safe placeholder resolution for generated OKF references.
|
||||
- Add restore-from-version UI/action.
|
||||
- Add richer category/tag management and role-preview tools.
|
||||
- Add correction-file creation from reviewed feedback after file-backed OKF storage exists.
|
||||
- Add update preservation tests/notes for OKF file paths once those paths exist.
|
||||
|
||||
- Add `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections` directories with documented ownership rules.
|
||||
- Implement OKF Markdown/frontmatter parsing with stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps.
|
||||
- Generate fixed core/plugin OKF from routes, commands, schemas, plugin metadata, README docs, and defaults.
|
||||
@ -122,6 +159,7 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
|
||||
- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values.
|
||||
- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity.
|
||||
- Review localization/translation keys if present so simplified wording remains consistent across languages.
|
||||
- IMPORTANT: on mobile, the navbar extends the viewport vertically, meaning users can only see as far down as "log in with discord". The navbar needs to be the full height of the viewport, and ensure all contained elements are visible. Should contents overflow the viewport, integrate a very slim and minimalistic scrollbar.
|
||||
|
||||
## Core Feedback System
|
||||
|
||||
@ -537,6 +575,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
||||
|
||||
## Done
|
||||
|
||||
- 2026-06-18: Started `experimental-okf` with a standalone OKF plugin: role-gated SQLite entries, sanitized Markdown browsing, admin/editor management, per-user OKF grants, workflow actions, version snapshots, and first verification coverage. No repo push yet.
|
||||
- 2026-06-18: Emergency patched `/admin/navigation` so the Unassigned items pool stays sticky while editing sections on desktop, and aligned Save navigation / Reset to default actions in a shared horizontal Lumi button group.
|
||||
- 2026-06-18: Removed feedback conversion actions from the experimental feedback system, added Finalize & Close/Reopen/Delete admin actions, made delete hard-remove feedback records/comments/notes/history/screenshots, kept admin feedback rows collapsed by default, and replaced browser tab capture screenshots with upload/clipboard-paste attachments.
|
||||
- 2026-06-18: Fixed experimental feedback review buttons to use the stable save endpoint for Finalize & Close/Reopen and added a reusable Lumi `button-group` utility, including the `.field.button-group` specificity case, so review buttons align horizontally.
|
||||
|
||||
114
plugins/okf/backend/markdown.js
Normal file
114
plugins/okf/backend/markdown.js
Normal file
@ -0,0 +1,114 @@
|
||||
function escapeHtml(value) {
|
||||
const map = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
};
|
||||
return String(value || "").replace(/[&<>"']/g, (char) => map[char]);
|
||||
}
|
||||
|
||||
function safeUrl(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
if (/^(https?:\/\/|\/(?!\/)|#)/i.test(raw)) return escapeHtml(raw);
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderMarkdownInline(value) {
|
||||
let output = escapeHtml(value);
|
||||
output = output.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
|
||||
const href = safeUrl(url);
|
||||
return href ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>` : escapeHtml(text);
|
||||
});
|
||||
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
output = output.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderMarkdown(value) {
|
||||
const lines = String(value || "").replace(/\r\n?/g, "\n").split("\n");
|
||||
let html = "";
|
||||
let paragraph = [];
|
||||
let listType = null;
|
||||
let inCode = false;
|
||||
let codeLang = "";
|
||||
let codeLines = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraph.length) return;
|
||||
html += `<p>${renderMarkdownInline(paragraph.join(" "))}</p>`;
|
||||
paragraph = [];
|
||||
};
|
||||
const closeList = () => {
|
||||
if (!listType) return;
|
||||
html += `</${listType}>`;
|
||||
listType = null;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (inCode) {
|
||||
if (trimmed.startsWith("```")) {
|
||||
const langClass = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : "";
|
||||
html += `<pre><code${langClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`;
|
||||
inCode = false;
|
||||
codeLang = "";
|
||||
codeLines = [];
|
||||
continue;
|
||||
}
|
||||
codeLines.push(line);
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("```")) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
inCode = true;
|
||||
codeLang = trimmed.slice(3).trim().replace(/[^a-z0-9_-]/gi, "").slice(0, 32);
|
||||
continue;
|
||||
}
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
html += `<h${level}>${renderMarkdownInline(heading[2])}</h${level}>`;
|
||||
continue;
|
||||
}
|
||||
const list = trimmed.match(/^([*-]|\d+\.)\s+(.*)$/);
|
||||
if (list) {
|
||||
flushParagraph();
|
||||
const nextType = list[1].endsWith(".") ? "ol" : "ul";
|
||||
if (listType && listType !== nextType) closeList();
|
||||
if (!listType) {
|
||||
listType = nextType;
|
||||
html += `<${listType}>`;
|
||||
}
|
||||
html += `<li>${renderMarkdownInline(list[2])}</li>`;
|
||||
continue;
|
||||
}
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
const langClass = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : "";
|
||||
html += `<pre><code${langClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`;
|
||||
}
|
||||
flushParagraph();
|
||||
closeList();
|
||||
return html || "<p></p>";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeHtml,
|
||||
renderMarkdown,
|
||||
renderMarkdownInline,
|
||||
safeUrl
|
||||
};
|
||||
493
plugins/okf/backend/okf_store.js
Normal file
493
plugins/okf/backend/okf_store.js
Normal file
@ -0,0 +1,493 @@
|
||||
const crypto = require("crypto");
|
||||
|
||||
const PERMISSION_LEVELS = Object.freeze(["edit", "edit_review", "edit_review_implement"]);
|
||||
const STATUS_VALUES = Object.freeze(["draft", "published", "archived"]);
|
||||
const VISIBILITY_VALUES = Object.freeze(["user", "mod", "admin"]);
|
||||
const REVIEW_STATES = Object.freeze(["draft", "review_pending", "approved", "rejected"]);
|
||||
|
||||
function ensureTables(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS okf_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
aliases_json TEXT NOT NULL DEFAULT '[]',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
user_markdown TEXT NOT NULL DEFAULT '',
|
||||
moderator_markdown TEXT NOT NULL DEFAULT '',
|
||||
admin_markdown TEXT NOT NULL DEFAULT '',
|
||||
ai_facts_markdown TEXT NOT NULL DEFAULT '',
|
||||
source_links_json TEXT NOT NULL DEFAULT '[]',
|
||||
visibility TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
review_state TEXT NOT NULL DEFAULT 'draft',
|
||||
created_by TEXT,
|
||||
updated_by TEXT,
|
||||
reviewed_by TEXT,
|
||||
published_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
reviewed_at INTEGER,
|
||||
published_at INTEGER,
|
||||
archived_at INTEGER,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS okf_entries_status_idx ON okf_entries (status, visibility, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS okf_entries_slug_idx ON okf_entries (slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS okf_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id TEXT NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
changed_by TEXT,
|
||||
change_type TEXT NOT NULL,
|
||||
previous_json TEXT,
|
||||
next_json TEXT,
|
||||
note TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS okf_versions_entry_idx ON okf_versions (entry_id, version_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS okf_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
granted_by TEXT,
|
||||
notes TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS okf_permissions_user_idx ON okf_permissions (user_id, revoked_at);
|
||||
`);
|
||||
}
|
||||
|
||||
function okfPermissionForUser(db, userId) {
|
||||
if (!userId) return null;
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT level FROM okf_permissions WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
.get(userId);
|
||||
return PERMISSION_LEVELS.includes(row?.level) ? row.level : null;
|
||||
}
|
||||
|
||||
function accessForUser(db, user) {
|
||||
const okfPermission = okfPermissionForUser(db, user?.id);
|
||||
const isAdmin = Boolean(user?.isAdmin);
|
||||
const isMod = Boolean(user?.isAdmin || user?.isMod);
|
||||
return {
|
||||
authenticated: Boolean(user),
|
||||
isAdmin,
|
||||
isMod,
|
||||
okfPermission,
|
||||
canEdit: isAdmin || ["edit", "edit_review", "edit_review_implement"].includes(okfPermission),
|
||||
canReview: isAdmin || ["edit_review", "edit_review_implement"].includes(okfPermission),
|
||||
canImplement: isAdmin || okfPermission === "edit_review_implement",
|
||||
canManagePermissions: isAdmin,
|
||||
maxVisibility: isAdmin ? "admin" : isMod ? "mod" : "user"
|
||||
};
|
||||
}
|
||||
|
||||
function canSeeEntry(access, entry, { management = false } = {}) {
|
||||
if (!access.authenticated || !entry || entry.deleted_at) return false;
|
||||
if (access.isAdmin) return true;
|
||||
if (management && access.canEdit) return true;
|
||||
if (entry.status !== "published") return false;
|
||||
if (entry.visibility === "admin") return false;
|
||||
if (entry.visibility === "mod" && !access.isMod) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function publicEntry(entry, access) {
|
||||
const base = {
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
tags: parseJsonArray(entry.tags_json),
|
||||
aliases: parseJsonArray(entry.aliases_json),
|
||||
summary: entry.summary,
|
||||
user_markdown: entry.user_markdown,
|
||||
visibility: entry.visibility,
|
||||
status: entry.status,
|
||||
review_state: entry.review_state,
|
||||
updated_at: entry.updated_at,
|
||||
published_at: entry.published_at
|
||||
};
|
||||
if (access.isMod || access.isAdmin) {
|
||||
base.moderator_markdown = entry.moderator_markdown;
|
||||
}
|
||||
if (access.isAdmin || access.canEdit) {
|
||||
base.admin_markdown = entry.admin_markdown;
|
||||
base.ai_facts_markdown = entry.ai_facts_markdown;
|
||||
base.source_links = parseJsonArray(entry.source_links_json);
|
||||
base.created_by = entry.created_by;
|
||||
base.updated_by = entry.updated_by;
|
||||
base.reviewed_by = entry.reviewed_by;
|
||||
base.published_by = entry.published_by;
|
||||
base.created_at = entry.created_at;
|
||||
base.reviewed_at = entry.reviewed_at;
|
||||
base.archived_at = entry.archived_at;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function listEntries(db, filters = {}, user, options = {}) {
|
||||
const access = accessForUser(db, user);
|
||||
const rows = db.prepare("SELECT * FROM okf_entries WHERE deleted_at IS NULL ORDER BY updated_at DESC").all();
|
||||
return rows
|
||||
.filter((entry) => canSeeEntry(access, entry, options))
|
||||
.filter((entry) => matchesFilters(entry, filters, access, options))
|
||||
.map((entry) => publicEntry(entry, access));
|
||||
}
|
||||
|
||||
function getEntryBySlug(db, slug, user, options = {}) {
|
||||
const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ?").get(normalizeSlug(slug));
|
||||
const access = accessForUser(db, user);
|
||||
if (!canSeeEntry(access, entry, options)) return null;
|
||||
return publicEntry(entry, access);
|
||||
}
|
||||
|
||||
function getEditableEntry(db, slug) {
|
||||
const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
|
||||
return entry ? normalizeEntry(entry) : null;
|
||||
}
|
||||
|
||||
function createEntry(db, values, actor) {
|
||||
const access = accessForUser(db, actor);
|
||||
if (!access.canEdit) throw new Error("OKF edit permission is required.");
|
||||
const now = Date.now();
|
||||
const entry = normalizeValues(values, {
|
||||
actor,
|
||||
existing: null,
|
||||
canImplement: access.canImplement,
|
||||
now
|
||||
});
|
||||
entry.id = crypto.randomUUID();
|
||||
entry.created_by = String(actor.id);
|
||||
entry.updated_by = String(actor.id);
|
||||
entry.created_at = now;
|
||||
entry.updated_at = now;
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO okf_entries (id, slug, title, category, tags_json, aliases_json, summary, user_markdown, moderator_markdown, admin_markdown, ai_facts_markdown, source_links_json, visibility, status, review_state, created_by, updated_by, reviewed_by, published_by, created_at, updated_at, reviewed_at, published_at, archived_at, deleted_at) " +
|
||||
"VALUES (@id, @slug, @title, @category, @tags_json, @aliases_json, @summary, @user_markdown, @moderator_markdown, @admin_markdown, @ai_facts_markdown, @source_links_json, @visibility, @status, @review_state, @created_by, @updated_by, @reviewed_by, @published_by, @created_at, @updated_at, @reviewed_at, @published_at, @archived_at, @deleted_at)"
|
||||
).run(entry);
|
||||
addVersion(db, entry.id, "create", null, entry, actor, values.change_note || "Entry created.");
|
||||
return normalizeEntry(entry);
|
||||
}
|
||||
|
||||
function updateEntry(db, slug, values, actor) {
|
||||
const access = accessForUser(db, actor);
|
||||
if (!access.canEdit) throw new Error("OKF edit permission is required.");
|
||||
const current = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
|
||||
if (!current) throw new Error("OKF entry was not found.");
|
||||
const now = Date.now();
|
||||
const next = normalizeValues(values, {
|
||||
actor,
|
||||
existing: current,
|
||||
canImplement: access.canImplement,
|
||||
now
|
||||
});
|
||||
next.id = current.id;
|
||||
next.created_by = current.created_by;
|
||||
next.created_at = current.created_at;
|
||||
next.updated_by = String(actor.id);
|
||||
next.updated_at = now;
|
||||
next.reviewed_by = current.reviewed_by;
|
||||
next.reviewed_at = current.reviewed_at;
|
||||
next.published_by = current.published_by;
|
||||
next.published_at = current.published_at;
|
||||
next.archived_at = next.status === "archived" ? (current.archived_at || now) : null;
|
||||
next.deleted_at = null;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE okf_entries SET slug = @slug, title = @title, category = @category, tags_json = @tags_json, aliases_json = @aliases_json, summary = @summary, user_markdown = @user_markdown, moderator_markdown = @moderator_markdown, admin_markdown = @admin_markdown, ai_facts_markdown = @ai_facts_markdown, source_links_json = @source_links_json, visibility = @visibility, status = @status, review_state = @review_state, updated_by = @updated_by, updated_at = @updated_at, reviewed_by = @reviewed_by, published_by = @published_by, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id"
|
||||
).run(next);
|
||||
addVersion(db, next.id, "update", current, next, actor, values.change_note || "Entry updated.");
|
||||
return normalizeEntry(next);
|
||||
}
|
||||
|
||||
function setEntryWorkflow(db, slug, action, actor, note = "") {
|
||||
const access = accessForUser(db, actor);
|
||||
const entry = db.prepare("SELECT * FROM okf_entries WHERE slug = ? AND deleted_at IS NULL").get(normalizeSlug(slug));
|
||||
if (!entry) throw new Error("OKF entry was not found.");
|
||||
const next = { ...entry };
|
||||
const now = Date.now();
|
||||
if (action === "review") {
|
||||
if (!access.canReview) throw new Error("OKF review permission is required.");
|
||||
next.review_state = "approved";
|
||||
next.reviewed_by = String(actor.id);
|
||||
next.reviewed_at = now;
|
||||
} else if (action === "publish") {
|
||||
if (!access.canImplement) throw new Error("OKF publish permission is required.");
|
||||
next.status = "published";
|
||||
next.review_state = "approved";
|
||||
next.published_by = String(actor.id);
|
||||
next.published_at = now;
|
||||
next.archived_at = null;
|
||||
} else if (action === "archive") {
|
||||
if (!access.canImplement) throw new Error("OKF archive permission is required.");
|
||||
next.status = "archived";
|
||||
next.archived_at = now;
|
||||
} else if (action === "restore") {
|
||||
if (!access.canImplement) throw new Error("OKF restore permission is required.");
|
||||
next.status = "draft";
|
||||
next.archived_at = null;
|
||||
} else if (action === "delete") {
|
||||
if (!access.canImplement) throw new Error("OKF delete permission is required.");
|
||||
next.deleted_at = now;
|
||||
} else {
|
||||
throw new Error("Unsupported OKF workflow action.");
|
||||
}
|
||||
next.updated_by = String(actor.id);
|
||||
next.updated_at = now;
|
||||
db.prepare(
|
||||
"UPDATE okf_entries SET status = @status, review_state = @review_state, reviewed_by = @reviewed_by, published_by = @published_by, updated_by = @updated_by, updated_at = @updated_at, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id"
|
||||
).run(next);
|
||||
addVersion(db, next.id, action, entry, next, actor, note || `Workflow action: ${action}.`);
|
||||
return normalizeEntry(next);
|
||||
}
|
||||
|
||||
function listVersions(db, entryId) {
|
||||
return db
|
||||
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? ORDER BY version_number DESC")
|
||||
.all(entryId)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
previous: parseJsonObject(row.previous_json),
|
||||
next: parseJsonObject(row.next_json)
|
||||
}));
|
||||
}
|
||||
|
||||
function grantPermission(db, values, actor) {
|
||||
if (!actor?.isAdmin) throw new Error("Only admins can grant OKF permissions.");
|
||||
const userId = String(values.user_id || "").trim();
|
||||
const level = String(values.level || "").trim();
|
||||
if (!userId) throw new Error("Select a user.");
|
||||
if (!PERMISSION_LEVELS.includes(level)) throw new Error("Select a valid OKF permission.");
|
||||
const now = Date.now();
|
||||
db.prepare("UPDATE okf_permissions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(now, userId);
|
||||
db.prepare(
|
||||
"INSERT INTO okf_permissions (user_id, level, granted_by, notes, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, NULL)"
|
||||
).run(userId, level, String(actor.id), cleanText(values.notes, 500), now);
|
||||
}
|
||||
|
||||
function revokePermission(db, id, actor) {
|
||||
if (!actor?.isAdmin) throw new Error("Only admins can revoke OKF permissions.");
|
||||
db.prepare("UPDATE okf_permissions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(Date.now(), Number(id));
|
||||
}
|
||||
|
||||
function listPermissions(db) {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT okf_permissions.*, user_profiles.internal_username AS user_name, grantor.internal_username AS granted_by_name " +
|
||||
"FROM okf_permissions " +
|
||||
"LEFT JOIN user_profiles ON user_profiles.id = okf_permissions.user_id " +
|
||||
"LEFT JOIN user_profiles AS grantor ON grantor.id = okf_permissions.granted_by " +
|
||||
"ORDER BY okf_permissions.revoked_at IS NULL DESC, okf_permissions.created_at DESC"
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
function listUsers(db) {
|
||||
return db.prepare("SELECT id, internal_username FROM user_profiles ORDER BY internal_username COLLATE NOCASE").all();
|
||||
}
|
||||
|
||||
function searchForAi(db, query, user, limit = 5) {
|
||||
const access = accessForUser(db, user);
|
||||
if (!access.authenticated) return [];
|
||||
return listEntries(db, { q: query }, user)
|
||||
.filter((entry) => entry.status === "published")
|
||||
.slice(0, limit)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
visibility: entry.visibility,
|
||||
summary: entry.summary,
|
||||
facts: [entry.user_markdown, access.isMod ? entry.moderator_markdown : "", access.isAdmin ? entry.admin_markdown : ""]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.slice(0, 4000),
|
||||
source: `/plugins/okf/${entry.slug}`
|
||||
}));
|
||||
}
|
||||
|
||||
function matchesFilters(entry, filters, access, options) {
|
||||
if (filters.status && entry.status !== filters.status) return false;
|
||||
if (filters.category && entry.category !== filters.category) return false;
|
||||
if (filters.tag && !parseJsonArray(entry.tags_json).includes(filters.tag)) return false;
|
||||
const q = String(filters.q || "").trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const visibleText = [
|
||||
entry.title,
|
||||
entry.category,
|
||||
entry.summary,
|
||||
entry.user_markdown,
|
||||
entry.tags_json,
|
||||
entry.aliases_json
|
||||
];
|
||||
if (access.isMod || access.isAdmin || options.management) visibleText.push(entry.moderator_markdown);
|
||||
if (access.isAdmin || options.management) visibleText.push(entry.admin_markdown, entry.ai_facts_markdown, entry.source_links_json);
|
||||
return visibleText.join(" ").toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
function normalizeValues(values, { actor, existing, canImplement, now }) {
|
||||
const status = canImplement
|
||||
? normalizeChoice(values.status, STATUS_VALUES, existing?.status || "draft")
|
||||
: existing?.status || "draft";
|
||||
const reviewState = canImplement
|
||||
? normalizeChoice(values.review_state, REVIEW_STATES, existing?.review_state || "draft")
|
||||
: "review_pending";
|
||||
const publishedAt = status === "published" ? (existing?.published_at || now) : existing?.published_at || null;
|
||||
return {
|
||||
slug: normalizeSlug(values.slug || existing?.slug || values.title),
|
||||
title: cleanText(values.title || existing?.title, 180),
|
||||
category: cleanText(values.category ?? existing?.category, 120),
|
||||
tags_json: JSON.stringify(splitList(values.tags ?? parseJsonArray(existing?.tags_json))),
|
||||
aliases_json: JSON.stringify(splitList(values.aliases ?? parseJsonArray(existing?.aliases_json))),
|
||||
summary: cleanText(values.summary ?? existing?.summary, 800),
|
||||
user_markdown: cleanText(values.user_markdown ?? existing?.user_markdown, 24000),
|
||||
moderator_markdown: cleanText(values.moderator_markdown ?? existing?.moderator_markdown, 24000),
|
||||
admin_markdown: cleanText(values.admin_markdown ?? existing?.admin_markdown, 24000),
|
||||
ai_facts_markdown: cleanText(values.ai_facts_markdown ?? existing?.ai_facts_markdown, 24000),
|
||||
source_links_json: JSON.stringify(cleanLinkList(values.source_links ?? parseJsonArray(existing?.source_links_json))),
|
||||
visibility: normalizeChoice(values.visibility, VISIBILITY_VALUES, existing?.visibility || "user"),
|
||||
status,
|
||||
review_state: reviewState,
|
||||
reviewed_by: existing?.reviewed_by || null,
|
||||
published_by: publishedAt && !existing?.published_by ? String(actor.id) : existing?.published_by || null,
|
||||
reviewed_at: existing?.reviewed_at || null,
|
||||
published_at: publishedAt,
|
||||
archived_at: existing?.archived_at || null,
|
||||
deleted_at: null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntry(entry) {
|
||||
return {
|
||||
...entry,
|
||||
tags: parseJsonArray(entry.tags_json),
|
||||
aliases: parseJsonArray(entry.aliases_json),
|
||||
source_links: parseJsonArray(entry.source_links_json)
|
||||
};
|
||||
}
|
||||
|
||||
function addVersion(db, entryId, changeType, previous, next, actor, note) {
|
||||
const row = db.prepare("SELECT MAX(version_number) AS latest FROM okf_versions WHERE entry_id = ?").get(entryId);
|
||||
const version = Number(row?.latest || 0) + 1;
|
||||
db.prepare(
|
||||
"INSERT INTO okf_versions (entry_id, version_number, changed_by, change_type, previous_json, next_json, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
entryId,
|
||||
version,
|
||||
actor?.id ? String(actor.id) : null,
|
||||
changeType,
|
||||
previous ? JSON.stringify(snapshotEntry(previous)) : null,
|
||||
next ? JSON.stringify(snapshotEntry(next)) : null,
|
||||
cleanText(note, 1000),
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotEntry(entry) {
|
||||
return {
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
tags: parseJsonArray(entry.tags_json),
|
||||
aliases: parseJsonArray(entry.aliases_json),
|
||||
summary: entry.summary,
|
||||
user_markdown: entry.user_markdown,
|
||||
moderator_markdown: entry.moderator_markdown,
|
||||
admin_markdown: entry.admin_markdown,
|
||||
ai_facts_markdown: entry.ai_facts_markdown,
|
||||
source_links: parseJsonArray(entry.source_links_json),
|
||||
visibility: entry.visibility,
|
||||
status: entry.status,
|
||||
review_state: entry.review_state
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSlug(value) {
|
||||
const slug = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 120);
|
||||
if (!slug) throw new Error("OKF slug is required.");
|
||||
return slug;
|
||||
}
|
||||
|
||||
function cleanText(value, maximum = 4000) {
|
||||
return String(value || "").replace(/\r\n?/g, "\n").trim().slice(0, maximum);
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
if (Array.isArray(value)) return value.map((item) => cleanText(item, 120)).filter(Boolean).slice(0, 50);
|
||||
return String(value || "")
|
||||
.split(/[,\n]/)
|
||||
.map((item) => cleanText(item, 120))
|
||||
.filter(Boolean)
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function cleanLinkList(value) {
|
||||
return splitList(value)
|
||||
.filter((item) => /^(https?:\/\/|\/(?!\/)|#)/i.test(item))
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function normalizeChoice(value, allowed, fallback) {
|
||||
const normalized = String(value || "").trim();
|
||||
return allowed.includes(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
function parseJsonArray(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value || "[]");
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "{}");
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERMISSION_LEVELS,
|
||||
REVIEW_STATES,
|
||||
STATUS_VALUES,
|
||||
VISIBILITY_VALUES,
|
||||
accessForUser,
|
||||
createEntry,
|
||||
ensureTables,
|
||||
getEditableEntry,
|
||||
getEntryBySlug,
|
||||
grantPermission,
|
||||
listEntries,
|
||||
listPermissions,
|
||||
listUsers,
|
||||
listVersions,
|
||||
okfPermissionForUser,
|
||||
revokePermission,
|
||||
searchForAi,
|
||||
setEntryWorkflow,
|
||||
updateEntry
|
||||
};
|
||||
220
plugins/okf/index.js
Normal file
220
plugins/okf/index.js
Normal file
@ -0,0 +1,220 @@
|
||||
const path = require("path");
|
||||
const {
|
||||
PERMISSION_LEVELS,
|
||||
REVIEW_STATES,
|
||||
STATUS_VALUES,
|
||||
VISIBILITY_VALUES,
|
||||
accessForUser,
|
||||
createEntry,
|
||||
ensureTables,
|
||||
getEditableEntry,
|
||||
getEntryBySlug,
|
||||
grantPermission,
|
||||
listEntries,
|
||||
listPermissions,
|
||||
listUsers,
|
||||
listVersions,
|
||||
revokePermission,
|
||||
searchForAi,
|
||||
setEntryWorkflow,
|
||||
updateEntry
|
||||
} = require("./backend/okf_store");
|
||||
const { renderMarkdown } = require("./backend/markdown");
|
||||
|
||||
const PLUGIN_ID = "okf";
|
||||
|
||||
module.exports = {
|
||||
id: PLUGIN_ID,
|
||||
init({ web, db }) {
|
||||
ensureTables(db);
|
||||
if (!global.lumiFrameworks) {
|
||||
global.lumiFrameworks = {};
|
||||
}
|
||||
global.lumiFrameworks.okf = {
|
||||
search: ({ query, user, limit } = {}) => searchForAi(db, query || "", user, limit || 5),
|
||||
accessForUser: (user) => accessForUser(db, user)
|
||||
};
|
||||
|
||||
const router = web.createRouter();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
res.locals.okfAccess = accessForUser(db, req.session.user);
|
||||
next();
|
||||
});
|
||||
|
||||
router.get("/", requireLogin, (req, res) => {
|
||||
const filters = {
|
||||
q: req.query.q || "",
|
||||
category: req.query.category || "",
|
||||
tag: req.query.tag || ""
|
||||
};
|
||||
const entries = listEntries(db, filters, req.session.user);
|
||||
res.render(view("index"), {
|
||||
title: "Knowledge",
|
||||
entries,
|
||||
filters,
|
||||
categories: categoriesFor(entries),
|
||||
tags: tagsFor(entries)
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/admin", requireOkfManagement(db), (req, res) => {
|
||||
renderAdmin(req, res, db);
|
||||
});
|
||||
|
||||
router.post("/admin/entries", requireOkfEdit(db), (req, res) => {
|
||||
try {
|
||||
const entry = createEntry(db, req.body, req.session.user);
|
||||
req.session.flash = { type: "success", message: "OKF entry created." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/admin/entries/:slug", requireOkfEdit(db), (req, res) => {
|
||||
try {
|
||||
const entry = updateEntry(db, req.params.slug, req.body, req.session.user);
|
||||
req.session.flash = { type: "success", message: "OKF entry updated." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/admin/entries/:slug/:action", requireOkfManagement(db), (req, res) => {
|
||||
try {
|
||||
const entry = setEntryWorkflow(db, req.params.slug, req.params.action, req.session.user, req.body.note || "");
|
||||
req.session.flash = { type: "success", message: "OKF workflow updated." };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`);
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/admin/permissions", requireAdmin, (req, res) => {
|
||||
try {
|
||||
grantPermission(db, req.body, req.session.user);
|
||||
req.session.flash = { type: "success", message: "OKF permission granted." };
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
}
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin#okf-permissions`);
|
||||
});
|
||||
|
||||
router.post("/admin/permissions/:id/revoke", requireAdmin, (req, res) => {
|
||||
try {
|
||||
revokePermission(db, req.params.id, req.session.user);
|
||||
req.session.flash = { type: "success", message: "OKF permission revoked." };
|
||||
} catch (error) {
|
||||
req.session.flash = { type: "error", message: error.message };
|
||||
}
|
||||
res.redirect(`/plugins/${PLUGIN_ID}/admin#okf-permissions`);
|
||||
});
|
||||
|
||||
router.get("/:slug", requireLogin, (req, res) => {
|
||||
const entry = getEntryBySlug(db, req.params.slug, req.session.user);
|
||||
if (!entry) {
|
||||
return res.status(404).render("error", {
|
||||
title: "Knowledge entry unavailable",
|
||||
message: "That OKF entry was not found or is not visible to your account."
|
||||
});
|
||||
}
|
||||
res.render(view("entry"), {
|
||||
title: entry.title,
|
||||
entry,
|
||||
renderMarkdown
|
||||
});
|
||||
});
|
||||
|
||||
web.mount(`/plugins/${PLUGIN_ID}`, router, {
|
||||
label: "Knowledge",
|
||||
role: "public",
|
||||
authRequired: true,
|
||||
section: "community",
|
||||
canAccess: (user) => Boolean(user)
|
||||
});
|
||||
web.addNavItem({
|
||||
label: "OKF Management",
|
||||
path: `/plugins/${PLUGIN_ID}/admin`,
|
||||
role: "public",
|
||||
authRequired: true,
|
||||
section: "admin",
|
||||
canAccess: (user) => accessForUser(db, user).canEdit
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function renderAdmin(req, res, db) {
|
||||
const filters = {
|
||||
q: req.query.q || "",
|
||||
status: req.query.status || "",
|
||||
category: req.query.category || "",
|
||||
tag: req.query.tag || ""
|
||||
};
|
||||
const entries = listEntries(db, filters, req.session.user, { management: true });
|
||||
const selected = req.query.edit ? getEditableEntry(db, req.query.edit) : null;
|
||||
const versions = selected ? listVersions(db, selected.id) : [];
|
||||
res.render(view("admin"), {
|
||||
title: "OKF Management",
|
||||
entries,
|
||||
filters,
|
||||
selected,
|
||||
versions,
|
||||
permissions: listPermissions(db),
|
||||
users: listUsers(db),
|
||||
levels: PERMISSION_LEVELS,
|
||||
statuses: STATUS_VALUES,
|
||||
visibilityValues: VISIBILITY_VALUES,
|
||||
reviewStates: REVIEW_STATES,
|
||||
categories: categoriesFor(entries),
|
||||
tags: tagsFor(entries),
|
||||
renderMarkdown
|
||||
});
|
||||
}
|
||||
|
||||
function view(name) {
|
||||
return path.join(__dirname, "views", `${name}.ejs`);
|
||||
}
|
||||
|
||||
function requireLogin(req, res, next) {
|
||||
if (req.session.user) return next();
|
||||
return res.redirect("/login");
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session.user?.isAdmin) return next();
|
||||
return denied(res);
|
||||
}
|
||||
|
||||
function requireOkfManagement(db) {
|
||||
return (req, res, next) => {
|
||||
if (accessForUser(db, req.session.user).canEdit) return next();
|
||||
return denied(res);
|
||||
};
|
||||
}
|
||||
|
||||
function requireOkfEdit(db) {
|
||||
return (req, res, next) => {
|
||||
if (accessForUser(db, req.session.user).canEdit) return next();
|
||||
return denied(res);
|
||||
};
|
||||
}
|
||||
|
||||
function denied(res) {
|
||||
return res.status(403).render("error", {
|
||||
title: "Access denied",
|
||||
message: "You do not have access to that page."
|
||||
});
|
||||
}
|
||||
|
||||
function categoriesFor(entries) {
|
||||
return Array.from(new Set(entries.map((entry) => entry.category).filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
function tagsFor(entries) {
|
||||
return Array.from(new Set(entries.flatMap((entry) => entry.tags || []).filter(Boolean))).sort();
|
||||
}
|
||||
7
plugins/okf/plugin.json
Normal file
7
plugins/okf/plugin.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "okf",
|
||||
"name": "OKF Knowledge",
|
||||
"version": "0.1.0",
|
||||
"description": "Role-gated knowledge, facts, and Q&A entries for Lumi communities.",
|
||||
"main": "index.js"
|
||||
}
|
||||
103
plugins/okf/tests/verify.js
Normal file
103
plugins/okf/tests/verify.js
Normal file
@ -0,0 +1,103 @@
|
||||
const assert = require("assert");
|
||||
const Database = require("better-sqlite3");
|
||||
const {
|
||||
accessForUser,
|
||||
createEntry,
|
||||
ensureTables,
|
||||
getEntryBySlug,
|
||||
grantPermission,
|
||||
listEntries,
|
||||
listPermissions,
|
||||
listVersions,
|
||||
revokePermission,
|
||||
searchForAi,
|
||||
setEntryWorkflow,
|
||||
updateEntry
|
||||
} = require("../backend/okf_store");
|
||||
const { renderMarkdown } = require("../backend/markdown");
|
||||
|
||||
const db = new Database(":memory:");
|
||||
db.exec(`
|
||||
CREATE TABLE user_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
internal_username TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
ensureTables(db);
|
||||
|
||||
const now = Date.now();
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("admin-1", "Admin", now, now);
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("mod-1", "Mod", now, now);
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("user-1", "User", now, now);
|
||||
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("editor-1", "Editor", now, now);
|
||||
|
||||
const admin = { id: "admin-1", isAdmin: true, isMod: true };
|
||||
const mod = { id: "mod-1", isMod: true };
|
||||
const user = { id: "user-1" };
|
||||
const editor = { id: "editor-1" };
|
||||
|
||||
grantPermission(db, { user_id: "editor-1", level: "edit", notes: "Trusted writer" }, admin);
|
||||
assert.equal(accessForUser(db, editor).canEdit, true);
|
||||
assert.equal(listPermissions(db).filter((grant) => !grant.revoked_at).length, 1);
|
||||
|
||||
const entry = createEntry(db, {
|
||||
title: "Currency basics",
|
||||
slug: "currency-basics",
|
||||
category: "Community",
|
||||
tags: "currency, coins",
|
||||
aliases: "How do coins work?\nWhat is currency?",
|
||||
summary: "Explains the community currency.",
|
||||
user_markdown: "Users earn **coins** through activity.",
|
||||
moderator_markdown: "Moderators may help users with missing transaction context.",
|
||||
admin_markdown: "Admin-only economy configuration details.",
|
||||
ai_facts_markdown: "The primary community currency is coins.",
|
||||
source_links: "/commands",
|
||||
visibility: "mod",
|
||||
status: "published",
|
||||
review_state: "approved"
|
||||
}, admin);
|
||||
|
||||
assert.equal(entry.slug, "currency-basics");
|
||||
assert.equal(listEntries(db, {}, user).length, 0);
|
||||
assert.equal(listEntries(db, {}, mod).length, 1);
|
||||
assert.equal(getEntryBySlug(db, "currency-basics", user), null);
|
||||
assert.equal(getEntryBySlug(db, "currency-basics", mod).moderator_markdown.includes("Moderators"), true);
|
||||
assert.equal(getEntryBySlug(db, "currency-basics", admin).admin_markdown.includes("Admin-only"), true);
|
||||
|
||||
const proposed = createEntry(db, {
|
||||
title: "Draft from editor",
|
||||
slug: "draft-from-editor",
|
||||
summary: "Editor draft",
|
||||
user_markdown: "Draft content."
|
||||
}, editor);
|
||||
assert.equal(proposed.status, "draft");
|
||||
assert.equal(proposed.review_state, "review_pending");
|
||||
assert.equal(listEntries(db, {}, user).some((item) => item.slug === "draft-from-editor"), false);
|
||||
assert.equal(listEntries(db, {}, editor, { management: true }).some((item) => item.slug === "draft-from-editor"), true);
|
||||
|
||||
updateEntry(db, "draft-from-editor", {
|
||||
title: "Draft from editor",
|
||||
slug: "draft-from-editor",
|
||||
summary: "Updated editor draft",
|
||||
user_markdown: "Updated draft content.",
|
||||
change_note: "Editor update."
|
||||
}, editor);
|
||||
assert.equal(listVersions(db, proposed.id).length, 2);
|
||||
|
||||
setEntryWorkflow(db, "draft-from-editor", "review", admin);
|
||||
setEntryWorkflow(db, "draft-from-editor", "publish", admin);
|
||||
assert.equal(getEntryBySlug(db, "draft-from-editor", user).summary, "Updated editor draft");
|
||||
|
||||
assert(searchForAi(db, "currency", mod).some((item) => item.slug === "currency-basics"));
|
||||
assert.equal(searchForAi(db, "admin-only economy", user).length, 0);
|
||||
assert(renderMarkdown("<script>alert(1)</script> **safe**").includes("<script>"));
|
||||
assert.equal(renderMarkdown("[bad](javascript:alert(1))").includes("javascript:"), false);
|
||||
|
||||
const grant = listPermissions(db).find((row) => row.user_id === "editor-1" && !row.revoked_at);
|
||||
revokePermission(db, grant.id, admin);
|
||||
assert.equal(accessForUser(db, editor).canEdit, false);
|
||||
|
||||
db.close();
|
||||
console.log("OKF plugin verification passed.");
|
||||
289
plugins/okf/views/admin.ejs
Normal file
289
plugins/okf/views/admin.ejs
Normal file
@ -0,0 +1,289 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<%- include("../../../src/web/views/partials/page-header", {
|
||||
eyebrow: "Administration",
|
||||
pageTitle: "OKF Management",
|
||||
description: "Manage role-gated knowledge entries, review state, version history, and OKF-specific editing permissions."
|
||||
}) %>
|
||||
<form method="get" action="/plugins/okf/admin" class="log-controls">
|
||||
<label>
|
||||
<span>Search</span>
|
||||
<input name="q" value="<%= filters.q %>" placeholder="Search visible OKF fields" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select name="status">
|
||||
<option value="">All statuses</option>
|
||||
<% statuses.forEach((status) => { %>
|
||||
<option value="<%= status %>" <%= filters.status === status ? "selected" : "" %>><%= status %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Category</span>
|
||||
<select name="category">
|
||||
<option value="">All categories</option>
|
||||
<% categories.forEach((category) => { %>
|
||||
<option value="<%= category %>" <%= filters.category === category ? "selected" : "" %>><%= category %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tag</span>
|
||||
<select name="tag">
|
||||
<option value="">All tags</option>
|
||||
<% tags.forEach((tag) => { %>
|
||||
<option value="<%= tag %>" <%= filters.tag === tag ? "selected" : "" %>><%= tag %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button subtle" type="submit">Filter</button>
|
||||
<a class="button subtle" href="/plugins/okf/admin">Reset</a>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Entries</h2>
|
||||
<p class="hint"><%= entries.length %> entr<%= entries.length === 1 ? "y" : "ies" %> shown.</p>
|
||||
</div>
|
||||
<a class="button subtle" href="/plugins/okf">Open OKF</a>
|
||||
</div>
|
||||
<% if (!entries.length) { %>
|
||||
<div class="empty-state">No OKF entries match this filter.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Entry</th>
|
||||
<th>Status</th>
|
||||
<th>Visibility</th>
|
||||
<th>Review</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% entries.forEach((entry) => { %>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/plugins/okf/admin?edit=<%= entry.slug %>"><strong><%= entry.title %></strong></a>
|
||||
<p class="hint"><%= entry.slug %> · <%= entry.category || "General" %></p>
|
||||
</td>
|
||||
<td><span class="badge"><%= entry.status %></span></td>
|
||||
<td><%= entry.visibility %></td>
|
||||
<td><%= entry.review_state %></td>
|
||||
<td><%= new Date(entry.updated_at).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section class="card" id="okf-editor">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2><%= selected ? "Edit OKF entry" : "Create OKF entry" %></h2>
|
||||
<p class="hint">Markdown is sanitized before rendering. User, moderator, and admin content are filtered server-side.</p>
|
||||
</div>
|
||||
<% if (selected) { %>
|
||||
<a class="button subtle" href="/plugins/okf/admin">Create new</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<form method="post" action="<%= selected ? `/plugins/okf/admin/entries/${selected.slug}` : '/plugins/okf/admin/entries' %>" class="form-grid">
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input name="title" required value="<%= selected ? selected.title : '' %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
<input name="slug" required value="<%= selected ? selected.slug : '' %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Category</label>
|
||||
<input name="category" value="<%= selected ? selected.category : '' %>" placeholder="General, Support, Commands" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Tags</label>
|
||||
<input name="tags" value="<%= selected ? selected.tags.join(', ') : '' %>" placeholder="Comma-separated tags" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<select name="visibility">
|
||||
<% visibilityValues.forEach((value) => { %>
|
||||
<option value="<%= value %>" <%= selected && selected.visibility === value ? "selected" : "" %>><%= value %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select name="status" <%= okfAccess.canImplement ? "" : "disabled" %>>
|
||||
<% statuses.forEach((status) => { %>
|
||||
<option value="<%= status %>" <%= selected && selected.status === status ? "selected" : "" %>><%= status %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<% if (!okfAccess.canImplement) { %><span class="hint">Editors can propose changes; publishing requires implement permission.</span><% } %>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Review state</label>
|
||||
<select name="review_state" <%= okfAccess.canImplement ? "" : "disabled" %>>
|
||||
<% reviewStates.forEach((state) => { %>
|
||||
<option value="<%= state %>" <%= selected && selected.review_state === state ? "selected" : "" %>><%= state %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Aliases / related questions</label>
|
||||
<textarea name="aliases" rows="3" placeholder="One per line or comma-separated"><%= selected ? selected.aliases.join('\n') : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Short summary</label>
|
||||
<textarea name="summary" rows="3"><%= selected ? selected.summary : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>User-facing Markdown answer</label>
|
||||
<textarea name="user_markdown" rows="8"><%= selected ? selected.user_markdown : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Moderator/support Markdown details</label>
|
||||
<textarea name="moderator_markdown" rows="6"><%= selected ? selected.moderator_markdown : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Admin/internal Markdown details</label>
|
||||
<textarea name="admin_markdown" rows="6"><%= selected ? selected.admin_markdown : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>AI-facing facts/context</label>
|
||||
<textarea name="ai_facts_markdown" rows="6"><%= selected ? selected.ai_facts_markdown : '' %></textarea>
|
||||
<span class="hint">Stored now for future AI retrieval integration. The public UI only shows this to admins/editors.</span>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Source links / references</label>
|
||||
<textarea name="source_links" rows="3" placeholder="One URL or local path per line"><%= selected ? selected.source_links.join('\n') : '' %></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Change note</label>
|
||||
<input name="change_note" placeholder="Optional note for version history" />
|
||||
</div>
|
||||
<div class="field full button-group centered">
|
||||
<button class="button" type="submit"><%= selected ? "Save OKF entry" : "Create OKF entry" %></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<% if (selected) { %>
|
||||
<div class="button-group centered">
|
||||
<% if (okfAccess.canReview) { %>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/review" class="inline-form">
|
||||
<button class="button subtle" type="submit">Mark reviewed</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (okfAccess.canImplement) { %>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/publish" class="inline-form">
|
||||
<button class="button subtle" type="submit">Publish</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/archive" class="inline-form">
|
||||
<button class="button subtle" type="submit">Archive</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/restore" class="inline-form">
|
||||
<button class="button subtle" type="submit">Restore as draft</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-text="Delete this OKF entry? The version history is kept for audit, but the entry will no longer be visible.">
|
||||
<button class="button danger" type="submit">Delete</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<details class="feedback-metadata">
|
||||
<summary>Version history</summary>
|
||||
<% if (!versions.length) { %>
|
||||
<p class="hint">No versions recorded yet.</p>
|
||||
<% } else { %>
|
||||
<% versions.forEach((version) => { %>
|
||||
<article class="feedback-copy-block">
|
||||
<strong>Version <%= version.version_number %> · <%= version.change_type %></strong>
|
||||
<p class="hint"><%= new Date(version.created_at).toLocaleString() %> · <%= version.changed_by || "system" %> · <%= version.note || "No note." %></p>
|
||||
<details>
|
||||
<summary>Snapshot</summary>
|
||||
<pre><%= JSON.stringify(version.next, null, 2) %></pre>
|
||||
</details>
|
||||
</article>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</details>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<% if (okfAccess.canManagePermissions) { %>
|
||||
<section class="card" id="okf-permissions">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>OKF permission grants</h2>
|
||||
<p class="hint">These grants are independent from normal Lumi admin/mod roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/plugins/okf/admin/permissions" class="form-grid">
|
||||
<div class="field">
|
||||
<label>User</label>
|
||||
<select name="user_id" required>
|
||||
<option value="">Select user</option>
|
||||
<% users.forEach((user) => { %>
|
||||
<option value="<%= user.id %>"><%= user.internal_username %> (<%= user.id %>)</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Permission</label>
|
||||
<select name="level" required>
|
||||
<% levels.forEach((level) => { %>
|
||||
<option value="<%= level %>"><%= level %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>Notes</label>
|
||||
<input name="notes" placeholder="Optional reason for this grant" />
|
||||
</div>
|
||||
<div class="field full button-group centered">
|
||||
<button class="button" type="submit">Grant OKF permission</button>
|
||||
</div>
|
||||
</form>
|
||||
<% if (!permissions.length) { %>
|
||||
<p class="hint">No OKF-specific permissions have been granted.</p>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Level</th>
|
||||
<th>Granted</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% permissions.forEach((grant) => { %>
|
||||
<tr>
|
||||
<td><%= grant.user_name || grant.user_id %></td>
|
||||
<td><%= grant.level %></td>
|
||||
<td><%= new Date(grant.created_at).toLocaleString() %><br><span class="hint">by <%= grant.granted_by_name || grant.granted_by || "unknown" %></span></td>
|
||||
<td><%= grant.revoked_at ? `Revoked ${new Date(grant.revoked_at).toLocaleString()}` : "Active" %></td>
|
||||
<td>
|
||||
<% if (!grant.revoked_at) { %>
|
||||
<form method="post" action="/plugins/okf/admin/permissions/<%= grant.id %>/revoke" class="inline-form">
|
||||
<button class="button subtle" type="submit">Revoke</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||
58
plugins/okf/views/entry.ejs
Normal file
58
plugins/okf/views/entry.ejs
Normal file
@ -0,0 +1,58 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<span class="eyebrow">Knowledge</span>
|
||||
<h1><%= entry.title %></h1>
|
||||
<p class="hint"><%= entry.category || "General" %> · Updated <%= new Date(entry.updated_at).toLocaleString() %></p>
|
||||
</div>
|
||||
<a class="button subtle" href="/plugins/okf">Back to OKF</a>
|
||||
</div>
|
||||
<% if (entry.summary) { %>
|
||||
<p><%= entry.summary %></p>
|
||||
<% } %>
|
||||
<% if (entry.tags && entry.tags.length) { %>
|
||||
<p>
|
||||
<% entry.tags.forEach((tag) => { %>
|
||||
<span class="badge"><%= tag %></span>
|
||||
<% }) %>
|
||||
</p>
|
||||
<% } %>
|
||||
<% if (entry.aliases && entry.aliases.length) { %>
|
||||
<p class="hint">Related questions: <%= entry.aliases.join(", ") %></p>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Answer</h2>
|
||||
<div class="custom-page-markdown"><%- renderMarkdown(entry.user_markdown) %></div>
|
||||
</section>
|
||||
|
||||
<% if (entry.moderator_markdown) { %>
|
||||
<section class="card">
|
||||
<h2>Moderator support details</h2>
|
||||
<div class="custom-page-markdown"><%- renderMarkdown(entry.moderator_markdown) %></div>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<% if (entry.admin_markdown || entry.ai_facts_markdown || (entry.source_links && entry.source_links.length)) { %>
|
||||
<section class="card">
|
||||
<h2>Admin/internal details</h2>
|
||||
<% if (entry.admin_markdown) { %>
|
||||
<div class="custom-page-markdown"><%- renderMarkdown(entry.admin_markdown) %></div>
|
||||
<% } %>
|
||||
<% if (entry.ai_facts_markdown) { %>
|
||||
<h3>AI facts</h3>
|
||||
<div class="custom-page-markdown"><%- renderMarkdown(entry.ai_facts_markdown) %></div>
|
||||
<% } %>
|
||||
<% if (entry.source_links && entry.source_links.length) { %>
|
||||
<h3>Sources</h3>
|
||||
<ul>
|
||||
<% entry.source_links.forEach((link) => { %>
|
||||
<li><a href="<%= link %>" target="_blank" rel="noopener noreferrer"><%= link %></a></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||
80
plugins/okf/views/index.ejs
Normal file
80
plugins/okf/views/index.ejs
Normal file
@ -0,0 +1,80 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<section class="card">
|
||||
<%- include("../../../src/web/views/partials/page-header", {
|
||||
eyebrow: "Knowledge",
|
||||
pageTitle: "OKF Knowledge",
|
||||
description: "Browse curated community facts, help answers, and role-aware support knowledge."
|
||||
}) %>
|
||||
<form method="get" action="/plugins/okf" class="log-controls">
|
||||
<label>
|
||||
<span>Search</span>
|
||||
<input name="q" value="<%= filters.q %>" placeholder="Search questions, facts, or topics" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Category</span>
|
||||
<select name="category">
|
||||
<option value="">All categories</option>
|
||||
<% categories.forEach((category) => { %>
|
||||
<option value="<%= category %>" <%= filters.category === category ? "selected" : "" %>><%= category %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tag</span>
|
||||
<select name="tag">
|
||||
<option value="">All tags</option>
|
||||
<% tags.forEach((tag) => { %>
|
||||
<option value="<%= tag %>" <%= filters.tag === tag ? "selected" : "" %>><%= tag %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button subtle" type="submit">Search</button>
|
||||
<a class="button subtle" href="/plugins/okf">Reset</a>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Knowledge entries</h2>
|
||||
<p class="hint"><%= entries.length %> visible entr<%= entries.length === 1 ? "y" : "ies" %>.</p>
|
||||
</div>
|
||||
<% if (okfAccess.canEdit) { %>
|
||||
<a class="button subtle" href="/plugins/okf/admin">Manage OKF</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (!entries.length) { %>
|
||||
<div class="empty-state">No OKF entries match this filter.</div>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Entry</th>
|
||||
<th>Category</th>
|
||||
<th>Tags</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% entries.forEach((entry) => { %>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/plugins/okf/<%= entry.slug %>"><strong><%= entry.title %></strong></a>
|
||||
<p class="hint"><%= entry.summary || "No summary provided." %></p>
|
||||
</td>
|
||||
<td><%= entry.category || "General" %></td>
|
||||
<td>
|
||||
<% (entry.tags || []).forEach((tag) => { %>
|
||||
<span class="badge"><%= tag %></span>
|
||||
<% }) %>
|
||||
</td>
|
||||
<td><%= new Date(entry.updated_at).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||
Loading…
Reference in New Issue
Block a user