Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.streamDeckPlugin
|
||||||
|
com.pdma.notion-timer.sdPlugin/bin/plugin.js
|
||||||
55
CLAUDE.md
Normal file
55
CLAUDE.md
Normal file
@@ -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.
|
||||||
8
com.pdma.notion-timer.sdPlugin/imgs/action-icon.svg
Normal file
8
com.pdma.notion-timer.sdPlugin/imgs/action-icon.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" width="144" height="144">
|
||||||
|
<rect width="144" height="144" rx="16" fill="#1e1e2e"/>
|
||||||
|
<circle cx="72" cy="76" r="44" fill="none" stroke="white" stroke-width="6"/>
|
||||||
|
<line x1="72" y1="76" x2="72" y2="44" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="76" x2="96" y2="90" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="58" y1="22" x2="86" y2="22" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="22" x2="72" y2="34" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 620 B |
8
com.pdma.notion-timer.sdPlugin/imgs/category-icon.svg
Normal file
8
com.pdma.notion-timer.sdPlugin/imgs/category-icon.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" width="144" height="144">
|
||||||
|
<rect width="144" height="144" rx="16" fill="#1e1e2e"/>
|
||||||
|
<circle cx="72" cy="76" r="44" fill="none" stroke="white" stroke-width="6"/>
|
||||||
|
<line x1="72" y1="76" x2="72" y2="44" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="76" x2="96" y2="90" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="58" y1="22" x2="86" y2="22" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="22" x2="72" y2="34" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 620 B |
7
com.pdma.notion-timer.sdPlugin/imgs/idle.svg
Normal file
7
com.pdma.notion-timer.sdPlugin/imgs/idle.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" width="144" height="144">
|
||||||
|
<circle cx="72" cy="58" r="42" fill="none" stroke="#888888" stroke-width="6"/>
|
||||||
|
<line x1="72" y1="58" x2="72" y2="28" stroke="#888888" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="58" x2="94" y2="71" stroke="#888888" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="62" y1="12" x2="82" y2="12" stroke="#888888" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="12" x2="72" y2="20" stroke="#888888" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 572 B |
8
com.pdma.notion-timer.sdPlugin/imgs/plugin-icon.svg
Normal file
8
com.pdma.notion-timer.sdPlugin/imgs/plugin-icon.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" width="144" height="144">
|
||||||
|
<rect width="144" height="144" rx="16" fill="#1e1e2e"/>
|
||||||
|
<circle cx="72" cy="76" r="44" fill="none" stroke="white" stroke-width="6"/>
|
||||||
|
<line x1="72" y1="76" x2="72" y2="44" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="76" x2="96" y2="90" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="58" y1="22" x2="86" y2="22" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="22" x2="72" y2="34" stroke="white" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 620 B |
8
com.pdma.notion-timer.sdPlugin/imgs/running.svg
Normal file
8
com.pdma.notion-timer.sdPlugin/imgs/running.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144" width="144" height="144">
|
||||||
|
<circle cx="72" cy="58" r="42" fill="#1a3a1a" stroke="#4caf50" stroke-width="6"/>
|
||||||
|
<line x1="72" y1="58" x2="72" y2="28" stroke="#4caf50" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="58" x2="94" y2="71" stroke="#4caf50" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="62" y1="12" x2="82" y2="12" stroke="#4caf50" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="72" y1="12" x2="72" y2="20" stroke="#4caf50" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="72" cy="58" r="5" fill="#4caf50"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 624 B |
35
com.pdma.notion-timer.sdPlugin/manifest.json
Normal file
35
com.pdma.notion-timer.sdPlugin/manifest.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
161
com.pdma.notion-timer.sdPlugin/ui/global-property-inspector.html
Normal file
161
com.pdma.notion-timer.sdPlugin/ui/global-property-inspector.html
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ccc;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #888;
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
}
|
||||||
|
.section-title:first-child { margin-top: 0; }
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
width: 90px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
input[type="text"], input[type="password"] {
|
||||||
|
flex: 1;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #eee;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin-top: -4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 98px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
#statusText {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 6px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
#statusText.ok { color: #4caf50; }
|
||||||
|
#statusText.error { color: #e57373; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<p class="section-title">Notion Connection</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>API Token</label>
|
||||||
|
<input type="password" id="notionToken" placeholder="ntn_…">
|
||||||
|
</div>
|
||||||
|
<p class="hint">From notion.so → Settings → Integrations → your integration → token.</p>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
<p class="section-title">Databases</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Time Entries</label>
|
||||||
|
<input type="text" id="timingDbId" placeholder="Database ID">
|
||||||
|
</div>
|
||||||
|
<p class="hint">The ID from your Time Entries database URL (32-char hex string).</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Projects</label>
|
||||||
|
<input type="text" id="projectsDbId" placeholder="Database ID">
|
||||||
|
</div>
|
||||||
|
<p class="hint">The ID from your Projects database URL.</p>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
<p class="section-title">Your Identity</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>User ID</label>
|
||||||
|
<input type="text" id="userId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||||||
|
</div>
|
||||||
|
<p class="hint">Your Notion user UUID — ask your workspace admin if unsure. Time entries are logged under this name.</p>
|
||||||
|
|
||||||
|
<p id="statusText"></p>
|
||||||
|
|
||||||
|
<script src="libs/constants.js"></script>
|
||||||
|
<script src="libs/prototypes.js"></script>
|
||||||
|
<script src="libs/timers.js"></script>
|
||||||
|
<script src="libs/utils.js"></script>
|
||||||
|
<script src="libs/events.js"></script>
|
||||||
|
<script src="libs/api.js"></script>
|
||||||
|
<script src="libs/property-inspector.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var ACTION_UUID = "com.pdma.notion-timer.toggle";
|
||||||
|
var saveTimer = null;
|
||||||
|
|
||||||
|
function setStatus(msg, cls) {
|
||||||
|
var el = document.getElementById("statusText");
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = cls || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormValues() {
|
||||||
|
return {
|
||||||
|
notionToken: document.getElementById("notionToken").value.trim(),
|
||||||
|
timingDbId: document.getElementById("timingDbId").value.trim(),
|
||||||
|
projectsDbId: document.getElementById("projectsDbId").value.trim(),
|
||||||
|
userId: document.getElementById("userId").value.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
var values = getFormValues();
|
||||||
|
$PI.setGlobalSettings(values);
|
||||||
|
setStatus("Settings saved.", "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(save, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
$PI.onConnected(function() {
|
||||||
|
$PI.getGlobalSettings();
|
||||||
|
|
||||||
|
["notionToken", "timingDbId", "projectsDbId", "userId"].forEach(function(id) {
|
||||||
|
document.getElementById(id).addEventListener("input", scheduleSave);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$PI.onDidReceiveGlobalSettings(function(jsn) {
|
||||||
|
var s = jsn.payload.settings || {};
|
||||||
|
document.getElementById("notionToken").value = s.notionToken || "";
|
||||||
|
document.getElementById("timingDbId").value = s.timingDbId || "";
|
||||||
|
document.getElementById("projectsDbId").value = s.projectsDbId || "";
|
||||||
|
document.getElementById("userId").value = s.userId || "";
|
||||||
|
if (s.notionToken) setStatus("Settings loaded.", "ok");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
349
com.pdma.notion-timer.sdPlugin/ui/libs/api.js
Normal file
349
com.pdma.notion-timer.sdPlugin/ui/libs/api.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/// <reference path="events.js"/>
|
||||||
|
|
||||||
|
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 {<void>}
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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<any>} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
com.pdma.notion-timer.sdPlugin/ui/libs/constants.js
Normal file
83
com.pdma.notion-timer.sdPlugin/ui/libs/constants.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
38
com.pdma.notion-timer.sdPlugin/ui/libs/events.js
Normal file
38
com.pdma.notion-timer.sdPlugin/ui/libs/events.js
Normal file
@@ -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();
|
||||||
82
com.pdma.notion-timer.sdPlugin/ui/libs/property-inspector.js
Normal file
82
com.pdma.notion-timer.sdPlugin/ui/libs/property-inspector.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/// <reference path="constants.js" />
|
||||||
|
/// <reference path="api.js" />
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
26
com.pdma.notion-timer.sdPlugin/ui/libs/prototypes.js
vendored
Normal file
26
com.pdma.notion-timer.sdPlugin/ui/libs/prototypes.js
vendored
Normal file
@@ -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));
|
||||||
|
// }
|
||||||
|
};
|
||||||
95
com.pdma.notion-timer.sdPlugin/ui/libs/timers.js
Normal file
95
com.pdma.notion-timer.sdPlugin/ui/libs/timers.js
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
94
com.pdma.notion-timer.sdPlugin/ui/libs/utils.js
Normal file
94
com.pdma.notion-timer.sdPlugin/ui/libs/utils.js
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
255
com.pdma.notion-timer.sdPlugin/ui/property-inspector.html
Normal file
255
com.pdma.notion-timer.sdPlugin/ui/property-inspector.html
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ccc;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #888;
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.section-title:first-child { margin-top: 0; }
|
||||||
|
.section-title .arrow { font-size: 9px; }
|
||||||
|
.collapsible { overflow: hidden; }
|
||||||
|
.collapsible.collapsed { display: none; }
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
width: 90px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
select, input[type="text"], input[type="password"] {
|
||||||
|
flex: 1;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #eee;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
margin-top: -4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 98px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
#statusText {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
#statusText.running { color: #4caf50; }
|
||||||
|
#statusText.error { color: #e57373; }
|
||||||
|
#credStatus {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
min-height: 14px;
|
||||||
|
}
|
||||||
|
#credStatus.ok { color: #4caf50; }
|
||||||
|
#credStatus.error { color: #e57373; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Credentials (collapsed by default once configured) -->
|
||||||
|
<p class="section-title" id="credToggle">
|
||||||
|
<span class="arrow" id="credArrow">▶</span> Notion Credentials
|
||||||
|
</p>
|
||||||
|
<div class="collapsible collapsed" id="credSection">
|
||||||
|
<div class="row">
|
||||||
|
<label>API Token</label>
|
||||||
|
<input type="password" id="notionToken" placeholder="ntn_…">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>User ID</label>
|
||||||
|
<input type="text" id="userId" placeholder="Your Notion user UUID">
|
||||||
|
</div>
|
||||||
|
<p class="hint">Shared across all buttons. Enter once per device.</p>
|
||||||
|
<p id="credStatus"></p>
|
||||||
|
<hr class="divider">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-button settings -->
|
||||||
|
<p class="section-title" style="cursor:default;">
|
||||||
|
<span class="arrow" style="visibility:hidden;">▶</span> Button
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<label>Project</label>
|
||||||
|
<select id="projectSelect">
|
||||||
|
<option value="">Loading projects…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="statusText"></p>
|
||||||
|
|
||||||
|
<script src="libs/constants.js"></script>
|
||||||
|
<script src="libs/prototypes.js"></script>
|
||||||
|
<script src="libs/timers.js"></script>
|
||||||
|
<script src="libs/utils.js"></script>
|
||||||
|
<script src="libs/events.js"></script>
|
||||||
|
<script src="libs/api.js"></script>
|
||||||
|
<script src="libs/property-inspector.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var ACTION_UUID = "com.pdma.notion-timer.toggle";
|
||||||
|
var currentSettings = {};
|
||||||
|
var credSaveTimer = null;
|
||||||
|
var credConfigured = false;
|
||||||
|
|
||||||
|
function setStatus(msg, cls) {
|
||||||
|
var el = document.getElementById("statusText");
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = cls || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCredStatus(msg, cls) {
|
||||||
|
var el = document.getElementById("credStatus");
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = cls || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsible credentials section
|
||||||
|
document.getElementById("credToggle").addEventListener("click", function() {
|
||||||
|
var section = document.getElementById("credSection");
|
||||||
|
var arrow = document.getElementById("credArrow");
|
||||||
|
if (section.classList.contains("collapsed")) {
|
||||||
|
section.classList.remove("collapsed");
|
||||||
|
arrow.textContent = "▼";
|
||||||
|
} else {
|
||||||
|
section.classList.add("collapsed");
|
||||||
|
arrow.textContent = "▶";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveCredentials() {
|
||||||
|
var creds = {
|
||||||
|
notionToken: document.getElementById("notionToken").value.trim(),
|
||||||
|
userId: document.getElementById("userId").value.trim(),
|
||||||
|
};
|
||||||
|
$PI.setGlobalSettings(creds);
|
||||||
|
setCredStatus("Credentials saved.", "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCredSave() {
|
||||||
|
clearTimeout(credSaveTimer);
|
||||||
|
credSaveTimer = setTimeout(saveCredentials, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
var sel = document.getElementById("projectSelect");
|
||||||
|
var opt = sel.options[sel.selectedIndex];
|
||||||
|
currentSettings.projectId = sel.value;
|
||||||
|
currentSettings.projectName = sel.value ? opt.textContent.trim() : "";
|
||||||
|
$PI.setSettings(currentSettings);
|
||||||
|
$PI.sendToPlugin({ event: "saveSettings", settings: currentSettings });
|
||||||
|
setStatus(currentSettings.projectName ? "Saved: " + currentSettings.projectName : "", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateProjects(projects) {
|
||||||
|
var sel = document.getElementById("projectSelect");
|
||||||
|
sel.innerHTML = '<option value="">— Select project —</option>';
|
||||||
|
projects.forEach(function(p) {
|
||||||
|
var opt = document.createElement("option");
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = p.name;
|
||||||
|
if (currentSettings.projectId === p.id) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (!currentSettings.projectId) {
|
||||||
|
setStatus("Select a project to get started.", "");
|
||||||
|
} else if (currentSettings.activeEntryId) {
|
||||||
|
setStatus("⏱ Timer running", "running");
|
||||||
|
} else {
|
||||||
|
setStatus("", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$PI.onConnected(function(jsn) {
|
||||||
|
currentSettings = jsn.actionInfo.payload.settings || {};
|
||||||
|
$PI.getSettings();
|
||||||
|
$PI.getGlobalSettings();
|
||||||
|
|
||||||
|
document.getElementById("projectSelect").addEventListener("change", save);
|
||||||
|
["notionToken", "userId"].forEach(function(id) {
|
||||||
|
document.getElementById(id).addEventListener("input", scheduleCredSave);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$PI.onDidReceiveGlobalSettings(function(jsn) {
|
||||||
|
var s = jsn.payload.settings || {};
|
||||||
|
document.getElementById("notionToken").value = s.notionToken || "";
|
||||||
|
document.getElementById("userId").value = s.userId || "";
|
||||||
|
|
||||||
|
credConfigured = !!(s.notionToken && s.userId);
|
||||||
|
|
||||||
|
// Auto-collapse if already configured, expand if not
|
||||||
|
var section = document.getElementById("credSection");
|
||||||
|
var arrow = document.getElementById("credArrow");
|
||||||
|
if (credConfigured) {
|
||||||
|
section.classList.add("collapsed");
|
||||||
|
arrow.textContent = "▶";
|
||||||
|
} else {
|
||||||
|
section.classList.remove("collapsed");
|
||||||
|
arrow.textContent = "▼";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$PI.onDidReceiveSettings(ACTION_UUID, function(jsn) {
|
||||||
|
currentSettings = jsn.payload.settings || {};
|
||||||
|
var sel = document.getElementById("projectSelect");
|
||||||
|
if (currentSettings.projectId && sel.options.length > 1) {
|
||||||
|
sel.value = currentSettings.projectId;
|
||||||
|
}
|
||||||
|
if (currentSettings.activeEntryId) {
|
||||||
|
setStatus("⏱ Timer running", "running");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$PI.onSendToPropertyInspector(ACTION_UUID, function(jsn) {
|
||||||
|
var payload = jsn.payload;
|
||||||
|
if (payload.event === "projects") {
|
||||||
|
if (payload.error) {
|
||||||
|
setStatus(payload.error, "error");
|
||||||
|
} else {
|
||||||
|
populateProjects(payload.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
576
package-lock.json
generated
Normal file
576
package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/notion.ts
Normal file
119
src/notion.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
green: "📗", blue: "📘", orange: "📙", red: "📕",
|
||||||
|
yellow: "📒", pink: "📔", purple: "📓",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_CIRCLE: Record<string, string> = {
|
||||||
|
green: "🟢", blue: "🔵", red: "🔴", orange: "🟠",
|
||||||
|
yellow: "🟡", purple: "🟣", pink: "🩷", brown: "🟤",
|
||||||
|
gray: "⚫", lightgray: "⬜", default: "⬜",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_NAME: Record<string, string> = {
|
||||||
|
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<NotionProject[]> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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()}`);
|
||||||
|
}
|
||||||
145
src/plugin.ts
Normal file
145
src/plugin.ts
Normal file
@@ -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<GlobalSettings> {
|
||||||
|
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>();
|
||||||
|
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<TimerSettings> {
|
||||||
|
private settingsCache = new Map<string, TimerSettings>();
|
||||||
|
|
||||||
|
async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> {
|
||||||
|
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<TimerSettings>): Promise<void> {
|
||||||
|
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<TimerSettings>): Promise<void> {
|
||||||
|
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();
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user