@timmy6942025/cli-timer 1.0.1 → 1.1.1
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 +8 -17
- package/package.json +1 -1
- package/settings-ui/main.go +30 -14
- package/settings-ui/main_test.go +9 -7
- 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 +156 -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,8 @@ 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)
|
|
105
|
+
- Completion sound/alarm on completion (default Off)
|
|
115
106
|
- Pause key / pause alt key
|
|
116
107
|
- Restart key
|
|
117
108
|
- Exit key / exit alt key
|
package/package.json
CHANGED
package/settings-ui/main.go
CHANGED
|
@@ -39,13 +39,15 @@ var defaultKeybindings = keybindings{
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
type config struct {
|
|
42
|
-
Font
|
|
43
|
-
CenterDisplay
|
|
44
|
-
ShowHeader
|
|
45
|
-
ShowControls
|
|
46
|
-
TickRateMs
|
|
47
|
-
CompletionMessage
|
|
48
|
-
|
|
42
|
+
Font string `json:"font"`
|
|
43
|
+
CenterDisplay bool `json:"centerDisplay"`
|
|
44
|
+
ShowHeader bool `json:"showHeader"`
|
|
45
|
+
ShowControls bool `json:"showControls"`
|
|
46
|
+
TickRateMs int `json:"tickRateMs"`
|
|
47
|
+
CompletionMessage string `json:"completionMessage"`
|
|
48
|
+
NotifyOnComplete bool `json:"notifyOnComplete"`
|
|
49
|
+
PlaySoundOnComplete bool `json:"playSoundOnComplete"`
|
|
50
|
+
Keybindings keybindings `json:"keybindings"`
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
type statePayload struct {
|
|
@@ -149,6 +151,8 @@ func buildMenuItems(cfg config) []list.Item {
|
|
|
149
151
|
menuEntry{id: "controls", title: "Show controls", description: boolText(cfg.ShowControls)},
|
|
150
152
|
menuEntry{id: "tickRate", title: "Tick rate", description: fmt.Sprintf("%d ms", cfg.TickRateMs)},
|
|
151
153
|
menuEntry{id: "message", title: "Completion message", description: summarizeMessage(cfg.CompletionMessage)},
|
|
154
|
+
menuEntry{id: "notify", title: "System notification", description: boolText(cfg.NotifyOnComplete)},
|
|
155
|
+
menuEntry{id: "sound", title: "Completion sound/alarm", description: boolText(cfg.PlaySoundOnComplete)},
|
|
152
156
|
menuEntry{id: "pauseKey", title: "Pause key", description: keyTokenLabel(cfg.Keybindings.PauseKey)},
|
|
153
157
|
menuEntry{id: "pauseAltKey", title: "Pause alt key", description: keyTokenLabel(cfg.Keybindings.PauseAltKey)},
|
|
154
158
|
menuEntry{id: "restartKey", title: "Restart key", description: keyTokenLabel(cfg.Keybindings.RestartKey)},
|
|
@@ -224,13 +228,15 @@ func normalizeKeybindings(cfg keybindings) keybindings {
|
|
|
224
228
|
|
|
225
229
|
func normalizeConfig(cfg config) config {
|
|
226
230
|
result := config{
|
|
227
|
-
Font:
|
|
228
|
-
CenterDisplay:
|
|
229
|
-
ShowHeader:
|
|
230
|
-
ShowControls:
|
|
231
|
-
TickRateMs:
|
|
232
|
-
CompletionMessage:
|
|
233
|
-
|
|
231
|
+
Font: defaultFont,
|
|
232
|
+
CenterDisplay: true,
|
|
233
|
+
ShowHeader: true,
|
|
234
|
+
ShowControls: true,
|
|
235
|
+
TickRateMs: defaultTickRateMs,
|
|
236
|
+
CompletionMessage: defaultCompletionMessage,
|
|
237
|
+
NotifyOnComplete: true,
|
|
238
|
+
PlaySoundOnComplete: false,
|
|
239
|
+
Keybindings: defaultKeybindings,
|
|
234
240
|
}
|
|
235
241
|
|
|
236
242
|
if strings.TrimSpace(cfg.Font) != "" {
|
|
@@ -245,6 +251,8 @@ func normalizeConfig(cfg config) config {
|
|
|
245
251
|
if cfg.CompletionMessage != "" {
|
|
246
252
|
result.CompletionMessage = normalizeCompletionMessage(cfg.CompletionMessage)
|
|
247
253
|
}
|
|
254
|
+
result.NotifyOnComplete = cfg.NotifyOnComplete
|
|
255
|
+
result.PlaySoundOnComplete = cfg.PlaySoundOnComplete
|
|
248
256
|
result.Keybindings = normalizeKeybindings(cfg.Keybindings)
|
|
249
257
|
return result
|
|
250
258
|
}
|
|
@@ -454,6 +462,14 @@ func (m *model) applyMenuAction() tea.Cmd {
|
|
|
454
462
|
m.messageInput.Focus()
|
|
455
463
|
m.screen = screenMessageEditor
|
|
456
464
|
return nil
|
|
465
|
+
case "notify":
|
|
466
|
+
m.payload.Config.NotifyOnComplete = !m.payload.Config.NotifyOnComplete
|
|
467
|
+
m.refreshMenu()
|
|
468
|
+
return nil
|
|
469
|
+
case "sound":
|
|
470
|
+
m.payload.Config.PlaySoundOnComplete = !m.payload.Config.PlaySoundOnComplete
|
|
471
|
+
m.refreshMenu()
|
|
472
|
+
return nil
|
|
457
473
|
case "pauseKey":
|
|
458
474
|
m.openKeyPicker("pauseKey", "Select Pause Key")
|
|
459
475
|
return nil
|
package/settings-ui/main_test.go
CHANGED
|
@@ -10,13 +10,15 @@ func testPayload() statePayload {
|
|
|
10
10
|
return statePayload{
|
|
11
11
|
ConfigPath: "/tmp/cli-timer-test-config.json",
|
|
12
12
|
Config: config{
|
|
13
|
-
Font:
|
|
14
|
-
CenterDisplay:
|
|
15
|
-
ShowHeader:
|
|
16
|
-
ShowControls:
|
|
17
|
-
TickRateMs:
|
|
18
|
-
CompletionMessage:
|
|
19
|
-
|
|
13
|
+
Font: "Standard",
|
|
14
|
+
CenterDisplay: true,
|
|
15
|
+
ShowHeader: true,
|
|
16
|
+
ShowControls: true,
|
|
17
|
+
TickRateMs: 100,
|
|
18
|
+
CompletionMessage: "Time is up!",
|
|
19
|
+
NotifyOnComplete: false,
|
|
20
|
+
PlaySoundOnComplete: false,
|
|
21
|
+
Keybindings: defaultKeybindings,
|
|
20
22
|
},
|
|
21
23
|
Fonts: []string{"Standard", "Big"},
|
|
22
24
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/index.js
CHANGED
|
@@ -38,6 +38,8 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
38
38
|
showControls: true,
|
|
39
39
|
tickRateMs: 100,
|
|
40
40
|
completionMessage: "Time is up!",
|
|
41
|
+
notifyOnComplete: true,
|
|
42
|
+
playSoundOnComplete: false,
|
|
41
43
|
keybindings: { ...DEFAULT_KEYBINDINGS }
|
|
42
44
|
});
|
|
43
45
|
|
|
@@ -201,6 +203,8 @@ function normalizeConfig(raw) {
|
|
|
201
203
|
showControls: DEFAULT_CONFIG.showControls,
|
|
202
204
|
tickRateMs: DEFAULT_CONFIG.tickRateMs,
|
|
203
205
|
completionMessage: DEFAULT_CONFIG.completionMessage,
|
|
206
|
+
notifyOnComplete: DEFAULT_CONFIG.notifyOnComplete,
|
|
207
|
+
playSoundOnComplete: DEFAULT_CONFIG.playSoundOnComplete,
|
|
204
208
|
keybindings: { ...DEFAULT_KEYBINDINGS }
|
|
205
209
|
};
|
|
206
210
|
|
|
@@ -220,6 +224,12 @@ function normalizeConfig(raw) {
|
|
|
220
224
|
if (typeof raw.completionMessage === "string") {
|
|
221
225
|
next.completionMessage = normalizeCompletionMessage(raw.completionMessage);
|
|
222
226
|
}
|
|
227
|
+
if (typeof raw.notifyOnComplete === "boolean") {
|
|
228
|
+
next.notifyOnComplete = raw.notifyOnComplete;
|
|
229
|
+
}
|
|
230
|
+
if (typeof raw.playSoundOnComplete === "boolean") {
|
|
231
|
+
next.playSoundOnComplete = raw.playSoundOnComplete;
|
|
232
|
+
}
|
|
223
233
|
next.keybindings = normalizeKeybindings(raw.keybindings);
|
|
224
234
|
if (typeof raw.font === "string") {
|
|
225
235
|
const normalizedFont = normalizeFontName(raw.font);
|
|
@@ -453,9 +463,144 @@ function drawFrame({ mode, seconds, paused, config, done }) {
|
|
|
453
463
|
}
|
|
454
464
|
}
|
|
455
465
|
|
|
466
|
+
function escapeAppleScriptString(value) {
|
|
467
|
+
return String(value).replace(/\\/g, "\\\\").replace(/\"/g, "\\\"");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function escapePowerShellSingleQuotedString(value) {
|
|
471
|
+
return String(value).replace(/'/g, "''");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function spawnOk(command, args, options) {
|
|
475
|
+
try {
|
|
476
|
+
const result = spawnSync(command, args, { stdio: "ignore", ...options });
|
|
477
|
+
return !result.error && result.status === 0;
|
|
478
|
+
} catch (_error) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function sendSystemNotification({ title, message }) {
|
|
484
|
+
const safeTitle = String(title || "").trim() || "Timer";
|
|
485
|
+
const safeMessage = String(message || "").trim();
|
|
486
|
+
|
|
487
|
+
if (!safeMessage) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
if (process.platform === "darwin") {
|
|
493
|
+
const script = `display notification "${escapeAppleScriptString(safeMessage)}" with title "${escapeAppleScriptString(safeTitle)}"`;
|
|
494
|
+
if (spawnOk("osascript", ["-e", script])) {
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
if (spawnOk("terminal-notifier", ["-title", safeTitle, "-message", safeMessage])) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (process.platform === "win32") {
|
|
504
|
+
const titlePs = escapePowerShellSingleQuotedString(safeTitle);
|
|
505
|
+
const messagePs = escapePowerShellSingleQuotedString(safeMessage);
|
|
506
|
+
|
|
507
|
+
const ps = [
|
|
508
|
+
"$ErrorActionPreference = 'Stop'",
|
|
509
|
+
"try {",
|
|
510
|
+
" [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null",
|
|
511
|
+
" $template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02",
|
|
512
|
+
" $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)",
|
|
513
|
+
" $txt = $xml.GetElementsByTagName('text')",
|
|
514
|
+
` $txt.Item(0).AppendChild($xml.CreateTextNode('${titlePs}')) > $null`,
|
|
515
|
+
` $txt.Item(1).AppendChild($xml.CreateTextNode('${messagePs}')) > $null`,
|
|
516
|
+
" $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
|
|
517
|
+
" $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cli-timer')",
|
|
518
|
+
" $notifier.Show($toast)",
|
|
519
|
+
"} catch { exit 1 }"
|
|
520
|
+
].join("; ");
|
|
521
|
+
|
|
522
|
+
const powershellArgs = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Sta", "-Command", ps];
|
|
523
|
+
if (spawnOk("powershell", powershellArgs, { windowsHide: true })) {
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const balloon = [
|
|
528
|
+
"$ErrorActionPreference = 'Stop'",
|
|
529
|
+
"try {",
|
|
530
|
+
" Add-Type -AssemblyName System.Windows.Forms",
|
|
531
|
+
" Add-Type -AssemblyName System.Drawing",
|
|
532
|
+
" $notify = New-Object System.Windows.Forms.NotifyIcon",
|
|
533
|
+
" $notify.Icon = [System.Drawing.SystemIcons]::Information",
|
|
534
|
+
` $notify.BalloonTipTitle = '${titlePs}'`,
|
|
535
|
+
` $notify.BalloonTipText = '${messagePs}'`,
|
|
536
|
+
" $notify.Visible = $true",
|
|
537
|
+
" $notify.ShowBalloonTip(5000)",
|
|
538
|
+
" Start-Sleep -Milliseconds 5500",
|
|
539
|
+
" $notify.Dispose()",
|
|
540
|
+
"} catch { exit 1 }"
|
|
541
|
+
].join("; ");
|
|
542
|
+
|
|
543
|
+
const balloonArgs = [
|
|
544
|
+
"-NoProfile",
|
|
545
|
+
"-NonInteractive",
|
|
546
|
+
"-ExecutionPolicy",
|
|
547
|
+
"Bypass",
|
|
548
|
+
"-Sta",
|
|
549
|
+
"-Command",
|
|
550
|
+
balloon
|
|
551
|
+
];
|
|
552
|
+
return spawnOk("powershell", balloonArgs, { windowsHide: true });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (process.platform === "linux") {
|
|
556
|
+
if (spawnOk("termux-notification", ["--title", safeTitle, "--content", safeMessage])) {
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
if (spawnOk("notify-send", [safeTitle, safeMessage])) {
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
if (spawnOk("kdialog", ["--title", safeTitle, "--passivepopup", safeMessage, "5"])) {
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
if (spawnOk("zenity", ["--notification", `--text=${safeTitle}: ${safeMessage}`])) {
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
} catch (_error) {
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function playCompletionAlarm(config) {
|
|
577
|
+
if (!config || !config.playSoundOnComplete) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
process.stderr.write("\x07\x07\x07");
|
|
582
|
+
} catch (_error) {
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function notifyTimerFinished(config, initialSeconds) {
|
|
587
|
+
if (!config) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (config.notifyOnComplete) {
|
|
592
|
+
const message = config.completionMessage || "Time is up!";
|
|
593
|
+
const title = initialSeconds ? `Timer finished (${formatHms(initialSeconds)})` : "Timer finished";
|
|
594
|
+
sendSystemNotification({ title, message });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
playCompletionAlarm(config);
|
|
598
|
+
}
|
|
599
|
+
|
|
456
600
|
function runNonInteractiveTimer(initialSeconds, tickRateMs) {
|
|
457
601
|
const startedAt = Date.now();
|
|
458
602
|
let lastSecond = null;
|
|
603
|
+
let notified = false;
|
|
459
604
|
|
|
460
605
|
function printRemaining() {
|
|
461
606
|
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
|
@@ -476,6 +621,10 @@ function runNonInteractiveTimer(initialSeconds, tickRateMs) {
|
|
|
476
621
|
const remaining = printRemaining();
|
|
477
622
|
if (remaining <= 0) {
|
|
478
623
|
clearInterval(interval);
|
|
624
|
+
if (!notified) {
|
|
625
|
+
notified = true;
|
|
626
|
+
notifyTimerFinished(readConfig(), initialSeconds);
|
|
627
|
+
}
|
|
479
628
|
}
|
|
480
629
|
}, tickRateMs);
|
|
481
630
|
}
|
|
@@ -496,6 +645,7 @@ function runClock({ mode, initialSeconds, config }) {
|
|
|
496
645
|
|
|
497
646
|
let paused = false;
|
|
498
647
|
let done = false;
|
|
648
|
+
let didNotifyCompletion = false;
|
|
499
649
|
const baseSeconds = initialSeconds;
|
|
500
650
|
let anchorMs = Date.now();
|
|
501
651
|
let elapsedWhilePaused = 0;
|
|
@@ -519,9 +669,13 @@ function runClock({ mode, initialSeconds, config }) {
|
|
|
519
669
|
}
|
|
520
670
|
|
|
521
671
|
function refreshDoneState(displaySeconds) {
|
|
522
|
-
if (isTimer && displaySeconds <= 0) {
|
|
672
|
+
if (isTimer && displaySeconds <= 0 && !done) {
|
|
523
673
|
done = true;
|
|
524
674
|
paused = true;
|
|
675
|
+
if (!didNotifyCompletion) {
|
|
676
|
+
didNotifyCompletion = true;
|
|
677
|
+
notifyTimerFinished(config, baseSeconds);
|
|
678
|
+
}
|
|
525
679
|
}
|
|
526
680
|
}
|
|
527
681
|
|
|
@@ -545,6 +699,7 @@ function runClock({ mode, initialSeconds, config }) {
|
|
|
545
699
|
function restart() {
|
|
546
700
|
paused = false;
|
|
547
701
|
done = false;
|
|
702
|
+
didNotifyCompletion = false;
|
|
548
703
|
elapsedWhilePaused = 0;
|
|
549
704
|
anchorMs = Date.now();
|
|
550
705
|
lastDrawState = "";
|