package app import ( "fmt" "os" "time" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file" ) // ============================================================================= // FILE CLEANUP OPERATIONS // ============================================================================= // DeleteFile soft-deletes a file from both the cloud and local storage func (a *Application) DeleteFile(fileID string) error { a.logger.Info("DeleteFile called", zap.String("file_id", fileID)) // Use the SDK client which has automatic token refresh on 401 apiClient := a.authService.GetAPIClient() // Use SDK's DeleteFile method which has automatic 401 retry if err := apiClient.DeleteFile(a.ctx, fileID); err != nil { a.logger.Error("Failed to delete file from cloud", zap.String("file_id", fileID), zap.Error(err)) return fmt.Errorf("failed to delete file: %w", err) } // Cloud delete succeeded - now clean up local data a.cleanupLocalFile(fileID) a.logger.Info("File deleted successfully", zap.String("file_id", fileID)) return nil } // cleanupLocalFile removes physical binary files immediately and marks the metadata as deleted. // The metadata record is kept for background cleanup later. func (a *Application) cleanupLocalFile(fileID string) { // Get the local file record localFile, err := a.mustGetFileRepo().Get(fileID) if err != nil || localFile == nil { a.logger.Debug("No local file record to clean up", zap.String("file_id", fileID)) return } // IMMEDIATELY delete physical binary files // Delete the physical decrypted file if it exists if localFile.FilePath != "" { if err := os.Remove(localFile.FilePath); err != nil { if !os.IsNotExist(err) { a.logger.Warn("Failed to delete local decrypted file", zap.String("file_id", fileID), zap.String("path", localFile.FilePath), zap.Error(err)) } } else { a.logger.Info("Deleted local decrypted file", zap.String("file_id", fileID), zap.String("path", localFile.FilePath)) } } // Delete the physical encrypted file if it exists if localFile.EncryptedFilePath != "" { if err := os.Remove(localFile.EncryptedFilePath); err != nil { if !os.IsNotExist(err) { a.logger.Warn("Failed to delete local encrypted file", zap.String("file_id", fileID), zap.String("path", localFile.EncryptedFilePath), zap.Error(err)) } } else { a.logger.Info("Deleted local encrypted file", zap.String("file_id", fileID), zap.String("path", localFile.EncryptedFilePath)) } } // Delete the thumbnail if it exists if localFile.ThumbnailPath != "" { if err := os.Remove(localFile.ThumbnailPath); err != nil { if !os.IsNotExist(err) { a.logger.Warn("Failed to delete local thumbnail", zap.String("file_id", fileID), zap.String("path", localFile.ThumbnailPath), zap.Error(err)) } } else { a.logger.Info("Deleted local thumbnail", zap.String("file_id", fileID), zap.String("path", localFile.ThumbnailPath)) } } // Mark the metadata record as deleted (will be cleaned up later by background process) // Clear the file paths since the physical files are now deleted localFile.State = file.StateDeleted localFile.FilePath = "" localFile.EncryptedFilePath = "" localFile.ThumbnailPath = "" localFile.ModifiedAt = time.Now() if err := a.mustGetFileRepo().Update(localFile); err != nil { a.logger.Warn("Failed to mark local file metadata as deleted", zap.String("file_id", fileID), zap.Error(err)) } else { a.logger.Info("Marked local file metadata as deleted (will be cleaned up later)", zap.String("file_id", fileID)) // Remove from search index if err := a.searchService.DeleteFile(fileID); err != nil { a.logger.Warn("Failed to remove file from search index", zap.String("file_id", fileID), zap.Error(err)) } } } // purgeDeletedFileMetadata permanently removes a deleted file's metadata record. // This is called by the background cleanup process after a retention period. func (a *Application) purgeDeletedFileMetadata(fileID string) { if err := a.mustGetFileRepo().Delete(fileID); err != nil { a.logger.Warn("Failed to purge deleted file metadata", zap.String("file_id", fileID), zap.Error(err)) } else { a.logger.Info("Purged deleted file metadata", zap.String("file_id", fileID)) } } // deletedFileRetentionPeriod is how long to keep deleted file metadata before purging. // This allows for potential recovery or sync conflict resolution. const deletedFileRetentionPeriod = 7 * 24 * time.Hour // 7 days // cleanupDeletedFiles runs in the background to clean up deleted files. // It handles two cases: // 1. Files marked as deleted that still have physical files (cleans up binaries immediately) // 2. Files marked as deleted past the retention period (purges metadata) func (a *Application) cleanupDeletedFiles() { a.logger.Info("Starting background cleanup of deleted files") // Get all local files localFiles, err := a.mustGetFileRepo().List() if err != nil { a.logger.Error("Failed to list local files for cleanup", zap.Error(err)) return } binaryCleanedCount := 0 metadataPurgedCount := 0 now := time.Now() for _, localFile := range localFiles { // Only process deleted files if localFile.State != file.StateDeleted { continue } // Check if there are still physical files to clean up if localFile.FilePath != "" || localFile.EncryptedFilePath != "" || localFile.ThumbnailPath != "" { a.logger.Info("Cleaning up orphaned binary files for deleted record", zap.String("file_id", localFile.ID)) a.cleanupLocalFile(localFile.ID) binaryCleanedCount++ continue } // Check if metadata is past retention period and can be purged if now.Sub(localFile.ModifiedAt) > deletedFileRetentionPeriod { a.logger.Info("Purging deleted file metadata (past retention period)", zap.String("file_id", localFile.ID), zap.Time("deleted_at", localFile.ModifiedAt)) a.purgeDeletedFileMetadata(localFile.ID) metadataPurgedCount++ } } if binaryCleanedCount > 0 || metadataPurgedCount > 0 { a.logger.Info("Background cleanup completed", zap.Int("binaries_cleaned", binaryCleanedCount), zap.Int("metadata_purged", metadataPurgedCount)) } else { a.logger.Debug("Background cleanup completed, no cleanup needed") } }