QR decode support (zbarimg) with searchable payload + preview panel; restructure repo root as installable omarchy plugin; plain-text safety for QR payloads
This commit is contained in:
@@ -1 +1,2 @@
|
||||
*.bak
|
||||
__pycache__/
|
||||
|
||||
@@ -794,9 +794,15 @@ Item {
|
||||
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))
|
||||
if (e.qr) {
|
||||
titleHtml = Fuzzy.escapeHtml(Classify.firstLine(e.qr, 60))
|
||||
subtitleParts.push("QR")
|
||||
if (e.mime) subtitleParts.push(e.mime.replace("image/", "").toUpperCase())
|
||||
} else {
|
||||
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)
|
||||
@@ -808,6 +814,7 @@ Item {
|
||||
}
|
||||
|
||||
subtitleParts.push(Classify.typeLabel(derived))
|
||||
if (e.type === "image" && e.qr) subtitleParts.pop() // "QR" badge already says it
|
||||
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)))
|
||||
|
||||
@@ -57,6 +57,7 @@ Item {
|
||||
var e = r.entry
|
||||
var chips = []
|
||||
if (r.app) chips.push(Classify.prettyApp(r.app))
|
||||
if (e.qr) chips.push("QR: " + Classify.firstLine(e.qr, 40))
|
||||
if (e.type === "text" && e.text) {
|
||||
var st = Classify.textStats(e.text)
|
||||
chips.push(Classify.plural(st.words, "word"))
|
||||
@@ -105,7 +106,7 @@ Item {
|
||||
text: {
|
||||
if (!root.entry) return ""
|
||||
var e = root.entry
|
||||
if (e.type === "image") return Classify.fileBase(e.path)
|
||||
if (e.type === "image") return e.qr ? Classify.firstLine(e.qr, 120) : 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
|
||||
@@ -116,6 +117,7 @@ Item {
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.title
|
||||
font.bold: true
|
||||
textFormat: Text.PlainText
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
@@ -303,9 +305,68 @@ Item {
|
||||
visible: root.derived === "image"
|
||||
spacing: Style.space(8)
|
||||
|
||||
// Decoded QR payload sits above the image so it is immediately readable.
|
||||
Rectangle {
|
||||
visible: root.entry && root.entry.qr
|
||||
width: parent.width
|
||||
height: qrContent.height + Style.space(12)
|
||||
radius: Style.cornerRadius
|
||||
color: root.chipBg
|
||||
|
||||
Column {
|
||||
id: qrContent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Style.space(6)
|
||||
anchors.topMargin: Style.space(6)
|
||||
anchors.leftMargin: Style.space(6)
|
||||
anchors.rightMargin: Style.space(6)
|
||||
spacing: Style.space(4)
|
||||
|
||||
Row {
|
||||
spacing: Style.space(6)
|
||||
|
||||
Text {
|
||||
text: ""
|
||||
color: Color.accent
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "QR code content"
|
||||
color: Color.accent
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
id: qrValue
|
||||
width: parent.width
|
||||
readOnly: true
|
||||
activeFocusOnPress: false
|
||||
text: root.entry && root.entry.qr ? root.entry.qr : ""
|
||||
textFormat: TextEdit.PlainText
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
font.bold: true
|
||||
wrapMode: TextEdit.WrapAnywhere
|
||||
selectionColor: Util.alpha(Color.accent, 0.4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - infoLabel.height - Style.space(12)
|
||||
height: Math.max(0, parent.height
|
||||
- (root.entry && root.entry.qr ? qrContent.height + Style.space(12) + Style.space(8) : 0)
|
||||
- infoLabel.height - Style.space(12))
|
||||
|
||||
Image {
|
||||
id: img
|
||||
@@ -474,6 +535,7 @@ Item {
|
||||
id: chipLabel
|
||||
anchors.centerIn: parent
|
||||
text: parent.chipText
|
||||
textFormat: Text.PlainText
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
@@ -10,6 +10,9 @@ rides on the system theme, fonts, and layer-shell infrastructure.
|
||||
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:
|
||||
- `type:image|link|text|files|code|json|color|email|html|number` (prefix match)
|
||||
@@ -46,22 +49,34 @@ rides on the system theme, fonts, and layer-shell infrastructure.
|
||||
./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`).
|
||||
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`).
|
||||
|
||||
On other machines, install straight from git — no clone/step needed:
|
||||
|
||||
```bash
|
||||
omarchy plugin add <this-repo-git-url> --enable
|
||||
```
|
||||
|
||||
Note: the local dev symlink trips `omarchy plugin validate` (it refuses
|
||||
symlinked plugin folders); a git-cloned copy validates clean.
|
||||
|
||||
## Layout
|
||||
|
||||
The repo root *is* the plugin folder (so `omarchy plugin add <git-url>` works
|
||||
directly — it validates and clones a repo whose root holds `manifest.json`).
|
||||
|
||||
```
|
||||
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
|
||||
├── 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
|
||||
|
||||
@@ -73,7 +88,7 @@ History is stored in `~/.local/state/omarchy/clipboard-history-rich.json`
|
||||
|
||||
## Development
|
||||
|
||||
- Run logic tests: `tests/run.sh` (45 tests).
|
||||
- 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
|
||||
|
||||
@@ -50,6 +50,7 @@ function normalize(value, now) {
|
||||
}
|
||||
if (value.w) out.w = Number(value.w)
|
||||
if (value.h) out.h = Number(value.h)
|
||||
if (value.qr) out.qr = String(value.qr)
|
||||
} else if (type === "files") {
|
||||
var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : []
|
||||
if (paths.length === 0) return null
|
||||
@@ -103,6 +104,8 @@ function addEntry(history, entry, now) {
|
||||
// Re-copy of existing content: keep its pin state and usage count.
|
||||
if (existing.pinned) normalized.pinned = true
|
||||
if (existing.uses > 0) normalized.uses = existing.uses
|
||||
// Keep expensive derived data (e.g. decoded QR) across re-captures.
|
||||
if (existing.qr && !normalized.qr) normalized.qr = existing.qr
|
||||
continue
|
||||
}
|
||||
next.push(existing)
|
||||
@@ -176,6 +179,7 @@ function buildRow(entry, derivedType, now) {
|
||||
var content = ""
|
||||
if (entry.type === "image") {
|
||||
content = fileLabel(entry.path) + " " + String(entry.mime || "")
|
||||
if (entry.qr) content += " " + String(entry.qr).slice(0, 500)
|
||||
} else if (entry.type === "files") {
|
||||
content = (entry.paths || []).join(" ")
|
||||
} else {
|
||||
@@ -65,6 +65,22 @@ def emit(entry):
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def decode_qr(path):
|
||||
"""Decode a QR code with zbarimg; returns the payload or None."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["zbarimg", "-q", "--raw", "--", path],
|
||||
capture_output=True, timeout=10
|
||||
)
|
||||
# zbarimg exits 4 when no barcode is found.
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
text = r.stdout.decode("utf-8", "replace").rstrip("\n")
|
||||
return text or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def capture_image(types, app):
|
||||
mime = next((m for m in IMAGE_MIMES if m in types), None)
|
||||
if not mime:
|
||||
@@ -98,6 +114,11 @@ def capture_image(types, app):
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
qr = decode_qr(path)
|
||||
if qr:
|
||||
entry["qr"] = qr
|
||||
|
||||
emit(entry)
|
||||
return True
|
||||
|
||||
+5
-3
@@ -1,21 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Install the tank.clipboard Omarchy shell plugin from this repo.
|
||||
#
|
||||
# - symlinks plugin/ into ~/.config/omarchy/plugins/tank.clipboard
|
||||
# - symlinks the repo root (the plugin folder) 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"
|
||||
PLUGIN_DIR="$REPO_DIR"
|
||||
PLUGINS_DIR="$HOME/.config/omarchy/plugins"
|
||||
PLUGIN_ID="tank.clipboard"
|
||||
BINDINGS="$HOME/.config/hypr/bindings.conf"
|
||||
|
||||
echo "==> Symlinking plugin into $PLUGINS_DIR/$PLUGIN_ID"
|
||||
echo "==> Linking plugin into $PLUGINS_DIR/$PLUGIN_ID"
|
||||
mkdir -p "$PLUGINS_DIR"
|
||||
ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID"
|
||||
# On other machines, install directly from git instead:
|
||||
# omarchy plugin add <this-repo-git-url> --enable
|
||||
chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh
|
||||
|
||||
echo "==> Rescanning shell plugins"
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -6,7 +6,7 @@ import path from "node:path"
|
||||
import vm from "node:vm"
|
||||
|
||||
const testsDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pluginDir = path.join(testsDir, "..", "plugin")
|
||||
const pluginDir = path.join(testsDir, "..")
|
||||
|
||||
export function loadLib(name) {
|
||||
let src = readFileSync(path.join(pluginDir, name), "utf8")
|
||||
|
||||
@@ -110,3 +110,19 @@ test("entryId distinct for distinct content", () => {
|
||||
const b = Store.entryId({ type: "text", text: "two" })
|
||||
assert.notEqual(a, b)
|
||||
})
|
||||
|
||||
test("qr payload passes through normalize and dedup", () => {
|
||||
const e = Store.normalize({ type: "image", path: "/x/qr.png", mime: "image/png", w: 100, h: 100, qr: "https://example.com" })
|
||||
assert.equal(e.qr, "https://example.com")
|
||||
// Re-copy of the same image keeps the decoded QR even if the new capture lacks it.
|
||||
let h = Store.addEntry([], { type: "image", path: "/x/qr.png", qr: "https://example.com", ts: 1 })
|
||||
h = Store.addEntry(h, { type: "image", path: "/x/qr.png", ts: 2 })
|
||||
assert.equal(h.length, 1)
|
||||
assert.equal(h[0].qr, "https://example.com")
|
||||
assert.equal(h[0].ts, 2)
|
||||
})
|
||||
|
||||
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"))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user