28 Commits
Author SHA1 Message Date
pdmarf f12a8d2b74 v1.0.65: default untagged timer entries to Client Books 2026-10-08 13:59:20 +01:00
pdmarf 43938dded5 v1.0.64: ship timer tags and description prompt via auto-update 2026-10-08 11:53:02 +01:00
pdmarf c50ee5e822 Add timer tags and optional start description
Buttons can carry a Timer Tags select value and prompt for an optional
description before starting. Tag options and the prompt toggle are
configured per-button in the property inspector; startTimer writes the
tag to the Timer Tags select property and the description to the
Description rich_text property when set.
2026-10-08 09:48:03 +01:00
pdmarf 0956b28864 v1.0.63: version bump for update-flow testing (no functional change) 2026-10-07 18:50:36 +01:00
pdmarf c6f2d9ae70 v1.0.62: version bump for update-flow testing (no functional change) 2026-10-07 18:47:19 +01:00
pdmarf b1c645c7a5 v1.0.61: restore recurring 30-min update check 2026-10-07 18:45:50 +01:00
pdmarf 6575f7a1e6 v1.0.60: revert recurring 30-min update check, keep 10s-after-launch only
Per Pete: staff Macs restart Stream Deck/the plugin process often enough
that the one-shot 10s-post-launch check is sufficient; the periodic
recheck was unwanted complexity.
2026-10-07 18:44:14 +01:00
pdmarf 08019e3c5c v1.0.59: version bump for update-flow testing (no functional change) 2026-10-07 18:43:03 +01:00
pdmarf 608b804e5e v1.0.58: add logging around the update-check so silent failures are visible 2026-10-07 18:41:04 +01:00
pdmarf c8667c3844 v1.0.57: version bump for update-flow testing (no functional change) 2026-10-07 18:37:24 +01:00
pdmarf b08126e06e v1.0.56: recheck for updates every 30 minutes, not just once at startup
The auto-updater used a one-shot setTimeout, so a version pushed after the
initial 10s post-launch check would sit unapplied until Stream Deck restarted
or someone hit the manual Check for Updates button.
2026-10-07 18:36:51 +01:00
pdmarf e2e29cc876 v1.0.55: version bump for update-flow testing (no functional change) 2026-10-07 18:34:16 +01:00
pdmarf ffaf7ff4e4 v1.0.54: version bump for update-flow testing (no functional change) 2026-10-07 18:22:13 +01:00
pdmarf 564fd0571f v1.0.53: version bump for update-flow testing (no functional change) 2026-10-07 18:20:12 +01:00
pdmarf 4a7ed58410 v1.0.52: add Launch Nextcloud button
Spawns the Nextcloud.app binary directly (detached, no Terminal window)
since it doesn't register properly with launch services on some setups.
New action requires a one-time plugin reinstall (manifest changes don't
ship through the OTA updater, which only touches plugin.js + assets).
2026-10-07 18:15:08 +01:00
pdmarf 0b4345dad6 v1.0.51: stop chasing auto-refresh, tell the user to confirm manually instead
v1.0.49's panel-polling approach and v1.0.50's diagnostic logging did
not produce a working auto-refresh in testing -- the panel still hung
after the restarting message until a different button was clicked.
Rather than keep guessing at Stream Deck's PI-reconnect lifecycle,
removed the polling/debug scaffolding entirely and changed the final
status message to explicitly instruct the user: 'Updated to vX. Click
any other button above to confirm version update has been successful.'
2026-09-02 11:52:01 +01:00
pdmarf feed18ed0d v1.0.50: add diagnostic logging for the update-refresh flow
No functional change. Adds streamDeck.logger.info() calls at
onPropertyInspectorDidAppear, sendProjectsToPI(), and onSendToPlugin
entry, plus a small on-panel debug trail (property-inspector.html)
that timestamps $PI.onConnected firings, button clicks, poll sends,
and received payloads. v1.0.49's panel-polling fix didn't visibly
work in testing and produced no status text at all on the second
attempt -- need to see the actual event sequence rather than guess
again. Remove once the refresh flow is confirmed working.
2026-09-02 11:46:08 +01:00
pdmarf acf4f98d6b v1.0.49: fix version auto-refresh by polling from the property inspector
The v1.0.47/48 attempt pushed sendProjectsToPI() from the plugin right
after streamDeck.connect() on startup, assuming that would reach an
already-open panel post-update-restart. It doesn't: per the SDK,
sendToPropertyInspector only delivers 'when the property inspector is
visible for an action provided by this plugin', and that visibility
state is tracked per-process via live didAppear/onSendToPlugin events
-- a freshly restarted process has never received one, so the push was
silently dropped. Confirmed in testing: the panel stayed frozen on
'Updating to vX...' indefinitely.

Removed the ineffective push. Instead, the property inspector's
'Check for Updates' handler now schedules a few refreshProjects
requests (1.5s/3.5s/6s/9s after the click) -- the same proven
PI-initiated round trip already used when entering an API token, which
reliably re-populates the panel including the version text once the
new process comes back up.

Also hardened checkForUpdates(): the final 'Updated to vX --
restarting...' status is now awaited with a short delay before
process.exit(0), best-effort only, since the panel no longer depends
on it arriving.
2026-09-02 11:32:06 +01:00
pdmarf ae230e45ed v1.0.48: version bump for update-flow testing (no functional change) 2026-09-02 11:27:28 +01:00
pdmarf 3a033679c8 v1.0.47: auto-refresh version display in property inspector after update
The version number shown in the PI only updates via the 'projects' push,
which only fires from onPropertyInspectorDidAppear (i.e. when a
different button is clicked, forcing the PI to re-appear). After
'Check for Updates' completes and the plugin process restarts, an
already-open PI kept showing the pre-update version indefinitely.

