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.
- privatecontext/__init__.py +3 -0
- privatecontext/capture.py +350 -0
- privatecontext/cli.py +228 -0
- privatecontext/dashboard.py +168 -0
- privatecontext/db.py +140 -0
- privatecontext/importers.py +572 -0
- privatecontext/mcp_server.py +123 -0
- privatecontext/schema.sql +53 -0
- privatecontext/summarize.py +260 -0
- privatecontext/tagging.py +83 -0
- privatecontext-0.1.0.dist-info/METADATA +269 -0
- privatecontext-0.1.0.dist-info/RECORD +16 -0
- privatecontext-0.1.0.dist-info/WHEEL +5 -0
- privatecontext-0.1.0.dist-info/entry_points.txt +2 -0
- privatecontext-0.1.0.dist-info/licenses/LICENSE +21 -0
- privatecontext-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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>◉ PrivateContext <small>v0 · local only</small></h1>
|
|
44
|
+
<p id="status">loading…</p>
|
|
45
|
+
<div>
|
|
46
|
+
<input id="q" placeholder="Search everything: servicenow, eduroam, zoom…" autocomplete="off">
|
|
47
|
+
<button onclick="doSearch()">Search</button>
|
|
48
|
+
</div>
|
|
49
|
+
<div id="results"></div>
|
|
50
|
+
<h2>Today — 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()">🗑 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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> — '+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}
|