commit bc383e4fd8b08bfc9866f64c25e62bc0aa314e3e Author: pdmarf <135653545+pdmarf@users.noreply.github.com> Date: Fri Apr 10 19:51:21 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..479ab63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.DS_Store +*.streamDeckPlugin +com.pdma.notion-timer.sdPlugin/bin/plugin.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7c874de --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Deploy + +```bash +# Build (TypeScript → bundled JS) +npm run build + +# Watch mode during development +npm run dev + +# Deploy to Stream Deck after building +cp com.pdma.notion-timer.sdPlugin/bin/plugin.js "$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/com.pdma.notion-timer.sdPlugin/bin/plugin.js" +pkill -f "notion-timer" # Stream Deck restarts the process automatically + +# Full install (first time — copy plugin folder, not symlink; Stream Deck doesn't follow symlinks) +cp -r com.pdma.notion-timer.sdPlugin "$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/" +``` + +There is no test suite. Manual testing is done by pressing buttons in the Stream Deck app and checking the Notion database. + +## Architecture + +The plugin has two distinct runtime contexts that communicate via WebSocket (managed by the SDK): + +**Plugin process** (`src/plugin.ts` → `bin/plugin.js`) runs in Node.js 20 under Stream Deck: +- One `TimerToggle` class (extends `SingletonAction`) handles all button instances of the action +- `settingsCache` (Map keyed by `action.id`) is required because the SDK doesn't expose other actions' settings — it must be updated after every `setSettings()` call, not just on appear +- Single-timer enforcement: when starting a timer, iterate `this.actions` and stop any other active timer via `settingsCache` +- Global settings (token, DB IDs, user ID) are stored as hardcoded `DEFAULTS` in `plugin.ts` + +**Property Inspector** (`ui/property-inspector.html`) runs in a browser frame inside the Stream Deck app: +- Uses Elgato's official `$PI` library (files in `ui/libs/`) — **do not modify these files** +- The libs were copied from an installed Time Tracker plugin; they are not in npm +- Settings are saved via `$PI.setSettings()`, not a custom WebSocket implementation +- Event listeners must be attached inside `$PI.onConnected()`, not `DOMContentLoaded` +- Projects are sent from the plugin to the PI via `streamDeck.ui.sendToPropertyInspector()` when `onPropertyInspectorDidAppear` fires (plugin pushes, PI doesn't pull) + +**Critical SDK v2 patterns** (different from v1 examples online): +- Use `streamDeck.ui.sendToPropertyInspector()` not `ev.action.sendToPropertyInspector()` +- Use `streamDeck.ui.onSendToPlugin()` not `SingletonAction.onSendToPlugin` (the method never fires in v2) +- `SingletonAction.onDidReceiveSettings` also does not reliably fire — settings come via `$PI.onDidReceiveSettings` on the PI side + +**Notion API** (`src/notion.ts`): +- Notion pages have icons of `type: "emoji"` (direct emoji string) or `type: "icon"` (named SVG icon with `name` + `color` fields) +- Named icons are mapped to emoji via lookup tables: `book`+color → colored book emoji, `skip-forward`+color → ⏭+circle, others in `ICON_NAME` +- Timer entries: created with `Status: "Running"`, patched with `Status: "Stopped"` and `End Time` + +## Debug + +Stream Deck logs are at `~/Library/Logs/ElgatoStreamDeck/`. The plugin process stderr also appears there. + +The `streamDeck.ui.onSendToPlugin` handler in `plugin.ts` still writes to `/tmp/sd-debug.log` — this should be removed once debugging is no longer needed. diff --git a/com.pdma.notion-timer.sdPlugin/imgs/action-icon.svg b/com.pdma.notion-timer.sdPlugin/imgs/action-icon.svg new file mode 100644 index 0000000..5d554a8 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/imgs/action-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/com.pdma.notion-timer.sdPlugin/imgs/category-icon.svg b/com.pdma.notion-timer.sdPlugin/imgs/category-icon.svg new file mode 100644 index 0000000..5d554a8 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/imgs/category-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/com.pdma.notion-timer.sdPlugin/imgs/idle.svg b/com.pdma.notion-timer.sdPlugin/imgs/idle.svg new file mode 100644 index 0000000..bfa20b7 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/imgs/idle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/com.pdma.notion-timer.sdPlugin/imgs/plugin-icon.svg b/com.pdma.notion-timer.sdPlugin/imgs/plugin-icon.svg new file mode 100644 index 0000000..5d554a8 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/imgs/plugin-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/com.pdma.notion-timer.sdPlugin/imgs/running.svg b/com.pdma.notion-timer.sdPlugin/imgs/running.svg new file mode 100644 index 0000000..1b8feeb --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/imgs/running.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/com.pdma.notion-timer.sdPlugin/manifest.json b/com.pdma.notion-timer.sdPlugin/manifest.json new file mode 100644 index 0000000..d000408 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/manifest.json @@ -0,0 +1,35 @@ +{ + "Author": "Pete Marfleet", + "Description": "Toggle Notion time tracking for a project with a single button press.", + "Name": "Notion Timer", + "Version": "1.0.0", + "SDKVersion": 2, + "Software": { "MinimumVersion": "5.0" }, + "OS": [{ "Platform": "mac", "MinimumVersion": "10.11" }], + "Nodejs": { "Version": "20", "Debug": "enabled" }, + "Icon": "imgs/plugin-icon", + "Category": "Notion", + "CategoryIcon": "imgs/category-icon", + "CodePath": "bin/plugin.js", + "GlobalPropertyInspectorPath": "ui/global-property-inspector.html", + "Actions": [ + { + "Icon": "imgs/action-icon", + "Name": "Toggle Timer", + "UUID": "com.pdma.notion-timer.toggle", + "Tooltip": "Start or stop a Notion time entry for the configured project.", + "States": [ + { + "Image": "imgs/idle", + "TitleAlignment": "bottom" + }, + { + "Image": "imgs/running", + "TitleAlignment": "bottom" + } + ], + "PropertyInspectorPath": "ui/property-inspector.html", + "SupportedInMultiActions": false + } + ] +} diff --git a/com.pdma.notion-timer.sdPlugin/ui/global-property-inspector.html b/com.pdma.notion-timer.sdPlugin/ui/global-property-inspector.html new file mode 100644 index 0000000..5c211e6 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/global-property-inspector.html @@ -0,0 +1,161 @@ + + + + + + + + +

