< Summary

Information
Class: Pomodoro.Web.Services.ExportService
Assembly: Pomodoro.Web
File(s): /home/runner/work/Pomodoro/Pomodoro/src/Pomodoro.Web/Services/ExportService.cs
Line coverage
100%
Covered lines: 259
Uncovered lines: 0
Coverable lines: 259
Total lines: 433
Line coverage: 100%
Branch coverage
100%
Covered branches: 40
Total branches: 40
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ExportToJsonAsync()100%11100%
ImportFromJsonAsync()100%44100%
ValidateJsonInput(...)100%22100%
ParseJsonDataAsync()100%44100%
LoadExistingDataAsync()100%11100%
ImportSettingsAsync()100%22100%
ImportTasksAsync()100%1414100%
ImportActivitiesAsync()100%1414100%
ClearAllDataAsync()100%11100%
get_Version()100%11100%
get_ExportDate()100%11100%
get_Settings()100%11100%
get_Activities()100%11100%
get_Tasks()100%11100%
get_Activities()100%11100%
get_Tasks()100%11100%
get_ActivityLookup()100%11100%
get_TaskLookup()100%11100%
get_TaskById()100%11100%
get_TaskByKey()100%11100%
get_TasksImported()100%11100%
get_TasksSkipped()100%11100%
get_TaskIdMapping()100%11100%
get_ActivitiesImported()100%11100%
get_ActivitiesSkipped()100%11100%

File(s)

