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 @@
+
+
+
+
+
+
+
+
+
+