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
17 changes: 17 additions & 0 deletions src/tauri-plugins.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Type declarations for Tauri plugins used via dynamic import.
* These modules are only available at runtime inside a Tauri webview.
*/

declare module '@tauri-apps/plugin-dialog' {
interface SaveDialogOptions {
title?: string;
defaultPath?: string;
filters?: Array<{ name: string; extensions: string[] }>;
}
export function save(options?: SaveDialogOptions): Promise<string | null>;
}

declare module '@tauri-apps/plugin-fs' {
export function writeTextFile(path: string, contents: string): Promise<void>;
}
94 changes: 94 additions & 0 deletions src/util/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Cross-platform file download utility.
*
* In a regular browser, uses the standard <a download> pattern.
* In a Tauri webview, uses the dialog + fs plugins to show a native
* save dialog and write the file directly (webviews don't support
* the <a download> pattern).
*
* See: https://github.com/ActivityWatch/aw-tauri/issues/199
*/

interface FileFilter {
name: string;
extensions: string[];
}

function isTauri(): boolean {
return '__TAURI__' in window;
}

/**
* Download/save a file with the given content.
*
* @param filename - Default filename for the download
* @param content - File content as a string
* @param mimeType - MIME type (e.g. 'application/json', 'text/csv')
*/
export async function downloadFile(
filename: string,
content: string,
mimeType: string
): Promise<void> {
if (isTauri()) {
await downloadFileTauri(filename, content, mimeType);
} else {
downloadFileBrowser(filename, content, mimeType);
}
}

function getFilters(mimeType: string): FileFilter[] {
if (mimeType.includes('json')) {
return [{ name: 'JSON', extensions: ['json'] }];
} else if (mimeType.includes('csv')) {
return [{ name: 'CSV', extensions: ['csv'] }];
}
return [];
}

async function downloadFileTauri(
filename: string,
content: string,
mimeType: string
): Promise<void> {
try {
// These modules are only available in the Tauri runtime (injected by aw-tauri).
// Using new Function() to bypass static analysis by webpack and rollup,
// which would otherwise fail to resolve these packages at build time.
// eslint-disable-next-line no-new-func
const dynamicImport = new Function('m', 'return import(m)') as (m: string) => Promise<unknown>;
const { save } = (await dynamicImport('@tauri-apps/plugin-dialog')) as {
save: (opts: object) => Promise<string | null>;
};
const { writeTextFile } = (await dynamicImport('@tauri-apps/plugin-fs')) as {
writeTextFile: (path: string, contents: string) => Promise<void>;
};

const path = await save({
title: 'Save export',
defaultPath: filename,
filters: getFilters(mimeType),
});

if (path) {
await writeTextFile(path, content);
}
} catch (e) {
console.warn('Tauri save failed, falling back to browser download:', e);
// Fall back to browser method if Tauri plugins aren't available
downloadFileBrowser(filename, content, mimeType);
}
}

function downloadFileBrowser(filename: string, content: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
33 changes: 19 additions & 14 deletions src/views/Buckets.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ div
b-dropdown(variant="outline-secondary", size="sm", text="More")
// FIXME: These also exist as almost-copies in the Bucket view, can maybe be shared/reused instead.
b-dropdown-item(
:href="$aw.baseURL + '/api/0/buckets/' + data.item.id + '/export'",
:download="'aw-bucket-export-' + data.item.id + '.json'",
@click="export_bucket_json(data.item.id)",
title="Export bucket to JSON",
variant="secondary")
icon(name="download")
Expand Down Expand Up @@ -100,9 +99,8 @@ div
| A valid file to import is a JSON file from either an export of a single bucket or an export from multiple buckets.
| If there are buckets with the same name the import will fail.
b-card(header="Export buckets")
b-button(:href="$aw.baseURL + '/api/0/export'",
:download="'aw-bucket-export.json'",
title="Export bucket to JSON",
b-button(@click="export_all_buckets_json()",
title="Export all buckets to JSON",
variant="outline-secondary")
icon(name="download")
| Export all buckets as JSON
Expand Down Expand Up @@ -160,6 +158,7 @@ import moment from 'moment';

import { useServerStore } from '~/stores/server';
import { useBucketsStore } from '~/stores/buckets';
import { downloadFile } from '~/util/export';

export default {
name: 'Buckets',
Expand Down Expand Up @@ -255,10 +254,22 @@ export default {
return this.$aw.req.post('/0/import', formData, { headers });
},

async export_bucket_json(bucketId: string) {
const response = await this.$aw.req.get(`/0/buckets/${bucketId}/export`);
const data = JSON.stringify(response.data, null, 2);
await downloadFile(`aw-bucket-export-${bucketId}.json`, data, 'application/json');
},

async export_all_buckets_json() {
const response = await this.$aw.req.get('/0/export');
const data = JSON.stringify(response.data, null, 2);
await downloadFile('aw-bucket-export.json', data, 'application/json');
},

async export_csv(bucketId: string) {
const bucket = await this.bucketsStore.getBucketWithEvents({ id: bucketId });
const events = bucket.events;
const datakeys = Object.keys(events[0].data);
const datakeys = events.length > 0 ? Object.keys(events[0].data) : [];
const columns = ['timestamp', 'duration'].concat(datakeys);
const data = events.map(e => {
return Object.assign(
Expand All @@ -267,16 +278,10 @@ export default {
);
});
const csv = Papa.unparse(data, { columns, header: true });
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `aw-events-export-${bucketId}-${new Date()
const filename = `aw-events-export-${bucketId}-${new Date()
.toISOString()
.substring(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
await downloadFile(filename, csv, 'text/csv');
},
},
};
Expand Down
23 changes: 5 additions & 18 deletions src/views/Report.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import { useCategoryStore } from '~/stores/categories';
import { useBucketsStore } from '~/stores/buckets';

import { getClient } from '~/util/awclient';
import { downloadFile } from '~/util/export';

export default {
name: 'Report',
Expand Down Expand Up @@ -161,33 +162,19 @@ export default {
}
},

export_json() {
async export_json() {
const data = JSON.stringify(this.events, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'events.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
await downloadFile('events.json', data, 'application/json');
},

export_csv() {
async export_csv() {
const data = this.events.map(e => {
return [e.timestamp, e.duration, e.data['$category'], e.data['app'], e.data['title']];
});
const csv = Papa.unparse(data, {
columns: ['timestamp', 'duration', 'category', 'app', 'title'],
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'events.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
await downloadFile('events.csv', csv, 'text/csv');
},
},
};
Expand Down
18 changes: 3 additions & 15 deletions src/views/settings/CategorizationSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import 'vue-awesome/icons/undo';
import { useCategoryStore } from '~/stores/categories';

import _ from 'lodash';
import { downloadFile } from '~/util/export';

const confirmationMessage = 'Your categories have unsaved changes, are you sure you want to leave?';

Expand Down Expand Up @@ -99,27 +100,14 @@ export default {
hideEditModal: function () {
this.editingId = null;
},
exportClasses: function () {
exportClasses: async function () {
console.log('Exporting categories...');

const export_data = {
categories: this.categoryStore.classes,
};
// Pretty-format it for easier reading
const text = JSON.stringify(export_data, null, 2);
const filename = 'aw-category-export.json';

// Initiate downloading a file by creating a hidden button and clicking it
const element = document.createElement('a');
element.setAttribute(
'href',
'data:application/json;charset=utf-8,' + encodeURIComponent(text)
);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
await downloadFile('aw-category-export.json', text, 'application/json');
},
importCategories: async function (elem) {
console.log('Importing categories...');
Expand Down
Loading