| |
| |
| |
| |
| |
| |
| |
| (function (root, factory) { |
| const mod = factory(); |
| if (typeof module !== "undefined" && module.exports) module.exports = mod; |
| root.KBV5Pure = mod; |
| })(typeof globalThis !== "undefined" ? globalThis : this, function () { |
| "use strict"; |
|
|
| |
| function commentAuthorName(c) { |
| if (!c) return ""; |
| return c.author_display_name || c.author_username || ("#" + c.author_id); |
| } |
|
|
| |
| 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"; |
| } |
|
|
| |
| 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} 篇文件`; |
| } |
|
|
| |
| function selectAllLabel(total) { |
| return `全選 ${total || 0} 篇`; |
| } |
|
|
| |
| 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("・")})` : ""); |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
| } |
| return hits; |
| } |
|
|
| |
| |
| 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), |
| }; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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 }; |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| 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()); |
| } |
|
|
| |
| function escapeHtml(s) { |
| return String(s == null ? "" : s) |
| .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") |
| .replace(/"/g, """).replace(/'/g, "'"); |
| } |
|
|
| |
| 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) { |
| 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); |
| } |
|
|
| |
| 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 }; |
| } |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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, |
| }; |
| }); |
|
|