[object Object]

← back to Opened Tagger

add light-blue-text web viewer (opened=blue, unopened=bold white)

bc8dd097a3e2d074c0564eedff696c6cd7b43203 · 2026-06-24 14:10:31 -0700 · Steve

Files touched

Diff

commit bc8dd097a3e2d074c0564eedff696c6cd7b43203
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 24 14:10:31 2026 -0700

    add light-blue-text web viewer (opened=blue, unopened=bold white)
---
 .gitignore |   1 +
 README.md  |  13 ++++
 viewer.py  | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 214 insertions(+)

diff --git a/.gitignore b/.gitignore
index 05de3c6..1ce4f85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 tagger.log
 .DS_Store
+v2.out
diff --git a/README.md b/README.md
index 8749f40..b1ea4ea 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,19 @@ Disable later:
 launchctl bootout gui/$(id -u)/com.steve.opened-tagger
 ```
 
+## Literal light-blue TEXT (the viewer)
+
+Finder only does the dot. For actual blue *text*, run the viewer — a local web
+page where opened items render in **light blue** and never-opened in **bold white**:
+
+```sh
+python3 ~/Projects/opened-tagger/viewer.py    # prints a http://127.0.0.1:PORT/ URL
+```
+
+Open the printed URL. Live from Spotlight, has a filter box, groups by surface
+(Desktop / Documents / Downloads / Projects / Apps) with opened-count per group.
+It's a separate window, not the real Finder — that's the tradeoff for true text color.
+
 ## Known limitations (honest)
 
 - **Blue dot, not blue text.** Finder renders tags as a colored dot/pill next to
diff --git a/viewer.py b/viewer.py
new file mode 100644
index 0000000..50e803e
--- /dev/null
+++ b/viewer.py
@@ -0,0 +1,200 @@
+#!/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
+
+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/", "/Library/Caches/", "/.Trash/")
+
+
+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,
+        })
+    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> &nbsp;·&nbsp; <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"]
+    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()

← a25520e opened-tagger: blue Finder tag for everything opened/launche  ·  back to Opened Tagger  ·  scope to entire computer (global mdfind + noise filter); add 7b12d6c →