Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 109 additions & 82 deletions Clockify/ClockifyService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using ClockifyClient;
using ClockifyClient.Models;
Expand All @@ -13,6 +14,8 @@ public class ClockifyService(Logger logger)
{
private const int MaxPageSize = 5000;

private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1);

private PluginSettings _settings = new();

private ClockifyApiClient _clockifyClient;
Expand All @@ -21,7 +24,6 @@ public class ClockifyService(Logger logger)
private ProjectDtoV1 _project = new();
private List<string> _tags = [];
private TaskDtoV1 _task = new();
private ClientWithCurrencyDtoV1 _client = new();

public bool IsValid => _clockifyClient is not null
&& !string.IsNullOrWhiteSpace(_settings.WorkspaceName)
Expand All @@ -30,116 +32,140 @@ public class ClockifyService(Logger logger)
public async Task<bool> ToggleTimerAsync()
{
logger.LogInfo("Toggling timer...");

if (!IsValid)

await _cacheLock.WaitAsync();
try
{
logger.LogError($"Toggling trimer failed, invalid settings: {_settings}");
return false;
}
if (!IsValid)
{
logger.LogError($"Toggling trimer failed, invalid settings: {_settings}");
return false;
}

var runningTimer = await StopRunningTimerAsync();
var runningTimer = await StopRunningTimerAsync();

if (runningTimer is not null)
{
logger.LogInfo("Toggling trimer successful, timer has been stopped");
return true;
}
if (runningTimer is not null)
{
logger.LogInfo("Toggling trimer successful, timer has been stopped");
return true;
}

try
{
var timeEntryRequest = await CreateTimeEntryRequestAsync();
await _clockifyClient.V1.Workspaces[_workspace.Id].TimeEntries.PostAsync(timeEntryRequest);

logger.LogInfo("Toggling trimer successful, timer has been started");
return true;
try
{
var timeEntryRequest = await CreateTimeEntryRequestAsync();
await _clockifyClient.V1.Workspaces[_workspace.Id].TimeEntries.PostAsync(timeEntryRequest);

logger.LogInfo("Toggling trimer successful, timer has been started");
return true;
}
catch (Exception exception) when (exception is ApiException or HttpRequestException)
{
logger.LogError($"Toggling trimer failed, TimeEntry creation failed: {exception.Message}");
return false;
}
}
catch (Exception exception) when (exception is ApiException or HttpRequestException)
finally
{
logger.LogError($"Toggling trimer failed, TimeEntry creation failed: {exception.Message}");
return false;
_cacheLock.Release();
}
}

public async Task<TimeEntryWithRatesDtoV1> GetRunningTimerAsync()
{
logger.LogInfo("Fetching running timer...");

if (!IsValid)
{
logger.LogError($"Fetching running timer failed, invalid settings: {_settings}");
return null;
}

await _cacheLock.WaitAsync();
try
{
var timeEntries = await _clockifyClient.V1.Workspaces[_workspace.Id].User[_currentUser.Id].TimeEntries
.GetAsync(p => p.QueryParameters.InProgress = true);

if (string.IsNullOrEmpty(_settings.ProjectName))
if (!IsValid)
{
logger.LogError($"Fetching running timer failed, invalid settings: {_settings}");
return null;
}

try
{
return timeEntries?.FirstOrDefault(t => string.IsNullOrEmpty(_settings.TimerName) || t.Description == _settings.TimerName);
var timeEntries = await _clockifyClient.V1.Workspaces[_workspace.Id].User[_currentUser.Id].TimeEntries
.GetAsync(p => p.QueryParameters.InProgress = true);

if (string.IsNullOrEmpty(_settings.ProjectName))
{
return timeEntries?.FirstOrDefault(t => string.IsNullOrEmpty(_settings.TimerName) || t.Description == _settings.TimerName);
}

if (_project is null)
{
logger.LogError($"Fetching running timer failed, no project in workspace matching {_settings.ProjectName}");
return null;
}

return timeEntries?.FirstOrDefault(t => t.ProjectId == _project.Id
&& (string.IsNullOrEmpty(_settings.TimerName) || t.Description == _settings.TimerName)
&& (string.IsNullOrEmpty(_settings.TaskName) || string.IsNullOrEmpty(_task?.Id) || t.TaskId == _task.Id)
&& ((t.TagIds is null && _tags is null) || (t.TagIds is not null && _tags is not null && t.TagIds.OrderBy(s => s, StringComparer.InvariantCulture)
.SequenceEqual(_tags.OrderBy(s => s, StringComparer.InvariantCulture))))
&& t.Billable == _settings.Billable);
}

if (_project is null)
catch (Exception exception) when (exception is ApiException or HttpRequestException)
{
logger.LogError($"Fetching running timer failed, no project in workspace matching {_settings.ProjectName}");
logger.LogError($"Fetching running timer failed, TimeEntry request failed: {exception.Message}");
return null;
}

return timeEntries?.FirstOrDefault(t => t.ProjectId == _project.Id
&& (string.IsNullOrEmpty(_settings.TimerName) || t.Description == _settings.TimerName)
&& (string.IsNullOrEmpty(_settings.TaskName) || string.IsNullOrEmpty(_task?.Id) || t.TaskId == _task.Id)
&& ((t.TagIds is null && _tags is null) || t.TagIds is not null && _tags is not null && t.TagIds.OrderBy(s => s, StringComparer.InvariantCulture)
.SequenceEqual(_tags.OrderBy(s => s, StringComparer.InvariantCulture)))
&& t.Billable == _settings.Billable);
}
catch (Exception exception) when (exception is ApiException or HttpRequestException)
finally
{
logger.LogError($"Fetching running timer failed, TimeEntry request failed: {exception.Message}");
return null;
_cacheLock.Release();
}
}

