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
178 changes: 164 additions & 14 deletions src/stores/categories.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import _ from 'lodash';
import {
saveClasses,
loadClasses,
saveCategories,
loadCategories,
cleanCategory,
defaultCategories,
build_category_hierarchy,
createMissingParents,
mergeCategorySets,
annotate,
Category,
CategorySet,
Rule,
} from '~/util/classes';
import { getColorFromCategory } from '~/util/color';
Expand All @@ -16,6 +19,10 @@ import { defineStore } from 'pinia';
interface State {
classes: Category[];
classes_unsaved_changes: boolean;
// Category sets — named collections of category rules
category_sets: CategorySet[];
// Ordered list of active set IDs; first entry has highest priority when merging
active_set_ids: string[];
}

function getScoreFromCategory(c: Category, allCats: Category[]): number {
Expand Down Expand Up @@ -45,10 +52,44 @@ function normalizeSegments(cat: string[]): string[] {
});
}

function assignIds(classes: Category[]): Category[] {
let i = 0;
return classes.map(c => Object.assign(c, { id: i++ }));
}

/** Recompute the effective `classes` list from the provided active sets. */
function computeEffectiveClasses(categorySets: CategorySet[], activeSetIds: string[]): Category[] {
const activeSets = categorySets.filter(s => activeSetIds.includes(s.id));
const merged = mergeCategorySets(activeSets);
return assignIds(createMissingParents(merged));
}

/**
* Copy current effective classes back into the primary active set.
*
* Only safe when exactly one set is active: with multiple sets `state.classes`
* is the merged result of all active sets and cannot be split back into
* individual sets, so we skip the sync to avoid corrupting secondary sets.
*/
function syncToPrimarySet(state: State) {
if (state.active_set_ids.length === 0 || state.category_sets.length === 0) return;
// Skip when multiple sets are active — state.classes is a merged result
// and writing it back to only the primary set would absorb all secondary
// sets' categories into it (data corruption).
if (state.active_set_ids.length > 1) return;
const primaryId = state.active_set_ids[0];
const primarySet = state.category_sets.find(s => s.id === primaryId);
if (primarySet) {
primarySet.categories = state.classes.map(cleanCategory);
}
}

