← back to Opened Tagger
viewer.py
221 lines
#!/usr/bin/env python3
"""
viewer.py — the literal light-blue-text version of opened-tagger.
Finder can only show a colored dot for tags, never recolored filename text.
This is the workaround: a tiny local web page where WE control every pixel, so
opened items render in light blue and never-opened items render bold white.
Same data source as the tagger: Spotlight's kMDItemUseCount (times opened) and
kMDItemLastUsedDate (when). Pure stdlib http.server — nothing to install.
Run:
python3 viewer.py # serves on a free port, prints the URL
python3 viewer.py --port 8765
Then open the printed http://127.0.0.1:PORT/ in a browser.
"""
import argparse
import html
import json
import os
import socket
import subprocess
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Whole-computer scope. Known surfaces get their own group; everything else
# opened anywhere on the Mac lands in an "Elsewhere" group, grouped by parent.
SURFACES = {
"Desktop": os.path.expanduser("~/Desktop"),
"Documents": os.path.expanduser("~/Documents"),
"Downloads": os.path.expanduser("~/Downloads"),
"Projects": os.path.expanduser("~/Projects"),
}
EXCLUDE = (
"/node_modules/", "/.git/", "/.Trash/",
"/Library/", "/Caches/", "/Application Support/",
"/.npm/", "/.cache/", "/Pictures/Photos Library.photoslibrary/",
)
def mdfind(query, only_in=None):
cmd = ["mdfind"]
if only_in:
cmd += ["-onlyin", only_in]
cmd.append(query)
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=60).stdout
except subprocess.TimeoutExpired:
return []
return [l for l in out.splitlines() if l]
def attrs(path):
"""Return (use_count, last_used_iso_or_None) for a path via mdls."""
r = subprocess.run(
["mdls", "-name", "kMDItemUseCount", "-name", "kMDItemLastUsedDate",
"-raw", path],
capture_output=True, text=True)
parts = r.stdout.split("\0") if "\0" in r.stdout else r.stdout.splitlines()
# -raw with two attrs returns them null-separated; fall back gracefully.
use, last = 0, None
raw = r.stdout
# mdls -raw multi-attr is finicky; query individually for reliability.
u = subprocess.run(["mdls", "-name", "kMDItemUseCount", "-raw", path],
capture_output=True, text=True).stdout.strip()
d = subprocess.run(["mdls", "-name", "kMDItemLastUsedDate", "-raw", path],
capture_output=True, text=True).stdout.strip()
if u.isdigit():
use = int(u)
if d and d != "(null)":
last = d
return use, last
def collect():
rows = []
for label, d in SURFACES.items():
if not os.path.isdir(d):
continue
# Every direct child of the surface (files + folders), opened or not.
try:
children = sorted(os.listdir(d))
except OSError:
continue
for name in children:
if name.startswith("."):
continue
p = os.path.join(d, name)
if any(s in p for s in EXCLUDE):
continue
use, last = attrs(p)
rows.append({
"surface": label, "name": name, "path": p,
"opened": use > 0, "use": use, "last": last,
})
# Launched apps, Mac-wide.
for p in mdfind('kMDItemContentType == "com.apple.application-bundle" '
'&& kMDItemUseCount > 0'):
use, last = attrs(p)
rows.append({
"surface": "Apps", "name": os.path.basename(p), "path": p,
"opened": True, "use": use, "last": last,
})
# Everything else opened anywhere on the Mac (whole-computer scope) that
# isn't already covered by a known surface or the apps query.
known = set(SURFACES.values())
have = {r["path"] for r in rows}
for p in mdfind("kMDItemUseCount > 0"):
if p in have or any(s in p for s in EXCLUDE) or p.endswith(".app"):
continue
if any(p.startswith(k + os.sep) for k in known):
continue # already shown under its surface group
use, last = attrs(p)
rows.append({
"surface": "Elsewhere", "name": os.path.basename(p), "path": p,
"opened": use > 0, "use": use, "last": last,
})
return rows
PAGE = """<!doctype html><html><head><meta charset=utf-8>
<title>Opened / Unopened</title><style>
body{{background:#0b0d12;color:#fff;font:14px -apple-system,Helvetica,sans-serif;margin:0;padding:24px}}
h1{{font-weight:600;font-size:18px;margin:0 0 4px}}
.sub{{color:#8a93a6;margin:0 0 20px}}
.legend{{margin:0 0 18px;color:#8a93a6}}
.legend b{{color:#fff;font-weight:700}} .legend i{{color:#7fd4ff;font-style:normal}}
.surface{{font-weight:600;color:#aab3c5;margin:22px 0 6px;border-bottom:1px solid #1d2230;padding-bottom:4px}}
.row{{padding:3px 0;display:flex;gap:12px;align-items:baseline}}
.opened{{color:#7fd4ff}} /* light blue = you've opened it */
.unopened{{color:#fff;font-weight:700}} /* bold white = never touched */
.meta{{color:#5b6377;font-size:12px;margin-left:auto;white-space:nowrap}}
input{{background:#161a24;border:1px solid #29303f;color:#fff;border-radius:6px;
padding:7px 10px;width:280px;margin-bottom:8px}}
</style></head><body>
<h1>Opened / Unopened</h1>
<p class=sub>Light blue = you've opened it before · bold white = never touched. Live from Spotlight.</p>
<input id=q placeholder="filter…" oninput="filt()">
<p class=legend><i>opened</i> · <b>never opened</b></p>
<div id=list>{body}</div>
<script>
function filt(){{var v=document.getElementById('q').value.toLowerCase();
document.querySelectorAll('.row').forEach(function(r){{
r.style.display=r.dataset.n.indexOf(v)>-1?'flex':'none';}});}}
</script></body></html>"""
def fmt_last(iso):
if not iso:
return ""
try:
# mdls gives e.g. 2026-06-24 20:41:27 +0000
dt = datetime.strptime(iso[:19], "%Y-%m-%d %H:%M:%S")
return dt.strftime("%b %-d, %Y %-I:%M %p")
except Exception:
return iso
def render(rows):
by_surface = {}
for r in rows:
by_surface.setdefault(r["surface"], []).append(r)
out = []
order = ["Desktop", "Documents", "Downloads", "Projects", "Apps", "Elsewhere"]
for s in order:
items = by_surface.get(s)
if not items:
continue
# opened first (blue), then unopened (white); each alphabetical.
items.sort(key=lambda r: (not r["opened"], r["name"].lower()))
op = sum(1 for r in items if r["opened"])
out.append(f'<div class=surface>{html.escape(s)} '
f'<span style="color:#5b6377;font-weight:400">'
f'({op} opened / {len(items)} total)</span></div>')
for r in items:
cls = "opened" if r["opened"] else "unopened"
meta = ""
if r["opened"]:
last = fmt_last(r["last"])
meta = f'opened {r["use"]}× · {last}' if last else f'opened {r["use"]}×'
else:
meta = "never opened"
nm = html.escape(r["name"])
out.append(
f'<div class=row data-n="{html.escape(r["name"].lower())}">'
f'<span class={cls}>{nm}</span>'
f'<span class=meta>{html.escape(meta)}</span></div>')
return "\n".join(out)
class H(BaseHTTPRequestHandler):
def do_GET(self):
body = render(collect())
page = PAGE.format(body=body)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(page.encode())
def log_message(self, *a):
pass
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=0)
args = ap.parse_args()
port = args.port or free_port()
srv = ThreadingHTTPServer(("127.0.0.1", port), H)
print(f"Opened/Unopened viewer → http://127.0.0.1:{port}/")
srv.serve_forever()