diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index ad256867..96e23014 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -1175,10 +1175,20 @@ export class CapacitorPlatformService implements PlatformService { logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); try { + // Validate JSON content + try { + JSON.parse(content); + } catch { + throw new Error('Content must be valid JSON'); + } + + // Generate unique filename + const uniqueFileName = this.generateUniqueFileName(fileName); + if (this.getCapabilities().isIOS) { // iOS: Use Filesystem to save to Documents directory const { uri } = await Filesystem.writeFile({ - path: fileName, + path: uniqueFileName, data: content, directory: Directory.Documents, encoding: Encoding.UTF8, @@ -1187,14 +1197,15 @@ export class CapacitorPlatformService implements PlatformService { logger.log("[CapacitorPlatformService] File saved to iOS Documents:", { uri, + fileName: uniqueFileName, timestamp: new Date().toISOString(), }); } else { // Android: Try to use native MediaStore/SAF implementation const result = await AndroidFileSaverImpl.saveToDownloads({ - fileName, + fileName: uniqueFileName, content, - mimeType: this.getMimeType(fileName), + mimeType: this.getMimeType(uniqueFileName), }); if (result.success) { @@ -1202,6 +1213,7 @@ export class CapacitorPlatformService implements PlatformService { "[CapacitorPlatformService] File saved to Android Downloads:", { path: result.path, + fileName: uniqueFileName, timestamp: new Date().toISOString(), }, ); @@ -1244,10 +1256,20 @@ export class CapacitorPlatformService implements PlatformService { logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); try { + // Validate JSON content + try { + JSON.parse(content); + } catch { + throw new Error('Content must be valid JSON'); + } + + // Generate unique filename + const uniqueFileName = this.generateUniqueFileName(fileName); + if (this.getCapabilities().isIOS) { // iOS: Use Filesystem to save to Documents directory with user choice const { uri } = await Filesystem.writeFile({ - path: fileName, + path: uniqueFileName, data: content, directory: Directory.Documents, encoding: Encoding.UTF8, @@ -1256,21 +1278,26 @@ export class CapacitorPlatformService implements PlatformService { logger.log("[CapacitorPlatformService] File saved to iOS Documents:", { uri, + fileName: uniqueFileName, timestamp: new Date().toISOString(), }); } else { - // Android: Use SAF to let user choose location + // Android: Use SAF for user-chosen location const result = await AndroidFileSaverImpl.saveAs({ - fileName, + fileName: uniqueFileName, content, - mimeType: this.getMimeType(fileName), + mimeType: this.getMimeType(uniqueFileName), }); if (result.success) { - logger.log("[CapacitorPlatformService] File saved via SAF:", { - path: result.path, - timestamp: new Date().toISOString(), - }); + logger.log( + "[CapacitorPlatformService] File saved via Android SAF:", + { + path: result.path, + fileName: uniqueFileName, + timestamp: new Date().toISOString(), + }, + ); } else { throw new Error(`Failed to save via SAF: ${result.error}`); } @@ -1283,11 +1310,89 @@ export class CapacitorPlatformService implements PlatformService { timestamp: new Date().toISOString(), }; logger.error( - "[CapacitorPlatformService] Error saving file as:", + "[CapacitorPlatformService] Error in saveAs:", JSON.stringify(errLog, null, 2), ); - throw new Error(`Failed to save file as: ${err.message}`); + throw new Error(`Failed to save file: ${err.message}`); + } + } + + /** + * Generates unique filename with timestamp, hashed device ID, and counter + */ + private generateUniqueFileName(baseName: string, counter = 0): string { + const now = new Date(); + const timestamp = now.toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .replace('Z', ''); + + const deviceIdHash = this.getHashedDeviceIdentifier(); + const counterSuffix = counter > 0 ? `_${counter}` : ''; + + const maxBaseLength = 45; + const truncatedBase = baseName.length > maxBaseLength + ? baseName.substring(0, maxBaseLength) + : baseName; + + const nameWithoutExt = truncatedBase.replace(/\.json$/i, ''); + const extension = '.json'; + const devicePart = `_${deviceIdHash}`; + const timestampPart = `_${timestamp}${counterSuffix}`; + + const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length; + + if (totalLength > 200) { + const availableLength = 200 - devicePart.length - timestampPart.length - extension.length; + const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength)); + return `${finalBase}${devicePart}${timestampPart}${extension}`; + } + + return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`; + } + + /** + * Gets hashed device identifier + */ + private getHashedDeviceIdentifier(): string { + try { + const deviceInfo = this.getDeviceInfo(); + return this.hashString(deviceInfo); + } catch (error) { + return 'mobile'; + } + } + + /** + * Gets device info string + */ + private getDeviceInfo(): string { + try { + // For mobile platforms, use device info + const capabilities = this.getCapabilities(); + if (capabilities.isIOS) { + return 'ios_mobile'; + } else if (capabilities.isAndroid) { + return 'android_mobile'; + } else { + return 'mobile'; + } + } catch (error) { + return 'mobile'; + } + } + + /** + * Simple hash function for device ID + */ + private hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; } + return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4); } /** diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index 3d68d987..0fbd1a78 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/src/services/platforms/ElectronPlatformService.ts @@ -155,26 +155,40 @@ export class ElectronPlatformService extends CapacitorPlatformService { * @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 { + // Ensure content is valid JSON + try { + JSON.parse(content); + } catch { + throw new Error('Content must be valid JSON'); + } + + // Generate unique filename + const uniqueFileName = this.generateUniqueFileNameElectron(fileName); + + logger.info( + `[ElectronPlatformService] Using native IPC for direct file save`, + uniqueFileName, + ); + // 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); + const result = await window.electronAPI.exportData(uniqueFileName, content); if (result.success) { logger.info( - `[ElectronPlatformService] File saved successfully to: ${result.path}`, + `[ElectronPlatformService] File saved successfully to`, + result.path, ); logger.info( - `[ElectronPlatformService] File saved to Downloads folder: ${fileName}`, + `[ElectronPlatformService] File saved to Downloads folder`, + uniqueFileName, ); } else { logger.error( - `[ElectronPlatformService] Native save failed: ${result.error}`, + `[ElectronPlatformService] Native save failed`, + result.error, ); throw new Error(`Native file save failed: ${result.error}`); } @@ -188,7 +202,7 @@ export class ElectronPlatformService extends CapacitorPlatformService { const url = URL.createObjectURL(blob); const downloadLink = document.createElement("a"); downloadLink.href = url; - downloadLink.download = fileName; + downloadLink.download = uniqueFileName; downloadLink.style.display = "none"; document.body.appendChild(downloadLink); @@ -198,11 +212,12 @@ export class ElectronPlatformService extends CapacitorPlatformService { setTimeout(() => URL.revokeObjectURL(url), 1000); logger.info( - `[ElectronPlatformService] Fallback download initiated: ${fileName}`, + `[ElectronPlatformService] Fallback download initiated`, + uniqueFileName, ); } } catch (error) { - logger.error("[ElectronPlatformService] File save failed:", error); + logger.error("[ElectronPlatformService] File save failed", error); throw new Error(`Failed to save file: ${error}`); } } @@ -216,27 +231,41 @@ export class ElectronPlatformService extends CapacitorPlatformService { * @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 { + // Ensure content is valid JSON + try { + JSON.parse(content); + } catch { + throw new Error('Content must be valid JSON'); + } + + // Generate unique filename + const uniqueFileName = this.generateUniqueFileNameElectron(fileName); + + logger.info( + `[ElectronPlatformService] Using native IPC for save as dialog`, + uniqueFileName, + ); + // 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); + const result = await window.electronAPI.exportData(uniqueFileName, content); if (result.success) { logger.info( - `[ElectronPlatformService] File saved successfully to: ${result.path}`, + `[ElectronPlatformService] File saved successfully to`, + result.path, ); logger.info( - `[ElectronPlatformService] File saved via save as: ${fileName}`, + `[ElectronPlatformService] File saved via save as`, + uniqueFileName, ); } else { logger.error( - `[ElectronPlatformService] Native save as failed: ${result.error}`, + `[ElectronPlatformService] Native save as failed`, + result.error, ); throw new Error(`Native file save as failed: ${result.error}`); } @@ -250,7 +279,7 @@ export class ElectronPlatformService extends CapacitorPlatformService { const url = URL.createObjectURL(blob); const downloadLink = document.createElement("a"); downloadLink.href = url; - downloadLink.download = fileName; + downloadLink.download = uniqueFileName; downloadLink.style.display = "none"; document.body.appendChild(downloadLink); @@ -260,15 +289,93 @@ export class ElectronPlatformService extends CapacitorPlatformService { setTimeout(() => URL.revokeObjectURL(url), 1000); logger.info( - `[ElectronPlatformService] Fallback download initiated: ${fileName}`, + `[ElectronPlatformService] Fallback download initiated`, + uniqueFileName, ); } } catch (error) { - logger.error("[ElectronPlatformService] File save as failed:", error); + logger.error("[ElectronPlatformService] File save as failed", error); throw new Error(`Failed to save file as: ${error}`); } } + /** + * Generates unique filename with timestamp, hashed device ID, and counter + */ + private generateUniqueFileNameElectron(baseName: string, counter = 0): string { + const now = new Date(); + const timestamp = now.toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .replace('Z', ''); + + const deviceIdHash = this.getHashedDeviceIdentifierElectron(); + const counterSuffix = counter > 0 ? `_${counter}` : ''; + + const maxBaseLength = 45; + const truncatedBase = baseName.length > maxBaseLength + ? baseName.substring(0, maxBaseLength) + : baseName; + + const nameWithoutExt = truncatedBase.replace(/\.json$/i, ''); + const extension = '.json'; + const devicePart = `_${deviceIdHash}`; + const timestampPart = `_${timestamp}${counterSuffix}`; + + const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length; + + if (totalLength > 200) { + const availableLength = 200 - devicePart.length - timestampPart.length - extension.length; + const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength)); + return `${finalBase}${devicePart}${timestampPart}${extension}`; + } + + return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`; + } + + /** + * Gets hashed device identifier + */ + private getHashedDeviceIdentifierElectron(): string { + try { + const deviceInfo = this.getDeviceInfoElectron(); + return this.hashStringElectron(deviceInfo); + } catch (error) { + return 'electron'; + } + } + + /** + * Gets device info string + */ + private getDeviceInfoElectron(): string { + try { + // Use machine-specific information + const os = require('os'); + const hostname = os.hostname() || 'unknown'; + const platform = os.platform() || 'unknown'; + const arch = os.arch() || 'unknown'; + + // Create device info string + return `${platform}_${hostname}_${arch}`; + } catch (error) { + return 'electron'; + } + } + + /** + * Simple hash function for device ID + */ + private hashStringElectron(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4); + } + /** * Checks if running on Capacitor platform. * diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index 88951131..a468af65 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -593,8 +593,8 @@ 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. + * 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 @@ -602,11 +602,21 @@ export class WebPlatformService implements PlatformService { */ async saveToDevice(fileName: string, content: string): Promise { try { + // Ensure content is valid JSON + try { + JSON.parse(content); + } catch { + throw new Error('Content must be valid JSON'); + } + + // Generate unique filename + const uniqueFileName = this.generateUniqueFileName(fileName); + // Web platform: Use the same download mechanism as writeAndShareFile - await this.writeAndShareFile(fileName, content); - logger.log("[WebPlatformService] File saved to device:", fileName); + await this.writeAndShareFile(uniqueFileName, content); + logger.log("[WebPlatformService] File saved to device", uniqueFileName); } catch (error) { - logger.error("[WebPlatformService] Error saving file to device:", 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"}`, ); @@ -623,17 +633,116 @@ export class WebPlatformService implements PlatformService { */ async saveAs(fileName: string, content: string): Promise { try { + // Ensure content is valid JSON + try { + JSON.parse(content); + } catch { + throw new Error('Content must be valid JSON'); + } + + // Generate unique filename + const uniqueFileName = this.generateUniqueFileName(fileName); + // Web platform: Use the same download mechanism as writeAndShareFile - await this.writeAndShareFile(fileName, content); - logger.log("[WebPlatformService] File saved as:", fileName); + await this.writeAndShareFile(uniqueFileName, content); + logger.log("[WebPlatformService] File saved as", uniqueFileName); } catch (error) { - logger.error("[WebPlatformService] Error saving file as:", error); + logger.error("[WebPlatformService] Error saving file as", error); throw new Error( `Failed to save file as: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } + /** + * Generates unique filename with timestamp, hashed device ID, and counter + */ + private generateUniqueFileName(baseName: string, counter = 0): string { + const now = new Date(); + const timestamp = now.toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .replace('Z', ''); + + const deviceIdHash = this.getHashedDeviceIdentifier(); + const counterSuffix = counter > 0 ? `_${counter}` : ''; + + const maxBaseLength = 45; + const truncatedBase = baseName.length > maxBaseLength + ? baseName.substring(0, maxBaseLength) + : baseName; + + const nameWithoutExt = truncatedBase.replace(/\.json$/i, ''); + const extension = '.json'; + const devicePart = `_${deviceIdHash}`; + const timestampPart = `_${timestamp}${counterSuffix}`; + + const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length; + + if (totalLength > 200) { + const availableLength = 200 - devicePart.length - timestampPart.length - extension.length; + const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength)); + return `${finalBase}${devicePart}${timestampPart}${extension}`; + } + + return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`; + } + + /** + * Gets hashed device identifier + */ + private getHashedDeviceIdentifier(): string { + try { + const deviceInfo = this.getDeviceInfo(); + return this.hashString(deviceInfo); + } catch (error) { + return 'web'; + } + } + + /** + * Gets device info string + */ + private getDeviceInfo(): string { + try { + // Use browser fingerprint or fallback + const userAgent = navigator.userAgent; + const language = navigator.language || 'unknown'; + const platform = navigator.platform || 'unknown'; + + let browser = 'unknown'; + let os = 'unknown'; + + if (userAgent.includes('Chrome')) browser = 'chrome'; + else if (userAgent.includes('Firefox')) browser = 'firefox'; + else if (userAgent.includes('Safari')) browser = 'safari'; + else if (userAgent.includes('Edge')) browser = 'edge'; + + if (userAgent.includes('Windows')) os = 'win'; + else if (userAgent.includes('Mac')) os = 'mac'; + else if (userAgent.includes('Linux')) os = 'linux'; + else if (userAgent.includes('Android')) os = 'android'; + else if (userAgent.includes('iOS')) os = 'ios'; + + return `${browser}_${os}_${platform}_${language}`; + } catch (error) { + return 'web'; + } + } + + /** + * Simple hash function for device ID + */ + private hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4); + } + /** * @see PlatformService.dbQuery */