Skip to content
Open
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
3 changes: 3 additions & 0 deletions Clockify.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
<None Update="PropertyInspector\sdtools.common.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="PropertyInspector\clockify-pi.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Images\toggleActionActiveIcon.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
Expand Down
194 changes: 194 additions & 0 deletions Clockify/ClockifyLookupService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using ClockifyClient;
using Microsoft.Kiota.Abstractions;

namespace Clockify;

// Read-only Lookups für den Property Inspector. Baut pro Aufruf einen
// kurzlebigen Client und teilt KEINEN Zustand mit ClockifyService, damit
// PI-Abfragen das laufende Timer-Matching in OnTick nicht beeinflussen.
public class ClockifyLookupService(Logger logger)
{
private const int MaxPageSize = 5000;

public async Task<List<string>> GetWorkspacesAsync(string apiKey, string serverUrl)
{
var client = TryCreateClient(apiKey, serverUrl);
if (client is null)
{
return [];
}

try
{
var workspaces = await client.V1.Workspaces.GetAsync();
return LookupMapping.ToSortedNames(workspaces, w => w.Name);
}
catch (Exception e) when (e is ApiException or HttpRequestException)
{
logger.LogWarn($"Lookup workspaces failed: {e.Message}");
return [];
}
}

public async Task<List<string>> GetClientsAsync(string apiKey, string serverUrl, string workspaceName)
{
var client = TryCreateClient(apiKey, serverUrl);
if (client is null)
{
return [];
}

try
{
var workspaceId = await ResolveWorkspaceIdAsync(client, workspaceName);
if (workspaceId is null)
{
return [];
}

var clients = await client.V1.Workspaces[workspaceId].Clients
.GetAsync(q => q.QueryParameters.PageSize = MaxPageSize);
return LookupMapping.ToSortedNames(clients, c => c.Name);
}
catch (Exception e) when (e is ApiException or HttpRequestException)
{
logger.LogWarn($"Lookup clients failed: {e.Message}");
return [];
}
}

public async Task<List<string>> GetProjectsAsync(string apiKey, string serverUrl, string workspaceName, string clientName)
{
var client = TryCreateClient(apiKey, serverUrl);
if (client is null)
{
return [];
}

try
{
var workspaceId = await ResolveWorkspaceIdAsync(client, workspaceName);
if (workspaceId is null)
{
return [];
}

string clientId = null;
if (!string.IsNullOrWhiteSpace(clientName))
{
var clients = await client.V1.Workspaces[workspaceId].Clients
.GetAsync(q =>
{
q.QueryParameters.Name = clientName;
q.QueryParameters.PageSize = MaxPageSize;
});
clientId = clients?.FirstOrDefault(c => c.Name == clientName)?.Id;

// Filter war angefragt, aber Client nicht auflösbar -> leere Liste statt
// stillschweigend alle Projekte des Workspace zurückzugeben.
if (clientId is null)
{
return [];
}
}

var projects = await client.V1.Workspaces[workspaceId].Projects
.GetAsync(q =>
{
q.QueryParameters.PageSize = MaxPageSize;
if (clientId is not null)
{
q.QueryParameters.Clients = [clientId];
}
});
return LookupMapping.ToSortedNames(projects, p => p.Name);
}
catch (Exception e) when (e is ApiException or HttpRequestException)
{
logger.LogWarn($"Lookup projects failed: {e.Message}");
return [];
}
}

public async Task<List<string>> GetTasksAsync(string apiKey, string serverUrl, string workspaceName, string projectName)
{
var client = TryCreateClient(apiKey, serverUrl);
if (client is null || string.IsNullOrWhiteSpace(projectName))
{
return [];
}

try
{
var workspaceId = await ResolveWorkspaceIdAsync(client, workspaceName);
if (workspaceId is null)
{
return [];
}

var projects = await client.V1.Workspaces[workspaceId].Projects
.GetAsync(q =>
{
q.QueryParameters.Name = projectName;
q.QueryParameters.StrictNameSearch = true;
q.QueryParameters.PageSize = MaxPageSize;
});
var projectId = projects?.FirstOrDefault(p => p.Name == projectName)?.Id;
if (projectId is null)
{
return [];
}

var tasks = await client.V1.Workspaces[workspaceId].Projects[projectId].Tasks
.GetAsync(q => q.QueryParameters.PageSize = MaxPageSize);
return LookupMapping.ToSortedNames(tasks, t => t.Name);
}
catch (Exception e) when (e is ApiException or HttpRequestException)
{
logger.LogWarn($"Lookup tasks failed: {e.Message}");
return [];
}
}

private static async Task<string> ResolveWorkspaceIdAsync(ClockifyApiClient client, string workspaceName)
{
if (string.IsNullOrWhiteSpace(workspaceName))
{
return null;
}

var workspaces = await client.V1.Workspaces.GetAsync();
return workspaces?.FirstOrDefault(w => w.Name == workspaceName)?.Id;
}

private ClockifyApiClient TryCreateClient(string apiKey, string serverUrl)
{
var settings = new PluginSettings
{
ApiKey = apiKey,
ServerUrl = string.IsNullOrWhiteSpace(serverUrl) ? "https://api.clockify.me/api" : serverUrl
};
SettingsValidator.MigrateServerUrl(settings);

var (isValid, _) = SettingsValidator.Validate(settings);
if (!isValid)
{
return null;
}

try
{
return ClockifyApiClientFactory.Create(settings.ApiKey, settings.ServerUrl);
}
catch (Exception e)
{
logger.LogWarn($"Lookup client creation failed: {e.Message}");
return null;
}
}
}
23 changes: 23 additions & 0 deletions Clockify/LookupMapping.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Clockify;