public async Task UpdateSettingsAsync(PluginSettings settings)
{
logger.LogInfo("Updating settings...");

SettingsValidator.MigrateServerUrl(settings);

var cacheInvalidationRequired = SettingsValidator.HasChanged(_settings, settings);

// Do we need to recreate the client?
if (!IsValid || _settings.ApiKey != settings.ApiKey || _settings.ServerUrl != settings.ServerUrl)
await _cacheLock.WaitAsync();
try
{
logger.LogInfo("Updating settings, recreate Clockify client");

var validation = SettingsValidator.Validate(settings);
SettingsValidator.MigrateServerUrl(settings);
var cacheInvalidationRequired = SettingsValidator.HasChanged(_settings, settings);

if (!validation.IsValid)

// Do we need to recreate the client?
if (!IsValid || _settings.ApiKey != settings.ApiKey || _settings.ServerUrl != settings.ServerUrl)
{
logger.LogError($"Updating settings failed, settings validation failed: {validation.Error}");
return;
logger.LogInfo("Updating settings, recreate Clockify client");

var validation = SettingsValidator.Validate(settings);

if (!validation.IsValid)
{
logger.LogError($"Updating settings failed, settings validation failed: {validation.Error}");
return;
}

_clockifyClient = ClockifyApiClientFactory.Create(settings.ApiKey, settings.ServerUrl);

if (!await TestConnectionAsync())
{
logger.LogError("Updating settings failed, invalid server URL or API key");
_clockifyClient = null;
_currentUser = new UserDtoV1();
return;
}

logger.LogInfo("Updating settings successful, connection to Clockify established");
cacheInvalidationRequired = true;
}

_clockifyClient = ClockifyApiClientFactory.Create(settings.ApiKey, settings.ServerUrl);
_settings = new PluginSettings(settings);

if (!await TestConnectionAsync())
if (cacheInvalidationRequired)
{
logger.LogError("Updating settings failed, invalid server URL or API key");
_clockifyClient = null;
_currentUser = new UserDtoV1();
return;
await ReloadCacheAsync();
}

logger.LogInfo("Updating settings successful, connection to Clockify established");
cacheInvalidationRequired = true;
}

_settings = settings;

if (cacheInvalidationRequired)
finally
{
await ReloadCacheAsync();
_cacheLock.Release();
}
}

Expand Down Expand Up @@ -180,22 +206,21 @@ private async Task ReloadCacheAsync()
_project = null;
_tags = [];
_task = null;
_client = null;

try
{
var workspaces = await _clockifyClient.V1.Workspaces.GetAsync();
_workspace = workspaces?.SingleOrDefault(w => w.Name == _settings.WorkspaceName);

if (_workspace != null)
if (_workspace is not null)
{
_project = await FindMatchingProjectAsync(_workspace.Id, _settings.ProjectName);
var client = await FindMatchingClientAsync(_workspace.Id, _settings.ClientName);
_project = await FindMatchingProjectAsync(_workspace.Id, _settings.ProjectName, client?.Id);
_tags = await FindMatchingTagsAsync(_workspace.Id, _settings.Tags);

if (_project != null)
if (_project is not null)
{
_task = await FindMatchingTaskAsync(_workspace.Id, _project.Id, _settings.TaskName);
_client = await FindMatchingClientAsync(_workspace.Id, _settings.ClientName);
}
}

Expand All @@ -216,7 +241,7 @@ private async Task<TimeEntryWithRatesDtoV1> StopRunningTimerAsync()
}

