pomotty 1.1.0 → 1.2.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/README.ja.md +51 -1
- package/README.md +48 -0
- package/dist/cli.js +1621 -0
- package/package.json +13 -8
- package/dist/bin/pomotty.js +0 -483
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pomotty",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "An interactive terminal Pomodoro timer with configurable work and break sessions and sound notifications.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -19,22 +19,25 @@
|
|
|
19
19
|
"url": "git+https://github.com/iririn1998/pomotty.git"
|
|
20
20
|
},
|
|
21
21
|
"bin": {
|
|
22
|
-
"pomotty": "dist/
|
|
22
|
+
"pomotty": "dist/cli.js"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
|
-
"dist/
|
|
25
|
+
"dist/cli.js",
|
|
26
26
|
"assets/*.wav"
|
|
27
27
|
],
|
|
28
28
|
"type": "module",
|
|
29
|
+
"imports": {
|
|
30
|
+
"#src/*": "./src/*"
|
|
31
|
+
},
|
|
29
32
|
"publishConfig": {
|
|
30
33
|
"access": "public",
|
|
31
34
|
"registry": "https://registry.npmjs.org/"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
34
|
-
"@types/node": "
|
|
37
|
+
"@types/node": "22.20.1",
|
|
35
38
|
"oxfmt": "^0.61.0",
|
|
36
39
|
"oxlint": "^1.76.0",
|
|
37
|
-
"
|
|
40
|
+
"tsdown": "^0.23.0",
|
|
38
41
|
"typescript": "^7.0.2",
|
|
39
42
|
"vitest": "^4.1.10"
|
|
40
43
|
},
|
|
@@ -48,12 +51,14 @@
|
|
|
48
51
|
"node": "^22.18.0 || >=24.11.0"
|
|
49
52
|
},
|
|
50
53
|
"scripts": {
|
|
51
|
-
"build": "
|
|
52
|
-
"
|
|
54
|
+
"build": "tsdown",
|
|
55
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
56
|
+
"check": "pnpm typecheck && pnpm build && pnpm test",
|
|
57
|
+
"start": "pnpm build && node dist/cli.js",
|
|
53
58
|
"lint": "oxlint . --deny-warnings --report-unused-disable-directives-severity=error",
|
|
54
59
|
"lint:fix": "oxlint . --fix --deny-warnings --report-unused-disable-directives-severity=error",
|
|
55
60
|
"format": "oxfmt --write",
|
|
56
61
|
"format:check": "oxfmt --check",
|
|
57
|
-
"test": "vitest run"
|
|
62
|
+
"test": "vitest run --dir src"
|
|
58
63
|
}
|
|
59
64
|
}
|
package/dist/bin/pomotty.js
DELETED
|
@@ -1,483 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import process$1 from "node:process";
|
|
3
|
-
import { setTimeout } from "node:timers/promises";
|
|
4
|
-
import { on } from "node:events";
|
|
5
|
-
import { emitKeypressEvents } from "node:readline";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
const MILLISECONDS_PER_MINUTE = 6e4;
|
|
9
|
-
const DEFAULT_BREAK_DURATION_MS = 5 * MILLISECONDS_PER_MINUTE;
|
|
10
|
-
const DEFAULT_WORK_DURATION_MS = 15 * MILLISECONDS_PER_MINUTE;
|
|
11
|
-
const waitFor = (durationMs) => setTimeout(durationMs);
|
|
12
|
-
const runPomodoroCycle = async ({ workDurationMs = DEFAULT_WORK_DURATION_MS, breakDurationMs = DEFAULT_BREAK_DURATION_MS, wait = waitFor, onPhaseStarted, onPhaseCompleted } = {}) => {
|
|
13
|
-
if (onPhaseStarted) onPhaseStarted("work", workDurationMs);
|
|
14
|
-
await wait(workDurationMs);
|
|
15
|
-
if (onPhaseCompleted) onPhaseCompleted("work");
|
|
16
|
-
if (onPhaseStarted) onPhaseStarted("break", breakDurationMs);
|
|
17
|
-
await wait(breakDurationMs);
|
|
18
|
-
if (onPhaseCompleted) onPhaseCompleted("break");
|
|
19
|
-
};
|
|
20
|
-
const OPTIONS = [
|
|
21
|
-
{
|
|
22
|
-
description: `Work duration in minutes (1-1440, default: 15)`,
|
|
23
|
-
name: "--work",
|
|
24
|
-
valueName: "<minutes>"
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
description: `Break duration in minutes (1-1440, default: 5)`,
|
|
28
|
-
name: "--break",
|
|
29
|
-
valueName: "<minutes>"
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
description: `Work-break repetitions (positive integer, default: 3)`,
|
|
33
|
-
name: "--loop",
|
|
34
|
-
valueName: "<count>"
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
alias: "-h",
|
|
38
|
-
description: "Print help",
|
|
39
|
-
name: "--help"
|
|
40
|
-
}
|
|
41
|
-
];
|
|
42
|
-
//#endregion
|
|
43
|
-
//#region src/terminal/input.ts
|
|
44
|
-
const CHOICES = ["OK", "NG"];
|
|
45
|
-
const CONTROL_C_KEY_NAME = "c";
|
|
46
|
-
const ENTER_KEY_NAMES = /* @__PURE__ */ new Set(["enter", "return"]);
|
|
47
|
-
const FIRST_CHOICE_INDEX = 0;
|
|
48
|
-
const KEYPRESS_EVENT_KEY_INDEX = 1;
|
|
49
|
-
const MOVE_KEY_NAMES = /* @__PURE__ */ new Set(["down", "up"]);
|
|
50
|
-
const NEXT_INDEX_OFFSET = 1;
|
|
51
|
-
const REDRAW_SEQUENCE = `\r\u001B[${CHOICES.length}A\u001B[J`;
|
|
52
|
-
const SELECTION_QUESTION = "Start working? (Use ↑/↓ to select, Enter to confirm)";
|
|
53
|
-
const keyFrom = (value) => {
|
|
54
|
-
let key = {}, normalizedName = "";
|
|
55
|
-
if (typeof value === "object" && value !== null) key = value;
|
|
56
|
-
const ctrl = Reflect.get(key, "ctrl"), name = Reflect.get(key, "name");
|
|
57
|
-
if (typeof name === "string") normalizedName = name;
|
|
58
|
-
return {
|
|
59
|
-
ctrl: ctrl === true,
|
|
60
|
-
name: normalizedName
|
|
61
|
-
};
|
|
62
|
-
};
|
|
63
|
-
const createSelectionTerminal = () => {
|
|
64
|
-
emitKeypressEvents(process$1.stdin);
|
|
65
|
-
const inputWasFlowing = process$1.stdin.readableFlowing === true, keypressEvents = on(process$1.stdin, "keypress"), rawModeWasEnabled = process$1.stdin.isRaw === true, supportsRawMode = process$1.stdin.isTTY === true;
|
|
66
|
-
let restored = false;
|
|
67
|
-
if (supportsRawMode && !rawModeWasEnabled) process$1.stdin.setRawMode(true);
|
|
68
|
-
if (!inputWasFlowing) process$1.stdin.resume();
|
|
69
|
-
return {
|
|
70
|
-
nextKey: async () => {
|
|
71
|
-
const event = await keypressEvents.next();
|
|
72
|
-
if (event.done) return {
|
|
73
|
-
ctrl: true,
|
|
74
|
-
name: CONTROL_C_KEY_NAME
|
|
75
|
-
};
|
|
76
|
-
return keyFrom(event.value.at(KEYPRESS_EVENT_KEY_INDEX));
|
|
77
|
-
},
|
|
78
|
-
restore: async () => {
|
|
79
|
-
if (restored) return;
|
|
80
|
-
restored = true;
|
|
81
|
-
const stopListening = keypressEvents.return;
|
|
82
|
-
if (stopListening) await stopListening.call(keypressEvents);
|
|
83
|
-
if (supportsRawMode && !rawModeWasEnabled) process$1.stdin.setRawMode(false);
|
|
84
|
-
if (!inputWasFlowing) process$1.stdin.pause();
|
|
85
|
-
},
|
|
86
|
-
write: (output) => {
|
|
87
|
-
process$1.stdout.write(output);
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
};
|
|
91
|
-
const markerFor = (selected) => {
|
|
92
|
-
if (selected) return "❯";
|
|
93
|
-
return " ";
|
|
94
|
-
};
|
|
95
|
-
const renderSelection = (selectedIndex) => [SELECTION_QUESTION, ...CHOICES.map((choice, index) => `${markerFor(index === selectedIndex)} ${choice}`)].join("\n");
|
|
96
|
-
const nextChoiceIndex = (selectedIndex) => {
|
|
97
|
-
if (selectedIndex === FIRST_CHOICE_INDEX) return CHOICES.length - NEXT_INDEX_OFFSET;
|
|
98
|
-
return FIRST_CHOICE_INDEX;
|
|
99
|
-
};
|
|
100
|
-
const handleKey = (key, selectedIndex, terminal) => {
|
|
101
|
-
if (key.ctrl && key.name === CONTROL_C_KEY_NAME) {
|
|
102
|
-
terminal.write("\n");
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
if (MOVE_KEY_NAMES.has(key.name)) {
|
|
106
|
-
const newSelectedIndex = nextChoiceIndex(selectedIndex);
|
|
107
|
-
terminal.write(`${REDRAW_SEQUENCE}${renderSelection(newSelectedIndex)}`);
|
|
108
|
-
return newSelectedIndex;
|
|
109
|
-
}
|
|
110
|
-
if (ENTER_KEY_NAMES.has(key.name)) {
|
|
111
|
-
terminal.write("\n");
|
|
112
|
-
return CHOICES[selectedIndex] === "OK";
|
|
113
|
-
}
|
|
114
|
-
return selectedIndex;
|
|
115
|
-
};
|
|
116
|
-
const confirmWorkStart = async ({ createTerminal = createSelectionTerminal } = {}) => {
|
|
117
|
-
const terminal = createTerminal();
|
|
118
|
-
let selectedIndex = FIRST_CHOICE_INDEX;
|
|
119
|
-
terminal.write(renderSelection(selectedIndex));
|
|
120
|
-
try {
|
|
121
|
-
for (;;) {
|
|
122
|
-
const key = await terminal.nextKey(), selection = handleKey(key, selectedIndex, terminal);
|
|
123
|
-
if (typeof selection === "boolean") return selection;
|
|
124
|
-
selectedIndex = selection;
|
|
125
|
-
}
|
|
126
|
-
} finally {
|
|
127
|
-
await terminal.restore();
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
//#endregion
|
|
131
|
-
//#region src/terminal/constants.ts
|
|
132
|
-
/**
|
|
133
|
-
* CLI起動時に表示するアスキーアートロゴです。
|
|
134
|
-
*/
|
|
135
|
-
const LOGO = `
|
|
136
|
-
██████╗ ██████╗ ███╗ ███╗ ██████╗ ████████╗████████╗██╗ ██╗
|
|
137
|
-
██╔══██╗██╔═══██╗████╗ ████║██╔═══██╗╚══██╔══╝╚══██╔══╝╚██╗ ██╔╝
|
|
138
|
-
██████╔╝██║ ██║██╔████╔██║██║ ██║ ██║ ██║ ╚████╔╝
|
|
139
|
-
██╔═══╝ ██║ ██║██║╚██╔╝██║██║ ██║ ██║ ██║ ╚██╔╝
|
|
140
|
-
██║ ╚██████╔╝██║ ╚═╝ ██║╚██████╔╝ ██║ ██║ ██║
|
|
141
|
-
╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
|
|
142
|
-
`;
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region src/terminal/render.ts
|
|
145
|
-
/**
|
|
146
|
-
* CLIへ渡されたオプションに応じた出力を生成します。
|
|
147
|
-
*
|
|
148
|
-
* オプションの名前または短縮形と一致した場合はヘルプを生成し、
|
|
149
|
-
* 一致しない場合は通常起動時のロゴを返します。
|
|
150
|
-
*
|
|
151
|
-
* @param parameters CLI出力の生成に必要な値。
|
|
152
|
-
* @returns 標準出力へ書き込む文字列。
|
|
153
|
-
*/
|
|
154
|
-
const CLI_OPTION_INDEX = 2;
|
|
155
|
-
const createCliOutput = ({ options, optionName = process.argv.at(CLI_OPTION_INDEX) }) => {
|
|
156
|
-
if (!options.find(({ alias, name }) => alias === optionName || name === optionName)) return `${LOGO}\n`;
|
|
157
|
-
return [
|
|
158
|
-
"Pomotty CLI",
|
|
159
|
-
"",
|
|
160
|
-
"Usage: pomotty [OPTIONS]",
|
|
161
|
-
"",
|
|
162
|
-
"Options:",
|
|
163
|
-
...options.flatMap(({ alias, description, name, valueName }) => {
|
|
164
|
-
return [` ${[alias, [name, valueName].filter(Boolean).join(" ")].filter(Boolean).join(", ")}`, ` ${description}`];
|
|
165
|
-
}),
|
|
166
|
-
""
|
|
167
|
-
].join("\n");
|
|
168
|
-
};
|
|
169
|
-
//#endregion
|
|
170
|
-
//#region src/diagnostics/writer.ts
|
|
171
|
-
/**
|
|
172
|
-
* 診断の出力先が閉じていても失敗しない書き込み処理を生成します。
|
|
173
|
-
*
|
|
174
|
-
* 最初の書き込みより前に`error`リスナーを登録し、閉じたパイプなどで
|
|
175
|
-
* 発生する非同期エラーを未処理の例外にしません。同期例外も握りつぶし、
|
|
176
|
-
* 一度でも書き込みに失敗したら以後の診断を出力しません。
|
|
177
|
-
* これにより出力先の状態が終了コードへ影響しなくなります。
|
|
178
|
-
*
|
|
179
|
-
* @param stream 診断の出力先。
|
|
180
|
-
* @returns 出力先の状態にかかわらず例外を投げない書き込み処理。
|
|
181
|
-
*/
|
|
182
|
-
const createDiagnosticWriter = (stream = process$1.stderr) => {
|
|
183
|
-
let unavailable = false;
|
|
184
|
-
stream.on("error", () => {
|
|
185
|
-
unavailable = true;
|
|
186
|
-
});
|
|
187
|
-
return (output) => {
|
|
188
|
-
if (unavailable) return;
|
|
189
|
-
try {
|
|
190
|
-
stream.write(output);
|
|
191
|
-
} catch {
|
|
192
|
-
unavailable = true;
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
};
|
|
196
|
-
//#endregion
|
|
197
|
-
//#region src/diagnostics/escape.ts
|
|
198
|
-
const BACKSLASH = "\\";
|
|
199
|
-
const BIDIRECTIONAL_PATTERN = /[\u061C\u200B\u200E\u200F\u2028-\u202E\u2066-\u206F\uFEFF]/u;
|
|
200
|
-
const CONTROL_HEX_LENGTH = 2;
|
|
201
|
-
const CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
202
|
-
const EMPTY_CODE_POINT = 0;
|
|
203
|
-
const FIRST_INDEX = 0;
|
|
204
|
-
const HEXADECIMAL_RADIX = 16;
|
|
205
|
-
const HEX_PAD_CHARACTER = "0";
|
|
206
|
-
const MAXIMUM_CODE_POINTS = 160;
|
|
207
|
-
const QUOTATION_MARK = "\"";
|
|
208
|
-
const SURROGATE_HEX_LENGTH = 4;
|
|
209
|
-
const SURROGATE_PATTERN = /[\uD800-\uDFFF]/u;
|
|
210
|
-
const TRUNCATION_MARK = "…";
|
|
211
|
-
const UNPADDED_HEX_LENGTH = 0;
|
|
212
|
-
const characterToHexadecimal = (character, length) => (character.codePointAt(FIRST_INDEX) ?? EMPTY_CODE_POINT).toString(HEXADECIMAL_RADIX).toUpperCase().padStart(length, HEX_PAD_CHARACTER);
|
|
213
|
-
const escapeUnsafeCharacter = (character) => {
|
|
214
|
-
if (CONTROL_PATTERN.test(character)) return String.raw`\x${characterToHexadecimal(character, CONTROL_HEX_LENGTH)}`;
|
|
215
|
-
if (BIDIRECTIONAL_PATTERN.test(character)) return String.raw`\u{${characterToHexadecimal(character, UNPADDED_HEX_LENGTH)}}`;
|
|
216
|
-
if (SURROGATE_PATTERN.test(character)) return String.raw`\u${characterToHexadecimal(character, SURROGATE_HEX_LENGTH)}`;
|
|
217
|
-
return character;
|
|
218
|
-
};
|
|
219
|
-
const escapeCharacter = (character) => {
|
|
220
|
-
if (character === BACKSLASH) return String.raw`\\`;
|
|
221
|
-
if (character === QUOTATION_MARK) return String.raw`\"`;
|
|
222
|
-
return escapeUnsafeCharacter(character);
|
|
223
|
-
};
|
|
224
|
-
const escapeDiagnostic = (value) => {
|
|
225
|
-
const codePoints = [...value], escaped = codePoints.slice(FIRST_INDEX, MAXIMUM_CODE_POINTS).map((character) => escapeCharacter(character)).join("");
|
|
226
|
-
if (codePoints.length > MAXIMUM_CODE_POINTS) return `${QUOTATION_MARK}${escaped}${TRUNCATION_MARK}${QUOTATION_MARK}`;
|
|
227
|
-
return `${QUOTATION_MARK}${escaped}${QUOTATION_MARK}`;
|
|
228
|
-
};
|
|
229
|
-
//#endregion
|
|
230
|
-
//#region src/cli/parse-arguments.ts
|
|
231
|
-
const ARGUMENT_INDEX_INCREMENT = 1;
|
|
232
|
-
const ARGUMENTS_START_INDEX = 0;
|
|
233
|
-
const DURATION_PATTERN = /^[1-9][0-9]{0,3}$/u;
|
|
234
|
-
const EQUALS_SIGN = "=";
|
|
235
|
-
const EQUALS_SIGN_NOT_FOUND = -1;
|
|
236
|
-
const HELP_OPTIONS = ["--help", "-h"];
|
|
237
|
-
const MAXIMUM_DURATION_MINUTES = 1440;
|
|
238
|
-
const NEXT_ARGUMENT_OFFSET = 1;
|
|
239
|
-
const LOOP_PATTERN = /^[1-9][0-9]*$/u;
|
|
240
|
-
const isNumericOption = (value) => value === "--break" || value === "--work" || value === "--loop";
|
|
241
|
-
const isHelpOption = (value) => HELP_OPTIONS.some((option) => option === value);
|
|
242
|
-
const createValueError = (option) => {
|
|
243
|
-
let maximum = MAXIMUM_DURATION_MINUTES;
|
|
244
|
-
if (option === "--loop") maximum = Number.MAX_SAFE_INTEGER;
|
|
245
|
-
return {
|
|
246
|
-
kind: "error",
|
|
247
|
-
message: `Error: ${option} requires an integer from 1 to ${maximum}.\n`
|
|
248
|
-
};
|
|
249
|
-
};
|
|
250
|
-
const createDuplicateError = (option) => ({
|
|
251
|
-
kind: "error",
|
|
252
|
-
message: `Error: ${option} may only be specified once.\n`
|
|
253
|
-
});
|
|
254
|
-
const parseNumericValue = (option, value) => {
|
|
255
|
-
let pattern = DURATION_PATTERN, maximum = MAXIMUM_DURATION_MINUTES;
|
|
256
|
-
if (option === "--loop") {
|
|
257
|
-
pattern = LOOP_PATTERN;
|
|
258
|
-
maximum = Number.MAX_SAFE_INTEGER;
|
|
259
|
-
}
|
|
260
|
-
if (typeof value !== "string" || !pattern.test(value)) return;
|
|
261
|
-
const numericValue = Number(value);
|
|
262
|
-
if (!Number.isSafeInteger(numericValue) || numericValue > maximum) return;
|
|
263
|
-
return numericValue;
|
|
264
|
-
};
|
|
265
|
-
const parseNumericArgument = (argument, nextArgument) => {
|
|
266
|
-
const equalsSignIndex = argument.indexOf(EQUALS_SIGN);
|
|
267
|
-
if (equalsSignIndex === EQUALS_SIGN_NOT_FOUND) return {
|
|
268
|
-
consumesNextArgument: true,
|
|
269
|
-
optionName: argument,
|
|
270
|
-
value: nextArgument
|
|
271
|
-
};
|
|
272
|
-
return {
|
|
273
|
-
consumesNextArgument: false,
|
|
274
|
-
optionName: argument.slice(ARGUMENTS_START_INDEX, equalsSignIndex),
|
|
275
|
-
value: argument.slice(equalsSignIndex + NEXT_ARGUMENT_OFFSET)
|
|
276
|
-
};
|
|
277
|
-
};
|
|
278
|
-
const parseCliArguments = (arguments_) => {
|
|
279
|
-
if (arguments_.some((argument) => isHelpOption(argument))) return { kind: "help" };
|
|
280
|
-
const specifiedOptions = /* @__PURE__ */ new Set();
|
|
281
|
-
let breakDurationMinutes = 5, loopCount = 3, workDurationMinutes = 15;
|
|
282
|
-
for (let argumentIndex = ARGUMENTS_START_INDEX; argumentIndex < arguments_.length; argumentIndex += ARGUMENT_INDEX_INCREMENT) {
|
|
283
|
-
const argument = arguments_[argumentIndex] ?? "", { consumesNextArgument, optionName, value } = parseNumericArgument(argument, arguments_[argumentIndex + NEXT_ARGUMENT_OFFSET]);
|
|
284
|
-
if (!isNumericOption(optionName)) return {
|
|
285
|
-
kind: "error",
|
|
286
|
-
message: `Error: Unknown option: ${escapeDiagnostic(argument)}\n`
|
|
287
|
-
};
|
|
288
|
-
if (specifiedOptions.has(optionName)) return createDuplicateError(optionName);
|
|
289
|
-
const numericValue = parseNumericValue(optionName, value);
|
|
290
|
-
if (typeof numericValue !== "number") return createValueError(optionName);
|
|
291
|
-
specifiedOptions.add(optionName);
|
|
292
|
-
if (consumesNextArgument) argumentIndex += NEXT_ARGUMENT_OFFSET;
|
|
293
|
-
if (optionName === "--work") workDurationMinutes = numericValue;
|
|
294
|
-
else if (optionName === "--loop") loopCount = numericValue;
|
|
295
|
-
else breakDurationMinutes = numericValue;
|
|
296
|
-
}
|
|
297
|
-
return {
|
|
298
|
-
breakDurationMinutes,
|
|
299
|
-
kind: "run",
|
|
300
|
-
loopCount,
|
|
301
|
-
workDurationMinutes
|
|
302
|
-
};
|
|
303
|
-
};
|
|
304
|
-
//#endregion
|
|
305
|
-
//#region src/notification/sound.ts
|
|
306
|
-
const FIRST_COMMAND_INDEX = 0;
|
|
307
|
-
const NEXT_COMMAND_OFFSET = 1;
|
|
308
|
-
const SUCCESS_EXIT_CODE$1 = 0;
|
|
309
|
-
const FALLBACK_SOUNDS = {
|
|
310
|
-
break: "\x07\x07",
|
|
311
|
-
work: "\x07"
|
|
312
|
-
};
|
|
313
|
-
const WINDOWS_SOUND_SCRIPT = [
|
|
314
|
-
"$player = New-Object System.Media.SoundPlayer",
|
|
315
|
-
"$player.SoundLocation = $env:POMOTTY_SOUND_FILE",
|
|
316
|
-
"$player.PlaySync()"
|
|
317
|
-
].join("; ");
|
|
318
|
-
const spawnSoundProcess = (command, commandArguments, options) => {
|
|
319
|
-
const child = spawn(command, [...commandArguments], options);
|
|
320
|
-
return {
|
|
321
|
-
onClose: (listener) => {
|
|
322
|
-
child.once("close", listener);
|
|
323
|
-
},
|
|
324
|
-
onError: (listener) => {
|
|
325
|
-
child.once("error", listener);
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
};
|
|
329
|
-
const soundFileFor = (phase) => fileURLToPath(new URL(`../../assets/${phase}-end.wav`, import.meta.url));
|
|
330
|
-
const soundEnvironmentFor = (soundFile) => {
|
|
331
|
-
const environment = structuredClone(process$1.env);
|
|
332
|
-
environment.POMOTTY_SOUND_FILE = soundFile;
|
|
333
|
-
return environment;
|
|
334
|
-
};
|
|
335
|
-
const commandsFor = (platform, soundFile) => {
|
|
336
|
-
switch (platform) {
|
|
337
|
-
case "darwin": return [{
|
|
338
|
-
command: "/usr/bin/afplay",
|
|
339
|
-
commandArguments: [soundFile]
|
|
340
|
-
}];
|
|
341
|
-
case "linux": return [{
|
|
342
|
-
command: "paplay",
|
|
343
|
-
commandArguments: [soundFile]
|
|
344
|
-
}, {
|
|
345
|
-
command: "aplay",
|
|
346
|
-
commandArguments: [soundFile]
|
|
347
|
-
}];
|
|
348
|
-
case "win32": return [{
|
|
349
|
-
command: "powershell.exe",
|
|
350
|
-
commandArguments: [
|
|
351
|
-
"-NoProfile",
|
|
352
|
-
"-NonInteractive",
|
|
353
|
-
"-Command",
|
|
354
|
-
WINDOWS_SOUND_SCRIPT
|
|
355
|
-
],
|
|
356
|
-
env: soundEnvironmentFor(soundFile)
|
|
357
|
-
}];
|
|
358
|
-
default: return [];
|
|
359
|
-
}
|
|
360
|
-
};
|
|
361
|
-
const observeFailure = (child, onFailure) => {
|
|
362
|
-
let settled = false;
|
|
363
|
-
const failOnce = () => {
|
|
364
|
-
if (settled) return;
|
|
365
|
-
settled = true;
|
|
366
|
-
onFailure();
|
|
367
|
-
};
|
|
368
|
-
child.onError(failOnce);
|
|
369
|
-
child.onClose((code) => {
|
|
370
|
-
if (code !== SUCCESS_EXIT_CODE$1) failOnce();
|
|
371
|
-
});
|
|
372
|
-
};
|
|
373
|
-
const runSoundCommands = ({ commands, fallback, index, spawnProcess }) => {
|
|
374
|
-
const soundCommand = commands.at(index), tryNextCommand = () => {
|
|
375
|
-
runSoundCommands({
|
|
376
|
-
commands,
|
|
377
|
-
fallback,
|
|
378
|
-
index: index + NEXT_COMMAND_OFFSET,
|
|
379
|
-
spawnProcess
|
|
380
|
-
});
|
|
381
|
-
};
|
|
382
|
-
if (!soundCommand) {
|
|
383
|
-
fallback();
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
try {
|
|
387
|
-
const child = spawnProcess(soundCommand.command, soundCommand.commandArguments, {
|
|
388
|
-
env: soundCommand.env,
|
|
389
|
-
stdio: "ignore",
|
|
390
|
-
windowsHide: true
|
|
391
|
-
});
|
|
392
|
-
observeFailure(child, tryNextCommand);
|
|
393
|
-
} catch {
|
|
394
|
-
tryNextCommand();
|
|
395
|
-
}
|
|
396
|
-
};
|
|
397
|
-
const playCompletionSound = (phase, { platform = process$1.platform, spawnProcess = spawnSoundProcess, writeFallback = (output) => {
|
|
398
|
-
process$1.stderr.write(output);
|
|
399
|
-
} } = {}) => {
|
|
400
|
-
const commands = commandsFor(platform, soundFileFor(phase)), fallback = () => {
|
|
401
|
-
writeFallback(FALLBACK_SOUNDS[phase]);
|
|
402
|
-
};
|
|
403
|
-
runSoundCommands({
|
|
404
|
-
commands,
|
|
405
|
-
fallback,
|
|
406
|
-
index: FIRST_COMMAND_INDEX,
|
|
407
|
-
spawnProcess
|
|
408
|
-
});
|
|
409
|
-
};
|
|
410
|
-
//#endregion
|
|
411
|
-
//#region src/cli/run.ts
|
|
412
|
-
const CLI_ARGUMENTS_START_INDEX = 2;
|
|
413
|
-
const COUNT_INCREMENT = 1;
|
|
414
|
-
const PHASE_ICONS = {
|
|
415
|
-
break: "☕",
|
|
416
|
-
work: "🍅"
|
|
417
|
-
};
|
|
418
|
-
const PHASE_NAMES = {
|
|
419
|
-
break: "Break",
|
|
420
|
-
work: "Work"
|
|
421
|
-
};
|
|
422
|
-
const SUCCESS_EXIT_CODE = 0;
|
|
423
|
-
const USAGE_ERROR_EXIT_CODE = 2;
|
|
424
|
-
const createPhaseStartedOutput = (phase, durationMs) => {
|
|
425
|
-
const durationMinutes = durationMs / MILLISECONDS_PER_MINUTE;
|
|
426
|
-
return `${PHASE_ICONS[phase]} ${PHASE_NAMES[phase]} started (${durationMinutes} min)\n`;
|
|
427
|
-
};
|
|
428
|
-
const emitDiagnostic = (writeError, message) => {
|
|
429
|
-
try {
|
|
430
|
-
writeError(message);
|
|
431
|
-
} catch {}
|
|
432
|
-
};
|
|
433
|
-
const emitStandardOutput = (output) => {
|
|
434
|
-
process$1.stdout.write(output);
|
|
435
|
-
};
|
|
436
|
-
const runCli = async ({ arguments_ = process$1.argv.slice(CLI_ARGUMENTS_START_INDEX), confirmStart = confirmWorkStart, playSound = playCompletionSound, wait, writeError = createDiagnosticWriter(), writeOutput = emitStandardOutput } = {}) => {
|
|
437
|
-
const parsedArguments = parseCliArguments(arguments_);
|
|
438
|
-
if (parsedArguments.kind === "error") {
|
|
439
|
-
emitDiagnostic(writeError, parsedArguments.message);
|
|
440
|
-
return USAGE_ERROR_EXIT_CODE;
|
|
441
|
-
}
|
|
442
|
-
if (parsedArguments.kind === "help") {
|
|
443
|
-
writeOutput(createCliOutput({
|
|
444
|
-
optionName: "--help",
|
|
445
|
-
options: OPTIONS
|
|
446
|
-
}));
|
|
447
|
-
return SUCCESS_EXIT_CODE;
|
|
448
|
-
}
|
|
449
|
-
writeOutput(createCliOutput({
|
|
450
|
-
optionName: "",
|
|
451
|
-
options: OPTIONS
|
|
452
|
-
}));
|
|
453
|
-
if (!await confirmStart()) {
|
|
454
|
-
writeOutput("⏹️ Work was not started.\n");
|
|
455
|
-
return SUCCESS_EXIT_CODE;
|
|
456
|
-
}
|
|
457
|
-
for (let cycleIndex = 0; cycleIndex < parsedArguments.loopCount; cycleIndex += COUNT_INCREMENT) await runPomodoroCycle({
|
|
458
|
-
breakDurationMs: parsedArguments.breakDurationMinutes * MILLISECONDS_PER_MINUTE,
|
|
459
|
-
onPhaseCompleted: (phase) => {
|
|
460
|
-
writeOutput(`✅ ${PHASE_NAMES[phase]} complete.\n`);
|
|
461
|
-
playSound(phase);
|
|
462
|
-
},
|
|
463
|
-
onPhaseStarted: (phase, durationMs) => writeOutput(createPhaseStartedOutput(phase, durationMs)),
|
|
464
|
-
wait,
|
|
465
|
-
workDurationMs: parsedArguments.workDurationMinutes * MILLISECONDS_PER_MINUTE
|
|
466
|
-
});
|
|
467
|
-
writeOutput("🎉 Pomodoro complete.\n");
|
|
468
|
-
return SUCCESS_EXIT_CODE;
|
|
469
|
-
};
|
|
470
|
-
//#endregion
|
|
471
|
-
//#region src/cli.ts
|
|
472
|
-
const FAILURE_EXIT_CODE = 1;
|
|
473
|
-
const handleFailure = (error) => {
|
|
474
|
-
process$1.exitCode = FAILURE_EXIT_CODE;
|
|
475
|
-
process$1.stderr.write(`${String(error)}\n`);
|
|
476
|
-
};
|
|
477
|
-
try {
|
|
478
|
-
process$1.exitCode = await runCli();
|
|
479
|
-
} catch (error) {
|
|
480
|
-
handleFailure(error);
|
|
481
|
-
}
|
|
482
|
-
//#endregion
|
|
483
|
-
export {};
|