public static class LookupMapping
{
public static List<string> ToSortedNames<T>(IEnumerable<T> items, Func<T, string> nameSelector)
{
if (items is null)
{
return [];
}

return items
.Select(nameSelector)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct()
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
}
16 changes: 16 additions & 0 deletions Clockify/PluginSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public PluginSettings(PluginSettings settings)
TitleFormat = settings.TitleFormat;
RefreshRate = settings.RefreshRate;
ServerUrl = settings.ServerUrl;
WorkspaceManual = settings.WorkspaceManual;
ClientManual = settings.ClientManual;
ProjectManual = settings.ProjectManual;
TaskManual = settings.TaskManual;
}

[JsonProperty(PropertyName = "apiKey")]
Expand Down Expand Up @@ -55,6 +59,18 @@ public PluginSettings(PluginSettings settings)
[JsonProperty(PropertyName = "serverUrl")]
public string ServerUrl { get; set; } = "https://api.clockify.me/api";

[JsonProperty(PropertyName = "workspaceManual")]
public bool WorkspaceManual { get; set; }

[JsonProperty(PropertyName = "clientManual")]
public bool ClientManual { get; set; }

[JsonProperty(PropertyName = "projectManual")]
public bool ProjectManual { get; set; }

[JsonProperty(PropertyName = "taskManual")]
public bool TaskManual { get; set; }

public override string ToString()
{
return $"Workspace: '{WorkspaceName}', Project: '{ProjectName}', Task: '{TaskName}', Timer: '{TimerName}', Tags: '{Tags}', Client: '{ClientName}', Billable: '{Billable}'";
Expand Down
46 changes: 46 additions & 0 deletions Clockify/ToggleAction.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using BarRaider.SdTools;
using BarRaider.SdTools.Events;
using BarRaider.SdTools.Wrappers;
using Newtonsoft.Json.Linq;

// ReSharper disable AsyncVoidMethod - Async overridse for SdTools
namespace Clockify;
Expand All @@ -13,6 +17,7 @@ public class ToggleAction : KeypadBase

private readonly ButtonState _buttonState;
private readonly ClockifyService _clockifyService;
private readonly ClockifyLookupService _lookupService;

public ToggleAction(ISDConnection connection, InitialPayload payload)
: base(connection, payload)
Expand All @@ -22,6 +27,8 @@ public ToggleAction(ISDConnection connection, InitialPayload payload)

_buttonState = new ButtonState();
_clockifyService = new ClockifyService(_logger);
_lookupService = new ClockifyLookupService(_logger);
Connection.OnSendToPlugin += OnSendToPlugin;

Tools.AutoPopulateSettings(_settings, payload.Settings);

Expand All @@ -30,6 +37,7 @@ public ToggleAction(ISDConnection connection, InitialPayload payload)

public override void Dispose()
{
Connection.OnSendToPlugin -= OnSendToPlugin;
_logger.LogInfo("Disposing ToggleAction...");
}

Expand Down Expand Up @@ -107,6 +115,44 @@ public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payloa
_logger.LogInfo("Global Settings Received");
}

private async void OnSendToPlugin(object sender, SDEventReceivedEventArgs<SendToPlugin> e)
{
var payload = e.Event.Payload;
var request = payload?["request"]?.ToString();
if (string.IsNullOrEmpty(request))
{
return;
}

var apiKey = payload["apiKey"]?.ToString() ?? string.Empty;
var serverUrl = payload["serverUrl"]?.ToString() ?? string.Empty;
var workspaceName = payload["workspaceName"]?.ToString() ?? string.Empty;
var clientName = payload["clientName"]?.ToString() ?? string.Empty;
var projectName = payload["projectName"]?.ToString() ?? string.Empty;

List<string> items;
switch (request)
{
case "getWorkspaces":
items = await _lookupService.GetWorkspacesAsync(apiKey, serverUrl);
break;
case "getClients":
items = await _lookupService.GetClientsAsync(apiKey, serverUrl, workspaceName);
break;
case "getProjects":
items = await _lookupService.GetProjectsAsync(apiKey, serverUrl, workspaceName, clientName);
break;
case "getTasks":
items = await _lookupService.GetTasksAsync(apiKey, serverUrl, workspaceName, projectName);
break;
default:
return;
}

var response = JObject.FromObject(new { response = request, items });
await Connection.SendToPropertyInspectorAsync(response);
}

private async Task<bool> TryInitializingClockifyContext()
{
if (_clockifyService.IsValid)
Expand Down
Loading