var runningTimer = await GetRunningTimerAsync();
if (runningTimer == null)
if (runningTimer is null)
{
// No running timer
return null;
Expand Down Expand Up @@ -246,13 +271,15 @@ private async Task<TimeEntryWithRatesDtoV1> StopRunningTimerAsync()
return runningTimer;
}

private async Task<ProjectDtoV1> FindMatchingProjectAsync(string workspaceId, string projectName)
private async Task<ProjectDtoV1> FindMatchingProjectAsync(string workspaceId, string projectName, string clientId = null)
{
if (string.IsNullOrEmpty(projectName))
{
return null;
}

logger.LogInfo("Finding matching project...");

try
{
var projects = await _clockifyClient.V1.Workspaces[workspaceId].Projects
Expand All @@ -262,9 +289,9 @@ private async Task<ProjectDtoV1> FindMatchingProjectAsync(string workspaceId, st
q.QueryParameters.StrictNameSearch = true;
q.QueryParameters.PageSize = MaxPageSize;

if (_client is not null)
if (clientId is not null)
{
q.QueryParameters.Clients = [_client.Id];
q.QueryParameters.Clients = [clientId];
}
});

Expand Down Expand Up @@ -395,7 +422,7 @@ private async Task<List<string>> FindMatchingTagsAsync(string workspaceId, strin
var tagsOnWorkspace = await _clockifyClient.V1.Workspaces[workspaceId].Tags
.GetAsync(q => q.QueryParameters.PageSize = MaxPageSize);

return tagsOnWorkspace == null ? [] : tagsOnWorkspace.Where(t => tagList.Contains(t.Name)).Select(t => t.Id).ToList();
return tagsOnWorkspace is null ? [] : tagsOnWorkspace.Where(t => tagList.Contains(t.Name)).Select(t => t.Id).ToList();
}
catch (Exception exception) when (exception is ApiException or HttpRequestException)
{
Expand Down
18 changes: 18 additions & 0 deletions Clockify/PluginSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ namespace Clockify;

public class PluginSettings
{
public PluginSettings()
{}

public PluginSettings(PluginSettings settings)
{
ApiKey = settings.ApiKey;
WorkspaceName = settings.WorkspaceName;
ProjectName = settings.ProjectName;
TaskName = settings.TaskName;
TimerName = settings.TimerName;
Tags = settings.Tags;
ClientName = settings.ClientName;
Billable = settings.Billable;
TitleFormat = settings.TitleFormat;
RefreshRate = settings.RefreshRate;
ServerUrl = settings.ServerUrl;
}

[JsonProperty(PropertyName = "apiKey")]
public string ApiKey { get; set; } = string.Empty;

Expand Down
14 changes: 8 additions & 6 deletions Clockify/TextFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;

namespace Clockify;

public static class TextFormatter
Expand All @@ -7,12 +9,12 @@ public static string CreateTimerText(PluginSettings settings, string timerTime)
if (!string.IsNullOrEmpty(settings.TitleFormat))
{
return settings.TitleFormat
.Replace("{workspaceName}", settings.WorkspaceName)
.Replace("{projectName}", settings.ProjectName)
.Replace("{taskName}", settings.TaskName)
.Replace("{timerName}", settings.TimerName)
.Replace("{clientName}", settings.ClientName)
.Replace("{timer}", timerTime);
.Replace("{workspaceName}", settings.WorkspaceName, StringComparison.InvariantCultureIgnoreCase)
.Replace("{projectName}", settings.ProjectName, StringComparison.InvariantCultureIgnoreCase)
.Replace("{taskName}", settings.TaskName, StringComparison.InvariantCultureIgnoreCase)
.Replace("{timerName}", settings.TimerName, StringComparison.InvariantCultureIgnoreCase)
.Replace("{clientName}", settings.ClientName, StringComparison.InvariantCultureIgnoreCase)
.Replace("{timer}", timerTime, StringComparison.InvariantCultureIgnoreCase);
}

string timerText;
Expand Down