Add shell plugin model tests
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user