ask-later 0.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/.claude-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.en.md +125 -0
- package/README.md +311 -0
- package/bin/ask-later.mjs +12 -0
- package/docs/field-log.md +182 -0
- package/docs/manual-free/01-why-it-stops.html +151 -0
- package/docs/manual-free/01-why-it-stops.md +192 -0
- package/docs/manual-free/README.md +26 -0
- package/docs/manual-free/index.html +53 -0
- package/docs/manual-free/templates/CLAUDE.snippet.md +20 -0
- package/docs/manual-free/templates/INBOX.md +22 -0
- package/docs/manual-free/templates/PENDING.md +59 -0
- package/docs/manual-free/templates/settings.hooks.json +45 -0
- package/hooks/hooks.json +19 -0
- package/lib/cli.mjs +213 -0
- package/lib/config.mjs +75 -0
- package/lib/hook-entry.mjs +20 -0
- package/lib/hook.mjs +220 -0
- package/lib/inbox.mjs +46 -0
- package/lib/install.mjs +282 -0
- package/lib/messages.mjs +125 -0
- package/lib/notify.mjs +51 -0
- package/lib/pending.mjs +115 -0
- package/lib/queue.mjs +147 -0
- package/lib/sentinel.mjs +65 -0
- package/lib/text.mjs +151 -0
- package/package.json +53 -0
- package/templates/CLAUDE.snippet.en.md +21 -0
- package/templates/CLAUDE.snippet.ja.md +20 -0
- package/templates/INBOX.en.md +22 -0
- package/templates/INBOX.ja.md +22 -0
- package/templates/PENDING.en.md +59 -0
- package/templates/PENDING.ja.md +59 -0
package/lib/messages.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// 人とモデルに見せる文言(日本語が正、英語は従)。
|
|
2
|
+
// 状態行・ヘッダ行のように「機械が読む書式」は pending.mjs が持ち、ここには置かない。
|
|
3
|
+
|
|
4
|
+
const ja = {
|
|
5
|
+
// --- 注入(additionalContext) ---
|
|
6
|
+
// 事実の陳述で書く。命令形(「〜すること」「〜せよ」)は Claude Code の prompt-injection 防御を起動し、
|
|
7
|
+
// 文脈ではなく「埋め込み指示」として人に差し戻される(公式 hooks リファレンス。設計監査 2 巡目 #1 で実測)。
|
|
8
|
+
// 「どう処理するか」の規律は CLAUDE.md の断片(templates/CLAUDE.snippet.*.md)に置き、ここでは「何がどうなっているか」だけを述べる。
|
|
9
|
+
ctxOpen: (n, pending) => `【${pending} に未処理の依頼: ${n}件】利用者の判断を待っている項目で、内容は ${pending} にある。`,
|
|
10
|
+
ctxOpenTail: "これらに依存しない作業は続行できる(このプロジェクトの規律〈CLAUDE.md「非同期キュー」〉)。",
|
|
11
|
+
ctxInboxHead: (n, inbox) =>
|
|
12
|
+
`【${inbox} に利用者本人の未読の行: ${n}件】${inbox} は利用者が手で書く連絡ファイルで、次の行は利用者本人が書いたもの(フックが写した)。このプロジェクトの規律(CLAUDE.md「非同期キュー」)では、${inbox} の行は利用者のチャット発言と同じ扱いになる。`,
|
|
13
|
+
ctxInboxTail: (pending) => [
|
|
14
|
+
`P-xxx の完了報告は ${pending} の該当項目の解消に対応する。命令・質問・提案は、そのままの意味の発言である。`,
|
|
15
|
+
"処理済みの行は行頭に ✔ を付けて残す決まりになっている(行は消さない)。",
|
|
16
|
+
],
|
|
17
|
+
ctxWithdrawn: (ids) => `【期限切れで自動取り下げ: ${ids.join(", ")}】状態行が取り下げに書き換わった。実施の有無は INBOX の報告で確定する。`,
|
|
18
|
+
ctxMalformed: (ids, pending) => `【状態行が無い項目: ${ids.join(", ")}】${pending} の「- [ ] 状態: 未処理」の書式になっておらず、件数に入っていない。`,
|
|
19
|
+
ctxNoPending: (pending) => `【ask-later】${pending} が無い(フックは動いている)。雛形は npx ask-later init が作る。`,
|
|
20
|
+
ctxOpenMore: (n) => ` - …ほか ${n} 件(一覧は PENDING にある)`,
|
|
21
|
+
ctxGarbled: (inbox) => `【注意】${inbox} に UTF-8 として読めない行があり、Shift_JIS として読み直した。その行を書き換えると壊れる。直るのは利用者が ${inbox} を UTF-8 で保存し直したとき(メモ帳: 名前を付けて保存 → 文字コード UTF-8)。`,
|
|
22
|
+
stopReason: (n, inbox) => `${inbox} に利用者本人の未読の行が ${n} 件残っている。未読が残ったままの停止は、このプロジェクトの規律で差し止められる。`,
|
|
23
|
+
stopReasonAgain: (k, max) => `(${k} 回目の差し止め。最大 ${max} 回。処理できない行は ✔ を付けて理由を返答に書けば通る)`,
|
|
24
|
+
|
|
25
|
+
// --- hook.log ---
|
|
26
|
+
logInject: (open, unread) => `注入(未処理 ${open}・未読 ${unread})`,
|
|
27
|
+
logBlock: (unread, k, max) => `差し止め(未読 ${unread}・${k}/${max} 回目)`,
|
|
28
|
+
logNone: (open, unread) => `-(未処理 ${open}・未読 ${unread})`,
|
|
29
|
+
logUnknown: "知らないイベント。何もしない",
|
|
30
|
+
logFail: (e) => `失敗(止めない): ${e}`,
|
|
31
|
+
logWithdrawn: (ids) => `取り下げ ${ids.join(",")}`,
|
|
32
|
+
logNotified: "通知",
|
|
33
|
+
logGarbled: (n) => `UTF-8 でない行 ${n}`,
|
|
34
|
+
|
|
35
|
+
// --- 番兵ファイル ---
|
|
36
|
+
sentinel: ({ n, items, now, pending, inbox }) => [
|
|
37
|
+
`利用者にお願いしたいことが ${n} 件あります。`,
|
|
38
|
+
"",
|
|
39
|
+
`内容 : ${pending} を開いてください`,
|
|
40
|
+
`完了報告 : ${inbox} に1行書いてください(例: P-003 done)`,
|
|
41
|
+
"",
|
|
42
|
+
"未処理の項目:",
|
|
43
|
+
...items.map((it) => ` - ${it.id} ${it.title}`),
|
|
44
|
+
"",
|
|
45
|
+
"このファイルは自動生成されます。消しても構いません(次回の集計で再生成されます)。",
|
|
46
|
+
`最終更新: ${now}`,
|
|
47
|
+
],
|
|
48
|
+
|
|
49
|
+
// --- 通知 ---
|
|
50
|
+
notifyTitle: (n) => `未処理の依頼 ${n} 件`,
|
|
51
|
+
|
|
52
|
+
// --- status の印字 ---
|
|
53
|
+
statusPending: (n) => `PENDING 未処理: ${n}件`,
|
|
54
|
+
statusWithdrawn: (id) => ` 取り下げ(期限切れ・自動): ${id}`,
|
|
55
|
+
statusInbox: (n) => `INBOX 未読: ${n}件`,
|
|
56
|
+
statusSentinel: (p, open = 0) => `番兵: ${p ?? (open > 0 ? `なし(未処理 ${open} 件。次の集計で作られる)` : "なし(0 件)")}`,
|
|
57
|
+
statusMissing: (name) => `${name} がありません(npx ask-later init で雛形を作れます)`,
|
|
58
|
+
statusMalformed: (ids) => `状態行が無い項目(件数に入っていない): ${ids.join(", ")}`,
|
|
59
|
+
statusGarbled: (n, inbox) => `注意: ${inbox} に UTF-8 として読めない行が ${n} 行あります(Shift_JIS として読みました)。メモ帳の「名前を付けて保存 → 文字コード UTF-8」で保存し直してください`,
|
|
60
|
+
statusHookVersion: (copy, pkg) => (copy === pkg ? `フック本体: ${copy}` : `フック本体: ${copy ?? "版が不明(古い写し)"}(このパッケージは ${pkg}。npx ask-later@latest init で更新)`),
|
|
61
|
+
statusNotified: (r) => `通知: ${r.notified ? "出した" : r.quiet ? "静音時間帯なので翌朝に持ち越し" : "変化なし"}`,
|
|
62
|
+
warnWrite: (e) => `注意: ${e}`,
|
|
63
|
+
errWrite: (path) => `書き込めません(権限が無いか、読み取り専用か、別のアプリが開いています): ${path}`,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const en = {
|
|
67
|
+
ctxOpen: (n, pending) => `[Open requests to the user in ${pending}: ${n}] Items waiting on the user's decision; the details are in ${pending}.`,
|
|
68
|
+
ctxOpenTail: "Work that does not depend on these can continue (this project's rule: CLAUDE.md \"Async queue\").",
|
|
69
|
+
ctxInboxHead: (n, inbox) =>
|
|
70
|
+
`[${inbox}: ${n} unread line(s) written by the user] ${inbox} is the file the user writes in by hand; the following line(s) are the user's own words (copied by the hook). Under this project's rule (CLAUDE.md "Async queue"), lines in ${inbox} are the user's chat messages.`,
|
|
71
|
+
ctxInboxTail: (pending) => [
|
|
72
|
+
`A completion report (P-xxx done) corresponds to resolving the matching item in ${pending}. Instructions, questions and proposals carry their plain meaning.`,
|
|
73
|
+
"Handled lines are kept with a leading ✔ (lines are never deleted).",
|
|
74
|
+
],
|
|
75
|
+
ctxWithdrawn: (ids) => `[Withdrawn automatically (deadline passed): ${ids.join(", ")}] The status line was rewritten. Whether it was done is settled by the INBOX report.`,
|
|
76
|
+
ctxMalformed: (ids, pending) => `[Items without a status line: ${ids.join(", ")}] They do not match the "- [ ] status: open" format in ${pending} and are not counted.`,
|
|
77
|
+
ctxNoPending: (pending) => `[ask-later] ${pending} is missing (the hooks are active). npx ask-later init creates the templates.`,
|
|
78
|
+
ctxOpenMore: (n) => ` - ...and ${n} more (the full list is in PENDING)`,
|
|
79
|
+
ctxGarbled: (inbox) => `[Warning] ${inbox} has lines that are not valid UTF-8; they were re-read as Shift_JIS. Editing them corrupts them. It is fixed once the user re-saves ${inbox} as UTF-8.`,
|
|
80
|
+
stopReason: (n, inbox) => `${inbox} still has ${n} unread line(s) written by the user. Stopping with unread lines is blocked under this project's rule.`,
|
|
81
|
+
stopReasonAgain: (k, max) => ` (block ${k} of at most ${max}. A line that cannot be acted on gets a ✔ and the reason in the reply; the stop then goes through)`,
|
|
82
|
+
|
|
83
|
+
logInject: (open, unread) => `inject (open ${open}, unread ${unread})`,
|
|
84
|
+
logBlock: (unread, k, max) => `block (unread ${unread}, ${k}/${max})`,
|
|
85
|
+
logNone: (open, unread) => `- (open ${open}, unread ${unread})`,
|
|
86
|
+
logUnknown: "unknown event; nothing done",
|
|
87
|
+
logFail: (e) => `failed (not blocking): ${e}`,
|
|
88
|
+
logWithdrawn: (ids) => `withdrawn ${ids.join(",")}`,
|
|
89
|
+
logNotified: "notified",
|
|
90
|
+
logGarbled: (n) => `non-UTF-8 lines ${n}`,
|
|
91
|
+
|
|
92
|
+
sentinel: ({ n, items, now, pending, inbox }) => [
|
|
93
|
+
`The agent is waiting on you for ${n} item(s).`,
|
|
94
|
+
"",
|
|
95
|
+
`What : open ${pending}`,
|
|
96
|
+
`To report : write one line in ${inbox} (e.g. P-003 done)`,
|
|
97
|
+
"",
|
|
98
|
+
"Open items:",
|
|
99
|
+
...items.map((it) => ` - ${it.id} ${it.title}`),
|
|
100
|
+
"",
|
|
101
|
+
"This file is generated automatically. You may delete it (it is recreated at the next count).",
|
|
102
|
+
`Last updated: ${now}`,
|
|
103
|
+
],
|
|
104
|
+
|
|
105
|
+
notifyTitle: (n) => `${n} open request(s)`,
|
|
106
|
+
|
|
107
|
+
statusPending: (n) => `PENDING open: ${n}`,
|
|
108
|
+
statusWithdrawn: (id) => ` withdrawn (deadline passed, automatic): ${id}`,
|
|
109
|
+
statusInbox: (n) => `INBOX unread: ${n}`,
|
|
110
|
+
statusSentinel: (p, open = 0) => `sentinel: ${p ?? (open > 0 ? `none (${open} open; written at the next count)` : "none (0 open)")}`,
|
|
111
|
+
statusMissing: (name) => `${name} is missing (npx ask-later init creates the templates)`,
|
|
112
|
+
statusMalformed: (ids) => `items without a status line (not counted): ${ids.join(", ")}`,
|
|
113
|
+
statusGarbled: (n, inbox) => `warning: ${n} line(s) in ${inbox} are not valid UTF-8 (read as Shift_JIS). Re-save the file as UTF-8`,
|
|
114
|
+
statusHookVersion: (copy, pkg) => (copy === pkg ? `hook files: ${copy}` : `hook files: ${copy ?? "unknown version (old copy)"} (this package is ${pkg}; run npx ask-later@latest init to update)`),
|
|
115
|
+
statusNotified: (r) => `notification: ${r.notified ? "sent" : r.quiet ? "quiet hours, deferred to the morning" : "no change"}`,
|
|
116
|
+
warnWrite: (e) => `warning: ${e}`,
|
|
117
|
+
errWrite: (path) => `cannot write (no permission, read-only, or open in another app): ${path}`,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const MESSAGES = { ja, en };
|
|
121
|
+
export const LANGS = Object.keys(MESSAGES);
|
|
122
|
+
|
|
123
|
+
export function msg(lang) {
|
|
124
|
+
return MESSAGES[lang] ?? MESSAGES.ja;
|
|
125
|
+
}
|
package/lib/notify.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// OS 通知。補助手段であり、すべて best-effort(出せない環境でも番兵ファイルは既に置いてある)。
|
|
2
|
+
// Windows PowerShell の toast(WinRT)。powershell.exe は .exe なのでシェル無しで起動できる
|
|
3
|
+
// macOS osascript の display notification
|
|
4
|
+
// Linux notify-send(無ければ何もしない)
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
function run(cmd, args, { timeoutMs = 10000 } = {}) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
let child;
|
|
10
|
+
try {
|
|
11
|
+
child = execFile(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
|
|
12
|
+
resolve({ ok: !err, error: err ? (stderr || err.message).toString().trim().split("\n")[0] : null });
|
|
13
|
+
});
|
|
14
|
+
} catch (e) {
|
|
15
|
+
resolve({ ok: false, error: e.message });
|
|
16
|
+
}
|
|
17
|
+
if (child && child.unref) child.unref();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const psQuote = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
22
|
+
const asQuote = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
23
|
+
|
|
24
|
+
export async function notify({ title, body, platform = process.platform }) {
|
|
25
|
+
const t = String(title ?? "").slice(0, 120);
|
|
26
|
+
const b = String(body ?? "").slice(0, 200);
|
|
27
|
+
try {
|
|
28
|
+
if (platform === "win32") {
|
|
29
|
+
const script = [
|
|
30
|
+
"[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
|
|
31
|
+
"$tmpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
|
|
32
|
+
'$nodes = $tmpl.GetElementsByTagName("text")',
|
|
33
|
+
`$nodes.Item(0).AppendChild($tmpl.CreateTextNode(${psQuote(t)})) | Out-Null`,
|
|
34
|
+
`$nodes.Item(1).AppendChild($tmpl.CreateTextNode(${psQuote(b)})) | Out-Null`,
|
|
35
|
+
"$toast = [Windows.UI.Notifications.ToastNotification]::new($tmpl)",
|
|
36
|
+
"$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'",
|
|
37
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)",
|
|
38
|
+
].join("; ");
|
|
39
|
+
const r = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script]);
|
|
40
|
+
return { ...r, method: "toast" };
|
|
41
|
+
}
|
|
42
|
+
if (platform === "darwin") {
|
|
43
|
+
const r = await run("osascript", ["-e", `display notification ${asQuote(b)} with title ${asQuote(t)}`]);
|
|
44
|
+
return { ...r, method: "osascript" };
|
|
45
|
+
}
|
|
46
|
+
const r = await run("notify-send", [t, b]);
|
|
47
|
+
return { ...r, method: "notify-send" };
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return { ok: false, method: null, error: e.message };
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/pending.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// PENDING.md の解析。機械が読むのは 3 行だけ:
|
|
2
|
+
// 見出し "## [P-003] タイトル"
|
|
3
|
+
// 状態行 "- [ ] 状態: 未処理" / "- [x] 状態: 完了 (2026-09-10)" / "- [x] 状態: 取り下げ(理由)"
|
|
4
|
+
// 英語版は "- [ ] status: open" / "- [x] status: done" / "- [x] status: withdrawn (...)"
|
|
5
|
+
// 期限行 "- 期限: 2026-09-11 21:30"("- due:" / "- deadline:" も可)
|
|
6
|
+
// 過ぎていれば自動で「取り下げ」に書き換える。動けなくなった項目を数え続けると番兵の件数の意味が薄まるため。
|
|
7
|
+
// コードブロック(``` / ~~~)の中は見ない(雛形の書き方の例が項目として数えられないように)。
|
|
8
|
+
import { splitLines, parseLocalDateTime, formatLocal } from "./text.mjs";
|
|
9
|
+
|
|
10
|
+
const HEADING = /^\s*##\s*\[([^\]]+)\]\s*(.*)$/;
|
|
11
|
+
// 箇条書きの記号は - のほか * と + も可(Markdown と同じ)。### 以下の見出しは項目にならない(## だけ)
|
|
12
|
+
const STATUS = /^(\s*[-*+]\s*)\[([ xX])\](\s*)(状態|status)(\s*[::]\s*)(.*)$/i;
|
|
13
|
+
// 状態行らしき行(チェックボックス付きの箇条書き)。これがあるのに STATUS に一致しなければ「崩れている」。
|
|
14
|
+
// 無ければ、その見出しは依頼ではない(例: 状態を持たない「報告」の見出し ## [R-010] …)
|
|
15
|
+
const CHECKBOX = /^\s*[-*+]\s*\[[^\]]*\]/;
|
|
16
|
+
const DEADLINE = /^\s*-\s*(期限|due|deadline)\s*[::]\s*(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2}:\d{2}))?/i;
|
|
17
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
18
|
+
const HEADER_JA = /^未処理: .*$/m;
|
|
19
|
+
const HEADER_EN = /^Open: .*$/m;
|
|
20
|
+
|
|
21
|
+
export function parsePending(text) {
|
|
22
|
+
const lines = splitLines(text ?? "");
|
|
23
|
+
const items = [];
|
|
24
|
+
let cur = null;
|
|
25
|
+
let inFence = false;
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
const line = lines[i];
|
|
28
|
+
if (FENCE.test(line)) {
|
|
29
|
+
inFence = !inFence;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (inFence) continue;
|
|
33
|
+
const h = HEADING.exec(line);
|
|
34
|
+
if (h) {
|
|
35
|
+
cur = {
|
|
36
|
+
id: h[1].trim(),
|
|
37
|
+
title: h[2].trim(),
|
|
38
|
+
headingIndex: i,
|
|
39
|
+
statusIndex: -1,
|
|
40
|
+
hasCheckbox: false,
|
|
41
|
+
open: false,
|
|
42
|
+
statusKey: null,
|
|
43
|
+
statusText: "",
|
|
44
|
+
deadline: null,
|
|
45
|
+
deadlineText: null,
|
|
46
|
+
};
|
|
47
|
+
items.push(cur);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!cur) continue;
|
|
51
|
+
if (cur.statusIndex < 0) {
|
|
52
|
+
const s = STATUS.exec(line);
|
|
53
|
+
if (s) {
|
|
54
|
+
cur.statusIndex = i;
|
|
55
|
+
cur.open = s[2] === " ";
|
|
56
|
+
cur.statusKey = s[4];
|
|
57
|
+
cur.statusText = s[6].trim();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (CHECKBOX.test(line)) cur.hasCheckbox = true;
|
|
61
|
+
}
|
|
62
|
+
if (!cur.deadline) {
|
|
63
|
+
const d = DEADLINE.exec(line);
|
|
64
|
+
if (d) {
|
|
65
|
+
const dt = parseLocalDateTime(d[2], d[3] ?? "23:59");
|
|
66
|
+
if (dt) {
|
|
67
|
+
cur.deadline = dt;
|
|
68
|
+
cur.deadlineText = formatLocal(dt);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
lines,
|
|
75
|
+
// 依頼(状態行がある、または状態行らしき行がある)
|
|
76
|
+
items: items.filter((it) => it.statusIndex >= 0 || it.hasCheckbox),
|
|
77
|
+
// 状態行らしき行はあるが書式が違う項目。件数に入らないので、人に知らせる
|
|
78
|
+
malformed: items.filter((it) => it.statusIndex < 0 && it.hasCheckbox).map((it) => it.id),
|
|
79
|
+
// 状態を持たない見出し(報告など)。数えず、警告もしない
|
|
80
|
+
notes: items.filter((it) => it.statusIndex < 0 && !it.hasCheckbox).map((it) => it.id),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function openItems(parsed) {
|
|
85
|
+
return parsed.items.filter((it) => it.statusIndex >= 0 && it.open).map(({ id, title, deadline }) => ({ id, title, deadline }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 期限切れの未処理を「取り下げ」に書き換える。返り値の lines は新しい配列(元は変えない)。items の open は更新する
|
|
89
|
+
export function expireDeadlines(parsed, { now = new Date() } = {}) {
|
|
90
|
+
const lines = parsed.lines.slice();
|
|
91
|
+
const withdrawn = [];
|
|
92
|
+
for (const it of parsed.items) {
|
|
93
|
+
if (it.statusIndex < 0 || !it.open || !it.deadline) continue;
|
|
94
|
+
if (now.getTime() <= it.deadline.getTime()) continue;
|
|
95
|
+
const m = STATUS.exec(lines[it.statusIndex]);
|
|
96
|
+
const isEn = /^status$/i.test(it.statusKey);
|
|
97
|
+
const body = isEn
|
|
98
|
+
? `withdrawn (deadline ${it.deadlineText} passed; automatic. Whether it was done is settled by the INBOX report)`
|
|
99
|
+
: `取り下げ(期限 ${it.deadlineText} を過ぎたため自動。実施の有無は INBOX の報告で確定する)`;
|
|
100
|
+
lines[it.statusIndex] = `${m[1]}[x]${m[3]}${m[4]}${m[5]}${body}`;
|
|
101
|
+
it.open = false;
|
|
102
|
+
it.statusText = body;
|
|
103
|
+
withdrawn.push(it.id);
|
|
104
|
+
}
|
|
105
|
+
return { lines, withdrawn };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ヘッダ行「未処理: N件 / INBOX未読: M件 / 最終集計: …」があれば更新する。無ければそのまま
|
|
109
|
+
export function updateHeader(text, { open, unread, now = new Date() }) {
|
|
110
|
+
if (text == null) return text;
|
|
111
|
+
const stamp = formatLocal(now);
|
|
112
|
+
if (HEADER_JA.test(text)) return text.replace(HEADER_JA, `未処理: ${open}件 / INBOX未読: ${unread}件 / 最終集計: ${stamp}`);
|
|
113
|
+
if (HEADER_EN.test(text)) return text.replace(HEADER_EN, `Open: ${open} / INBOX unread: ${unread} / Last count: ${stamp}`);
|
|
114
|
+
return text;
|
|
115
|
+
}
|
package/lib/queue.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// 集計。PENDING の未処理と INBOX の未読を数え、番兵ファイルとヘッダ行を更新し、必要なら通知する。
|
|
2
|
+
// フック(hook.mjs)と CLI(status)の両方がここを通る。
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readText, readTextLenient, writeTextAtomic, detectEol, isDirectory } from "./text.mjs";
|
|
5
|
+
import { parsePending, openItems, expireDeadlines, updateHeader } from "./pending.mjs";
|
|
6
|
+
import { unreadLines, signatureOf } from "./inbox.mjs";
|
|
7
|
+
import { updateSentinel, findSentinel } from "./sentinel.mjs";
|
|
8
|
+
import { notify } from "./notify.mjs";
|
|
9
|
+
import { loadState, saveState, isQuietHour } from "./config.mjs";
|
|
10
|
+
import { msg } from "./messages.mjs";
|
|
11
|
+
|
|
12
|
+
// 権限・ロックの失敗は生の Node 文(EPERM: operation not permitted, rename '…' -> '…')でなく人向けの 1 行にする
|
|
13
|
+
export function humanError(e, lang = "ja") {
|
|
14
|
+
// rename の失敗は e.path が一時ファイル側(PENDING.md.1234.tmp)で e.dest が本当のファイル。dest を先に見る(設計監査 2 巡目 #7)
|
|
15
|
+
if (e && ["EACCES", "EPERM", "EROFS", "EBUSY"].includes(e.code)) return msg(lang).errWrite(e.dest ?? e.path ?? "");
|
|
16
|
+
return e?.message ?? String(e);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// lenient=true なら UTF-8 でない行を Shift_JIS として読み直す(INBOX。人がメモ帳の ANSI で追記することがある)
|
|
20
|
+
function readOrNull(path, { lenient = false } = {}) {
|
|
21
|
+
try {
|
|
22
|
+
if (isDirectory(path)) return { text: null, error: "ディレクトリです / is a directory", garbled: 0 };
|
|
23
|
+
if (lenient) {
|
|
24
|
+
const r = readTextLenient(path);
|
|
25
|
+
return { text: r?.text ?? null, error: null, garbled: r?.garbled ?? 0 };
|
|
26
|
+
}
|
|
27
|
+
return { text: readText(path), error: null, garbled: 0 };
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return { text: null, error: e.message, garbled: 0 };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 読むだけ(expire=true なら期限切れの取り下げを lines に反映するが、ファイルには書かない)
|
|
34
|
+
export function collect(root, config, { now = new Date(), expire = false } = {}) {
|
|
35
|
+
const pendingPath = join(root, config.pending);
|
|
36
|
+
const inboxPath = join(root, config.inbox);
|
|
37
|
+
const p = readOrNull(pendingPath);
|
|
38
|
+
const ib = readOrNull(inboxPath, { lenient: true });
|
|
39
|
+
|
|
40
|
+
const parsed = parsePending(p.text ?? "");
|
|
41
|
+
let withdrawn = [];
|
|
42
|
+
let pendingText = p.text;
|
|
43
|
+
let pendingChanged = false;
|
|
44
|
+
if (expire && p.text != null) {
|
|
45
|
+
const r = expireDeadlines(parsed, { now });
|
|
46
|
+
if (r.withdrawn.length) {
|
|
47
|
+
withdrawn = r.withdrawn;
|
|
48
|
+
pendingText = r.lines.join(detectEol(p.text));
|
|
49
|
+
pendingChanged = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const open = openItems(parsed);
|
|
53
|
+
const inboxLines = unreadLines(ib.text);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
root,
|
|
57
|
+
pendingPath,
|
|
58
|
+
inboxPath,
|
|
59
|
+
pendingExists: p.text != null,
|
|
60
|
+
inboxExists: ib.text != null,
|
|
61
|
+
pendingError: p.error,
|
|
62
|
+
inboxError: ib.error,
|
|
63
|
+
pendingText,
|
|
64
|
+
pendingChanged,
|
|
65
|
+
openCount: open.length,
|
|
66
|
+
openItems: open,
|
|
67
|
+
withdrawn,
|
|
68
|
+
malformed: parsed.malformed,
|
|
69
|
+
inboxUnread: inboxLines.length,
|
|
70
|
+
inboxLines,
|
|
71
|
+
inboxGarbled: ib.garbled ?? 0,
|
|
72
|
+
signature: signatureOf(inboxLines),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 集計して副作用を行う。write=false なら何も書かない(status --dry 等)。
|
|
77
|
+
// expire 期限切れの自動取り下げを PENDING に書き戻す
|
|
78
|
+
// notify 通知を出す(静音時間帯なら持ち越し。同じ集合には繰り返さない)
|
|
79
|
+
export async function summarize(root, config, { now = new Date(), expire = true, notify: doNotify = false, write = true } = {}) {
|
|
80
|
+
const c = collect(root, config, { now, expire: expire && write });
|
|
81
|
+
const result = { ...c, sentinel: null, sentinelError: null, notified: false, quiet: false, deferred: false, writeErrors: [] };
|
|
82
|
+
|
|
83
|
+
if (write) {
|
|
84
|
+
if (c.pendingChanged) {
|
|
85
|
+
try {
|
|
86
|
+
writeTextAtomic(c.pendingPath, c.pendingText);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
result.writeErrors.push(`${config.pending}: ${humanError(e, config.lang)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (c.pendingExists) {
|
|
92
|
+
const cur = c.pendingChanged ? c.pendingText : readOrNull(c.pendingPath).text;
|
|
93
|
+
const upd = updateHeader(cur, { open: c.openCount, unread: c.inboxUnread, now });
|
|
94
|
+
if (upd != null && upd !== cur) {
|
|
95
|
+
try {
|
|
96
|
+
writeTextAtomic(c.pendingPath, upd);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
result.writeErrors.push(`${config.pending}: ${humanError(e, config.lang)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const s = updateSentinel(root, { items: c.openItems, config, now });
|
|
103
|
+
result.sentinel = s.path;
|
|
104
|
+
result.sentinelError = s.error;
|
|
105
|
+
} else {
|
|
106
|
+
// 読むだけ(status --dry-run)でも、今ある番兵は今あるものとして報告する
|
|
107
|
+
result.sentinel = findSentinel(root, config.sentinelPrefix);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 通知。前回通知した集合と同じなら鳴らさない。静音時間帯は持ち越す
|
|
111
|
+
const state = loadState(root);
|
|
112
|
+
const signature = c.openItems.map((it) => it.id).sort().join(",");
|
|
113
|
+
const lastSig = typeof state.lastNotifiedSignature === "string" ? state.lastNotifiedSignature : "";
|
|
114
|
+
let deferred = state.deferred === true;
|
|
115
|
+
const quiet = isQuietHour(now, config.quietHours);
|
|
116
|
+
const isNew = signature !== "" && signature !== lastSig;
|
|
117
|
+
let notified = false;
|
|
118
|
+
// ASK_LATER_NO_NOTIFY=1 で OS 通知を止められる(テスト・CI・鳴らしたくない端末)
|
|
119
|
+
if (doNotify && config.notify && !process.env.ASK_LATER_NO_NOTIFY && (isNew || deferred) && c.openCount > 0) {
|
|
120
|
+
if (quiet) deferred = true;
|
|
121
|
+
else {
|
|
122
|
+
const m = msg(config.lang);
|
|
123
|
+
let body = c.openItems.map((it) => `${it.id} ${it.title}`).join(" / ");
|
|
124
|
+
if (body.length > 120) body = body.slice(0, 117) + "...";
|
|
125
|
+
const r = await notify({ title: m.notifyTitle(c.openCount), body });
|
|
126
|
+
notified = r.ok;
|
|
127
|
+
deferred = false;
|
|
128
|
+
result.notifyError = r.error ?? null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (write) {
|
|
132
|
+
if (notified || signature === "") state.lastNotifiedSignature = signature;
|
|
133
|
+
state.deferred = deferred;
|
|
134
|
+
state.openCount = c.openCount;
|
|
135
|
+
state.inboxUnread = c.inboxUnread;
|
|
136
|
+
state.updatedAt = now.toISOString();
|
|
137
|
+
try {
|
|
138
|
+
saveState(root, state);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
result.writeErrors.push(`state.json: ${humanError(e, config.lang)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
result.notified = notified;
|
|
144
|
+
result.quiet = quiet;
|
|
145
|
+
result.deferred = deferred;
|
|
146
|
+
return result;
|
|
147
|
+
}
|
package/lib/sentinel.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// 番兵ファイル。名前そのものが件数("!pending_3.txt")。
|
|
2
|
+
// エクスプローラ/Finder でプロジェクトを開くだけで目に入る。音も光も出さないので夜間でも安全であり、
|
|
3
|
+
// これが第一の通知手段である。0 件なら消す(ルートを汚さない)。
|
|
4
|
+
import { readdirSync, rmSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { writeTextAtomic, formatLocal } from "./text.mjs";
|
|
7
|
+
import { msg } from "./messages.mjs";
|
|
8
|
+
|
|
9
|
+
export function sentinelName(n, prefix = "!pending_") {
|
|
10
|
+
return `${prefix}${n}.txt`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function escapeRe(s) {
|
|
14
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sentinelNames(root, prefix) {
|
|
18
|
+
const re = new RegExp(`^${escapeRe(prefix)}\\d+\\.txt$`);
|
|
19
|
+
try {
|
|
20
|
+
return readdirSync(root).filter((n) => re.test(n));
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 今ある番兵のパス(無ければ null)。読むだけ
|
|
27
|
+
export function findSentinel(root, prefix = "!pending_") {
|
|
28
|
+
const names = sentinelNames(root, prefix);
|
|
29
|
+
return names.length ? join(root, names[0]) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 自分が作った番兵だけを消す(<prefix><数字>.txt に一致するもの)
|
|
33
|
+
export function removeSentinels(root, prefix = "!pending_") {
|
|
34
|
+
const removed = [];
|
|
35
|
+
for (const name of sentinelNames(root, prefix)) {
|
|
36
|
+
try {
|
|
37
|
+
rmSync(join(root, name), { force: true });
|
|
38
|
+
removed.push(name);
|
|
39
|
+
} catch {
|
|
40
|
+
/* 開かれている等。次回に持ち越す */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return removed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 返り値: 作った番兵のパス(0 件なら null)。失敗しても例外にしない({ path: null, error })
|
|
47
|
+
export function updateSentinel(root, { items, config, now = new Date() }) {
|
|
48
|
+
const prefix = config.sentinelPrefix;
|
|
49
|
+
const removed = removeSentinels(root, prefix);
|
|
50
|
+
if (!items.length) return { path: null, removed, error: null };
|
|
51
|
+
const path = join(root, sentinelName(items.length, prefix));
|
|
52
|
+
const body = msg(config.lang).sentinel({
|
|
53
|
+
n: items.length,
|
|
54
|
+
items,
|
|
55
|
+
now: formatLocal(now, { seconds: true }),
|
|
56
|
+
pending: config.pending,
|
|
57
|
+
inbox: config.inbox,
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
writeTextAtomic(path, body.join("\n") + "\n");
|
|
61
|
+
return { path, removed, error: null };
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return { path: null, removed, error: e.message };
|
|
64
|
+
}
|
|
65
|
+
}
|
package/lib/text.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// 小さな共通部品。ファイルは UTF-8(BOM を除いて読み、BOM 無しで書く)。
|
|
2
|
+
// PowerShell 5.1 が BOM 無しを CP932 として読む罠(この道具の移植元の記録)を、Node 側では作らない。
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
|
|
7
|
+
// 無ければ null。読めない(ディレクトリ等)は例外(呼び出し側が「読めない」として扱う)
|
|
8
|
+
export function readText(path) {
|
|
9
|
+
if (!existsSync(path)) return null;
|
|
10
|
+
let s = readFileSync(path, "utf8");
|
|
11
|
+
if (s.charCodeAt(0) === 0xfeff) s = s.slice(1);
|
|
12
|
+
return s;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// UTF-8 として読めない行(Notepad の ANSI=CP932 で追記された行など)を行単位で Shift_JIS として読み直す。
|
|
16
|
+
// 返り値 { text, garbled }。garbled は「UTF-8 でなかった行」の数(0 なら全行 UTF-8)。Shift_JIS でも読めなければ U+FFFD のまま残す。
|
|
17
|
+
// ファイル全体を一括で再解釈しないのは、雛形(UTF-8)と追記(CP932)が混在したファイルで一括変換が失敗するため。
|
|
18
|
+
export function readTextLenient(path) {
|
|
19
|
+
if (!existsSync(path)) return null;
|
|
20
|
+
let buf = readFileSync(path);
|
|
21
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) buf = buf.subarray(3);
|
|
22
|
+
const strict = tryDecode(buf, "utf-8");
|
|
23
|
+
if (strict != null) return { text: strict, garbled: 0 };
|
|
24
|
+
const parts = [];
|
|
25
|
+
let garbled = 0;
|
|
26
|
+
let start = 0;
|
|
27
|
+
for (let i = 0; i <= buf.length; i++) {
|
|
28
|
+
if (i === buf.length || buf[i] === 0x0a) {
|
|
29
|
+
const line = buf.subarray(start, i);
|
|
30
|
+
let s = tryDecode(line, "utf-8");
|
|
31
|
+
if (s == null) {
|
|
32
|
+
garbled++;
|
|
33
|
+
s = tryDecode(line, "shift_jis") ?? line.toString("utf8");
|
|
34
|
+
}
|
|
35
|
+
parts.push(s);
|
|
36
|
+
start = i + 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { text: parts.join("\n"), garbled };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tryDecode(buf, encoding) {
|
|
43
|
+
try {
|
|
44
|
+
return new TextDecoder(encoding, { fatal: true }).decode(buf);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// JSON を書き戻すときに元ファイルの体裁(字下げ幅・改行コード・末尾改行)を写す。元が無ければ 2 スペース・LF・末尾改行あり
|
|
51
|
+
export function detectJsonFormat(raw) {
|
|
52
|
+
if (raw == null) return { indent: 2, eol: "\n", trailingNewline: true };
|
|
53
|
+
const m = /^\r?\n?([ \t]+)"/m.exec(raw) ?? /\n([ \t]+)\S/.exec(raw);
|
|
54
|
+
const indent = m ? (m[1].includes("\t") ? "\t" : m[1].length) : 2;
|
|
55
|
+
return { indent, eol: detectEol(raw), trailingNewline: /\r?\n$/.test(raw) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatJson(value, { indent = 2, eol = "\n", trailingNewline = true } = {}) {
|
|
59
|
+
let s = JSON.stringify(value, null, indent);
|
|
60
|
+
if (eol !== "\n") s = s.replace(/\n/g, eol);
|
|
61
|
+
return trailingNewline ? s + eol : s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isDirectory(path) {
|
|
65
|
+
try {
|
|
66
|
+
return statSync(path).isDirectory();
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function detectEol(text) {
|
|
73
|
+
return /\r\n/.test(text ?? "") ? "\r\n" : "\n";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function splitLines(text) {
|
|
77
|
+
return String(text ?? "").split(/\r?\n/);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 一時ファイルに書いてから置き換える(途中で切れても元のファイルが半端にならない)
|
|
81
|
+
export function writeTextAtomic(path, text) {
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
84
|
+
writeFileSync(tmp, text, "utf8");
|
|
85
|
+
try {
|
|
86
|
+
renameSync(tmp, path);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
rmSync(tmp, { force: true });
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sha256(text) {
|
|
94
|
+
return createHash("sha256").update(String(text), "utf8").digest("hex");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const pad2 = (n) => String(n).padStart(2, "0");
|
|
98
|
+
|
|
99
|
+
// ローカル時刻 "YYYY-MM-DD HH:MM"(人が読む用。ファイルに書く)
|
|
100
|
+
export function formatLocal(d = new Date(), { seconds = false } = {}) {
|
|
101
|
+
const s = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
102
|
+
return seconds ? `${s}:${pad2(d.getSeconds())}` : s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// "2026-09-11" + "21:30" → ローカル時刻の Date。読めなければ null
|
|
106
|
+
export function parseLocalDateTime(dateStr, timeStr = "23:59") {
|
|
107
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr ?? "");
|
|
108
|
+
const t = /^(\d{1,2}):(\d{2})$/.exec(timeStr ?? "");
|
|
109
|
+
if (!m || !t) return null;
|
|
110
|
+
const d = new Date(+m[1], +m[2] - 1, +m[3], +t[1], +t[2], 0, 0);
|
|
111
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
112
|
+
// 2026-02-31 のような存在しない日付は Date が繰り上げるので、戻して比べる
|
|
113
|
+
if (d.getMonth() !== +m[2] - 1 || d.getDate() !== +m[3] || d.getHours() !== +t[1]) return null;
|
|
114
|
+
return d;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 標準入力を最後まで読む。TTY(人が直接叩いた)なら読まない。timeoutMs で諦める(フックを固めない)
|
|
118
|
+
export function readStream(stream, { timeoutMs = 3000 } = {}) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
if (!stream || stream.isTTY) return resolve("");
|
|
121
|
+
let data = "";
|
|
122
|
+
let done = false;
|
|
123
|
+
const finish = () => {
|
|
124
|
+
if (done) return;
|
|
125
|
+
done = true;
|
|
126
|
+
try {
|
|
127
|
+
stream.pause();
|
|
128
|
+
} catch {
|
|
129
|
+
/* ignore */
|
|
130
|
+
}
|
|
131
|
+
resolve(data);
|
|
132
|
+
};
|
|
133
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
134
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
135
|
+
try {
|
|
136
|
+
stream.setEncoding("utf8");
|
|
137
|
+
stream.on("data", (c) => (data += c));
|
|
138
|
+
stream.on("end", () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
finish();
|
|
141
|
+
});
|
|
142
|
+
stream.on("error", () => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
finish();
|
|
145
|
+
});
|
|
146
|
+
} catch {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
finish();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|