merge: import notif-vault sources with history
git-subtree-dir: notif-vault git-subtree-mainline:e3622e32d6git-subtree-split:050d43c2b9
This commit is contained in:
commit
19f84c6463
8 files changed
+386
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,78 @@
|
||||
import argparse
|
||||
import time
|
||||
|
||||
from notif_vault import storage
|
||||
|
||||
|
||||
def formatTimestamp(epochSeconds):
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(epochSeconds))
|
||||
|
||||
|
||||
def truncate(text, length):
|
||||
text = (text or "").replace("\n", " ").strip()
|
||||
return text if len(text) <= length else text[: length - 3] + "..."
|
||||
|
||||
|
||||
def printRows(rows):
|
||||
if not rows:
|
||||
print("(no notifications match)")
|
||||
return
|
||||
for notificationId, appName, summary, body, urgency, createdAt in rows:
|
||||
print(f"[{formatTimestamp(createdAt)}] {appName}: {truncate(summary, 60)} - {truncate(body, 80)}")
|
||||
|
||||
|
||||
def cmdList(args):
|
||||
rows = storage.queryNotifications(
|
||||
appName=args.app, search=args.search, sinceSeconds=parseSince(args.since), limit=args.limit
|
||||
)
|
||||
printRows(rows)
|
||||
|
||||
|
||||
def cmdTail(args):
|
||||
seenIds = set()
|
||||
print("watching for new notifications, ctrl-c to stop")
|
||||
while True:
|
||||
rows = storage.queryNotifications(limit=20)
|
||||
for row in reversed(rows):
|
||||
if row[0] not in seenIds:
|
||||
seenIds.add(row[0])
|
||||
printRows([row])
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def parseSince(sinceText):
|
||||
if not sinceText:
|
||||
return None
|
||||
unit = sinceText[-1]
|
||||
amount = float(sinceText[:-1])
|
||||
multiplier = {"m": 60, "h": 3600, "d": 86400}.get(unit)
|
||||
if multiplier is None:
|
||||
raise ValueError("since must look like 30m, 2h or 1d")
|
||||
return amount * multiplier
|
||||
|
||||
|
||||
def buildParser():
|
||||
parser = argparse.ArgumentParser(prog="notif-vault", description="searchable desktop notification history")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
listParser = subparsers.add_parser("list", help="list recent notifications")
|
||||
listParser.add_argument("--app", help="filter by app name substring")
|
||||
listParser.add_argument("--search", help="filter by text in summary/body")
|
||||
listParser.add_argument("--since", help="only show notifications newer than this, e.g. 30m, 2h, 1d")
|
||||
listParser.add_argument("--limit", type=int, default=50)
|
||||
listParser.set_defaults(func=cmdList)
|
||||
|
||||
tailParser = subparsers.add_parser("tail", help="live-follow new notifications")
|
||||
tailParser.set_defaults(func=cmdTail)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = buildParser()
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
configDir = Path.home() / ".config" / "notif-vault"
|
||||
configPath = configDir / "config.json"
|
||||
defaultConfigPath = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) / "config.default.json"
|
||||
|
||||
|
||||
def loadConfig():
|
||||
if configPath.exists():
|
||||
with open(configPath) as f:
|
||||
return json.load(f)
|
||||
with open(defaultConfigPath) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def ensureUserConfig():
|
||||
if configPath.exists():
|
||||
return
|
||||
configDir.mkdir(parents=True, exist_ok=True)
|
||||
with open(defaultConfigPath) as f:
|
||||
default = f.read()
|
||||
with open(configPath, "w") as f:
|
||||
f.write(default)
|
||||
@@ -0,0 +1,77 @@
|
||||
import fnmatch
|
||||
from datetime import datetime
|
||||
|
||||
import dbus
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
|
||||
from notif_vault import storage, webhook
|
||||
from notif_vault.config import ensureUserConfig, loadConfig
|
||||
|
||||
URGENCY_NAMES = {0: "low", 1: "normal", 2: "critical"}
|
||||
|
||||
|
||||
def isIgnored(appName, config):
|
||||
for pattern in config.get("ignoreApps", []):
|
||||
if fnmatch.fnmatch(appName, pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def isWithinDnd(config):
|
||||
dnd = config.get("doNotDisturb", {})
|
||||
if not dnd.get("enabled"):
|
||||
return False
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
start, end = dnd.get("start", "23:00"), dnd.get("end", "08:00")
|
||||
if start <= end:
|
||||
return start <= now < end
|
||||
return now >= start or now < end
|
||||
|
||||
|
||||
class NotifVaultDaemon:
|
||||
def __init__(self):
|
||||
self.config = loadConfig()
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
self.bus = dbus.SessionBus()
|
||||
self.bus.add_match_string_non_blocking(
|
||||
"eavesdrop=true,interface='org.freedesktop.Notifications',member='Notify'"
|
||||
)
|
||||
self.bus.add_message_filter(self.onMessage)
|
||||
|
||||
def onMessage(self, bus, message):
|
||||
if message.get_member() != "Notify" or message.get_interface() != "org.freedesktop.Notifications":
|
||||
return
|
||||
args = message.get_args_list()
|
||||
if len(args) < 8:
|
||||
return
|
||||
appName, _replacesId, icon, summary, body, _actions, hints, _expireTimeout = args[:8]
|
||||
appName = str(appName)
|
||||
if isIgnored(appName, self.config):
|
||||
return
|
||||
|
||||
urgency = int(hints.get("urgency", 1)) if hasattr(hints, "get") else 1
|
||||
storage.addNotification(appName, str(summary), str(body), urgency, str(icon))
|
||||
storage.trimHistory(self.config.get("maxHistory", 5000))
|
||||
notificationId = storage.lastInsertId()
|
||||
|
||||
if isWithinDnd(self.config) and not (
|
||||
self.config.get("doNotDisturb", {}).get("allowUrgent", True) and urgency == 2
|
||||
):
|
||||
return
|
||||
|
||||
def onWebhookDone(sentNames):
|
||||
if sentNames and notificationId:
|
||||
storage.markWebhookSent(notificationId, sentNames)
|
||||
|
||||
webhook.dispatchAsync(appName, str(summary), str(body), urgency, self.config, onDone=onWebhookDone)
|
||||
|
||||
|
||||
def main():
|
||||
ensureUserConfig()
|
||||
NotifVaultDaemon()
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
dbDir = Path.home() / ".local" / "share" / "notif-vault"
|
||||
dbPath = dbDir / "notifications.db"
|
||||
|
||||
|
||||
def connect():
|
||||
dbDir.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(dbPath)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
appName TEXT,
|
||||
summary TEXT,
|
||||
body TEXT,
|
||||
urgency INTEGER,
|
||||
icon TEXT,
|
||||
createdAt REAL,
|
||||
webhookSentTo TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute("CREATE INDEX IF NOT EXISTS idxCreatedAt ON notifications(createdAt)")
|
||||
connection.execute("CREATE INDEX IF NOT EXISTS idxAppName ON notifications(appName)")
|
||||
return connection
|
||||
|
||||
|
||||
def addNotification(appName, summary, body, urgency, icon):
|
||||
connection = connect()
|
||||
connection.execute(
|
||||
"INSERT INTO notifications (appName, summary, body, urgency, icon, createdAt) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(appName, summary, body, urgency, icon, time.time()),
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
|
||||
def markWebhookSent(notificationId, sentNames):
|
||||
connection = connect()
|
||||
connection.execute(
|
||||
"UPDATE notifications SET webhookSentTo = ? WHERE id = ?",
|
||||
(",".join(sentNames), notificationId),
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
|
||||
def trimHistory(maxHistory):
|
||||
connection = connect()
|
||||
connection.execute(
|
||||
"DELETE FROM notifications WHERE id NOT IN (SELECT id FROM notifications ORDER BY id DESC LIMIT ?)",
|
||||
(maxHistory,),
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
|
||||
def lastInsertId():
|
||||
connection = connect()
|
||||
row = connection.execute("SELECT id FROM notifications ORDER BY id DESC LIMIT 1").fetchone()
|
||||
connection.close()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def queryNotifications(appName=None, search=None, sinceSeconds=None, limit=50):
|
||||
connection = connect()
|
||||
clauses = []
|
||||
params = []
|
||||
if appName:
|
||||
clauses.append("appName LIKE ?")
|
||||
params.append(f"%{appName}%")
|
||||
if search:
|
||||
clauses.append("(summary LIKE ? OR body LIKE ?)")
|
||||
params.append(f"%{search}%")
|
||||
params.append(f"%{search}%")
|
||||
if sinceSeconds:
|
||||
clauses.append("createdAt >= ?")
|
||||
params.append(time.time() - sinceSeconds)
|
||||
whereClause = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
rows = connection.execute(
|
||||
f"SELECT id, appName, summary, body, urgency, createdAt FROM notifications {whereClause} ORDER BY id DESC LIMIT ?",
|
||||
(*params, limit),
|
||||
).fetchall()
|
||||
connection.close()
|
||||
return rows
|
||||
@@ -0,0 +1,61 @@
|
||||
import fnmatch
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
logDir = Path.home() / ".local" / "share" / "notif-vault"
|
||||
logPath = logDir / "webhook.log"
|
||||
|
||||
|
||||
def logLine(line):
|
||||
logDir.mkdir(parents=True, exist_ok=True)
|
||||
with open(logPath, "a") as logFile:
|
||||
logFile.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {line}\n")
|
||||
|
||||
|
||||
def matchingWebhooks(appName, urgency, config):
|
||||
matches = []
|
||||
for webhook in config.get("webhooks", []):
|
||||
if not webhook.get("enabled"):
|
||||
continue
|
||||
if urgency < webhook.get("minUrgency", 0):
|
||||
continue
|
||||
patterns = webhook.get("appPatterns", ["*"])
|
||||
if any(fnmatch.fnmatch(appName, pattern) for pattern in patterns):
|
||||
matches.append(webhook)
|
||||
return matches
|
||||
|
||||
|
||||
def sendWebhook(webhook, appName, summary, body):
|
||||
payload = {"appName": appName, "summary": summary, "body": body, "timestamp": time.time()}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers.update(webhook.get("headers", {}))
|
||||
request = urllib.request.Request(webhook["url"], data=data, headers=headers, method=webhook.get("method", "POST"))
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=webhook.get("timeoutSeconds", 4)) as response:
|
||||
logLine(f"webhook '{webhook.get('name')}' -> {response.status}")
|
||||
return True
|
||||
except Exception as sendError:
|
||||
logLine(f"webhook '{webhook.get('name')}' failed: {sendError}")
|
||||
return False
|
||||
|
||||
|
||||
def dispatchAsync(appName, summary, body, urgency, config, onDone=None):
|
||||
matches = matchingWebhooks(appName, urgency, config)
|
||||
if not matches:
|
||||
if onDone:
|
||||
onDone([])
|
||||
return
|
||||
|
||||
def worker():
|
||||
sentNames = []
|
||||
for webhook in matches:
|
||||
if sendWebhook(webhook, appName, summary, body):
|
||||
sentNames.append(webhook.get("name", webhook["url"]))
|
||||
if onDone:
|
||||
onDone(sentNames)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
Reference in new issue
Block a user