Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a979a6b143 | |||
| f4fc81eefe | |||
| 4522fdbce1 | |||
| b1ebdb83e6 | |||
| b1d98971c9 | |||
| 8127ae4bba | |||
| 4662c8002d | |||
| 4f562ab215 | |||
| 2c999201db | |||
| 1d7a9d6cde |
+24
-14
@@ -10,7 +10,7 @@ import "Classify.js" as Classify
|
|||||||
|
|
||||||
// Clipboard history picker — Raycast-style: fuzzy search bar, result list,
|
// Clipboard history picker — Raycast-style: fuzzy search bar, result list,
|
||||||
// and a per-type preview pane. Clone of omarchy.clipboard with richer
|
// and a per-type preview pane. Clone of omarchy.clipboard with richer
|
||||||
// capture metadata (app, size, dims, pins, usage) and full-text fuzzy search.
|
// capture metadata (app, size, dims, pins, usage) and full-text fuzzy filtering.
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
@@ -83,20 +83,8 @@ Item {
|
|||||||
else root.open()
|
else root.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct target for explicit bindings and pause/resume automation.
|
|
||||||
IpcHandler {
|
|
||||||
target: "alanfortlink.clipboard"
|
|
||||||
function open(): void { root.open() }
|
|
||||||
function close(): void { root.close() }
|
|
||||||
function show(): void { root.open() }
|
|
||||||
function hide(): void { root.close() }
|
|
||||||
function toggle(): void { root.toggle() }
|
|
||||||
function pause(payloadJson: string): string { return root.pause(payloadJson) }
|
|
||||||
function isPaused(): string { return root.isPaused() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ pause
|
// ------------------------------------------------------------ pause
|
||||||
// Toggle via IPC: omarchy-shell alanfortlink.clipboard pause '{"paused":true}'
|
// Toggle via IPC: omarchy-shell shell call alanfortlink.clipboard pause '{"paused":true}'
|
||||||
// (or {"paused":false}, or {"paused":"toggle"}), and Ctrl+Space in the picker.
|
// (or {"paused":false}, or {"paused":"toggle"}), and Ctrl+Space in the picker.
|
||||||
function setPaused(next) {
|
function setPaused(next) {
|
||||||
root.paused = !!next
|
root.paused = !!next
|
||||||
@@ -280,6 +268,22 @@ Item {
|
|||||||
Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id])
|
Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text content of an entry as a string: the text itself, or for images the
|
||||||
|
// recognized (OCR) text, falling back to a decoded QR payload.
|
||||||
|
function textContent(result) {
|
||||||
|
if (!result) return ""
|
||||||
|
var e = result.row.entry
|
||||||
|
if (e.type === "image") return String(e.ocr || e.qr || "")
|
||||||
|
return String(e.text || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy a plain string (not an entry) to the clipboard and close.
|
||||||
|
function copyText(text) {
|
||||||
|
if (!text) return
|
||||||
|
root.close()
|
||||||
|
Quickshell.execDetached(["wl-copy", "--type", "text/plain", "--", text])
|
||||||
|
}
|
||||||
|
|
||||||
function removeIndex(index) {
|
function removeIndex(index) {
|
||||||
if (index < 0 || index >= root.results.length) return
|
if (index < 0 || index >= root.results.length) return
|
||||||
var entry = root.results[index].row.entry
|
var entry = root.results[index].row.entry
|
||||||
@@ -572,6 +576,9 @@ Item {
|
|||||||
} else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
|
} else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
|
||||||
root.openResult(root.currentResult)
|
root.openResult(root.currentResult)
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
|
} else if (event.key === Qt.Key_C && (event.modifiers & Qt.ControlModifier) && (event.modifiers & Qt.ShiftModifier)) {
|
||||||
|
root.copyText(root.textContent(root.currentResult))
|
||||||
|
event.accepted = true
|
||||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||||
if (event.modifiers & Qt.ShiftModifier) root.copyResult(root.currentResult)
|
if (event.modifiers & Qt.ShiftModifier) root.copyResult(root.currentResult)
|
||||||
else if (event.modifiers & Qt.AltModifier) root.openResult(root.currentResult)
|
else if (event.modifiers & Qt.AltModifier) root.openResult(root.currentResult)
|
||||||
@@ -939,6 +946,8 @@ Item {
|
|||||||
anchors.leftMargin: root.listWidth + Style.space(14)
|
anchors.leftMargin: root.listWidth + Style.space(14)
|
||||||
result: root.currentResult
|
result: root.currentResult
|
||||||
openAction: function() { root.openResult(root.currentResult) }
|
openAction: function() { root.openResult(root.currentResult) }
|
||||||
|
copyTextAction: function(text) { root.copyText(text) }
|
||||||
|
pluginDir: root.pluginDir
|
||||||
visible: root.currentResult !== null
|
visible: root.currentResult !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,6 +993,7 @@ Item {
|
|||||||
{ keys: "enter", hint: "paste" },
|
{ keys: "enter", hint: "paste" },
|
||||||
{ keys: "shift+enter", hint: "copy" },
|
{ keys: "shift+enter", hint: "copy" },
|
||||||
{ keys: "ctrl+o", hint: "open" },
|
{ keys: "ctrl+o", hint: "open" },
|
||||||
|
{ keys: "ctrl+shift+c", hint: "copy text" },
|
||||||
{ keys: "tab", hint: "pin" },
|
{ keys: "tab", hint: "pin" },
|
||||||
{ keys: "ctrl+=", hint: "pause" },
|
{ keys: "ctrl+=", hint: "pause" },
|
||||||
{ keys: "del", hint: "remove" },
|
{ keys: "del", hint: "remove" },
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function fuzzyMatch(needle, haystack) {
|
|||||||
score += 8
|
score += 8
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gap penalty (light — we rank mostly by bonuses and recency).
|
// Gap penalty (light).
|
||||||
if (k > 0) {
|
if (k > 0) {
|
||||||
var gap = pos - positions[k - 1] - 1
|
var gap = pos - positions[k - 1] - 1
|
||||||
if (gap > 0) score -= Math.min(6, gap)
|
if (gap > 0) score -= Math.min(6, gap)
|
||||||
@@ -227,7 +227,7 @@ function recencyBonus(ts, now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rows: [{ entry, content, app, type, ts, pinned, uses, bytes }]
|
// rows: [{ entry, content, app, type, ts, pinned, uses, bytes }]
|
||||||
// Returns up to `limit` rows sorted by relevance: [{ row, score, positions, type }]
|
// Returns up to `limit` matching rows, newest first: [{ row, score, positions, type }]
|
||||||
function searchRows(rows, queryStr, now, limit) {
|
function searchRows(rows, queryStr, now, limit) {
|
||||||
var parsed = parseQuery(queryStr)
|
var parsed = parseQuery(queryStr)
|
||||||
var results = []
|
var results = []
|
||||||
@@ -255,11 +255,9 @@ function searchRows(rows, queryStr, now, limit) {
|
|||||||
if (!matched) continue
|
if (!matched) continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var score = matched ? matched.score : 0
|
// Search only filters. The list is always ordered newest-first; no
|
||||||
score += recencyBonus(row.ts, now)
|
// relevance, pin, or usage ranking.
|
||||||
if (row.entry.pinned) score += 500
|
var score = 0
|
||||||
if (row.uses > 0) score += 4 * Math.min(10, row.uses)
|
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
row: row,
|
row: row,
|
||||||
score: score,
|
score: score,
|
||||||
@@ -268,7 +266,6 @@ function searchRows(rows, queryStr, now, limit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
results.sort(function(a, b) {
|
results.sort(function(a, b) {
|
||||||
if (b.score !== a.score) return b.score - a.score
|
|
||||||
return (b.row.ts || 0) - (a.row.ts || 0)
|
return (b.row.ts || 0) - (a.row.ts || 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+283
-61
@@ -1,4 +1,5 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell.Io
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
import "Classify.js" as Classify
|
import "Classify.js" as Classify
|
||||||
@@ -14,6 +15,57 @@ Item {
|
|||||||
property string derived: result ? result.row.type : ""
|
property string derived: result ? result.row.type : ""
|
||||||
// Wired by the picker: opens the current result (browser for links).
|
// Wired by the picker: opens the current result (browser for links).
|
||||||
property var openAction: function() {}
|
property var openAction: function() {}
|
||||||
|
// Wired by the picker: copies a string (OCR text, QR payload) to the clipboard.
|
||||||
|
property var copyTextAction: function(text) {}
|
||||||
|
property string pluginDir: ""
|
||||||
|
property int fileIndex: 0
|
||||||
|
readonly property var filePaths: entry && entry.type === "files" ? entry.paths || [] : []
|
||||||
|
readonly property string selectedFile: filePaths.length ? filePaths[Math.min(fileIndex, filePaths.length - 1)] : ""
|
||||||
|
property var fileInfo: ({})
|
||||||
|
property int fileRequest: 0
|
||||||
|
onFilePathsChanged: fileIndex = 0
|
||||||
|
onSelectedFileChanged: {
|
||||||
|
fileRequest++
|
||||||
|
fileInfo = ({})
|
||||||
|
fileProbe.running = false
|
||||||
|
fileDelay.restart()
|
||||||
|
}
|
||||||
|
Timer {
|
||||||
|
id: fileDelay
|
||||||
|
interval: 120
|
||||||
|
onTriggered: {
|
||||||
|
if (!root.selectedFile || !root.pluginDir) return
|
||||||
|
fileProbe.command = ["python3", root.pluginDir + "/scripts/file-preview.py", String(root.fileRequest), root.selectedFile]
|
||||||
|
fileProbe.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: fileProbe
|
||||||
|
stdout: StdioCollector {
|
||||||
|
waitForEnd: true
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(text)
|
||||||
|
if (String(data.request) === String(root.fileRequest)) root.fileInfo = data.file
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fileDetails() {
|
||||||
|
var f = fileInfo
|
||||||
|
var parts = []
|
||||||
|
if (f.kind) parts.push(f.kind)
|
||||||
|
if (f.bytes !== undefined && f.kind !== "Folder") parts.push(Classify.formatBytes(f.bytes))
|
||||||
|
if (f.width && f.height) parts.push(f.width + "×" + f.height)
|
||||||
|
if (f.duration) {
|
||||||
|
var seconds = Math.floor(f.duration)
|
||||||
|
parts.push(Math.floor(seconds / 60) + ":" + (seconds % 60 < 10 ? "0" : "") + seconds % 60)
|
||||||
|
}
|
||||||
|
if (f.audio) parts.push(f.audio)
|
||||||
|
if (f.modified) parts.push("Modified " + new Date(f.modified * 1000).toLocaleString())
|
||||||
|
return parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
readonly property string font_: Style.font.menuFamily
|
readonly property string font_: Style.font.menuFamily
|
||||||
readonly property color fg: Color.menu.text
|
readonly property color fg: Color.menu.text
|
||||||
@@ -21,22 +73,19 @@ Item {
|
|||||||
readonly property color chipBg: Util.alpha(fg, 0.07)
|
readonly property color chipBg: Util.alpha(fg, 0.07)
|
||||||
readonly property color lineColor: Util.alpha(fg, 0.16)
|
readonly property color lineColor: Util.alpha(fg, 0.16)
|
||||||
|
|
||||||
property string bodyText: ""
|
// Textual types render in the scrollable body; every other type has its
|
||||||
|
// own block below. Derived as bindings (not set from onResultChanged) so
|
||||||
onResultChanged: prepare()
|
// the body text and the type can never disagree mid-update, which used to
|
||||||
|
// leave a stale text body visible underneath an image preview.
|
||||||
function prepare() {
|
readonly property bool textual: derived === "text" || derived === "code"
|
||||||
bodyText = ""
|
|| derived === "email" || derived === "number"
|
||||||
if (!entry) return
|
|| derived === "json" || derived === "html"
|
||||||
var t = derived
|
readonly property string bodyText: {
|
||||||
if (t === "json") {
|
if (!entry || !textual) return ""
|
||||||
var pretty = Classify.prettyJson(String(entry.text || ""), 200000)
|
var raw = String(entry.text || "")
|
||||||
bodyText = pretty || String(entry.text || "")
|
if (derived === "json") return Classify.prettyJson(raw, 200000) || raw
|
||||||
} else if (t === "html") {
|
if (derived === "html") return Classify.stripHtml(raw) || raw
|
||||||
bodyText = Classify.stripHtml(String(entry.text || "")) || String(entry.text || "")
|
return raw
|
||||||
} else if (t === "text" || t === "code" || t === "email" || t === "number") {
|
|
||||||
bodyText = String(entry.text || "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rawSafe() {
|
function rawSafe() {
|
||||||
@@ -80,7 +129,7 @@ Item {
|
|||||||
chips.push(Classify.plural(st.words, "word"))
|
chips.push(Classify.plural(st.words, "word"))
|
||||||
chips.push(Classify.plural(st.lines, "line"))
|
chips.push(Classify.plural(st.lines, "line"))
|
||||||
}
|
}
|
||||||
if (r.bytes > 0) chips.push(Classify.formatBytes(r.bytes))
|
if (r.bytes > 0 && e.type !== "files") chips.push(Classify.formatBytes(r.bytes))
|
||||||
if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h)
|
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.type === "image") chips.push(e.mime || "image")
|
||||||
if (e.pinned) chips.push("★ pinned")
|
if (e.pinned) chips.push("★ pinned")
|
||||||
@@ -197,7 +246,7 @@ Item {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.topMargin: Style.space(10)
|
anchors.topMargin: Style.space(10)
|
||||||
anchors.bottomMargin: Style.space(10)
|
anchors.bottomMargin: Style.space(10)
|
||||||
visible: root.bodyText !== ""
|
visible: root.textual && root.bodyText !== ""
|
||||||
clip: true
|
clip: true
|
||||||
contentWidth: width
|
contentWidth: width
|
||||||
contentHeight: bodyEdit.implicitHeight
|
contentHeight: bodyEdit.implicitHeight
|
||||||
@@ -396,6 +445,30 @@ Item {
|
|||||||
font.bold: true
|
font.bold: true
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
radius: height / 2
|
||||||
|
color: Util.alpha(Color.accent, 0.15)
|
||||||
|
width: qrCopyLabel.implicitWidth + Style.space(16)
|
||||||
|
height: Style.space(20)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: qrCopyLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: " Copy"
|
||||||
|
color: Color.accent
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.copyTextAction(root.entry ? String(root.entry.qr || "") : "")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TextEdit {
|
TextEdit {
|
||||||
@@ -458,9 +531,13 @@ Item {
|
|||||||
id: ocrPanel
|
id: ocrPanel
|
||||||
visible: !!(root.entry && root.entry.ocr)
|
visible: !!(root.entry && root.entry.ocr)
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: Math.min(Style.space(150), ocrContent.height + Style.space(12))
|
// Never taller than the cap: the body scrolls instead of overflowing
|
||||||
|
// the panel (which used to spill over the image below it).
|
||||||
|
readonly property int maxHeight: Math.max(Style.space(80), Math.floor(parent.height * 0.4))
|
||||||
|
height: Math.min(maxHeight, ocrHeader.height + Style.space(4) + ocrText.implicitHeight + Style.space(12))
|
||||||
radius: Style.cornerRadius
|
radius: Style.cornerRadius
|
||||||
color: root.chipBg
|
color: root.chipBg
|
||||||
|
clip: true
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: ocrContent
|
id: ocrContent
|
||||||
@@ -472,20 +549,62 @@ Item {
|
|||||||
anchors.rightMargin: Style.space(6)
|
anchors.rightMargin: Style.space(6)
|
||||||
spacing: Style.space(4)
|
spacing: Style.space(4)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: ocrHeader
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: " Recognized text (OCR)"
|
text: " Recognized text (OCR)"
|
||||||
color: Color.accent
|
color: Color.accent
|
||||||
font.family: root.font_
|
font.family: root.font_
|
||||||
font.pixelSize: Style.font.caption
|
font.pixelSize: Style.font.caption
|
||||||
font.bold: true
|
font.bold: true
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
radius: height / 2
|
||||||
|
color: Util.alpha(Color.accent, 0.15)
|
||||||
|
width: ocrCopyLabel.implicitWidth + Style.space(16)
|
||||||
|
height: Style.space(20)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: ocrCopyLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: " Copy text"
|
||||||
|
color: Color.accent
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.copyTextAction(root.entry ? String(root.entry.ocr || "") : "")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Flickable {
|
Flickable {
|
||||||
|
id: ocrFlick
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: ocrText.implicitHeight
|
height: ocrPanel.height - ocrHeader.height - Style.space(4) - Style.space(12)
|
||||||
clip: true
|
clip: true
|
||||||
contentWidth: width
|
contentWidth: width
|
||||||
contentHeight: ocrText.implicitHeight
|
contentHeight: ocrText.implicitHeight
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
WheelHandler {
|
||||||
|
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||||
|
onWheel: function(ev) {
|
||||||
|
if (ev.angleDelta.y < 0) ocrFlick.flick(0, -240)
|
||||||
|
else ocrFlick.flick(0, 240)
|
||||||
|
ev.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextEdit {
|
TextEdit {
|
||||||
id: ocrText
|
id: ocrText
|
||||||
@@ -556,8 +675,10 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// files body
|
// File content is bounded by a scrollable viewport, including long paths
|
||||||
Column {
|
// and text snippets. Selecting another file loads its details on demand.
|
||||||
|
Flickable {
|
||||||
|
id: filesBody
|
||||||
anchors.top: divider.bottom
|
anchors.top: divider.bottom
|
||||||
anchors.bottom: metaRow.top
|
anchors.bottom: metaRow.top
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -565,57 +686,158 @@ Item {
|
|||||||
anchors.topMargin: Style.space(10)
|
anchors.topMargin: Style.space(10)
|
||||||
anchors.bottomMargin: Style.space(10)
|
anchors.bottomMargin: Style.space(10)
|
||||||
visible: root.derived === "files"
|
visible: root.derived === "files"
|
||||||
spacing: Style.space(6)
|
|
||||||
clip: true
|
clip: true
|
||||||
|
contentWidth: width
|
||||||
Repeater {
|
contentHeight: fileContent.height
|
||||||
model: {
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
if (!root.entry || root.entry.type !== "files") return []
|
Connections {
|
||||||
return (root.entry.paths || []).slice(0, 10)
|
target: root
|
||||||
|
function onSelectedFileChanged() { filesBody.contentY = 0 }
|
||||||
}
|
}
|
||||||
|
WheelHandler {
|
||||||
delegate: Row {
|
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||||
required property var modelData
|
onWheel: function(ev) {
|
||||||
width: parent ? parent.width : 0
|
filesBody.flick(0, ev.angleDelta.y < 0 ? -240 : 240)
|
||||||
|
ev.accepted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
id: fileContent
|
||||||
|
width: filesBody.width
|
||||||
spacing: Style.space(8)
|
spacing: Style.space(8)
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.space(8)
|
||||||
|
visible: root.filePaths.length > 1
|
||||||
|
Repeater {
|
||||||
|
model: ["‹ Previous", "Next ›"]
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property int index
|
||||||
|
required property string modelData
|
||||||
|
width: navLabel.implicitWidth + Style.space(16)
|
||||||
|
height: Style.space(24)
|
||||||
|
color: root.chipBg
|
||||||
|
radius: Style.cornerRadius
|
||||||
Text {
|
Text {
|
||||||
text: ""
|
id: navLabel
|
||||||
color: Color.accent
|
anchors.centerIn: parent
|
||||||
|
text: modelData
|
||||||
|
color: root.fg
|
||||||
font.family: root.font_
|
font.family: root.font_
|
||||||
font.pixelSize: Style.font.body
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: root.fileIndex = (root.fileIndex + (index === 0 ? -1 : 1) + root.filePaths.length) % root.filePaths.length
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: Classify.fileBase(modelData)
|
text: (root.fileIndex + 1) + " / " + root.filePaths.length
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: Classify.fileBase(root.selectedFile)
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
color: root.fg
|
color: root.fg
|
||||||
font.family: root.font_
|
font.family: root.font_
|
||||||
font.pixelSize: Style.font.body
|
font.pixelSize: Style.font.body
|
||||||
elide: Text.ElideMiddle
|
font.bold: true
|
||||||
width: parent.width - Style.space(24)
|
|
||||||
maximumLineCount: 1
|
|
||||||
}
|
}
|
||||||
}
|
Rectangle {
|
||||||
}
|
id: thumbnailPanel
|
||||||
|
readonly property bool loading: fileDelay.running || fileProbe.running || fileThumbnail.status === Image.Loading
|
||||||
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
|
width: parent.width
|
||||||
maximumLineCount: 1
|
height: visible ? Math.min(Style.space(240), filesBody.height * 0.55) : 0
|
||||||
|
visible: loading || !!root.fileInfo.thumbnail
|
||||||
|
color: root.chipBg
|
||||||
|
radius: Style.cornerRadius
|
||||||
|
clip: true
|
||||||
|
Image {
|
||||||
|
id: fileThumbnail
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: status === Image.Ready && !thumbnailPanel.loading
|
||||||
|
source: root.fileInfo.thumbnail || ""
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
asynchronous: true
|
||||||
|
sourceSize.width: 640
|
||||||
|
sourceSize.height: 360
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Style.space(8)
|
||||||
|
visible: thumbnailPanel.loading
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: "◌"
|
||||||
|
color: Color.accent
|
||||||
|
font.pixelSize: Style.space(28)
|
||||||
|
NumberAnimation on rotation {
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 1000
|
||||||
|
loops: Animation.Infinite
|
||||||
|
running: thumbnailPanel.loading && filesBody.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "Loading preview…"
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: !thumbnailPanel.loading && fileThumbnail.status === Image.Error
|
||||||
|
text: "Thumbnail unavailable"
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.fileInfo.error || root.fileDetails() || (thumbnailPanel.loading ? "Loading file information…" : "File information unavailable")
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.selectedFile
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
|
TextEdit {
|
||||||
|
width: parent.width
|
||||||
|
visible: text.length > 0
|
||||||
|
text: root.fileInfo.text || root.fileInfo.details || ""
|
||||||
|
textFormat: TextEdit.PlainText
|
||||||
|
readOnly: true
|
||||||
|
activeFocusOnPress: false
|
||||||
|
wrapMode: TextEdit.WrapAnywhere
|
||||||
|
color: root.fg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: !!root.fileInfo.truncated
|
||||||
|
text: "Preview limited to the first 8 KiB"
|
||||||
|
color: root.mutedFg
|
||||||
|
font.family: root.font_
|
||||||
|
font.pixelSize: Style.font.caption
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ full theme integration.
|
|||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **Fuzzy search over everything** — fzf-style scoring across content, source
|
- **Fuzzy search over everything** — fzf-style matching across content, source
|
||||||
app, and type, boosted by recency, pins, and usage. Query tokens:
|
app, and type. The list is always newest-first; search only filters it. 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)
|
||||||
- `app:firefox` — fuzzy match on the source app
|
- `app:firefox` — fuzzy match on the source app
|
||||||
- `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d`
|
- `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d`
|
||||||
@@ -61,6 +61,35 @@ That's the whole install — `omarchy plugin add` clones the repo, validates the
|
|||||||
manifest, and enables it. It replaces the built-in `omarchy.clipboard`
|
manifest, and enables it. It replaces the built-in `omarchy.clipboard`
|
||||||
(restore it later with `omarchy plugin disable alanfortlink.clipboard`).
|
(restore it later with `omarchy plugin disable alanfortlink.clipboard`).
|
||||||
|
|
||||||
|
### File previews
|
||||||
|
|
||||||
|
Copied files show their name, type, size, modification time, and path. Image
|
||||||
|
and video files show a thumbnail and resolution; videos and audio show duration,
|
||||||
|
and audio also shows codec, sample rate, and channels. PDFs show a first-page
|
||||||
|
thumbnail and page information. Text and code files show the first 8 KiB.
|
||||||
|
Folders and other files retain basic information. Use Previous/Next to inspect
|
||||||
|
multiple copied files. Details load on selection, including for existing history.
|
||||||
|
|
||||||
|
Media previews use optional `ffmpeg` (`ffprobe` included); PDF previews use
|
||||||
|
optional `poppler` (`pdfinfo` and `pdftoppm`). Without them, basic file information
|
||||||
|
still works. Preview commands have time and output limits, and media thumbnails
|
||||||
|
are skipped above 40 megapixels. Missing or moved files show an error.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
All are regular Arch packages; nothing is downloaded or run at install time.
|
||||||
|
Required: `wl-clipboard`, `jq`, `python3`, `wtype` (paste into the focused
|
||||||
|
window). Optional: `zbar` (QR decoding) and `tesseract` (OCR search) — without
|
||||||
|
them the picker still works and shows the `omarchy pkg add …` command to add them.
|
||||||
|
No sudo or pkexec is required by the plugin itself.
|
||||||
|
|
||||||
|
The clipboard owner is treated as untrusted. Payloads are streamed through a
|
||||||
|
capped reader and dropped when they exceed 32 MiB (images) or 4 MiB (text), or
|
||||||
|
take longer than 5 s to deliver; QR decoding and OCR only run on images under
|
||||||
|
40 megapixels, as read from the file header without decoding. Override with
|
||||||
|
`CLIPBOARD_MAX_IMAGE_BYTES`, `CLIPBOARD_MAX_TEXT_BYTES`, and
|
||||||
|
`CLIPBOARD_MAX_PARSE_PIXELS` in the shell's environment.
|
||||||
|
|
||||||
Omarchy's default `Super+Ctrl+V` routes to it via the clone mechanism. An
|
Omarchy's default `Super+Ctrl+V` routes to it via the clone mechanism. An
|
||||||
existing `Super+Shift+V` binding targeting `omarchy.clipboard` routes to it too.
|
existing `Super+Shift+V` binding targeting `omarchy.clipboard` routes to it too.
|
||||||
For a custom binding, edit the **live** config —
|
For a custom binding, edit the **live** config —
|
||||||
@@ -68,14 +97,31 @@ For a custom binding, edit the **live** config —
|
|||||||
but is not sourced):
|
but is not sourced):
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
o.bind("SUPER + SHIFT + V", "Clipboard manager (clipboard-history)",
|
o.bind("SUPER + SHIFT + V", "Clipboard manager",
|
||||||
"omarchy-shell alanfortlink.clipboard toggle")
|
"omarchy-shell shell toggle omarchy.clipboard")
|
||||||
```
|
```
|
||||||
|
|
||||||
(If your config is still legacy `bindings.conf`: `bindd = ALT SHIFT, V, …`.)
|
(If your config is still legacy `bindings.conf`: `bindd = ALT SHIFT, V, …`.)
|
||||||
|
|
||||||
Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy plugin remove alanfortlink.clipboard`
|
Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy plugin remove alanfortlink.clipboard`
|
||||||
|
|
||||||
|
### Upgrading from a pre-1.0 install
|
||||||
|
|
||||||
|
If you installed an earlier build (plugin id `tank.clipboard`, or a symlinked
|
||||||
|
checkout via `install.sh`), remove it, reinstall from git, and repair any direct
|
||||||
|
shortcut bindings so they target Omarchy's clone-aware `omarchy.clipboard` id:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
omarchy plugin remove tank.clipboard --yes # or: alanfortlink.clipboard
|
||||||
|
omarchy plugin add https://github.com/alanfortlink/clipboard-history.git --enable --yes
|
||||||
|
bash ~/.config/omarchy/plugins/alanfortlink.clipboard/scripts/configure-bindings.sh
|
||||||
|
omarchy restart shell
|
||||||
|
```
|
||||||
|
|
||||||
|
History data is preserved. `configure-bindings.sh` edits `bindings.lua` (or
|
||||||
|
`bindings.conf`) and leaves a timestamped `.bak` copy next to it; it is only
|
||||||
|
ever run when you invoke it explicitly.
|
||||||
|
|
||||||
## Keys
|
## Keys
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
@@ -84,6 +130,7 @@ Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy
|
|||||||
| `Enter` | copy to clipboard and paste into the focused window |
|
| `Enter` | copy to clipboard and paste into the focused window |
|
||||||
| `Shift+Enter` | copy only |
|
| `Shift+Enter` | copy only |
|
||||||
| `Ctrl+O` | open a pure link in the browser, an image in the editor, or a file externally. Text entries stay in the picker; use `Return` to paste them. QR images open their **decoded link** in the browser; the preview pane also has clickable "Open link" chips for links and QR payloads |
|
| `Ctrl+O` | open a pure link in the browser, an image in the editor, or a file externally. Text entries stay in the picker; use `Return` to paste them. QR images open their **decoded link** in the browser; the preview pane also has clickable "Open link" chips for links and QR payloads |
|
||||||
|
| `Ctrl+Shift+C` | copy the entry's text content as plain text: for images, the OCR text (or QR payload). The QR and OCR panels also have **Copy** chips |
|
||||||
| `Tab` | pin/unpin |
|
| `Tab` | pin/unpin |
|
||||||
| `Ctrl+=` | pause/resume recording |
|
| `Ctrl+=` | pause/resume recording |
|
||||||
| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) |
|
| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) |
|
||||||
@@ -92,8 +139,8 @@ Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy
|
|||||||
Pause/resume is also scriptable — useful for automation or a custom binding:
|
Pause/resume is also scriptable — useful for automation or a custom binding:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
omarchy-shell alanfortlink.clipboard pause '{"paused":"toggle"}'
|
omarchy-shell shell call alanfortlink.clipboard pause '{"paused":"toggle"}'
|
||||||
omarchy-shell alanfortlink.clipboard isPaused
|
omarchy-shell shell call alanfortlink.clipboard isPaused
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
+172
-21
@@ -11,12 +11,19 @@ the current clipboard. Emits exactly one JSON line per capture:
|
|||||||
|
|
||||||
Sensitive clips (x-kde-passwordManagerHint / CLIPBOARD_STATE=sensitive) and
|
Sensitive clips (x-kde-passwordManagerHint / CLIPBOARD_STATE=sensitive) and
|
||||||
binary payloads are skipped silently.
|
binary payloads are skipped silently.
|
||||||
|
|
||||||
|
The clipboard owner is untrusted: every payload is read through a bounded
|
||||||
|
reader (byte cap + deadline) and QR/OCR only run on images whose header
|
||||||
|
dimensions are under a pixel cap, so a hostile or runaway source cannot make
|
||||||
|
the plugin buffer an unbounded payload or decode a decompression bomb.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import select
|
||||||
import shutil
|
import shutil
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -34,6 +41,24 @@ IMAGE_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp",
|
|||||||
TEXT_TYPES = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "COMPOUND_TEXT"]
|
TEXT_TYPES = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "COMPOUND_TEXT"]
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name, default):
|
||||||
|
try:
|
||||||
|
return max(0, int(os.environ.get(name, "") or default))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# Payload limits. Anything larger is dropped (not truncated) so the history
|
||||||
|
# never holds a partial clip. Overridable per environment.
|
||||||
|
MAX_IMAGE_BYTES = _env_int("CLIPBOARD_MAX_IMAGE_BYTES", 32 * 1024 * 1024)
|
||||||
|
MAX_TEXT_BYTES = _env_int("CLIPBOARD_MAX_TEXT_BYTES", 4 * 1024 * 1024)
|
||||||
|
# QR decoding and OCR decode the full bitmap; skip them above this many
|
||||||
|
# pixels (the image is still recorded and previewed by Qt, which has its own
|
||||||
|
# allocation limits).
|
||||||
|
MAX_PARSE_PIXELS = _env_int("CLIPBOARD_MAX_PARSE_PIXELS", 40 * 1000 * 1000)
|
||||||
|
READ_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
|
||||||
def run(args, timeout=5):
|
def run(args, timeout=5):
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(args, capture_output=True, timeout=timeout)
|
r = subprocess.run(args, capture_output=True, timeout=timeout)
|
||||||
@@ -42,6 +67,115 @@ def run(args, timeout=5):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_bounded(args, limit, timeout=READ_TIMEOUT):
|
||||||
|
"""Run `args` and return its stdout, or None if it exits non-zero, writes
|
||||||
|
more than `limit` bytes, or does not finish within `timeout` seconds.
|
||||||
|
Output is streamed and the process is killed as soon as the cap is hit,
|
||||||
|
so memory use is bounded by `limit` regardless of what the source sends."""
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
chunks = []
|
||||||
|
total = 0
|
||||||
|
ok = True
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
ready, _, _ = select.select([proc.stdout], [], [], remaining)
|
||||||
|
if not ready:
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
chunk = os.read(proc.stdout.fileno(), 65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > limit:
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
finally:
|
||||||
|
if not ok:
|
||||||
|
proc.kill()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
proc.kill()
|
||||||
|
proc.stdout.close()
|
||||||
|
if not ok or proc.returncode != 0:
|
||||||
|
return None
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def read_clipboard(mime, limit):
|
||||||
|
return read_bounded(["wl-paste", "--type", mime, "--no-newline"], limit)
|
||||||
|
|
||||||
|
|
||||||
|
def image_size(data, mime):
|
||||||
|
"""(width, height) from the container header alone — no pixel decoding.
|
||||||
|
Returns None when the header is not understood."""
|
||||||
|
try:
|
||||||
|
if mime == "image/png" and data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR":
|
||||||
|
return struct.unpack(">II", data[16:24])
|
||||||
|
if mime == "image/gif" and data[:6] in (b"GIF87a", b"GIF89a"):
|
||||||
|
return struct.unpack("<HH", data[6:10])
|
||||||
|
if mime == "image/bmp" and data[:2] == b"BM":
|
||||||
|
w, h = struct.unpack("<ii", data[18:26])
|
||||||
|
return abs(w), abs(h)
|
||||||
|
if mime == "image/webp" and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||||
|
chunk = data[12:16]
|
||||||
|
if chunk == b"VP8X":
|
||||||
|
w = int.from_bytes(data[24:27], "little") + 1
|
||||||
|
h = int.from_bytes(data[27:30], "little") + 1
|
||||||
|
return w, h
|
||||||
|
if chunk == b"VP8L" and data[20] == 0x2F:
|
||||||
|
bits = int.from_bytes(data[21:25], "little")
|
||||||
|
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||||
|
if chunk == b"VP8 ":
|
||||||
|
w, h = struct.unpack("<HH", data[26:30])
|
||||||
|
return w & 0x3FFF, h & 0x3FFF
|
||||||
|
if mime == "image/jpeg" and data[:2] == b"\xff\xd8":
|
||||||
|
i = 2
|
||||||
|
n = len(data)
|
||||||
|
while i + 9 < n:
|
||||||
|
if data[i] != 0xFF:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
marker = data[i + 1]
|
||||||
|
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
length = struct.unpack(">H", data[i + 2:i + 4])[0]
|
||||||
|
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
||||||
|
h, w = struct.unpack(">HH", data[i + 5:i + 9])
|
||||||
|
return w, h
|
||||||
|
i += 2 + length
|
||||||
|
if mime == "image/tiff" and data[:4] in (b"II*\x00", b"MM\x00*"):
|
||||||
|
end = "<" if data[:2] == b"II" else ">"
|
||||||
|
off = struct.unpack(end + "I", data[4:8])[0]
|
||||||
|
count = struct.unpack(end + "H", data[off:off + 2])[0]
|
||||||
|
w = h = None
|
||||||
|
for k in range(min(count, 64)):
|
||||||
|
e = off + 2 + k * 12
|
||||||
|
tag, typ = struct.unpack(end + "HH", data[e:e + 4])
|
||||||
|
val = struct.unpack(end + ("H" if typ == 3 else "I"), data[e + 8:e + 8 + (2 if typ == 3 else 4)])[0]
|
||||||
|
if tag == 256:
|
||||||
|
w = val
|
||||||
|
elif tag == 257:
|
||||||
|
h = val
|
||||||
|
if w and h:
|
||||||
|
return w, h
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def list_types():
|
def list_types():
|
||||||
out = run(["wl-paste", "--list-types"])
|
out = run(["wl-paste", "--list-types"])
|
||||||
if not out:
|
if not out:
|
||||||
@@ -86,22 +220,22 @@ 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:
|
||||||
return
|
return
|
||||||
data = run(["wl-paste", "--type", mime, "--no-newline"])
|
data = read_clipboard(mime, MAX_IMAGE_BYTES)
|
||||||
if not data:
|
if not data:
|
||||||
return
|
# Oversized, slow, or empty: skip the clip entirely (an image mime was
|
||||||
|
# offered, so we do not fall through to a text capture of it either).
|
||||||
|
return True
|
||||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||||
digest = hashlib.sha256(data).hexdigest()
|
digest = hashlib.sha256(data).hexdigest()
|
||||||
path = os.path.join(IMAGE_DIR, f"{digest}.{IMAGE_EXT[mime]}")
|
path = os.path.join(IMAGE_DIR, f"{digest}.{IMAGE_EXT[mime]}")
|
||||||
entry = {"type": "image", "mime": mime, "path": path, "bytes": len(data), "app": app}
|
entry = {"type": "image", "mime": mime, "path": path, "bytes": len(data), "app": app}
|
||||||
|
|
||||||
# Dimensions via Pillow when available; harmless without it.
|
# Dimensions from the header only; the bitmap is never decoded here.
|
||||||
try:
|
size = image_size(data, mime)
|
||||||
from PIL import Image # noqa: PLC0415
|
if size:
|
||||||
import io # noqa: PLC0415
|
entry["w"], entry["h"] = int(size[0]), int(size[1])
|
||||||
with Image.open(io.BytesIO(data)) as im:
|
# Unknown or huge dimensions → no QR/OCR pass (both decode the full image).
|
||||||
entry["w"], entry["h"] = im.size
|
parse_ok = bool(size) and size[0] * size[1] <= MAX_PARSE_PIXELS
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
fd, tmp = tempfile.mkstemp(dir=IMAGE_DIR)
|
fd, tmp = tempfile.mkstemp(dir=IMAGE_DIR)
|
||||||
@@ -116,11 +250,11 @@ def capture_image(types, app):
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
qr = decode_qr(path) if os.environ.get("CLIPBOARD_QR", "1") != "0" else None
|
qr = decode_qr(path) if parse_ok and os.environ.get("CLIPBOARD_QR", "1") != "0" else None
|
||||||
if qr:
|
if qr:
|
||||||
entry["qr"] = qr
|
entry["qr"] = qr
|
||||||
|
|
||||||
ocr = decode_ocr(path, os.environ.get("CLIPBOARD_OCR_LANG", "eng"))
|
ocr = decode_ocr(path, os.environ.get("CLIPBOARD_OCR_LANG", "eng")) if parse_ok else None
|
||||||
if ocr:
|
if ocr:
|
||||||
entry["ocr"] = ocr
|
entry["ocr"] = ocr
|
||||||
|
|
||||||
@@ -175,7 +309,7 @@ def decode_ocr(path, lang):
|
|||||||
def capture_uri_list(types, app):
|
def capture_uri_list(types, app):
|
||||||
if "text/uri-list" not in types:
|
if "text/uri-list" not in types:
|
||||||
return False
|
return False
|
||||||
data = run(["wl-paste", "--type", "text/uri-list", "--no-newline"])
|
data = read_clipboard("text/uri-list", MAX_TEXT_BYTES)
|
||||||
if not data:
|
if not data:
|
||||||
return False
|
return False
|
||||||
text = decode_text(data)
|
text = decode_text(data)
|
||||||
@@ -200,7 +334,7 @@ def capture_text(types, app):
|
|||||||
mime = next((m for m in TEXT_TYPES if m in types), None)
|
mime = next((m for m in TEXT_TYPES if m in types), None)
|
||||||
if not mime:
|
if not mime:
|
||||||
return
|
return
|
||||||
data = run(["wl-paste", "--type", mime, "--no-newline"])
|
data = read_clipboard(mime, MAX_TEXT_BYTES)
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
text = decode_text(data)
|
text = decode_text(data)
|
||||||
@@ -209,14 +343,31 @@ def capture_text(types, app):
|
|||||||
emit({"type": "text", "text": text, "bytes": len(data), "app": app})
|
emit({"type": "text", "text": text, "bytes": len(data), "app": app})
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def drain_stdin(timeout=READ_TIMEOUT):
|
||||||
# The watcher pipes the clipboard payload to us; wl-clipboard blocks on
|
"""Discard the watcher's payload in fixed chunks, with a total deadline.
|
||||||
# writes if we close the pipe early, so drain it instead (we still probe
|
|
||||||
# types ourselves below).
|
False makes main exit without probing; process exit closes the pipe so
|
||||||
|
a stalled or endless producer cannot keep this helper alive indefinitely.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
try:
|
try:
|
||||||
sys.stdin.buffer.read()
|
fd = sys.stdin.fileno()
|
||||||
except Exception:
|
while True:
|
||||||
pass
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return False
|
||||||
|
ready, _, _ = select.select([fd], [], [], remaining)
|
||||||
|
if not ready:
|
||||||
|
return False
|
||||||
|
if not os.read(fd, 65536):
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not drain_stdin():
|
||||||
|
return
|
||||||
types = list_types()
|
types = list_types()
|
||||||
if not types:
|
if not types:
|
||||||
return
|
return
|
||||||
|
|||||||
+5
-47
@@ -5,7 +5,7 @@
|
|||||||
# - 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)
|
||||||
# - checks and installs dependencies transparently (QR/OCR degrade without them)
|
# - checks and installs dependencies transparently (QR/OCR degrade without them)
|
||||||
# - binds SUPER+SHIFT+V and ALT+SHIFT+V directly to this picker (with backup)
|
# - binds SUPER+SHIFT+V and ALT+SHIFT+V to the clone-aware source id (with backup)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -38,7 +38,8 @@ 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:
|
# On other machines, install directly from git instead:
|
||||||
# omarchy plugin add <this-repo-git-url> --enable
|
# 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 \
|
||||||
|
"$PLUGIN_DIR"/scripts/configure-bindings.sh
|
||||||
|
|
||||||
echo "==> Rescanning shell plugins"
|
echo "==> Rescanning shell plugins"
|
||||||
omarchy-shell shell rescanPlugins >/dev/null
|
omarchy-shell shell rescanPlugins >/dev/null
|
||||||
@@ -58,51 +59,8 @@ fi
|
|||||||
echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)"
|
echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)"
|
||||||
omarchy plugin enable "$PLUGIN_ID"
|
omarchy plugin enable "$PLUGIN_ID"
|
||||||
|
|
||||||
echo "==> Binding SUPER+SHIFT+V and ALT+SHIFT+V directly to $PLUGIN_ID"
|
echo "==> Binding SUPER+SHIFT+V and ALT+SHIFT+V to the clipboard source"
|
||||||
# Do not route through `shell toggle`: plugin rescans can leave its panel Loader
|
"$PLUGIN_DIR/scripts/configure-bindings.sh"
|
||||||
# stale. The plugin-owned IPC target always controls the mapped picker window.
|
|
||||||
rebound=0
|
|
||||||
if [[ -f $HOME/.config/hypr/bindings.lua ]]; then
|
|
||||||
LUA="$HOME/.config/hypr/bindings.lua"
|
|
||||||
tmp=$(mktemp)
|
|
||||||
python3 - "$LUA" >"$tmp" <<'PY'
|
|
||||||
import re, sys
|
|
||||||
text = open(sys.argv[1]).read()
|
|
||||||
command = "omarchy-shell alanfortlink.clipboard toggle"
|
|
||||||
for chord in ("SUPER + SHIFT + V", "ALT + SHIFT + V"):
|
|
||||||
line = f'o.bind("{chord}", "Clipboard manager (clipboard-history)", "{command}")'
|
|
||||||
pattern = re.compile(r'^\s*o\.bind\("' + re.escape(chord) + r'".*$', re.M)
|
|
||||||
if pattern.search(text):
|
|
||||||
text = pattern.sub(line, text, count=1)
|
|
||||||
else:
|
|
||||||
text = text.rstrip() + "\n" + line + "\n"
|
|
||||||
sys.stdout.write(text)
|
|
||||||
PY
|
|
||||||
if ! cmp -s "$LUA" "$tmp"; then
|
|
||||||
cp "$LUA" "$LUA.bak.$(date +%s)"
|
|
||||||
mv "$tmp" "$LUA"
|
|
||||||
echo " updated bindings.lua (backup created)"
|
|
||||||
else
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo " bindings.lua already correct"
|
|
||||||
fi
|
|
||||||
rebound=1
|
|
||||||
elif [[ -f $HOME/.config/hypr/bindings.conf ]]; then
|
|
||||||
CONF="$HOME/.config/hypr/bindings.conf"
|
|
||||||
cp "$CONF" "$CONF.bak.$(date +%s)"
|
|
||||||
sed -i '/^bindd = \(SUPER\|ALT\) SHIFT, V, /d' "$CONF"
|
|
||||||
printf '%s\n' \
|
|
||||||
'bindd = SUPER SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell alanfortlink.clipboard toggle' \
|
|
||||||
'bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell alanfortlink.clipboard toggle' >>"$CONF"
|
|
||||||
echo " updated bindings.conf (backup created)"
|
|
||||||
rebound=1
|
|
||||||
fi
|
|
||||||
if (( rebound )); then
|
|
||||||
hyprctl reload >/dev/null
|
|
||||||
else
|
|
||||||
echo "ERROR: no live Hyprland bindings file found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Done. Press SUPER+SHIFT+V (or ALT+SHIFT+V) to open the clipboard picker."
|
echo "Done. Press SUPER+SHIFT+V (or ALT+SHIFT+V) to open the clipboard picker."
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "alanfortlink.clipboard",
|
"id": "alanfortlink.clipboard",
|
||||||
"name": "Clipboard History",
|
"name": "Clipboard History",
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"author": "alanfortlink",
|
"author": "alanfortlink",
|
||||||
"description": "Raycast-style clipboard history: fuzzy search over content, type, app and date, with rich per-type previews",
|
"description": "Raycast-style clipboard history: fuzzy search over content, type, app and date, with rich per-type previews",
|
||||||
"kinds": [
|
"kinds": [
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
+11
-1
@@ -11,7 +11,9 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
import capture # noqa: E402 (image_size / MAX_PARSE_PIXELS shared with the daemon)
|
||||||
|
|
||||||
STATE = os.path.join(
|
STATE = os.path.join(
|
||||||
os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")),
|
os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")),
|
||||||
@@ -56,6 +58,14 @@ def main():
|
|||||||
continue
|
continue
|
||||||
if not os.path.exists(entry["path"]):
|
if not os.path.exists(entry["path"]):
|
||||||
continue
|
continue
|
||||||
|
# Same guard as the capture daemon: never decode an image whose header
|
||||||
|
# dimensions are unknown or above the pixel cap.
|
||||||
|
with open(entry["path"], "rb") as f:
|
||||||
|
head = f.read(65536)
|
||||||
|
size = capture.image_size(head, entry.get("mime") or "image/png")
|
||||||
|
if not size or size[0] * size[1] > capture.MAX_PARSE_PIXELS:
|
||||||
|
print(f" - {entry['id']}: skipped (size {size or 'unknown'})")
|
||||||
|
continue
|
||||||
text = ocr(entry["path"], args.lang)
|
text = ocr(entry["path"], args.lang)
|
||||||
if text:
|
if text:
|
||||||
entry["ocr"] = text
|
entry["ocr"] = text
|
||||||
|
|||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Configure clipboard shortcuts through Omarchy's clone-aware source id.
|
||||||
|
# The same bindings open alanfortlink.clipboard while installed and the
|
||||||
|
# built-in omarchy.clipboard after this plugin is disabled or removed.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HYPR_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
|
||||||
|
LUA="$HYPR_DIR/bindings.lua"
|
||||||
|
CONF="$HYPR_DIR/bindings.conf"
|
||||||
|
STAMP=$(date +%s)
|
||||||
|
changed=0
|
||||||
|
|
||||||
|
if [[ -f $LUA ]]; then
|
||||||
|
tmp=$(mktemp)
|
||||||
|
trap 'rm -f "${tmp:-}"' EXIT
|
||||||
|
python3 - "$LUA" >"$tmp" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
text = open(path, encoding="utf-8").read()
|
||||||
|
command = "omarchy-shell shell toggle omarchy.clipboard"
|
||||||
|
for chord in ("SUPER + SHIFT + V", "ALT + SHIFT + V"):
|
||||||
|
line = f'o.bind("{chord}", "Clipboard manager", "{command}")'
|
||||||
|
pattern = re.compile(r'^\s*o\.bind\("' + re.escape(chord) + r'".*$', re.MULTILINE)
|
||||||
|
if pattern.search(text):
|
||||||
|
text = pattern.sub(line, text, count=1)
|
||||||
|
else:
|
||||||
|
text = text.rstrip() + "\n" + line + "\n"
|
||||||
|
sys.stdout.write(text)
|
||||||
|
PY
|
||||||
|
if ! cmp -s "$LUA" "$tmp"; then
|
||||||
|
cp "$LUA" "$LUA.bak.clipboard.$STAMP"
|
||||||
|
mv "$tmp" "$LUA"
|
||||||
|
changed=1
|
||||||
|
echo "Updated $LUA (backup: $LUA.bak.clipboard.$STAMP)"
|
||||||
|
else
|
||||||
|
echo "$LUA already uses clone-aware clipboard bindings"
|
||||||
|
fi
|
||||||
|
elif [[ -f $CONF ]]; then
|
||||||
|
tmp=$(mktemp)
|
||||||
|
trap 'rm -f "${tmp:-}"' EXIT
|
||||||
|
grep -vE '^bindd = (SUPER|ALT) SHIFT, V, ' "$CONF" >"$tmp" || true
|
||||||
|
printf '%s\n' \
|
||||||
|
'bindd = SUPER SHIFT, V, Clipboard manager, exec, omarchy-shell shell toggle omarchy.clipboard' \
|
||||||
|
'bindd = ALT SHIFT, V, Clipboard manager, exec, omarchy-shell shell toggle omarchy.clipboard' >>"$tmp"
|
||||||
|
if ! cmp -s "$CONF" "$tmp"; then
|
||||||
|
cp "$CONF" "$CONF.bak.clipboard.$STAMP"
|
||||||
|
mv "$tmp" "$CONF"
|
||||||
|
changed=1
|
||||||
|
echo "Updated $CONF (backup: $CONF.bak.clipboard.$STAMP)"
|
||||||
|
else
|
||||||
|
echo "$CONF already uses clone-aware clipboard bindings"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "configure-bindings: no bindings.lua or bindings.conf found in $HYPR_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( changed )); then
|
||||||
|
hyprctl reload >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
errors=$(hyprctl configerrors)
|
||||||
|
if [[ -n $errors ]]; then
|
||||||
|
printf '%s\n' "$errors" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Clipboard shortcuts ready: SUPER+SHIFT+V and ALT+SHIFT+V"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bounded, on-demand metadata for one local copied file; never modifies it."""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from capture import read_bounded
|
||||||
|
|
||||||
|
|
||||||
|
def preview(path):
|
||||||
|
result = {'path': path}
|
||||||
|
deadline = time.monotonic() + 6
|
||||||
|
|
||||||
|
def run(args, limit=128 * 1024):
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
return read_bounded(args, limit, timeout=min(2, remaining)) if remaining > 0 else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = os.stat(path)
|
||||||
|
result.update(bytes=info.st_size, modified=int(info.st_mtime))
|
||||||
|
if stat.S_ISDIR(info.st_mode):
|
||||||
|
result['kind'] = 'Folder'
|
||||||
|
return result
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
result['kind'] = 'Special file'
|
||||||
|
return result
|
||||||
|
mime = mimetypes.guess_type(path)[0] or 'application/octet-stream'
|
||||||
|
result['mime'] = mime
|
||||||
|
result['kind'] = mime
|
||||||
|
# Do not let a media playlist cause ffmpeg to open referenced resources.
|
||||||
|
media = Path(path).suffix.lower() in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.mp3', '.flac', '.wav', '.ogg', '.opus', '.m4a', '.aac'}
|
||||||
|
if media:
|
||||||
|
options = ['-v', 'error', '-max_alloc', '67108864', '-protocol_whitelist', 'file', '-probesize', '1048576', '-analyzeduration', '1000000']
|
||||||
|
data = run(['ffprobe'] + options + ['-show_entries', 'format=duration:stream=codec_type,codec_name,width,height,sample_rate,channels', '-of', 'json', path])
|
||||||
|
if data:
|
||||||
|
parsed = json.loads(data)
|
||||||
|
duration = float(parsed.get('format', {}).get('duration', 0))
|
||||||
|
if 0 < duration < 1e9:
|
||||||
|
result['duration'] = duration
|
||||||
|
streams = parsed.get('streams', [])
|
||||||
|
visual = next((s for s in streams if s.get('codec_type') == 'video'), {})
|
||||||
|
audio = next((s for s in streams if s.get('codec_type') == 'audio'), {})
|
||||||
|
for key in ('width', 'height'):
|
||||||
|
if visual.get(key):
|
||||||
|
result[key] = int(visual[key])
|
||||||
|
if audio:
|
||||||
|
result['audio'] = ' · '.join(str(audio[k]) + suffix for k, suffix in [('codec_name', ''), ('sample_rate', ' Hz'), ('channels', ' channels')] if audio.get(k))
|
||||||
|
if 0 < result.get('width', 0) * result.get('height', 0) <= 40000000:
|
||||||
|
thumb = run(['ffmpeg'] + options + ['-nostdin', '-threads', '1', '-i', path, '-frames:v', '1', '-vf', 'scale=640:360:force_original_aspect_ratio=decrease', '-threads', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-protocol_whitelist', 'pipe', 'pipe:1'], 512 * 1024)
|
||||||
|
if thumb:
|
||||||
|
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
|
||||||
|
elif mime == 'application/pdf':
|
||||||
|
thumb = run(['pdftoppm', '-f', '1', '-singlefile', '-scale-to', '640', '-jpeg', path], 512 * 1024)
|
||||||
|
if thumb:
|
||||||
|
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
|
||||||
|
data = run(['pdfinfo', path])
|
||||||
|
if data:
|
||||||
|
result['details'] = '\n'.join(line.strip() for line in data.decode('utf-8', 'replace').splitlines() if line.startswith(('Pages:', 'Page size:', 'Title:', 'Author:')))
|
||||||
|
elif mime.startswith('text/') or Path(path).suffix.lower() in {'.json', '.yaml', '.yml', '.toml', '.md', '.py', '.js', '.ts', '.qml', '.sh', '.rs', '.go', '.log', '.csv'}:
|
||||||
|
# Nonblocking open avoids hanging on a file replaced with a FIFO.
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
with os.fdopen(fd, 'rb') as source:
|
||||||
|
if stat.S_ISREG(os.fstat(source.fileno()).st_mode):
|
||||||
|
data = source.read(8193)
|
||||||
|
if b'\0' not in data:
|
||||||
|
result['text'] = data[:8192].decode('utf-8', 'replace')
|
||||||
|
result['truncated'] = len(data) > 8192
|
||||||
|
except (OSError, ValueError, TypeError) as error:
|
||||||
|
result['error'] = str(error)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print(json.dumps({'request': sys.argv[1], 'file': preview(os.path.abspath(sys.argv[2]))}))
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Run all plugin logic tests. Requires node.
|
# Run all plugin logic tests. Requires node and python3.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
python3 -m unittest discover -s tests -p 'test_*.py'
|
||||||
exec node --test 'tests/*.mjs'
|
exec node --test 'tests/*.mjs'
|
||||||
|
|||||||
+21
-4
@@ -79,6 +79,21 @@ test("searchRows empty query sorts by recency", () => {
|
|||||||
assert.equal(res[0].row.content, "new")
|
assert.equal(res[0].row.content, "new")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("searchRows: always newest first, pins and pastes do not reorder", () => {
|
||||||
|
const now = 1000000
|
||||||
|
const rows = [
|
||||||
|
{ entry: {}, content: "pasted twice, 5 min ago", app: "", type: "text", ts: now - 300, pinned: false, uses: 2, bytes: 3 },
|
||||||
|
{ entry: {}, content: "just copied", app: "", type: "text", ts: now - 5, pinned: false, uses: 0, bytes: 3 },
|
||||||
|
{ entry: { pinned: true }, content: "pinned, old", app: "", type: "text", ts: now - 86400 * 3, pinned: true, uses: 0, bytes: 3 }
|
||||||
|
]
|
||||||
|
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||||
|
assert.deepEqual(Array.from(res, r => r.row.content), ["just copied", "pasted twice, 5 min ago", "pinned, old"])
|
||||||
|
const typed = Fuzzy.searchRows(rows, "type:text <1h", now, 10)
|
||||||
|
assert.deepEqual(Array.from(typed, r => r.row.content), ["just copied", "pasted twice, 5 min ago"])
|
||||||
|
const searched = Fuzzy.searchRows(rows, "past", now, 10)
|
||||||
|
assert.deepEqual(Array.from(searched, r => r.row.content), ["pasted twice, 5 min ago"])
|
||||||
|
})
|
||||||
|
|
||||||
test("searchRows fuzzy ranks match above recency when strong", () => {
|
test("searchRows fuzzy ranks match above recency when strong", () => {
|
||||||
const now = 1000000
|
const now = 1000000
|
||||||
const rows = [
|
const rows = [
|
||||||
@@ -122,14 +137,16 @@ test("searchRows matches app field", () => {
|
|||||||
assert.equal(res[0].row.app, "firefox")
|
assert.equal(res[0].row.app, "firefox")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("searchRows pinned boost wins", () => {
|
test("searchRows is:pinned filters without reordering", () => {
|
||||||
const now = 1000000
|
const now = 1000000
|
||||||
const rows = [
|
const rows = [
|
||||||
{ entry: { pinned: true }, content: "zzz pinned weak", app: "", type: "text", ts: now - 86400 * 10, pinned: true, uses: 0, bytes: 3 },
|
{ entry: { pinned: true }, content: "zzz pinned old", 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 }
|
{ entry: {}, content: "fresh", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||||
]
|
]
|
||||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
assert.equal(Fuzzy.searchRows(rows, "", now, 10)[0].row.content, "fresh")
|
||||||
assert.equal(res[0].row.content, "zzz pinned weak")
|
const pinned = Fuzzy.searchRows(rows, "is:pinned", now, 10)
|
||||||
|
assert.equal(pinned.length, 1)
|
||||||
|
assert.equal(pinned[0].row.content, "zzz pinned old")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("searchRows respects limit", () => {
|
test("searchRows respects limit", () => {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class WatcherInputTests(unittest.TestCase):
|
||||||
|
def helper(self, stdin, timeout=0.2):
|
||||||
|
return subprocess.Popen(
|
||||||
|
[sys.executable, "-c", """
|
||||||
|
import capture
|
||||||
|
import sys
|
||||||
|
import tracemalloc
|
||||||
|
tracemalloc.start()
|
||||||
|
drain = capture.drain_stdin
|
||||||
|
capture.drain_stdin = lambda: drain(timeout=float(sys.argv[1]))
|
||||||
|
capture.list_types = lambda: print('probed') or []
|
||||||
|
capture.main()
|
||||||
|
assert tracemalloc.get_traced_memory()[1] < 1024 * 1024
|
||||||
|
""", str(timeout)], stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def finish(self, proc, expected, payload=None):
|
||||||
|
try:
|
||||||
|
out, err = proc.communicate(input=payload, timeout=8)
|
||||||
|
self.assertEqual(proc.returncode, 0, err.decode())
|
||||||
|
self.assertEqual(out, expected)
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
def test_large_payload_is_discarded_without_accumulation(self):
|
||||||
|
with tempfile.TemporaryFile() as source:
|
||||||
|
source.truncate(64 * 1024 * 1024)
|
||||||
|
self.finish(self.helper(source, timeout=5), b"probed\n")
|
||||||
|
|
||||||
|
def test_finite_pipe_still_probes(self):
|
||||||
|
self.finish(self.helper(subprocess.PIPE, timeout=5), b"probed\n",
|
||||||
|
payload=b"clipboard content\n" * 16384)
|
||||||
|
|
||||||
|
def test_stalled_pipe_exits_without_probing(self):
|
||||||
|
reader, writer = os.pipe()
|
||||||
|
try:
|
||||||
|
with os.fdopen(reader, "rb") as source:
|
||||||
|
self.finish(self.helper(source), b"")
|
||||||
|
finally:
|
||||||
|
os.close(writer)
|
||||||
|
|
||||||
|
def test_endless_source_exits_without_probing(self):
|
||||||
|
with open('/dev/zero', 'rb') as source:
|
||||||
|
self.finish(self.helper(source), b"")
|
||||||
|
|
||||||
|
def test_empty_input_still_probes(self):
|
||||||
|
self.finish(self.helper(subprocess.DEVNULL), b"probed\n")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('file_preview', Path(__file__).resolve().parents[1] / 'scripts/file-preview.py')
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
|
||||||
|
class FilePreviewTests(unittest.TestCase):
|
||||||
|
def test_text_is_bounded_and_path_preserved(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / 'a " quoted.txt'
|
||||||
|
path.write_text('a' * 10000)
|
||||||
|
data = module.preview(str(path))
|
||||||
|
self.assertEqual(data['bytes'], 10000)
|
||||||
|
self.assertEqual(len(data['text']), 8192)
|
||||||
|
self.assertTrue(data['truncated'])
|
||||||
|
self.assertEqual(data['path'], str(path))
|
||||||
|
|
||||||
|
def test_missing_file_and_folder(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
self.assertEqual(module.preview(directory)['kind'], 'Folder')
|
||||||
|
self.assertIn('error', module.preview(directory + '/missing'))
|
||||||
|
|
||||||
|
def test_special_file_is_not_read(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = directory + '/fifo.txt'
|
||||||
|
os.mkfifo(path)
|
||||||
|
self.assertEqual(module.preview(path)['kind'], 'Special file')
|
||||||
|
|
||||||
|
def test_missing_media_tools_preserve_basic_metadata(self):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.mp4') as source:
|
||||||
|
with patch.object(module, 'read_bounded', return_value=None):
|
||||||
|
data = module.preview(source.name)
|
||||||
|
self.assertEqual(data['mime'], 'video/mp4')
|
||||||
|
self.assertIn('bytes', data)
|
||||||
|
self.assertNotIn('thumbnail', data)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
|
||||||
|
def test_video_resolution_duration_and_thumbnail(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = directory + '/clip.mkv'
|
||||||
|
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=160x90:d=1', '-c:v', 'ffv1', path], check=True, timeout=10)
|
||||||
|
data = module.preview(path)
|
||||||
|
self.assertEqual((data['width'], data['height']), (160, 90))
|
||||||
|
self.assertAlmostEqual(data['duration'], 1, places=1)
|
||||||
|
self.assertTrue(data['thumbnail'].startswith('data:image/jpeg;base64,'))
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
|
||||||
|
def test_audio_details(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = directory + '/audio.wav'
|
||||||
|
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'sine=duration=1', path], check=True, timeout=10)
|
||||||
|
data = module.preview(path)
|
||||||
|
self.assertAlmostEqual(data['duration'], 1, places=1)
|
||||||
|
self.assertIn('44100 Hz', data['audio'])
|
||||||
|
self.assertNotIn('thumbnail', data)
|
||||||
|
|
||||||
|
def test_generic_binary_not_shown_as_text(self):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.txt') as source:
|
||||||
|
source.write(b'abc\x00def')
|
||||||
|
source.flush()
|
||||||
|
self.assertNotIn('text', module.preview(source.name))
|
||||||
Reference in New Issue
Block a user