401b46c886
Rules watch every notification for words and apps and answer with an effect: nudge, flash, banner, airplane, confetti, blink, sound, focus, or a command. Silenced notifications that match a rule are let through. Settings popup behind a bar bell, rules kept in one JSON file.
210 lines
6.6 KiB
JavaScript
210 lines
6.6 KiB
JavaScript
// Rule parsing and matching. Pure functions, no QML, so the whole thing can
|
|
// be exercised from a terminal: `qmltestrunner` is not needed, `node` is
|
|
// enough (see tests/rules.test.js).
|
|
// Not a .pragma library: the shell caches those across plugin reloads.
|
|
|
|
var DEFAULT_COOLDOWN = 3
|
|
var DEFAULT_FIELDS = ["summary", "body"]
|
|
var DEFAULT_EFFECTS = [{ type: "nudge" }]
|
|
|
|
function str(v) {
|
|
return v === undefined || v === null ? "" : String(v)
|
|
}
|
|
|
|
function list(v) {
|
|
if (Array.isArray(v)) return v.map(str).filter(function(x) { return x.length > 0 })
|
|
if (typeof v === "string" && v.length) return [v]
|
|
return []
|
|
}
|
|
|
|
// Notification bodies arrive as markup ("<a href=...>deliveroo.co.uk</a>").
|
|
// Matching is done on the words a person would read, not the tags.
|
|
function stripTags(s) {
|
|
return str(s)
|
|
.replace(/<[^>]*>/g, " ")
|
|
.replace(/ /g, " ")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, "\"")
|
|
.replace(/'/g, "'")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
}
|
|
|
|
// A word is a case-insensitive substring. Written as /.../ it is a regular
|
|
// expression instead, case-insensitive unless flags are given.
|
|
function compileWord(w) {
|
|
var m = /^\/(.+)\/([a-z]*)$/.exec(w)
|
|
if (m) {
|
|
var flags = m[2] || "i"
|
|
try {
|
|
return { regex: new RegExp(m[1], flags), source: w }
|
|
} catch (e) {
|
|
return { text: w.toLowerCase(), source: w, error: String(e) }
|
|
}
|
|
}
|
|
return { text: w.toLowerCase(), source: w }
|
|
}
|
|
|
|
function normalizeEffect(e) {
|
|
if (typeof e === "string") {
|
|
var name = e.trim().toLowerCase()
|
|
return name ? { type: name } : null
|
|
}
|
|
if (e && typeof e === "object" && e.type) {
|
|
var out = {}
|
|
for (var k in e) out[k] = e[k]
|
|
out.type = str(e.type).trim().toLowerCase()
|
|
return out.type ? out : null
|
|
}
|
|
return null
|
|
}
|
|
|
|
// No `effects` key at all means the nudge; an explicit empty list means
|
|
// the rule matches but nothing happens (a rule still being set up).
|
|
function normalizeEffects(raw) {
|
|
if (raw === undefined || raw === null) return DEFAULT_EFFECTS.map(function(d) { return { type: d.type } })
|
|
var items = Array.isArray(raw) ? raw : [raw]
|
|
var out = []
|
|
for (var i = 0; i < items.length; i++) {
|
|
var e = normalizeEffect(items[i])
|
|
if (e) out.push(e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function normalizeRule(raw, index) {
|
|
if (!raw || typeof raw !== "object") return null
|
|
var fields = list(raw.fields).map(function(f) { return f.toLowerCase() })
|
|
var cooldown = Number(raw.cooldown)
|
|
return {
|
|
name: str(raw.name).trim() || ("rule-" + (index + 1)),
|
|
enabled: raw.enabled !== false,
|
|
words: list(raw.words).map(compileWord),
|
|
match: str(raw.match).toLowerCase() === "all" ? "all" : "any",
|
|
apps: list(raw.apps).map(function(a) { return a.toLowerCase() }),
|
|
fields: fields.length ? fields : DEFAULT_FIELDS.slice(),
|
|
effects: normalizeEffects(raw.effects),
|
|
cooldown: isFinite(cooldown) && cooldown >= 0 ? cooldown : DEFAULT_COOLDOWN
|
|
}
|
|
}
|
|
|
|
// Top-level "defaults": options applied to every effect of that type unless
|
|
// the rule's own effect sets them. {"defaults": {"nudge": {"intensity": 10}}}
|
|
function normalizeDefaults(raw) {
|
|
var out = {}
|
|
if (!raw || typeof raw !== "object") return out
|
|
for (var type in raw) {
|
|
var opts = raw[type]
|
|
if (!opts || typeof opts !== "object") continue
|
|
var copy = {}
|
|
for (var k in opts) if (k !== "type") copy[k] = opts[k]
|
|
out[String(type).trim().toLowerCase()] = copy
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The effect as it will run: the defaults for its type under the options set
|
|
// on the rule.
|
|
function withDefaults(cfg, effect) {
|
|
if (!effect || !effect.type) return effect
|
|
var base = cfg && cfg.defaults ? cfg.defaults[effect.type] : null
|
|
if (!base) return effect
|
|
var out = {}
|
|
for (var d in base) out[d] = base[d]
|
|
for (var k in effect) out[k] = effect[k]
|
|
return out
|
|
}
|
|
|
|
function normalizeConfig(raw) {
|
|
var cfg = { whileDnd: true, letThrough: true, defaults: {}, rules: [] }
|
|
if (!raw || typeof raw !== "object") return cfg
|
|
cfg.whileDnd = raw.whileDnd !== false
|
|
// While silenced, a notification a rule matched is posted again so its
|
|
// toast shows: silence everything, let the rules through.
|
|
cfg.letThrough = raw.letThrough !== false
|
|
cfg.defaults = normalizeDefaults(raw.defaults)
|
|
var rules = Array.isArray(raw.rules) ? raw.rules : []
|
|
for (var i = 0; i < rules.length; i++) {
|
|
var r = normalizeRule(rules[i], i)
|
|
if (r) cfg.rules.push(r)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
function textFor(rule, n) {
|
|
var parts = []
|
|
for (var i = 0; i < rule.fields.length; i++) {
|
|
var f = rule.fields[i]
|
|
if (f === "summary" || f === "title") parts.push(str(n.summary))
|
|
else if (f === "body") parts.push(stripTags(n.body))
|
|
else if (f === "app") parts.push(str(n.app))
|
|
}
|
|
return parts.join("\n")
|
|
}
|
|
|
|
function appMatches(rule, app) {
|
|
if (!rule.apps.length) return true
|
|
var a = str(app).toLowerCase()
|
|
for (var i = 0; i < rule.apps.length; i++)
|
|
if (a.indexOf(rule.apps[i]) !== -1) return true
|
|
return false
|
|
}
|
|
|
|
function wordMatches(w, text, lower) {
|
|
return w.regex ? w.regex.test(text) : lower.indexOf(w.text) !== -1
|
|
}
|
|
|
|
// Empty means anything: no words matches every notification (from the apps
|
|
// given, or from every app when those are empty too).
|
|
function ruleMatches(rule, n) {
|
|
if (!rule.enabled) return false
|
|
if (!appMatches(rule, n.app)) return false
|
|
if (!rule.words.length) return true
|
|
var text = textFor(rule, n)
|
|
var lower = text.toLowerCase()
|
|
for (var i = 0; i < rule.words.length; i++) {
|
|
var hit = wordMatches(rule.words[i], text, lower)
|
|
if (hit && rule.match === "any") return true
|
|
if (!hit && rule.match === "all") return false
|
|
}
|
|
return rule.match === "all"
|
|
}
|
|
|
|
function matchingRules(cfg, n) {
|
|
var out = []
|
|
for (var i = 0; i < cfg.rules.length; i++)
|
|
if (ruleMatches(cfg.rules[i], n)) out.push(cfg.rules[i])
|
|
return out
|
|
}
|
|
|
|
// What `state` and `rules` report over IPC: the compiled rule without the
|
|
// RegExp objects, which do not survive JSON.stringify.
|
|
function describeRule(rule) {
|
|
return {
|
|
name: rule.name,
|
|
enabled: rule.enabled,
|
|
words: rule.words.map(function(w) { return w.source }),
|
|
match: rule.match,
|
|
apps: rule.apps,
|
|
fields: rule.fields,
|
|
effects: rule.effects,
|
|
cooldown: rule.cooldown
|
|
}
|
|
}
|
|
|
|
// "{summary}" style templates for effects that show text. An empty template
|
|
// is the summary.
|
|
function renderTemplate(tpl, notif, rule) {
|
|
var t = str(tpl).trim()
|
|
if (!t) t = "{summary}"
|
|
var map = {
|
|
app: str(notif ? notif.app : ""),
|
|
summary: str(notif ? notif.summary : ""),
|
|
body: stripTags(notif ? notif.body : ""),
|
|
rule: str(rule ? rule.name : "")
|
|
}
|
|
return t.replace(/\{(app|summary|body|rule)\}/g, function(m, k) { return map[k] })
|
|
}
|