Skip to content
Open
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
37 changes: 35 additions & 2 deletions src/components/CategoryEditModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';

Expand All @@ -79,6 +90,7 @@ export default {
color: null,
inherit_score: true,
score: null,
match_fields: [],
},
};
},
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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] : [],
};
},
},
Expand Down
5 changes: 4 additions & 1 deletion src/components/CategoryEditTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
66 changes: 52 additions & 14 deletions src/util/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,35 @@ 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';

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[];
Comment thread
TimeToBuildBob marked this conversation as resolved.
}

/** 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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)'
Expand All @@ -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]));
}
Expand All @@ -367,26 +406,25 @@ 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 => {
const re = RegExp(c.rule.regex, c.rule.ignore_case ? 'i' : '');
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;
});
}
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 matched = matchString(categorizationString, allCats, e);
if (matched !== null) {
return getColorFromCategory(matched, allCats);
}
return fallbackColor(categorizationString);
}

if (bucket.type == 'afkstatus') {
Expand Down
18 changes: 18 additions & 0 deletions src/util/presetCategories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
2 changes: 1 addition & 1 deletion src/views/Timeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
47 changes: 47 additions & 0 deletions test/unit/CategoryEditModal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
63 changes: 63 additions & 0 deletions test/unit/classes.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
Loading
Loading