← back to Opened Tagger

tag-opened.py

144 lines

#!/usr/bin/env python3
"""
tag-opened.py — Mark everything you've ever opened/launched with the blue Finder tag.

How it works
------------
macOS Spotlight records `kMDItemUseCount` (times opened) on every indexed file and
app bundle. We ask Spotlight (via `mdfind`) for items whose use-count > 0, then add
the system "Blue" tag to each. Untouched items stay untagged, so in any Finder
window the blue dot = "I've opened this before", no dot = "never touched".

No third-party installs: uses the built-in `mdfind` and `xattr` commands plus the
Python stdlib `plistlib`. Tags are written into com.apple.metadata:_kMDItemUserTags,
exactly where Finder stores them.

Existing tags are PRESERVED — Blue is appended only if not already present.

Usage
-----
  python3 tag-opened.py            # tag opened items in the default surfaces
  python3 tag-opened.py --dry-run  # show what WOULD be tagged, change nothing
  python3 tag-opened.py --untag    # remove the Blue tag everywhere we'd add it
  python3 tag-opened.py --dirs ~/Desktop ~/Projects   # custom surfaces
"""

import argparse
import os
import plistlib
import subprocess
import sys

TAG = "Blue\n4"          # Finder tag name + color index (4 == blue)
TAG_NAME = "Blue"
XATTR = "com.apple.metadata:_kMDItemUserTags"

# Whole-Mac scope: a single global Spotlight query for everything opened,
# minus the system-noise paths below. Pass --dirs to restrict to specific
# folders instead.
DEFAULT_DIRS = None   # None == scope the entire computer

# Paths we never tag — app-managed files a human never actually "opens".
EXCLUDE_SUBSTR = (
    "/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=120).stdout
    except subprocess.TimeoutExpired:
        return []
    return [l for l in out.splitlines() if l]


def read_tags(path):
    """Return the current list of Finder tags on a file (empty if none)."""
    r = subprocess.run(["xattr", "-px", XATTR, path], capture_output=True, text=True)
    if r.returncode != 0 or not r.stdout.strip():
        return []
    try:
        raw = bytes.fromhex(r.stdout.replace("\n", "").replace(" ", ""))
        val = plistlib.loads(raw)
        return val if isinstance(val, list) else []
    except Exception:
        return []


def write_tags(path, tags):
    blob = plistlib.dumps(tags, fmt=plistlib.FMT_BINARY).hex()
    subprocess.run(["xattr", "-wx", XATTR, blob, path], capture_output=True)


def has_blue(tags):
    # A tag may be stored as "Blue\n4" or bare "Blue"; match either.
    return any(t.split("\n")[0] == TAG_NAME for t in tags)


def gather_targets(dirs):
    seen = set()
    if dirs is None:
        # Whole-computer scope: one global query for everything opened.
        for p in mdfind("kMDItemUseCount > 0"):
            if any(s in p for s in EXCLUDE_SUBSTR):
                continue
            seen.add(p)
    else:
        # Restricted scope: query each named surface.
        for d in dirs:
            if not os.path.isdir(d):
                continue
            for p in mdfind("kMDItemUseCount > 0", only_in=d):
                if any(s in p for s in EXCLUDE_SUBSTR):
                    continue
                seen.add(p)
    # Launched apps, anywhere on the Mac (apps live under /Library so the
    # exclusion list would otherwise drop them — add them explicitly).
    for p in mdfind('kMDItemContentType == "com.apple.application-bundle" '
                    '&& kMDItemUseCount > 0'):
        seen.add(p)
    return sorted(seen)


def main():
    ap = argparse.ArgumentParser(description="Blue-tag everything you've opened.")
    ap.add_argument("--dry-run", action="store_true",
                    help="list targets, change nothing")
    ap.add_argument("--untag", action="store_true",
                    help="remove the Blue tag from the same targets")
    ap.add_argument("--dirs", nargs="*", default=None,
                    help="override the default surfaces")
    args = ap.parse_args()

    dirs = [os.path.expanduser(d) for d in args.dirs] if args.dirs else DEFAULT_DIRS
    targets = gather_targets(dirs)

    changed = 0
    for p in targets:
        tags = read_tags(p)
        if args.untag:
            if has_blue(tags):
                if not args.dry_run:
                    write_tags(p, [t for t in tags if t.split("\n")[0] != TAG_NAME])
                changed += 1
                print(("WOULD UNTAG " if args.dry_run else "untagged ") + p)
        else:
            if not has_blue(tags):
                if not args.dry_run:
                    write_tags(p, tags + [TAG])
                changed += 1
                print(("WOULD TAG " if args.dry_run else "tagged ") + p)

    verb = "would change" if args.dry_run else ("untagged" if args.untag else "tagged")
    print(f"\n{len(targets)} opened items found · {changed} {verb}.", file=sys.stderr)


if __name__ == "__main__":
    main()