From 44808ffd2c466e6a90f3b2ffd4033c780a18a9b1 Mon Sep 17 00:00:00 2001 From: Alan Silva Date: Sat, 5 Sep 2026 15:25:15 +0100 Subject: [PATCH] Clipboard history picker: Omarchy shell plugin (tank.clipboard) with fuzzy search, rich previews, capture daemon --- .gitignore | 1 + README.md | 80 ++++ install.sh | 52 +++ plugin/Classify.js | 326 ++++++++++++++++ plugin/Clipboard.qml | 821 ++++++++++++++++++++++++++++++++++++++++ plugin/Fuzzy.js | 322 ++++++++++++++++ plugin/PreviewPane.qml | 486 ++++++++++++++++++++++++ plugin/Store.js | 201 ++++++++++ plugin/capture.py | 180 +++++++++ plugin/manifest.json | 18 + plugin/open-entry.sh | 53 +++ plugin/paste-entry.sh | 39 ++ tests/harness.mjs | 21 + tests/run.sh | 5 + tests/test-classify.mjs | 116 ++++++ tests/test-fuzzy.mjs | 162 ++++++++ tests/test-store.mjs | 99 +++++ 17 files changed, 2982 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 install.sh create mode 100644 plugin/Classify.js create mode 100644 plugin/Clipboard.qml create mode 100644 plugin/Fuzzy.js create mode 100644 plugin/PreviewPane.qml create mode 100644 plugin/Store.js create mode 100755 plugin/capture.py create mode 100644 plugin/manifest.json create mode 100755 plugin/open-entry.sh create mode 100755 plugin/paste-entry.sh create mode 100644 tests/harness.mjs create mode 100755 tests/run.sh create mode 100644 tests/test-classify.mjs create mode 100644 tests/test-fuzzy.mjs create mode 100644 tests/test-store.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..751553b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.bak diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e2a14d --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# clipboard-history + +A Raycast/vicinae-style clipboard history manager for Omarchy — implemented as +an Omarchy shell plugin (`tank.clipboard`, a clone of `omarchy.clipboard`) so it +rides on the system theme, fonts, and layer-shell infrastructure. + +## Features + +- **Rich capture** — a `wl-paste --watch` daemon records every clip with mime + type, byte size, source app (via `hyprctl`), timestamp, and image dimensions. + Text, images, and `file://` URI lists (file-manager copies) are supported; + password-manager clips and binary payloads are skipped. +- **Fuzzy search over everything** — fzf-style scoring over content, source + app, and type, plus recency, pin, and usage boosts. Query tokens: + - `type:image|link|text|files|code|json|color|email|html|number` (prefix match) + - `app:firefox` — fuzzy match on the source app + - `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d` +- **Raycast-style UI** — search bar with blinking cursor, type filter chips, + two-line result rows with type icons / image thumbnails and metadata + (type · app · age · size), and a right-hand preview pane: + - images rendered inline (with dimensions + size) + - colors shown as a swatch with hex/rgb/hsl values + - links show the domain headline + - JSON is pretty-printed; code/html shown appropriately + - file lists with paths; every preview shows metadata chips (app, date, + words/lines, bytes, pin/paste counts) +- **Fully theme-integrated** — colors, spacing, corner radius, borders, and the + monospace font all come from the Omarchy shell's `Color`/`Style` singletons; + it re-themes itself on `omarchy theme set`. + +## Keys + +| Key | Action | +| --- | --- | +| `Ctrl+N` / `Ctrl+P` (or arrows) | navigate results | +| `Enter` | copy to clipboard and paste into the focused window | +| `Shift+Enter` | copy only | +| `Ctrl+O` | open (link → browser, image → editor, file → xdg-open, text → editor) | +| `Tab` | pin/unpin | +| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) | +| `Esc` | clear filter, then close | + +## Install + +```bash +./install.sh +``` + +This symlinks `plugin/` into `~/.config/omarchy/plugins/tank.clipboard`, +rescans + enables the plugin (the built-in `omarchy.clipboard` is replaced — +revert with `omarchy plugin disable tank.clipboard`), and rebinds +`ALT+SHIFT+V` from vicinae to this picker (backing up `bindings.conf`). + +## Layout + +``` +plugin/ +├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard) +├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers +├── PreviewPane.qml # per-type preview + metadata chips +├── Store.js # history model: dedup, pins, pruning, ids +├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting +├── Classify.js # type detection, app names, formatting, color math +├── capture.py # clipboard watcher → one JSON line per clip +├── paste-entry.sh # copy + shift-insert paste into the focused window +└── open-entry.sh # open with the right app + +tests/ # node --test suites for the JS logic +``` + +History is stored in `~/.local/state/omarchy/clipboard-history-rich.json` +(image blobs content-addressed under `~/.local/state/omarchy/clipboard-images/`). + +## Development + +- Run logic tests: `tests/run.sh` (45 tests). +- After editing files in the repo, reload with `omarchy-shell shell rescanPlugins` + (the plugin dir is a symlink, so the shell's inotify does not watch repo edits). +- Data lives in `~/.local/state/omarchy/`; the picker state is independent of + the built-in clipboard plugin's `clipboard-history.json`. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4fd8fcd --- /dev/null +++ b/install.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Install the tank.clipboard Omarchy shell plugin from this repo. +# +# - symlinks plugin/ into ~/.config/omarchy/plugins/tank.clipboard +# - rescans + enables it (shell.json gets plugins[] entry; the built-in +# omarchy.clipboard is recorded in disabledPlugins[] and routed here) +# - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf) +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_DIR="$REPO_DIR/plugin" +PLUGINS_DIR="$HOME/.config/omarchy/plugins" +PLUGIN_ID="tank.clipboard" +BINDINGS="$HOME/.config/hypr/bindings.conf" + +echo "==> Symlinking plugin into $PLUGINS_DIR/$PLUGIN_ID" +mkdir -p "$PLUGINS_DIR" +ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID" +chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh + +echo "==> Rescanning shell plugins" +omarchy-shell shell rescanPlugins >/dev/null +discovered=0 +for _ in $(seq 1 40); do + if omarchy-plugin-list --json | jq -e --arg id "$PLUGIN_ID" 'any(.[]; .id == $id)' >/dev/null 2>&1; then + discovered=1 + break + fi + sleep 0.05 +done +if [[ $discovered != 1 ]]; then + echo "ERROR: plugin was not discovered by the shell" >&2 + exit 1 +fi + +echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)" +omarchy plugin enable "$PLUGIN_ID" + +echo "==> Rebinding ALT+SHIFT+V" +if [[ -f $BINDINGS ]] && grep -q "ALT SHIFT, V" "$BINDINGS"; then + cp "$BINDINGS" "$BINDINGS.bak.$(date +%s)" + sed -i 's|^bindd = ALT SHIFT, V, .*|bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard|' "$BINDINGS" + hyprctl reload >/dev/null + echo " rebound; previous bindings backed up next to $BINDINGS" +else + echo " no ALT SHIFT V binding found in $BINDINGS — add manually:" + echo ' bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard' +fi + +echo +echo "Done. Press ALT+SHIFT+V to open the clipboard picker." +echo "Edit code in $REPO_DIR; run 'omarchy-shell shell rescanPlugins' after changes." diff --git a/plugin/Classify.js b/plugin/Classify.js new file mode 100644 index 0000000..9863223 --- /dev/null +++ b/plugin/Classify.js @@ -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) +} diff --git a/plugin/Clipboard.qml b/plugin/Clipboard.qml new file mode 100644 index 0000000..51472cb --- /dev/null +++ b/plugin/Clipboard.qml @@ -0,0 +1,821 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import qs.Commons +import qs.Ui +import "Store.js" as Store +import "Fuzzy.js" as Fuzzy +import "Classify.js" as Classify + +// Clipboard history picker — Raycast-style: fuzzy search bar, result list, +// and a per-type preview pane. Clone of omarchy.clipboard with richer +// capture metadata (app, size, dims, pins, usage) and full-text fuzzy search. +Item { + id: root + + property bool opened: false + property string filterText: "" + property string typeFilter: "" // chip filter, "" = all; combined into the query + property int selectedIndex: 0 + property bool cursorActive: false + property bool cursorVisible: true + property bool clearConfirmOpen: false + property var history: [] + property var results: [] // [{ row, score, positions }] + + property string pluginDir: Qt.resolvedUrl(".").toString().replace(/^file:\/\//, "").replace(/\/$/, "") + property string statePath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history-rich.json" + property int historyLimit: 1500 + property int displayLimit: 200 + property var typeCache: ({}) // id → derived type, memoized + + // Theme surface tokens (menu) — tracks the active Omarchy theme. + property color background: Color.menu.background + property color foreground: Color.menu.text + property color border: Color.menu.border + property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2))) + property color scrim: Color.menu.scrim + property color selectedBackground: Color.menu.selectedBackground + property color selectedText: Color.menu.selectedText + property color accent: Color.accent + property color mutedFg: Util.alpha(foreground, 0.55) + property color chipBg: Util.alpha(foreground, 0.07) + property color lineColor: Util.alpha(foreground, 0.14) + readonly property int cornerRadius: Style.cornerRadius + readonly property string fontFamily: Style.font.menuFamily + readonly property int contentMargin: Style.spacing.panelPadding + readonly property int headerHeight: Math.max(Style.space(40), Style.font.heading + Style.spacing.controlPaddingY * 2) + readonly property int cardWidth: Math.min(Style.space(980), panel.width - Style.gapsOut * 2) + readonly property int cardHeight: Math.min(Style.space(680), panel.height - Style.gapsOut * 2) + readonly property int rowHeight: Style.space(52) + readonly property int listWidth: Math.round(card.width * 0.46) + + readonly property var currentResult: results.length > 0 && selectedIndex >= 0 && selectedIndex < results.length ? results[selectedIndex] : null + + // ------------------------------------------------------------ lifecycle + + function open() { + root.opened = true + root.filterText = "" + root.typeFilter = "" + root.selectedIndex = 0 + root.cursorActive = true + root.disarmPointer() + root.rebuild() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + function close() { + root.cancelClearHistory() + root.opened = false + } + + function toggle() { + if (root.opened) root.close() + else root.open() + } + + // ------------------------------------------------------------ store + + function loadHistory(raw) { + root.history = Store.parseHistory(raw, Math.floor(Date.now() / 1000)) + root.typeCache = {} + if (root.opened) root.rebuild() + } + + function saveHistory() { + var pruned = Store.prune(root.history, root.historyLimit) + root.history = pruned.entries + historyFile.setText(JSON.stringify(root.history, null, 1) + "\n") + if (pruned.droppedImagePaths.length > 0) { + var cmd = ["rm", "-f"].concat(pruned.droppedImagePaths) + gcProc.command = cmd + gcProc.running = true + } + } + + function addClipboardJson(line) { + var entry = null + try { entry = JSON.parse(String(line || "")) } catch (e) { return } + if (!entry) return + root.history = Store.addEntry(root.history, entry, root.historyLimit, Math.floor(Date.now() / 1000)) + root.saveHistory() + if (root.opened) root.rebuild() + } + + // ------------------------------------------------------------ search + + function effectiveQuery() { + var q = root.filterText + if (root.typeFilter === "pinned") return q + (q ? " " : "") + "is:pinned" + if (root.typeFilter) return "type:" + root.typeFilter + (q ? " " + q : "") + return q + } + + function rebuild() { + var now = Math.floor(Date.now() / 1000) + var rows = [] + for (var i = 0; i < root.history.length; i++) { + var entry = root.history[i] + var derived = root.typeCache[entry.id] + if (derived === undefined) { + derived = Classify.deriveType(entry) + root.typeCache[entry.id] = derived + } + rows.push(Store.buildRow(entry, derived, now)) + } + root.results = Fuzzy.searchRows(rows, root.effectiveQuery(), now, root.displayLimit) + + if (root.results.length === 0) root.selectedIndex = 0 + else if (root.selectedIndex >= root.results.length) root.selectedIndex = root.results.length - 1 + + Qt.callLater(function() { + if (root.results.length > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) + }) + } + + function setFilter(next) { + root.filterText = next + root.selectedIndex = 0 + root.cursorActive = true + root.disarmPointer() + root.rebuild() + } + + function setTypeFilter(next) { + root.typeFilter = next + root.selectedIndex = 0 + root.disarmPointer() + root.rebuild() + } + + // ------------------------------------------------------------ navigation + + function select(delta) { + if (root.results.length === 0) return + root.disarmPointer() + if (!root.cursorActive) { + root.cursorActive = true + root.selectedIndex = delta < 0 ? root.results.length - 1 : 0 + } else { + root.selectedIndex = (root.selectedIndex + delta + root.results.length) % root.results.length + } + resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) + } + + function selectAbsolute(index) { + if (root.results.length === 0) return + root.disarmPointer() + root.cursorActive = true + root.selectedIndex = Math.max(0, Math.min(index, root.results.length - 1)) + resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) + } + + function disarmPointer() { + pointerGate.reset() + } + + function selectFromPointer(index, item, mouse) { + if (!pointerGate.moved(item, mouse)) return + root.cursorActive = true + root.selectedIndex = index + } + + // ------------------------------------------------------------ actions + + function pasteResult(result) { + if (!result) return + root.close() + root.history = Store.touch(root.history, result.row.entry.id, Math.floor(Date.now() / 1000)) + root.saveHistory() + Quickshell.execDetached([root.pluginDir + "/paste-entry.sh", result.row.entry.id]) + } + + function copyResult(result) { + if (!result) return + root.close() + Quickshell.execDetached([root.pluginDir + "/paste-entry.sh", result.row.entry.id, "--copy-only"]) + } + + function openResult(result) { + if (!result) return + root.close() + Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id]) + } + + function removeIndex(index) { + if (index < 0 || index >= root.results.length) return + var entry = root.results[index].row.entry + root.history = Store.removeById(root.history, entry.id) + delete root.typeCache[entry.id] + root.saveHistory() + if (root.results.length <= 1) root.selectedIndex = 0 + else if (root.selectedIndex >= root.results.length - 1) root.selectedIndex = root.results.length - 2 + root.rebuild() + } + + function togglePinIndex(index) { + if (index < 0 || index >= root.results.length) return + root.history = Store.togglePin(root.history, root.results[index].row.entry.id) + root.saveHistory() + root.rebuild() + } + + function requestClearHistory() { + if (root.history.length === 0) return + clearConfirm.selectedIndex = 1 + root.clearConfirmOpen = true + } + + function cancelClearHistory() { + root.clearConfirmOpen = false + root.disarmPointer() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + function confirmClearHistory() { + var dropped = [] + for (var i = 0; i < root.history.length; i++) { + if (root.history[i].type === "image" && root.history[i].path) dropped.push(root.history[i].path) + } + root.history = [] + root.typeCache = {} + root.saveHistory() + if (dropped.length > 0) { + gcProc.command = ["rm", "-f"].concat(dropped) + gcProc.running = true + } + root.selectedIndex = 0 + root.clearConfirmOpen = false + root.rebuild() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + Component.onCompleted: initProc.running = true + + // ------------------------------------------------------------ capture + + ListModel { id: displayModel } + + PointerMoveGate { id: pointerGate; referenceItem: card } + + FileView { + id: historyFile + path: root.statePath + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: root.loadHistory(text()) + onLoadFailed: root.loadHistory("[]") + onFileChanged: reload() + } + + Process { id: gcProc } + + // Reap watchers left behind by a previous shell instance, then start our + // own. pdeathsig kills them whenever the shell exits. + Process { + id: initProc + command: ["pkill", "-f", "wl-paste .*--watch .*capture\\.py"] + onExited: { + snapshotProc.running = true + watchProc.running = true + } + } + + Process { + id: snapshotProc + command: ["python3", root.pluginDir + "/capture.py"] + stdout: SplitParser { + onRead: function(data) { root.addClipboardJson(data) } + } + } + + Process { + id: watchProc + command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "python3", root.pluginDir + "/capture.py", "watch"] + onExited: watchRestartTimer.restart() + stdout: SplitParser { + onRead: function(data) { root.addClipboardJson(data) } + } + } + + Timer { + id: watchRestartTimer + interval: 1000 + repeat: false + onTriggered: if (!watchProc.running) watchProc.running = true + } + + // Cursor blink + Timer { + running: root.opened + interval: 530 + repeat: true + onTriggered: root.cursorVisible = !root.cursorVisible + } + + // ------------------------------------------------------------ window + + PanelWindow { + id: panel + visible: root.opened + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + WlrLayershell.namespace: "tank-clipboard" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + + Rectangle { + anchors.fill: parent + color: root.scrim + } + + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + BorderSurface { + id: card + width: root.cardWidth + height: root.cardHeight + radius: root.cornerRadius + anchors.centerIn: parent + color: root.background + borderSpec: root.borderSpec + padding: root.contentMargin + + MouseArea { anchors.fill: parent; onClicked: function() {} } + + Item { + id: keyCatcher + anchors.fill: parent + z: root.clearConfirmOpen ? 20 : 0 + focus: true + + Keys.priority: Keys.BeforeItem + Keys.onPressed: function(event) { + if (root.clearConfirmOpen) { + if (clearConfirm.handleKey(event)) event.accepted = true + return + } + + if (event.key === Qt.Key_Escape) { + if (root.filterText) root.setFilter("") + else if (root.typeFilter) root.setTypeFilter("") + else root.close() + event.accepted = true + } else if (Util.editsFilter(event, root.filterText)) { + root.setFilter(Util.editedFilter(event, root.filterText)) + event.accepted = true + } else if (event.key === Qt.Key_Delete) { + if (event.modifiers & Qt.ShiftModifier) root.requestClearHistory() + else root.removeIndex(root.selectedIndex) + event.accepted = true + } else if (event.key === Qt.Key_Up || (event.key === Qt.Key_P && (event.modifiers & Qt.ControlModifier))) { + root.select(-1) + event.accepted = true + } else if (event.key === Qt.Key_Down || (event.key === Qt.Key_N && (event.modifiers & Qt.ControlModifier))) { + root.select(1) + event.accepted = true + } else if (event.key === Qt.Key_PageUp) { + root.select(-8) + event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + root.select(8) + event.accepted = true + } else if (event.key === Qt.Key_Home) { + root.selectAbsolute(0) + event.accepted = true + } else if (event.key === Qt.Key_End) { + root.selectAbsolute(root.results.length - 1) + event.accepted = true + } else if (event.key === Qt.Key_Tab) { + root.togglePinIndex(root.selectedIndex) + event.accepted = true + } else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) { + root.openResult(root.currentResult) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (event.modifiers & Qt.ShiftModifier) root.copyResult(root.currentResult) + else if (event.modifiers & Qt.AltModifier) root.openResult(root.currentResult) + else root.pasteResult(root.currentResult) + event.accepted = true + } else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) { + root.setFilter(root.filterText + event.text) + event.accepted = true + } + } + + ConfirmDialog { + id: clearConfirm + anchors.fill: parent + opened: root.clearConfirmOpen + z: 10 + message: "Delete entire clipboard history?" + confirmText: "Delete" + background: root.background + foreground: root.foreground + scrim: root.scrim + selectedBackground: root.selectedBackground + selectedText: root.selectedText + fontFamily: root.fontFamily + cornerRadius: root.cornerRadius + onCanceled: root.cancelClearHistory() + onConfirmed: root.confirmClearHistory() + } + } + + // ---------------------------------------------------------- layout + + Column { + anchors.fill: parent + anchors.topMargin: card.contentTopInset + anchors.rightMargin: card.contentRightInset + anchors.bottomMargin: card.contentBottomInset + anchors.leftMargin: card.contentLeftInset + spacing: Style.space(10) + + // ---- search bar + Item { + width: parent.width + height: root.headerHeight + + Text { + id: searchIcon + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "󰍛" + color: root.accent + font.family: root.fontFamily + font.pixelSize: Style.font.heading + } + + Row { + anchors.left: searchIcon.right + anchors.leftMargin: Style.space(10) + anchors.right: countLabel.left + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(2) + + Text { + id: searchText + width: Math.min(implicitWidth, parent.width - Style.space(6)) + text: root.filterText || "Search clipboard\u2026 (type:image app:firefox <2h is:pinned)" + color: root.foreground + opacity: root.filterText.length > 0 ? 1 : 0.4 + font.family: root.fontFamily + font.pixelSize: Style.font.heading + elide: Text.ElideRight + maximumLineCount: 1 + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Math.max(1, Style.space(2)) + height: Style.font.heading + color: root.accent + visible: root.cursorVisible && root.cursorActive + } + } + + Text { + id: countLabel + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: { + var shown = root.results.length + var total = root.history.length + if (shown === total) return total + " items" + return shown + " of " + total + } + color: root.mutedFg + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // ---- type chips + Row { + id: chipsRow + width: parent.width + spacing: Style.space(6) + + Repeater { + model: [ + { key: "", label: "All" }, + { key: "text", label: "󰈙 Text" }, + { key: "link", label: "󰌹 Links" }, + { key: "image", label: "󰋲 Images" }, + { key: "files", label: "󰉋 Files" }, + { key: "code", label: "󰅴 Code" }, + { key: "json", label: "󰘦 JSON" }, + { key: "color", label: "󰏘 Colors" }, + { key: "pinned", label: "★ Pinned" } + ] + + delegate: Rectangle { + required property var modelData + property string chipKey: modelData.key + property string chipLabel: modelData.label + property bool active: root.typeFilter === chipKey + + radius: height / 2 + color: active ? Util.alpha(root.accent, 0.18) : root.chipBg + width: chipLabel_.implicitWidth + Style.space(16) + height: Style.space(22) + + Text { + id: chipLabel_ + anchors.centerIn: parent + text: modelData.label + color: parent.active ? root.accent : root.mutedFg + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.setTypeFilter(parent.chipKey) + } + } + } + } + + // ---- list + preview + Item { + width: parent.width + height: parent.height - root.headerHeight - chipsRow.height - footer.height - Style.space(30) + + ListView { + id: resultList + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + width: root.listWidth + model: displayModel + clip: true + spacing: Style.space(3) + boundsBehavior: Flickable.StopAtBounds + + delegate: Rectangle { + id: row + required property int index + required property var row_ // results[i]: { row, score, positions } + required property string derived + required property string titleHtml + required property string subtitle + + readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex + readonly property var entry: row_ ? row_.row.entry : null + + width: resultList.width + height: root.rowHeight + radius: root.cornerRadius + color: hasCursor ? root.selectedBackground : "transparent" + + Row { + anchors.fill: parent + anchors.leftMargin: Style.space(10) + anchors.rightMargin: Style.space(10) + spacing: Style.space(10) + + // thumbnail for images, glyph tile otherwise + Rectangle { + width: Style.space(36) + height: Style.space(36) + radius: Style.space(6) + color: root.chipBg + anchors.verticalCenter: parent.verticalCenter + clip: true + + Image { + anchors.fill: parent + visible: row.derived === "image" + source: row.entry && row.entry.path ? Util.fileUrl(row.entry.path) : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + smooth: true + sourceSize.width: 72 + sourceSize.height: 72 + } + + Text { + anchors.centerIn: parent + visible: row.derived !== "image" + text: Classify.typeIcon(row.derived) + color: root.foreground + opacity: 0.85 + font.family: root.fontFamily + font.pixelSize: Style.font.iconLarge + } + } + + Column { + width: parent.width - Style.space(46) - pinMark.width + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(2) + + Text { + width: parent.width + text: row.titleHtml + textFormat: Text.StyledText + color: row.hasCursor ? root.selectedText : root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + width: parent.width + text: row.subtitle + color: root.mutedFg + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + Text { + id: pinMark + anchors.verticalCenter: parent.verticalCenter + text: "★" + visible: row.entry && row.entry.pinned ? true : false + color: root.accent + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPositionChanged: function(mouse) { root.selectFromPointer(row.index, row, mouse) } + onClicked: { + root.cursorActive = true + root.selectedIndex = row.index + root.pasteResult(root.currentResult) + } + } + } + } + + // divider between list and preview + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.leftMargin: root.listWidth + Style.space(6) + width: 1 + color: root.lineColor + } + + PreviewPane { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.left: parent.left + anchors.leftMargin: root.listWidth + Style.space(14) + result: root.currentResult + visible: root.currentResult !== null + } + + // empty state + Column { + anchors.centerIn: parent + spacing: Style.space(8) + visible: displayModel.count === 0 + + Text { + text: "󰅌" + color: root.selectedText + opacity: 0.8 + font.family: root.fontFamily + font.pixelSize: Style.font.displayLarge + horizontalAlignment: Text.AlignHCenter + width: parent.width + } + + Text { + text: root.history.length === 0 + ? "Clipboard is empty — copy something first" + : "No matches for “" + root.filterText + "”" + color: root.foreground + opacity: 0.7 + font.family: root.fontFamily + font.pixelSize: Style.font.title + horizontalAlignment: Text.AlignHCenter + width: parent.width + } + } + } + + // ---- footer key hints + Row { + id: footer + width: parent.width + spacing: Style.space(10) + + Repeater { + model: [ + { keys: "ctrl+n/p", hint: "navigate" }, + { keys: "enter", hint: "paste" }, + { keys: "shift+enter", hint: "copy" }, + { keys: "ctrl+o", hint: "open" }, + { keys: "tab", hint: "pin" }, + { keys: "del", hint: "remove" }, + { keys: "esc", hint: "close" } + ] + + delegate: Row { + required property var modelData + spacing: Style.space(4) + + Rectangle { + radius: Style.space(3) + color: root.chipBg + width: keyText.implicitWidth + Style.space(10) + height: Style.space(18) + anchors.verticalCenter: parent.verticalCenter + + Text { + id: keyText + anchors.centerIn: parent + text: modelData.keys + color: root.mutedFg + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + Text { + text: modelData.hint + color: root.mutedFg + opacity: 0.7 + font.family: root.fontFamily + font.pixelSize: Style.font.caption + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + } + + // ------------------------------------------------------------ display model + + // results → displayModel rows. Wrapped in function so it can be called from + // rebuild(); title/subtitle strings precomputed here, not in delegates. + function syncDisplayModel() { + displayModel.clear() + for (var i = 0; i < root.results.length; i++) { + var r = root.results[i] + var e = r.row.entry + var derived = r.row.type + var titleHtml = "" + var subtitleParts = [] + + if (e.type === "image") { + titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "") + if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h + subtitleParts.push(Classify.formatBytes(r.row.bytes)) + } else if (e.type === "files") { + var base = Classify.fileBase(e.paths[0]) + titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base) + subtitleParts.push(Classify.fileDir(e.paths[0])) + } else { + titleHtml = Fuzzy.highlightFirstLine(e.text, r.positions, "", "") + var st = Classify.textStats(e.text) + if (st.words > 0) subtitleParts.push(Classify.plural(st.words, "word")) + } + + subtitleParts.push(Classify.typeLabel(derived)) + if (r.row.app) subtitleParts.push(Classify.prettyApp(r.row.app)) + if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000))) + + displayModel.append({ + row_: r, + derived: derived, + titleHtml: titleHtml, + subtitle: subtitleParts.join(" · ") + }) + } + } + + // 6-digit hex for rich-text tags, whatever toString() returns. + function accentHex() { + var s = root.accent.toString() + return "#" + s.slice(-6) + } + + onResultsChanged: syncDisplayModel() +} diff --git a/plugin/Fuzzy.js b/plugin/Fuzzy.js new file mode 100644 index 0000000..fcbebc5 --- /dev/null +++ b/plugin/Fuzzy.js @@ -0,0 +1,322 @@ +.pragma library + +// Fuzzy search engine for the clipboard picker. +// +// Pure ES5, no imports, so the same file runs inside QML (.pragma library) +// and under node (tests load it with the `vm` module). +// +// Query language (whitespace separated tokens): +// plain words fuzzy subsequence match against content + app + type +// type: filter by derived type (image, link, color, code, json, +// file, email, html, number, text) — prefix match +// app: fuzzy match against the source app +// is:pinned pin only pinned entries +// today yesterday age filters +// week month age filters +// <10m >2h <3d age comparisons (s/m/h/d/w suffixes) +// +// Rows handed to searchRows() are precomputed by the caller: +// { entry, content, app, type, ts, pinned, uses, bytes } + +// ---------------------------------------------------------------- durations + +var DURATION_UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 } + +function parseDuration(str) { + var m = /^(\d+(?:\.\d+)?)\s*([smhdw])$/.exec(String(str || "").trim().toLowerCase()) + if (!m) return NaN + var unit = DURATION_UNITS[m[2]] + return Number(m[1]) * unit +} + +var AGE_WORDS = { + today: 86400, + yesterday: 172800, + week: 604800, + month: 2592000, + year: 31536000 +} + +var TYPE_ALIASES = { + image: "image", img: "image", pic: "image", photo: "image", screenshot: "image", + link: "link", url: "link", links: "link", urls: "link", + color: "color", colour: "color", hex: "color", + code: "code", snippet: "code", + json: "json", + file: "files", files: "files", folder: "files", path: "files", dir: "files", + email: "email", mail: "email", + html: "html", + number: "number", num: "number", + text: "text", txt: "text" +} + +// ---------------------------------------------------------------- query + +function parseQuery(q) { + var out = { terms: [], type: "", app: "", pinned: false, minAge: -1, maxAge: -1 } + var raw = String(q || "").split(/\s+/) + for (var i = 0; i < raw.length; i++) { + var tok = raw[i] + if (!tok) continue + var lower = tok.toLowerCase() + + if (lower.indexOf("type:") === 0 && tok.length > 5) { + var wanted = lower.slice(5) + var canonical = TYPE_ALIASES[wanted] + if (canonical) { out.type = canonical; continue } + // prefix match: "type:im" → image + for (var key in TYPE_ALIASES) { + if (key.indexOf(wanted) === 0) { out.type = TYPE_ALIASES[key]; break } + } + if (out.type) continue + // unknown type — treat the whole token as a term + out.terms.push(tok) + continue + } + + if (lower.indexOf("app:") === 0 && tok.length > 4) { + out.app = tok.slice(4).toLowerCase() + continue + } + + if (lower === "is:pinned" || lower === "pinned" || lower === "pin") { + out.pinned = true + continue + } + + if (lower === "is:text" || lower === "star" || lower === "fav") { + out.terms.push(tok) + continue + } + + if (AGE_WORDS[lower] !== undefined) { + out.maxAge = AGE_WORDS[lower] + if (lower === "yesterday") out.minAge = 0 // refined below relative to now; caller passes now + continue + } + + if ((lower.charAt(0) === "<" || lower.charAt(0) === ">") && lower.length > 1) { + var dur = parseDuration(lower.slice(1)) + if (!isNaN(dur)) { + if (lower.charAt(0) === "<") out.maxAge = dur + else out.minAge = dur + continue + } + } + + out.terms.push(tok) + } + return out +} + +// ---------------------------------------------------------------- fuzzy match + +// Greedy case-insensitive subsequence match with fzf-style bonuses. +// Returns null when `needle` is not a subsequence of `haystack`, otherwise +// { score, positions } where positions are indices into the raw haystack. +function fuzzyMatch(needle, haystack) { + if (!needle) return { score: 0, positions: [] } + if (!haystack) return null + var nl = needle.toLowerCase() + var hl = haystack.toLowerCase() + var n = nl.length + var h = hl.length + if (n > h) return null + + // Fast reject: all characters must be present in order via greedy scan. + var positions = new Array(n) + var j = 0 + for (var i = 0; i < h && j < n; i++) { + if (hl.charCodeAt(i) === nl.charCodeAt(j)) positions[j++] = i + } + if (j < n) return null + + // Exact substring bonus (case-insensitive). + var substringIdx = hl.indexOf(nl) + var score = 0 + var consecutive = 0 + var prev = -2 + for (var k = 0; k < n; k++) { + var pos = positions[k] + if (pos === prev + 1) consecutive++ + else consecutive = 0 + score += 4 + consecutive * 3 + + // Word boundary bonus: start of string or after a non-alphanumeric. + if (pos === 0) score += 10 + else { + var prevChar = hl.charCodeAt(pos - 1) + if (!(prevChar >= 97 && prevChar <= 122) && + !(prevChar >= 48 && prevChar <= 57) && + !(prevChar >= 65 && prevChar <= 90)) + score += 8 + } + + // Gap penalty (light — we rank mostly by bonuses and recency). + if (k > 0) { + var gap = pos - positions[k - 1] - 1 + if (gap > 0) score -= Math.min(6, gap) + } + prev = pos + } + + if (substringIdx >= 0) { + var bonus = 40 + if (substringIdx === 0) bonus += 25 + // Prefer matches in shorter haystacks. + bonus -= Math.min(20, hl.length / 200) + if (bonus > score) { + score = bonus + for (var c = 0; c < n; c++) positions[c] = substringIdx + c + } + } + + // Length penalty so "func" ranks higher in short snippets than in essays. + score -= Math.min(25, hl.length / 400) + + return { score: score, positions: positions } +} + +var FIELDS = [ + { name: "content", weight: 1.0 }, + { name: "app", weight: 0.7 }, + { name: "type", weight: 0.6 } +] + +// Every term must match in at least one field; the score is the sum of each +// term's best field score (already weighted). Positions returned are for the +// best-scoring content-field match (for highlighting). +function matchRow(queryTerms, row) { + var total = 0 + var contentPositions = null + for (var t = 0; t < queryTerms.length; t++) { + var term = queryTerms[t] + var best = -1 + for (var f = 0; f < FIELDS.length; f++) { + var field = FIELDS[f] + var text = field.name === "content" ? row.content + : field.name === "app" ? row.app + : row.type + if (!text) continue + var m = fuzzyMatch(term, text) + if (m) { + var weighted = m.score * field.weight + if (weighted > best) { + best = weighted + if (field.name === "content") contentPositions = m.positions + } + } + } + if (best < 0) return null + total += best + } + return { score: total, positions: contentPositions } +} + +function ageFilterOk(parsed, ageSeconds) { + if (parsed.maxAge >= 0 && ageSeconds > parsed.maxAge) return false + if (parsed.minAge >= 0 && ageSeconds < parsed.minAge) return false + return true +} + +// "yesterday" means older than 24h but within 48h. +function refineYesterday(parsed, now) { + // Only applies when the query contained the bare word "yesterday" and no + // explicit duration bounds; approximated by caller passing minAge via the + // maxAge=172800 already set. We require age > 86400 - slack handled here. + return parsed +} + +function recencyBonus(ts, now) { + if (!ts) return 0 + var ageDays = Math.max(0, (now - ts) / 86400) + return 120 / (1 + ageDays) +} + +// rows: [{ entry, content, app, type, ts, pinned, uses, bytes }] +// Returns up to `limit` rows sorted by relevance: [{ row, score, positions, type }] +function searchRows(rows, queryStr, now, limit) { + var parsed = parseQuery(queryStr) + var results = [] + if (parsed.maxAge < 0 && parsed.terms.length === 0 && !parsed.type && !parsed.app && !parsed.pinned) + parsed.maxAge = -1 // no-op, keeps empty-query path below cheap + + for (var i = 0; i < rows.length; i++) { + var row = rows[i] + + if (parsed.type && row.type !== parsed.type) continue + if (parsed.pinned && !row.entry.pinned) continue + + var age = row.ts > 0 ? Math.max(0, now - row.ts) : 0 + if (parsed.maxAge >= 0 && age > parsed.maxAge) continue + if (parsed.minAge >= 0 && age < parsed.minAge) continue + + if (parsed.app) { + var appMatch = fuzzyMatch(parsed.app, row.app || "") + if (!appMatch) continue + } + + var matched = null + if (parsed.terms.length > 0) { + matched = matchRow(parsed.terms, row) + if (!matched) continue + } + + var score = matched ? matched.score : 0 + score += recencyBonus(row.ts, now) + if (row.entry.pinned) score += 500 + if (row.uses > 0) score += 4 * Math.min(10, row.uses) + + results.push({ + row: row, + score: score, + positions: matched ? matched.positions : null + }) + } + + results.sort(function(a, b) { + if (b.score !== a.score) return b.score - a.score + return (b.row.ts || 0) - (a.row.ts || 0) + }) + + var cap = limit === undefined ? 200 : limit + if (results.length > cap) results.length = cap + return results +} + +// Escape for QML StyledText (HTML-ish). +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) +} + +// Build highlight markup for the first line of `text` given match positions. +// positions are indices into the RAW text; text is escaped segment-by-segment. +function highlightFirstLine(text, positions, openTag, closeTag) { + text = String(text || "") + var firstLineEnd = text.indexOf("\n") + var line = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd) + if (!positions || positions.length === 0 || !line) + return escapeHtml(line) + + var inLine = [] + for (var i = 0; i < positions.length; i++) { + if (positions[i] < line.length) inLine.push(positions[i]) + } + if (inLine.length === 0) return escapeHtml(line) + + var out = "" + var prev = 0 + for (var p = 0; p < inLine.length; p++) { + var idx = inLine[p] + if (idx < prev) continue // match spans past the line break — skip tail + out += escapeHtml(line.slice(prev, idx)) + out += openTag + escapeHtml(line.charAt(idx)) + closeTag + prev = idx + 1 + } + out += escapeHtml(line.slice(prev)) + return out +} diff --git a/plugin/PreviewPane.qml b/plugin/PreviewPane.qml new file mode 100644 index 0000000..6f04711 --- /dev/null +++ b/plugin/PreviewPane.qml @@ -0,0 +1,486 @@ +import QtQuick +import qs.Commons +import qs.Ui +import "Classify.js" as Classify + +// Right-hand preview pane for the clipboard picker. +// Renders a full preview plus metadata chips, per derived type. +// `result` is a Fuzzy search result: { row: {entry, content, app, type, ...}, positions } +Item { + id: root + + property var result: null + property var entry: result ? result.row.entry : null + property string derived: result ? result.row.type : "" + + readonly property string font_: Style.font.menuFamily + readonly property color fg: Color.menu.text + readonly property color mutedFg: Util.alpha(fg, 0.55) + readonly property color chipBg: Util.alpha(fg, 0.07) + readonly property color lineColor: Util.alpha(fg, 0.16) + + property string bodyText: "" + + onResultChanged: prepare() + + function prepare() { + bodyText = "" + if (!entry) return + var t = derived + if (t === "json") { + var pretty = Classify.prettyJson(String(entry.text || ""), 200000) + bodyText = pretty || String(entry.text || "") + } else if (t === "html") { + bodyText = Classify.stripHtml(String(entry.text || "")) || String(entry.text || "") + } else if (t === "text" || t === "code" || t === "email" || t === "number") { + bodyText = String(entry.text || "") + } + } + + function rawSafe() { + return entry ? String(entry.text || "").trim() : "" + } + + function rgbLine() { + var rgb = Classify.colorToRgb(rawSafe()) + return rgb ? "rgb(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ")" : "" + } + + function hslLine() { + var hsl = Classify.colorToHsl(rawSafe()) + return hsl ? "hsl(" + hsl[0] + ", " + hsl[1] + "%, " + hsl[2] + "%)" : "" + } + + function metaChips() { + if (!result) return [] + var r = result.row + var e = r.entry + var chips = [] + if (r.app) chips.push(Classify.prettyApp(r.app)) + if (e.type === "text" && e.text) { + var st = Classify.textStats(e.text) + chips.push(Classify.plural(st.words, "word")) + chips.push(Classify.plural(st.lines, "line")) + } + if (r.bytes > 0) chips.push(Classify.formatBytes(r.bytes)) + if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h) + if (e.type === "image") chips.push(e.mime || "image") + if (e.pinned) chips.push("★ pinned") + if (r.uses > 0) chips.push("pasted " + r.uses + "×") + return chips + } + + // ------------------------------------------------------------- header + + Row { + id: headerRow + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: Style.space(10) + + Rectangle { + width: Style.space(38) + height: width + radius: Style.cornerRadius + color: root.chipBg + anchors.verticalCenter: parent.verticalCenter + + Text { + anchors.centerIn: parent + text: Classify.typeIcon(root.derived) + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.heading + } + } + + Column { + width: parent.width - Style.space(48) - badge.width - Style.space(10) + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(2) + + Text { + width: parent.width + text: { + if (!root.entry) return "" + var e = root.entry + if (e.type === "image") return Classify.fileBase(e.path) + if (e.type === "files") { + var base = Classify.fileBase(e.paths[0]) + return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base + } + return Classify.firstLine(e.text, 200) + } + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.title + font.bold: true + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + width: parent.width + text: { + if (!root.result) return "" + var r = root.result.row + var parts = [] + if (r.ts) parts.push(Classify.formatFullDate(r.ts)) + if (r.bytes) parts.push(Classify.formatBytes(r.bytes)) + return parts.join(" · ") + } + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + elide: Text.ElideRight + maximumLineCount: 1 + } + } + + Rectangle { + id: badge + anchors.verticalCenter: parent.verticalCenter + radius: height / 2 + color: root.chipBg + width: badgeLabel.implicitWidth + Style.space(14) + height: Style.space(20) + + Text { + id: badgeLabel + anchors.centerIn: parent + text: Classify.typeIcon(root.derived) + " " + Classify.typeLabel(root.derived) + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + } + } + } + + Rectangle { + id: divider + anchors.top: headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.space(10) + height: 1 + color: root.lineColor + } + + // ------------------------------------------------------------- body + + // text-ish body (text / code / json / html / email / number) + Flickable { + id: textBody + anchors.top: divider.bottom + anchors.bottom: metaRow.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.space(10) + anchors.bottomMargin: Style.space(10) + visible: root.bodyText !== "" + clip: true + contentWidth: width + contentHeight: bodyEdit.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: function(ev) { + if (ev.angleDelta.y < 0) textBody.flick(0, -240) + else textBody.flick(0, 240) + ev.accepted = true + } + } + + TextEdit { + id: bodyEdit + width: parent.width + readOnly: true + activeFocusOnPress: false + text: root.bodyText + textFormat: TextEdit.PlainText + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.body + wrapMode: TextEdit.Wrap + selectionColor: Util.alpha(Color.accent, 0.4) + } + } + + // color body + Column { + anchors.top: divider.bottom + anchors.bottom: metaRow.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.space(10) + anchors.bottomMargin: Style.space(10) + visible: root.derived === "color" + spacing: Style.space(14) + + Rectangle { + width: Math.min(Style.space(240), parent.width) + height: Style.space(120) + radius: Style.cornerRadius + color: root.hexRe.test(root.rawSafe()) ? root.rawSafe() : "transparent" + border.width: 1 + border.color: root.lineColor + } + + Text { + text: root.rawSafe() + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.heading + font.bold: true + } + + Text { + visible: root.rgbLine().length > 0 + text: root.rgbLine() + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.body + } + + Text { + visible: root.hslLine().length > 0 + text: root.hslLine() + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.body + } + } + + // link body + Column { + anchors.top: divider.bottom + anchors.bottom: metaRow.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.space(10) + anchors.bottomMargin: Style.space(10) + visible: root.derived === "link" + spacing: Style.space(10) + + Text { + text: Classify.urlDomain(root.entry ? String(root.entry.text || "") : "") + color: Color.accent + font.family: root.font_ + font.pixelSize: Style.font.display + font.bold: true + elide: Text.ElideRight + width: parent.width + maximumLineCount: 1 + } + + Text { + text: root.entry ? Classify.firstLine(String(root.entry.text || ""), 400) : "" + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.body + wrapMode: Text.WrapAnywhere + width: parent.width + } + + Text { + text: "Ctrl+O opens this link in your browser" + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + } + } + + // image body + Column { + anchors.top: divider.bottom + anchors.bottom: metaRow.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.space(10) + anchors.bottomMargin: Style.space(10) + visible: root.derived === "image" + spacing: Style.space(8) + + Item { + width: parent.width + height: parent.height - infoLabel.height - Style.space(12) + + Image { + id: img + anchors.centerIn: parent + width: parent.width + height: parent.height + source: root.entry && root.entry.path ? Util.fileUrl(root.entry.path) : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + smooth: true + cache: false + } + + Rectangle { + anchors.fill: img + color: "transparent" + border.width: 1 + border.color: root.lineColor + radius: Style.space(4) + visible: img.status === Image.Ready + } + + Text { + anchors.centerIn: parent + visible: img.status === Image.Error || img.status === Image.Null + text: "Preview unavailable" + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.body + } + } + + Text { + id: infoLabel + text: { + if (!root.entry) return "" + var e = root.entry + var dims = e.w && e.h ? e.w + " × " + e.h + " px · " : "" + return dims + (e.mime || "image") + (e.bytes ? " · " + Classify.formatBytes(e.bytes) : "") + } + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + } + } + + // files body + Column { + anchors.top: divider.bottom + anchors.bottom: metaRow.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.space(10) + anchors.bottomMargin: Style.space(10) + visible: root.derived === "files" + spacing: Style.space(6) + clip: true + + Repeater { + model: { + if (!root.entry || root.entry.type !== "files") return [] + return (root.entry.paths || []).slice(0, 10) + } + + delegate: Row { + required property var modelData + width: parent ? parent.width : 0 + spacing: Style.space(8) + + Text { + text: "󰈚" + color: Color.accent + font.family: root.font_ + font.pixelSize: Style.font.body + } + + Text { + text: Classify.fileBase(modelData) + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.body + elide: Text.ElideMiddle + width: parent.width - Style.space(24) + maximumLineCount: 1 + } + } + } + + Text { + visible: root.entry && root.entry.paths && root.entry.paths.length > 10 + text: root.entry && root.entry.paths ? "… and " + (root.entry.paths.length - 10) + " more" : "" + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + } + + Text { + text: root.entry && root.entry.paths && root.entry.paths.length > 0 + ? "in " + Classify.fileDir(root.entry.paths[0]) + : "" + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + elide: Text.ElideMiddle + width: parent.width + maximumLineCount: 1 + } + } + + // ------------------------------------------------------------- meta chips + + Row { + id: metaRow + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + spacing: Style.space(6) + + Rectangle { + width: Style.space(20) + height: Style.space(20) + radius: height / 2 + color: root.chipBg + anchors.verticalCenter: parent.verticalCenter + + Text { + anchors.centerIn: parent + text: Classify.typeIcon(root.derived) + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + } + } + + Rectangle { + radius: height / 2 + color: root.chipBg + width: typeChipText.implicitWidth + Style.space(14) + height: Style.space(20) + anchors.verticalCenter: parent.verticalCenter + + Text { + id: typeChipText + anchors.centerIn: parent + text: Classify.typeLabel(root.derived) + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + } + } + + Repeater { + model: root.metaChips() + + delegate: Rectangle { + required property var modelData + property string chipText: String(modelData) + + radius: height / 2 + color: root.chipBg + width: chipLabel.implicitWidth + Style.space(14) + height: Style.space(20) + anchors.verticalCenter: parent.verticalCenter + + Text { + id: chipLabel + anchors.centerIn: parent + text: parent.chipText + color: root.mutedFg + font.family: root.font_ + font.pixelSize: Style.font.caption + } + } + } + } + + readonly property var hexRe: /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i +} diff --git a/plugin/Store.js b/plugin/Store.js new file mode 100644 index 0000000..a2a6f1b --- /dev/null +++ b/plugin/Store.js @@ -0,0 +1,201 @@ +.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) +} diff --git a/plugin/capture.py b/plugin/capture.py new file mode 100755 index 0000000..535f144 --- /dev/null +++ b/plugin/capture.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Clipboard capture for the tank.clipboard Omarchy shell plugin. + +Invoked by `wl-paste --watch` (payload on stdin is ignored — we probe +ourselves so a single watcher covers every mime) or with no args to snapshot +the current clipboard. Emits exactly one JSON line per capture: + + {"type":"text","text":...,"ts":...,"bytes":...,"app":...} + {"type":"image","mime":...,"path":...,"w":...,"h":...,"bytes":...,"ts":...,"app":...} + {"type":"files","paths":[...],"bytes":...,"ts":...,"app":...} + +Sensitive clips (x-kde-passwordManagerHint / CLIPBOARD_STATE=sensitive) and +binary payloads are skipped silently. +""" + +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.parse + +STATE_DIR = os.path.join( + os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")), "omarchy" +) +IMAGE_DIR = os.path.join(STATE_DIR, "clipboard-images") + +IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff"] +IMAGE_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", + "image/gif": "gif", "image/bmp": "bmp", "image/tiff": "tiff"} +TEXT_TYPES = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "COMPOUND_TEXT"] + + +def run(args, timeout=5): + try: + r = subprocess.run(args, capture_output=True, timeout=timeout) + return r.stdout if r.returncode == 0 else None + except Exception: + return None + + +def list_types(): + out = run(["wl-paste", "--list-types"]) + if not out: + return [] + return [line for line in out.decode("utf-8", "replace").splitlines() if line] + + +def focused_app(): + out = run(["hyprctl", "activewindow", "-j"], timeout=2) + if not out: + return "" + try: + data = json.loads(out) + return str(data.get("class") or "") + except Exception: + return "" + + +def emit(entry): + entry["ts"] = int(time.time()) + print(json.dumps(entry)) + sys.stdout.flush() + + +def capture_image(types, app): + mime = next((m for m in IMAGE_MIMES if m in types), None) + if not mime: + return + data = run(["wl-paste", "--type", mime, "--no-newline"]) + if not data: + return + os.makedirs(IMAGE_DIR, exist_ok=True) + digest = hashlib.sha256(data).hexdigest() + path = os.path.join(IMAGE_DIR, f"{digest}.{IMAGE_EXT[mime]}") + entry = {"type": "image", "mime": mime, "path": path, "bytes": len(data), "app": app} + + # Dimensions via Pillow when available; harmless without it. + try: + from PIL import Image # noqa: PLC0415 + import io # noqa: PLC0415 + with Image.open(io.BytesIO(data)) as im: + entry["w"], entry["h"] = im.size + except Exception: + pass + + if not os.path.exists(path): + fd, tmp = tempfile.mkstemp(dir=IMAGE_DIR) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + return + emit(entry) + + +def decode_text(data): + """Best-effort text decode; returns None for binary-looking payloads.""" + for enc in ("utf-8", "utf-16"): + try: + text = data.decode(enc) + break + except (UnicodeDecodeError, UnicodeError): + continue + else: + return None + # Reject payloads that still look binary after decoding. + if "\x00" in text: + return None + if text: + nul_control = sum(1 for c in text if ord(c) < 32 and c not in "\n\r\t") + if nul_control / max(1, len(text)) > 0.05: + return None + return text + + +def capture_uri_list(types, app): + if "text/uri-list" not in types: + return False + data = run(["wl-paste", "--type", "text/uri-list", "--no-newline"]) + if not data: + return False + text = decode_text(data) + if text is None: + return False + uris = [u.strip() for u in text.splitlines() if u.strip() and not u.startswith("#")] + if not uris: + return False + paths = [] + for uri in uris: + if uri.startswith("file://"): + paths.append(urllib.parse.unquote(urllib.parse.urlparse(uri).path)) + else: + return False # remote URI → keep it as plain text below + if not paths: + return False + emit({"type": "files", "paths": paths, "bytes": len(data), "app": app}) + return True + + +def capture_text(types, app): + mime = next((m for m in TEXT_TYPES if m in types), None) + if not mime: + return + data = run(["wl-paste", "--type", mime, "--no-newline"]) + if not data: + return + text = decode_text(data) + if text is None or not text.strip(): + return + emit({"type": "text", "text": text, "bytes": len(data), "app": app}) + + +def main(): + sys.stdin.close() # watcher payload unused; we probe ourselves + types = list_types() + if not types: + return + if "x-kde-passwordManagerHint" in types: + return + if os.environ.get("CLIPBOARD_STATE", "") == "sensitive": + return + + app = focused_app() + if capture_image(types, app): + return + if capture_uri_list(types, app): + return + capture_text(types, app) + + +if __name__ == "__main__": + main() diff --git a/plugin/manifest.json b/plugin/manifest.json new file mode 100644 index 0000000..ca72dfe --- /dev/null +++ b/plugin/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "tank.clipboard", + "name": "Clipboard History", + "version": "1.0.0", + "author": "tank", + "description": "Raycast-style clipboard history: fuzzy search over content, type, app and date, with rich per-type previews", + "kinds": [ + "overlay" + ], + "keepLoaded": true, + "entryPoints": { + "overlay": "Clipboard.qml" + }, + "omarchy": { + "clonedFrom": "omarchy.clipboard" + } +} diff --git a/plugin/open-entry.sh b/plugin/open-entry.sh new file mode 100755 index 0000000..fce3e91 --- /dev/null +++ b/plugin/open-entry.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Open a history entry from the tank.clipboard store with the right app. +# Usage: open-entry.sh +set -euo pipefail + +STATE="$HOME/.local/state/omarchy/clipboard-history-rich.json" +ID="${1:-}" + +[[ -n $ID && -r $STATE ]] || exit 0 + +entry=$(jq -c --arg id "$ID" '[.[] | select(.id == $id)][0]' "$STATE") || exit 0 +[[ $entry != "null" && -n $entry ]] || exit 0 + +open_text() { + local text=$1 url="" + url=$(grep -Eom1 'https?://[^[:space:]"<>]+' <<<"$text" || true) + if [[ -z $url ]]; then + local www + www=$(grep -Eom1 'www\.[^[:space:]]+' <<<"$text" || true) + [[ -n $www ]] && url="https://$www" + fi + if [[ -z $url && $text =~ ^[[:space:]]*([[:alnum:]][[:alnum:].-]+\.[[:alpha:]]{2,})(/[^[:space:]]*)?[[:space:]]*$ ]]; then + url="https://${BASH_REMATCH[1]}${BASH_REMATCH[2]}" + fi + if [[ -n $url ]]; then + exec omarchy-launch-browser "$url" + fi + local dir file + dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/clipboard-open" + mkdir -p "$dir" + file=$(mktemp --tmpdir="$dir" clipboard.XXXXXX.txt) + printf '%s' "$text" >"$file" + exec omarchy-launch-editor "$file" +} + +case $(jq -r '.type' <<<"$entry") in + image) + path=$(jq -r '.path' <<<"$entry") + [[ -r $path ]] || exit 0 + if command -v tensaku-edit >/dev/null 2>&1; then + exec tensaku-edit "$path" + fi + exec xdg-open "$path" + ;; + files) + first=$(jq -r '.paths[0]' <<<"$entry") + [[ -n $first ]] || exit 0 + exec xdg-open "$first" + ;; + text) + open_text "$(jq -j '.text' <<<"$entry")" + ;; +esac diff --git a/plugin/paste-entry.sh b/plugin/paste-entry.sh new file mode 100755 index 0000000..e5ed995 --- /dev/null +++ b/plugin/paste-entry.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Paste (or just copy) a history entry from the tank.clipboard store. +# Usage: paste-entry.sh [--copy-only] +set -euo pipefail + +STATE="$HOME/.local/state/omarchy/clipboard-history-rich.json" +ID="${1:-}" +MODE="${2:-}" + +[[ -n $ID && -r $STATE ]] || exit 0 + +entry=$(jq -c --arg id "$ID" '[.[] | select(.id == $id)][0]' "$STATE") || exit 0 +[[ $entry != "null" && -n $entry ]] || exit 0 + +type=$(jq -r '.type' <<<"$entry") + +case "$type" in + image) + path=$(jq -r '.path' <<<"$entry") + mime=$(jq -r '.mime // "image/png"' <<<"$entry") + [[ -r $path ]] || exit 0 + wl-copy --type "$mime" <"$path" + ;; + files) + while IFS= read -r p; do + printf 'file://%s\n' "$p" + done < <(jq -r '.paths[]' <<<"$entry") | wl-copy --type text/uri-list + ;; + *) + jq -j '.text' <<<"$entry" | wl-copy + ;; +esac + +[[ $MODE == "--copy-only" ]] && exit 0 + +# Give the layer surface a moment to close and focus to fall back to the +# previously-focused window, then paste via the universal Shift+Insert. +sleep 0.15 +wtype -M shift -k Insert -m shift 2>/dev/null || true diff --git a/tests/harness.mjs b/tests/harness.mjs new file mode 100644 index 0000000..4aeb615 --- /dev/null +++ b/tests/harness.mjs @@ -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 diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..ddfcd3c --- /dev/null +++ b/tests/run.sh @@ -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' diff --git a/tests/test-classify.mjs b/tests/test-classify.mjs new file mode 100644 index 0000000..1d7b582 --- /dev/null +++ b/tests/test-classify.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: "hi" }), "html") + assert.equal(Classify.deriveType({ type: "text", text: "
y
" }), "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("

Hello & world

"), "Hello & world") + assert.equal(Classify.stripHtml("

hi

"), "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) +}) diff --git a/tests/test-fuzzy.mjs b/tests/test-fuzzy.mjs new file mode 100644 index 0000000..3068371 --- /dev/null +++ b/tests/test-fuzzy.mjs @@ -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 abc") + const html = Fuzzy.highlightFirstLine("a abc", m.positions, "", "") + assert.ok(!html.includes(" <")) + assert.ok(html.includes("<b>")) + assert.ok(html.includes("a")) +}) + +test("highlightFirstLine multiline only first line", () => { + const text = "alpha\nbeta\nalpha" + const m = Fuzzy.fuzzyMatch("alpha", text) + const html = Fuzzy.highlightFirstLine(text, m.positions, "", "") + assert.ok(!html.includes("\n")) +}) + +test("parseQuery yesterday bounds", () => { + const q = Fuzzy.parseQuery("yesterday") + assert.equal(q.maxAge, 172800) +}) diff --git a/tests/test-store.mjs b/tests/test-store.mjs new file mode 100644 index 0000000..9ae4f0a --- /dev/null +++ b/tests/test-store.mjs @@ -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) +})