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:
@@ -0,0 +1,205 @@
|
||||
.pragma library
|
||||
|
||||
// History store operations: normalization, dedup, pins, pruning.
|
||||
// Pure ES5 — runs in QML (.pragma library) and node (tests).
|
||||
|
||||
var DEFAULT_LIMIT = 1500
|
||||
|
||||
// FNV-1a 32-bit, hex. Used for stable entry ids.
|
||||
function hash32(s) {
|
||||
var h = 0x811c9dc5
|
||||
s = String(s || "")
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0
|
||||
}
|
||||
return ("0000000" + h.toString(16)).slice(-8)
|
||||
}
|
||||
|
||||
function entryKey(entry) {
|
||||
if (!entry) return ""
|
||||
if (entry.type === "image") return "image:" + String(entry.path || "")
|
||||
if (entry.type === "files") return "files:" + (entry.paths || []).join("\n")
|
||||
return "text:" + String(entry.text || "")
|
||||
}
|
||||
|
||||
function entryId(entry) {
|
||||
if (!entry) return ""
|
||||
if (entry.type === "image") return "img:" + hash32(String(entry.path || ""))
|
||||
if (entry.type === "files") return "files:" + hash32((entry.paths || []).join("\n"))
|
||||
return "txt:" + hash32(String(entry.text || "")) + ":" + String(entry.text || "").length
|
||||
}
|
||||
|
||||
// Validate + fill defaults. Returns null for entries that should be dropped.
|
||||
function normalize(value, now) {
|
||||
if (!value || typeof value !== "object") return null
|
||||
var type = String(value.type || "")
|
||||
var out = null
|
||||
|
||||
if (type === "text") {
|
||||
var text = String(value.text || "")
|
||||
if (!text.trim()) return null
|
||||
out = { type: "text", text: text }
|
||||
} else if (type === "image") {
|
||||
var path = String(value.path || "")
|
||||
if (!path) return null
|
||||
out = {
|
||||
type: "image",
|
||||
path: path,
|
||||
mime: String(value.mime || "image/png")
|
||||
}
|
||||
if (value.w) out.w = Number(value.w)
|
||||
if (value.h) out.h = Number(value.h)
|
||||
if (value.qr) out.qr = String(value.qr)
|
||||
} else if (type === "files") {
|
||||
var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : []
|
||||
if (paths.length === 0) return null
|
||||
out = { type: "files", paths: paths }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
var ts = Number(value.ts)
|
||||
out.ts = isFinite(ts) && ts > 0 ? Math.floor(ts) : (now || 0)
|
||||
var bytes = Number(value.bytes)
|
||||
out.bytes = isFinite(bytes) && bytes >= 0 ? Math.floor(bytes) : 0
|
||||
out.app = String(value.app || "")
|
||||
out.id = String(value.id || entryId(out))
|
||||
var uses = Number(value.uses)
|
||||
out.uses = isFinite(uses) && uses > 0 ? Math.floor(uses) : 0
|
||||
if (value.pinned) out.pinned = true
|
||||
return out
|
||||
}
|
||||
|
||||
function parseHistory(raw, now) {
|
||||
try {
|
||||
var parsed = JSON.parse(String(raw || "[]"))
|
||||
var next = []
|
||||
if (!Array.isArray(parsed)) return next
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
var e = normalize(parsed[i], now)
|
||||
if (e) next.push(e)
|
||||
}
|
||||
return next
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Add (or bump) an entry; newest first. Keeps pin state and usage count of
|
||||
// the existing copy when the same content is copied again. Does NOT truncate
|
||||
// to the limit: the caller prunes via prune() so evicted image blobs can be
|
||||
// garbage-collected (truncating here would leak them).
|
||||
function addEntry(history, entry, now) {
|
||||
var normalized = normalize(entry, now)
|
||||
if (!normalized) return Array.isArray(history) ? history.slice() : []
|
||||
|
||||
var key = entryKey(normalized)
|
||||
var next = [normalized]
|
||||
var values = Array.isArray(history) ? history : []
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var existing = normalize(values[i], now)
|
||||
if (!existing) continue
|
||||
if (entryKey(existing) === key) {
|
||||
// Re-copy of existing content: keep its pin state and usage count.
|
||||
if (existing.pinned) normalized.pinned = true
|
||||
if (existing.uses > 0) normalized.uses = existing.uses
|
||||
// Keep expensive derived data (e.g. decoded QR) across re-captures.
|
||||
if (existing.qr && !normalized.qr) normalized.qr = existing.qr
|
||||
continue
|
||||
}
|
||||
next.push(existing)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function findById(history, id) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i] && values[i].id === id) return values[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function removeById(history, id) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var next = []
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i] && values[i].id === id) continue
|
||||
next.push(values[i])
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function togglePin(history, id) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var entry = findById(values, id)
|
||||
if (!entry) return values
|
||||
if (entry.pinned) delete entry.pinned
|
||||
else entry.pinned = true
|
||||
return values
|
||||
}
|
||||
|
||||
function touch(history, id, now) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var entry = findById(values, id)
|
||||
if (!entry) return values
|
||||
entry.uses = (Number(entry.uses) || 0) + 1
|
||||
entry.ts = now || entry.ts
|
||||
// Move to front like a fresh copy would.
|
||||
var next = [entry]
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i] !== entry) next.push(values[i])
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Enforce the limit. Returns { entries, droppedImagePaths } so the caller can
|
||||
// garbage-collect image blobs.
|
||||
function prune(history, limit) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var max = limit === undefined || limit === null ? DEFAULT_LIMIT : Number(limit)
|
||||
if (isNaN(max) || max < 1) max = DEFAULT_LIMIT
|
||||
if (values.length <= max) return { entries: values, droppedImagePaths: [] }
|
||||
|
||||
var kept = values.slice(0, max)
|
||||
var dropped = values.slice(max)
|
||||
var droppedImagePaths = []
|
||||
for (var i = 0; i < dropped.length; i++) {
|
||||
if (dropped[i] && dropped[i].type === "image" && dropped[i].path)
|
||||
droppedImagePaths.push(dropped[i].path)
|
||||
}
|
||||
// Keep pins even beyond the limit? No — pins are ordered to the top by
|
||||
// search instead; the store stays purely recency-ordered.
|
||||
return { entries: kept, droppedImagePaths: droppedImagePaths }
|
||||
}
|
||||
|
||||
// Build the search-row context Fuzzy.searchRows expects.
|
||||
function buildRow(entry, derivedType, now) {
|
||||
var content = ""
|
||||
if (entry.type === "image") {
|
||||
content = fileLabel(entry.path) + " " + String(entry.mime || "")
|
||||
if (entry.qr) content += " " + String(entry.qr).slice(0, 500)
|
||||
} else if (entry.type === "files") {
|
||||
content = (entry.paths || []).join(" ")
|
||||
} else {
|
||||
// Cap haystack size for speed; deep content is still previewable.
|
||||
content = String(entry.text || "").slice(0, 4000)
|
||||
}
|
||||
return {
|
||||
entry: entry,
|
||||
content: content,
|
||||
app: String(entry.app || ""),
|
||||
type: derivedType,
|
||||
ts: Number(entry.ts) || 0,
|
||||
pinned: !!entry.pinned,
|
||||
uses: Number(entry.uses) || 0,
|
||||
bytes: Number(entry.bytes) || 0
|
||||
}
|
||||
}
|
||||
|
||||
function fileLabel(p) {
|
||||
var s = String(p || "")
|
||||
var idx = s.lastIndexOf("/")
|
||||
return idx === -1 ? s : s.slice(idx + 1)
|
||||
}
|
||||
Reference in New Issue
Block a user