Notion Connection

+ +
+ + +
+

From notion.so → Settings → Integrations → your integration → token.

+ +
+

Databases

+ +
+ + +
+

The ID from your Time Entries database URL (32-char hex string).

+ +
+ + +
+

The ID from your Projects database URL.

+ +
+

Your Identity

+ +
+ + +
+

Your Notion user UUID — ask your workspace admin if unsure. Time entries are logged under this name.

+ +

+ + + + + + + + + + + + diff --git a/com.pdma.notion-timer.sdPlugin/ui/libs/api.js b/com.pdma.notion-timer.sdPlugin/ui/libs/api.js new file mode 100644 index 0000000..8bc7fa1 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/libs/api.js @@ -0,0 +1,349 @@ +/// + +class ELGSDPlugin { + #data = {}; + #language = 'en'; + #localization; + test = new Set(); + on = EventEmitter.on; + emit = EventEmitter.emit; + + localizationLoaded = false; + constructor () { + // super(); + if(ELGSDPlugin.__instance) { + return ELGSDPlugin.__instance; + } + + ELGSDPlugin.__instance = this; + const pathArr = location.pathname.split("/"); + const idx = pathArr.findIndex(f => f.endsWith('sdPlugin')); + this.#data.__filename = pathArr[pathArr.length - 1]; + this.#data.__dirname = pathArr[pathArr.length - 2]; + this.#data.__folderpath = `${pathArr.slice(0, idx + 1).join("/")}/`; + this.#data.__folderroot = `${pathArr.slice(0, idx).join("/")}/`; + this.#data.__parentdir = `${pathArr.slice(0, idx-1).join("/")}/`; + } + + set language(value) { + this.#language = value; + this.loadLocalization(this.#data.__folderpath).then(l => { + this.emit('languageChanged', value); + }); + } + + get language() { + return this.#language; + } + + set localization(value) { + this.#localization = value; + this.localizeUI(); + this.emit('localizationChanged', value); + } + + get localization() { + return this.#localization; + } + + get __filename() { + return this.#data.__filename; + } + + get __dirname() { + return this.#data.__dirname; + } + + get __folderpath() { + return this.#data.__folderpath; + } + get __folderroot() { + return this.#data.__folderroot; + } + get data() { + return this.#data; + } + + /** + * Finds the original key of the string value + * Note: This is used by the localization UI to find the original key (not used here) + * @param {string} str + * @returns {string} + */ + + localizedString(str) { + return Object.keys(this.localization).find(e => e == str); + } + + /** + * Returns the localized string or the original string if not found + * @param {string} str + * @returns {string} + */ + + localize(s) { + if(typeof s === 'undefined') return ''; + let str = String(s); + try { + str = this.localization[str] || str; + } catch(b) {} + return str; + }; + + /** + * Searches the document tree to find elements with data-localize attributes + * and replaces their values with the localized string + * @returns {} + */ + + localizeUI = () => { + const el = document.querySelector('.sdpi-wrapper'); + if(!el) return console.warn("No element found to localize"); + const selectorsList = '[data-localize]'; + // see if we have any data-localize attributes + // that means we can skip the rest of the DOM + el.querySelectorAll(selectorsList).forEach(e => { + const s = e.innerText.trim(); + e.innerHTML = e.innerHTML.replace(s, this.localize(s)); + if(e.placeholder && e.placeholder.length) { + e.placeholder = this.localize(e.placeholder); + } + if(e.title && e.title.length) { + e.title = this.localize(e.title); + } + }); + }; + /** + * Fetches the specified language json file + * @param {string} pathPrefix + * @returns {Promise} + */ +async loadLocalization(pathPrefix) { + if(!pathPrefix) { + pathPrefix = this.#data.__folderpath; + } + // here we save the promise to the JSON-reader result, + // which we can later re-use to see, if the strings are already loaded + this.localizationLoaded = this.readJson(`${pathPrefix}${this.language}.json`); + const manifest = await this.localizationLoaded; + this.localization = manifest['Localization'] ?? null; + window.$localizedStrings = this.localization; + this.emit('localizationLoaded', this.localization); + + return this.localization; +} + + /** + * + * @param {string} path + * @returns {Promise} json + */ + async readJson(path) { + if(!path) { + console.error('A path is required to readJson.'); + } + + return new Promise((resolve, reject) => { + const req = new XMLHttpRequest(); + req.onerror = reject; + req.overrideMimeType('application/json'); + req.open('GET', path, true); + req.onreadystatechange = (response) => { + if(req.readyState === 4) { + const jsonString = response?.target?.response; + if(jsonString) { + resolve(JSON.parse(response?.target?.response)); + } else { + reject(); + } + } + }; + + req.send(); + }); + } +} + +class ELGSDApi extends ELGSDPlugin { + port; + uuid; + messageType; + actionInfo; + websocket; + appInfo; + #data = {}; + + /** + * Connect to Stream Deck + * @param {string} port + * @param {string} uuid + * @param {string} messageType + * @param {string} appInfoString + * @param {string} actionString + */ + connect(port, uuid, messageType, appInfoString, actionString) { + this.port = port; + this.uuid = uuid; + this.messageType = messageType; + this.actionInfo = actionString ? JSON.parse(actionString) : null; + this.appInfo = JSON.parse(appInfoString); + this.language = this.appInfo?.application?.language ?? null; + + if(this.websocket) { + this.websocket.close(); + this.websocket = null; + } + + this.websocket = new WebSocket('ws://127.0.0.1:' + this.port); + + this.websocket.onopen = () => { + const json = { + event: this.messageType, + uuid: this.uuid, + }; + + this.websocket.send(JSON.stringify(json)); + + this.emit(Events.connected, { + connection: this.websocket, + port: this.port, + uuid: this.uuid, + actionInfo: this.actionInfo, + appInfo: this.appInfo, + messageType: this.messageType, + }); + }; + + this.websocket.onerror = (evt) => { + const error = `WEBSOCKET ERROR: ${evt}, ${evt.data}, ${SocketErrors[evt?.code]}`; + console.warn(error); + this.logMessage(error); + }; + + this.websocket.onclose = (evt) => { + console.warn('WEBSOCKET CLOSED:', SocketErrors[evt?.code]); + }; + + this.websocket.onmessage = (evt) => { + const data = evt?.data ? JSON.parse(evt.data) : null; + + const {action, event} = data; + const message = action ? `${action}.${event}` : event; + if(message && message !== '') this.emit(message, data); + }; + } + + /** + * Write to log file + * @param {string} message + */ + logMessage(message) { + if(!message) { + console.error('A message is required for logMessage.'); + } + + try { + if(this.websocket) { + const json = { + event: Events.logMessage, + payload: { + message: message, + }, + }; + this.websocket.send(JSON.stringify(json)); + } else { + console.error('Websocket not defined'); + } + } catch(e) { + console.error('Websocket not defined'); + } + } + + /** + * Send JSON payload to StreamDeck + * @param {string} context + * @param {string} event + * @param {object} [payload] + */ + send(context, event, payload = {}) { + this.websocket && this.websocket.send(JSON.stringify({context, event, ...payload})); + } + + /** + * Save the plugin's persistent data + * @param {object} payload + */ + setGlobalSettings(payload) { + this.send(this.uuid, Events.setGlobalSettings, { + payload: payload, + }); + } + + /** + * Request the plugin's persistent data. StreamDeck does not return the data, but trigger the plugin/property inspectors didReceiveGlobalSettings event + */ + getGlobalSettings() { + this.send(this.uuid, Events.getGlobalSettings); + } + + /** + * Opens a URL in the default web browser + * @param {string} url + */ + openUrl(url) { + if(!url) { + console.error('A url is required for openUrl.'); + } + + this.send(this.uuid, Events.openUrl, { + payload: { + url, + }, + }); + } + + /** + * Registers a callback function for when Stream Deck is connected + * @param {function} fn + * @returns ELGSDStreamDeck + */ + onConnected(fn) { + if(!fn) { + console.error('A callback function for the connected event is required for onConnected.'); + } + + this.on(Events.connected, (jsn) => fn(jsn)); + return this; + } + + /** + * Registers a callback function for the didReceiveGlobalSettings event, which fires when calling getGlobalSettings + * @param {function} fn + */ + onDidReceiveGlobalSettings(fn) { + if(!fn) { + console.error( + 'A callback function for the didReceiveGlobalSettings event is required for onDidReceiveGlobalSettings.' + ); + } + + this.on(Events.didReceiveGlobalSettings, (jsn) => fn(jsn)); + return this; + } + + /** + * Registers a callback function for the didReceiveSettings event, which fires when calling getSettings + * @param {string} action + * @param {function} fn + */ + onDidReceiveSettings(action, fn) { + if(!fn) { + console.error( + 'A callback function for the didReceiveSettings event is required for onDidReceiveSettings.' + ); + } + + this.on(`${action}.${Events.didReceiveSettings}`, (jsn) => fn(jsn)); + return this; + } +} diff --git a/com.pdma.notion-timer.sdPlugin/ui/libs/constants.js b/com.pdma.notion-timer.sdPlugin/ui/libs/constants.js new file mode 100644 index 0000000..21a3f80 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/libs/constants.js @@ -0,0 +1,83 @@ +/** + * Errors received from WebSocket + */ +const SocketErrors = { + 0: 'The connection has not yet been established', + 1: 'The connection is established and communication is possible', + 2: 'The connection is going through the closing handshake', + 3: 'The connection has been closed or could not be opened', + 1000: 'Normal Closure. The purpose for which the connection was established has been fulfilled.', + 1001: 'Going Away. An endpoint is "going away", such as a server going down or a browser having navigated away from a page.', + 1002: 'Protocol error. An endpoint is terminating the connection due to a protocol error', + 1003: "Unsupported Data. An endpoint received a type of data it doesn't support.", + 1004: '--Reserved--. The specific meaning might be defined in the future.', + 1005: 'No Status. No status code was actually present.', + 1006: 'Abnormal Closure. The connection was closed abnormally, e.g., without sending or receiving a Close control frame', + 1007: 'Invalid frame payload data. The connection was closed, because the received data was not consistent with the type of the message (e.g., non-UTF-8 [http://tools.ietf.org/html/rfc3629]).', + 1008: 'Policy Violation. The connection was closed, because current message data "violates its policy". This reason is given either if there is no other suitable reason, or if there is a need to hide specific details about the policy.', + 1009: 'Message Too Big. Connection closed because the message is too big for it to process.', + 1010: "Mandatory Extension. Connection is terminated the connection because the server didn't negotiate one or more extensions in the WebSocket handshake.", + 1011: 'Internl Server Error. Connection closed because it encountered an unexpected condition that prevented it from fulfilling the request.', + 1015: "TLS Handshake. The connection was closed due to a failure to perform a TLS handshake (e.g., the server certificate can't be verified).", +}; + +/** + * Events used for communicating with Stream Deck + */ +const Events = { + didReceiveSettings: 'didReceiveSettings', + didReceiveGlobalSettings: 'didReceiveGlobalSettings', + keyDown: 'keyDown', + keyUp: 'keyUp', + willAppear: 'willAppear', + willDisappear: 'willDisappear', + titleParametersDidChange: 'titleParametersDidChange', + deviceDidConnect: 'deviceDidConnect', + deviceDidDisconnect: 'deviceDidDisconnect', + applicationDidLaunch: 'applicationDidLaunch', + applicationDidTerminate: 'applicationDidTerminate', + systemDidWakeUp: 'systemDidWakeUp', + propertyInspectorDidAppear: 'propertyInspectorDidAppear', + propertyInspectorDidDisappear: 'propertyInspectorDidDisappear', + sendToPlugin: 'sendToPlugin', + sendToPropertyInspector: 'sendToPropertyInspector', + connected: 'connected', + setImage: 'setImage', + setXYWHImage: 'setXYWHImage', + setTitle: 'setTitle', + setState: 'setState', + showOk: 'showOk', + showAlert: 'showAlert', + openUrl: 'openUrl', + setGlobalSettings: 'setGlobalSettings', + getGlobalSettings: 'getGlobalSettings', + setSettings: 'setSettings', + getSettings: 'getSettings', + registerPropertyInspector: 'registerPropertyInspector', + registerPlugin: 'registerPlugin', + logMessage: 'logMessage', + switchToProfile: 'switchToProfile', + dialRotate: 'dialRotate', + dialPress: 'dialPress', + dialDown: 'dialDown', + dialUp: 'dialUp', + touchTap: 'touchTap', + setFeedback: 'setFeedback', + setFeedbackLayout: 'setFeedbackLayout', +}; + +/** + * Constants used for Stream Deck + */ +const Constants = { + dataLocalize: '[data-localize]', + hardwareAndSoftware: 0, + hardwareOnly: 1, + softwareOnly: 2, +}; + +const DestinationEnum = { + HARDWARE_AND_SOFTWARE: 0, + HARDWARE_ONLY: 1, + SOFTWARE_ONLY: 2, +}; diff --git a/com.pdma.notion-timer.sdPlugin/ui/libs/events.js b/com.pdma.notion-timer.sdPlugin/ui/libs/events.js new file mode 100644 index 0000000..cc18825 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/libs/events.js @@ -0,0 +1,38 @@ +/** ELGEvents + * Publish/Subscribe pattern to quickly signal events to + * the plugin, property inspector and data. + */ + +const ELGEvents = { + eventEmitter: function (name, fn) { + const eventList = new Map(); + + const on = (name, fn) => { + if (!eventList.has(name)) eventList.set(name, ELGEvents.pubSub()); + + return eventList.get(name).sub(fn); + }; + + const has = name => eventList.has(name); + + const emit = (name, data) => eventList.has(name) && eventList.get(name).pub(data); + + return Object.freeze({on, has, emit, eventList}); + }, + + pubSub: function pubSub() { + const subscribers = new Set(); + + const sub = fn => { + subscribers.add(fn); + return () => { + subscribers.delete(fn); + }; + }; + + const pub = data => subscribers.forEach(fn => fn(data)); + return Object.freeze({pub, sub}); + } +}; + +const EventEmitter = ELGEvents.eventEmitter(); \ No newline at end of file diff --git a/com.pdma.notion-timer.sdPlugin/ui/libs/property-inspector.js b/com.pdma.notion-timer.sdPlugin/ui/libs/property-inspector.js new file mode 100644 index 0000000..d8b6705 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/libs/property-inspector.js @@ -0,0 +1,82 @@ +/// +/// + +class ELGSDPropertyInspector extends ELGSDApi { + constructor() { + super(); + if (ELGSDPropertyInspector.__instance) { + return ELGSDPropertyInspector.__instance; + } + + ELGSDPropertyInspector.__instance = this; + } + + /** + * Registers a callback function for when Stream Deck sends data to the property inspector + * @param {string} actionUUID + * @param {function} fn + * @returns ELGSDStreamDeck + */ + onSendToPropertyInspector(actionUUID, fn) { + if (typeof actionUUID != 'string') { + console.error('An action UUID string is required for onSendToPropertyInspector.'); + } + + if (!fn) { + console.error( + 'A callback function for the sendToPropertyInspector event is required for onSendToPropertyInspector.' + ); + } + + this.on(`${actionUUID}.${Events.sendToPropertyInspector}`, (jsn) => fn(jsn)); + return this; + } + + /** + * Send payload from the property inspector to the plugin + * @param {object} payload + */ + sendToPlugin(payload) { + this.send(this.uuid, Events.sendToPlugin, { + action: this?.actionInfo?.action, + payload: payload || null, + }); + } + + /** + * Save the actions's persistent data. + * @param {object} payload + */ + setSettings(payload) { + this.send(this.uuid, Events.setSettings, { + action: this?.actionInfo?.action, + payload: payload || null, + }); + } + + /** + * Request the actions's persistent data. StreamDeck does not return the data, but trigger the actions's didReceiveSettings event + */ + getSettings() { + this.send(this.uuid, Events.getSettings); + } +} + +const $PI = new ELGSDPropertyInspector(); + +/** + * connectElgatoStreamDeckSocket + * This is the first function StreamDeck Software calls, when + * establishing the connection to the plugin or the Property Inspector + * @param {string} port - The socket's port to communicate with StreamDeck software. + * @param {string} uuid - A unique identifier, which StreamDeck uses to communicate with the plugin + * @param {string} messageType - Identifies, if the event is meant for the property inspector or the plugin. + * @param {string} appInfoString - Information about the host (StreamDeck) application + * @param {string} actionInfo - Context is an internal identifier used to communicate to the host application. + */ +function connectElgatoStreamDeckSocket(port, uuid, messageType, appInfoString, actionInfo) { + const delay = window?.initialConnectionDelay || 0; + setTimeout(() => { + $PI.connect(port, uuid, messageType, appInfoString, actionInfo); + }, delay); +} diff --git a/com.pdma.notion-timer.sdPlugin/ui/libs/prototypes.js b/com.pdma.notion-timer.sdPlugin/ui/libs/prototypes.js new file mode 100644 index 0000000..e3b84fe --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/libs/prototypes.js @@ -0,0 +1,26 @@ +/** reaches out for a magical global localization object, hopefully loaded by $SD and swaps the string **/ +String.prototype.lox = function () { + var a = String(this); + try { + a = $localizedStrings[a] || a; + } catch (b) { + } + return a; +}; + +String.prototype.sprintf = function (inArr) { + let i = 0; + const args = inArr && Array.isArray(inArr) ? inArr : arguments; + return this.replace(/%s/g, function () { + return args[i++]; + }); +}; + +WebSocket.prototype.sendJSON = function (jsn, log) { + if (log) { + console.log('SendJSON', this, jsn); + } + // if (this.readyState) { + this.send(JSON.stringify(jsn)); + // } +}; diff --git a/com.pdma.notion-timer.sdPlugin/ui/libs/timers.js b/com.pdma.notion-timer.sdPlugin/ui/libs/timers.js new file mode 100644 index 0000000..5d74e36 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/libs/timers.js @@ -0,0 +1,95 @@ +/* global ESDTimerWorker */ +/*eslint no-unused-vars: "off"*/ +/*eslint-env es6*/ + +let ESDTimerWorker = new Worker(URL.createObjectURL( + new Blob([timerFn.toString().replace(/^[^{]*{\s*/, '').replace(/\s*}[^}]*$/, '')], {type: 'text/javascript'}) +)); +ESDTimerWorker.timerId = 1; +ESDTimerWorker.timers = {}; +const ESDDefaultTimeouts = { + timeout: 0, + interval: 10 +}; + +Object.freeze(ESDDefaultTimeouts); + +function _setTimer(callback, delay, type, params) { + const id = ESDTimerWorker.timerId++; + ESDTimerWorker.timers[id] = {callback, params}; + ESDTimerWorker.onmessage = (e) => { + if (ESDTimerWorker.timers[e.data.id]) { + if (e.data.type === 'clearTimer') { + delete ESDTimerWorker.timers[e.data.id]; + } else { + const cb = ESDTimerWorker.timers[e.data.id].callback; + if (cb && typeof cb === 'function') cb(...ESDTimerWorker.timers[e.data.id].params); + } + } + }; + ESDTimerWorker.postMessage({type, id, delay}); + return id; +} + +function _setTimeoutESD(...args) { + let [callback, delay = 0, ...params] = [...args]; + return _setTimer(callback, delay, 'setTimeout', params); +} + +function _setIntervalESD(...args) { + let [callback, delay = 0, ...params] = [...args]; + return _setTimer(callback, delay, 'setInterval', params); +} + +function _clearTimeoutESD(id) { + ESDTimerWorker.postMessage({type: 'clearTimeout', id}); // ESDTimerWorker.postMessage({type: 'clearInterval', id}); = same thing + delete ESDTimerWorker.timers[id]; +} + +window.setTimeout = _setTimeoutESD; +window.setInterval = _setIntervalESD; +window.clearTimeout = _clearTimeoutESD; //timeout and interval share the same timer-pool +window.clearInterval = _clearTimeoutESD; + +/** This is our worker-code + * It is executed in it's own (global) scope + * which is wrapped above @ `let ESDTimerWorker` + */ + +function timerFn() { + /*eslint indent: ["error", 4, { "SwitchCase": 1 }]*/ + + let timers = {}; + let debug = false; + let supportedCommands = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']; + + function log(e) { + console.log('Worker-Info::Timers', timers); + } + + function clearTimerAndRemove(id) { + if (timers[id]) { + if (debug) console.log('clearTimerAndRemove', id, timers[id], timers); + clearTimeout(timers[id]); + delete timers[id]; + postMessage({type: 'clearTimer', id: id}); + if (debug) log(); + } + } + + onmessage = function (e) { + // first see, if we have a timer with this id and remove it + // this automatically fulfils clearTimeout and clearInterval + supportedCommands.includes(e.data.type) && timers[e.data.id] && clearTimerAndRemove(e.data.id); + if (e.data.type === 'setTimeout') { + timers[e.data.id] = setTimeout(() => { + postMessage({id: e.data.id}); + clearTimerAndRemove(e.data.id); //cleaning up + }, Math.max(e.data.delay || 0)); + } else if (e.data.type === 'setInterval') { + timers[e.data.id] = setInterval(() => { + postMessage({id: e.data.id}); + }, Math.max(e.data.delay || ESDDefaultTimeouts.interval)); + } + }; +} diff --git a/com.pdma.notion-timer.sdPlugin/ui/libs/utils.js b/com.pdma.notion-timer.sdPlugin/ui/libs/utils.js new file mode 100644 index 0000000..a7b84cc --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/libs/utils.js @@ -0,0 +1,94 @@ +class Utils { + /** + * Returns the value from a form using the form controls name property + * @param {Element | string} form + * @returns + */ + static getFormValue(form) { + if (typeof form === 'string') { + form = document.querySelector(form); + } + + const elements = form?.elements; + + if (!elements) { + console.error('Could not find form!'); + } + + const formData = new FormData(form); + let formValue = {}; + + formData.forEach((value, key) => { + if (!Reflect.has(formValue, key)) { + formValue[key] = value; + return; + } + if (!Array.isArray(formValue[key])) { + formValue[key] = [formValue[key]]; + } + formValue[key].push(value); + }); + + return formValue; + } + + /** + * Sets the value of form controls using their name attribute and the jsn object key + * @param {*} jsn + * @param {Element | string} form + */ + static setFormValue(jsn, form) { + if (!jsn) { + return; + } + + if (typeof form === 'string') { + form = document.querySelector(form); + } + + const elements = form?.elements; + + if (!elements) { + console.error('Could not find form!'); + } + + Array.from(elements) + .filter((element) => element?.name) + .forEach((element) => { + const { name, type } = element; + const value = name in jsn ? jsn[name] : null; + const isCheckOrRadio = type === 'checkbox' || type === 'radio'; + + if (value === null) return; + + if (isCheckOrRadio) { + const isSingle = value === element.value; + if (isSingle || (Array.isArray(value) && value.includes(element.value))) { + element.checked = true; + } + } else { + element.value = value ?? ''; + } + }); + } + + /** + * This provides a slight delay before processing rapid events + * @param {number} wait - delay before processing function (recommended time 150ms) + * @param {function} fn + * @returns + */ + static debounce(wait, fn) { + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + fn.apply(null, args); + }, wait); + }; + } + + static delay(wait) { + return new Promise((fn) => setTimeout(fn, wait)); + } +} diff --git a/com.pdma.notion-timer.sdPlugin/ui/property-inspector.html b/com.pdma.notion-timer.sdPlugin/ui/property-inspector.html new file mode 100644 index 0000000..8c48b05 --- /dev/null +++ b/com.pdma.notion-timer.sdPlugin/ui/property-inspector.html @@ -0,0 +1,255 @@ + + + + + + + + + +

