Compare commits

...

12 Commits

14 changed files with 3804 additions and 74 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,6 +1,6 @@
# These are supported funding model platforms
github: daniep01 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: [daniep01, krombel] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
lint-staged

View File

@@ -1,3 +1,3 @@
# companion-module-toggl-track
See [HELP.md](./HELP.md) and [LICENSE](./LICENSE)
See [HELP.md](./companion/HELP.md) and [LICENSE](./LICENSE)

View File

@@ -18,7 +18,7 @@ Start a new timer running with the description set in the action and store the I
**Get Current Timer**
Companion only knows the ID of timers it has started, if a timer is started from another application or the toggle website then this action will get the ID so Companion knows about it.
Companion only knows the ID of timers it has started. If you did not enable time entry poller you can use this action to poll the current time entry, if a timer is started from another application or the toggle website so Companion knows about it.
**Stop Current Timer**
@@ -36,32 +36,46 @@ Presets are available for **Start Timer** and **Stop Timer**.
### Version 1.0.0
First release
- First release
### Version 1.0.1
Fix broken link
- Fix broken link
### Version 1.0.2
Allow a project to be specified when starting a new timer button
Add an action to refresh the project list
Add 'Always start' configuration option
- Allow a project to be specified when starting a new timer button
- Add an action to refresh the project list
- Add 'Always start' configuration option
### Version 1.0.3
Add variables for timerId and timerDescription
- Add variables for timerId and timerDescription
### Version 2.0.0
Updated for Companion version 3
Updated for toggl API version 9
- Updated for Companion version 3
- Updated for toggl API version 9
### Version 2.0.1
Make the API token config field required
- Make the API token config field required
- Fix manifest file
Fix manifest file
### Version 2.1.0
- Rewrite module in typescript
- Use module toggl-track instead of implementing api on our own
- Add status reports for some failure cases in connections dashboard
- Add configurable time entry poller
- Add feedback for currently running project and client
- Update timerDuration to contain the correct duration formatted as time string
### Version 2.1.1
- Prevent module crash if user has no Clients
### Version 2.1.2
- Update node runtime to node22
- make polling interval configurable as toggl is updating their [API usage limits](https://support.toggl.com/en/articles/11484112-api-webhook-limits)

View File

@@ -3,7 +3,7 @@
"name": "toggl-track",
"shortname": "toggl-track",
"description": "Companion module for toggltrack timers",
"version": "2.0.1",
"version": "0.0.0",
"license": "MIT",
"repository": "git+https://github.com/bitfocus/companion-module-toggl-track.git",
"bugs": "https://github.com/bitfocus/companion-module-toggl-track/issues",
@@ -17,7 +17,7 @@
],
"legacyIds": [],
"runtime": {
"type": "node18",
"type": "node22",
"api": "nodejs-ipc",
"apiVersion": "0.0.0",
"entrypoint": "../dist/main.js"

View File

@@ -1,9 +1,10 @@
{
"name": "toggl-track",
"version": "2.1.0",
"version": "2.1.2",
"main": "dist/main.js",
"type": "module",
"scripts": {
"postinstall": "husky",
"format": "prettier -w .",
"package": "yarn run build && companion-module-build",
"build": "rimraf dist && yarn run build:main",
@@ -17,19 +18,27 @@
"type": "git",
"url": "git+https://github.com/bitfocus/companion-module-toggl-track.git"
},
"engines": {
"node": "^22.14",
"yarn": "^4"
},
"license": "MIT",
"dependencies": {
"@companion-module/base": "~1.11",
"toggl-track": "^0.8.0"
"@companion-module/base": "~1.11.3",
"toggl-track": "^0.8.0",
"ws": "^8.18.2"
},
"devDependencies": {
"@companion-module/tools": "~2.1.1",
"@types/node": "^22.10.2",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"rimraf": "^5.0.10",
"typescript": "~5.5.4",
"typescript-eslint": "^8.18.1"
"@companion-module/tools": "~2.3.0",
"@types/node": "^22.14.1",
"@types/ws": "^8",
"eslint": "^9.24.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
"rimraf": "^6.0.1",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1"
},
"prettier": "@companion-module/tools/.prettierrc.json",
"lint-staged": {
@@ -39,5 +48,6 @@
"*.{ts,tsx,js,jsx}": [
"yarn lint:raw --fix"
]
}
},
"packageManager": "yarn@4.9.1"
}

View File

