diff --git a/Clockify.csproj b/Clockify.csproj index 9aecaff..027ca32 100644 --- a/Clockify.csproj +++ b/Clockify.csproj @@ -62,6 +62,9 @@ PreserveNewest + + PreserveNewest + Always diff --git a/Clockify/ClockifyLookupService.cs b/Clockify/ClockifyLookupService.cs new file mode 100644 index 0000000..9e8af30 --- /dev/null +++ b/Clockify/ClockifyLookupService.cs @@ -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> 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> 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> 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> 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 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; + } + } +} diff --git a/Clockify/LookupMapping.cs b/Clockify/LookupMapping.cs new file mode 100644 index 0000000..6580c8f --- /dev/null +++ b/Clockify/LookupMapping.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Clockify; + +public static class LookupMapping +{ + public static List ToSortedNames(IEnumerable items, Func nameSelector) + { + if (items is null) + { + return []; + } + + return items + .Select(nameSelector) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct() + .OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + } +} diff --git a/Clockify/PluginSettings.cs b/Clockify/PluginSettings.cs index 8fa2517..eb8843e 100644 --- a/Clockify/PluginSettings.cs +++ b/Clockify/PluginSettings.cs @@ -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")] @@ -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}'"; diff --git a/Clockify/ToggleAction.cs b/Clockify/ToggleAction.cs index 8cea331..355a728 100644 --- a/Clockify/ToggleAction.cs +++ b/Clockify/ToggleAction.cs @@ -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; @@ -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) @@ -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); @@ -30,6 +37,7 @@ public ToggleAction(ISDConnection connection, InitialPayload payload) public override void Dispose() { + Connection.OnSendToPlugin -= OnSendToPlugin; _logger.LogInfo("Disposing ToggleAction..."); } @@ -107,6 +115,44 @@ public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payloa _logger.LogInfo("Global Settings Received"); } + private async void OnSendToPlugin(object sender, SDEventReceivedEventArgs 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 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 TryInitializingClockifyContext() { if (_clockifyService.IsValid) diff --git a/PropertyInspector/PluginActionPI.html b/PropertyInspector/PluginActionPI.html index 1e3e75d..3664c91 100644 --- a/PropertyInspector/PluginActionPI.html +++ b/PropertyInspector/PluginActionPI.html @@ -1,4 +1,4 @@ - + @@ -7,6 +7,11 @@ Clockify Settings + @@ -15,18 +20,39 @@
API Key
+
-
Workspace Name
- +
-
-
Project Name
- + +
+
Workspace
+ + +
-
-
Task Name
- + +
+
Client
+ + + +
+ +
+
Projekt
+ + +
+ +
+
Task
+ + + +
+
Timer Name
@@ -47,10 +73,6 @@
Advanced
-
-
Client Name
- -
Title Format
@@ -73,6 +95,17 @@
+ + + + + + + + + +
+ diff --git a/PropertyInspector/clockify-pi.js b/PropertyInspector/clockify-pi.js new file mode 100644 index 0000000..7131346 --- /dev/null +++ b/PropertyInspector/clockify-pi.js @@ -0,0 +1,182 @@ +// Clockify Property Inspector – kaskadierende Live-Dropdowns. +// Hängt sich über das EasyPI-Event 'websocketCreate' ein und ergänzt einen +// eigenen message-Listener (addEventListener), ohne sdtools.common.js zu ändern. + +(function () { + // fieldKey -> { name: hiddenNameId, manual: hiddenCheckboxId, request, children } + var FIELDS = { + workspace: { name: 'workspaceName', manual: 'workspaceManual', request: 'getWorkspaces', children: ['client', 'project'] }, + client: { name: 'clientName', manual: 'clientManual', request: 'getClients', children: ['project'] }, + project: { name: 'projectName', manual: 'projectManual', request: 'getProjects', children: ['task'] }, + task: { name: 'taskName', manual: 'taskManual', request: 'getTasks', children: [] } + }; + + function $(id) { return document.getElementById(id); } + function selectEl(key) { return $(key + 'Select'); } + function textEl(key) { return $(key + 'Text'); } + function toggleEl(key) { return $(key + 'Toggle'); } + function hiddenName(key) { return $(FIELDS[key].name); } + function hiddenManual(key) { return $(FIELDS[key].manual); } + + function currentValue(key) { + return FIELDS[key] ? (hiddenName(key).value || '') : ''; + } + + // Schreibt UI -> verstecktes Quell-Feld und persistiert über EasyPI. + function commit(key, value, manual) { + hiddenName(key).value = value || ''; + hiddenManual(key).checked = !!manual; + if (typeof setSettings === 'function') { + setSettings(); + } + } + + function requestList(key) { + var payload = { + request: FIELDS[key].request, + apiKey: ($('apiKey') ? $('apiKey').value : '') || '', + serverUrl: ($('serverUrl') ? $('serverUrl').value : '') || '', + workspaceName: currentValue('workspace'), + clientName: currentValue('client'), + projectName: currentValue('project') + }; + if (typeof sendPayloadToPlugin === 'function') { + sendPayloadToPlugin(payload); + } + } + + function requestAll() { + requestList('workspace'); + requestList('client'); + requestList('project'); + requestList('task'); + } + + // Befüllt das Dropdown und stellt die gespeicherte Auswahl wieder her. + // Ist der gespeicherte Wert nicht in der Liste, wird er als Option ergänzt, + // damit die Auswahl nicht still verloren geht. + function fillDropdown(key, items) { + var sel = selectEl(key); + var saved = hiddenName(key).value || ''; + sel.options.length = 0; + + var emptyOpt = document.createElement('option'); + emptyOpt.value = ''; + emptyOpt.text = '(keine Auswahl)'; + sel.appendChild(emptyOpt); + + var found = false; + for (var i = 0; i < items.length; i++) { + var opt = document.createElement('option'); + opt.value = items[i]; + opt.text = items[i]; + sel.appendChild(opt); + if (items[i] === saved) { found = true; } + } + if (saved && !found) { + var keep = document.createElement('option'); + keep.value = saved; + keep.text = saved + ' (gespeichert)'; + sel.appendChild(keep); + } + sel.value = saved; + } + + function onPluginMessage(evt) { + var data; + try { data = JSON.parse(evt.data); } catch (e) { return; } + if (data.event !== 'sendToPropertyInspector' || !data.payload) { return; } + var response = data.payload.response; + var items = data.payload.items || []; + var key = null; + for (var k in FIELDS) { + if (FIELDS[k].request === response) { key = k; break; } + } + if (key) { fillDropdown(key, items); } + } + + function setManualMode(key, manual) { + if (manual) { + selectEl(key).classList.add('cf-hidden'); + textEl(key).classList.remove('cf-hidden'); + } else { + textEl(key).classList.add('cf-hidden'); + selectEl(key).classList.remove('cf-hidden'); + } + } + + function cascadeFrom(key) { + // Bewusst KEIN Reset abhängiger Felder: bei Workspace-Wechsel nutzt + // requestList('project') vorerst den bisherigen clientName. Für den + // Patfor-Fall gewollt – nicht zu einem Reset "korrigieren". + var children = FIELDS[key].children; + for (var i = 0; i < children.length; i++) { + requestList(children[i]); + } + } + + function wireField(key) { + // Dropdown-Auswahl -> Quelle + Kaskade. + selectEl(key).addEventListener('change', function () { + commit(key, selectEl(key).value, false); + cascadeFrom(key); + }); + // Freitext -> Quelle (+ Kaskade, da abhängige Felder neu laden müssen). + textEl(key).addEventListener('input', function () { + commit(key, textEl(key).value, true); + }); + textEl(key).addEventListener('change', function () { + cascadeFrom(key); + }); + // Umschalter Liste/Freitext. Den wahren Wert (verstecktes Quell-Feld) über den + // Umschalt-Vorgang tragen und ins Ziel-Widget spiegeln – sonst überschreibt ein + // leeres/veraltetes Widget den gespeicherten Wert. + toggleEl(key).addEventListener('click', function () { + var manual = !hiddenManual(key).checked; + var value = currentValue(key); + setManualMode(key, manual); + if (manual) { + textEl(key).value = value; + } else { + selectEl(key).value = value; + } + commit(key, value, manual); + }); + } + + // Liest die von EasyPI geladenen versteckten Felder und initialisiert die UI. + function initFromSettings() { + for (var key in FIELDS) { + var manual = hiddenManual(key).checked; + var value = hiddenName(key).value || ''; + setManualMode(key, manual); + if (manual) { + textEl(key).value = value; + } + } + // Auto-Load der Wurzel; Kaskade folgt über die Antworten + gespeicherte Werte. + var apiKey = $('apiKey') ? $('apiKey').value : ''; + if (apiKey) { + requestAll(); + } + } + + function attach() { + if (typeof websocket === 'undefined' || !websocket) { return; } + websocket.addEventListener('message', onPluginMessage); + for (var key in FIELDS) { wireField(key); } + $('cfRefresh').addEventListener('click', requestAll); + // Initiales Laden erst, wenn der Socket OFFEN ist. Beim 'websocketCreate' + // ist der Socket noch CONNECTING; ein setTimeout(0) liefe vor 'open' und + // sendPayloadToPlugin (readyState !== 1) würde alle Requests verwerfen. + // loadConfiguration der Lib hat die versteckten Felder zu diesem Zeitpunkt + // bereits synchron gefüllt, daher ist die Auswahl-Wiederherstellung intakt. + if (websocket.readyState === 1) { + initFromSettings(); + } else { + websocket.addEventListener('open', initFromSettings); + } + } + + document.addEventListener('websocketCreate', attach); +})();