← 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()