269 lines
8.8 KiB
JavaScript
269 lines
8.8 KiB
JavaScript
.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)
|
|
if (value.ocr) out.ocr = String(value.ocr)
|
|
} 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 (decoded QR, OCR) across re-captures.
|
|
if (existing.qr && !normalized.qr) normalized.qr = existing.qr
|
|
if (existing.ocr && !normalized.ocr) normalized.ocr = existing.ocr
|
|
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 }
|
|
}
|
|
|
|
// Retention: drop entries older than maxAgeSeconds (-1 = keep forever).
|
|
// Pinned entries are exempt — pins are favorites; delete them explicitly.
|
|
// Returns { entries, droppedImagePaths } like prune().
|
|
function pruneByAge(history, maxAgeSeconds, now) {
|
|
var values = Array.isArray(history) ? history : []
|
|
if (maxAgeSeconds === undefined || maxAgeSeconds === null || maxAgeSeconds < 0)
|
|
return { entries: values, droppedImagePaths: [] }
|
|
var cutoff = now - maxAgeSeconds
|
|
var kept = []
|
|
var droppedImagePaths = []
|
|
for (var i = 0; i < values.length; i++) {
|
|
var e = values[i]
|
|
if (!e) continue
|
|
if ((Number(e.ts) || 0) < cutoff && !e.pinned) {
|
|
if (e.type === "image" && e.path) droppedImagePaths.push(e.path)
|
|
continue
|
|
}
|
|
kept.push(e)
|
|
}
|
|
return { entries: kept, droppedImagePaths: droppedImagePaths }
|
|
}
|
|
|
|
// Parse this plugin's settings from the shell.json contents. Every key is
|
|
// optional; unknown keys are ignored. Reads only the plugins[] entry whose
|
|
// id matches.
|
|
function parseSettings(raw, pluginId) {
|
|
var out = {
|
|
historyLimit: DEFAULT_LIMIT,
|
|
maxAgeDays: 0,
|
|
maxRows: 200,
|
|
qrDecode: true,
|
|
ocr: true,
|
|
ocrLang: "eng"
|
|
}
|
|
var config = null
|
|
try { config = JSON.parse(String(raw || "{}")) } catch (e) { return out }
|
|
if (!config || !Array.isArray(config.plugins)) return out
|
|
for (var i = 0; i < config.plugins.length; i++) {
|
|
var entry = config.plugins[i]
|
|
if (!entry || entry.id !== pluginId) continue
|
|
|
|
var n = Number(entry.historyLimit)
|
|
if (isFinite(n) && n >= 1) out.historyLimit = Math.floor(n)
|
|
|
|
n = Number(entry.maxAgeDays)
|
|
if (isFinite(n) && n >= 0) out.maxAgeDays = Math.floor(n)
|
|
|
|
n = Number(entry.maxRows)
|
|
if (isFinite(n) && n >= 1) out.maxRows = Math.floor(n)
|
|
|
|
if (typeof entry.qrDecode === "boolean") out.qrDecode = entry.qrDecode
|
|
if (typeof entry.ocr === "boolean") out.ocr = entry.ocr
|
|
if (typeof entry.ocrLang === "string" && entry.ocrLang) out.ocrLang = entry.ocrLang
|
|
|
|
break
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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)
|
|
// OCR text makes screenshots searchable like any text clip.
|
|
if (entry.ocr) content += " " + String(entry.ocr).slice(0, 4000)
|
|
} 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)
|
|
}
|