Files
upstream-activitywatch/scripts/emit_research_category_preset.py
upstream-mirror c5c5af8cde
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 22:29:06 +05:00

165 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Emit the Research Edition category preset consumed by aw-webui at build time.
The approved study contract keeps application names while the watcher replaces
browser titles with study categories and removes every URL. aw-webui categorises
client-side, so this preset matches both stored browser category labels and the
known raw application-name aliases. Top Applications can therefore show Word,
Spotify, and Teams while Top Categories still uses the study taxonomy.
aw-webui (ActivityWatch/aw-webui#936) reads a preset category set from the
`AW_PRESET_CATEGORY_SETS` env var at build time. This script derives that
preset from the same taxonomy source as the watcher build patch, so the two can
never drift:
python3 scripts/emit_research_category_preset.py > preset.json
Rules are exact, case-insensitive matches. aw-webui applies every category rule
to `app` and `title`, and the oldest web UI pinned by the release carriers drops
unknown per-rule metadata, so the preset cannot rely on field or priority keys.
Each category carries a `data.color` so the Activity view is not unstyled
(ActivityWatch/activitywatch#1439). Explicitly excluded app aliases map to
`Excluded`; unknown applications remain `Uncategorized` instead of overlapping
every specific rule with a catch-all.
"""
import importlib.util
import json
import pathlib
import sys
# Characters that are special to BOTH Python's `re` and JavaScript's RegExp
# outside a character class. Escaping is deliberately restricted to these.
#
# `re.escape()` is not usable here: it escapes space as `\ ` and `&` as `\&`,
# which are *invalid identity escapes* in JavaScript unicode-mode regex. Names
# like "AI Chatbots & Assistants" then throw `Invalid escape` under `new
# RegExp(r, "u")` -- 14 of the 18 study categories do. They happen to compile
# today only because aw-webui builds the regex without the `u` flag, and the
# failure would present as "categories don't show", i.e. indistinguishable from
# the bug this preset exists to fix. Escape only what both engines agree on.
_REGEX_METACHARACTERS = set(r"\^$.|?*+()[]{}")
PRESET_ID = "research-study"
PRESET_NAME = "Research Edition study categories"
# Qualitative palette for the study taxonomy. aw-webui only colors a category
# when `data.color` is set — there is no name-hash fallback for categories —
# so omitting this is what made the research-study set render grey.
# Keys must stay in lockstep with CATEGORY_MAP ∪ APP_CATEGORY_MAP; build_preset
# raises if a category is missing.
CATEGORY_COLORS: dict[str, str] = {
"AI Chatbots & Assistants": "#7B1FA2",
"Banking & Finance": "#1B5E20",
"Education & Learning": "#1565C0",
"Email": "#00838F",
"Excluded": "#BDBDBD",
"Games": "#EF6C00",
"Messaging": "#00897B",
"Music & Audio": "#7CB342",
"News & Current Affairs": "#C62828",
"Public Services": "#455A64",
"Search & Navigation": "#5C6BC0",
"Sensitive / Excluded": "#757575",
"Shopping - Goods": "#6D4C41",
"Shopping - Groceries & Food": "#F9A825",
"Social Networking": "#AD1457",
"Travel & Mobility": "#0277BD",
"Video Streaming": "#E53935",
"Work & Productivity": "#2E7D32",
}
_PATCHER = pathlib.Path(__file__).with_name("patch_research_edition_config.py")
def _load_category_source():
"""Import the patch script without executing its CLI entry point."""
spec = importlib.util.spec_from_file_location("_re_patcher", _PATCHER)
if spec is None or spec.loader is None:
raise RuntimeError(f"could not load {_PATCHER}")
module = importlib.util.module_from_spec(spec)
sys.modules["_re_patcher"] = module
spec.loader.exec_module(module)
return module
def escape_portable(value: str) -> str:
"""Escape `value` so the result is a literal in both Python and JS regex.
Portable across engines *and* across JS flag modes, unlike `re.escape()`.
"""
return "".join(
"\\" + char if char in _REGEX_METACHARACTERS else char for char in value
)
def exact_alternation(values: set[str]) -> str:
"""Build a stable whole-value alternation portable across Python and JS."""
escaped = [escape_portable(value) for value in sorted(values)]
return f"^(?:{'|'.join(escaped)})$"
def color_for(category: str) -> str:
"""Look up the Activity-view color for a study category.
Missing keys fail the build rather than ship an unstyled taxonomy.
"""
try:
return CATEGORY_COLORS[category]
except KeyError as exc:
raise RuntimeError(
f"study category {category!r} has no color in CATEGORY_COLORS"
) from exc
def build_preset() -> dict:
source = _load_category_source()
categories = {category for _, category in source.CATEGORY_MAP}
categories |= set(source.APP_CATEGORY_MAP.values())
if not categories:
raise RuntimeError("no categories found -- refusing to emit an empty preset")
extra_colors = set(CATEGORY_COLORS) - categories
if extra_colors:
raise RuntimeError(
"CATEGORY_COLORS has entries not in the study taxonomy: "
+ ", ".join(sorted(extra_colors))
)
return {
"id": PRESET_ID,
"name": PRESET_NAME,
# Sorted so the same taxonomy always produces a byte-identical preset.
"categories": [
{
"name": [category],
"rule": {
"type": "regex",
"regex": exact_alternation(
{category}
| {
app
for app, app_category in source.APP_CATEGORY_MAP.items()
if app_category == category
}
),
"ignore_case": True,
},
"data": {"color": color_for(category)},
}
for category in sorted(categories)
],
}
def main() -> None:
preset = build_preset()
# Compact and newline-free: this is written straight into $GITHUB_ENV,
# which treats a newline as the end of the value.
print(json.dumps([preset], separators=(",", ":")))
if __name__ == "__main__":
main()