Compare commits

..

14 Commits

Author SHA1 Message Date
1d5a3c1e51 WIP: Add websocket connection for streaming toggl updates
Some checks failed
Companion Module Checks / Check module (push) Failing after 0s
Node CI / Lint (push) Successful in 1m14s
2025-11-03 20:19:57 +01:00
0a327b247c more align with template implementation 2025-11-03 20:18:15 +01:00
8cd7127af8 update dependencies and handle rate limits 2025-10-29 14:23:41 +01:00
b9060b683f increase package version 2025-09-29 14:57:02 +02:00
34a2814c19 update deps to current state of module template; update runtime to node22 2025-09-29 14:54:12 +02:00
bryce
2f9fa1cb0b fix: module crash when there are no clients (#8)
* fix: module crashing with no clients

* chore: version bump

* chore: format
2025-09-14 14:16:26 -07:00
0e8d7ffa32 add hint for funding additional maintainer 2025-01-21 14:14:45 +01:00
168d938435 add loadStaticData() which loads data in steps instead of chaining through 'getWorkspace' 2025-01-20 18:40:52 +01:00
6b8b0a9097 track client and add feedback for running client 2025-01-20 17:03:08 +01:00
8faa0443a5 fix link from README to HELP.md 2025-01-17 13:28:54 +01:00
2e885a890d add changelog in HELP.md 2025-01-17 13:14:32 +01:00
96e1d0f10a update 'timerDuration' to contain an updating timestamp of how long it is running 2025-01-17 12:53:55 +01:00
3aa54c4474 add feedback for currently running project 2025-01-17 12:51:45 +01:00
c62a74a635 print available workspaces if there might be a typo 2025-01-17 12:51:45 +01:00
22 changed files with 3795 additions and 106 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

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

76
.github/workflows/node.yaml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Node CI
on:
push:
branches:
- '**'
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*'
pull_request:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22.x
# This should match the version of Node.js you have defined in the manifest.json runtime field
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Prepare Environment
run: |
corepack enable
- name: Prepare Environment (For template repository)
# Only run this step if the repository is a template repository
# If you are using this in a module, you can remove this step
if: ${{ contains(github.repository, 'companion-module-template-') }}
run: |
# Perform an install to generate the lockfile
yarn install
env:
CI: false
- name: Prepare module
run: |
yarn install
env:
CI: true
- name: Build and check types
run: |
yarn build
env:
CI: true
- name: Run lint
run: |
yarn lint
env:
CI: true
# Uncomment this to enable running unit tests
# test:
# name: Test
# runs-on: ubuntu-latest
# timeout-minutes: 15
# steps:
# - uses: actions/checkout@v4
# - name: Use Node.js 22.x
# uses: actions/setup-node@v4
# with:
# node-version: 22.x
# - name: Prepare Environment
# run: |
# corepack enable
# yarn install
# env:
# CI: true
# - name: Run tests
# run: |
# yarn test
# env:
# CI: true
# - name: Send coverage
# uses: codecov/codecov-action@v5

3
.gitignore vendored
View File

@@ -2,7 +2,8 @@ node_modules/
package-lock.json
.DS_Store
/pkg
/pkg.tgz
/*.tgz
/dist
DEBUG-*
/.yarn
/.vscode

1
.husky/pre-commit Normal file
View File

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

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 Bitfocus AS - Open Source
Copyright (c) 2022 Bitfocus AS - Open Source
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,3 +1,11 @@
# companion-module-toggl-track
See [HELP.md](./HELP.md) and [LICENSE](./LICENSE)
See [HELP.md](./companion/HELP.md) and [LICENSE](./LICENSE)
## Getting started
Executing a `yarn` command should perform all necessary steps to develop the module, if it does not then follow the steps below.
The module can be built once with `yarn build`. This should be enough to get the module to be loadable by companion.
While developing the module, by using `yarn dev` the compiler will be run in watch mode to recompile the files on change.

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,51 @@ 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)
### Version 2.1.3
- update dependencies (fix CVE-2025-58754)
- handle rate limiting of toggl

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,35 +1,44 @@
{
"name": "toggl-track",
"version": "2.1.0",
"version": "2.1.3",
"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",
"package": "run build && companion-module-build",
"build": "rimraf dist && run build:main",
"build:main": "tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch",
"lint:raw": "eslint",
"lint": "yarn run lint:raw .",
"lint": "run lint:raw .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/bitfocus/companion-module-toggl-track.git"
},
"license": "MIT",
"engines": {
"node": "^22.14",
"yarn": "^4"
},
"dependencies": {
"@companion-module/base": "~1.11",
"toggl-track": "^0.8.0"
"@companion-module/base": "~1.13.4",
"toggl-track": "https://github.com/krombel/toggl-track#v0.8.0-2",
"ws": "^8.18.0"
},
"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.4.2",
"@types/node": "^22.18.12",
"@types/ws": "^8",
"eslint": "^9.38.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"prettier": "^3.6.2",
"rimraf": "^6.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.2"
},
"prettier": "@companion-module/tools/.prettierrc.json",
"lint-staged": {
@@ -39,5 +48,6 @@
"*.{ts,tsx,js,jsx}": [
"yarn lint:raw --fix"
]
}
},
"packageManager": "yarn@4.10.2"
}

View File

@@ -1,6 +1,6 @@
import { TogglTrack } from './main.js'
export default function (self: TogglTrack): void {
export function UpdateActions(self: TogglTrack): void {
self.setActionDefinitions({
startNewTimer: {
name: 'Start New Timer',
@@ -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
startTogglWebsocket: boolean
wsToken?: string
}
export function GetConfigFields(): SomeCompanionConfigField[] {
@@ -36,9 +39,42 @@ 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,
isVisible: (opt) => {
return opt.startTimerPoller === true
},
},
{
type: 'checkbox',
id: 'startTogglWebsocket',
label: 'EXPERIMENTAL: Use background websocket connection for updates',
width: 12,
default: false,
isVisible: (opt) => {
return !opt.startTimerPoller
},
},
{
type: 'textinput',
id: 'wsToken',
label: 'Personal API Token from your Toggl user profile (required)',
width: 12,
required: true,
default: '',
isVisible: (opt) => {
return !opt.startTimerPoller && opt.startTogglWebsocket === true
},
},
]
}

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,69 +1,85 @@
// toggltrack module
// Peter Daniel
// Peter Daniel, Matthias Kesler
import { InstanceBase, runEntrypoint, InstanceStatus, SomeCompanionConfigField } from '@companion-module/base'
import { InstanceBase, runEntrypoint, InstanceStatus, SomeCompanionConfigField, LogLevel } from '@companion-module/base'
import { GetConfigFields, type ModuleConfig } from './config.js'
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 { UpdatePresets } from './presets.js'
import { UpdateVariableDefinitions } from './variables.js'
import { UpgradeScripts } from './upgrades.js'
import { UpdateActions } from './actions.js'
import { UpdateFeedbacks } from './feedbacks.js'
import { Toggl, ITimeEntry, IWorkspaceProject, IClient, isRatelimitError } from 'toggl-track'
import { togglGetWorkspaces } from './toggl-extend.js'
import { timecodeSince } from './utils.js'
import { TogglStream } from './toggl-stream.js'
export class TogglTrack extends InstanceBase<ModuleConfig> {
config!: ModuleConfig // Setup in init()
toggl?: Toggl
stream?: TogglStream
workspaceId?: number // current active workspace id
workspaceName: string = '' // name of workspace
projects?: { id: number; label: string }[]
intervalId?: NodeJS.Timeout
projects?: { id: number; label: string; clientID?: number }[]
clients?: { id: number; label: string }[]
currentEntry?: ITimeEntry
intervalId?: NodeJS.Timeout // used for time entry poller and to postpone init on ratelimit
currentTimerUpdaterIntervalId?: NodeJS.Timeout // used to update the timer duration variable every second
constructor(internal: unknown) {
super(internal)
}
getConfigFields(): SomeCompanionConfigField[] {
return GetConfigFields()
}
async destroy(): Promise<void> {
this.log('info', 'destroy ' + this.id)
if (this.config.startTimerPoller) {
this.stopTimeEntryPoller()
}
}
async init(config: ModuleConfig): Promise<void> {
this.log('info', '--- init toggltrack ' + this.id + ' ---')
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()
if (this.toggl && this.toggl.getRateLimitedUntil() > 0) {
this.log('warn', 'Ratelimited. Retry init in ' + this.toggl.getRateLimitedUntil() + ' seconds')
this.intervalId = setTimeout(() => {
void this.init(this.config)
}, this.toggl.getRateLimitedUntil() * 1000)
// skip further init until ratelimit is over
return
}
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.startTogglWebsocket) {
await this.startToggleStream()
}
}
// When module gets deleted
async destroy(): Promise<void> {
this.log('debug', 'destroy ' + this.id)
if (this.config.startTimerPoller) {
this.stopTimeEntryPoller()
}
clearInterval(this.currentTimerUpdaterIntervalId)
if (this.config.startTogglWebsocket && this.stream) {
await this.stream.destroy()
}
}
async configUpdated(config: ModuleConfig): Promise<void> {
@@ -73,15 +89,30 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
const workSpaceDefaultChanged: boolean = this.config.workspaceName != config.workspaceName
const timeEntryPollerChanged: boolean = this.config.startTimerPoller != config.startTimerPoller
const oldConfig = this.config
this.config = config
if (apiTokenChanged) {
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 (this.toggl && this.toggl.getRateLimitedUntil() > 0) {
this.log('warn', 'Ratelimited. Retry init in ' + this.toggl.getRateLimitedUntil() + ' seconds')
this.config = oldConfig // revert to old config until ratelimit is over
this.intervalId = setTimeout(() => {
// this harms the linter (handle unawaited promise in an non-async context)
void (async () => {
await this.configUpdated(config)
})()
}, this.toggl.getRateLimitedUntil() * 1000)
// skip further init until ratelimit is over
return
}
if (timeEntryPollerChanged) {
@@ -91,6 +122,14 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.stopTimeEntryPoller()
}
}
if (this.config.startTogglWebsocket) {
await this.startToggleStream()
} else {
if (this.stream) {
await this.stream.destroy()
delete this.stream
}
}
this.updateActions()
//this.updateVariables()
@@ -98,6 +137,11 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.updateStatus(InstanceStatus.Ok)
}
}
getConfigFields(): SomeCompanionConfigField[] {
return GetConfigFields()
}
updateVariables(): void {
this.log('error', 'updateVariables not implemented')
//throw new Error('Method not implemented.')
@@ -106,6 +150,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
UpdateActions(this)
}
updateFeedbacks(): void {
UpdateFeedbacks(this)
}
updatePresets(): void {
UpdatePresets(this)
}
@@ -122,15 +170,25 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
},
})
const resp = await this.toggl.me.logged()
if (resp !== '') {
this.log('warn', 'error during token check: ' + resp)
try {
const resp = await this.toggl.me.logged()
if (resp !== '') {
this.log('warn', 'error during token check: ' + resp)
this.toggl = undefined
this.updateStatus(InstanceStatus.AuthenticationFailure, resp as string)
return
}
} catch (e: unknown) {
this.log('warn', 'error during token check: ' + (e as Error).message)
if (isRatelimitError(e)) {
this.log('warn', 'ratelimited. Will reset in' + e.resetAfterSeconds)
this.updateStatus(InstanceStatus.ConnectionFailure, `Ratelimited. Retry after ${e.resetAfterSeconds} seconds`)
return
}
this.toggl = undefined
this.updateStatus(InstanceStatus.AuthenticationFailure, resp)
this.updateStatus(InstanceStatus.AuthenticationFailure, (e as Error).message)
return
}
await this.getWorkspace()
await this.getCurrentTimer()
}
}
@@ -142,13 +200,87 @@ 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 async startToggleStream(): Promise<void> {
if (this.stream) {
await this.stream.destroy()
delete this.stream
}
this.stream = new TogglStream({
apiToken: this.config.wsToken!,
reconnect: true,
log: (level: LogLevel, message: string) => {
this.log(level, 'ws: ' + message)
},
onTimeEntryCreated: (e: ITimeEntry) => {
if (!e.stop) {
this.setCurrentlyRunningTimeEntry(e)
}
},
onTimeEntryUpdate: (e: ITimeEntry) => {
if (e.id == this.currentEntry?.id && e.stop) {
// currently running timer got a stop entry
this.setCurrentlyRunningTimeEntry(undefined)
} else {
// store update on this time entry (e.g. rename)
this.setCurrentlyRunningTimeEntry(e)
}
},
})
this.stream.start()
}
/**
* 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')
@@ -156,36 +288,50 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.log('warn', 'Not authorized')
return null
}
if (this.toggl.getRateLimitedUntil() > 0) {
this.log('warn', 'Ratelimited. Skipping get current timer')
return null
}
const entry: ITimeEntry = await this.toggl.timeEntry.current()
this.log('debug', 'response for timer id ' + JSON.stringify(entry))
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
}
if (this.toggl.getRateLimitedUntil() > 0) {
this.log('warn', 'Ratelimited. Skipping get current timer')
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')
return
}
if (this.toggl.getRateLimitedUntil() > 0) {
this.log('warn', 'Ratelimited. Skipping get current timer')
return
}
// reset
this.workspaceId = undefined
@@ -210,7 +356,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 +366,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
})
await this.getProjects()
await this.getClients()
}
async getProjects(): Promise<void> {
@@ -232,11 +379,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 +392,7 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
return {
id: p.id,
label: p.name,
clientID: p.client_id,
}
})
.sort((a, b) => {
@@ -265,11 +411,56 @@ 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')
return
}
if (this.toggl.getRateLimitedUntil() > 0) {
this.log('warn', 'Ratelimited. Skipping get current timer')
return
}
const currentId = await this.getCurrentTimer()
let newEntry: ITimeEntry
@@ -284,11 +475,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!')
}
@@ -301,6 +488,10 @@ export class TogglTrack extends InstanceBase<ModuleConfig> {
this.log('error', 'toggle not initialized. Do not start time')
return
}
if (this.toggl.getRateLimitedUntil() > 0) {
this.log('warn', 'Ratelimited. Skipping get current timer')
return
}
const currentId = await this.getCurrentTimer()
this.log('info', 'Trying to stop current timer id: ' + currentId)
@@ -308,10 +499,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,7 +1,7 @@
import { combineRgb } from '@companion-module/base'
import { TogglTrack } from './main.js'
export default function (self: TogglTrack): void {
export function UpdatePresets(self: TogglTrack): void {
self.setPresetDefinitions({
Start: {
type: 'button',

143
src/toggl-stream.ts Normal file
View File

@@ -0,0 +1,143 @@
import { LogLevel } from '@companion-module/base'
import { ITimeEntry } from 'toggl-track'
import WebSocket from 'ws'
interface IToggleStreamConfig {
apiToken: string
reconnect?: boolean
onMessage?: (body: string) => void
log: (level: LogLevel, message: string) => void
onTimeEntryCreated: (entry: ITimeEntry) => void
onTimeEntryUpdate: (entry: ITimeEntry) => void
onTimeEntryDelete?: (entry: ITimeEntry) => void
}
const url = 'wss://doh.krombel.de/toggl-websocket'
export class TogglStream {
ws?: WebSocket
apiToken: string
reconnect: boolean
ping_timer?: NodeJS.Timeout
reconnect_timer?: NodeJS.Timeout
log: (level: LogLevel, message: string) => void
onTimeEntryCreated: (entry: ITimeEntry) => void
onTimeEntryUpdate: (entry: ITimeEntry) => void
onTimeEntryDelete?: (entry: ITimeEntry) => void
constructor({ apiToken, reconnect, log, onTimeEntryCreated, onTimeEntryUpdate }: IToggleStreamConfig) {
this.apiToken = apiToken
this.reconnect = Boolean(reconnect)
this.log = log
this.onTimeEntryCreated = onTimeEntryCreated
this.onTimeEntryUpdate = onTimeEntryUpdate
}
async destroy(): Promise<void> {
clearInterval(this.ping_timer)
this.ping_timer = undefined
if (this.reconnect_timer) {
clearTimeout(this.reconnect_timer)
this.reconnect_timer = undefined
}
if (this.ws) {
this.ws.close(1000)
delete this.ws
}
}
start(): void {
this.log('debug', 'startToggleStream')
if (this.ping_timer) {
clearInterval(this.ping_timer)
this.ping_timer = undefined
}
if (this.reconnect_timer) {
clearTimeout(this.reconnect_timer)
this.reconnect_timer = undefined
}
if (this.ws) {
this.ws.close(1000)
delete this.ws
}
this.ws = new WebSocket(url)
this.ws.addEventListener('open', () => {
this.log('debug', 'websocket connection opened')
this.ws?.send(
JSON.stringify({
type: 'authenticate',
api_token: this.apiToken,
}),
)
})
this.ws.on('close', (code) => {
this.log('debug', `websocket connection closed with code ${code}`)
})
this.ws.on('error', (err) => {
this.log('debug', `websocket connection errored with code ${err.message}`)
this.maybeReconnect()
})
this.ws.on('message', this.onMessageReceived.bind(this))
this.ping_timer = setInterval(() => {
this.ws?.ping()
}, 25000) // send ping every 25 seconds
}
private maybeReconnect(): void {
if (this.reconnect) {
clearTimeout(this.reconnect_timer)
this.reconnect_timer = setTimeout(() => {
this.start()
}, 5000)
}
}
private onMessageReceived(data: WebSocket.RawData, isBinary: boolean): void {
this.log('debug', 'Got ' + (isBinary ? 'binary' : 'string') + ' message: ' + JSON.stringify(data))
const message = (data as Buffer).toString()
let msgValue // IToggleStreamPing | string
try {
msgValue = JSON.parse(message)
} catch (e) {
this.log('warn', 'Failed parsing message' + JSON.stringify(e))
msgValue = data
}
this.log('debug', 'parsed message: ' + JSON.stringify(msgValue))
if ('type' in msgValue && msgValue.type == 'ping') {
this.ws?.send(
JSON.stringify({
type: 'pong',
}),
)
return
}
if ('action' in msgValue) {
switch (msgValue.action) {
case 'update': // stop:
// {"action":"update","data":{"id":3766092771,"wid":3516429,"duration":4115,"duration_diff":4115,"pid":193750968,"project_name":"Notizen","project_color":"#c7af14","project_active":true,"project_billable":false,"tid":null,"tag_ids":null,"tags":[],"description":"StreamDeck","billable":false,"duronly":true,"start":"2025-01-17T14:14:31Z","stop":"2025-01-17T15:23:06Z","shared_with":null,"at":"2025-01-17T15:23:06.279165Z"},"model":"time_entry"}
if ('model' in msgValue && msgValue.model == 'time_entry') {
this.onTimeEntryUpdate(msgValue.data as ITimeEntry)
return
}
break
case 'insert': // start
// {"action":"insert","data":{"id":3766232279,"wid":3516429,"duration":-1,"pid":193750968,"project_name":"Notizen","project_color":"#c7af14","project_active":true,"project_billable":false,"tid":null,"tag_ids":null,"tags":[],"description":"StreamDeck","billable":false,"duronly":true,"start":"2025-01-17T15:29:32Z","stop":null,"shared_with":null,"at":"2025-01-17T15:29:32.533896Z"},"model":"time_entry"}
if ('model' in msgValue && msgValue.model == 'time_entry') {
this.onTimeEntryCreated(msgValue.data as ITimeEntry)
return
}
break
case 'delete':
if ('model' in msgValue && msgValue.model == 'time_entry') {
this.onTimeEntryDelete!(msgValue.data as ITimeEntry)
return
}
}
}
}
}

View File

@@ -1,4 +1,7 @@
export default [
import type { CompanionStaticUpgradeScript } from '@companion-module/base'
import type { ModuleConfig } from './config.js'
export const UpgradeScripts: CompanionStaticUpgradeScript<ModuleConfig>[] = [
/*
* Place your upgrade scripts here
* Remember that once it has been added it cannot be removed!

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

@@ -1,6 +1,6 @@
import { TogglTrack } from './main.js'
export default function (self: TogglTrack): void {
export function UpdateVariableDefinitions(self: TogglTrack): void {
self.setVariableDefinitions([
{
name: 'Workspace',
@@ -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',
},
])
}

View File

@@ -1,5 +1,5 @@
{
"extends": "@companion-module/tools/tsconfig/node18/recommended",
"extends": "@companion-module/tools/tsconfig/node22/recommended",
"include": ["src/**/*.ts"],
"exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"],
"compilerOptions": {

View File

@@ -3,6 +3,6 @@
"include": ["src/**/*.ts"],
"exclude": ["node_modules/**"],
"compilerOptions": {
"types": ["jest", "node"]
"types": ["node" /* , "jest" ] // uncomment this if using jest */]
}
}

3116
yarn.lock Normal file

File diff suppressed because it is too large Load Diff