From bc383e4fd8b08bfc9866f64c25e62bc0aa314e3e Mon Sep 17 00:00:00 2001
From: pdmarf <135653545+pdmarf@users.noreply.github.com>
Date: Fri, 10 Apr 2026 19:51:21 +0100
Subject: [PATCH] Initial commit
---
.gitignore | 4 +
CLAUDE.md | 55 ++
.../imgs/action-icon.svg | 8 +
.../imgs/category-icon.svg | 8 +
com.pdma.notion-timer.sdPlugin/imgs/idle.svg | 7 +
.../imgs/plugin-icon.svg | 8 +
.../imgs/running.svg | 8 +
com.pdma.notion-timer.sdPlugin/manifest.json | 35 ++
.../ui/global-property-inspector.html | 161 +++++
com.pdma.notion-timer.sdPlugin/ui/libs/api.js | 349 +++++++++++
.../ui/libs/constants.js | 83 +++
.../ui/libs/events.js | 38 ++
.../ui/libs/property-inspector.js | 82 +++
.../ui/libs/prototypes.js | 26 +
.../ui/libs/timers.js | 95 +++
.../ui/libs/utils.js | 94 +++
.../ui/property-inspector.html | 255 ++++++++
package-lock.json | 576 ++++++++++++++++++
package.json | 18 +
src/notion.ts | 119 ++++
src/plugin.ts | 145 +++++
tsconfig.json | 16 +
22 files changed, 2190 insertions(+)
create mode 100644 .gitignore
create mode 100644 CLAUDE.md
create mode 100644 com.pdma.notion-timer.sdPlugin/imgs/action-icon.svg
create mode 100644 com.pdma.notion-timer.sdPlugin/imgs/category-icon.svg
create mode 100644 com.pdma.notion-timer.sdPlugin/imgs/idle.svg
create mode 100644 com.pdma.notion-timer.sdPlugin/imgs/plugin-icon.svg
create mode 100644 com.pdma.notion-timer.sdPlugin/imgs/running.svg
create mode 100644 com.pdma.notion-timer.sdPlugin/manifest.json
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/global-property-inspector.html
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/libs/api.js
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/libs/constants.js
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/libs/events.js
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/libs/property-inspector.js
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/libs/prototypes.js
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/libs/timers.js
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/libs/utils.js
create mode 100644 com.pdma.notion-timer.sdPlugin/ui/property-inspector.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 src/notion.ts
create mode 100644 src/plugin.ts
create mode 100644 tsconfig.json
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
+
+
+
+
+
+
+
+
+
+
+
Shared across all buttons. Enter once per device.
+
+
+
+
+
+
+ ▶ 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"]
+}