/home/runner/work/Pomodoro/Pomodoro/src/Pomodoro.Web/Services/ExportService.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.Json.Serialization;
 3using Microsoft.Extensions.Logging;
 4using Pomodoro.Web.Models;
 5using Pomodoro.Web.Services.Repositories;
 6
 7namespace Pomodoro.Web.Services;
 8
 9/// <summary>
 10/// Service for exporting and importing application data
 11/// </summary>
 12public class ExportService : IExportService
 13{
 14    private readonly IActivityRepository _activityRepository;
 15    private readonly ITaskRepository _taskRepository;
 16    private readonly ISettingsRepository _settingsRepository;
 17    private readonly ILogger<ExportService> _logger;
 18
 4019    public ExportService(
 4020        IActivityRepository activityRepository,
 4021        ITaskRepository taskRepository,
 4022        ISettingsRepository settingsRepository,
 4023        ILogger<ExportService> logger)
 4024    {
 4025        _activityRepository = activityRepository;
 4026        _taskRepository = taskRepository;
 4027        _settingsRepository = settingsRepository;
 4028        _logger = logger;
 4029    }
 30
 31    /// <summary>
 32    /// Exports all data to JSON format
 33    /// </summary>
 34    public async Task<string> ExportToJsonAsync()
 735    {
 36        try
 737        {
 38            // Get all data from IndexedDB
 739            var activities = await _activityRepository.GetAllAsync();
 540            var tasks = await _taskRepository.GetAllAsync();
 541            var settings = await _settingsRepository.GetAsync();
 42
 43            // Create export data structure
 544            var exportData = new
 545            {
 546                Version = 1,
 547                ExportDate = DateTime.UtcNow,
 548                Settings = settings,
 549                Activities = activities,
 550                Tasks = tasks
 551            };
 52
 553            var json = JsonSerializer.Serialize(exportData, new JsonSerializerOptions
 554            {
 555                WriteIndented = true,
 556                PropertyNamingPolicy = JsonNamingPolicy.CamelCase
 557            });
 58
 559            _logger.LogInformation(Constants.Messages.LogExportJsonFormat, activities.Count, tasks.Count);
 560            return json;
 61        }
 262        catch (Exception ex)
 263        {
 264            _logger.LogError(ex, Constants.Messages.LogExportJsonFailed);
 265            throw;
 66        }
 567    }
 68
 69    /// <summary>
 70    /// Imports data from JSON format with duplicate detection and validation
 71    /// </summary>
 72    public async Task<ImportResult> ImportFromJsonAsync(string jsonData)
 2773    {
 74        try
 2775        {
 76            // Validate input
 2777            var validationResult = ValidateJsonInput(jsonData);
 2778            if (!validationResult.IsValid)
 379            {
 380                return validationResult.Result;
 81            }
 82
 83            // Parse JSON
 2484            var parseResult = await ParseJsonDataAsync(jsonData);
 2485            if (!parseResult.IsValid)
 586            {
 587                return parseResult.Result;
 88            }
 89
 1990            var importData = parseResult.ImportData!;
 91
 92            // Load existing data for duplicate detection
 1993            var existingData = await LoadExistingDataAsync();
 94
 95            // Import settings
 1896            var settingsImported = await ImportSettingsAsync(importData.Settings);
 97
 98            // Import tasks with duplicate detection
 1899            var taskImportResult = await ImportTasksAsync(importData.Tasks, existingData);
 100
 101            // Import activities with duplicate detection
 18102            var activityImportResult = await ImportActivitiesAsync(importData.Activities, existingData, taskImportResult
 103
 18104            _logger.LogInformation(
 18105                "Import completed: {ActivitiesImported} activities imported, {ActivitiesSkipped} activities skipped, " +
 18106                "{TasksImported} tasks imported, {TasksSkipped} tasks skipped",
 18107                activityImportResult.ActivitiesImported, activityImportResult.ActivitiesSkipped,
 18108                taskImportResult.TasksImported, taskImportResult.TasksSkipped);
 109
 18110            return ImportResult.Succeeded(
 18111                activityImportResult.ActivitiesImported,
 18112                activityImportResult.ActivitiesSkipped,
 18113                taskImportResult.TasksImported,
 18114                taskImportResult.TasksSkipped,
 18115                settingsImported);
 116        }
 1117        catch (Exception ex)
 1118        {
 1119            _logger.LogError(ex, Constants.Messages.LogImportJsonFailed);
 1120            return ImportResult.Failed(Constants.Messages.ImportErrorFailed);
 121        }
 27122    }
 123
 124    /// <summary>
 125    /// Validates JSON input data
 126    /// </summary>
 127    private (bool IsValid, ImportResult Result) ValidateJsonInput(string jsonData)
 27128    {
 27129        if (string.IsNullOrWhiteSpace(jsonData))
 3130        {
 3131            _logger.LogWarning(Constants.Messages.LogImportJsonInvalid);
 3132            return (false, ImportResult.Failed(Constants.Messages.ImportErrorEmptyFile));
 133        }
 134
 24135        return (true, ImportResult.Succeeded(0, 0, 0, 0, false));
 27136    }
 137
 138    /// <summary>
 139    /// Parses JSON data and validates structure
 140    /// </summary>
 141    private async Task<(bool IsValid, ImportResult Result, ExportData? ImportData)> ParseJsonDataAsync(string jsonData)
 24142    {
 24143        var options = new JsonSerializerOptions
 24144        {
 24145            PropertyNameCaseInsensitive = true,
 24146            MaxDepth = 64
 24147        };
 148
 149        ExportData? importData;
 150        try
 24151        {
 24152            importData = JsonSerializer.Deserialize<ExportData>(jsonData, options);
 23153        }
 1154        catch (JsonException ex)
 1155        {
 1156            _logger.LogError(ex, Constants.Messages.LogImportJsonParseFailed);
 1157            return (false, ImportResult.Failed(Constants.Messages.ImportErrorInvalidJson), null);
 158        }
 159
 160        // Validate parsed data structure
 23161        if (importData == null)
 1162        {
 1163            _logger.LogWarning(Constants.Messages.LogImportJsonInvalid);
 1164            return (false, ImportResult.Failed(Constants.Messages.ImportErrorInvalidFormat), null);
 165        }
 166
 167        // Validate required fields
 22168        if (importData.Version <= 0)
 3169        {
 3170            _logger.LogWarning("Import file has invalid version: {Version}", importData.Version);
 3171            return (false, ImportResult.Failed(Constants.Messages.ImportErrorInvalidVersion), null);
 172        }
 173
 19174        return (true, ImportResult.Succeeded(0, 0, 0, 0, false), importData);
 24175    }
 176
 177    /// <summary>
 178    /// Loads existing data for duplicate detection
 179    /// </summary>
 180    private async Task<ExistingData> LoadExistingDataAsync()
 19181    {
 19182        var existingActivities = await _activityRepository.GetAllAsync();
 18183        var existingTasks = await _taskRepository.GetAllAsync();
 184
 185        // Create lookup sets for efficient duplicate detection
 18186        var activityLookup = existingActivities
 3187            .Select(a => new ActivityKey(a.Type, a.CompletedAt, a.DurationMinutes, a.TaskName))
 18188            .ToHashSet();
 189
 18190        var taskLookup = existingTasks
 4191            .Select(t => new TaskKey(t.Name, t.CreatedAt))
 18192            .ToHashSet();
 193
 194        // Build dictionary for O(1) task ID lookups during activity import
 26195        var existingTaskById = existingTasks.ToDictionary(t => t.Id, t => t);
 196
 197        // Build dictionary for O(1) task lookups by key (Name + CreatedAt) for duplicate mapping
 198        // Use GroupBy to handle potential duplicates gracefully (take first occurrence)
 18199        var existingTaskByKey = existingTasks
 4200            .GroupBy(t => new TaskKey(t.Name, t.CreatedAt))
 26201            .ToDictionary(g => g.Key, g => g.First());
 202
 18203        return new ExistingData
 18204        {
 18205            Activities = existingActivities,
 18206            Tasks = existingTasks,
 18207            ActivityLookup = activityLookup,
 18208            TaskLookup = taskLookup,
 18209            TaskById = existingTaskById,
 18210            TaskByKey = existingTaskByKey
 18211        };
 18212    }
 213
 214    /// <summary>
 215    /// Imports settings
 216    /// </summary>
 217    private async Task<bool> ImportSettingsAsync(TimerSettings? settings)
 18218    {
 18219        if (settings != null)
 4220        {
 4221            await _settingsRepository.SaveAsync(settings);
 4222            return true;
 223        }
 224
 14225        return false;
 18226    }
 227
 228    /// <summary>
 229    /// Imports tasks with duplicate detection
 230    /// </summary>
 231    private async Task<TaskImportResult> ImportTasksAsync(List<TaskItem>? tasks, ExistingData existingData)
 18232    {
 18233        var tasksImported = 0;
 18234        var tasksSkipped = 0;
 18235        var taskIdMapping = new Dictionary<Guid, Guid>();
 236
 18237        if (tasks != null)
 18238        {
 88239            foreach (var task in tasks)
 17240            {
 17241                var key = new TaskKey(task.Name, task.CreatedAt);
 242
 17243                if (existingData.TaskLookup.Contains(key))
 5244                {
 245                    // Duplicate found - skip but still map the ID for activity references
 246                    // Use O(1) dictionary lookup instead of O(n) FirstOrDefault
 5247                    if (existingData.TaskByKey.TryGetValue(key, out var existingTask) && task.Id != Guid.Empty)
 2248                    {
 2249                        taskIdMapping[task.Id] = existingTask.Id;
 2250                    }
 3251                    else if (task.Id != Guid.Empty)
 2252                    {
 253                        // Edge case: taskLookup contained the key but dictionary lookup failed (data inconsistency)
 2254                        _logger.LogWarning("Duplicate task found but couldn't map ID {TaskId} for task {Name}", task.Id,
 2255                    }
 5256                    tasksSkipped++;
 5257                    _logger.LogDebug("Skipping duplicate task: {Name} created at {CreatedAt}", task.Name, task.CreatedAt
 5258                }
 259                else
 12260                {
 261                    // Not a duplicate - create new record with new ID
 12262                    var newId = Guid.NewGuid();
 12263                    var importedTask = new TaskItem
 12264                    {
 12265                        Id = newId,
 12266                        Name = task.Name,
 12267                        PomodoroCount = task.PomodoroCount,
 12268                        TotalFocusMinutes = task.TotalFocusMinutes,
 12269                        IsCompleted = task.IsCompleted,
 12270                        CreatedAt = task.CreatedAt,
 12271                        LastWorkedOn = task.LastWorkedOn,
 12272                        IsDeleted = task.IsDeleted,
 12273                        DeletedAt = task.DeletedAt
 12274                    };
 12275                    await _taskRepository.SaveAsync(importedTask);
 276
 277                    // Map old task ID to new task ID for activity references
 12278                    if (task.Id != Guid.Empty)
 12279                    {
 12280                        taskIdMapping[task.Id] = newId;
 12281                    }
 282
 12283                    tasksImported++;
 12284                    existingData.TaskLookup.Add(key); // Add to lookup to catch duplicates within the same import file
 12285                }
 17286            }
 18287        }
 288
 18289        return new TaskImportResult
 18290        {
 18291            TasksImported = tasksImported,
 18292            TasksSkipped = tasksSkipped,
 18293            TaskIdMapping = taskIdMapping
 18294        };
 18295    }
 296
 297    /// <summary>
 298    /// Imports activities with duplicate detection
 299    /// </summary>
 300    private async Task<ActivityImportResult> ImportActivitiesAsync(List<ActivityRecord>? activities, ExistingData existi
 18301    {
 18302        var activitiesImported = 0;
 18303        var activitiesSkipped = 0;
 304
 18305        if (activities != null)
 18306        {
 82307            foreach (var activity in activities)
 14308            {
 14309                var key = new ActivityKey(activity.Type, activity.CompletedAt, activity.DurationMinutes, activity.TaskNa
 310
 14311                if (existingData.ActivityLookup.Contains(key))
 4312                {
 313                    // Duplicate found - skip
 4314                    activitiesSkipped++;
 4315                    _logger.LogDebug("Skipping duplicate activity: {Type} at {CompletedAt}", activity.Type, activity.Com
 4316                }
 317                else
 10318                {
 319                    // Map the old TaskId to the new TaskId if it exists
 10320                    Guid? newTaskId = null;
 10321                    if (activity.TaskId.HasValue && activity.TaskId.Value != Guid.Empty)
 4322                    {
 4323                        if (taskIdMapping.TryGetValue(activity.TaskId.Value, out var mappedId))
 2324                        {
 2325                            newTaskId = mappedId;
 2326                        }
 2327                        else if (existingData.TaskById.TryGetValue(activity.TaskId.Value, out var existingTask))
 1328                        {
 329                            // Task wasn't in the import but exists in the database, keep the reference
 1330                            newTaskId = activity.TaskId.Value;
 1331                        }
 332                        // else: Task doesn't exist, leave TaskId as null
 4333                    }
 334
 335                    // Not a duplicate - create new record with new ID
 10336                    var importedActivity = new ActivityRecord
 10337                    {
 10338                        Id = Guid.NewGuid(),
 10339                        Type = activity.Type,
 10340                        TaskId = newTaskId,
 10341                        TaskName = activity.TaskName,
 10342                        CompletedAt = activity.CompletedAt,
 10343                        DurationMinutes = activity.DurationMinutes,
 10344                        WasCompleted = activity.WasCompleted
 10345                    };
 10346                    await _activityRepository.SaveAsync(importedActivity);
 10347                    activitiesImported++;
 10348                    existingData.ActivityLookup.Add(key); // Add to lookup to catch duplicates within the same import fi
 10349                }
 14350            }
 18351        }
 352
 18353        return new ActivityImportResult
 18354        {
 18355            ActivitiesImported = activitiesImported,
 18356            ActivitiesSkipped = activitiesSkipped
 18357        };
 18358    }
 359
 360    /// <summary>
 361    /// Clears all application data
 362    /// </summary>
 363    public async Task ClearAllDataAsync()
 6364    {
 365        try
 6366        {
 6367            var activityCount = await _activityRepository.GetCountAsync();
 4368            var taskCount = await _taskRepository.GetCountAsync();
 369
 4370            await _activityRepository.ClearAllAsync();
 4371            await _taskRepository.ClearAllAsync();
 372
 373            // Clear settings by resetting to defaults
 4374            var defaultSettings = new TimerSettings();
 4375            await _settingsRepository.SaveAsync(defaultSettings);
 376
 4377            _logger.LogInformation(Constants.Messages.LogClearDataSuccess, activityCount, taskCount);
 4378        }
 2379        catch (Exception ex)
 2380        {
 2381            _logger.LogError(ex, Constants.Messages.LogClearDataFailed);
 2382            throw;
 383        }
 4384    }
 385
 386    #region Export Data Model
 387
 388    /// <summary>
 389    /// Data structure for JSON export/import
 390    /// </summary>
 391    private class ExportData
 392    {
 46393        public int Version { get; set; }
 21394        public DateTime ExportDate { get; set; }
 39395        public TimerSettings? Settings { get; set; }
 39396        public List<ActivityRecord>? Activities { get; set; }
 39397        public List<TaskItem>? Tasks { get; set; }
 398    }
 399
 400    /// <summary>
 401    /// Container for existing data used during import
 402    /// </summary>
 403    private class ExistingData
 404    {
 36405        public List<ActivityRecord> Activities { get; set; } = new();
 36406        public List<TaskItem> Tasks { get; set; } = new();
 60407        public HashSet<ActivityKey> ActivityLookup { get; set; } = new();
 65408        public HashSet<TaskKey> TaskLookup { get; set; } = new();
 38409        public Dictionary<Guid, TaskItem> TaskById { get; set; } = new();
 41410        public Dictionary<TaskKey, TaskItem> TaskByKey { get; set; } = new();
 411    }
 412
 413    /// <summary>
 414    /// Result of task import operation
 415    /// </summary>
 416    private class TaskImportResult
 417    {
 54418        public int TasksImported { get; set; }
 54419        public int TasksSkipped { get; set; }
 54420        public Dictionary<Guid, Guid> TaskIdMapping { get; set; } = new();
 421    }
 422
 423    /// <summary>
 424    /// Result of activity import operation
 425    /// </summary>
 426    private class ActivityImportResult
 427    {
 54428        public int ActivitiesImported { get; set; }
 54429        public int ActivitiesSkipped { get; set; }
 430    }
 431
 432    #endregion
 433}