@sayue_ltr/fleq 1.51.0 → 2.0.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.
@@ -36,9 +36,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.SOUND_LEVELS = void 0;
37
37
  exports.isSoundLevel = isSoundLevel;
38
38
  exports.clearCustomSoundCache = clearCustomSoundCache;
39
+ exports._setUptimeProviderForTest = _setUptimeProviderForTest;
40
+ exports._nowMsForTest = _nowMsForTest;
39
41
  exports.playSound = playSound;
40
42
  exports.dispose = dispose;
41
43
  exports.resetSoundPlayer = resetSoundPlayer;
44
+ exports.checkSoundBackend = checkSoundBackend;
42
45
  const child_process_1 = require("child_process");
43
46
  const fs = __importStar(require("fs"));
44
47
  const path = __importStar(require("path"));
@@ -112,6 +115,26 @@ function clearCustomSoundCache() {
112
115
  customSoundCache.clear();
113
116
  windowsSoundCache.clear();
114
117
  }
118
+ // ── 起動ウィンドウ用の単調時計 ──
119
+ /** 起動後リトライのウィンドウ (ms) */
120
+ const STARTUP_WINDOW_MS = 60_000;
121
+ /** 失敗時のリトライ待機 (ms) */
122
+ const RETRY_DELAY_MS = 20_000;
123
+ /** テスト用に差し替え可能な uptime プロバイダ (秒) */
124
+ let uptimeProvider = null;
125
+ /** プロセス起動からの経過ミリ秒 (Date.now の非単調性を避ける) */
126
+ function nowMs() {
127
+ const seconds = uptimeProvider != null ? uptimeProvider() : process.uptime();
128
+ return Math.floor(seconds * 1000);
129
+ }
130
+ /** テスト用: uptime プロバイダを差し替える (null で本物に戻す) */
131
+ function _setUptimeProviderForTest(fn) {
132
+ uptimeProvider = fn;
133
+ }
134
+ /** テスト用: 現在の nowMs 値を返す */
135
+ function _nowMsForTest() {
136
+ return nowMs();
137
+ }
115
138
  // ── 有界キュー ──
116
139
  /** 同時再生の最大数 */
117
140
  const MAX_CONCURRENT = 1;
@@ -121,12 +144,19 @@ const MAX_QUEUE_SIZE = 3;
121
144
  const PLAY_TIMEOUT_MS = 10_000;
122
145
  /** 再生中のプロセス数 */
123
146
  let activeCount = 0;
124
- /** 再生待ちキュー */
147
+ /** 再生待ちキュー (level と isRetry フラグを保持) */
125
148
  const playQueue = [];
126
149
  /** 現在再生中のプロセス (タイムアウト kill 用) */
127
150
  let activeProcess = null;
128
151
  /** タイムアウトタイマー */
129
152
  let activeTimer = null;
153
+ /** 起動後リトライのタイマーハンドル集合 (dispose でクリア) */
154
+ const retryTimers = new Set();
155
+ /**
156
+ * 再生世代カウンタ。dispose/resetSoundPlayer で増やすことで、
157
+ * 旧世代の子プロセスコールバックが戻ってきても completion を拒否する。
158
+ */
159
+ let runGeneration = 0;
130
160
  /** dispose 済みフラグ */
131
161
  let disposed = false;
132
162
  /**
@@ -143,48 +173,95 @@ function onPlayFinished() {
143
173
  return;
144
174
  if (playQueue.length > 0) {
145
175
  const next = playQueue.shift();
146
- runPlay(next);
176
+ runPlay(next.level, { isRetry: next.isRetry });
147
177
  }
148
178
  }
179
+ /**
180
+ * 起動ウィンドウ内で失敗した場合に、指定秒後の再試行をスケジュールする。
181
+ * - リトライ由来の失敗 (opts.isRetry) は再リトライしない
182
+ * - 起動ウィンドウ判定は runPlay 開始時に捕捉済みの値を渡すこと
183
+ */
184
+ function scheduleRetryIfNeeded(level, opts) {
185
+ if (opts?.isRetry)
186
+ return;
187
+ if (disposed)
188
+ return;
189
+ log.warn(`通知音を ${RETRY_DELAY_MS / 1000} 秒後に再試行します (${level})`);
190
+ const timer = setTimeout(() => {
191
+ retryTimers.delete(timer);
192
+ if (!disposed)
193
+ playSound(level, { isRetry: true });
194
+ }, RETRY_DELAY_MS);
195
+ retryTimers.add(timer);
196
+ }
149
197
  /**
150
198
  * プロセスを起動して再生を実行する。
151
- * タイムアウトを設定し、完了時に onPlayFinished を呼ぶ。
199
+ * タイムアウトと launch 経路のコールバックが競合した場合、先に claim() した
200
+ * 側だけが log/bell/キュー進行を実行する (二重完了ガード)。
201
+ * dispose/resetSoundPlayer で runGeneration が変わると旧 runPlay の完了は拒否される。
152
202
  */
