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
25 changes: 17 additions & 8 deletions src/stores/categories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import _ from 'lodash';
import {
saveClasses,
saveCategories,
loadCategories,
cleanCategory,
Expand Down Expand Up @@ -209,12 +208,17 @@ export const useCategoryStore = defineStore('categories', {
this.classes_unsaved_changes = false;
},

save(this: State) {
async 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);
// saveCategories already writes the legacy `classes` field. Do not also
// call saveClasses() — the two settingsStore.update() calls raced and
// could persist an empty/default snapshot (ActivityWatch/aw-android#247).
if (process.env.NODE_ENV === 'test') {
this.classes_unsaved_changes = false;
return;
}
await saveCategories(this.category_sets, this.active_set_ids);
this.classes_unsaved_changes = false;
},

Expand Down Expand Up @@ -311,9 +315,14 @@ export const useCategoryStore = defineStore('categories', {

// mutations
import(this: State, classes: Category[]) {
let i = 0;
// overwrite id even if already set
this.classes = classes.map(c => Object.assign(c, { id: i++ }));
this.classes = assignIds(createMissingParents(classes));
if (this.category_sets.length === 0) {
const setId = this.active_set_ids[0] || 'default';
this.category_sets = [{ id: setId, categories: [] }];
this.active_set_ids = [setId];
}
// Keep the primary set in sync so save() persists the import, not defaults.
syncToPrimarySet(this);
this.classes_unsaved_changes = true;
},
updateClass(this: State, new_class: Category) {
Expand Down
13 changes: 10 additions & 3 deletions src/stores/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ import { isEqual } from 'lodash';
import { AppLocale, i18n, isAppLocale, setAppLocale } from '~/i18n';

function jsonEq(a: any, b: any) {
const jsonA = JSON.parse(JSON.stringify(a));
const jsonB = JSON.parse(JSON.stringify(b));
return isEqual(jsonA, jsonB);
try {
const jsonA = JSON.parse(JSON.stringify(a));
const jsonB = JSON.parse(JSON.stringify(b));
return isEqual(jsonA, jsonB);
} catch (e) {
// Don't abort the whole settings save if one key cannot be serialized
// (circular Vue objects, etc.). Treat as "not equal" so we still attempt POST.
console.error('jsonEq failed', e);
return false;
}
}

let settingsLoadPromise: Promise<void> | null = null;
Expand Down
2 changes: 1 addition & 1 deletion src/util/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export function saveCategories(sets: CategorySet[], activeIds: string[]) {
const effectiveClasses = mergeCategorySets(sets.filter(s => activeIds.includes(s.id))).map(
cleanCategory
);
settingsStore.update({
return settingsStore.update({
category_sets: cleanSets,
active_set_ids: activeIds,
classes: effectiveClasses,
Expand Down
39 changes: 39 additions & 0 deletions src/util/importFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Category-import file helpers.
*
* Android's Storage Access Framework often reports `.json` files as
* `application/octet-stream`, empty, or `text/plain` instead of
* `application/json`. Rejecting on MIME type alone makes in-app import
* silently no-op (ActivityWatch/aw-android#247).
*/

export function shouldAttemptJsonImport(file: { name?: string; type?: string }): boolean {
const type = (file.type || '').toLowerCase();
if (type.startsWith('image/') || type.startsWith('video/') || type.startsWith('audio/')) {
return false;
}
if (type === 'application/json' || type === 'text/json' || type.endsWith('+json')) {
return true;
}
if (/\.json$/i.test(file.name || '')) {
return true;
}
// Android SAF / WebView File.type is often empty or octet-stream, sometimes
// without a .json display name. Try parse; the caller surfaces JSON errors.
return type === '' || type === 'application/octet-stream' || type === 'text/plain';
}

export function parseCategoryImport(text: string): { categories: unknown[]; id?: string } {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object') {
throw new Error('Unrecognized import format');
}
if (Array.isArray((parsed as { categories?: unknown }).categories)) {
const obj = parsed as { categories: unknown[]; id?: unknown };
return {
categories: obj.categories,
id: typeof obj.id === 'string' ? obj.id : undefined,
};
}
throw new Error('Unrecognized import format');
}
35 changes: 27 additions & 8 deletions src/views/settings/CategorizationSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ div
| {{ $t('settings.categorization.restoreDefaults') }}
label.btn.btn-sm.ml-1.btn-outline-primary(style="margin: 0")
| {{ $t('common.import') }}
input(type="file" @change="importCategories" hidden)
input(type="file" accept=".json,application/json" @change="importCategories" hidden)
b-btn.ml-1(@click="exportClasses", variant="outline-primary" size="sm")
| {{ $t('common.export') }}

Expand Down Expand Up @@ -97,6 +97,7 @@ import 'vue-awesome/icons/angle-double-up';
import { useCategoryStore } from '~/stores/categories';

import { downloadFile } from '~/util/export';
import { parseCategoryImport, shouldAttemptJsonImport } from '~/util/importFile';

export default {
name: 'CategorizationSettings',
Expand Down Expand Up @@ -155,7 +156,17 @@ export default {
this.editingId = lastId;
},
saveClasses: async function () {
await this.categoryStore.save();
try {
await this.categoryStore.save();
} catch (e) {
console.error('Failed to save categories', e);
const httpStatus = e && e.response && e.response.status;
const detail = (e && e.message) || String(e);
const prefix = httpStatus
? `Failed to save categories (HTTP ${httpStatus})`
: 'Failed to save categories';
alert(`${prefix}: ${detail}`);
}
},
resetClasses: async function () {
await this.categoryStore.load();
Expand All @@ -174,13 +185,23 @@ export default {
},
importCategories: async function (elem) {
const file = elem.target.files[0];
if (file.type != 'application/json') {
console.error('Only JSON files are possible to import');
if (!file) return;
// Reset so picking the same file again retriggers change.
elem.target.value = '';

if (!shouldAttemptJsonImport(file)) {
alert('Please select a JSON category export, not an image or other file type.');
return;
}

const text = await file.text();
const import_obj = JSON.parse(text);
let import_obj;
try {
import_obj = parseCategoryImport(await file.text());
} catch (e) {
console.error('Failed to parse category import', e);
alert('Could not import categories: file is not a valid JSON category export.');
return;
}

if (import_obj.categories && !import_obj.id) {
this.categoryStore.import(import_obj.categories);
Expand All @@ -207,8 +228,6 @@ export default {
this.categoryStore.switchToSet(setId);
}
this.categoryStore.classes_unsaved_changes = true;
} else {
console.error('Unrecognized import format');
}
},
createSet: function () {
Expand Down
60 changes: 60 additions & 0 deletions test/unit/importFile.test.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { parseCategoryImport, shouldAttemptJsonImport } from '~/util/importFile';

describe('shouldAttemptJsonImport', () => {
test('accepts application/json regardless of filename', () => {
expect(shouldAttemptJsonImport({ name: 'rules', type: 'application/json' })).toBe(true);
});

test('accepts .json files Android reports as octet-stream', () => {
expect(
shouldAttemptJsonImport({
name: 'aw-category-export-default.json',
type: 'application/octet-stream',
})
).toBe(true);
});

test('accepts .json files with empty MIME (WebView/SAF)', () => {
expect(shouldAttemptJsonImport({ name: 'cats.json', type: '' })).toBe(true);
});

test('accepts .json files reported as text/plain', () => {
expect(shouldAttemptJsonImport({ name: 'cats.json', type: 'text/plain' })).toBe(true);
});

test('rejects camera/gallery image picks', () => {
expect(shouldAttemptJsonImport({ name: 'IMG_001.jpg', type: 'image/jpeg' })).toBe(false);
});

test('attempts octet-stream without a .json name (JSON.parse decides)', () => {
expect(shouldAttemptJsonImport({ name: 'document', type: 'application/octet-stream' })).toBe(
true
);
});
});

describe('parseCategoryImport', () => {
test('parses named category-set export', () => {
const parsed = parseCategoryImport(
JSON.stringify({ id: 'default', categories: [{ name: ['Work'], rule: { type: 'none' } }] })
);
expect(parsed.id).toBe('default');
expect(parsed.categories).toHaveLength(1);
});

test('parses legacy flat {categories} export', () => {
const parsed = parseCategoryImport(
JSON.stringify({ categories: [{ name: ['Work'], rule: { type: 'none' } }] })
);
expect(parsed.id).toBeUndefined();
expect(parsed.categories).toHaveLength(1);
});

test('rejects JSON that is not a category export', () => {
expect(() => parseCategoryImport('{"foo": 1}')).toThrow(/Unrecognized import format/);
});

test('rejects invalid JSON', () => {
expect(() => parseCategoryImport('{')).toThrow();
});
});
14 changes: 14 additions & 0 deletions test/unit/store/categories.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,18 @@ describe('categories store', () => {
const id = categoryStore.addClass({ name: ['D'], rule: { type: 'none' } });
expect(id).toBe(maxBefore + 1);
});

test('import syncs classes into a primary set so save does not persist defaults', () => {
categoryStore.category_sets = [];
categoryStore.active_set_ids = ['default'];
categoryStore.import([{ name: ['Work', 'Coding'], rule: { type: 'regex', regex: 'code' } }]);
expect(categoryStore.classes_unsaved_changes).toBeTruthy();
expect(categoryStore.category_sets).toHaveLength(1);
expect(categoryStore.category_sets[0].id).toBe('default');
const names = categoryStore.category_sets[0].categories.map(c => c.name);
expect(names).toContainEqual(['Work']);
expect(names).toContainEqual(['Work', 'Coding']);
categoryStore.save();
expect(categoryStore.classes_unsaved_changes).toBeFalsy();
});
});
Loading