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.
This commit is contained in:
@@ -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}`);
|
||||||
@@ -6555,14 +6567,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 });
|
||||||
@@ -6571,8 +6587,9 @@ 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 ELAPSED_CHECK_MS = 60 * 60 * 1e3;
|
||||||
var DIALOG_TIMEOUT_SECONDS = 300;
|
var DIALOG_TIMEOUT_SECONDS = 300;
|
||||||
@@ -6592,6 +6609,22 @@ Should it continue?" buttons {"Stop", "Continue"} default button "Continue" givi
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
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;
|
keyDownBusy = false;
|
||||||
@@ -6631,7 +6664,7 @@ var TimerToggle = class extends SingletonAction {
|
|||||||
}
|
}
|
||||||
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) {
|
||||||
@@ -6645,7 +6678,7 @@ var TimerToggle = class extends SingletonAction {
|
|||||||
}
|
}
|
||||||
async onKeyDown(ev) {
|
async onKeyDown(ev) {
|
||||||
if (this.keyDownBusy) {
|
if (this.keyDownBusy) {
|
||||||
const title = buttonTitle(ev.payload.settings.projectName || "");
|
const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
|
||||||
const shouldBeRunning = memRunningActionId === ev.action.id;
|
const shouldBeRunning = memRunningActionId === ev.action.id;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -6656,9 +6689,15 @@ var TimerToggle = class extends SingletonAction {
|
|||||||
}
|
}
|
||||||
this.keyDownBusy = true;
|
this.keyDownBusy = true;
|
||||||
try {
|
try {
|
||||||
const { projectId, projectName } = ev.payload.settings;
|
const { projectId, projectName, tag, askDescription } = ev.payload.settings;
|
||||||
const title = buttonTitle(projectName || "");
|
const title = buttonTitle(projectName || "", tag);
|
||||||
const isRunning = memRunningActionId === ev.action.id;
|
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 (projectId) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
|
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
|
||||||
@@ -6667,7 +6706,7 @@ var TimerToggle = class extends SingletonAction {
|
|||||||
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(`\u23F1 ${title}`)]);
|
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
|
||||||
@@ -6702,7 +6741,7 @@ var TimerToggle = class extends SingletonAction {
|
|||||||
setRunningEntry(null, null);
|
setRunningEntry(null, null);
|
||||||
this.clearElapsedCheck();
|
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, description);
|
||||||
setRunningEntry(entryId, ev.action.id);
|
setRunningEntry(entryId, ev.action.id);
|
||||||
this.scheduleElapsedCheck(entryId, ev.action.id, title);
|
this.scheduleElapsedCheck(entryId, ev.action.id, title);
|
||||||
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
|
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
|
||||||
@@ -6749,7 +6788,7 @@ 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") {
|
||||||
|
|||||||
Binary file not shown.
@@ -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 {
|
||||||
|
|||||||
+28
-8
@@ -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 }] },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+49
-12
@@ -101,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;
|
||||||
@@ -115,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 = {
|
||||||
@@ -155,14 +158,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 });
|
||||||
@@ -173,8 +180,9 @@ 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 ELAPSED_CHECK_MS = 60 * 60 * 1000; // 1 hour
|
||||||
@@ -192,6 +200,25 @@ async function promptContinue(title: string): Promise<"stop" | "continue" | "tim
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>();
|
||||||
@@ -238,7 +265,7 @@ class TimerToggle extends SingletonAction<TimerSettings> {
|
|||||||
|
|
||||||
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) {
|
||||||
@@ -262,7 +289,7 @@ class TimerToggle extends SingletonAction<TimerSettings> {
|
|||||||
// Notion edit) — a pure visual correction, no Notion calls. Delayed slightly
|
// Notion edit) — a pure visual correction, no Notion calls. Delayed slightly
|
||||||
// because redrawing immediately can collide with Stream Deck's own native
|
// because redrawing immediately can collide with Stream Deck's own native
|
||||||
// key-press flash animation and get silently dropped.
|
// key-press flash animation and get silently dropped.
|
||||||
const title = buttonTitle(ev.payload.settings.projectName || "");
|
const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag);
|
||||||
const shouldBeRunning = memRunningActionId === ev.action.id;
|
const shouldBeRunning = memRunningActionId === ev.action.id;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -273,10 +300,20 @@ class TimerToggle extends SingletonAction<TimerSettings> {
|
|||||||
}
|
}
|
||||||
this.keyDownBusy = true;
|
this.keyDownBusy = true;
|
||||||
try {
|
try {
|
||||||
const { projectId, projectName } = ev.payload.settings;
|
const { projectId, projectName, tag, askDescription } = ev.payload.settings;
|
||||||
const title = buttonTitle(projectName || "");
|
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) {
|
||||||
@@ -286,7 +323,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}`)]);
|
||||||
@@ -321,7 +358,7 @@ class TimerToggle extends SingletonAction<TimerSettings> {
|
|||||||
setRunningEntry(null, null);
|
setRunningEntry(null, null);
|
||||||
this.clearElapsedCheck();
|
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, description);
|
||||||
setRunningEntry(entryId, ev.action.id);
|
setRunningEntry(entryId, ev.action.id);
|
||||||
this.scheduleElapsedCheck(entryId, ev.action.id, title);
|
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}`)]);
|
||||||
@@ -370,7 +407,7 @@ 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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user