@timmy6942025/cli-timer 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +34 -0
- package/README.md +7 -17
- package/package.json +1 -1
- package/settings-ui/main.go +8 -0
- package/settings-ui/main_test.go +1 -0
- package/settings-ui/prebuilt/darwin-arm64/cli-timer-settings-ui +0 -0
- package/settings-ui/prebuilt/darwin-x64/cli-timer-settings-ui +0 -0
- package/settings-ui/prebuilt/linux-arm64/cli-timer-settings-ui +0 -0
- package/settings-ui/prebuilt/linux-x64/cli-timer-settings-ui +0 -0
- package/settings-ui/prebuilt/windows-arm64/cli-timer-settings-ui.exe +0 -0
- package/settings-ui/prebuilt/windows-x64/cli-timer-settings-ui.exe +0 -0
- package/src/index.js +142 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: npm-publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- "v*"
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
publish:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout
|
|
17
|
+
uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Setup Node.js
|
|
20
|
+
uses: actions/setup-node@v4
|
|
21
|
+
with:
|
|
22
|
+
node-version: 22
|
|
23
|
+
registry-url: https://registry.npmjs.org
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: npm ci
|
|
27
|
+
|
|
28
|
+
- name: Run tests
|
|
29
|
+
run: npm test
|
|
30
|
+
|
|
31
|
+
- name: Publish package
|
|
32
|
+
run: npm publish --access public --provenance
|
|
33
|
+
env:
|
|
34
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
CHANGED
|
@@ -2,28 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
A simple and customizable, easy to setup/use timer and stopwatch that runs in your terminal.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
Install
|
|
7
|
+
Install globally (this is all you need):
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm
|
|
10
|
+
npm i -g @timmy6942025/cli-timer
|
|
11
|
+
# or
|
|
12
|
+
bun add -g @timmy6942025/cli-timer
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
node bin/timer.js 5 min
|
|
17
|
-
node bin/stopwatch.js
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Or install globally from this folder:
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
npm link
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Then use commands:
|
|
15
|
+
Then use commands anywhere:
|
|
27
16
|
|
|
28
17
|
```bash
|
|
29
18
|
timer 5 min
|
|
@@ -112,6 +101,7 @@ This launches a Bubble Tea based screen where you can change:
|
|
|
112
101
|
- Show controls
|
|
113
102
|
- Tick rate (50-1000 ms)
|
|
114
103
|
- Completion message
|
|
104
|
+
- System notification on completion (default On)
|
|
115
105
|
- Pause key / pause alt key
|
|
116
106
|
- Restart key
|
|
117
107
|
- Exit key / exit alt key
|
package/package.json
CHANGED
package/settings-ui/main.go
CHANGED
|
@@ -45,6 +45,7 @@ type config struct {
|
|
|
45
45
|
ShowControls bool `json:"showControls"`
|
|
46
46
|
TickRateMs int `json:"tickRateMs"`
|
|
47
47
|
CompletionMessage string `json:"completionMessage"`
|
|
48
|
+
NotifyOnComplete bool `json:"notifyOnComplete"`
|
|
48
49
|
Keybindings keybindings `json:"keybindings"`
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -149,6 +150,7 @@ func buildMenuItems(cfg config) []list.Item {
|
|
|
149
150
|
menuEntry{id: "controls", title: "Show controls", description: boolText(cfg.ShowControls)},
|
|
150
151
|
menuEntry{id: "tickRate", title: "Tick rate", description: fmt.Sprintf("%d ms", cfg.TickRateMs)},
|
|
151
152
|
menuEntry{id: "message", title: "Completion message", description: summarizeMessage(cfg.CompletionMessage)},
|
|
153
|
+
menuEntry{id: "notify", title: "System notification", description: boolText(cfg.NotifyOnComplete)},
|
|
152
154
|
menuEntry{id: "pauseKey", title: "Pause key", description: keyTokenLabel(cfg.Keybindings.PauseKey)},
|
|
153
155
|
menuEntry{id: "pauseAltKey", title: "Pause alt key", description: keyTokenLabel(cfg.Keybindings.PauseAltKey)},
|
|
154
156
|
menuEntry{id: "restartKey", title: "Restart key", description: keyTokenLabel(cfg.Keybindings.RestartKey)},
|
|
@@ -230,6 +232,7 @@ func normalizeConfig(cfg config) config {
|
|
|
230
232
|
ShowControls: true,
|
|
231
233
|
TickRateMs: defaultTickRateMs,
|
|
232
234
|
CompletionMessage: defaultCompletionMessage,
|
|
235
|
+
NotifyOnComplete: true,
|
|
233
236
|
Keybindings: defaultKeybindings,
|
|
234
237
|
}
|
|
235
238
|
|
|
@@ -245,6 +248,7 @@ func normalizeConfig(cfg config) config {
|
|
|
245
248
|
if cfg.CompletionMessage != "" {
|
|
246
249
|
result.CompletionMessage = normalizeCompletionMessage(cfg.CompletionMessage)
|
|
247
250
|
}
|
|
251
|
+
result.NotifyOnComplete = cfg.NotifyOnComplete
|
|
248
252
|
result.Keybindings = normalizeKeybindings(cfg.Keybindings)
|
|
249
253
|
return result
|
|
250
254
|
}
|
|
@@ -454,6 +458,10 @@ func (m *model) applyMenuAction() tea.Cmd {
|
|
|
454
458
|
m.messageInput.Focus()
|
|
455
459
|
m.screen = screenMessageEditor
|
|
456
460
|
return nil
|
|
461
|
+
case "notify":
|
|
462
|
+
m.payload.Config.NotifyOnComplete = !m.payload.Config.NotifyOnComplete
|
|
463
|
+
m.refreshMenu()
|
|
464
|
+
return nil
|
|
457
465
|
case "pauseKey":
|
|
458
466
|
m.openKeyPicker("pauseKey", "Select Pause Key")
|
|
459
467
|
return nil
|
package/settings-ui/main_test.go
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/index.js
CHANGED
|
@@ -38,6 +38,7 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
38
38
|
showControls: true,
|
|
39
39
|
tickRateMs: 100,
|
|
40
40
|
completionMessage: "Time is up!",
|
|
41
|
+
notifyOnComplete: true,
|
|
41
42
|
keybindings: { ...DEFAULT_KEYBINDINGS }
|
|
42
43
|
});
|
|
43
44
|
|
|
@@ -201,6 +202,7 @@ function normalizeConfig(raw) {
|
|
|
201
202
|
showControls: DEFAULT_CONFIG.showControls,
|
|
202
203
|
tickRateMs: DEFAULT_CONFIG.tickRateMs,
|
|
203
204
|
completionMessage: DEFAULT_CONFIG.completionMessage,
|
|
205
|
+
notifyOnComplete: DEFAULT_CONFIG.notifyOnComplete,
|
|
204
206
|
keybindings: { ...DEFAULT_KEYBINDINGS }
|
|
205
207
|
};
|
|
206
208
|
|
|
@@ -220,6 +222,9 @@ function normalizeConfig(raw) {
|
|
|
220
222
|
if (typeof raw.completionMessage === "string") {
|
|
221
223
|
next.completionMessage = normalizeCompletionMessage(raw.completionMessage);
|
|
222
224
|
}
|
|
225
|
+
if (typeof raw.notifyOnComplete === "boolean") {
|
|
226
|
+
next.notifyOnComplete = raw.notifyOnComplete;
|
|
227
|
+
}
|
|
223
228
|
next.keybindings = normalizeKeybindings(raw.keybindings);
|
|
224
229
|
if (typeof raw.font === "string") {
|
|
225
230
|
const normalizedFont = normalizeFontName(raw.font);
|
|
@@ -453,9 +458,135 @@ function drawFrame({ mode, seconds, paused, config, done }) {
|
|
|
453
458
|
}
|
|
454
459
|
}
|
|
455
460
|
|
|
461
|
+
function escapeAppleScriptString(value) {
|
|
462
|
+
return String(value).replace(/\\/g, "\\\\").replace(/\"/g, "\\\"");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function escapePowerShellSingleQuotedString(value) {
|
|
466
|
+
return String(value).replace(/'/g, "''");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function spawnOk(command, args, options) {
|
|
470
|
+
try {
|
|
471
|
+
const result = spawnSync(command, args, { stdio: "ignore", ...options });
|
|
472
|
+
return !result.error && result.status === 0;
|
|
473
|
+
} catch (_error) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function sendSystemNotification({ title, message }) {
|
|
479
|
+
const safeTitle = String(title || "").trim() || "Timer";
|
|
480
|
+
const safeMessage = String(message || "").trim();
|
|
481
|
+
|
|
482
|
+
if (!safeMessage) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
if (process.platform === "darwin") {
|
|
488
|
+
const script = `display notification "${escapeAppleScriptString(safeMessage)}" with title "${escapeAppleScriptString(safeTitle)}"`;
|
|
489
|
+
if (spawnOk("osascript", ["-e", script])) {
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
if (spawnOk("terminal-notifier", ["-title", safeTitle, "-message", safeMessage])) {
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (process.platform === "win32") {
|
|
499
|
+
const titlePs = escapePowerShellSingleQuotedString(safeTitle);
|
|
500
|
+
const messagePs = escapePowerShellSingleQuotedString(safeMessage);
|
|
501
|
+
|
|
502
|
+
const ps = [
|
|
503
|
+
"$ErrorActionPreference = 'Stop'",
|
|
504
|
+
"try {",
|
|
505
|
+
" [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null",
|
|
506
|
+
" $template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02",
|
|
507
|
+
" $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)",
|
|
508
|
+
" $txt = $xml.GetElementsByTagName('text')",
|
|
509
|
+
` $txt.Item(0).AppendChild($xml.CreateTextNode('${titlePs}')) > $null`,
|
|
510
|
+
` $txt.Item(1).AppendChild($xml.CreateTextNode('${messagePs}')) > $null`,
|
|
511
|
+
" $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
|
|
512
|
+
" $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cli-timer')",
|
|
513
|
+
" $notifier.Show($toast)",
|
|
514
|
+
"} catch { exit 1 }"
|
|
515
|
+
].join("; ");
|
|
516
|
+
|
|
517
|
+
const powershellArgs = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Sta", "-Command", ps];
|
|
518
|
+
if (spawnOk("powershell", powershellArgs, { windowsHide: true })) {
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const balloon = [
|
|
523
|
+
"$ErrorActionPreference = 'Stop'",
|
|
524
|
+
"try {",
|
|
525
|
+
" Add-Type -AssemblyName System.Windows.Forms",
|
|
526
|
+
" Add-Type -AssemblyName System.Drawing",
|
|
527
|
+
" $notify = New-Object System.Windows.Forms.NotifyIcon",
|
|
528
|
+
" $notify.Icon = [System.Drawing.SystemIcons]::Information",
|
|
529
|
+
` $notify.BalloonTipTitle = '${titlePs}'`,
|
|
530
|
+
` $notify.BalloonTipText = '${messagePs}'`,
|
|
531
|
+
" $notify.Visible = $true",
|
|
532
|
+
" $notify.ShowBalloonTip(5000)",
|
|
533
|
+
" Start-Sleep -Milliseconds 5500",
|
|
534
|
+
" $notify.Dispose()",
|
|
535
|
+
"} catch { exit 1 }"
|
|
536
|
+
].join("; ");
|
|
537
|
+
|
|
538
|
+
const balloonArgs = [
|
|
539
|
+
"-NoProfile",
|
|
540
|
+
"-NonInteractive",
|
|
541
|
+
"-ExecutionPolicy",
|
|
542
|
+
"Bypass",
|
|
543
|
+
"-Sta",
|
|
544
|
+
"-Command",
|
|
545
|
+
balloon
|
|
546
|
+
];
|
|
547
|
+
return spawnOk("powershell", balloonArgs, { windowsHide: true });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (process.platform === "linux") {
|
|
551
|
+
if (spawnOk("termux-notification", ["--title", safeTitle, "--content", safeMessage])) {
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
if (spawnOk("notify-send", [safeTitle, safeMessage])) {
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
if (spawnOk("kdialog", ["--title", safeTitle, "--passivepopup", safeMessage, "5"])) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
if (spawnOk("zenity", ["--notification", `--text=${safeTitle}: ${safeMessage}`])) {
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
} catch (_error) {
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function notifyTimerFinished(config, initialSeconds) {
|
|
572
|
+
if (!config || !config.notifyOnComplete) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const message = config.completionMessage || "Time is up!";
|
|
576
|
+
const title = initialSeconds ? `Timer finished (${formatHms(initialSeconds)})` : "Timer finished";
|
|
577
|
+
const notified = sendSystemNotification({ title, message });
|
|
578
|
+
if (!notified) {
|
|
579
|
+
try {
|
|
580
|
+
process.stderr.write("\x07");
|
|
581
|
+
} catch (_error) {
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
456
586
|
function runNonInteractiveTimer(initialSeconds, tickRateMs) {
|
|
457
587
|
const startedAt = Date.now();
|
|
458
588
|
let lastSecond = null;
|
|
589
|
+
let notified = false;
|
|
459
590
|
|
|
460
591
|
function printRemaining() {
|
|
461
592
|
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
@@ -476,6 +607,10 @@ function runNonInteractiveTimer(initialSeconds, tickRateMs) {
|
|
|
476
607
|
const remaining = printRemaining();
|
|
477
608
|
if (remaining <= 0) {
|
|
478
609
|
clearInterval(interval);
|
|
610
|
+
if (!notified) {
|
|
611
|
+
notified = true;
|
|
612
|
+
notifyTimerFinished(readConfig(), initialSeconds);
|
|
613
|
+
}
|
|
479
614
|
}
|
|
480
615
|
}, tickRateMs);
|
|
481
616
|
}
|
|
@@ -496,6 +631,7 @@ function runClock({ mode, initialSeconds, config }) {
|
|
|
496
631
|
|
|
497
632
|
let paused = false;
|
|
498
633
|
let done = false;
|
|
634
|
+
let didNotifyCompletion = false;
|
|
499
635
|
const baseSeconds = initialSeconds;
|
|
500
636
|
let anchorMs = Date.now();
|
|
501
637
|
let elapsedWhilePaused = 0;
|
|
@@ -519,9 +655,13 @@ function runClock({ mode, initialSeconds, config }) {
|
|
|
519
655
|
}
|
|
520
656
|
|
|
521
657
|
function refreshDoneState(displaySeconds) {
|
|
522
|
-
if (isTimer && displaySeconds <= 0) {
|
|
658
|
+
if (isTimer && displaySeconds <= 0 && !done) {
|
|
523
659
|
done = true;
|
|
524
660
|
paused = true;
|
|
661
|
+
if (!didNotifyCompletion) {
|
|
662
|
+
didNotifyCompletion = true;
|
|
663
|
+
notifyTimerFinished(config, baseSeconds);
|
|
664
|
+
}
|
|
525
665
|
}
|
|
526
666
|
}
|
|
527
667
|
|
|
@@ -545,6 +685,7 @@ function runClock({ mode, initialSeconds, config }) {
|
|
|
545
685
|
function restart() {
|
|
546
686
|
paused = false;
|
|
547
687
|
done = false;
|
|
688
|
+
didNotifyCompletion = false;
|
|
548
689
|
elapsedWhilePaused = 0;
|
|
549
690
|
anchorMs = Date.now();
|
|
550
691
|
lastDrawState = "";
|