diff --git a/doc/android-filesaver-plugin.md b/doc/android-filesaver-plugin.md new file mode 100644 index 00000000..2c1180b2 --- /dev/null +++ b/doc/android-filesaver-plugin.md @@ -0,0 +1,251 @@ +# Android File Saver Plugin Implementation Guide + +## Overview + +This document outlines the implementation of the `AndroidFileSaver` Capacitor plugin that provides Storage Access Framework (SAF) and MediaStore functionality for direct file saving on Android devices. + +## Plugin Purpose + +The `AndroidFileSaver` plugin enables two key file operations: +1. **`saveToDownloads`**: Direct save to Downloads folder using MediaStore (API 29+) +2. **`saveAs`**: User-chosen location using Storage Access Framework (SAF) + +## Implementation Requirements + +### 1. Plugin Structure + +```typescript +// Plugin interface +interface AndroidFileSaverPlugin { + saveToDownloads(options: { + fileName: string; + content: string; + mimeType: string + }): Promise<{ + success: boolean; + path?: string; + error?: string + }>; + + saveAs(options: { + fileName: string; + content: string; + mimeType: string + }): Promise<{ + success: boolean; + path?: string; + error?: string + }>; +} +``` + +### 2. Android Implementation + +#### MediaStore for Downloads (API 29+) + +```java +@PluginMethod +public void saveToDownloads(PluginCall call) { + String fileName = call.getString("fileName"); + String content = call.getString("content"); + String mimeType = call.getString("mimeType"); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Use MediaStore for Downloads + ContentValues values = new ContentValues(); + values.put(MediaStore.Downloads.DISPLAY_NAME, fileName); + values.put(MediaStore.Downloads.MIME_TYPE, mimeType); + values.put(MediaStore.Downloads.IS_PENDING, 1); + + ContentResolver resolver = getContext().getContentResolver(); + Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); + + if (uri != null) { + try (ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "w")) { + if (pfd != null) { + try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) { + fos.write(content.getBytes()); + fos.flush(); + + // Mark as no longer pending + values.clear(); + values.put(MediaStore.Downloads.IS_PENDING, 0); + resolver.update(uri, values, null, null); + + call.resolve(new JSObject() + .put("success", true) + .put("path", uri.toString())); + return; + } + } + } catch (IOException e) { + resolver.delete(uri, null, null); + } + } + + call.resolve(new JSObject() + .put("success", false) + .put("error", "Failed to save file")); + } else { + // Fallback for older Android versions + call.resolve(new JSObject() + .put("success", false) + .put("error", "Requires Android API 29+")); + } +} +``` + +#### Storage Access Framework (SAF) for Save As + +```java +@PluginMethod +public void saveAs(PluginCall call) { + String fileName = call.getString("fileName"); + String content = call.getString("content"); + String mimeType = call.getString("mimeType"); + + // Store call for later use + bridge.saveCall(call); + + // Create intent for SAF + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(mimeType); + intent.putExtra(Intent.EXTRA_TITLE, fileName); + + // Start activity for result + bridge.startActivityForResult(call, intent, "saveAsResult"); +} + +@ActivityCallback +private void saveAsResult(PluginCall call, ActivityResult result) { + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + Uri uri = result.getData().getData(); + String content = call.getString("content"); + + try (ParcelFileDescriptor pfd = getContext().getContentResolver() + .openFileDescriptor(uri, "w")) { + if (pfd != null) { + try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) { + fos.write(content.getBytes()); + fos.flush(); + + call.resolve(new JSObject() + .put("success", true) + .put("path", uri.toString())); + return; + } + } + } catch (IOException e) { + // Handle error + } + + call.resolve(new JSObject() + .put("success", false) + .put("error", "Failed to save file")); + } else { + call.resolve(new JSObject() + .put("success", false) + .put("error", "User cancelled or failed")); + } +} +``` + +### 3. Plugin Registration + +```java +@CapacitorPlugin(name = "AndroidFileSaver") +public class AndroidFileSaverPlugin extends Plugin { + // Implementation methods here +} +``` + +## Integration Steps + +### 1. Create Plugin Project + +```bash +# Create new Capacitor plugin +npx @capacitor/cli plugin:generate android-file-saver +cd android-file-saver +``` + +### 2. Add to Android Project + +```bash +# In your main project +npm install ./android-file-saver +npx cap sync android +``` + +### 3. Update Android Manifest + +Ensure the following permissions are present: + +```xml + + +``` + +## Fallback Behavior + +When the plugin is not available, the system falls back to: +1. **Web**: Browser download mechanism +2. **Electron**: Native file save via IPC +3. **Capacitor**: Share dialog (existing behavior) + +## Testing + +### 1. Plugin Availability + +```typescript +// Check if plugin is available +if (AndroidFileSaver) { + // Plugin available - use native methods +} else { + // Plugin not available - use fallback +} +``` + +### 2. Error Handling + +```typescript +try { + const result = await AndroidFileSaver.saveToDownloads({ + fileName: "test.json", + content: '{"test": "data"}', + mimeType: "application/json" + }); + + if (result.success) { + logger.info("File saved:", result.path); + } else { + logger.error("Save failed:", result.error); + } +} catch (error) { + logger.error("Plugin error:", error); +} +``` + +## Security Considerations + +1. **Content Validation**: Validate file content before saving +2. **MIME Type Verification**: Ensure MIME type matches file content +3. **Permission Handling**: Request storage permissions appropriately +4. **Error Logging**: Log errors without exposing sensitive data + +## Future Enhancements + +1. **Progress Callbacks**: Add progress reporting for large files +2. **Batch Operations**: Support saving multiple files +3. **Custom Locations**: Allow saving to app-specific directories +4. **File Compression**: Add optional file compression + +## References + +- [Android Storage Access Framework](https://developer.android.com/guide/topics/providers/document-provider) +- [MediaStore Downloads](https://developer.android.com/reference/android/provider/MediaStore.Downloads) +- [Capacitor Plugin Development](https://capacitorjs.com/docs/plugins/android) +- [Android Scoped Storage](https://developer.android.com/training/data-storage) diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue index 61bf4753..ac894856 100644 --- a/src/components/DataExportSection.vue +++ b/src/components/DataExportSection.vue @@ -19,33 +19,46 @@ messages * - Conditional UI based on platform capabilities * * @component * Backup Identifier Seed - +
+ + + +
-

