// clip.js — pure clipboard logic: normalize, content-typing, sort, filter, shapers. // NO I/O. Dual-mode: node `require` (tests) + QML import. Same pattern as caldav.js/sysinfo.js. // --- Nerd Font glyphs (verified to render). Kept as \uXXXX escapes. --- var ICON_IMAGE = "\uF03E"; // image var ICON_COLOR = "\uF043"; // color (tint) var ICON_URL = "\uF0C1"; // url (link) var ICON_PATH = "\uF15B"; // path (file) var ICON_TEXT = "\uF0F6"; // text (file-text) // --- lowercase, null-safe (search/dedupe key) --- function normalize(text) { return String(text == null ? "" : text).toLowerCase(); } // --- reject empty / oversized / mostly-control-character (binary) captures --- function isReadableText(text) { var value = String(text == null ? "" : text); if (value.length === 0 || value.length > 65536) return false; var bad = 0; for (var i = 0; i < value.length; i++) { var code = value.charCodeAt(i); if ((code >= 0 && code < 9) || (code > 13 && code < 32) || code === 65533) bad++; } return bad <= Math.max(2, Math.floor(value.length / 100)); } // --- FNV-1a-ish XOR+imul hash. Literals kept identical to the original QML so // existing clip- dedupe IDs survive across restart. --- function hashText(text) { var value = String(text == null ? "" : text); var hash = 2.16614e+09; for (var i = 0; i < value.length; i++) { hash ^= value.charCodeAt(i); hash = Math.imul(hash, 1.67776e+07); } return (hash >>> 0).toString(16); } // --- first non-empty-trimmed line of a value --- function firstLine(text) { return String(text == null ? "" : text).split(/\r?\n/)[0].trim(); } // --- a single-line path-like token (spaces allowed — screenshots have them) --- function looksLikePath(value) { if (/[\n\r\t]/.test(value)) return false; return /^(\/|~\/|\.\/|\.\.\/|file:\/\/)/.test(value); } // --- temp /tmp image path OR any path ending in an image extension --- function isImagePath(text) { var value = String(text == null ? "" : text).trim(); if (!looksLikePath(value)) return false; return /\.(png|jpg|jpeg|webp|gif)$/i.test(value); } // --- a single http(s) / www URL token --- function isUrl(text) { var value = String(text == null ? "" : text).trim(); if (/\s/.test(value)) return false; return /^(https?:\/\/|www\.)\S+$/i.test(value); } // --- clamp an integer string into a 0..255 byte --- function clampByte(s) { var n = parseInt(s, 10); if (!isFinite(n)) return 0; return n < 0 ? 0 : (n > 255 ? 255 : n); } function byteHex(n) { var h = n.toString(16); return h.length < 2 ? "0" + h : h; } // --- parse #rgb / #rgba / #rrggbb / #rrggbbaa and rgb()/rgba() into "#rrggbb". // Shorthand is expanded; alpha is dropped. Returns null on anything else. --- function parseColor(text) { var v = String(text == null ? "" : text).trim().toLowerCase(); var hex = /^#([0-9a-f]{3,8})$/.exec(v); if (hex) { var h = hex[1]; if (h.length === 3 || h.length === 4) { h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; // expand, drop shorthand alpha } else if (h.length === 6) { // already #rrggbb } else if (h.length === 8) { h = h.slice(0, 6); // drop alpha } else { return null; // length 5 or 7 — not a valid color } return "#" + h; } var rgb = /^rgba?\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*(?:,\s*[0-9.]+\s*)?\)$/.exec(v); if (rgb) { return "#" + byteHex(clampByte(rgb[1])) + byteHex(clampByte(rgb[2])) + byteHex(clampByte(rgb[3])); } return null; } // --- content-type classification. Precedence: image, color, url, path, else text. --- function detectKind(text) { if (isImagePath(text)) return { kind: "image", icon: ICON_IMAGE }; if (parseColor(text) !== null) return { kind: "color", icon: ICON_COLOR }; if (isUrl(text)) return { kind: "url", icon: ICON_URL }; if (looksLikePath(text)) return { kind: "path", icon: ICON_PATH }; return { kind: "text", icon: ICON_TEXT }; } // --- title for a row: "Image" for image paths, else the (truncated) first line --- function rowTitle(text) { if (isImagePath(text)) return "Image"; var line = firstLine(text); if (line.length === 0) return "Clipboard"; return line.length > 64 ? line.slice(0, 61) + "…" : line; } // --- "just now" | "Nm" | "Nh" | "Nd" relative age --- function ageLabel(createdAt, now) { var t = Number(createdAt); var ref = (now == null ? Date.now() : Number(now)); if (!isFinite(t) || !isFinite(ref)) return "just now"; var diff = ref - t; if (diff < 60000) return "just now"; var minutes = Math.floor(diff / 60000); if (minutes < 60) return minutes + "m"; var hours = Math.floor(diff / 3600000); if (hours < 24) return hours + "h"; return Math.floor(diff / 86400000) + "d"; } // --- pinned first, then createdAt descending (recency). Returns a new array. --- function sortRows(rows) { var list = (rows || []).slice(); list.sort(function (a, b) { var pa = a && a.pinned ? 1 : 0; var pb = b && b.pinned ? 1 : 0; if (pa !== pb) return pb - pa; return Number(b && b.createdAt || 0) - Number(a && a.createdAt || 0); }); return list; } // --- substring match on title+body, then sortRows --- function filterRows(rows, query) { var q = normalize(query); var matched = []; var list = rows || []; for (var i = 0; i < list.length; i++) { var row = list[i]; if (q.length === 0 || normalize(row.title).indexOf(q) >= 0 || normalize(row.body).indexOf(q) >= 0) { matched.push(row); } } return sortRows(matched); } // --- view + launcher row shape. Launcher-compatible: id,title,body,pinned,provider,route // (the service adds subtitle/keyboard). `now` is injectable for deterministic age. --- function cloneRow(row, now) { var src = row || {}; var body = String(src.body == null ? "" : src.body); var kind = detectKind(body); var isImage = kind.kind === "image"; var color = kind.kind === "color" ? parseColor(body) : null; return { id: String(src.id == null ? "" : src.id), title: isImage ? "Image" : rowTitle(body), body: body, pinned: !!src.pinned, kind: kind.kind, icon: kind.icon, isImage: isImage, imagePath: isImage ? body.trim() : null, swatch: color, age: ageLabel(src.createdAt, now), provider: "clipboard", route: "clipboard" }; } // --- durable persistence shape (clipboard-history.json rows) --- function persistedRow(row) { var src = row || {}; return { id: String(src.id == null ? "" : src.id), title: String(src.title == null ? "Clipboard" : src.title), body: String(src.body == null ? "" : src.body), pinned: !!src.pinned, createdAt: Number(src.createdAt == null ? Date.now() : src.createdAt) }; } if (typeof module !== 'undefined' && module.exports) { module.exports = { normalize: normalize, isReadableText: isReadableText, hashText: hashText, firstLine: firstLine, rowTitle: rowTitle, isImagePath: isImagePath, parseColor: parseColor, detectKind: detectKind, sortRows: sortRows, filterRows: filterRows, ageLabel: ageLabel, cloneRow: cloneRow, persistedRow: persistedRow }; }