QR decode support (zbarimg) with searchable payload + preview panel; restructure repo root as installable omarchy plugin; plain-text safety for QR payloads
This commit is contained in:
+326
@@ -0,0 +1,326 @@
|
||||
.pragma library
|
||||
|
||||
// Entry classification and formatting helpers.
|
||||
// Pure ES5, no imports — runs in QML (.pragma library) and node (tests).
|
||||
|
||||
// ---------------------------------------------------------------- types
|
||||
|
||||
var TYPES = {
|
||||
image: { icon: "", label: "Image" },
|
||||
files: { icon: "", label: "Files" },
|
||||
color: { icon: "", label: "Color" },
|
||||
link: { icon: "", label: "Link" },
|
||||
email: { icon: "", label: "Email" },
|
||||
json: { icon: "", label: "JSON" },
|
||||
code: { icon: "", label: "Code" },
|
||||
html: { icon: "", label: "HTML" },
|
||||
number: { icon: "", label: "Number" },
|
||||
text: { icon: "", label: "Text" }
|
||||
}
|
||||
|
||||
function typeIcon(t) { var e = TYPES[t]; return e ? e.icon : TYPES.text.icon }
|
||||
function typeLabel(t) { var e = TYPES[t]; return e ? e.label : "Text" }
|
||||
|
||||
// Derived display type for a stored entry.
|
||||
// entry: { type: "text"|"image"|"files", text?, path?, paths? }
|
||||
function deriveType(entry) {
|
||||
if (!entry) return "text"
|
||||
if (entry.type === "image") return "image"
|
||||
if (entry.type === "files") return "files"
|
||||
var text = String(entry.text || "")
|
||||
if (!text) return "text"
|
||||
var trimmed = text.trim()
|
||||
if (!trimmed) return "text"
|
||||
|
||||
if (trimmed.indexOf("\n") === -1) {
|
||||
if (isColor(trimmed)) return "color"
|
||||
if (isEmail(trimmed)) return "email"
|
||||
if (isUrl(trimmed)) return "link"
|
||||
if (isNumber(trimmed)) return "number"
|
||||
}
|
||||
|
||||
if (trimmed.charAt(0) === "<" &&
|
||||
/<\/?[a-z][a-z0-9-]*(\s[^>]*)?>/i.test(trimmed.slice(0, 200)))
|
||||
return "html"
|
||||
|
||||
if (isJson(trimmed)) return "json"
|
||||
if (looksLikeCode(trimmed)) return "code"
|
||||
return "text"
|
||||
}
|
||||
|
||||
var COLOR_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
|
||||
var RGB_RE = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/i
|
||||
var HSL_RE = /^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/i
|
||||
|
||||
function isColor(s) {
|
||||
return COLOR_RE.test(s) || RGB_RE.test(s) || HSL_RE.test(s)
|
||||
}
|
||||
|
||||
function clampByte(n) { return Math.max(0, Math.min(255, Math.round(n))) }
|
||||
|
||||
// "#rgb"/"#rgba"/"#rrggbb"/"#rrggbbaa"/"rgb()"/"hsl()" → [r, g, b] or null.
|
||||
function colorToRgb(s) {
|
||||
s = String(s || "").trim()
|
||||
var m = /^#([0-9a-f]+)$/i.exec(s)
|
||||
if (m) {
|
||||
var hex = m[1]
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
return [
|
||||
parseInt(hex.charAt(0) + hex.charAt(0), 16),
|
||||
parseInt(hex.charAt(1) + hex.charAt(1), 16),
|
||||
parseInt(hex.charAt(2) + hex.charAt(2), 16)
|
||||
]
|
||||
}
|
||||
if (hex.length === 6 || hex.length === 8) {
|
||||
return [
|
||||
parseInt(hex.slice(0, 2), 16),
|
||||
parseInt(hex.slice(2, 4), 16),
|
||||
parseInt(hex.slice(4, 6), 16)
|
||||
]
|
||||
}
|
||||
return null
|
||||
}
|
||||
m = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i.exec(s)
|
||||
if (m) return [clampByte(Number(m[1])), clampByte(Number(m[2])), clampByte(Number(m[3]))]
|
||||
if (!HSL_RE.test(s)) return null
|
||||
var hsl = colorToHsl(s)
|
||||
if (!hsl) return null
|
||||
return hslToRgb(hsl[0], hsl[1], hsl[2])
|
||||
}
|
||||
|
||||
function colorToHsl(s) {
|
||||
s = String(s || "").trim()
|
||||
var m = /^hsla?\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%/i.exec(s)
|
||||
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])]
|
||||
var rgb = colorToRgb(s)
|
||||
if (!rgb) return null
|
||||
return rgbToHsl(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
var max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
var h = 0, s = 0
|
||||
var l = (max + min) / 2
|
||||
if (max !== min) {
|
||||
var d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0))
|
||||
else if (max === g) h = (b - r) / d + 2
|
||||
else h = (r - g) / d + 4
|
||||
h = Math.round(h * 60)
|
||||
}
|
||||
return [h, Math.round(s * 100), Math.round(l * 100)]
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
s /= 100; l /= 100
|
||||
var c = (1 - Math.abs(2 * l - 1)) * s
|
||||
var x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
||||
var m = l - c / 2
|
||||
var rgb
|
||||
if (h < 60) rgb = [c, x, 0]
|
||||
else if (h < 120) rgb = [x, c, 0]
|
||||
else if (h < 180) rgb = [0, c, x]
|
||||
else if (h < 240) rgb = [0, x, c]
|
||||
else if (h < 300) rgb = [x, 0, c]
|
||||
else rgb = [c, 0, x]
|
||||
return [clampByte((rgb[0] + m) * 255), clampByte((rgb[1] + m) * 255), clampByte((rgb[2] + m) * 255)]
|
||||
}
|
||||
|
||||
var URL_RE = /^(?:https?:\/\/|www\.)[^\s]+$/i
|
||||
var BARE_DOMAIN_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?:\/[^\s]*)?$/i
|
||||
|
||||
function isUrl(s) {
|
||||
return URL_RE.test(s) || BARE_DOMAIN_RE.test(s)
|
||||
}
|
||||
|
||||
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
|
||||
|
||||
function isEmail(s) { return EMAIL_RE.test(s) }
|
||||
|
||||
var NUMBER_RE = /^[+-]?(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d+)?%?$/
|
||||
|
||||
function isNumber(s) { return NUMBER_RE.test(s) }
|
||||
|
||||
function isJson(s) {
|
||||
var c = s.charAt(0)
|
||||
if (c !== "{" && c !== "[" && c !== '"') return false
|
||||
try {
|
||||
JSON.parse(s)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Pretty-print JSON; returns "" when not parseable or too large.
|
||||
function prettyJson(text, maxLen) {
|
||||
var cap = maxLen === undefined ? 65536 : maxLen
|
||||
if (!text || text.length > cap) return ""
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch (e) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var CODE_HINTS = [
|
||||
/(?:^|\n)\s*(?:def |class |function |fn |func |const |let |var |import |from |package |using |#include)/,
|
||||
/(?:=>|->|::|&&|\|\||===|!==|:=)/,
|
||||
/(?:^|\n)\s*(?:if|for|while|switch|try|elif|elsif)\s*[(\s{]/,
|
||||
/;\s*$/,
|
||||
/^\s*#!/m,
|
||||
/<\/?[a-z][^>]*>/ // xml-ish (checked after html derive, fine for code view)
|
||||
]
|
||||
|
||||
function looksLikeCode(s) {
|
||||
if (s.length > 100000) return false
|
||||
// Needs at least a little structure; single prose words are never code.
|
||||
var hints = 0
|
||||
for (var i = 0; i < CODE_HINTS.length; i++) {
|
||||
if (CODE_HINTS[i].test(s)) hints++
|
||||
if (hints >= 2) return true
|
||||
}
|
||||
// Many lines with consistent indentation / trailing semicolons.
|
||||
var lines = s.split("\n")
|
||||
if (lines.length >= 3) {
|
||||
var indented = 0
|
||||
for (var l = 0; l < lines.length; l++) {
|
||||
if (/^[ \t]+\S/.test(lines[l]) || /;\s*$/.test(lines[l])) indented++
|
||||
}
|
||||
if (indented / lines.length > 0.5) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function stripHtml(s) {
|
||||
return String(s || "")
|
||||
.replace(/<(?:script|style)\b[^>]*>[\s\S]*?<\/(?:script|style)>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function urlDomain(url) {
|
||||
var m = /^(?:https?:\/\/)?(?:www\.)?([^\/\s]+)/i.exec(String(url || "").trim())
|
||||
return m ? m[1] : ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- apps
|
||||
|
||||
var APP_NAMES = {
|
||||
ghostty: "Ghostty", "com.mitchellh.ghostty": "Ghostty", foot: "Foot",
|
||||
alacritty: "Alacritty", kitty: "kitty", wezterm: "WezTerm",
|
||||
"org.wezfurlong.wezterm": "WezTerm", code: "VS Code", "code-oss": "VS Code",
|
||||
Code: "VS Code", firefox: "Firefox", chromium: "Chromium",
|
||||
"Google-chrome": "Chrome", "google-chrome": "Chrome", brave: "Brave",
|
||||
"brave-browser": "Brave", slack: "Slack", discord: "Discord",
|
||||
obsidian: "Obsidian", spotify: "Spotify", telegram: "Telegram",
|
||||
"org.telegram.desktop": "Telegram", nautilus: "Files",
|
||||
"org.gnome.Nautilus": "Files", thunar: "Thunar", dolphin: "Dolphin",
|
||||
"org.kde.dolphin": "Dolphin", nvim: "Neovim", neovide: "Neovide",
|
||||
emacs: "Emacs", "jetbrains-idea": "IntelliJ IDEA", zed: "Zed", dev: "Dev",
|
||||
wps: "WPS", gimp: "GIMP", inkscape: "Inkscape", blender: "Blender",
|
||||
thunderbird: "Thunderbird", keepassxc: "KeePassXC", steam: "Steam"
|
||||
}
|
||||
|
||||
function prettyApp(cls) {
|
||||
var s = String(cls || "").trim()
|
||||
if (!s) return ""
|
||||
if (APP_NAMES[s]) return APP_NAMES[s]
|
||||
// Strip reverse-DNS prefixes and version suffixes: com.foo.Bar-2.1 → Bar
|
||||
var noDns = s.replace(/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+\./i, "")
|
||||
noDns = noDns.replace(/[-_.]\d.*$/, "")
|
||||
if (!noDns) return s
|
||||
return noDns.charAt(0).toUpperCase() + noDns.slice(1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- formatting
|
||||
|
||||
function formatBytes(n) {
|
||||
n = Number(n)
|
||||
if (!isFinite(n) || n < 0) return ""
|
||||
if (n < 1024) return n + " B"
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(n < 10240 ? 1 : 0) + " KB"
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB"
|
||||
}
|
||||
|
||||
function formatAge(ts, now) {
|
||||
ts = Number(ts)
|
||||
if (!isFinite(ts) || ts <= 0) return ""
|
||||
var s = Math.max(0, Math.floor((now - ts)))
|
||||
if (s < 45) return "just now"
|
||||
if (s < 3600) return Math.floor(s / 60) + "m ago"
|
||||
if (s < 86400) {
|
||||
var h = Math.floor(s / 3600)
|
||||
return h + "h ago"
|
||||
}
|
||||
var d = Math.floor(s / 86400)
|
||||
if (d < 7) return d + "d ago"
|
||||
return formatDate(ts)
|
||||
}
|
||||
|
||||
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
|
||||
function pad2(n) { return n < 10 ? "0" + n : "" + n }
|
||||
|
||||
function formatDate(ts) {
|
||||
ts = Number(ts)
|
||||
if (!isFinite(ts) || ts <= 0) return ""
|
||||
var d = new Date(ts * 1000)
|
||||
return MONTHS[d.getMonth()] + " " + d.getDate() + ", " +
|
||||
pad2(d.getHours()) + ":" + pad2(d.getMinutes())
|
||||
}
|
||||
|
||||
function formatFullDate(ts) {
|
||||
ts = Number(ts)
|
||||
if (!isFinite(ts) || ts <= 0) return ""
|
||||
var d = new Date(ts * 1000)
|
||||
return MONTHS[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear() +
|
||||
" at " + pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds())
|
||||
}
|
||||
|
||||
function plural(n, word) {
|
||||
return n === 1 ? "1 " + word : n + " " + word + "s"
|
||||
}
|
||||
|
||||
function textStats(text) {
|
||||
var s = String(text || "")
|
||||
var words = 0
|
||||
var m = s.match(/\S+/g)
|
||||
if (m) words = m.length
|
||||
return {
|
||||
chars: s.length,
|
||||
words: words,
|
||||
lines: s === "" ? 0 : s.split("\n").length
|
||||
}
|
||||
}
|
||||
|
||||
function firstLine(text, max) {
|
||||
var s = String(text || "")
|
||||
var nl = s.indexOf("\n")
|
||||
var line = nl === -1 ? s : s.slice(0, nl)
|
||||
if (max && line.length > max) return line.slice(0, max) + "…"
|
||||
return line
|
||||
}
|
||||
|
||||
function fileBase(p) {
|
||||
var s = String(p || "").replace(/\/+$/, "")
|
||||
var idx = s.lastIndexOf("/")
|
||||
return idx === -1 ? s : s.slice(idx + 1)
|
||||
}
|
||||
|
||||
function fileDir(p) {
|
||||
var s = String(p || "")
|
||||
var idx = s.lastIndexOf("/")
|
||||
if (idx <= 0) return idx === 0 ? "/" : ""
|
||||
return s.slice(0, idx)
|
||||
}
|
||||
Reference in New Issue
Block a user