254 lines
7.7 KiB
Go
254 lines
7.7 KiB
Go
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,
|
|
}
|
|
}
|