diff --git a/src/App.ts b/src/App.ts index e874511411..33422ccb6a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -23,6 +23,7 @@ import { loadFromStorage, parseMapUrlState, saveToStorage, isMobileDevice } from import type { ParsedMapUrlState } from '@/utils'; import { SignalModal, IntelligenceGapBadge, BreakingNewsBanner } from '@/components'; import { initBreakingNewsAlerts, destroyBreakingNewsAlerts } from '@/services/breaking-news-alerts'; +import { normalizeMonitors } from '@/services/monitors'; import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel'; import type { StablecoinPanel } from '@/components/StablecoinPanel'; import type { ETFFlowsPanel } from '@/components/ETFFlowsPanel'; @@ -330,7 +331,7 @@ export class App { const isMobile = isMobileDevice(); const isDesktopApp = isDesktopRuntime(); - const monitors = loadFromStorage(STORAGE_KEYS.monitors, []); + const monitors = normalizeMonitors(loadFromStorage(STORAGE_KEYS.monitors, [])); // Use mobile-specific defaults on first load (no saved layers) const defaultLayers = isMobile ? MOBILE_DEFAULT_MAP_LAYERS : DEFAULT_MAP_LAYERS; diff --git a/src/app/app-context.ts b/src/app/app-context.ts index 6ada9423dd..05cfc7ed72 100644 --- a/src/app/app-context.ts +++ b/src/app/app-context.ts @@ -4,6 +4,7 @@ import type { IranEvent } from '@/generated/client/worldmonitor/conflict/v1/serv import type { SanctionsPressureResult } from '@/services/sanctions-pressure'; import type { RadiationWatchResult } from '@/services/radiation'; import type { SecurityAdvisory } from '@/services/security-advisories'; +import type { ListCrossSourceSignalsResponse } from '@/services/cross-source-signals'; import type { Earthquake } from '@/services/earthquakes'; export type { CountryBriefSignals } from '@/types'; @@ -20,6 +21,7 @@ export interface IntelligenceCache { iranEvents?: IranEvent[]; orefAlerts?: { alertCount: number; historyCount24h: number }; advisories?: SecurityAdvisory[]; + crossSourceSignals?: ListCrossSourceSignalsResponse; sanctions?: SanctionsPressureResult; radiation?: RadiationWatchResult; imageryScenes?: Array<{ id: string; satellite: string; datetime: string; resolutionM: number; mode: string; geometryGeojson: string; previewUrl: string; assetUrl: string }>; diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index aad5cb2eff..328914e83b 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -107,6 +107,7 @@ import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchU import { fetchUnhcrPopulation } from '@/services/displacement'; import { fetchClimateAnomalies } from '@/services/climate'; import { fetchSecurityAdvisories } from '@/services/security-advisories'; +import { applyMonitorHighlightsToNews, hasMonitorProAccess } from '@/services/monitors'; import { fetchThermalEscalations } from '@/services/thermal-escalation'; import { fetchCrossSourceSignals } from '@/services/cross-source-signals'; import { fetchTelegramFeed } from '@/services/telegram-intel'; @@ -2532,7 +2533,28 @@ export class DataLoaderManager implements AppModule { updateMonitorResults(): void { const monitorPanel = this.ctx.panels['monitors'] as MonitorPanel | undefined; - monitorPanel?.renderResults(this.ctx.allNews); + const highlightedAllNews = applyMonitorHighlightsToNews( + this.ctx.monitors, + this.ctx.allNews, + { proAccess: hasMonitorProAccess() }, + ); + this.ctx.allNews = highlightedAllNews; + + Object.entries(this.ctx.newsByCategory).forEach(([category, items]) => { + const highlightedItems = applyMonitorHighlightsToNews( + this.ctx.monitors, + items, + { proAccess: hasMonitorProAccess() }, + ); + this.ctx.newsByCategory[category] = highlightedItems; + this.renderNewsForCategory(category, highlightedItems); + }); + + monitorPanel?.renderResults({ + news: highlightedAllNews, + advisories: this.ctx.intelligenceCache.advisories ?? [], + crossSourceSignals: this.ctx.intelligenceCache.crossSourceSignals?.signals ?? [], + }); } async runCorrelationAnalysis(): Promise { @@ -2894,6 +2916,7 @@ export class DataLoaderManager implements AppModule { async loadCrossSourceSignals(): Promise { try { const result = await fetchCrossSourceSignals(); + this.ctx.intelligenceCache.crossSourceSignals = result; this.callPanel('cross-source-signals', 'setData', result); dataFreshness.recordUpdate('cross-source-signals' as DataSourceId, result.signals?.length ?? 0); } catch (error) { diff --git a/src/components/MonitorPanel.ts b/src/components/MonitorPanel.ts index a4b5a975e9..1e38384e8b 100644 --- a/src/components/MonitorPanel.ts +++ b/src/components/MonitorPanel.ts @@ -1,97 +1,496 @@ import { Panel } from './Panel'; +import { track } from '@/services/analytics'; +import type { BreakingAlert } from '@/services/breaking-news-alerts'; +import { + FREE_MONITOR_LIMIT, + evaluateMonitorMatches, + hasMonitorProAccess, + mergeMonitorEdits, + monitorUsesProFeatures, + normalizeMonitor, + normalizeMonitors, + type MonitorFeedInput, + type MonitorMatch, +} from '@/services/monitors'; import { t } from '@/services/i18n'; -import type { Monitor, NewsItem } from '@/types'; +import type { Monitor, MonitorMatchMode, MonitorSourceKind } from '@/types'; import { MONITOR_COLORS } from '@/config'; -import { generateId, formatTime, getCSSColor } from '@/utils'; +import { formatTime, getCSSColor } from '@/utils'; import { sanitizeUrl } from '@/utils/sanitize'; import { h, replaceChildren, clearChildren } from '@/utils/dom-utils'; +const SOURCE_ORDER: MonitorSourceKind[] = ['news', 'breaking', 'advisories', 'cross-source']; + +const SOURCE_LABELS: Record = { + news: 'News', + breaking: 'Breaking', + advisories: 'Advisories', + 'cross-source': 'Cross-source', +}; + +const SEVERITY_COLORS: Record = { + critical: 'var(--semantic-critical)', + high: 'var(--semantic-high)', + medium: 'var(--semantic-elevated)', + low: 'var(--semantic-normal)', + info: 'var(--text-dim)', +}; + +function parseKeywords(value: string): string[] { + return value + .split(/[\n,]+/g) + .map((item) => item.trim()) + .filter(Boolean); +} + +function isProOnlySource(source: MonitorSourceKind): boolean { + return source === 'advisories' || source === 'cross-source'; +} + +function sourceKindLabel(kind: MonitorMatch['sourceKind']): string { + return kind === 'cross-source' ? SOURCE_LABELS['cross-source'] : SOURCE_LABELS[kind]; +} + export class MonitorPanel extends Panel { private monitors: Monitor[] = []; private onMonitorsChange?: (monitors: Monitor[]) => void; + private feed: MonitorFeedInput = { news: [] }; + private breakingAlerts: BreakingAlert[] = []; + private lastMatchIds = new Set(); + private statusEl: HTMLElement | null = null; + private monitorsListEl: HTMLElement | null = null; + private resultsEl: HTMLElement | null = null; + private nameInput: HTMLInputElement | null = null; + private includeInput: HTMLInputElement | null = null; + private excludeInput: HTMLInputElement | null = null; + private modeSelect: HTMLSelectElement | null = null; + private addBtn: HTMLButtonElement | null = null; + private cancelBtn: HTMLButtonElement | null = null; + private sourceInputs = new Map(); + private readonly boundOnBreakingAlert: (e: Event) => void; + private editingMonitorId: string | null = null; constructor(initialMonitors: Monitor[] = []) { - super({ id: 'monitors', title: t('panels.monitors') }); - this.monitors = initialMonitors; + super({ + id: 'monitors', + title: t('panels.monitors'), + infoTooltip: 'Build local monitoring rules over live news, breaking events, security advisories, and cross-source escalation signals.', + }); + this.monitors = normalizeMonitors(initialMonitors); + this.boundOnBreakingAlert = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.id) return; + const idx = this.breakingAlerts.findIndex((alert) => alert.id === detail.id); + if (idx >= 0) { + this.breakingAlerts[idx] = detail; + } else { + this.breakingAlerts.unshift(detail); + this.breakingAlerts = this.breakingAlerts.slice(0, 50); + } + this.refreshResults(); + }; + document.addEventListener('wm:breaking-news', this.boundOnBreakingAlert); this.renderInput(); } private renderInput(): void { clearChildren(this.content); - const input = h('input', { + this.nameInput = h('input', { + type: 'text', + className: 'monitor-input', + placeholder: t('components.monitor.namePlaceholder'), + }) as HTMLInputElement; + + this.includeInput = h('input', { type: 'text', className: 'monitor-input', - id: 'monitorKeywords', placeholder: t('components.monitor.placeholder'), - onKeypress: (e: Event) => { if ((e as KeyboardEvent).key === 'Enter') this.addMonitor(); }, + onKeypress: (e: Event) => { + if ((e as KeyboardEvent).key === 'Enter') this.addMonitor(); + }, + }) as HTMLInputElement; + + this.excludeInput = h('input', { + type: 'text', + className: 'monitor-input', + placeholder: t('components.monitor.excludePlaceholder'), + onKeypress: (e: Event) => { + if ((e as KeyboardEvent).key === 'Enter') this.addMonitor(); + }, + }) as HTMLInputElement; + + this.modeSelect = h('select', { className: 'unified-settings-select' }, + h('option', { value: 'any' }, t('components.monitor.modeAny')), + h('option', { value: 'all' }, t('components.monitor.modeAll')), + ) as HTMLSelectElement; + + const sourceToggles = h('div', { + style: 'display:flex;flex-wrap:wrap;gap:8px;margin-top:8px', + }); + this.sourceInputs.clear(); + for (const source of SOURCE_ORDER) { + const input = h('input', { + type: 'checkbox', + checked: source === 'news' || source === 'breaking', + }) as HTMLInputElement; + this.sourceInputs.set(source, input); + const badge = isProOnlySource(source) + ? h('span', { + style: 'font-size:9px;padding:1px 4px;border:1px solid rgba(255,255,255,0.14);border-radius:999px;color:var(--text-dim);font-family:var(--font-mono);letter-spacing:0.04em', + }, 'PRO') + : null; + const label = h('label', { + style: 'display:inline-flex;align-items:center;gap:6px;padding:5px 8px;border:1px solid var(--border);border-radius:999px;font-size:11px;color:var(--text);cursor:pointer', + }, input, SOURCE_LABELS[source], badge); + sourceToggles.appendChild(label); + } + + this.addBtn = h('button', { + className: 'monitor-add-btn', + onClick: () => this.addMonitor(), + }, t('components.monitor.add')) as HTMLButtonElement; + + this.cancelBtn = h('button', { + className: 'monitor-add-btn', + style: 'display:none;background:transparent;border:1px solid var(--border);color:var(--text-dim)', + onClick: () => this.cancelEdit(), + }, t('common.cancel')) as HTMLButtonElement; + + this.statusEl = h('div', { + style: 'color:var(--text-dim);font-size:11px;line-height:1.5;margin-top:8px', }); - const inputContainer = h('div', { className: 'monitor-input-container' }, - input, - h('button', { className: 'monitor-add-btn', id: 'addMonitorBtn', onClick: () => this.addMonitor() }, - t('components.monitor.add'), - ), + const composer = h('div', { + className: 'monitor-input-container', + style: 'display:flex;flex-direction:column;gap:8px', + }, + this.nameInput, + this.includeInput, + this.excludeInput, + h('div', { + style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap', + }, + h('span', { + style: 'font-size:11px;color:var(--text-dim);min-width:56px', + }, t('components.monitor.ruleMode')), + this.modeSelect, + this.addBtn, + this.cancelBtn, + ), + h('div', { + style: 'font-size:11px;color:var(--text-dim);margin-top:2px', + }, t('components.monitor.sources')), + sourceToggles, + this.statusEl, ); - const monitorsList = h('div', { id: 'monitorsList' }); - const monitorsResults = h('div', { id: 'monitorsResults' }); + this.monitorsListEl = h('div', { + style: 'display:flex;flex-direction:column;gap:8px;margin-top:12px', + }); + this.resultsEl = h('div', { style: 'margin-top:12px' }); - this.content.appendChild(inputContainer); - this.content.appendChild(monitorsList); - this.content.appendChild(monitorsResults); + this.content.appendChild(composer); + this.content.appendChild(this.monitorsListEl); + this.content.appendChild(this.resultsEl); + this.applyComposerAccessState(); this.renderMonitorsList(); + this.refreshResults(); + } + + private applyComposerAccessState(): void { + const proAccess = hasMonitorProAccess(); + if (this.excludeInput) { + this.excludeInput.disabled = !proAccess; + this.excludeInput.title = proAccess ? '' : t('components.monitor.lockedAdvanced'); + } + for (const [source, input] of this.sourceInputs) { + if (!isProOnlySource(source)) continue; + input.disabled = !proAccess; + input.title = proAccess ? '' : t('components.monitor.lockedAdvanced'); + if (!proAccess) input.checked = false; + } + this.setComposerStatus( + t('components.monitor.freeLimit', { count: String(FREE_MONITOR_LIMIT) }), + 'info', + ); + } + + private setComposerStatus(message: string, tone: 'info' | 'warn' = 'info'): void { + if (!this.statusEl) return; + this.statusEl.textContent = message; + this.statusEl.style.color = tone === 'warn' ? getCSSColor('--semantic-elevated') : 'var(--text-dim)'; + } + + private selectedSources(fallbackWhenEmpty = true): MonitorSourceKind[] { + const out: MonitorSourceKind[] = []; + for (const [source, input] of this.sourceInputs) { + if (input.checked) out.push(source); + } + return out.length > 0 || !fallbackWhenEmpty ? out : ['news']; } private addMonitor(): void { - const input = document.getElementById('monitorKeywords') as HTMLInputElement; - const keywords = input.value.trim(); + const includeKeywords = parseKeywords(this.includeInput?.value || ''); + if (includeKeywords.length === 0) return; + + const proAccess = hasMonitorProAccess(); + if (!proAccess && this.monitors.length >= FREE_MONITOR_LIMIT) { + track('gate-hit', { feature: 'monitor-limit' }); + this.setComposerStatus(t('components.monitor.limitReached', { count: String(FREE_MONITOR_LIMIT) }), 'warn'); + return; + } - if (!keywords) return; + const excludeKeywords = parseKeywords(this.excludeInput?.value || ''); + const existing = this.editingMonitorId + ? this.monitors.find((item) => item.id === this.editingMonitorId) + : undefined; + const preserveLockedFields = Boolean(existing && !proAccess && monitorUsesProFeatures(existing)); + const sources = this.selectedSources(!preserveLockedFields); + const hasAdvancedRule = excludeKeywords.length > 0 || sources.some((source) => isProOnlySource(source)); + if (!proAccess && hasAdvancedRule) { + track('gate-hit', { feature: 'monitor-advanced-rules' }); + this.setComposerStatus(t('components.monitor.lockedAdvanced'), 'warn'); + return; + } - const monitor: Monitor = { - id: generateId(), - keywords: keywords.split(',').map((k) => k.trim().toLowerCase()), + const draftMonitor: Monitor = { + id: '', + name: this.nameInput?.value.trim() || undefined, + keywords: includeKeywords, + includeKeywords, + excludeKeywords, color: MONITOR_COLORS[this.monitors.length % MONITOR_COLORS.length] ?? getCSSColor('--status-live'), + matchMode: (this.modeSelect?.value === 'all' ? 'all' : 'any') as MonitorMatchMode, + sources, }; - this.monitors.push(monitor); - input.value = ''; + if (this.editingMonitorId) { + const idx = this.monitors.findIndex((item) => item.id === this.editingMonitorId); + if (idx >= 0) { + const existing = this.monitors[idx]!; + const nextMonitor = preserveLockedFields + ? mergeMonitorEdits(existing, draftMonitor, false) + : draftMonitor; + this.monitors[idx] = normalizeMonitor({ + ...existing, + ...nextMonitor, + id: existing.id, + color: existing.color, + createdAt: existing.createdAt, + }, idx); + } + } else { + this.monitors.push(normalizeMonitor(draftMonitor, this.monitors.length)); + } + + this.resetComposer(); + this.setComposerStatus(t('components.monitor.freeLimit', { count: String(FREE_MONITOR_LIMIT) }), 'info'); this.renderMonitorsList(); + this.refreshResults(); this.onMonitorsChange?.(this.monitors); } public removeMonitor(id: string): void { - this.monitors = this.monitors.filter((m) => m.id !== id); + this.monitors = this.monitors.filter((monitor) => monitor.id !== id); + if (this.editingMonitorId === id) this.resetComposer(); this.renderMonitorsList(); + this.refreshResults(); this.onMonitorsChange?.(this.monitors); } + private startEdit(id: string): void { + const monitor = this.monitors.find((item) => item.id === id); + if (!monitor) return; + const proAccess = hasMonitorProAccess(); + this.editingMonitorId = id; + if (this.nameInput) this.nameInput.value = monitor.name || ''; + if (this.includeInput) this.includeInput.value = (monitor.includeKeywords ?? monitor.keywords).join(', '); + if (this.excludeInput) { + this.excludeInput.value = proAccess ? (monitor.excludeKeywords ?? []).join(', ') : ''; + } + if (this.modeSelect) this.modeSelect.value = monitor.matchMode === 'all' ? 'all' : 'any'; + for (const [source, input] of this.sourceInputs) { + const selected = (monitor.sources ?? ['news']).includes(source); + input.checked = proAccess ? selected : (selected && !isProOnlySource(source)); + } + if (this.addBtn) this.addBtn.textContent = t('components.monitor.save'); + if (this.cancelBtn) this.cancelBtn.style.display = 'inline-flex'; + if (!proAccess && monitorUsesProFeatures(monitor)) { + this.setComposerStatus(t('components.monitor.lockedRule'), 'warn'); + } else { + this.setComposerStatus(t('components.monitor.editing'), 'info'); + } + } + + private cancelEdit(): void { + this.resetComposer(); + this.setComposerStatus(t('components.monitor.freeLimit', { count: String(FREE_MONITOR_LIMIT) }), 'info'); + } + + private resetComposer(): void { + this.editingMonitorId = null; + if (this.nameInput) this.nameInput.value = ''; + if (this.includeInput) this.includeInput.value = ''; + if (this.excludeInput) this.excludeInput.value = ''; + if (this.modeSelect) this.modeSelect.value = 'any'; + for (const [source, input] of this.sourceInputs) { + input.checked = source === 'news' || source === 'breaking'; + } + if (this.addBtn) this.addBtn.textContent = t('components.monitor.add'); + if (this.cancelBtn) this.cancelBtn.style.display = 'none'; + } + + private renderMonitorCard(monitor: Monitor): HTMLElement { + const metaBits: string[] = []; + if (monitor.matchMode === 'all') metaBits.push(t('components.monitor.modeAll')); + if ((monitor.excludeKeywords?.length ?? 0) > 0) metaBits.push(`exclude: ${monitor.excludeKeywords?.join(', ')}`); + + const sources = monitor.sources ?? ['news']; + const sourceRow = h('div', { + style: 'display:flex;flex-wrap:wrap;gap:6px;margin-top:8px', + }, + ...sources.map((source) => + h('span', { + style: 'font-size:10px;padding:2px 6px;border:1px solid var(--border);border-radius:999px;color:var(--text-dim);font-family:var(--font-mono);letter-spacing:0.04em', + }, SOURCE_LABELS[source]), + )); + + const lockedNote = monitorUsesProFeatures(monitor) && !hasMonitorProAccess() + ? h('div', { + style: 'margin-top:8px;font-size:11px;color:var(--semantic-elevated)', + }, t('components.monitor.lockedRule')) + : null; + + return h('div', { + style: `border:1px solid var(--border);border-left:3px solid ${monitor.color};padding:10px 12px;border-radius:10px;background:rgba(255,255,255,0.02)`, + }, + h('div', { + style: 'display:flex;justify-content:space-between;gap:8px;align-items:flex-start', + }, + h('div', {}, + h('div', { + style: 'font-size:13px;font-weight:600;color:var(--text)', + }, monitor.name || t('panels.monitors')), + h('div', { + style: 'margin-top:4px;font-size:11px;color:var(--text-dim);line-height:1.5', + }, (monitor.includeKeywords ?? monitor.keywords).join(', ')), + metaBits.length > 0 + ? h('div', { + style: 'margin-top:4px;font-size:10px;color:var(--text-dim)', + }, metaBits.join(' · ')) + : null, + ), + h('div', { + style: 'display:flex;align-items:center;gap:8px', + }, + h('button', { + className: 'icon-btn', + title: t('components.monitor.edit'), + 'aria-label': t('components.monitor.edit'), + onClick: () => this.startEdit(monitor.id), + }, t('components.monitor.edit')), + h('button', { + className: 'monitor-tag-remove', + title: t('components.monitor.remove'), + 'aria-label': t('components.monitor.remove'), + onClick: () => this.removeMonitor(monitor.id), + }, '×'), + ), + ), + sourceRow, + lockedNote, + ); + } + private renderMonitorsList(): void { - const list = document.getElementById('monitorsList'); - if (!list) return; - - replaceChildren(list, - ...this.monitors.map((m) => - h('span', { className: 'monitor-tag' }, - h('span', { className: 'monitor-tag-color', style: { background: m.color } }), - m.keywords.join(', '), - h('span', { - className: 'monitor-tag-remove', - onClick: () => this.removeMonitor(m.id), - }, '×'), - ), - ), + if (!this.monitorsListEl) return; + if (this.monitors.length === 0) { + replaceChildren(this.monitorsListEl, + h('div', { + style: 'color:var(--text-dim);font-size:11px;line-height:1.5;padding:8px 0', + }, t('components.monitor.addKeywords')), + ); + return; + } + + replaceChildren(this.monitorsListEl, + ...this.monitors.map((monitor) => this.renderMonitorCard(monitor)), ); } - public renderResults(news: NewsItem[]): void { - const results = document.getElementById('monitorsResults'); - if (!results) return; + public renderResults(feed: MonitorFeedInput): void { + this.feed = { + news: feed.news ?? [], + advisories: feed.advisories ?? [], + crossSourceSignals: feed.crossSourceSignals ?? [], + breakingAlerts: this.breakingAlerts, + }; + this.refreshResults(); + } + + private renderMatchCard(match: MonitorMatch): HTMLElement { + const severityColor = match.severity ? (SEVERITY_COLORS[match.severity] || 'var(--text-dim)') : 'var(--text-dim)'; + const titleNode = match.link + ? h('a', { + className: 'item-title', + href: sanitizeUrl(match.link), + target: '_blank', + rel: 'noopener', + }, match.title) + : h('div', { className: 'item-title' }, match.title); + + return h('div', { + className: 'item', + style: `border-left:2px solid ${match.monitorColor};padding-left:8px;margin-left:-8px`, + }, + h('div', { + style: 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:4px', + }, + h('span', { className: 'item-source' }, match.monitorName), + h('span', { + style: 'font-size:10px;padding:1px 6px;border:1px solid var(--border);border-radius:999px;color:var(--text-dim);font-family:var(--font-mono);letter-spacing:0.04em', + }, sourceKindLabel(match.sourceKind)), + match.severity + ? h('span', { + style: `font-size:10px;padding:1px 6px;border-radius:999px;background:${severityColor};color:#fff;font-family:var(--font-mono);letter-spacing:0.04em`, + }, match.severity.toUpperCase()) + : null, + ), + titleNode, + h('div', { + style: 'margin-top:4px;font-size:11px;color:var(--text-dim);line-height:1.5', + }, [match.subtitle, match.summary].filter(Boolean).join(' · ')), + h('div', { + style: 'margin-top:4px;font-size:10px;color:var(--text-dim);display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap', + }, + h('span', {}, t('components.monitor.matchedTerms', { terms: match.matchedTerms.join(', ') })), + h('span', { className: 'item-time' }, formatTime(new Date(match.timestamp))), + ), + ); + } + + private refreshResults(): void { + if (!this.resultsEl) return; + + const matches = evaluateMonitorMatches(this.monitors, { + ...this.feed, + breakingAlerts: this.breakingAlerts, + }); + + const nextMatchIds = new Set(matches.map((match) => `${match.monitorId}:${match.sourceKind}:${match.id}`)); + if (this.lastMatchIds.size > 0) { + let newCount = 0; + for (const id of nextMatchIds) { + if (!this.lastMatchIds.has(id)) newCount++; + } + if (newCount > 0) this.setNewBadge(newCount); + } + this.lastMatchIds = nextMatchIds; if (this.monitors.length === 0) { - replaceChildren(results, + replaceChildren(this.resultsEl, h('div', { style: 'color: var(--text-dim); font-size: 10px; margin-top: 12px;' }, t('components.monitor.addKeywords'), ), @@ -99,62 +498,26 @@ export class MonitorPanel extends Panel { return; } - const matchedItems: NewsItem[] = []; - - news.forEach((item) => { - this.monitors.forEach((monitor) => { - // Search both title and description for better coverage - const searchText = `${item.title} ${(item as unknown as { description?: string }).description || ''}`.toLowerCase(); - const matched = monitor.keywords.some((kw) => { - // Use word boundary matching to avoid false positives like "ai" in "train" - const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(`\\b${escaped}\\b`, 'i'); - return regex.test(searchText); - }); - if (matched) { - matchedItems.push({ ...item, monitorColor: monitor.color }); - } - }); - }); - - // Dedupe by link - const seen = new Set(); - const unique = matchedItems.filter(item => { - if (seen.has(item.link)) return false; - seen.add(item.link); - return true; - }); - - if (unique.length === 0) { - replaceChildren(results, + if (matches.length === 0) { + replaceChildren(this.resultsEl, h('div', { style: 'color: var(--text-dim); font-size: 10px; margin-top: 12px;' }, - t('components.monitor.noMatches', { count: String(news.length) }), + this.feed.news.length > 0 + ? t('components.monitor.noMatches', { count: String(this.feed.news.length) }) + : t('components.monitor.noFeedMatches'), ), ); return; } - const countText = unique.length > 10 - ? t('components.monitor.showingMatches', { count: '10', total: String(unique.length) }) - : `${unique.length} ${unique.length === 1 ? t('components.monitor.match') : t('components.monitor.matches')}`; + const countText = matches.length > 12 + ? t('components.monitor.showingMatches', { count: '12', total: String(matches.length) }) + : `${matches.length} ${matches.length === 1 ? t('components.monitor.match') : t('components.monitor.matches')}`; - replaceChildren(results, - h('div', { style: 'color: var(--text-dim); font-size: 10px; margin: 12px 0 8px;' }, countText), - ...unique.slice(0, 10).map((item) => - h('div', { - className: 'item', - style: `border-left: 2px solid ${item.monitorColor || ''}; padding-left: 8px; margin-left: -8px;`, - }, - h('div', { className: 'item-source' }, item.source), - h('a', { - className: 'item-title', - href: sanitizeUrl(item.link), - target: '_blank', - rel: 'noopener', - }, item.title), - h('div', { className: 'item-time' }, formatTime(item.pubDate)), - ), - ), + replaceChildren(this.resultsEl, + h('div', { + style: 'color: var(--text-dim); font-size: 10px; margin: 12px 0 8px;', + }, `${t('components.monitor.resultsTitle')} · ${countText}`), + ...matches.slice(0, 12).map((match) => this.renderMatchCard(match)), ); } @@ -167,7 +530,13 @@ export class MonitorPanel extends Panel { } public setMonitors(monitors: Monitor[]): void { - this.monitors = monitors; + this.monitors = normalizeMonitors(monitors); this.renderMonitorsList(); + this.refreshResults(); + } + + public override destroy(): void { + document.removeEventListener('wm:breaking-news', this.boundOnBreakingAlert); + super.destroy(); } } diff --git a/src/locales/en.json b/src/locales/en.json index f4a3261af7..6da91ab7ee 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -817,13 +817,30 @@ "noData": "No progress data available" }, "monitor": { + "namePlaceholder": "Monitor name (optional)", "placeholder": "Keywords (comma separated)", + "excludePlaceholder": "Exclude keywords (Pro)", "add": "+ Add Monitor", "addKeywords": "Add keywords to monitor news", "noMatches": "No matches in {{count}} articles", + "noFeedMatches": "No monitor matches in current feeds", "showingMatches": "Showing {{count}} of {{total}} matches", "match": "match", - "matches": "matches" + "matches": "matches", + "ruleMode": "Rule mode", + "modeAny": "Any term", + "modeAll": "All terms", + "sources": "Sources", + "freeLimit": "Free: up to {{count}} monitors. Pro unlocks advisories, cross-source signals, and exclude rules.", + "matchedTerms": "Matched: {{terms}}", + "resultsTitle": "Matches", + "lockedAdvanced": "Advanced monitor rules require Pro.", + "limitReached": "Free plan supports up to {{count}} monitors.", + "lockedRule": "Advanced rule locked until Pro is enabled.", + "remove": "Remove monitor", + "edit": "Edit monitor", + "save": "Save monitor", + "editing": "Editing monitor rule" }, "regulation": { "dashboard": "AI Regulation Dashboard", diff --git a/src/services/monitors.ts b/src/services/monitors.ts new file mode 100644 index 0000000000..175f894241 --- /dev/null +++ b/src/services/monitors.ts @@ -0,0 +1,422 @@ +import { MONITOR_COLORS } from '@/config/variants/base'; +import type { BreakingAlert } from '@/services/breaking-news-alerts'; +import type { ListCrossSourceSignalsResponse } from '@/services/cross-source-signals'; +import { getSecretState } from '@/services/runtime-config'; +import type { SecurityAdvisory } from '@/services/security-advisories'; +import type { + Monitor, + MonitorMatchMode, + MonitorSourceKind, + NewsItem, + ThreatLevel, +} from '@/types'; + +export const FREE_MONITOR_LIMIT = 3; + +const FREE_MONITOR_SOURCES: MonitorSourceKind[] = ['news', 'breaking']; +const DEFAULT_MONITOR_SOURCES: MonitorSourceKind[] = ['news']; + +export interface MonitorFeedInput { + news: NewsItem[]; + advisories?: SecurityAdvisory[]; + crossSourceSignals?: ListCrossSourceSignalsResponse['signals']; + breakingAlerts?: BreakingAlert[]; +} + +export interface MonitorMatch { + id: string; + monitorId: string; + monitorName: string; + monitorColor: string; + sourceKind: MonitorSourceKind; + title: string; + subtitle: string; + summary: string; + link?: string; + timestamp: number; + severity?: ThreatLevel; + matchedTerms: string[]; +} + +export interface MonitorHighlight { + color: string; + monitorId: string; + matchedTerms: string[]; +} + +function trimText(value: string | undefined): string { + return (value || '').trim(); +} + +function uniqueKeywords(items: string[] | undefined): string[] { + const out: string[] = []; + const seen = new Set(); + for (const item of items || []) { + const normalized = item.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + out.push(normalized); + } + return out; +} + +function uniqueSources(items: MonitorSourceKind[] | undefined): MonitorSourceKind[] { + const out: MonitorSourceKind[] = []; + const seen = new Set(); + for (const item of items || []) { + if (seen.has(item)) continue; + seen.add(item); + out.push(item); + } + return out; +} + +function normalizeSources(items: MonitorSourceKind[] | undefined): MonitorSourceKind[] { + const out = uniqueSources(items); + return out.length > 0 ? out : [...DEFAULT_MONITOR_SOURCES]; +} + +function filterFreeSources(items: MonitorSourceKind[] | undefined): MonitorSourceKind[] { + const freeSources = uniqueSources(items).filter((source) => FREE_MONITOR_SOURCES.includes(source)); + return freeSources.length > 0 ? freeSources : [...FREE_MONITOR_SOURCES]; +} + +function resolveSourcesForMatching(items: MonitorSourceKind[] | undefined): MonitorSourceKind[] { + return items === undefined ? [...DEFAULT_MONITOR_SOURCES] : uniqueSources(items); +} + +function inferMonitorName(keywords: string[], fallbackIndex: number): string { + if (keywords.length === 0) return `Monitor ${fallbackIndex + 1}`; + return keywords.slice(0, 2).join(' + '); +} + +export function hasMonitorProAccess(): boolean { + return getSecretState('WORLDMONITOR_API_KEY').present || hasStoredProKey(); +} + +export function monitorUsesProFeatures(monitor: Monitor): boolean { + const excludeKeywords = uniqueKeywords(monitor.excludeKeywords); + const sources = normalizeSources(monitor.sources); + return excludeKeywords.length > 0 || sources.some((source) => !FREE_MONITOR_SOURCES.includes(source)); +} + +export function normalizeMonitor(input: Monitor, index = 0): Monitor { + const includeKeywords = uniqueKeywords(input.includeKeywords ?? input.keywords); + const excludeKeywords = uniqueKeywords(input.excludeKeywords); + const now = Date.now(); + return { + ...input, + id: trimText(input.id) || `id-${crypto.randomUUID()}`, + name: trimText(input.name) || inferMonitorName(includeKeywords, index), + keywords: includeKeywords, + includeKeywords, + excludeKeywords, + color: trimText(input.color) || MONITOR_COLORS[index % MONITOR_COLORS.length] || 'var(--status-live)', + matchMode: input.matchMode === 'all' ? 'all' : 'any', + sources: normalizeSources(input.sources), + createdAt: typeof input.createdAt === 'number' ? input.createdAt : now, + updatedAt: typeof input.updatedAt === 'number' ? input.updatedAt : now, + }; +} + +export function normalizeMonitors(monitors: Monitor[]): Monitor[] { + return (monitors || []).map((monitor, index) => normalizeMonitor(monitor, index)); +} + +export function prepareMonitorsForRuntime(monitors: Monitor[], proAccess = hasMonitorProAccess()): Monitor[] { + const normalized = normalizeMonitors(monitors); + return normalized + .slice(0, proAccess ? normalized.length : FREE_MONITOR_LIMIT) + .map((monitor) => { + if (proAccess) return monitor; + return { + ...monitor, + excludeKeywords: [], + sources: filterFreeSources(monitor.sources), + }; + }); +} + +export function mergeMonitorEdits(existing: Monitor, draft: Monitor, proAccess = hasMonitorProAccess()): Monitor { + if (proAccess || !monitorUsesProFeatures(existing)) return draft; + + const freeSources = uniqueSources(draft.sources).filter((source) => FREE_MONITOR_SOURCES.includes(source)); + const lockedSources = uniqueSources(existing.sources).filter((source) => !FREE_MONITOR_SOURCES.includes(source)); + return { + ...draft, + excludeKeywords: uniqueKeywords(existing.excludeKeywords), + sources: uniqueSources([...freeSources, ...lockedSources]), + }; +} + +function matchesKeyword(haystack: string, keyword: string): boolean { + const normalizedKeyword = keyword.trim().toLowerCase(); + if (!normalizedKeyword) return false; + if (/\s/.test(normalizedKeyword)) { + return haystack.includes(normalizedKeyword); + } + const escaped = normalizedKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`(^|[^a-z0-9])${escaped}($|[^a-z0-9])`, 'i'); + if (re.test(haystack)) return true; + + // Broad monitor terms like "iran" should also catch close derivatives such as + // "iranian" without requiring users to enumerate every suffix manually. + if (/^[a-z0-9]{4,}$/.test(normalizedKeyword)) { + const prefixRe = new RegExp(`(^|[^a-z0-9])${escaped}[a-z0-9]+`, 'i'); + return prefixRe.test(haystack); + } + + return false; +} + +function evaluateTextRule( + haystack: string, + includeKeywords: string[], + excludeKeywords: string[], + matchMode: MonitorMatchMode, +): string[] { + if (includeKeywords.length === 0) return []; + + const matchedIncludes = includeKeywords.filter((keyword) => matchesKeyword(haystack, keyword)); + const includeMatch = matchMode === 'all' + ? matchedIncludes.length === includeKeywords.length + : matchedIncludes.length > 0; + if (!includeMatch) return []; + + const matchedExcludes = excludeKeywords.filter((keyword) => matchesKeyword(haystack, keyword)); + if (matchedExcludes.length > 0) return []; + + return matchedIncludes; +} + +function advisorySeverity(level: SecurityAdvisory['level']): ThreatLevel { + switch (level) { + case 'do-not-travel': return 'critical'; + case 'reconsider': return 'high'; + case 'caution': return 'medium'; + case 'normal': return 'low'; + default: return 'info'; + } +} + +function crossSourceSeverity(level: string | undefined): ThreatLevel { + switch (level) { + case 'CROSS_SOURCE_SIGNAL_SEVERITY_CRITICAL': return 'critical'; + case 'CROSS_SOURCE_SIGNAL_SEVERITY_HIGH': return 'high'; + case 'CROSS_SOURCE_SIGNAL_SEVERITY_MEDIUM': return 'medium'; + case 'CROSS_SOURCE_SIGNAL_SEVERITY_LOW': return 'low'; + default: return 'info'; + } +} + +function dedupeMatches(matches: MonitorMatch[]): MonitorMatch[] { + const seen = new Set(); + const out: MonitorMatch[] = []; + for (const match of matches) { + const key = `${match.monitorId}:${match.sourceKind}:${match.id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(match); + } + return out; +} + +function asTimestamp(input: Date | number | undefined): number { + if (typeof input === 'number') return input; + if (input instanceof Date) return input.getTime(); + return Date.now(); +} + +export function evaluateMonitorMatches( + monitors: Monitor[], + feed: MonitorFeedInput, + options?: { proAccess?: boolean }, +): MonitorMatch[] { + const runtimeMonitors = prepareMonitorsForRuntime(monitors, options?.proAccess); + if (runtimeMonitors.length === 0) return []; + + const matches: MonitorMatch[] = []; + + for (const monitor of runtimeMonitors) { + const includeKeywords = uniqueKeywords(monitor.includeKeywords ?? monitor.keywords); + const excludeKeywords = uniqueKeywords(monitor.excludeKeywords); + const matchMode = monitor.matchMode === 'all' ? 'all' : 'any'; + const sources = resolveSourcesForMatching(monitor.sources); + + if (sources.includes('news')) { + for (const item of feed.news || []) { + const extraDescription = trimText((item as NewsItem & { description?: string; summary?: string }).description) + || trimText((item as NewsItem & { description?: string; summary?: string }).summary); + const haystack = [ + item.title, + item.locationName, + extraDescription, + ].filter(Boolean).join(' ').toLowerCase(); + const matchedTerms = evaluateTextRule(haystack, includeKeywords, excludeKeywords, matchMode); + if (matchedTerms.length === 0) continue; + matches.push({ + id: item.link || `${item.source}:${item.title}`, + monitorId: monitor.id, + monitorName: monitor.name || 'Monitor', + monitorColor: monitor.color, + sourceKind: 'news', + title: item.title, + subtitle: item.source, + summary: item.locationName || item.link, + link: item.link, + timestamp: asTimestamp(item.pubDate), + severity: item.threat?.level, + matchedTerms, + }); + } + } + + if (sources.includes('breaking')) { + for (const item of feed.breakingAlerts || []) { + const haystack = [ + item.headline, + item.source, + item.origin, + ].filter(Boolean).join(' ').toLowerCase(); + const matchedTerms = evaluateTextRule(haystack, includeKeywords, excludeKeywords, matchMode); + if (matchedTerms.length === 0) continue; + matches.push({ + id: item.id, + monitorId: monitor.id, + monitorName: monitor.name || 'Monitor', + monitorColor: monitor.color, + sourceKind: 'breaking', + title: item.headline, + subtitle: item.source, + summary: item.origin.replace(/_/g, ' '), + link: item.link, + timestamp: asTimestamp(item.timestamp), + severity: item.threatLevel, + matchedTerms, + }); + } + } + + if (sources.includes('advisories')) { + for (const item of feed.advisories || []) { + const haystack = [ + item.title, + item.source, + item.country, + item.sourceCountry, + item.level, + ].filter(Boolean).join(' ').toLowerCase(); + const matchedTerms = evaluateTextRule(haystack, includeKeywords, excludeKeywords, matchMode); + if (matchedTerms.length === 0) continue; + matches.push({ + id: item.link || `${item.source}:${item.title}`, + monitorId: monitor.id, + monitorName: monitor.name || 'Monitor', + monitorColor: monitor.color, + sourceKind: 'advisories', + title: item.title, + subtitle: item.source, + summary: [item.country, item.level].filter(Boolean).join(' · '), + link: item.link, + timestamp: asTimestamp(item.pubDate), + severity: advisorySeverity(item.level), + matchedTerms, + }); + } + } + + if (sources.includes('cross-source')) { + for (const item of feed.crossSourceSignals || []) { + const haystack = [ + item.theater, + item.summary, + item.type, + ...(item.contributingTypes || []), + ].filter(Boolean).join(' ').toLowerCase(); + const matchedTerms = evaluateTextRule(haystack, includeKeywords, excludeKeywords, matchMode); + if (matchedTerms.length === 0) continue; + matches.push({ + id: item.id, + monitorId: monitor.id, + monitorName: monitor.name || 'Monitor', + monitorColor: monitor.color, + sourceKind: 'cross-source', + title: item.theater, + subtitle: 'Cross-source signal', + summary: item.summary, + timestamp: asTimestamp(item.detectedAt), + severity: crossSourceSeverity(item.severity), + matchedTerms, + }); + } + } + } + + return dedupeMatches(matches).sort((a, b) => b.timestamp - a.timestamp); +} + +export function buildNewsMonitorHighlights( + monitors: Monitor[], + news: NewsItem[], + options?: { proAccess?: boolean }, +): Map { + const matches = evaluateMonitorMatches(monitors, { news }, options) + .filter((match) => match.sourceKind === 'news'); + const out = new Map(); + for (const match of matches) { + if (!match.link || out.has(match.link)) continue; + out.set(match.link, { + color: match.monitorColor, + monitorId: match.monitorId, + matchedTerms: match.matchedTerms, + }); + } + return out; +} + +export function applyMonitorHighlightsToNews( + monitors: Monitor[], + news: NewsItem[], + options?: { proAccess?: boolean }, +): NewsItem[] { + const highlightMap = buildNewsMonitorHighlights(monitors, news, options); + return (news || []).map((item) => { + const highlight = item.link ? highlightMap.get(item.link) : undefined; + return { + ...item, + ...(highlight ? { monitorColor: highlight.color } : { monitorColor: undefined }), + }; + }); +} + +function hasStoredProKey(): boolean { + try { + const cookie = document.cookie || ''; + const cookieEntries = cookie.split(';').map((entry) => entry.trim()).filter(Boolean); + const hasCookieKey = (name: string): boolean => cookieEntries.some((entry) => { + const separatorIndex = entry.indexOf('='); + const key = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : entry.trim(); + if (key !== name) return false; + const rawValue = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : ''; + if (!rawValue) return false; + try { + return decodeURIComponent(rawValue).trim().length > 0; + } catch { + return rawValue.length > 0; + } + }); + if (hasCookieKey('wm-widget-key') || hasCookieKey('wm-pro-key')) return true; + } catch { + // ignore + } + + try { + const hasStoredKey = (name: 'wm-widget-key' | 'wm-pro-key'): boolean => { + const value = localStorage.getItem(name); + return typeof value === 'string' && value.trim().length > 0; + }; + return hasStoredKey('wm-widget-key') || hasStoredKey('wm-pro-key'); + } catch { + return false; + } +} diff --git a/src/services/widget-store.ts b/src/services/widget-store.ts index d59fb2ea40..decdbd2939 100644 --- a/src/services/widget-store.ts +++ b/src/services/widget-store.ts @@ -109,7 +109,10 @@ function usesCookies(): boolean { function getCookieValue(name: string): string { try { - const match = document.cookie.split('; ').find((c) => c.startsWith(`${name}=`)); + const match = document.cookie + .split(';') + .map((entry) => entry.trim()) + .find((entry) => entry.startsWith(`${name}=`)); return match ? match.slice(name.length + 1) : ''; } catch { return ''; @@ -123,8 +126,14 @@ function setDomainCookie(name: string, value: string): void { function getKey(name: string): string { const cookieVal = getCookieValue(name); - if (cookieVal) return decodeURIComponent(cookieVal); - try { return localStorage.getItem(name) ?? ''; } catch { return ''; } + if (cookieVal) { + try { + return decodeURIComponent(cookieVal).trim(); + } catch { + return cookieVal.trim(); + } + } + try { return (localStorage.getItem(name) ?? '').trim(); } catch { return ''; } } export function setWidgetKey(key: string): void { diff --git a/src/types/index.ts b/src/types/index.ts index eff6bd5795..2135068661 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -569,13 +569,23 @@ export interface Earthquake { url: string; } +export type MonitorMatchMode = 'any' | 'all'; +export type MonitorSourceKind = 'news' | 'breaking' | 'advisories' | 'cross-source'; + export interface Monitor { id: string; keywords: string[]; + includeKeywords?: string[]; + excludeKeywords?: string[]; color: string; name?: string; + matchMode?: MonitorMatchMode; + sources?: MonitorSourceKind[]; lat?: number; lon?: number; + radiusKm?: number; + createdAt?: number; + updatedAt?: number; } export interface PanelConfig { diff --git a/src/utils/cross-domain-storage.ts b/src/utils/cross-domain-storage.ts index d072c55425..78b580322a 100644 --- a/src/utils/cross-domain-storage.ts +++ b/src/utils/cross-domain-storage.ts @@ -7,7 +7,10 @@ function usesCookies(): boolean { export function getDismissed(key: string): boolean { if (usesCookies()) { - return document.cookie.split('; ').some((c) => c === `${key}=1`); + return document.cookie + .split(';') + .map((entry) => entry.trim()) + .some((entry) => entry === `${key}=1`); } return localStorage.getItem(key) === '1' || localStorage.getItem(key) === 'true'; } diff --git a/tests/monitors.test.mts b/tests/monitors.test.mts new file mode 100644 index 0000000000..921d902ec2 --- /dev/null +++ b/tests/monitors.test.mts @@ -0,0 +1,368 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + applyMonitorHighlightsToNews, + evaluateMonitorMatches, + hasMonitorProAccess, + mergeMonitorEdits, + normalizeMonitor, + prepareMonitorsForRuntime, +} from '../src/services/monitors.ts'; +import type { Monitor } from '../src/types/index.ts'; +import { getSecretState } from '../src/services/runtime-config.ts'; + +describe('normalizeMonitor', () => { + it('migrates legacy keyword monitors into the richer rule shape', () => { + const monitor = normalizeMonitor({ + id: 'legacy', + keywords: ['Iran', ' Hormuz '], + color: '#fff', + } as Monitor); + + assert.deepEqual(monitor.keywords, ['iran', 'hormuz']); + assert.deepEqual(monitor.includeKeywords, ['iran', 'hormuz']); + assert.deepEqual(monitor.excludeKeywords, []); + assert.deepEqual(monitor.sources, ['news']); + assert.equal(monitor.matchMode, 'any'); + assert.ok(monitor.name); + }); +}); + +describe('prepareMonitorsForRuntime', () => { + it('strips pro-only rule features for free runtime execution', () => { + const runtime = prepareMonitorsForRuntime([{ + id: 'm1', + name: 'Hormuz', + keywords: ['hormuz'], + includeKeywords: ['hormuz'], + excludeKeywords: ['analysis'], + sources: ['news', 'advisories', 'cross-source'], + color: '#0f0', + }], false); + + assert.equal(runtime.length, 1); + assert.deepEqual(runtime[0]?.excludeKeywords, []); + assert.deepEqual(runtime[0]?.sources, ['news']); + }); + + it('falls back to free sources when no free monitor sources remain', () => { + const runtime = prepareMonitorsForRuntime([{ + id: 'm2', + name: 'Advisories only', + keywords: ['hormuz'], + includeKeywords: ['hormuz'], + sources: ['advisories'], + color: '#0f0', + }], false); + + assert.equal(runtime.length, 1); + assert.deepEqual(runtime[0]?.sources, ['news', 'breaking']); + }); +}); + +describe('evaluateMonitorMatches', () => { + it('matches across news, advisories, and cross-source feeds when pro access is enabled', () => { + const monitor: Monitor = { + id: 'm1', + name: 'Hormuz Watch', + keywords: ['hormuz'], + includeKeywords: ['hormuz'], + sources: ['news', 'advisories', 'cross-source'], + color: '#0f0', + }; + + const matches = evaluateMonitorMatches([monitor], { + news: [{ + source: 'Reuters', + title: 'Shipping insurance rises near Hormuz', + link: 'https://example.com/hormuz-news', + pubDate: new Date('2026-03-28T10:00:00Z'), + isAlert: true, + }], + advisories: [{ + title: 'Travel advisory updated for Strait of Hormuz transits', + link: 'https://example.com/hormuz-advisory', + pubDate: new Date('2026-03-28T11:00:00Z'), + source: 'UK FCDO', + sourceCountry: 'GB', + country: 'OM', + level: 'reconsider', + }], + crossSourceSignals: [{ + id: 'sig-1', + type: 'CROSS_SOURCE_SIGNAL_TYPE_SHIPPING_DISRUPTION', + theater: 'Strait of Hormuz', + summary: 'Composite shipping disruption detected around Hormuz traffic lanes.', + severity: 'CROSS_SOURCE_SIGNAL_SEVERITY_HIGH', + severityScore: 82, + detectedAt: Date.parse('2026-03-28T12:00:00Z'), + contributingTypes: ['shipping_disruption', 'market_stress'], + signalCount: 2, + }], + }, { proAccess: true }); + + assert.equal(matches.length, 3); + assert.deepEqual(matches.map((match) => match.sourceKind), ['cross-source', 'advisories', 'news']); + }); + + it('honors exclude keywords when pro access is enabled', () => { + const monitor: Monitor = { + id: 'm2', + name: 'Iran hard match', + keywords: ['iran'], + includeKeywords: ['iran'], + excludeKeywords: ['opinion'], + sources: ['news'], + color: '#f00', + }; + + const matches = evaluateMonitorMatches([monitor], { + news: [{ + source: 'Example', + title: 'Opinion: Iran strategy is shifting', + link: 'https://example.com/opinion', + pubDate: new Date('2026-03-28T10:00:00Z'), + isAlert: false, + }], + }, { proAccess: true }); + + assert.equal(matches.length, 0); + }); + + it('matches close word derivatives for broad monitor terms', () => { + const monitor: Monitor = { + id: 'm3', + name: 'Iran broad', + keywords: ['iran'], + includeKeywords: ['iran'], + sources: ['news'], + color: '#00f', + }; + + const matches = evaluateMonitorMatches([monitor], { + news: [{ + source: 'Example', + title: 'Iranian shipping patterns shift after new sanctions', + link: 'https://example.com/iranian-shipping', + pubDate: new Date('2026-03-28T10:00:00Z'), + isAlert: false, + }], + }, { proAccess: false }); + + assert.equal(matches.length, 1); + assert.equal(matches[0]?.matchedTerms[0], 'iran'); + }); + + it('does not match monitor keywords from URL slug text', () => { + const monitor: Monitor = { + id: 'm4', + name: 'Iran watch', + keywords: ['iran'], + includeKeywords: ['iran'], + sources: ['news'], + color: '#00f', + }; + + const matches = evaluateMonitorMatches([monitor], { + news: [{ + source: 'Example', + title: 'Oil shipping patterns shift in the gulf', + locationName: 'Strait of Hormuz', + description: 'Insurers report no direct state attribution yet.', + link: 'https://example.com/world/iran/oil-markets', + pubDate: new Date('2026-03-28T10:00:00Z'), + isAlert: false, + }], + }, { proAccess: false }); + + assert.equal(matches.length, 0); + }); + + it('falls back to free feeds when pro-only sources are unavailable', () => { + const monitor: Monitor = { + id: 'm5', + name: 'Advisories only', + keywords: ['hormuz'], + includeKeywords: ['hormuz'], + sources: ['advisories'], + color: '#0ff', + }; + + const matches = evaluateMonitorMatches([monitor], { + news: [{ + source: 'Example', + title: 'Hormuz shipping insurance rises', + link: 'https://example.com/hormuz-news', + pubDate: new Date('2026-03-28T10:00:00Z'), + isAlert: false, + }], + breakingAlerts: [{ + id: 'alert-1', + headline: 'Breaking: Hormuz transit disruption reported', + source: 'World Monitor', + threatLevel: 'high', + timestamp: new Date('2026-03-28T10:05:00Z'), + origin: 'keyword_spike', + }], + }, { proAccess: false }); + + assert.equal(matches.length, 2); + assert.deepEqual(matches.map((match) => match.sourceKind).sort(), ['breaking', 'news']); + }); +}); + +describe('applyMonitorHighlightsToNews', () => { + it('annotates matched news items with monitor colors and clears unmatched colors', () => { + const monitor: Monitor = { + id: 'm4', + name: 'China Watch', + keywords: ['china'], + includeKeywords: ['china'], + sources: ['news'], + color: '#abc', + }; + + const highlighted = applyMonitorHighlightsToNews([monitor], [ + { + source: 'Example', + title: 'China export controls tighten', + link: 'https://example.com/china', + pubDate: new Date('2026-03-28T10:00:00Z'), + isAlert: false, + }, + { + source: 'Example', + title: 'Brazil soybean crop outlook improves', + link: 'https://example.com/brazil', + pubDate: new Date('2026-03-28T10:00:00Z'), + isAlert: false, + monitorColor: '#stale', + }, + ], { proAccess: false }); + + assert.equal(highlighted[0]?.monitorColor, '#abc'); + assert.equal(highlighted[1]?.monitorColor, undefined); + }); +}); + +describe('hasMonitorProAccess', () => { + it('requires exact cookie key matches', () => { + if (getSecretState('WORLDMONITOR_API_KEY').present) { + return; + } + + const originalDocument = (globalThis as { document?: unknown }).document; + const originalLocalStorage = (globalThis as { localStorage?: unknown }).localStorage; + + try { + (globalThis as { document?: unknown }).document = { cookie: 'x-wm-widget-key=abc; session=1' }; + (globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null }; + assert.equal(hasMonitorProAccess(), false); + + (globalThis as { document?: unknown }).document = { cookie: 'wm-widget-key=abc; session=1' }; + assert.equal(hasMonitorProAccess(), true); + } finally { + if (originalDocument === undefined) { + delete (globalThis as { document?: unknown }).document; + } else { + (globalThis as { document?: unknown }).document = originalDocument; + } + + if (originalLocalStorage === undefined) { + delete (globalThis as { localStorage?: unknown }).localStorage; + } else { + (globalThis as { localStorage?: unknown }).localStorage = originalLocalStorage; + } + } + }); + + it('requires non-empty cookie values and handles separators without spaces', () => { + if (getSecretState('WORLDMONITOR_API_KEY').present) { + return; + } + + const originalDocument = (globalThis as { document?: unknown }).document; + const originalLocalStorage = (globalThis as { localStorage?: unknown }).localStorage; + + try { + (globalThis as { document?: unknown }).document = { cookie: 'wm-widget-key=;wm-pro-key=' }; + (globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null }; + assert.equal(hasMonitorProAccess(), false); + + (globalThis as { document?: unknown }).document = { cookie: 'wm-widget-key=abc;wm-pro-key=' }; + assert.equal(hasMonitorProAccess(), true); + } finally { + if (originalDocument === undefined) { + delete (globalThis as { document?: unknown }).document; + } else { + (globalThis as { document?: unknown }).document = originalDocument; + } + + if (originalLocalStorage === undefined) { + delete (globalThis as { localStorage?: unknown }).localStorage; + } else { + (globalThis as { localStorage?: unknown }).localStorage = originalLocalStorage; + } + } + }); + + it('requires non-empty localStorage values', () => { + if (getSecretState('WORLDMONITOR_API_KEY').present) { + return; + } + + const originalDocument = (globalThis as { document?: unknown }).document; + const originalLocalStorage = (globalThis as { localStorage?: unknown }).localStorage; + + try { + (globalThis as { document?: unknown }).document = { cookie: '' }; + (globalThis as { localStorage?: unknown }).localStorage = { getItem: () => ' ' }; + assert.equal(hasMonitorProAccess(), false); + + (globalThis as { localStorage?: unknown }).localStorage = { getItem: () => 'abc' }; + assert.equal(hasMonitorProAccess(), true); + } finally { + if (originalDocument === undefined) { + delete (globalThis as { document?: unknown }).document; + } else { + (globalThis as { document?: unknown }).document = originalDocument; + } + + if (originalLocalStorage === undefined) { + delete (globalThis as { localStorage?: unknown }).localStorage; + } else { + (globalThis as { localStorage?: unknown }).localStorage = originalLocalStorage; + } + } + }); +}); + +describe('mergeMonitorEdits', () => { + it('preserves locked pro fields when a free user edits an existing monitor', () => { + const existing: Monitor = normalizeMonitor({ + id: 'm6', + name: 'Locked monitor', + keywords: ['hormuz'], + includeKeywords: ['hormuz'], + excludeKeywords: ['analysis'], + sources: ['advisories'], + color: '#0f0', + }); + + const edited = mergeMonitorEdits(existing, { + id: '', + name: 'Renamed monitor', + keywords: ['hormuz'], + includeKeywords: ['hormuz'], + excludeKeywords: [], + sources: [], + color: existing.color, + matchMode: 'any', + }, false); + + assert.equal(edited.name, 'Renamed monitor'); + assert.deepEqual(edited.excludeKeywords, ['analysis']); + assert.deepEqual(edited.sources, ['advisories']); + }); +});