From 0e07a289dfd1d4caa540935e6c66917d25d9edb2 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 30 Aug 2026 09:05:59 +0000 Subject: [PATCH 1/3] feat(categories): optional select_keys for regex rules Category regex rules can now target explicit event fields (app, title, url) instead of matching every string. The editor exposes field checkboxes; omitted select_keys keeps the legacy all-string-fields behavior. Query serialization, client-side matching, and timeline coloring honor the same field list. Rust query-engine parity already landed in aw-server-rust#605. Does not open a PR while this fork already has three open aw-webui PRs. Refs: ActivityWatch/aw-webui#823 Git-Session-Id: 9e7d --- src/components/CategoryEditModal.vue | 41 +++++++++++++- src/components/CategoryEditTree.vue | 5 +- src/queries.ts | 2 + src/util/classes.ts | 85 ++++++++++++++++++++++------ src/util/color.ts | 9 ++- src/views/Timeline.vue | 8 +-- test/unit/classes.test.node.ts | 36 ++++++++++++ test/unit/queries.test.node.ts | 12 +++- 8 files changed, 172 insertions(+), 26 deletions(-) diff --git a/src/components/CategoryEditModal.vue b/src/components/CategoryEditModal.vue index bba98d2c..fb9534ed 100644 --- a/src/components/CategoryEditModal.vue +++ b/src/components/CategoryEditModal.vue @@ -24,6 +24,16 @@ b-modal(id="edit" ref="edit" title="Edit category" @show="resetModal" @hidden="h small.text-right div.text-danger(v-if="!validPattern") Invalid pattern div.text-warning(v-if="validPattern && broad_pattern") Pattern too broad + div.mt-2 + small.text-muted Match fields + div.d-flex.flex-wrap + b-form-checkbox.mr-3( + v-for="key in fieldOptions" + :key="key" + v-model="editing.match_fields" + :value="key" + ) {{ key }} + small.text-muted Leave all checked to match every string field (default). hr div.my-1 @@ -55,6 +65,7 @@ import ColorPicker from '~/components/ColorPicker.vue'; import { useCategoryStore } from '~/stores/categories'; import { mapState } from 'pinia'; import { validateRegex, isRegexBroad } from '~/util/validate'; +import { CANONICAL_SELECT_KEYS, normalizeSelectKeys } from '~/util/classes'; import 'vue-awesome/icons/trash'; @@ -79,6 +90,7 @@ export default { color: null, inherit_score: true, score: null, + match_fields: [...CANONICAL_SELECT_KEYS], }, }; }, @@ -102,6 +114,12 @@ export default { broad_pattern: function () { return this.editing.rule.type === 'regex' && isRegexBroad(this.editing.rule.regex || ''); }, + fieldOptions: function () { + const extra = (this.editing.match_fields || []).filter( + k => !(CANONICAL_SELECT_KEYS as readonly string[]).includes(k) + ); + return [...CANONICAL_SELECT_KEYS, ...extra]; + }, }, watch: { categoryId: function (new_value) { @@ -177,10 +195,26 @@ export default { return; } const nameSegments = parent.concat(this.editing.name); + const rule = + this.editing.rule.type !== 'none' ? _.cloneDeep(this.editing.rule) : { type: 'none' }; + if (rule.type === 'regex') { + const selected = normalizeSelectKeys(this.editing.match_fields); + const allCanonical = + selected && + selected.length === CANONICAL_SELECT_KEYS.length && + CANONICAL_SELECT_KEYS.every(k => selected.includes(k)); + if (!selected || allCanonical) { + delete rule.select_keys; + } else { + rule.select_keys = selected; + } + } else { + delete rule.select_keys; + } const new_class = { id: this.editing.id, name: nameSegments, - rule: this.editing.rule.type !== 'none' ? this.editing.rule : { type: 'none' }, + rule, data: { color: this.editing.inherit_color === true ? undefined : this.editing.color, score: this.editing.inherit_score === true ? undefined : this.editing.score, @@ -199,15 +233,18 @@ export default { const inherit_color = !color; const score = cat.data ? cat.data.score : undefined; const inherit_score = !score; + const rule = _.cloneDeep(cat.rule) || {}; + const storedKeys = normalizeSelectKeys(rule.select_keys); this.editing = { id: cat.id, name: cat.subname, - rule: _.cloneDeep(cat.rule), + rule, parent: cat.parent ? cat.parent : [], color, inherit_color, score, inherit_score, + match_fields: storedKeys ? [...storedKeys] : [...CANONICAL_SELECT_KEYS], }; }, }, diff --git a/src/components/CategoryEditTree.vue b/src/components/CategoryEditTree.vue index ca0d04a1..ac3e3e85 100644 --- a/src/components/CategoryEditTree.vue +++ b/src/components/CategoryEditTree.vue @@ -16,7 +16,10 @@ div div.col-4.col-md-8 span.d-none.d-md-inline - span(v-if="_class.rule.type === 'regex'") Rule ({{_class.rule.type}}): #[code {{_class.rule.regex}}] + span(v-if="_class.rule.type === 'regex'") + | Rule ({{_class.rule.type}}): #[code {{_class.rule.regex}}] + span.text-muted(v-if="_class.rule.select_keys && _class.rule.select_keys.length") + | [{{ _class.rule.select_keys.join(', ') }}] span.text-muted(v-else) No rule span.float-right b-btn.ml-1.border-0(size="sm", variant="outline-secondary", @click="showEditModal(_class.id)" pill) diff --git a/src/queries.ts b/src/queries.ts index 5fed7e17..c56ae832 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -48,6 +48,8 @@ export function safeHostname(hostname: string): string { interface Rule { type: string; regex?: string; + ignore_case?: boolean; + select_keys?: string[]; } type Category = [string[], Rule]; diff --git a/src/util/classes.ts b/src/util/classes.ts index c09146ad..713b472b 100644 --- a/src/util/classes.ts +++ b/src/util/classes.ts @@ -7,6 +7,9 @@ const level_sep = '>'; const CLASSIFY_KEYS = ['app', 'title']; const UNCATEGORIZED = ['Uncategorized']; +/** Canonical event fields offered in the category-rule editor. */ +export const CANONICAL_SELECT_KEYS = ['app', 'title', 'url'] as const; + /** ID of the implicit set holding a user's own (non-preset) categories. */ export const DEFAULT_SET_ID = 'default'; @@ -14,6 +17,22 @@ export interface Rule { type: 'regex' | 'none'; regex?: string; ignore_case?: boolean; + /** When set, only these event.data keys are tested. Absent = all string fields. */ + select_keys?: string[]; +} + +/** Drop empty/invalid select_keys so the rust parser never sees `[]`. */ +export function normalizeSelectKeys(keys?: string[] | null): string[] | undefined { + if (!keys || keys.length === 0) { + return undefined; + } + const unique: string[] = []; + for (const key of keys) { + if (typeof key === 'string' && key && !unique.includes(key)) { + unique.push(key); + } + } + return unique.length > 0 ? unique : undefined; } export interface Category { @@ -243,6 +262,13 @@ export function cleanCategory(cat: Category): Category { // we also want to strip any excess properties that may have belonged to another rule type if (cat.rule && (cat.rule.type === null || cat.rule.type === 'none')) { cat.rule = { type: 'none' }; + } else if (cat.rule && cat.rule.type === 'regex') { + const keys = normalizeSelectKeys(cat.rule.select_keys); + if (keys) { + cat.rule.select_keys = keys; + } else { + delete cat.rule.select_keys; + } } return cat; } @@ -365,28 +391,55 @@ export function matchString(str: string, categories: Category[] | null): Categor return null; } -// this is used only in tests -export function classifyEvents(events: IEvent[], categories: Category[]): IEvent[] { - // Compile regexes +function eventDataMatchesRegex( + data: Record, + re: RegExp, + select_keys?: string[] +): boolean { + const keys = normalizeSelectKeys(select_keys); + if (keys) { + return keys.some(key => { + const val = data[key]; + return typeof val === 'string' && re.test(val); + }); + } + // Legacy / server parity: test every string-valued field, skipping derived `$` keys. + return Object.entries(data).some(([key, val]) => { + return !key.startsWith('$') && typeof val === 'string' && re.test(val); + }); +} + +/** Match an event's data against category rules, honoring optional select_keys. */ +export function matchEventData( + data: Record, + categories: Category[] | null +): Category | null { + if (!categories) { + categories = loadClasses(); + } const regexes: [Category, RegExp][] = categories - .filter(c => c.rule.type == 'regex') + .filter(c => c.rule.type == 'regex' && c.rule.regex) .map(c => { - const re = RegExp(c.rule.regex, c.rule.ignore_case ? 'i' : ''); + const re = RegExp(c.rule.regex as string, c.rule.ignore_case ? 'i' : ''); return [c, re]; }); + const matchingCats = regexes.filter(([c, re]) => + eventDataMatchesRegex(data, re, c.rule.select_keys) + ); + if (matchingCats.length > 0) { + return pickDeepest(matchingCats.map(([c]) => c)) ?? null; + } + return null; +} - // Classify events using compiled regexes. - // If several categories match the event, the deepest category will be chosen. +// this is used only in tests +export function classifyEvents(events: IEvent[], categories: Category[]): IEvent[] { return events.map((e: IEvent) => { - const matchingCats: [Category, RegExp][] = regexes.filter(c => { - return _.map(CLASSIFY_KEYS, key => c[1].test(e.data[key])).some(x => x); - }); - if (matchingCats.length > 0) { - const category = pickDeepest(matchingCats.map(c => c[0])); - e.data.$category = category.name; - } else { - e.data.$category = UNCATEGORIZED; - } + const category = matchEventData(e.data, categories); + e.data.$category = category ? category.name : UNCATEGORIZED; return e; }); } + +// Keep CLASSIFY_KEYS exported for callers/tests that still want the legacy app+title pair. +export { CLASSIFY_KEYS }; diff --git a/src/util/color.ts b/src/util/color.ts index 0ad12b68..19f73ee9 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -1,5 +1,5 @@ import _ from 'lodash'; -import { Category, matchString, loadClasses } from './classes'; +import { Category, matchString, matchEventData, loadClasses } from './classes'; import Color from 'color'; import * as d3 from 'd3'; import { IEvent, IBucket } from './interfaces'; @@ -159,7 +159,12 @@ export function getCategorizationStringFromEvent(bucket: IBucket, e: IEvent): st export function getCategoryColorFromEvent(bucket: IBucket, e: IEvent) { const categorizationString = getCategorizationStringFromEvent(bucket, e); if (categorizationString !== null) { - return getCategoryColorFromString(categorizationString); + const allCats = loadClasses(); + const matched = matchEventData(e.data, allCats); + if (matched !== null) { + return getColorFromCategory(matched, allCats); + } + return fallbackColor(categorizationString); } if (bucket.type == 'afkstatus') { diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index d133b793..2379557b 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -117,7 +117,7 @@ import { useBucketsStore } from '~/stores/buckets'; import { getClient } from '~/util/awclient'; import { canonicalEvents, querystr_to_array } from '~/queries'; import { useCategoryStore } from '~/stores/categories'; -import { matchString } from '~/util/classes'; +import { matchEventData } from '~/util/classes'; import { getCategorizationStringFromEvent } from '~/util/color'; import { seconds_to_duration } from '~/util/time'; @@ -276,9 +276,9 @@ export default { // Skip AFK buckets — they don't have meaningful categorization if (bucket.type === 'afkstatus') continue; bucket.events = _.filter(bucket.events, e => { - const str = getCategorizationStringFromEvent(bucket, e); - if (str === null) return true; // Keep events from unknown bucket types - const matched = matchString(str, allCats); + // Keep events from unknown bucket types (no categorization string). + if (getCategorizationStringFromEvent(bucket, e) === null) return true; + const matched = matchEventData(e.data, allCats); const eventCat = matched ? matched.name : ['Uncategorized']; // Check if the event's category matches any selected filter category // (including parent matches: selecting "Work" also shows "Work > Programming") diff --git a/test/unit/classes.test.node.ts b/test/unit/classes.test.node.ts index c0f7432a..509c0906 100644 --- a/test/unit/classes.test.node.ts +++ b/test/unit/classes.test.node.ts @@ -37,3 +37,39 @@ test('matches events to category', () => { expect(events[1].data.$category).toEqual(testClasses[0].name); expect(events[2].data.$category).toEqual(['Uncategorized']); }); + +test('select_keys restricts regex matching to named fields', () => { + const cats: Category[] = [ + { name: ['AppOnly'], rule: { type: 'regex', regex: 'Firefox', select_keys: ['app'] } }, + ]; + const titleHit = classes.matchEventData({ app: 'Chrome', title: 'Firefox docs' }, cats); + const appHit = classes.matchEventData({ app: 'Firefox', title: 'Chrome docs' }, cats); + expect(titleHit).toBeNull(); + expect(appHit?.name).toEqual(['AppOnly']); +}); + +test('absent select_keys still matches any string field', () => { + const cats: Category[] = [{ name: ['Any'], rule: { type: 'regex', regex: 'Firefox' } }]; + expect(classes.matchEventData({ app: 'Chrome', title: 'Firefox' }, cats)?.name).toEqual(['Any']); + expect(classes.matchEventData({ url: 'https://Firefox.com' }, cats)?.name).toEqual(['Any']); +}); + +test('cleanCategory drops empty select_keys and keeps a real list', () => { + const empty = classes.cleanCategory({ + name: ['X'], + rule: { type: 'regex', regex: 'a', select_keys: [] }, + }); + expect(empty.rule.select_keys).toBeUndefined(); + + const kept = classes.cleanCategory({ + name: ['Y'], + rule: { type: 'regex', regex: 'a', select_keys: ['title', 'title', ''] }, + }); + expect(kept.rule.select_keys).toEqual(['title']); +}); + +test('normalizeSelectKeys rejects empty lists', () => { + expect(classes.normalizeSelectKeys([])).toBeUndefined(); + expect(classes.normalizeSelectKeys(null)).toBeUndefined(); + expect(classes.normalizeSelectKeys(['app', 'title'])).toEqual(['app', 'title']); +}); diff --git a/test/unit/queries.test.node.ts b/test/unit/queries.test.node.ts index b3eae0c7..6c86dcc9 100644 --- a/test/unit/queries.test.node.ts +++ b/test/unit/queries.test.node.ts @@ -53,7 +53,7 @@ * (Flatpak app ID retained: 'one.ablaze.floorp') */ -import { browser_appname_regex, querystr_to_array } from '~/queries'; +import { browser_appname_regex, querystr_to_array, canonicalEvents } from '~/queries'; // Convert ActivityWatch (?i) patterns to JS RegExp with i flag for testing. // AW server uses Python-style (?i) inline flag; JS uses RegExp 'i' flag instead. @@ -265,3 +265,13 @@ describe('querystr_to_array', () => { expect(result).toHaveLength(2); }); }); + +test('canonicalEvents serializes select_keys into categorize()', () => { + const query = canonicalEvents({ + bid_android: 'aw-watcher-android_test', + categories: [[['Work'], { type: 'regex', regex: 'Firefox', select_keys: ['app'] }]], + filter_categories: [], + }); + expect(query).toContain('"select_keys":["app"]'); + expect(query).toContain('"regex":"Firefox"'); +}); From 46baa3eccbf4fe8966cf7258359979b94d8b9394 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 30 Aug 2026 12:18:27 +0000 Subject: [PATCH 2/3] fix(categories): preserve field-rule compatibility Git-Session-Id: c673 --- src/components/CategoryEditModal.vue | 12 ++-- src/util/classes.ts | 79 ++++++++++--------------- src/util/color.ts | 4 +- src/views/Timeline.vue | 8 +-- test/unit/CategoryEditModal.test.js | 47 +++++++++++++++ test/unit/classes.test.node.ts | 37 ++++++++++-- test/unit/store/categories.test.node.ts | 15 +++++ 7 files changed, 136 insertions(+), 66 deletions(-) diff --git a/src/components/CategoryEditModal.vue b/src/components/CategoryEditModal.vue index fb9534ed..986ea62a 100644 --- a/src/components/CategoryEditModal.vue +++ b/src/components/CategoryEditModal.vue @@ -33,7 +33,7 @@ b-modal(id="edit" ref="edit" title="Edit category" @show="resetModal" @hidden="h v-model="editing.match_fields" :value="key" ) {{ key }} - small.text-muted Leave all checked to match every string field (default). + small.text-muted Leave blank to match every string field (default). hr div.my-1 @@ -90,7 +90,7 @@ export default { color: null, inherit_score: true, score: null, - match_fields: [...CANONICAL_SELECT_KEYS], + match_fields: [], }, }; }, @@ -199,11 +199,7 @@ export default { this.editing.rule.type !== 'none' ? _.cloneDeep(this.editing.rule) : { type: 'none' }; if (rule.type === 'regex') { const selected = normalizeSelectKeys(this.editing.match_fields); - const allCanonical = - selected && - selected.length === CANONICAL_SELECT_KEYS.length && - CANONICAL_SELECT_KEYS.every(k => selected.includes(k)); - if (!selected || allCanonical) { + if (!selected) { delete rule.select_keys; } else { rule.select_keys = selected; @@ -244,7 +240,7 @@ export default { inherit_color, score, inherit_score, - match_fields: storedKeys ? [...storedKeys] : [...CANONICAL_SELECT_KEYS], + match_fields: storedKeys ? [...storedKeys] : [], }; }, }, diff --git a/src/util/classes.ts b/src/util/classes.ts index 713b472b..20b2c3a9 100644 --- a/src/util/classes.ts +++ b/src/util/classes.ts @@ -4,11 +4,11 @@ import { useSettingsStore } from '~/stores/settings'; import { getPresetCategorySets } from '~/util/presetCategories'; const level_sep = '>'; -const CLASSIFY_KEYS = ['app', 'title']; +export const CLASSIFY_KEYS = ['app', 'title'] as const; const UNCATEGORIZED = ['Uncategorized']; /** Canonical event fields offered in the category-rule editor. */ -export const CANONICAL_SELECT_KEYS = ['app', 'title', 'url'] as const; +export const CANONICAL_SELECT_KEYS = CLASSIFY_KEYS; /** ID of the implicit set holding a user's own (non-preset) categories. */ export const DEFAULT_SET_ID = 'default'; @@ -365,7 +365,11 @@ function pickDeepest(categories: Category[]) { return _.maxBy(categories, c => c.name.length); } -export function matchString(str: string, categories: Category[] | null): Category | null { +export function matchString( + str: string, + categories: Category[] | null, + event?: IEvent +): Category | null { if (!categories) { console.log( 'Categories not passed, loading... (if you see this outside of a test, you should probably pass them)' @@ -384,62 +388,43 @@ export function matchString(str: string, categories: Category[] | null): Categor // Find the matching category. // If several categories match the event, the deepest category will be chosen. - const matchingCats: [Category, RegExp][] = regexes.filter(c => c[1].test(str)); + const matchingCats: [Category, RegExp][] = regexes.filter(([category, re]) => { + const selectKeys = normalizeSelectKeys(category.rule.select_keys); + if (event && selectKeys) { + return selectKeys.some(key => { + const value = event.data[key]; + return typeof value === 'string' && re.test(value); + }); + } + return re.test(str); + }); if (matchingCats.length > 0) { return pickDeepest(matchingCats.map(c => c[0])); } return null; } -function eventDataMatchesRegex( - data: Record, - re: RegExp, - select_keys?: string[] -): boolean { - const keys = normalizeSelectKeys(select_keys); - if (keys) { - return keys.some(key => { - const val = data[key]; - return typeof val === 'string' && re.test(val); - }); - } - // Legacy / server parity: test every string-valued field, skipping derived `$` keys. - return Object.entries(data).some(([key, val]) => { - return !key.startsWith('$') && typeof val === 'string' && re.test(val); - }); -} - -/** Match an event's data against category rules, honoring optional select_keys. */ -export function matchEventData( - data: Record, - categories: Category[] | null -): Category | null { - if (!categories) { - categories = loadClasses(); - } +// this is used only in tests +export function classifyEvents(events: IEvent[], categories: Category[]): IEvent[] { const regexes: [Category, RegExp][] = categories - .filter(c => c.rule.type == 'regex' && c.rule.regex) + .filter(c => c.rule.type == 'regex') .map(c => { - const re = RegExp(c.rule.regex as string, c.rule.ignore_case ? 'i' : ''); + const re = RegExp(c.rule.regex, c.rule.ignore_case ? 'i' : ''); return [c, re]; }); - const matchingCats = regexes.filter(([c, re]) => - eventDataMatchesRegex(data, re, c.rule.select_keys) - ); - if (matchingCats.length > 0) { - return pickDeepest(matchingCats.map(([c]) => c)) ?? null; - } - return null; -} -// this is used only in tests -export function classifyEvents(events: IEvent[], categories: Category[]): IEvent[] { return events.map((e: IEvent) => { - const category = matchEventData(e.data, categories); - e.data.$category = category ? category.name : UNCATEGORIZED; + const matchingCats = regexes.filter(([category, re]) => { + const keys = normalizeSelectKeys(category.rule.select_keys) || CLASSIFY_KEYS; + return keys.some(key => { + const value = e.data[key]; + return typeof value === 'string' && re.test(value); + }); + }); + e.data.$category = + matchingCats.length > 0 + ? pickDeepest(matchingCats.map(([category]) => category)).name + : UNCATEGORIZED; return e; }); } - -// Keep CLASSIFY_KEYS exported for callers/tests that still want the legacy app+title pair. -export { CLASSIFY_KEYS }; diff --git a/src/util/color.ts b/src/util/color.ts index 19f73ee9..431f0f4e 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -1,5 +1,5 @@ import _ from 'lodash'; -import { Category, matchString, matchEventData, loadClasses } from './classes'; +import { Category, matchString, loadClasses } from './classes'; import Color from 'color'; import * as d3 from 'd3'; import { IEvent, IBucket } from './interfaces'; @@ -160,7 +160,7 @@ export function getCategoryColorFromEvent(bucket: IBucket, e: IEvent) { const categorizationString = getCategorizationStringFromEvent(bucket, e); if (categorizationString !== null) { const allCats = loadClasses(); - const matched = matchEventData(e.data, allCats); + const matched = matchString(categorizationString, allCats, e); if (matched !== null) { return getColorFromCategory(matched, allCats); } diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index 2379557b..06cb4a84 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -117,7 +117,7 @@ import { useBucketsStore } from '~/stores/buckets'; import { getClient } from '~/util/awclient'; import { canonicalEvents, querystr_to_array } from '~/queries'; import { useCategoryStore } from '~/stores/categories'; -import { matchEventData } from '~/util/classes'; +import { matchString } from '~/util/classes'; import { getCategorizationStringFromEvent } from '~/util/color'; import { seconds_to_duration } from '~/util/time'; @@ -276,9 +276,9 @@ export default { // Skip AFK buckets — they don't have meaningful categorization if (bucket.type === 'afkstatus') continue; bucket.events = _.filter(bucket.events, e => { - // Keep events from unknown bucket types (no categorization string). - if (getCategorizationStringFromEvent(bucket, e) === null) return true; - const matched = matchEventData(e.data, allCats); + const str = getCategorizationStringFromEvent(bucket, e); + if (str === null) return true; // Keep events from unknown bucket types + const matched = matchString(str, allCats, e); const eventCat = matched ? matched.name : ['Uncategorized']; // Check if the event's category matches any selected filter category // (including parent matches: selecting "Work" also shows "Work > Programming") diff --git a/test/unit/CategoryEditModal.test.js b/test/unit/CategoryEditModal.test.js index 099f98cd..bd9c2cdd 100644 --- a/test/unit/CategoryEditModal.test.js +++ b/test/unit/CategoryEditModal.test.js @@ -47,3 +47,50 @@ describe('CategoryEditModal handleEnter', () => { expect(vm.handleSubmit).toHaveBeenCalled(); }); }); + +describe('CategoryEditModal field-scoped rules', () => { + const handleSubmit = vm => CategoryEditModal.methods.handleSubmit.call(vm); + + function ctx(match_fields) { + const updateClass = jest.fn(); + return { + vm: { + editing: { + id: 1, + name: 'Browser', + parent: ['Work'], + rule: { type: 'regex', regex: 'Firefox', select_keys: ['stale'] }, + match_fields, + inherit_color: true, + color: null, + inherit_score: true, + score: null, + }, + checkFormValidity: () => true, + categoryStore: { updateClass }, + $nextTick: callback => callback(), + $refs: { edit: { hide: jest.fn() } }, + }, + updateClass, + }; + } + + test('blank field selection keeps the legacy unrestricted rule', () => { + const { vm, updateClass } = ctx([]); + handleSubmit(vm); + expect(updateClass.mock.calls[0][0].rule.select_keys).toBeUndefined(); + }); + + test('explicit field selection is preserved even when all offered fields are selected', () => { + const { vm, updateClass } = ctx(['app', 'title']); + handleSubmit(vm); + expect(updateClass.mock.calls[0][0].rule.select_keys).toEqual(['app', 'title']); + }); + + test('only fields categorized by canonicalEvents are offered by default', () => { + const fieldOptions = CategoryEditModal.computed.fieldOptions.call({ + editing: { match_fields: [] }, + }); + expect(fieldOptions).toEqual(['app', 'title']); + }); +}); diff --git a/test/unit/classes.test.node.ts b/test/unit/classes.test.node.ts index 509c0906..e92fbbb2 100644 --- a/test/unit/classes.test.node.ts +++ b/test/unit/classes.test.node.ts @@ -42,16 +42,43 @@ test('select_keys restricts regex matching to named fields', () => { const cats: Category[] = [ { name: ['AppOnly'], rule: { type: 'regex', regex: 'Firefox', select_keys: ['app'] } }, ]; - const titleHit = classes.matchEventData({ app: 'Chrome', title: 'Firefox docs' }, cats); - const appHit = classes.matchEventData({ app: 'Firefox', title: 'Chrome docs' }, cats); + const titleEvent: IEvent = { + timestamp: new Date().toISOString(), + duration: 0, + data: { app: 'Chrome', title: 'Firefox docs' }, + }; + const appEvent: IEvent = { + timestamp: new Date().toISOString(), + duration: 0, + data: { app: 'Firefox', title: 'Chrome docs' }, + }; + const titleHit = classes.matchString('Chrome\nFirefox docs', cats, titleEvent); + const appHit = classes.matchString('Firefox\nChrome docs', cats, appEvent); expect(titleHit).toBeNull(); expect(appHit?.name).toEqual(['AppOnly']); }); -test('absent select_keys still matches any string field', () => { +test('absent select_keys preserves legacy categorization-string matching', () => { const cats: Category[] = [{ name: ['Any'], rule: { type: 'regex', regex: 'Firefox' } }]; - expect(classes.matchEventData({ app: 'Chrome', title: 'Firefox' }, cats)?.name).toEqual(['Any']); - expect(classes.matchEventData({ url: 'https://Firefox.com' }, cats)?.name).toEqual(['Any']); + const event: IEvent = { + timestamp: new Date().toISOString(), + duration: 0, + data: { app: 'Chrome', title: 'Docs', url: 'https://Firefox.com' }, + }; + expect(classes.matchString('Chrome\nDocs', cats, event)).toBeNull(); + expect(classes.matchString('Chrome\nFirefox docs', cats, event)?.name).toEqual(['Any']); +}); + +test('absent select_keys preserves cross-field regex matching', () => { + const cats: Category[] = [ + { name: ['CrossField'], rule: { type: 'regex', regex: 'Chrome\\nDocs' } }, + ]; + const event: IEvent = { + timestamp: new Date().toISOString(), + duration: 0, + data: { app: 'Chrome', title: 'Docs' }, + }; + expect(classes.matchString('Chrome\nDocs', cats, event)?.name).toEqual(['CrossField']); }); test('cleanCategory drops empty select_keys and keeps a real list', () => { diff --git a/test/unit/store/categories.test.node.ts b/test/unit/store/categories.test.node.ts index 25238f3a..0c69c669 100644 --- a/test/unit/store/categories.test.node.ts +++ b/test/unit/store/categories.test.node.ts @@ -45,6 +45,21 @@ describe('categories store', () => { expect(categoryStore.all_categories).toHaveLength(1); }); + test('updateClass preserves regex select_keys', () => { + categoryStore.load([ + { + name: ['Browser'], + rule: { type: 'regex', regex: 'Firefox', select_keys: ['app'] }, + }, + ]); + + const browserCat = categoryStore.get_category(['Browser']); + browserCat.rule.select_keys = ['title']; + categoryStore.updateClass(browserCat); + + expect(categoryStore.get_category(['Browser']).rule.select_keys).toEqual(['title']); + }); + test('get category hierarchy', () => { categoryStore.restoreDefaultClasses(); const hier = categoryStore.classes_hierarchy; From 0029ecd3206c1c29176386046ca891326a716750 Mon Sep 17 00:00:00 2001 From: Bob Date: Sun, 30 Aug 2026 12:29:55 +0000 Subject: [PATCH 3/3] fix(categories): preserve select_keys in preset parsing Preset regex rules dropped field scope, so embedder-supplied select_keys silently matched every string field. --- src/util/presetCategories.ts | 18 ++++++++ test/unit/presetCategories.test.node.ts | 57 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/util/presetCategories.ts b/src/util/presetCategories.ts index 467e3ee1..0a7d0eda 100644 --- a/src/util/presetCategories.ts +++ b/src/util/presetCategories.ts @@ -72,9 +72,27 @@ function parseRule(raw: unknown, context: string): Rule | null { } const rule: Rule = { type: 'regex', regex: raw.regex }; if (raw.ignore_case === true) rule.ignore_case = true; + // Inlined from classes.normalizeSelectKeys to avoid a runtime cycle + // (classes.ts imports this module). Empty/duplicate lists are dropped so + // the rust parser never sees `select_keys: []`. + const selectKeys = normalizePresetSelectKeys(raw.select_keys); + if (selectKeys) rule.select_keys = selectKeys; return rule; } +function normalizePresetSelectKeys(keys: unknown): string[] | undefined { + if (!Array.isArray(keys) || keys.length === 0) { + return undefined; + } + const unique: string[] = []; + for (const key of keys) { + if (typeof key === 'string' && key && !unique.includes(key)) { + unique.push(key); + } + } + return unique.length > 0 ? unique : undefined; +} + function parseCategory(raw: unknown, context: string): Category | null { if (!isPlainObject(raw)) { console.warn(`[presets] ${context}: category is not an object, skipping`); diff --git a/test/unit/presetCategories.test.node.ts b/test/unit/presetCategories.test.node.ts index aae9ec06..7f0aaea1 100644 --- a/test/unit/presetCategories.test.node.ts +++ b/test/unit/presetCategories.test.node.ts @@ -123,6 +123,31 @@ describe('parsePresetCategorySets', () => { expect(sets[0].categories[1].data).toEqual({ color: '#FFF' }); }); + test('preserves select_keys on regex rules and drops empty/duplicate lists', () => { + const sets = parsePresetCategorySets([ + { + id: 'set', + categories: [ + { + name: ['App only'], + rule: { type: 'regex', regex: 'Chrome', select_keys: ['app'] }, + }, + { + name: ['Empty keys'], + rule: { type: 'regex', regex: 'x', select_keys: [] }, + }, + { + name: ['Dup keys'], + rule: { type: 'regex', regex: 'x', select_keys: ['title', 'app', 'title', ''] }, + }, + ], + }, + ]); + expect(sets[0].categories[0].rule.select_keys).toEqual(['app']); + expect(sets[0].categories[1].rule.select_keys).toBeUndefined(); + expect(sets[0].categories[2].rule.select_keys).toEqual(['title', 'app']); + }); + test('keeps the first of duplicate set ids', () => { const sets = parsePresetCategorySets([ presetSet, @@ -355,6 +380,38 @@ describe('categories store with presets', () => { expect(classified[1].data.$category).toEqual(['Uncategorized']); }); + test('field-scoped preset rules do not match other string fields', () => { + setPresetGlobal([ + { + id: 'study', + categories: [ + { + name: ['Docs'], + rule: { type: 'regex', regex: 'Google Docs', select_keys: ['app'] }, + }, + ], + }, + ]); + const categoryStore = useCategoryStore(); + categoryStore.load(); + + const events = [ + { + timestamp: new Date().toISOString(), + duration: 0, + data: { app: 'Google Docs', title: 'Inbox' }, + }, + { + timestamp: new Date().toISOString(), + duration: 0, + data: { app: 'Chrome', title: 'Google Docs' }, + }, + ]; + const classified = classifyEvents(events, categoryStore.classes); + expect(classified[0].data.$category).toEqual(['Docs']); + expect(classified[1].data.$category).toEqual(['Uncategorized']); + }); + test('restore defaults restores the preset, not the built-in categories', () => { setPresetGlobal([presetSet]); const categoryStore = useCategoryStore();