552 lines
22 KiB
JavaScript
552 lines
22 KiB
JavaScript
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 restoreVersion(db, entryId, versionNumber, actor, note = "") {
|
|
const access = accessForUser(db, actor);
|
|
if (!access.canImplement) throw new Error("OKF restore permission is required.");
|
|
const current = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId);
|
|
if (!current) throw new Error("OKF entry was not found.");
|
|
const version = db
|
|
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? AND version_number = ?")
|
|
.get(entryId, Number(versionNumber));
|
|
const snapshot = parseJsonObject(version?.next_json);
|
|
if (!version || !snapshot.title || !snapshot.slug) {
|
|
throw new Error("OKF version snapshot is not restorable.");
|
|
}
|
|
const now = Date.now();
|
|
const restored = {
|
|
...current,
|
|
slug: normalizeSlug(snapshot.slug),
|
|
title: cleanText(snapshot.title, 180),
|
|
category: cleanText(snapshot.category, 120),
|
|
tags_json: JSON.stringify(splitList(snapshot.tags || [])),
|
|
aliases_json: JSON.stringify(splitLines(snapshot.aliases || [])),
|
|
summary: cleanText(snapshot.summary, 800),
|
|
user_markdown: cleanText(snapshot.user_markdown, 24000),
|
|
moderator_markdown: cleanText(snapshot.moderator_markdown, 24000),
|
|
admin_markdown: cleanText(snapshot.admin_markdown, 24000),
|
|
ai_facts_markdown: cleanText(snapshot.ai_facts_markdown, 24000),
|
|
source_links_json: JSON.stringify(cleanLinkList(snapshot.source_links || [])),
|
|
visibility: normalizeChoice(snapshot.visibility, VISIBILITY_VALUES, "user"),
|
|
status: normalizeChoice(snapshot.status, STATUS_VALUES, "draft"),
|
|
review_state: normalizeChoice(snapshot.review_state, REVIEW_STATES, "draft"),
|
|
updated_by: String(actor.id),
|
|
updated_at: now,
|
|
reviewed_by: snapshot.review_state === "approved" ? current.reviewed_by : null,
|
|
published_by: snapshot.status === "published" ? current.published_by : null,
|
|
reviewed_at: snapshot.review_state === "approved" ? current.reviewed_at : null,
|
|
published_at: snapshot.status === "published" ? current.published_at : null,
|
|
archived_at: snapshot.status === "archived" ? (current.archived_at || now) : null,
|
|
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(restored);
|
|
addVersion(
|
|
db,
|
|
restored.id,
|
|
"restore_version",
|
|
current,
|
|
restored,
|
|
actor,
|
|
note || `Restored version ${Number(versionNumber)}.`
|
|
);
|
|
return normalizeEntry(restored);
|
|
}
|
|
|
|
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 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" && entry.review_state === "approved")
|
|
.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, entry.moderator_markdown, entry.admin_markdown, entry.ai_facts_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(splitLines(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 splitLines(value) {
|
|
if (Array.isArray(value)) return value.map((item) => cleanText(item, 240)).filter(Boolean).slice(0, 50);
|
|
return String(value || "")
|
|
.split(/\n+/)
|
|
.map((item) => cleanText(item, 240))
|
|
.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,
|
|
listVersions,
|
|
okfPermissionForUser,
|
|
revokePermission,
|
|
restoreVersion,
|
|
searchForAi,
|
|
setEntryWorkflow,
|
|
updateEntry
|
|
};
|