@@ -16,7 +16,7 @@ export default function (self: TogglTrack): void {
label: 'Project',
id: 'project',
default: '0',
choices: self.projects!,
choices: self.projects ?? [{ id: -1, label: 'None' }],
},
],
callback: async ({ options }) => {
@@ -44,7 +44,15 @@ export default function (self: TogglTrack): void {
name: 'Refresh Project List',
options: [],
callback: async () => {
await self.getWorkspace()
await self.getProjects()
},
},
refreshStaticData: {
name: 'Refresh Workspace, Project and Client List',
options: [],
callback: async () => {
await self.loadStaticData()
},
},
})

View File

@@ -5,6 +5,9 @@ export interface ModuleConfig {
workspaceName: string
alwaysStart: boolean
startTimerPoller: boolean
timerPollerInterval: number
startWebSocket: boolean
websocketSecret: string
}
export function GetConfigFields(): SomeCompanionConfigField[] {
@@ -36,9 +39,32 @@ export function GetConfigFields(): SomeCompanionConfigField[] {
{
type: 'checkbox',
id: 'startTimerPoller',
label: 'Poll for current time entry every 30 seconds',
label: 'Poll for current time entry every n seconds',
width: 12,
default: false,
},
{
type: 'number',
id: 'timerPollerInterval',
label: 'Poll interval in seconds',
width: 12,
default: 60,
min: 30,
max: 3600,
},
{
type: 'checkbox',
id: 'startWebSocket',
label: 'start Websocket (krombel testing)',
width: 12,
default: false,
},
{
type: 'textinput',
id: 'websocketSecret',
label: 'secret to authenticate against Websocket server (krombel testing)',
width: 12,
default: 'secret',
},
]
}

50
src/feedbacks.ts Normal file
View File

@@ -0,0 +1,50 @@
import { combineRgb } from '@companion-module/base'
import type { TogglTrack } from './main.js'
export function UpdateFeedbacks(self: TogglTrack): void {
self.setFeedbackDefinitions({
ProjectRunningState: {
name: 'Project Counting',
type: 'boolean',
defaultStyle: {
bgcolor: combineRgb(255, 0, 0),
color: combineRgb(0, 0, 0),
},
options: [
{
id: 'project',
type: 'dropdown',
label: 'Project',
default: -1,
choices: self.projects ?? [{ id: -1, label: 'None' }],
},
],
callback: (feedback) => {
//self.log('debug', 'check project counting ' + feedback.options.project)
return feedback.options.project == self.currentEntry?.project_id
},
},
ClientRunningState: {
name: 'Client Counting',
type: 'boolean',
defaultStyle: {
bgcolor: combineRgb(255, 0, 0),
color: combineRgb(0, 0, 0),
},
options: [
{
id: 'client',
type: 'dropdown',
label: 'Client',
default: -1,
choices: self.clients ?? [{ id: -1, label: 'None' }],
},
],
callback: (feedback) => {
//self.log('debug', 'check client counting ' + feedback.options.client)
// find the project that matches the project_id of the current entry and compare its client_id with the configured one
return feedback.options.client == self.projects?.find((p) => p.id == self.currentEntry?.project_id)?.clientID
},
},
})
}

View File

