diff --git a/config/areafix_grammars.json.example b/config/areafix_grammars.json.example new file mode 100644 index 000000000..ef266403d --- /dev/null +++ b/config/areafix_grammars.json.example @@ -0,0 +1,14 @@ +[ + { + "id": "example_hub_format", + "enabled": false, + "header_pattern": "My Hub Mailer v[0-9.]+ Area Report", + "row_pattern": "^(?[A-Za-z0-9_\\-.]+)\\s{2,}(?\\S+)?\\s{2,}(?.*)$", + "stop_pattern": "^-{3,}", + "default_action": "available", + "status_rules": [ + { "pattern": "^linked$", "action": "subscribe" }, + { "pattern": "^unlinked$", "action": "unsubscribe" } + ] + } +] diff --git a/config/i18n/de/common.php b/config/i18n/de/common.php index 7de22cd3e..2c6169215 100644 --- a/config/i18n/de/common.php +++ b/config/i18n/de/common.php @@ -22,6 +22,7 @@ 'ui.common.error' => 'Fehler', 'ui.common.unknown_error' => 'Unbekannter Fehler', 'ui.common.saving' => 'Speichern...', + 'ui.common.processing' => 'Verarbeitung läuft...', 'ui.common.copy_failed_manual' => 'Kopieren in die Zwischenablage fehlgeschlagen. Bitte manuell kopieren.', 'ui.common.copy_not_supported_manual' => 'Kopieren in die Zwischenablage wird nicht unterstützt. Bitte manuell kopieren.', 'ui.common.loading' => 'Laden...', @@ -1829,6 +1830,42 @@ 'ui.admin.webdoors_config.true' => 'true', 'ui.admin.webdoors_config.false' => 'false', 'ui.admin.webdoors_config.not_in_config' => '(nicht in Konfiguration)', + 'ui.admin.areafix_grammars.page_title' => 'AreaFix-Grammatiken', + 'ui.admin.areafix_grammars.heading' => 'AreaFix-Grammatiken', + 'ui.admin.areafix_grammars.info_text_prefix' => 'Datengesteuerte AreaFix/FileFix-Antwortgrammatiken werden definiert in', + 'ui.admin.areafix_grammars.info_text_suffix' => '. Eingebaute Grammatiken werden zuerst versucht; diese werden vor dem Freiform-Fallback versucht.', + 'ui.admin.areafix_grammars.doc_hint' => 'Siehe die Schemareferenz in', + 'ui.admin.areafix_grammars.grammars_list_heading' => 'Definierte Grammatiken', + 'ui.admin.areafix_grammars.no_grammars_defined' => 'Keine Grammatiken definiert.', + 'ui.admin.areafix_grammars.invalid_json' => 'Ungültiges JSON.', + 'ui.admin.areafix_grammars.invalid_entry_label' => '(ungültiger Eintrag)', + 'ui.admin.areafix_grammars.enabled_label' => 'aktiviert', + 'ui.admin.areafix_grammars.disabled_label' => 'deaktiviert', + 'ui.admin.areafix_grammars.format_json' => 'JSON formatieren', + 'ui.admin.areafix_grammars.waiting_for_config' => 'Warte auf Konfiguration...', + 'ui.admin.areafix_grammars.json_validation_before_save' => 'Die JSON-Validierung läuft vor dem Speichern.', + 'ui.admin.areafix_grammars.json_valid' => 'JSON ist gültig', + 'ui.admin.areafix_grammars.json_has_errors' => 'JSON enthält Fehler', + 'ui.admin.areafix_grammars.cannot_format_invalid_json' => 'Formatierung nicht möglich: ungültiges JSON', + 'ui.admin.areafix_grammars.fix_json_before_save' => 'Bitte JSON-Fehler vor dem Speichern beheben.', + 'ui.admin.areafix_grammars.save_failed' => 'Speichern der Konfiguration fehlgeschlagen', + 'ui.admin.areafix_grammars.saved_success' => 'AreaFix-Grammatikkonfiguration gespeichert.', + 'ui.admin.areafix_grammars.load_failed' => 'Laden der Konfiguration fehlgeschlagen', + 'ui.admin.areafix_grammars.config_filename' => 'areafix_grammars.json', + 'ui.admin.areafix_grammars.populate_from_example' => 'Aus Beispiel übernehmen', + 'ui.admin.areafix_grammars.populated_from_example' => 'Editor aus areafix_grammars.json.example befüllt. Bitte prüfen und zum Übernehmen auf Speichern klicken.', + 'ui.admin.areafix_grammars.paste_from_message' => 'Aus AreaFix-Nachricht einfügen', + 'ui.admin.areafix_grammars.ai_modal_title' => 'Grammatik aus AreaFix-Nachricht generieren', + 'ui.admin.areafix_grammars.ai_modal_instructions' => 'Fügen Sie unten den Rohtext einer AreaFix/FileFix-Antwort ein. Die KI schlägt eine Grammatikdefinition vor, die deaktiviert hinzugefügt wird, damit Sie sie vor dem Aktivieren prüfen können.', + 'ui.admin.areafix_grammars.ai_message_text_label' => 'Text der AreaFix/FileFix-Antwortnachricht', + 'ui.admin.areafix_grammars.ai_suggested_grammar_label' => 'Vorgeschlagene Grammatik', + 'ui.admin.areafix_grammars.ai_generate_button' => 'Mit KI generieren', + 'ui.admin.areafix_grammars.ai_generating' => 'Generiere...', + 'ui.admin.areafix_grammars.ai_insert_button' => 'In Editor einfügen', + 'ui.admin.areafix_grammars.ai_message_text_required' => 'Bitte zuerst den Nachrichtentext einfügen.', + 'ui.admin.areafix_grammars.ai_generate_failed' => 'Generieren der Grammatik fehlgeschlagen', + 'ui.admin.areafix_grammars.ai_generated_success' => 'Grammatikvorschlag generiert. Er ist standardmäßig deaktiviert — prüfen Sie ihn und klicken Sie auf Einfügen, um ihn zum Editor hinzuzufügen.', + 'ui.admin.areafix_grammars.ai_inserted_into_editor' => 'KI-vorgeschlagene Grammatik zum Editor hinzugefügt (deaktiviert). Prüfen Sie sie und klicken Sie auf Speichern, um sie zu übernehmen.', // Admin JS-DOS Doors Config 'ui.admin.jsdosdoors_config.page_title' => 'JS-DOS-Doors-Konfiguration', @@ -4842,6 +4879,7 @@ // AreaFix / FileFix Manager 'ui.base.admin.areafix' => 'AreaFix / DateiFix', + 'ui.base.admin.areafix_grammars' => 'AreaFix-Grammatiken', 'ui.admin.areafix.page_title' => 'AreaFix / DateiFix Manager', 'ui.admin.areafix.heading' => 'AreaFix / DateiFix Manager', 'ui.admin.areafix.not_configured_title' => 'Keine Uplinks für AreaFix / DateiFix konfiguriert.', @@ -4888,6 +4926,30 @@ 'ui.admin.areafix.sync_ok' => 'Sync complete: {created} erstellt, {activated} activated, {deactivated} deactivated', 'ui.admin.areafix.sync_failed' => 'Sync failed', 'ui.admin.areafix.no_areas_to_sync' => 'Keine Bereiche zum Synchronisieren verfügbar', + 'ui.admin.areafix.preview_modal_title' => 'AreaFix-Synchronisierung vorschauen', + 'ui.admin.areafix.preview_intro' => 'Überprüfen Sie die folgenden Änderungen, bevor Sie sie auf Ihre lokale Bereichsliste anwenden.', + 'ui.admin.areafix.preview_loading' => 'Vorschau wird geladen...', + 'ui.admin.areafix.preview_load_failed' => 'Vorschau konnte nicht geladen werden', + 'ui.admin.areafix.preview_no_changes' => 'Keine Änderungen anzuwenden', + 'ui.admin.areafix.tier_mystic_blocks' => 'Mystic BBS / MBSE-Blöcke', + 'ui.admin.areafix.tier_delimited_table' => 'Begrenzte Tabelle', + 'ui.admin.areafix.tier_columnar_table' => 'Spalten-/Punktführungstabelle', + 'ui.admin.areafix.tier_quoted_address_list' => 'Zitierte Adressliste', + 'ui.admin.areafix.tier_flagged_dotted_quoted_list' => 'Markierte Punktführungsliste', + 'ui.admin.areafix.tier_freeform' => 'Freiform-Fallback', + 'ui.admin.areafix.tier_configured' => 'benutzerdefinierte Grammatik "{id}"', + 'ui.admin.areafix.tier_unknown' => 'unbekanntes Format', + 'ui.admin.areafix.format_changed_warning' => 'Das Antwortformat dieses Hubs sieht anders aus als beim letzten Mal (war {old_tier}, jetzt {new_tier}). Prüfen Sie die Bereiche unten, bevor Sie bestätigen, da dies bedeuten kann, dass sich die Mailer-Software des Hubs geändert hat oder neu konfiguriert wurde.', + 'ui.admin.areafix.status_new' => 'Neu', + 'ui.admin.areafix.status_reactivate' => 'Reaktivieren', + 'ui.admin.areafix.status_deactivate' => 'Deaktivieren', + 'ui.admin.areafix.status_unchanged' => 'Unverändert', + 'ui.admin.areafix.status_updated' => 'Aktualisiert', + 'ui.admin.areafix.hub_description_differs' => 'Hub listet: "{desc}"', + 'ui.admin.areafix.select_all' => 'Alle auswählen', + 'ui.admin.areafix.select_none' => 'Keine auswählen', + 'ui.admin.areafix.no_areas_selected' => 'Wählen Sie mindestens einen Bereich zum Synchronisieren aus', + 'ui.admin.areafix.btn_confirm_apply' => 'Bestätigen & Anwenden', // LovlyNet admin page 'ui.base.admin.lovlynet' => 'LovlyNet Areas', @@ -5390,6 +5452,22 @@ 'ui.admin.binkp_config.uplinks.modal.edit_network_settings' => 'Edit network settings', 'ui.admin.binkp_config.uplinks.modal.connection_options' => 'Connection Options', 'ui.admin.binkp_config.uplinks.modal.unconfigured_network' => 'unconfigured', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_heading' => 'Gemerktes Antwortformat', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_help' => 'BinktermPHP merkt sich, welche Parser-Stufe zuletzt zu den AreaFix/FileFix-Antworten dieses Hubs gepasst hat, damit künftige Antworten schneller verarbeitet werden und ein Formatwechsel erkannt werden kann. Sie können es hier manuell erzwingen oder löschen.', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_areafix' => 'AreaFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_filefix' => 'FileFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_not_recorded' => 'noch nicht erfasst', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_set' => 'Erzwingen', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_clear' => 'Löschen', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_load_failed' => 'Laden des Grammatikgedächtnisses fehlgeschlagen', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_save_failed' => 'Aktualisieren des Grammatikgedächtnisses fehlgeschlagen', + 'ui.admin.binkp_config.uplinks.modal.tier_mystic_blocks' => 'Mystic BBS / MBSE-Blöcke', + 'ui.admin.binkp_config.uplinks.modal.tier_delimited_table' => 'Begrenzte Tabelle', + 'ui.admin.binkp_config.uplinks.modal.tier_columnar_table' => 'Spalten-/Punktführungstabelle', + 'ui.admin.binkp_config.uplinks.modal.tier_quoted_address_list' => 'Zitierte Adressliste', + 'ui.admin.binkp_config.uplinks.modal.tier_flagged_dotted_quoted_list' => 'Markierte Punktführungsliste', + 'ui.admin.binkp_config.uplinks.modal.tier_freeform' => 'Freiform-Fallback', + 'ui.admin.binkp_config.uplinks.modal.tier_configured' => 'benutzerdefinierte Grammatik "{id}"', 'ui.admin.binkp_config.validation.uplink_domain_required' => 'Network is required.', 'ui.admin.binkp_config.validation.uplink_domain_unknown' => 'Select a configured network.', 'ui.admin.networks.page_title' => 'Networks', diff --git a/config/i18n/de/errors.php b/config/i18n/de/errors.php index bc1f6d24d..08c55c195 100644 --- a/config/i18n/de/errors.php +++ b/config/i18n/de/errors.php @@ -446,6 +446,12 @@ 'errors.admin.webdoors_config.load_failed' => 'webdoors configuration konnten nicht geladen werden', 'errors.admin.webdoors_config.save_failed' => 'webdoors configuration konnte nicht gespeichert werden', 'errors.admin.webdoors_config.activate_failed' => 'Failed to activate webdoors configuration', + 'errors.admin.areafix_grammars.load_failed' => 'Laden der AreaFix-Grammatikkonfiguration fehlgeschlagen', + 'errors.admin.areafix_grammars.save_failed' => 'Speichern der AreaFix-Grammatikkonfiguration fehlgeschlagen', + 'errors.admin.areafix_grammars.message_text_required' => 'Bitte zuerst den Nachrichtentext einfügen', + 'errors.admin.areafix_grammars.ai_no_provider' => 'Es ist kein KI-Anbieter konfiguriert', + 'errors.admin.areafix_grammars.ai_invalid_response' => 'Die KI hat keine verwendbare Grammatikdefinition zurückgegeben', + 'errors.admin.areafix_grammars.ai_generate_failed' => 'Generieren der Grammatik fehlgeschlagen', 'errors.admin.jsdosdoors_config.load_failed' => 'JS-DOS doors configuration konnten nicht geladen werden', 'errors.admin.jsdosdoors_config.save_failed' => 'JS-DOS doors configuration konnte nicht gespeichert werden', 'errors.admin.jsdosdoors_config.activate_failed' => 'Failed to activate JS-DOS doors configuration', @@ -668,11 +674,13 @@ 'errors.admin.areafix.invalid_json' => 'Ungültig: request payload', 'errors.admin.areafix.uplink_required' => 'Uplink address ist erforderlich', 'errors.admin.areafix.invalid_robot' => 'Robot muss sein "areafix" or "filefix"', + 'errors.admin.areafix.invalid_tier' => 'Unbekannte Grammatikstufe', 'errors.admin.areafix.commands_required' => 'At least one command ist erforderlich', 'errors.admin.areafix.send_failed' => 'command konnte nicht gesendet werden', 'errors.admin.areafix.history_failed' => 'message history konnten nicht geladen werden', 'errors.admin.areafix.sync_failed' => 'Failed to sync areas', 'errors.admin.areafix.no_area_list_found' => 'Keine Bereichsliste in den letzten Antworten für diesen Uplink gefunden', + 'errors.admin.areafix.preview_failed' => 'Vorschau der Synchronisierung konnte nicht erstellt werden', 'errors.admin.poll.failed' => 'Failed to Umfrage BinkP uplink', 'errors.admin.lovlynet.invalid_json' => 'Ungültig: request payload', diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index bb017a1f2..4f35299c2 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -22,6 +22,7 @@ 'ui.common.error' => 'Error', 'ui.common.unknown_error' => 'Unknown error', 'ui.common.saving' => 'Saving...', + 'ui.common.processing' => 'Processing...', 'ui.common.copy_failed_manual' => 'Copy to clipboard failed. Please copy manually.', 'ui.common.copy_not_supported_manual' => 'Copy to clipboard not supported. Please copy manually.', 'ui.common.loading' => 'Loading...', @@ -1844,6 +1845,42 @@ 'ui.admin.webdoors_config.true' => 'true', 'ui.admin.webdoors_config.false' => 'false', 'ui.admin.webdoors_config.not_in_config' => '(not in config)', + 'ui.admin.areafix_grammars.page_title' => 'AreaFix Grammars', + 'ui.admin.areafix_grammars.heading' => 'AreaFix Grammars', + 'ui.admin.areafix_grammars.info_text_prefix' => 'Data-driven AreaFix/FileFix reply grammars are defined in', + 'ui.admin.areafix_grammars.info_text_suffix' => '. Built-in grammars are tried first; these are tried before the freeform fallback.', + 'ui.admin.areafix_grammars.doc_hint' => 'See the schema reference in', + 'ui.admin.areafix_grammars.grammars_list_heading' => 'Defined Grammars', + 'ui.admin.areafix_grammars.no_grammars_defined' => 'No grammars defined.', + 'ui.admin.areafix_grammars.invalid_json' => 'Invalid JSON.', + 'ui.admin.areafix_grammars.invalid_entry_label' => '(invalid entry)', + 'ui.admin.areafix_grammars.enabled_label' => 'enabled', + 'ui.admin.areafix_grammars.disabled_label' => 'disabled', + 'ui.admin.areafix_grammars.format_json' => 'Format JSON', + 'ui.admin.areafix_grammars.waiting_for_config' => 'Waiting for config...', + 'ui.admin.areafix_grammars.json_validation_before_save' => 'JSON validation runs before saving.', + 'ui.admin.areafix_grammars.json_valid' => 'JSON is valid', + 'ui.admin.areafix_grammars.json_has_errors' => 'JSON has errors', + 'ui.admin.areafix_grammars.cannot_format_invalid_json' => 'Cannot format: invalid JSON', + 'ui.admin.areafix_grammars.fix_json_before_save' => 'Please fix JSON errors before saving.', + 'ui.admin.areafix_grammars.save_failed' => 'Failed to save config', + 'ui.admin.areafix_grammars.saved_success' => 'AreaFix grammar config saved.', + 'ui.admin.areafix_grammars.load_failed' => 'Failed to load config', + 'ui.admin.areafix_grammars.config_filename' => 'areafix_grammars.json', + 'ui.admin.areafix_grammars.populate_from_example' => 'Populate from Example', + 'ui.admin.areafix_grammars.populated_from_example' => 'Editor populated from areafix_grammars.json.example. Review and click Save to apply.', + 'ui.admin.areafix_grammars.paste_from_message' => 'Paste from AreaFix Message', + 'ui.admin.areafix_grammars.ai_modal_title' => 'Generate Grammar from AreaFix Message', + 'ui.admin.areafix_grammars.ai_modal_instructions' => 'Paste the raw text of an AreaFix/FileFix reply below. AI will suggest a grammar definition, added disabled so you can review it before enabling.', + 'ui.admin.areafix_grammars.ai_message_text_label' => 'AreaFix/FileFix reply message text', + 'ui.admin.areafix_grammars.ai_suggested_grammar_label' => 'Suggested grammar', + 'ui.admin.areafix_grammars.ai_generate_button' => 'Generate with AI', + 'ui.admin.areafix_grammars.ai_generating' => 'Generating...', + 'ui.admin.areafix_grammars.ai_insert_button' => 'Insert into Editor', + 'ui.admin.areafix_grammars.ai_message_text_required' => 'Please paste some message text first.', + 'ui.admin.areafix_grammars.ai_generate_failed' => 'Failed to generate grammar', + 'ui.admin.areafix_grammars.ai_generated_success' => 'Grammar suggestion generated. It is disabled by default — review it, then click Insert to add it to the editor.', + 'ui.admin.areafix_grammars.ai_inserted_into_editor' => 'AI-suggested grammar added to the editor (disabled). Review it, then click Save to persist.', // Admin JS-DOS Doors Config 'ui.admin.jsdosdoors_config.page_title' => 'JS-DOS Doors Config', @@ -4881,6 +4918,7 @@ // AreaFix / FileFix Manager 'ui.base.admin.areafix' => 'AreaFix / FileFix', + 'ui.base.admin.areafix_grammars' => 'AreaFix Grammars', 'ui.admin.areafix.page_title' => 'AreaFix / FileFix Manager', 'ui.admin.areafix.heading' => 'AreaFix / FileFix Manager', 'ui.admin.areafix.not_configured_title' => 'No uplinks configured for AreaFix / FileFix.', @@ -4927,6 +4965,30 @@ 'ui.admin.areafix.sync_ok' => 'Sync complete: {created} created, {activated} activated, {deactivated} deactivated', 'ui.admin.areafix.sync_failed' => 'Sync failed', 'ui.admin.areafix.no_areas_to_sync' => 'No areas available to sync', + 'ui.admin.areafix.preview_modal_title' => 'Preview AreaFix Sync', + 'ui.admin.areafix.preview_intro' => 'Review the changes below before applying them to your local area list.', + 'ui.admin.areafix.preview_loading' => 'Loading preview...', + 'ui.admin.areafix.preview_load_failed' => 'Failed to load sync preview', + 'ui.admin.areafix.preview_no_changes' => 'No changes to apply', + 'ui.admin.areafix.tier_mystic_blocks' => 'Mystic BBS / MBSE blocks', + 'ui.admin.areafix.tier_delimited_table' => 'Delimited table', + 'ui.admin.areafix.tier_columnar_table' => 'Columnar / dotted-leader table', + 'ui.admin.areafix.tier_quoted_address_list' => 'Quoted address list', + 'ui.admin.areafix.tier_flagged_dotted_quoted_list' => 'Flag-prefixed dotted-leader list', + 'ui.admin.areafix.tier_freeform' => 'Freeform fallback', + 'ui.admin.areafix.tier_configured' => 'custom grammar "{id}"', + 'ui.admin.areafix.tier_unknown' => 'unknown format', + 'ui.admin.areafix.format_changed_warning' => 'This hub\'s reply format looks different than last time (was {old_tier}, now {new_tier}). Double-check the areas below before confirming, since this can mean the hub\'s mailer software changed or was reconfigured.', + 'ui.admin.areafix.status_new' => 'New', + 'ui.admin.areafix.status_reactivate' => 'Reactivate', + 'ui.admin.areafix.status_deactivate' => 'Deactivate', + 'ui.admin.areafix.status_unchanged' => 'Unchanged', + 'ui.admin.areafix.status_updated' => 'Updated', + 'ui.admin.areafix.hub_description_differs' => 'Hub lists: "{desc}"', + 'ui.admin.areafix.select_all' => 'Select All', + 'ui.admin.areafix.select_none' => 'Select None', + 'ui.admin.areafix.no_areas_selected' => 'Select at least one area to sync', + 'ui.admin.areafix.btn_confirm_apply' => 'Confirm & Apply', // LovlyNet admin page 'ui.base.admin.lovlynet' => 'LovlyNet Areas', @@ -5413,6 +5475,22 @@ 'ui.admin.binkp_config.uplinks.modal.edit_network_settings' => 'Edit network settings', 'ui.admin.binkp_config.uplinks.modal.connection_options' => 'Connection Options', 'ui.admin.binkp_config.uplinks.modal.unconfigured_network' => 'unconfigured', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_heading' => 'Remembered Reply Format', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_help' => 'BinktermPHP remembers which parser tier last matched this hub\'s AreaFix/FileFix replies, so future replies parse faster and a format change can be flagged. You can manually force or clear it here.', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_areafix' => 'AreaFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_filefix' => 'FileFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_not_recorded' => 'not yet recorded', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_set' => 'Force', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_clear' => 'Clear', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_load_failed' => 'Failed to load grammar memory', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_save_failed' => 'Failed to update grammar memory', + 'ui.admin.binkp_config.uplinks.modal.tier_mystic_blocks' => 'Mystic BBS / MBSE blocks', + 'ui.admin.binkp_config.uplinks.modal.tier_delimited_table' => 'Delimited table', + 'ui.admin.binkp_config.uplinks.modal.tier_columnar_table' => 'Columnar / dotted-leader table', + 'ui.admin.binkp_config.uplinks.modal.tier_quoted_address_list' => 'Quoted address list', + 'ui.admin.binkp_config.uplinks.modal.tier_flagged_dotted_quoted_list' => 'Flag-prefixed dotted-leader list', + 'ui.admin.binkp_config.uplinks.modal.tier_freeform' => 'Freeform fallback', + 'ui.admin.binkp_config.uplinks.modal.tier_configured' => 'custom grammar "{id}"', 'ui.admin.binkp_config.validation.uplink_domain_required' => 'Network is required.', 'ui.admin.binkp_config.validation.uplink_domain_unknown' => 'Select a configured network.', 'ui.admin.networks.page_title' => 'Networks', diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index dfc59c121..8aa3addc3 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -451,6 +451,12 @@ 'errors.admin.webdoors_config.load_failed' => 'Failed to load webdoors configuration', 'errors.admin.webdoors_config.save_failed' => 'Failed to save webdoors configuration', 'errors.admin.webdoors_config.activate_failed' => 'Failed to activate webdoors configuration', + 'errors.admin.areafix_grammars.load_failed' => 'Failed to load AreaFix grammar configuration', + 'errors.admin.areafix_grammars.save_failed' => 'Failed to save AreaFix grammar configuration', + 'errors.admin.areafix_grammars.message_text_required' => 'Please paste some message text first', + 'errors.admin.areafix_grammars.ai_no_provider' => 'No AI provider is configured', + 'errors.admin.areafix_grammars.ai_invalid_response' => 'AI did not return a usable grammar definition', + 'errors.admin.areafix_grammars.ai_generate_failed' => 'Failed to generate grammar', 'errors.admin.jsdosdoors_config.load_failed' => 'Failed to load JS-DOS doors configuration', 'errors.admin.jsdosdoors_config.save_failed' => 'Failed to save JS-DOS doors configuration', 'errors.admin.jsdosdoors_config.activate_failed' => 'Failed to activate JS-DOS doors configuration', @@ -673,11 +679,13 @@ 'errors.admin.areafix.invalid_json' => 'Invalid request payload', 'errors.admin.areafix.uplink_required' => 'Uplink address is required', 'errors.admin.areafix.invalid_robot' => 'Robot must be "areafix" or "filefix"', + 'errors.admin.areafix.invalid_tier' => 'Unrecognized grammar tier', 'errors.admin.areafix.commands_required' => 'At least one command is required', 'errors.admin.areafix.send_failed' => 'Failed to send command', 'errors.admin.areafix.history_failed' => 'Failed to load message history', 'errors.admin.areafix.sync_failed' => 'Failed to sync areas', 'errors.admin.areafix.no_area_list_found' => 'No area list found in recent replies for this uplink', + 'errors.admin.areafix.preview_failed' => 'Failed to generate sync preview', 'errors.admin.poll.failed' => 'Failed to poll BinkP uplink', 'errors.admin.lovlynet.invalid_json' => 'Invalid request payload', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index 8884c9328..9b67aedbe 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -22,6 +22,7 @@ 'ui.common.error' => 'Error', 'ui.common.unknown_error' => 'Error desconocido', 'ui.common.saving' => 'Guardando...', + 'ui.common.processing' => 'Procesando...', 'ui.common.copy_failed_manual' => 'No se pudo copiar al portapapeles. Copie manualmente.', 'ui.common.copy_not_supported_manual' => 'El portapapeles no es compatible. Copie manualmente.', 'ui.common.loading' => 'Cargando...', @@ -1877,6 +1878,42 @@ 'ui.admin.webdoors_config.true' => 'true', 'ui.admin.webdoors_config.false' => 'false', 'ui.admin.webdoors_config.not_in_config' => '(no esta en config)', + 'ui.admin.areafix_grammars.page_title' => 'Gramáticas de AreaFix', + 'ui.admin.areafix_grammars.heading' => 'Gramáticas de AreaFix', + 'ui.admin.areafix_grammars.info_text_prefix' => 'Las gramáticas de respuesta de AreaFix/FileFix basadas en datos se definen en', + 'ui.admin.areafix_grammars.info_text_suffix' => '. Las gramáticas integradas se prueban primero; estas se prueban antes del modo de reserva libre.', + 'ui.admin.areafix_grammars.doc_hint' => 'Consulte la referencia del esquema en', + 'ui.admin.areafix_grammars.grammars_list_heading' => 'Gramáticas definidas', + 'ui.admin.areafix_grammars.no_grammars_defined' => 'No hay gramáticas definidas.', + 'ui.admin.areafix_grammars.invalid_json' => 'JSON no válido.', + 'ui.admin.areafix_grammars.invalid_entry_label' => '(entrada no válida)', + 'ui.admin.areafix_grammars.enabled_label' => 'habilitada', + 'ui.admin.areafix_grammars.disabled_label' => 'deshabilitada', + 'ui.admin.areafix_grammars.format_json' => 'Formatear JSON', + 'ui.admin.areafix_grammars.waiting_for_config' => 'Esperando configuración...', + 'ui.admin.areafix_grammars.json_validation_before_save' => 'La validación de JSON se ejecuta antes de guardar.', + 'ui.admin.areafix_grammars.json_valid' => 'El JSON es válido', + 'ui.admin.areafix_grammars.json_has_errors' => 'El JSON tiene errores', + 'ui.admin.areafix_grammars.cannot_format_invalid_json' => 'No se puede formatear: JSON no válido', + 'ui.admin.areafix_grammars.fix_json_before_save' => 'Corrija los errores de JSON antes de guardar.', + 'ui.admin.areafix_grammars.save_failed' => 'Error al guardar la configuración', + 'ui.admin.areafix_grammars.saved_success' => 'Configuración de gramáticas de AreaFix guardada.', + 'ui.admin.areafix_grammars.load_failed' => 'Error al cargar la configuración', + 'ui.admin.areafix_grammars.config_filename' => 'areafix_grammars.json', + 'ui.admin.areafix_grammars.populate_from_example' => 'Rellenar desde el ejemplo', + 'ui.admin.areafix_grammars.populated_from_example' => 'Editor rellenado desde areafix_grammars.json.example. Revise y haga clic en Guardar para aplicar.', + 'ui.admin.areafix_grammars.paste_from_message' => 'Pegar desde mensaje AreaFix', + 'ui.admin.areafix_grammars.ai_modal_title' => 'Generar gramática desde un mensaje de AreaFix', + 'ui.admin.areafix_grammars.ai_modal_instructions' => 'Pegue el texto sin formato de una respuesta de AreaFix/FileFix a continuación. La IA sugerirá una definición de gramática, añadida deshabilitada para que pueda revisarla antes de habilitarla.', + 'ui.admin.areafix_grammars.ai_message_text_label' => 'Texto del mensaje de respuesta AreaFix/FileFix', + 'ui.admin.areafix_grammars.ai_suggested_grammar_label' => 'Gramática sugerida', + 'ui.admin.areafix_grammars.ai_generate_button' => 'Generar con IA', + 'ui.admin.areafix_grammars.ai_generating' => 'Generando...', + 'ui.admin.areafix_grammars.ai_insert_button' => 'Insertar en el editor', + 'ui.admin.areafix_grammars.ai_message_text_required' => 'Pegue primero el texto del mensaje.', + 'ui.admin.areafix_grammars.ai_generate_failed' => 'Error al generar la gramática', + 'ui.admin.areafix_grammars.ai_generated_success' => 'Sugerencia de gramática generada. Está deshabilitada por defecto: revísela y haga clic en Insertar para añadirla al editor.', + 'ui.admin.areafix_grammars.ai_inserted_into_editor' => 'Gramática sugerida por IA añadida al editor (deshabilitada). Revísela y haga clic en Guardar para conservarla.', // Admin JS-DOS Doors Config 'ui.admin.jsdosdoors_config.page_title' => 'Config Puertas JS-DOS', @@ -4845,6 +4882,7 @@ // AreaFix / FileFix Manager 'ui.base.admin.areafix' => 'AreaFix / FileFix', + 'ui.base.admin.areafix_grammars' => 'Gramáticas de AreaFix', 'ui.admin.areafix.page_title' => 'Gestor AreaFix / FileFix', 'ui.admin.areafix.heading' => 'Gestor AreaFix / FileFix', 'ui.admin.areafix.not_configured_title' => 'Ningún uplink configurado para AreaFix / FileFix.', @@ -4891,6 +4929,30 @@ 'ui.admin.areafix.sync_ok' => 'Sincronización completada: {created} creadas, {activated} activadas, {deactivated} desactivadas', 'ui.admin.areafix.sync_failed' => 'Error en la sincronización', 'ui.admin.areafix.no_areas_to_sync' => 'No hay áreas disponibles para sincronizar', + 'ui.admin.areafix.preview_modal_title' => 'Vista previa de sincronización de AreaFix', + 'ui.admin.areafix.preview_intro' => 'Revise los cambios a continuación antes de aplicarlos a su lista de áreas local.', + 'ui.admin.areafix.preview_loading' => 'Cargando vista previa...', + 'ui.admin.areafix.preview_load_failed' => 'Error al cargar la vista previa de sincronización', + 'ui.admin.areafix.preview_no_changes' => 'No hay cambios que aplicar', + 'ui.admin.areafix.tier_mystic_blocks' => 'Bloques Mystic BBS / MBSE', + 'ui.admin.areafix.tier_delimited_table' => 'Tabla delimitada', + 'ui.admin.areafix.tier_columnar_table' => 'Tabla columnar / de puntos guía', + 'ui.admin.areafix.tier_quoted_address_list' => 'Lista de direcciones entre comillas', + 'ui.admin.areafix.tier_flagged_dotted_quoted_list' => 'Lista de puntos guía con indicador', + 'ui.admin.areafix.tier_freeform' => 'Reserva de formato libre', + 'ui.admin.areafix.tier_configured' => 'gramática personalizada "{id}"', + 'ui.admin.areafix.tier_unknown' => 'formato desconocido', + 'ui.admin.areafix.format_changed_warning' => 'El formato de respuesta de este hub parece diferente al de la última vez (antes {old_tier}, ahora {new_tier}). Revise las áreas a continuación antes de confirmar, ya que esto puede significar que el software de correo del hub cambió o fue reconfigurado.', + 'ui.admin.areafix.status_new' => 'Nueva', + 'ui.admin.areafix.status_reactivate' => 'Reactivar', + 'ui.admin.areafix.status_deactivate' => 'Desactivar', + 'ui.admin.areafix.status_unchanged' => 'Sin cambios', + 'ui.admin.areafix.status_updated' => 'Actualizada', + 'ui.admin.areafix.hub_description_differs' => 'El uplink indica: "{desc}"', + 'ui.admin.areafix.select_all' => 'Seleccionar todo', + 'ui.admin.areafix.select_none' => 'No seleccionar nada', + 'ui.admin.areafix.no_areas_selected' => 'Seleccione al menos un área para sincronizar', + 'ui.admin.areafix.btn_confirm_apply' => 'Confirmar y aplicar', // LovlyNet admin page 'ui.base.admin.lovlynet' => 'Áreas LovlyNet', @@ -5378,6 +5440,22 @@ 'ui.admin.binkp_config.uplinks.modal.edit_network_settings' => 'Edit network settings', 'ui.admin.binkp_config.uplinks.modal.connection_options' => 'Connection Options', 'ui.admin.binkp_config.uplinks.modal.unconfigured_network' => 'unconfigured', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_heading' => 'Formato de respuesta recordado', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_help' => 'BinktermPHP recuerda qué nivel de análisis coincidió por última vez con las respuestas de AreaFix/FileFix de este hub, para que las próximas respuestas se analicen más rápido y se pueda señalar un cambio de formato. Puede forzarlo o borrarlo manualmente aquí.', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_areafix' => 'AreaFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_filefix' => 'FileFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_not_recorded' => 'aún no registrado', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_set' => 'Forzar', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_clear' => 'Borrar', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_load_failed' => 'Error al cargar la memoria de gramática', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_save_failed' => 'Error al actualizar la memoria de gramática', + 'ui.admin.binkp_config.uplinks.modal.tier_mystic_blocks' => 'Bloques Mystic BBS / MBSE', + 'ui.admin.binkp_config.uplinks.modal.tier_delimited_table' => 'Tabla delimitada', + 'ui.admin.binkp_config.uplinks.modal.tier_columnar_table' => 'Tabla columnar / de puntos guía', + 'ui.admin.binkp_config.uplinks.modal.tier_quoted_address_list' => 'Lista de direcciones entre comillas', + 'ui.admin.binkp_config.uplinks.modal.tier_flagged_dotted_quoted_list' => 'Lista de puntos guía con indicador', + 'ui.admin.binkp_config.uplinks.modal.tier_freeform' => 'Reserva de formato libre', + 'ui.admin.binkp_config.uplinks.modal.tier_configured' => 'gramática personalizada "{id}"', 'ui.admin.binkp_config.validation.uplink_domain_required' => 'Network is required.', 'ui.admin.binkp_config.validation.uplink_domain_unknown' => 'Select a configured network.', 'ui.admin.networks.page_title' => 'Networks', diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index fef72d205..909effd7f 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -447,6 +447,12 @@ 'errors.admin.webdoors_config.load_failed' => 'No se pudo cargar la configuracion de webdoors', 'errors.admin.webdoors_config.save_failed' => 'No se pudo guardar la configuracion de webdoors', 'errors.admin.webdoors_config.activate_failed' => 'No se pudo activar la configuracion de webdoors', + 'errors.admin.areafix_grammars.load_failed' => 'Error al cargar la configuración de gramáticas de AreaFix', + 'errors.admin.areafix_grammars.save_failed' => 'Error al guardar la configuración de gramáticas de AreaFix', + 'errors.admin.areafix_grammars.message_text_required' => 'Pegue primero el texto del mensaje', + 'errors.admin.areafix_grammars.ai_no_provider' => 'No hay ningún proveedor de IA configurado', + 'errors.admin.areafix_grammars.ai_invalid_response' => 'La IA no devolvió una definición de gramática utilizable', + 'errors.admin.areafix_grammars.ai_generate_failed' => 'Error al generar la gramática', 'errors.admin.jsdosdoors_config.load_failed' => 'No se pudo cargar la configuracion de puertas JS-DOS', 'errors.admin.jsdosdoors_config.save_failed' => 'No se pudo guardar la configuracion de puertas JS-DOS', 'errors.admin.jsdosdoors_config.activate_failed' => 'No se pudo activar la configuracion de puertas JS-DOS', @@ -669,11 +675,13 @@ 'errors.admin.areafix.invalid_json' => 'Carga de solicitud no válida', 'errors.admin.areafix.uplink_required' => 'Se requiere la dirección del uplink', 'errors.admin.areafix.invalid_robot' => 'El robot debe ser "areafix" o "filefix"', + 'errors.admin.areafix.invalid_tier' => 'Nivel de gramática no reconocido', 'errors.admin.areafix.commands_required' => 'Se requiere al menos un comando', 'errors.admin.areafix.send_failed' => 'Error al enviar el comando', 'errors.admin.areafix.history_failed' => 'Error al cargar el historial de mensajes', 'errors.admin.areafix.sync_failed' => 'Error al sincronizar las áreas', 'errors.admin.areafix.no_area_list_found' => 'No se encontró lista de áreas en las respuestas recientes para este uplink', + 'errors.admin.areafix.preview_failed' => 'Error al generar la vista previa de sincronización', 'errors.admin.poll.failed' => 'No se pudo consultar el uplink BinkP', 'errors.admin.lovlynet.invalid_json' => 'Carga de solicitud no válida', diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index a74713ce7..344a5cc1d 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -19,6 +19,7 @@ 'ui.common.error' => 'Erreur', 'ui.common.unknown_error' => 'Erreur inconnue', 'ui.common.saving' => 'Enregistrement...', + 'ui.common.processing' => 'Traitement en cours...', 'ui.common.copy_failed_manual' => 'Échec de la copie dans le presse-papiers. Veuillez copier manuellement.', 'ui.common.copy_not_supported_manual' => 'Copie dans le presse-papiers non prise en charge. Veuillez copier manuellement.', 'ui.common.loading' => 'Chargement...', @@ -1586,6 +1587,42 @@ 'ui.admin.webdoors_config.true' => 'true', 'ui.admin.webdoors_config.false' => 'false', 'ui.admin.webdoors_config.not_in_config' => '(absent de la configuration)', + 'ui.admin.areafix_grammars.page_title' => 'Grammaires AreaFix', + 'ui.admin.areafix_grammars.heading' => 'Grammaires AreaFix', + 'ui.admin.areafix_grammars.info_text_prefix' => 'Les grammaires de réponse AreaFix/FileFix pilotées par la configuration sont définies dans', + 'ui.admin.areafix_grammars.info_text_suffix' => '. Les grammaires intégrées sont essayées en premier ; celles-ci sont essayées avant le repli en texte libre.', + 'ui.admin.areafix_grammars.doc_hint' => 'Consultez la référence du schéma dans', + 'ui.admin.areafix_grammars.grammars_list_heading' => 'Grammaires définies', + 'ui.admin.areafix_grammars.no_grammars_defined' => 'Aucune grammaire définie.', + 'ui.admin.areafix_grammars.invalid_json' => 'JSON invalide.', + 'ui.admin.areafix_grammars.invalid_entry_label' => '(entrée invalide)', + 'ui.admin.areafix_grammars.enabled_label' => 'activée', + 'ui.admin.areafix_grammars.disabled_label' => 'désactivée', + 'ui.admin.areafix_grammars.format_json' => 'Formater le JSON', + 'ui.admin.areafix_grammars.waiting_for_config' => 'En attente de la configuration...', + 'ui.admin.areafix_grammars.json_validation_before_save' => 'La validation JSON s\'exécute avant l\'enregistrement.', + 'ui.admin.areafix_grammars.json_valid' => 'Le JSON est valide', + 'ui.admin.areafix_grammars.json_has_errors' => 'Le JSON contient des erreurs', + 'ui.admin.areafix_grammars.cannot_format_invalid_json' => 'Impossible de formater : JSON invalide', + 'ui.admin.areafix_grammars.fix_json_before_save' => 'Veuillez corriger les erreurs JSON avant d\'enregistrer.', + 'ui.admin.areafix_grammars.save_failed' => 'Échec de l\'enregistrement de la configuration', + 'ui.admin.areafix_grammars.saved_success' => 'Configuration des grammaires AreaFix enregistrée.', + 'ui.admin.areafix_grammars.load_failed' => 'Échec du chargement de la configuration', + 'ui.admin.areafix_grammars.config_filename' => 'areafix_grammars.json', + 'ui.admin.areafix_grammars.populate_from_example' => 'Remplir depuis l\'exemple', + 'ui.admin.areafix_grammars.populated_from_example' => 'Éditeur rempli à partir de areafix_grammars.json.example. Vérifiez puis cliquez sur Enregistrer pour appliquer.', + 'ui.admin.areafix_grammars.paste_from_message' => 'Coller depuis un message AreaFix', + 'ui.admin.areafix_grammars.ai_modal_title' => 'Générer une grammaire à partir d\'un message AreaFix', + 'ui.admin.areafix_grammars.ai_modal_instructions' => 'Collez ci-dessous le texte brut d\'une réponse AreaFix/FileFix. L\'IA suggérera une définition de grammaire, ajoutée désactivée afin que vous puissiez la vérifier avant de l\'activer.', + 'ui.admin.areafix_grammars.ai_message_text_label' => 'Texte du message de réponse AreaFix/FileFix', + 'ui.admin.areafix_grammars.ai_suggested_grammar_label' => 'Grammaire suggérée', + 'ui.admin.areafix_grammars.ai_generate_button' => 'Générer avec l\'IA', + 'ui.admin.areafix_grammars.ai_generating' => 'Génération...', + 'ui.admin.areafix_grammars.ai_insert_button' => 'Insérer dans l\'éditeur', + 'ui.admin.areafix_grammars.ai_message_text_required' => 'Veuillez d\'abord coller le texte du message.', + 'ui.admin.areafix_grammars.ai_generate_failed' => 'Échec de la génération de la grammaire', + 'ui.admin.areafix_grammars.ai_generated_success' => 'Suggestion de grammaire générée. Elle est désactivée par défaut : vérifiez-la puis cliquez sur Insérer pour l\'ajouter à l\'éditeur.', + 'ui.admin.areafix_grammars.ai_inserted_into_editor' => 'Grammaire suggérée par l\'IA ajoutée à l\'éditeur (désactivée). Vérifiez-la puis cliquez sur Enregistrer pour la conserver.', // Admin JS-DOS Doors Config 'ui.admin.jsdosdoors_config.page_title' => 'Config Portes JS-DOS', @@ -4608,6 +4645,7 @@ 'ui.base.admin.community' => 'Communauté', 'ui.base.admin.licensing' => 'Licences', 'ui.base.admin.areafix' => 'AreaFix / FileFix', + 'ui.base.admin.areafix_grammars' => 'Grammaires AreaFix', 'ui.admin.areafix.page_title' => 'Gestionnaire AreaFix / FileFix', 'ui.admin.areafix.heading' => 'Gestionnaire AreaFix / FileFix', 'ui.admin.areafix.not_configured_title' => 'Aucun uplink configuré pour AreaFix / FileFix.', @@ -4654,6 +4692,30 @@ 'ui.admin.areafix.sync_ok' => 'Synchronisation terminée : {created} créée(s), {activated} activée(s), {deactivated} désactivée(s)', 'ui.admin.areafix.sync_failed' => 'Échec de la synchronisation', 'ui.admin.areafix.no_areas_to_sync' => 'Aucune zone disponible à synchroniser', + 'ui.admin.areafix.preview_modal_title' => 'Aperçu de la synchronisation AreaFix', + 'ui.admin.areafix.preview_intro' => 'Vérifiez les changements ci-dessous avant de les appliquer à votre liste de zones locale.', + 'ui.admin.areafix.preview_loading' => 'Chargement de l\'aperçu...', + 'ui.admin.areafix.preview_load_failed' => 'Échec du chargement de l\'aperçu de synchronisation', + 'ui.admin.areafix.preview_no_changes' => 'Aucun changement à appliquer', + 'ui.admin.areafix.tier_mystic_blocks' => 'Blocs Mystic BBS / MBSE', + 'ui.admin.areafix.tier_delimited_table' => 'Tableau délimité', + 'ui.admin.areafix.tier_columnar_table' => 'Tableau en colonnes / à points de suite', + 'ui.admin.areafix.tier_quoted_address_list' => 'Liste d\'adresses entre guillemets', + 'ui.admin.areafix.tier_flagged_dotted_quoted_list' => 'Liste à points de suite avec indicateur', + 'ui.admin.areafix.tier_freeform' => 'Repli en texte libre', + 'ui.admin.areafix.tier_configured' => 'grammaire personnalisée "{id}"', + 'ui.admin.areafix.tier_unknown' => 'format inconnu', + 'ui.admin.areafix.format_changed_warning' => 'Le format de réponse de ce hub semble différent de la dernière fois (était {old_tier}, maintenant {new_tier}). Vérifiez les zones ci-dessous avant de confirmer, car cela peut signifier que le logiciel du hub a changé ou a été reconfiguré.', + 'ui.admin.areafix.status_new' => 'Nouvelle', + 'ui.admin.areafix.status_reactivate' => 'Réactiver', + 'ui.admin.areafix.status_deactivate' => 'Désactiver', + 'ui.admin.areafix.status_unchanged' => 'Inchangée', + 'ui.admin.areafix.status_updated' => 'Mise à jour', + 'ui.admin.areafix.hub_description_differs' => 'Le hub indique : "{desc}"', + 'ui.admin.areafix.select_all' => 'Tout sélectionner', + 'ui.admin.areafix.select_none' => 'Tout désélectionner', + 'ui.admin.areafix.no_areas_selected' => 'Sélectionnez au moins une zone à synchroniser', + 'ui.admin.areafix.btn_confirm_apply' => 'Confirmer et appliquer', 'ui.base.admin.lovlynet' => 'Zones LovlyNet', 'ui.base.admin.referrals' => 'Analytiques de parrainage', 'ui.base.admin.register' => 'Enregistrer BinktermPHP', @@ -5319,6 +5381,22 @@ 'ui.admin.binkp_config.uplinks.modal.edit_network_settings' => 'Edit network settings', 'ui.admin.binkp_config.uplinks.modal.connection_options' => 'Connection Options', 'ui.admin.binkp_config.uplinks.modal.unconfigured_network' => 'unconfigured', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_heading' => 'Format de réponse mémorisé', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_help' => 'BinktermPHP mémorise le niveau d\'analyse qui a correspondu en dernier aux réponses AreaFix/FileFix de ce hub, afin que les prochaines réponses soient analysées plus vite et qu\'un changement de format puisse être signalé. Vous pouvez le forcer ou l\'effacer manuellement ici.', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_areafix' => 'AreaFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_filefix' => 'FileFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_not_recorded' => 'pas encore enregistré', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_set' => 'Forcer', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_clear' => 'Effacer', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_load_failed' => 'Échec du chargement de la mémoire de grammaire', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_save_failed' => 'Échec de la mise à jour de la mémoire de grammaire', + 'ui.admin.binkp_config.uplinks.modal.tier_mystic_blocks' => 'Blocs Mystic BBS / MBSE', + 'ui.admin.binkp_config.uplinks.modal.tier_delimited_table' => 'Tableau délimité', + 'ui.admin.binkp_config.uplinks.modal.tier_columnar_table' => 'Tableau en colonnes / à points de suite', + 'ui.admin.binkp_config.uplinks.modal.tier_quoted_address_list' => 'Liste d\'adresses entre guillemets', + 'ui.admin.binkp_config.uplinks.modal.tier_flagged_dotted_quoted_list' => 'Liste à points de suite avec indicateur', + 'ui.admin.binkp_config.uplinks.modal.tier_freeform' => 'Repli en texte libre', + 'ui.admin.binkp_config.uplinks.modal.tier_configured' => 'grammaire personnalisée "{id}"', 'ui.admin.binkp_config.validation.uplink_domain_required' => 'Network is required.', 'ui.admin.binkp_config.validation.uplink_domain_unknown' => 'Select a configured network.', 'ui.admin.networks.page_title' => 'Networks', diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index 47c70909d..5c4d9269b 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -335,6 +335,12 @@ 'errors.admin.webdoors_config.load_failed' => 'Échec du chargement de la configuration des portes web', 'errors.admin.webdoors_config.save_failed' => 'Échec de l\'enregistrement de la configuration des portes web', 'errors.admin.webdoors_config.activate_failed' => 'Échec de l\'activation de la configuration des portes web', + 'errors.admin.areafix_grammars.load_failed' => 'Échec du chargement de la configuration des grammaires AreaFix', + 'errors.admin.areafix_grammars.save_failed' => 'Échec de l\'enregistrement de la configuration des grammaires AreaFix', + 'errors.admin.areafix_grammars.message_text_required' => 'Veuillez d\'abord coller le texte du message', + 'errors.admin.areafix_grammars.ai_no_provider' => 'Aucun fournisseur d\'IA n\'est configuré', + 'errors.admin.areafix_grammars.ai_invalid_response' => 'L\'IA n\'a pas renvoyé de définition de grammaire utilisable', + 'errors.admin.areafix_grammars.ai_generate_failed' => 'Échec de la génération de la grammaire', 'errors.admin.jsdosdoors_config.load_failed' => 'Échec du chargement de la configuration des portes JS-DOS', 'errors.admin.jsdosdoors_config.save_failed' => 'Échec de l\'enregistrement de la configuration des portes JS-DOS', 'errors.admin.jsdosdoors_config.activate_failed' => 'Échec de l\'activation de la configuration des portes JS-DOS', @@ -540,11 +546,13 @@ 'errors.admin.areafix.invalid_json' => 'Charge utile de demande invalide', 'errors.admin.areafix.uplink_required' => 'L\'adresse uplink est requise', 'errors.admin.areafix.invalid_robot' => 'Le robot doit être "areafix" ou "filefix"', + 'errors.admin.areafix.invalid_tier' => 'Niveau de grammaire non reconnu', 'errors.admin.areafix.commands_required' => 'Au moins une commande est requise', 'errors.admin.areafix.send_failed' => 'Échec de l\'envoi de la commande', 'errors.admin.areafix.history_failed' => 'Échec du chargement de l\'historique des messages', 'errors.admin.areafix.sync_failed' => 'Échec de la synchronisation des zones', 'errors.admin.areafix.no_area_list_found' => 'Aucune liste de zones trouvée dans les réponses récentes pour cet uplink', + 'errors.admin.areafix.preview_failed' => 'Échec de la génération de l\'aperçu de synchronisation', 'errors.admin.poll.failed' => 'Échec de l\'interrogation du lien montant BinkP', 'errors.admin.lovlynet.invalid_json' => 'Charge utile de demande invalide', diff --git a/config/i18n/it/common.php b/config/i18n/it/common.php index a978fafb2..15635935f 100644 --- a/config/i18n/it/common.php +++ b/config/i18n/it/common.php @@ -22,6 +22,7 @@ 'ui.common.error' => 'Errore', 'ui.common.unknown_error' => 'Errore sconosciuto', 'ui.common.saving' => 'Salvataggio in corso...', + 'ui.common.processing' => 'Elaborazione in corso...', 'ui.common.copy_failed_manual' => 'Copia negli appunti non riuscita. Copia manualmente.', 'ui.common.copy_not_supported_manual' => 'Copia negli appunti non supportata. Copia manualmente.', 'ui.common.loading' => 'Caricamento in corso...', @@ -1824,6 +1825,42 @@ 'ui.admin.webdoors_config.true' => 'true', 'ui.admin.webdoors_config.false' => 'false', 'ui.admin.webdoors_config.not_in_config' => '(non in configurazione)', + 'ui.admin.areafix_grammars.page_title' => 'Grammatiche AreaFix', + 'ui.admin.areafix_grammars.heading' => 'Grammatiche AreaFix', + 'ui.admin.areafix_grammars.info_text_prefix' => 'Le grammatiche di risposta AreaFix/FileFix basate sui dati sono definite in', + 'ui.admin.areafix_grammars.info_text_suffix' => '. Le grammatiche integrate vengono provate per prime; queste vengono provate prima del fallback in formato libero.', + 'ui.admin.areafix_grammars.doc_hint' => 'Consultare il riferimento dello schema in', + 'ui.admin.areafix_grammars.grammars_list_heading' => 'Grammatiche definite', + 'ui.admin.areafix_grammars.no_grammars_defined' => 'Nessuna grammatica definita.', + 'ui.admin.areafix_grammars.invalid_json' => 'JSON non valido.', + 'ui.admin.areafix_grammars.invalid_entry_label' => '(voce non valida)', + 'ui.admin.areafix_grammars.enabled_label' => 'abilitata', + 'ui.admin.areafix_grammars.disabled_label' => 'disabilitata', + 'ui.admin.areafix_grammars.format_json' => 'Formatta JSON', + 'ui.admin.areafix_grammars.waiting_for_config' => 'In attesa della configurazione...', + 'ui.admin.areafix_grammars.json_validation_before_save' => 'La convalida JSON viene eseguita prima del salvataggio.', + 'ui.admin.areafix_grammars.json_valid' => 'Il JSON è valido', + 'ui.admin.areafix_grammars.json_has_errors' => 'Il JSON contiene errori', + 'ui.admin.areafix_grammars.cannot_format_invalid_json' => 'Impossibile formattare: JSON non valido', + 'ui.admin.areafix_grammars.fix_json_before_save' => 'Correggere gli errori JSON prima di salvare.', + 'ui.admin.areafix_grammars.save_failed' => 'Salvataggio della configurazione non riuscito', + 'ui.admin.areafix_grammars.saved_success' => 'Configurazione delle grammatiche AreaFix salvata.', + 'ui.admin.areafix_grammars.load_failed' => 'Caricamento della configurazione non riuscito', + 'ui.admin.areafix_grammars.config_filename' => 'areafix_grammars.json', + 'ui.admin.areafix_grammars.populate_from_example' => 'Popola dall\'esempio', + 'ui.admin.areafix_grammars.populated_from_example' => 'Editor popolato da areafix_grammars.json.example. Rivedere e fare clic su Salva per applicare.', + 'ui.admin.areafix_grammars.paste_from_message' => 'Incolla da messaggio AreaFix', + 'ui.admin.areafix_grammars.ai_modal_title' => 'Genera grammatica da messaggio AreaFix', + 'ui.admin.areafix_grammars.ai_modal_instructions' => 'Incolla qui sotto il testo grezzo di una risposta AreaFix/FileFix. L\'IA suggerirà una definizione di grammatica, aggiunta disabilitata in modo da poterla rivedere prima di abilitarla.', + 'ui.admin.areafix_grammars.ai_message_text_label' => 'Testo del messaggio di risposta AreaFix/FileFix', + 'ui.admin.areafix_grammars.ai_suggested_grammar_label' => 'Grammatica suggerita', + 'ui.admin.areafix_grammars.ai_generate_button' => 'Genera con IA', + 'ui.admin.areafix_grammars.ai_generating' => 'Generazione in corso...', + 'ui.admin.areafix_grammars.ai_insert_button' => 'Inserisci nell\'editor', + 'ui.admin.areafix_grammars.ai_message_text_required' => 'Incollare prima il testo del messaggio.', + 'ui.admin.areafix_grammars.ai_generate_failed' => 'Generazione della grammatica non riuscita', + 'ui.admin.areafix_grammars.ai_generated_success' => 'Suggerimento di grammatica generato. È disabilitato per impostazione predefinita: rivedilo, quindi fai clic su Inserisci per aggiungerlo all\'editor.', + 'ui.admin.areafix_grammars.ai_inserted_into_editor' => 'Grammatica suggerita dall\'IA aggiunta all\'editor (disabilitata). Rivedila, quindi fai clic su Salva per conservarla.', // Admin JS-DOS Doors Config 'ui.admin.jsdosdoors_config.page_title' => 'Configurazione door JS-DOS', @@ -4844,6 +4881,7 @@ // AreaFix / FileFix Manager 'ui.base.admin.areafix' => 'AreaFix / FileFix', + 'ui.base.admin.areafix_grammars' => 'Grammatiche AreaFix', 'ui.admin.areafix.page_title' => 'Gestore AreaFix / FileFix', 'ui.admin.areafix.heading' => 'Gestore AreaFix / FileFix', 'ui.admin.areafix.not_configured_title' => 'Nessun uplink configurato per AreaFix / FileFix.', @@ -4890,6 +4928,30 @@ 'ui.admin.areafix.sync_ok' => 'Sincronizzazione completata: {created} create, {activated} attivate, {deactivated} disattivate', 'ui.admin.areafix.sync_failed' => 'Sincronizzazione non riuscita', 'ui.admin.areafix.no_areas_to_sync' => 'Nessuna area disponibile da sincronizzare', + 'ui.admin.areafix.preview_modal_title' => 'Anteprima sincronizzazione AreaFix', + 'ui.admin.areafix.preview_intro' => 'Rivedi le modifiche seguenti prima di applicarle al tuo elenco di aree locale.', + 'ui.admin.areafix.preview_loading' => 'Caricamento anteprima...', + 'ui.admin.areafix.preview_load_failed' => 'Impossibile caricare l\'anteprima di sincronizzazione', + 'ui.admin.areafix.preview_no_changes' => 'Nessuna modifica da applicare', + 'ui.admin.areafix.tier_mystic_blocks' => 'Blocchi Mystic BBS / MBSE', + 'ui.admin.areafix.tier_delimited_table' => 'Tabella delimitata', + 'ui.admin.areafix.tier_columnar_table' => 'Tabella colonnare / a punti guida', + 'ui.admin.areafix.tier_quoted_address_list' => 'Elenco di indirizzi tra virgolette', + 'ui.admin.areafix.tier_flagged_dotted_quoted_list' => 'Elenco a punti guida contrassegnato', + 'ui.admin.areafix.tier_freeform' => 'Riserva in formato libero', + 'ui.admin.areafix.tier_configured' => 'grammatica personalizzata "{id}"', + 'ui.admin.areafix.tier_unknown' => 'formato sconosciuto', + 'ui.admin.areafix.format_changed_warning' => 'Il formato di risposta di questo hub sembra diverso rispetto all\'ultima volta (era {old_tier}, ora {new_tier}). Controllare le aree qui sotto prima di confermare, poiché questo può significare che il software di posta dell\'hub è cambiato o è stato riconfigurato.', + 'ui.admin.areafix.status_new' => 'Nuova', + 'ui.admin.areafix.status_reactivate' => 'Riattiva', + 'ui.admin.areafix.status_deactivate' => 'Disattiva', + 'ui.admin.areafix.status_unchanged' => 'Invariata', + 'ui.admin.areafix.status_updated' => 'Aggiornata', + 'ui.admin.areafix.hub_description_differs' => 'L\'hub indica: "{desc}"', + 'ui.admin.areafix.select_all' => 'Seleziona tutto', + 'ui.admin.areafix.select_none' => 'Deseleziona tutto', + 'ui.admin.areafix.no_areas_selected' => 'Seleziona almeno un\'area da sincronizzare', + 'ui.admin.areafix.btn_confirm_apply' => 'Conferma e applica', // LovlyNet admin page 'ui.base.admin.lovlynet' => 'Aree LovlyNet', @@ -5375,6 +5437,22 @@ 'ui.admin.binkp_config.uplinks.modal.edit_network_settings' => 'Edit network settings', 'ui.admin.binkp_config.uplinks.modal.connection_options' => 'Connection Options', 'ui.admin.binkp_config.uplinks.modal.unconfigured_network' => 'unconfigured', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_heading' => 'Formato di risposta memorizzato', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_help' => 'BinktermPHP ricorda quale livello di analisi ha corrisposto per ultimo alle risposte AreaFix/FileFix di questo hub, cosi le prossime risposte vengono analizzate piu velocemente e un cambio di formato puo essere segnalato. Puoi forzarlo o cancellarlo manualmente qui.', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_areafix' => 'AreaFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_filefix' => 'FileFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_not_recorded' => 'non ancora registrato', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_set' => 'Forza', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_clear' => 'Cancella', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_load_failed' => 'Caricamento della memoria della grammatica non riuscito', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_save_failed' => 'Aggiornamento della memoria della grammatica non riuscito', + 'ui.admin.binkp_config.uplinks.modal.tier_mystic_blocks' => 'Blocchi Mystic BBS / MBSE', + 'ui.admin.binkp_config.uplinks.modal.tier_delimited_table' => 'Tabella delimitata', + 'ui.admin.binkp_config.uplinks.modal.tier_columnar_table' => 'Tabella colonnare / a punti guida', + 'ui.admin.binkp_config.uplinks.modal.tier_quoted_address_list' => 'Elenco di indirizzi tra virgolette', + 'ui.admin.binkp_config.uplinks.modal.tier_flagged_dotted_quoted_list' => 'Elenco a punti guida contrassegnato', + 'ui.admin.binkp_config.uplinks.modal.tier_freeform' => 'Riserva in formato libero', + 'ui.admin.binkp_config.uplinks.modal.tier_configured' => 'grammatica personalizzata "{id}"', 'ui.admin.binkp_config.validation.uplink_domain_required' => 'Network is required.', 'ui.admin.binkp_config.validation.uplink_domain_unknown' => 'Select a configured network.', 'ui.admin.networks.page_title' => 'Networks', diff --git a/config/i18n/it/errors.php b/config/i18n/it/errors.php index 350988f34..6349a377a 100644 --- a/config/i18n/it/errors.php +++ b/config/i18n/it/errors.php @@ -447,6 +447,12 @@ 'errors.admin.webdoors_config.load_failed' => 'Impossibile caricare la configurazione webdoors', 'errors.admin.webdoors_config.save_failed' => 'Impossibile salvare la configurazione webdoors', 'errors.admin.webdoors_config.activate_failed' => 'Impossibile attivare la configurazione webdoors', + 'errors.admin.areafix_grammars.load_failed' => 'Caricamento della configurazione delle grammatiche AreaFix non riuscito', + 'errors.admin.areafix_grammars.save_failed' => 'Salvataggio della configurazione delle grammatiche AreaFix non riuscito', + 'errors.admin.areafix_grammars.message_text_required' => 'Incollare prima il testo del messaggio', + 'errors.admin.areafix_grammars.ai_no_provider' => 'Nessun provider IA configurato', + 'errors.admin.areafix_grammars.ai_invalid_response' => 'L\'IA non ha restituito una definizione di grammatica utilizzabile', + 'errors.admin.areafix_grammars.ai_generate_failed' => 'Generazione della grammatica non riuscita', 'errors.admin.jsdosdoors_config.load_failed' => 'Impossibile caricare la configurazione doors JS-DOS', 'errors.admin.jsdosdoors_config.save_failed' => 'Impossibile salvare la configurazione doors JS-DOS', 'errors.admin.jsdosdoors_config.activate_failed' => 'Impossibile attivare la configurazione doors JS-DOS', @@ -669,11 +675,13 @@ 'errors.admin.areafix.invalid_json' => 'Payload richiesta non valido', 'errors.admin.areafix.uplink_required' => 'Indirizzo uplink obbligatorio', 'errors.admin.areafix.invalid_robot' => 'Il robot deve essere "areafix" o "filefix"', + 'errors.admin.areafix.invalid_tier' => 'Livello di grammatica non riconosciuto', 'errors.admin.areafix.commands_required' => 'È richiesto almeno un comando', 'errors.admin.areafix.send_failed' => 'Impossibile inviare il comando', 'errors.admin.areafix.history_failed' => 'Impossibile caricare la cronologia messaggi', 'errors.admin.areafix.sync_failed' => 'Impossibile sincronizzare le aree', 'errors.admin.areafix.no_area_list_found' => 'Nessun elenco di aree trovato nelle risposte recenti per questo uplink', + 'errors.admin.areafix.preview_failed' => 'Impossibile generare l\'anteprima di sincronizzazione', 'errors.admin.poll.failed' => 'Polling uplink BinkP non riuscito', 'errors.admin.lovlynet.invalid_json' => 'Payload richiesta non valido', diff --git a/config/i18n/ru/common.php b/config/i18n/ru/common.php index d19a366e7..a33b4c17c 100644 --- a/config/i18n/ru/common.php +++ b/config/i18n/ru/common.php @@ -22,6 +22,7 @@ 'ui.common.error' => 'Ошибка', 'ui.common.unknown_error' => 'Неизвестная ошибка', 'ui.common.saving' => 'Сохранение...', + 'ui.common.processing' => 'Обработка...', 'ui.common.copy_failed_manual' => 'Копирование в буфер обмена не удалось. Скопируйте вручную.', 'ui.common.copy_not_supported_manual' => 'Копирование в буфер обмена не поддерживается. Скопируйте вручную.', 'ui.common.loading' => 'Загрузка...', @@ -1864,6 +1865,42 @@ 'ui.admin.webdoors_config.true' => 'истина', 'ui.admin.webdoors_config.false' => 'ложь', 'ui.admin.webdoors_config.not_in_config' => '(не в конфигурации)', + 'ui.admin.areafix_grammars.page_title' => 'Грамматики AreaFix', + 'ui.admin.areafix_grammars.heading' => 'Грамматики AreaFix', + 'ui.admin.areafix_grammars.info_text_prefix' => 'Грамматики ответов AreaFix/FileFix на основе данных определены в', + 'ui.admin.areafix_grammars.info_text_suffix' => '. Встроенные грамматики проверяются первыми; эти проверяются перед резервным свободным форматом.', + 'ui.admin.areafix_grammars.doc_hint' => 'См. описание схемы в', + 'ui.admin.areafix_grammars.grammars_list_heading' => 'Определённые грамматики', + 'ui.admin.areafix_grammars.no_grammars_defined' => 'Грамматики не определены.', + 'ui.admin.areafix_grammars.invalid_json' => 'Недопустимый JSON.', + 'ui.admin.areafix_grammars.invalid_entry_label' => '(недопустимая запись)', + 'ui.admin.areafix_grammars.enabled_label' => 'включена', + 'ui.admin.areafix_grammars.disabled_label' => 'отключена', + 'ui.admin.areafix_grammars.format_json' => 'Форматировать JSON', + 'ui.admin.areafix_grammars.waiting_for_config' => 'Ожидание конфигурации...', + 'ui.admin.areafix_grammars.json_validation_before_save' => 'Проверка JSON выполняется перед сохранением.', + 'ui.admin.areafix_grammars.json_valid' => 'JSON корректен', + 'ui.admin.areafix_grammars.json_has_errors' => 'JSON содержит ошибки', + 'ui.admin.areafix_grammars.cannot_format_invalid_json' => 'Невозможно отформатировать: недопустимый JSON', + 'ui.admin.areafix_grammars.fix_json_before_save' => 'Исправьте ошибки JSON перед сохранением.', + 'ui.admin.areafix_grammars.save_failed' => 'Не удалось сохранить конфигурацию', + 'ui.admin.areafix_grammars.saved_success' => 'Конфигурация грамматик AreaFix сохранена.', + 'ui.admin.areafix_grammars.load_failed' => 'Не удалось загрузить конфигурацию', + 'ui.admin.areafix_grammars.config_filename' => 'areafix_grammars.json', + 'ui.admin.areafix_grammars.populate_from_example' => 'Заполнить из примера', + 'ui.admin.areafix_grammars.populated_from_example' => 'Редактор заполнен из areafix_grammars.json.example. Проверьте и нажмите «Сохранить», чтобы применить.', + 'ui.admin.areafix_grammars.paste_from_message' => 'Вставить из сообщения AreaFix', + 'ui.admin.areafix_grammars.ai_modal_title' => 'Создать грамматику из сообщения AreaFix', + 'ui.admin.areafix_grammars.ai_modal_instructions' => 'Вставьте ниже необработанный текст ответа AreaFix/FileFix. ИИ предложит определение грамматики, добавленное отключённым, чтобы вы могли проверить его перед включением.', + 'ui.admin.areafix_grammars.ai_message_text_label' => 'Текст сообщения-ответа AreaFix/FileFix', + 'ui.admin.areafix_grammars.ai_suggested_grammar_label' => 'Предложенная грамматика', + 'ui.admin.areafix_grammars.ai_generate_button' => 'Создать с помощью ИИ', + 'ui.admin.areafix_grammars.ai_generating' => 'Создание...', + 'ui.admin.areafix_grammars.ai_insert_button' => 'Вставить в редактор', + 'ui.admin.areafix_grammars.ai_message_text_required' => 'Сначала вставьте текст сообщения.', + 'ui.admin.areafix_grammars.ai_generate_failed' => 'Не удалось создать грамматику', + 'ui.admin.areafix_grammars.ai_generated_success' => 'Предложение грамматики создано. По умолчанию оно отключено — проверьте его и нажмите «Вставить», чтобы добавить в редактор.', + 'ui.admin.areafix_grammars.ai_inserted_into_editor' => 'Предложенная ИИ грамматика добавлена в редактор (отключена). Проверьте её и нажмите «Сохранить», чтобы сохранить.', // Admin JS-DOS Doors Config 'ui.admin.jsdosdoors_config.page_title' => 'Конфигурация JS-DOS-дверей', @@ -4887,6 +4924,7 @@ // AreaFix / FileFix Manager 'ui.base.admin.areafix' => 'AreaFix / FileFix', + 'ui.base.admin.areafix_grammars' => 'Грамматики AreaFix', 'ui.admin.areafix.page_title' => 'Менеджер AreaFix / FileFix', 'ui.admin.areafix.heading' => 'Менеджер AreaFix / FileFix', 'ui.admin.areafix.not_configured_title' => 'Для AreaFix / FileFix не настроены аплинки.', @@ -4933,6 +4971,30 @@ 'ui.admin.areafix.sync_ok' => 'Синхронизация завершена: создано {created}, активировано {activated}, деактивировано {deactivated}', 'ui.admin.areafix.sync_failed' => 'Синхронизация не удалась', 'ui.admin.areafix.no_areas_to_sync' => 'Областей для синхронизации нет', + 'ui.admin.areafix.preview_modal_title' => 'Предпросмотр синхронизации AreaFix', + 'ui.admin.areafix.preview_intro' => 'Просмотрите изменения ниже перед их применением к локальному списку эхоконференций.', + 'ui.admin.areafix.preview_loading' => 'Загрузка предпросмотра...', + 'ui.admin.areafix.preview_load_failed' => 'Не удалось загрузить предпросмотр синхронизации', + 'ui.admin.areafix.preview_no_changes' => 'Нет изменений для применения', + 'ui.admin.areafix.tier_mystic_blocks' => 'Блоки Mystic BBS / MBSE', + 'ui.admin.areafix.tier_delimited_table' => 'Таблица с разделителями', + 'ui.admin.areafix.tier_columnar_table' => 'Столбцовая таблица / список с точками-разделителями', + 'ui.admin.areafix.tier_quoted_address_list' => 'Список адресов в кавычках', + 'ui.admin.areafix.tier_flagged_dotted_quoted_list' => 'Список с точками-разделителями и флагом', + 'ui.admin.areafix.tier_freeform' => 'Резервный свободный формат', + 'ui.admin.areafix.tier_configured' => 'пользовательская грамматика «{id}»', + 'ui.admin.areafix.tier_unknown' => 'неизвестный формат', + 'ui.admin.areafix.format_changed_warning' => 'Формат ответа этого хаба отличается от прошлого раза (было {old_tier}, стало {new_tier}). Проверьте области ниже перед подтверждением, так как это может означать, что почтовое ПО хаба изменилось или было переконфигурировано.', + 'ui.admin.areafix.status_new' => 'Новая', + 'ui.admin.areafix.status_reactivate' => 'Реактивировать', + 'ui.admin.areafix.status_deactivate' => 'Деактивировать', + 'ui.admin.areafix.status_unchanged' => 'Без изменений', + 'ui.admin.areafix.status_updated' => 'Обновлено', + 'ui.admin.areafix.hub_description_differs' => 'Хаб указывает: "{desc}"', + 'ui.admin.areafix.select_all' => 'Выбрать всё', + 'ui.admin.areafix.select_none' => 'Снять выбор', + 'ui.admin.areafix.no_areas_selected' => 'Выберите хотя бы одну область для синхронизации', + 'ui.admin.areafix.btn_confirm_apply' => 'Подтвердить и применить', // LovlyNet admin page 'ui.base.admin.lovlynet' => 'Области LovlyNet', @@ -5421,6 +5483,22 @@ 'ui.admin.binkp_config.uplinks.modal.edit_network_settings' => 'Редактировать настройки сети', 'ui.admin.binkp_config.uplinks.modal.connection_options' => 'Параметры подключения', 'ui.admin.binkp_config.uplinks.modal.unconfigured_network' => 'не настроена', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_heading' => 'Запомненный формат ответа', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_help' => 'BinktermPHP запоминает, какой уровень разбора последним подошёл к ответам AreaFix/FileFix этого хаба, чтобы будущие ответы обрабатывались быстрее и можно было заметить смену формата. Здесь его можно принудительно задать или сбросить вручную.', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_areafix' => 'AreaFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_filefix' => 'FileFix', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_not_recorded' => 'ещё не записано', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_set' => 'Задать', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_clear' => 'Сбросить', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_load_failed' => 'Не удалось загрузить память грамматики', + 'ui.admin.binkp_config.uplinks.modal.grammar_memory_save_failed' => 'Не удалось обновить память грамматики', + 'ui.admin.binkp_config.uplinks.modal.tier_mystic_blocks' => 'Блоки Mystic BBS / MBSE', + 'ui.admin.binkp_config.uplinks.modal.tier_delimited_table' => 'Таблица с разделителями', + 'ui.admin.binkp_config.uplinks.modal.tier_columnar_table' => 'Столбцовая таблица / список с точками-разделителями', + 'ui.admin.binkp_config.uplinks.modal.tier_quoted_address_list' => 'Список адресов в кавычках', + 'ui.admin.binkp_config.uplinks.modal.tier_flagged_dotted_quoted_list' => 'Список с точками-разделителями и флагом', + 'ui.admin.binkp_config.uplinks.modal.tier_freeform' => 'Резервный свободный формат', + 'ui.admin.binkp_config.uplinks.modal.tier_configured' => 'пользовательская грамматика «{id}»', 'ui.admin.binkp_config.validation.uplink_domain_required' => 'Сеть обязательна.', 'ui.admin.binkp_config.validation.uplink_domain_unknown' => 'Выберите настроенную сеть.', 'ui.admin.networks.page_title' => 'Сети', diff --git a/config/i18n/ru/errors.php b/config/i18n/ru/errors.php index f3d4369f9..c9a1676ea 100644 --- a/config/i18n/ru/errors.php +++ b/config/i18n/ru/errors.php @@ -447,6 +447,12 @@ 'errors.admin.webdoors_config.load_failed' => 'Не удалось загрузить конфигурацию web‑дверей', 'errors.admin.webdoors_config.save_failed' => 'Не удалось сохранить конфигурацию web‑дверей', 'errors.admin.webdoors_config.activate_failed' => 'Не удалось активировать конфигурацию web‑дверей', + 'errors.admin.areafix_grammars.load_failed' => 'Не удалось загрузить конфигурацию грамматик AreaFix', + 'errors.admin.areafix_grammars.save_failed' => 'Не удалось сохранить конфигурацию грамматик AreaFix', + 'errors.admin.areafix_grammars.message_text_required' => 'Сначала вставьте текст сообщения', + 'errors.admin.areafix_grammars.ai_no_provider' => 'ИИ-провайдер не настроен', + 'errors.admin.areafix_grammars.ai_invalid_response' => 'ИИ не вернул пригодное определение грамматики', + 'errors.admin.areafix_grammars.ai_generate_failed' => 'Не удалось создать грамматику', 'errors.admin.jsdosdoors_config.load_failed' => 'Не удалось загрузить конфигурацию JS‑DOS дверей', 'errors.admin.jsdosdoors_config.save_failed' => 'Не удалось сохранить конфигурацию JS‑DOS дверей', 'errors.admin.jsdosdoors_config.activate_failed' => 'Не удалось активировать конфигурацию JS‑DOS дверей', @@ -670,11 +676,13 @@ 'errors.admin.areafix.invalid_json' => 'Недопустимые данные запроса', 'errors.admin.areafix.uplink_required' => 'Требуется адрес аплинка', 'errors.admin.areafix.invalid_robot' => 'Робот должен быть "areafix" или "filefix"', + 'errors.admin.areafix.invalid_tier' => 'Нераспознанный уровень грамматики', 'errors.admin.areafix.commands_required' => 'Требуется хотя бы одна команда', 'errors.admin.areafix.send_failed' => 'Не удалось отправить команду', 'errors.admin.areafix.history_failed' => 'Не удалось загрузить историю сообщений', 'errors.admin.areafix.sync_failed' => 'Не удалось синхронизировать области', 'errors.admin.areafix.no_area_list_found' => 'В недавних ответах для этого аплинка не найдено списка эхоконференций', + 'errors.admin.areafix.preview_failed' => 'Не удалось сформировать предпросмотр синхронизации', 'errors.admin.poll.failed' => 'Не удалось выполнить опрос аплинка BinkP', 'errors.admin.lovlynet.invalid_json' => 'Недопустимые данные запроса', diff --git a/database/migrations/v20260926031305_add_areafix_grammar_memory_table.sql b/database/migrations/v20260926031305_add_areafix_grammar_memory_table.sql new file mode 100644 index 000000000..f5bf87036 --- /dev/null +++ b/database/migrations/v20260926031305_add_areafix_grammar_memory_table.sql @@ -0,0 +1,20 @@ +-- Migration: 20260926031305 - add areafix grammar memory table +-- Created: 2026-09-26 03:13:05 UTC + +-- Per-uplink AreaFix/FileFix parser tier memory (PR460Proposal Improvement 6). +-- Records which AreaFixParser tier ("mystic_blocks", "delimited_table", +-- "columnar_table", "quoted_address_list", "flagged_dotted_quoted_list", +-- "configured:", or "freeform") successfully parsed the most +-- recent CONFIRMED sync for a given uplink+domain+robot, so the next reply +-- from that uplink can be tried against the remembered tier first, and a +-- change in tier can be flagged to the sysop on the preview screen. +CREATE TABLE IF NOT EXISTS areafix_grammar_memory ( + id SERIAL PRIMARY KEY, + uplink_address VARCHAR(60) NOT NULL, + domain VARCHAR(50) NOT NULL, + robot VARCHAR(10) NOT NULL, + tier VARCHAR(120) NOT NULL, + last_matched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (uplink_address, domain, robot) +); diff --git a/docs/AIProviders.md b/docs/AIProviders.md index 101e55c35..7c2225759 100644 --- a/docs/AIProviders.md +++ b/docs/AIProviders.md @@ -25,6 +25,7 @@ - [Translation Catalog Generation](#translation-catalog-generation) - [Message Reader Assistant](#message-reader-assistant) - [AI Chat Bots](#ai-chat-bots) + - [AreaFix Grammar Generation](#areafix-grammar-generation) - [Admin Dashboard](#admin-dashboard) - [What It Shows](#what-it-shows) - [Supported Periods](#supported-periods) @@ -514,6 +515,20 @@ Unlike other features, each bot has its own provider and model configured direct --- +### AreaFix Grammar Generation + +`POST /api/admin/areafix/grammars-ai-generate` (see `docs/AreaFix.md#data-driven-grammar-definitions`) uses `AiService::generateJson()` to infer a data-driven `AreaFixParser` grammar definition from a pasted AreaFix/FileFix reply message, via the "Paste from AreaFix Message" button on `/admin/areafix-grammars`. + +Relevant feature id: + +```text +areafix_grammar_ai_generate +``` + +The suggested grammar always comes back with `enabled: false` regardless of what the model returns, and every regex is validated with `AreaFixParser::isValidPattern()` before it's shown to the sysop — nothing is written to `config/areafix_grammars.json` until the sysop reviews the suggestion and clicks Save. + +--- + ## Admin Dashboard The AI usage dashboard is available at: diff --git a/docs/API.md b/docs/API.md index 226a7533c..d8e128c13 100644 --- a/docs/API.md +++ b/docs/API.md @@ -60,7 +60,7 @@ Content-Type: application/json - [Account](#account) (1) - [Address Book](#address-book) (8) - [Ads](#ads) (2) - - [AreaFix](#areafix) (1) + - [AreaFix](#areafix) (8) - [Auth](#auth) (7) - [Binkp](#binkp) (23) - [Bulletins](#bulletins) (3) @@ -555,13 +555,200 @@ Click recording confirmation with redirect URL | Method | Path | Auth | Summary | |--------|------|------|---------| +| `POST` | [`/api/admin/areafix/preview-latest`](#post-apiadminareafixpreview-latest) | Yes | Parse the latest incoming AreaFix/FileFix reply for an uplink and return a diff against current local area state, without writing anything to the database. | +| `POST` | [`/api/admin/areafix/sync`](#post-apiadminareafixsync) | Yes | Sync a sysop-curated list of areas (typically a subset selected in the preview) to the database. | | `POST` | [`/api/admin/areafix/sync-latest`](#post-apiadminareafixsync-latest) | Yes | Inspect the latest incoming AreaFix/FileFix reply for an uplink and sync areas to the database. | +| `GET` | [`/api/admin/areafix/grammars-config`](#get-apiadminareafixgrammars-config) | Yes | Return the raw contents of `config/areafix_grammars.json`. | +| `POST` | [`/api/admin/areafix/grammars-config`](#post-apiadminareafixgrammars-config) | Yes | Replace `config/areafix_grammars.json` wholesale. | +| `POST` | [`/api/admin/areafix/grammars-ai-generate`](#post-apiadminareafixgrammars-ai-generate) | Yes | Ask the configured AI provider to suggest a grammar definition from a pasted AreaFix/FileFix reply message. | +| `GET` | [`/api/admin/areafix/grammar-memory`](#get-apiadminareafixgrammar-memory) | Yes | Return the remembered `AreaFixParser` tier for both robots on an uplink. | +| `POST` | [`/api/admin/areafix/grammar-memory`](#post-apiadminareafixgrammar-memory) | Yes | Manually force or clear the remembered tier for one uplink+robot. | + +#### `GET /api/admin/areafix/grammars-config` + +**Requires authentication** (Admin only) + +Returns the raw contents of `config/areafix_grammars.json`, the data-driven AreaFix/FileFix grammar definitions loaded by `AreaFixParser` (see `docs/AreaFix.md#data-driven-grammar-definitions`). Used by the `/admin/areafix-grammars` editor page. + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if the config was read | +| `config` | object | Config wrapper | +| `config.config_json` | string | Raw JSON text of `config/areafix_grammars.json` (`"[]"` if the file doesn't exist) | +| `config.example_json` | string\|null | Raw JSON text of `config/areafix_grammars.json.example`, for the admin UI's "Populate from Example" button; `null` if no example file is shipped | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 500 | Failed to load AreaFix grammar configuration | + +--- + +#### `POST /api/admin/areafix/grammars-config` + +**Requires authentication** (Admin only) + +Replaces `config/areafix_grammars.json` wholesale with the given JSON array, written via the admin daemon (the web process cannot write config files directly; see `docs/AdminDaemon.md`). + +**Request Body** _(JSON)_ + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `json` | string | Yes | New contents of `config/areafix_grammars.json`, as a JSON-encoded array of grammar definition objects | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on successful save | +| `config` | object | Config wrapper | +| `config.config_json` | string | Raw JSON text of the saved config | +| `message_code` | string | i18n key for the success message | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Missing/invalid JSON payload, or failed to save AreaFix grammar configuration | + +--- + +#### `POST /api/admin/areafix/grammars-ai-generate` + +**Requires authentication** (Admin only) + +Asks the configured AI provider (see `docs/AIProviders.md`) to infer a data-driven `AreaFixParser` grammar definition (see `docs/AreaFix.md#data-driven-grammar-definitions`) from the raw text of a pasted AreaFix/FileFix reply message. Nothing is written to `config/areafix_grammars.json` by this endpoint — it only returns a suggestion for the sysop to review, edit, and save via `/api/admin/areafix/grammars-config`. The suggestion always comes back with `enabled: false` regardless of what the AI returns, and every regex is validated with `AreaFixParser::isValidPattern()` before being returned. + +**Request Body** _(JSON)_ + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `message_text` | string | Yes | Raw text of an AreaFix/FileFix reply message to infer a grammar from (truncated to 6000 characters) | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if a grammar suggestion was generated | +| `grammar` | object | Suggested grammar definition, in the same shape documented in `docs/AreaFix.md#data-driven-grammar-definitions` | +| `grammar.id` | string | Suggested identifier, sanitized to `[a-z0-9_-]` | +| `grammar.enabled` | boolean | Always `false` | +| `grammar.header_pattern` | string | Suggested header-detection regex | +| `grammar.row_pattern` | string | Suggested per-row regex, guaranteed to contain a `(?...)` named group | +| `grammar.stop_pattern` | string | Suggested stop-scan regex; omitted if the AI didn't provide one | +| `grammar.default_action` | string | `"subscribe"`, `"unsubscribe"`, or `"available"` | +| `grammar.status_rules` | array of objects | Suggested status-to-action rules; omitted if empty | +| `grammar.status_rules[].pattern` | string | Regex tested against the row's captured status text | +| `grammar.status_rules[].action` | string | `"subscribe"`, `"unsubscribe"`, or `"available"` | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 422 | Missing `message_text`, or the AI's response wasn't a usable grammar definition (invalid regex, or `row_pattern` missing a `tag` capture group) | +| 500 | Failed to generate grammar (AI request error) | +| 503 | No AI provider is configured | + +--- + +#### `POST /api/admin/areafix/preview-latest` + +**Requires authentication** (Admin only) + +Inspects recent message history from the specified uplink to find an incoming AreaFix or FileFix area list reply (`%LIST` or `%QUERY`), parses the available areas, and returns a per-area diff against the current `echoareas`/`file_areas` state — without applying any changes. The admin UI calls this endpoint to render a mandatory preview/confirmation step before calling `/api/admin/areafix/sync-latest`. + +When `message_id` is omitted, the newest actionable incoming reply is used (the "Latest Reply" panel's sync button). When `message_id` is given, that specific incoming netmail message is previewed instead (the per-row sync button in the message history table); the endpoint returns 404 if that message isn't an incoming, actionable reply from this uplink. + +**Request Body** _(JSON)_ + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `uplink` | string | Yes | Uplink node address (e.g. `1:229/426`) | +| `robot` | string | No | Robot name: `"areafix"` (default) or `"filefix"` | +| `message_id` | integer | No | `netmail.id` of a specific incoming reply to preview; defaults to the newest actionable reply | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if a preview was generated | +| `areas` | array of objects | Parsed areas, each classified against current local state | +| `areas[].name` | string | Area tag | +| `areas[].description` | string\|null | Area description, if known | +| `areas[].action` | string | Parsed action: `"subscribe"`, `"unsubscribe"`, or `"available"` | +| `areas[].is_subscribed` | boolean | Whether the reply indicates this area is subscribed | +| `areas[].status` | string | Diff classification: `"new"`, `"reactivate"`, `"deactivate"`, or `"unchanged"` | +| `areas[].currently_active` | boolean | Whether the area is currently active locally, before any sync is applied | +| `areas[].current_description` | string\|null | The area's current local description, before any sync is applied (`null` if the area doesn't exist locally yet) | +| `areas[].description_will_change` | boolean | Whether applying the sync would update the local description. True for a new area with a non-empty description, or an existing area whose current description is a placeholder (see `AreaFixManager::isPlaceholderDescription()`) and the incoming one is not. Always false when `status` is `"deactivate"`, since unsubscribing never touches the description. | +| `areas[].description_differs` | boolean | True when the hub's description differs from the current local one but `description_will_change` is false (the local description is a real, non-placeholder value and will not be overwritten). Lets the UI surface the mismatch for the sysop to review, without implying the sync will change anything. Always false when `description_will_change` is true, and always false for a new area. | +| `areas_count` | integer | Number of areas in the diff | +| `from` | string | Sender name or address of the reply message | +| `date` | string\|null | Timestamp the reply was received or written | +| `tier` | string\|null | `AreaFixParser` tier identifier that matched this reply (see `docs/AreaFix.md#per-uplink-grammar-memory`), e.g. `"mystic_blocks"` or `"configured:my_hub_format"` | +| `remembered_tier` | string\|null | Tier last recorded for this uplink+domain+robot from a previously confirmed sync; `null` if this uplink has never been synced before | +| `format_changed` | boolean | True only when both `tier` and `remembered_tier` are known and differ from each other | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Invalid payload or missing uplink address | +| 404 | No area list found in recent replies for this uplink | +| 500 | Failed to generate sync preview | + +--- + +#### `POST /api/admin/areafix/sync` + +**Requires authentication** (Admin only) + +Syncs an explicit, caller-provided list of areas into the local database. This is what the admin UI's preview modal calls to apply the sysop's checkbox selection — the `areas` array is normally the subset of `/api/admin/areafix/preview-latest`'s response the sysop left checked, but the endpoint accepts any well-formed area list. + +When `force_descriptions` is true, an existing area's description is overwritten whenever the submitted one differs from the current one, regardless of whether the current one is a placeholder. This is appropriate here because the sysop has already reviewed each selected area's description (including any mismatch flagged by `description_differs` in the preview) and explicitly chosen to include it. Without `force_descriptions`, the usual protection applies: an existing, non-placeholder description is never overwritten (see `AreaFixManager::isPlaceholderDescription()`). + +**Request Body** _(JSON)_ + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `uplink` | string | Yes | Uplink node address (e.g. `1:229/426`) | +| `robot` | string | No | Robot name: `"areafix"` (default) or `"filefix"` | +| `areas` | array of objects | Yes | Areas to sync | +| `areas[].name` | string | Yes | Area tag | +| `areas[].description` | string\|null | No | Area description | +| `areas[].action` | string | No | `"subscribe"`, `"unsubscribe"`, or `"available"`; derived from `is_subscribed` if omitted | +| `areas[].is_subscribed` | boolean | No | Used to derive `action` when `action` is omitted (defaults to `true`) | +| `deactivate_missing` | boolean | No | If true, deactivate any locally-active areas for this uplink/domain not present in `areas` (default `false`) | +| `force_descriptions` | boolean | No | If true, overwrite an existing area's description whenever it differs from the submitted one, bypassing the placeholder-only protection (default `false`) | +| `tier` | string | No | The `AreaFixParser` tier `/api/admin/areafix/preview-latest` reported for the reply this selection came from (see `docs/AreaFix.md#per-uplink-grammar-memory`). When given, updates the remembered tier for this uplink+domain+robot after a successful sync. | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on successful synchronization | +| `summary` | object | Summary of changes applied to the local database | +| `summary.created` | integer | Number of new areas inserted | +| `summary.activated` | integer | Number of existing inactive areas re-activated | +| `summary.deactivated` | integer | Number of areas deactivated | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Invalid payload, missing uplink address, or invalid robot | +| 500 | Failed to sync areas | + +--- #### `POST /api/admin/areafix/sync-latest` **Requires authentication** (Admin only) -Inspects recent message history from the specified uplink to find the latest incoming AreaFix or FileFix area list reply (`%LIST` or `%QUERY`), parses the available areas, and synchronizes them into the local database (`echoareas` or `file_areas`). +Inspects recent message history from the specified uplink to find an incoming AreaFix or FileFix area list reply (`%LIST` or `%QUERY`), parses the available areas, and synchronizes all of them into the local database (`echoareas` or `file_areas`) — an all-or-nothing apply of the whole reply, without the `force_descriptions` protection override or the ability to select a subset of areas. The admin UI's preview modal now calls `/api/admin/areafix/sync` with the sysop's curated selection instead (see above); this endpoint remains available for callers that want to apply an entire reply directly without a preview step. **Request Body** _(JSON)_ @@ -569,6 +756,7 @@ Inspects recent message history from the specified uplink to find the latest inc |-------|------|----------|-------------| | `uplink` | string | Yes | Uplink node address (e.g. `1:229/426`) | | `robot` | string | No | Robot name: `"areafix"` (default) or `"filefix"` | +| `message_id` | integer | No | `netmail.id` of a specific incoming reply to apply; defaults to the newest actionable reply | **Response** _(JSON)_ @@ -591,6 +779,66 @@ Inspects recent message history from the specified uplink to find the latest inc --- +#### `GET /api/admin/areafix/grammar-memory` + +**Requires authentication** (Admin only) + +Returns the per-uplink `AreaFixParser` grammar memory (see `docs/AreaFix.md#per-uplink-grammar-memory`) for both robots on an uplink, plus the list of tier identifiers a "force this tier" selector may choose from. Backs the **Admin → Networks → Edit Uplink** dialog. + +**Query Parameters** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `uplink` | string | Yes | Uplink node address (e.g. `1:229/426`) | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True if the memory was read | +| `areafix` | object\|null | Remembered tier for the `areafix` robot on this uplink; `null` if never recorded | +| `areafix.tier` | string | Tier identifier, e.g. `"mystic_blocks"` or `"configured:my_hub_format"` | +| `areafix.last_matched_at` | string | Timestamp this tier was last recorded | +| `filefix` | object\|null | Same shape as `areafix`, for the `filefix` robot | +| `known_tiers` | array of strings | Every tier identifier `AreaFixParser::getKnownTierIds()` currently knows about, in the order they're tried | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Missing uplink address | + +--- + +#### `POST /api/admin/areafix/grammar-memory` + +**Requires authentication** (Admin only) + +Manually forces or clears the remembered grammar tier for one uplink+robot, without requiring a real AreaFix sync. Setting a tier here stores it exactly the way a confirmed sync would (`AreaFixManager::rememberTier()`), so it's tried first on the uplink's next reply. + +**Request Body** _(JSON)_ + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `uplink` | string | Yes | Uplink node address (e.g. `1:229/426`) | +| `robot` | string | Yes | Robot name: `"areafix"` or `"filefix"` | +| `tier` | string\|null | No | Tier identifier to force (must be one of `known_tiers` from the `GET` response above); omit or pass `null` to clear the remembered tier instead | + +**Response** _(JSON)_ + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | True on successful update | +| `tier` | string\|null | The tier now recorded (`null` if cleared) | + +**Error Responses** + +| Status | Description | +|--------|-------------| +| 400 | Invalid payload, missing uplink address, invalid robot, or `tier` isn't a recognized identifier | + +--- + ### Auth | Method | Path | Auth | Summary | diff --git a/docs/AreaFix.md b/docs/AreaFix.md index 2248d23e7..f6091d6c6 100644 --- a/docs/AreaFix.md +++ b/docs/AreaFix.md @@ -191,18 +191,24 @@ Return AreaFix/FileFix message history for an uplink. ``` ### `POST /api/admin/areafix/sync` -Sync a parsed area list into the local echo/file area table. +Sync an explicit, caller-provided area list into the local echo/file area table. This is what the Admin → AreaFix / FileFix Manager page's preview modal calls to apply the sysop's checkbox selection — `areas` is normally the subset of `/api/admin/areafix/preview-latest`'s response the sysop left checked. **Request body:** ```json { - "uplink": "1:1/23", - "robot": "areafix", - "areas": [{"name": "FIDONEWS", "description": "FidoNet news"}], - "deactivate_missing": false + "uplink": "1:1/23", + "robot": "areafix", + "areas": [{"name": "FIDONEWS", "description": "FidoNet news"}], + "deactivate_missing": false, + "force_descriptions": true, + "tier": "mystic_blocks" } ``` +`force_descriptions` (optional, default `false`): when true, an existing area's description is overwritten whenever the submitted one differs, bypassing the usual placeholder-only protection (see `AreaFixManager::isPlaceholderDescription()`). The admin UI always sends `true` here, since the sysop has already reviewed each selected area's description in the preview — including any mismatch flagged by `description_differs` — before confirming. + +`tier` (optional): the `AreaFixParser` tier identifier that `/api/admin/areafix/preview-latest` reported for the reply this selection came from (see [Per-Uplink Grammar Memory](#per-uplink-grammar-memory)). The admin UI passes this straight back so the remembered tier for this uplink+domain+robot is only updated once the sysop has actually confirmed the sync, not merely previewed it. + **Response:** ```json { @@ -211,17 +217,60 @@ Sync a parsed area list into the local echo/file area table. } ``` +### `POST /api/admin/areafix/preview-latest` +Inspect an incoming AreaFix/FileFix reply for an uplink from message history, parse available areas, and return a diff against current local area state — without writing anything to the database. The Admin → AreaFix / FileFix Manager page always calls this endpoint first and shows the result as a mandatory preview before a sysop can confirm a sync. + +By default the newest actionable incoming reply is used (the "Latest Reply" panel's sync button). Passing `message_id` targets one specific incoming message instead — this backs the per-row sync button next to each incoming message in the Message History table, so a sysop can sync from an older reply without needing it to still be the newest one. + +**Request body:** +```json +{ + "uplink": "1:1/23", + "robot": "areafix", + "message_id": 4821 +} +``` + +`message_id` is optional; omit it to preview the newest actionable incoming reply. + +**Response:** +```json +{ + "success": true, + "areas": [ + { "name": "FIDONEWS", "description": "FidoNet news", "action": "subscribe", "is_subscribed": true, "status": "new", "currently_active": false, "current_description": null, "description_will_change": true, "description_differs": false }, + { "name": "SYS_GEN", "description": "SysOp Chat", "action": "subscribe", "is_subscribed": true, "status": "unchanged", "currently_active": true, "current_description": "Auto-created area", "description_will_change": true, "description_differs": false }, + { "name": "SYS_TST", "description": "Test Area", "action": "subscribe", "is_subscribed": true, "status": "unchanged", "currently_active": true, "current_description": "Our own custom description", "description_will_change": false, "description_differs": true } + ], + "areas_count": 3, + "from": "AreaFix", + "date": "2026-09-23 14:02:11", + "tier": "mystic_blocks", + "remembered_tier": "mystic_blocks", + "format_changed": false +} +``` + +`status` is one of `new`, `reactivate`, `deactivate`, or `unchanged`, describing what applying that area via `/api/admin/areafix/sync` would do to its activation state. Separately, `description_will_change` reports whether the sync would also update the local description if applied without `force_descriptions` — an area's activation status can be `unchanged` while its description is still filled in, because the local description is normally only overwritten when it's currently a placeholder (see `AreaFixManager::isPlaceholderDescription()`); a real, sysop-set description is otherwise never overwritten by a hub's reply. When the local description is a real value and would not be overwritten, but the hub's reply lists a different one anyway (`SYS_TST` above), `description_differs` is true so the admin UI can still point out the mismatch. + +`tier` is the `AreaFixParser` tier that matched this specific reply; `remembered_tier` is the tier last recorded for this uplink+domain+robot from a previously *confirmed* sync (`null` the first time an uplink is synced). `format_changed` is true only when both are known and differ — a concrete signal that this hub's mailer software may have changed or been reconfigured (or, more rarely, that the reply isn't actually from the expected hub), distinct from the generic "review before applying" prompt every sync already gets. See [Per-Uplink Grammar Memory](#per-uplink-grammar-memory). + +In the admin UI, an area whose `status` is `unchanged` but which has either `description_will_change` or `description_differs` set is displayed with an "Updated" badge instead of "Unchanged", since something about it did differ from the hub's reply. The UI renders one checkbox per area, pre-checked for `new`/`reactivate`/`deactivate` and for any area flagged "Updated", and unchecked by default only for a genuine no-op `unchanged` area (no description difference of any kind). The checked subset is submitted to `/api/admin/areafix/sync` with `force_descriptions: true`, which is what actually applies a flagged description mismatch — the sysop can still deselect a specific "Updated" row before confirming if they don't want that particular description overwritten. + ### `POST /api/admin/areafix/sync-latest` -Inspect the latest incoming AreaFix/FileFix reply for an uplink from message history, parse available areas, and sync them to the local database. +Inspect an incoming AreaFix/FileFix reply for an uplink from message history, parse available areas, and sync **all** of them to the local database in one all-or-nothing step, without the `force_descriptions` override or the ability to select a subset. The admin UI's preview modal now applies the sysop's curated selection via `/api/admin/areafix/sync` instead (see above); this endpoint remains available for callers that want to apply an entire reply directly without a preview step. **Request body:** ```json { - "uplink": "1:1/23", - "robot": "areafix" + "uplink": "1:1/23", + "robot": "areafix", + "message_id": 4821 } ``` +`message_id` is optional; omit it to apply the newest actionable incoming reply. The remembered tier for this uplink+domain+robot (see [Per-Uplink Grammar Memory](#per-uplink-grammar-memory)) is used as a parsing hint and updated automatically after a successful sync — there is no request field for it since this endpoint always applies the reply directly. + **Response:** ```json { @@ -234,16 +283,199 @@ Inspect the latest incoming AreaFix/FileFix reply for an uplink from message his --- +### `GET /api/admin/areafix/grammar-memory?uplink=1:1/23` +Return the per-uplink grammar memory (see [Per-Uplink Grammar Memory](#per-uplink-grammar-memory)) for both the `areafix` and `filefix` robots on this uplink, plus the full list of tier identifiers the admin UI's "force a tier" selector may offer. Backs the **Admin → Networks → Edit Uplink** dialog. + +**Response:** +```json +{ + "success": true, + "areafix": { "tier": "mystic_blocks", "last_matched_at": "2026-09-25 14:02:11" }, + "filefix": null, + "known_tiers": ["mystic_blocks", "delimited_table", "columnar_table", "quoted_address_list", "flagged_dotted_quoted_list", "configured:my_hub_format", "freeform"] +} +``` + +`areafix`/`filefix` are each `null` if no tier has ever been recorded for that robot on this uplink. + +### `POST /api/admin/areafix/grammar-memory` +Manually edit the remembered grammar tier for one uplink+robot — force it to a specific tier, or clear it entirely. + +**Request body:** +```json +{ "uplink": "1:1/23", "robot": "areafix", "tier": "mystic_blocks" } +``` + +Pass `"tier": null` (or omit it) to clear the remembered tier instead of setting one. A non-null `tier` must be one of the identifiers `AreaFixParser::getKnownTierIds()` returns (the same list as `known_tiers` in the `GET` response above); anything else is rejected with `errors.admin.areafix.invalid_tier`. Setting a tier here stores it exactly the way a confirmed sync would (`AreaFixManager::rememberTier()`), so it's tried first on the next reply. + +**Response:** +```json +{ "success": true, "tier": "mystic_blocks" } +``` + +--- + +### `GET /admin/areafix-grammars` +Admin UI page for editing data-driven AreaFix/FileFix grammar definitions (`config/areafix_grammars.json`) as raw JSON. See [Data-Driven Grammar Definitions](#data-driven-grammar-definitions) below for the schema. + +### `GET /api/admin/areafix/grammars-config` +Return the raw contents of `config/areafix_grammars.json` (or `"[]"` if the file doesn't exist yet). + +**Response:** +```json +{ + "success": true, + "config": { "config_json": "[]" } +} +``` + +### `POST /api/admin/areafix/grammars-config` +Replace `config/areafix_grammars.json` wholesale with the given JSON array. Written via the admin daemon, like other runtime config files (see `docs/AdminDaemon.md`). + +**Request body:** +```json +{ "json": "[ { \"id\": \"my_hub_format\", \"enabled\": true, ... } ]" } +``` + +**Response:** +```json +{ + "success": true, + "config": { "config_json": "..." }, + "message_code": "ui.admin.areafix_grammars.saved_success" +} +``` + +--- + +### `POST /api/admin/areafix/grammars-ai-generate` +Ask the configured AI provider to suggest a grammar definition from a pasted AreaFix/FileFix reply message, via the "Paste from AreaFix Message" button on `/admin/areafix-grammars`. See `docs/AIProviders.md#areafix-grammar-generation` and `docs/API.md` for the full request/response shape. The suggestion is always returned with `enabled: false` and every regex validated with `AreaFixParser::isValidPattern()`, but nothing is written to `config/areafix_grammars.json` until the sysop reviews it and clicks Save. + +--- + + +## Parser Architecture & Structural Parsing + +AreaFix and FileFix responses are parsed using `BinktermPHP\AreaFix\AreaFixParser` (`src/AreaFix/AreaFixParser.php`). + +Rather than relying on fragile keyword blacklists or naive line regexes, the parser recognizes concrete structural grammars generated by major FTN hub software: + +1. **Mystic BBS & MBSE Command / Result Blocks**: + - Parses stacked multi-command requests in a single reply (e.g. `+TAG` followed by `-TAG` and `%LINKED`). + - Pairs `Command:` lines with their corresponding `Result:` status. + - Extracts indented area listings under `%LINKED`, `%QUERY`, `%LIST`, and `%UNLINKED` command results. +2. **Delimited Tables (Husky, Clearing Houz, FastEcho, FrontDoor)**: + - Detects colon (`:`) and pipe (`|`) table headers (`AREA`, `DESCRIPTION`, `STATUS`, `MSGS`, `FILES`). + - Slices table columns safely to preserve internal colons or special characters in area descriptions (e.g. `FSX: Ads + ANSI Art`). + - Detects subscription markers (`*`, `+`) in status columns. +3. **Columnar & Dotted-Leader Tables (HPT, Husky)**: + - Parses fixed-width and dotted-leader rows (`TAG .... status/description`). + - Differentiates subscription states (`subscribed`, `rescanned` vs `unsubscribed`). +4. **Quoted-Address Lists (BBBS/Li6)** and **Flag-Prefixed Dotted-Leader Quoted Lists (HPT `%LIST`)**: + - Parses `+TAG (address) "description"` style listings, including wrapped multi-line descriptions and combined echo-area/file-area sections in one reply. + - Parses `*S TAG ..... "description"` flag-prefixed dotted-leader listings, treating `*` as the linked/subscribed marker. +5. **Data-driven grammars** (`config/areafix_grammars.json`, see [below](#data-driven-grammar-definitions)) and, as a last resort, a **conservative freeform `TAG Description` line matcher** for hub formats with no recognizable header, banner, or delimiter at all. Freeform matches are never marked `subscribe` and are logged via `BinktermPHP\Binkp\Logger` so an unrecognized format can be turned into a proper grammar later. +6. **Syntactic Tag Validation & Guard Rails**: + - Validates area tags using `AreaFixParser::isValidTag()` (2–60 alphanumeric/dash/dot characters with at least one letter). + - Does not maintain an English word blacklist, ensuring valid echo areas like `LINUX`, `BASE`, or `WINDOWS` are never dropped. + - Discards ANSI box-drawing/block art characters (`▄█▀▌▐░▒▓─│┌┐└┘`) from descriptions. + - Rejects non-actionable replies (help manuals, password failure notices, rescan receipts without area lists). + +### Area Action Types + +Each parsed area contains an `action` attribute: + +| Action | Description | `is_subscribed` | DB Sync Behavior | +|---|---|---|---| +| `subscribe` | Confirmation of an added or existing subscription | `true` | Inserts or updates area with `is_active = true` | +| `unsubscribe` | Confirmation of a removed subscription | `false` | Marks existing area with `is_active = false` | +| `available` | Area listed in a `%LIST` or `%UNLINKED` catalog | `false` | Inserts area with `is_active = false`, or updates description | + +--- + +### Data-Driven Grammar Definitions + +The three built-in structural grammars, the quoted-address/flag-prefixed grammars, and the freeform fallback tier are enough to cover the major FTN hub mailers, but a new or unusual hub format can still slip through as an empty result. Rather than requiring a PHP change for every new format, `AreaFixParser` also loads grammar definitions from `config/areafix_grammars.json` (managed via the [`/admin/areafix-grammars`](#get-adminareafix-grammars) admin page, the same way `webdoors.json` is edited through `/admin/webdoors`). + +Configured grammars are tried **after** every built-in grammar and **before** the freeform fallback tier, in the order they appear in the file. The first grammar whose `header_pattern` matches the body, and which then finds at least one row, wins. + +If `config/areafix_grammars.json` doesn't exist yet, `AreaFixParser` falls back to reading `config/areafix_grammars.json.example` instead, so the shipped sample grammar is available as a starting point without requiring a sysop to create the real file first. Every grammar in the shipped example ships with `enabled: false`, so this fallback never changes parsing behavior until a sysop deliberately enables (or replaces) a grammar. The admin page's **Populate from Example** button loads `areafix_grammars.json.example`'s contents into the editor so it can be reviewed and edited before saving as the real `areafix_grammars.json`. + +`config/areafix_grammars.json` (or `.example`) is a JSON array of grammar objects: + +```json +[ + { + "id": "my_hub_format", + "enabled": true, + "header_pattern": "My Hub Mailer v[0-9.]+ Area Report", + "row_pattern": "^(?[A-Za-z0-9_\\-.]+)\\s{2,}(?\\S+)?\\s{2,}(?.*)$", + "stop_pattern": "^-{3,}", + "default_action": "available", + "status_rules": [ + { "pattern": "^linked$", "action": "subscribe" }, + { "pattern": "^unlinked$", "action": "unsubscribe" } + ] + } +] +``` + +| Field | Required | Description | +|---|---|---| +| `id` | yes | Identifier used in log entries when this grammar matches, and as the `configured:` tier name in [per-uplink grammar memory](#per-uplink-grammar-memory). Not otherwise interpreted. | +| `enabled` | yes | Grammar is skipped entirely unless `true`. | +| `header_pattern` | yes | PCRE pattern (no delimiters, matched case-insensitively with the multiline flag against the whole body) that must appear somewhere in the reply for this grammar to be attempted. Cheap gate against false positives on unrelated replies. | +| `row_pattern` | yes | PCRE pattern (no delimiters, matched per line) with a required named group `tag`, and optional named groups `description` and `status`. | +| `stop_pattern` | no | PCRE pattern; a line matching it ends the row scan (in addition to the default stop rule: a blank line after at least one matched row). | +| `default_action` | no | One of `subscribe`, `unsubscribe`, `available`. Defaults to `available` — a configured grammar never defaults to `subscribe` unless a `status_rules` entry says so. | +| `status_rules` | no | Ordered list of `{ "pattern": "...", "action": "..." }`. Each `pattern` is tested (case-insensitively) against the row's captured `status` text; the first match wins. Anchor patterns (`^...$`) when one status word is a substring of another (e.g. `unlinked` contains `linked`). | + +A grammar with a missing/invalid `header_pattern` or `row_pattern`, an invalid regex anywhere in it, or `enabled: false`, is skipped entirely rather than partially applied — a typo in one definition can never produce a misleading partial match, and never crashes the parser. Invalid regexes are logged as a warning via `BinktermPHP\Binkp\Logger`. + +Every parsed area — whether from a built-in grammar, a configured grammar, or the freeform fallback — still passes through the same `AreaFixParser::isValidTag()` check and the mandatory sync preview (see [Sync to Echo Areas](#sync-to-echo-areas)) before anything is written to the database, so a loosely-written grammar can produce noise but not silently corrupt subscription state. + +--- + +### Per-Uplink Grammar Memory + +Each hub uplink is internally consistent — a given hub's AreaFix/FileFix robot always emits the same reply format — even though the overall population of uplinks a BBS connects to is heterogeneous. `AreaFixParser` and `AreaFixManager` exploit this to skip straight to the right tier on repeat replies, and to flag it when a hub's format unexpectedly changes. + +**Tiers.** Every grammar `AreaFixParser` can try has a stable identifier: the five built-in structural grammars (`AreaFixParser::TIER_MYSTIC_BLOCKS`, `TIER_DELIMITED_TABLE`, `TIER_COLUMNAR_TABLE`, `TIER_QUOTED_ADDRESS_LIST`, `TIER_FLAGGED_DOTTED_QUOTED_LIST`), a data-driven grammar as `configured:`, or `AreaFixParser::TIER_FREEFORM` for the last-resort fallback. `AreaFixParser::parseWithTier($body, $subject, $preferredTier)` returns `{areas, tier}` — the same tiers as `parse()` tries, in the same order, except `$preferredTier` (when given and still present) is tried first. This never changes which tier ultimately wins for a given body, only how quickly it's found when the hint is correct. + +**Storage.** The `areafix_grammar_memory` table (one row per `uplink_address` + `domain` + `robot`) records the tier that last produced a *confirmed* sync — via `AreaFixManager::getRememberedTier()` and `AreaFixManager::rememberTier()`. "Confirmed" specifically means an actual sync was applied, not merely previewed: + +- `POST /api/admin/areafix/sync-latest` and the auto-sync path (`AreaFixManager::processIncomingReply()`, used by scheduled polling) record the tier immediately after a successful sync, since both apply a reply directly. +- `POST /api/admin/areafix/preview-latest` looks up the remembered tier to use as a parsing hint and to compute `format_changed`, but never writes to `areafix_grammar_memory` itself — previewing a reply the sysop then cancels must never overwrite a known-good remembered tier. +- `POST /api/admin/areafix/sync` (applying a sysop-curated selection from the preview) records the tier only when the caller passes one back via the optional `tier` request field, which the admin UI does automatically using the tier `preview-latest` reported for that reply. + +**Format-change detection.** `preview-latest`'s response includes `tier` (what matched this reply), `remembered_tier` (what was last confirmed for this uplink+domain+robot), and `format_changed` (true only when both are known and differ). The Admin → AreaFix / FileFix Manager preview modal shows a warning banner when this happens, naming the old and new tier — a concrete signal that the hub's mailer software may have changed or been reconfigured, distinct from the generic "review before applying" prompt every sync already gets. + +A first-time sync from any uplink has no remembered tier yet, so `remembered_tier` is `null` and `format_changed` is always `false` — the full ordered tier list is tried exactly as it always was. + +**Manual editing.** The remembered tier for each robot on an uplink can be viewed, forced to a specific tier, or cleared directly from **Admin → Networks → Edit Uplink**, without needing to trigger a real AreaFix sync — useful after confirming a hub's format really did change (clear the stale memory) or to pre-seed a known format for a brand-new uplink (force it). This is backed by `GET`/`POST /api/admin/areafix/grammar-memory`; see [API Reference](#get-apiadminareafixgrammar-memory) below. A manually forced tier is stored exactly the same way a confirmed sync's tier is (via `AreaFixManager::rememberTier()`), so it's tried first on the next reply and is subject to the same `format_changed` detection if a later reply doesn't match it. + +--- + +## Backend Classes + +### `src/AreaFix/AreaFixParser.php` -## Backend Class +| Method | Description | +|---|---| +| `parse(string $body, ?string $subject = null): array` | Parse response text into structured area records with actions | +| `hasActionableContent(string $body, ?string $subject = null): bool` | Check if reply contains actionable subscriptions or area listings | +| `isValidTag(string $tag): bool` | Syntactically validate an FTN area tag | -`src/AreaFixManager.php` — key public methods: +### `src/AreaFixManager.php` | Method | Description | |---|---| | `sendCommand($uplinkAddress, $commands, $robot, $sysopUserId)` | Send commands via netmail | -| `parseResponseText($body, $commandType)` | Parse hub reply body into area records | -| `syncSubscribedAreas($uplinkAddress, $domain, $parsedAreas, $deactivateMissing, $robot)` | Sync parsed areas to DB | +| `parseResponseText($body, $commandType)` | Parse hub reply body into area records via `AreaFixParser` | +| `isAreaListResponse($subject, $body)` | Determine if a netmail is an actionable AreaFix reply | +| `isPlaceholderDescription(?string $desc)` | Check if a description is an auto-created placeholder or contains ANSI art | +| `syncSubscribedAreas($uplinkAddress, $domain, $parsedAreas, $deactivateMissing, $robot)` | Sync parsed areas to DB respecting action semantics | | `deactivateArea($areaTag, $domain)` | Mark a local area as inactive | | `getHistory($uplinkAddress, $sysopUserId)` | Fetch message history | | `getConfiguredUplinks()` | List uplinks with passwords configured | + diff --git a/docs/UPGRADING_1.10.7.md b/docs/UPGRADING_1.10.7.md index 7de111fc5..a4371b773 100644 --- a/docs/UPGRADING_1.10.7.md +++ b/docs/UPGRADING_1.10.7.md @@ -8,6 +8,11 @@ Make sure you have a current backup of your database and files before upgrading. - [Messaging](#messaging) - [Date Display Preferences](#date-display-preferences) - [Message Search Scoped by Network and Interest](#message-search-scoped-by-network-and-interest) +- [AreaFix / FileFix](#areafix-filefix) + - [Structural Reply Parsing Across More Hub Mailers](#structural-reply-parsing-across-more-hub-mailers) + - [Mandatory Preview Before Syncing Areas](#mandatory-preview-before-syncing-areas) + - [Data-Driven Grammar Definitions](#data-driven-grammar-definitions) + - [Per-Uplink Format Memory](#per-uplink-format-memory) - [Administration](#administration) - [Fixed: user-manager.php create Command](#fixed-user-managerphp-create-command) - [Security](#security) @@ -24,6 +29,13 @@ Make sure you have a current backup of your database and files before upgrading. - **Date display preferences:** users and sysops can now choose between relative timestamps ("4d ago") and exact date/time for message lists and headers, and choose whether echomail is ordered and displayed by received date or written date. - **Message search scoped by network and interest:** searching for messages from the Echo Areas page now respects the network and interest filters selected there, and searching while browsing a single interest on the Echomail page now stays within that interest's echo areas, instead of always searching every echo area. +### AreaFix / FileFix + +- **Structural reply parsing across more hub mailers:** AreaFix and FileFix replies are now parsed by recognizing the concrete layout each hub mailer actually sends — Mystic BBS/MBSE command blocks, delimited and columnar tables, BBBS/Li6-style quoted address lists, and HPT-style flag-prefixed quoted lists — instead of scanning for keywords. Real echo areas with common names such as `LINUX`, `WINDOWS`, or `BASE` are no longer mistaken for header text or help output. +- **Mandatory preview before syncing areas:** clicking "Sync Areas to Local BBS" (from the latest reply, or from any individual incoming message in the Message History table) now shows a preview of exactly which areas will be created, reactivated, deactivated, or left unchanged. Nothing is written to the database until this preview is explicitly confirmed. +- **Data-driven grammar definitions:** a new **Admin -> Area Management -> AreaFix Grammars** page lets a sysop teach AreaFix a new hub reply format without a code change, either by hand or by pasting a sample reply and asking the built-in AI assistant to suggest one. Suggestions are always added disabled for review before saving. +- **Per-uplink format memory:** BinktermPHP now remembers which reply format last matched each hub's confirmed sync, tries that format first on the hub's next reply, and flags it on the preview screen if the format changes unexpectedly. The remembered format for each uplink can be viewed, forced, or cleared from **Admin -> BBS Settings -> BinkP Uplinks -> Edit Uplink**. + ### Administration - **Fixed `scripts/user-manager.php create`:** the operator CLI's `create` command failed on PostgreSQL with `column "is_active" is of type boolean but expression is of type integer`, because it inserted the literal `1` instead of a boolean. This is now fixed. @@ -51,6 +63,54 @@ The Echo Areas page lets you filter the area list down to one or more networks a On the Echomail page, searching while browsing a single interest under the Interests tab is likewise scoped to that interest's echo areas. Searching from a specific echo area continues to scope to that single area, as before, taking priority over any network or interest scope. +## AreaFix / FileFix + +### Structural Reply Parsing Across More Hub Mailers + +AreaFix and FileFix replies from a hub are parsed by matching the actual layout the hub's mailer software produces, rather than by scanning line-by-line for known words and phrases. The parser recognizes: + +- Mystic BBS and MBSE `Command:`/`Result:` blocks, including stacked multi-command replies and `%LIST`/`%QUERY`/`%LINKED`/`%UNLINKED` result listings. +- Colon- and pipe-delimited tables (Husky, Clearing Houz, FastEcho, FrontDoor, InterMail). +- Columnar and dotted-leader tables (HPT, Husky), including table headers that name the tag column something other than the literal word "Area" (for example "Message area"). +- BBBS/Li6-style quoted address lists (`+TAG (address) "description"`), including descriptions that wrap onto a continuation line and a single reply that lists both echo areas and file areas. +- HPT-style flag-prefixed dotted-leader lists with quoted descriptions (`*S TAG ....... "description"`). +- As a last resort, a conservative bare `TAG Description` line matcher for hub replies that don't match any of the above, which never marks a matched area as subscribed on its own. + +Because this approach recognizes real structure instead of matching words, an echo area named the same as an ordinary English word or a common piece of software (`LINUX`, `WINDOWS`, `BASE`, and similar) is preserved correctly instead of being mistaken for a header, a help topic, or unrelated prose. + +A Mystic BBS/MBSE `%QUERY` reply that lists both linked and unlinked areas in a single block, with individual rows explicitly annotated `(linked)`, `(unlinked)`, or `(not linked)`, now honors each row's own annotation instead of marking every row in the block the same way. + +### Mandatory Preview Before Syncing Areas + +Previously, clicking "Sync Areas to Local BBS" on the AreaFix / FileFix Manager page applied the parsed area list to your local echo areas or file areas immediately, with no chance to review it first. It now opens a preview dialog instead, and nothing is written to your database until you explicitly confirm it there. This preview is available in two places: from the "Latest Reply" panel's sync button, and per-message from a sync button next to each incoming reply in the Message History table, so you can also review and apply an older reply without it needing to still be the most recent one. + +The preview lists every area found in the reply as a row with a checkbox, its tag, its description, and a status badge: + +- **New** — the area doesn't exist locally yet and will be created. +- **Reactivate** — the area exists but is currently inactive and will be turned on. +- **Deactivate** — the area is currently active and the reply says to unsubscribe from it. +- **Updated** — the area's activation state isn't changing, but its description will be filled in or updated to match the hub's reply. +- **Unchanged** — nothing about the area differs from what the reply says; selecting it has no effect. + +For an "Updated" row, the description cell shows your current description struck through above the incoming one when it will actually be replaced. A description is only ever replaced when your current one is empty, an auto-generated placeholder, or you've explicitly selected that row for sync (see below) — a real, sysop-set description is never silently overwritten. If the hub's reply lists a different description for an area whose own real description would otherwise be left alone, the preview still shows what the hub sent underneath it, so the mismatch doesn't go unnoticed just because it's not required to be applied. + +Every row starts checked except a genuine no-op "Unchanged" row — including every "New", "Reactivate", "Deactivate", and "Updated" row, so the normal case (review, then confirm) still applies everything in one click. Use the checkboxes, or the "Select All" / "Select None" buttons above the list, to apply only a subset instead. Confirming a checked "Updated" row is what actually lets a hub's description win over your own where it otherwise wouldn't — uncheck that specific row first if you'd rather keep your own description for that one area. + +### Data-Driven Grammar Definitions + +The structural parser recognizes several hub mailer formats out of the box, but a new or unusual format can still come back as an empty reply. A new admin page, **Admin -> Area Management -> AreaFix Grammars** (`/admin/areafix-grammars`), lets a sysop describe a new format as data instead of waiting for a code change: + +- Each grammar definition is a JSON object specifying a header pattern (to detect the format), a per-row pattern (to extract the area tag, description, and status), and how status text maps to subscribed/unsubscribed/available. The full schema is documented on the page and in `docs/AreaFix.md`. +- A **Paste from AreaFix Message** button lets you paste the raw text of a hub reply and have the configured AI provider suggest a grammar definition for it. The suggestion is always added disabled, and every regex in it is validated, so nothing starts matching mail until you review and explicitly enable it. +- A **Populate from Example** button loads a starter definition from `config/areafix_grammars.json.example`, which ships disabled and has no effect until you edit and save it. +- Grammars you define are tried after the built-in structural formats and before the last-resort freeform line matcher, in the order they appear on the page. + +### Per-Uplink Format Memory + +A given hub's AreaFix/FileFix robot always replies in the same format, so BinktermPHP now remembers which format matched the last confirmed sync for each uplink and robot (AreaFix and FileFix are tracked separately). On the next reply from that uplink, the remembered format is tried first, and if a reply no longer matches it, the sync preview shows a warning naming the old and new format — a concrete signal that the hub's mailer software may have changed or been reconfigured. + +The remembered format for each uplink is visible and directly editable from **Admin -> BBS Settings -> BinkP Uplinks -> Edit Uplink**: a "Remembered Reply Format" panel shows the current format for AreaFix and FileFix, with buttons to force it to a specific format or clear it. Clearing is useful after you've confirmed a hub's format really did change; forcing is useful to pre-seed a known format for a brand-new uplink before its first reply arrives. + ## Administration ### Fixed: user-manager.php create Command diff --git a/docs/proposals/PR460Proposal.md b/docs/proposals/PR460Proposal.md new file mode 100644 index 000000000..fb244402e --- /dev/null +++ b/docs/proposals/PR460Proposal.md @@ -0,0 +1,413 @@ +# AreaFix Structural Parser: Hardening & Preview Confirmation + +> **Draft Notice:** This proposal is a draft, generated with AI assistance, and may not have been reviewed for accuracy. It is intended as a starting point for discussion and implementation planning. + +--- + +## Table of Contents + +1. [Problem Statement](#problem-statement) +2. [Background: What PR 460 Changed](#background-what-pr-460-changed) +3. [Gaps Identified in PR 460](#gaps-identified-in-pr-460) +4. [Proposed Improvement 1: Layered Fallback Parsing](#proposed-improvement-1-layered-fallback-parsing) +5. [Proposed Improvement 2: Fix the Stale Actionable-Reply Threshold](#proposed-improvement-2-fix-the-stale-actionable-reply-threshold) +6. [Proposed Improvement 3: Row-Level Status for %QUERY/%LINKED Blocks](#proposed-improvement-3-row-level-status-for-querylinked-blocks) +7. [Proposed Improvement 4: Preview Screen Before Applying Changes](#proposed-improvement-4-preview-screen-before-applying-changes) +8. [Proposed Improvement 5: Data-Driven Grammar Definitions](#proposed-improvement-5-data-driven-grammar-definitions) +9. [Proposed Improvement 6: Per-Uplink Format Memory](#proposed-improvement-6-per-uplink-format-memory) +10. [Out of Scope](#out-of-scope) +11. [Open Questions](#open-questions) + +## Problem Statement + +PR 460 (`refactor(areafix): structural response parser and multi-command +support`) replaced the old regex/blocklist-based AreaFix reply parser with a +structural parser (`src/AreaFix/AreaFixParser.php`) that recognizes three +concrete grammars: Mystic/MBSE Command/Result blocks, delimited (`:`/`|`) +tables, and columnar/dotted-leader tables. This is a meaningful improvement in +precision — it eliminates false positives caused by the old English-word +blocklist (e.g. real echoareas named `LINUX`, `BASE`, `WINDOWS` no longer +collide with blocklisted words) and adds real subscribe/unsubscribe/available +semantics that the old parser lacked entirely. + +However, the new parser is a closed world: if a hub's reply doesn't match one +of the three recognized grammars, `parse()` returns an empty array with no +error, no log entry, and no fallback. Combined with the fact that AreaFix sync +results are applied directly to the `echoareas`/`file_areas` tables +(activating and deactivating rows), a sysop currently has no way to see what a +sync operation is about to do before it happens. This proposal builds on top +of PR 460's structural parser rather than replacing it, and adds the missing +safety net and visibility. + +## Background: What PR 460 Changed + +- Added `src/AreaFix/AreaFixParser.php` with three grammar-specific parsers + (`parseMysticBlocks`, `parseDelimitedTable`, `parseColumnarTable`), tried in + order, first non-empty result wins. +- Added `AreaFixParser::isValidTag()` for strict syntactic tag validation + (letters/digits/underscore/hyphen/period, 2-60 chars, at least one letter, + not a reserved structural keyword) in place of the old blocklist. +- Added action semantics: `ACTION_SUBSCRIBE`, `ACTION_UNSUBSCRIBE`, + `ACTION_AVAILABLE`, each driving different behavior in + `AreaFixManager::syncSubscribedAreas()`. +- Added `tests/test_structural_areafix_parser.php` and + `tests/test_areafix_response_guard.php` covering the three recognized + grammars. +- Updated `docs/AreaFix.md` to document the new parser architecture. + +## Gaps Identified in PR 460 + +1. **No fallback for unrecognized formats.** The old parser had a freeform + fallback (any bare `TAG description` line, filtered by a blocklist) that + is not present in the new parser. A hub mailer whose reply doesn't match + one of the three recognized headers/banners now silently produces zero + areas, and auto-sync for that uplink appears to just stop working with no + visible error. +2. **Stale `count($areas) >= 2` threshold.** `routes/admin-routes.php` + (`/api/admin/areafix/sync-latest`) still requires at least two parsed areas + before treating a reply as actionable, even though PR 460 removed that + same threshold from the parser itself and added a test asserting that a + single `+TAG` confirmation is a valid actionable reply. This makes the + admin "sync latest" action reject exactly the single-area confirmations + the new parser was built to accept. +3. **`%QUERY`/`%LINKED` blocks don't check row-level status.** Every row + found inside a `%QUERY`/`%LINKED`/`%LINK` result block is marked + `ACTION_SUBSCRIBE` purely because of which command produced the block, not + because of anything in the row itself. If a hub's `%QUERY` response ever + lists both linked and unlinked areas together, every row — including ones + the sysop is not actually subscribed to — is synced as subscribed. +4. **No confirmation step before changes are applied.** Whether a sync comes + from an automatic scheduled poll or a manual admin action, parsed areas + are synced directly into `echoareas`/`file_areas` with no intermediate + step where a human can see what will be created, activated, or + deactivated. + +## Proposed Improvement 1: Layered Fallback Parsing + +> **Status: Implemented.** See `src/AreaFix/AreaFixParser.php` and +> `tests/test_areafix_real_world_samples.php`. What actually landed turned out +> to be broader than originally scoped here — see **Implementation Notes** +> below for what was added beyond the freeform fallback tier. + +Keep the three structural grammars in `AreaFixParser` as the primary, +preferred path — they should continue to be tried first since they carry +correct action semantics and delimiter-safe description slicing that a +freeform parser cannot replicate. Add a fourth, last-resort tier: + +- A conservative freeform line parser, gated by the same strict + `AreaFixParser::isValidTag()` check used by the structural parsers (never + the old English-word blocklist). +- Rows matched only by the fallback tier default to `ACTION_AVAILABLE` and + `is_subscribed = false` — never `ACTION_SUBSCRIBE` — so an unrecognized + format cannot silently auto-activate an area. +- Every time the fallback tier is the one that produced a non-empty result, + log it via `BinktermPHP\Binkp\Logger` (uplink address, message subject, + first N characters of the body) so an unrecognized hub format becomes + visible for follow-up instead of vanishing. + +This preserves PR 460's precision gains for the mailers it already +recognizes, while turning "silently returns zero areas" into "parses +conservatively and leaves a log trail for adding a proper grammar later." + +### Implementation Notes + +Testing this against real hub reply samples (`tests/test_areafix_real_world_samples.php`) +showed that a single freeform tier wasn't enough on its own — two of the three +originally-failing samples turned out to have real, recognizable structure +that a proper grammar could handle more precisely than a blind line-by-line +fallback ever could: + +- **AreaMgr-style `Con / Message area / Description` tables.** This was not a + headerless format at all — it has a header and a dashed separator, just + like the existing HPT/Husky columnar grammar, except the tag column isn't + literally named `Area` (it's `Message area`, with a `Con` flags column + first). Fixed by broadening `parseColumnarTable()`'s header regex from + requiring the literal prefix `Area\s{3,}(Status|Description|...)` to + matching `area` as a whole word anywhere in the header line, still gated by + requiring an immediately-following dashed separator line (so this doesn't + loosen into matching arbitrary prose). This one didn't need the fallback + tier at all — it's now handled with full precision as a fourth case of the + existing structural grammar. +- **BBBS/Li6 `+TAG (address) "description"` lists** (with wrapped multi-line + descriptions and a single reply combining both an echo-area and file-area + section). This is also a real, well-defined structural grammar, not + freeform prose, so it was added as its own tier — + `parseQuotedAddressList()` — ahead of the freeform fallback rather than + folded into it. It handles the multi-line description wrapping by + continuing to consume lines until a closing quote appears, with guards + against ever swallowing the next entry, a blank line, or a banner line. +- **SBBSecho bare `TAG Description` lists** (no header, no delimiter, no + banner — just a plain two-column list terminated by a `--- ` + tearline) is the genuine freeform case the fallback tier was designed for, + implemented as `parseFreeformList()`. Gated by `isValidTag()` and a + required 2+ space (or tab) gap between tag and description; always returns + `ACTION_AVAILABLE`, never `ACTION_SUBSCRIBE`; logs via + `BinktermPHP\Binkp\Logger` to `server.log` whenever it's the tier that + produced results. + +**Known, accepted limitations** (verified against the real samples, not +theoretical): +- A tag whose name is long enough to leave only a single space before its + description (e.g. `CHEESE_HUMANSONLY` in the SBBSecho sample) is not + captured by the freeform tier. Loosening the gap requirement to 1+ space + would make it match ordinary two-word prose sentences — confirmed against + `tests/test_areafix_response_guard.php`'s existing prose-rejection test, + which relies on single-spaced English sentences never being mistaken for + area rows. This tradeoff was chosen deliberately: coverage loss on a rare + column-alignment edge case is preferable to false positives on ordinary + text. +- Tags containing characters outside `isValidTag()`'s allowed set (letters, + digits, `_`, `-`, `.`) are not captured by any grammar, including the new + ones — e.g. `WHAT'S_HOT!` and `WILDCAT!_SUPPORT` in the BBBS sample. Widening + `isValidTag()`'s character class was considered out of scope here since it's + shared by every grammar and widening it for two rare tag names isn't worth + the added false-positive surface elsewhere. +- When the same tag legitimately appears in two different sections of one + reply with two different descriptions (e.g. `ECHOLIST` as both an echo area + and a file area in the BBBS sample), `deduplicateAreas()` collapses them to + one entry, keeping the first description seen. The parser has no concept of + "echo area" vs. "file area" — that distinction is applied later by the + caller (`robot` parameter in `AreaFixManager::syncSubscribedAreas()`), so + this is a pre-existing architectural limitation, not something Improvement + 1 introduced or was expected to fix. + +## Proposed Improvement 2: Fix the Stale Actionable-Reply Threshold + +> **Status: Implemented.** Landed as part of building Improvement 4 (both the +> preview and apply endpoints needed to agree on what "actionable" means). + +Remove the `count($areas) >= 2` check in `routes/admin-routes.php`'s +`/api/admin/areafix/sync-latest` handler (near the `isAreaListResponse()` +call) and rely solely on `AreaFixParser`/`AreaFixManager`'s own notion of +"actionable" (non-empty parsed area list), matching the behavior PR 460 +already established and tested in `AreaFixManager` itself. This is a small, +isolated fix that removes an inconsistency introduced by PR 460 rather than a +new feature. + +## Proposed Improvement 3: Row-Level Status for %QUERY/%LINKED Blocks + +> **Status: Implemented.** See `src/AreaFix/AreaFixParser.php` +> (`parseMysticBlocks()`) and Test 8 in +> `tests/test_structural_areafix_parser.php`. + +Extend `parseMysticBlocks()` so that indented rows under a `%QUERY`/`%LINKED`/ +`%LINK`/`%UNLINKED`/`%LIST`/`%AVAIL` result block are classified the same way +the delimited-table and columnar-table parsers already do: by inspecting each +row's own status text (a leading marker, or a trailing word like +"unsubscribed") rather than assuming every row shares the same action because +of which command produced the block. Where a hub's block format genuinely +carries no per-row status (true today for the `%LINKED`-only case, where every +row *is* linked by definition), the current command-based classification is +correct and should stay — the change is only needed for blocks whose rows +can mix linked and unlinked entries (`%QUERY`). + +### Implementation Notes + +No captured real-world sample of a hub mixing linked and unlinked areas in a +single `%QUERY` block was available, so the row-level signal implemented is a +conservative, explicit annotation: a row ending in `(linked)`, `(unlinked)`, +or `(not linked)` is classified by that annotation (with the annotation +stripped from the stored description), overriding the command-level default. +A row with no such annotation still falls back to the command-level default +exactly as before, so this is purely additive — no existing passing test +(Mystic `%LIST`/`%LINKED` samples with no per-row markers) changed behavior. +If a real hub reply using a different per-row marker convention for mixed +`%QUERY` listings turns up, `parseMysticBlocks()`'s row-classification block is +the place to extend. + +## Proposed Improvement 4: Preview Screen Before Applying Changes + +> **Status: Implemented**, including the per-message extension described in +> the Open Questions resolution below (a sync button on each incoming +> Message History row, not just the "latest reply" panel, via an optional +> `message_id` on both endpoints). + +This is the centerpiece of the proposal. Whether a sync is triggered +automatically (scheduled poll processing an incoming AreaFix reply) or +manually (an admin clicking "sync latest" in Admin → Networks), the parsed +result should be shown to a human before any row in `echoareas`/`file_areas` +is created, activated, or deactivated. + +### Flow + +1. `AreaFixParser::parse()` runs as it does today, producing the list of + `{name, description, action, is_subscribed}` items (now including any + fallback-tier items from Improvement 1, clearly flagged as such). +2. Instead of calling `AreaFixManager::syncSubscribedAreas()` immediately, + the result is diffed against the current `echoareas`/`file_areas` rows for + that uplink+domain to classify each parsed area as one of: + - **New** — will be created and activated. + - **Reactivate** — exists but currently `is_active = false`, will be + turned on. + - **Deactivate** — currently active but missing from the parsed list + (only relevant when `$deactivateMissing` is true). + - **Unchanged** — already in the desired state; shown for completeness + but visually de-emphasized. + - **Uncertain (fallback-parsed)** — produced only by the Improvement 1 + fallback tier; always shown with a distinct visual treatment and never + silently defaulted to "new/active." +3. This diff is rendered as a preview screen (Admin UI: a Bootstrap 5 modal + or dedicated panel under Admin → Networks; consistent styling with the + rest of the admin UI, respecting the theme stylesheets per project + convention) showing, per row: area tag, description, current state, and + proposed action. Nothing is written to the database at this point. +4. The sysop reviews the preview and either confirms (triggers the actual + `syncSubscribedAreas()` call using the previewed data) or cancels (nothing + changes). + +The preview/confirm step is **mandatory for every sync, with no opt-out +setting** — there is no configuration path that lets a sync apply directly +without a human reviewing the diff first. Automatic/scheduled AreaFix polling +is out of scope for this proposal; it is addressed only to the extent that it +already exists today, and is not being redesigned here. + +### API Shape (illustrative) + +- `POST /api/admin/areafix/preview-sync` — parses the latest actionable + reply for an uplink and returns the diff described above without applying + it. +- `POST /api/admin/areafix/apply-sync` — takes a previously previewed diff + (or re-parses and re-diffs immediately before applying, to avoid acting on + stale data if new mail arrived in between) and performs the actual + `syncSubscribedAreas()` call. + +Per project convention, any new API routes must be documented in +`docs/API.md` with complete response tables, and any new user-facing text +must go through the i18n catalog system rather than being hardcoded. + +### Why This Matters Given Improvements 1–3 + +The preview screen is also what makes the more permissive fallback parsing +in Improvement 1 safe to ship: instead of trusting an unrecognized format's +best-effort parse and quietly applying it, the sysop sees exactly which rows +came from the fallback tier (visually flagged as "uncertain") before +anything is activated. This turns the fallback tier from a risk into a +net improvement — coverage goes up, but nothing changes without a human +looking at it first. + +## Proposed Improvement 5: Data-Driven Grammar Definitions + +> **Status: Implemented.** See `src/AreaFix/AreaFixParser.php` +> (`loadConfiguredGrammars()`/`matchConfiguredGrammar()`), `config/areafix_grammars.json` +> (created on first save; absent by default), the `/admin/areafix-grammars` editor page, +> and `tests/test_configured_areafix_grammars.php`. Full schema reference in +> `docs/AreaFix.md` under "Data-Driven Grammar Definitions". + +Today, adding support for a new hub mailer's AreaFix reply format means +writing a new private method in `AreaFixParser` (as `parseMysticBlocks`, +`parseDelimitedTable`, and `parseColumnarTable` already are), which requires +a PHP change, a PR, and a release. To make broader format coverage +achievable without that overhead, structural grammars should be describable +declaratively instead of only in code: + +- A grammar definition specifies, in a config file (e.g. + `config/areafix_grammars.json` or similar, following this project's + convention of runtime configuration living under `config/`): a header + detection pattern, column layout (which column holds the tag, description, + status), the delimiter or column-width rule, and how status text maps to + `ACTION_SUBSCRIBE` / `ACTION_UNSUBSCRIBE` / `ACTION_AVAILABLE`. +- `AreaFixParser` loads and tries each defined grammar (built-in three plus + any config-defined ones) in order, same as today, before falling through + to the Improvement 1 fallback tier. +- This does not remove the three existing grammars or require rewriting them + as data — they can stay as the built-in, well-tested defaults. It only + adds a path for new formats to be described as data going forward, + lowering the bar for contributors (including sysops who can identify their + own hub's format from a real reply) to add coverage without touching + parser internals. +- Per project convention, any new runtime config file of this kind should be + editable through an Admin UI page rather than requiring direct file edits, + consistent with how `binkp.json`/`bbs.json`/`webdoors.json` settings are + exposed today; direct file edits should only be a fallback for cases the + UI doesn't yet cover. + +This is a natural complement to Improvement 1: the fallback tier's logging +identifies *that* a format isn't recognized, and data-driven grammars make it +cheap to turn a logged sample into permanent, tested coverage. + +## Proposed Improvement 6: Per-Uplink Format Memory + +> **Status: Implemented.** See `src/AreaFix/AreaFixParser.php` (`TIER_*` +> constants, `parseWithTier()`, `buildTierList()`), `src/AreaFixManager.php` +> (`getRememberedTier()`, `rememberTier()`), the +> `areafix_grammar_memory` table (migration +> `v20260926031305_add_areafix_grammar_memory_table.sql`), and +> `tests/test_areafix_grammar_memory.php`. Full reference in +> `docs/AreaFix.md` under "Per-Uplink Grammar Memory". + +### Implementation Notes + +What landed matches the proposal closely, with these concrete choices: + +- Memory is written on **confirmed** syncs only: `POST /api/admin/areafix/sync-latest` + and the auto-sync path (`AreaFixManager::processIncomingReply()`) record the + tier immediately (they always apply directly); `POST + /api/admin/areafix/preview-latest` never writes memory, only reads it (as a + parsing hint and to compute `format_changed`); `POST + /api/admin/areafix/sync` (the sysop-curated preview confirmation) records + the tier only when the admin UI passes one back via an optional `tier` + field, populated from what `preview-latest` reported for that reply. +- `rememberTier()` treats a `null`/empty tier as a no-op rather than clearing + the row — a reply that matched nothing (or only the freeform fallback on + garbage input) must never erase a previously-known-good remembered tier. +- Memory is keyed by `uplink_address` + `domain` + `robot`, not just the + uplink address, since an AreaFix and FileFix robot on the same hub could in + principle use different reply formats. +- `parseWithTier()`'s `$preferredTier` parameter is a pure reordering hint: it + moves the remembered tier to the front of the ordered tier list but never + changes which tier ultimately wins for a given body, so a stale or wrong + remembered tier degrades to "try the normal order first" rather than ever + producing an incorrect parse. +- Beyond the proposal's original scope, the remembered tier is also directly + editable: **Admin → Networks → Edit Uplink** shows the current tier for + each robot and lets a sysop force it to a specific value or clear it, + backed by `GET`/`POST /api/admin/areafix/grammar-memory` and + `AreaFixParser::getKnownTierIds()`. This covers the two manual-intervention + cases the automatic path can't: resetting a stale memory after confirming a + format change out-of-band, and pre-seeding a known format for a brand-new + uplink before its first reply ever arrives. + +Each individual uplink is internally consistent — a given hub always emits +the same AreaFix reply format — even though the overall population of +uplinks a BBS might connect to is heterogeneous. The parser can exploit this: + +- When a reply from a given uplink address is successfully parsed and its + resulting sync is confirmed through the mandatory preview screen + (Improvement 4), record which grammar/tier matched (one of the three + built-in structural parsers, a data-driven grammar from Improvement 5, or + the Improvement 1 fallback tier) against that uplink. +- On the next reply from the same uplink, try the remembered grammar first + before falling through the full ordered list. This is a minor performance + optimization, but more importantly it establishes a per-uplink expectation + of "this is the format this hub sends." +- If a reply from an uplink with a remembered format *doesn't* match that + remembered grammar anymore, treat that as notable on the preview screen — + distinct from an uplink being parsed for the first time — since it likely + means the hub's mailer software changed, was reconfigured, or (in rarer + cases) the reply is coming from something other than the expected hub. + This gives the sysop a concrete signal to look more closely at that + specific sync, rather than a generic "review before applying" prompt. + +Together, Improvements 5 and 6 move the system from "recognizes a fixed set +of formats correctly" toward "recognizes a growing, contributor-extensible +set of formats correctly, and notices when a known uplink's format changes +out from under it" — without weakening the precision gains PR 460 already +delivered. + +## Out of Scope + +- Replacing or supplementing the structural parser with an LLM/AI-based + extraction step. As discussed in review, this is not recommended as a + primary parsing mechanism given the non-deterministic/unauditable nature of + LLM output for a subsystem that drives real subscription state; it is not + part of this proposal. +- Changes to FileFix-specific behavior beyond what naturally falls out of + sharing `AreaFixParser` with AreaFix (both should be kept at feature parity + per project convention, but this proposal does not introduce new + FileFix-only functionality). + +## Open Questions + +None remaining. The preview/confirm step is mandatory for all syncs with no +opt-out setting (so no new configuration surface is needed for it), and +automatic/scheduled sync behavior is out of scope for this proposal. diff --git a/public_html/sw.js b/public_html/sw.js index 694b4079e..e1bb59863 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v989'; +const CACHE_NAME = 'binkcache-v998'; // Static assets to precache const staticAssets = [ diff --git a/routes/admin-routes.php b/routes/admin-routes.php index 82ce1e877..0285e2bc4 100644 --- a/routes/admin-routes.php +++ b/routes/admin-routes.php @@ -10531,6 +10531,290 @@ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType) ]); }); +/** + * GET /admin/areafix-grammars + * Data-driven AreaFix/FileFix grammar definitions editor (raw JSON). + * See docs/AreaFix.md for the grammar schema. + */ +SimpleRouter::get('/admin/areafix-grammars', function () { + $user = RouteHelper::requireAdmin(); + + $template = new Template(); + $template->renderResponse('admin/areafix_grammars.twig'); +}); + +/** + * GET /api/admin/areafix/grammars-config + * Return the raw config/areafix_grammars.json contents (or "[]" if absent). + */ +SimpleRouter::get('/api/admin/areafix/grammars-config', function () { + $user = RouteHelper::requireAdmin(); + header('Content-Type: application/json'); + + try { + $client = new \BinktermPHP\Admin\AdminDaemonClient(); + $config = $client->getAreafixGrammarsConfig(); + echo json_encode(['success' => true, 'config' => $config]); + } catch (Exception $e) { + http_response_code(500); + apiError('errors.admin.areafix_grammars.load_failed', apiLocalizedText('errors.admin.areafix_grammars.load_failed', 'Failed to load AreaFix grammar configuration', $user)); + } +}); + +/** + * POST /api/admin/areafix/grammars-config + * Body: { json: string } — replaces config/areafix_grammars.json wholesale. + */ +SimpleRouter::post('/api/admin/areafix/grammars-config', function () { + $user = RouteHelper::requireAdmin(); + header('Content-Type: application/json'); + + try { + $payload = json_decode(file_get_contents('php://input'), true); + $json = (string)($payload['json'] ?? ''); + $client = new \BinktermPHP\Admin\AdminDaemonClient(); + $updated = $client->saveAreafixGrammarsConfig($json); + echo json_encode([ + 'success' => true, + 'config' => $updated, + 'message_code' => 'ui.admin.areafix_grammars.saved_success', + ]); + } catch (Exception $e) { + http_response_code(400); + apiError('errors.admin.areafix_grammars.save_failed', apiLocalizedText('errors.admin.areafix_grammars.save_failed', 'Failed to save AreaFix grammar configuration', $user)); + } +}); + +/** + * POST /api/admin/areafix/grammars-ai-generate + * Body: { message_text: string } — the raw text of a pasted AreaFix/FileFix + * reply message. Asks the configured AI provider to infer a grammar + * definition matching docs/AreaFix.md's schema and returns it for the sysop + * to review; nothing is written to config/areafix_grammars.json here. The + * returned grammar always has enabled=false regardless of what the AI + * returns, so a bad suggestion can never silently start matching mail. + */ +SimpleRouter::post('/api/admin/areafix/grammars-ai-generate', function () { + $user = RouteHelper::requireAdmin(); + header('Content-Type: application/json'); + + try { + $payload = json_decode(file_get_contents('php://input'), true); + $messageText = trim((string)($payload['message_text'] ?? '')); + + if ($messageText === '') { + http_response_code(422); + apiError('errors.admin.areafix_grammars.message_text_required', apiLocalizedText('errors.admin.areafix_grammars.message_text_required', 'Please paste some message text first', $user), 422); + return; + } + + // Bound token usage/cost regardless of how much the sysop pastes. + $messageText = mb_substr($messageText, 0, 6000); + + $aiService = \BinktermPHP\AI\AiService::create(); + if (empty($aiService->getConfiguredProviders())) { + http_response_code(503); + apiError('errors.admin.areafix_grammars.ai_no_provider', apiLocalizedText('errors.admin.areafix_grammars.ai_no_provider', 'No AI provider is configured', $user), 503); + return; + } + + $systemPrompt = <<<'PROMPT' +You generate AreaFix/FileFix hub-reply parsing grammar definitions for a BBS platform. +Given the raw text of a reply message from an FTN hub mailer's AreaFix/FileFix robot, +infer a structural grammar that can parse every area/tag row in it. + +Return ONLY a JSON object (not an array) with this shape: +{ + "id": "short_snake_case_identifier_for_this_hub_format", + "header_pattern": "PCRE regex (no delimiters, matched case-insensitively across the whole message) that uniquely identifies this reply format, e.g. a distinctive banner line", + "row_pattern": "PCRE regex (no delimiters, matched against ONE line at a time) with a REQUIRED named group (?...) capturing the area tag, and OPTIONAL named groups (?...) and (?...)", + "stop_pattern": "optional PCRE regex; a line matching it ends the row scan (omit if not needed)", + "default_action": "one of: subscribe, unsubscribe, available", + "status_rules": [ { "pattern": "PCRE regex tested against the captured status text", "action": "one of: subscribe, unsubscribe, available" } ] +} + +Rules: +- Use PHP PCRE syntax. Do not include the regex delimiters (no leading/trailing /). +- Escape literal backslashes as needed for JSON (e.g. \\s for whitespace). +- row_pattern MUST anchor to a full data row and MUST NOT match header, banner, blank, or tearline lines. +- Prefer anchored status_rules patterns (e.g. ^linked$) over unanchored ones, since e.g. "unlinked" contains "linked" as a substring. +- If you cannot confidently determine a status column, omit "status_rules" and set "default_action" to "available". +- Return only the JSON object. No explanation, no markdown fences. +PROMPT; + + $request = new \BinktermPHP\AI\AiRequest( + feature: 'areafix_grammar_ai_generate', + systemPrompt: $systemPrompt, + userPrompt: "Here is the raw text of an AreaFix/FileFix reply message. Generate a grammar definition for it:\n\n{$messageText}", + temperature: 0.1, + maxOutputTokens: 800, + timeoutSeconds: 30, + userId: (int)($user['user_id'] ?? $user['id'] ?? 0) ?: null, + ); + + $response = $aiService->generateJson($request); + $parsed = $response->getParsedJson(); + + if (!is_array($parsed) || !is_string($parsed['header_pattern'] ?? null) || !is_string($parsed['row_pattern'] ?? null)) { + http_response_code(422); + apiError('errors.admin.areafix_grammars.ai_invalid_response', apiLocalizedText('errors.admin.areafix_grammars.ai_invalid_response', 'AI did not return a usable grammar definition', $user), 422); + return; + } + + $headerPattern = $parsed['header_pattern']; + $rowPattern = $parsed['row_pattern']; + + if (!\BinktermPHP\AreaFix\AreaFixParser::isValidPattern($headerPattern) + || !\BinktermPHP\AreaFix\AreaFixParser::isValidPattern($rowPattern) + || !str_contains($rowPattern, '(?')) { + http_response_code(422); + apiError('errors.admin.areafix_grammars.ai_invalid_response', apiLocalizedText('errors.admin.areafix_grammars.ai_invalid_response', 'AI did not return a usable grammar definition', $user), 422); + return; + } + + $id = is_string($parsed['id'] ?? null) ? strtolower(trim($parsed['id'])) : ''; + $id = preg_replace('/[^a-z0-9_\-]+/', '_', $id) ?? ''; + $id = trim($id, '_-'); + if ($id === '') { + $id = 'ai_generated_' . substr(md5($messageText), 0, 8); + } + $id = substr($id, 0, 60); + + $allowedActions = ['subscribe', 'unsubscribe', 'available']; + $defaultAction = (is_string($parsed['default_action'] ?? null) && in_array($parsed['default_action'], $allowedActions, true)) + ? $parsed['default_action'] + : 'available'; + + $stopPattern = null; + if (is_string($parsed['stop_pattern'] ?? null) && $parsed['stop_pattern'] !== '' + && \BinktermPHP\AreaFix\AreaFixParser::isValidPattern($parsed['stop_pattern'])) { + $stopPattern = $parsed['stop_pattern']; + } + + $statusRules = []; + foreach ((array)($parsed['status_rules'] ?? []) as $rule) { + if (!is_array($rule) || !is_string($rule['pattern'] ?? null) || !is_string($rule['action'] ?? null)) { + continue; + } + if (!in_array($rule['action'], $allowedActions, true) || !\BinktermPHP\AreaFix\AreaFixParser::isValidPattern($rule['pattern'])) { + continue; + } + $statusRules[] = ['pattern' => $rule['pattern'], 'action' => $rule['action']]; + } + + $grammar = [ + 'id' => $id, + // Always disabled: an AI suggestion is a starting point for + // sysop review, never something that silently starts matching + // mail on its own. + 'enabled' => false, + 'header_pattern' => $headerPattern, + 'row_pattern' => $rowPattern, + 'default_action' => $defaultAction, + ]; + if ($stopPattern !== null) { + $grammar['stop_pattern'] = $stopPattern; + } + if (!empty($statusRules)) { + $grammar['status_rules'] = $statusRules; + } + + echo json_encode([ + 'success' => true, + 'grammar' => $grammar, + ]); + } catch (\Throwable $e) { + getServerLogger()->error('AreaFix AI grammar generation failed', ['error' => $e->getMessage()]); + http_response_code(500); + apiError('errors.admin.areafix_grammars.ai_generate_failed', apiLocalizedText('errors.admin.areafix_grammars.ai_generate_failed', 'Failed to generate grammar', $user), 500); + } +}); + +/** + * GET /api/admin/areafix/grammar-memory?uplink=1:1/23 + * Return the per-uplink AreaFixParser grammar memory (see + * docs/AreaFix.md#per-uplink-grammar-memory) for both robots on this uplink, + * plus the list of tier identifiers the "force a tier" selector may choose + * from. Surfaced in the Admin → Networks → Edit Uplink dialog. + */ +SimpleRouter::get('/api/admin/areafix/grammar-memory', function () { + $user = RouteHelper::requireAdmin(); + header('Content-Type: application/json'); + + $uplinkAddress = trim((string)($_GET['uplink'] ?? '')); + if ($uplinkAddress === '') { + apiError('errors.admin.areafix.uplink_required', apiLocalizedText('errors.admin.areafix.uplink_required', 'Uplink address is required', $user), 400, ['success' => false]); + return; + } + + $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); + $uplink = $binkpConfig->getUplinkByAddress($uplinkAddress); + $domain = (string)($uplink['domain'] ?? 'fidonet'); + + $areafixManager = new \BinktermPHP\AreaFixManager(); + echo json_encode([ + 'success' => true, + 'areafix' => $areafixManager->getRememberedTierRecord($uplinkAddress, $domain, 'areafix'), + 'filefix' => $areafixManager->getRememberedTierRecord($uplinkAddress, $domain, 'filefix'), + 'known_tiers' => (new \BinktermPHP\AreaFix\AreaFixParser())->getKnownTierIds(), + ]); +}); + +/** + * POST /api/admin/areafix/grammar-memory + * Body: { uplink: string, robot: "areafix"|"filefix", tier: string|null } + * + * Manually edit the remembered grammar tier for one uplink+robot: a null or + * empty tier clears it (the next reply tries the full ordered tier list + * again); a non-empty tier must be one of AreaFixParser::getKnownTierIds() + * and forces that tier to be tried first on the next reply, exactly as if it + * had just been confirmed via a real sync. + */ +SimpleRouter::post('/api/admin/areafix/grammar-memory', function () { + $user = RouteHelper::requireAdmin(); + header('Content-Type: application/json'); + + $body = json_decode(file_get_contents('php://input'), true); + if (!is_array($body)) { + apiError('errors.admin.areafix.invalid_json', apiLocalizedText('errors.admin.areafix.invalid_json', 'Invalid request payload', $user), 400, ['success' => false]); + return; + } + + $uplinkAddress = trim((string)($body['uplink'] ?? '')); + $robot = strtolower(trim((string)($body['robot'] ?? ''))); + $tier = isset($body['tier']) && is_string($body['tier']) ? trim($body['tier']) : null; + + if ($uplinkAddress === '') { + apiError('errors.admin.areafix.uplink_required', apiLocalizedText('errors.admin.areafix.uplink_required', 'Uplink address is required', $user), 400, ['success' => false]); + return; + } + if (!in_array($robot, ['areafix', 'filefix'], true)) { + apiError('errors.admin.areafix.invalid_robot', apiLocalizedText('errors.admin.areafix.invalid_robot', 'Robot must be "areafix" or "filefix"', $user), 400, ['success' => false]); + return; + } + + $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); + $uplink = $binkpConfig->getUplinkByAddress($uplinkAddress); + $domain = (string)($uplink['domain'] ?? 'fidonet'); + + $areafixManager = new \BinktermPHP\AreaFixManager(); + + if ($tier === null || $tier === '') { + $areafixManager->clearRememberedTier($uplinkAddress, $domain, $robot); + echo json_encode(['success' => true, 'tier' => null]); + return; + } + + $parser = new \BinktermPHP\AreaFix\AreaFixParser(); + if (!in_array($tier, $parser->getKnownTierIds(), true)) { + apiError('errors.admin.areafix.invalid_tier', apiLocalizedText('errors.admin.areafix.invalid_tier', 'Unrecognized grammar tier', $user), 400, ['success' => false]); + return; + } + + $areafixManager->rememberTier($uplinkAddress, $domain, $robot, $tier); + echo json_encode(['success' => true, 'tier' => $tier]); +}); + /** * GET /api/admin/areafix/uplinks * Return uplinks that have areafix or filefix passwords configured. @@ -10669,7 +10953,21 @@ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType) /** * POST /api/admin/areafix/sync * Parse area list and sync to local echo/file area table. - * Body: { uplink: string, robot: "areafix"|"filefix", areas: [{name,description},...], deactivate_missing: bool } + * + * Used by the admin preview screen to apply a sysop-curated subset of + * previewed areas (see /api/admin/areafix/preview-latest). When + * force_descriptions is true, an existing area's description is overwritten + * whenever the submitted one differs, bypassing the usual placeholder-only + * protection — appropriate here because the sysop has explicitly selected + * these specific areas after reviewing the preview's description diff. + * + * Body: { uplink: string, robot: "areafix"|"filefix", areas: [{name,description},...], deactivate_missing: bool, force_descriptions: bool, tier?: string } + * + * `tier` is optional and, when present, is the AreaFixParser tier that + * /api/admin/areafix/preview-latest reported for the reply this selection + * came from — passed straight back by the admin UI so per-uplink grammar + * memory (see PR460Proposal Improvement 6) can be updated once the sysop has + * actually confirmed the sync, not merely previewed it. */ SimpleRouter::post('/api/admin/areafix/sync', function () { $user = RouteHelper::requireAdmin(); @@ -10689,6 +10987,8 @@ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType) $robot = strtolower(trim((string)($body['robot'] ?? ''))); $parsedAreas = $body['areas'] ?? []; $deactivateMissing = (bool)($body['deactivate_missing'] ?? false); + $forceDescriptions = (bool)($body['force_descriptions'] ?? false); + $tier = is_string($body['tier'] ?? null) ? trim($body['tier']) : null; if ($uplinkAddress === '') { apiError( @@ -10729,8 +11029,11 @@ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType) $domain, $parsedAreas, $deactivateMissing, - $robot + $robot, + false, + $forceDescriptions ); + $areafixManager->rememberTier($uplinkAddress, $domain, $robot, $tier); } catch (\Throwable $e) { apiError( 'errors.admin.areafix.sync_failed', @@ -10744,11 +11047,20 @@ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType) }); /** - * POST /api/admin/areafix/sync-latest - * Find the latest incoming AreaFix/FileFix reply for an uplink, parse areas, and sync them to DB. - * Body: { uplink: string, robot: "areafix"|"filefix" } + * POST /api/admin/areafix/preview-latest + * Find an actionable incoming AreaFix/FileFix reply for an uplink, parse it, and + * return a diff (new/reactivate/deactivate/unchanged) against current local area + * state WITHOUT writing anything to the database. The admin UI must call this before + * /api/admin/areafix/sync-latest so a sysop can review changes before they're applied. + * + * When message_id is omitted, the newest actionable incoming reply is used (the + * "Sync Areas to Local BBS" button on the Latest Reply panel). When message_id is + * given, that specific incoming message is previewed instead (a per-row "Sync" + * button in the message history table). + * + * Body: { uplink: string, robot: "areafix"|"filefix", message_id?: int } */ -SimpleRouter::post('/api/admin/areafix/sync-latest', function () { +SimpleRouter::post('/api/admin/areafix/preview-latest', function () { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); @@ -10759,6 +11071,7 @@ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType) $uplinkAddress = trim((string)($body['uplink'] ?? '')); $robot = strtolower(trim((string)($body['robot'] ?? 'areafix'))); + $messageId = isset($body['message_id']) ? (int)$body['message_id'] : 0; if ($uplinkAddress === '') { apiError('errors.admin.areafix.uplink_required', 'Uplink address is required', 400, ['success' => false]); @@ -10766,43 +11079,96 @@ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType) $sysopUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); $areafixManager = new \BinktermPHP\AreaFixManager(); - $historyData = $areafixManager->getHistory($uplinkAddress, $sysopUserId); - $messages = ($historyData['messages'] ?? $historyData); - if (!is_array($messages)) { - $messages = []; + + $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); + $uplink = $binkpConfig->getUplinkByAddress($uplinkAddress); + $domain = (string)($uplink['domain'] ?? 'fidonet'); + $rememberedTier = $areafixManager->getRememberedTier($uplinkAddress, $domain, $robot); + + try { + $found = $messageId > 0 + ? $areafixManager->findActionableReplyById($uplinkAddress, $sysopUserId, $messageId, $rememberedTier) + : $areafixManager->findLatestActionableReply($uplinkAddress, $sysopUserId, $rememberedTier); + } catch (\Throwable $e) { + apiError('errors.admin.areafix.preview_failed', 'Failed to generate sync preview', 500, ['success' => false]); } - $replyFound = null; - $parsedAreas = []; + if (!$found) { + apiError('errors.admin.areafix.no_area_list_found', 'No area list found in recent replies for this uplink', 404, ['success' => false]); + } - // Search incoming messages from newest to oldest for one containing an area list - foreach ($messages as $m) { - if (($m['direction'] ?? '') !== 'incoming') { - continue; - } - $subj = (string)($m['subject'] ?? ''); - $bodyText = (string)($m['message_text'] ?? ''); + try { + $diff = $areafixManager->previewSync($uplinkAddress, $domain, $found['areas'], false, $robot); + } catch (\Throwable $e) { + apiError('errors.admin.areafix.preview_failed', 'Failed to generate sync preview', 500, ['success' => false]); + } - // Skip result receipts, change request confirmations, or help text - if (!$areafixManager->isAreaListResponse($subj, $bodyText)) { - continue; - } + // A remembered tier that no longer matches the current reply is a concrete + // signal the hub's mailer software changed, was reconfigured, or the reply + // isn't actually coming from the expected hub — surfaced distinctly from + // the generic "review before applying" prompt every sync already gets. + $matchedTier = $found['tier'] ?? null; + $formatChanged = $rememberedTier !== null && $matchedTier !== null && $matchedTier !== $rememberedTier; - $areas = $areafixManager->parseResponseText($bodyText, '%LIST'); - if (count($areas) >= 2) { - $replyFound = $m; - $parsedAreas = $areas; - break; - } + $replyFound = $found['message']; + echo json_encode([ + 'success' => true, + 'areas' => $diff, + 'areas_count' => count($diff), + 'from' => $replyFound['from_name'] ?? $replyFound['from_address'] ?? '', + 'date' => $replyFound['date_received'] ?? $replyFound['date_written'] ?? null, + 'tier' => $matchedTier, + 'remembered_tier' => $rememberedTier, + 'format_changed' => $formatChanged, + ]); +}); + +/** + * POST /api/admin/areafix/sync-latest + * Find an incoming AreaFix/FileFix reply for an uplink, parse areas, and sync them to DB. + * + * The admin UI calls /api/admin/areafix/preview-latest first (with the same optional + * message_id) and only calls this endpoint after the sysop has reviewed and confirmed + * the resulting preview. When message_id is omitted, the newest actionable incoming + * reply is used; otherwise that specific message is applied. + * + * Body: { uplink: string, robot: "areafix"|"filefix", message_id?: int } + */ +SimpleRouter::post('/api/admin/areafix/sync-latest', function () { + $user = RouteHelper::requireAdmin(); + header('Content-Type: application/json'); + + $body = json_decode(file_get_contents('php://input'), true); + if (!is_array($body)) { + apiError('errors.admin.areafix.invalid_json', 'Invalid request payload', 400, ['success' => false]); } - if (!$replyFound || empty($parsedAreas)) { - apiError('errors.admin.areafix.no_area_list_found', 'No area list found in recent replies for this uplink', 404, ['success' => false]); + $uplinkAddress = trim((string)($body['uplink'] ?? '')); + $robot = strtolower(trim((string)($body['robot'] ?? 'areafix'))); + $messageId = isset($body['message_id']) ? (int)$body['message_id'] : 0; + + if ($uplinkAddress === '') { + apiError('errors.admin.areafix.uplink_required', 'Uplink address is required', 400, ['success' => false]); } + $sysopUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); + $areafixManager = new \BinktermPHP\AreaFixManager(); + $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $uplink = $binkpConfig->getUplinkByAddress($uplinkAddress); $domain = (string)($uplink['domain'] ?? 'fidonet'); + $rememberedTier = $areafixManager->getRememberedTier($uplinkAddress, $domain, $robot); + + $found = $messageId > 0 + ? $areafixManager->findActionableReplyById($uplinkAddress, $sysopUserId, $messageId, $rememberedTier) + : $areafixManager->findLatestActionableReply($uplinkAddress, $sysopUserId, $rememberedTier); + + if (!$found) { + apiError('errors.admin.areafix.no_area_list_found', 'No area list found in recent replies for this uplink', 404, ['success' => false]); + } + + $replyFound = $found['message']; + $parsedAreas = $found['areas']; $summary = $areafixManager->syncSubscribedAreas( $uplinkAddress, @@ -10811,6 +11177,7 @@ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType) false, $robot ); + $areafixManager->rememberTier($uplinkAddress, $domain, $robot, $found['tier'] ?? null); echo json_encode([ 'success' => true, diff --git a/src/AI/Providers/OpenRouterProvider.php b/src/AI/Providers/OpenRouterProvider.php index 23a4c51d1..84994da1d 100644 --- a/src/AI/Providers/OpenRouterProvider.php +++ b/src/AI/Providers/OpenRouterProvider.php @@ -170,44 +170,34 @@ private function requestCompletion(AiRequest $request, bool $expectJson): AiResp } $messages[] = ['role' => 'user', 'content' => $request->getUserPrompt()]; - $payload = [ + $basePayload = [ 'model' => $request->getModel(), 'messages' => $messages, 'temperature' => $request->getTemperature(), 'max_tokens' => $request->getMaxOutputTokens(), - // Some models routed via openrouter/auto are hybrid reasoning models that spend - // the max_tokens budget on hidden reasoning tokens before ever emitting content, - // leaving message.content empty. Ask OpenRouter to skip reasoning where the - // underlying model supports toggling it; unsupported models ignore this field. - 'reasoning' => ['enabled' => false], ]; // Omit response_format: the underlying model selected by openrouter/auto may not // support JSON mode. JSON responses rely on prompt-level instructions instead. $url = $this->apiBase . '/chat/completions'; - $this->logger->debug('OpenRouter request: ' . $url . ' model=' . ($request->getModel() ?? $this->defaultModel)); + // Some models routed via openrouter/auto are hybrid reasoning models that spend + // the max_tokens budget on hidden reasoning tokens before ever emitting content, + // leaving message.content empty. Ask OpenRouter to skip reasoning where the + // underlying model supports toggling it; unsupported models ignore this field. + // A minority of models (routed via openrouter/auto, where the actual model isn't + // known ahead of time) instead make reasoning mandatory and reject the request + // outright with a 400 when asked to disable it — retry once without the field. try { - $response = HttpClient::postJson( - $url, - $payload, - $this->buildHeaders(), - $request->getTimeoutSeconds() - ); - } catch (\Throwable $exception) { - $this->logger->debug('OpenRouter network error: ' . $exception->getMessage()); - throw new AiException($this->getName(), $exception->getMessage(), null, 'network_error', null, $exception); - } - - $this->logger->debug('OpenRouter response status=' . $response['status']); - - $body = $response['body']; - if ($response['status'] >= 400) { - $message = $body['error']['message'] ?? 'OpenRouter API request failed.'; - $code = $body['error']['code'] ?? 'api_error'; - $this->logger->error('OpenRouter API error: status=' . $response['status'] . ' code=' . $code . ' message=' . $message); - throw new AiException($this->getName(), (string)$message, $response['status'], (string)$code, $response['raw']); + $body = $this->postCompletion($url, $basePayload + ['reasoning' => ['enabled' => false]], $request->getTimeoutSeconds()); + } catch (AiException $exception) { + if ($exception->getHttpStatus() === 400 && $this->isReasoningMandatoryError($exception->getMessage())) { + $this->logger->debug('OpenRouter: model requires mandatory reasoning, retrying without the reasoning field'); + $body = $this->postCompletion($url, $basePayload, $request->getTimeoutSeconds()); + } else { + throw $exception; + } } $content = $this->extractContent($body); @@ -237,6 +227,48 @@ private function requestCompletion(AiRequest $request, bool $expectJson): AiResp ); } + /** + * POST a /chat/completions payload and return the decoded response body, + * throwing AiException on a network failure or an HTTP error status. + * + * @param array $payload + * @return array + */ + private function postCompletion(string $url, array $payload, int $timeoutSeconds): array + { + $this->logger->debug('OpenRouter request: ' . $url . ' model=' . ($payload['model'] ?? $this->defaultModel)); + + try { + $response = HttpClient::postJson($url, $payload, $this->buildHeaders(), $timeoutSeconds); + } catch (\Throwable $exception) { + $this->logger->debug('OpenRouter network error: ' . $exception->getMessage()); + throw new AiException($this->getName(), $exception->getMessage(), null, 'network_error', null, $exception); + } + + $this->logger->debug('OpenRouter response status=' . $response['status']); + + $body = $response['body']; + if ($response['status'] >= 400) { + $message = $body['error']['message'] ?? 'OpenRouter API request failed.'; + $code = $body['error']['code'] ?? 'api_error'; + $this->logger->error('OpenRouter API error: status=' . $response['status'] . ' code=' . $code . ' message=' . $message); + throw new AiException($this->getName(), (string)$message, $response['status'], (string)$code, $response['raw']); + } + + return $body; + } + + /** + * Detect OpenRouter's "reasoning is mandatory and cannot be disabled" error, which some + * models routed via openrouter/auto return when asked to disable reasoning via the + * 'reasoning' => ['enabled' => false] payload field. + */ + private function isReasoningMandatoryError(string $message): bool + { + return stripos($message, 'reasoning') !== false + && (stripos($message, 'mandatory') !== false || stripos($message, 'cannot be disabled') !== false); + } + /** * @param array $body */ diff --git a/src/Admin/AdminDaemonClient.php b/src/Admin/AdminDaemonClient.php index fc33a99b1..f134868c7 100644 --- a/src/Admin/AdminDaemonClient.php +++ b/src/Admin/AdminDaemonClient.php @@ -155,6 +155,16 @@ public function saveLovlyNetConfig(string $json): array return $this->sendCommand('save_lovlynet_config', ['json' => $json]); } + public function getAreafixGrammarsConfig(): array + { + return $this->sendCommand('get_areafix_grammars_config'); + } + + public function saveAreafixGrammarsConfig(string $json): array + { + return $this->sendCommand('save_areafix_grammars_config', ['json' => $json]); + } + public function getWebdoorsConfig(): array { return $this->sendCommand('get_webdoors_config'); diff --git a/src/Admin/AdminDaemonServer.php b/src/Admin/AdminDaemonServer.php index e4661a570..a78b296de 100644 --- a/src/Admin/AdminDaemonServer.php +++ b/src/Admin/AdminDaemonServer.php @@ -530,6 +530,23 @@ private function handleCommand($client, array $payload): void $this->writeResponse($client, ['ok' => false, 'error' => 'failed_to_send_signal']); } break; + case 'get_areafix_grammars_config': + $this->writeResponse($client, ['ok' => true, 'result' => $this->getAreafixGrammarsConfig()]); + break; + case 'save_areafix_grammars_config': + $json = $data['json'] ?? null; + if (!is_string($json) || trim($json) === '') { + $this->writeResponse($client, ['ok' => false, 'error' => 'missing_json']); + break; + } + $decoded = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { + $this->writeResponse($client, ['ok' => false, 'error' => 'invalid_json']); + break; + } + $this->writeAreafixGrammarsConfig($decoded); + $this->writeResponse($client, ['ok' => true, 'result' => $this->getAreafixGrammarsConfig()]); + break; case 'get_webdoors_config': $this->writeResponse($client, ['ok' => true, 'result' => $this->getWebdoorsConfig()]); break; @@ -1855,6 +1872,46 @@ private function getTaglinesPath(): string return __DIR__ . '/../../config/taglines.txt'; } + private function getAreafixGrammarsConfig(): array + { + $configPath = $this->getAreafixGrammarsConfigPath(); + $examplePath = $this->getAreafixGrammarsExamplePath(); + + $configJson = file_exists($configPath) ? file_get_contents($configPath) : '[]'; + $exampleJson = file_exists($examplePath) ? file_get_contents($examplePath) : null; + + return [ + 'config_json' => $configJson, + 'example_json' => $exampleJson, + ]; + } + + private function getAreafixGrammarsExamplePath(): string + { + return __DIR__ . '/../../config/areafix_grammars.json.example'; + } + + private function writeAreafixGrammarsConfig(array $config): void + { + $configPath = $this->getAreafixGrammarsConfigPath(); + $configDir = dirname($configPath); + if (!is_dir($configDir)) { + mkdir($configDir, 0755, true); + } + + $json = json_encode(array_values($config), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException('Failed to encode AreaFix grammars config'); + } + + file_put_contents($configPath, $json . PHP_EOL); + } + + private function getAreafixGrammarsConfigPath(): string + { + return __DIR__ . '/../../config/areafix_grammars.json'; + } + private function getWebdoorsConfig(): array { $configPath = $this->getWebdoorsConfigPath(); diff --git a/src/AreaFix/AreaFixParser.php b/src/AreaFix/AreaFixParser.php new file mode 100644 index 000000000..b4ff53845 --- /dev/null +++ b/src/AreaFix/AreaFixParser.php @@ -0,0 +1,1094 @@ +" rather than a fixed constant. + */ + public const TIER_MYSTIC_BLOCKS = 'mystic_blocks'; + public const TIER_DELIMITED_TABLE = 'delimited_table'; + public const TIER_COLUMNAR_TABLE = 'columnar_table'; + public const TIER_QUOTED_ADDRESS_LIST = 'quoted_address_list'; + public const TIER_FLAGGED_DOTTED_QUOTED_LIST = 'flagged_dotted_quoted_list'; + public const TIER_FREEFORM = 'freeform'; + private const CONFIGURED_TIER_PREFIX = 'configured:'; + + /** @var array>|null */ + private ?array $configuredGrammarsCache = null; + + private Logger $logger; + + public function __construct() + { + $this->logger = new Logger(Config::getLogPath('server.log'), Logger::LEVEL_INFO, false); + } + + /** + * Parse AreaFix or FileFix response body into structured area items. + * + * @param string $body Raw message body text + * @param string|null $subject Optional message subject for context + * @return array + */ + public function parse(string $body, ?string $subject = null, ?string $preferredTier = null): array + { + return $this->parseWithTier($body, $subject, $preferredTier)['areas']; + } + + /** + * Same as parse(), but also reports which tier produced the result, so a + * caller (AreaFixManager) can remember it per-uplink and notice when a + * hub's format changes between replies (see PR460Proposal Improvement 6). + * + * Tries each tier in its normal order (the five built-in structural + * grammars, then data-driven configured grammars in config file order, + * then the freeform fallback), except that $preferredTier — when given and + * still present in the tier list — is tried first. This is a pure + * reordering: it never changes which tier ultimately wins for a given + * body, only how quickly it's found when the hint is correct. + * + * @param string $body Raw message body text + * @param string|null $subject Optional message subject for context + * @param string|null $preferredTier A tier identifier (one of the TIER_* constants, + * or "configured:") to try before the normal order + * @return array{ + * areas: array, + * tier: ?string + * } + */ + public function parseWithTier(string $body, ?string $subject = null, ?string $preferredTier = null): array + { + $body = str_replace(["\r\n", "\r"], "\n", $body); + $tiers = $this->buildTierList($body); + + if ($preferredTier !== null) { + $index = null; + foreach ($tiers as $i => $tier) { + if ($tier['name'] === $preferredTier) { + $index = $i; + break; + } + } + if ($index !== null) { + $preferred = $tiers[$index]; + unset($tiers[$index]); + array_unshift($tiers, $preferred); + } + } + + foreach ($tiers as $tier) { + $areas = ($tier['match'])(); + if ($areas === null || empty($areas)) { + continue; + } + + if ($tier['name'] === self::TIER_FREEFORM) { + $this->logger->info('AreaFixParser: no structural grammar matched, used freeform fallback', [ + 'areas_found' => count($areas), + 'body_excerpt' => substr($body, 0, 200), + ]); + } elseif (str_starts_with($tier['name'], self::CONFIGURED_TIER_PREFIX)) { + $this->logger->info('AreaFixParser: matched data-driven grammar', [ + 'grammar_id' => substr($tier['name'], strlen(self::CONFIGURED_TIER_PREFIX)), + 'areas_found' => count($areas), + ]); + } + + return ['areas' => $this->deduplicateAreas($areas), 'tier' => $tier['name']]; + } + + return ['areas' => [], 'tier' => null]; + } + + /** + * Build the ordered list of tiers to try against a message body: the five + * built-in structural grammars, then any data-driven grammars from + * config/areafix_grammars.json (or its .example fallback) in file order, + * then the freeform fallback. + * + * @return array + */ + private function buildTierList(string $body): array + { + $tiers = [ + ['name' => self::TIER_MYSTIC_BLOCKS, 'match' => fn() => $this->parseMysticBlocks($body)], + ['name' => self::TIER_DELIMITED_TABLE, 'match' => fn() => $this->parseDelimitedTable($body)], + ['name' => self::TIER_COLUMNAR_TABLE, 'match' => fn() => $this->parseColumnarTable($body)], + ['name' => self::TIER_QUOTED_ADDRESS_LIST, 'match' => fn() => $this->parseQuotedAddressList($body)], + ['name' => self::TIER_FLAGGED_DOTTED_QUOTED_LIST, 'match' => fn() => $this->parseFlaggedDottedQuotedList($body)], + ]; + + foreach ($this->loadConfiguredGrammars() as $grammar) { + $tierName = self::CONFIGURED_TIER_PREFIX . (string)($grammar['id'] ?? ''); + $tiers[] = ['name' => $tierName, 'match' => fn() => $this->matchConfiguredGrammar($grammar, $body)]; + } + + $tiers[] = ['name' => self::TIER_FREEFORM, 'match' => fn() => $this->parseFreeformList($body)]; + + return $tiers; + } + + /** + * Check whether message body contains actionable AreaFix area data + * (subscriptions, unsubscriptions, or valid area lists). + * + * Returns false for help manuals, empty rescan receipts, and error notifications. + */ + public function hasActionableContent(string $body, ?string $subject = null): bool + { + $areas = $this->parse($body, $subject); + return !empty($areas); + } + + /** + * Parse Mystic BBS and MBSE Command / Result blocks. + * + * Matches patterns: + * Command: +TAG [R=100] + * Result: Echo will now be exported + * + * Command: -TAG + * Result: Echo will no longer be exported + * + * Command: %LINKED (or %QUERY, %LIST, %UNLINKED) + * Result: List of all linked areas: + * TAG1 Description 1 + * TAG2 Description 2 + * + * %QUERY in particular can list both linked and unlinked areas in one + * block; a row annotated "(linked)"/"(unlinked)"/"(not linked)" is + * classified by that annotation rather than by the command name. + * + * @return array|null + */ + private function parseMysticBlocks(string $body): ?array + { + if (!preg_match('/^Command:\s*/m', $body)) { + return null; + } + + $lines = explode("\n", $body); + $areas = []; + $totalLines = count($lines); + + for ($i = 0; $i < $totalLines; $i++) { + $line = $lines[$i]; + + // Stop command parsing once a help manual banner is reached + if (preg_match('/^[\.\+\s]*-{3,}.*Help/i', $line) || preg_match('/^\|\s*.*(?:AreaFix|FileFix)\s+Help\s*\|/i', $line)) { + break; + } + + // Real Mystic command lines start flush at column 0 (not indented inside tutorial examples) + if (!preg_match('/^Command:\s*(.+)$/i', $line, $cmdMatch)) { + continue; + } + + $commandRaw = trim($cmdMatch[1]); + + // Find subsequent Result: line (within next 3 lines) + $resultRaw = null; + $resultLineIndex = -1; + for ($j = $i + 1; $j < min($i + 4, $totalLines); $j++) { + if (preg_match('/^Result:\s*(.*)$/i', trim($lines[$j]), $resMatch)) { + $resultRaw = trim($resMatch[1]); + $resultLineIndex = $j; + break; + } + } + + if ($resultRaw === null) { + continue; + } + + // A) Direct subscription command: +TAG [params] + if (preg_match('/^\+\s*([A-Za-z0-9_\-\.]+)(?:\s+\[[^\]]*\])?$/', $commandRaw, $tagMatch)) { + $tag = strtoupper($tagMatch[1]); + if (self::isValidTag($tag)) { + // Check if result indicates success + if (preg_match('/(?:will now be exported|linked|added|subscribed|now connected|echo linked)/i', $resultRaw)) { + $areas[] = [ + 'name' => $tag, + 'description' => null, + 'action' => self::ACTION_SUBSCRIBE, + 'is_subscribed' => true, + ]; + } + } + $i = $resultLineIndex; + continue; + } + + // B) Direct unsubscription command: -TAG [params] + if (preg_match('/^\-\s*([A-Za-z0-9_\-\.]+)(?:\s+\[[^\]]*\])?$/', $commandRaw, $tagMatch)) { + $tag = strtoupper($tagMatch[1]); + if (self::isValidTag($tag)) { + if (preg_match('/(?:will no longer be exported|unlinked|removed|unsubscribed|disconnected)/i', $resultRaw)) { + $areas[] = [ + 'name' => $tag, + 'description' => null, + 'action' => self::ACTION_UNSUBSCRIBE, + 'is_subscribed' => false, + ]; + } + } + $i = $resultLineIndex; + continue; + } + + // C) List commands: %LINKED, %QUERY, %LIST, %UNLINKED, %AVAIL + if (preg_match('/^%([A-Za-z0-9_\-]+)\b/i', $commandRaw, $pctMatch)) { + $cmdName = strtoupper($pctMatch[1]); + $isLinkedCmd = in_array($cmdName, ['LINKED', 'QUERY', 'LINK'], true); + $isUnlinkedCmd = in_array($cmdName, ['UNLINKED'], true); + $isListCmd = in_array($cmdName, ['LIST', 'AVAIL'], true); + + if (($isLinkedCmd || $isUnlinkedCmd || $isListCmd) && preg_match('/list of/i', $resultRaw)) { + // Indented lines immediately follow the Result: line + $k = $resultLineIndex + 1; + while ($k < $totalLines) { + $dataLine = $lines[$k]; + $dataTrim = trim($dataLine); + + // Stop if we hit a new command or a non-indented decorative section + if (preg_match('/^Command:\s*/i', $dataTrim) || preg_match('/^[.\-+=\*]{3,}/', $dataTrim)) { + break; + } + + // An indented data row starts with 2+ spaces or a tab + if (preg_match('/^(?:[ ]{2,}|\t+)([A-Za-z0-9_\-\.]+)(?:\s{2,}|\t+)(.*)$/', $dataLine, $rowMatch)) { + $tag = strtoupper(trim($rowMatch[1])); + $desc = trim($rowMatch[2]); + if (self::isValidTag($tag)) { + // A command like %QUERY can list both linked and + // unlinked areas in one block. If this specific row + // carries its own "(linked)"/"(unlinked)" annotation, + // trust that over the command-level default; otherwise + // fall back to the command default (correct for a + // %LINKED-only listing, where every row is linked by + // definition). + $rowIsLinked = $isLinkedCmd; + if (preg_match('/\(\s*(?:not\s+linked|unlinked)\s*\)\s*$/i', $desc)) { + $rowIsLinked = false; + $desc = trim(preg_replace('/\(\s*(?:not\s+linked|unlinked)\s*\)\s*$/i', '', $desc)); + } elseif (preg_match('/\(\s*linked\s*\)\s*$/i', $desc)) { + $rowIsLinked = true; + $desc = trim(preg_replace('/\(\s*linked\s*\)\s*$/i', '', $desc)); + } + + $action = $rowIsLinked ? self::ACTION_SUBSCRIBE : self::ACTION_AVAILABLE; + $areas[] = [ + 'name' => $tag, + 'description' => $desc !== '' ? $desc : null, + 'action' => $action, + 'is_subscribed' => $rowIsLinked, + ]; + } + } elseif ($dataTrim !== '' && !preg_match('/^[ ]{2,}|\t+/', $dataLine)) { + // Non-indented non-empty line ends the listing block + break; + } + + $k++; + } + $i = $k - 1; + continue; + } + } + + // Advance past the result line + $i = $resultLineIndex; + } + + return $areas; + } + + /** + * Parse delimited tables (colon ':' or pipe '|'). + * + * Matches patterns: + * :---:------------:--------------------------------------------------:------: + * : : AREA : DESCRIPTION : MSGS : + * :---:------------:--------------------------------------------------:------: + * :* : FSX_ADS : FSX: Ads + ANSI Art : 547 : + * : : FSX_BBS : FSX: BBS Support/Dev : 53 : + * + * Or pipe tables: + * | AREA | DESCRIPTION | + * | FSX_ADS | FSX: Ads + ANSI Art | + * + * @return array|null + */ + private function parseDelimitedTable(string $body): ?array + { + $lines = explode("\n", $body); + $headerFound = false; + $delimiter = null; + $tagCol = -1; + $descCol = -1; + $statusCol = -1; + $areas = []; + + $totalLines = count($lines); + + for ($i = 0; $i < $totalLines; $i++) { + $line = trim($lines[$i]); + if ($line === '') { + if ($headerFound && !empty($areas)) { + // Blank line after table rows concludes the table + break; + } + continue; + } + + // Look for table header if not found yet + if (!$headerFound) { + // Must contain AREA/ECHOTAG and DESCRIPTION/STATUS/MSGS/FILES + if (preg_match('/[:|]\s*(?:AREA|ECHOTAG|TAG)\s*[:|]/i', $line)) { + $delimiter = str_contains($line, ':') ? ':' : '|'; + $cols = array_map('trim', explode($delimiter, $line)); + + // Remove empty outside elements from boundary delimiters + if (isset($cols[0]) && $cols[0] === '') { + array_shift($cols); + } + if (!empty($cols) && end($cols) === '') { + array_pop($cols); + } + + foreach ($cols as $idx => $colName) { + $colUpper = strtoupper($colName); + if (in_array($colUpper, ['AREA', 'ECHOTAG', 'TAG'], true)) { + $tagCol = $idx; + } elseif (in_array($colUpper, ['DESCRIPTION', 'DESC'], true)) { + $descCol = $idx; + } elseif ($idx === 0 && ($colUpper === '' || in_array($colUpper, ['STATUS', 'ST', 'FLAGS'], true))) { + $statusCol = $idx; + } + } + + if ($tagCol >= 0) { + $headerFound = true; + } + } + continue; + } + + // Once header is found, parse table rows + // Skip decorative separator lines (:---:---:, |---|---|) + if (preg_match('/^[:|\s]*[-=*#~:\s]{3,}[:|\s]*$/', $line)) { + continue; + } + + // Data line must start with or contain the delimiter + if (!str_contains($line, $delimiter)) { + if (!empty($areas)) { + break; // End of table + } + continue; + } + + $rawCols = explode($delimiter, $line); + if (isset($rawCols[0]) && trim($rawCols[0]) === '') { + array_shift($rawCols); + } + if (!empty($rawCols) && trim(end($rawCols)) === '') { + array_pop($rawCols); + } + + if (isset($rawCols[$tagCol])) { + $tag = strtoupper(trim($rawCols[$tagCol])); + if (self::isValidTag($tag)) { + $desc = null; + if ($descCol >= 0 && isset($rawCols[$descCol])) { + $hasTrailingCol = count($cols) > ($descCol + 1); + if ($hasTrailingCol && count($rawCols) > ($descCol + 1)) { + $numTrailing = count($cols) - 1 - $descCol; + $sliceLen = max(1, count($rawCols) - $descCol - $numTrailing); + $descParts = array_slice($rawCols, $descCol, $sliceLen); + $desc = trim(implode($delimiter, $descParts)); + } else { + $descParts = array_slice($rawCols, $descCol); + $desc = trim(implode($delimiter, $descParts)); + } + } + + // Discard description if it contains ANSI box drawing / block art + if ($desc !== null && (preg_match('/[▄█▀▌▐░▒▓─│┌┐└┘├┤┬┴┼═║]/u', $desc) || preg_match('/[\xB0-\xDF]/', $desc))) { + $desc = null; + } + + $status = ($statusCol >= 0 && isset($rawCols[$statusCol])) ? trim($rawCols[$statusCol]) : ''; + + $isSubscribed = str_contains($status, '*') || str_contains($status, '+'); + $action = $isSubscribed ? self::ACTION_SUBSCRIBE : self::ACTION_AVAILABLE; + + $areas[] = [ + 'name' => $tag, + 'description' => ($desc !== null && $desc !== '') ? $desc : null, + 'action' => $action, + 'is_subscribed' => $isSubscribed, + ]; + } + } + } + + return $headerFound ? $areas : null; + } + + /** + * Parse columnar fixed-width or dotted-leader tables (HPT, Husky, FastEcho). + * + * Matches patterns: + * Area Status + * -------------------------------------------------- ------------------------- + * LVLY_ANNOUNCE .................................... rescanned 22 mails + * LVLY_BINKTERMPHP ................................. subscribed + * + * Or: + * Area Description + * -------------------------------------------------- ------------------------- + * SYS_GEN SysOp General Chat + * + * Also matches headers where the tag column isn't literally named "Area" but + * contains it as a whole word (e.g. AreaMgr-style "Con Message area Description"), + * as long as a dashed separator line immediately follows. + * + * @return array|null + */ + private function parseColumnarTable(string $body): ?array + { + $lines = explode("\n", $body); + $headerFound = false; + $isStatusTable = false; + $areas = []; + + $totalLines = count($lines); + + for ($i = 0; $i < $totalLines; $i++) { + $line = trim($lines[$i]); + + if (!$headerFound) { + // Look for an "area" column (as a whole word, anywhere in the header) + // followed later by "Status", "Description", "Msgs", or "Files" + if (preg_match('/\barea\b.*?\b(Status|Description|Msgs|Files)\b/i', $line, $hMatch)) { + // Check if next line is a dash separator + if (isset($lines[$i + 1]) && preg_match('/^[-=]{3,}\s+[-=]{3,}/', trim($lines[$i + 1]))) { + $headerFound = true; + $isStatusTable = (strcasecmp($hMatch[1], 'Status') === 0); + $i++; // skip separator line + continue; + } + } + continue; + } + + // End of table boundaries + if ($line === '' || str_starts_with($line, '---') || str_starts_with($line, '* Origin:') || str_contains($line, 'Following is the original message text')) { + if (!empty($areas)) { + break; + } + continue; + } + + // Parse dotted-leader line: TAG ........ Status or Description + if (preg_match('/^([A-Za-z0-9_\-\.]+)\s+\.{3,}\s*(.*)$/', $line, $rowMatch)) { + $tag = strtoupper(trim($rowMatch[1])); + $colValue = trim($rowMatch[2]); + + if (self::isValidTag($tag)) { + if ($isStatusTable) { + $action = self::ACTION_AVAILABLE; + $isSubscribed = false; + + if (preg_match('/\b(?:unsubscribed|removed)\b/i', $colValue)) { + $action = self::ACTION_UNSUBSCRIBE; + $isSubscribed = false; + } elseif (preg_match('/\b(?:already\s+subscribed|subscribed|rescanned\s+\d+)\b/i', $colValue)) { + $action = self::ACTION_SUBSCRIBE; + $isSubscribed = true; + } + + $areas[] = [ + 'name' => $tag, + 'description' => null, + 'action' => $action, + 'is_subscribed' => $isSubscribed, + ]; + } else { + $areas[] = [ + 'name' => $tag, + 'description' => $colValue !== '' ? $colValue : null, + 'action' => self::ACTION_AVAILABLE, + 'is_subscribed' => false, + ]; + } + } + continue; + } + + // Parse standard two-column line: TAG Description + if (preg_match('/^([A-Za-z0-9_\-\.]+)\s{3,}(.*)$/', $line, $rowMatch)) { + $tag = strtoupper(trim($rowMatch[1])); + $colValue = trim($rowMatch[2]); + + if (self::isValidTag($tag)) { + $areas[] = [ + 'name' => $tag, + 'description' => $colValue !== '' ? $colValue : null, + 'action' => self::ACTION_AVAILABLE, + 'is_subscribed' => false, + ]; + } + } + } + + return $headerFound ? $areas : null; + } + + /** + * Parse BBBS/Li6-style quoted-address lists. + * + * Matches patterns: + * List of all echo areas available for node 1:153/150.0. + * + = Area already connected + * + * +10TH_AMD (1:153/757) "10th Amendment Discussion" + * ABLED (1:153/757) "disABLED Users Information + * Exchange" + * + * A leading "+" marks a subscribed/connected area; a leading space marks one + * that is merely available. Descriptions may wrap onto continuation lines + * with no tag of their own, terminated by the closing quote. The same + * grammar also covers a file-area list in the same reply (address may carry + * a ", NkB" size suffix), including reuse of a tag between the echo-area and + * file-area sections with different descriptions (deduplicateAreas() keeps + * whichever description and action wins by its normal precedence rules). + * + * @return array|null + */ + private function parseQuotedAddressList(string $body): ?array + { + $lines = explode("\n", $body); + $totalLines = count($lines); + $areas = []; + $found = false; + + for ($i = 0; $i < $totalLines; $i++) { + $line = $lines[$i]; + + if (!preg_match('/^[+ ]([A-Za-z0-9_\-.]+)\s+\([^)]*\)\s+"(.*)$/', $line, $m)) { + continue; + } + + $tag = strtoupper(trim($m[1])); + if (!self::isValidTag($tag)) { + continue; + } + + $found = true; + $isSubscribed = ($line[0] === '+'); + + // Collect wrapped continuation lines until the closing quote appears, + // stopping early if the next line is itself a new entry, a blank + // line, or a banner/tearline (defends against ever merging entries). + $descParts = [$m[2]]; + $j = $i; + while (!str_ends_with(rtrim(end($descParts)), '"') && ($j + 1) < $totalLines) { + $nextLine = $lines[$j + 1]; + $nextTrimmed = trim($nextLine); + if ($nextTrimmed === '' + || preg_match('/^[+ ][A-Za-z0-9_\-.]+\s+\([^)]*\)\s+"/', $nextLine) + || preg_match('/^-{2,}\s/', $nextTrimmed) + || stripos($nextTrimmed, 'List of all') !== false) { + break; + } + $j++; + $descParts[] = $nextTrimmed; + } + $i = $j; + + $desc = trim(rtrim(trim(implode(' ', $descParts)), '"')); + + $areas[] = [ + 'name' => $tag, + 'description' => $desc !== '' ? $desc : null, + 'action' => $isSubscribed ? self::ACTION_SUBSCRIBE : self::ACTION_AVAILABLE, + 'is_subscribed' => $isSubscribed, + ]; + } + + return $found ? $areas : null; + } + + /** + * Parse HPT-style flag-prefixed dotted-leader lists with quoted descriptions. + * + * Matches patterns: + * Available areas for 227:1/400 + * + * *S LVLY_ADULT ............... "Mature/18+ topics of discussion, etc." + * *S LVLY_COLDWARCOMMS ............................................... "Coldwar + * Communications with an emphasis on AT&T Longlines" + * + * '*' = area is active + * 'R' = area is readonly for you + * + * The leading 0-2 character flag field (any combination of '*', 'R', 'W', + * 'M', 'S') indicates linked/subscribed state; '*' present means the area + * is currently linked. Descriptions may wrap onto an unindented-tag + * continuation line, terminated by the closing quote. The area block ends + * at the first blank line, before the "'X' = ..." flag legend. + * + * @return array|null + */ + private function parseFlaggedDottedQuotedList(string $body): ?array + { + $lines = explode("\n", $body); + $totalLines = count($lines); + $areas = []; + $found = false; + + for ($i = 0; $i < $totalLines; $i++) { + $line = $lines[$i]; + + if (trim($line) === '') { + if ($found) { + // Blank line after at least one matched row ends the list + // (the flag legend and summary text follow). + break; + } + continue; + } + + if (!preg_match('/^([*RWMS]*)\s+([A-Za-z0-9_\-.]+)\s+\.{3,}\s*"(.*)$/', $line, $m)) { + continue; + } + + $tag = strtoupper(trim($m[2])); + if (!self::isValidTag($tag)) { + continue; + } + + $found = true; + $isSubscribed = str_contains($m[1], '*'); + + // Collect wrapped continuation lines until the closing quote appears, + // stopping early if the next line is itself a new entry, blank, or + // the start of the flag legend (defends against merging entries). + $descParts = [$m[3]]; + $j = $i; + while (!str_ends_with(rtrim(end($descParts)), '"') && ($j + 1) < $totalLines) { + $nextLine = $lines[$j + 1]; + $nextTrimmed = trim($nextLine); + if ($nextTrimmed === '' + || preg_match('/^[*RWMS]*\s+[A-Za-z0-9_\-.]+\s+\.{3,}\s*"/', $nextLine) + || str_starts_with($nextTrimmed, "'")) { + break; + } + $j++; + $descParts[] = $nextTrimmed; + } + $i = $j; + + $desc = trim(rtrim(trim(implode(' ', $descParts)), '"')); + + $areas[] = [ + 'name' => $tag, + 'description' => $desc !== '' ? $desc : null, + 'action' => $isSubscribed ? self::ACTION_SUBSCRIBE : self::ACTION_AVAILABLE, + 'is_subscribed' => $isSubscribed, + ]; + } + + return $found ? $areas : null; + } + + /** + * Last-resort conservative freeform line matcher for hub formats with no + * recognizable header, banner, or delimiter at all (e.g. a bare SBBSecho + * "TAG Description" list terminated only by a "--- " tearline). + * + * Gated by the same strict isValidTag() check as every other grammar rather + * than a keyword blocklist. Rows found here are never marked as subscribed + * (always ACTION_AVAILABLE) since there is no structural signal to confirm + * a subscription — only a sysop confirming the mandatory sync preview can + * turn one into an active subscription. + * + * @return array + */ + private function parseFreeformList(string $body): array + { + $lines = explode("\n", $body); + $areas = []; + + foreach ($lines as $line) { + $trimmed = trim($line); + if ($trimmed === '') { + continue; + } + + // Tearlines, origin lines, kludges, and decorative separators are never area rows + if (preg_match('/^-{2,}\s/', $trimmed) + || str_starts_with($trimmed, '* Origin:') + || str_starts_with($trimmed, '...') + || preg_match('/^(?:to|from|subject|date|cost|flags|origin|dest|intl|replyaddr|msgid|chrs|pid|tzutc)\s*[:\s]/i', $trimmed) + || preg_match('/^[:|\s]*[-=*#~:\s]{3,}[:|\s]*$/', $trimmed)) { + continue; + } + + // Bare "TAG Description" line: tag, then 2+ spaces or a tab, then description + if (!preg_match('/^([A-Za-z0-9_\-.]+)(?:[ ]{2,}|\t+)(.+)$/', $trimmed, $m)) { + continue; + } + + $tag = strtoupper(trim($m[1])); + if (!self::isValidTag($tag)) { + continue; + } + + $desc = trim($m[2]); + if ($desc !== '' && (preg_match('/[▄█▀▌▐░▒▓─│┌┐└┘├┤┬┴┼═║]/u', $desc) || preg_match('/[\xB0-\xDF]/', $desc))) { + $desc = null; + } + + $areas[] = [ + 'name' => $tag, + 'description' => ($desc !== null && $desc !== '') ? $desc : null, + 'action' => self::ACTION_AVAILABLE, + 'is_subscribed' => false, + ]; + } + + return $areas; + } + + /** + * Load data-driven grammar definitions from config/areafix_grammars.json. + * + * The file is optional; a missing file, empty array, or invalid JSON all + * result in no configured grammars (built-in grammars and the freeform + * fallback are unaffected). Cached per-process since this is invoked once + * per parse() call and the file only changes via the admin daemon. + * + * If config/areafix_grammars.json doesn't exist yet, falls back to the + * shipped config/areafix_grammars.json.example so its (disabled-by-default) + * sample grammar is available as a starting point without requiring a + * sysop to create the real file first. The example ships with every + * grammar disabled, so this fallback never changes parsing behavior on + * its own. + * + * @return array> + */ + private function loadConfiguredGrammars(): array + { + if ($this->configuredGrammarsCache !== null) { + return $this->configuredGrammarsCache; + } + + $path = __DIR__ . '/../../config/areafix_grammars.json'; + if (!file_exists($path)) { + $path = __DIR__ . '/../../config/areafix_grammars.json.example'; + } + if (!file_exists($path)) { + return $this->configuredGrammarsCache = []; + } + + $json = file_get_contents($path); + if ($json === false) { + return $this->configuredGrammarsCache = []; + } + + $decoded = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { + $this->logger->warning('AreaFixParser: config/areafix_grammars.json is invalid JSON, ignoring', [ + 'json_error' => json_last_error_msg(), + ]); + return $this->configuredGrammarsCache = []; + } + + return $this->configuredGrammarsCache = array_values(array_filter($decoded, 'is_array')); + } + + /** + * List every tier identifier parse()/parseWithTier() could currently + * report, in the same order they're tried: the five built-in structural + * grammars, then "configured:" for each data-driven grammar + * currently defined (regardless of its own enabled flag), then the + * freeform fallback. Used by the admin UI (per-uplink grammar memory + * editor) to populate a "force this tier" selector without accepting + * arbitrary strings into areafix_grammar_memory. + * + * @return array + */ + public function getKnownTierIds(): array + { + $tiers = [ + self::TIER_MYSTIC_BLOCKS, + self::TIER_DELIMITED_TABLE, + self::TIER_COLUMNAR_TABLE, + self::TIER_QUOTED_ADDRESS_LIST, + self::TIER_FLAGGED_DOTTED_QUOTED_LIST, + ]; + + foreach ($this->loadConfiguredGrammars() as $grammar) { + $id = (string)($grammar['id'] ?? ''); + if ($id !== '') { + $tiers[] = self::CONFIGURED_TIER_PREFIX . $id; + } + } + + $tiers[] = self::TIER_FREEFORM; + + return $tiers; + } + + /** + * Attempt to match a single data-driven grammar definition against the + * message body. + * + * Grammar schema (see docs/AreaFix.md for the full reference): + * { + * "id": "my_hub_format", + * "enabled": true, + * "header_pattern": "regex (no delimiters) that must appear somewhere in the body", + * "row_pattern": "regex (no delimiters) with named groups , optional , ", + * "stop_pattern": "optional regex; a matching line ends the row scan", + * "default_action": "subscribe|unsubscribe|available", + * "status_rules": [ { "pattern": "regex tested against the group", "action": "subscribe|unsubscribe|available" } ] + * } + * + * A grammar missing `header_pattern` or `row_pattern`, disabled, or whose + * regexes fail to compile is skipped entirely rather than partially + * applied, so a sysop typo in one definition can never produce a + * misleading partial match. + * + * @param array $grammar + * @return array|null + */ + private function matchConfiguredGrammar(array $grammar, string $body): ?array + { + if (empty($grammar['enabled'])) { + return null; + } + + $headerPattern = $grammar['header_pattern'] ?? null; + $rowPattern = $grammar['row_pattern'] ?? null; + if (!is_string($headerPattern) || $headerPattern === '' || !is_string($rowPattern) || $rowPattern === '') { + return null; + } + + $defaultAction = $grammar['default_action'] ?? self::ACTION_AVAILABLE; + if (!in_array($defaultAction, self::VALID_ACTIONS, true)) { + $defaultAction = self::ACTION_AVAILABLE; + } + + $stopPattern = is_string($grammar['stop_pattern'] ?? null) && $grammar['stop_pattern'] !== '' ? $grammar['stop_pattern'] : null; + + $statusRules = []; + foreach ((array)($grammar['status_rules'] ?? []) as $rule) { + if (!is_array($rule) || !isset($rule['pattern'], $rule['action']) || !is_string($rule['pattern'])) { + continue; + } + if (!in_array($rule['action'], self::VALID_ACTIONS, true)) { + continue; + } + if (!self::isValidPattern($rule['pattern'])) { + continue; + } + $statusRules[] = $rule; + } + + if (!self::isValidPattern($headerPattern) || !self::isValidPattern($rowPattern) + || ($stopPattern !== null && !self::isValidPattern($stopPattern))) { + $this->logger->warning('AreaFixParser: data-driven grammar has an invalid regex, skipping', [ + 'grammar_id' => (string)($grammar['id'] ?? ''), + ]); + return null; + } + + if (!preg_match('/' . $headerPattern . '/im', $body)) { + return null; + } + + $lines = explode("\n", $body); + $areas = []; + + foreach ($lines as $line) { + $trimmed = trim($line); + if ($trimmed === '') { + if (!empty($areas)) { + break; + } + continue; + } + + if ($stopPattern !== null && preg_match('/' . $stopPattern . '/i', $trimmed)) { + break; + } + + if (preg_match('/' . $rowPattern . '/', $line, $m) !== 1) { + continue; + } + + $tag = strtoupper(trim($m['tag'] ?? '')); + if ($tag === '' || !self::isValidTag($tag)) { + continue; + } + + $desc = isset($m['description']) ? trim($m['description']) : ''; + $statusText = isset($m['status']) ? trim($m['status']) : ''; + + $action = $defaultAction; + foreach ($statusRules as $rule) { + if (preg_match('/' . $rule['pattern'] . '/i', $statusText)) { + $action = $rule['action']; + break; + } + } + + $areas[] = [ + 'name' => $tag, + 'description' => $desc !== '' ? $desc : null, + 'action' => $action, + 'is_subscribed' => $action === self::ACTION_SUBSCRIBE, + ]; + } + + return $areas; + } + + /** + * Check whether a user-supplied regex fragment (no delimiters) compiles + * without throwing a PHP warning, so an invalid pattern in a sysop-edited + * (or AI-generated) grammar definition is skipped rather than crashing + * the parser. Public so callers validating a grammar before it's ever + * written to config/areafix_grammars.json (e.g. the AI-assisted grammar + * generator route) can reuse the exact same check. + */ + public static function isValidPattern(string $pattern): bool + { + return @preg_match('/' . $pattern . '/i', '') !== false; + } + + /** + * Validate whether a string is syntactically a valid FTN echoarea/filearea tag. + */ + public static function isValidTag(string $tag): bool + { + $upper = strtoupper(trim($tag)); + $len = strlen($upper); + + if ($len < 2 || $len > 60) { + return false; + } + + // Permitted characters: letters, digits, underscore, hyphen, period + if (!preg_match('/^[A-Z0-9_\-\.]+$/', $upper)) { + return false; + } + + // Must contain at least one letter (prevents pure numbers or punctuation) + if (!preg_match('/[A-Z]/', $upper)) { + return false; + } + + // Must not be a structural table token or command keyword + $reserved = [ + 'AREA', 'ECHOTAG', 'TAG', 'DESCRIPTION', 'DESC', + 'STATUS', 'MSGS', 'FILES', 'COMMAND', 'RESULT', + 'HELP', 'PASSWORD', 'ORIGIN', 'DEST' + ]; + + return !in_array($upper, $reserved, true); + } + + /** + * Deduplicate parsed areas by uppercase tag, giving precedence to subscriptions + * and non-empty descriptions. + * + * @param array $areas + * @return array + */ + private function deduplicateAreas(array $areas): array + { + $map = []; + + foreach ($areas as $item) { + $tag = strtoupper($item['name']); + if (!isset($map[$tag])) { + $map[$tag] = $item; + continue; + } + + // Precedence: subscribe > unsubscribe > available + if ($item['action'] === self::ACTION_SUBSCRIBE) { + $map[$tag]['action'] = self::ACTION_SUBSCRIBE; + $map[$tag]['is_subscribed'] = true; + } elseif ($item['action'] === self::ACTION_UNSUBSCRIBE && $map[$tag]['action'] !== self::ACTION_SUBSCRIBE) { + $map[$tag]['action'] = self::ACTION_UNSUBSCRIBE; + $map[$tag]['is_subscribed'] = false; + } + + // Retain description if provided + if (!empty($item['description']) && empty($map[$tag]['description'])) { + $map[$tag]['description'] = $item['description']; + } + } + + return array_values($map); + } +} diff --git a/src/AreaFixManager.php b/src/AreaFixManager.php index 2ba7a1cb1..20a8a0be7 100644 --- a/src/AreaFixManager.php +++ b/src/AreaFixManager.php @@ -15,6 +15,7 @@ namespace BinktermPHP; +use BinktermPHP\AreaFix\AreaFixParser; use BinktermPHP\Binkp\Config\BinkpConfig; use BinktermPHP\Binkp\Logger; @@ -87,235 +88,101 @@ public function sendCommand( } /** - * Parse a %QUERY, %LIST, or %UNLINKED reply body into an array of area records. + * Parse an AreaFix or FileFix reply body into an array of area records. * - * Handles multiple hub software formats (Binkd/Husky, FrontDoor/InterMail, - * Mystic BBS/MBSE). Returns an empty array if fewer than 2 valid areas are - * found, which indicates the body is likely an error or status message rather - * than an area list. + * Delegates to the structural AreaFixParser, which handles Mystic BBS / MBSE + * command blocks, delimited colon/pipe tables, and columnar/dotted-leader tables. * * @param string $body Raw message body text - * @param string $commandType Hint for parsing context (e.g. "list", "query", "unlinked") - * @return array Parsed area records + * @param string $commandType Hint for parsing context (e.g. "%LIST", "%QUERY", "%UNLINKED") + * @param string|null $preferredTier A tier identifier to try first (see getRememberedTier()) + * @return array Parsed area records */ - public function parseResponseText(string $body, string $commandType): array + public function parseResponseText(string $body, string $commandType = '%LIST', ?string $preferredTier = null): array { - $areas = []; - - // Normalize line endings - $body = str_replace(["\r\n", "\r"], "\n", $body); - $lines = explode("\n", $body); - - foreach ($lines as $line) { - $trimmed = trim($line); - - // Skip blank lines - if ($trimmed === '') { - continue; - } - - // Skip lines starting with error or percent command prefixes - if (str_starts_with($trimmed, '-ERR') || str_starts_with($trimmed, '+ERR') || str_starts_with($trimmed, '%')) { - continue; - } - - // Pure separator/decorative lines (---, ===, ***, :---:, etc.) - if (preg_match('/^[:|\s]*[-=*#~:\s]{3,}[:|\s]*$/', $trimmed)) { - continue; - } - - // Table header lines (e.g. ": AREA : DESCRIPTION :") - if (preg_match('/[:|\s]+AREA[:|\s]+DESCRIPTION/i', $trimmed)) { - continue; - } - - // Check if colon or pipe delimited table row (e.g. :* : TAG : DESC : MSGS :) - if (preg_match('/^[:|]\s*([\*\+\-\s]?)\s*[:|]\s*([A-Z0-9_\-\.]+)\s*[:|]\s*(.*?)\s*(?:[:|]\s*[0-9]+\s*)?[:|]?$/i', $trimmed, $m)) { - $tag = strtoupper(trim($m[2])); - if ($tag !== 'AREA' && preg_match('/^[A-Z0-9_\-\.]{2,}$/', $tag)) { - $desc = trim($m[3]); - $areas[] = [ - 'name' => $tag, - 'description' => $desc !== '' ? $desc : null, - ]; - continue; - } - } - - // Pipe table format: | TAG | DESC | ... - if (preg_match('/^\|?\s*([A-Z0-9_\-\.]+)\s*\|\s*(.*?)\s*(?:\|.*)?$/i', $trimmed, $m)) { - $tag = strtoupper(trim($m[1])); - if ($tag !== 'AREA' && preg_match('/^[A-Z0-9_\-\.]{2,}$/', $tag)) { - $desc = trim($m[2]); - $areas[] = [ - 'name' => $tag, - 'description' => $desc !== '' ? $desc : null, - ]; - continue; - } - } - - // Skip general header/footer lines before trying freeform parsing - if ($this->isSkippableLine($trimmed)) { - continue; - } - - // Standard line parsing with leading status symbol stripping (*, +, -, :, |) - $cleaned = trim(preg_replace('/^[\*\+\-\:\|\s]+/', '', $trimmed)); - if ($cleaned === '' || $cleaned === ':') { - continue; - } - - $parts = preg_split('/\s+/', $cleaned, 2); - if (!$parts || count($parts) === 0) { - continue; - } - - $tag = strtoupper($parts[0]); - - // List of words that appear in receipts, headers, or English sentences and cannot be area tags - $ignoredTags = [ - 'AREA', 'FTN', 'FOLLOWING', 'FOLLOWS', 'ORIGINAL', 'STATUS', 'MESSAGE', - 'TEXT', 'COMMAND', 'COMMANDS', 'REQUEST', 'NOTE', 'NOTES', 'DATE', - 'COST', 'FLAGS', 'ORIGIN', 'DEST', 'INTL', 'REPLYADDR', 'MSGID', - 'CHRS', 'PID', 'TZUTC', 'THIS', 'THAT', 'THERE', 'HERE', 'YOUR', - 'PLEASE', 'BELOW', 'REPLY', 'RESULT', 'RESULTS', 'HELP' - ]; - - // Validate tag pattern: uppercase letters, digits, underscore, hyphen, dot; minimum 2 chars - if (in_array($tag, $ignoredTags, true) || !preg_match('/^[A-Z0-9_\-\.]{2,}$/', $tag) || preg_match('/^\.+$/', $tag)) { - continue; - } - - // Extract description from remainder of line - $description = null; - if (isset($parts[1])) { - $remainder = $parts[1]; - - // Strip leading separators: " - ", tab, or two or more spaces - $remainder = preg_replace('/^(\s*-\s+|\t+|\s{2,})/', '', $remainder); - $remainder = trim($remainder); - - if ($remainder !== '') { - $description = $remainder; - } - } - - $areas[] = [ - 'name' => $tag, - 'description' => $description, - ]; - } - - // If fewer than 2 valid areas parsed, assume this is not an area list response - if (count($areas) < 2) { - return []; - } - - return $areas; + return $this->parseResponseTextWithTier($body, $commandType, $preferredTier)['areas']; } /** - * Determine whether a line should be skipped during area list parsing. + * Same as parseResponseText(), but also reports which AreaFixParser tier + * produced the result (see PR460Proposal Improvement 6: per-uplink + * grammar memory). * - * Skips header/footer lines, error lines, decorative separators, and lines - * that are clearly not area tags. - * - * @param string $line Trimmed line to evaluate - * @return bool True if the line should be skipped + * @param string $body Raw message body text + * @param string $commandType Hint for parsing context (currently unused by AreaFixParser) + * @param string|null $preferredTier A tier identifier to try first + * @return array{areas: array, tier: ?string} */ - private function isSkippableLine(string $line): bool + public function parseResponseTextWithTier(string $body, string $commandType = '%LIST', ?string $preferredTier = null): array { - $lower = strtolower($line); - - // Lines starting with error or percent prefixes - if (str_starts_with($line, '-ERR') || str_starts_with($line, '+ERR')) { - return true; - } - if (str_starts_with($line, '%')) { - return true; - } - - // Taglines, tearlines, and origin lines - if (str_starts_with($line, '...') || str_starts_with($line, '---') || str_starts_with($line, '* Origin:')) { - return true; - } - - // Lines containing block drawing characters (CP437 / Unicode box art). - if (mb_check_encoding($line, 'UTF-8')) { - // Valid UTF-8: match the actual box-drawing / block code points. - if (@preg_match('/[▄█▀▌▐░▒▓─│┌┐└┘├┤┬┴┼═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬■]/u', $line)) { - return true; - } - } else { - // Not valid UTF-8: treat as CP437 and look for the raw box-art byte range - // (0xB0-0xDF covers the block/box-drawing glyphs). Doing this byte scan on - // valid UTF-8 would wrongly match accented-Latin lead bytes (e.g. 'é'). - if (preg_match('/[\xB0-\xDF]/', $line)) { - return true; - } - $cp437 = @iconv('CP437', 'UTF-8//IGNORE', $line); - if ($cp437 !== false && @preg_match('/[▄█▀▌▐░▒▓─│┌┐└┘├┤┬┴┼═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬■]/u', $cp437)) { - return true; - } - } - - // Explanatory footer lines (e.g. '*' = Subscribed, '+' = available, (MSGS = Messages...) - if (preg_match('/^[\'\"\(]?[\*\+\-RW\s]+[\'\"\)]?\s*=\s*/i', $line) || str_contains($lower, 'messages in the last month')) { - return true; - } - - // FTN mailer/tosser software banners - if (str_contains($lower, 'ftn mailer') || str_contains($lower, 'ftn tosser')) { - return true; - } - - // Quoted message box borders and headers - if (str_starts_with($line, '+--') || str_contains($lower, 'begin message') || str_contains($lower, 'end message') || str_contains($lower, 'control lines') || str_contains($lower, 'message body') || str_contains($lower, 'original message text')) { - return true; - } + $parser = new AreaFixParser(); + return $parser->parseWithTier($body, null, $preferredTier); + } - // FidoNet header fields - if (preg_match('/^(to|from|subject|date|cost|flags|origin|dest|intl|replyaddr|msgid|chrs|pid|tzutc)\s*[:\s]/i', $line)) { - return true; - } + /** + * Return the AreaFixParser tier (see AreaFixParser::TIER_* constants, or + * "configured:") that last produced a CONFIRMED sync for this + * uplink+domain+robot, if any. Used as a parsing hint for the next reply + * and to detect when a hub's reply format changes. + */ + public function getRememberedTier(string $uplinkAddress, string $domain, string $robot): ?string + { + $record = $this->getRememberedTierRecord($uplinkAddress, $domain, $robot); + return $record ? $record['tier'] : null; + } - // Common text lines in help / result receipts / change requests - if (preg_match('/^(this|that|there|here|your|please|check|note|notes|use|arguments|items|no\s+path|the\s+hub|following|the following|below|status)\b/i', $line)) { - return true; - } + /** + * Same as getRememberedTier(), but also returns when it was last recorded, + * for display in the admin UI's per-uplink grammar memory editor (see + * Admin → Networks → Edit Uplink). + * + * @return array{tier: string, last_matched_at: string}|null + */ + public function getRememberedTierRecord(string $uplinkAddress, string $domain, string $robot): ?array + { + $stmt = $this->db->prepare( + "SELECT tier, last_matched_at FROM areafix_grammar_memory WHERE uplink_address = ? AND domain = ? AND robot = ?" + ); + $stmt->execute([$uplinkAddress, $domain, $robot]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + return $row ? ['tier' => (string)$row['tier'], 'last_matched_at' => (string)$row['last_matched_at']] : null; + } - // Husky status table headers and lines (e.g. Area ... Status, or rescanned X mails) - if (preg_match('/[:|\s]+AREA[:|\s]+STATUS/i', $line) || preg_match('/\b(rescanned\s+\d+\s+mails?|area\s+not\s+found|already\s+subscribed|unsubscribed)\b/i', $line)) { - return true; + /** + * Record which AreaFixParser tier matched for this uplink+domain+robot, + * after a sync using that parse has actually been confirmed/applied (or + * a sysop has manually forced a tier via the admin UI). A null or empty + * tier (nothing matched) is never recorded, since that would erase a + * previously-known-good remembered tier for no reason — use + * clearRememberedTier() to actually remove a remembered tier. + */ + public function rememberTier(string $uplinkAddress, string $domain, string $robot, ?string $tier): void + { + if ($tier === null || $tier === '') { + return; } - // Known header patterns - $headerPatterns = [ - 'area list', - 'linked at', - 'areas linked', - 'echo areas', - 'file areas', - 'areafix', - 'filefix', - 'available areas', - 'subscribed areas', - 'not linked', - 'unlinked areas', - 'areas available', - 'your subscriptions', - 'end of', - 'begin of', - ]; - - foreach ($headerPatterns as $pattern) { - if (str_contains($lower, $pattern)) { - return true; - } - } + $stmt = $this->db->prepare( + "INSERT INTO areafix_grammar_memory (uplink_address, domain, robot, tier, last_matched_at) + VALUES (?, ?, ?, ?, NOW()) + ON CONFLICT (uplink_address, domain, robot) DO UPDATE + SET tier = EXCLUDED.tier, last_matched_at = NOW()" + ); + $stmt->execute([$uplinkAddress, $domain, $robot, $tier]); + } - return false; + /** + * Forget the remembered tier for this uplink+domain+robot, so the next + * reply tries the full ordered tier list again from scratch. Used by the + * admin UI when a sysop wants to reset a stale or incorrect memory (e.g. + * after manually confirming a hub's format really did change). + */ + public function clearRememberedTier(string $uplinkAddress, string $domain, string $robot): void + { + $stmt = $this->db->prepare( + "DELETE FROM areafix_grammar_memory WHERE uplink_address = ? AND domain = ? AND robot = ?" + ); + $stmt->execute([$uplinkAddress, $domain, $robot]); } /** @@ -332,11 +199,21 @@ private function isSkippableLine(string $line): bool * For FileFix (robot = "filefix") the sync targets the file_areas table. * For AreaFix the sync targets the echoareas table. * + * By default, an existing area's description is only overwritten when the + * current one is empty or an auto-generated placeholder + * (isPlaceholderDescription()) — a real, sysop-set description is left + * alone. Passing $forceDescriptions = true (used when a sysop has + * explicitly selected specific areas to sync via the admin preview screen) + * overwrites the description whenever the incoming one is non-empty and + * different from the current one, regardless of placeholder status. + * * @param string $uplinkAddress FTN address of the uplink hub * @param string $domain Network domain (e.g. "fidonet") * @param array $parsedAreas * @param bool $deactivateMissing If true, deactivate areas not in the list * @param string $robot "areafix" or "filefix" + * @param bool $activateAll If true, newly created areas are active regardless of parsed action + * @param bool $forceDescriptions If true, overwrite an existing area's description whenever the incoming one differs, bypassing the placeholder-only protection * @return array{created: int, activated: int, deactivated: int} */ public function syncSubscribedAreas( @@ -344,7 +221,9 @@ public function syncSubscribedAreas( string $domain, array $parsedAreas, bool $deactivateMissing = false, - string $robot = 'areafix' + string $robot = 'areafix', + bool $activateAll = false, + bool $forceDescriptions = false ): array { $created = 0; $activated = 0; @@ -354,13 +233,26 @@ public function syncSubscribedAreas( $syncedTags = []; foreach ($parsedAreas as $area) { - $tag = strtoupper(trim($area['name'])); + $tag = strtoupper(trim((string)($area['name'] ?? ''))); if ($tag === '') { continue; } $syncedTags[] = $tag; $description = $area['description'] ?? null; + $action = $area['action'] ?? ($activateAll ? AreaFixParser::ACTION_SUBSCRIBE : ($area['is_subscribed'] ?? true ? AreaFixParser::ACTION_SUBSCRIBE : AreaFixParser::ACTION_AVAILABLE)); + + // If action is unsubscribe, deactivate the area if it exists + if ($action === AreaFixParser::ACTION_UNSUBSCRIBE) { + $stmt = $this->db->prepare( + "UPDATE {$table} SET is_active = FALSE WHERE UPPER(tag) = UPPER(?) AND domain = ? AND is_active = TRUE" + ); + $stmt->execute([$tag, $domain]); + if ($stmt->rowCount() > 0) { + $deactivated++; + } + continue; + } // Check if area already exists $stmt = $this->db->prepare( @@ -372,11 +264,11 @@ public function syncSubscribedAreas( $existing = $stmt->fetch(\PDO::FETCH_ASSOC); if ($existing) { - // Update existing: ensure active, set uplink/description if missing + // Update existing $updates = []; $params = []; - if (!$existing['is_active']) { + if ($action === AreaFixParser::ACTION_SUBSCRIBE && !$existing['is_active']) { $updates[] = 'is_active = TRUE'; $activated++; } @@ -387,7 +279,11 @@ public function syncSubscribedAreas( $params[] = $uplinkAddress; } - if ($description !== null && (empty($existing['description']) || str_starts_with((string)$existing['description'], 'Auto-created:'))) { + $descriptionShouldUpdate = $forceDescriptions + ? ($description !== null && trim($description) !== '' && $description !== ($existing['description'] ?? null)) + : ($description !== null && !self::isPlaceholderDescription($description) && self::isPlaceholderDescription($existing['description'] ?? null)); + + if ($descriptionShouldUpdate) { $updates[] = 'description = ?'; $params[] = $description; } @@ -398,29 +294,42 @@ public function syncSubscribedAreas( $this->db->prepare($sql)->execute($params); } } else { - // Insert new area + // Determine whether new area should be active (only if confirmed subscribed or explicitly requested) + $isActive = ($action === AreaFixParser::ACTION_SUBSCRIBE || $activateAll); + + // Insert new area. On a race (a row appeared between our SELECT + // and this INSERT), fall back to the same overwrite rule as the + // UPDATE branch above: force always wins, otherwise only an + // empty existing description is filled in. + $descriptionConflictClause = $forceDescriptions + ? 'EXCLUDED.description' + : 'COALESCE(NULLIF(%1$s.description, \'\'), EXCLUDED.description)'; + if ($table === 'echoareas') { $stmt = $this->db->prepare( "INSERT INTO echoareas (tag, domain, uplink_address, description, is_active, color) - VALUES (?, ?, ?, ?, TRUE, '#28a745') + VALUES (?, ?, ?, ?, ?, '#28a745') ON CONFLICT (tag, domain) DO UPDATE - SET is_active = TRUE, + SET is_active = EXCLUDED.is_active, uplink_address = COALESCE(NULLIF(echoareas.uplink_address, ''), EXCLUDED.uplink_address), - description = COALESCE(NULLIF(echoareas.description, ''), EXCLUDED.description)" + description = " . sprintf($descriptionConflictClause, 'echoareas') ); - $stmt->execute([$tag, $domain, $uplinkAddress, $description]); + $stmt->execute([$tag, $domain, $uplinkAddress, $description, $isActive ? 'true' : 'false']); } else { // file_areas uses domain to link to uplink — no uplink_address column $stmt = $this->db->prepare( "INSERT INTO file_areas (tag, domain, description, is_active) - VALUES (?, ?, ?, TRUE) + VALUES (?, ?, ?, ?) ON CONFLICT (tag, domain) DO UPDATE - SET is_active = TRUE, - description = COALESCE(NULLIF(file_areas.description, ''), EXCLUDED.description)" + SET is_active = EXCLUDED.is_active, + description = " . sprintf($descriptionConflictClause, 'file_areas') ); - $stmt->execute([$tag, $domain, $description]); + $stmt->execute([$tag, $domain, $description, $isActive ? 'true' : 'false']); } $created++; + if ($isActive) { + $activated++; + } } } @@ -460,6 +369,231 @@ public function syncSubscribedAreas( ]; } + /** + * Find the newest incoming AreaFix/FileFix reply for an uplink that contains + * actionable, parseable area data (skipping result receipts, error notices, and help text). + * + * Shared by the preview and apply code paths so that "this reply is actionable" + * means exactly the same thing in both places. + * + * @param string $uplinkAddress FTN address of the hub uplink + * @param int $sysopUserId User ID of the sysop account + * @param string|null $preferredTier A tier identifier to try first (see getRememberedTier()) + * @return array{message: array, areas: array, tier: ?string}|null + */ + public function findLatestActionableReply(string $uplinkAddress, int $sysopUserId, ?string $preferredTier = null): ?array + { + foreach ($this->getIncomingMessages($uplinkAddress, $sysopUserId) as $m) { + $parsed = $this->toActionableReply($m, $preferredTier); + if ($parsed !== null) { + return $parsed; + } + } + + return null; + } + + /** + * Find a specific incoming AreaFix/FileFix reply for an uplink by its netmail id + * and confirm it contains actionable, parseable area data. + * + * @param string $uplinkAddress FTN address of the hub uplink + * @param int $sysopUserId User ID of the sysop account + * @param int $messageId netmail.id of the incoming reply to inspect + * @param string|null $preferredTier A tier identifier to try first (see getRememberedTier()) + * @return array{message: array, areas: array, tier: ?string}|null + */ + public function findActionableReplyById(string $uplinkAddress, int $sysopUserId, int $messageId, ?string $preferredTier = null): ?array + { + foreach ($this->getIncomingMessages($uplinkAddress, $sysopUserId) as $m) { + if ((int)($m['id'] ?? 0) !== $messageId) { + continue; + } + return $this->toActionableReply($m, $preferredTier); + } + + return null; + } + + /** + * Return the incoming (hub-to-us) messages from an uplink's AreaFix/FileFix history. + * + * @return array> + */ + private function getIncomingMessages(string $uplinkAddress, int $sysopUserId): array + { + $historyData = $this->getHistory($uplinkAddress, $sysopUserId); + $messages = ($historyData['messages'] ?? $historyData); + if (!is_array($messages)) { + return []; + } + + return array_values(array_filter($messages, static fn($m) => ($m['direction'] ?? '') === 'incoming')); + } + + /** + * Check whether a single incoming message is an actionable AreaFix/FileFix reply + * and, if so, return it paired with its parsed areas. + * + * @param array $message + * @param string|null $preferredTier A tier identifier to try first (see getRememberedTier()) + * @return array{message: array, areas: array, tier: ?string}|null + */ + private function toActionableReply(array $message, ?string $preferredTier = null): ?array + { + $subj = (string)($message['subject'] ?? ''); + $bodyText = (string)($message['message_text'] ?? ''); + + if (!$this->isAreaListResponse($subj, $bodyText)) { + return null; + } + + $result = $this->parseResponseTextWithTier($bodyText, '%LIST', $preferredTier); + if (empty($result['areas'])) { + return null; + } + + return ['message' => $message, 'areas' => $result['areas'], 'tier' => $result['tier']]; + } + + /** + * Compute what syncSubscribedAreas() would do for the given parsed areas, + * without writing anything to the database. + * + * Each parsed area is classified against current local state as one of: + * - "new": area does not exist locally yet and will be created (active or not, + * depending on action). + * - "reactivate": area exists but is currently inactive and will be turned on. + * - "deactivate": area is currently active and the parsed action is unsubscribe + * (or, when $deactivateMissing is true, the area is active locally but missing + * from the parsed list). + * - "unchanged": area already matches the state the sync would produce. + * + * Independently of that status, `description_will_change` reports whether + * applying the sync would also update the local description, mirroring + * syncSubscribedAreas()'s own rule: a new area always gets the parsed + * description, while an existing area's description is only overwritten + * when the current one is a placeholder (isPlaceholderDescription()) and + * the incoming one is not — an area can therefore be "unchanged" in + * activation state while still having its description filled in. + * + * When the description won't be overwritten (the local one is a real, + * non-placeholder value) but the hub's reply lists a different one, + * `description_differs` is true so the sysop can still see the mismatch + * and decide whether to update it manually — the sync itself will never + * touch it. + * + * @param string $uplinkAddress FTN address of the uplink hub + * @param string $domain Network domain (e.g. "fidonet") + * @param array $parsedAreas + * @param bool $deactivateMissing If true, also list locally-active areas missing from the parsed list as deactivation candidates + * @param string $robot "areafix" or "filefix" + * @return array + */ + public function previewSync( + string $uplinkAddress, + string $domain, + array $parsedAreas, + bool $deactivateMissing = false, + string $robot = 'areafix' + ): array { + $table = ($robot === 'filefix') ? 'file_areas' : 'echoareas'; + $items = []; + $seenTags = []; + + foreach ($parsedAreas as $area) { + $tag = strtoupper(trim((string)($area['name'] ?? ''))); + if ($tag === '' || isset($seenTags[$tag])) { + continue; + } + $seenTags[$tag] = true; + + $description = $area['description'] ?? null; + $isSubscribed = (bool)($area['is_subscribed'] ?? true); + $action = $area['action'] ?? ($isSubscribed ? AreaFixParser::ACTION_SUBSCRIBE : AreaFixParser::ACTION_AVAILABLE); + + $stmt = $this->db->prepare( + "SELECT is_active, description FROM {$table} WHERE UPPER(tag) = UPPER(?) AND domain = ?" + ); + $stmt->execute([$tag, $domain]); + $existing = $stmt->fetch(\PDO::FETCH_ASSOC); + $currentlyActive = $existing ? (bool)$existing['is_active'] : false; + $currentDescription = $existing['description'] ?? null; + + $descriptionDiffers = false; + + if ($action === AreaFixParser::ACTION_UNSUBSCRIBE) { + $status = $currentlyActive ? 'deactivate' : 'unchanged'; + $descriptionWillChange = false; + } elseif (!$existing) { + $status = 'new'; + $descriptionWillChange = ($description !== null && trim($description) !== ''); + } else { + $status = (!$currentlyActive && $action === AreaFixParser::ACTION_SUBSCRIBE) ? 'reactivate' : 'unchanged'; + $descriptionWillChange = $description !== null + && !self::isPlaceholderDescription($description) + && self::isPlaceholderDescription($currentDescription); + + // Even when the local description won't be overwritten (it's a + // real, non-placeholder value), the sysop should still be told + // the hub's reply lists a different one, so they can decide + // whether to update it manually. + if (!$descriptionWillChange) { + $normalizedIncoming = $description !== null ? trim($description) : ''; + $normalizedCurrent = $currentDescription !== null ? trim($currentDescription) : ''; + $descriptionDiffers = $normalizedIncoming !== '' + && $normalizedCurrent !== '' + && $normalizedIncoming !== $normalizedCurrent; + } + } + + $items[] = [ + 'name' => $tag, + 'description' => $description, + 'action' => $action, + 'is_subscribed' => $isSubscribed, + 'status' => $status, + 'currently_active' => $currentlyActive, + 'current_description' => $currentDescription, + 'description_will_change' => $descriptionWillChange, + 'description_differs' => $descriptionDiffers, + ]; + } + + if ($deactivateMissing) { + $sql = "SELECT tag, description FROM {$table} WHERE domain = ? AND is_active = TRUE"; + $params = [$domain]; + if ($table === 'echoareas') { + $sql .= " AND uplink_address = ?"; + $params[] = $uplinkAddress; + } + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $tag = strtoupper(trim((string)$row['tag'])); + if ($tag === '' || isset($seenTags[$tag])) { + continue; + } + $seenTags[$tag] = true; + + $items[] = [ + 'name' => $tag, + 'description' => $row['description'] ?? null, + 'action' => AreaFixParser::ACTION_UNSUBSCRIBE, + 'is_subscribed' => false, + 'status' => 'deactivate', + 'currently_active' => true, + 'current_description' => $row['description'] ?? null, + 'description_will_change' => false, + 'description_differs' => false, + ]; + } + } + + return $items; + } + /** * Mark a local echo area as inactive (called after successful unsubscribe). * @@ -475,21 +609,38 @@ public function deactivateArea(string $areaTag, string $domain): void } /** - * Check whether a message subject and body represent an AreaFix/FileFix area list - * rather than a command receipt, execution log, help text, or rescan confirmation. + * Check if an area description is considered an auto-generated placeholder + * or contains ANSI box-drawing/block corruption. */ - public function isAreaListResponse(string $subject, string $body): bool + public static function isPlaceholderDescription(?string $desc): bool { - // Skip receipts, error notifications, rescan results, and help text unless explicitly requested as a list/query - if (preg_match('/\b(result|results|help|invalid password|scan results|node change request|change request|request processed)\b/i', $subject) && !preg_match('/\b(list|query)\b/i', $subject)) { - return false; + if ($desc === null || trim($desc) === '') { + return true; } + $trimmed = trim($desc); + // Starts with "Auto-created" (e.g. "Auto-created from TIC file", "Auto-created: ...") + if (preg_match('/^Auto-created\b/i', $trimmed)) { + return true; + } + // Contains ANSI box drawing / block art characters (e.g. ▄▄▄) + if (preg_match('/[▄█▀▌▐░▒▓─│┌┐└┘├┤┬┴┼═║]/u', $trimmed) || preg_match('/[\xB0-\xDF]/', $trimmed)) { + return true; + } + return false; + } - if (str_contains($body, '<-- COMMAND PROCESSED') || str_contains($body, '[ BEGIN MESSAGE ]') || str_contains($body, 'Here are the list of commands') || str_contains($body, 'original message text') || str_contains($body, 'rescanned')) { + /** + * Check whether a message subject and body represent an AreaFix/FileFix area list + * or actionable reply rather than an error or help notification. + */ + public function isAreaListResponse(string $subject, string $body): bool + { + if (preg_match('/\b(invalid password|password error)\b/i', $subject)) { return false; } - return true; + $parser = new AreaFixParser(); + return $parser->hasActionableContent($body, $subject); } /** @@ -539,7 +690,7 @@ public function processIncomingReply(array $message): ?array } } - // Do not process command receipts / execution logs, rescan replies, or help responses as area lists + // Do not process non-actionable receipts or error notifications if (!$this->isAreaListResponse($subject, $body)) { return null; } @@ -559,18 +710,21 @@ public function processIncomingReply(array $message): ?array } $robot = $isFilefix ? 'filefix' : 'areafix'; - $parsedAreas = $this->parseResponseText($body, '%LIST'); + $uplinkAddress = (string)$targetUplink['address']; + $domain = (string)($targetUplink['domain'] ?? 'fidonet'); + + $preferredTier = $this->getRememberedTier($uplinkAddress, $domain, $robot); + $parseResult = $this->parseResponseTextWithTier($body, '%LIST', $preferredTier); + $parsedAreas = $parseResult['areas']; - if (count($parsedAreas) < 2) { + if (empty($parsedAreas)) { return null; } - $uplinkAddress = (string)$targetUplink['address']; - $domain = (string)($targetUplink['domain'] ?? 'fidonet'); - $summary = $this->syncSubscribedAreas($uplinkAddress, $domain, $parsedAreas, false, $robot); + $this->rememberTier($uplinkAddress, $domain, $robot, $parseResult['tier']); - $this->logger->info("[AreaFixManager] Auto-imported " . count($parsedAreas) . " areas for domain '{$domain}' from {$uplinkAddress}: created={$summary['created']}, activated={$summary['activated']}"); + $this->logger->info("[AreaFixManager] Auto-imported " . count($parsedAreas) . " areas for domain '{$domain}' from {$uplinkAddress}: created={$summary['created']}, activated={$summary['activated']}, deactivated={$summary['deactivated']}"); return [ 'matched' => true, diff --git a/src/TicFileProcessor.php b/src/TicFileProcessor.php index 2b5853295..c44abeb11 100644 --- a/src/TicFileProcessor.php +++ b/src/TicFileProcessor.php @@ -501,10 +501,15 @@ protected function getFileArea(string $tag, ?string $domain = null): ?array */ protected function autoCreateFileArea(string $areaTag, array $ticData, ?string $domain = null): int { - // Generate description from TIC data if available - $description = "Auto-created from TIC file"; - if (isset($ticData['Desc'])) { - $description = "Auto-created: " . $ticData['Desc']; + // Do NOT use file-specific descriptions or ANSI block art as the file area description. + // Leaving description null allows FileFix sync to populate the authoritative description. + $description = null; + if (!empty($ticData['Desc'])) { + $rawDesc = trim($ticData['Desc']); + // Only use if it does NOT contain ANSI box drawing art and does not look like banner art + if (!preg_match('/[▄█▀▌▐░▒▓─│┌┐└┘├┤┬┴┼═║]/u', $rawDesc) && !preg_match('/[\xB0-\xDF]/', $rawDesc)) { + $description = $rawDesc; + } } $domain = $domain ?: $this->getDomainFromTicData($ticData); diff --git a/templates/admin/areafix.twig b/templates/admin/areafix.twig index b23359b4e..ea62fc1fe 100644 --- a/templates/admin/areafix.twig +++ b/templates/admin/areafix.twig @@ -266,6 +266,45 @@ +{# Preview modal — shown before any AreaFix/FileFix sync is applied #} + + {% endblock %} {% block scripts %} @@ -499,13 +538,23 @@ ? (m.from_address || '') : (m.to_address || ''); + var syncCell = ''; + var msgId = parseInt(m.id, 10); + if (m.direction === 'incoming' && msgId) { + syncCell = '' + syncCell; + } + html += ''; html += '' + escapeHtml(dateStr) + ''; html += '' + dirLabel + ''; html += '' + escapeHtml(nodeAddr) + ''; html += '' + escapeHtml(network) + ''; html += '' + excerpt + ''; - html += ''; + html += '' + syncCell + ''; html += ''; html += ''; html += '
' +
@@ -538,7 +587,7 @@
                     '' + from + '' +
                     (date ? ' — ' + date : '') +
                 '' +
