Skip to content

Commit 97891e1

Browse files
fix(categories): import JSON from Android SAF and persist a single save (#956)
Android file picks often report .json as octet-stream, so the MIME-only import check silently no-op'd. Import also left category_sets unsynced and save() raced two settings updates, which could write defaults back. ActivityWatch/aw-android#247 Git-Session-Id: f10637f5-76f0-5f0b-ae4a-7a9d86fab73f
1 parent 7141e39 commit 97891e1

7 files changed

Lines changed: 168 additions & 20 deletions

File tree

src/stores/categories.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import _ from 'lodash';
22
import {
3-
saveClasses,
43
saveCategories,
54
loadCategories,
65
cleanCategory,
@@ -209,12 +208,17 @@ export const useCategoryStore = defineStore('categories', {
209208
this.classes_unsaved_changes = false;
210209
},
211210

212-
save(this: State) {
211+
async save(this: State) {
213212
// Sync current classes back to the primary active set before persisting
214213
syncToPrimarySet(this);
215-
saveCategories(this.category_sets, this.active_set_ids);
216-
// Also update legacy flat classes field for backwards compatibility
217-
saveClasses(this.classes);
214+
// saveCategories already writes the legacy `classes` field. Do not also
215+
// call saveClasses() — the two settingsStore.update() calls raced and
216+
// could persist an empty/default snapshot (ActivityWatch/aw-android#247).
217+
if (process.env.NODE_ENV === 'test') {
218+
this.classes_unsaved_changes = false;
219+
return;
220+
}
221+
await saveCategories(this.category_sets, this.active_set_ids);
218222
this.classes_unsaved_changes = false;
219223
},
220224

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

312316
// mutations
313317
import(this: State, classes: Category[]) {
314-
let i = 0;
315-
// overwrite id even if already set
316-
this.classes = classes.map(c => Object.assign(c, { id: i++ }));
318+
this.classes = assignIds(createMissingParents(classes));
319+
if (this.category_sets.length === 0) {
320+
const setId = this.active_set_ids[0] || 'default';
321+
this.category_sets = [{ id: setId, categories: [] }];
322+
this.active_set_ids = [setId];
323+
}
324+
// Keep the primary set in sync so save() persists the import, not defaults.
325+
syncToPrimarySet(this);
317326
this.classes_unsaved_changes = true;
318327
},
319328
updateClass(this: State, new_class: Category) {

src/stores/settings.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,16 @@ import { isEqual } from 'lodash';
99
import { AppLocale, i18n, isAppLocale, setAppLocale } from '~/i18n';
1010

1111
function jsonEq(a: any, b: any) {
12-
const jsonA = JSON.parse(JSON.stringify(a));
13-
const jsonB = JSON.parse(JSON.stringify(b));
14-
return isEqual(jsonA, jsonB);
12+
try {
13+
const jsonA = JSON.parse(JSON.stringify(a));
14+
const jsonB = JSON.parse(JSON.stringify(b));
15+
return isEqual(jsonA, jsonB);
16+
} catch (e) {
17+
// Don't abort the whole settings save if one key cannot be serialized
18+
// (circular Vue objects, etc.). Treat as "not equal" so we still attempt POST.
19+
console.error('jsonEq failed', e);
20+
return false;
21+
}
1522
}
1623

1724
let settingsLoadPromise: Promise<void> | null = null;

src/util/classes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export function saveCategories(sets: CategorySet[], activeIds: string[]) {
266266
const effectiveClasses = mergeCategorySets(sets.filter(s => activeIds.includes(s.id))).map(
267267
cleanCategory
268268
);
269-
settingsStore.update({
269+
return settingsStore.update({
270270
category_sets: cleanSets,
271271
active_set_ids: activeIds,
272272
classes: effectiveClasses,

src/util/importFile.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Category-import file helpers.
3+
*
4+
* Android's Storage Access Framework often reports `.json` files as
5+
* `application/octet-stream`, empty, or `text/plain` instead of
6+
* `application/json`. Rejecting on MIME type alone makes in-app import
7+
* silently no-op (ActivityWatch/aw-android#247).
8+
*/
9+
10+
export function shouldAttemptJsonImport(file: { name?: string; type?: string }): boolean {
11+
const type = (file.type || '').toLowerCase();
12+
if (type.startsWith('image/') || type.startsWith('video/') || type.startsWith('audio/')) {
13+
return false;
14+
}
15+
if (type === 'application/json' || type === 'text/json' || type.endsWith('+json')) {
16+
return true;
17+
}
18+
if (/\.json$/i.test(file.name || '')) {
19+
return true;
20+
}
21+
// Android SAF / WebView File.type is often empty or octet-stream, sometimes
22+
// without a .json display name. Try parse; the caller surfaces JSON errors.
23+
return type === '' || type === 'application/octet-stream' || type === 'text/plain';
24+
}
25+
26+
export function parseCategoryImport(text: string): { categories: unknown[]; id?: string } {
27+
const parsed = JSON.parse(text);
28+
if (!parsed || typeof parsed !== 'object') {
29+
throw new Error('Unrecognized import format');
30+
}
31+
if (Array.isArray((parsed as { categories?: unknown }).categories)) {
32+
const obj = parsed as { categories: unknown[]; id?: unknown };
33+
return {
34+
categories: obj.categories,
35+
id: typeof obj.id === 'string' ? obj.id : undefined,
36+
};
37+
}
38+
throw new Error('Unrecognized import format');
39+
}

src/views/settings/CategorizationSettings.vue

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ div
4444
| {{ $t('settings.categorization.restoreDefaults') }}
4545
label.btn.btn-sm.ml-1.btn-outline-primary(style="margin: 0")
4646
| {{ $t('common.import') }}
47-
input(type="file" @change="importCategories" hidden)
47+
input(type="file" accept=".json,application/json" @change="importCategories" hidden)
4848
b-btn.ml-1(@click="exportClasses", variant="outline-primary" size="sm")
4949
| {{ $t('common.export') }}
5050

@@ -97,6 +97,7 @@ import 'vue-awesome/icons/angle-double-up';
9797
import { useCategoryStore } from '~/stores/categories';
9898
9999
import { downloadFile } from '~/util/export';
100+
import { parseCategoryImport, shouldAttemptJsonImport } from '~/util/importFile';
100101
101102
export default {
102103
name: 'CategorizationSettings',
@@ -155,7 +156,17 @@ export default {
155156
this.editingId = lastId;
156157
},
157158
saveClasses: async function () {
158-
await this.categoryStore.save();
159+
try {
160+
await this.categoryStore.save();
161+
} catch (e) {
162+
console.error('Failed to save categories', e);
163+
const httpStatus = e && e.response && e.response.status;
164+
const detail = (e && e.message) || String(e);
165+
const prefix = httpStatus
166+
? `Failed to save categories (HTTP ${httpStatus})`
167+
: 'Failed to save categories';
168+
alert(`${prefix}: ${detail}`);
169+
}
159170
},
160171
resetClasses: async function () {
161172
await this.categoryStore.load();
@@ -174,13 +185,23 @@ export default {
174185
},
175186
importCategories: async function (elem) {
176187
const file = elem.target.files[0];
177-
if (file.type != 'application/json') {
178-
console.error('Only JSON files are possible to import');
188+
if (!file) return;
189+
// Reset so picking the same file again retriggers change.
190+
elem.target.value = '';
191+
192+
if (!shouldAttemptJsonImport(file)) {
193+
alert('Please select a JSON category export, not an image or other file type.');
179194
return;
180195
}
181196
182-
const text = await file.text();
183-
const import_obj = JSON.parse(text);
197+
let import_obj;
198+
try {
199+
import_obj = parseCategoryImport(await file.text());
200+
} catch (e) {
201+
console.error('Failed to parse category import', e);
202+
alert('Could not import categories: file is not a valid JSON category export.');
203+
return;
204+
}
184205
185206
if (import_obj.categories && !import_obj.id) {
186207
this.categoryStore.import(import_obj.categories);
@@ -207,8 +228,6 @@ export default {
207228
this.categoryStore.switchToSet(setId);
208229
}
209230
this.categoryStore.classes_unsaved_changes = true;
210-
} else {
211-
console.error('Unrecognized import format');
212231
}
213232
},
214233
createSet: function () {

test/unit/importFile.test.node.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { parseCategoryImport, shouldAttemptJsonImport } from '~/util/importFile';
2+
3+
describe('shouldAttemptJsonImport', () => {
4+
test('accepts application/json regardless of filename', () => {
5+
expect(shouldAttemptJsonImport({ name: 'rules', type: 'application/json' })).toBe(true);
6+
});
7+
8+
test('accepts .json files Android reports as octet-stream', () => {
9+
expect(
10+
shouldAttemptJsonImport({
11+
name: 'aw-category-export-default.json',
12+
type: 'application/octet-stream',
13+
})
14+
).toBe(true);
15+
});
16+
17+
test('accepts .json files with empty MIME (WebView/SAF)', () => {
18+
expect(shouldAttemptJsonImport({ name: 'cats.json', type: '' })).toBe(true);
19+
});
20+
21+
test('accepts .json files reported as text/plain', () => {
22+
expect(shouldAttemptJsonImport({ name: 'cats.json', type: 'text/plain' })).toBe(true);
23+
});
24+
25+
test('rejects camera/gallery image picks', () => {
26+
expect(shouldAttemptJsonImport({ name: 'IMG_001.jpg', type: 'image/jpeg' })).toBe(false);
27+
});
28+
29+
test('attempts octet-stream without a .json name (JSON.parse decides)', () => {
30+
expect(shouldAttemptJsonImport({ name: 'document', type: 'application/octet-stream' })).toBe(
31+
true
32+
);
33+
});
34+
});
35+
36+
describe('parseCategoryImport', () => {
37+
test('parses named category-set export', () => {
38+
const parsed = parseCategoryImport(
39+
JSON.stringify({ id: 'default', categories: [{ name: ['Work'], rule: { type: 'none' } }] })
40+
);
41+
expect(parsed.id).toBe('default');
42+
expect(parsed.categories).toHaveLength(1);
43+
});
44+
45+
test('parses legacy flat {categories} export', () => {
46+
const parsed = parseCategoryImport(
47+
JSON.stringify({ categories: [{ name: ['Work'], rule: { type: 'none' } }] })
48+
);
49+
expect(parsed.id).toBeUndefined();
50+
expect(parsed.categories).toHaveLength(1);
51+
});
52+
53+
test('rejects JSON that is not a category export', () => {
54+
expect(() => parseCategoryImport('{"foo": 1}')).toThrow(/Unrecognized import format/);
55+
});
56+
57+
test('rejects invalid JSON', () => {
58+
expect(() => parseCategoryImport('{')).toThrow();
59+
});
60+
});

test/unit/store/categories.test.node.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,18 @@ describe('categories store', () => {
182182
const id = categoryStore.addClass({ name: ['D'], rule: { type: 'none' } });
183183
expect(id).toBe(maxBefore + 1);
184184
});
185+
186+
test('import syncs classes into a primary set so save does not persist defaults', () => {
187+
categoryStore.category_sets = [];
188+
categoryStore.active_set_ids = ['default'];
189+
categoryStore.import([{ name: ['Work', 'Coding'], rule: { type: 'regex', regex: 'code' } }]);
190+
expect(categoryStore.classes_unsaved_changes).toBeTruthy();
191+
expect(categoryStore.category_sets).toHaveLength(1);
192+
expect(categoryStore.category_sets[0].id).toBe('default');
193+
const names = categoryStore.category_sets[0].categories.map(c => c.name);
194+
expect(names).toContainEqual(['Work']);
195+
expect(names).toContainEqual(['Work', 'Coding']);
196+
categoryStore.save();
197+
expect(categoryStore.classes_unsaved_changes).toBeFalsy();
198+
});
185199
});

0 commit comments

Comments
 (0)