@@ -1,5 +1,5 @@
// toggltrack module
// Peter Daniel
// Peter Daniel, Matthias Kesler
import { InstanceBase, runEntrypoint, InstanceStatus, SomeCompanionConfigField } from '@companion-module/base'
import { GetConfigFields, type ModuleConfig } from './config.js'
@@ -7,8 +7,11 @@ import UpdateActions from './actions.js'
import UpdatePresets from './presets.js'
import UpdateVariableDefinitions from './variables.js'
import UpgradeScripts from './upgrades.js'
import { Toggl, ITimeEntry, IWorkspaceProject } from 'toggl-track'
import { togglGetWorkspaces } from './toggl-extend.js'
import { UpdateFeedbacks } from './feedbacks.js'
import { Toggl, ITimeEntry, IWorkspaceProject, IClient } from 'toggl-track'
import { isITimeEntryWebhookPayload, togglGetWorkspaces } from './toggl-extend.js'
import { timecodeSince } from './utils.js'
import WebSocket from 'ws'
export class TogglTrack extends InstanceBase<ModuleConfig> {
config!: ModuleConfig // Setup in init()
@@ -17,8 +20,15 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
workspaceId?: number // current active workspace id
workspaceName: string = '' // name of workspace
projects?: { id: number; label: string }[]
projects?: { id: number; label: string; clientID?: number }[]
clients?: { id: number; label: string }[]
currentEntry?: ITimeEntry
intervalId?: NodeJS.Timeout
currentTimerUpdaterIntervalId?: NodeJS.Timeout
wsReconnectTimer?: NodeJS.Timeout
ws?: WebSocket
constructor(internal: unknown) {
super(internal)
@@ -33,6 +43,12 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
if (this.config.startTimerPoller) {
this.stopTimeEntryPoller()
}
if (this.config.startWebSocket) {
this.stopWebSocket()
}
clearInterval(this.currentTimerUpdaterIntervalId)
}
async init(config: ModuleConfig): Promise<void> {
@@ -40,30 +56,27 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.config = config
this.projects = [{ id: 0, label: 'None' }]
this.updateVariableDefinitions()
this.updatePresets()
this.setVariableValues({
timerId: undefined,
timerDuration: undefined,
timerDescription: undefined,
lastTimerDuration: undefined,
workspace: undefined,
})
await this.initToggleConnection()
await this.loadStaticData()
this.updateActions()
this.updateFeedbacks()
if (this.toggl && this.workspaceId) {
this.updateStatus(InstanceStatus.Ok)
}
await this.getCurrentTimer()
if (this.config.startTimerPoller) {
this.startTimeEntryPoller()
}
if (this.config.startWebSocket) {
this.startWebSocket()
}
}
async configUpdated(config: ModuleConfig): Promise<void> {
@@ -72,6 +85,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
const apiTokenChanged: boolean = this.config.apiToken != config.apiToken
const workSpaceDefaultChanged: boolean = this.config.workspaceName != config.workspaceName
const timeEntryPollerChanged: boolean = this.config.startTimerPoller != config.startTimerPoller
const webSocketChanged: boolean = this.config.startWebSocket != config.startWebSocket
this.config = config
@@ -79,9 +93,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.log('debug', 'api token changed. init new toggle connection')
this.toggl = undefined
await this.initToggleConnection()
await this.loadStaticData()
} else if (workSpaceDefaultChanged) {
this.log('debug', 'workspace default changed. reload workspaces')
await this.getWorkspace()
await this.loadStaticData()
}
if (timeEntryPollerChanged) {
@@ -91,6 +106,13 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.stopTimeEntryPoller()
}
}
if (webSocketChanged) {
if (this.config.startWebSocket) {
this.startWebSocket()
} else {
this.stopWebSocket()
}
}
this.updateActions()
//this.updateVariables()
@@ -105,6 +127,9 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
updateActions(): void {
UpdateActions(this)
}
updateFeedbacks(): void {
UpdateFeedbacks(this)
}
updatePresets(): void {
UpdatePresets(this)
@@ -129,8 +154,6 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.updateStatus(InstanceStatus.AuthenticationFailure, resp)
return
}
await this.getWorkspace()
await this.getCurrentTimer()
}
}
@@ -142,13 +165,131 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
void (async () => {
await this.getCurrentTimer()
})()
}, 30 * 1000)
}, this.config.timerPollerInterval * 1000)
}
private stopTimeEntryPoller(): void {
this.log('info', 'Stopping TimeEntry-Poller')
clearInterval(this.intervalId)
}
private startWebSocket(): void {
this.log('info', 'start websocket')
this.updateStatus(InstanceStatus.Connecting)
if (this.ws) {
this.ws.close(1000)
delete this.ws
}
this.ws = new WebSocket('wss://doh.krombel.de/toggl-websocket')
this.ws.addEventListener('open', () => {
this.updateStatus(InstanceStatus.Ok)
this.ws?.send(this.config.websocketSecret)
})
this.ws.addEventListener('close', (code) => {
this.log('debug', `Connection closed with code ${code.code}`)
this.updateStatus(InstanceStatus.Disconnected, `WS-Connection closed with code ${code.code}`)
this.maybeReconnectWebSocket()
})
this.ws.addEventListener('message', this.websocketMessageHandler.bind(this))
this.ws.addEventListener('error', (data) => {
this.log('error', `WebSocket error: ${data.error}`)
})
}
private stopWebSocket(): void {
this.log('info', 'stop websocket')
if (this.wsReconnectTimer) {
clearTimeout(this.wsReconnectTimer)
this.wsReconnectTimer = undefined
}
if (this.ws) {
this.ws.close(1000)
delete this.ws
}
}
private maybeReconnectWebSocket() {
if (this.wsReconnectTimer) {
clearTimeout(this.wsReconnectTimer)
}
this.wsReconnectTimer = setTimeout(() => {
this.startWebSocket()
}, 5000)
}
private websocketMessageHandler(data: WebSocket.MessageEvent) {
this.log('info', 'Websocket message received')
if (typeof data.data === 'string') {
this.log('debug', `data: ${data.data}`)
const event = JSON.parse(data.data)
if (isITimeEntryWebhookPayload(event)) {
const entry = event.payload
if (entry.stop === '' && event.metadata.action != 'deleted') {
this.log('info', `update time entry to ${entry.description}`)
this.setCurrentlyRunningTimeEntry(entry)
} else if (entry.id == this.currentEntry?.id) {
// only unset value if update is for current entry
this.log('info', 'stop time entry')
this.setCurrentlyRunningTimeEntry(undefined)
} else {
this.log('warn', `unhandled case for time entry ${JSON.stringify(entry)}`)
}
} else {
this.log('warn', `unhandled event ${data.data}`)
}
return
}
this.log('warn', `unhandled websocket event '${JSON.stringify(data)}'`)
}
/**
* Set variables to this time entry
* @param entry running entry or undefined
*/
private setCurrentlyRunningTimeEntry(entry: ITimeEntry | undefined): void {
this.currentEntry = entry
if (entry) {
const project = this.projects?.find((p) => p.id == entry.project_id)
this.setVariableValues({
timerId: entry.id,
timerDescription: entry.description,
timerDuration: timecodeSince(new Date(entry.start)),
timerProject: project?.label,
timerProjectID: entry.project_id,
timerClient: this.clients?.find((c) => c.id == project?.clientID)?.label ?? 'None',
timerClientID: project?.clientID,
})
// in case there is on update thread running clear it
clearInterval(this.currentTimerUpdaterIntervalId)
// Update timerDuration once per second
this.currentTimerUpdaterIntervalId = setInterval(() => {
// this harms the linter (handle unawaited promise in an non-async context)
void (async () => {
this.setVariableValues({
timerDuration: timecodeSince(new Date(entry.start)),
})
})()
}, 1000) // update every second
} else {
clearInterval(this.currentTimerUpdaterIntervalId)
this.setVariableValues({
timerId: undefined,
timerDescription: undefined,
timerDuration: undefined,
timerProject: undefined,
timerProjectID: undefined,
timerClient: undefined,
timerClientID: undefined,
})
}
this.checkFeedbacks('ProjectRunningState', 'ClientRunningState')
}
async getCurrentTimer(): Promise<number | null> {
this.log('debug', 'function: getCurrentTimer')
@@ -162,25 +303,27 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
if (entry) {
this.log('info', 'Current timer id: ' + entry.id)
this.setVariableValues({
timerId: entry.id,
timerDescription: entry.description,
timerDuration: entry.duration,
})
this.setCurrentlyRunningTimeEntry(entry)
return entry.id
} else {
this.log('info', 'No current timer')
this.setVariableValues({
timerId: undefined,
timerDescription: undefined,
timerDuration: undefined,
})
this.setCurrentlyRunningTimeEntry(undefined)
return null
}
}
async getWorkspace(): Promise<void> {
async loadStaticData(): Promise<void> {
if (!this.toggl) {
this.log('warn', 'loadStaticData: toggle connection not set up')
return
}
await this.getWorkspace()
await this.getProjects()
await this.getClients()
}
private async getWorkspace(): Promise<void> {
this.log('debug', 'function: getWorkspace')
if (!this.toggl) {
this.log('warn', 'Not authorized')
@@ -210,7 +353,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
if (this.workspaceId == undefined) {
// no workspace found
this.log('debug', 'workspace not found. Response: ' + JSON.stringify(workspaces))
this.updateStatus(InstanceStatus.BadConfig, 'No workspace found')
this.updateStatus(InstanceStatus.BadConfig, 'Available Workspaces: ' + workspaces.map((ws) => ws.name).join(','))
return
}
@@ -220,6 +363,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
})
await this.getProjects()
await this.getClients()
}
async getProjects(): Promise<void> {
@@ -232,11 +376,9 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
const projects: IWorkspaceProject[] = await this.toggl!.projects.list(this.workspaceId)
//const projects: IWorkspaceProject[] = await togglGetProjects(this.toggl!, this.workspaceId!)
if (typeof projects === 'string' || projects.length == 0) {
this.log('debug', 'No projects found')
this.projects = [{ id: 0, label: 'None' }]
this.projects = undefined
this.log('debug', 'projects response' + JSON.stringify(projects))
return
}
@@ -247,6 +389,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
return {
id: p.id,
label: p.name,
clientID: p.client_id,
}
})
.sort((a, b) => {
@@ -265,6 +408,47 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.log('debug', 'Projects: ' + JSON.stringify(this.projects))
}
private async getClients(): Promise<void> {
this.log('debug', 'function: getClients ' + this.workspaceId)
if (!this.workspaceId) {
this.log('warn', 'workspaceId undefined')
return
}
const clients: IClient[] = await this.toggl!.me.clients()
if (typeof clients === 'string' || clients.length == 0) {
this.log('debug', 'No clients found')
this.clients = undefined
this.log('debug', 'clients response' + JSON.stringify(clients))
return
}
this.clients = clients
.filter((c) => c.wid == this.workspaceId)
.map((c) => {
return {
id: c.id,
label: c.name,
}
})
.sort((a, b) => {
const fa = a.label.toLowerCase()
const fb = b.label.toLowerCase()
if (fa < fb) {
return -1
}
if (fa > fb) {
return 1
}
return 0
})
this.log('debug', 'Clients: ' + JSON.stringify(this.clients))
}
async startTimer(project: number, description: string): Promise<void> {
if (!this.toggl || !this.workspaceId) {
this.log('error', 'toggle not initialized. Do not start time')
@@ -284,11 +468,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
project_id: project != 0 ? project : undefined,
})
this.log('info', 'New timer started ' + newEntry.id + ' ' + newEntry.description)
this.setVariableValues({
timerId: newEntry.id,
timerDescription: newEntry.description,
timerDuration: newEntry.duration,
})
this.setCurrentlyRunningTimeEntry(newEntry)
} else {
this.log('info', 'A timer is already running ' + currentId + ' not starting a new one!')
}
@@ -308,10 +488,8 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
const updated: ITimeEntry = await this.toggl.timeEntry.stop(currentId, this.workspaceId)
this.log('info', 'Stopped ' + updated.id + ', duration ' + updated.duration)
this.setCurrentlyRunningTimeEntry(undefined)
this.setVariableValues({
timerId: undefined,
timerDescription: undefined,
timerDuration: undefined,
lastTimerDuration: updated.duration,
})
} else {

View File

@@ -1,4 +1,4 @@
import { Toggl } from 'toggl-track'
import { ITimeEntry, Toggl } from 'toggl-track'
export interface IWorkspace {
id: number
@@ -9,3 +9,55 @@ export async function togglGetWorkspaces(toggl: Toggl): Promise<IWorkspace[]> {
const resp: IWorkspace[] = await toggl.request<IWorkspace[]>('workspaces', {})
return resp
}
export interface IWebhookMetadata {
action: 'created' | 'updated' | 'deleted'
event_user_id: number
entity_id: number
model: 'time_entry'
workspace_id: number
project_id: number
}
export interface ITimeEntryWebhookPayload {
event_id: number
created_at: string
creator_id: number
metadata: IWebhookMetadata
payload: ITimeEntry
subscription_id: number
timestamp: string
url_callback: string
}
export function isITimeEntryWebhookPayload(event: unknown): event is ITimeEntryWebhookPayload {
return (
typeof event === 'object' &&
event != null &&
'metadata' in event &&
typeof event.metadata === 'object' &&
event.metadata !== null &&
'model' in event.metadata &&
typeof event.metadata.model === 'string' &&
event.metadata.model === 'time_entry' &&
'payload' in event &&
typeof event.payload === 'object' &&
event.payload !== null &&
isITimeEntry(event.payload)
)
}
export function isITimeEntry(entry: unknown): entry is ITimeEntry {
return (
typeof entry === 'object' &&
entry != null &&
'id' in entry &&
typeof entry.id === 'number' &&
'description' in entry &&
typeof entry.description === 'string' &&
'project_id' in entry &&
typeof entry.project_id === 'number' &&
'start' in entry &&
typeof entry.start === 'string'
)
}

12
src/utils.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* returns formatted timecode from date until now
* @param date start date
* @returns formatted string 0:00:00 - where hours and minutes are hidden if 0
*/
export function timecodeSince(date: Date): string {
const dateObj = new Date(Date.now() - date.getTime())
const hours = dateObj.getUTCHours()
const minutes = `0${dateObj.getUTCMinutes()}`.slice(-2)
const seconds = `0${dateObj.getSeconds()}`.slice(-2)
return (hours > 0 ? hours + ':' : '') + (hours > 0 || minutes !== '00' ? minutes + ':' : '') + seconds
}

View File

@@ -22,5 +22,21 @@ export default function (self: TogglTrack): void {
name: 'Current Timer Description',
variableId: 'timerDescription',
},
{
name: 'Current Timer Project ID',
variableId: 'timerProjectID',
},
{
name: 'Current Timer Project',
variableId: 'timerProject',
},
{
name: 'Current Timer Client ID',
variableId: 'timerClientID',
},
{
name: 'Current Timer Client',
variableId: 'timerClient',
},
])
}

3363
yarn.lock Normal file

File diff suppressed because it is too large Load Diff