export const useCategoryStore = defineStore('categories', {
state: (): State => ({
classes: [],
classes_unsaved_changes: false,
category_sets: [],
active_set_ids: ['default'],
}),

// getters
Expand Down Expand Up @@ -140,22 +181,134 @@ export const useCategoryStore = defineStore('categories', {
},

actions: {
load(this: State, classes: Category[] = null) {
if (classes === null) {
classes = loadClasses();
/**
* Load categories into the store.
*
* When called with an explicit `classes` array (e.g. in tests), those categories are loaded
* directly without touching category sets.
*
* When called without arguments, loads from the settings store — including multi-set support.
* Falls back to the legacy flat `classes` setting if no sets are defined yet.
*/
load(this: State, classes?: Category[]) {
if (classes !== undefined) {
// Explicit categories provided (test / programmatic path)
classes = createMissingParents(classes);
this.classes = assignIds(classes);
this.classes_unsaved_changes = false;
return;
}
classes = createMissingParents(classes);

let i = 0;
this.classes = classes.map(c => Object.assign(c, { id: i++ }));
// Load sets from settings store
const { sets, activeIds } = loadCategories();
this.category_sets = sets;
this.active_set_ids = activeIds;

// Compute effective classes from active sets (merged in priority order)
this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids);
this.classes_unsaved_changes = false;
},

save(this: State) {
// Sync current classes back to the primary active set before persisting
syncToPrimarySet(this);
saveCategories(this.category_sets, this.active_set_ids);
// Also update legacy flat classes field for backwards compatibility
saveClasses(this.classes);
this.classes_unsaved_changes = false;
},

// ── Category set management ──────────────────────────────────────────────

/**
* Create a new empty category set.
* The new set is NOT activated automatically — call switchToSet() after if needed.
*/
createSet(this: State, id: string) {
if (this.category_sets.find(s => s.id === id)) {
console.warn('Category set already exists:', id);
return;
}
this.category_sets.push({ id, categories: [] });
},

/**
* Delete a category set by ID.
* The last remaining set cannot be deleted.
*/
deleteSet(this: State, id: string) {
if (this.category_sets.length <= 1) {
console.warn('Cannot delete the last category set');
return;
}
this.category_sets = this.category_sets.filter(s => s.id !== id);
this.active_set_ids = this.active_set_ids.filter(aid => aid !== id);
if (this.active_set_ids.length === 0) {
this.active_set_ids = [this.category_sets[0].id];
}
this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids);
this.classes_unsaved_changes = true;
},

/**
* Switch to a single active set by ID.
* Saves the current classes to the previously active set first.
*/
switchToSet(this: State, id: string) {
if (!this.category_sets.find(s => s.id === id)) {
console.warn('Category set not found:', id);
return;
}
syncToPrimarySet(this);
this.active_set_ids = [id];
this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids);
this.classes_unsaved_changes = false;
},
save() {
const r = saveClasses(this.classes);

/**
* Discard unsaved in-memory edits and recompute classes from the stored sets.
*
* Call this before switchToSet() when the user explicitly chooses to discard
* changes. Without this, switchToSet() would call syncToPrimarySet() first —
* writing the discarded edits back into the set's in-memory state.
*/
discardChanges(this: State) {
this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids);
this.classes_unsaved_changes = false;
return r;
},

/**
* Set multiple active sets (combined in priority order).
* The first ID in the list is the primary set (edits go here).
*/
setActiveSets(this: State, ids: string[]) {
syncToPrimarySet(this);
this.active_set_ids = ids;
this.classes = computeEffectiveClasses(this.category_sets, this.active_set_ids);
this.classes_unsaved_changes = true;
},

/**
* Rename a category set.
*/
renameSet(this: State, oldId: string, newId: string) {
if (newId === oldId) return;
if (this.category_sets.find(s => s.id === newId)) {
console.warn('A set with that name already exists:', newId);
return;
}
const set = this.category_sets.find(s => s.id === oldId);
if (!set) {
console.warn('Category set not found:', oldId);
return;
}
set.id = newId;
this.active_set_ids = this.active_set_ids.map(id => (id === oldId ? newId : id));
this.classes_unsaved_changes = true;
},

// ── Legacy mutations (operate on the effective `classes` list) ───────────

// mutations
import(this: State, classes: Category[]) {
let i = 0;
Expand Down Expand Up @@ -212,10 +365,7 @@ export const useCategoryStore = defineStore('categories', {
this.classes_unsaved_changes = true;
},
restoreDefaultClasses(this: State) {
let i = 0;
this.classes = createMissingParents(defaultCategories).map(c =>
Object.assign(c, { id: i++ })
);
this.classes = assignIds(createMissingParents(defaultCategories));
this.classes_unsaved_changes = true;
},
clearAll(this: State) {
Expand Down
17 changes: 15 additions & 2 deletions src/stores/settings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import moment, { Moment } from 'moment';
import { getClient } from '~/util/awclient';
import { Category, defaultCategories, cleanCategory } from '~/util/classes';
import { Category, CategorySet, defaultCategories, cleanCategory } from '~/util/classes';
import { View, defaultViews } from '~/stores/views';
import { isEqual } from 'lodash';

Expand Down Expand Up @@ -37,6 +37,11 @@ interface State {
};
always_active_pattern: string;
classes: Category[];
// Named category sets — each set is an independent collection of category rules.
// The active_set_ids list controls which sets are combined (in priority order).
category_sets: CategorySet[];
// Ordered list of active set IDs. First entry has highest priority when merging.
active_set_ids: string[];
views: View[];

// Whether to show certain WIP features
Expand Down Expand Up @@ -75,6 +80,8 @@ export const useSettingsStore = defineStore('settings', {

always_active_pattern: '',
classes: defaultCategories,
category_sets: [],
active_set_ids: ['default'],
views: defaultViews,

// Developer settings
Expand Down Expand Up @@ -125,7 +132,13 @@ export const useSettingsStore = defineStore('settings', {
//console.debug(`${locstr} ${key}:`, value);

// Keys ending with 'Data' are JSON-serialized objects in localStorage
if ((key.endsWith('Data') || key == 'views' || key == 'classes') && !set_in_server) {
const isJsonKey =
key.endsWith('Data') ||
key == 'views' ||
key == 'classes' ||
key == 'category_sets' ||
key == 'active_set_ids';
if (isJsonKey && !set_in_server) {
try {
value = JSON.parse(value);
// Needed due to https://github.com/ActivityWatch/activitywatch/issues/1067
Expand Down
67 changes: 67 additions & 0 deletions src/util/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ export interface Category {
children?: Category[];
}

export interface CategorySet {
id: string;
categories: Category[];
}

/**
* Merge multiple category sets in priority order (first set = highest priority).
* When the same category name appears in multiple sets, the first occurrence wins.
* Within each set, the standard specificity rule applies (deeper category wins).
*/
export function mergeCategorySets(sets: CategorySet[]): Category[] {
const seen = new Set<string>();
const merged: Category[] = [];
for (const set of sets) {
for (const cat of set.categories) {
const key = JSON.stringify(cat.name);
if (!seen.has(key)) {
seen.add(key);
merged.push(cat);
}
}
}
return merged;
}

const COLOR_UNCAT = '#CCC';

// The default categories
Expand Down Expand Up @@ -198,6 +223,48 @@ export function loadClasses(): Category[] {
return settingsStore.classes;
}

/**
* Persist category sets and active set IDs to the settings store.
* Also updates the legacy `classes` field for backwards compatibility with external readers.
*/
export function saveCategories(sets: CategorySet[], activeIds: string[]) {
if (areWeTesting()) {
console.log('Not saving categories in test mode');
return;
}
const settingsStore = useSettingsStore();
const cleanSets = sets.map(s => ({ ...s, categories: s.categories.map(cleanCategory) }));
const effectiveClasses = mergeCategorySets(sets.filter(s => activeIds.includes(s.id))).map(
cleanCategory
);
settingsStore.update({
category_sets: cleanSets,
active_set_ids: activeIds,
classes: effectiveClasses,
});
}

/**
* Load category sets and active set IDs from the settings store.
* Falls back to the legacy flat `classes` setting if no sets are defined yet.
*/
export function loadCategories(): { sets: CategorySet[]; activeIds: string[] } {
const settingsStore = useSettingsStore();
const sets: CategorySet[] = settingsStore.category_sets;
const activeIds: string[] = settingsStore.active_set_ids;

if (sets && sets.length > 0) {
return { sets, activeIds: activeIds && activeIds.length > 0 ? activeIds : [sets[0].id] };
}

// Migration path: no sets defined yet — wrap the existing flat classes into a "default" set
const legacyClasses = settingsStore.classes || defaultCategories;
return {
sets: [{ id: 'default', categories: legacyClasses }],
activeIds: ['default'],
};
}

function pickDeepest(categories: Category[]) {
return _.maxBy(categories, c => c.name.length);
}
Expand Down
Loading
Loading