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
|
*.bak
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -794,9 +794,15 @@ Item {
|
|||||||
var subtitleParts = []
|
var subtitleParts = []
|
||||||
|
|
||||||
if (e.type === "image") {
|
if (e.type === "image") {
|
||||||
titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "")
|
if (e.qr) {
|
||||||
if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h
|
titleHtml = Fuzzy.escapeHtml(Classify.firstLine(e.qr, 60))
|
||||||
subtitleParts.push(Classify.formatBytes(r.row.bytes))
|
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") {
|
} else if (e.type === "files") {
|
||||||
var base = Classify.fileBase(e.paths[0])
|
var base = Classify.fileBase(e.paths[0])
|
||||||
titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base)
|
titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base)
|
||||||
@@ -808,6 +814,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subtitleParts.push(Classify.typeLabel(derived))
|
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.app) subtitleParts.push(Classify.prettyApp(r.row.app))
|
||||||
if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000)))
|
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 e = r.entry
|
||||||
var chips = []
|
var chips = []
|
||||||
if (r.app) chips.push(Classify.prettyApp(r.app))
|
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) {
|
if (e.type === "text" && e.text) {
|
||||||
var st = Classify.textStats(e.text)
|
var st = Classify.textStats(e.text)
|
||||||
chips.push(Classify.plural(st.words, "word"))
|
chips.push(Classify.plural(st.words, "word"))
|
||||||
@@ -105,7 +106,7 @@ Item {
|
|||||||
text: {
|
text: {
|
||||||
if (!root.entry) return ""
|
if (!root.entry) return ""
|
||||||
var e = root.entry
|
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") {
|
if (e.type === "files") {
|
||||||
var base = Classify.fileBase(e.paths[0])
|
var base = Classify.fileBase(e.paths[0])
|
||||||
return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base
|
return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base
|
||||||
@@ -116,6 +117,7 @@ Item {
|
|||||||
font.family: root.font_
|
font.family: root.font_
|
||||||
font.pixelSize: Style.font.title
|
font.pixelSize: Style.font.title
|
||||||
font.bold: true
|
font.bold: true
|
||||||
|
textFormat: Text.PlainText
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
maximumLineCount: 1
|
maximumLineCount: 1
|
||||||
}
|
}
|
||||||
@@ -303,9 +305,68 @@ Item {
|
|||||||
visible: root.derived === "image"
|
visible: root.derived === "image"
|
||||||
spacing: Style.space(8)
|
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 {
|
Item {
|
||||||
width: parent.width
|
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 {
|
Image {
|
||||||
id: img
|
id: img
|
||||||
@@ -474,6 +535,7 @@ Item {
|
|||||||
id: chipLabel
|
id: chipLabel
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: parent.chipText
|
text: parent.chipText
|
||||||
|
textFormat: Text.PlainText
|
||||||
color: root.mutedFg
|
color: root.mutedFg
|
||||||
font.family: root.font_
|
font.family: root.font_
|
||||||
font.pixelSize: Style.font.caption
|
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.
|
type, byte size, source app (via `hyprctl`), timestamp, and image dimensions.
|
||||||
Text, images, and `file://` URI lists (file-manager copies) are supported;
|
Text, images, and `file://` URI lists (file-manager copies) are supported;
|
||||||
password-manager clips and binary payloads are skipped.
|
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
|
- **Fuzzy search over everything** — fzf-style scoring over content, source
|
||||||
app, and type, plus recency, pin, and usage boosts. Query tokens:
|
app, and type, plus recency, pin, and usage boosts. Query tokens:
|
||||||
- `type:image|link|text|files|code|json|color|email|html|number` (prefix match)
|
- `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
|
./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This symlinks `plugin/` into `~/.config/omarchy/plugins/tank.clipboard`,
|
This symlinks the repo (the plugin root) into
|
||||||
rescans + enables the plugin (the built-in `omarchy.clipboard` is replaced —
|
`~/.config/omarchy/plugins/tank.clipboard`, rescans + enables the plugin (the
|
||||||
revert with `omarchy plugin disable tank.clipboard`), and rebinds
|
built-in `omarchy.clipboard` is replaced — revert with
|
||||||
`ALT+SHIFT+V` from vicinae to this picker (backing up `bindings.conf`).
|
`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
|
## 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)
|
├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard)
|
||||||
├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers
|
├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers
|
||||||
├── PreviewPane.qml # per-type preview + metadata chips
|
├── PreviewPane.qml # per-type preview + metadata chips
|
||||||
├── Store.js # history model: dedup, pins, pruning, ids
|
├── Store.js # history model: dedup, pins, pruning, ids
|
||||||
├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting
|
├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting
|
||||||
├── Classify.js # type detection, app names, formatting, color math
|
├── 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
|
├── paste-entry.sh # copy + shift-insert paste into the focused window
|
||||||
└── open-entry.sh # open with the right app
|
└── open-entry.sh # open with the right app
|
||||||
|
|
||||||
@@ -73,7 +88,7 @@ History is stored in `~/.local/state/omarchy/clipboard-history-rich.json`
|
|||||||
|
|
||||||
## Development
|
## 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`
|
- 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).
|
(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
|
- 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.w) out.w = Number(value.w)
|
||||||
if (value.h) out.h = Number(value.h)
|
if (value.h) out.h = Number(value.h)
|
||||||
|
if (value.qr) out.qr = String(value.qr)
|
||||||
} else if (type === "files") {
|
} else if (type === "files") {
|
||||||
var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : []
|
var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : []
|
||||||
if (paths.length === 0) return null
|
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.
|
// Re-copy of existing content: keep its pin state and usage count.
|
||||||
if (existing.pinned) normalized.pinned = true
|
if (existing.pinned) normalized.pinned = true
|
||||||
if (existing.uses > 0) normalized.uses = existing.uses
|
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
|
continue
|
||||||
}
|
}
|
||||||
next.push(existing)
|
next.push(existing)
|
||||||
@@ -176,6 +179,7 @@ function buildRow(entry, derivedType, now) {
|
|||||||
var content = ""
|
var content = ""
|
||||||
if (entry.type === "image") {
|
if (entry.type === "image") {
|
||||||
content = fileLabel(entry.path) + " " + String(entry.mime || "")
|
content = fileLabel(entry.path) + " " + String(entry.mime || "")
|
||||||
|
if (entry.qr) content += " " + String(entry.qr).slice(0, 500)
|
||||||
} else if (entry.type === "files") {
|
} else if (entry.type === "files") {
|
||||||
content = (entry.paths || []).join(" ")
|
content = (entry.paths || []).join(" ")
|
||||||
} else {
|
} else {
|
||||||
@@ -65,6 +65,22 @@ def emit(entry):
|
|||||||
sys.stdout.flush()
|
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):
|
def capture_image(types, app):
|
||||||
mime = next((m for m in IMAGE_MIMES if m in types), None)
|
mime = next((m for m in IMAGE_MIMES if m in types), None)
|
||||||
if not mime:
|
if not mime:
|
||||||
@@ -98,6 +114,11 @@ def capture_image(types, app):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
|
qr = decode_qr(path)
|
||||||
|
if qr:
|
||||||
|
entry["qr"] = qr
|
||||||
|
|
||||||
emit(entry)
|
emit(entry)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
+5
-3
@@ -1,21 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Install the tank.clipboard Omarchy shell plugin from this repo.
|
# 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
|
# - rescans + enables it (shell.json gets plugins[] entry; the built-in
|
||||||
# omarchy.clipboard is recorded in disabledPlugins[] and routed here)
|
# omarchy.clipboard is recorded in disabledPlugins[] and routed here)
|
||||||
# - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf)
|
# - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PLUGIN_DIR="$REPO_DIR/plugin"
|
PLUGIN_DIR="$REPO_DIR"
|
||||||
PLUGINS_DIR="$HOME/.config/omarchy/plugins"
|
PLUGINS_DIR="$HOME/.config/omarchy/plugins"
|
||||||
PLUGIN_ID="tank.clipboard"
|
PLUGIN_ID="tank.clipboard"
|
||||||
BINDINGS="$HOME/.config/hypr/bindings.conf"
|
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"
|
mkdir -p "$PLUGINS_DIR"
|
||||||
ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID"
|
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
|
chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh
|
||||||
|
|
||||||
echo "==> Rescanning shell plugins"
|
echo "==> Rescanning shell plugins"
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -6,7 +6,7 @@ import path from "node:path"
|
|||||||
import vm from "node:vm"
|
import vm from "node:vm"
|
||||||
|
|
||||||
const testsDir = path.dirname(fileURLToPath(import.meta.url))
|
const testsDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const pluginDir = path.join(testsDir, "..", "plugin")
|
const pluginDir = path.join(testsDir, "..")
|
||||||
|
|
||||||
export function loadLib(name) {
|
export function loadLib(name) {
|
||||||
let src = readFileSync(path.join(pluginDir, name), "utf8")
|
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" })
|
const b = Store.entryId({ type: "text", text: "two" })
|
||||||
assert.notEqual(a, b)
|
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