+ Notion Credentials +

+ + + +

+ Button +

+
+ + +
+ +

+ + + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..99b7393 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,576 @@ +{ + "name": "com.pdma.notion-timer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "com.pdma.notion-timer", + "version": "1.0.0", + "dependencies": { + "@elgato/streamdeck": "^2.0.4" + }, + "devDependencies": { + "esbuild": "^0.28.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@elgato/schemas": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/@elgato/schemas/-/schemas-0.4.15.tgz", + "integrity": "sha512-UpELAqazf52PFer+hqg/hvEhYoPHCH3R0af6P7Ih3Hk6xCCJENn7cA5TIpXAIGEWe6dIteHuo/e/iPGLUP7yXA==", + "license": "MIT" + }, + "node_modules/@elgato/streamdeck": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@elgato/streamdeck/-/streamdeck-2.0.4.tgz", + "integrity": "sha512-4WUbarjogRU/EiEnI/EwfJQjRhSLT6Jdrgje8x0BTyCxLpAP06bGvQdjlV5gDc6u15bFruUUDnPQglmdlzeMxw==", + "license": "MIT", + "dependencies": { + "@elgato/schemas": "^0.4.14", + "@elgato/utils": "^0.4.4", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=20.5.1" + } + }, + "node_modules/@elgato/utils": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@elgato/utils/-/utils-0.4.4.tgz", + "integrity": "sha512-KJbBp9JopLKR+0fO7QEK2HCurnKysVTMAD1GB8RklNHGcCzMqw0ep6mY8XRy3FX6OpDz+sIXZqz5DMws8Ec8AQ==", + "license": "MIT", + "dependencies": { + "zod": "^3.25.24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d00ab15 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "com.pdma.notion-timer", + "version": "1.0.0", + "description": "Notion time tracking toggle for Stream Deck", + "scripts": { + "build": "esbuild src/plugin.ts --bundle --platform=node --target=node20 --outfile=com.pdma.notion-timer.sdPlugin/bin/plugin.js --external:electron", + "dev": "esbuild src/plugin.ts --bundle --platform=node --target=node20 --outfile=com.pdma.notion-timer.sdPlugin/bin/plugin.js --external:electron --watch", + "link": "ln -sf \"$(pwd)/com.pdma.notion-timer.sdPlugin\" \"$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/com.pdma.notion-timer.sdPlugin\"", + "unlink": "rm -f \"$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/com.pdma.notion-timer.sdPlugin\"" + }, + "dependencies": { + "@elgato/streamdeck": "^2.0.4" + }, + "devDependencies": { + "esbuild": "^0.28.0", + "typescript": "^5.0.0" + } +} diff --git a/src/notion.ts b/src/notion.ts new file mode 100644 index 0000000..ac9d409 --- /dev/null +++ b/src/notion.ts @@ -0,0 +1,119 @@ +const NOTION_BASE = "https://api.notion.com/v1"; + +export interface NotionProject { + id: string; + name: string; +} + +function headers(token: string) { + return { + Authorization: `Bearer ${token}`, + "Notion-Version": "2022-06-28", + "Content-Type": "application/json", + }; +} + +const BOOK_COLOR: Record = { + green: "📗", blue: "📘", orange: "📙", red: "📕", + yellow: "📒", pink: "📔", purple: "📓", +}; + +const COLOR_CIRCLE: Record = { + green: "🟢", blue: "🔵", red: "🔴", orange: "🟠", + yellow: "🟡", purple: "🟣", pink: "🩷", brown: "🟤", + gray: "⚫", lightgray: "⬜", default: "⬜", +}; + +const ICON_NAME: Record = { + graduate: "🎓", science: "🔬", gears: "⚙️", comment: "💬", + building: "🏢", chart: "📊", code: "💻", document: "📄", + flag: "🚩", globe: "🌐", home: "🏠", link: "🔗", + music: "🎵", person: "👤", phone: "📞", pin: "📌", + rocket: "🚀", star: "⭐", tag: "🏷️", target: "🎯", +}; + +function notionIconToEmoji(page: any): string { + const icon = page?.icon; + if (!icon) return ""; + if (icon.type === "emoji") return icon.emoji as string; + if (icon.type === "icon") { + const { name, color } = icon.icon ?? {}; + if (name === "book") return BOOK_COLOR[color] ?? "📚"; + if (name === "skip-forward") return `⏭ ${COLOR_CIRCLE[color] ?? ""}`.trimEnd(); + return ICON_NAME[name] ?? ""; + } + return ""; +} + +export async function fetchProjects(token: string, dbId: string): Promise { + const resp = await fetch(`${NOTION_BASE}/databases/${dbId}/query`, { + method: "POST", + headers: headers(token), + body: JSON.stringify({ + page_size: 100, + sorts: [{ property: "Project name", direction: "ascending" }], + }), + }); + + if (!resp.ok) throw new Error(`Notion error ${resp.status}: ${await resp.text()}`); + + const data = (await resp.json()) as { results: unknown[] }; + return data.results.map((page: any) => { + const titleArr: any[] = page.properties?.["Project name"]?.title ?? []; + const title = titleArr.map((t: any) => t.plain_text).join("").trim() || "Untitled"; + const emoji = notionIconToEmoji(page); + return { + id: page.id as string, + name: emoji ? `${emoji} ${title}` : title, + }; + }); +} + +export async function startTimer( + token: string, + timingDbId: string, + projectId: string, + projectName: string, + userId: string, +): Promise { + const now = new Date().toISOString(); + const date = new Date().toLocaleDateString("en-GB"); + + const resp = await fetch(`${NOTION_BASE}/pages`, { + method: "POST", + headers: headers(token), + body: JSON.stringify({ + parent: { database_id: timingDbId }, + icon: { type: "emoji", emoji: "⏱️" }, + properties: { + Name: { title: [{ text: { content: "Timer Task" } }] }, + "Start Time": { date: { start: now } }, + Projects: { relation: [{ id: projectId }] }, + Status: { status: { name: "Running" } }, + Responsible: { people: [{ object: "user", id: userId }] }, + }, + }), + }); + + if (!resp.ok) throw new Error(`Failed to start timer: ${await resp.text()}`); + + const data = (await resp.json()) as { id: string }; + return data.id; +} + +export async function stopTimer(token: string, entryId: string): Promise { + const now = new Date().toISOString(); + + const resp = await fetch(`${NOTION_BASE}/pages/${entryId}`, { + method: "PATCH", + headers: headers(token), + body: JSON.stringify({ + properties: { + "End Time": { date: { start: now } }, + Status: { status: { name: "Stopped" } }, + }, + }), + }); + + if (!resp.ok) throw new Error(`Failed to stop timer: ${await resp.text()}`); +} diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..a27ef1b --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,145 @@ +import streamDeck, { + action, + KeyDownEvent, + PropertyInspectorDidAppearEvent, + SingletonAction, + WillAppearEvent, +} from "@elgato/streamdeck"; +import { fetchProjects, startTimer, stopTimer } from "./notion.js"; + +interface GlobalSettings { + notionToken: string; + timingDbId: string; + projectsDbId: string; + userId: string; +} + +interface TimerSettings { + projectId: string; + projectName: string; + activeEntryId: string | null; +} + +const HARDCODED = { + timingDbId: "2ec93117da3a80799ee2cf91244ee264", + projectsDbId: "cd65c760-0f0a-4d6e-a262-d572c9e31585", +}; + +async function getGlobal(): Promise { + const stored = await streamDeck.settings.getGlobalSettings(); + return { ...stored, ...HARDCODED }; +} + +function isConfigured(g: GlobalSettings): boolean { + return !!(g.notionToken && g.userId); +} + +// Strip leading emoji characters so the button title shows just the project name +function buttonTitle(projectName: string): string { + return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim(); +} + +@action({ UUID: "com.pdma.notion-timer.toggle" }) +class TimerToggle extends SingletonAction { + private settingsCache = new Map(); + + async onWillAppear(ev: WillAppearEvent): Promise { + this.settingsCache.set(ev.action.id, ev.payload.settings); + const { activeEntryId, projectName } = ev.payload.settings; + const title = buttonTitle(projectName || ""); + if (activeEntryId) { + await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]); + } else { + await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]); + } + } + + async onPropertyInspectorDidAppear(ev: PropertyInspectorDidAppearEvent): Promise { + try { + const global = await getGlobal(); + if (!isConfigured(global)) { + await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Configure Notion credentials in plugin settings first." }); + return; + } + const projects = await fetchProjects(global.notionToken, global.projectsDbId); + await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: projects }); + } catch (err) { + streamDeck.logger.error("Failed to fetch projects:", err); + await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err) }); + } + } + + async onKeyDown(ev: KeyDownEvent): Promise { + this.settingsCache.set(ev.action.id, ev.payload.settings); + const global = await getGlobal(); + const { projectId, projectName, activeEntryId } = ev.payload.settings; + const title = buttonTitle(projectName || ""); + + if (!isConfigured(global)) { + await ev.action.showAlert(); + return; + } + + if (!projectId) { + await ev.action.showAlert(); + return; + } + + try { + if (activeEntryId) { + await stopTimer(global.notionToken, activeEntryId); + const stopped = { ...ev.payload.settings, activeEntryId: null }; + await ev.action.setSettings(stopped); + this.settingsCache.set(ev.action.id, stopped); + await ev.action.setState(0); + await ev.action.setTitle(title); + await ev.action.showOk(); + } else { + // Stop any other running timer first + for (const other of this.actions) { + if (other.id === ev.action.id) continue; + const otherSettings = this.settingsCache.get(other.id); + if (otherSettings?.activeEntryId) { + await stopTimer(global.notionToken, otherSettings.activeEntryId); + const stopped = { ...otherSettings, activeEntryId: null }; + await other.setSettings(stopped); + this.settingsCache.set(other.id, stopped); + await other.setState(0); + await other.setTitle(buttonTitle(otherSettings.projectName || "")); + } + } + + const entryId = await startTimer( + global.notionToken, + global.timingDbId, + projectId, + projectName, + global.userId, + ); + const started = { ...ev.payload.settings, activeEntryId: entryId }; + await ev.action.setSettings(started); + this.settingsCache.set(ev.action.id, started); + await ev.action.setState(1); + await ev.action.setTitle(`⏱ ${title}`); + await ev.action.showOk(); + } + } catch (err) { + streamDeck.logger.error("Timer toggle failed:", err); + await ev.action.showAlert(); + } + } +} + +const timerAction = new TimerToggle(); +streamDeck.actions.registerAction(timerAction); + +// v2 requires using streamDeck.ui.onSendToPlugin — the SingletonAction method does not fire +streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings }>(async (ev) => { + if (ev.payload.event === "saveSettings" && ev.payload.settings) { + await ev.action.setSettings(ev.payload.settings); + const title = buttonTitle(ev.payload.settings.projectName || ""); + if (title) await ev.action.setTitle(title); + } +}); + +streamDeck.connect(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d107b2e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}