- After the export, you can save the file in your preferred storage - location. -

+

Choose how you want to export your contacts:

    +
  • + Share Contacts: Opens the system share dialog to send + to apps like Gmail, Drive, or messaging apps. +
  • +
  • + Save to Device: Saves directly to your device's + Downloads folder (Android) or Documents folder (iOS). +
  • - On iOS: You will be prompted to choose a location to save your backup - file. + On iOS: Files are saved to the Files app in your Documents folder.
  • - On Android: You will be prompted to choose a location to save your - backup file. + On Android: Files are saved directly to your Downloads folder.
@@ -139,6 +152,20 @@ export default class DataExportSection extends Vue { return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"; } + /** + * CSS classes for the share button + */ + get shareButtonClasses(): string { + return "block w-full text-center text-md bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"; + } + + /** + * CSS classes for the save to device button + */ + get saveButtonClasses(): string { + return "block w-full text-center text-md bg-gradient-to-b from-purple-400 to-purple-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"; + } + /** * CSS classes for the instructions container */ @@ -216,6 +243,100 @@ export default class DataExportSection extends Vue { } } + /** + * Shares contacts data using the platform's share functionality + * Opens the system share dialog for app-to-app handoff + * + * @throws {Error} If sharing fails + */ + public async shareContacts(): Promise { + if (this.isExporting) { + return; // Prevent multiple simultaneous exports + } + + try { + this.isExporting = true; + + // Fetch contacts from database using mixin's cached method + const allContacts = await this.$contacts(); + + // Convert contacts to export format + const processedContacts: Contact[] = allContacts.map((contact) => { + const exContact: Contact = R.omit(["contactMethods"], contact); + exContact.contactMethods = contact.contactMethods + ? typeof contact.contactMethods === "string" && + contact.contactMethods.trim() !== "" + ? JSON.parse(contact.contactMethods) + : [] + : []; + return exContact; + }); + + const exportData = contactsToExportJson(processedContacts); + const jsonStr = JSON.stringify(exportData, null, 2); + + // Use platform service to share the file + await this.platformService.writeAndShareFile(this.fileName, jsonStr); + + this.notify.success( + "Contact sharing completed successfully. Use the share dialog to send to your preferred app.", + ); + } catch (error) { + logger.error("Share Error:", error); + this.notify.error( + `There was an error sharing the data: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + this.isExporting = false; + } + } + + /** + * Saves contacts data directly to the device's storage + * Uses platform-specific save methods (Downloads folder, Documents, etc.) + * + * @throws {Error} If saving fails + */ + public async saveContactsToDevice(): Promise { + if (this.isExporting) { + return; // Prevent multiple simultaneous exports + } + + try { + this.isExporting = true; + + // Fetch contacts from database using mixin's cached method + const allContacts = await this.$contacts(); + + // Convert contacts to export format + const processedContacts: Contact[] = allContacts.map((contact) => { + const exContact: Contact = R.omit(["contactMethods"], contact); + exContact.contactMethods = contact.contactMethods + ? typeof contact.contactMethods === "string" && + contact.contactMethods.trim() !== "" + ? JSON.parse(contact.contactMethods) + : [] + : []; + return exContact; + }); + + const exportData = contactsToExportJson(processedContacts); + const jsonStr = JSON.stringify(exportData, null, 2); + + // Use platform service to save directly to device + await this.platformService.saveToDevice(this.fileName, jsonStr); + + this.notify.success("Contact data saved successfully to your device."); + } catch (error) { + logger.error("Save Error:", error); + this.notify.error( + `There was an error saving the data: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + this.isExporting = false; + } + } + created() { this.notify = createNotifyHelpers(this.$notify); } diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts index a3dbff6a..3869f9a9 100644 --- a/src/services/PlatformService.ts +++ b/src/services/PlatformService.ts @@ -88,6 +88,24 @@ export interface PlatformService { */ writeAndShareFile(fileName: string, content: string): Promise; + /** + * Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS). + * Uses MediaStore on Android API 29+ and falls back to SAF on older versions. + * @param fileName - The filename of the file to save + * @param content - The content to write to the file + * @returns Promise that resolves when the file is saved + */ + saveToDevice(fileName: string, content: string): Promise; + + /** + * Opens the system file picker to let the user choose where to save a file. + * Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms. + * @param fileName - The suggested filename for the file + * @param content - The content to write to the file + * @returns Promise that resolves when the file is saved + */ + saveAs(fileName: string, content: string): Promise; + /** * Deletes a file at the specified path. * @param path - The path to the file to delete diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index bd22ef8d..ad256867 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -14,6 +14,65 @@ import { DBSQLiteValues, } from "@capacitor-community/sqlite"; +// Android-specific imports for SAF and MediaStore +import { registerPlugin } from "@capacitor/core"; + +// Define interfaces for Android-specific functionality +interface AndroidFileSaverPlugin { + saveToDownloads(options: { + fileName: string; + content: string; + mimeType: string; + }): Promise<{ success: boolean; path?: string; error?: string }>; + saveAs(options: { + fileName: string; + content: string; + mimeType: string; + }): Promise<{ success: boolean; path?: string; error?: string }>; +} + +// Register the plugin (will be undefined if not available) +const AndroidFileSaver = + registerPlugin("AndroidFileSaver"); + +// Fallback implementation for when the plugin is not available +class AndroidFileSaverFallback implements AndroidFileSaverPlugin { + async saveToDownloads(options: { + fileName: string; + content: string; + mimeType: string; + }): Promise<{ success: boolean; path?: string; error?: string }> { + logger.warn( + "[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback", + { + fileName: options.fileName, + mimeType: options.mimeType, + contentLength: options.content.length, + }, + ); + return { success: false, error: "AndroidFileSaver plugin not available" }; + } + + async saveAs(options: { + fileName: string; + content: string; + mimeType: string; + }): Promise<{ success: boolean; path?: string; error?: string }> { + logger.warn( + "[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback", + { + fileName: options.fileName, + mimeType: options.mimeType, + contentLength: options.content.length, + }, + ); + return { success: false, error: "AndroidFileSaver plugin not available" }; + } +} + +// Use fallback if plugin is not available +const AndroidFileSaverImpl = AndroidFileSaver || new AndroidFileSaverFallback(); + import { runMigrations } from "@/db-sql/migration"; import { QueryExecResult } from "@/interfaces/database"; import { @@ -460,7 +519,7 @@ export class CapacitorPlatformService implements PlatformService { * ## Logging: * * Detailed logging is provided throughout the process using emoji-tagged - * console messages that appear in the Electron DevTools console. This + * log messages that appear in the Electron DevTools. This * includes: * - SQL statement execution details * - Parameter values for debugging @@ -1096,6 +1155,175 @@ export class CapacitorPlatformService implements PlatformService { } } + /** + * Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS). + * Uses MediaStore on Android API 29+ and falls back to SAF on older versions. + * + * @param fileName - The filename of the file to save + * @param content - The content to write to the file + * @returns Promise that resolves when the file is saved + */ + async saveToDevice(fileName: string, content: string): Promise { + const timestamp = new Date().toISOString(); + const logData = { + action: "saveToDevice", + fileName, + contentLength: content.length, + platform: this.getCapabilities().isIOS ? "iOS" : "Android", + timestamp, + }; + logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); + + try { + if (this.getCapabilities().isIOS) { + // iOS: Use Filesystem to save to Documents directory + const { uri } = await Filesystem.writeFile({ + path: fileName, + data: content, + directory: Directory.Documents, + encoding: Encoding.UTF8, + recursive: true, + }); + + logger.log("[CapacitorPlatformService] File saved to iOS Documents:", { + uri, + timestamp: new Date().toISOString(), + }); + } else { + // Android: Try to use native MediaStore/SAF implementation + const result = await AndroidFileSaverImpl.saveToDownloads({ + fileName, + content, + mimeType: this.getMimeType(fileName), + }); + + if (result.success) { + logger.log( + "[CapacitorPlatformService] File saved to Android Downloads:", + { + path: result.path, + timestamp: new Date().toISOString(), + }, + ); + } else { + throw new Error(`Failed to save to Downloads: ${result.error}`); + } + } + } catch (error) { + const err = error as Error; + const errLog = { + message: err.message, + stack: err.stack, + timestamp: new Date().toISOString(), + }; + logger.error( + "[CapacitorPlatformService] Error saving file to device:", + JSON.stringify(errLog, null, 2), + ); + throw new Error(`Failed to save file to device: ${err.message}`); + } + } + + /** + * Opens the system file picker to let the user choose where to save a file. + * Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms. + * + * @param fileName - The suggested filename for the file + * @param content - The content to write to the file + * @returns Promise that resolves when the file is saved + */ + async saveAs(fileName: string, content: string): Promise { + const timestamp = new Date().toISOString(); + const logData = { + action: "saveAs", + fileName, + contentLength: content.length, + platform: this.getCapabilities().isIOS ? "iOS" : "Android", + timestamp, + }; + logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); + + try { + if (this.getCapabilities().isIOS) { + // iOS: Use Filesystem to save to Documents directory with user choice + const { uri } = await Filesystem.writeFile({ + path: fileName, + data: content, + directory: Directory.Documents, + encoding: Encoding.UTF8, + recursive: true, + }); + + logger.log("[CapacitorPlatformService] File saved to iOS Documents:", { + uri, + timestamp: new Date().toISOString(), + }); + } else { + // Android: Use SAF to let user choose location + const result = await AndroidFileSaverImpl.saveAs({ + fileName, + content, + mimeType: this.getMimeType(fileName), + }); + + if (result.success) { + logger.log("[CapacitorPlatformService] File saved via SAF:", { + path: result.path, + timestamp: new Date().toISOString(), + }); + } else { + throw new Error(`Failed to save via SAF: ${result.error}`); + } + } + } catch (error) { + const err = error as Error; + const errLog = { + message: err.message, + stack: err.stack, + timestamp: new Date().toISOString(), + }; + logger.error( + "[CapacitorPlatformService] Error saving file as:", + JSON.stringify(errLog, null, 2), + ); + throw new Error(`Failed to save file as: ${err.message}`); + } + } + + /** + * Determines the MIME type for a given filename based on its extension. + * + * @param fileName - The filename to determine MIME type for + * @returns The MIME type string + */ + private getMimeType(fileName: string): string { + const extension = fileName.split(".").pop()?.toLowerCase(); + + switch (extension) { + case "json": + return "application/json"; + case "txt": + return "text/plain"; + case "csv": + return "text/csv"; + case "pdf": + return "application/pdf"; + case "xml": + return "application/xml"; + case "html": + return "text/html"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "png": + return "image/png"; + case "gif": + return "image/gif"; + default: + return "application/octet-stream"; + } + } + /** * Deletes a file from the app's data directory. * @param path - Relative path to the file to delete diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index 1a077c65..3d68d987 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/src/services/platforms/ElectronPlatformService.ts @@ -146,6 +146,129 @@ export class ElectronPlatformService extends CapacitorPlatformService { return true; } + /** + * Saves content directly to the device's Downloads folder (Electron platform). + * Uses Electron's IPC to save files directly to the Downloads directory. + * + * @param fileName - The filename of the file to save + * @param content - The content to write to the file + * @returns Promise that resolves when the file is saved + */ + async saveToDevice(fileName: string, content: string): Promise { + logger.info( + `[ElectronPlatformService] Using native IPC for direct file save: ${fileName}`, + ); + + try { + // Check if we're running in Electron with the API available + if (typeof window !== "undefined" && window.electronAPI) { + // Use the native Electron IPC API for file exports + const result = await window.electronAPI.exportData(fileName, content); + + if (result.success) { + logger.info( + `[ElectronPlatformService] File saved successfully to: ${result.path}`, + ); + logger.info( + `[ElectronPlatformService] File saved to Downloads folder: ${fileName}`, + ); + } else { + logger.error( + `[ElectronPlatformService] Native save failed: ${result.error}`, + ); + throw new Error(`Native file save failed: ${result.error}`); + } + } else { + // Fallback to web-style download if Electron API is not available + logger.warn( + "[ElectronPlatformService] Electron API not available, falling back to web download", + ); + + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const downloadLink = document.createElement("a"); + downloadLink.href = url; + downloadLink.download = fileName; + downloadLink.style.display = "none"; + + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + + setTimeout(() => URL.revokeObjectURL(url), 1000); + + logger.info( + `[ElectronPlatformService] Fallback download initiated: ${fileName}`, + ); + } + } catch (error) { + logger.error("[ElectronPlatformService] File save failed:", error); + throw new Error(`Failed to save file: ${error}`); + } + } + + /** + * Opens the system file picker to let the user choose where to save a file (Electron platform). + * Uses Electron's IPC to show the native save dialog. + * + * @param fileName - The suggested filename for the file + * @param content - The content to write to the file + * @returns Promise that resolves when the file is saved + */ + async saveAs(fileName: string, content: string): Promise { + logger.info( + `[ElectronPlatformService] Using native IPC for save as dialog: ${fileName}`, + ); + + try { + // Check if we're running in Electron with the API available + if (typeof window !== "undefined" && window.electronAPI) { + // Use the native Electron IPC API for file exports (same as saveToDevice for now) + // TODO: Implement native save dialog when available + const result = await window.electronAPI.exportData(fileName, content); + + if (result.success) { + logger.info( + `[ElectronPlatformService] File saved successfully to: ${result.path}`, + ); + logger.info( + `[ElectronPlatformService] File saved via save as: ${fileName}`, + ); + } else { + logger.error( + `[ElectronPlatformService] Native save as failed: ${result.error}`, + ); + throw new Error(`Native file save as failed: ${result.error}`); + } + } else { + // Fallback to web-style download if Electron API is not available + logger.warn( + "[ElectronPlatformService] Electron API not available, falling back to web download", + ); + + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const downloadLink = document.createElement("a"); + downloadLink.href = url; + downloadLink.download = fileName; + downloadLink.style.display = "none"; + + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + + setTimeout(() => URL.revokeObjectURL(url), 1000); + + logger.info( + `[ElectronPlatformService] Fallback download initiated: ${fileName}`, + ); + } + } catch (error) { + logger.error("[ElectronPlatformService] File save as failed:", error); + throw new Error(`Failed to save file as: ${error}`); + } + } + /** * Checks if running on Capacitor platform. * diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index a731ee22..88951131 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -97,9 +97,7 @@ export class WebPlatformService implements PlatformService { } } else { // We're in a worker context - skip initBackend call - // Use console for critical startup message to avoid circular dependency - // eslint-disable-next-line no-console - console.log( + logger.info( "[WebPlatformService] Skipping initBackend call in worker context", ); } @@ -594,6 +592,48 @@ export class WebPlatformService implements PlatformService { } } + /** + * Saves content directly to the device's Downloads folder (web platform). + * Uses the browser's download mechanism to save files. + * + * @param fileName - The filename of the file to save + * @param content - The content to write to the file + * @returns Promise that resolves when the file is saved + */ + async saveToDevice(fileName: string, content: string): Promise { + try { + // Web platform: Use the same download mechanism as writeAndShareFile + await this.writeAndShareFile(fileName, content); + logger.log("[WebPlatformService] File saved to device:", fileName); + } catch (error) { + logger.error("[WebPlatformService] Error saving file to device:", error); + throw new Error( + `Failed to save file to device: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Opens the system file picker to let the user choose where to save a file (web platform). + * Uses the browser's download mechanism with a suggested filename. + * + * @param fileName - The suggested filename for the file + * @param content - The content to write to the file + * @returns Promise that resolves when the file is saved + */ + async saveAs(fileName: string, content: string): Promise { + try { + // Web platform: Use the same download mechanism as writeAndShareFile + await this.writeAndShareFile(fileName, content); + logger.log("[WebPlatformService] File saved as:", fileName); + } catch (error) { + logger.error("[WebPlatformService] Error saving file as:", error); + throw new Error( + `Failed to save file as: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + /** * @see PlatformService.dbQuery */ diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index f635df32..eb99665c 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -182,7 +182,6 @@ @change="onLocationCheckboxChange" /> -