diff --git a/Clipboard.qml b/Clipboard.qml index da55ee3..fc36041 100644 --- a/Clipboard.qml +++ b/Clipboard.qml @@ -28,6 +28,9 @@ Item { property string statePath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history-rich.json" property int historyLimit: 1500 property int displayLimit: 200 + property int maxAgeDays: 0 // 0 = keep forever + property bool qrDecode: true + property bool paused: false property var typeCache: ({}) // id → derived type, memoized // Theme surface tokens (menu) — tracks the active Omarchy theme. @@ -46,8 +49,8 @@ Item { 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 cardWidth: Style.space(980) + readonly property int cardHeight: Style.space(680) readonly property int rowHeight: Style.space(52) readonly property int listWidth: Math.round(card.width * 0.46) @@ -76,19 +79,65 @@ Item { else root.open() } + // ------------------------------------------------------------ pause + // Toggle via IPC: omarchy-shell shell call tank.clipboard pause '{"paused":true}' + // (or {"paused":false}, or {"paused":"toggle"}), and Ctrl+Space in the picker. + function setPaused(next) { + root.paused = !!next + pausedFile.setText(JSON.stringify({ paused: root.paused }) + "\n") + } + + function pause(payloadJson) { + var p = null + try { p = JSON.parse(String(payloadJson || "{}")) } catch (e) { p = null } + var next = p && typeof p.paused !== "undefined" + ? (p.paused === "toggle" ? !root.paused : !!p.paused) + : !root.paused + root.setPaused(next) + Quickshell.execDetached(["notify-send", "-a", "Clipboard History", + next ? "Clipboard history paused" : "Clipboard history resumed", + next ? "New copies are not being recorded." : "Recording new copies again."]) + return JSON.stringify({ paused: root.paused }) + } + + function isPaused() { return JSON.stringify({ paused: root.paused }) } + + // Screenshot helper: omarchy-shell shell call tank.clipboard debugSetFilter '{"text":"…"}' + // Opens the picker with a preset query so scripts can capture it keyboard-free. + function debugSetFilter(payloadJson) { + var p = {} + try { p = JSON.parse(String(payloadJson || "{}")) } catch (e) { p = {} } + root.open() + root.setFilter(String(p.text || "")) + return "ok" + } + // ------------------------------------------------------------ store function loadHistory(raw) { root.history = Store.parseHistory(raw, Math.floor(Date.now() / 1000)) root.typeCache = {} + root.applyRetentionPolicy() if (root.opened) root.rebuild() } function saveHistory() { var pruned = Store.prune(root.history, root.historyLimit) - root.history = pruned.entries + var aged = Store.pruneByAge(pruned.entries, root.maxAgeDays > 0 ? root.maxAgeDays * 86400 : -1, Math.floor(Date.now() / 1000)) + root.history = aged.entries historyFile.setText(JSON.stringify(root.history, null, 1) + "\n") - if (pruned.droppedImagePaths.length > 0) queueGc(pruned.droppedImagePaths) + var paths = pruned.droppedImagePaths.concat(aged.droppedImagePaths) + if (paths.length > 0) queueGc(paths) + } + + // Full retention pass, run when history loads and when settings change. + function applyRetentionPolicy() { + if (root.maxAgeDays <= 0) return + var aged = Store.pruneByAge(root.history, root.maxAgeDays * 86400, Math.floor(Date.now() / 1000)) + if (aged.entries.length === root.history.length) return + root.history = aged.entries + if (aged.droppedImagePaths.length > 0) queueGc(aged.droppedImagePaths) + root.saveHistory() } // Serialize GC batches: a single reusable Process would silently drop @@ -105,6 +154,7 @@ Item { } function addClipboardJson(line) { + if (root.paused) return var entry = null try { entry = JSON.parse(String(line || "")) } catch (e) { return } if (!entry) return @@ -266,6 +316,41 @@ Item { PointerMoveGate { id: pointerGate; referenceItem: card } + // User settings live on this plugin's entry in shell.json (hot-reloads): + // { "id": "tank.clipboard", "historyLimit": 1500, "maxAgeDays": 30, "maxRows": 200 } + FileView { + id: shellConfigFile + path: Quickshell.env("HOME") + "/.config/omarchy/shell.json" + watchChanges: true + printErrors: false + onLoaded: root.applySettings(text()) + onLoadFailed: root.applySettings("{}") + onFileChanged: reload() + } + + function applySettings(raw) { + var s = Store.parseSettings(raw, "tank.clipboard") + root.historyLimit = s.historyLimit + root.maxAgeDays = s.maxAgeDays + root.displayLimit = s.maxRows + root.qrDecode = s.qrDecode + root.applyRetentionPolicy() + if (root.opened) root.rebuild() + } + + // Pause state survives shell restarts. + FileView { + id: pausedFile + path: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-paused.json" + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: { + try { root.paused = !!JSON.parse(text()).paused } catch (e) { root.paused = false } + } + onLoadFailed: root.paused = false + } + FileView { id: historyFile path: root.statePath @@ -304,6 +389,7 @@ Item { Process { id: watchProc command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "python3", root.pluginDir + "/capture.py", "watch"] + environment: ({ "CLIPBOARD_QR": root.qrDecode ? "1" : "0" }) onExited: watchRestartTimer.restart() stdout: SplitParser { onRead: function(data) { root.addClipboardJson(data) } @@ -327,32 +413,23 @@ Item { // ------------------------------------------------------------ window + // Floating card: an unanchored layer surface is centered by the compositor, + // so the picker is just the card — no fullscreen scrim underneath. PanelWindow { id: panel visible: root.opened - anchors { top: true; bottom: true; left: true; right: true } + width: root.cardWidth + height: root.cardHeight 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 + anchors.fill: parent radius: root.cornerRadius - anchors.centerIn: parent color: root.background borderSpec: root.borderSpec padding: root.contentMargin @@ -405,6 +482,9 @@ Item { } else if (event.key === Qt.Key_Tab) { root.togglePinIndex(root.selectedIndex) event.accepted = true + } else if (event.key === Qt.Key_Equal && (event.modifiers & Qt.ControlModifier)) { + root.pause(JSON.stringify({ paused: "toggle" })) + event.accepted = true } else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) { root.openResult(root.currentResult) event.accepted = true @@ -497,6 +577,7 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: { + if (root.paused) return "⏸ paused" var shown = root.results.length var total = root.history.length if (shown === total) return total + " items" @@ -556,10 +637,29 @@ Item { } } + // ---- paused banner + Rectangle { + width: parent.width + height: root.paused ? Style.space(24) : 0 + visible: root.paused + radius: Style.cornerRadius + color: Util.alpha(Color.urgent, 0.15) + + Text { + anchors.centerIn: parent + text: "⏸ Paused — new copies are not being recorded · Ctrl+= to resume" + color: Color.urgent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + // ---- list + preview Item { width: parent.width - height: parent.height - root.headerHeight - chipsRow.height - footer.height - Style.space(30) + height: parent.height - root.headerHeight - chipsRow.height + - (root.paused ? Style.space(24) + Style.space(10) : 0) + - footer.height - Style.space(30) ListView { id: resultList @@ -740,6 +840,7 @@ Item { { keys: "shift+enter", hint: "copy" }, { keys: "ctrl+o", hint: "open" }, { keys: "tab", hint: "pin" }, + { keys: "ctrl+=", hint: "pause" }, { keys: "del", hint: "remove" }, { keys: "esc", hint: "close" } ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d4bfcd1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 alanfortlink + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fb56571..fefd8ac 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,64 @@ # 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. +A Raycast/vicinae-style clipboard history manager for [Omarchy](https://omarchy.org), +built as an Omarchy shell plugin. Fuzzy search over everything (content, type, +source app, date — even text inside QR codes), rich per-type previews, and +full theme integration. -## Features +

+ clipboard-history picker showing a QR code with its decoded content +

-- **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. -- **QR codes** — copied QR images are decoded with `zbarimg`; the payload shows - in the list title, the preview pane (copy-selectable), and a metadata chip, - and is fuzzy-searchable like any text clip. -- **Fuzzy search over everything** — fzf-style scoring over content, source - app, and type, plus recency, pin, and usage boosts. Query tokens: +## Highlights + +- **Fuzzy search over everything** — fzf-style scoring across content, source + app, and type, boosted by recency, pins, and usage. 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`. +- **Rich previews, per type** — images rendered inline, QR codes show their + decoded content, colors show a swatch with hex/rgb/hsl, links show the + domain, JSON is pretty-printed, file lists show paths — every preview with + metadata chips (app, date, words/lines, size, paste counts) +- **QR codes** — copied QR images are decoded with `zbarimg`; the payload is + shown in the list, preview, and is searchable like any text clip +- **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 +- **Retention control** — configure how much and how long history is kept +- **Pause/resume** — stop recording new copies without losing your history +- **Fully theme-integrated** — colors, spacing, corner radius, borders, and + the monospace font all come from the Omarchy shell's theme singletons; it + re-themes itself on `omarchy theme set` + +

+ fuzzy search for “omarchy” with highlighted matches +

+ +## Install + +```bash +omarchy plugin add https://github.com/alanfortlink/clipboard-history.git --enable +``` + +Optional QR support (usually already installed): + +```bash +omarchy pkg add zbar +``` + +That's the whole install — `omarchy plugin add` clones the repo, validates the +manifest, and enables it. It replaces the built-in `omarchy.clipboard` +(restore it later with `omarchy plugin disable tank.clipboard`), and you bind +it wherever you like: + +```bash +# ~/.config/hypr/bindings.conf +bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard +``` + +Updating: `omarchy plugin update tank.clipboard` · Uninstall: `omarchy plugin remove tank.clipboard` ## Keys @@ -40,56 +69,89 @@ rides on the system theme, fonts, and layer-shell infrastructure. | `Shift+Enter` | copy only | | `Ctrl+O` | open (link → browser, image → editor, file → xdg-open, text → editor) | | `Tab` | pin/unpin | +| `Ctrl+=` | pause/resume recording | | `Delete` | remove entry · `Shift+Delete` clear all (with confirm) | | `Esc` | clear filter, then close | -## Install +Pause/resume is also scriptable — useful for automation or a custom binding: ```bash -./install.sh +omarchy-shell shell call tank.clipboard pause '{"paused":"toggle"}' +omarchy-shell shell call tank.clipboard isPaused ``` -This symlinks the repo (the plugin root) 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`). +## Configuration -On other machines, install straight from git — no clone/step needed: +Settings live on the plugin's entry in the `plugins` array of +`~/.config/omarchy/shell.json` and hot-reload on save: -```bash -omarchy plugin add --enable +```json +{ + "version": 1, + "plugins": [ + { + "id": "tank.clipboard", + "historyLimit": 1500, + "maxAgeDays": 30, + "maxRows": 200 + } + ] +} ``` -Note: the local dev symlink trips `omarchy plugin validate` (it refuses -symlinked plugin folders); a git-cloned copy validates clean. +| Key | Default | Meaning | +|-----|---------|---------| +| `historyLimit` | `1500` | max entries kept in history | +| `maxAgeDays` | `0` (forever) | drop entries older than N days (images get garbage-collected; pinned entries are exempt) | +| `maxRows` | `200` | max rows the picker shows per search | +| `qrDecode` | `true` | decode QR codes with `zbarimg` on captured images | -## Layout +History lives in `~/.local/state/omarchy/clipboard-history-rich.json`; image +blobs are content-addressed under `~/.local/state/omarchy/clipboard-images/`. -The repo root *is* the plugin folder (so `omarchy plugin add ` works -directly — it validates and clones a repo whose root holds `manifest.json`). +## How it compares + +| | built-in `omarchy.clipboard` | [sspaeti's OCR fork](https://github.com/sspaeti/omarchy-clipboard-plugin) | [Clipbasket](https://github.com/clipbasket/clipbasket-omarchy) | vicinae | **clipboard-history** | +|---|---|---|---|---|---| +| Fuzzy search | substring | + OCR text | ✓ | ✓ | content + app + type + date tokens (`type:`, `app:`, `<2h`, `today`) | +| Previews | text/image | — | — | ✓ | images, **QR payloads**, color swatches, JSON pretty-print, links, files | +| Metadata | basic | basic | SQLite | ✓ | size, source app, dims, word/line counts, pins, paste counts | +| Retention config | 300 cap | limit | ✓ | ✓ | `historyLimit` + `maxAgeDays` + GC | +| Pause recording | ✗ | ✗ | ? | ✓ | ✓ (in-picker + IPC) | +| Theme-integrated shell overlay | ✓ | ✓ | ✓ | own | ✓ (uses Omarchy's theme singletons) | + +## Roadmap / ideas + +- OCR search: tesseract on captured images so text inside screenshots is + searchable ([reference implementation](https://github.com/sspaeti/omarchy-clipboard-plugin)) +- `autoPaste` mode swap: Enter = copy-only, Shift+Enter = paste (walker-style) +- Small bar widget: last-copied item + paused indicator +- Snippet editing: edit a pinned entry's content in place +- Multi-select delete, export/import of history +- Optional per-app capture exclusions beyond the password-manager hint + +## Development ``` -├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard) +├── manifest.json # plugin manifest (replaces omarchy.clipboard via clonedFrom) ├── 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 +├── Store.js # history model: dedup, pins, retention, settings parsing ├── 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 (incl. QR decode) ├── 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 +├── open-entry.sh # open with the right app +├── tests/ # node --test suites for the JS logic +└── scripts/ # screenshot regeneration helpers ``` -History is stored in `~/.local/state/omarchy/clipboard-history-rich.json` -(image blobs content-addressed under `~/.local/state/omarchy/clipboard-images/`). +- Run logic tests: `tests/run.sh` +- Validate the manifest: `omarchy plugin validate ` +- Regenerate screenshots: `scripts/take-screenshots.sh` (stages demo content + and captures the picker keyboard-free over IPC) +- Hot-reload after edits: `omarchy-shell shell rescanPlugins` -## Development +## License -- Run logic tests: `tests/run.sh` (49 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`. +[MIT](LICENSE) diff --git a/Store.js b/Store.js index 1ff9ac7..5966698 100644 --- a/Store.js +++ b/Store.js @@ -174,6 +174,56 @@ function prune(history, limit) { return { entries: kept, droppedImagePaths: droppedImagePaths } } +// Retention: drop entries older than maxAgeSeconds (-1 = keep forever). +// Pinned entries are exempt — pins are favorites; delete them explicitly. +// Returns { entries, droppedImagePaths } like prune(). +function pruneByAge(history, maxAgeSeconds, now) { + var values = Array.isArray(history) ? history : [] + if (maxAgeSeconds === undefined || maxAgeSeconds === null || maxAgeSeconds < 0) + return { entries: values, droppedImagePaths: [] } + var cutoff = now - maxAgeSeconds + var kept = [] + var droppedImagePaths = [] + for (var i = 0; i < values.length; i++) { + var e = values[i] + if (!e) continue + if ((Number(e.ts) || 0) < cutoff && !e.pinned) { + if (e.type === "image" && e.path) droppedImagePaths.push(e.path) + continue + } + kept.push(e) + } + return { entries: kept, droppedImagePaths: droppedImagePaths } +} + +// Parse this plugin's settings from the shell.json contents. Every key is +// optional; unknown keys are ignored. Reads only the plugins[] entry whose +// id matches. +function parseSettings(raw, pluginId) { + var out = { historyLimit: DEFAULT_LIMIT, maxAgeDays: 0, maxRows: 200, qrDecode: true } + var config = null + try { config = JSON.parse(String(raw || "{}")) } catch (e) { return out } + if (!config || !Array.isArray(config.plugins)) return out + for (var i = 0; i < config.plugins.length; i++) { + var entry = config.plugins[i] + if (!entry || entry.id !== pluginId) continue + + var n = Number(entry.historyLimit) + if (isFinite(n) && n >= 1) out.historyLimit = Math.floor(n) + + n = Number(entry.maxAgeDays) + if (isFinite(n) && n >= 0) out.maxAgeDays = Math.floor(n) + + n = Number(entry.maxRows) + if (isFinite(n) && n >= 1) out.maxRows = Math.floor(n) + + if (typeof entry.qrDecode === "boolean") out.qrDecode = entry.qrDecode + + break + } + return out +} + // Build the search-row context Fuzzy.searchRows expects. function buildRow(entry, derivedType, now) { var content = "" diff --git a/capture.py b/capture.py index 262307a..3066d9b 100755 --- a/capture.py +++ b/capture.py @@ -115,7 +115,7 @@ def capture_image(types, app): pass return - qr = decode_qr(path) + qr = decode_qr(path) if os.environ.get("CLIPBOARD_QR", "1") != "0" else None if qr: entry["qr"] = qr diff --git a/docs/screenshots/picker.png b/docs/screenshots/picker.png new file mode 100644 index 0000000..2946077 Binary files /dev/null and b/docs/screenshots/picker.png differ diff --git a/docs/screenshots/search.png b/docs/screenshots/search.png new file mode 100644 index 0000000..145dd41 Binary files /dev/null and b/docs/screenshots/search.png differ diff --git a/scripts/physical-crop.py b/scripts/physical-crop.py new file mode 100755 index 0000000..273822d --- /dev/null +++ b/scripts/physical-crop.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Print the PHYSICAL-pixel crop geometry (WxH+X+Y) of the tank-clipboard +layer surface on grim's combined framebuffer. Monitors report logical .x/.y +with .scale; layers report logical xywh; grim captures physical pixels.""" +import json +import re +import subprocess + +layers = subprocess.run(["hyprctl", "layers"], capture_output=True, text=True).stdout +m = re.search(r"xywh:\s*(\d+) (\d+) (\d+) (\d+).*namespace: tank-clipboard", layers) +if not m: + raise SystemExit(1) +lx, ly, lw, lh = map(int, m.groups()) + +mons = json.loads(subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True).stdout) +# Physical framebuffer layout: monitors sorted by (y, x); each occupies its +# physical width/height; offsets accumulate. +mons.sort(key=lambda d: (d["y"], d["x"])) +for i, d in enumerate(mons): + logical_w = d["width"] / d["scale"] + if d["x"] <= lx < d["x"] + logical_w and d["y"] <= ly < d["y"] + d["height"] / d["scale"]: + phys_x = sum(o["width"] for o in mons[:i]) + px = phys_x + round((lx - d["x"]) * d["scale"]) + py = round((ly - d["y"]) * d["scale"]) + pw = round(lw * d["scale"]) + ph = round(lh * d["scale"]) + print(f"{pw}x{ph}+{px}+{py}") + break +else: + raise SystemExit(1) diff --git a/scripts/take-screenshots.sh b/scripts/take-screenshots.sh new file mode 100755 index 0000000..c70b706 --- /dev/null +++ b/scripts/take-screenshots.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Regenerate README screenshots (docs/screenshots/*.png). +# Stages demo clipboard entries, opens the picker keyboard-free (IPC), and +# crops the card from a full-screen capture. Run in a graphical session. +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$REPO_DIR/docs/screenshots" +mkdir -p "$OUT" + +crop_shot() { # — open via IPC first; caller decides what's on screen + local out=$1 + omarchy-shell shell hide tank.clipboard + sleep 0.4 + local geo + case $out in + "$OUT/search.png") + omarchy-shell shell call tank.clipboard debugSetFilter '{"text":"omarchy"}' + ;; + *) + omarchy-shell shell toggle tank.clipboard + ;; + esac + sleep 1.5 + local geo; geo=$(python3 "$REPO_DIR/scripts/physical-crop.py") + grim /tmp/clipshot-full.png + omarchy-shell shell hide tank.clipboard + sleep 0.4 + magick /tmp/clipshot-full.png -crop "$geo" +repage "$out" +} + +# ---- stage demo content ---------------------------------------------------- +qrencode -s 10 -o /tmp/clipshot-qr-repo.png "https://github.com/alanfortlink/clipboard-history" +qrencode -s 10 -o /tmp/clipshot-qr-omarchy.png "https://omarchy.org" +# stage newest-last so the QR pointing at this repo is selected on open +printf 'The quick brown fox jumps over the lazy dog.' | wl-copy; sleep 1.5 +printf 'function pasteEntry(id) {\n const entry = store.findById(id)\n return paste(entry)\n}' | wl-copy; sleep 1.5 +printf '#7aa2f7' | wl-copy; sleep 1.5 +printf 'https://omarchy.org/manual/shell-plugins/' | wl-copy; sleep 1.5 +wl-copy --type image/png < /tmp/clipshot-qr-omarchy.png; sleep 1.5 +wl-copy --type image/png < /tmp/clipshot-qr-repo.png; sleep 1.5 + +# ---- shots ----------------------------------------------------------------- +crop_shot "$OUT/picker.png" +crop_shot "$OUT/search.png" + +echo "Wrote $OUT/picker.png and $OUT/search.png" diff --git a/tests/test-store.mjs b/tests/test-store.mjs index a4a8199..56f65e6 100644 --- a/tests/test-store.mjs +++ b/tests/test-store.mjs @@ -126,3 +126,47 @@ test("buildRow makes QR payload searchable", () => { const row = Store.buildRow({ type: "image", path: "/x/qr.png", mime: "image/png", qr: "secret-payload-xyz", ts: 1, bytes: 10, app: "", uses: 0 }, "image", 1) assert.ok(row.content.includes("secret-payload-xyz")) }) + +test("pruneByAge drops old entries, keeps pins, reports images", () => { + const now = 1000000 + const h = [ + { id: "new", type: "text", text: "fresh", ts: now - 10, bytes: 1, app: "", uses: 0 }, + { id: "old", type: "text", text: "old", ts: now - 100 * 86400, bytes: 1, app: "", uses: 0 }, + { id: "oldimg", type: "image", path: "/tmp/old.png", ts: now - 90 * 86400, bytes: 1, app: "", uses: 0 }, + { id: "oldpin", type: "text", text: "pinned old", ts: now - 200 * 86400, pinned: true, bytes: 1, app: "", uses: 0 } + ] + const r = Store.pruneByAge(h, 30 * 86400, now) + assert.deepEqual(r.entries.map(e => e.id).sort().join(","), ["new", "oldpin"].sort().join(",")) + assert.equal(r.droppedImagePaths.length, 1) +}) + +test("pruneByAge no-op when forever", () => { + const h = [{ id: "a", type: "text", text: "x", ts: 1, bytes: 1, app: "", uses: 0 }] + const r = Store.pruneByAge(h, -1, 1000000) + assert.equal(r.entries.length, 1) + assert.equal(r.droppedImagePaths.length, 0) +}) + +test("parseSettings from shell.json", () => { + const raw = JSON.stringify({ + plugins: [ + { id: "other.plugin" }, + { id: "tank.clipboard", historyLimit: 500, maxAgeDays: 14, maxRows: 50, qrDecode: false, customKey: 1 } + ] + }) + const s = Store.parseSettings(raw, "tank.clipboard") + assert.equal(s.historyLimit, 500) + assert.equal(s.maxAgeDays, 14) + assert.equal(s.maxRows, 50) + assert.equal(s.qrDecode, false) + const d = Store.parseSettings("{}", "tank.clipboard") + assert.equal(d.historyLimit, 1500) + assert.equal(d.maxAgeDays, 0) + assert.equal(d.maxRows, 200) + assert.equal(d.qrDecode, true) +}) + +test("parseSettings ignores garbage", () => { + const s = Store.parseSettings("not json", "tank.clipboard") + assert.equal(s.historyLimit, 1500) +})