Files
clipboard-history/plugin/Store.js
T

202 lines
6.3 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)
} 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.
function addEntry(history, entry, limit, now) {
var normalized = normalize(entry, now)
var max = limit === undefined || limit === null ? DEFAULT_LIMIT : Number(limit)
if (isNaN(max) || max < 1) max = DEFAULT_LIMIT
if (!normalized) return Array.isArray(history) ? history.slice(0, max) : []
var key = entryKey(normalized)
var next = [normalized]
var values = Array.isArray(history) ? history : []
for (var i = 0; i < values.length && next.length < max; 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
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 || "")
} 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)
}