Add shell plugin model tests

This commit is contained in:
David Heinemeier Hansson
2026-05-25 14:18:39 +02:00
parent 4277f5346b
commit 829c1fa4f7
61 changed files with 3236 additions and 1168 deletions
+3 -10
View File
@@ -3,6 +3,7 @@ import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Ui
import "NotificationLogic.js" as NotificationLogic
BarWidget {
id: root
@@ -29,19 +30,11 @@ BarWidget {
: null
function isChromiumDerived(app, appIcon) {
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
return NotificationLogic.isChromiumDerived(app, appIcon)
}
function sanitizeBody(s, app, appIcon) {
var text = String(s || "").replace(/<img[^>]*>/gi, "")
if (!isChromiumDerived(app, appIcon)) return text
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
return NotificationLogic.sanitizeBody(s, app, appIcon)
}
function notificationIconSource(icon) {
@@ -0,0 +1,178 @@
function isChromiumDerived(app, appIcon) {
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
function sanitizeBody(body, app, appIcon) {
var text = String(body || "").replace(/<img[^>]*>/gi, "")
if (!isChromiumDerived(app, appIcon)) return text
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
}
function summaryStartsWithGlyph(summary) {
var text = String(summary || "").replace(/^\s+/, "")
if (!text) return false
var offset = 1
var first = text.charCodeAt(0)
if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2
var spaces = 0
while (offset < text.length && text.charAt(offset) === " ") {
spaces++
offset++
}
return spaces >= 2
}
function shouldBypassDnd(notification, criticalUrgency) {
var appName = String((notification && notification.appName) || "")
if (appName === "omarchy-action") return true
return appName === "notify-send" && notification && notification.urgency === criticalUrgency
}
function glyphFromHints(hints) {
try {
if (hints) {
var glyph = hints["omarchy-glyph"]
if (glyph !== undefined && glyph !== null) return String(glyph)
}
} catch (e) {
}
return ""
}
function snapshotOf(notification, timestamp) {
var n = notification || {}
var id = n.id || 0
return {
id: id,
originalId: id,
app: n.appName || "",
appIcon: n.appIcon || "",
summary: String(n.summary || ""),
body: n.body || "",
image: n.image || "",
glyph: glyphFromHints(n.hints),
urgency: n.urgency,
timestamp: timestamp === undefined ? Date.now() : timestamp,
ref: notification
}
}
function historyEntry(value, normalUrgency) {
var e = value || {}
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
timestamp: e.timestamp || 0,
ref: null
}
}
function dedupeByOriginalId(rows) {
var values = Array.isArray(rows) ? rows : []
var keep = {}
for (var i = 0; i < values.length; i++) {
var row = values[i]
if (!row) continue
var key = row.originalId
if (key === undefined || key === null) key = "_" + i
var prior = keep[key]
if (!prior || (row.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = row
}
var out = []
for (var id in keep) out.push(keep[id])
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out
}
function parseHistory(raw, normalUrgency, historyCap) {
var text = String(raw || "").trim()
var cap = historyCap === undefined || historyCap === null ? 100 : Number(historyCap)
if (isNaN(cap)) cap = 100
cap = Math.max(0, cap)
if (!text) return { empty: true, error: false, dnd: null, pending: [], past: [], hadDuplicates: false }
try {
var parsed = JSON.parse(text)
var pendingRaw = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
var pastRaw = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
if (parsed && Array.isArray(parsed.entries)) pastRaw = pastRaw.concat(parsed.entries)
var pendingDeduped = dedupeByOriginalId(pendingRaw)
var pastDeduped = dedupeByOriginalId(pastRaw)
return {
empty: false,
error: false,
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
pending: pendingDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }),
past: pastDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }),
hadDuplicates: pendingDeduped.length !== pendingRaw.length || pastDeduped.length !== pastRaw.length
}
} catch (e) {
return { empty: false, error: true, errorMessage: String(e), dnd: null, pending: [], past: [], hadDuplicates: false }
}
}
function dumpRows(rows) {
var values = Array.isArray(rows) ? rows : []
var out = []
for (var i = 0; i < values.length; i++) {
var r = values[i]
if (!r) continue
out.push({
id: r.id,
originalId: r.originalId,
app: r.app,
appIcon: r.appIcon,
summary: r.summary,
body: r.body,
image: r.image,
glyph: r.glyph || "",
urgency: r.urgency,
timestamp: r.timestamp
})
}
return out
}
function imageExtension(srcPath) {
var lower = String(srcPath || "").toLowerCase()
var dot = lower.lastIndexOf(".")
if (dot < 0) return "png"
var ext = lower.substring(dot + 1)
if (ext.length === 0 || ext.length > 5) return "png"
return ext
}
if (typeof module !== "undefined") {
module.exports = {
isChromiumDerived: isChromiumDerived,
sanitizeBody: sanitizeBody,
summaryStartsWithGlyph: summaryStartsWithGlyph,
shouldBypassDnd: shouldBypassDnd,
glyphFromHints: glyphFromHints,
snapshotOf: snapshotOf,
historyEntry: historyEntry,
dedupeByOriginalId: dedupeByOriginalId,
parseHistory: parseHistory,
dumpRows: dumpRows,
imageExtension: imageExtension
}
}
+26 -102
View File
@@ -9,6 +9,7 @@ import Quickshell.Services.Notifications
import qs.Commons
import "components"
import "NotificationLogic.js" as NotificationLogic
Item {
id: service
@@ -112,36 +113,11 @@ Item {
// chat apps set app_name to their brand (Discord/Slack/Vesktop), which
// falls outside this rule.
function shouldBypassDnd(notification) {
var appName = String(notification.appName || "")
if (appName === "omarchy-action") return true
if (appName === "notify-send" && notification.urgency === NotificationUrgency.Critical) return true
return false
return NotificationLogic.shouldBypassDnd(notification, NotificationUrgency.Critical)
}
function snapshotOf(notification) {
var glyph = ""
try {
if (notification.hints) {
var hintGlyph = notification.hints["omarchy-glyph"]
if (hintGlyph !== undefined && hintGlyph !== null)
glyph = String(hintGlyph)
}
} catch (e) { glyph = "" }
var summary = String(notification.summary || "")
return {
id: notification.id,
originalId: notification.id,
app: notification.appName || "",
appIcon: notification.appIcon || "",
summary: summary,
body: notification.body || "",
image: notification.image || "",
glyph: glyph,
urgency: notification.urgency,
timestamp: Date.now(),
ref: notification
}
return NotificationLogic.snapshotOf(notification, Date.now())
}
function handleNotification(notification) {
@@ -384,12 +360,7 @@ Item {
// and skip them for v1.
function imageExtension(srcPath) {
var lower = srcPath.toLowerCase()
var dot = lower.lastIndexOf(".")
if (dot < 0) return "png"
var ext = lower.substring(dot + 1)
if (ext.length === 0 || ext.length > 5) return "png"
return ext
return NotificationLogic.imageExtension(srcPath)
}
function maybeCacheImage(snapshot) {
@@ -541,78 +512,31 @@ Item {
// guard, the second fire appends a second copy of every persisted row
// to the in-memory model.
if (service.historyLoaded) return
var text = String(raw || "").trim()
if (!text) { service.historyLoaded = true; return }
try {
var parsed = JSON.parse(text)
if (parsed && typeof parsed.dnd === "boolean") {
service._hydrating = true
persisted.doNotDisturb = parsed.dnd
service._hydrating = false
}
var pending = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
var past = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
// v1 backwards compat: the old schema had a single `entries` array.
// Treat all of those as past since the user already presumably saw
// them (and DND-suppressed notifications from before the split are
// a rare edge case).
if (parsed && Array.isArray(parsed.entries)) past = past.concat(parsed.entries)
function entryFor(e) {
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
urgency: typeof e.urgency === "number" ? e.urgency : NotificationUrgency.Normal,
timestamp: e.timestamp || 0,
ref: null
}
}
// Older builds didn't dedupe chat-app replacements, so hydrated files
// can hold hundreds of identical rows (same originalId). Collapse on
// load — keep the newest occurrence (highest timestamp) and drop the
// rest. Save is rescheduled below so the disk file rewrites cleanly.
function dedupeByOriginalId(rows) {
var keep = {}
for (var k = 0; k < rows.length; k++) {
var r = rows[k]
if (!r) continue
var key = r.originalId
if (key === undefined || key === null) { keep["_" + k] = r; continue }
var prior = keep[key]
if (!prior || (r.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = r
}
var out = []
for (var id in keep) out.push(keep[id])
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out
}
var pendingDeduped = dedupeByOriginalId(pending)
var pastDeduped = dedupeByOriginalId(past)
var hadDuplicates = pendingDeduped.length !== pending.length
|| pastDeduped.length !== past.length
// Newest-first on disk; insert in order so models match.
Qt.callLater(function() {
for (var i = 0; i < pendingDeduped.length; i++) {
pendingModel.append(entryFor(pendingDeduped[i]))
if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1)
}
for (var j = 0; j < pastDeduped.length; j++) {
pastModel.append(entryFor(pastDeduped[j]))
if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1)
}
service.historyLoaded = true
if (hadDuplicates) service.scheduleHistorySave()
})
} catch (e) {
console.warn("notifications: history parse failed:", e)
var parsed = NotificationLogic.parseHistory(raw, NotificationUrgency.Normal, service.historyCap)
if (parsed.empty) {
service.historyLoaded = true
return
}
if (parsed.error) {
console.warn("notifications: history parse failed:", parsed.errorMessage || "")
service.historyLoaded = true
return
}
if (parsed.dnd !== null) {
service._hydrating = true
persisted.doNotDisturb = parsed.dnd
service._hydrating = false
}
// Newest-first on disk; append in order so models match.
Qt.callLater(function() {
for (var i = 0; i < parsed.pending.length; i++) pendingModel.append(parsed.pending[i])
for (var j = 0; j < parsed.past.length; j++) pastModel.append(parsed.past[j])
service.historyLoaded = true
if (parsed.hadDuplicates) service.scheduleHistorySave()
})
}
function flushHistory() {
@@ -6,6 +6,7 @@ import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import "../NotificationLogic.js" as NotificationLogic
Rectangle {
id: root
@@ -37,15 +38,9 @@ Rectangle {
readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon)
readonly property bool hasGlyph: glyph.length > 0
readonly property bool hasSmallIcon: smallIconSource.length > 0 || hasGlyph
readonly property bool summaryStartsWithGlyph: /^\s*\S\s{2,}/.test(summary)
readonly property bool summaryStartsWithGlyph: NotificationLogic.summaryStartsWithGlyph(summary)
readonly property bool singleLineToast: sanitizedBody.length === 0
readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph
readonly property bool chromiumDerived: {
var source = (app + "\n" + appIcon).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
readonly property string sanitizedBody: sanitizeBody(body)
readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "<br/>")
@@ -54,15 +49,7 @@ Rectangle {
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
function sanitizeBody(s) {
var text = String(s).replace(/<img[^>]*>/gi, "")
if (!chromiumDerived) return text
// Chromium web notifications often prefix the body with the sending
// origin, sometimes as a hyperlink. The browser icon already identifies
// the source, so drop only that leading URL/domain.
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
return NotificationLogic.sanitizeBody(s, app, appIcon)
}
function iconSource(icon) {