Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/components/CategoryEditModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ b-modal(id="edit" ref="edit" title="Edit category" @show="resetModal" @hidden="h
div(v-if="editing.rule.type === 'regex'")
b-input-group.my-1(prepend="Pattern")
b-form-input(v-model="editing.rule.regex")
div.my-2
div.mb-1 Fields
b-form-checkbox-group(
v-model="editing.rule.select_keys"
:options="regexFieldOptions"
)
small.text-muted Leave blank to match all canonical string fields.
div.d-flex
div.flex-grow-1
b-form-checkbox(v-model="editing.rule.ignore_case" switch)
Expand Down Expand Up @@ -56,6 +63,7 @@ import ColorPicker from '~/components/ColorPicker.vue';
import { useCategoryStore } from '~/stores/categories';
import { mapState } from 'pinia';
import { validateRegex, isRegexBroad } from '~/util/validate';
import { CLASSIFY_KEYS } from '~/util/classes';

export default {
name: 'CategoryEditModal',
Expand All @@ -72,7 +80,7 @@ export default {
editing: {
id: 0, // FIXME: Use ID assigned to category in store, in order for saves to be uniquely targeted
name: null,
rule: {},
rule: { type: 'none', select_keys: [] },
parent: [],
inherit_color: true,
color: null,
Expand All @@ -92,6 +100,12 @@ export default {
//{ value: 'glob', text: 'Glob pattern' },
];
},
regexFieldOptions: function () {
return CLASSIFY_KEYS.map(key => ({
value: key,
text: key,
}));
},
valid: function () {
return this.editing.rule.type !== 'none' && this.validPattern;
},
Expand Down Expand Up @@ -144,10 +158,15 @@ export default {
}

// Save the category
const rule = _.cloneDeep(this.editing.rule);
if (rule.type === 'regex' && (!rule.select_keys || rule.select_keys.length === 0)) {
delete rule.select_keys;
}

const new_class = {
id: this.editing.id,
name: this.editing.parent.concat(this.editing.name),
rule: this.editing.rule.type !== 'none' ? this.editing.rule : { type: 'none' },
rule: rule.type !== 'none' ? rule : { type: 'none' },
data: {
color: this.editing.inherit_color === true ? undefined : this.editing.color,
score: this.editing.inherit_score === true ? undefined : this.editing.score,
Expand All @@ -166,10 +185,12 @@ export default {
const inherit_color = !color;
const score = cat.data ? cat.data.score : undefined;
const inherit_score = !score;
const rule = _.cloneDeep(cat.rule);
rule.select_keys = rule.type === 'regex' && rule.select_keys ? [...rule.select_keys] : [];
this.editing = {
id: cat.id,
name: cat.subname,
rule: _.cloneDeep(cat.rule),
rule,
parent: cat.parent ? cat.parent : [],
color,
inherit_color,
Expand Down
38 changes: 34 additions & 4 deletions src/util/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { IEvent } from './interfaces';
import { useSettingsStore } from '~/stores/settings';

const level_sep = '>';
const CLASSIFY_KEYS = ['app', 'title'];
export const CLASSIFY_KEYS = ['app', 'title'];
const UNCATEGORIZED = ['Uncategorized'];

export interface Rule {
type: 'regex' | 'none';
regex?: string;
ignore_case?: boolean;
select_keys?: string[];
}

export interface Category {
Expand Down Expand Up @@ -189,10 +190,22 @@ 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?.type === 'regex') {
const select_keys = cat.rule.select_keys?.filter(key => CLASSIFY_KEYS.includes(key));
if (select_keys && select_keys.length > 0) {
cat.rule.select_keys = select_keys;
} else {
delete cat.rule.select_keys;
}
}
return cat;
}

function getRuleSelectKeys(rule: Rule): string[] {
const select_keys = rule.select_keys?.filter(key => CLASSIFY_KEYS.includes(key));
return select_keys && select_keys.length > 0 ? select_keys : CLASSIFY_KEYS;
}

export function loadClasses(): Category[] {
const settingsStore = useSettingsStore();
return settingsStore.classes;
Expand All @@ -202,7 +215,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)'
Expand All @@ -221,7 +238,17 @@ 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(([cat, re]) => {
// When the caller provides the raw event and the category has select_keys,
// test only the specified fields so field-scoped rules work consistently.
if (event && cat.rule.select_keys && cat.rule.select_keys.length > 0) {
return getRuleSelectKeys(cat.rule).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]));
}
Expand All @@ -242,7 +269,10 @@ export function classifyEvents(events: IEvent[], categories: Category[]): IEvent
// 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);
return _.map(getRuleSelectKeys(c[0].rule), key => {
const value = e.data[key];
return typeof value === 'string' ? c[1].test(value) : false;
}).some(x => x);
});
if (matchingCats.length > 0) {
const category = pickDeepest(matchingCats.map(c => c[0]));
Expand Down
7 changes: 6 additions & 1 deletion src/util/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 c = matchString(categorizationString, allCats, e);
if (c !== null) {
return getColorFromCategory(c, allCats);
}
return fallbackColor(categorizationString);
}

if (bucket.type == 'afkstatus') {
Expand Down
2 changes: 1 addition & 1 deletion src/views/Timeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,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")
Expand Down
71 changes: 71 additions & 0 deletions test/unit/classes.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,74 @@ test('matches events to category', () => {
expect(events[1].data.$category).toEqual(testClasses[0].name);
expect(events[2].data.$category).toEqual(['Uncategorized']);
});

test('matches events using selected fields only', () => {
const fieldScopedClasses: Category[] = [
{ name: ['Browser'], rule: { type: 'regex', regex: 'Firefox', select_keys: ['app'] } },
{ name: ['Docs'], rule: { type: 'regex', regex: 'Firefox', select_keys: ['title'] } },
];
let events: IEvent[] = [
{
timestamp: new Date().toISOString(),
duration: 0,
data: { app: 'Firefox', title: 'Session notes' },
},
{
timestamp: new Date().toISOString(),
duration: 0,
data: { app: 'Ghostty', title: 'Firefox docs' },
},
{
timestamp: new Date().toISOString(),
duration: 0,
data: { app: 'Ghostty', title: 'Session notes' },
},
];

events = classes.classifyEvents(events, fieldScopedClasses);

expect(events[0].data.$category).toEqual(fieldScopedClasses[0].name);
expect(events[1].data.$category).toEqual(fieldScopedClasses[1].name);
expect(events[2].data.$category).toEqual(['Uncategorized']);
});

test('matchString with event applies select_keys field scoping', () => {
const appOnlyCat: Category = {
name: ['Browser'],
rule: { type: 'regex', regex: 'Firefox', select_keys: ['app'] },
};
const categories = [appOnlyCat];

const eventWithAppMatch: IEvent = {
timestamp: new Date().toISOString(),
duration: 0,
data: { app: 'Firefox', title: 'Session notes' },
};
const eventWithTitleMatchOnly: IEvent = {
timestamp: new Date().toISOString(),
duration: 0,
data: { app: 'Ghostty', title: 'Firefox docs' },
};

// With event: only the app field is tested, so app-match hits and title-only-match misses
expect(classes.matchString('', categories, eventWithAppMatch)).toEqual(appOnlyCat);
expect(classes.matchString('', categories, eventWithTitleMatchOnly)).toBeNull();

// Without event: falls back to testing the concatenated string argument
expect(classes.matchString('Firefox', categories)).toEqual(appOnlyCat);
expect(classes.matchString('no match', categories)).toBeNull();
});

test('cleanCategory drops empty select_keys but preserves valid ones', () => {
const emptySelectKeys = classes.cleanCategory({
name: ['Test'],
rule: { type: 'regex', regex: 'Firefox', select_keys: [] },
});
const validSelectKeys = classes.cleanCategory({
name: ['Test'],
rule: { type: 'regex', regex: 'Firefox', select_keys: ['title'] },
});

expect(emptySelectKeys.rule.select_keys).toBeUndefined();
expect(validSelectKeys.rule.select_keys).toEqual(['title']);
});
15 changes: 15 additions & 0 deletions test/unit/store/categories.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ describe('categories store', () => {
expect(cats).toHaveLength(2);
});

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('update implicit parent category', () => {
// The default categories have implicit Media and Comms categories (with 'No Rule')
// Tests against https://github.com/ActivityWatch/activitywatch/issues/580
Expand Down