diff --git a/src/tauri-plugins.d.ts b/src/tauri-plugins.d.ts new file mode 100644 index 00000000..510ab8aa --- /dev/null +++ b/src/tauri-plugins.d.ts @@ -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; +} + +declare module '@tauri-apps/plugin-fs' { + export function writeTextFile(path: string, contents: string): Promise; +} diff --git a/src/util/export.ts b/src/util/export.ts new file mode 100644 index 00000000..a7cffa21 --- /dev/null +++ b/src/util/export.ts @@ -0,0 +1,94 @@ +/** + * Cross-platform file download utility. + * + * In a regular browser, uses the standard 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 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 { + 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 { + 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; + const { save } = (await dynamicImport('@tauri-apps/plugin-dialog')) as { + save: (opts: object) => Promise; + }; + const { writeTextFile } = (await dynamicImport('@tauri-apps/plugin-fs')) as { + writeTextFile: (path: string, contents: string) => Promise; + }; + + 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); +} diff --git a/src/views/Buckets.vue b/src/views/Buckets.vue index 3a87cc0d..1c9100ef 100644 --- a/src/views/Buckets.vue +++ b/src/views/Buckets.vue @@ -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") @@ -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 @@ -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', @@ -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( @@ -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'); }, }, }; diff --git a/src/views/Report.vue b/src/views/Report.vue index 440339f6..e92a1bf7 100644 --- a/src/views/Report.vue +++ b/src/views/Report.vue @@ -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', @@ -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'); }, }, }; diff --git a/src/views/settings/CategorizationSettings.vue b/src/views/settings/CategorizationSettings.vue index 3d3b436d..0b40f16e 100644 --- a/src/views/settings/CategorizationSettings.vue +++ b/src/views/settings/CategorizationSettings.vue @@ -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?'; @@ -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...');