-                '' +
             '' +
@@ -547,35 +596,283 @@
             '
'; } - window.syncLatestReply = function (robot) { + // ── Preview modal ───────────────────────────────────────────────────────── + // Every sync (manual or from the "Sync Areas to Local BBS" button) is + // previewed before anything is written to the database. The sysop reviews + // the new/reactivate/deactivate/unchanged diff and must explicitly confirm. + + var previewState = { robot: null, uplink: null, messageId: null, areas: [], checked: [], tier: null }; + + // Human-readable labels for AreaFixParser::TIER_* identifiers (see + // src/AreaFix/AreaFixParser.php and docs/AreaFix.md). A data-driven + // grammar's tier is "configured:" rather than one of these. + var TIER_LABELS = { + 'mystic_blocks': { key: 'ui.admin.areafix.tier_mystic_blocks', fallback: 'Mystic BBS / MBSE blocks' }, + 'delimited_table': { key: 'ui.admin.areafix.tier_delimited_table', fallback: 'Delimited table' }, + 'columnar_table': { key: 'ui.admin.areafix.tier_columnar_table', fallback: 'Columnar / dotted-leader table' }, + 'quoted_address_list': { key: 'ui.admin.areafix.tier_quoted_address_list', fallback: 'Quoted address list' }, + 'flagged_dotted_quoted_list': { key: 'ui.admin.areafix.tier_flagged_dotted_quoted_list', fallback: 'Flag-prefixed dotted-leader list' }, + 'freeform': { key: 'ui.admin.areafix.tier_freeform', fallback: 'Freeform fallback' } + }; + + function tierLabel(tier) { + if (!tier) { + return window.t('ui.admin.areafix.tier_unknown', {}, 'unknown format'); + } + if (tier.indexOf('configured:') === 0) { + var id = tier.slice('configured:'.length); + return window.t('ui.admin.areafix.tier_configured', { id: id }, 'custom grammar "' + id + '"'); + } + var entry = TIER_LABELS[tier]; + return entry ? window.t(entry.key, {}, entry.fallback) : tier; + } + + function renderFormatChangeWarning(data) { + var el = document.getElementById('areafixFormatChangeWarning'); + if (!el) return; + if (!data || !data.format_changed) { + el.style.display = 'none'; + el.innerHTML = ''; + return; + } + var msg = window.t('ui.admin.areafix.format_changed_warning', { + old_tier: tierLabel(data.remembered_tier), + new_tier: tierLabel(data.tier) + }, 'This hub\'s reply format looks different than last time (was ' + tierLabel(data.remembered_tier) + ', now ' + tierLabel(data.tier) + '). Double-check the areas below before confirming — this can mean the hub\'s mailer software changed or was reconfigured.'); + el.innerHTML = '' + escapeHtml(msg); + el.style.display = ''; + } + var STATUS_LABELS = { + 'new': { key: 'ui.admin.areafix.status_new', fallback: 'New', badge: 'bg-success' }, + 'reactivate': { key: 'ui.admin.areafix.status_reactivate', fallback: 'Reactivate', badge: 'bg-primary' }, + 'deactivate': { key: 'ui.admin.areafix.status_deactivate', fallback: 'Deactivate', badge: 'bg-danger' }, + 'unchanged': { key: 'ui.admin.areafix.status_unchanged', fallback: 'Unchanged', badge: 'bg-secondary' }, + 'updated': { key: 'ui.admin.areafix.status_updated', fallback: 'Updated', badge: 'bg-info text-dark' } + }; + + // An area whose activation state is otherwise unchanged but whose + // description differs from the hub's (whether that difference would be + // applied automatically, or only if the sysop selects it) is shown as + // "Updated" rather than "Unchanged", since something about it did differ. + function displayStatus(a) { + if (a.status === 'unchanged' && (a.description_will_change || a.description_differs)) { + return 'updated'; + } + return a.status; + } + + function statusBadge(a) { + var s = STATUS_LABELS[displayStatus(a)] || STATUS_LABELS.unchanged; + var label = window.t(s.key, {}, s.fallback); + return '' + escapeHtml(label) + ''; + } + + function descriptionCell(a) { + var current = a.current_description; + var incoming = a.description; + + if (a.description_will_change) { + var oldHtml = (current !== null && current !== undefined && current !== '') + ? '
' + escapeHtml(current) + '
' + : ''; + return oldHtml + '
' + escapeHtml(incoming || '') + '
'; + } + + // No description change will be applied — show what will remain locally + // (a real, sysop-set description is never overwritten by the hub's reply). + var shown = (current !== null && current !== undefined && current !== '') ? current : incoming; + var html = escapeHtml(shown || ''); + + if (a.description_differs && incoming) { + html += '
' + + escapeHtml(window.t('ui.admin.areafix.hub_description_differs', { desc: incoming }, 'Hub lists: "' + incoming + '"')) + + '
'; + } + + return html; + } + + // A row defaults to unchecked only for a genuine no-op: activation state + // unchanged AND no description difference of any kind. Anything flagged + // "Updated" (a description difference, whether it would apply + // automatically or only via force_descriptions once selected) defaults + // checked right along with new/reactivate/deactivate — reviewing the + // preview shouldn't also require re-checking every row it flags. + function defaultAreafixRowChecked(a) { + return displayStatus(a) !== 'unchanged'; + } + + function renderPreviewBody(areas) { + var container = document.getElementById('areafixPreviewBody'); + if (!container) return; + + previewState.areas = areas; + previewState.checked = areas.map(defaultAreafixRowChecked); + + if (!areas.length) { + container.innerHTML = '

' + + escapeHtml(window.t('ui.admin.areafix.preview_no_changes', {}, 'No changes to apply')) + '

'; + return; + } + + var html = '
' + + '' + + '' + + '' + + '' + + '' + + ''; + + areas.forEach(function (a, i) { + html += '' + + '' + + '' + + '' + + '' + + ''; + }); + + html += '
' + escapeHtml(window.t('ui.admin.areafix.col_tag', {}, 'Tag')) + '' + escapeHtml(window.t('ui.admin.areafix.col_description', {}, 'Description')) + '' + escapeHtml(window.t('ui.admin.areafix.col_status', {}, 'Status')) + '
' + escapeHtml(a.name || '') + '' + descriptionCell(a) + '' + statusBadge(a) + '
'; + container.innerHTML = html; + updateAreafixPreviewConfirmState(); + } + + function updateAreafixPreviewConfirmState() { + var confirmBtn = document.getElementById('areafixPreviewConfirmBtn'); + if (!confirmBtn) return; + var anyChecked = previewState.checked.some(function (c) { return c; }); + confirmBtn.disabled = !anyChecked; + } + + window.toggleAreafixPreviewRow = function (index, checked) { + previewState.checked[index] = checked; + updateAreafixPreviewConfirmState(); + }; + + window.setAllAreafixPreviewChecked = function (checked) { + previewState.checked = previewState.areas.map(function () { return checked; }); + document.querySelectorAll('.areafix-preview-row-check').forEach(function (el) { + el.checked = checked; + }); + updateAreafixPreviewConfirmState(); + }; + + window.previewLatestReply = function (robot, messageId) { var uplink = getSelectedUplink(); if (!uplink) { showToast(window.t('ui.admin.areafix.select_uplink_first', {}, 'Please select an uplink'), true); return; } - var btn = document.getElementById('btnSync_' + robot); - if (btn) { - btn.disabled = true; - btn.innerHTML = '' + escapeHtml(window.t('ui.common.processing', {}, 'Processing...')); + previewState.robot = robot; + previewState.uplink = uplink; + previewState.messageId = messageId ? parseInt(messageId, 10) : null; + previewState.tier = null; + renderFormatChangeWarning(null); + + var modalEl = document.getElementById('areafixPreviewModal'); + var confirmBtn = document.getElementById('areafixPreviewConfirmBtn'); + if (confirmBtn) confirmBtn.disabled = true; + + var container = document.getElementById('areafixPreviewBody'); + if (container) { + container.innerHTML = '
' + + ' ' + + escapeHtml(window.t('ui.admin.areafix.preview_loading', {}, 'Loading preview...')) + + '
'; + } + + var modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + + var requestBody = { uplink: uplink, robot: robot }; + if (previewState.messageId) requestBody.message_id = previewState.messageId; + + fetch('/api/admin/areafix/preview-latest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.success) { + previewState.tier = data.tier || null; + renderFormatChangeWarning(data); + renderPreviewBody(data.areas || []); + } else { + renderFormatChangeWarning(null); + var errMsg = window.getApiErrorMessage ? window.getApiErrorMessage(data) : (data.message || data.error || window.t('ui.admin.areafix.preview_load_failed', {}, 'Failed to load sync preview')); + if (container) { + container.innerHTML = '

' + escapeHtml(errMsg) + '

'; + } + } + }) + .catch(function () { + if (container) { + container.innerHTML = '

' + + escapeHtml(window.t('ui.admin.areafix.preview_load_failed', {}, 'Failed to load sync preview')) + '

'; + } + }); + }; + + window.confirmAreafixPreview = function () { + var robot = previewState.robot; + var uplink = previewState.uplink; + if (!robot || !uplink) return; + + var selectedAreas = previewState.areas.filter(function (a, i) { return previewState.checked[i]; }) + .map(function (a) { + return { name: a.name, description: a.description, action: a.action, is_subscribed: a.is_subscribed }; + }); + + if (!selectedAreas.length) { + showToast(window.t('ui.admin.areafix.no_areas_selected', {}, 'Select at least one area to sync'), true); + return; + } + + var confirmBtn = document.getElementById('areafixPreviewConfirmBtn'); + if (confirmBtn) { + confirmBtn.disabled = true; + confirmBtn.innerHTML = '' + escapeHtml(window.t('ui.common.processing', {}, 'Processing...')); + } + + // force_descriptions: the sysop has explicitly reviewed and selected + // these specific areas in the preview, so a description difference + // shown there (even one that would otherwise be protected) is applied. + var requestBody = { + uplink: uplink, + robot: robot, + areas: selectedAreas, + deactivate_missing: false, + force_descriptions: true + }; + if (previewState.tier) { + requestBody.tier = previewState.tier; } - fetch('/api/admin/areafix/sync-latest', { + fetch('/api/admin/areafix/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uplink: uplink, robot: robot }), + body: JSON.stringify(requestBody), }) .then(function (r) { return r.json(); }) .then(function (data) { + var modalEl = document.getElementById('areafixPreviewModal'); + var modal = bootstrap.Modal.getInstance(modalEl); + if (modal) modal.hide(); + if (data.success) { var s = data.summary || {}; - var count = data.areas_count || 0; + var count = selectedAreas.length; var msg = window.t('ui.admin.areafix.sync_success', { count: count, created: s.created || 0, activated: s.activated || 0 }, 'Successfully synced ' + count + ' areas (' + (s.created || 0) + ' created, ' + (s.activated || 0) + ' activated)'); showToast(msg, false); + loadHistory(robot); } else { var errMsg = window.getApiErrorMessage ? window.getApiErrorMessage(data) : (data.message || data.error || window.t('ui.admin.areafix.sync_failed', {}, 'Failed to sync areas')); showToast(errMsg, true); @@ -585,9 +882,9 @@ showToast(window.t('ui.admin.areafix.sync_failed', {}, 'Failed to sync areas'), true); }) .finally(function () { - if (btn) { - btn.disabled = false; - btn.innerHTML = '' + escapeHtml(window.t('ui.admin.areafix.btn_sync_areas', {}, 'Sync Areas to Local BBS')); + if (confirmBtn) { + confirmBtn.disabled = false; + confirmBtn.innerHTML = '' + escapeHtml(window.t('ui.admin.areafix.btn_confirm_apply', {}, 'Confirm & Apply')); } }); }; diff --git a/templates/admin/areafix_grammars.twig b/templates/admin/areafix_grammars.twig new file mode 100644 index 000000000..2168bc468 --- /dev/null +++ b/templates/admin/areafix_grammars.twig @@ -0,0 +1,359 @@ +{% extends "base.twig" %} + +{% block title %}{{ t('ui.admin.areafix_grammars.page_title', {}, locale, ['common']) }}{% endblock %} + +{% block content %} +
+
+

+ + {{ t('ui.admin.areafix_grammars.heading', {}, locale, ['common']) }} +

+
+ +
+ + {{ t('ui.admin.areafix_grammars.info_text_prefix', {}, locale, ['common']) }} config/areafix_grammars.json {{ t('ui.admin.areafix_grammars.info_text_suffix', {}, locale, ['common']) }} + {{ t('ui.admin.areafix_grammars.doc_hint', {}, locale, ['common']) }} docs/AreaFix.md. +
+ +
+ +
+
+
+
+
{{ t('ui.admin.areafix_grammars.grammars_list_heading', {}, locale, ['common']) }}
+
+
+
{{ t('ui.admin.areafix_grammars.waiting_for_config', {}, locale, ['common']) }}
+
+
+
+
+
+
+
{{ t('ui.admin.areafix_grammars.config_filename', {}, locale, ['common']) }}
+
+ + + + +
+
+
+
+ +
+ {{ t('ui.admin.areafix_grammars.waiting_for_config', {}, locale, ['common']) }} + {{ t('ui.admin.areafix_grammars.json_validation_before_save', {}, locale, ['common']) }} +
+
+
+
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/admin/binkp_config.twig b/templates/admin/binkp_config.twig index f38f27fde..19fa7ce4a 100644 --- a/templates/admin/binkp_config.twig +++ b/templates/admin/binkp_config.twig @@ -296,6 +296,13 @@ +
@@ -726,6 +733,7 @@ function openUplinkModal(index = null) { document.getElementById('uplinkCrypt').checked = false; document.getElementById('uplinkDefault').checked = false; document.getElementById('uplinkAllowInsecureEchomail').checked = false; + hideUplinkGrammarMemory(); } else { const uplink = (binkpConfig.uplinks || [])[index]; if (!uplink) { @@ -753,12 +761,148 @@ function openUplinkModal(index = null) { document.getElementById('uplinkCompression').checked = !!uplink.compression; document.getElementById('uplinkCrypt').checked = !!uplink.crypt; document.getElementById('uplinkDefault').checked = !!uplink.default; + loadUplinkGrammarMemory(uplink.address || ''); } resetUplinkPasswordVisibility(); uplinkModal.show(); } +// ── AreaFix/FileFix grammar memory (PR460Proposal Improvement 6) ────────── +// Lets a sysop view, manually force, or clear the per-uplink AreaFixParser +// tier memory (see docs/AreaFix.md#per-uplink-grammar-memory) directly from +// the uplink editor, without needing to trigger an actual AreaFix sync. + +const GRAMMAR_TIER_LABELS = { + 'mystic_blocks': { key: 'ui.admin.binkp_config.uplinks.modal.tier_mystic_blocks', fallback: 'Mystic BBS / MBSE blocks' }, + 'delimited_table': { key: 'ui.admin.binkp_config.uplinks.modal.tier_delimited_table', fallback: 'Delimited table' }, + 'columnar_table': { key: 'ui.admin.binkp_config.uplinks.modal.tier_columnar_table', fallback: 'Columnar / dotted-leader table' }, + 'quoted_address_list': { key: 'ui.admin.binkp_config.uplinks.modal.tier_quoted_address_list', fallback: 'Quoted address list' }, + 'flagged_dotted_quoted_list': { key: 'ui.admin.binkp_config.uplinks.modal.tier_flagged_dotted_quoted_list', fallback: 'Flag-prefixed dotted-leader list' }, + 'freeform': { key: 'ui.admin.binkp_config.uplinks.modal.tier_freeform', fallback: 'Freeform fallback' } +}; + +function grammarTierLabel(tier) { + if (!tier) { + return ''; + } + if (tier.indexOf('configured:') === 0) { + const id = tier.slice('configured:'.length); + return uiT('ui.admin.binkp_config.uplinks.modal.tier_configured', 'custom grammar "' + id + '"', { id: id }); + } + const entry = GRAMMAR_TIER_LABELS[tier]; + return entry ? uiT(entry.key, entry.fallback) : tier; +} + +function hideUplinkGrammarMemory() { + const section = document.getElementById('uplinkGrammarMemorySection'); + if (section) section.style.display = 'none'; + const rows = document.getElementById('uplinkGrammarMemoryRows'); + if (rows) rows.innerHTML = ''; +} + +function loadUplinkGrammarMemory(address) { + const section = document.getElementById('uplinkGrammarMemorySection'); + const rowsEl = document.getElementById('uplinkGrammarMemoryRows'); + if (!address || !section || !rowsEl) { + hideUplinkGrammarMemory(); + return; + } + + section.style.display = ''; + rowsEl.innerHTML = '
' + + escapeHtml(uiT('ui.common.loading', 'Loading...')) + '
'; + + fetch('/api/admin/areafix/grammar-memory?uplink=' + encodeURIComponent(address)) + .then(r => r.json()) + .then(data => { + if (!data.success) { + rowsEl.innerHTML = '
' + + escapeHtml(uiT('ui.admin.binkp_config.uplinks.modal.grammar_memory_load_failed', 'Failed to load grammar memory')) + '
'; + return; + } + renderUplinkGrammarMemoryRows(address, data); + }) + .catch(() => { + rowsEl.innerHTML = '
' + + escapeHtml(uiT('ui.admin.binkp_config.uplinks.modal.grammar_memory_load_failed', 'Failed to load grammar memory')) + '
'; + }); +} + +function renderUplinkGrammarMemoryRows(address, data) { + const rowsEl = document.getElementById('uplinkGrammarMemoryRows'); + if (!rowsEl) return; + + const knownTiers = Array.isArray(data.known_tiers) ? data.known_tiers : []; + const robots = [ + { key: 'areafix', record: data.areafix, label: uiT('ui.admin.binkp_config.uplinks.modal.grammar_memory_areafix', 'AreaFix') }, + { key: 'filefix', record: data.filefix, label: uiT('ui.admin.binkp_config.uplinks.modal.grammar_memory_filefix', 'FileFix') } + ]; + + let html = ''; + robots.forEach(function (r) { + const rowId = 'uplinkGrammarMemory_' + r.key; + const current = r.record ? r.record.tier : null; + const currentLabel = current + ? grammarTierLabel(current) + : uiT('ui.admin.binkp_config.uplinks.modal.grammar_memory_not_recorded', 'not yet recorded'); + const lastMatched = r.record && r.record.last_matched_at ? ' (' + escapeHtml(r.record.last_matched_at) + ')' : ''; + + const options = knownTiers.map(function (tier) { + const selected = tier === current ? ' selected' : ''; + return ''; + }).join(''); + + html += '
' + + '
' + escapeHtml(r.label) + ':
' + + '
' + escapeHtml(currentLabel) + lastMatched + '
' + + '' + + '' + + '' + + '
'; + }); + + rowsEl.innerHTML = html; + rowsEl.dataset.uplinkAddress = address; +} + +function postUplinkGrammarMemory(robot, tier) { + const address = document.getElementById('uplinkGrammarMemoryRows').dataset.uplinkAddress || document.getElementById('uplinkAddress').value.trim(); + if (!address) return; + + fetch('/api/admin/areafix/grammar-memory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uplink: address, robot: robot, tier: tier }) + }) + .then(r => r.json()) + .then(data => { + if (!data.success) { + const errMsg = window.getApiErrorMessage ? window.getApiErrorMessage(data) : (data.error || uiT('ui.admin.binkp_config.uplinks.modal.grammar_memory_save_failed', 'Failed to update grammar memory')); + showUplinkModalAlert(escapeHtml(errMsg), 'danger'); + return; + } + loadUplinkGrammarMemory(address); + }) + .catch(() => { + showUplinkModalAlert(escapeHtml(uiT('ui.admin.binkp_config.uplinks.modal.grammar_memory_save_failed', 'Failed to update grammar memory')), 'danger'); + }); +} + +function setUplinkGrammarMemory(robot) { + const select = document.getElementById('uplinkGrammarMemory_' + robot + 'Select'); + if (!select) return; + postUplinkGrammarMemory(robot, select.value); +} + +function clearUplinkGrammarMemory(robot) { + postUplinkGrammarMemory(robot, null); +} + function ensureUplinkDomainOption(domain) { if (!domain || configuredNetworkDomains.includes(domain)) { return; diff --git a/templates/base.twig b/templates/base.twig index ba2e82242..0f4f1c842 100644 --- a/templates/base.twig +++ b/templates/base.twig @@ -377,6 +377,7 @@
  • {{ t('ui.base.admin.lovlynet', {}, 'common') }}
  • {{ t('ui.base.admin.areafix', {}, 'common') }}
  • +
  • {{ t('ui.base.admin.areafix_grammars', {}, 'common') }}
  • {{ t('ui.base.admin.lovlynet', {}, 'common') }}
  • {{ t('ui.base.admin.areafix', {}, 'common') }}
  • +
  • {{ t('ui.base.admin.areafix_grammars', {}, 'common') }}