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" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/syncstate" ) // CollectionSyncService defines the interface for collection synchronization type CollectionSyncService interface { Execute(ctx context.Context, input *SyncInput) (*SyncResult, error) } type collectionSyncService struct { logger *zap.Logger apiClient *client.Client repoProvider RepositoryProvider } // ProvideCollectionSyncService creates a new collection sync service for Wire func ProvideCollectionSyncService( logger *zap.Logger, apiClient *client.Client, repoProvider RepositoryProvider, ) CollectionSyncService { return &collectionSyncService{ logger: logger.Named("CollectionSyncService"), apiClient: apiClient, repoProvider: repoProvider, } } // getCollectionRepo returns the collection repository, or an error if not initialized func (s *collectionSyncService) getCollectionRepo() (collectionDomain.Repository, error) { if !s.repoProvider.IsInitialized() { return nil, fmt.Errorf("storage not initialized - user must be logged in") } repo := s.repoProvider.GetCollectionRepository() if repo == nil { return nil, fmt.Errorf("collection repository not available") } return repo, nil } // getSyncStateRepo returns the sync state repository, or an error if not initialized func (s *collectionSyncService) 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 collections from the cloud to local storage func (s *collectionSyncService) Execute(ctx context.Context, input *SyncInput) (*SyncResult, error) { s.logger.Info("Starting collection 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.CollectionCursor, Limit: input.BatchSize, } // Fetch batch from cloud resp, err := s.apiClient.SyncCollections(ctx, syncInput) if err != nil { s.logger.Error("Failed to fetch collections from cloud", zap.Error(err)) result.Errors = append(result.Errors, "failed to fetch collections: "+err.Error()) break } // Process each collection in the batch for _, cloudCol := range resp.Collections { if err := s.processCollection(ctx, cloudCol, input.Password, result); err != nil { s.logger.Error("Failed to process collection", zap.String("id", cloudCol.ID), zap.Error(err)) result.Errors = append(result.Errors, "failed to process collection "+cloudCol.ID+": "+err.Error()) } result.CollectionsProcessed++ } // Update sync state with new cursor state.UpdateCollectionSync(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("Collection sync completed - no more items") break } } s.logger.Info("Collection sync finished", zap.Int("processed", result.CollectionsProcessed), zap.Int("added", result.CollectionsAdded), zap.Int("updated", result.CollectionsUpdated), zap.Int("deleted", result.CollectionsDeleted), zap.Int("errors", len(result.Errors))) return result, nil } // processCollection handles a single collection from the cloud // Note: ctx and password are reserved for future use (on-demand content decryption) func (s *collectionSyncService) processCollection(_ context.Context, cloudCol *client.Collection, _ string, result *SyncResult) error { // Get collection repository collectionRepo, err := s.getCollectionRepo() if err != nil { return err } // Check if collection exists locally localCol, err := collectionRepo.Get(cloudCol.ID) if err != nil { return err } // Handle deleted collections if cloudCol.State == collectionDomain.StateDeleted { if localCol != nil { if err := collectionRepo.Delete(cloudCol.ID); err != nil { return err } result.CollectionsDeleted++ } return nil } // The collection name comes from the API already decrypted for owned collections. // For shared collections, it would need decryption using the key chain. // For now, we use the name as-is from the API response. collectionName := cloudCol.Name // Create or update local collection if localCol == nil { // Create new local collection newCol := s.mapCloudToLocal(cloudCol, collectionName) if err := collectionRepo.Create(newCol); err != nil { return err } result.CollectionsAdded++ } else { // Update existing collection updatedCol := s.mapCloudToLocal(cloudCol, collectionName) updatedCol.SyncStatus = localCol.SyncStatus // Preserve local sync status updatedCol.LastSyncedAt = time.Now() if err := collectionRepo.Update(updatedCol); err != nil { return err } result.CollectionsUpdated++ } return nil } // mapCloudToLocal converts a cloud collection to local domain model func (s *collectionSyncService) mapCloudToLocal(cloudCol *client.Collection, decryptedName string) *collectionDomain.Collection { return &collectionDomain.Collection{ ID: cloudCol.ID, ParentID: cloudCol.ParentID, OwnerID: cloudCol.UserID, EncryptedCollectionKey: cloudCol.EncryptedCollectionKey.Ciphertext, Nonce: cloudCol.EncryptedCollectionKey.Nonce, Name: decryptedName, Description: cloudCol.Description, CustomIcon: cloudCol.CustomIcon, // Custom icon (emoji or "icon:") TotalFiles: cloudCol.TotalFiles, TotalSizeInBytes: cloudCol.TotalSizeInBytes, PermissionLevel: cloudCol.PermissionLevel, IsOwner: cloudCol.IsOwner, OwnerName: cloudCol.OwnerName, OwnerEmail: cloudCol.OwnerEmail, SyncStatus: collectionDomain.SyncStatusCloudOnly, LastSyncedAt: time.Now(), State: cloudCol.State, CreatedAt: cloudCol.CreatedAt, ModifiedAt: cloudCol.ModifiedAt, } }