package sync import ( "context" "fmt" "time" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client" collectionDomain "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/collection" fileDomain "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/syncstate" ) // FileSyncService defines the interface for file synchronization type FileSyncService interface { Execute(ctx context.Context, input *SyncInput) (*SyncResult, error) } // RepositoryProvider provides access to user-specific repositories. // This interface allows sync services to work with dynamically initialized storage. // The storagemanager.Manager implements this interface. type RepositoryProvider interface { GetFileRepository() fileDomain.Repository GetCollectionRepository() collectionDomain.Repository GetSyncStateRepository() syncstate.Repository IsInitialized() bool } type fileSyncService struct { logger *zap.Logger apiClient *client.Client repoProvider RepositoryProvider } // ProvideFileSyncService creates a new file sync service for Wire func ProvideFileSyncService( logger *zap.Logger, apiClient *client.Client, repoProvider RepositoryProvider, ) FileSyncService { return &fileSyncService{ logger: logger.Named("FileSyncService"), apiClient: apiClient, repoProvider: repoProvider, } } // getFileRepo returns the file repository, or an error if not initialized func (s *fileSyncService) getFileRepo() (fileDomain.Repository, error) { if !s.repoProvider.IsInitialized() { return nil, fmt.Errorf("storage not initialized - user must be logged in") } repo := s.repoProvider.GetFileRepository() if repo == nil { return nil, fmt.Errorf("file repository not available") } return repo, nil } // getSyncStateRepo returns the sync state repository, or an error if not initialized func (s *fileSyncService) getSyncStateRepo() (syncstate.Repository, error) { if !s.repoProvider.IsInitialized() { return nil, fmt.Errorf("storage not initialized - user must be logged in") } repo := s.repoProvider.GetSyncStateRepository() if repo == nil { return nil, fmt.Errorf("sync state repository not available") } return repo, nil } // Execute synchronizes files from the cloud to local storage (metadata only) func (s *fileSyncService) Execute(ctx context.Context, input *SyncInput) (*SyncResult, error) { s.logger.Info("Starting file synchronization") // Get repositories (will fail if user not logged in) syncStateRepo, err := s.getSyncStateRepo() if err != nil { s.logger.Error("Cannot sync - storage not initialized", zap.Error(err)) return nil, err } // Set defaults if input == nil { input = &SyncInput{} } if input.BatchSize <= 0 { input.BatchSize = DefaultBatchSize } if input.MaxBatches <= 0 { input.MaxBatches = DefaultMaxBatches } // Get current sync state state, err := syncStateRepo.Get() if err != nil { s.logger.Error("Failed to get sync state", zap.Error(err)) return nil, err } result := &SyncResult{} batchCount := 0 // Sync loop - fetch and process batches until done or max reached for batchCount < input.MaxBatches { // Prepare API request syncInput := &client.SyncInput{ Cursor: state.FileCursor, Limit: input.BatchSize, } // Fetch batch from cloud resp, err := s.apiClient.SyncFiles(ctx, syncInput) if err != nil { s.logger.Error("Failed to fetch files from cloud", zap.Error(err)) result.Errors = append(result.Errors, "failed to fetch files: "+err.Error()) break } // Process each file in the batch for _, cloudFile := range resp.Files { if err := s.processFile(ctx, cloudFile, input.Password, result); err != nil { s.logger.Error("Failed to process file", zap.String("id", cloudFile.ID), zap.Error(err)) result.Errors = append(result.Errors, "failed to process file "+cloudFile.ID+": "+err.Error()) } result.FilesProcessed++ } // Update sync state with new cursor state.UpdateFileSync(resp.NextCursor, resp.HasMore) if err := syncStateRepo.Save(state); err != nil { s.logger.Error("Failed to save sync state", zap.Error(err)) result.Errors = append(result.Errors, "failed to save sync state: "+err.Error()) } batchCount++ // Check if we're done if !resp.HasMore { s.logger.Info("File sync completed - no more items") break } } s.logger.Info("File sync finished", zap.Int("processed", result.FilesProcessed), zap.Int("added", result.FilesAdded), zap.Int("updated", result.FilesUpdated), zap.Int("deleted", result.FilesDeleted), zap.Int("errors", len(result.Errors))) return result, nil } // processFile handles a single file from the cloud // Note: ctx and password are reserved for future use (on-demand content decryption) func (s *fileSyncService) processFile(_ context.Context, cloudFile *client.File, _ string, result *SyncResult) error { // Get file repository fileRepo, err := s.getFileRepo() if err != nil { return err } // Check if file exists locally localFile, err := fileRepo.Get(cloudFile.ID) if err != nil { return err } // Handle deleted files if cloudFile.State == fileDomain.StateDeleted { if localFile != nil { if err := fileRepo.Delete(cloudFile.ID); err != nil { return err } result.FilesDeleted++ } return nil } // Create or update local file (metadata only - no content download) if localFile == nil { // Create new local file record newFile := s.mapCloudToLocal(cloudFile) if err := fileRepo.Create(newFile); err != nil { return err } result.FilesAdded++ } else { // Update existing file metadata updatedFile := s.mapCloudToLocal(cloudFile) // Preserve local-only fields updatedFile.FilePath = localFile.FilePath updatedFile.EncryptedFilePath = localFile.EncryptedFilePath updatedFile.ThumbnailPath = localFile.ThumbnailPath updatedFile.Name = localFile.Name updatedFile.MimeType = localFile.MimeType updatedFile.Metadata = localFile.Metadata // If file has local content, it's synced; otherwise it's cloud-only if localFile.HasLocalContent() { updatedFile.SyncStatus = fileDomain.SyncStatusSynced } else { updatedFile.SyncStatus = fileDomain.SyncStatusCloudOnly } updatedFile.LastSyncedAt = time.Now() if err := fileRepo.Update(updatedFile); err != nil { return err } result.FilesUpdated++ } return nil } // mapCloudToLocal converts a cloud file to local domain model func (s *fileSyncService) mapCloudToLocal(cloudFile *client.File) *fileDomain.File { return &fileDomain.File{ ID: cloudFile.ID, CollectionID: cloudFile.CollectionID, OwnerID: cloudFile.UserID, EncryptedFileKey: fileDomain.EncryptedFileKeyData{ Ciphertext: cloudFile.EncryptedFileKey.Ciphertext, Nonce: cloudFile.EncryptedFileKey.Nonce, }, FileKeyNonce: cloudFile.FileKeyNonce, EncryptedMetadata: cloudFile.EncryptedMetadata, MetadataNonce: cloudFile.MetadataNonce, FileNonce: cloudFile.FileNonce, EncryptedSizeInBytes: cloudFile.EncryptedSizeInBytes, DecryptedSizeInBytes: cloudFile.DecryptedSizeInBytes, // Local paths are empty until file is downloaded (onloaded) EncryptedFilePath: "", FilePath: "", ThumbnailPath: "", // Metadata will be decrypted when file is onloaded Name: "", MimeType: "", Metadata: nil, SyncStatus: fileDomain.SyncStatusCloudOnly, // Files start as cloud-only LastSyncedAt: time.Now(), State: cloudFile.State, StorageMode: cloudFile.StorageMode, Version: cloudFile.Version, CreatedAt: cloudFile.CreatedAt, ModifiedAt: cloudFile.ModifiedAt, ThumbnailURL: cloudFile.ThumbnailURL, } }