From 7690ed17ee250738691b3da301454a5600a82683 Mon Sep 17 00:00:00 2001 From: davegilligan Date: Fri, 15 May 2026 00:59:12 +0200 Subject: [PATCH] feat(timeline): full form UI with engine selection and advanced settings Add 4-language switcher (EN/NO/UK/PL), engine choice (Azure mini/full, GPU/cuttlefish), and expandable Advanced panel (Focus, Confidence filter, Date types) to timeline.php. Wire new params through api/timeline.php and LegalTools::timeline() with engine routing, focus-aware prompt injection, and confidence/date-type post-filters. Add TIMELINE_I18N to tools.js with improved renderTimeline() confidence colour-coding and new CSS classes. Co-Authored-By: Claude Sonnet 4.6 --- api/timeline.php | 16 ++- assets/css/tools.css | 50 ++++++++- assets/js/tools.js | 218 ++++++++++++++++++++++++++++++++++++++-- includes/LegalTools.php | 107 ++++++++++++++++---- timeline.php | 99 +++++++++++++++++- 5 files changed, 455 insertions(+), 35 deletions(-) diff --git a/api/timeline.php b/api/timeline.php index 31f3323..84f9a71 100644 --- a/api/timeline.php +++ b/api/timeline.php @@ -10,5 +10,19 @@ $language = dbnToolsNormalizeLanguage($input['language'] ?? 'en'); dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language): array { $text = dbnToolsString($input, 'text', 128000); - return (new DbnLegalToolsService())->timeline($text, $language); + + $validEngines = ['azure_mini', 'azure_full', 'gpu']; + $engine = in_array((string)($input['engine'] ?? ''), $validEngines, true) + ? (string)$input['engine'] : 'azure_mini'; + + $validFocus = ['all', 'deadlines', 'hearings', 'cps']; + $focus = in_array((string)($input['focus'] ?? ''), $validFocus, true) + ? (string)$input['focus'] : 'all'; + + $confidenceFilter = (string)($input['confidence_filter'] ?? '') === 'high_medium' + ? 'high_medium' : 'all'; + + $includeRelative = ($input['include_relative'] ?? true) !== false; + + return (new DbnLegalToolsService())->timeline($text, $language, $engine, $focus, $confidenceFilter, $includeRelative); }); diff --git a/assets/css/tools.css b/assets/css/tools.css index 03d1d74..c1c5153 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -362,25 +362,65 @@ p { .timeline-list { display: grid; gap: 10px; - padding-left: 20px; + padding-left: 0; + list-style: none; } -.timeline-list li { - padding-left: 4px; +.timeline-item { + padding: 10px 12px 10px 14px; + border-left: 4px solid var(--line); + border-radius: 0 6px 6px 0; + background: var(--bg-card, #fff); } -.timeline-list span { +.timeline-item.confidence-high { border-left-color: #16a34a; } +.timeline-item.confidence-medium { border-left-color: #d97706; } +.timeline-item.confidence-low { border-left-color: #9ca3af; opacity: 0.75; font-style: italic; } + +.timeline-header { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +.timeline-date { + font-size: 0.95rem; +} + +.timeline-actor { display: block; color: var(--teal-dark); font-weight: 700; + font-size: 0.85rem; + margin-bottom: 3px; } -.timeline-list small { +.timeline-event { + margin: 4px 0; +} + +.timeline-excerpt { display: block; margin-top: 5px; color: var(--muted); + font-size: 0.82rem; } +.confidence-badge { + font-size: 0.7rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.03em; + font-style: normal; +} +.confidence-badge--high { background: #dcfce7; color: #15803d; } +.confidence-badge--medium { background: #fef3c7; color: #b45309; } +.confidence-badge--low { background: #f3f4f6; color: #6b7280; } + .redacted-output { max-height: 420px; overflow: auto; diff --git a/assets/js/tools.js b/assets/js/tools.js index df67b45..7967700 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -222,6 +222,141 @@ const REDACT_I18N = { }, }; +const TIMELINE_I18N = { + en: { + timelineEngine: 'Engine', + timelineEngineAzureMini: 'Azure gpt-4o-mini', + timelineEngineAzureFull: 'Azure gpt-4o', + timelineEngineGpu: 'GPU (cuttlefish)', + timelineEngineHint: 'Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on cuttlefish.', + timelineAdvancedToggle: 'Advanced settings', + timelineFocus: 'Focus', + timelineFocusAll: 'All events', + timelineFocusDeadlines: 'Legal deadlines', + timelineFocusHearings: 'Court hearings', + timelineFocusCps: 'CPS milestones', + timelineFocusHint: 'All events: every temporal reference. Legal deadlines: filing dates, appeal windows, statutory limits. Court hearings: tribunal and mediation sessions. CPS milestones: Barnevernet interventions and Fylkesnemnda proceedings.', + timelineConfidence: 'Confidence', + timelineConfidenceAll: 'Show all events', + timelineConfidenceHighMed: 'Hide low-confidence', + timelineConfidenceHint: 'Show all: includes uncertain events (shown in grey). Hide low-confidence: only returns events the model is reasonably sure of.', + timelineDates: 'Date types', + timelineIncludeRelative: 'Include relative / recurring dates', + timelineDatesHint: 'When checked, relative references ("three weeks later", "every Monday") are included. Uncheck to return only exact calendar dates.', + timelineUploadAria: 'File upload', + timelineUploadDrop: 'Drop up to 5 files here, or', + timelineUploadBrowse: 'browse', + timelineUploadHint: 'text extracted in memory, never stored', + timelineUploadClear: '× Clear', + timelineInputLabel: 'Pasted text', + timelineInputPlaceholder: 'Paste case notes, court decisions, or correspondence containing dates and events.', + timelineRun: 'Run', + timelineRunning: 'Building timeline…', + timelineReadyTitle: 'Ready', + timelineReadyDesc: 'Paste text or upload a file, configure options, then run.', + timelineExportCsv: 'Download CSV', + }, + no: { + timelineEngine: 'Motor', + timelineEngineAzureMini: 'Azure gpt-4o-mini', + timelineEngineAzureFull: 'Azure gpt-4o', + timelineEngineGpu: 'GPU (cuttlefish)', + timelineEngineHint: 'Azure-motorer bruker BNL Azure-kreditter. GPU kjører lokal LiteLLM-proxy på cuttlefish.', + timelineAdvancedToggle: 'Avanserte innstillinger', + timelineFocus: 'Fokus', + timelineFocusAll: 'Alle hendelser', + timelineFocusDeadlines: 'Juridiske frister', + timelineFocusHearings: 'Rettsmøter', + timelineFocusCps: 'Barnevernet-milepæler', + timelineFocusHint: 'Alle hendelser: alle temporale referanser. Juridiske frister: innleveringsfrister, ankefrister, lovpålagte frister. Rettsmøter: domstol- og meklingsmøter. Barnevernet: akuttvedtak og Fylkesnemnda-saker.', + timelineConfidence: 'Sikkerhet', + timelineConfidenceAll: 'Vis alle hendelser', + timelineConfidenceHighMed: 'Skjul usikre hendelser', + timelineConfidenceHint: 'Vis alle: inkluderer usikre hendelser (vist i grått). Skjul usikre: returnerer bare hendelser modellen er rimelig sikker på.', + timelineDates: 'Datotyper', + timelineIncludeRelative: 'Inkluder relative / gjentakende datoer', + timelineDatesHint: 'Når avkrysset inkluderes relative referanser ("tre uker senere", "hver mandag"). Fjern haken for å returnere kun eksakte kalenderdatoer.', + timelineUploadAria: 'Filopplasting', + timelineUploadDrop: 'Slipp opptil 5 filer her, eller', + timelineUploadBrowse: 'bla', + timelineUploadHint: 'tekst hentes i minnet, lagres aldri', + timelineUploadClear: '× Tøm', + timelineInputLabel: 'Limt inn tekst', + timelineInputPlaceholder: 'Lim inn saksdokumenter, rettsavgjørelser eller korrespondanse med datoer og hendelser.', + timelineRun: 'Kjør', + timelineRunning: 'Bygger tidslinje…', + timelineReadyTitle: 'Klar', + timelineReadyDesc: 'Lim inn tekst eller last opp en fil, konfigurer alternativene, og kjør.', + timelineExportCsv: 'Last ned CSV', + }, + uk: { + timelineEngine: 'Рушій', + timelineEngineAzureMini: 'Azure gpt-4o-mini', + timelineEngineAzureFull: 'Azure gpt-4o', + timelineEngineGpu: 'GPU (cuttlefish)', + timelineEngineHint: 'Рушії Azure використовують кредити BNL Azure. GPU запускає локальний проксі LiteLLM на cuttlefish.', + timelineAdvancedToggle: 'Розширені налаштування', + timelineFocus: 'Фокус', + timelineFocusAll: 'Всі події', + timelineFocusDeadlines: 'Юридичні терміни', + timelineFocusHearings: 'Судові засідання', + timelineFocusCps: 'Вехи органів опіки', + timelineFocusHint: 'Всі події: кожне часове посилання. Юридичні терміни: дати подання, вікна апеляції. Судові засідання: трибунали та медіації. Опіка: втручання Barnevernet та провадження Fylkesnemnda.', + timelineConfidence: 'Достовірність', + timelineConfidenceAll: 'Показати всі події', + timelineConfidenceHighMed: 'Приховати малодостовірні', + timelineConfidenceHint: 'Показати всі: включає непевні події (сірим). Приховати малодостовірні: повертає лише впевнено визначені події.', + timelineDates: 'Типи дат', + timelineIncludeRelative: 'Включити відносні / повторювані дати', + timelineDatesHint: 'Якщо позначено, відносні посилання ("три тижні потому", "щопонеділка") включаються. Зніміть, щоб повертати лише точні календарні дати.', + timelineUploadAria: 'Завантаження файлів', + timelineUploadDrop: 'Перетягніть до 5 файлів сюди, або', + timelineUploadBrowse: 'огляд', + timelineUploadHint: 'текст обробляється в памʼяті, ніколи не зберігається', + timelineUploadClear: '× Очистити', + timelineInputLabel: 'Вставлений текст', + timelineInputPlaceholder: 'Вставте нотатки справи, судові рішення або кореспонденцію з датами та подіями.', + timelineRun: 'Запустити', + timelineRunning: 'Будую хронологію…', + timelineReadyTitle: 'Готово', + timelineReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.', + timelineExportCsv: 'Завантажити CSV', + }, + pl: { + timelineEngine: 'Silnik', + timelineEngineAzureMini: 'Azure gpt-4o-mini', + timelineEngineAzureFull: 'Azure gpt-4o', + timelineEngineGpu: 'GPU (cuttlefish)', + timelineEngineHint: 'Silniki Azure używają kredytów Azure BNL. GPU korzysta z lokalnego proxy LiteLLM na cuttlefish.', + timelineAdvancedToggle: 'Ustawienia zaawansowane', + timelineFocus: 'Fokus', + timelineFocusAll: 'Wszystkie zdarzenia', + timelineFocusDeadlines: 'Terminy prawne', + timelineFocusHearings: 'Rozprawy sądowe', + timelineFocusCps: 'Kamienie milowe OPS', + timelineFocusHint: 'Wszystkie: każde odniesienie czasowe. Terminy: daty złożenia, okna apelacyjne. Rozprawy: trybunały i mediacje. OPS: interwencje Barnevernet i postępowania Fylkesnemnda.', + timelineConfidence: 'Pewność', + timelineConfidenceAll: 'Pokaż wszystkie zdarzenia', + timelineConfidenceHighMed: 'Ukryj mało pewne', + timelineConfidenceHint: 'Pokaż wszystkie: zawiera niepewne zdarzenia (szarym). Ukryj mało pewne: zwraca tylko zdarzenia o rozsądnej pewności.', + timelineDates: 'Typy dat', + timelineIncludeRelative: 'Uwzględnij daty względne / cykliczne', + timelineDatesHint: 'Gdy zaznaczone, odniesienia względne ("trzy tygodnie później", "co poniedziałek") są uwzględniane. Odznacz, aby zwracać tylko dokładne daty kalendarzowe.', + timelineUploadAria: 'Przesyłanie pliku', + timelineUploadDrop: 'Upuść do 5 plików tutaj lub', + timelineUploadBrowse: 'przeglądaj', + timelineUploadHint: 'tekst wyodrębniany w pamięci, nigdy nie przechowywany', + timelineUploadClear: '× Wyczyść', + timelineInputLabel: 'Wklejony tekst', + timelineInputPlaceholder: 'Wklej notatki sprawy, decyzje sądowe lub korespondencję z datami i zdarzeniami.', + timelineRun: 'Uruchom', + timelineRunning: 'Tworzę oś czasu…', + timelineReadyTitle: 'Gotowe', + timelineReadyDesc: 'Wklej tekst lub wgraj plik, skonfiguruj opcje, uruchom.', + timelineExportCsv: 'Pobierz CSV', + }, +}; + let lastTimelineEvents = []; let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}] let lastTranscriptData = null; @@ -585,6 +720,56 @@ function setupRedactControls() { applyRedactI18n(uiLang); } +function currentTimelineT(key) { + const t = TIMELINE_I18N[uiLang] || TIMELINE_I18N.en; + return (key in t) ? t[key] : (TIMELINE_I18N.en[key] ?? key); +} + +function applyTimelineI18n(lang) { + uiLang = lang; + localStorage.setItem('dbn-ui-lang', lang); + document.querySelectorAll('[data-i18n]').forEach((el) => { + const text = currentTimelineT(el.dataset.i18n); + if (text != null) el.textContent = text; + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { + const text = currentTimelineT(el.dataset.i18nPlaceholder); + if (text != null) el.placeholder = text; + }); + document.querySelectorAll('[data-i18n-aria]').forEach((el) => { + const text = currentTimelineT(el.dataset.i18nAria); + if (text != null) el.setAttribute('aria-label', text); + }); + document.querySelectorAll('#timelineLangSwitcher .lang-btn').forEach((btn) => { + btn.classList.toggle('is-active', btn.dataset.lang === lang); + }); +} + +function currentTimelineEngine() { + return document.querySelector('input[name="timelineEngine"]:checked')?.value || 'azure_mini'; +} + +function currentTimelineFocus() { + return document.querySelector('input[name="timelineFocus"]:checked')?.value || 'all'; +} + +function currentConfidenceFilter() { + return document.querySelector('input[name="confidenceFilter"]:checked')?.value || 'all'; +} + +function currentIncludeRelative() { + return document.getElementById('includeRelativeCheck')?.checked ?? true; +} + +function setupTimelineControls() { + const switcher = document.getElementById('timelineLangSwitcher'); + if (!switcher) return; + switcher.querySelectorAll('.lang-btn').forEach((btn) => { + btn.addEventListener('click', () => applyTimelineI18n(btn.dataset.lang)); + }); + applyTimelineI18n(uiLang); +} + function setupExemptNames() { const addBtn = document.getElementById('addExemptRow'); const rows = document.getElementById('exemptRows'); @@ -738,6 +923,7 @@ document.addEventListener('DOMContentLoaded', () => { setupVocabPresets(); setupRedactControls(); setupExemptNames(); + setupTimelineControls(); // Wire transcribe lang switcher (only present on transcribe page) document.querySelectorAll('#uiLangSwitcher .lang-btn').forEach((btn) => { btn.addEventListener('click', () => applyTranscribeI18n(btn.dataset.lang)); @@ -852,6 +1038,12 @@ async function runTool(event) { payload.exempt_names = getExemptNames(); payload.redact_types = currentRedactTypes(); } + if (state.activeTool === 'timeline') { + payload.engine = currentTimelineEngine(); + payload.focus = currentTimelineFocus(); + payload.confidence_filter = currentConfidenceFilter(); + payload.include_relative = currentIncludeRelative(); + } setBusy(true); renderTrace([ @@ -1109,8 +1301,9 @@ function renderMainFinding(data) { } if (data.tool === 'timeline') { lastTimelineEvents = data.events || []; + const csvLabel = currentTimelineT('timelineExportCsv') || 'Download CSV'; const csvBtn = lastTimelineEvents.length - ? `
` + ? `
` : ''; return `

${escapeHtml(data.what_we_found || '')}

${renderTimeline(lastTimelineEvents)}${csvBtn}`; } @@ -1172,15 +1365,20 @@ function renderTimeline(events) { if (!events.length) { return '

No events were identified.

'; } - return `
    ${events.map((ev) => ` -
  1. - ${escapeHtml(ev.date || 'unknown')} - ${ev.date_type ? `${escapeHtml(ev.date_type)}` : ''} - ${escapeHtml(ev.actor || 'unknown actor')} -

    ${escapeHtml(ev.event || '')}

    - ${ev.source_excerpt ? `${escapeHtml(ev.source_excerpt)}` : ''} -
  2. - `).join('')}
`; + return `
    ${events.map((ev) => { + const conf = ev.confidence || 'medium'; + return ` +
  1. +
    + ${escapeHtml(ev.date || 'unknown')} + ${ev.date_type ? `${escapeHtml(ev.date_type)}` : ''} + ${escapeHtml(conf)} +
    + ${escapeHtml(ev.actor || 'unknown actor')} +

    ${escapeHtml(ev.event || '')}

    + ${ev.source_excerpt ? `${escapeHtml(ev.source_excerpt)}` : ''} +
  2. `; + }).join('')}
`; } function exportTimelineCSV(events) { diff --git a/includes/LegalTools.php b/includes/LegalTools.php index 2c577ae..2eeb13c 100644 --- a/includes/LegalTools.php +++ b/includes/LegalTools.php @@ -279,16 +279,35 @@ PROMPT; ]; } - public function timeline(string $text, string $language = 'en'): array - { - $text = $this->requirePasteText($text); - $this->azure->requireChat(); + public function timeline( + string $text, + string $language = 'en', + string $engine = 'azure_mini', + string $focus = 'all', + string $confidenceFilter = 'all', + bool $includeRelative = true + ): array { + $text = $this->requirePasteText($text); + $engine = in_array($engine, ['azure_mini', 'azure_full', 'gpu'], true) ? $engine : 'azure_mini'; + $focus = in_array($focus, ['all', 'deadlines', 'hearings', 'cps'], true) ? $focus : 'all'; + + if ($engine !== 'gpu') { + $this->azure->requireChat(); + } $locale = $language === 'no' ? 'Norwegian' : 'English'; + + $focusInstruction = match ($focus) { + 'deadlines' => "\nFocus specifically on: legal deadlines, filing dates, response windows, appeal periods, and statutory time limits. Deprioritise narrative events with no legal deadline significance.", + 'hearings' => "\nFocus specifically on: court hearings, tribunal sessions, mediation sessions, formal meetings, and hearing-related procedural dates.", + 'cps' => "\nFocus specifically on: CPS (Barnevernet) interventions, home visits, case reviews, acute measures (akuttvedtak), and Fylkesnemnda proceedings.", + default => '', + }; + $prompt = <<runJsonTool($prompt, $language, 4000); + $system = $this->legalJsonSystemPrompt($language); + $messages = [ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => $prompt], + ]; + $chatOptions = ['json' => true, 'temperature' => 0.1, 'max_tokens' => 4000]; + $deployLabel = $this->azure->chatDeployment(); + + try { + if ($engine === 'gpu') { + $response = $this->callGpuLlm($messages, $chatOptions); + $deployLabel = 'GPU (cuttlefish)'; + } elseif ($engine === 'azure_full') { + $response = $this->azure->withDeployment('gpt-4o')->chat($messages, $chatOptions); + $deployLabel = 'gpt-4o'; + } else { + $response = $this->azure->chat($messages, $chatOptions); + $deployLabel = $this->azure->chatDeployment(); + } + } catch (Throwable $e) { + dbnToolsAbort('LLM request failed: ' . $e->getMessage(), 502, 'llm_error'); + } + + $raw = (string)($response['choices'][0]['message']['content'] ?? ''); + $json = $this->azure->decodeJsonObject($raw); + if (!$json) { + dbnToolsAbort('The selected engine did not return valid structured JSON.', 502, 'llm_invalid_json'); + } + $events = is_array($json['events'] ?? null) ? $json['events'] : []; + + // Post-filter: confidence + if ($confidenceFilter === 'high_medium') { + $events = array_values(array_filter($events, fn($ev) => ($ev['confidence'] ?? 'low') !== 'low')); + } + + // Post-filter: relative/recurring date types + if (!$includeRelative) { + $events = array_values(array_filter($events, fn($ev) => ($ev['date_type'] ?? 'absolute') === 'absolute')); + } + + $engineLabel = match ($engine) { + 'gpu' => 'GPU (cuttlefish)', + 'azure_full' => 'gpt-4o', + default => $deployLabel ?? $this->azure->chatDeployment(), + }; + + $focusLabel = match ($focus) { + 'deadlines' => 'legal deadlines', + 'hearings' => 'court hearings', + 'cps' => 'CPS milestones', + default => 'all events', + }; + $trace = [ - $this->trace('Query interpretation', 'Extract dated events from pasted text without saving the text or output.', 'complete'), + $this->trace('Query interpretation', "Extract {$focusLabel} from pasted text. Engine: {$engineLabel}. Without saving the text or output.", 'complete'), $this->trace('Search tools used', 'No external corpus search; source is the user-pasted text.', 'complete'), - $this->trace('Evidence found', count($events) . ' event(s) identified.', count($events) ? 'complete' : 'warning'), + $this->trace('Evidence found', count($events) . ' event(s) identified' . ($confidenceFilter === 'high_medium' ? ' (low-confidence filtered out)' : '') . '.', count($events) ? 'complete' : 'warning'), $this->trace('Citation confidence', 'Confidence is per event and based only on the pasted text.', 'complete'), $this->trace('Uncertainty / missing evidence', $this->uncertaintySummary($json['what_remains_uncertain'] ?? []), 'complete'), $this->trace('Next practical step', (string)($json['next_practical_step'] ?? 'Verify dates against original documents.'), 'complete'), ]; return [ - 'tool' => 'timeline', - 'language' => $language, - 'what_we_found' => (string)($json['what_we_found'] ?? ''), - 'events' => $events, - 'evidence_trail' => $json['evidence_trail'] ?? [['title' => 'Pasted text', 'excerpt' => 'Processed in-memory only; not stored.']], + 'tool' => 'timeline', + 'language' => $language, + 'what_we_found' => (string)($json['what_we_found'] ?? ''), + 'events' => $events, + 'evidence_trail' => $json['evidence_trail'] ?? [['title' => 'Pasted text', 'excerpt' => 'Processed in-memory only; not stored.']], 'what_remains_uncertain' => $json['what_remains_uncertain'] ?? [], - 'next_practical_step' => (string)($json['next_practical_step'] ?? ''), - 'trace' => $trace, - 'trace_metadata' => [ - 'chunk_count' => count($events), + 'next_practical_step' => (string)($json['next_practical_step'] ?? ''), + 'trace' => $trace, + 'trace_metadata' => [ + 'chunk_count' => count($events), 'source_count' => 1, - 'deployment' => $this->azure->chatDeployment(), + 'deployment' => $engineLabel, ], 'disclaimer' => dbnToolsDisclaimer($language), ]; diff --git a/timeline.php b/timeline.php index 7bf69d7..1914dda 100644 --- a/timeline.php +++ b/timeline.php @@ -6,5 +6,102 @@ $toolKind = 'Timeline Builder'; $toolBadge = 'process-and-forget'; require_once __DIR__ . '/includes/layout.php'; ?> - +
+ +
+ + + + +
+ +
+ Engine + + + +
+

Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy on cuttlefish.

+ +
+ Advanced settings + +
+ Focus + + + + +
+

All events: extract every temporal reference. Legal deadlines: filing dates, appeal windows, statutory limits. Court hearings: tribunal and mediation sessions. CPS milestones: Barnevernet interventions and Fylkesnemnda proceedings.

+ +
+ Confidence + + +
+

Show all: includes events the model is uncertain about (shown in grey). Hide low-confidence: only returns events the model is reasonably sure of.

+ +
+ Date types + +
+

When checked, relative references ("three weeks later", "every Monday") and recurring dates are included. Uncheck to return only exact calendar dates.

+ +
+ +
+ +
+ +

Drop up to 5 files here, or

+

PDF, DOCX, TXTtext extracted in memory, never stored

+
+ +
+ + + + + +
+ +
+
+

Ready

+

Paste text or upload a file, configure options, then run.

+
+
+ + + + + + + + + +