diff --git a/src/components/CategoryEditModal.vue b/src/components/CategoryEditModal.vue index bba98d2c..986ea62a 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 blank 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: [], }, }; }, @@ -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,22 @@ 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); + if (!selected) { + 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 +229,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] : [], }; }, }, 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..20b2c3a9 100644 --- a/src/util/classes.ts +++ b/src/util/classes.ts @@ -4,9 +4,12 @@ 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 = CLASSIFY_KEYS; + /** 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; } @@ -339,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)' @@ -358,7 +388,16 @@ 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])); } @@ -367,7 +406,6 @@ export function matchString(str: string, categories: Category[] | null): Categor // this is used only in tests export function classifyEvents(events: IEvent[], categories: Category[]): IEvent[] { - // Compile regexes const regexes: [Category, RegExp][] = categories .filter(c => c.rule.type == 'regex') .map(c => { @@ -375,18 +413,18 @@ export function classifyEvents(events: IEvent[], categories: Category[]): IEvent return [c, re]; }); - // Classify events using compiled regexes. - // If several categories match the event, the deepest category will be chosen. 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); + 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); + }); }); - if (matchingCats.length > 0) { - const category = pickDeepest(matchingCats.map(c => c[0])); - e.data.$category = category.name; - } else { - e.data.$category = UNCATEGORIZED; - } + e.data.$category = + matchingCats.length > 0 + ? pickDeepest(matchingCats.map(([category]) => category)).name + : UNCATEGORIZED; return e; }); } diff --git a/src/util/color.ts b/src/util/color.ts index 0ad12b68..431f0f4e 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -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 = matchString(categorizationString, allCats, e); + if (matched !== null) { + return getColorFromCategory(matched, allCats); + } + return fallbackColor(categorizationString); } if (bucket.type == 'afkstatus') { 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/src/views/Timeline.vue b/src/views/Timeline.vue index d133b793..06cb4a84 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -278,7 +278,7 @@ export default { 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); + 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 c0f7432a..e92fbbb2 100644 --- a/test/unit/classes.test.node.ts +++ b/test/unit/classes.test.node.ts @@ -37,3 +37,66 @@ 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 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 preserves legacy categorization-string matching', () => { + const cats: Category[] = [{ name: ['Any'], rule: { type: 'regex', regex: 'Firefox' } }]; + 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', () => { + 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/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(); 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"'); +}); 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;