Two changes: send a final 'Updated to vX — restarting…' status right
before process.exit(0) so the update message doesn't hang on
'Updating…', and push sendProjectsToPI() once on every plugin startup
(after streamDeck.connect()) so a PI that survived the restart gets
the new version immediately instead of waiting for an unrelated button
click.
2026-09-02 11:24:30 +01:00
pdmarf 5b3ba8186c v1.0.46: clear stale running-timer state even when stopTimer fails in Notion
The onKeyDown stop/switch paths only cleared memRunningActionId/EntryId
after a successful stopTimer call. If the tracked Notion entry was
already gone (archived/deleted/edited outside the plugin), stopTimer
threw and the stale ID was never cleared -- every subsequent keypress
retried the same doomed stop call, showed a warning triangle, and the
deck got permanently stuck (unable to unselect the stuck button or
start any new timer). checkElapsed already handled this correctly by
clearing state unconditionally; onKeyDown now does the same: stopTimer
failures are logged but non-fatal, and local state clears regardless.
2026-09-02 11:16:24 +01:00
pdmarf e897f0a8f5 v1.0.45: delay blocked-press icon correction to avoid native flash collision
Diagnosed via instrumented logging: the v1.0.44 blocked-press redraw
was calling setState()/setTitle() successfully (no error, confirmed
in logs) but Stream Deck wasn't visually applying it — the corrective
image call was landing too close to Stream Deck's own native
key-press flash animation and getting silently dropped. Forcing a
page navigation away and back always redrew correctly, proving the
tracked state was right all along and this was purely a rendering
timing issue. Adding a 150ms delay before the corrective redraw lets
the native animation settle first; confirmed fixed on device.
2026-08-25 16:52:32 +01:00
pdmarf 2e2f2553ac v1.0.44: clear tracked running state immediately after stopping old timer
Found while testing v1.0.43's icon self-heal: when switching from one
running timer to another, the old Notion entry was stopped but
memRunningActionId/EntryId weren't cleared until AFTER the new
startTimer call resolved. During that gap, a blocked press on the
just-stopped button read the stale (not-yet-cleared) tracked id and
self-healed itself back to a wrongly-'running' green — reinforcing
the exact staleness the self-heal was meant to fix. Now cleared to
null the moment the old timer is actually stopped.
2026-08-25 16:34:38 +01:00
pdmarf 45201bc376 v1.0.43: self-heal stale button icon when blocked by the keyDown lock
When a keyDown is blocked by the v1.0.42 lock, redraw that button to
match memRunningActionId instead of leaving whatever it was already
showing. This closes the follow-on issue found while testing 1.0.42:
a button's key image can go stale if its Notion timer was ever
stopped/started outside the plugin (e.g. a manual Notion edit), and
a blocked press was leaving that stale image in place — misleading
the user into thinking a timer was running when it wasn't, which
then caused a *real* unintended timer start on the next press.
2026-08-25 16:27:46 +01:00
pdmarf 18f8007df5 v1.0.42: fix duplicate timer race across different buttons
pendingKeyDown was a Set keyed by action.id, so it only blocked a
button from re-firing itself. Pressing two different buttons in fast
succession (e.g. RB01 then RA01) both passed the guard, both read
memRunningActionId before either had persisted it, and both started
a Notion timer entry. Replaced with a single keyDownBusy flag shared
across all buttons of the action, set synchronously before the first
await, closing the race.
2026-08-25 15:42:43 +01:00
pdmarf ce1a17975d v1.0.41: add 1-hour elapsed check with continue/stop dialog, auto-stop on no response 2026-07-13 13:21:34 +01:00
pdmarfandClaude Sonnet 4.6 f76c5ac33b v1.0.40: bump version.json so auto-updater delivers update to staff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:07:17 +01:00
pdmarfandClaude Sonnet 4.6 6e0976a284 v1.0.40: fix duplicate timer race condition on rapid double-press
onKeyDown is async and calls await startTimer (~1s network). A second
press before that resolves saw the same state (isRunning=false,
memRunningEntryId=null) and created a second Notion entry. Only the
last startTimer call's ID was tracked, orphaning the first entry
running indefinitely in Notion.

