fix(monitors): harden free-mode fallback and highlight sync

This commit is contained in:
fayez bast
2026-03-31 04:05:19 +03:00
parent 5ec7ec808c
commit 187646ad8d
6 changed files with 287 additions and 22 deletions

View File

@@ -2546,6 +2546,7 @@ export class DataLoaderManager implements AppModule {
items,
{ proAccess: hasMonitorProAccess() },
);
this.ctx.newsByCategory[category] = highlightedItems;
this.renderNewsForCategory(category, highlightedItems);
});

View File

@@ -5,6 +5,7 @@ import {
FREE_MONITOR_LIMIT,
evaluateMonitorMatches,
hasMonitorProAccess,
mergeMonitorEdits,
monitorUsesProFeatures,
normalizeMonitor,
normalizeMonitors,
@@ -222,12 +223,12 @@ export class MonitorPanel extends Panel {
this.statusEl.style.color = tone === 'warn' ? getCSSColor('--semantic-elevated') : 'var(--text-dim)';
}
private selectedSources(): MonitorSourceKind[] {
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 ? out : ['news'];
return out.length > 0 || !fallbackWhenEmpty ? out : ['news'];
}
private addMonitor(): void {
@@ -242,7 +243,11 @@ export class MonitorPanel extends Panel {
}
const excludeKeywords = parseKeywords(this.excludeInput?.value || '');
const sources = this.selectedSources();
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' });
@@ -250,7 +255,7 @@ export class MonitorPanel extends Panel {
return;
}
const monitor = normalizeMonitor({
const draftMonitor: Monitor = {
id: '',
name: this.nameInput?.value.trim() || undefined,
keywords: includeKeywords,
@@ -259,22 +264,25 @@ export class MonitorPanel extends Panel {
color: MONITOR_COLORS[this.monitors.length % MONITOR_COLORS.length] ?? getCSSColor('--status-live'),
matchMode: (this.modeSelect?.value === 'all' ? 'all' : 'any') as MonitorMatchMode,
sources,
}, this.monitors.length);
};
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,
...monitor,
...nextMonitor,
id: existing.id,
color: existing.color,
createdAt: existing.createdAt,
}, idx);
}
} else {
this.monitors.push(monitor);
this.monitors.push(normalizeMonitor(draftMonitor, this.monitors.length));
}
this.resetComposer();
@@ -295,17 +303,25 @@ export class MonitorPanel extends Panel {
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 = (monitor.excludeKeywords ?? []).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) {
input.checked = (monitor.sources ?? ['news']).includes(source);
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';
this.setComposerStatus(t('components.monitor.editing'), 'info');
if (!proAccess && monitorUsesProFeatures(monitor)) {
this.setComposerStatus(t('components.monitor.lockedRule'), 'warn');
} else {
this.setComposerStatus(t('components.monitor.editing'), 'info');
}
}
private cancelEdit(): void {

View File

@@ -60,17 +60,31 @@ function uniqueKeywords(items: string[] | undefined): string[] {
return out;
}
function normalizeSources(items: MonitorSourceKind[] | undefined): MonitorSourceKind[] {
function uniqueSources(items: MonitorSourceKind[] | undefined): MonitorSourceKind[] {
const out: MonitorSourceKind[] = [];
const seen = new Set<MonitorSourceKind>();
for (const item of items || DEFAULT_MONITOR_SOURCES) {
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(' + ');
@@ -118,11 +132,23 @@ export function prepareMonitorsForRuntime(monitors: Monitor[], proAccess = hasMo
return {
...monitor,
excludeKeywords: [],
sources: monitor.sources?.filter((source) => FREE_MONITOR_SOURCES.includes(source)) ?? [...FREE_MONITOR_SOURCES],
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;
@@ -215,7 +241,7 @@ export function evaluateMonitorMatches(
const includeKeywords = uniqueKeywords(monitor.includeKeywords ?? monitor.keywords);
const excludeKeywords = uniqueKeywords(monitor.excludeKeywords);
const matchMode = monitor.matchMode === 'all' ? 'all' : 'any';
const sources = normalizeSources(monitor.sources);
const sources = resolveSourcesForMatching(monitor.sources);
if (sources.includes('news')) {
for (const item of feed.news || []) {
@@ -223,9 +249,7 @@ export function evaluateMonitorMatches(
|| trimText((item as NewsItem & { description?: string; summary?: string }).summary);
const haystack = [
item.title,
item.source,
item.locationName,
item.link,
extraDescription,
].filter(Boolean).join(' ').toLowerCase();
const matchedTerms = evaluateTextRule(haystack, includeKeywords, excludeKeywords, matchMode);
@@ -368,13 +392,30 @@ export function applyMonitorHighlightsToNews(
function hasStoredProKey(): boolean {
try {
const cookie = document.cookie || '';
if (cookie.includes('wm-widget-key=') || cookie.includes('wm-pro-key=')) return true;
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 {
return Boolean(localStorage.getItem('wm-widget-key') || localStorage.getItem('wm-pro-key'));
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;
}

View File

@@ -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 {

View File

@@ -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';
}

View File

@@ -4,10 +4,13 @@ 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', () => {
@@ -42,6 +45,20 @@ describe('prepareMonitorsForRuntime', () => {
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', () => {
@@ -136,6 +153,63 @@ describe('evaluateMonitorMatches', () => {
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', () => {
@@ -171,3 +245,124 @@ describe('applyMonitorHighlightsToNews', () => {
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']);
});
});