← 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> &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", "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()