pendingKeyDown Set acts as a per-action mutex: a second press while the
first is in-flight is dropped. try/finally guarantees the lock is always
released.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:18:52 +01:00
10 changed files with 527 additions and 89 deletions
+217 -61
View File
@@ -6375,6 +6375,15 @@ async function fetchUsers(token) {
const data = await resp.json(); const data = await resp.json();
return data.results.filter((u) => u.type === "person" && u.person?.email).map((u) => ({ id: u.id, name: u.name })).sort((a, b) => a.name.localeCompare(b.name)); return data.results.filter((u) => u.type === "person" && u.person?.email).map((u) => ({ id: u.id, name: u.name })).sort((a, b) => a.name.localeCompare(b.name));
} }
async function fetchTagOptions(token, databaseId, propertyName) {
const resp = await fetchWithTimeout(`${NOTION_BASE}/databases/${databaseId}`, {
headers: headers(token)
});
if (!resp.ok) throw new Error(`Failed to fetch database schema: ${resp.status}`);
const data = await resp.json();
const options = data.properties?.[propertyName]?.select?.options ?? [];
return options.map((o) => o.name);
}
async function fetchProjects(token, dbId) { async function fetchProjects(token, dbId) {
const results = []; const results = [];
let cursor; let cursor;
@@ -6401,21 +6410,24 @@ async function fetchProjects(token, dbId) {
} while (cursor); } while (cursor);
return results; return results;
} }
async function startTimer(token, timingDbId, projectId, projectName, userId) { async function startTimer(token, timingDbId, projectId, projectName, userId, tag, description) {
const now = (/* @__PURE__ */ new Date()).toISOString(); const now = (/* @__PURE__ */ new Date()).toISOString();
const 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 (tag) properties["Timer Tags"] = { select: { name: tag } };
if (description) properties.Description = { rich_text: [{ text: { content: description } }] };
const resp = await fetchWithTimeout(`${NOTION_BASE}/pages`, { const resp = await fetchWithTimeout(`${NOTION_BASE}/pages`, {
method: "POST", method: "POST",
headers: headers(token), headers: headers(token),
body: JSON.stringify({ body: JSON.stringify({
parent: { database_id: timingDbId }, parent: { database_id: timingDbId },
icon: { type: "emoji", emoji: "\u23F1\uFE0F" }, icon: { type: "emoji", emoji: "\u23F1\uFE0F" },
properties: { 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: ${resp.status}`); if (!resp.ok) throw new Error(`Failed to start timer: ${resp.status}`);
@@ -6438,7 +6450,7 @@ async function stopTimer(token, entryId) {
} }
// src/plugin.ts // src/plugin.ts
var CURRENT_VERSION = "1.0.39"; var CURRENT_VERSION = "1.0.65";
var GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild"; var GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild";
var SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- var SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4= MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4=
@@ -6460,12 +6472,17 @@ async function checkForUpdates(sendStatus) {
try { try {
const resp = await fetchWithTimeout2(`${GITEA_BASE}/version.json`); const resp = await fetchWithTimeout2(`${GITEA_BASE}/version.json`);
if (!resp.ok) { if (!resp.ok) {
plugin_default.logger.warn(`Update check: version.json fetch returned ${resp.status}`);
sendStatus?.("Update check failed"); sendStatus?.("Update check failed");
return; return;
} }
const { version } = await resp.json(); const { version } = await resp.json();
if (!/^\d+\.\d+\.\d+$/.test(version)) return; if (!/^\d+\.\d+\.\d+$/.test(version)) {
plugin_default.logger.warn(`Update check: version.json had unexpected content: ${JSON.stringify(version)}`);
return;
}
if (!isNewerVersion(version, CURRENT_VERSION)) { if (!isNewerVersion(version, CURRENT_VERSION)) {
plugin_default.logger.info(`Update check: up to date (v${CURRENT_VERSION}, server has v${version})`);
sendStatus?.(`Already up to date (v${CURRENT_VERSION})`); sendStatus?.(`Already up to date (v${CURRENT_VERSION})`);
return; return;
} }
@@ -6512,6 +6529,8 @@ async function checkForUpdates(sendStatus) {
} }
fs3.writeFileSync(__filename, newCode); fs3.writeFileSync(__filename, newCode);
plugin_default.logger.info(`Updated to ${version}, restarting\u2026`); plugin_default.logger.info(`Updated to ${version}, restarting\u2026`);
await sendStatus?.(`Updated to v${version}. Click any other button above to confirm version update has been successful.`);
await new Promise((resolve) => setTimeout(resolve, 200));
process.exit(0); process.exit(0);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -6523,6 +6542,7 @@ var HARDCODED = {
timingDbId: "2ec93117da3a80799ee2cf91244ee264", timingDbId: "2ec93117da3a80799ee2cf91244ee264",
projectsDbId: "cd65c760-0f0a-4d6e-a262-d572c9e31585" projectsDbId: "cd65c760-0f0a-4d6e-a262-d572c9e31585"
}; };
var DEFAULT_TAG = "Client Books";
async function getGlobal() { async function getGlobal() {
const stored = await plugin_default.settings.getGlobalSettings(); const stored = await plugin_default.settings.getGlobalSettings();
return { ...stored, ...HARDCODED }; return { ...stored, ...HARDCODED };
@@ -6548,14 +6568,18 @@ async function sendProjectsToPI(tokenOverride) {
await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION }); await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION });
return; return;
} }
const [projects, usersResult] = await Promise.all([ const [projects, usersResult, tagsResult] = await Promise.all([
fetchProjects(token, global.projectsDbId), fetchProjects(token, global.projectsDbId),
fetchUsers(token).catch((err) => { fetchUsers(token).catch((err) => {
plugin_default.logger.error("Failed to fetch users:", err); plugin_default.logger.error("Failed to fetch users:", err);
return []; return [];
}),
fetchTagOptions(token, global.timingDbId, "Timer Tags").catch((err) => {
plugin_default.logger.error("Failed to fetch tag options:", err);
return [];
}) })
]); ]);
await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, version: CURRENT_VERSION }); await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, tags: tagsResult, version: CURRENT_VERSION });
} catch (err) { } catch (err) {
plugin_default.logger.error("Failed to fetch projects:", err); plugin_default.logger.error("Failed to fetch projects:", err);
await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err), version: CURRENT_VERSION }); await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err), version: CURRENT_VERSION });
@@ -6564,14 +6588,84 @@ async function sendProjectsToPI(tokenOverride) {
function isConfigured(g) { function isConfigured(g) {
return !!(g.notionToken && g.userId); return !!(g.notionToken && g.userId);
} }
function buttonTitle(projectName) { function buttonTitle(projectName, tag) {
return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim(); const base = projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
return tag ? `${base} \xB7 ${tag}` : base;
}
var ELAPSED_CHECK_MS = 60 * 60 * 1e3;
var DIALOG_TIMEOUT_SECONDS = 300;
async function promptContinue(title) {
const { execFile: execFile3 } = await import("node:child_process");
const safeTitle = title.replace(/[\\"]/g, "");
const script = `display dialog "Timer for ${safeTitle} has been running for over an hour.
Should it continue?" buttons {"Stop", "Continue"} default button "Continue" giving up after ${DIALOG_TIMEOUT_SECONDS}`;
return new Promise((resolve) => {
execFile3("osascript", ["-e", script], (err, stdout) => {
if (err || /gave up:true/.test(String(stdout))) {
resolve("timeout");
return;
}
resolve(/button returned:Stop/.test(String(stdout)) ? "stop" : "continue");
});
});
}
async function promptDescription(title) {
const { execFile: execFile3 } = await import("node:child_process");
const safeTitle = title.replace(/[\\"]/g, "");
const script = `display dialog "Description for ${safeTitle} (optional):" default answer "" buttons {"Cancel", "Start"} default button "Start" giving up after ${DIALOG_TIMEOUT_SECONDS}`;
return new Promise((resolve) => {
execFile3("osascript", ["-e", script], (err, stdout) => {
const out = String(stdout);
if (err || /gave up:true/.test(out) || !/button returned:Start/.test(out)) {
resolve({ ok: false, text: "" });
return;
}
const match = /text returned:(.*?)(?:, gave up:|$)/.exec(out);
resolve({ ok: true, text: match ? match[1].trim() : "" });
});
});
} }
var TimerToggle = class extends SingletonAction { var TimerToggle = class extends SingletonAction {
projectCache = /* @__PURE__ */ new Map(); projectCache = /* @__PURE__ */ new Map();
keyDownBusy = false;
elapsedCheckTimer = null;
clearElapsedCheck() {
if (this.elapsedCheckTimer) clearTimeout(this.elapsedCheckTimer);
this.elapsedCheckTimer = null;
}
scheduleElapsedCheck(entryId, actionId, title) {
this.clearElapsedCheck();
this.elapsedCheckTimer = setTimeout(() => {
this.checkElapsed(entryId, actionId, title);
}, ELAPSED_CHECK_MS);
}
async checkElapsed(entryId, actionId, title) {
if (memRunningEntryId !== entryId) return;
const answer = await promptContinue(title);
if (memRunningEntryId !== entryId) return;
if (answer === "continue") {
this.scheduleElapsedCheck(entryId, actionId, title);
return;
}
try {
const global = await getGlobal();
await stopTimer(global.notionToken, entryId);
} catch (err) {
plugin_default.logger.error("Auto-stop after elapsed-check failed:", err);
}
setRunningEntry(null, null);
this.clearElapsedCheck();
for (const action2 of this.actions) {
if (action2.id === actionId) {
await Promise.all([action2.setState(0), action2.setTitle(title)]);
break;
}
}
}
async onWillAppear(ev) { async onWillAppear(ev) {
this.projectCache.set(ev.action.id, ev.payload.settings); this.projectCache.set(ev.action.id, ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
await loadRunningState(); await loadRunningState();
const isRunning = memRunningActionId === ev.action.id; const isRunning = memRunningActionId === ev.action.id;
if (isRunning) { if (isRunning) {
@@ -6584,64 +6678,118 @@ var TimerToggle = class extends SingletonAction {
await sendProjectsToPI(); await sendProjectsToPI();
} }
async onKeyDown(ev) { async onKeyDown(ev) {
const { projectId, projectName } = ev.payload.settings; if (this.keyDownBusy) {
const title = buttonTitle(projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
const isRunning = memRunningActionId === ev.action.id; const shouldBeRunning = memRunningActionId === ev.action.id;
if (projectId) { await new Promise((resolve) => setTimeout(resolve, 150));
if (isRunning) {
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else {
for (const other of this.actions) {
if (other.id === ev.action.id) continue;
if (memRunningActionId === other.id) {
const s = this.projectCache.get(other.id);
await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || ""))]);
}
}
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
}
}
const global = await getGlobal();
if (!isConfigured(global)) {
await ev.action.showAlert();
return;
}
if (!projectId) {
await ev.action.showAlert();
return;
}
try {
if (isRunning) {
await stopTimer(global.notionToken, memRunningEntryId);
setRunningEntry(null, null);
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else {
if (memRunningEntryId) {
await stopTimer(global.notionToken, memRunningEntryId);
}
const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId);
setRunningEntry(entryId, ev.action.id);
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
}
} catch (err) {
await Promise.all([ await Promise.all([
ev.action.setState(isRunning ? 1 : 0), ev.action.setState(shouldBeRunning ? 1 : 0),
ev.action.setTitle(isRunning ? `\u23F1 ${title}` : title) ev.action.setTitle(shouldBeRunning ? `\u23F1 ${title}` : title)
]); ]);
plugin_default.logger.error("Timer toggle failed:", err); return;
await ev.action.showAlert(); }
this.keyDownBusy = true;
try {
const { projectId, projectName, tag, askDescription } = ev.payload.settings;
const title = buttonTitle(projectName || "", tag);
const isRunning = memRunningActionId === ev.action.id;
let description = "";
if (!isRunning && projectId && askDescription) {
const result = await promptDescription(title);
if (!result.ok) return;
description = result.text;
}
if (projectId) {
if (isRunning) {
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else {
for (const other of this.actions) {
if (other.id === ev.action.id) continue;
if (memRunningActionId === other.id) {
const s = this.projectCache.get(other.id);
await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || "", s?.tag))]);
}
}
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
}
}
const global = await getGlobal();
if (!isConfigured(global)) {
await ev.action.showAlert();
return;
}
if (!projectId) {
await ev.action.showAlert();
return;
}
try {
if (isRunning) {
try {
await stopTimer(global.notionToken, memRunningEntryId);
} catch (err) {
plugin_default.logger.error("Failed to stop timer in Notion (clearing local state anyway):", err);
}
setRunningEntry(null, null);
this.clearElapsedCheck();
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else {
if (memRunningEntryId) {
try {
await stopTimer(global.notionToken, memRunningEntryId);
} catch (err) {
plugin_default.logger.error("Failed to stop previous timer in Notion (clearing local state anyway):", err);
}
setRunningEntry(null, null);
this.clearElapsedCheck();
}
const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId, tag || DEFAULT_TAG, description);
setRunningEntry(entryId, ev.action.id);
this.scheduleElapsedCheck(entryId, ev.action.id, title);
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
}
} catch (err) {
await Promise.all([
ev.action.setState(isRunning ? 1 : 0),
ev.action.setTitle(isRunning ? `\u23F1 ${title}` : title)
]);
plugin_default.logger.error("Timer toggle failed:", err);
await ev.action.showAlert();
}
} finally {
this.keyDownBusy = false;
} }
} }
}; };
TimerToggle = __decorateClass([ TimerToggle = __decorateClass([
action({ UUID: "com.pdma.notion-timer.toggle" }) action({ UUID: "com.pdma.notion-timer.toggle" })
], TimerToggle); ], TimerToggle);
var NEXTCLOUD_BIN = "/Applications/Nextcloud.app/Contents/MacOS/Nextcloud";
var LaunchNextcloud = class extends SingletonAction {
async onKeyDown(ev) {
try {
const { spawn } = await import("node:child_process");
const child = spawn(NEXTCLOUD_BIN, [], { detached: true, stdio: "ignore" });
child.once("error", (err) => {
plugin_default.logger.error("Failed to launch Nextcloud:", err);
});
child.unref();
await ev.action.showOk();
} catch (err) {
plugin_default.logger.error("Failed to launch Nextcloud:", err);
await ev.action.showAlert();
}
}
};
LaunchNextcloud = __decorateClass([
action({ UUID: "com.pdma.notion-timer.launch-nextcloud" })
], LaunchNextcloud);
var timerAction = new TimerToggle(); var timerAction = new TimerToggle();
plugin_default.actions.registerAction(timerAction); plugin_default.actions.registerAction(timerAction);
plugin_default.actions.registerAction(new LaunchNextcloud());
plugin_default.ui.onSendToPlugin(async (ev) => { plugin_default.ui.onSendToPlugin(async (ev) => {
if (ev.payload.event === "saveSettings" && ev.payload.settings) { if (ev.payload.event === "saveSettings" && ev.payload.settings) {
await ev.action.setSettings(ev.payload.settings); await ev.action.setSettings(ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
if (title) await ev.action.setTitle(title); if (title) await ev.action.setTitle(title);
} }
if (ev.payload.event === "refreshProjects") { if (ev.payload.event === "refreshProjects") {
@@ -6653,8 +6801,16 @@ plugin_default.ui.onSendToPlugin(async (ev) => {
await checkForUpdates(send); await checkForUpdates(send);
} }
}); });
var UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1e3;
plugin_default.connect(); plugin_default.connect();
setTimeout(() => checkForUpdates(), 1e4); setTimeout(() => {
plugin_default.logger.info("Running initial (10s post-launch) update check");
checkForUpdates();
}, 1e4);
setInterval(() => {
plugin_default.logger.info("Running scheduled (30min) update check");
checkForUpdates();
}, UPDATE_CHECK_INTERVAL_MS);
/*! Bundled license information: /*! Bundled license information:
@elgato/schemas/dist/streamdeck/plugins/index.mjs: @elgato/schemas/dist/streamdeck/plugins/index.mjs:
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,5 @@
<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="#0082c9"/>
<path d="M72 46c-13.3 0-24.4 9.4-27.1 22a16 16 0 0 0 3.1 31.8h48a16.5 16.5 0 0 0 3.6-32.6C97.3 55.6 85.9 46 72 46z"
fill="none" stroke="white" stroke-width="6" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

+14 -1
View File
@@ -2,7 +2,7 @@
"Author": "Pete Marfleet", "Author": "Pete Marfleet",
"Description": "Toggle Notion time tracking for a project with a single button press.", "Description": "Toggle Notion time tracking for a project with a single button press.",
"Name": "Notion Timer", "Name": "Notion Timer",
"Version": "1.0.39", "Version": "1.0.40",
"SDKVersion": 2, "SDKVersion": 2,
"Software": { "MinimumVersion": "5.0" }, "Software": { "MinimumVersion": "5.0" },
"OS": [{ "Platform": "mac", "MinimumVersion": "10.11" }], "OS": [{ "Platform": "mac", "MinimumVersion": "10.11" }],
@@ -30,6 +30,19 @@
], ],
"PropertyInspectorPath": "ui/property-inspector.html", "PropertyInspectorPath": "ui/property-inspector.html",
"SupportedInMultiActions": false "SupportedInMultiActions": false
},
{
"Icon": "imgs/nextcloud-icon",
"Name": "Launch Nextcloud",
"UUID": "com.pdma.notion-timer.launch-nextcloud",
"Tooltip": "Launch the Nextcloud desktop client in the background.",
"States": [
{
"Image": "imgs/nextcloud-icon",
"TitleAlignment": "bottom"
}
],
"SupportedInMultiActions": false
} }
] ]
} }
@@ -58,6 +58,11 @@
padding-left: 98px; padding-left: 98px;
line-height: 1.4; line-height: 1.4;
} }
.info-icon {
color: #888;
font-size: 13px;
cursor: help;
}
.divider { .divider {
border: none; border: none;
border-top: 1px solid #333; border-top: 1px solid #333;
@@ -142,6 +147,22 @@
<option value="">Loading projects…</option> <option value="">Loading projects…</option>
</select> </select>
</div> </div>
<div class="row">
<label>Ask description (optional)</label>
<input type="checkbox" id="askDescription" style="flex: none; width: auto;">
<span class="info-icon" title="Prompt for a description when starting this button's timer.">ⓘ</span>
</div>
<div class="row">
<label>Use tag (Pete only)</label>
<input type="checkbox" id="tagEnabled" style="flex: none; width: auto;">
</div>
<div class="row" id="tagRow">
<label></label>
<select id="tagSelect">
<option value="">— No tag —</option>
</select>
</div>
<p class="hint" id="tagHint">Recorded as the entry's Tag in Notion and shown on the button.</p>
<p id="statusText"></p> <p id="statusText"></p>
<p id="versionText"></p> <p id="versionText"></p>
@@ -163,6 +184,7 @@
var credConfigured = false; var credConfigured = false;
var globalUserId = ""; var globalUserId = "";
var cachedUsers = []; var cachedUsers = [];
var cachedTags = [];
function setStatus(msg, cls) { function setStatus(msg, cls) {
var el = document.getElementById("statusText"); var el = document.getElementById("statusText");
@@ -224,11 +246,39 @@
}); });
} }
function populateTags(tags) {
cachedTags = tags;
var sel = document.getElementById("tagSelect");
sel.innerHTML = '<option value="">— No tag —</option>';
var values = tags.slice();
// A tag saved on this button before it existed as a Notion select option
// (or since removed there) must still show up, selected, instead of
// silently reverting to "No tag".
if (currentSettings.tag && values.indexOf(currentSettings.tag) === -1) {
values.push(currentSettings.tag);
}
values.forEach(function(t) {
var opt = document.createElement("option");
opt.value = t;
opt.textContent = t;
if (currentSettings.tag === t) opt.selected = true;
sel.appendChild(opt);
});
}
function setTagRowVisible(visible) {
document.getElementById("tagRow").style.display = visible ? "" : "none";
document.getElementById("tagHint").style.display = visible ? "" : "none";
}
function save() { function save() {
var sel = document.getElementById("projectSelect"); var sel = document.getElementById("projectSelect");
var opt = sel.options[sel.selectedIndex]; var opt = sel.options[sel.selectedIndex];
currentSettings.projectId = sel.value; currentSettings.projectId = sel.value;
currentSettings.projectName = sel.value ? opt.textContent.trim() : ""; currentSettings.projectName = sel.value ? opt.textContent.trim() : "";
currentSettings.tagEnabled = document.getElementById("tagEnabled").checked;
currentSettings.tag = currentSettings.tagEnabled ? document.getElementById("tagSelect").value : "";
currentSettings.askDescription = document.getElementById("askDescription").checked;
$PI.setSettings(currentSettings); $PI.setSettings(currentSettings);
$PI.sendToPlugin({ event: "saveSettings", settings: currentSettings }); $PI.sendToPlugin({ event: "saveSettings", settings: currentSettings });
setStatus(currentSettings.projectName ? "Saved: " + currentSettings.projectName : "", ""); setStatus(currentSettings.projectName ? "Saved: " + currentSettings.projectName : "", "");
@@ -255,10 +305,19 @@
$PI.onConnected(function(jsn) { $PI.onConnected(function(jsn) {
currentSettings = jsn.actionInfo.payload.settings || {}; currentSettings = jsn.actionInfo.payload.settings || {};
document.getElementById("tagEnabled").checked = !!currentSettings.tagEnabled;
setTagRowVisible(!!currentSettings.tagEnabled);
document.getElementById("askDescription").checked = !!currentSettings.askDescription;
$PI.getSettings(); $PI.getSettings();
$PI.getGlobalSettings(); $PI.getGlobalSettings();
document.getElementById("projectSelect").addEventListener("change", save); document.getElementById("projectSelect").addEventListener("change", save);
document.getElementById("tagEnabled").addEventListener("change", function() {
setTagRowVisible(this.checked);
save();
});
document.getElementById("tagSelect").addEventListener("change", save);
document.getElementById("askDescription").addEventListener("change", save);
document.getElementById("notionToken").addEventListener("input", scheduleCredSave); document.getElementById("notionToken").addEventListener("input", scheduleCredSave);
document.getElementById("userId").addEventListener("change", saveCredentials); document.getElementById("userId").addEventListener("change", saveCredentials);
document.getElementById("updateBtn").addEventListener("click", function() { document.getElementById("updateBtn").addEventListener("click", function() {
@@ -296,6 +355,10 @@
if (currentSettings.projectId && sel.options.length > 1) { if (currentSettings.projectId && sel.options.length > 1) {
sel.value = currentSettings.projectId; sel.value = currentSettings.projectId;
} }
document.getElementById("tagEnabled").checked = !!currentSettings.tagEnabled;
setTagRowVisible(!!currentSettings.tagEnabled);
populateTags(cachedTags);
document.getElementById("askDescription").checked = !!currentSettings.askDescription;
if (currentSettings.activeEntryId) { if (currentSettings.activeEntryId) {
setStatus("⏱ Timer running", "running"); setStatus("⏱ Timer running", "running");
} }
@@ -313,6 +376,9 @@
if (payload.users) { if (payload.users) {
populateUsers(payload.users, globalUserId); populateUsers(payload.users, globalUserId);
} }
if (payload.tags) {
populateTags(payload.tags);
}
if (payload.error) { if (payload.error) {
setStatus(payload.error, "error"); setStatus(payload.error, "error");
} else { } else {
Binary file not shown.
+28 -8
View File
@@ -63,6 +63,20 @@ export async function fetchUsers(token: string): Promise<NotionUser[]> {
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
} }
export async function fetchTagOptions(
token: string,
databaseId: string,
propertyName: string
): Promise<string[]> {
const resp = await fetchWithTimeout(`${NOTION_BASE}/databases/${databaseId}`, {
headers: headers(token),
});
if (!resp.ok) throw new Error(`Failed to fetch database schema: ${resp.status}`);
const data = (await resp.json()) as { properties?: Record<string, any> };
const options: any[] = data.properties?.[propertyName]?.select?.options ?? [];
return options.map((o: any) => o.name as string);
}
export async function fetchProjects(token: string, dbId: string): Promise<NotionProject[]> { export async function fetchProjects(token: string, dbId: string): Promise<NotionProject[]> {
const results: NotionProject[] = []; const results: NotionProject[] = [];
let cursor: string | undefined; let cursor: string | undefined;
@@ -102,23 +116,29 @@ export async function startTimer(
timingDbId: string, timingDbId: string,
projectId: string, projectId: string,
projectName: string, projectName: string,
userId: string userId: string,
tag?: string,
description?: string
): Promise<string> { ): Promise<string> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const properties: Record<string, unknown> = {
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 (tag) properties["Timer Tags"] = { select: { name: tag } };
if (description) properties.Description = { rich_text: [{ text: { content: description } }] };
const resp = await fetchWithTimeout(`${NOTION_BASE}/pages`, { const resp = await fetchWithTimeout(`${NOTION_BASE}/pages`, {
method: "POST", method: "POST",
headers: headers(token), headers: headers(token),
body: JSON.stringify({ body: JSON.stringify({
parent: { database_id: timingDbId }, parent: { database_id: timingDbId },
icon: { type: "emoji", emoji: "⏱️" }, icon: { type: "emoji", emoji: "⏱️" },
properties: { 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 }] },
},
}), }),
}); });
+196 -18
View File
@@ -1,4 +1,4 @@
const CURRENT_VERSION = "1.0.39"; const CURRENT_VERSION = "1.0.65";
const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild"; const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild";
const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4= MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4=
@@ -19,13 +19,21 @@ function fetchWithTimeout(url: string): Promise<Response> {
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
} }
async function checkForUpdates(sendStatus?: (msg: string) => void): Promise<void> { async function checkForUpdates(sendStatus?: (msg: string) => void | Promise<void>): Promise<void> {
try { try {
const resp = await fetchWithTimeout(`${GITEA_BASE}/version.json`); const resp = await fetchWithTimeout(`${GITEA_BASE}/version.json`);
if (!resp.ok) { sendStatus?.("Update check failed"); return; } if (!resp.ok) {
streamDeck.logger.warn(`Update check: version.json fetch returned ${resp.status}`);
sendStatus?.("Update check failed");
return;
}
const { version } = await resp.json() as { version: string }; const { version } = await resp.json() as { version: string };
if (!/^\d+\.\d+\.\d+$/.test(version)) return; if (!/^\d+\.\d+\.\d+$/.test(version)) {
streamDeck.logger.warn(`Update check: version.json had unexpected content: ${JSON.stringify(version)}`);
return;
}
if (!isNewerVersion(version, CURRENT_VERSION)) { if (!isNewerVersion(version, CURRENT_VERSION)) {
streamDeck.logger.info(`Update check: up to date (v${CURRENT_VERSION}, server has v${version})`);
sendStatus?.(`Already up to date (v${CURRENT_VERSION})`); sendStatus?.(`Already up to date (v${CURRENT_VERSION})`);
return; return;
} }
@@ -73,6 +81,11 @@ async function checkForUpdates(sendStatus?: (msg: string) => void): Promise<void
fs.writeFileSync(__filename, newCode); fs.writeFileSync(__filename, newCode);
streamDeck.logger.info(`Updated to ${version}, restarting…`); streamDeck.logger.info(`Updated to ${version}, restarting…`);
// Await + a short delay so the message actually reaches the socket before
// the process dies. The panel can't reliably learn its own new version
// number after this restart, so it just tells the user how to confirm it.
await sendStatus?.(`Updated to v${version}. Click any other button above to confirm version update has been successful.`);
await new Promise((resolve) => setTimeout(resolve, 200));
process.exit(0); process.exit(0);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -88,7 +101,7 @@ import streamDeck, {
SingletonAction, SingletonAction,
WillAppearEvent, WillAppearEvent,
} from "@elgato/streamdeck"; } from "@elgato/streamdeck";
import { fetchProjects, fetchUsers, startTimer, stopTimer } from "./notion.js"; import { fetchProjects, fetchTagOptions, fetchUsers, startTimer, stopTimer } from "./notion.js";
interface GlobalSettings { interface GlobalSettings {
notionToken: string; notionToken: string;
@@ -102,6 +115,9 @@ interface GlobalSettings {
interface TimerSettings { interface TimerSettings {
projectId: string; projectId: string;
projectName: string; projectName: string;
tag?: string;
tagEnabled?: boolean;
askDescription?: boolean;
} }
const HARDCODED = { const HARDCODED = {
@@ -109,6 +125,8 @@ const HARDCODED = {
projectsDbId: "cd65c760-0f0a-4d6e-a262-d572c9e31585", projectsDbId: "cd65c760-0f0a-4d6e-a262-d572c9e31585",
}; };
const DEFAULT_TAG = "Client Books";
async function getGlobal(): Promise<GlobalSettings> { async function getGlobal(): Promise<GlobalSettings> {
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>(); const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>();
return { ...stored, ...HARDCODED }; return { ...stored, ...HARDCODED };
@@ -142,14 +160,18 @@ async function sendProjectsToPI(tokenOverride?: string): Promise<void> {
await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION }); await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION });
return; return;
} }
const [projects, usersResult] = await Promise.all([ const [projects, usersResult, tagsResult] = await Promise.all([
fetchProjects(token, global.projectsDbId), fetchProjects(token, global.projectsDbId),
fetchUsers(token).catch((err) => { fetchUsers(token).catch((err) => {
streamDeck.logger.error("Failed to fetch users:", err); streamDeck.logger.error("Failed to fetch users:", err);
return []; return [];
}), }),
fetchTagOptions(token, global.timingDbId, "Timer Tags").catch((err) => {
streamDeck.logger.error("Failed to fetch tag options:", err);
return [];
}),
]); ]);
await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, version: CURRENT_VERSION }); await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, tags: tagsResult, version: CURRENT_VERSION });
} catch (err) { } catch (err) {
streamDeck.logger.error("Failed to fetch projects:", err); streamDeck.logger.error("Failed to fetch projects:", err);
await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err), version: CURRENT_VERSION }); await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err), version: CURRENT_VERSION });
@@ -160,17 +182,92 @@ function isConfigured(g: GlobalSettings): boolean {
return !!(g.notionToken && g.userId); return !!(g.notionToken && g.userId);
} }
function buttonTitle(projectName: string): string { function buttonTitle(projectName: string, tag?: string): string {
return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim(); const base = projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
return tag ? `${base} \u00B7 ${tag}` : base;
}
const ELAPSED_CHECK_MS = 60 * 60 * 1000; // 1 hour
const DIALOG_TIMEOUT_SECONDS = 300; // 5 minutes
async function promptContinue(title: string): Promise<"stop" | "continue" | "timeout"> {
const { execFile } = await import("node:child_process");
const safeTitle = title.replace(/[\\"]/g, "");
const script = `display dialog "Timer for ${safeTitle} has been running for over an hour.\n\nShould it continue?" buttons {"Stop", "Continue"} default button "Continue" giving up after ${DIALOG_TIMEOUT_SECONDS}`;
return new Promise((resolve) => {
execFile("osascript", ["-e", script], (err, stdout) => {
if (err || /gave up:true/.test(String(stdout))) { resolve("timeout"); return; }
resolve(/button returned:Stop/.test(String(stdout)) ? "stop" : "continue");
});
});
}
// Cancelling or letting the dialog time out means "don't start the timer" —
// the caller treats ok:false as a no-op, not as "start with no description".
async function promptDescription(title: string): Promise<{ ok: boolean; text: string }> {
const { execFile } = await import("node:child_process");
const safeTitle = title.replace(/[\\"]/g, "");
const script = `display dialog "Description for ${safeTitle} (optional):" default answer "" buttons {"Cancel", "Start"} default button "Start" giving up after ${DIALOG_TIMEOUT_SECONDS}`;
return new Promise((resolve) => {
execFile("osascript", ["-e", script], (err, stdout) => {
const out = String(stdout);
if (err || /gave up:true/.test(out) || !/button returned:Start/.test(out)) {
resolve({ ok: false, text: "" });
return;
}
const match = /text returned:(.*?)(?:, gave up:|$)/.exec(out);
resolve({ ok: true, text: match ? match[1].trim() : "" });
});
});
} }
@action({ UUID: "com.pdma.notion-timer.toggle" }) @action({ UUID: "com.pdma.notion-timer.toggle" })
class TimerToggle extends SingletonAction<TimerSettings> { class TimerToggle extends SingletonAction<TimerSettings> {
private projectCache = new Map<string, TimerSettings>(); private projectCache = new Map<string, TimerSettings>();
private keyDownBusy = false;
private elapsedCheckTimer: NodeJS.Timeout | null = null;
private clearElapsedCheck(): void {
if (this.elapsedCheckTimer) clearTimeout(this.elapsedCheckTimer);
this.elapsedCheckTimer = null;
}
private scheduleElapsedCheck(entryId: string, actionId: string, title: string): void {
this.clearElapsedCheck();
this.elapsedCheckTimer = setTimeout(() => {
this.checkElapsed(entryId, actionId, title);
}, ELAPSED_CHECK_MS);
}
private async checkElapsed(entryId: string, actionId: string, title: string): Promise<void> {
if (memRunningEntryId !== entryId) return;
const answer = await promptContinue(title);
if (memRunningEntryId !== entryId) return;
if (answer === "continue") {
this.scheduleElapsedCheck(entryId, actionId, title);
return;
}
try {
const global = await getGlobal();
await stopTimer(global.notionToken, entryId);
} catch (err) {
streamDeck.logger.error("Auto-stop after elapsed-check failed:", err);
}
setRunningEntry(null, null);
this.clearElapsedCheck();
for (const action of this.actions) {
if (action.id === actionId) {
await Promise.all([action.setState(0), action.setTitle(title)]);
break;
}
}
}
async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> { async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> {
this.projectCache.set(ev.action.id, ev.payload.settings); this.projectCache.set(ev.action.id, ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
await loadRunningState(); await loadRunningState();
const isRunning = memRunningActionId === ev.action.id; const isRunning = memRunningActionId === ev.action.id;
if (isRunning) { if (isRunning) {
@@ -185,10 +282,40 @@ class TimerToggle extends SingletonAction<TimerSettings> {
} }
async onKeyDown(ev: KeyDownEvent<TimerSettings>): Promise<void> { async onKeyDown(ev: KeyDownEvent<TimerSettings>): Promise<void> {
const { projectId, projectName } = ev.payload.settings; // Global lock across ALL buttons of this action, not just this one — otherwise
const title = buttonTitle(projectName || ""); // near-simultaneous presses on two different buttons both read memRunningActionId
// as "nothing running" and both start a timer in Notion.
if (this.keyDownBusy) {
// Blocked press: redraw THIS button to match known truth instead of leaving
// whatever it was already showing (which can be stale from an out-of-band
// Notion edit) — a pure visual correction, no Notion calls. Delayed slightly
// because redrawing immediately can collide with Stream Deck's own native
// key-press flash animation and get silently dropped.
const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
const shouldBeRunning = memRunningActionId === ev.action.id;
await new Promise((resolve) => setTimeout(resolve, 150));
await Promise.all([
ev.action.setState(shouldBeRunning ? 1 : 0),
ev.action.setTitle(shouldBeRunning ? `⏱ ${title}` : title),
]);
return;
}
this.keyDownBusy = true;
try {
const { projectId, projectName, tag, askDescription } = ev.payload.settings;
const title = buttonTitle(projectName || "", tag);
const isRunning = memRunningActionId === ev.action.id; const isRunning = memRunningActionId === ev.action.id;
// Asking for a description blocks on a native dialog, so it has to happen
// before any optimistic UI update — a cancel/timeout must leave everything
// exactly as it was, not flip the button and then silently revert it.
let description = "";
if (!isRunning && projectId && askDescription) {
const result = await promptDescription(title);
if (!result.ok) return;
description = result.text;
}
// Instant visual feedback — no setSettings, no flash // Instant visual feedback — no setSettings, no flash
if (projectId) { if (projectId) {
if (isRunning) { if (isRunning) {
@@ -198,7 +325,7 @@ class TimerToggle extends SingletonAction<TimerSettings> {
if (other.id === ev.action.id) continue; if (other.id === ev.action.id) continue;
if (memRunningActionId === other.id) { if (memRunningActionId === other.id) {
const s = this.projectCache.get(other.id); const s = this.projectCache.get(other.id);
await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || ""))]); await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || "", s?.tag))]);
} }
} }
await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]); await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]);
@@ -211,15 +338,31 @@ class TimerToggle extends SingletonAction<TimerSettings> {
try { try {
if (isRunning) { if (isRunning) {
await stopTimer(global.notionToken, memRunningEntryId!); // Stopping the entry in Notion is best-effort: if the page is already
// gone/archived (e.g. edited outside the plugin) this throws, but the
// local "running" tracking must clear anyway or every future keypress
// retries this same doomed stop call and the deck gets stuck forever.
try {
await stopTimer(global.notionToken, memRunningEntryId!);
} catch (err) {
streamDeck.logger.error("Failed to stop timer in Notion (clearing local state anyway):", err);
}
setRunningEntry(null, null); setRunningEntry(null, null);
this.clearElapsedCheck();
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
} else { } else {
if (memRunningEntryId) { if (memRunningEntryId) {
await stopTimer(global.notionToken, memRunningEntryId); try {
await stopTimer(global.notionToken, memRunningEntryId);
} catch (err) {
streamDeck.logger.error("Failed to stop previous timer in Notion (clearing local state anyway):", err);
}
setRunningEntry(null, null);
this.clearElapsedCheck();
} }
const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId); const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId, tag || DEFAULT_TAG, description);
setRunningEntry(entryId, ev.action.id); setRunningEntry(entryId, ev.action.id);
this.scheduleElapsedCheck(entryId, ev.action.id, title);
await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]); await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]);
} }
} catch (err) { } catch (err) {
@@ -231,16 +374,42 @@ class TimerToggle extends SingletonAction<TimerSettings> {
streamDeck.logger.error("Timer toggle failed:", err); streamDeck.logger.error("Timer toggle failed:", err);
await ev.action.showAlert(); await ev.action.showAlert();
} }
} finally {
this.keyDownBusy = false;
}
}
}
const NEXTCLOUD_BIN = "/Applications/Nextcloud.app/Contents/MacOS/Nextcloud";
@action({ UUID: "com.pdma.notion-timer.launch-nextcloud" })
class LaunchNextcloud extends SingletonAction {
async onKeyDown(ev: KeyDownEvent): Promise<void> {
try {
const { spawn } = await import("node:child_process");
const child = spawn(NEXTCLOUD_BIN, [], { detached: true, stdio: "ignore" });
// "error" (e.g. ENOENT if Nextcloud isn't installed at this path) fires
// asynchronously — without this listener it would crash the plugin process.
child.once("error", (err) => {
streamDeck.logger.error("Failed to launch Nextcloud:", err);
});
child.unref();
await ev.action.showOk();
} catch (err) {
streamDeck.logger.error("Failed to launch Nextcloud:", err);
await ev.action.showAlert();
}
} }
} }
const timerAction = new TimerToggle(); const timerAction = new TimerToggle();
streamDeck.actions.registerAction(timerAction); streamDeck.actions.registerAction(timerAction);
streamDeck.actions.registerAction(new LaunchNextcloud());
streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?: string }>(async (ev) => { streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?: string }>(async (ev) => {
if (ev.payload.event === "saveSettings" && ev.payload.settings) { if (ev.payload.event === "saveSettings" && ev.payload.settings) {
await ev.action.setSettings(ev.payload.settings); await ev.action.setSettings(ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
if (title) await ev.action.setTitle(title); if (title) await ev.action.setTitle(title);
} }
if (ev.payload.event === "refreshProjects") { if (ev.payload.event === "refreshProjects") {
@@ -253,6 +422,15 @@ streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?:
} }
}); });
const UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
streamDeck.connect(); streamDeck.connect();
setTimeout(() => checkForUpdates(), 10_000); setTimeout(() => {
streamDeck.logger.info("Running initial (10s post-launch) update check");
checkForUpdates();
}, 10_000);
setInterval(() => {
streamDeck.logger.info("Running scheduled (30min) update check");
checkForUpdates();
}, UPDATE_CHECK_INTERVAL_MS);
+1 -1
View File
@@ -1 +1 @@
{ "version": "1.0.39" } { "version": "1.0.65" }