privatecontext 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ """Local dashboard for PrivateContext (stdlib http.server only).
2
+
3
+ Single page on 127.0.0.1:18790. The page polls /api/timeline and /api/usage
4
+ every 2 seconds — live updates without WebSockets. Views: today's timeline
5
+ (reverse-chron), app-usage bars, FTS5 search, and a big Purge button.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import threading
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from urllib.parse import urlparse, parse_qs
13
+
14
+ from .db import app_usage, connect, init_db, search, timeline
15
+
16
+ PAGE = """<!doctype html>
17
+ <html><head><meta charset="utf-8">
18
+ <meta name="viewport" content="width=device-width,initial-scale=1">
19
+ <title>PrivateContext</title>
20
+ <style>
21
+ :root{color-scheme:dark}
22
+ body{font-family:system-ui,-apple-system,sans-serif;background:#141414;color:#e8e8e8;
23
+ max-width:920px;margin:0 auto;padding:24px;line-height:1.45}
24
+ h1 small{color:#888;font-weight:normal}
25
+ #status{color:#888;font-size:0.9em}
26
+ input#q{width:60%;padding:8px;font-size:1em;background:#222;color:#eee;border:1px solid #444;border-radius:6px}
27
+ button{padding:8px 14px;font-size:1em;border-radius:6px;border:1px solid #444;background:#2a2a2a;color:#eee;cursor:pointer}
28
+ button:hover{background:#333}
29
+ .ev{padding:6px 0;border-bottom:1px solid #222;font-size:0.95em}
30
+ .ev .t{color:#888;font-variant-numeric:tabular-nums}
31
+ .ev .ty{display:inline-block;min-width:52px;color:#9cf;font-size:0.85em}
32
+ .ev .c{color:#8a8;font-size:0.85em}
33
+ .bar{display:flex;align-items:center;gap:10px;margin:6px 0}
34
+ .bar .al{width:180px;text-align:right;color:#bbb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
35
+ .bar .track{flex:1;background:#222;border-radius:4px;height:14px}
36
+ .bar .fill{background:#4a9;height:14px;border-radius:4px}
37
+ .bar .v{width:60px;color:#888;font-size:0.9em}
38
+ a{color:#9cf}
39
+ #purge{background:#5a1d1d;border-color:#a33;margin-top:24px;font-size:1.1em;padding:12px 22px}
40
+ #purge:hover{background:#7a2626}
41
+ </style></head>
42
+ <body>
43
+ <h1>&#9673; PrivateContext <small>v0 &middot; local only</small></h1>
44
+ <p id="status">loading&hellip;</p>
45
+ <div>
46
+ <input id="q" placeholder="Search everything: servicenow, eduroam, zoom&hellip;" autocomplete="off">
47
+ <button onclick="doSearch()">Search</button>
48
+ </div>
49
+ <div id="results"></div>
50
+ <h2>Today &mdash; app usage</h2>
51
+ <div id="usage"></div>
52
+ <h2>Timeline (newest first)</h2>
53
+ <div id="timeline"></div>
54
+ <hr>
55
+ <button id="purge" onclick="doPurge()">&#128465; Purge all data</button>
56
+ <script>
57
+ async function j(p, opt){ const r = await fetch(p, opt); return r.json(); }
58
+ function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,
59
+ c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
60
+ function fmtT(ts){ return new Date(ts*1000).toLocaleTimeString(); }
61
+ function evHTML(e){
62
+ return '<div class="ev"><span class="t">'+fmtT(e.ts)+'</span> '+
63
+ '<span class="ty">'+esc(e.type)+'</span> '+
64
+ '<b>'+esc(e.app)+'</b> &mdash; '+esc(e.title)+
65
+ (e.url ? ' <a href="'+esc(e.url)+'">link</a>' : '')+
66
+ (e.tags ? ' <span class="c">['+esc(e.tags)+']</span>' : '')+
67
+ (e.x!=null ? ' <span class="c">click@'+e.x+','+e.y+'</span>' : '')+
68
+ (e.duration_ms!=null ? ' <span class="c">'+Math.round(e.duration_ms/1000)+'s idle</span>' : '')+
69
+ '</div>';
70
+ }
71
+ async function refresh(){
72
+ try{
73
+ const tl = await j('/api/timeline?limit=200');
74
+ const u = await j('/api/usage');
75
+ document.getElementById('timeline').innerHTML = tl.map(evHTML).join('');
76
+ const max = Math.max(1, ...u.map(x=>x[1]));
77
+ document.getElementById('usage').innerHTML = u.map(pair =>
78
+ '<div class="bar"><span class="al">'+esc(pair[0])+'</span>'+
79
+ '<div class="track"><div class="fill" style="width:'+(100*pair[1]/max).toFixed(1)+'%"></div></div>'+
80
+ '<span class="v">'+(pair[1]/60).toFixed(0)+'m</span></div>').join('');
81
+ document.getElementById('status').textContent =
82
+ tl.length+' events today · updated '+new Date().toLocaleTimeString()+' (auto-refreshes every 2s)';
83
+ }catch(e){ document.getElementById('status').textContent = 'error: '+e; }
84
+ }
85
+ async function doSearch(){
86
+ const q = document.getElementById('q').value;
87
+ const r = await j('/api/search?q='+encodeURIComponent(q));
88
+ document.getElementById('results').innerHTML =
89
+ '<h3>'+r.length+' result'+(r.length==1?'':'s')+'</h3>'+r.map(evHTML).join('');
90
+ }
91
+ async function doPurge(){
92
+ if(!confirm('Delete ALL captured data? This cannot be undone.')) return;
93
+ const r = await j('/api/purge', {method:'POST'});
94
+ alert('Deleted '+r.deleted+' events.');
95
+ document.getElementById('results').innerHTML='';
96
+ refresh();
97
+ }
98
+ document.getElementById('q').addEventListener('keydown', e=>{ if(e.key==='Enter') doSearch(); });
99
+ setInterval(refresh, 2000);
100
+ refresh();
101
+ </script>
102
+ </body></html>
103
+ """
104
+
105
+
106
+ class Handler(BaseHTTPRequestHandler):
107
+ server_version = "PrivateContext/0.1"
108
+
109
+ def _json(self, obj, code: int = 200) -> None:
110
+ body = json.dumps(obj).encode()
111
+ self.send_response(code)
112
+ self.send_header("Content-Type", "application/json")
113
+ self.send_header("Content-Length", str(len(body)))
114
+ self.end_headers()
115
+ self.wfile.write(body)
116
+
117
+ def _with_db(self, fn):
118
+ with self.server.lock:
119
+ return fn(self.server.db)
120
+
121
+ def do_GET(self) -> None:
122
+ u = urlparse(self.path)
123
+ if u.path == "/":
124
+ body = PAGE.encode()
125
+ self.send_response(200)
126
+ self.send_header("Content-Type", "text/html; charset=utf-8")
127
+ self.send_header("Content-Length", str(len(body)))
128
+ self.end_headers()
129
+ self.wfile.write(body)
130
+ elif u.path == "/api/timeline":
131
+ qs = parse_qs(u.query)
132
+ limit = int(qs.get("limit", ["200"])[0])
133
+ self._json(self._with_db(lambda db: timeline(db, min(limit, 1000))))
134
+ elif u.path == "/api/usage":
135
+ self._json(self._with_db(app_usage))
136
+ elif u.path == "/api/search":
137
+ q = parse_qs(u.query).get("q", [""])[0]
138
+ self._json(self._with_db(lambda db: search(db, q)))
139
+ else:
140
+ self._json({"error": "not found"}, 404)
141
+
142
+ def do_POST(self) -> None:
143
+ if urlparse(self.path).path == "/api/purge":
144
+ def _purge(db):
145
+ n = db.execute("DELETE FROM events").rowcount
146
+ db.execute("DELETE FROM days")
147
+ db.commit()
148
+ db.execute("VACUUM")
149
+ return {"deleted": n}
150
+ self._json(self._with_db(_purge))
151
+ else:
152
+ self._json({"error": "not found"}, 404)
153
+
154
+ def log_message(self, *args) -> None: # keep the console quiet
155
+ pass
156
+
157
+
158
+ def serve(db_path: str | None = None, port: int = 18790) -> None:
159
+ db = connect(db_path)
160
+ init_db(db)
161
+ server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
162
+ server.db = db # type: ignore[attr-defined]
163
+ server.lock = threading.Lock() # type: ignore[attr-defined]
164
+ print(f"[privatecontext] dashboard at http://127.0.0.1:{port} — Ctrl-C to stop")
165
+ try:
166
+ server.serve_forever()
167
+ except KeyboardInterrupt:
168
+ print("\n[privatecontext] dashboard stopped.")
privatecontext/db.py ADDED
@@ -0,0 +1,140 @@
1
+ """SQLite helpers for PrivateContext: connect, init, insert, search, usage."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sqlite3
6
+ import time
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from .tagging import extract_tags
11
+
12
+ APP_NAME = "privatecontext"
13
+ DB_NAME = "context.db"
14
+
15
+
16
+ def data_dir() -> Path:
17
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
18
+ d = Path(base) / APP_NAME
19
+ d.mkdir(parents=True, exist_ok=True)
20
+ return d
21
+
22
+
23
+ def default_db_path() -> str:
24
+ return str(data_dir() / DB_NAME)
25
+
26
+
27
+ def _schema_text() -> str:
28
+ here = Path(__file__).resolve()
29
+ for p in (here.parent / "schema.sql", here.parent.parent / "schema.sql"):
30
+ if p.exists():
31
+ return p.read_text()
32
+ raise FileNotFoundError("schema.sql not found next to package or repo root")
33
+
34
+
35
+ def connect(path: str | None = None) -> sqlite3.Connection:
36
+ """Open the DB, creating parent dirs. Safe to share across threads."""
37
+ p = Path(path) if path else Path(default_db_path())
38
+ p.parent.mkdir(parents=True, exist_ok=True)
39
+ con = sqlite3.connect(str(p), check_same_thread=False)
40
+ con.row_factory = sqlite3.Row
41
+ return con
42
+
43
+
44
+ def init_db(con: sqlite3.Connection) -> None:
45
+ con.executescript(_schema_text())
46
+ # Migration for databases created before the tags column existed.
47
+ cols = [r[1] for r in con.execute("PRAGMA table_info(events)")]
48
+ if "tags" not in cols:
49
+ con.execute("ALTER TABLE events ADD COLUMN tags TEXT")
50
+ con.commit()
51
+
52
+
53
+ def strip_url(url: str | None) -> str | None:
54
+ """Strip query params and fragments. Never store tracking junk."""
55
+ if not url:
56
+ return url
57
+ return url.split("#", 1)[0].split("?", 1)[0]
58
+
59
+
60
+ def log_event(con: sqlite3.Connection, type: str, ts: float | None = None,
61
+ app: str | None = None, title: str | None = None,
62
+ url: str | None = None, x: int | None = None,
63
+ y: int | None = None, duration_ms: int | None = None,
64
+ tags: str | list | None = None,
65
+ commit: bool = True) -> int:
66
+ if tags is None:
67
+ found = extract_tags(app, title, url)
68
+ tag_str = ",".join(found) if found else None
69
+ elif isinstance(tags, (list, tuple)):
70
+ tag_str = ",".join(tags) if tags else None
71
+ else:
72
+ tag_str = tags or None
73
+ cur = con.execute(
74
+ "INSERT INTO events(ts, type, app, title, url, x, y, duration_ms, tags)"
75
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
76
+ (ts if ts is not None else time.time(), type, app, title,
77
+ strip_url(url), x, y, duration_ms, tag_str),
78
+ )
79
+ if commit:
80
+ con.commit()
81
+ return cur.lastrowid
82
+
83
+
84
+ def today_range() -> tuple[float, float]:
85
+ now = datetime.now().astimezone()
86
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0)
87
+ return start.timestamp(), now.timestamp()
88
+
89
+
90
+ def timeline(con: sqlite3.Connection, limit: int = 200) -> list[dict]:
91
+ start, _ = today_range()
92
+ rows = con.execute(
93
+ "SELECT id, ts, type, app, title, url, x, y, duration_ms, tags FROM events"
94
+ " WHERE ts >= ? ORDER BY ts DESC LIMIT ?",
95
+ (start, limit),
96
+ ).fetchall()
97
+ return [dict(r) for r in rows]
98
+
99
+
100
+ def app_usage(con: sqlite3.Connection, cap_s: float = 600) -> list[tuple[str, float]]:
101
+ """Seconds per app today. Each window event owns time until the next
102
+ event, capped so a forgotten window doesn't claim the whole afternoon."""
103
+ start, _ = today_range()
104
+ rows = con.execute(
105
+ "SELECT ts, app FROM events WHERE type = 'window' AND ts >= ? ORDER BY ts",
106
+ (start,),
107
+ ).fetchall()
108
+ usage: dict[str, float] = {}
109
+ now = time.time()
110
+ for i, r in enumerate(rows):
111
+ nxt = rows[i + 1]["ts"] if i + 1 < len(rows) else now
112
+ dur = min(max(0.0, nxt - r["ts"]), cap_s)
113
+ app = r["app"] or "unknown"
114
+ usage[app] = usage.get(app, 0.0) + dur
115
+ return sorted(usage.items(), key=lambda kv: -kv[1])
116
+
117
+
118
+ def _fts_query(q: str) -> str:
119
+ # Quote each token so user punctuation can't break FTS5 syntax.
120
+ toks = [t for t in q.split() if t]
121
+ if not toks:
122
+ return '""'
123
+ return " ".join('"' + t.replace('"', '""') + '"' for t in toks)
124
+
125
+
126
+ def search(con: sqlite3.Connection, q: str, limit: int = 50) -> list[dict]:
127
+ if not q.strip():
128
+ return []
129
+ rows = con.execute(
130
+ "SELECT e.id, e.ts, e.type, e.app, e.title, e.url"
131
+ " FROM events_fts f JOIN events e ON e.id = f.rowid"
132
+ " WHERE events_fts MATCH ? ORDER BY rank LIMIT ?",
133
+ (_fts_query(q), limit),
134
+ ).fetchall()
135
+ return [dict(r) for r in rows]
136
+
137
+
138
+ def counts(con: sqlite3.Connection) -> dict:
139
+ rows = con.execute("SELECT type, COUNT(*) AS n FROM events GROUP BY type").fetchall()
140
+ return {r["type"]: r["n"] for r in rows}