Clipboard history picker: Omarchy shell plugin (tank.clipboard) with fuzzy search, rich previews, capture daemon
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
// Test harness: loads .pragma library files (plain ES5, no imports) into a
|
||||
// fresh vm context so the same files work in QML and under node.
|
||||
import { readFileSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import path from "node:path"
|
||||
import vm from "node:vm"
|
||||
|
||||
const testsDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pluginDir = path.join(testsDir, "..", "plugin")
|
||||
|
||||
export function loadLib(name) {
|
||||
let src = readFileSync(path.join(pluginDir, name), "utf8")
|
||||
// QML's ".pragma library" directive is not valid JS outside QML.
|
||||
src = src.replace(/^\.pragma\s+library\s*$/m, "")
|
||||
const sandbox = {}
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(src, sandbox)
|
||||
return sandbox
|
||||
}
|
||||
|
||||
export const here = pluginDir
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Run all plugin logic tests. Requires node.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
exec node --test 'tests/*.mjs'
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { loadLib } from "./harness.mjs"
|
||||
|
||||
const Classify = loadLib("Classify.js")
|
||||
|
||||
test("deriveType image/files pass through", () => {
|
||||
assert.equal(Classify.deriveType({ type: "image", path: "/tmp/a.png" }), "image")
|
||||
assert.equal(Classify.deriveType({ type: "files", paths: ["/tmp/a"] }), "files")
|
||||
})
|
||||
|
||||
test("deriveType colors", () => {
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "#ff0000" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "#f00" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "#ff0000cc" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "rgb(12, 34, 56)" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "rgba(1,2,3,0.5)" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "hsl(120, 50%, 50%)" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "#gggggg" }), "text")
|
||||
})
|
||||
|
||||
test("deriveType link / email / number", () => {
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "https://example.com/x?y=1" }), "link")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "www.example.com" }), "link")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "example.com" }), "link")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "foo@bar.io" }), "email")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "42" }), "number")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "-3.14" }), "number")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "1,234,567" }), "number")
|
||||
})
|
||||
|
||||
test("deriveType json / code / html", () => {
|
||||
assert.equal(Classify.deriveType({ type: "text", text: '{"a": [1, 2, 3]}' }), "json")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "[1, 2, 3]" }), "json")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "<html><body>hi</body></html>" }), "html")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "<div class=\"x\">y</div>" }), "html")
|
||||
const code = "def main():\n return print('hello world')\n\nif __name__ == '__main__':\n main()"
|
||||
assert.equal(Classify.deriveType({ type: "text", text: code }), "code")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "just a normal sentence, nothing special" }), "text")
|
||||
})
|
||||
|
||||
test("prettyJson", () => {
|
||||
assert.equal(Classify.prettyJson('{"a":1}'), '{\n "a": 1\n}')
|
||||
assert.equal(Classify.prettyJson("not json"), "")
|
||||
assert.equal(Classify.prettyJson('{"a":1}', 3), "")
|
||||
})
|
||||
|
||||
test("stripHtml", () => {
|
||||
assert.equal(Classify.stripHtml("<p>Hello & <b>world</b></p>"), "Hello & world")
|
||||
assert.equal(Classify.stripHtml("<style>x{}</style><p>hi</p>"), "hi")
|
||||
})
|
||||
|
||||
test("urlDomain", () => {
|
||||
assert.equal(Classify.urlDomain("https://www.example.com/a/b"), "example.com")
|
||||
assert.equal(Classify.urlDomain("example.com:8080/x"), "example.com:8080")
|
||||
})
|
||||
|
||||
test("prettyApp", () => {
|
||||
assert.equal(Classify.prettyApp("com.mitchellh.ghostty"), "Ghostty")
|
||||
assert.equal(Classify.prettyApp("firefox"), "Firefox")
|
||||
assert.equal(Classify.prettyApp("Code"), "VS Code")
|
||||
assert.equal(Classify.prettyApp("org.gnome.Nautilus"), "Files")
|
||||
assert.equal(Classify.prettyApp("someapp-2.1.0"), "Someapp")
|
||||
assert.equal(Classify.prettyApp(""), "")
|
||||
})
|
||||
|
||||
test("formatBytes", () => {
|
||||
assert.equal(Classify.formatBytes(512), "512 B")
|
||||
assert.equal(Classify.formatBytes(2048), "2.0 KB")
|
||||
assert.equal(Classify.formatBytes(1048576), "1.0 MB")
|
||||
assert.equal(Classify.formatBytes(NaN), "")
|
||||
})
|
||||
|
||||
test("formatAge", () => {
|
||||
const now = 1000000000
|
||||
assert.equal(Classify.formatAge(now - 10, now), "just now")
|
||||
assert.equal(Classify.formatAge(now - 120, now), "2m ago")
|
||||
assert.equal(Classify.formatAge(now - 7200, now), "2h ago")
|
||||
assert.equal(Classify.formatAge(now - 86400 * 2, now), "2d ago")
|
||||
assert.ok(Classify.formatAge(now - 86400 * 30, now).length > 0)
|
||||
})
|
||||
|
||||
test("textStats", () => {
|
||||
const s = Classify.textStats("one two three\nfour")
|
||||
assert.equal(s.words, 4)
|
||||
assert.equal(s.lines, 2)
|
||||
assert.equal(s.chars, 18)
|
||||
})
|
||||
|
||||
test("typeIcon/label cover all types", () => {
|
||||
for (const t of ["image", "files", "color", "link", "email", "json", "code", "html", "number", "text"]) {
|
||||
assert.ok(Classify.typeIcon(t).length > 0, t)
|
||||
assert.ok(Classify.typeLabel(t).length > 0, t)
|
||||
}
|
||||
})
|
||||
|
||||
test("colorToRgb hex forms", () => {
|
||||
assert.deepEqual([...Classify.colorToRgb("#ff0000")], [255, 0, 0])
|
||||
assert.deepEqual([...Classify.colorToRgb("#f00")], [255, 0, 0])
|
||||
assert.deepEqual([...Classify.colorToRgb("#ff0000cc")], [255, 0, 0])
|
||||
assert.deepEqual([...Classify.colorToRgb("rgb(12, 34, 56)")], [12, 34, 56])
|
||||
})
|
||||
|
||||
test("colorToHsl conversions", () => {
|
||||
const hsl = Classify.colorToHsl("#ff0000")
|
||||
assert.deepEqual([...hsl], [0, 100, 50])
|
||||
const rgb = Classify.colorToRgb("hsl(120, 100%, 50%)")
|
||||
assert.deepEqual([...rgb], [0, 255, 0])
|
||||
const back = Classify.rgbToHsl(...Classify.hslToRgb(240, 100, 50))
|
||||
assert.deepEqual([...back], [240, 100, 50])
|
||||
})
|
||||
|
||||
test("colorToRgb rejects junk", () => {
|
||||
assert.equal(Classify.colorToRgb("#gg"), null)
|
||||
assert.equal(Classify.colorToRgb("hello"), null)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { loadLib } from "./harness.mjs"
|
||||
|
||||
const Fuzzy = loadLib("Fuzzy.js")
|
||||
|
||||
test("parseDuration", () => {
|
||||
assert.equal(Fuzzy.parseDuration("10m"), 600)
|
||||
assert.equal(Fuzzy.parseDuration("2h"), 7200)
|
||||
assert.equal(Fuzzy.parseDuration("3d"), 259200)
|
||||
assert.equal(Fuzzy.parseDuration("1w"), 604800)
|
||||
assert.equal(Fuzzy.parseDuration("45s"), 45)
|
||||
assert.ok(isNaN(Fuzzy.parseDuration("abc")))
|
||||
assert.ok(isNaN(Fuzzy.parseDuration("10")))
|
||||
})
|
||||
|
||||
test("parseQuery basics", () => {
|
||||
const q = Fuzzy.parseQuery("type:image app:firefox today")
|
||||
assert.equal(q.type, "image")
|
||||
assert.equal(q.app, "firefox")
|
||||
assert.equal(q.maxAge, 86400)
|
||||
assert.equal(q.terms.length, 0)
|
||||
})
|
||||
|
||||
test("parseQuery type prefixes and aliases", () => {
|
||||
assert.equal(Fuzzy.parseQuery("type:img").type, "image")
|
||||
assert.equal(Fuzzy.parseQuery("type:url").type, "link")
|
||||
assert.equal(Fuzzy.parseQuery("type:colour").type, "color")
|
||||
assert.equal(Fuzzy.parseQuery("type:js").type, "json")
|
||||
})
|
||||
|
||||
test("parseQuery age comparisons", () => {
|
||||
const q = Fuzzy.parseQuery("<2h hello >30s")
|
||||
assert.equal(q.maxAge, 7200)
|
||||
assert.equal(q.minAge, 30)
|
||||
assert.equal(q.terms.length, 1); assert.equal(q.terms[0], "hello")
|
||||
})
|
||||
|
||||
test("parseQuery pinned", () => {
|
||||
assert.equal(Fuzzy.parseQuery("pin").pinned, true)
|
||||
assert.equal(Fuzzy.parseQuery("is:pinned foo").pinned, true)
|
||||
assert.equal(Fuzzy.parseQuery("foo").pinned, false)
|
||||
})
|
||||
|
||||
test("fuzzyMatch subsequence", () => {
|
||||
assert.ok(Fuzzy.fuzzyMatch("hlo", "hello world"))
|
||||
assert.equal(Fuzzy.fuzzyMatch("zzz", "hello world"), null)
|
||||
assert.ok(Fuzzy.fuzzyMatch("", ""))
|
||||
const m = Fuzzy.fuzzyMatch("", "anything")
|
||||
assert.equal(m.positions.length, 0)
|
||||
})
|
||||
|
||||
test("fuzzyMatch scores prefix above scattered", () => {
|
||||
const prefix = Fuzzy.fuzzyMatch("fun", "function foo()")
|
||||
const scattered = Fuzzy.fuzzyMatch("fun", "a x f a u a n a")
|
||||
assert.ok(prefix.score > scattered.score)
|
||||
})
|
||||
|
||||
test("fuzzyMatch consecutive beats gapped", () => {
|
||||
const consec = Fuzzy.fuzzyMatch("abc", "xxabcxx")
|
||||
const gapped = Fuzzy.fuzzyMatch("abc", "axbxc")
|
||||
assert.ok(consec.score > gapped.score)
|
||||
})
|
||||
|
||||
test("fuzzyMatch prefers shorter haystack on substring ties", () => {
|
||||
const short = Fuzzy.fuzzyMatch("error", "error handling")
|
||||
const long = Fuzzy.fuzzyMatch("error", "an unexpected error occurred while processing the request payload")
|
||||
assert.ok(short.score > long.score)
|
||||
})
|
||||
|
||||
test("searchRows empty query sorts by recency", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "old", app: "", type: "text", ts: now - 500000, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "new", app: "", type: "text", ts: now - 10, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||
assert.equal(res.length, 2)
|
||||
assert.equal(res[0].row.content, "new")
|
||||
})
|
||||
|
||||
test("searchRows fuzzy ranks match above recency when strong", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "completely unrelated old entry", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 5 },
|
||||
{ entry: {}, content: "function makeAwesome() { return true }", app: "", type: "code", ts: now - 86400 * 30, pinned: false, uses: 0, bytes: 5 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "awesome", now, 10)
|
||||
assert.ok(res[0].row.content.toLowerCase().includes("awesome"))
|
||||
})
|
||||
|
||||
test("searchRows type filter", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "https://example.com", app: "", type: "link", ts: now, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "plain text", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "type:link", now, 10)
|
||||
assert.equal(res.length, 1)
|
||||
assert.equal(res[0].row.type, "link")
|
||||
})
|
||||
|
||||
test("searchRows multi-term AND", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "deploy staging now", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "deploy production", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "deploy staging", now, 10)
|
||||
assert.equal(res.length, 1)
|
||||
assert.equal(res[0].row.content, "deploy staging now")
|
||||
})
|
||||
|
||||
test("searchRows matches app field", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "some text", app: "firefox", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "other text", app: "ghostty", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "fire", now, 10)
|
||||
assert.equal(res.length, 1)
|
||||
assert.equal(res[0].row.app, "firefox")
|
||||
})
|
||||
|
||||
test("searchRows pinned boost wins", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: { pinned: true }, content: "zzz pinned weak", app: "", type: "text", ts: now - 86400 * 10, pinned: true, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "fresh", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||
assert.equal(res[0].row.content, "zzz pinned weak")
|
||||
})
|
||||
|
||||
test("searchRows respects limit", () => {
|
||||
const now = 1000000
|
||||
const rows = []
|
||||
for (let i = 0; i < 50; i++)
|
||||
rows.push({ entry: {}, content: "item " + i, app: "", type: "text", ts: now - i, pinned: false, uses: 0, bytes: 3 })
|
||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||
assert.equal(res.length, 10)
|
||||
})
|
||||
|
||||
test("highlightFirstLine escapes and wraps", () => {
|
||||
const m = Fuzzy.fuzzyMatch("ab", "a <b> abc")
|
||||
const html = Fuzzy.highlightFirstLine("a <b> abc", m.positions, "<b>", "</b>")
|
||||
assert.ok(!html.includes("<b> <"))
|
||||
assert.ok(html.includes("<b>"))
|
||||
assert.ok(html.includes("<b>a</b>"))
|
||||
})
|
||||
|
||||
test("highlightFirstLine multiline only first line", () => {
|
||||
const text = "alpha\nbeta\nalpha"
|
||||
const m = Fuzzy.fuzzyMatch("alpha", text)
|
||||
const html = Fuzzy.highlightFirstLine(text, m.positions, "<b>", "</b>")
|
||||
assert.ok(!html.includes("\n"))
|
||||
})
|
||||
|
||||
test("parseQuery yesterday bounds", () => {
|
||||
const q = Fuzzy.parseQuery("yesterday")
|
||||
assert.equal(q.maxAge, 172800)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { loadLib } from "./harness.mjs"
|
||||
|
||||
const Store = loadLib("Store.js")
|
||||
|
||||
test("hash32 stable and hex", () => {
|
||||
assert.equal(Store.hash32("hello"), Store.hash32("hello"))
|
||||
assert.match(Store.hash32("hello"), /^[0-9a-f]{8}$/)
|
||||
assert.notEqual(Store.hash32("hello"), Store.hash32("hellp"))
|
||||
})
|
||||
|
||||
test("normalize text", () => {
|
||||
const e = Store.normalize({ type: "text", text: " hi ", ts: 100, app: "ff", bytes: 4 })
|
||||
assert.equal(e.text, " hi ")
|
||||
assert.equal(e.ts, 100)
|
||||
assert.equal(e.uses, 0)
|
||||
assert.ok(e.id.startsWith("txt:"))
|
||||
assert.equal(Store.normalize({ type: "text", text: " " }), null)
|
||||
assert.equal(Store.normalize({ type: "bogus" }), null)
|
||||
})
|
||||
|
||||
test("normalize image and files", () => {
|
||||
const img = Store.normalize({ type: "image", path: "/x/a.png", mime: "image/png", w: 10, h: 20 })
|
||||
assert.equal(img.mime, "image/png")
|
||||
assert.equal(img.w, 10)
|
||||
assert.equal(Store.normalize({ type: "image", path: "" }), null)
|
||||
const files = Store.normalize({ type: "files", paths: ["/a", "", "/b"] })
|
||||
assert.equal(files.paths.length, 2); assert.equal(files.paths[0], "/a"); assert.equal(files.paths[1], "/b")
|
||||
assert.equal(Store.normalize({ type: "files", paths: [] }), null)
|
||||
})
|
||||
|
||||
test("addEntry dedupes and bumps to front, keeps pin/uses", () => {
|
||||
let h = []
|
||||
h = Store.addEntry(h, { type: "text", text: "a", ts: 1 }, 10)
|
||||
h = Store.addEntry(h, { type: "text", text: "b", ts: 2 }, 10)
|
||||
assert.equal(h.length, 2)
|
||||
const a = Store.findById(h, h.find(e => e.text === "a").id)
|
||||
a.pinned = true
|
||||
a.uses = 3
|
||||
h = Store.addEntry(h, { type: "text", text: "a", ts: 5 }, 10)
|
||||
assert.equal(h.length, 2)
|
||||
assert.equal(h[0].text, "a")
|
||||
assert.equal(h[0].ts, 5)
|
||||
assert.equal(h[0].pinned, true)
|
||||
assert.equal(h[0].uses, 3)
|
||||
})
|
||||
|
||||
test("addEntry respects limit", () => {
|
||||
let h = []
|
||||
for (let i = 0; i < 20; i++) h = Store.addEntry(h, { type: "text", text: "t" + i, ts: i }, 5)
|
||||
assert.equal(h.length, 5)
|
||||
assert.equal(h[0].text, "t19")
|
||||
})
|
||||
|
||||
test("removeById / findById / togglePin / touch", () => {
|
||||
let h = Store.addEntry([], { type: "text", text: "x", ts: 1 }, 10)
|
||||
const id = h[0].id
|
||||
assert.equal(Store.findById(h, id).text, "x")
|
||||
h = Store.togglePin(h, id)
|
||||
assert.equal(h[0].pinned, true)
|
||||
h = Store.togglePin(h, id)
|
||||
assert.equal(h[0].pinned, undefined)
|
||||
h = Store.touch(h, id, 999)
|
||||
assert.equal(h[0].uses, 1)
|
||||
assert.equal(h[0].ts, 999)
|
||||
h = Store.removeById(h, id)
|
||||
assert.equal(h.length, 0)
|
||||
assert.equal(Store.findById(h, id), null)
|
||||
})
|
||||
|
||||
test("prune reports dropped images", () => {
|
||||
let h = []
|
||||
for (let i = 0; i < 10; i++)
|
||||
h.push({ id: "i" + i, type: i >= 6 ? "image" : "text", path: "/tmp/img" + i + ".png", text: "t" + i, ts: i, bytes: 1, app: "", uses: 0 })
|
||||
const r = Store.prune(h, 5)
|
||||
assert.equal(r.entries.length, 5)
|
||||
assert.equal(r.droppedImagePaths.length, 4)
|
||||
assert.deepEqual([...r.droppedImagePaths].join(","), ["/tmp/img6.png","/tmp/img7.png","/tmp/img8.png","/tmp/img9.png"].join(","))
|
||||
})
|
||||
|
||||
test("parseHistory tolerates garbage", () => {
|
||||
assert.equal(Store.parseHistory("not json").length, 0)
|
||||
assert.equal(Store.parseHistory('{"a":1}').length, 0)
|
||||
assert.equal(Store.parseHistory("[]").length, 0)
|
||||
const h = Store.parseHistory('[{"type":"text","text":"ok"},{"type":"bogus"}]')
|
||||
assert.equal(h.length, 1)
|
||||
})
|
||||
|
||||
test("buildRow caps content haystack", () => {
|
||||
const row = Store.buildRow({ type: "text", text: "x".repeat(9000), ts: 1, bytes: 9000, app: "a", uses: 0 }, "text", 1)
|
||||
assert.equal(row.content.length, 4000)
|
||||
})
|
||||
|
||||
test("entryId distinct for distinct content", () => {
|
||||
const a = Store.entryId({ type: "text", text: "one" })
|
||||
const b = Store.entryId({ type: "text", text: "two" })
|
||||
assert.notEqual(a, b)
|
||||
})
|
||||
Reference in New Issue
Block a user