153
- function runPlay(level) {
203
+ function runPlay(level, opts) {
154
204
  activeCount++;
205
+ let finished = false;
206
+ const myGeneration = runGeneration;
207
+ // リトライ対象判定は runPlay 開始時の uptime で確定させる。
208
+ // 10 秒 timeout などで失敗確定時点でウィンドウ外でも、
209
+ // 起動直後に始まった再生はリトライ対象とする。
210
+ const startedInStartupWindow = nowMs() < STARTUP_WINDOW_MS;
211
+ const handle = {
212
+ claim: () => {
213
+ if (finished)
214
+ return false;
215
+ // dispose/reset された旧世代の runPlay は完了処理を行わない
216
+ if (myGeneration !== runGeneration)
217
+ return false;
218
+ finished = true;
219
+ return true;
220
+ },
221
+ done: (failed) => {
222
+ if (failed === true && startedInStartupWindow) {
223
+ scheduleRetryIfNeeded(level, opts);
224
+ }
225
+ onPlayFinished();
226
+ },
227
+ };
155
228
  try {
156
229
  const customPath = findCustomSound(level);
157
230
  const platform = process.platform;
158
231
  if (customPath) {
159
- activeProcess = launchCustomSound(customPath, platform, onPlayFinished);
232
+ activeProcess = launchCustomSound(customPath, platform, handle);
160
233
  }
161
234
  else if (platform === "win32") {
162
- activeProcess = launchSystemSoundWindows(level, onPlayFinished);
235
+ activeProcess = launchSystemSoundWindows(level, handle);
163
236
  }
164
237
  else if (platform === "darwin") {
165
- activeProcess = launchSystemSoundMacOS(level, onPlayFinished);
238
+ activeProcess = launchSystemSoundMacOS(level, handle);
166
239
  }
167
240
  else {
168
- activeProcess = launchSystemSoundLinux(level, onPlayFinished);
241
+ activeProcess = launchSystemSoundLinux(level, handle);
169
242
  }
170
243
  if (activeProcess != null) {
171
244
  activeTimer = setTimeout(() => {
172
- log.debug(`通知音の再生がタイムアウトしました (${PLAY_TIMEOUT_MS}ms)`);
245
+ if (!handle.claim())
246
+ return;
247
+ log.warn(`通知音の再生がタイムアウトしました (${PLAY_TIMEOUT_MS}ms)`);
173
248
  try {
174
249
  activeProcess?.kill();
175
250
  }
176
251
  catch {
177
252
  // ignore
178
253
  }
179
- onPlayFinished();
254
+ handle.done(true);
180
255
  }, PLAY_TIMEOUT_MS);
181
256
  }
182
257
  }
183
258
  catch (err) {
259
+ if (!handle.claim())
260
+ return;
184
261
  if (err instanceof Error) {
185
- log.debug(`通知音の再生に失敗しました: ${err.message}`);
262
+ log.warn(`通知音の再生に失敗しました: ${err.message}`);
186
263
  }
187
- onPlayFinished();
264
+ handle.done(true);
188
265
  }
189
266
  }
190
267
  /**
@@ -194,18 +271,32 @@ function runPlay(level) {
194
271
  * カスタム効果音ファイルがあればそちらを優先、なければ OS システムサウンドにフォールバック。
195
272
  * 再生失敗はログに記録するのみで例外は投げない。
196
273
  */
197
- function playSound(level) {
274
+ function playSound(level, opts) {
198
275
  if (disposed)
199
276
  return;
200
277
  if (activeCount < MAX_CONCURRENT) {
201
- runPlay(level);
278
+ runPlay(level, opts);
202
279
  }
203
280
  else {
204
281
  if (playQueue.length >= MAX_QUEUE_SIZE) {
205
- const dropped = playQueue.shift();
206
- log.debug(`通知音キューが上限 (${MAX_QUEUE_SIZE}) に達したため破棄しました: ${dropped}`);
282
+ // critical は警報相当のため drop 候補から除外。それ以外の最古を落とす。
283
+ const dropIdx = playQueue.findIndex((e) => e.level !== "critical");
284
+ if (dropIdx >= 0) {
285
+ const [dropped] = playQueue.splice(dropIdx, 1);
286
+ log.debug(`通知音キューが上限 (${MAX_QUEUE_SIZE}) に達したため破棄しました: ${dropped.level}`);
287
+ }
288
+ else if (level !== "critical") {
289
+ // キューが critical で埋まっている → 非 critical の新規は諦める
290
+ log.debug(`通知音キューが critical で飽和しているため新規再生を破棄しました: ${level}`);
291
+ return;
292
+ }
293
+ else {
294
+ // 新規も critical で、既存も全て critical → 最古の critical を落とす (従来動作)
295
+ const dropped = playQueue.shift();
296
+ log.debug(`通知音キューが上限 (${MAX_QUEUE_SIZE}) に達したため破棄しました: ${dropped?.level}`);
297
+ }
207
298
  }
208
- playQueue.push(level);
299
+ playQueue.push({ level, isRetry: opts?.isRetry === true });
209
300
  }
210
301
  }
211
302
  /**
@@ -214,11 +305,17 @@ function playSound(level) {
214
305
  */
215
306
  function dispose() {
216
307
  disposed = true;
308
+ // 世代を進めて旧 runPlay の遅延コールバックを無効化する
309
+ runGeneration++;
217
310
  playQueue.length = 0;
218
311
  if (activeTimer != null) {
219
312
  clearTimeout(activeTimer);
220
313
  activeTimer = null;
221
314
  }
315
+ for (const timer of retryTimers) {
316
+ clearTimeout(timer);
317
+ }
318
+ retryTimers.clear();
222
319
  if (activeProcess != null) {
223
320
  try {
224
321
  activeProcess.kill();
@@ -235,6 +332,8 @@ function dispose() {
235
332
  */
236
333
  function resetSoundPlayer() {
237
334
  disposed = false;
335
+ // 世代を進めて旧 runPlay の遅延コールバックを無効化する
336
+ runGeneration++;
238
337
  activeCount = 0;
239
338
  playQueue.length = 0;
240
339
  activeProcess = null;
@@ -242,6 +341,10 @@ function resetSoundPlayer() {
242
341
  clearTimeout(activeTimer);
243
342
  activeTimer = null;
244
343
  }
344
+ for (const timer of retryTimers) {
345
+ clearTimeout(timer);
346
+ }
347
+ retryTimers.clear();
245
348
  }
246
349
  // ── カスタム効果音再生 ──
247
350
  function launchCustomSound(filePath, platform, onDone) {
@@ -268,20 +371,30 @@ function launchCustomSoundWindows(filePath, onDone) {
268
371
  `[Win32.Mci]::mciSendStringW('close fleqsnd',$null,0,[IntPtr]::Zero)|Out-Null`,
269
372
  ].join(" ");
270
373
  const proc = (0, child_process_1.execFile)("powershell", ["-NoProfile", "-Command", psCommand], (err) => {
374
+ if (!onDone.claim())
375
+ return;
271
376
  if (err) {
272
- log.debug(`Windows カスタム通知音の再生に失敗しました: ${err.message}`);
377
+ log.warn(`Windows カスタム通知音の再生に失敗しました: ${err.message}`);
378
+ onDone.done(true);
379
+ }
380
+ else {
381
+ onDone.done();
273
382
  }
274
- onDone();
275
383
  });
276
384
  return proc;
277
385
  }
278
386
  /** macOS: afplay で mp3/wav を再生 */
279
387
  function launchCustomSoundMacOS(filePath, onDone) {
280
388
  const proc = (0, child_process_1.execFile)("afplay", [filePath], (err) => {
389
+ if (!onDone.claim())
390
+ return;
281
391
  if (err) {
282
- log.debug(`macOS カスタム通知音の再生に失敗しました: ${err.message}`);
392
+ log.warn(`macOS カスタム通知音の再生に失敗しました: ${err.message}`);
393
+ onDone.done(true);
394
+ }
395
+ else {
396
+ onDone.done();
283
397
  }
284
- onDone();
285
398
  });
286
399
  return proc;
287
400
  }
@@ -291,11 +404,16 @@ function launchCustomSoundLinux(filePath, onDone) {
291
404
  if (ext === ".mp3") {
292
405
  // mp3 は ffplay で再生
293
406
  const proc = (0, child_process_1.execFile)("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", filePath], (err) => {
407
+ if (!onDone.claim())
408
+ return;
294
409
  if (err) {
295
- log.debug(`Linux ffplay での再生に失敗しました: ${err.message}`);
410
+ log.warn(`Linux ffplay での再生に失敗しました: ${err.message}`);
296
411
  printBell();
412
+ onDone.done(true);
413
+ }
414
+ else {
415
+ onDone.done();
297
416
  }
298
- onDone();
299
417
  });
300
418
  return proc;
301
419
  }
@@ -304,15 +422,22 @@ function launchCustomSoundLinux(filePath, onDone) {
304
422
  const proc = (0, child_process_1.execFile)("paplay", [filePath], (err) => {
305
423
  if (err) {
306
424
  (0, child_process_1.execFile)("aplay", ["-q", filePath], (err2) => {
425
+ if (!onDone.claim())
426
+ return;
307
427
  if (err2) {
308
- log.debug(`Linux 通知音の再生に失敗しました: ${err2.message}`);
428
+ log.warn(`Linux 通知音の再生に失敗しました: ${err2.message}`);
309
429
  printBell();
430
+ onDone.done(true);
431
+ }
432
+ else {
433
+ onDone.done();
310
434
  }
311
- onDone();
312
435
  });
313
436
  }
314
437
  else {
315
- onDone();
438
+ if (!onDone.claim())
439
+ return;
440
+ onDone.done();
316
441
  }
317
442
  });
318
443
  return proc;
@@ -344,17 +469,24 @@ function findWindowsSystemSound(level) {
344
469
  function launchSystemSoundWindows(level, onDone) {
345
470
  const soundPath = findWindowsSystemSound(level);
346
471
  if (soundPath == null) {
347
- log.debug(`Windows 通知音が見つかりません。bell にフォールバックします: ${WINDOWS_SOUNDS[level]}`);
472
+ if (!onDone.claim())
473
+ return null;
474
+ log.warn(`Windows 通知音が見つかりません。bell にフォールバックします: ${WINDOWS_SOUNDS[level]}`);
348
475
  printBell();
349
- onDone();
476
+ onDone.done(true);
350
477
  return null;
351
478
  }
352
479
  const psCommand = `(New-Object System.Media.SoundPlayer '${soundPath}').PlaySync()`;
353
480
  const proc = (0, child_process_1.execFile)("powershell", ["-NoProfile", "-Command", psCommand], (err) => {
481
+ if (!onDone.claim())
482
+ return;
354
483
  if (err) {
355
- log.debug(`Windows 通知音の再生に失敗しました: ${err.message}`);
484
+ log.warn(`Windows 通知音の再生に失敗しました: ${err.message}`);
485
+ onDone.done(true);
486
+ }
487
+ else {
488
+ onDone.done();
356
489
  }
357
- onDone();
358
490
  });
359
491
  return proc;
360
492
  }
@@ -362,26 +494,38 @@ function launchSystemSoundMacOS(level, onDone) {
362
494
  const soundFile = MACOS_SOUNDS[level];
363
495
  const soundPath = `/System/Library/Sounds/${soundFile}`;
364
496
  const proc = (0, child_process_1.execFile)("afplay", [soundPath], (err) => {
497
+ if (!onDone.claim())
498
+ return;
365
499
  if (err) {
366
- log.debug(`macOS 通知音の再生に失敗しました: ${err.message}`);
500
+ log.warn(`macOS 通知音の再生に失敗しました: ${err.message}`);
501
+ onDone.done(true);
502
+ }
503
+ else {
504
+ onDone.done();
367
505
  }
368
- onDone();
369
506
  });
370
507
  return proc;
371
508
  }
372
509
  function launchSystemSoundLinux(level, onDone) {
373
510
  const eventName = LINUX_CANBERRA_EVENTS[level];
374
511
  if (eventName === "bell") {
512
+ if (!onDone.claim())
513
+ return null;
375
514
  printBell();
376
- onDone();
515
+ onDone.done();
377
516
  return null;
378
517
  }
379
518
  const proc = (0, child_process_1.execFile)("canberra-gtk-play", ["-i", eventName], (err) => {
519
+ if (!onDone.claim())
520
+ return;
380
521
  if (err) {
381
- log.debug(`canberra-gtk-play 失敗、bell にフォールバック: ${err.message}`);
522
+ log.warn(`canberra-gtk-play 失敗、bell にフォールバック: ${err.message}`);
382
523
  printBell();
524
+ onDone.done(true);
525
+ }
526
+ else {
527
+ onDone.done();
383
528
  }
384
- onDone();
385
529
  });
386
530
  return proc;
387
531
  }
@@ -393,3 +537,56 @@ function printBell() {
393
537
  // ignore
394
538
  }
395
539
  }
540
+ // ── バックエンド健康チェック ──
541
+ /** バックエンドプローブの timeout (ms) */
542
+ const BACKEND_PROBE_TIMEOUT_MS = 2_000;
543
+ /**
544
+ * 音声バックエンドが利用可能かを判定する。
545
+ * Linux: ffplay で 0.1 秒の無音サンプルを再生し、終了コードで判定する。
546
+ * PATH 上に ffplay があっても音声デバイスが使えない場合は ok=false を返す。
547
+ * Windows / macOS: 即座に ok=true を返す (ビルトインの再生経路が常に利用可能)。
548
+ */
549
+ async function checkSoundBackend() {
550
+ const platform = process.platform;
551
+ if (platform === "win32")
552
+ return { ok: true, label: "winmm" };
553
+ if (platform === "darwin")
554
+ return { ok: true, label: "afplay" };
555
+ return await new Promise((resolve) => {
556
+ let settled = false;
557
+ let timer = null;
558
+ const proc = (0, child_process_1.execFile)("ffplay", ["-f", "lavfi", "-i", "anullsrc=d=0.1", "-nodisp", "-autoexit", "-loglevel", "quiet"], (err) => {
559
+ if (settled)
560
+ return;
561
+ settled = true;
562
+ if (timer != null)
563
+ clearTimeout(timer);
564
+ if (err) {
565
+ const e = err;
566
+ const reason = e.code === "ENOENT"
567
+ ? "ffplay not found in PATH"
568
+ : `ffplay probe failed: ${e.message}`;
569
+ resolve({ ok: false, label: "ffplay", reason });
570
+ }
571
+ else {
572
+ resolve({ ok: true, label: "ffplay" });
573
+ }
574
+ });
575
+ // モック環境などで execFile のコールバックが同期的に呼ばれ settled=true に
576
+ // なっている場合、ここで timer を作らないようガードする。
577
+ if (settled)
578
+ return;
579
+ timer = setTimeout(() => {
580
+ if (settled)
581
+ return;
582
+ settled = true;
583
+ try {
584
+ proc?.kill();
585
+ }
586
+ catch {
587
+ // ignore
588
+ }
589
+ resolve({ ok: false, label: "ffplay", reason: "probe timeout" });
590
+ }, BACKEND_PROBE_TIMEOUT_MS);
591
+ });
592
+ }
@@ -52,6 +52,21 @@ function processEew(msg, eewTracker, eewLogger) {
52
52
  log.debug(`EEW 重複報スキップ: EventID=${eewInfo.eventId} 第${eewInfo.serial}報`);
53
53
  return { kind: "duplicate" };
54
54
  }
55
+ // VXSE44 は VXSE45 と重複するため常時抑制 (VXSE44 は配信終了予定)
56
+ const isSuppressed = result.isSuppressed || msg.head.type === "VXSE44";
57
+ if (isSuppressed) {
58
+ log.debug(`EEW 抑制 (${msg.head.type === "VXSE44" ? "VXSE44常時抑制" : "VXSE45優先"}): type=${eewInfo.type} EventID=${eewInfo.eventId} 第${eewInfo.serial}報`);
59
+ eewLogger.logReport(eewInfo, result);
60
+ // 抑制されても終端処理は実行する
61
+ if (result.isCancelled && eewInfo.eventId) {
62
+ eewLogger.closeEvent(eewInfo.eventId, "取消");
63
+ }
64
+ if (eewInfo.nextAdvisory && eewInfo.eventId && !result.isCancelled) {
65
+ eewLogger.closeEvent(eewInfo.eventId, "最終報");
66
+ eewTracker.finalizeEvent(eewInfo.eventId);
67
+ }
68
+ return { kind: "suppressed" };
69
+ }
55
70
  // ログ記録
56
71
  eewLogger.logReport(eewInfo, result);
57
72
  if (result.isCancelled && eewInfo.eventId) {
@@ -23,8 +23,8 @@ function processMessage(msg, route, deps) {
23
23
  const eewResult = (0, process_eew_1.processEew)(msg, deps.eewTracker, deps.eewLogger);
24
24
  if (eewResult.kind === "ok")
25
25
  return eewResult.outcome;
26
- if (eewResult.kind === "duplicate")
27
- return null; // 重複 → 表示・統計なし
26
+ if (eewResult.kind === "duplicate" || eewResult.kind === "suppressed")
27
+ return null; // 重複・抑制 → 表示・統計なし
28
28
  // parse-failed → raw 表示するが統計には含めない(旧 router と同じ動作)
29
29
  const raw = (0, process_raw_1.processRaw)(msg, category);
30
30
  raw.stats.shouldRecord = false;
@@ -96,7 +96,10 @@ function isTruthy(val) {
96
96
  function stringify(value) {
97
97
  if (value == null)
98
98
  return "";
99
+ // 表示専用ポリシー対応: 配列は改行で結合する。
100
+ // ", " 区切りで 1 行に並べる「機械可読 1 行出力」を作れないようにし、
101
+ // shell pipe 連携での再配信足場化を防ぐ。
99
102
  if (Array.isArray(value))
100
- return value.join(", ");
103
+ return value.join("\n");
101
104
  return String(value);
102
105
  }
@@ -2,14 +2,20 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getFieldValue = getFieldValue;
4
4
  /**
5
- * PresentationEvent からドットパス + index access で値を取得する。
5
+ * PresentationEvent からドットパスで値を取得する。
6
+ *
7
+ * 表示専用ポリシー (dmdata.jp 再配信ポリシー対応) のため、配列インデックス参照
8
+ * (`[N]`) は parser 側で禁止済み。本関数では二重防御として、念のため `raw`
9
+ * フィールドへのアクセスも拒否する。
6
10
  *
7
11
  * segments 例:
8
- * - ["title"] → event.title
9
- * - ["raw", "xxx"] → event.raw.xxx
10
- * - ["areaItems", 0, "name"] → event.areaItems[0].name
12
+ * - ["title"] → event.title
13
+ * - ["earthquake", "magnitude"] → event.earthquake.magnitude
11
14
  */
12
15
  function getFieldValue(event, segments) {
16
+ // 二重防御: parser を経由せず直接呼ばれた場合にも raw への参照を拒否
17
+ if (segments[0] === "raw")
18
+ return undefined;
13
19
  let current = event;
14
20
  for (const seg of segments) {
15
21
  if (current == null)
@@ -2,9 +2,18 @@
2
2
  // ── Template filter functions ──
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.applyFilter = applyFilter;
5
+ /**
6
+ * フィルタ内部で値を文字列化する共通関数。
7
+ *
8
+ * 表示専用ポリシー対応: 配列は改行区切りで文字列化する。
9
+ * `String([...])` が "," 連結するため、`|upper` や `|replace` といった
10
+ * 文字列系フィルタ経由で配列を 1 行機械可読出力に整形できてしまうのを防ぐ。
11
+ */
5
12
  function toString(value) {
6
13
  if (value == null)
7
14
  return "";
15
+ if (Array.isArray(value))
16
+ return value.join("\n");
8
17
  return String(value);
9
18
  }
10
19
  function filterDefault(value, args) {
@@ -27,12 +36,6 @@ function filterPad(value, args) {
27
36
  return str;
28
37
  return str.padEnd(width);
29
38
  }
30
- function filterJoin(value, args) {
31
- if (!Array.isArray(value))
32
- return toString(value);
33
- const separator = args[0] != null ? String(args[0]) : ",";
34
- return value.join(separator);
35
- }
36
39
  function filterDate(value, args) {
37
40
  const format = args[0] != null ? String(args[0]) : "HH:mm";
38
41
  const date = value instanceof Date ? value : new Date(String(value));
@@ -58,6 +61,11 @@ function filterReplace(value, args) {
58
61
  const str = toString(value);
59
62
  const search = args[0] != null ? String(args[0]) : "";
60
63
  const replacement = args[1] != null ? String(args[1]) : "";
64
+ // 表示専用ポリシー対応: search/replacement に改行文字を含めると、
65
+ // 配列の改行 join を 1 行化する経路(\n → "," 等の置換)を作れてしまうため禁止。
66
+ if (/[\r\n]/.test(search) || /[\r\n]/.test(replacement)) {
67
+ throw new Error("テンプレート実行エラー: replace フィルタの引数に改行文字 (\\n / \\r) を含めることはできません。表示専用制限です。");
68
+ }
61
69
  return str.split(search).join(replacement);
62
70
  }
63
71
  function filterUpper(value) {
@@ -78,8 +86,6 @@ function applyFilter(name, value, args) {
78
86
  return filterTruncate(value, args);
79
87
  case "pad":
80
88
  return filterPad(value, args);
81
- case "join":
82
- return filterJoin(value, args);
83
89
  case "date":
84
90
  return filterDate(value, args);
85
91
  case "replace":
@@ -144,9 +144,12 @@ class TemplateParser {
144
144
  }
145
145
  }
146
146
  /**
147
- * ドットパスおよびブラケット記法を segments 配列に分割する。
148
- * 例: "areaItems[0].name" → ["areaItems", 0, "name"]
149
- * "foo.bar.baz" → ["foo", "bar", "baz"]
147
+ * ドットパスを segments 配列に分割する。
148
+ * 例: "foo.bar.baz" → ["foo", "bar", "baz"]
149
+ *
150
+ * 表示専用ポリシー (dmdata.jp 再配信ポリシー対応) のため、以下を禁止する:
151
+ * - ブラケット記法 `[N]` (配列インデックス参照)
152
+ * - 先頭セグメントが `raw` のパス (生 XML データへの直接参照)
150
153
  */
151
154
  function parsePathSegments(path) {
152
155
  const segments = [];
@@ -157,18 +160,7 @@ function parsePathSegments(path) {
157
160
  continue;
158
161
  }
159
162
  if (path[i] === "[") {
160
- // ブラケットアクセス: [N]
161
- i++; // [
162
- const start = i;
163
- while (i < path.length && path[i] !== "]")
164
- i++;
165
- const inner = path.slice(start, i);
166
- if (i < path.length)
167
- i++; // ]
168
- // 数値ならnumber、そうでなければstring
169
- const num = Number(inner);
170
- segments.push(Number.isNaN(num) ? inner : num);
171
- continue;
163
+ throw new Error(`テンプレート構文エラー: 配列インデックス参照 [N] は無効です (path: "${path}")。表示専用制限により、要素を1行に並べる機械可読出力を防いでいます。`);
172
164
  }
173
165
  // 識別子: 次の . または [ まで
174
166
  const start = i;
@@ -176,6 +168,9 @@ function parsePathSegments(path) {
176
168
  i++;
177
169
  segments.push(path.slice(start, i));
178
170
  }
171
+ if (segments[0] === "raw") {
172
+ throw new Error(`テンプレート構文エラー: raw フィールド参照は無効です (path: "${path}")。表示専用制限により、生 XML データへの直接アクセスを禁止しています。`);
173
+ }
179
174
  return segments;
180
175
  }
181
176
  /** 文字列リテラル内のエスケープシーケンスを復元する */
package/dist/types.js CHANGED
@@ -23,7 +23,7 @@ exports.DEFAULT_CONFIG = {
23
23
  volcano: true,
24
24
  },
25
25
  sound: true,
26
- eewLog: true,
26
+ eewLog: false,
27
27
  eewLogFields: {
28
28
  hypocenter: true,
29
29
  originTime: true,
@@ -381,6 +381,11 @@ function displayEarthquakeInfo(info) {
381
381
  buf.push(wl);
382
382
  }
383
383
  }
384
+ // EventID (同一地震の紐付け用、通常モードのみ表示)
385
+ if (info.eventId) {
386
+ buf.push((0, formatter_1.frameDivider)(level, width));
387
+ buf.push((0, formatter_1.frameLine)(level, chalk_1.default.gray(`EventID: ${info.eventId}`), width));
388
+ }
384
389
  // フッター
385
390
  (0, formatter_1.renderFooter)(level, info.type, info.reportDateTime, info.publishingOffice, width, buf);
386
391
  buf.push((0, formatter_1.frameBottom)(level, width));