mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(monitors): harden free-mode fallback and highlight sync
This commit is contained in:
@@ -2546,6 +2546,7 @@ export class DataLoaderManager implements AppModule {
|
||||
items,
|
||||
{ proAccess: hasMonitorProAccess() },
|
||||
);
|
||||
this.ctx.newsByCategory[category] = highlightedItems;
|
||||
this.renderNewsForCategory(category, highlightedItems);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user