kb-demo / static /lib /pure.js
RayLi-Git
fix: 檔案移至根目錄 + 套用示範 README
a7cd101
Raw
History Blame Contribute Delete
13.1 kB
/* KBV5 純函式庫(可單元測試)。
*
* 把 app.js 裡「高回歸風險、純輸入→輸出」的資料轉換邏輯抽出來,讓 vitest 能直接測,
* 同時 app.js 以 <script> 載入後用 window.KBV5Pure.xxx 呼叫——測到的就是線上跑的同一份。
*
* UMD:CommonJS(vitest require)與瀏覽器全域(window.KBV5Pure)雙支援。
*/
(function (root, factory) {
const mod = factory();
if (typeof module !== "undefined" && module.exports) module.exports = mod;
root.KBV5Pure = mod; // 瀏覽器 window / vitest globalThis 都掛上,測試與線上用同一份
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
// 評論作者顯示名:display_name → username → 退回 #id(B2;避免顯示「#42」)
function commentAuthorName(c) {
if (!c) return "";
return c.author_display_name || c.author_username || ("#" + c.author_id);
}
// 媒體庫卡片 thumb 類別(依 mime 分 image / video / pdf / doc)
function mediaThumbKind(mime) {
mime = mime || "";
if (mime.indexOf("image/") === 0) return "image";
if (mime.indexOf("video/") === 0) return "video";
if (mime === "application/pdf") return "pdf";
return "doc";
}
// library 套篩選後的 header 文案(B1)。narrowed = 篩選後比載入的少。
function libFilterHeaderText(opts) {
const baseTotal = opts.baseTotal || 0;
const baseQuery = opts.baseQuery || "";
const filteredLen = opts.filteredLen || 0;
const loadedLen = opts.loadedLen || 0;
const narrowed = filteredLen !== loadedLen;
if (narrowed) {
return (baseQuery ? `搜尋「${baseQuery}」· ` : "") + `篩選後顯示 ${filteredLen} 篇`;
}
return baseQuery ? `搜尋「${baseQuery}」· 找到 ${baseTotal} 篇` : `共 ${baseTotal} 篇文件`;
}
// 「全選 N 篇」標籤(#8)
function selectAllLabel(total) {
return `全選 ${total || 0} 篇`;
}
// 提交頁可見度標籤(從 wireSubmit 抽出的純函式;baseLabel 由呼叫端傳入避免耦合 VIS_BADGE)
function visibilityLabel(cfg, baseLabel) {
cfg = cfg || {};
const extra = [];
if (cfg.visibility === "selected_departments" &&
cfg.allowed_departments && cfg.allowed_departments.length) {
extra.push(`${cfg.allowed_departments.length} 部門`);
}
if (cfg.access_list && cfg.access_list.length) extra.push(`${cfg.access_list.length} 人`);
return "可見度:" + baseLabel + (extra.length ? `(${extra.join("・")})` : "");
}
// PII 關鍵字/片語比對(層1/2)。rules=[{rule, pattern, variants[]}] → 命中清單。
function scanKeywords(text, rules) {
const low = (text || "").toLowerCase();
const hits = [];
for (const k of (rules || [])) {
const cands = [k.pattern].concat(k.variants || []);
for (const c of cands) {
if (c && low.includes(String(c).toLowerCase())) {
hits.push({ name: k.rule, match: String(c), index: low.indexOf(String(c).toLowerCase()) });
break;
}
}
}
return hits;
}
// 正則規則比對(scanPii 的純核心)。rules=[{name, re}] → 命中清單。
function matchRegexRules(text, rules) {
const hits = [];
if (!text) return hits;
for (const r of (rules || [])) {
r.re.lastIndex = 0;
let m;
while ((m = r.re.exec(text)) !== null) {
hits.push({ name: r.name, match: m[0], index: m.index });
if (!r.re.global) break; // 非 global 不會推進 lastIndex → 防無限迴圈
}
}
return hits;
}
// 提交鈕閘門:依字數 / 上限 / PII 命中數,決定可否送出 / 存草稿。
// 安全關鍵:PII 命中 → 一律不可送出(§9.1 鐵則的前端把關)。
function submitGate(opts) {
opts = opts || {};
const empty = (opts.len || 0) === 0;
const tooMany = (opts.len || 0) > (opts.max || 0);
const piiBlocked = (opts.piiHitCount || 0) > 0;
return {
empty, tooMany, piiBlocked,
canSubmit: !(empty || tooMany || piiBlocked),
canDraft: !(empty || tooMany),
};
}
// 相對時間標籤(「剛剛 / N 分鐘前 / N 天前」)。nowMs 由呼叫端傳入以利測試。
function relativeTime(iso, nowMs) {
if (!iso) return "";
const t = new Date(iso.replace(" ", "T") + (iso.endsWith("Z") ? "" : "Z"));
if (isNaN(t.getTime())) return iso;
const diff = ((nowMs || 0) - t.getTime()) / 1000;
if (diff < 60) return "剛剛";
if (diff < 3600) return Math.floor(diff / 60) + " 分鐘前";
if (diff < 86400) return Math.floor(diff / 3600) + " 小時前";
if (diff < 604800) return Math.floor(diff / 86400) + " 天前";
return t.toLocaleDateString("zh-TW");
}
// 逗號/頓號/換行分隔字串 → 去空白、去空項、去重 的陣列(標籤/清單輸入共用)。
function parseCsvList(str) {
const seen = new Set();
const out = [];
for (const x of String(str || "").split(/[,,、\n]/)) {
const v = x.trim();
if (v && !seen.has(v)) { seen.add(v); out.push(v); }
}
return out;
}
// 密碼強度評分(PRD §10.5:≥8 字、≥1 大寫、≥1 小寫;數字/特殊字元加分)。
function gradePassword(p) {
if (!p) return { score: 0, label: "—", color: "var(--text-3)", barColor: "transparent" };
let s = 0;
if (p.length >= 8) s++;
if (/[A-Z]/.test(p)) s++;
if (/[a-z]/.test(p)) s++;
if (/[0-9]/.test(p) || /[^A-Za-z0-9]/.test(p)) s++;
if (p.length >= 12 && /[0-9]/.test(p) && /[^A-Za-z0-9]/.test(p)) s++;
const missing = [];
if (p.length < 8) missing.push("≥ 8 字");
if (!/[A-Z]/.test(p)) missing.push("1 大寫");
if (!/[a-z]/.test(p)) missing.push("1 小寫");
let label, color, barColor, width;
if (s <= 1) { label = "弱"; color = "var(--danger)"; barColor = "var(--danger)"; width = "25%"; }
else if (s === 2) { label = "中等"; color = "#d97706"; barColor = "#d97706"; width = "50%"; }
else if (s === 3) { label = "良好"; color = "var(--ok)"; barColor = "var(--ok)"; width = "75%"; }
else { label = "強"; color = "var(--ok)"; barColor = "var(--ok)"; width = "100%"; }
let hint;
if (missing.length) hint = "強度:" + label + " · 還需:" + missing.join("、");
else if (s < 4) hint = "強度:" + label + " · 建議加入數字或特殊符號";
else hint = "強度:" + label;
return { score: s, label, color, barColor, width, hint, missing };
}
// 檔案大小格式化(bytes → KB / MB 字串,預設精度與既有顯示一致)
function fmtKB(bytes) { return (Number(bytes || 0) / 1024).toFixed(1); }
function fmtMB(bytes, digits) {
return (Number(bytes || 0) / 1024 / 1024).toFixed(digits == null ? 2 : digits);
}
// token 數簡寫(1.2K / 3.4M)
function fmtTok(n) {
n = Number(n || 0);
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
return String(n);
}
// email 網域是否在白名單內(公司網域限制,§10.5)
function isAllowedEmailDomain(email, domains) {
const m = String(email || "").match(/@([A-Za-z0-9.\-]+)$/);
if (!m) return false;
return (domains || []).map((d) => String(d).toLowerCase()).includes(m[1].toLowerCase());
}
// HTML 跳脫(XSS 防護,全站插 DOM 前用)
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// ===== 字詞級 diff(LCS):合併預覽 / 版本比對共用 =====
function diffTokens(s) {
return String(s || "").match(/[一-鿿]|[A-Za-z0-9_]+|\n|\s+|[^\s]/g) || [];
}
function lcsOps(a, b) {
const n = a.length, m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--)
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
const ops = []; let i = 0, j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { ops.push({ t: "eq", v: a[i] }); i++; j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "del", v: a[i] }); i++; }
else { ops.push({ t: "ins", v: b[j] }); j++; }
}
while (i < n) ops.push({ t: "del", v: a[i++] });
while (j < m) ops.push({ t: "ins", v: b[j++] });
return ops;
}
function wordDiffOps(base, merged) {
const a = diffTokens(base), b = diffTokens(merged);
if (a.length > 2600 || b.length > 2600) { // 太長 → 改行級,避免 O(n*m) 爆量
const al = String(base || "").split("\n"), bl = String(merged || "").split("\n");
return lcsOps(al.map((x) => x + "\n"), bl.map((x) => x + "\n"));
}
return lcsOps(a, b);
}
// 回 {left, right}:left=既有(刪除紅底)、right=合併後(新增綠底)
function diffSides(base, merged) {
let left = "", right = "";
for (const o of wordDiffOps(base, merged)) {
const esc = escapeHtml(o.v);
if (o.t === "eq") { left += esc; right += esc; }
else if (o.t === "del") left += `<span style="background:#fecaca;">${esc}</span>`;
else right += `<span style="background:#bbf7d0;">${esc}</span>`;
}
return { left, right };
}
// 行內 markdown(**粗體** *斜體* `code` [text](url))。輸入須已 escapeHtml。
// 連結只允許 https?:// → 擋 javascript: 等危險協定(XSS)。
function inlineMarkdown(escaped) {
let s = String(escaped || "");
s = s.replace(/`([^`\n]+)`/g, '<code style="background:#f1f5f9;padding:1px 5px;border-radius:3px;font-family:ui-monospace,monospace;font-size:0.92em;">$1</code>');
s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^\\*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
return s;
}
// 極簡 markdown 渲染(標題/清單/程式碼/段落)。先 escape 再轉 → XSS 安全。
function renderMarkdown(md) {
const lines = String(md || "").split(/\r?\n/);
let html = "", inCode = false, inList = false;
const closeList = () => { if (inList) { html += "</ul>"; inList = false; } };
for (const line of lines) {
if (line.trim().startsWith("```")) {
if (!inCode) { closeList(); html += "<pre>"; inCode = true; }
else { html += "</pre>"; inCode = false; }
continue;
}
if (inCode) { html += escapeHtml(line) + "\n"; continue; }
const h = line.match(/^(#{1,4})\s+(.*)$/);
if (h) {
closeList();
const lvl = Math.min(h[1].length + 1, 6);
html += `<h${lvl}>${inlineMarkdown(escapeHtml(h[2]))}</h${lvl}>`;
continue;
}
const li = line.match(/^\s*[-*]\s+(.*)$/);
if (li) {
if (!inList) { html += "<ul>"; inList = true; }
html += `<li>${inlineMarkdown(escapeHtml(li[1]))}</li>`;
continue;
}
if (line.trim() === "") { closeList(); continue; }
closeList();
html += `<p>${inlineMarkdown(escapeHtml(line))}</p>`;
}
if (inCode) html += "</pre>";
closeList();
return html;
}
// 稽核 facet 真實統計(#9 的純運算部分)。
// items: audit-log 列(含 category / risk / result / timestamp / display_name|username)
// nowMs: 現在時間(ms);上限 cap 命中時 capped=true。
function tallyAuditFacets(items, nowMs) {
items = items || [];
const capped = items.length >= 500;
const cat = {}, risk = {}, res = { success: 0, fail: 0 }, byUser = {};
let d7 = 0, d30 = 0, d90 = 0;
const within = (iso, days) => {
const s = (iso || "").replace(" ", "T");
const t = new Date(s + (s.endsWith("Z") ? "" : "Z")).getTime();
return !isNaN(t) && t >= nowMs - days * 86400000;
};
items.forEach((it) => {
cat[it.category] = (cat[it.category] || 0) + 1;
risk[it.risk] = (risk[it.risk] || 0) + 1;
if (it.result === "fail") res.fail++; else res.success++;
const nm = it.display_name || it.username || "(未知)";
byUser[nm] = (byUser[nm] || 0) + 1;
if (within(it.timestamp, 7)) d7++;
if (within(it.timestamp, 30)) d30++;
if (within(it.timestamp, 90)) d90++;
});
const topUsers = Object.entries(byUser).sort((a, b) => b[1] - a[1]).slice(0, 6);
return { cat, risk, res, d7, d30, d90, topUsers, capped };
}
return {
commentAuthorName,
mediaThumbKind,
libFilterHeaderText,
selectAllLabel,
visibilityLabel,
scanKeywords,
matchRegexRules,
submitGate,
relativeTime,
parseCsvList,
gradePassword,
fmtTok,
fmtKB,
fmtMB,
isAllowedEmailDomain,
escapeHtml,
diffTokens,
lcsOps,
wordDiffOps,
diffSides,
inlineMarkdown,
renderMarkdown,
tallyAuditFacets,
};
});