merge: import notif-vault sources with history

git-subtree-dir: notif-vault
git-subtree-mainline: e3622e32d6
git-subtree-split: 050d43c2b9
This commit is contained in:
dualfroz committed 2026-08-05 19:46:07 +02:00
commit 19f84c6463
8 files changed
+386

No files matched your search

View File
Whitespace-only changes.
+78
View File
@@ -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()
+25
View File
@@ -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)
+77
View File
@@ -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()
+88
View File
@@ -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
+61
View File
@@ -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()