From c64dc7a4b334a0fa33854b4d46a2f82bc843daa6 Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 13 Mar 2026 20:41:15 -0700 Subject: [PATCH 001/246] Fix false PETSCII detection in imported messages --- CLAUDE.md | 1 + docs/UPGRADING_1.8.7.md | 117 +++++++++++++++++++++++++++ src/ArtFormatDetector.php | 54 +++++++++++-- src/Version.php | 2 +- tests/Unit/ArtFormatDetectorTest.php | 35 ++++++++ 5 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 docs/UPGRADING_1.8.7.md create mode 100644 tests/Unit/ArtFormatDetectorTest.php diff --git a/CLAUDE.md b/CLAUDE.md index df176992..c056ded3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ A modern web interface and mailer tool that receives and sends Fidonet message p - When adding features to netmail and echomail, keep in mind feature parity. Ask for clarification about whether a feature is appropriate to both - Leave the vendor directory alone. It's managed by composer only - **Composer Dependencies**: When adding a new required package to composer.json, the UPGRADING_x.x.x.md document for that version MUST include instructions to run `composer install` before `php scripts/setup.php`. Without this, the upgrade will fail because `vendor/autoload.php` is loaded before setup.php runs. + - **Upgrade docs TOC**: When creating or maintaining an `UPGRADING_x.y.z.md` document, always add or update its table of contents so the headings in that file remain navigable and in sync with the document. - When updating style.css, also update the theme stylesheets: amber.css, dark.css, greenterm.css, and cyberpunk.css - Database migrations are handled through scripts/setup.php. setup.php will also call upgrade.php which handles other upgrade related tasks. - Migrations can be SQL or PHP. Use the naming convention vX.Y.Z_description (e.g., v1.9.1.6_migrate_file_area_dirs.sql or .php). diff --git a/docs/UPGRADING_1.8.7.md b/docs/UPGRADING_1.8.7.md new file mode 100644 index 00000000..00e884c0 --- /dev/null +++ b/docs/UPGRADING_1.8.7.md @@ -0,0 +1,117 @@ +# Upgrading to 1.8.7 + +⚠️ Make sure you've made a backup of your database and files before upgrading. + +## Table of Contents + +- [Summary of Changes](#summary-of-changes) +- [Echomail Art Format Detection](#echomail-art-format-detection) + - [Existing Misdetected Messages](#existing-misdetected-messages) + - [psql Instructions](#psql-instructions) + - [Notes](#notes) +- [Upgrade Instructions](#upgrade-instructions) + - [From Git](#from-git) + - [Using the Installer](#using-the-installer) + +## Summary of Changes + +1.8.7 is a maintenance release. + +## Echomail Art Format Detection + +- Fixed a false-positive PETSCII detection bug when importing echomail and + netmail without a valid `CHRS` kludge. +- Previously, some non-UTF-8 messages containing arbitrary high-bit bytes could + be incorrectly stored with: + - `message_charset = null` + - `art_format = petscii` +- This was most visible in file listing / file echo announcement messages whose + body included 8-bit text from other character sets. +- PETSCII auto-detection is now more conservative. Messages are only tagged as + PETSCII when the raw body has stronger PETSCII-like characteristics. Unknown + 8-bit text should now remain untagged instead of being misclassified. + +### Existing Misdetected Messages + +If you already imported messages that were incorrectly stored with +`art_format = 'petscii'`, upgrading the code will not change those existing +rows automatically. + +If you want those messages to fall back to normal text rendering, reset the +stored metadata in PostgreSQL for the affected messages. + +### psql Instructions + +Start `psql` and connect to your BinktermPHP database: + +```bash +psql -U your_db_user -d your_db_name +``` + +Preview the rows that currently look misdetected: + +```sql +SELECT id, echoarea_id, subject, message_charset, art_format, date_written +FROM echomail +WHERE art_format = 'petscii' + AND message_charset IS NULL +ORDER BY id; +``` + +If that result set matches what you want to fix, reset those columns: + +```sql +UPDATE echomail +SET message_charset = NULL, + art_format = NULL +WHERE art_format = 'petscii' + AND message_charset IS NULL; +``` + +If you want to target only a specific message first, for example message +`39898`, do this instead: + +```sql +UPDATE echomail +SET message_charset = NULL, + art_format = NULL +WHERE id = 39898; +``` + +Check the result: + +```sql +SELECT id, message_charset, art_format +FROM echomail +WHERE id = 39898; +``` + +Then exit `psql`: + +```sql +\q +``` + +### Notes + +- This release does not add a schema migration. +- Resetting these columns only affects rendering hints stored in the database. +- It does not alter the message body text itself. + +## Upgrade Instructions + +### From Git + +```bash +git pull +php scripts/setup.php +scripts/restart_daemons.sh +``` + +### Using the Installer + +```bash +wget https://raw.githubusercontent.com/awehttam/binkterm-php-installer/main/binkterm-installer.phar +php binkterm-installer.phar +scripts/restart_daemons.sh +``` diff --git a/src/ArtFormatDetector.php b/src/ArtFormatDetector.php index 3b1d82df..f18876f7 100644 --- a/src/ArtFormatDetector.php +++ b/src/ArtFormatDetector.php @@ -7,6 +7,11 @@ */ class ArtFormatDetector { + private const PETSCII_HEURISTIC_MIN_CONTROL_HITS = 4; + private const PETSCII_HEURISTIC_MIN_UNIQUE_CONTROLS = 2; + private const PETSCII_HEURISTIC_MIN_TEXT_RATIO = 0.70; + private const PETSCII_HEURISTIC_MAX_HIGH_BYTE_RATIO = 0.15; + public static function normalizeDetectedEncoding(?string $encoding, ?string $rawBody = null): ?string { if ($encoding !== null && trim($encoding) !== '') { @@ -125,16 +130,55 @@ private static function looksLikePetscii(string $rawBody): bool ]; $controlHits = 0; + $uniqueControls = []; $length = strlen($rawBody); + if ($length === 0) { + return false; + } + + $textishBytes = 0; + $highBytes = 0; + for ($i = 0; $i < $length; $i++) { - if (in_array(ord($rawBody[$i]), $petsciiControlBytes, true)) { + $byte = ord($rawBody[$i]); + + if (in_array($byte, $petsciiControlBytes, true)) { $controlHits++; - if ($controlHits >= 2) { - return true; - } + $uniqueControls[$byte] = true; } + + if ( + $byte === 0x09 || + $byte === 0x0a || + $byte === 0x0d || + ($byte >= 0x20 && $byte <= 0x7e) + ) { + $textishBytes++; + } + + if ($byte >= 0x80) { + $highBytes++; + } + } + + if ($controlHits < self::PETSCII_HEURISTIC_MIN_CONTROL_HITS) { + return false; + } + + if (count($uniqueControls) < self::PETSCII_HEURISTIC_MIN_UNIQUE_CONTROLS) { + return false; + } + + $textRatio = $textishBytes / $length; + if ($textRatio < self::PETSCII_HEURISTIC_MIN_TEXT_RATIO) { + return false; + } + + $highByteRatio = $highBytes / $length; + if ($highByteRatio > self::PETSCII_HEURISTIC_MAX_HIGH_BYTE_RATIO) { + return false; } - return false; + return true; } } diff --git a/src/Version.php b/src/Version.php index de1b7c75..46ed5460 100644 --- a/src/Version.php +++ b/src/Version.php @@ -31,7 +31,7 @@ class Version * This should be updated when releasing new versions. * Format: MAJOR.MINOR.PATCH */ - private const VERSION = '1.8.6'; + private const VERSION = '1.8.7'; /** * Get the current application version diff --git a/tests/Unit/ArtFormatDetectorTest.php b/tests/Unit/ArtFormatDetectorTest.php new file mode 100644 index 00000000..7edd592d --- /dev/null +++ b/tests/Unit/ArtFormatDetectorTest.php @@ -0,0 +1,35 @@ +assertSame('petscii', ArtFormatDetector::detectArtFormat($body, 'PETSCII')); + } + + public function testAnsiSequencesAreDetected(): void + { + $body = "\x1b[31mRED\x1b[0m\n"; + + $this->assertSame('ansi', ArtFormatDetector::detectArtFormat($body, null)); + } + + public function testUnknownEightBitTextIsNotMisclassifiedAsPetscii(): void + { + $body = "Files arrived at Zruspa's BBS\n" + . "Area 957HELP\n" + . "\x91\x94\xe1\xef\xf0\x9d\xee\xeb\xea\xae\xbf\x80\x81\x82\n" + . "Orig: 2:5053/51\n" + . "From: 2:5020/1042\n"; + + $this->assertNull(ArtFormatDetector::detectArtFormat($body, null)); + } +} From 845a2fd3c5e736da257a04b9d0699d9be22ec5b5 Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 13 Mar 2026 21:09:28 -0700 Subject: [PATCH 002/246] Show C64 badge in message list for PETSCII messages Add art_format to echomail list queries so the JS can detect PETSCII messages, and display a small C64 badge beside the envelope icon in both the echomail and netmail message lists. Co-Authored-By: Claude Sonnet 4.6 --- public_html/js/echomail.js | 3 ++- public_html/js/netmail.js | 2 +- src/MessageHandler.php | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/public_html/js/echomail.js b/public_html/js/echomail.js index 8fe84bab..dc0a9b96 100644 --- a/public_html/js/echomail.js +++ b/public_html/js/echomail.js @@ -472,6 +472,7 @@ function displayMessages(messages, isThreaded = false) { const isSaved = msg.is_saved == 1; const readClass = isRead ? 'read' : 'unread'; const readIcon = isRead ? `` : ``; + const petsciiIcon = msg.art_format === 'petscii' ? `C64` : ''; const shareIcon = isShared ? `` : ''; const saveIcon = ` - ${threadIcon}${readIcon}${shareIcon}${saveIcon}${escapeHtml(msg.from_name)} + ${threadIcon}${readIcon}${petsciiIcon}${shareIcon}${saveIcon}${escapeHtml(msg.from_name)} ${!currentEchoarea ? `
diff --git a/public_html/js/netmail.js b/public_html/js/netmail.js index 1f2cb230..306a0fb9 100644 --- a/public_html/js/netmail.js +++ b/public_html/js/netmail.js @@ -282,7 +282,7 @@ function displayMessages(messages, isThreaded = false) {
- ${isUnread ? `` : ``}${threadIcon}${escapeHtml(isSent ? `${uiT('ui.common.to_label', 'To:')} ` + msg.to_name : msg.from_name)} + ${isUnread ? `` : ``}${msg.art_format === 'petscii' ? `C64` : ''}${threadIcon}${escapeHtml(isSent ? `${uiT('ui.common.to_label', 'To:')} ` + msg.to_name : msg.from_name)}
diff --git a/src/MessageHandler.php b/src/MessageHandler.php index 99a22158..9920ae2e 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -297,6 +297,7 @@ public function getEchomail($echoareaTag = null, $domain = null, $page = 1, $lim em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -342,6 +343,7 @@ public function getEchomail($echoareaTag = null, $domain = null, $page = 1, $lim em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved From 2501710fbea21e31789fe707e44d23d5db2c9ab9 Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 13 Mar 2026 21:12:49 -0700 Subject: [PATCH 003/246] Bump service worker cache version for echomail/netmail JS changes Co-Authored-By: Claude Sonnet 4.6 --- public_html/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public_html/sw.js b/public_html/sw.js index c8bedcf3..8e9284e5 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v220'; +const CACHE_NAME = 'binkcache-v221'; // Static assets to precache const staticAssets = [ From 965d5ea2225f33e315019998d3a87dcdb424c67a Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 13 Mar 2026 21:21:50 -0700 Subject: [PATCH 004/246] Add art_format to all echomail list queries The C64 PETSCII badge was only visible from the two non-threaded flat list queries. All remaining list queries (threaded view, subscribed areas, thread context loading, child/parent loading) were also missing art_format from their SELECT clause. Co-Authored-By: Claude Sonnet 4.6 --- src/MessageHandler.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/MessageHandler.php b/src/MessageHandler.php index 9920ae2e..887bfa21 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -497,6 +497,7 @@ public function getEchomailFromSubscribedAreas($userId, $page = 1, $limit = null em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -3368,6 +3369,7 @@ private function ensureCompleteThreadContext($messages, $userId) em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -3401,6 +3403,7 @@ private function ensureCompleteThreadContext($messages, $userId) em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -3687,6 +3690,7 @@ private function getThreadedEchomailFromSubscribedAreas($userId, $page = 1, $lim em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -3879,6 +3883,7 @@ public function getThreadedEchomail($echoareaTag = null, $domain = null, $page = em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -3908,6 +3913,7 @@ public function getThreadedEchomail($echoareaTag = null, $domain = null, $page = em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -4007,6 +4013,7 @@ private function loadThreadChildren($rootMessages, $userId) em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -4424,6 +4431,7 @@ private function getThreadContextForMessages($pageMessages, $userId, $echoareaId em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved @@ -4464,6 +4472,7 @@ private function getThreadContextForMessages($pageMessages, $userId, $echoareaId em.subject, em.date_received, em.date_written, em.echoarea_id, em.message_id, em.reply_to_id, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, CASE WHEN mrs.read_at IS NOT NULL THEN 1 ELSE 0 END as is_read, CASE WHEN sm.id IS NOT NULL THEN 1 ELSE 0 END as is_shared, CASE WHEN sav.id IS NOT NULL THEN 1 ELSE 0 END as is_saved From f0932eb9dc0d3ec5b93df23416999fea150787e0 Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 13 Mar 2026 22:26:53 -0700 Subject: [PATCH 005/246] Add message artwork encoding editor for echomail/netmail Sysops can now edit art_format and message_charset on any echomail message directly from the message reader. Netmail senders/receivers can do the same for their own messages. Edit button (pencil icon) lives in the message header toolbar alongside prev/next navigation. Also adds a C64 badge in message lists for PETSCII-detected messages, fixes art_format missing from echomail list queries in MessageHandler, and updates i18n keys, API routes, and UPGRADING_1.8.7 docs. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + config/i18n/en/common.php | 12 ++++ config/i18n/en/errors.php | 5 ++ config/i18n/es/common.php | 12 ++++ config/i18n/es/errors.php | 5 ++ docs/UPGRADING_1.8.7.md | 31 +++++++++- public_html/js/echomail.js | 55 +++++++++++++++++ public_html/js/netmail.js | 51 ++++++++++++++++ public_html/sw.js | 2 +- routes/api-routes.php | 121 +++++++++++++++++++++++++++++++++++++ templates/echomail.twig | 80 ++++++++++++++++++++++++ templates/netmail.twig | 75 +++++++++++++++++++++++ 12 files changed, 448 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a334343..79f2d9f0 100644 --- a/README.md +++ b/README.md @@ -579,6 +579,7 @@ Individual versions with specific upgrade documentation: | Version | Date | Highlights | |----------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [1.8.7](docs/UPGRADING_1.8.7.md) | Mar 2026 | In-browser message artwork encoding editor (echomail for sysops, netmail for sender/receiver); fixed false-positive PETSCII detection on import; C64 badge in message lists | | [1.8.6](docs/UPGRADING_1.8.6.md) | Mar 2026 | i18n/localization, SSH daemon, file areas terminal, ZMODEM, telnet ANSI auto-detect, echomail/netmail reader keyboard shortcuts | | [1.8.5](docs/UPGRADING_1.8.5.md) | Mar 4 2026 | Native doors (PTY), StyleCodes rendering, LSC-001 Draft 2 MARKUP kludge, markup format composer selector, allow_markup uplink config key | | [1.8.4](docs/UPGRADING_1.8.4.md) | Mar 1 2026 | Username/real name cross-collision check, MRC room list fix, collapsible compose sidebar, echolist new-tab support | diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index a990fce0..67338377 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -2571,6 +2571,18 @@ 'ui.echomail.shortcuts.help' => 'Show / hide keyboard shortcuts', 'ui.echomail.shortcuts.close' => 'Close message', 'ui.echomail.shortcuts.dismiss' => 'Press ? or H to close this help', + 'ui.echomail.edit_message' => 'Edit Message', + 'ui.echomail.edit_message_saved' => 'Changes saved.', + 'ui.echomail.art_format' => 'Art Format', + 'ui.echomail.art_format_auto' => 'Auto-detect', + 'ui.echomail.art_format_plain' => 'Plain text', + 'ui.echomail.art_format_ansi' => 'ANSI', + 'ui.echomail.art_format_amiga_ansi' => 'Amiga ANSI', + 'ui.echomail.art_format_petscii' => 'PETSCII / C64', + 'ui.echomail.message_charset' => 'Art Encoding', + 'ui.echomail.message_charset_help' => 'Character encoding of the raw art bytes. Only affects rendering of ANSI/PETSCII art. Leave blank to clear.', + 'ui.common.db_id' => 'DB ID', + 'ui.common.message_id' => 'Message ID', // Admin subscriptions 'ui.admin_subscriptions.page_title' => 'Admin: Manage Subscriptions', diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index 12d66568..5b488965 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -55,6 +55,11 @@ 'errors.messages.echomail.bulk_delete.user_not_found' => 'User not found', 'errors.messages.echomail.stats.subscription_required' => 'Subscription required for this echo area', 'errors.messages.echomail.not_found' => 'Message not found', + 'errors.messages.echomail.edit.admin_required' => 'Admin privileges are required', + 'errors.messages.echomail.edit.invalid_art_format' => 'Invalid art format value', + 'errors.messages.echomail.edit.nothing_to_update' => 'No fields to update', + 'errors.messages.echomail.edit.save_failed' => 'Failed to save changes', + 'errors.messages.netmail.edit.forbidden' => 'You do not have permission to edit this message', 'errors.messages.netmail.attachment.no_file' => 'No attachment uploaded', 'errors.messages.netmail.attachment.upload_error' => 'Attachment upload failed', 'errors.messages.netmail.attachment.too_large' => 'Attachment exceeds maximum allowed size', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index 77a97c8f..fdb83730 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -2571,6 +2571,18 @@ 'ui.echomail.shortcuts.help' => 'Mostrar / ocultar atajos de teclado', 'ui.echomail.shortcuts.close' => 'Cerrar mensaje', 'ui.echomail.shortcuts.dismiss' => 'Presione ? o H para cerrar esta ayuda', + 'ui.echomail.edit_message' => 'Editar mensaje', + 'ui.echomail.edit_message_saved' => 'Cambios guardados.', + 'ui.echomail.art_format' => 'Formato de arte', + 'ui.echomail.art_format_auto' => 'Deteccion automatica', + 'ui.echomail.art_format_plain' => 'Texto plano', + 'ui.echomail.art_format_ansi' => 'ANSI', + 'ui.echomail.art_format_amiga_ansi' => 'ANSI Amiga', + 'ui.echomail.art_format_petscii' => 'PETSCII / C64', + 'ui.echomail.message_charset' => 'Codificacion de arte', + 'ui.echomail.message_charset_help' => 'Codificacion de caracteres de los bytes de arte sin procesar. Solo afecta la representacion de arte ANSI/PETSCII. Dejar en blanco para borrar.', + 'ui.common.db_id' => 'ID de base de datos', + 'ui.common.message_id' => 'ID de mensaje', // Admin subscriptions 'ui.admin_subscriptions.page_title' => 'Admin: Gestionar suscripciones', diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index 20c8cf00..e64bf00f 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -55,6 +55,11 @@ 'errors.messages.echomail.bulk_delete.user_not_found' => 'Usuario no encontrado', 'errors.messages.echomail.stats.subscription_required' => 'Se requiere suscripcion para esta area de eco', 'errors.messages.echomail.not_found' => 'Mensaje no encontrado', + 'errors.messages.echomail.edit.admin_required' => 'Se requieren privilegios de administrador', + 'errors.messages.echomail.edit.invalid_art_format' => 'Formato de arte no valido', + 'errors.messages.echomail.edit.nothing_to_update' => 'No hay campos para actualizar', + 'errors.messages.echomail.edit.save_failed' => 'Error al guardar los cambios', + 'errors.messages.netmail.edit.forbidden' => 'No tienes permiso para editar este mensaje', 'errors.messages.netmail.attachment.no_file' => 'No se subio ningun archivo adjunto', 'errors.messages.netmail.attachment.upload_error' => 'La carga del archivo adjunto fallo', 'errors.messages.netmail.attachment.too_large' => 'El archivo adjunto excede el tamano maximo permitido', diff --git a/docs/UPGRADING_1.8.7.md b/docs/UPGRADING_1.8.7.md index 00e884c0..9b125d17 100644 --- a/docs/UPGRADING_1.8.7.md +++ b/docs/UPGRADING_1.8.7.md @@ -5,6 +5,7 @@ ## Table of Contents - [Summary of Changes](#summary-of-changes) +- [Message Artwork Encoding Editor](#message-artwork-encoding-editor) - [Echomail Art Format Detection](#echomail-art-format-detection) - [Existing Misdetected Messages](#existing-misdetected-messages) - [psql Instructions](#psql-instructions) @@ -15,7 +16,33 @@ ## Summary of Changes -1.8.7 is a maintenance release. +- Sysops can now edit artwork encoding metadata on any echomail message directly + from the message reader — no more manual SQL updates for misdetected art format + or encoding. +- Netmail senders and receivers can similarly correct artwork encoding on their + own messages. +- Fixed a false-positive PETSCII detection bug on import. + +## Message Artwork Encoding Editor + +The message reader now includes an **Edit** button (pencil icon) in the message +header toolbar. This lets you correct artwork rendering metadata that was +auto-detected incorrectly at import time, without touching the database manually. + +**Who can use it:** +- **Echomail** — sysops (admin users) only. +- **Netmail** — the sender or receiver of the message. + +**What you can change:** +- **Art Format** — override the detected artwork type (`Auto`, `Plain Text`, + `ANSI`, `Amiga ANSI`, or `PETSCII / C64`). Setting it to `Auto` clears the + stored override and lets the renderer decide. +- **Art Encoding** — the raw byte encoding used when rendering artwork + (e.g. `CP437`, `PETSCII`, `UTF-8`). Leave blank for the default. + +This is the **preferred way** to fix misdetected messages going forward. The SQL +approach below remains available for bulk corrections or when direct database +access is more convenient. ## Echomail Art Format Detection @@ -97,6 +124,8 @@ Then exit `psql`: - This release does not add a schema migration. - Resetting these columns only affects rendering hints stored in the database. - It does not alter the message body text itself. +- For individual messages the in-browser editor (see above) is easier and safer + than direct SQL. Use the SQL approach for bulk resets or scripted corrections. ## Upgrade Instructions diff --git a/public_html/js/echomail.js b/public_html/js/echomail.js index dc0a9b96..9bde7b33 100644 --- a/public_html/js/echomail.js +++ b/public_html/js/echomail.js @@ -1835,6 +1835,61 @@ function navigateMessage(direction) { } +// Edit message (admin) +function openEditMessage() { + if (!window.isAdmin || !currentMessageData) return; + const msg = currentMessageData; + + const dbId = currentMessageId || msg.id; + $('#editMessageModalTitle').html(`${uiT('ui.echomail.edit_message', 'Edit Message')} #${dbId}`); + $('#editMsgDbId').text(dbId); + $('#editMsgId').text(msg.message_id || ''); + $('#editMsgDate').text(formatFullDate(msg.date_written)); + $('#editMsgFrom').text((msg.from_name || '') + (msg.from_address ? ' <' + msg.from_address + '>' : '')); + $('#editMsgSubject').text(msg.subject || ''); + $('#editArtFormat').val(msg.art_format || ''); + $('#editCharset').val(msg.message_charset || ''); + $('#editMessageError').addClass('d-none'); + $('#editMessageSuccess').addClass('d-none'); + $('#saveEditMessageBtn').prop('disabled', false); + + $('#editMessageModal').modal('show'); +} + +function saveEditMessage() { + if (!window.isAdmin || !currentMessageData) return; + + const artFormat = $('#editArtFormat').val(); + const charset = $('#editCharset').val().trim(); + + $('#editMessageError').addClass('d-none'); + $('#editMessageSuccess').addClass('d-none'); + $('#saveEditMessageBtn').prop('disabled', true); + + $.ajax({ + url: `/api/messages/echomail/${currentMessageId}/edit`, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ art_format: artFormat, message_charset: charset }), + }).done(function() { + // Update local cached data so the list badge reflects the change immediately + currentMessageData.art_format = artFormat || null; + currentMessageData.message_charset = charset || null; + const listMsg = currentMessages.find(m => m.id == currentMessageId); + if (listMsg) { + listMsg.art_format = artFormat || null; + } + // Refresh the list row + displayMessages(currentMessages, currentMessages.some(m => m.thread_level > 0)); + $('#editMessageSuccess').removeClass('d-none'); + $('#saveEditMessageBtn').prop('disabled', false); + }).fail(function(xhr) { + const payload = xhr.responseJSON || {}; + $('#editMessageError').text(window.getApiErrorMessage ? window.getApiErrorMessage(payload, uiT('errors.messages.echomail.edit.save_failed', 'Failed to save changes')) : (payload.error || uiT('errors.messages.echomail.edit.save_failed', 'Failed to save changes'))).removeClass('d-none'); + $('#saveEditMessageBtn').prop('disabled', false); + }); +} + // Sharing functionality function showShareDialog(messageId) { currentMessageId = messageId; diff --git a/public_html/js/netmail.js b/public_html/js/netmail.js index 306a0fb9..59f059dd 100644 --- a/public_html/js/netmail.js +++ b/public_html/js/netmail.js @@ -695,8 +695,59 @@ function renderMessageContent(message, parsedMessage, isSent, isInAddressBook) { $('#deleteButton').show().off('click').on('click', function() { deleteMessage(currentMessageId); }); + + // Edit button is always shown — getMessage already enforces sender/receiver access +} + +function openEditMessage() { + if (!currentMessageData) return; + const msg = currentMessageData; + + $('#editMessageModalTitle').html(`${uiT('ui.echomail.edit_message', 'Edit Message')} #${currentMessageId}`); + $('#editMsgDbId').text(currentMessageId); + $('#editMsgId').text(msg.message_id || ''); + $('#editMsgDate').text(formatFullDate(msg.date_written)); + $('#editMsgFrom').text((msg.from_name || '') + (msg.from_address ? ' <' + msg.from_address + '>' : '')); + $('#editMsgSubject').text(msg.subject || ''); + $('#editArtFormat').val(msg.art_format || ''); + $('#editCharset').val(msg.message_charset || ''); + $('#editMessageError').addClass('d-none'); + $('#editMessageSuccess').addClass('d-none'); + $('#saveEditMessageBtn').prop('disabled', false); + + $('#editMessageModal').modal('show'); } +function saveEditMessage() { + if (!currentMessageData) return; + + const artFormat = $('#editArtFormat').val(); + const charset = $('#editCharset').val().trim(); + + $('#editMessageError').addClass('d-none'); + $('#editMessageSuccess').addClass('d-none'); + $('#saveEditMessageBtn').prop('disabled', true); + + $.ajax({ + url: `/api/messages/netmail/${currentMessageId}/edit`, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ art_format: artFormat, message_charset: charset }), + }).done(function() { + currentMessageData.art_format = artFormat || null; + currentMessageData.message_charset = charset || null; + const listMsg = currentMessages.find(m => m.id == currentMessageId); + if (listMsg) { + listMsg.art_format = artFormat || null; + } + $('#editMessageSuccess').removeClass('d-none'); + $('#saveEditMessageBtn').prop('disabled', false); + }).fail(function(xhr) { + const payload = xhr.responseJSON || {}; + $('#editMessageError').text(window.getApiErrorMessage ? window.getApiErrorMessage(payload, uiT('errors.messages.echomail.edit.save_failed', 'Failed to save changes')) : (payload.error || uiT('errors.messages.echomail.edit.save_failed', 'Failed to save changes'))).removeClass('d-none'); + $('#saveEditMessageBtn').prop('disabled', false); + }); +} function composeMessage(type, replyToId = null) { window.location.href = `/compose/netmail${replyToId ? '?reply=' + replyToId : ''}`; diff --git a/public_html/sw.js b/public_html/sw.js index 8e9284e5..5b0fd5f4 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v221'; +const CACHE_NAME = 'binkcache-v223'; // Static assets to precache const staticAssets = [ diff --git a/routes/api-routes.php b/routes/api-routes.php index b5f1beda..fd38f98a 100644 --- a/routes/api-routes.php +++ b/routes/api-routes.php @@ -2884,6 +2884,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $message['attachments'] = []; } + $message['can_edit'] = ((int)($message['user_id'] ?? 0) === (int)$userId); echo json_encode($message); } else { http_response_code(404); @@ -2960,6 +2961,69 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array exit; })->where(['id' => '[0-9]+']); + // Netmail message meta edit endpoint (sender or receiver only) + SimpleRouter::post('/messages/netmail/{id}/edit', function($id) { + $user = RouteHelper::requireAuth(); + + header('Content-Type: application/json'); + + $userId = $user['user_id'] ?? $user['id'] ?? null; + $db = Database::getInstance()->getPdo(); + + // Verify the message exists and belongs to the current user + $stmt = $db->prepare('SELECT user_id FROM netmail WHERE id = ?'); + $stmt->execute([(int)$id]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$row) { + http_response_code(404); + apiError('errors.messages.netmail.not_found', apiLocalizedText('errors.messages.netmail.not_found', 'Message not found', $user)); + return; + } + + if ((int)$row['user_id'] !== (int)$userId && empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.messages.netmail.edit.forbidden', apiLocalizedText('errors.messages.netmail.edit.forbidden', 'You do not have permission to edit this message', $user)); + return; + } + + $input = json_decode(file_get_contents('php://input'), true) ?? []; + + $validArtFormats = ['', 'ansi', 'amiga_ansi', 'petscii', 'plain']; + $artFormat = isset($input['art_format']) ? strtolower(trim((string)$input['art_format'])) : null; + $charset = isset($input['message_charset']) ? strtoupper(trim((string)$input['message_charset'])) : null; + + if ($artFormat !== null && !in_array($artFormat, $validArtFormats, true)) { + http_response_code(400); + apiError('errors.messages.echomail.edit.invalid_art_format', apiLocalizedText('errors.messages.echomail.edit.invalid_art_format', 'Invalid art format', $user)); + return; + } + + $setClauses = []; + $params = []; + + if ($artFormat !== null) { + $setClauses[] = 'art_format = ?'; + $params[] = $artFormat === '' ? null : $artFormat; + } + if ($charset !== null) { + $setClauses[] = 'message_charset = ?'; + $params[] = $charset === '' ? null : $charset; + } + + if (empty($setClauses)) { + http_response_code(400); + apiError('errors.messages.echomail.edit.nothing_to_update', apiLocalizedText('errors.messages.echomail.edit.nothing_to_update', 'No fields to update', $user)); + return; + } + + $params[] = (int)$id; + $stmt = $db->prepare('UPDATE netmail SET ' . implode(', ', $setClauses) . ' WHERE id = ?'); + $stmt->execute($params); + + echo json_encode(['success' => true]); + })->where(['id' => '[0-9]+']); + SimpleRouter::post('/messages/netmail/bulk-delete', function() { $user = RouteHelper::requireAuth(); @@ -3430,6 +3494,63 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array echo $content; })->where(['id' => '[0-9]+']); + // Echomail message meta edit endpoint (admin only) + SimpleRouter::post('/messages/echomail/{id}/edit', function($id) { + $user = RouteHelper::requireAuth(); + + header('Content-Type: application/json'); + + if (empty($user['is_admin'])) { + http_response_code(403); + apiError('errors.messages.echomail.edit.admin_required', apiLocalizedText('errors.messages.echomail.edit.admin_required', 'Admin access required', $user)); + return; + } + + $db = Database::getInstance()->getPdo(); + $input = json_decode(file_get_contents('php://input'), true) ?? []; + + $validArtFormats = ['', 'ansi', 'amiga_ansi', 'petscii', 'plain']; + $artFormat = isset($input['art_format']) ? strtolower(trim((string)$input['art_format'])) : null; + $charset = isset($input['message_charset']) ? strtoupper(trim((string)$input['message_charset'])) : null; + + if ($artFormat !== null && !in_array($artFormat, $validArtFormats, true)) { + http_response_code(400); + apiError('errors.messages.echomail.edit.invalid_art_format', apiLocalizedText('errors.messages.echomail.edit.invalid_art_format', 'Invalid art format', $user)); + return; + } + + // Build update + $setClauses = []; + $params = []; + + if ($artFormat !== null) { + $setClauses[] = 'art_format = ?'; + $params[] = $artFormat === '' ? null : $artFormat; + } + if ($charset !== null) { + $setClauses[] = 'message_charset = ?'; + $params[] = $charset === '' ? null : $charset; + } + + if (empty($setClauses)) { + http_response_code(400); + apiError('errors.messages.echomail.edit.nothing_to_update', apiLocalizedText('errors.messages.echomail.edit.nothing_to_update', 'No fields to update', $user)); + return; + } + + $params[] = (int)$id; + $stmt = $db->prepare('UPDATE echomail SET ' . implode(', ', $setClauses) . ' WHERE id = ?'); + $stmt->execute($params); + + if ($stmt->rowCount() === 0) { + http_response_code(404); + apiError('errors.messages.echomail.not_found', apiLocalizedText('errors.messages.echomail.not_found', 'Message not found', $user)); + return; + } + + echo json_encode(['success' => true]); + })->where(['id' => '[0-9]+']); + SimpleRouter::get('/messages/echomail/{echoarea}', function($echoarea) { $user = RouteHelper::requireAuth(); diff --git a/templates/echomail.twig b/templates/echomail.twig index 759d4f56..065180e1 100644 --- a/templates/echomail.twig +++ b/templates/echomail.twig @@ -274,6 +274,11 @@ + {% if current_user.is_admin %} + + {% endif %} + + + + + + +{% endif %} {% endblock %} {% block styles %} diff --git a/templates/netmail.twig b/templates/netmail.twig index 23b79a6c..545bdae7 100644 --- a/templates/netmail.twig +++ b/templates/netmail.twig @@ -153,6 +153,9 @@ + + + + + + + {% endblock %} {% block scripts %} From f4d7f9a0e43e9d4e88fdfbb1b3d701695e8e0027 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 09:10:09 -0700 Subject: [PATCH 006/246] Add advanced message search with date range and trigram indexes - Advanced Search modal on echomail and netmail with per-field filters (poster name, subject, body) and date range picker; fields ANDed together - Simple search bar unchanged - Backend: field-specific ILIKE conditions built separately from general q= search; date range computed in PHP to avoid PDO/pgsql ::cast issues - Performance: derive echoarea and filter counts from already-fetched results instead of running two additional full-table ILIKE scans - Add trigram GIN indexes (pg_trgm) on echomail/netmail subject and message_text for fast substring search - Add index on echomail(date_received) for date range filtering - Fix search returning blank response caused by bytea raw_message_bytes column being included in SELECT em.* and returned as unserializable PHP resource - Fix stale message list showing after search failure (fail handler now clears the container) - Update UPGRADING_1.8.7.md with search and reindex notes Co-Authored-By: Claude Sonnet 4.6 --- config/i18n/en/common.php | 13 + config/i18n/es/common.php | 13 + ...1.11.0.11_echomail_date_received_index.sql | 5 + .../v1.11.0.12_trigram_search_indexes.sql | 11 + docs/UPGRADING_1.8.7.md | 28 ++ public_html/js/echomail.js | 93 ++++++- public_html/js/netmail.js | 67 ++++- public_html/sw.js | 2 +- routes/api-routes.php | 76 +++++- src/MessageHandler.php | 246 +++++++++++++++--- templates/echomail.twig | 55 +++- templates/netmail.twig | 55 +++- 12 files changed, 616 insertions(+), 48 deletions(-) create mode 100644 database/migrations/v1.11.0.11_echomail_date_received_index.sql create mode 100644 database/migrations/v1.11.0.12_trigram_search_indexes.sql diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index 67338377..18406030 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -32,6 +32,19 @@ 'ui.common.import' => 'Import', 'ui.common.search_placeholder' => 'Search...', 'ui.common.search_messages_placeholder' => 'Search messages...', + 'ui.common.advanced_search' => 'Advanced Search', + 'ui.common.advanced_search.title' => 'Advanced Search', + 'ui.common.advanced_search.from_name' => 'From / Poster Name', + 'ui.common.advanced_search.from_name_placeholder' => 'Search by poster...', + 'ui.common.advanced_search.subject' => 'Subject', + 'ui.common.advanced_search.subject_placeholder' => 'Search by subject...', + 'ui.common.advanced_search.body' => 'Message Body', + 'ui.common.advanced_search.body_placeholder' => 'Search in message body...', + 'ui.common.advanced_search.help' => 'Fill in one or more fields. Fields are combined with AND logic.', + 'ui.common.advanced_search.date_from' => 'Date From', + 'ui.common.advanced_search.date_to' => 'Date To', + 'ui.common.advanced_search.date_range' => 'Date Range', + 'ui.common.advanced_search.fill_one_field' => 'Please fill in at least one field (minimum 2 characters for text fields).', 'ui.common.all' => 'All', 'ui.common.all_messages' => 'All Messages', 'ui.common.unread' => 'Unread', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index fdb83730..f5f17585 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -32,6 +32,19 @@ 'ui.common.import' => 'Importar', 'ui.common.search_placeholder' => 'Buscar...', 'ui.common.search_messages_placeholder' => 'Buscar mensajes...', + 'ui.common.advanced_search' => 'Búsqueda avanzada', + 'ui.common.advanced_search.title' => 'Búsqueda avanzada', + 'ui.common.advanced_search.from_name' => 'De / Nombre del autor', + 'ui.common.advanced_search.from_name_placeholder' => 'Buscar por autor...', + 'ui.common.advanced_search.subject' => 'Asunto', + 'ui.common.advanced_search.subject_placeholder' => 'Buscar por asunto...', + 'ui.common.advanced_search.body' => 'Cuerpo del mensaje', + 'ui.common.advanced_search.body_placeholder' => 'Buscar en el cuerpo del mensaje...', + 'ui.common.advanced_search.help' => 'Complete uno o más campos. Los campos se combinan con lógica AND.', + 'ui.common.advanced_search.date_from' => 'Fecha desde', + 'ui.common.advanced_search.date_to' => 'Fecha hasta', + 'ui.common.advanced_search.date_range' => 'Rango de fechas', + 'ui.common.advanced_search.fill_one_field' => 'Por favor complete al menos un campo (mínimo 2 caracteres para campos de texto).', 'ui.common.all' => 'Todos', 'ui.common.all_messages' => 'Todos los mensajes', 'ui.common.unread' => 'No leidos', diff --git a/database/migrations/v1.11.0.11_echomail_date_received_index.sql b/database/migrations/v1.11.0.11_echomail_date_received_index.sql new file mode 100644 index 00000000..cfc1ee96 --- /dev/null +++ b/database/migrations/v1.11.0.11_echomail_date_received_index.sql @@ -0,0 +1,5 @@ +-- v1.11.0.11 - Add index on echomail(date_received) for date range search performance +-- date_written already has an index; date_received did not, causing full table scans +-- when ECHOMAIL_ORDER_DATE is set to 'received' (the default). + +CREATE INDEX IF NOT EXISTS idx_echomail_date_received ON echomail(date_received); diff --git a/database/migrations/v1.11.0.12_trigram_search_indexes.sql b/database/migrations/v1.11.0.12_trigram_search_indexes.sql new file mode 100644 index 00000000..504c0787 --- /dev/null +++ b/database/migrations/v1.11.0.12_trigram_search_indexes.sql @@ -0,0 +1,11 @@ +-- v1.11.0.12 - Add trigram GIN indexes for fast ILIKE search on message text and subject +-- Without these, ILIKE '%term%' does a full sequential table scan. +-- pg_trgm lets PostgreSQL use a GIN index for arbitrary substring/ILIKE queries. +-- Initial index build may take a moment on large tables. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS idx_echomail_subject_trgm ON echomail USING GIN (subject gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_echomail_body_trgm ON echomail USING GIN (message_text gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_netmail_subject_trgm ON netmail USING GIN (subject gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_netmail_body_trgm ON netmail USING GIN (message_text gin_trgm_ops); diff --git a/docs/UPGRADING_1.8.7.md b/docs/UPGRADING_1.8.7.md index 9b125d17..77eeb647 100644 --- a/docs/UPGRADING_1.8.7.md +++ b/docs/UPGRADING_1.8.7.md @@ -5,6 +5,8 @@ ## Table of Contents - [Summary of Changes](#summary-of-changes) +- [Enhanced Message Search](#enhanced-message-search) + - [Search Reindexing](#search-reindexing) - [Message Artwork Encoding Editor](#message-artwork-encoding-editor) - [Echomail Art Format Detection](#echomail-art-format-detection) - [Existing Misdetected Messages](#existing-misdetected-messages) @@ -16,6 +18,10 @@ ## Summary of Changes +- Added advanced message search with per-field filtering (poster name, subject, + message body) and date range support for both echomail and netmail. +- Search performance significantly improved via trigram GIN indexes on subject + and message body columns. - Sysops can now edit artwork encoding metadata on any echomail message directly from the message reader — no more manual SQL updates for misdetected art format or encoding. @@ -23,6 +29,28 @@ own messages. - Fixed a false-positive PETSCII detection bug on import. +## Enhanced Message Search + +The search sidebar now includes an **Advanced Search** button (sliders icon) +that opens a modal with individual fields for poster name, subject, message body, +and a date range picker. Fields are combined with AND logic — fill in only the +ones you need. + +The simple search bar continues to work as before (searches across all fields at +once). + +### Search Reindexing + +This release adds trigram GIN indexes (`pg_trgm`) on the `subject` and +`message_text` columns of both `echomail` and `netmail`. These indexes make +`ILIKE '%term%'` searches fast regardless of table size. + +**`setup.php` will build these indexes automatically, but on large message +databases the process may take a few minutes.** The upgrade will appear to pause +at the migration step — this is normal. Do not interrupt it. + +A date range index on `echomail(date_received)` is also added in this release. + ## Message Artwork Encoding Editor The message reader now includes an **Edit** button (pencil icon) in the message diff --git a/public_html/js/echomail.js b/public_html/js/echomail.js index 9bde7b33..ac2857af 100644 --- a/public_html/js/echomail.js +++ b/public_html/js/echomail.js @@ -1074,7 +1074,8 @@ function searchMessages() { $('#mobileSearchCollapse').collapse('hide'); }) .fail(function() { - showError(uiT('ui.echomail.search.failed', 'Search failed')); + $('#messagesContainer').html('
' + uiT('ui.echomail.search.failed', 'Search failed') + '
'); + $('#pagination').empty(); }); } @@ -1085,6 +1086,96 @@ function searchMessagesFromMobile() { searchMessages(); } +function openAdvancedSearch() { + $('#advSearchFromName').val(''); + $('#advSearchSubject').val(''); + $('#advSearchBody').val(''); + $('#advSearchDateFrom').val(''); + $('#advSearchDateTo').val(''); + $('#advSearchError').addClass('d-none').text(''); + $('#advancedSearchModal').modal('show'); +} + +function runAdvancedSearch() { + const fromName = $('#advSearchFromName').val().trim(); + const subject = $('#advSearchSubject').val().trim(); + const body = $('#advSearchBody').val().trim(); + const dateFrom = $('#advSearchDateFrom').val(); + const dateTo = $('#advSearchDateTo').val(); + + const textFields = [fromName, subject, body].filter(v => v.length > 0); + const hasDate = dateFrom || dateTo; + + // Validate: at least one field filled, and text fields must be 2+ chars each + if (textFields.length === 0 && !hasDate) { + $('#advSearchError') + .removeClass('d-none') + .text(window.t('ui.common.advanced_search.fill_one_field', {}, 'Please fill in at least one field (minimum 2 characters for text fields).')); + return; + } + if (textFields.some(v => v.length < 2)) { + $('#advSearchError') + .removeClass('d-none') + .text(window.t('ui.common.advanced_search.fill_one_field', {}, 'Please fill in at least one field (minimum 2 characters for text fields).')); + return; + } + + $('#advSearchError').addClass('d-none'); + $('#advancedSearchModal').modal('hide'); + showLoading('#messagesContainer'); + + // Collect text search terms for highlighting + currentSearchTerms = [fromName, subject, body] + .filter(v => v.length > 0) + .join(' ') + .toLowerCase() + .split(/\s+/) + .filter(term => term.length > 1); + + const params = new URLSearchParams({ type: 'echomail' }); + if (fromName) params.set('from_name', fromName); + if (subject) params.set('subject', subject); + if (body) params.set('body', body); + if (dateFrom) params.set('date_from', dateFrom); + if (dateTo) params.set('date_to', dateTo); + if (currentEchoarea) params.set('echoarea', currentEchoarea); + + $.get('/api/messages/search?' + params.toString()) + .done(function(data) { + displayMessages(data.messages); + $('#pagination').empty(); + + if (!originalFilterCounts) { + originalFilterCounts = { + all: parseInt($('#allCount').text()) || 0, + unread: parseInt($('#unreadCount').text()) || 0, + read: parseInt($('#readCount').text()) || 0, + tome: parseInt($('#toMeCount').text()) || 0, + saved: parseInt($('#savedCount').text()) || 0, + drafts: parseInt($('#draftsCount').text()) || 0 + }; + } + + if (data.echoarea_counts) { + searchResultCounts = data.echoarea_counts; + isSearchActive = true; + updateEchoareaCountsWithSearchResults(); + showClearSearchButton(); + } + + if (data.filter_counts) { + searchFilterCounts = data.filter_counts; + updateFilterCounts(data.filter_counts); + } + + $('#mobileSearchCollapse').collapse('hide'); + }) + .fail(function() { + $('#messagesContainer').html('
' + uiT('ui.echomail.search.failed', 'Search failed') + '
'); + $('#pagination').empty(); + }); +} + function updateEchoareaCountsWithSearchResults() { if (!searchResultCounts) return; diff --git a/public_html/js/netmail.js b/public_html/js/netmail.js index 59f059dd..c2cfd569 100644 --- a/public_html/js/netmail.js +++ b/public_html/js/netmail.js @@ -788,7 +788,72 @@ function searchMessages() { $('#pagination').empty(); }) .fail(function() { - showError(uiT('ui.netmail.search.failed', 'Search failed')); + $('#messagesContainer').html('
' + uiT('ui.netmail.search.failed', 'Search failed') + '
'); + $('#pagination').empty(); + }); +} + +function openAdvancedSearch() { + $('#advSearchFromName').val(''); + $('#advSearchSubject').val(''); + $('#advSearchBody').val(''); + $('#advSearchDateFrom').val(''); + $('#advSearchDateTo').val(''); + $('#advSearchError').addClass('d-none').text(''); + $('#advancedSearchModal').modal('show'); +} + +function runAdvancedSearch() { + const fromName = $('#advSearchFromName').val().trim(); + const subject = $('#advSearchSubject').val().trim(); + const body = $('#advSearchBody').val().trim(); + const dateFrom = $('#advSearchDateFrom').val(); + const dateTo = $('#advSearchDateTo').val(); + + const textFields = [fromName, subject, body].filter(v => v.length > 0); + const hasDate = dateFrom || dateTo; + + // Validate: at least one field filled, and text fields must be 2+ chars each + if (textFields.length === 0 && !hasDate) { + $('#advSearchError') + .removeClass('d-none') + .text(window.t('ui.common.advanced_search.fill_one_field', {}, 'Please fill in at least one field (minimum 2 characters for text fields).')); + return; + } + if (textFields.some(v => v.length < 2)) { + $('#advSearchError') + .removeClass('d-none') + .text(window.t('ui.common.advanced_search.fill_one_field', {}, 'Please fill in at least one field (minimum 2 characters for text fields).')); + return; + } + + $('#advSearchError').addClass('d-none'); + $('#advancedSearchModal').modal('hide'); + showLoading('#messagesContainer'); + + // Collect text search terms for highlighting + currentSearchTerms = [fromName, subject, body] + .filter(v => v.length > 0) + .join(' ') + .toLowerCase() + .split(/\s+/) + .filter(term => term.length > 1); + + const params = new URLSearchParams({ type: 'netmail' }); + if (fromName) params.set('from_name', fromName); + if (subject) params.set('subject', subject); + if (body) params.set('body', body); + if (dateFrom) params.set('date_from', dateFrom); + if (dateTo) params.set('date_to', dateTo); + + $.get('/api/messages/search?' + params.toString()) + .done(function(data) { + displayMessages(data.messages); + $('#pagination').empty(); + }) + .fail(function() { + $('#messagesContainer').html('
' + uiT('ui.netmail.search.failed', 'Search failed') + '
'); + $('#pagination').empty(); }); } diff --git a/public_html/sw.js b/public_html/sw.js index 5b0fd5f4..b5941353 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v223'; +const CACHE_NAME = 'binkcache-v226'; // Static assets to precache const staticAssets = [ diff --git a/routes/api-routes.php b/routes/api-routes.php index fd38f98a..85ecdd70 100644 --- a/routes/api-routes.php +++ b/routes/api-routes.php @@ -4053,6 +4053,8 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array header('Content-Type: application/json'); + try { + $query = $_GET['q'] ?? ''; $type = $_GET['type'] ?? null; $echoarea = $_GET['echoarea'] ?? null; @@ -4062,7 +4064,41 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $echoarea = urldecode($echoarea); } - if (strlen($query) < 2) { + // Collect field-specific search params + $searchParams = []; + if (!empty($_GET['from_name'])) { + $searchParams['from_name'] = $_GET['from_name']; + } + if (!empty($_GET['subject'])) { + $searchParams['subject'] = $_GET['subject']; + } + if (!empty($_GET['body'])) { + $searchParams['body'] = $_GET['body']; + } + // Date range params — validate YYYY-MM-DD format + foreach (['date_from', 'date_to'] as $dateKey) { + if (!empty($_GET[$dateKey])) { + $val = $_GET[$dateKey]; + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $val)) { + $searchParams[$dateKey] = $val; + } + } + } + + $hasTextParams = !empty($searchParams['from_name']) || !empty($searchParams['subject']) || !empty($searchParams['body']); + $hasDateParams = !empty($searchParams['date_from']) || !empty($searchParams['date_to']); + $hasAdvancedParams = $hasTextParams || $hasDateParams; + + // Validate: need a general query of 2+ chars, or at least one valid text/date field + if ($hasTextParams) { + foreach (['from_name', 'subject', 'body'] as $textKey) { + if (isset($searchParams[$textKey]) && strlen($searchParams[$textKey]) < 2) { + http_response_code(400); + apiError('errors.messages.search.query_too_short', apiLocalizedText('errors.messages.search.query_too_short', 'Search query must be at least 2 characters', $user)); + return; + } + } + } elseif (!$hasDateParams && strlen($query) < 2) { http_response_code(400); apiError('errors.messages.search.query_too_short', apiLocalizedText('errors.messages.search.query_too_short', 'Search query must be at least 2 characters', $user)); return; @@ -4073,21 +4109,47 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array // Handle both 'user_id' and 'id' field names for compatibility $userId = $user['user_id'] ?? $user['id'] ?? null; - $messages = $handler->searchMessages($query, $type, $echoarea, $userId); + $messages = $handler->searchMessages($query, $type, $echoarea, $userId, $searchParams); - // For echomail searches, also get per-echo-area counts and filter counts + // For echomail searches, derive per-echo-area counts from already-fetched results + // and compute filter counts by PK lookup — avoids re-running the expensive search query. $echoareaCounts = []; $filterCounts = []; if ($type === 'echomail' || $type === null) { - $echoareaCounts = $handler->getSearchResultCounts($query, $echoarea, $userId); - $filterCounts = $handler->getSearchFilterCounts($query, $echoarea, $userId); + $countMap = []; + foreach ($messages as $msg) { + $tag = $msg['echoarea'] ?? ''; + $domain = $msg['echoarea_domain'] ?? ''; + $key = "{$tag}@{$domain}"; + if (!isset($countMap[$key])) { + $countMap[$key] = ['tag' => $tag, 'domain' => $domain, 'message_count' => 0]; + } + $countMap[$key]['message_count']++; + } + $echoareaCounts = array_values($countMap); + + $messageIds = array_column($messages, 'id'); + $filterCounts = $handler->getSearchFilterCountsByIds($messageIds, $userId); } - echo json_encode([ + $json = json_encode([ 'messages' => $messages, 'echoarea_counts' => $echoareaCounts, 'filter_counts' => $filterCounts - ]); + ], JSON_INVALID_UTF8_SUBSTITUTE); + + if ($json === false) { + http_response_code(500); + echo json_encode(['error' => 'Failed to encode results: ' . json_last_error_msg()]); + return; + } + + echo $json; + + } catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage(), 'trace' => $e->getFile() . ':' . $e->getLine()]); + } }); // Mark message as read diff --git a/src/MessageHandler.php b/src/MessageHandler.php index 887bfa21..6bab4139 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -1267,26 +1267,117 @@ public function getEchoareas($userId = null, $subscribedOnly = false) return $stmt->fetchAll(); } - public function searchMessages($query, $type = null, $echoarea = null, $userId = null) + /** + * Build a SQL WHERE fragment for text-based message searches. + * Returns [null, []] when no text search terms are present (date-only searches). + * + * @param string $query General search query used when $searchParams has no text fields + * @param array $searchParams Field-specific params: keys 'from_name', 'subject', 'body', 'date_from', 'date_to' + * @param string $tableAlias Table alias prefix, e.g. 'em.' or '' + * @return array [string|null $whereFragment, array $bindParams] + */ + private function buildSearchWhereFragment($query, $searchParams, $tableAlias = '') + { + $conditions = []; + $params = []; + + $hasFieldSearch = !empty($searchParams['from_name']) || !empty($searchParams['subject']) || !empty($searchParams['body']); + + if ($hasFieldSearch) { + if (!empty($searchParams['from_name'])) { + $conditions[] = $tableAlias . 'from_name ILIKE ?'; + $params[] = '%' . $searchParams['from_name'] . '%'; + } + if (!empty($searchParams['subject'])) { + $conditions[] = $tableAlias . 'subject ILIKE ?'; + $params[] = '%' . $searchParams['subject'] . '%'; + } + if (!empty($searchParams['body'])) { + $conditions[] = $tableAlias . 'message_text ILIKE ?'; + $params[] = '%' . $searchParams['body'] . '%'; + } + return ['(' . implode(' AND ', $conditions) . ')', $params]; + } + + if ($query !== '') { + $searchTerm = '%' . $query . '%'; + return [ + '(' . $tableAlias . 'subject ILIKE ? OR ' . $tableAlias . 'message_text ILIKE ? OR ' . $tableAlias . 'from_name ILIKE ?)', + [$searchTerm, $searchTerm, $searchTerm] + ]; + } + + // No text search terms — caller handles date-only searches + return [null, []]; + } + + /** + * Build SQL conditions for date range filtering. + * Date arithmetic is done in PHP to avoid PDO/pgsql issues with ?::cast syntax. + * + * @param array $searchParams Keys 'date_from' and/or 'date_to' (YYYY-MM-DD strings) + * @param string $dateColumn Fully-qualified column name, e.g. 'em.date_received' + * @return array [array $conditions, array $bindParams] + */ + private function buildDateRangeConditions($searchParams, $dateColumn) { - $searchTerm = '%' . $query . '%'; + $conditions = []; + $params = []; + + if (!empty($searchParams['date_from'])) { + $conditions[] = "{$dateColumn} >= ?"; + $params[] = $searchParams['date_from'] . ' 00:00:00'; + } + if (!empty($searchParams['date_to'])) { + // Advance by one day so the range is inclusive of the end date + $dateTo = new \DateTime($searchParams['date_to']); + $dateTo->modify('+1 day'); + $conditions[] = "{$dateColumn} < ?"; + $params[] = $dateTo->format('Y-m-d') . ' 00:00:00'; + } + return [$conditions, $params]; + } + + /** + * Search messages by query or field-specific parameters. + * + * @param string $query General search query (used when $searchParams is empty) + * @param string|null $type 'echomail' or 'netmail' + * @param string|null $echoarea Echo area tag to restrict search + * @param int|null $userId User ID for permission checking + * @param array $searchParams Field-specific search: keys 'from_name', 'subject', 'body', 'date_from', 'date_to' + * @return array + */ + public function searchMessages($query, $type = null, $echoarea = null, $userId = null, $searchParams = []) + { if ($type === 'netmail') { if ($userId === null) { // If no user ID provided, return empty results for privacy return []; } - $stmt = $this->db->prepare(" - SELECT * FROM netmail - WHERE (subject ILIKE ? OR message_text ILIKE ? OR from_name ILIKE ?) - AND user_id = ? - ORDER BY CASE - WHEN date_received > NOW() THEN 0 - ELSE 1 - END, date_received DESC - LIMIT 50 - "); - $stmt->execute([$searchTerm, $searchTerm, $searchTerm, $userId]); + [$whereFragment, $searchBindParams] = $this->buildSearchWhereFragment($query, $searchParams, ''); + [$dateConditions, $dateParams] = $this->buildDateRangeConditions($searchParams, 'date_received'); + + $sql = "SELECT id, from_name, from_address, to_name, to_address, + subject, date_received, date_written, message_id, reply_to_id, + art_format, message_charset, user_id, + deleted_by_sender, deleted_by_recipient + FROM netmail WHERE user_id = ?"; + $params = [$userId]; + + if ($whereFragment !== null) { + $sql .= " AND {$whereFragment}"; + $params = array_merge($params, $searchBindParams); + } + foreach ($dateConditions as $cond) { + $sql .= " AND {$cond}"; + } + $params = array_merge($params, $dateParams); + + $sql .= " ORDER BY CASE WHEN date_received > NOW() THEN 0 ELSE 1 END, date_received DESC LIMIT 50"; + $stmt = $this->db->prepare($sql); + $stmt->execute($params); } else { $dateField = $this->getEchomailDateField(); $isAdmin = false; @@ -1294,14 +1385,30 @@ public function searchMessages($query, $type = null, $echoarea = null, $userId = $user = $this->getUserById($userId); $isAdmin = $user && !empty($user['is_admin']); } + [$whereFragment, $searchBindParams] = $this->buildSearchWhereFragment($query, $searchParams, 'em.'); + [$dateConditions, $dateParams] = $this->buildDateRangeConditions($searchParams, "em.{$dateField}"); + $sql = " - SELECT em.*, ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain + SELECT em.id, em.from_name, em.from_address, em.to_name, + em.subject, em.date_received, em.date_written, em.echoarea_id, + em.message_id, em.reply_to_id, + COALESCE(NULLIF(em.art_format, ''), NULLIF(ea.art_format_hint, '')) as art_format, + ea.tag as echoarea, ea.color as echoarea_color, ea.domain as echoarea_domain FROM echomail em JOIN echoareas ea ON em.echoarea_id = ea.id - WHERE (em.subject ILIKE ? OR em.message_text ILIKE ? OR em.from_name ILIKE ?) + WHERE 1=1 "; + $params = []; + + if ($whereFragment !== null) { + $sql .= " AND {$whereFragment}"; + $params = array_merge($params, $searchBindParams); + } + foreach ($dateConditions as $cond) { + $sql .= " AND {$cond}"; + } + $params = array_merge($params, $dateParams); - $params = [$searchTerm, $searchTerm, $searchTerm]; if (!$isAdmin) { $sql .= " AND COALESCE(ea.is_sysop_only, FALSE) = FALSE"; } @@ -1311,7 +1418,7 @@ public function searchMessages($query, $type = null, $echoarea = null, $userId = $params[] = $echoarea; } - $sql .= " ORDER BY CASE WHEN em.{$dateField} > NOW() THEN 0 ELSE 1 END, em.{$dateField} DESC"; + $sql .= " ORDER BY CASE WHEN em.{$dateField} > NOW() THEN 0 ELSE 1 END, em.{$dateField} DESC LIMIT 200"; $stmt = $this->db->prepare($sql); $stmt->execute($params); @@ -1326,12 +1433,11 @@ public function searchMessages($query, $type = null, $echoarea = null, $userId = * @param string $query The search query * @param string|null $echoarea Optional specific echo area to search within * @param int|null $userId User ID for permission checking + * @param array $searchParams Field-specific search: keys 'from_name', 'subject', 'body', 'date_from', 'date_to' * @return array Array with filter counts */ - public function getSearchFilterCounts($query, $echoarea = null, $userId = null) + public function getSearchFilterCounts($query, $echoarea = null, $userId = null, $searchParams = []) { - $searchTerm = '%' . $query . '%'; - $isAdmin = false; $userRealName = null; if ($userId) { @@ -1340,6 +1446,10 @@ public function getSearchFilterCounts($query, $echoarea = null, $userId = null) $userRealName = $user['real_name'] ?? null; } + $dateField = $this->getEchomailDateField(); + [$whereFragment, $searchBindParams] = $this->buildSearchWhereFragment($query, $searchParams, 'em.'); + [$dateConditions, $dateParams] = $this->buildDateRangeConditions($searchParams, "em.{$dateField}"); + $sql = " SELECT COUNT(*) as all_count, @@ -1355,11 +1465,19 @@ public function getSearchFilterCounts($query, $echoarea = null, $userId = null) LEFT JOIN saved_messages sm ON sm.message_id = em.id AND sm.message_type = 'echomail' AND sm.user_id = ? - WHERE (em.subject ILIKE ? OR em.message_text ILIKE ? OR em.from_name ILIKE ?) - AND ea.is_active = TRUE + WHERE ea.is_active = TRUE "; - $params = [$userRealName, $userId, $userId, $searchTerm, $searchTerm, $searchTerm]; + $params = [$userRealName, $userId, $userId]; + + if ($whereFragment !== null) { + $sql .= " AND {$whereFragment}"; + $params = array_merge($params, $searchBindParams); + } + foreach ($dateConditions as $cond) { + $sql .= " AND {$cond}"; + } + $params = array_merge($params, $dateParams); if (!$isAdmin) { $sql .= " AND COALESCE(ea.is_sysop_only, FALSE) = FALSE"; @@ -1391,18 +1509,35 @@ public function getSearchFilterCounts($query, $echoarea = null, $userId = null) * @param string $query The search query * @param string|null $echoarea Optional specific echo area to search within * @param int|null $userId User ID for permission checking + * @param array $searchParams Field-specific search: keys 'from_name', 'subject', 'body', 'date_from', 'date_to' * @return array Array of echo areas with their search result counts */ - public function getSearchResultCounts($query, $echoarea = null, $userId = null) + public function getSearchResultCounts($query, $echoarea = null, $userId = null, $searchParams = []) { - $searchTerm = '%' . $query . '%'; - $isAdmin = false; if ($userId) { $user = $this->getUserById($userId); $isAdmin = $user && !empty($user['is_admin']); } + $dateField = $this->getEchomailDateField(); + [$whereFragment, $searchBindParams] = $this->buildSearchWhereFragment($query, $searchParams, 'em.'); + [$dateConditions, $dateParams] = $this->buildDateRangeConditions($searchParams, "em.{$dateField}"); + + // Build the ON clause for the LEFT JOIN + $joinConditions = ['em.echoarea_id = ea.id']; + $params = []; + if ($whereFragment !== null) { + $joinConditions[] = $whereFragment; + $params = array_merge($params, $searchBindParams); + } + foreach ($dateConditions as $cond) { + $joinConditions[] = $cond; + } + $params = array_merge($params, $dateParams); + + $joinClause = implode(' AND ', $joinConditions); + $sql = " SELECT ea.id, @@ -1410,13 +1545,10 @@ public function getSearchResultCounts($query, $echoarea = null, $userId = null) ea.domain, COUNT(em.id) as message_count FROM echoareas ea - LEFT JOIN echomail em ON em.echoarea_id = ea.id - AND (em.subject ILIKE ? OR em.message_text ILIKE ? OR em.from_name ILIKE ?) + LEFT JOIN echomail em ON {$joinClause} WHERE ea.is_active = TRUE "; - $params = [$searchTerm, $searchTerm, $searchTerm]; - if (!$isAdmin) { $sql .= " AND COALESCE(ea.is_sysop_only, FALSE) = FALSE"; } @@ -1434,6 +1566,60 @@ public function getSearchResultCounts($query, $echoarea = null, $userId = null) return $stmt->fetchAll(); } + /** + * Get filter counts (all, unread, read, tome, saved) for a specific set of message IDs. + * Much faster than re-running the full search query — used after searchMessages() returns results. + * + * @param array $messageIds Array of echomail IDs already fetched by searchMessages() + * @param int|null $userId + * @return array + */ + public function getSearchFilterCountsByIds($messageIds, $userId) + { + if (empty($messageIds)) { + return ['all' => 0, 'unread' => 0, 'read' => 0, 'tome' => 0, 'saved' => 0, 'drafts' => 0]; + } + + $userRealName = null; + if ($userId) { + $user = $this->getUserById($userId); + $userRealName = $user['real_name'] ?? null; + } + + $placeholders = implode(',', array_fill(0, count($messageIds), '?')); + + $sql = " + SELECT + COUNT(*) as all_count, + COUNT(*) FILTER (WHERE mr.id IS NULL) as unread_count, + COUNT(*) FILTER (WHERE mr.id IS NOT NULL) as read_count, + COUNT(*) FILTER (WHERE em.to_name = ?) as tome_count, + COUNT(*) FILTER (WHERE sm.message_id IS NOT NULL) as saved_count + FROM echomail em + LEFT JOIN message_read_status mr ON mr.message_id = em.id + AND mr.message_type = 'echomail' + AND mr.user_id = ? + LEFT JOIN saved_messages sm ON sm.message_id = em.id + AND sm.message_type = 'echomail' + AND sm.user_id = ? + WHERE em.id IN ({$placeholders}) + "; + + $params = array_merge([$userRealName, $userId, $userId], $messageIds); + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + $result = $stmt->fetch(); + + return [ + 'all' => (int)$result['all_count'], + 'unread' => (int)$result['unread_count'], + 'read' => (int)$result['read_count'], + 'tome' => (int)$result['tome_count'], + 'saved' => (int)$result['saved_count'], + 'drafts' => 0 + ]; + } + private function getUserById($userId) { $stmt = $this->db->prepare("SELECT * FROM users WHERE id = ?"); diff --git a/templates/echomail.twig b/templates/echomail.twig index 065180e1..ada9ad9f 100644 --- a/templates/echomail.twig +++ b/templates/echomail.twig @@ -224,10 +224,15 @@
- +
+ + +
@@ -441,6 +446,48 @@ + + + {% if current_user.is_admin %} @@ -224,6 +229,48 @@ + + + +
+
+
+ {{ t('ui.admin.dashboard.db_size', {}, locale, ['common']) }} +
+ +
@@ -194,17 +204,43 @@
{{ t('ui.admin.dashboard.service_status', {}, locale, ['common']) }}
- {% set services = { + {% set coreServices = { 'admin_daemon': t('ui.admin.dashboard.service.admin_daemon', {}, locale, ['common']), 'binkp_scheduler': t('ui.admin.dashboard.service.binkp_scheduler', {}, locale, ['common']), - 'binkp_server': t('ui.admin.dashboard.service.binkp_server', {}, locale, ['common']), - 'telnetd': t('ui.admin.dashboard.service.telnetd', {}, locale, ['common']) + 'binkp_server': t('ui.admin.dashboard.service.binkp_server', {}, locale, ['common']) } %} - {% for key, label in services %} + {% set optionalServices = { + 'telnetd': t('ui.admin.dashboard.service.telnetd', {}, locale, ['common']), + 'ssh_daemon': t('ui.admin.dashboard.service.ssh_daemon', {}, locale, ['common']), + 'gemini_daemon': t('ui.admin.dashboard.service.gemini_daemon', {}, locale, ['common']), + 'mrc_daemon': t('ui.admin.dashboard.service.mrc_daemon', {}, locale, ['common']), + 'multiplexing_server': t('ui.admin.dashboard.service.multiplexing_server', {}, locale, ['common']) + } %} + {% for key, label in coreServices %} + {% set info = daemon_status[key] ?? null %} +
+
+ {{ label }}: +
+
+ {% if info and info.running %} + {{ t('ui.admin.dashboard.running', {}, locale, ['common']) }} + {% else %} + {{ t('ui.admin.dashboard.stopped', {}, locale, ['common']) }} + {% endif %} + {% if info and info.pid %} + {{ t('ui.admin.dashboard.pid', {}, locale, ['common']) }} {{ info.pid }} + {% endif %} +
+
+
+ {% endfor %} + {% for key, label in optionalServices %} {% set info = daemon_status[key] ?? null %}
{{ label }}: + ({{ t('ui.common.optional', {}, locale, ['common']) }})
{% if info and info.running %} diff --git a/templates/admin/database_stats.twig b/templates/admin/database_stats.twig new file mode 100644 index 00000000..8ef3603c --- /dev/null +++ b/templates/admin/database_stats.twig @@ -0,0 +1,649 @@ +{% extends "base.twig" %} + +{% block title %}{{ t('ui.admin.db_stats.page_title', {}, locale, ['common']) }}{% endblock %} + +{% block content %} +
+ + + + + +
+ + +
+ +
+
+
+
+
+ {{ t('ui.admin.db_stats.db_total_size', {}, locale, ['common']) }} +
+
{{ size.db_size ?? 'N/A' }}
+
+
+
+
+ +
+
+
+
+
{{ t('ui.admin.db_stats.table_sizes', {}, locale, ['common']) }}
+
+
+ {% if size.table_sizes|length > 0 %} +
+ + + + + + + + + + + {% for row in size.table_sizes %} + + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.table', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.total_size', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.table_size', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.index_size', {}, locale, ['common']) }}
{{ row.table_name }}{{ row.total_size }}{{ row.table_size }}{{ row.index_size }}
+
+ {% else %} +

{{ t('ui.admin.db_stats.no_data', {}, locale, ['common']) }}

+ {% endif %} +
+
+
+ +
+
+
+
{{ t('ui.admin.db_stats.index_sizes', {}, locale, ['common']) }}
+
+
+ {% if size.index_sizes|length > 0 %} +
+ + + + + + + + + + {% for row in size.index_sizes %} + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.index', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.table', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.size', {}, locale, ['common']) }}
{{ row.indexname }}{{ row.tablename }}{{ row.index_size }}
+
+ {% else %} +

{{ t('ui.admin.db_stats.no_data', {}, locale, ['common']) }}

+ {% endif %} +
+
+ +
+
+
{{ t('ui.admin.db_stats.bloat', {}, locale, ['common']) }}
+
+
+ {% if size.bloat|length > 0 %} +
+ + + + + + + + + + {% for row in size.bloat %} + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.table', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.dead_tuples', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.dead_pct', {}, locale, ['common']) }}
{{ row.table_name }}{{ row.dead_tuples|number_format }}{{ row.dead_pct }}%
+
+ {% else %} +

{{ t('ui.admin.db_stats.no_bloat', {}, locale, ['common']) }}

+ {% endif %} +
+
+
+
+
+ + +
+ +
+ {% if activity.connections_current is not null %} +
+
+
+
+ {{ t('ui.admin.db_stats.connections', {}, locale, ['common']) }} +
+
+ {{ activity.connections_current }} / {{ activity.connections_max }} +
+
+
+
+ {{ activity.connections_pct }}% {{ t('ui.admin.db_stats.used', {}, locale, ['common']) }} +
+
+
+ {% endif %} + + {% if activity.cache_hit_ratio is not null %} +
+
+
+
+ {{ t('ui.admin.db_stats.cache_hit_ratio', {}, locale, ['common']) }} +
+
{{ activity.cache_hit_ratio }}%
+ {% if activity.cache_hit_ratio < 99 %} + {{ t('ui.admin.db_stats.cache_hit_warning', {}, locale, ['common']) }} + {% endif %} +
+
+
+ {% endif %} + + {% if activity.xact_commit is not null %} +
+
+
+
+ {{ t('ui.admin.db_stats.transactions', {}, locale, ['common']) }} +
+
+ {{ activity.xact_commit|number_format }} {{ t('ui.admin.db_stats.committed', {}, locale, ['common']) }} +
+ {{ activity.xact_rollback|number_format }} {{ t('ui.admin.db_stats.rolled_back', {}, locale, ['common']) }} +
+
+
+ {% endif %} + + {% if activity.tup_inserted is not null %} +
+
+
+
+ {{ t('ui.admin.db_stats.tuples', {}, locale, ['common']) }} +
+
+ {{ t('ui.admin.db_stats.inserted', {}, locale, ['common']) }}: {{ activity.tup_inserted|number_format }}
+ {{ t('ui.admin.db_stats.updated', {}, locale, ['common']) }}: {{ activity.tup_updated|number_format }}
+ {{ t('ui.admin.db_stats.deleted', {}, locale, ['common']) }}: {{ activity.tup_deleted|number_format }} +
+
+
+
+ {% endif %} +
+ + {% if activity.connection_states|length > 0 %} +
+
+
{{ t('ui.admin.db_stats.connection_states', {}, locale, ['common']) }}
+
+
+
+ + + + + + + {% for row in activity.connection_states %} + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.state', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.count', {}, locale, ['common']) }}
{{ row.state }}{{ row.cnt }}
+
+
+
+ {% endif %} + +
+ + +
+ + {% if not queries.pg_stat_statements_available %} +
+ {{ t('ui.admin.db_stats.pg_stat_statements_unavailable', {}, locale, ['common']) }} +
+ {% endif %} + +
+
+
+
+
+ {{ t('ui.admin.db_stats.lock_waits', {}, locale, ['common']) }} +
+
{{ queries.lock_waits ?? 'N/A' }}
+
+
+
+
+
+
+
+ {{ t('ui.admin.db_stats.deadlocks', {}, locale, ['common']) }} +
+
{{ queries.deadlocks ?? 'N/A' }}
+
+
+
+
+ + {% if queries.long_running|length > 0 %} +
+
+
{{ t('ui.admin.db_stats.long_running', {}, locale, ['common']) }}
+
+
+
+ + + + + + + + + {% for row in queries.long_running %} + + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.pid', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.user', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.duration', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.query', {}, locale, ['common']) }}
{{ row.pid }}{{ row.usename }}{{ row.duration_sec }}s{{ row.query }}
+
+
+
+ {% endif %} + + {% if queries.slowest_queries|length > 0 %} +
+
+
{{ t('ui.admin.db_stats.slow_queries', {}, locale, ['common']) }}
+
+
+
+ + + + + + + + + {% for row in queries.slowest_queries %} + + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.mean_ms', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.total_ms', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.calls', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.query', {}, locale, ['common']) }}
{{ row.mean_ms }} ms{{ row.total_ms }} ms{{ row.calls|number_format }}{{ row.query }}
+
+
+
+ {% endif %} + + {% if queries.most_called|length > 0 %} +
+
+
{{ t('ui.admin.db_stats.frequent_queries', {}, locale, ['common']) }}
+
+
+
+ + + + + + + + {% for row in queries.most_called %} + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.calls', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.mean_ms', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.query', {}, locale, ['common']) }}
{{ row.calls|number_format }}{{ row.mean_ms }} ms{{ row.query }}
+
+
+
+ {% endif %} + + {% if not queries.pg_stat_statements_available and queries.long_running|length == 0 %} +

{{ t('ui.admin.db_stats.no_data', {}, locale, ['common']) }}

+ {% endif %} + +
+ + +
+ + {% if replication.senders|length == 0 and replication.receiver is null %} +
+ {{ t('ui.admin.db_stats.no_replication', {}, locale, ['common']) }} +
+ {% else %} + + {% if replication.senders|length > 0 %} +
+
+
{{ t('ui.admin.db_stats.replication_senders', {}, locale, ['common']) }}
+
+
+
+ + + + + + + + {% for row in replication.senders %} + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.client', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.state', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.lag_bytes', {}, locale, ['common']) }}
{{ row.client_addr }}{{ row.state }} + {{ row.lag_bytes|number_format }} +
+
+
+
+ {% endif %} + + {% if replication.receiver %} +
+
+
{{ t('ui.admin.db_stats.wal_receiver', {}, locale, ['common']) }}
+
+
+
+
{{ t('ui.admin.db_stats.col.status', {}, locale, ['common']) }}
+
{{ replication.receiver.status ?? 'N/A' }}
+
{{ t('ui.admin.db_stats.col.sender', {}, locale, ['common']) }}
+
{{ replication.receiver.sender_host ?? 'N/A' }}:{{ replication.receiver.sender_port ?? '' }}
+
+
+
+ {% endif %} + + {% endif %} +
+ + +
+ + {% if maintenance.needs_vacuum|length > 0 %} +
+ + {{ t('ui.admin.db_stats.vacuum_needed', {'count': maintenance.needs_vacuum|length}, locale, ['common']) }} +
+ {% endif %} + + {% if maintenance.autovacuum_workers|length > 0 %} +
+
+
{{ t('ui.admin.db_stats.autovacuum_active', {}, locale, ['common']) }}
+
+
+
+ + + + + + + + {% for row in maintenance.autovacuum_workers %} + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.pid', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.duration', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.query', {}, locale, ['common']) }}
{{ row.pid }}{{ row.duration_sec }}s{{ row.query }}
+
+
+
+ {% endif %} + +
+
+
{{ t('ui.admin.db_stats.maintenance_health', {}, locale, ['common']) }}
+
+
+ {% if maintenance.tables|length > 0 %} +
+ + + + + + + + + + {% for row in maintenance.tables %} + + + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.table', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.last_vacuum', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.last_analyze', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.dead_tuples', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.dead_pct', {}, locale, ['common']) }}
{{ row.table_name }} + {{ row.last_autovacuum ?? row.last_vacuum ?? t('ui.admin.db_stats.never', {}, locale, ['common']) }} + + {{ row.last_autoanalyze ?? row.last_analyze ?? t('ui.admin.db_stats.never', {}, locale, ['common']) }} + {{ row.dead_tuples|number_format }}{{ row.dead_pct }}%
+
+ {% else %} +

{{ t('ui.admin.db_stats.no_data', {}, locale, ['common']) }}

+ {% endif %} +
+
+ +
+ + +
+ + {% if indexes.unused|length > 0 %} +
+
+
+ {{ t('ui.admin.db_stats.unused_indexes', {}, locale, ['common']) }} + {{ indexes.unused|length }} +
+
+
+
+ + + + + + + + {% for row in indexes.unused %} + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.index', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.table', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.size', {}, locale, ['common']) }}
{{ row.indexname }}{{ row.tablename }}{{ row.index_size }}
+
+
+
+ {% else %} +
+ {{ t('ui.admin.db_stats.no_unused_indexes', {}, locale, ['common']) }} +
+ {% endif %} + + {% if indexes.duplicates|length > 0 %} +
+
+
{{ t('ui.admin.db_stats.duplicate_indexes', {}, locale, ['common']) }}
+
+
+
+ + + + + + + + {% for row in indexes.duplicates %} + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.table', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.index', {}, locale, ['common']) }} 1{{ t('ui.admin.db_stats.col.index', {}, locale, ['common']) }} 2
{{ row.table_name }}{{ row.index1 }}{{ row.index2 }}
+
+
+
+ {% endif %} + +
+
+
{{ t('ui.admin.db_stats.scan_ratios', {}, locale, ['common']) }}
+
+
+ {% if indexes.scan_ratios|length > 0 %} +
+ + + + + + + + + {% for row in indexes.scan_ratios %} + + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.col.table', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.seq_scans', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.idx_scans', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.col.idx_pct', {}, locale, ['common']) }}
{{ row.table_name }}{{ row.seq_scan|number_format }}{{ row.idx_scan|number_format }} + {% if row.idx_scan_pct is not null %} + {{ row.idx_scan_pct }}% + {% else %} + — + {% endif %} +
+
+ {% else %} +

{{ t('ui.admin.db_stats.no_data', {}, locale, ['common']) }}

+ {% endif %} +
+
+ +
+
+ +
+{% endblock %} From e6f0f4dd84f692202cdb598369e12e7775c92cbc Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 10:16:48 -0700 Subject: [PATCH 008/246] Add i18n catalog stats tab to database statistics page - New tab on /admin/database-stats showing per-locale, per-file key counts, file sizes, and serialized memory footprint of i18n catalogs - Summary cards give a quick overview per locale; detail table groups files under each locale with totals row - Fix back button using correct ui.common.go_back key - i18n keys added for en, es, fr Co-Authored-By: Claude Sonnet 4.6 --- config/i18n/en/common.php | 10 ++++ config/i18n/es/common.php | 10 ++++ config/i18n/fr/common.php | 10 ++++ routes/admin-routes.php | 1 + src/DatabaseStats.php | 63 +++++++++++++++++++++++ templates/admin/database_stats.twig | 79 ++++++++++++++++++++++++++++- 6 files changed, 172 insertions(+), 1 deletion(-) diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index 20fa65d7..006be5c9 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -674,6 +674,16 @@ 'ui.admin.db_stats.col.seq_scans' => 'Seq Scans', 'ui.admin.db_stats.col.idx_scans' => 'Idx Scans', 'ui.admin.db_stats.col.idx_pct' => 'Idx %', + 'ui.admin.db_stats.tab.i18n_catalogs' => 'i18n Catalogs', + 'ui.admin.db_stats.i18n.keys' => 'keys', + 'ui.admin.db_stats.i18n.memory' => 'memory', + 'ui.admin.db_stats.i18n.detail' => 'Catalog Detail', + 'ui.admin.db_stats.i18n.total' => 'Total', + 'ui.admin.db_stats.i18n.col.locale' => 'Locale', + 'ui.admin.db_stats.i18n.col.file' => 'File', + 'ui.admin.db_stats.i18n.col.keys' => 'Keys', + 'ui.admin.db_stats.i18n.col.file_size' => 'File Size', + 'ui.admin.db_stats.i18n.col.memory' => 'Memory', // Admin Users (legacy users page) 'ui.admin.users.load_failed' => 'Error loading users', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index 879edd49..22bb46a3 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -674,6 +674,16 @@ 'ui.admin.db_stats.col.seq_scans' => 'Escaneos sec.', 'ui.admin.db_stats.col.idx_scans' => 'Escaneos idx.', 'ui.admin.db_stats.col.idx_pct' => 'Idx %', + 'ui.admin.db_stats.tab.i18n_catalogs' => 'Catálogos i18n', + 'ui.admin.db_stats.i18n.keys' => 'claves', + 'ui.admin.db_stats.i18n.memory' => 'memoria', + 'ui.admin.db_stats.i18n.detail' => 'Detalle de catálogos', + 'ui.admin.db_stats.i18n.total' => 'Total', + 'ui.admin.db_stats.i18n.col.locale' => 'Idioma', + 'ui.admin.db_stats.i18n.col.file' => 'Archivo', + 'ui.admin.db_stats.i18n.col.keys' => 'Claves', + 'ui.admin.db_stats.i18n.col.file_size' => 'Tamaño', + 'ui.admin.db_stats.i18n.col.memory' => 'Memoria', // Admin Users (legacy users page) 'ui.admin.users.load_failed' => 'Error al cargar usuarios', diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index 72900026..ad7ae13d 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -641,6 +641,16 @@ 'ui.admin.db_stats.col.seq_scans' => 'Scans séq.', 'ui.admin.db_stats.col.idx_scans' => 'Scans idx.', 'ui.admin.db_stats.col.idx_pct' => 'Idx %', + 'ui.admin.db_stats.tab.i18n_catalogs' => 'Catalogues i18n', + 'ui.admin.db_stats.i18n.keys' => 'clés', + 'ui.admin.db_stats.i18n.memory' => 'mémoire', + 'ui.admin.db_stats.i18n.detail' => 'Détail des catalogues', + 'ui.admin.db_stats.i18n.total' => 'Total', + 'ui.admin.db_stats.i18n.col.locale' => 'Langue', + 'ui.admin.db_stats.i18n.col.file' => 'Fichier', + 'ui.admin.db_stats.i18n.col.keys' => 'Clés', + 'ui.admin.db_stats.i18n.col.file_size' => 'Taille', + 'ui.admin.db_stats.i18n.col.memory' => 'Mémoire', 'ui.admin.users.load_failed' => 'Erreur lors du chargement des utilisateurs', 'ui.admin.users.load_details_failed' => 'Erreur lors du chargement des détails de l\'utilisateur', 'ui.admin.users.updated_success' => 'Utilisateur mis à jour avec succès', diff --git a/routes/admin-routes.php b/routes/admin-routes.php index d9e71dd5..271350cd 100644 --- a/routes/admin-routes.php +++ b/routes/admin-routes.php @@ -109,6 +109,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array 'replication' => $dbStats->getReplication(), 'maintenance' => $dbStats->getMaintenanceHealth(), 'indexes' => $dbStats->getIndexHealth(), + 'i18n_catalogs' => $dbStats->getI18nCatalogStats(), ]); }); diff --git a/src/DatabaseStats.php b/src/DatabaseStats.php index 142b80af..734c5e18 100644 --- a/src/DatabaseStats.php +++ b/src/DatabaseStats.php @@ -449,6 +449,69 @@ public function getIndexHealth(): array return $result; } + /** + * i18n catalog stats: file sizes, key counts, and serialized memory footprint + * for each locale and namespace file under config/i18n/. + * + * @return array List of per-locale entries, each with a 'files' sub-array. + */ + public function getI18nCatalogStats(): array + { + $i18nDir = dirname(__DIR__) . '/config/i18n'; + $locales = []; + + if (!is_dir($i18nDir)) { + return $locales; + } + + foreach (scandir($i18nDir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $localeDir = $i18nDir . '/' . $entry; + if (!is_dir($localeDir)) { + continue; + } + + $files = []; + $totalKeys = 0; + $totalBytes = 0; + $totalMemory = 0; + + foreach (glob($localeDir . '/*.php') as $filePath) { + $fileBytes = (int)filesize($filePath); + $catalog = include $filePath; + $keyCount = is_array($catalog) ? count($catalog) : 0; + $memBytes = strlen(serialize($catalog)); + + $files[] = [ + 'filename' => basename($filePath), + 'file_bytes' => $fileBytes, + 'key_count' => $keyCount, + 'memory_bytes' => $memBytes, + ]; + + $totalKeys += $keyCount; + $totalBytes += $fileBytes; + $totalMemory += $memBytes; + } + + usort($files, fn($a, $b) => strcmp($a['filename'], $b['filename'])); + + $locales[] = [ + 'locale' => $entry, + 'files' => $files, + 'total_keys' => $totalKeys, + 'total_bytes' => $totalBytes, + 'total_memory' => $totalMemory, + ]; + } + + usort($locales, fn($a, $b) => strcmp($a['locale'], $b['locale'])); + + return $locales; + } + /** * Returns the PostgreSQL server version number (e.g. 140005). */ diff --git a/templates/admin/database_stats.twig b/templates/admin/database_stats.twig index 8ef3603c..0e1f73d5 100644 --- a/templates/admin/database_stats.twig +++ b/templates/admin/database_stats.twig @@ -11,7 +11,7 @@ {{ t('ui.common.refresh', {}, locale, ['common']) }} - {{ t('ui.common.back', {}, locale, ['common']) }} + {{ t('ui.common.go_back', {}, locale, ['common']) }}
@@ -48,6 +48,11 @@ {{ t('ui.admin.db_stats.tab.index_health', {}, locale, ['common']) }} +
@@ -643,6 +648,78 @@
+ +
+ + {% if i18n_catalogs|length == 0 %} +

{{ t('ui.admin.db_stats.no_data', {}, locale, ['common']) }}

+ {% else %} + + {# Summary row — one card per locale #} +
+ {% for loc in i18n_catalogs %} +
+
+
+
+ {{ loc.locale }} +
+
{{ loc.total_keys|number_format }}
+ {{ t('ui.admin.db_stats.i18n.keys', {}, locale, ['common']) }}
+ {{ (loc.total_memory / 1024)|round(1) }} KB {{ t('ui.admin.db_stats.i18n.memory', {}, locale, ['common']) }} +
+
+
+ {% endfor %} +
+ + {# Detail table #} +
+
+
{{ t('ui.admin.db_stats.i18n.detail', {}, locale, ['common']) }}
+
+
+
+ + + + + + + + + + + + {% for loc in i18n_catalogs %} + {% for file in loc.files %} + + {% if loop.first %} + + {% endif %} + + + + + + {% endfor %} + + + + + + + + {% endfor %} + +
{{ t('ui.admin.db_stats.i18n.col.locale', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.i18n.col.file', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.i18n.col.keys', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.i18n.col.file_size', {}, locale, ['common']) }}{{ t('ui.admin.db_stats.i18n.col.memory', {}, locale, ['common']) }}
{{ loc.locale }}{{ file.filename }}{{ file.key_count|number_format }}{{ (file.file_bytes / 1024)|round(1) }} KB{{ (file.memory_bytes / 1024)|round(1) }} KB
{{ t('ui.admin.db_stats.i18n.total', {}, locale, ['common']) }}{{ loc.total_keys|number_format }}{{ (loc.total_bytes / 1024)|round(1) }} KB{{ (loc.total_memory / 1024)|round(1) }} KB
+
+
+
+ + {% endif %} +
+ From 2bfe7a505047e53449673532d44b1b354949132f Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 10:25:26 -0700 Subject: [PATCH 009/246] Add 118 missing French translation keys Fills gaps in fr/common.php relative to en/common.php: - ui.common.advanced_search.* (advanced search modal) - ui.common.db_id, ui.common.message_id - ui.base.admin.bbs_directory, echomail_robots, bbs_lists - ui.admin.bbs_directory.* (full admin BBS directory UI) - ui.admin.echomail_robots.* - ui.admin.bbs_settings.features.enable_bbs_directory / bbs_directory_help - ui.bbs_directory.* (public directory page) - ui.echomail.art_format*, edit_message*, message_charset* - ui.files.edit, edit_file, move_to_area, scan_status_*, short_description, virus_name_placeholder Co-Authored-By: Claude Sonnet 4.6 --- config/i18n/fr/common.php | 118 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index ad7ae13d..92fcf6e9 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -143,6 +143,21 @@ 'ui.common.time.24_hours' => '24 heures', 'ui.common.time.1_week' => '1 semaine', 'ui.common.time.30_days' => '30 jours', + 'ui.common.advanced_search' => 'Recherche avancée', + 'ui.common.advanced_search.title' => 'Recherche avancée', + 'ui.common.advanced_search.from_name' => 'De / Nom de l\'auteur', + 'ui.common.advanced_search.from_name_placeholder' => 'Rechercher par auteur...', + 'ui.common.advanced_search.subject' => 'Sujet', + 'ui.common.advanced_search.subject_placeholder' => 'Rechercher par sujet...', + 'ui.common.advanced_search.body' => 'Corps du message', + 'ui.common.advanced_search.body_placeholder' => 'Rechercher dans le corps du message...', + 'ui.common.advanced_search.help' => 'Remplissez un ou plusieurs champs. Les champs sont combinés avec la logique AND.', + 'ui.common.advanced_search.fill_one_field' => 'Veuillez remplir au moins un champ (minimum 2 caractères pour les champs texte).', + 'ui.common.advanced_search.date_from' => 'Date de début', + 'ui.common.advanced_search.date_to' => 'Date de fin', + 'ui.common.advanced_search.date_range' => 'Plage de dates', + 'ui.common.db_id' => 'ID DB', + 'ui.common.message_id' => 'ID Message', 'ui.about.title' => 'À propos', 'ui.about.about_system' => 'À propos de {system_name}', 'ui.about.system_name' => 'Nom du système', @@ -225,6 +240,10 @@ 'ui.base.admin.upgrade_notes' => 'Notes de mise à jour v{version}', 'ui.base.admin.claudes_bbs' => 'BBS de Claude', 'ui.base.admin.report_issue' => 'Signaler un problème', + 'ui.base.admin.bbs_directory' => 'Répertoire BBS', + 'ui.base.admin.echomail_robots' => 'Robots Echomail', + 'ui.base.bbs_directory' => 'Répertoire BBS', + 'ui.base.bbs_lists' => 'Listes BBS', 'ui.admin_users.pending_users_error' => 'Erreur utilisateurs en attente :', 'ui.admin_users.pending_users_load_failed_prefix' => 'Échec du chargement des utilisateurs en attente : ', 'ui.admin_users.all_users_error' => 'Erreur tous les utilisateurs :', @@ -1088,6 +1107,8 @@ 'ui.admin.bbs_settings.features.max_cross_post_areas' => 'Nombre max de zones de cross-post', 'ui.admin.bbs_settings.features.max_cross_post_help' => 'Nombre maximum de zones supplémentaires vers lesquelles un utilisateur peut faire un cross-post (2-20).', 'ui.admin.bbs_settings.features.save' => 'Enregistrer les paramètres', + 'ui.admin.bbs_settings.features.enable_bbs_directory' => 'Activer le répertoire BBS', + 'ui.admin.bbs_settings.features.bbs_directory_help' => 'Affiche la page publique /bbs-directory et le menu de navigation Listes BBS. Lorsqu\'il est désactivé, la page renvoie une erreur 404.', 'ui.admin.bbs_settings.credits.title' => 'Configuration du système de crédits', 'ui.admin.bbs_settings.credits.enabled' => 'Système de crédits activé', 'ui.admin.bbs_settings.credits.currency_symbol' => 'Symbole monétaire', @@ -1834,6 +1855,14 @@ 'ui.files.share_revoked' => 'Lien de partage révoqué', 'ui.files.share_link_copied_clipboard' => 'Lien de partage copié dans le presse-papiers', 'ui.files.share_link_copied' => 'Lien de partage copié', + 'ui.files.edit' => 'Modifier', + 'ui.files.edit_file' => 'Modifier le fichier', + 'ui.files.edit_success' => 'Fichier mis à jour avec succès', + 'ui.files.move_to_area' => 'Déplacer vers une zone', + 'ui.files.scan_status_save_failed' => 'Échec de la mise à jour du statut de scan', + 'ui.files.scan_status_saved' => 'Statut de scan mis à jour', + 'ui.files.short_description' => 'Description courte', + 'ui.files.virus_name_placeholder' => 'Nom du virus (optionnel)', 'ui.polls.title' => 'Sondages', 'ui.polls.create' => 'Créer un sondage', 'ui.polls.loading' => 'Chargement du sondage...', @@ -2525,6 +2554,16 @@ 'ui.echomail.shortcuts.help' => 'Afficher / masquer les raccourcis clavier', 'ui.echomail.shortcuts.close' => 'Fermer le message', 'ui.echomail.shortcuts.dismiss' => 'Appuyez sur ? ou H pour fermer cette aide', + 'ui.echomail.art_format' => 'Format d\'art', + 'ui.echomail.art_format_auto' => 'Détection automatique', + 'ui.echomail.art_format_plain' => 'Texte brut', + 'ui.echomail.art_format_ansi' => 'ANSI', + 'ui.echomail.art_format_amiga_ansi' => 'ANSI Amiga', + 'ui.echomail.art_format_petscii' => 'PETSCII / C64', + 'ui.echomail.edit_message' => 'Modifier le message', + 'ui.echomail.edit_message_saved' => 'Modifications enregistrées.', + 'ui.echomail.message_charset' => 'Encodage d\'art', + 'ui.echomail.message_charset_help' => 'Encodage des octets d\'art brut. Affecte uniquement le rendu de l\'art ANSI/PETSCII. Laissez vide pour effacer.', 'ui.admin_subscriptions.page_title' => 'Admin : Gérer les abonnements', 'ui.admin_subscriptions.heading' => 'Gestion des abonnements', 'ui.admin_subscriptions.breadcrumb_aria' => 'fil d\'Ariane', @@ -2642,10 +2681,89 @@ 'ui.api.door.session_resumed' => 'Reprise de la session existante', 'ui.files.previous_file' => 'Fichier précédent', 'ui.files.next_file' => 'Fichier suivant', + 'ui.bbs_directory.heading' => 'Répertoire BBS', + 'ui.bbs_directory.page_title' => 'Répertoire BBS', + 'ui.bbs_directory.description' => 'Une liste des systèmes BBS connus de ce BBS, découverts automatiquement via diverses sources et complétés par des entrées gérées manuellement.', + 'ui.bbs_directory.col_name' => 'Nom du BBS', + 'ui.bbs_directory.col_sysop' => 'Sysop', + 'ui.bbs_directory.col_location' => 'Emplacement', + 'ui.bbs_directory.col_telnet' => 'Telnet', + 'ui.bbs_directory.col_telnet_port' => 'Port', + 'ui.bbs_directory.col_website' => 'Site web', + 'ui.bbs_directory.col_os' => 'OS', + 'ui.bbs_directory.col_notes' => 'Notes', + 'ui.bbs_directory.col_last_seen' => 'Vu la dernière fois', + 'ui.bbs_directory.connect' => 'Connecter', + 'ui.bbs_directory.no_entries' => 'Aucune entrée BBS pour l\'instant.', + 'ui.bbs_directory.no_results' => 'Aucune entrée ne correspond à votre recherche.', + 'ui.bbs_directory.search_placeholder' => 'Rechercher par nom, sysop, emplacement...', + 'ui.bbs_directory.source_auto' => 'Auto', + 'ui.bbs_directory.source_manual' => 'Manuel', + 'ui.bbs_directory.status_pending' => 'En attente', + 'ui.bbs_directory.status_rejected' => 'Rejeté', + 'ui.bbs_directory.add_listing' => 'Ajouter votre BBS', + 'ui.bbs_directory.submit_title' => 'Soumettre votre BBS', + 'ui.bbs_directory.submit_description' => 'Soumettez votre BBS au répertoire. Les nouvelles inscriptions sont examinées par le sysop avant d\'apparaître publiquement.', + 'ui.bbs_directory.submit_btn' => 'Soumettre l\'inscription', + 'ui.bbs_directory.submit_success' => 'Votre inscription a été soumise et est en attente de validation. Merci !', 'ui.bbs_directory.tab_list' => 'Liste du répertoire', 'ui.bbs_directory.tab_map' => 'Carte des systèmes BBS', 'ui.bbs_directory.map_no_coordinates' => 'Aucun système BBS ne dispose encore de coordonnées cartographiques.', 'ui.bbs_directory.map_partial_notice' => '{count} systèmes BBS ne sont pas affichés sur la carte car leurs emplacements n\'ont pas encore pu être géocodés.', + 'ui.admin.bbs_directory.heading' => 'Répertoire BBS', + 'ui.admin.bbs_directory.page_title' => 'Répertoire BBS', + 'ui.admin.bbs_directory.tab_entries' => 'Entrées du répertoire', + 'ui.admin.bbs_directory.tab_all' => 'Toutes les entrées', + 'ui.admin.bbs_directory.tab_pending' => 'En attente de validation', + 'ui.admin.bbs_directory.tab_robots' => 'Règles des robots', + 'ui.admin.bbs_directory.col_name' => 'Nom', + 'ui.admin.bbs_directory.col_sysop' => 'Sysop', + 'ui.admin.bbs_directory.col_location' => 'Emplacement', + 'ui.admin.bbs_directory.col_telnet' => 'Telnet', + 'ui.admin.bbs_directory.col_last_seen' => 'Vu la dernière fois', + 'ui.admin.bbs_directory.col_source' => 'Source', + 'ui.admin.bbs_directory.col_enabled' => 'Activé', + 'ui.admin.bbs_directory.col_submitted_by' => 'Soumis par', + 'ui.admin.bbs_directory.col_actions' => 'Actions', + 'ui.admin.bbs_directory.col_echo_area' => 'Zone echo', + 'ui.admin.bbs_directory.col_subject_pattern' => 'Modèle de sujet', + 'ui.admin.bbs_directory.col_processor' => 'Processeur', + 'ui.admin.bbs_directory.col_last_run' => 'Dernier déclenchement', + 'ui.admin.bbs_directory.col_messages_processed' => 'Msg traités', + 'ui.admin.bbs_directory.no_entries' => 'Aucune entrée pour l\'instant.', + 'ui.admin.bbs_directory.no_pending' => 'Aucune inscription en attente.', + 'ui.admin.bbs_directory.no_robots' => 'Aucune règle de robot configurée.', + 'ui.admin.bbs_directory.never' => 'Jamais', + 'ui.admin.bbs_directory.approve' => 'Approuver', + 'ui.admin.bbs_directory.reject' => 'Rejeter', + 'ui.admin.bbs_directory.approved' => 'Entrée approuvée', + 'ui.admin.bbs_directory.rejected' => 'Entrée rejetée', + 'ui.admin.bbs_directory.entry_saved' => 'Entrée enregistrée avec succès', + 'ui.admin.bbs_directory.entry_deleted' => 'Entrée supprimée avec succès', + 'ui.admin.bbs_directory.add_entry' => 'Ajouter une entrée', + 'ui.admin.bbs_directory.add_robot' => 'Ajouter un robot', + 'ui.admin.bbs_directory.run_now' => 'Exécuter maintenant', + 'ui.admin.bbs_directory.run_complete' => 'Exécution du robot terminée : examiné {examined}, traité {processed}', + 'ui.admin.bbs_directory.robot_saved' => 'Règle de robot enregistrée avec succès', + 'ui.admin.bbs_directory.robot_deleted' => 'Règle de robot supprimée avec succès', + 'ui.admin.bbs_directory.is_local_label' => 'Notre BBS (protégé)', + 'ui.admin.bbs_directory.is_local_help' => 'Les imports automatiques et les robots ne modifieront pas cette entrée.', + 'ui.admin.bbs_directory.merge' => 'Fusionner le doublon', + 'ui.admin.bbs_directory.merge_title' => 'Fusionner l\'entrée en doublon', + 'ui.admin.bbs_directory.merge_keep_label' => 'Conserver :', + 'ui.admin.bbs_directory.merge_discard_label' => 'Supprimer (sera effacé) :', + 'ui.admin.bbs_directory.merge_search_placeholder' => 'Rechercher le doublon...', + 'ui.admin.bbs_directory.merge_help' => 'L\'entrée conservée sera complétée par les champs manquants de l\'entrée supprimée. L\'entrée supprimée sera effacée.', + 'ui.admin.bbs_directory.merge_will_delete' => 'Sera supprimé :', + 'ui.admin.bbs_directory.merge_confirm' => 'Fusionner et supprimer le doublon', + 'ui.admin.bbs_directory.merge_success' => 'Entrées fusionnées avec succès', + 'ui.admin.echomail_robots.heading' => 'Robots Echomail', + 'ui.admin.echomail_robots.page_title' => 'Robots Echomail', + 'ui.admin.echomail_robots.about_title' => 'À propos des robots', + 'ui.admin.echomail_robots.about_text' => 'Les règles de robot surveillent les zones echo à la recherche de messages correspondants et les traitent automatiquement. Les résultats sont stockés dans le répertoire BBS ou d\'autres cibles selon le processeur.', + 'ui.admin.echomail_robots.processor_config_label' => 'Config du processeur (JSON)', + 'ui.admin.echomail_robots.cron_hint' => 'Exécuter périodiquement via cron :', + 'ui.admin.echomail_robots.run_output_title' => 'Sortie du robot', 'ui.admin.appearance.message_reader.email_link_url' => 'URL du lien E-mail', 'ui.admin.appearance.message_reader.email_link_url_placeholder' => 'https://mail.example.com/', 'ui.admin.appearance.message_reader.email_link_url_help' => 'Lien optionnel affiche dans le menu Messagerie juste sous Echomail.', From 3b32e96cf24b17ec362c3ac76f24e89535e66cae Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 10:31:37 -0700 Subject: [PATCH 010/246] Fix table-warning/danger/secondary contrast on dark themes Bootstrap's contextual table classes use bright solid backgrounds that make text unreadable on dark themes. Override them in cyberpunk, dark, greenterm, and amber to use low-opacity tinted backgrounds that preserve the semantic colour signal without drowning out foreground text. Co-Authored-By: Claude Sonnet 4.6 --- public_html/css/amber.css | 20 ++++++++++++++++++++ public_html/css/cyberpunk.css | 20 ++++++++++++++++++++ public_html/css/dark.css | 20 ++++++++++++++++++++ public_html/css/greenterm.css | 20 ++++++++++++++++++++ public_html/sw.js | 2 +- 5 files changed, 81 insertions(+), 1 deletion(-) diff --git a/public_html/css/amber.css b/public_html/css/amber.css index 208c1a7f..44b11494 100644 --- a/public_html/css/amber.css +++ b/public_html/css/amber.css @@ -1463,3 +1463,23 @@ tbody, td, tfoot, th, thead, tr { .sc-inverse { filter: invert(1); } + +/* Bootstrap contextual table rows — override bright backgrounds for dark theme */ +.table-warning, .table-warning > td, .table-warning > th { + --bs-table-bg: rgba(255, 176, 0, 0.15); + --bs-table-color: var(--text-color); + background-color: rgba(255, 176, 0, 0.15) !important; + color: var(--text-color) !important; +} +.table-danger, .table-danger > td, .table-danger > th { + --bs-table-bg: rgba(255, 51, 51, 0.15); + --bs-table-color: var(--text-color); + background-color: rgba(255, 51, 51, 0.15) !important; + color: var(--text-color) !important; +} +.table-secondary, .table-secondary > td, .table-secondary > th { + --bs-table-bg: rgba(255, 176, 0, 0.08); + --bs-table-color: var(--text-color); + background-color: rgba(255, 176, 0, 0.08) !important; + color: var(--text-color) !important; +} diff --git a/public_html/css/cyberpunk.css b/public_html/css/cyberpunk.css index 19ab30ed..08530ece 100644 --- a/public_html/css/cyberpunk.css +++ b/public_html/css/cyberpunk.css @@ -1558,3 +1558,23 @@ tbody, td, tfoot, th, thead, tr { .sc-inverse { filter: invert(1); } + +/* Bootstrap contextual table rows — override bright backgrounds for dark theme */ +.table-warning, .table-warning > td, .table-warning > th { + --bs-table-bg: rgba(249, 240, 2, 0.12); + --bs-table-color: var(--text-color); + background-color: rgba(249, 240, 2, 0.12) !important; + color: var(--text-color) !important; +} +.table-danger, .table-danger > td, .table-danger > th { + --bs-table-bg: rgba(255, 42, 109, 0.15); + --bs-table-color: var(--text-color); + background-color: rgba(255, 42, 109, 0.15) !important; + color: var(--text-color) !important; +} +.table-secondary, .table-secondary > td, .table-secondary > th { + --bs-table-bg: rgba(157, 78, 221, 0.15); + --bs-table-color: var(--text-color); + background-color: rgba(157, 78, 221, 0.15) !important; + color: var(--text-color) !important; +} diff --git a/public_html/css/dark.css b/public_html/css/dark.css index 6c94cd27..742dae43 100644 --- a/public_html/css/dark.css +++ b/public_html/css/dark.css @@ -1091,3 +1091,23 @@ a:hover { .sc-inverse { filter: invert(1); } + +/* Bootstrap contextual table rows — override bright backgrounds for dark theme */ +.table-warning, .table-warning > td, .table-warning > th { + --bs-table-bg: rgba(255, 193, 7, 0.15); + --bs-table-color: var(--text-color); + background-color: rgba(255, 193, 7, 0.15) !important; + color: var(--text-color) !important; +} +.table-danger, .table-danger > td, .table-danger > th { + --bs-table-bg: rgba(255, 77, 77, 0.15); + --bs-table-color: var(--text-color); + background-color: rgba(255, 77, 77, 0.15) !important; + color: var(--text-color) !important; +} +.table-secondary, .table-secondary > td, .table-secondary > th { + --bs-table-bg: rgba(255, 255, 255, 0.07); + --bs-table-color: var(--text-color); + background-color: rgba(255, 255, 255, 0.07) !important; + color: var(--text-color) !important; +} diff --git a/public_html/css/greenterm.css b/public_html/css/greenterm.css index 15b078ee..ac47153e 100644 --- a/public_html/css/greenterm.css +++ b/public_html/css/greenterm.css @@ -1463,3 +1463,23 @@ tbody, td, tfoot, th, thead, tr { .sc-inverse { filter: invert(1); } + +/* Bootstrap contextual table rows — override bright backgrounds for dark theme */ +.table-warning, .table-warning > td, .table-warning > th { + --bs-table-bg: rgba(255, 170, 0, 0.15); + --bs-table-color: var(--text-color); + background-color: rgba(255, 170, 0, 0.15) !important; + color: var(--text-color) !important; +} +.table-danger, .table-danger > td, .table-danger > th { + --bs-table-bg: rgba(255, 51, 51, 0.15); + --bs-table-color: var(--text-color); + background-color: rgba(255, 51, 51, 0.15) !important; + color: var(--text-color) !important; +} +.table-secondary, .table-secondary > td, .table-secondary > th { + --bs-table-bg: rgba(51, 255, 51, 0.08); + --bs-table-color: var(--text-color); + background-color: rgba(51, 255, 51, 0.08) !important; + color: var(--text-color) !important; +} diff --git a/public_html/sw.js b/public_html/sw.js index a5dc6807..f75bd33f 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v227'; +const CACHE_NAME = 'binkcache-v228'; // Static assets to precache const staticAssets = [ From 627837d05339e839cef584aa3606a5794a1e70c1 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 10:35:10 -0700 Subject: [PATCH 011/246] Fix striped table-warning rows on dark themes The previous fix only set --bs-table-bg but not --bs-table-striped-bg, so table-warning rows alternated between the tinted dark colour (odd/striped rows) and Bootstrap's bright #fff3cd (even rows). Add all Bootstrap table CSS variable overrides (striped, active, hover variants) so every row in a contextual table class renders consistently dark across all four themes. Co-Authored-By: Claude Sonnet 4.6 --- public_html/css/amber.css | 18 +++++++++++++++--- public_html/css/cyberpunk.css | 18 +++++++++++++++--- public_html/css/dark.css | 18 +++++++++++++++--- public_html/css/greenterm.css | 18 +++++++++++++++--- public_html/sw.js | 2 +- 5 files changed, 61 insertions(+), 13 deletions(-) diff --git a/public_html/css/amber.css b/public_html/css/amber.css index 44b11494..b921dbbd 100644 --- a/public_html/css/amber.css +++ b/public_html/css/amber.css @@ -1466,20 +1466,32 @@ tbody, td, tfoot, th, thead, tr { /* Bootstrap contextual table rows — override bright backgrounds for dark theme */ .table-warning, .table-warning > td, .table-warning > th { - --bs-table-bg: rgba(255, 176, 0, 0.15); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 176, 0, 0.15); + --bs-table-striped-bg: rgba(255, 176, 0, 0.22); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 176, 0, 0.25); + --bs-table-hover-bg: rgba(255, 176, 0, 0.25); background-color: rgba(255, 176, 0, 0.15) !important; color: var(--text-color) !important; } .table-danger, .table-danger > td, .table-danger > th { - --bs-table-bg: rgba(255, 51, 51, 0.15); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 51, 51, 0.15); + --bs-table-striped-bg: rgba(255, 51, 51, 0.22); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 51, 51, 0.25); + --bs-table-hover-bg: rgba(255, 51, 51, 0.25); background-color: rgba(255, 51, 51, 0.15) !important; color: var(--text-color) !important; } .table-secondary, .table-secondary > td, .table-secondary > th { - --bs-table-bg: rgba(255, 176, 0, 0.08); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 176, 0, 0.08); + --bs-table-striped-bg: rgba(255, 176, 0, 0.13); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 176, 0, 0.13); + --bs-table-hover-bg: rgba(255, 176, 0, 0.13); background-color: rgba(255, 176, 0, 0.08) !important; color: var(--text-color) !important; } diff --git a/public_html/css/cyberpunk.css b/public_html/css/cyberpunk.css index 08530ece..a48826e4 100644 --- a/public_html/css/cyberpunk.css +++ b/public_html/css/cyberpunk.css @@ -1561,20 +1561,32 @@ tbody, td, tfoot, th, thead, tr { /* Bootstrap contextual table rows — override bright backgrounds for dark theme */ .table-warning, .table-warning > td, .table-warning > th { - --bs-table-bg: rgba(249, 240, 2, 0.12); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(249, 240, 2, 0.12); + --bs-table-striped-bg: rgba(249, 240, 2, 0.18); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(249, 240, 2, 0.20); + --bs-table-hover-bg: rgba(249, 240, 2, 0.20); background-color: rgba(249, 240, 2, 0.12) !important; color: var(--text-color) !important; } .table-danger, .table-danger > td, .table-danger > th { - --bs-table-bg: rgba(255, 42, 109, 0.15); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 42, 109, 0.15); + --bs-table-striped-bg: rgba(255, 42, 109, 0.22); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 42, 109, 0.25); + --bs-table-hover-bg: rgba(255, 42, 109, 0.25); background-color: rgba(255, 42, 109, 0.15) !important; color: var(--text-color) !important; } .table-secondary, .table-secondary > td, .table-secondary > th { - --bs-table-bg: rgba(157, 78, 221, 0.15); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(157, 78, 221, 0.15); + --bs-table-striped-bg: rgba(157, 78, 221, 0.22); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(157, 78, 221, 0.22); + --bs-table-hover-bg: rgba(157, 78, 221, 0.22); background-color: rgba(157, 78, 221, 0.15) !important; color: var(--text-color) !important; } diff --git a/public_html/css/dark.css b/public_html/css/dark.css index 742dae43..3f6d7efe 100644 --- a/public_html/css/dark.css +++ b/public_html/css/dark.css @@ -1094,20 +1094,32 @@ a:hover { /* Bootstrap contextual table rows — override bright backgrounds for dark theme */ .table-warning, .table-warning > td, .table-warning > th { - --bs-table-bg: rgba(255, 193, 7, 0.15); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 193, 7, 0.15); + --bs-table-striped-bg: rgba(255, 193, 7, 0.22); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 193, 7, 0.25); + --bs-table-hover-bg: rgba(255, 193, 7, 0.25); background-color: rgba(255, 193, 7, 0.15) !important; color: var(--text-color) !important; } .table-danger, .table-danger > td, .table-danger > th { - --bs-table-bg: rgba(255, 77, 77, 0.15); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 77, 77, 0.15); + --bs-table-striped-bg: rgba(255, 77, 77, 0.22); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 77, 77, 0.25); + --bs-table-hover-bg: rgba(255, 77, 77, 0.25); background-color: rgba(255, 77, 77, 0.15) !important; color: var(--text-color) !important; } .table-secondary, .table-secondary > td, .table-secondary > th { - --bs-table-bg: rgba(255, 255, 255, 0.07); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 255, 255, 0.07); + --bs-table-striped-bg: rgba(255, 255, 255, 0.11); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 255, 255, 0.11); + --bs-table-hover-bg: rgba(255, 255, 255, 0.11); background-color: rgba(255, 255, 255, 0.07) !important; color: var(--text-color) !important; } diff --git a/public_html/css/greenterm.css b/public_html/css/greenterm.css index ac47153e..19d99dd2 100644 --- a/public_html/css/greenterm.css +++ b/public_html/css/greenterm.css @@ -1466,20 +1466,32 @@ tbody, td, tfoot, th, thead, tr { /* Bootstrap contextual table rows — override bright backgrounds for dark theme */ .table-warning, .table-warning > td, .table-warning > th { - --bs-table-bg: rgba(255, 170, 0, 0.15); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 170, 0, 0.15); + --bs-table-striped-bg: rgba(255, 170, 0, 0.22); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 170, 0, 0.25); + --bs-table-hover-bg: rgba(255, 170, 0, 0.25); background-color: rgba(255, 170, 0, 0.15) !important; color: var(--text-color) !important; } .table-danger, .table-danger > td, .table-danger > th { - --bs-table-bg: rgba(255, 51, 51, 0.15); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(255, 51, 51, 0.15); + --bs-table-striped-bg: rgba(255, 51, 51, 0.22); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(255, 51, 51, 0.25); + --bs-table-hover-bg: rgba(255, 51, 51, 0.25); background-color: rgba(255, 51, 51, 0.15) !important; color: var(--text-color) !important; } .table-secondary, .table-secondary > td, .table-secondary > th { - --bs-table-bg: rgba(51, 255, 51, 0.08); --bs-table-color: var(--text-color); + --bs-table-bg: rgba(51, 255, 51, 0.08); + --bs-table-striped-bg: rgba(51, 255, 51, 0.13); + --bs-table-striped-color: var(--text-color); + --bs-table-active-bg: rgba(51, 255, 51, 0.13); + --bs-table-hover-bg: rgba(51, 255, 51, 0.13); background-color: rgba(51, 255, 51, 0.08) !important; color: var(--text-color) !important; } diff --git a/public_html/sw.js b/public_html/sw.js index f75bd33f..cbd1181d 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v228'; +const CACHE_NAME = 'binkcache-v229'; // Static assets to precache const staticAssets = [ From c5c74895fd7e83d2feeb4f3859aceb6a3271bfd3 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 10:46:45 -0700 Subject: [PATCH 012/246] Add FAQ entry explaining sequential scan behaviour in database stats Co-Authored-By: Claude Sonnet 4.6 --- FAQ.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/FAQ.md b/FAQ.md index 2e3efd69..9fef2603 100644 --- a/FAQ.md +++ b/FAQ.md @@ -436,6 +436,31 @@ Public nodes can also poll as a fallback, though the hub will deliver mail direc --- +## Database + +### Q: The database stats page shows high sequential scan counts on some tables. Is this a problem? +**A:** Not necessarily. PostgreSQL uses sequential scans when they are more efficient than index scans, which is often the case for small tables or queries where a large fraction of rows would be returned. Here are the common cases you may see in BinktermPHP: + +**Small tables (users_meta, user_settings, mrc_state)** +For tables with only a few hundred or thousand rows, PostgreSQL's query planner will almost always prefer a sequential scan — the overhead of walking an index is greater than simply reading the table directly. High seq scan counts here are expected and correct behaviour, not an indexing gap. + +**High-frequency daemon tables (mrc_outbound, mrc_state)** +The MRC daemon polls these tables continuously (multiple times per second). Because these tables are very small, every poll results in a sequential scan. The counts look alarming but reflect normal operation. + +**OR-condition queries (chat_messages, shared_messages)** +Queries that filter on `col_a = ? OR col_b = ?` cannot use a single B-tree index to satisfy both conditions simultaneously. PostgreSQL must either do a seq scan or merge two separate index scans (bitmap OR). For moderate table sizes or if the planner estimates a large result set, it will choose a seq scan even when indexes exist on both columns individually. This is a structural query pattern — adding more indexes will not change the planner's decision. + +**When to investigate further** +A high seq scan ratio is worth investigating when: +- The table is large (tens of thousands of rows or more) +- The query is selecting a small, specific subset of rows (highly selective filter) +- You can see a long-running or slow query in the Query Performance tab that targets that table +- `pg_stat_user_tables.seq_tup_read` is very large relative to `idx_tup_fetch` + +In those cases, review the actual queries and consider adding a targeted index. For the tables listed above, no additional indexing is needed. + +--- + ## WebDoors ### Q: How do I add a connection to my text-based BBS? From f49baf520fd2e092d80117eeb45aff9c6a28ee41 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 11:34:23 -0700 Subject: [PATCH 013/246] Add configurable file upload/download credits --- README.md | 2 +- config/bbs.json.example | 4 + config/i18n/en/common.php | 12 +++ config/i18n/en/errors.php | 2 + config/i18n/es/common.php | 12 +++ config/i18n/es/errors.php | 2 + config/i18n/fr/common.php | 12 +++ config/i18n/fr/errors.php | 2 + docs/CreditSystem.md | 10 +- docs/UPGRADING_1.8.7.md | 27 ++++++ public_html/sw.js | 2 +- routes/admin-routes.php | 16 ++++ routes/api-routes.php | 77 ++++++++++++++- scripts/database_maintenance.php | 154 ++++++++++++++++-------------- src/UserCredit.php | 8 ++ templates/admin/bbs_settings.twig | 56 +++++++++++ 16 files changed, 321 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 79f2d9f0..74dd41d6 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ See **[docs/SSHServer.md](docs/SSHServer.md)** for daemon setup, configuration, ## Credits System -BinktermPHP includes an integrated credits economy that rewards user participation and allows charging for certain actions. Credits can be used to encourage quality content, manage resource usage, and gamify the BBS experience. Configuration is done in `config/bbs.json` under the `credits` section, or via **Admin → BBS Settings → Credits System Configuration**. +BinktermPHP includes an integrated credits economy that rewards user participation and allows charging for certain actions. Credits can be used to encourage quality content, manage resource usage, and gamify the BBS experience. Configuration is done in `config/bbs.json` under the `credits` section, or via **Admin → BBS Settings → Credits System Configuration**. Current built-in actions include login bonuses, message costs/rewards, poll creation cost, and configurable file upload/download costs and rewards. See **[docs/CreditSystem.md](docs/CreditSystem.md)** for default values, configuration options, transaction types, and the developer API. diff --git a/config/bbs.json.example b/config/bbs.json.example index 09fdebcd..15feb69d 100644 --- a/config/bbs.json.example +++ b/config/bbs.json.example @@ -21,6 +21,10 @@ "echomail_reward": 3, "crashmail_cost": 20, "poll_creation_cost": 15, + "file_upload_cost": 0, + "file_upload_reward": 0, + "file_download_cost": 0, + "file_download_reward": 0, "return_14days": 50, "transfer_fee_percent": 0.05, "referral_enabled": false, diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index 006be5c9..f9d04e29 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -1171,6 +1171,14 @@ 'ui.admin.bbs_settings.credits.crashmail_cost_help' => 'Credits charged when sending direct/crashmail delivery.', 'ui.admin.bbs_settings.credits.poll_creation_cost' => 'Poll Creation Cost', 'ui.admin.bbs_settings.credits.poll_creation_cost_help' => 'Credits charged when creating a new poll.', + 'ui.admin.bbs_settings.credits.file_upload_cost' => 'File Upload Cost', + 'ui.admin.bbs_settings.credits.file_upload_cost_help' => 'Credits charged to the acting user when uploading a file.', + 'ui.admin.bbs_settings.credits.file_upload_reward' => 'File Upload Reward', + 'ui.admin.bbs_settings.credits.file_upload_reward_help' => 'Credits awarded to the acting user after a successful file upload.', + 'ui.admin.bbs_settings.credits.file_download_cost' => 'File Download Cost', + 'ui.admin.bbs_settings.credits.file_download_cost_help' => 'Credits charged to the acting user when downloading a file.', + 'ui.admin.bbs_settings.credits.file_download_reward' => 'File Download Reward', + 'ui.admin.bbs_settings.credits.file_download_reward_help' => 'Credits awarded to the acting user after a successful file download.', 'ui.admin.bbs_settings.credits.transfer_fee_percentage' => 'Transfer Fee Percentage', 'ui.admin.bbs_settings.credits.transfer_fee_help' => 'Percentage of credit transfers taken as fee (0.05 = 5%). Distributed to sysops.', 'ui.admin.bbs_settings.credits.referral_system' => 'Referral System', @@ -1192,6 +1200,10 @@ 'ui.admin.bbs_settings.validation.echomail_reward_non_negative' => 'Echomail reward must be a non-negative integer.', 'ui.admin.bbs_settings.validation.crashmail_cost_non_negative' => 'Crashmail cost must be a non-negative integer.', 'ui.admin.bbs_settings.validation.poll_creation_cost_non_negative' => 'Poll creation cost must be a non-negative integer.', + 'ui.admin.bbs_settings.validation.file_upload_cost_non_negative' => 'File upload cost must be a non-negative integer.', + 'ui.admin.bbs_settings.validation.file_upload_reward_non_negative' => 'File upload reward must be a non-negative integer.', + 'ui.admin.bbs_settings.validation.file_download_cost_non_negative' => 'File download cost must be a non-negative integer.', + 'ui.admin.bbs_settings.validation.file_download_reward_non_negative' => 'File download reward must be a non-negative integer.', 'ui.admin.bbs_settings.validation.return_14_days_non_negative' => '14-day return bonus must be a non-negative integer.', 'ui.admin.bbs_settings.validation.transfer_fee_range' => 'Transfer fee must be between 0 and 1 (0% to 100%).', 'ui.admin.bbs_settings.validation.referral_bonus_non_negative' => 'Referral bonus must be a non-negative integer.', diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index 5b488965..0615b9f0 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -153,7 +153,9 @@ 'errors.files.upload.read_only' => 'This file area is read-only', 'errors.files.upload.admin_only' => 'Only administrators can upload files to this area', 'errors.files.upload.virus_detected' => 'File rejected: virus detected', + 'errors.files.upload.insufficient_credits' => 'Insufficient credits to upload this file', 'errors.files.upload.failed' => 'Failed to upload file', + 'errors.files.download.insufficient_credits' => 'Insufficient credits to download this file', // Admin Users 'errors.admin.users.not_found' => 'User not found', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index 22bb46a3..13e07d00 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -1171,6 +1171,14 @@ 'ui.admin.bbs_settings.credits.crashmail_cost_help' => 'Creditos cobrados al enviar entrega directa/crashmail.', 'ui.admin.bbs_settings.credits.poll_creation_cost' => 'Costo de creacion de encuesta', 'ui.admin.bbs_settings.credits.poll_creation_cost_help' => 'Creditos cobrados al crear una nueva encuesta.', + 'ui.admin.bbs_settings.credits.file_upload_cost' => 'Costo de subida de archivos', + 'ui.admin.bbs_settings.credits.file_upload_cost_help' => 'Creditos cobrados al usuario que realiza la subida de un archivo.', + 'ui.admin.bbs_settings.credits.file_upload_reward' => 'Recompensa por subida de archivos', + 'ui.admin.bbs_settings.credits.file_upload_reward_help' => 'Creditos otorgados al usuario que realiza la subida despues de una carga exitosa.', + 'ui.admin.bbs_settings.credits.file_download_cost' => 'Costo de descarga de archivos', + 'ui.admin.bbs_settings.credits.file_download_cost_help' => 'Creditos cobrados al usuario que realiza la descarga de un archivo.', + 'ui.admin.bbs_settings.credits.file_download_reward' => 'Recompensa por descarga de archivos', + 'ui.admin.bbs_settings.credits.file_download_reward_help' => 'Creditos otorgados al usuario que realiza la descarga despues de una descarga exitosa.', 'ui.admin.bbs_settings.credits.transfer_fee_percentage' => 'Porcentaje de tarifa por transferencia', 'ui.admin.bbs_settings.credits.transfer_fee_help' => 'Porcentaje de transferencias de credito tomado como tarifa (0.05 = 5%). Distribuido a sysops.', 'ui.admin.bbs_settings.credits.referral_system' => 'Sistema de referidos', @@ -1192,6 +1200,10 @@ 'ui.admin.bbs_settings.validation.echomail_reward_non_negative' => 'La recompensa de echomail debe ser un entero no negativo.', 'ui.admin.bbs_settings.validation.crashmail_cost_non_negative' => 'El costo de crashmail debe ser un entero no negativo.', 'ui.admin.bbs_settings.validation.poll_creation_cost_non_negative' => 'El costo de creacion de encuesta debe ser un entero no negativo.', + 'ui.admin.bbs_settings.validation.file_upload_cost_non_negative' => 'El costo de subida de archivos debe ser un entero no negativo.', + 'ui.admin.bbs_settings.validation.file_upload_reward_non_negative' => 'La recompensa por subida de archivos debe ser un entero no negativo.', + 'ui.admin.bbs_settings.validation.file_download_cost_non_negative' => 'El costo de descarga de archivos debe ser un entero no negativo.', + 'ui.admin.bbs_settings.validation.file_download_reward_non_negative' => 'La recompensa por descarga de archivos debe ser un entero no negativo.', 'ui.admin.bbs_settings.validation.return_14_days_non_negative' => 'El bono de retorno de 14 dias debe ser un entero no negativo.', 'ui.admin.bbs_settings.validation.transfer_fee_range' => 'La tarifa de transferencia debe estar entre 0 y 1 (0% a 100%).', 'ui.admin.bbs_settings.validation.referral_bonus_non_negative' => 'El bono de referido debe ser un entero no negativo.', diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index e64bf00f..b2cf03a2 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -153,7 +153,9 @@ 'errors.files.upload.read_only' => 'Esta area de archivos es de solo lectura', 'errors.files.upload.admin_only' => 'Solo los administradores pueden subir archivos a esta area', 'errors.files.upload.virus_detected' => 'Archivo rechazado: virus detectado', + 'errors.files.upload.insufficient_credits' => 'No tiene creditos suficientes para subir este archivo', 'errors.files.upload.failed' => 'No se pudo subir el archivo', + 'errors.files.download.insufficient_credits' => 'No tiene creditos suficientes para descargar este archivo', // Admin Users 'errors.admin.users.not_found' => 'Usuario no encontrado', diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index 92fcf6e9..a6ae8e0e 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -1129,6 +1129,14 @@ 'ui.admin.bbs_settings.credits.crashmail_cost_help' => 'Crédits débités lors d\'un envoi direct/crashmail.', 'ui.admin.bbs_settings.credits.poll_creation_cost' => 'Coût de création de sondage', 'ui.admin.bbs_settings.credits.poll_creation_cost_help' => 'Crédits débités lors de la création d\'un nouveau sondage.', + 'ui.admin.bbs_settings.credits.file_upload_cost' => 'Coût de téléversement de fichier', + 'ui.admin.bbs_settings.credits.file_upload_cost_help' => 'Crédits débités à l\'utilisateur effectuant le téléversement d\'un fichier.', + 'ui.admin.bbs_settings.credits.file_upload_reward' => 'Récompense de téléversement de fichier', + 'ui.admin.bbs_settings.credits.file_upload_reward_help' => 'Crédits attribués à l\'utilisateur après un téléversement réussi.', + 'ui.admin.bbs_settings.credits.file_download_cost' => 'Coût de téléchargement de fichier', + 'ui.admin.bbs_settings.credits.file_download_cost_help' => 'Crédits débités à l\'utilisateur effectuant le téléchargement d\'un fichier.', + 'ui.admin.bbs_settings.credits.file_download_reward' => 'Récompense de téléchargement de fichier', + 'ui.admin.bbs_settings.credits.file_download_reward_help' => 'Crédits attribués à l\'utilisateur après un téléchargement réussi.', 'ui.admin.bbs_settings.credits.transfer_fee_percentage' => 'Pourcentage de frais de transfert', 'ui.admin.bbs_settings.credits.transfer_fee_help' => 'Pourcentage des transferts de crédits prélevé en frais (0,05 = 5%). Distribué aux sysops.', 'ui.admin.bbs_settings.credits.referral_system' => 'Système de parrainage', @@ -1150,6 +1158,10 @@ 'ui.admin.bbs_settings.validation.echomail_reward_non_negative' => 'La récompense echomail doit être un entier non négatif.', 'ui.admin.bbs_settings.validation.crashmail_cost_non_negative' => 'Le coût du crashmail doit être un entier non négatif.', 'ui.admin.bbs_settings.validation.poll_creation_cost_non_negative' => 'Le coût de création de sondage doit être un entier non négatif.', + 'ui.admin.bbs_settings.validation.file_upload_cost_non_negative' => 'Le coût de téléversement de fichier doit être un entier non négatif.', + 'ui.admin.bbs_settings.validation.file_upload_reward_non_negative' => 'La récompense de téléversement de fichier doit être un entier non négatif.', + 'ui.admin.bbs_settings.validation.file_download_cost_non_negative' => 'Le coût de téléchargement de fichier doit être un entier non négatif.', + 'ui.admin.bbs_settings.validation.file_download_reward_non_negative' => 'La récompense de téléchargement de fichier doit être un entier non négatif.', 'ui.admin.bbs_settings.validation.return_14_days_non_negative' => 'Le bonus de retour à 14 jours doit être un entier non négatif.', 'ui.admin.bbs_settings.validation.transfer_fee_range' => 'Les frais de transfert doivent être compris entre 0 et 1 (0% à 100%).', 'ui.admin.bbs_settings.validation.referral_bonus_non_negative' => 'Le bonus de parrainage doit être un entier non négatif.', diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index 13c04d7c..d1cefb8d 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -116,7 +116,9 @@ 'errors.files.upload.read_only' => 'Cette zone de fichiers est en lecture seule', 'errors.files.upload.admin_only' => 'Seuls les administrateurs peuvent téléverser des fichiers dans cette zone', 'errors.files.upload.virus_detected' => 'Fichier rejeté : virus détecté', + 'errors.files.upload.insufficient_credits' => 'Crédits insuffisants pour téléverser ce fichier', 'errors.files.upload.failed' => 'Échec du téléversement du fichier', + 'errors.files.download.insufficient_credits' => 'Crédits insuffisants pour télécharger ce fichier', 'errors.admin.users.not_found' => 'Utilisateur introuvable', 'errors.admin.users.create_failed' => 'Échec de la création de l\'utilisateur', 'errors.admin.users.update_failed' => 'Échec de la mise à jour de l\'utilisateur', diff --git a/docs/CreditSystem.md b/docs/CreditSystem.md index ea32aaf8..7645249d 100644 --- a/docs/CreditSystem.md +++ b/docs/CreditSystem.md @@ -21,6 +21,10 @@ BinktermPHP includes an integrated credits economy that rewards user participati | Echomail Posted (approx. 2 paragraphs) | +6 | Bonus | 2× reward for substantial posts (2+ paragraphs) | | Crashmail Sent | -10 | Cost | Direct delivery bypassing uplink | | Poll Creation | -15 | Cost | Creating a new poll in voting booth | +| File Upload | 0 | Cost | Optional charge applied before a file upload | +| File Upload | 0 | Reward | Optional reward applied after a successful upload | +| File Download | 0 | Cost | Optional charge applied before a file download | +| File Download | 0 | Reward | Optional reward applied after a successful download | ## Configuration @@ -37,7 +41,11 @@ Credits are configured in `config/bbs.json` under the `credits` section. All val "netmail_cost": 1, "echomail_reward": 5, "crashmail_cost": 10, - "poll_creation_cost": 15 + "poll_creation_cost": 15, + "file_upload_cost": 0, + "file_upload_reward": 0, + "file_download_cost": 0, + "file_download_reward": 0 } } ``` diff --git a/docs/UPGRADING_1.8.7.md b/docs/UPGRADING_1.8.7.md index de07edbf..82afa471 100644 --- a/docs/UPGRADING_1.8.7.md +++ b/docs/UPGRADING_1.8.7.md @@ -13,6 +13,7 @@ - [psql Instructions](#psql-instructions) - [Notes](#notes) - [Database Statistics Page](#database-statistics-page) +- [Credits System Updates](#credits-system-updates) - [Upgrade Instructions](#upgrade-instructions) - [From Git](#from-git) - [Using the Installer](#using-the-installer) @@ -32,6 +33,8 @@ - New admin **Database Statistics** page (`/admin/database-stats`) showing size and growth, activity metrics, query performance, replication status, maintenance health, and index health. +- Added configurable file upload and file download credit costs/rewards in the + **Credits System Configuration** page. ## Enhanced Message Search @@ -189,6 +192,30 @@ Then add it to `shared_preload_libraries` in `postgresql.conf` and restart PostgreSQL. The statistics page works without it but the slow/frequent query tabs will be empty. +## Credits System Updates + +This release adds four new optional credits settings: + +- `file_upload_cost` +- `file_upload_reward` +- `file_download_cost` +- `file_download_reward` + +These settings are available in **Admin -> BBS Settings -> Credits System Configuration**. + +No database migration is required for these options. They are configuration-only +settings and default to `0` if not present in an older `config/bbs.json`. + +If you manage `config/bbs.json` manually, you can add them under the `credits` +section: + +```json +"file_upload_cost": 0, +"file_upload_reward": 0, +"file_download_cost": 0, +"file_download_reward": 0 +``` + ## Upgrade Instructions ### From Git diff --git a/public_html/sw.js b/public_html/sw.js index cbd1181d..974053df 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v229'; +const CACHE_NAME = 'binkcache-v230'; // Static assets to precache const staticAssets = [ diff --git a/routes/admin-routes.php b/routes/admin-routes.php index 271350cd..6d79f91a 100644 --- a/routes/admin-routes.php +++ b/routes/admin-routes.php @@ -842,6 +842,18 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array if (!is_numeric($credits['poll_creation_cost'] ?? null) || (int)$credits['poll_creation_cost'] < 0) { throw new Exception('Poll creation cost must be a non-negative integer'); } + if (!is_numeric($credits['file_upload_cost'] ?? 0) || (int)($credits['file_upload_cost'] ?? 0) < 0) { + throw new Exception('File upload cost must be a non-negative integer'); + } + if (!is_numeric($credits['file_upload_reward'] ?? 0) || (int)($credits['file_upload_reward'] ?? 0) < 0) { + throw new Exception('File upload reward must be a non-negative integer'); + } + if (!is_numeric($credits['file_download_cost'] ?? 0) || (int)($credits['file_download_cost'] ?? 0) < 0) { + throw new Exception('File download cost must be a non-negative integer'); + } + if (!is_numeric($credits['file_download_reward'] ?? 0) || (int)($credits['file_download_reward'] ?? 0) < 0) { + throw new Exception('File download reward must be a non-negative integer'); + } if (!is_numeric($credits['return_14days'] ?? null) || (int)$credits['return_14days'] < 0) { throw new Exception('14-day return bonus must be a non-negative integer'); } @@ -861,6 +873,10 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array 'echomail_reward' => (int)$credits['echomail_reward'], 'crashmail_cost' => (int)$credits['crashmail_cost'], 'poll_creation_cost' => (int)$credits['poll_creation_cost'], + 'file_upload_cost' => (int)($credits['file_upload_cost'] ?? 0), + 'file_upload_reward' => (int)($credits['file_upload_reward'] ?? 0), + 'file_download_cost' => (int)($credits['file_download_cost'] ?? 0), + 'file_download_reward' => (int)($credits['file_download_reward'] ?? 0), 'return_14days' => (int)$credits['return_14days'], 'transfer_fee_percent' => (float)$credits['transfer_fee_percent'], 'referral_enabled' => !empty($credits['referral_enabled']), diff --git a/routes/api-routes.php b/routes/api-routes.php index 85ecdd70..6455b62c 100644 --- a/routes/api-routes.php +++ b/routes/api-routes.php @@ -2280,6 +2280,36 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array // Properly encode filename for Content-Disposition header (RFC 6266 & RFC 5987) $filename = basename($file['filename']); $encodedFilename = rawurlencode($filename); + $downloadCost = UserCredit::isEnabled() ? UserCredit::getCreditCost('file_download', 0) : 0; + $downloadReward = UserCredit::isEnabled() ? UserCredit::getRewardAmount('file_download', 0) : 0; + + if ($downloadCost > 0) { + $debitSuccess = UserCredit::debit( + (int)$userId, + $downloadCost, + "Downloaded file: {$filename}", + null, + UserCredit::TYPE_PAYMENT + ); + if (!$debitSuccess) { + http_response_code(402); + echo apiLocalizedText('errors.files.download.insufficient_credits', 'Insufficient credits to download this file', $user); + return; + } + } + + if ($downloadReward > 0) { + $creditSuccess = UserCredit::credit( + (int)$userId, + $downloadReward, + "Download reward: {$filename}", + null, + UserCredit::TYPE_SYSTEM_REWARD + ); + if (!$creditSuccess) { + error_log("Failed to award file download credits for user {$userId} and file {$id}"); + } + } ActivityTracker::track($userId, ActivityTracker::TYPE_FILE_DOWNLOAD, (int)$id, $filename); @@ -2438,6 +2468,10 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array header('Content-Type: application/json'); + $ownerId = (int)($user['user_id'] ?? $user['id'] ?? 0); + $uploadCostCharged = false; + $uploadCost = 0; + try { if (!isset($_FILES['file'])) { throw new \Exception('No file uploaded'); @@ -2475,7 +2509,23 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array // Get user's FidoNet address or username $uploadedBy = $user['username'] ?? 'Unknown'; - $ownerId = $user['user_id'] ?? $user['id'] ?? null; + $ownerId = (int)($user['user_id'] ?? $user['id'] ?? 0); + $uploadCost = UserCredit::isEnabled() ? UserCredit::getCreditCost('file_upload', 0) : 0; + $uploadReward = UserCredit::isEnabled() ? UserCredit::getRewardAmount('file_upload', 0) : 0; + + if ($uploadCost > 0) { + $uploadCostCharged = UserCredit::debit( + $ownerId, + $uploadCost, + "Uploaded file cost: " . ($_FILES['file']['name'] ?? 'unknown'), + null, + UserCredit::TYPE_PAYMENT + ); + if (!$uploadCostCharged) { + throw new \Exception('Insufficient credits for file upload'); + } + } + $fileId = $manager->uploadFile( $fileAreaId, $_FILES['file'], @@ -2485,6 +2535,19 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $ownerId ); + if ($uploadReward > 0) { + $creditSuccess = UserCredit::credit( + $ownerId, + $uploadReward, + "Upload reward: " . ($_FILES['file']['name'] ?? 'unknown'), + null, + UserCredit::TYPE_SYSTEM_REWARD + ); + if (!$creditSuccess) { + error_log("Failed to award file upload credits for user {$ownerId} and file {$fileId}"); + } + } + ActivityTracker::track($ownerId, ActivityTracker::TYPE_FILE_UPLOAD, (int)$fileId, $_FILES['file']['name'] ?? null, ['file_area_id' => $fileAreaId]); echo json_encode([ @@ -2494,6 +2557,16 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array ]); } catch (\Exception $e) { + if ($uploadCostCharged && $uploadCost > 0) { + UserCredit::credit( + $ownerId, + $uploadCost, + 'Refund: File upload failed', + null, + UserCredit::TYPE_REFUND + ); + } + http_response_code(400); $message = $e->getMessage(); if ($message === 'No file uploaded') { @@ -2508,6 +2581,8 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array apiError('errors.files.upload.read_only', apiLocalizedText('errors.files.upload.read_only', 'This file area is read-only', $user)); } elseif ($message === 'Only administrators can upload files to this area.') { apiError('errors.files.upload.admin_only', apiLocalizedText('errors.files.upload.admin_only', 'Only administrators can upload files to this area', $user)); + } elseif ($message === 'Insufficient credits for file upload') { + apiError('errors.files.upload.insufficient_credits', apiLocalizedText('errors.files.upload.insufficient_credits', 'Insufficient credits to upload this file', $user), 402); } elseif ($message === 'File rejected: virus detected.') { \BinktermPHP\Admin\AdminDaemonClient::log('WARNING', 'Infected file upload rejected', [ 'username' => $user['username'] ?? 'unknown', diff --git a/scripts/database_maintenance.php b/scripts/database_maintenance.php index fddf9df9..9daff11d 100644 --- a/scripts/database_maintenance.php +++ b/scripts/database_maintenance.php @@ -7,16 +7,18 @@ * Performs routine cleanup and maintenance on various database tables. * Run this script periodically (e.g., daily via cron) to keep the database clean. * - * Usage: php scripts/database_maintenance.php [--verbose] [--dry-run] + * Usage: php scripts/database_maintenance.php [--verbose] [--dry-run] [-r|--registration-attempts] */ require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../src/functions.php'; use BinktermPHP\Database; // Parse command line arguments $verbose = in_array('--verbose', $argv) || in_array('-v', $argv); $dryRun = in_array('--dry-run', $argv); +$cleanRegistrationAttempts = in_array('--registration-attempts', $argv) || in_array('-r', $argv); if ($dryRun) { echo "DRY RUN MODE - No changes will be made\n"; @@ -37,35 +39,39 @@ // ======================================================================== echo "[1] Cleaning old registration attempts...\n"; - // Check if table exists first - $tableCheck = $db->query(" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'registration_attempts' - ) - "); + if (!$cleanRegistrationAttempts) { + echo " Skipping by default; pass -r or --registration-attempts to enable\n"; + } else { + // Check if table exists first + $tableCheck = $db->query(" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'registration_attempts' + ) + "); - if ($tableCheck->fetchColumn()) { - if ($dryRun) { - $stmt = $db->query(" - SELECT COUNT(*) as count - FROM registration_attempts - WHERE attempt_time < NOW() - INTERVAL '30 days' - "); - $result = $stmt->fetch(); - echo " Would delete {$result['count']} registration attempts older than 30 days\n"; + if ($tableCheck->fetchColumn()) { + if ($dryRun) { + $stmt = $db->query(" + SELECT COUNT(*) as count + FROM registration_attempts + WHERE attempt_time < NOW() - INTERVAL '30 days' + "); + $result = $stmt->fetch(); + echo " Would delete {$result['count']} registration attempts older than 30 days\n"; + } else { + $stmt = $db->prepare(" + DELETE FROM registration_attempts + WHERE attempt_time < NOW() - INTERVAL '30 days' + "); + $stmt->execute(); + $deleted = $stmt->rowCount(); + $totalCleaned += $deleted; + echo " Deleted $deleted old registration attempts\n"; + } } else { - $stmt = $db->prepare(" - DELETE FROM registration_attempts - WHERE attempt_time < NOW() - INTERVAL '30 days' - "); - $stmt->execute(); - $deleted = $stmt->rowCount(); - $totalCleaned += $deleted; - echo " Deleted $deleted old registration attempts\n"; + echo " Table 'registration_attempts' does not exist, skipping\n"; } - } else { - echo " Table 'registration_attempts' does not exist, skipping\n"; } // ======================================================================== @@ -153,15 +159,15 @@ } // ======================================================================== - // 5. Clean up expired webshare links + // 5. Clean up expired shared message links // ======================================================================== - echo "\n[5] Cleaning expired webshare links...\n"; + echo "\n[5] Cleaning expired shared message links...\n"; // Check if table exists first $tableCheck = $db->query(" SELECT EXISTS ( SELECT FROM information_schema.tables - WHERE table_name = 'webshare' + WHERE table_name = 'shared_messages' ) "); @@ -169,31 +175,69 @@ if ($dryRun) { $stmt = $db->query(" SELECT COUNT(*) as count - FROM webshare + FROM shared_messages WHERE expires_at IS NOT NULL AND expires_at < NOW() "); $result = $stmt->fetch(); - echo " Would delete {$result['count']} expired webshare links\n"; + echo " Would delete {$result['count']} expired shared message links\n"; } else { $stmt = $db->prepare(" - DELETE FROM webshare + DELETE FROM shared_messages WHERE expires_at IS NOT NULL AND expires_at < NOW() "); $stmt->execute(); $deleted = $stmt->rowCount(); $totalCleaned += $deleted; - echo " Deleted $deleted expired webshare links\n"; + echo " Deleted $deleted expired shared message links\n"; } } else { - echo " Table 'webshare' does not exist, skipping\n"; + echo " Table 'shared_messages' does not exist, skipping\n"; + } + + // ======================================================================== + // 6. Clean up expired shared file links + // ======================================================================== + echo "\n[6] Cleaning expired shared file links...\n"; + + $tableCheck = $db->query(" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'shared_files' + ) + "); + + if ($tableCheck->fetchColumn()) { + if ($dryRun) { + $stmt = $db->query(" + SELECT COUNT(*) as count + FROM shared_files + WHERE expires_at IS NOT NULL AND expires_at < NOW() + "); + $result = $stmt->fetch(); + echo " Would deactivate {$result['count']} expired shared file links\n"; + } else { + $stmt = $db->prepare(" + UPDATE shared_files + SET is_active = FALSE + WHERE is_active = TRUE + AND expires_at IS NOT NULL + AND expires_at < NOW() + "); + $stmt->execute(); + $deactivated = $stmt->rowCount(); + $totalCleaned += $deactivated; + echo " Deactivated $deactivated expired shared file links\n"; + } + } else { + echo " Table 'shared_files' does not exist, skipping\n"; } // ======================================================================== - // 6. Clean up old rejected pending users (older than 90 days) + // 7. Clean up old rejected pending users (older than 90 days) // ======================================================================== if(0) { // TODO: Check if .env variable is set and use for number of days - echo "\n[6] Cleaning old rejected pending users...\n"; + echo "\n[7] Cleaning old rejected pending users...\n"; if ($dryRun) { $stmt = $db->query(" @@ -216,42 +260,6 @@ echo " Deleted $deleted old rejected pending users\n"; } } - // ======================================================================== - // 7. Clean up old login attempts (older than 30 days) - // ======================================================================== - echo "\n[7] Cleaning old login attempts...\n"; - - // Check if table exists first - $tableCheck = $db->query(" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'login_attempts' - ) - "); - - if ($tableCheck->fetchColumn()) { - if ($dryRun) { - $stmt = $db->query(" - SELECT COUNT(*) as count - FROM login_attempts - WHERE attempt_time < NOW() - INTERVAL '30 days' - "); - $result = $stmt->fetch(); - echo " Would delete {$result['count']} old login attempts\n"; - } else { - $stmt = $db->prepare(" - DELETE FROM login_attempts - WHERE attempt_time < NOW() - INTERVAL '30 days' - "); - $stmt->execute(); - $deleted = $stmt->rowCount(); - $totalCleaned += $deleted; - echo " Deleted $deleted old login attempts\n"; - } - } else { - echo " Table 'login_attempts' does not exist, skipping\n"; - } - // ======================================================================== // 8. PostgreSQL VACUUM and ANALYZE (if not dry run) // ======================================================================== diff --git a/src/UserCredit.php b/src/UserCredit.php index 3f009e65..bbc71ec5 100644 --- a/src/UserCredit.php +++ b/src/UserCredit.php @@ -350,6 +350,10 @@ public static function getCreditsConfig(): array 'echomail_reward' => 3, 'crashmail_cost' => 10, 'poll_creation_cost' => 15, + 'file_upload_cost' => 0, + 'file_upload_reward' => 0, + 'file_download_cost' => 0, + 'file_download_reward' => 0, 'return_14days' => 50, 'transfer_fee_percent' => 0.05, 'referral_enabled' => false, @@ -370,6 +374,10 @@ public static function getCreditsConfig(): array $merged['echomail_reward'] = max(0, (int)$merged['echomail_reward']); $merged['crashmail_cost'] = max(0, (int)$merged['crashmail_cost']); $merged['poll_creation_cost'] = max(0, (int)$merged['poll_creation_cost']); + $merged['file_upload_cost'] = max(0, (int)$merged['file_upload_cost']); + $merged['file_upload_reward'] = max(0, (int)$merged['file_upload_reward']); + $merged['file_download_cost'] = max(0, (int)$merged['file_download_cost']); + $merged['file_download_reward'] = max(0, (int)$merged['file_download_reward']); $merged['return_14days'] = max(0, (int)$merged['return_14days']); $merged['transfer_fee_percent'] = max(0, min(1, (float)$merged['transfer_fee_percent'])); $merged['referral_enabled'] = !empty($merged['referral_enabled']); diff --git a/templates/admin/bbs_settings.twig b/templates/admin/bbs_settings.twig index b5572db9..a25e9819 100644 --- a/templates/admin/bbs_settings.twig +++ b/templates/admin/bbs_settings.twig @@ -178,6 +178,26 @@ {{ t('ui.admin.bbs_settings.credits.poll_creation_cost_help', {}, locale, ['common']) }} +
+ + + {{ t('ui.admin.bbs_settings.credits.file_upload_cost_help', {}, locale, ['common']) }} +
+
+ + + {{ t('ui.admin.bbs_settings.credits.file_upload_reward_help', {}, locale, ['common']) }} +
+
+ + + {{ t('ui.admin.bbs_settings.credits.file_download_cost_help', {}, locale, ['common']) }} +
+
+ + + {{ t('ui.admin.bbs_settings.credits.file_download_reward_help', {}, locale, ['common']) }} +
@@ -268,6 +288,10 @@ function loadBbsSettings() { const echomailRewardValue = parseInt(credits.echomail_reward, 10); const crashmailCostValue = parseInt(credits.crashmail_cost, 10); const pollCreationCostValue = parseInt(credits.poll_creation_cost, 10); + const fileUploadCostValue = parseInt(credits.file_upload_cost, 10); + const fileUploadRewardValue = parseInt(credits.file_upload_reward, 10); + const fileDownloadCostValue = parseInt(credits.file_download_cost, 10); + const fileDownloadRewardValue = parseInt(credits.file_download_reward, 10); const new14DaysValue = parseInt(credits.return_14days, 10); const transferFeeValue = parseFloat(credits.transfer_fee_percent); document.getElementById('bbsCreditsDailyAmount').value = Number.isNaN(dailyAmountValue) ? 25 : dailyAmountValue; @@ -277,6 +301,10 @@ function loadBbsSettings() { document.getElementById('bbsCreditsEchomailReward').value = Number.isNaN(echomailRewardValue) ? 3 : echomailRewardValue; document.getElementById('bbsCreditsCrashmailCost').value = Number.isNaN(crashmailCostValue) ? 10 : crashmailCostValue; document.getElementById('bbsCreditsPollCreationCost').value = Number.isNaN(pollCreationCostValue) ? 15 : pollCreationCostValue; + document.getElementById('bbsCreditsFileUploadCost').value = Number.isNaN(fileUploadCostValue) ? 0 : fileUploadCostValue; + document.getElementById('bbsCreditsFileUploadReward').value = Number.isNaN(fileUploadRewardValue) ? 0 : fileUploadRewardValue; + document.getElementById('bbsCreditsFileDownloadCost').value = Number.isNaN(fileDownloadCostValue) ? 0 : fileDownloadCostValue; + document.getElementById('bbsCreditsFileDownloadReward').value = Number.isNaN(fileDownloadRewardValue) ? 0 : fileDownloadRewardValue; document.getElementById('bbsCreditsNew14Days').value = Number.isNaN(new14DaysValue) ? 50 : new14DaysValue; document.getElementById('bbsCreditsTransferFee').value = Number.isNaN(transferFeeValue) ? 0.05 : transferFeeValue; @@ -439,6 +467,30 @@ function saveBbsCredits() { return; } + const fileUploadCost = parseInt(document.getElementById('bbsCreditsFileUploadCost').value, 10); + if (Number.isNaN(fileUploadCost) || fileUploadCost < 0) { + showBbsCreditsAlert(uiT('ui.admin.bbs_settings.validation.file_upload_cost_non_negative', 'File upload cost must be a non-negative integer.'), 'danger'); + return; + } + + const fileUploadReward = parseInt(document.getElementById('bbsCreditsFileUploadReward').value, 10); + if (Number.isNaN(fileUploadReward) || fileUploadReward < 0) { + showBbsCreditsAlert(uiT('ui.admin.bbs_settings.validation.file_upload_reward_non_negative', 'File upload reward must be a non-negative integer.'), 'danger'); + return; + } + + const fileDownloadCost = parseInt(document.getElementById('bbsCreditsFileDownloadCost').value, 10); + if (Number.isNaN(fileDownloadCost) || fileDownloadCost < 0) { + showBbsCreditsAlert(uiT('ui.admin.bbs_settings.validation.file_download_cost_non_negative', 'File download cost must be a non-negative integer.'), 'danger'); + return; + } + + const fileDownloadReward = parseInt(document.getElementById('bbsCreditsFileDownloadReward').value, 10); + if (Number.isNaN(fileDownloadReward) || fileDownloadReward < 0) { + showBbsCreditsAlert(uiT('ui.admin.bbs_settings.validation.file_download_reward_non_negative', 'File download reward must be a non-negative integer.'), 'danger'); + return; + } + const new14Days = parseInt(document.getElementById('bbsCreditsNew14Days').value, 10); if (Number.isNaN(new14Days) || new14Days < 0) { showBbsCreditsAlert(uiT('ui.admin.bbs_settings.validation.return_14_days_non_negative', '14-day return bonus must be a non-negative integer.'), 'danger'); @@ -475,6 +527,10 @@ function saveBbsCredits() { echomail_reward: echomailReward, crashmail_cost: crashmailCost, poll_creation_cost: pollCreationCost, + file_upload_cost: fileUploadCost, + file_upload_reward: fileUploadReward, + file_download_cost: fileDownloadCost, + file_download_reward: fileDownloadReward, return_14days: new14Days, transfer_fee_percent: transferFee, referral_enabled: document.getElementById('bbsCreditsReferralEnabled').checked, From 0180d5cc2c63caa1b3e435d11e7759f6504b1feb Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 11:48:32 -0700 Subject: [PATCH 014/246] Fix partial binkp frame writes --- src/Binkp/Protocol/BinkpFrame.php | 49 ++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/Binkp/Protocol/BinkpFrame.php b/src/Binkp/Protocol/BinkpFrame.php index a8a519be..3e0bb72e 100644 --- a/src/Binkp/Protocol/BinkpFrame.php +++ b/src/Binkp/Protocol/BinkpFrame.php @@ -181,31 +181,46 @@ public function writeToSocket($socket) if ($this->isCommand) { $lengthAndFlags |= self::COMMAND_FRAME; } - - $header = pack('n', $lengthAndFlags); - $written = @fwrite($socket, $header); - if ($written === false) { - return; + + $buffer = pack('n', $lengthAndFlags); + + if ($this->isCommand) { + $buffer .= chr($this->command); } - - if ($this->isCommand && $this->length > 0) { - if (@fwrite($socket, chr($this->command)) === false) { - return; - } - if (strlen($this->data) > 0) { - if (@fwrite($socket, $this->data) === false) { - return; - } - } - } elseif (!$this->isCommand && $this->length > 0) { - @fwrite($socket, $this->data); + + if ($this->length > 0 && $this->data !== '') { + $buffer .= $this->data; } + + self::writeAll($socket, $buffer); // Force immediate transmission of frames if (is_resource($socket)) { fflush($socket); } } + + private static function writeAll($socket, string $buffer): void + { + $offset = 0; + $length = strlen($buffer); + + while ($offset < $length) { + $written = @fwrite($socket, substr($buffer, $offset)); + if ($written === false || $written === 0) { + $meta = stream_get_meta_data($socket); + $timedOut = !empty($meta['timed_out']); + $eof = !empty($meta['eof']); + throw new \Exception( + 'Failed to write complete frame' + . ' (written=' . $offset . '/' . $length + . ', timed_out=' . ($timedOut ? 'yes' : 'no') + . ', eof=' . ($eof ? 'yes' : 'no') . ')' + ); + } + $offset += $written; + } + } public function isCommand() { From 5393c48e89a62b0f8d5cacb0a0cffa34de460232 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 11:58:26 -0700 Subject: [PATCH 015/246] Improve binkp handshake diagnostics and socket tuning --- .env.example | 5 ++++ src/Binkp/Protocol/BinkpClient.php | 14 +++++++++++ src/Binkp/Protocol/BinkpFrame.php | 39 ++++++++++++++++++++++++----- src/Binkp/Protocol/BinkpServer.php | 14 +++++++++++ src/Binkp/Protocol/BinkpSession.php | 35 ++++++++++++++++++++++++-- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index fe80c386..d15319be 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,11 @@ PERF_LOG_SLOW_MS=500 # Use {archive} and {dest} placeholders. # ARCMAIL_EXTRACTORS=["7z x -y -o{dest} {archive}","unzip -o {archive} -d {dest}"] +# BinkP socket tuning +# Disable Nagle's algorithm by default to reduce latency for small handshake/control frames. +# Set to false only if you need to troubleshoot interoperability with a specific peer/network stack. +# BINKP_TCP_NODELAY=true + # PID File locations (defaults to data/run) # ADMIN_DAEMON_PID_FILE=data/run/admin_daemon.pid diff --git a/src/Binkp/Protocol/BinkpClient.php b/src/Binkp/Protocol/BinkpClient.php index 41c42ab3..8679db3d 100644 --- a/src/Binkp/Protocol/BinkpClient.php +++ b/src/Binkp/Protocol/BinkpClient.php @@ -17,6 +17,7 @@ namespace BinktermPHP\Binkp\Protocol; use BinktermPHP\Binkp\Config\BinkpConfig; +use BinktermPHP\Config; class BinkpClient { @@ -67,6 +68,7 @@ public function connect($address, $hostname = null, $port = null, $password = nu socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $this->config->getBinkpTimeout(), 'usec' => 0]); socket_set_option($socket, SOL_SOCKET, SO_SNDTIMEO, ['sec' => $this->config->getBinkpTimeout(), 'usec' => 0]); + $this->configureTcpNoDelay($socket); $result = socket_connect($socket, $hostname, $port); if ($result === false) { @@ -185,6 +187,7 @@ public function testConnection($hostname, $port = 24554, $timeout = 30) socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $timeout, 'usec' => 0]); socket_set_option($socket, SOL_SOCKET, SO_SNDTIMEO, ['sec' => $timeout, 'usec' => 0]); + $this->configureTcpNoDelay($socket); $startTime = microtime(true); $result = socket_connect($socket, $hostname, $port); @@ -229,6 +232,17 @@ private function socketToStream($socket) return $socketResource; } + + private function configureTcpNoDelay($socket): void + { + if (!defined('TCP_NODELAY')) { + return; + } + + $raw = strtolower(trim((string)Config::env('BINKP_TCP_NODELAY', 'true'))); + $enabled = !in_array($raw, ['0', 'false', 'no', 'off'], true); + @socket_set_option($socket, SOL_TCP, TCP_NODELAY, $enabled ? 1 : 0); + } public function sendFile($address, $filePath) { diff --git a/src/Binkp/Protocol/BinkpFrame.php b/src/Binkp/Protocol/BinkpFrame.php index 3e0bb72e..15c39cb4 100644 --- a/src/Binkp/Protocol/BinkpFrame.php +++ b/src/Binkp/Protocol/BinkpFrame.php @@ -38,6 +38,7 @@ class BinkpFrame private $isCommand; private $command; private $data; + private static $lastReadDiagnostics = null; public function __construct($length = 0, $isCommand = false, $command = 0, $data = '') { @@ -61,6 +62,8 @@ public static function createData($data) public static function parseFromSocket($socket, $nonBlocking = false) { + self::$lastReadDiagnostics = null; + if ($nonBlocking) { // Check if data is available before attempting to read $read = [$socket]; @@ -73,7 +76,7 @@ public static function parseFromSocket($socket, $nonBlocking = false) } } - $header = self::readExactly($socket, 2); + $header = self::readExactly($socket, 2, 'header'); if (strlen($header) < 2) { return null; } @@ -89,7 +92,7 @@ public static function parseFromSocket($socket, $nonBlocking = false) $payload = ''; if ($length > 0) { if ($isCommand) { - $commandByte = self::readExactly($socket, 1); + $commandByte = self::readExactly($socket, 1, 'command'); if (strlen($commandByte) < 1) { return null; } @@ -97,7 +100,7 @@ public static function parseFromSocket($socket, $nonBlocking = false) $length--; if ($length > 0) { - $payload = self::readExactly($socket, $length); + $payload = self::readExactly($socket, $length, 'payload'); if (strlen($payload) < $length) { return null; } @@ -105,7 +108,7 @@ public static function parseFromSocket($socket, $nonBlocking = false) return new self($length + 1, true, $command, $payload); } else { - $payload = self::readExactly($socket, $length); + $payload = self::readExactly($socket, $length, 'payload'); if (strlen($payload) < $length) { return null; } @@ -114,7 +117,7 @@ public static function parseFromSocket($socket, $nonBlocking = false) } if ($isCommand) { - $commandByte = self::readExactly($socket, 1); + $commandByte = self::readExactly($socket, 1, 'command'); if (strlen($commandByte) < 1) { return null; } @@ -124,7 +127,7 @@ public static function parseFromSocket($socket, $nonBlocking = false) return new self($length, $isCommand, 0, ''); } - private static function readExactly($socket, $length) + private static function readExactly($socket, $length, string $phase = 'read') { $data = ''; $remaining = $length; @@ -137,6 +140,7 @@ private static function readExactly($socket, $length) // Check for stream errors or EOF if ($chunk === false) { // Actual error occurred + self::$lastReadDiagnostics = self::buildReadDiagnostics($socket, $phase, $length, strlen($data), 'read_error'); break; } @@ -146,16 +150,19 @@ private static function readExactly($socket, $length) $meta = stream_get_meta_data($socket); if ($meta['timed_out']) { // Stream timed out - this is a real timeout + self::$lastReadDiagnostics = self::buildReadDiagnostics($socket, $phase, $length, strlen($data), 'timeout'); break; } if ($meta['eof']) { // Connection closed + self::$lastReadDiagnostics = self::buildReadDiagnostics($socket, $phase, $length, strlen($data), 'eof'); break; } // Temporary empty read - retry a few times $retries++; if ($retries >= $maxRetries) { + self::$lastReadDiagnostics = self::buildReadDiagnostics($socket, $phase, $length, strlen($data), 'empty_read_retries_exhausted'); break; } usleep(10000); // 10ms delay before retry @@ -170,6 +177,26 @@ private static function readExactly($socket, $length) return $data; } + + private static function buildReadDiagnostics($socket, string $phase, int $requested, int $received, string $reason): array + { + $meta = is_resource($socket) ? stream_get_meta_data($socket) : []; + + return [ + 'phase' => $phase, + 'requested' => $requested, + 'received' => $received, + 'reason' => $reason, + 'timed_out' => !empty($meta['timed_out']), + 'eof' => !empty($meta['eof']), + 'blocked' => !empty($meta['blocked']), + ]; + } + + public static function getLastReadDiagnostics(): ?array + { + return self::$lastReadDiagnostics; + } public function writeToSocket($socket) { diff --git a/src/Binkp/Protocol/BinkpServer.php b/src/Binkp/Protocol/BinkpServer.php index 0ba4ae7f..5eef9f4c 100644 --- a/src/Binkp/Protocol/BinkpServer.php +++ b/src/Binkp/Protocol/BinkpServer.php @@ -18,6 +18,7 @@ use BinktermPHP\Binkp\Config\BinkpConfig; use BinktermPHP\Admin\AdminDaemonClient; +use BinktermPHP\Config; class BinkpServer { @@ -129,6 +130,8 @@ private function acceptConnection() return; } + $this->configureTcpNoDelay($clientSocket); + // Check max connections (count active child processes) $this->reapChildren(); if (count($this->childPids) >= $this->config->getMaxConnections()) { @@ -261,6 +264,17 @@ private function socketToStream($socket) return $socketResource; } + + private function configureTcpNoDelay($socket): void + { + if (!defined('TCP_NODELAY')) { + return; + } + + $raw = strtolower(trim((string)Config::env('BINKP_TCP_NODELAY', 'true'))); + $enabled = !in_array($raw, ['0', 'false', 'no', 'off'], true); + @socket_set_option($socket, SOL_TCP, TCP_NODELAY, $enabled ? 1 : 0); + } public function stop() { diff --git a/src/Binkp/Protocol/BinkpSession.php b/src/Binkp/Protocol/BinkpSession.php index c61fdd7a..cc82cfbb 100644 --- a/src/Binkp/Protocol/BinkpSession.php +++ b/src/Binkp/Protocol/BinkpSession.php @@ -148,7 +148,11 @@ public function handshake() } catch (\Exception $e) { $this->log("Handshake failed: " . $e->getMessage(), 'ERROR'); - $this->sendError($e->getMessage()); + if ($this->shouldSendHandshakeError()) { + $this->sendError($e->getMessage()); + } else { + $this->log("Skipping handshake M_ERR because remote side already closed the connection", 'DEBUG'); + } // Re-throw with the actual error message so caller can see it throw new \Exception('Handshake failed: ' . $e->getMessage(), 0, $e); } @@ -189,8 +193,9 @@ private function buildHandshakeSocketDiagnostics(): string $eof = !empty($meta['eof']) ? 'yes' : 'no'; $blocked = !empty($meta['blocked']) ? 'yes' : 'no'; $preview = $this->getHandshakeSocketPreview(); + $read = $this->formatLastReadDiagnostics(); - return "(timed_out={$timedOut}, eof={$eof}, blocked={$blocked}, {$preview})"; + return "(timed_out={$timedOut}, eof={$eof}, blocked={$blocked}, {$preview}, {$read})"; } private function getHandshakeSocketPreview(int $maxBytes = 24): string @@ -211,6 +216,32 @@ private function getHandshakeSocketPreview(int $maxBytes = 24): string return 'preview_hex=' . $hex . ', preview_ascii="' . $ascii . '"'; } + private function formatLastReadDiagnostics(): string + { + $diag = BinkpFrame::getLastReadDiagnostics(); + if (!is_array($diag)) { + return 'read_diag=none'; + } + + return sprintf( + 'read_diag_phase=%s, read_diag_reason=%s, read_diag_received=%d/%d', + $diag['phase'] ?? 'unknown', + $diag['reason'] ?? 'unknown', + (int)($diag['received'] ?? 0), + (int)($diag['requested'] ?? 0) + ); + } + + private function shouldSendHandshakeError(): bool + { + if (!is_resource($this->socket)) { + return false; + } + + $meta = stream_get_meta_data($this->socket); + return empty($meta['eof']); + } + public function processSession() { try { From 0f7c7c9d0dea1a24a68c842b3e87c9fcfc854532 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 13:12:45 -0700 Subject: [PATCH 016/246] Fix index sizes query using wrong pg_stat_user_indexes column names Co-Authored-By: Claude Sonnet 4.6 --- src/DatabaseStats.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DatabaseStats.php b/src/DatabaseStats.php index 734c5e18..8db1bc4a 100644 --- a/src/DatabaseStats.php +++ b/src/DatabaseStats.php @@ -93,7 +93,7 @@ public function getSizeAndGrowth(): array // Index sizes try { $stmt = $this->db->query( - "SELECT indexname, tablename, + "SELECT indexrelname AS indexname, relname AS tablename, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, pg_relation_size(indexrelid) AS size_bytes FROM pg_stat_user_indexes From 4c01702132f859b50992b28d47e5e7110de19362 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 13:14:58 -0700 Subject: [PATCH 017/246] Truncate long index/table names in db stats with tooltip for full name Co-Authored-By: Claude Sonnet 4.6 --- templates/admin/database_stats.twig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/admin/database_stats.twig b/templates/admin/database_stats.twig index 0e1f73d5..104761cc 100644 --- a/templates/admin/database_stats.twig +++ b/templates/admin/database_stats.twig @@ -129,8 +129,8 @@ {% for row in size.index_sizes %} - {{ row.indexname }} - {{ row.tablename }} + 30 %} title="{{ row.indexname }}"{% endif %}>{{ row.indexname|length > 30 ? row.indexname|slice(0, 30) ~ '…' : row.indexname }} + 20 %} title="{{ row.tablename }}"{% endif %}>{{ row.tablename|length > 20 ? row.tablename|slice(0, 20) ~ '…' : row.tablename }} {{ row.index_size }} {% endfor %} From f96d5ff4e37b83902dbec7094cb9615b1d10b602 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 13:18:25 -0700 Subject: [PATCH 018/246] Add partial index on mrc_outbound to eliminate seq scans The daemon polls every 100ms accumulating millions of seq scans. The old index (sent_at, priority) didn't match ORDER BY priority DESC, created_at ASC so PostgreSQL chose seq scans. The new partial index covers only unsent rows with the exact sort order, enabling efficient index scans. Co-Authored-By: Claude Sonnet 4.6 --- .../v1.11.0.14_mrc_outbound_partial_index.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 database/migrations/v1.11.0.14_mrc_outbound_partial_index.sql diff --git a/database/migrations/v1.11.0.14_mrc_outbound_partial_index.sql b/database/migrations/v1.11.0.14_mrc_outbound_partial_index.sql new file mode 100644 index 00000000..9a33230f --- /dev/null +++ b/database/migrations/v1.11.0.14_mrc_outbound_partial_index.sql @@ -0,0 +1,15 @@ +-- Migration: v1.11.0.14 - Replace mrc_outbound pending index with a partial index +-- +-- The daemon polls mrc_outbound every 100ms with: +-- WHERE sent_at IS NULL ORDER BY priority DESC, created_at ASC LIMIT 10 +-- +-- The old index (sent_at, priority) didn't match the ORDER BY, so PostgreSQL +-- fell back to a seq scan on the (usually tiny) table, accumulating millions +-- of seq scans over time. A partial index covering only unsent rows with the +-- exact sort order allows PostgreSQL to use an index scan with no extra sort. + +DROP INDEX IF EXISTS idx_mrc_outbound_pending; + +CREATE INDEX IF NOT EXISTS idx_mrc_outbound_unsent + ON mrc_outbound (priority DESC, created_at ASC) + WHERE sent_at IS NULL; From 36ddd8dbbdb1761d126e69a35157d14fa8232ad2 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 13:34:50 -0700 Subject: [PATCH 019/246] Fix seq scans on users, shared_messages, saved_messages, and chat_messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit users: add UNIQUE LOWER(username) index — covers login queries and OR-based name-matching in mail delivery that were forcing seq scans on every auth request. shared_messages: replace (message_id, message_type) index with composite (message_id, message_type, shared_by_user_id) WHERE is_active = TRUE to cover the LEFT JOIN on every echomail listing page load. saved_messages: replace (message_id, message_type) index with composite (message_id, message_type, user_id) matching the echomail-driven LEFT JOIN. chat_messages: replace total-count polling with incremental max-ID approach. The old query counted ALL messages on every 30s poll (full table scan with OR across three columns). New approach queries only rows newer than the last seen message ID, using the PK index. Stores last_chat_max_id in user meta; JS passes chat_max_id from stats response to markSeen. Co-Authored-By: Claude Sonnet 4.6 --- .../v1.11.0.15_users_lower_username_index.sql | 16 ++++++ ...1.0.16_shared_messages_composite_index.sql | 19 +++++++ ..._saved_messages_message_id_first_index.sql | 17 +++++++ public_html/js/chat-notify.js | 2 +- public_html/sw.js | 2 +- routes/api-routes.php | 51 ++++++++++++------- 6 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 database/migrations/v1.11.0.15_users_lower_username_index.sql create mode 100644 database/migrations/v1.11.0.16_shared_messages_composite_index.sql create mode 100644 database/migrations/v1.11.0.17_saved_messages_message_id_first_index.sql diff --git a/database/migrations/v1.11.0.15_users_lower_username_index.sql b/database/migrations/v1.11.0.15_users_lower_username_index.sql new file mode 100644 index 00000000..0672a6c2 --- /dev/null +++ b/database/migrations/v1.11.0.15_users_lower_username_index.sql @@ -0,0 +1,16 @@ +-- Migration: v1.11.0.15 - Add case-insensitive functional index on users.username +-- +-- Several hot-path queries use LOWER(username) comparisons but no functional +-- index existed, forcing PostgreSQL to seq scan the users table: +-- +-- 1. Auth::login() WHERE LOWER(username) = LOWER(?) AND is_active = TRUE +-- 2. BinkdProcessor WHERE LOWER(real_name) = LOWER(?) OR LOWER(username) = LOWER(?) +-- 3. MessageHandler WHERE LOWER(real_name) = LOWER(?) OR LOWER(username) = LOWER(?) +-- 4. Collision trigger WHERE LOWER(username) = LOWER(NEW.real_name) OR ... +-- +-- A UNIQUE index on LOWER(username) covers all of the above: plain lookups use +-- it directly, and OR conditions with the existing users_real_name_lower_idx +-- allow PostgreSQL to use a bitmap OR index scan instead of a seq scan. +-- UNIQUE also enforces case-insensitive username uniqueness at the DB level. + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_lower_username ON users (LOWER(username)); diff --git a/database/migrations/v1.11.0.16_shared_messages_composite_index.sql b/database/migrations/v1.11.0.16_shared_messages_composite_index.sql new file mode 100644 index 00000000..a0994ecf --- /dev/null +++ b/database/migrations/v1.11.0.16_shared_messages_composite_index.sql @@ -0,0 +1,19 @@ +-- Migration: v1.11.0.16 - Composite index on shared_messages for echomail listing joins +-- +-- The LEFT JOIN in every echomail list query filters on: +-- message_id = ? AND message_type = ? AND shared_by_user_id = ? AND is_active = TRUE +-- +-- The existing idx_shared_messages_message only covers (message_id, message_type), +-- leaving shared_by_user_id as a post-index filter and causing ~450k seq scans. +-- The new partial index covers all three equality conditions for active rows only, +-- which is the only subset that matters for share lookups. +-- +-- idx_shared_messages_active is also dropped — a standalone boolean partial index +-- with no other columns is not useful for any actual query pattern. + +DROP INDEX IF EXISTS idx_shared_messages_message; +DROP INDEX IF EXISTS idx_shared_messages_active; + +CREATE INDEX IF NOT EXISTS idx_shared_messages_lookup + ON shared_messages (message_id, message_type, shared_by_user_id) + WHERE is_active = TRUE; diff --git a/database/migrations/v1.11.0.17_saved_messages_message_id_first_index.sql b/database/migrations/v1.11.0.17_saved_messages_message_id_first_index.sql new file mode 100644 index 00000000..c737c82c --- /dev/null +++ b/database/migrations/v1.11.0.17_saved_messages_message_id_first_index.sql @@ -0,0 +1,17 @@ +-- Migration: v1.11.0.17 - Add (message_id, message_type, user_id) index on saved_messages +-- +-- Every echomail listing LEFT JOINs saved_messages from the echomail side: +-- ON (sav.message_id = em.id AND sav.message_type = 'echomail' AND sav.user_id = ?) +-- +-- The existing UNIQUE index is (user_id, message_id, message_type) — wrong column +-- order for a join driven from message_id. PostgreSQL cannot efficiently seek into +-- it by message_id alone. The new index puts message_id first so nested-loop joins +-- from echomail can probe it directly per row. +-- +-- The existing idx_saved_messages_message on (message_id, message_type) is +-- superseded by the new index (which adds user_id), so it is dropped. + +DROP INDEX IF EXISTS idx_saved_messages_message; + +CREATE INDEX IF NOT EXISTS idx_saved_messages_msg_user + ON saved_messages (message_id, message_type, user_id); diff --git a/public_html/js/chat-notify.js b/public_html/js/chat-notify.js index b408fe0b..70cb1d92 100644 --- a/public_html/js/chat-notify.js +++ b/public_html/js/chat-notify.js @@ -130,7 +130,7 @@ if (isPathMatch('/chat')) { chatUnread = false; updateChatIcons(); - await markSeen('chat', stats.total_chat); + await markSeen('chat', stats.chat_max_id ?? 0); } if (isPathMatch('/netmail')) { await markSeen('netmail', stats.total_netmail); diff --git a/public_html/sw.js b/public_html/sw.js index 974053df..7aa77d1c 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v230'; +const CACHE_NAME = 'binkcache-v231'; // Static assets to precache const staticAssets = [ diff --git a/routes/api-routes.php b/routes/api-routes.php index 6455b62c..14b07c01 100644 --- a/routes/api-routes.php +++ b/routes/api-routes.php @@ -645,8 +645,12 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $meta = new UserMeta(); $currentCount = (int)($input['current_count'] ?? 0); - // Store the current count - badge will only show if count increases - $meta->setValue((int)$userId, 'last_' . $target . '_count', (string)$currentCount); + // Chat uses max message ID for efficient incremental polling; others use counts + if ($target === 'chat') { + $meta->setValue((int)$userId, 'last_chat_max_id', (string)$currentCount); + } else { + $meta->setValue((int)$userId, 'last_' . $target . '_count', (string)$currentCount); + } echo json_encode(['success' => true, 'target' => $target, 'count' => $currentCount]); }); @@ -664,7 +668,7 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array // Get last seen counts (not timestamps - we compare counts, not dates) $lastNetmailCount = (int)($meta->getValue((int)$userId, 'last_netmail_count') ?? 0); $lastEchomailCount = (int)($meta->getValue((int)$userId, 'last_echomail_count') ?? 0); - $lastChatCount = (int)($meta->getValue((int)$userId, 'last_chat_count') ?? 0); + $lastChatMaxId = $meta->getValue((int)$userId, 'last_chat_max_id'); // Get address list for netmail queries try { @@ -715,22 +719,35 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array $unreadEchomailStmt->execute([$userId, $userId]); $unreadEchomail = $unreadEchomailStmt->fetch()['count'] ?? 0; - // Total chat count - $chatTotalStmt = $db->prepare(" - SELECT COUNT(*) as count - FROM chat_messages m - LEFT JOIN chat_rooms r ON m.room_id = r.id - WHERE (m.room_id IS NOT NULL AND r.is_active = TRUE) - OR m.to_user_id = ? - OR m.from_user_id = ? - "); - $chatTotalStmt->execute([$userId, $userId]); - $chatTotal = $chatTotalStmt->fetch()['count'] ?? 0; + // New chat messages since last seen max ID (avoids full table scan) + if ($lastChatMaxId === null) { + // Not yet initialized — baseline to current max so no false badge on first load + $maxStmt = $db->query("SELECT COALESCE(MAX(id), 0) as max_id FROM chat_messages"); + $chatMaxId = (int)($maxStmt->fetch()['max_id'] ?? 0); + $meta->setValue((int)$userId, 'last_chat_max_id', (string)$chatMaxId); + $chatBadge = 0; + } else { + $lastChatMaxId = (int)$lastChatMaxId; + $chatStmt = $db->prepare(" + SELECT COUNT(*) as new_count, COALESCE(MAX(m.id), ?) as max_id + FROM chat_messages m + LEFT JOIN chat_rooms r ON m.room_id = r.id + WHERE m.id > ? + AND ( + (m.room_id IS NOT NULL AND r.is_active = TRUE) + OR m.to_user_id = ? + OR m.from_user_id = ? + ) + "); + $chatStmt->execute([$lastChatMaxId, $lastChatMaxId, $userId, $userId]); + $chatRow = $chatStmt->fetch(); + $chatBadge = (int)$chatRow['new_count']; + $chatMaxId = (int)$chatRow['max_id']; + } // Notification badge shows ONLY if count increased $netmailBadge = $unreadNetmail > $lastNetmailCount ? $unreadNetmail : 0; $echomailBadge = $unreadEchomail > $lastEchomailCount ? $unreadEchomail : 0; - $chatBadge = $chatTotal > $lastChatCount ? $chatTotal : 0; // Get user's credit balance $creditBalance = 0; @@ -745,10 +762,10 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array echo json_encode([ 'unread_netmail' => $netmailBadge, 'new_echomail' => $echomailBadge, - 'chat_total' => (int)$chatBadge, + 'chat_total' => $chatBadge, 'total_netmail' => $unreadNetmail, 'total_echomail' => $unreadEchomail, - 'total_chat' => $chatTotal, + 'chat_max_id' => $chatMaxId, 'credit_balance' => $creditBalance ]); }); From 48d4beb182a45da66e85788ebc04270bb78208d0 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 13:38:35 -0700 Subject: [PATCH 020/246] Update UPGRADING_1.8.7 with database performance improvements section Co-Authored-By: Claude Sonnet 4.6 --- docs/UPGRADING_1.8.7.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/UPGRADING_1.8.7.md b/docs/UPGRADING_1.8.7.md index 82afa471..cf37c7f4 100644 --- a/docs/UPGRADING_1.8.7.md +++ b/docs/UPGRADING_1.8.7.md @@ -14,6 +14,7 @@ - [Notes](#notes) - [Database Statistics Page](#database-statistics-page) - [Credits System Updates](#credits-system-updates) +- [Database Performance Improvements](#database-performance-improvements) - [Upgrade Instructions](#upgrade-instructions) - [From Git](#from-git) - [Using the Installer](#using-the-installer) @@ -35,6 +36,10 @@ health, and index health. - Added configurable file upload and file download credit costs/rewards in the **Credits System Configuration** page. +- Database performance improvements: new indexes on `mrc_outbound`, `users`, + `shared_messages`, and `saved_messages` eliminate millions of unnecessary + sequential scans. Chat notification polling rewritten to use primary key + index instead of full table count. ## Enhanced Message Search @@ -216,6 +221,37 @@ section: "file_download_reward": 0 ``` +## Database Performance Improvements + +Several tables that were generating excessive sequential scans have been +addressed with new indexes and one query change. These are applied automatically +by `setup.php` via five new migrations (v1.11.0.13 – v1.11.0.17). + +**Index changes:** + +- `mrc_outbound` — replaced `(sent_at, priority)` with a partial index + `(priority DESC, created_at ASC) WHERE sent_at IS NULL`. The MRC daemon polls + this table every 100 ms; the old index did not match the query's `ORDER BY` + so the planner seq-scanned a near-empty table millions of times. +- `users` — added `UNIQUE INDEX ON users(LOWER(username))`. Login queries and + name-matching in mail delivery used `LOWER(username)` comparisons with no + supporting index, causing a seq scan on every authentication request. +- `shared_messages` — replaced `(message_id, message_type)` with a partial + composite index `(message_id, message_type, shared_by_user_id) WHERE is_active = TRUE`, + matching the LEFT JOIN condition used in every echomail listing page load. +- `saved_messages` — replaced `(message_id, message_type)` with + `(message_id, message_type, user_id)`. The previous index had the wrong column + order for joins driven from the echomail side. + +**Query change — chat notification polling:** + +The `/api/dashboard/stats` endpoint previously counted all chat messages on +every 30-second poll using a query with an un-indexable OR condition across +three columns. It now queries only messages newer than the last seen message ID +(`WHERE m.id > ?`), using the primary key index. The last seen position is +stored as `last_chat_max_id` in user meta. Existing users will have this +initialized silently on first poll with no false notification badge. + ## Upgrade Instructions ### From Git From 883124a510adee9698cb99c40eb6bdefcdd24d63 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 14:06:37 -0700 Subject: [PATCH 021/246] Add nodelist map with grouped markers, zone colour coding, and geocoder script Map features: - Nodes grouped by system_name into a single marker per BBS; popup lists all network addresses the system appears in across different nodelists - Colour-coded markers by zone (blue=Z1, green=Z2, amber=Z3, red=Z4, purple=Z5, teal=Z6, gold=multi-zone, grey=unknown) - Map legend in bottom-right corner - Lazy-loads via /api/nodelist/map-data on first tab click - Leaflet MarkerCluster for dense areas Geocoder: - scripts/geocode_nodelist.php: geocode nodelist location strings using the shared bbs_directory_geocode_cache; supports --limit, --force, --dry-run - Migration v1.11.0.18: latitude/longitude columns on nodelist table - BbsDirectoryGeocoder: removed 32-day TTL; cache is now permanent To populate coordinates after upgrading: php scripts/setup.php php scripts/geocode_nodelist.php Co-Authored-By: Claude Sonnet 4.6 --- config/i18n/en/common.php | 6 + config/i18n/es/common.php | 6 + .../v1.11.0.18_nodelist_coordinates.sql | 9 + routes/nodelist-routes.php | 5 + scripts/geocode_nodelist.php | 159 ++++++++ src/BbsDirectoryGeocoder.php | 9 +- src/Web/NodelistController.php | 85 +++- templates/nodelist/index.twig | 385 +++++++++++++++--- 8 files changed, 593 insertions(+), 71 deletions(-) create mode 100644 database/migrations/v1.11.0.18_nodelist_coordinates.sql create mode 100755 scripts/geocode_nodelist.php diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index f9d04e29..f85959d3 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -2256,6 +2256,12 @@ 'ui.nodelist.import.guideline_note' => 'For ARC, ARJ, LZH, RAR files, use the command-line import script', 'ui.nodelist.import.importing' => 'Importing...', 'ui.nodelist.import.success' => 'Successfully imported {count} nodes from {filename} (Day {day}) for domain @{domain}', + 'ui.nodelist.tab_list' => 'List', + 'ui.nodelist.tab_map' => 'Map', + 'ui.nodelist.map_loading' => 'Loading map data…', + 'ui.nodelist.map_no_coordinates' => 'No geocoded nodes available. Run the geocode_nodelist script to populate coordinates.', + 'ui.nodelist.map_node_count' => '{count} geocoded nodes', + 'ui.nodelist.map_send_netmail' => 'Send Netmail', 'ui.dosdoor_player.page_title' => 'DOS Door Player', 'ui.dosdoor_player.document_title_suffix' => 'DOS Door', 'ui.dosdoor_player.status_prefix' => 'Status:', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index 13e07d00..21655e91 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -2256,6 +2256,12 @@ 'ui.nodelist.import.guideline_note' => 'Para archivos ARC, ARJ, LZH, RAR, use el script de importacion por linea de comandos', 'ui.nodelist.import.importing' => 'Importando...', 'ui.nodelist.import.success' => 'Se importaron correctamente {count} nodos desde {filename} (Dia {day}) para el dominio @{domain}', + 'ui.nodelist.tab_list' => 'Lista', + 'ui.nodelist.tab_map' => 'Mapa', + 'ui.nodelist.map_loading' => 'Cargando datos del mapa…', + 'ui.nodelist.map_no_coordinates' => 'No hay nodos geocodificados disponibles. Ejecuta el script geocode_nodelist para poblar las coordenadas.', + 'ui.nodelist.map_node_count' => '{count} nodos geocodificados', + 'ui.nodelist.map_send_netmail' => 'Enviar Netmail', 'ui.dosdoor_player.page_title' => 'Reproductor de puerta DOS', 'ui.dosdoor_player.document_title_suffix' => 'Puerta DOS', 'ui.dosdoor_player.status_prefix' => 'Estado:', diff --git a/database/migrations/v1.11.0.18_nodelist_coordinates.sql b/database/migrations/v1.11.0.18_nodelist_coordinates.sql new file mode 100644 index 00000000..bb7552cf --- /dev/null +++ b/database/migrations/v1.11.0.18_nodelist_coordinates.sql @@ -0,0 +1,9 @@ +-- Migration: v1.11.0.18 - Add geocoding coordinates to nodelist table + +ALTER TABLE nodelist + ADD COLUMN IF NOT EXISTS latitude DECIMAL(10,6) NULL, + ADD COLUMN IF NOT EXISTS longitude DECIMAL(10,6) NULL; + +CREATE INDEX IF NOT EXISTS idx_nodelist_coordinates + ON nodelist (latitude, longitude) + WHERE latitude IS NOT NULL AND longitude IS NOT NULL; diff --git a/routes/nodelist-routes.php b/routes/nodelist-routes.php index 83f179d9..491b729b 100644 --- a/routes/nodelist-routes.php +++ b/routes/nodelist-routes.php @@ -64,4 +64,9 @@ $controller = new BinktermPHP\Web\NodelistController(); $controller->api('stats'); }); + + SimpleRouter::get('/map-data', function() { + $controller = new BinktermPHP\Web\NodelistController(); + $controller->api('map-data'); + }); }); diff --git a/scripts/geocode_nodelist.php b/scripts/geocode_nodelist.php new file mode 100755 index 00000000..46c6955f --- /dev/null +++ b/scripts/geocode_nodelist.php @@ -0,0 +1,159 @@ +#!/usr/bin/env php +getPdo(); + $geocoder = new BbsDirectoryGeocoder(); + + if (!$geocoder->isEnabled()) { + fwrite(STDERR, "Geocoding is disabled (BBS_DIRECTORY_GEOCODING_ENABLED=false).\n"); + exit(1); + } + + // Fetch nodes with a location that need geocoding + $whereClause = $force + ? "WHERE location IS NOT NULL AND location != '' AND location != '-Unpublished-'" + : "WHERE location IS NOT NULL AND location != '' AND location != '-Unpublished-' + AND (latitude IS NULL OR longitude IS NULL)"; + + $limitClause = $limit !== null ? "LIMIT $limit" : ''; + + $stmt = $db->query(" + SELECT id, zone, net, node, point, system_name, location, latitude, longitude + FROM nodelist + $whereClause + ORDER BY id ASC + $limitClause + "); + $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $total = count($nodes); + $geocoded = 0; + $cached = 0; + $skipped = 0; + $failed = 0; + + echo "Nodes to process: $total\n\n"; + + if ($total === 0) { + echo "Nothing to do.\n"; + exit(0); + } + + $updateStmt = $dryRun ? null : $db->prepare(" + UPDATE nodelist SET latitude = ?, longitude = ? WHERE id = ? + "); + + foreach ($nodes as $row) { + $address = "{$row['zone']}:{$row['net']}/{$row['node']}.{$row['point']}"; + $location = trim((string)$row['location']); + + $coords = $geocoder->geocodeLocation($location); + + if ($coords === null) { + $failed++; + printf("[FAIL] %s | %s -> no result\n", $address, $location); + continue; + } + + if ($coords['latitude'] === null || $coords['longitude'] === null) { + $skipped++; + printf("[SKIP] %s | %s -> not geocodable\n", $address, $location); + continue; + } + + $lat = $coords['latitude']; + $lon = $coords['longitude']; + + // Detect whether this came from cache (geocoder handles it internally; + // we just note it was fast if the existing DB value already matches) + $fromCache = ($row['latitude'] !== null && + abs((float)$row['latitude'] - $lat) < 0.000001 && + abs((float)$row['longitude'] - $lon) < 0.000001); + + if ($fromCache) { + $cached++; + } else { + $geocoded++; + } + + printf("[%s] %s | %s -> %.6f, %.6f\n", + $fromCache ? 'CACHE' : 'OK ', + $address, + $location, + $lat, + $lon + ); + + if (!$dryRun && $updateStmt) { + $updateStmt->execute([$lat, $lon, (int)$row['id']]); + } + } + + echo "\n"; + echo "Total processed: $total\n"; + echo "Geocoded (API): $geocoded\n"; + echo "Resolved (cache): $cached\n"; + echo "Skipped (no coords): $skipped\n"; + echo "Failed: $failed\n"; + + if ($dryRun) { + echo "\nNo database changes were made (dry run).\n"; + } + + exit(0); + +} catch (\Throwable $e) { + fwrite(STDERR, "ERROR: " . $e->getMessage() . "\n"); + exit(1); +} diff --git a/src/BbsDirectoryGeocoder.php b/src/BbsDirectoryGeocoder.php index 2483dca2..1c3bc662 100644 --- a/src/BbsDirectoryGeocoder.php +++ b/src/BbsDirectoryGeocoder.php @@ -7,8 +7,6 @@ class BbsDirectoryGeocoder private const DEFAULT_ENDPOINT = 'https://nominatim.openstreetmap.org/search'; private const REQUEST_INTERVAL_US = 1000000; private const TIMEOUT_SECONDS = 10; - private const CACHE_TTL_SECONDS = 2764800; // 32 days - private static float $lastRequestAt = 0.0; public function isEnabled(): bool @@ -69,7 +67,7 @@ private function getCachedResult(string $cacheKey): ?array try { $db = Database::getInstance()->getPdo(); $stmt = $db->prepare(" - SELECT latitude, longitude, cached_at + SELECT latitude, longitude FROM bbs_directory_geocode_cache WHERE location_key = ? LIMIT 1 @@ -80,11 +78,6 @@ private function getCachedResult(string $cacheKey): ?array return null; } - $cachedAt = strtotime((string)($row['cached_at'] ?? '')); - if ($cachedAt === false || (time() - $cachedAt) > self::CACHE_TTL_SECONDS) { - return null; - } - if ($row['latitude'] === null || $row['longitude'] === null) { return ['latitude' => null, 'longitude' => null]; } diff --git a/src/Web/NodelistController.php b/src/Web/NodelistController.php index 9250a699..680247d1 100644 --- a/src/Web/NodelistController.php +++ b/src/Web/NodelistController.php @@ -230,6 +230,8 @@ public function api($action = '') return $this->apiNets(); case 'stats': return $this->apiStats(); + case 'map-data': + return $this->apiMapData(); default: $this->respondApiError('errors.nodelist.api.endpoint_not_found', 'API endpoint not found', 404); } @@ -331,12 +333,93 @@ private function apiStats() { $stats = $this->nodelistManager->getNodelistStats(); $activeNodelist = $this->nodelistManager->getActiveNodelist(); - + echo json_encode([ 'stats' => $stats, 'active_nodelist' => $activeNodelist ]); } + + /** + * Return all geocoded nodelist entries for the map view. + * Only nodes with coordinates are included. + */ + private function apiMapData() + { + $filterZone = $_GET['zone'] ?? ''; + + $params = []; + $where = ['n.latitude IS NOT NULL', 'n.longitude IS NOT NULL']; + + if ($filterZone !== '') { + $where[] = 'n.zone = ?'; + $params[] = (int)$filterZone; + } + + $whereClause = 'WHERE ' . implode(' AND ', $where); + + $stmt = $this->db->prepare(" + SELECT n.zone, n.net, n.node, n.point, n.keyword_type, + n.system_name, n.sysop_name, n.location, n.phone, n.flags, + n.latitude, n.longitude + FROM nodelist n + $whereClause + ORDER BY n.system_name, n.zone, n.net, n.node + "); + $stmt->execute($params); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + // Group by system_name (case-insensitive). Each group becomes one map marker. + // The first row in a group provides coordinates and primary sysop/location. + $groups = []; + foreach ($rows as $row) { + $flags = []; + if (!empty($row['flags'])) { + $decoded = json_decode($row['flags'], true); + if (is_array($decoded)) { + $flags = $decoded; + } + } + + $address = "{$row['zone']}:{$row['net']}/{$row['node']}"; + if ((int)$row['point'] > 0) { + $address .= ".{$row['point']}"; + } + + $inetHost = $flags['INA'] ?? ($flags['IBN'] ?? null); + + $groupKey = mb_strtolower(trim((string)$row['system_name'])); + if ($groupKey === '') { + $groupKey = $address; // fallback: unnamed nodes each get their own pin + } + + if (!isset($groups[$groupKey])) { + $groups[$groupKey] = [ + 'system_name' => $row['system_name'], + 'sysop_name' => $row['sysop_name'], + 'location' => $row['location'], + 'latitude' => (float)$row['latitude'], + 'longitude' => (float)$row['longitude'], + 'networks' => [], + 'zones' => [], + ]; + } + + $groups[$groupKey]['networks'][] = [ + 'address' => $address, + 'zone' => (int)$row['zone'], + 'keyword_type' => $row['keyword_type'], + 'inet_host' => $inetHost, + ]; + + if (!in_array((int)$row['zone'], $groups[$groupKey]['zones'], true)) { + $groups[$groupKey]['zones'][] = (int)$row['zone']; + } + } + + $nodes = array_values($groups); + echo json_encode(['nodes' => $nodes, 'total' => count($nodes)]); + } private function validateNodelistFile($filepath) { diff --git a/templates/nodelist/index.twig b/templates/nodelist/index.twig index b23f8c58..f5d4de80 100644 --- a/templates/nodelist/index.twig +++ b/templates/nodelist/index.twig @@ -2,6 +2,48 @@ {% block title %}{{ t('ui.nodelist.index.heading', {}, locale, ['common']) }} - {{ system_name }}{% endblock %} +{% block head %} + + + + +{% endblock %} + {% block content %}
@@ -41,6 +83,25 @@
{% endif %} + + + +
+
+
@@ -163,47 +224,25 @@ {% endif %}
{% endif %} + +
{# end list pane #} + + +
+
+
+
+
+
+
+
+
+ +
{# end tab-content #}
- - +{% endblock %} - + function makeIcon(colour) { + const svg = '' + + '' + + ''; + return L.divIcon({ + className: '', + html: svg, + iconSize: [14, 14], + iconAnchor: [7, 7], + popupAnchor: [0, -9] + }); + } + function escapeHtml(v) { + return String(v || '').replace(/[&<>"']/g, function(ch) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]; + }); + } + + function addFullscreenControl() { + const FS = L.Control.extend({ + options: { position: 'topleft' }, + onAdd: function() { + const c = L.DomUtil.create('div', 'leaflet-bar'); + const a = L.DomUtil.create('a', '', c); + a.href = '#'; a.title = 'Toggle fullscreen'; + a.innerHTML = ''; + L.DomEvent.disableClickPropagation(c); + L.DomEvent.on(a, 'click', function(e) { + L.DomEvent.stop(e); + if (!document.fullscreenElement) { + mapShell && mapShell.requestFullscreen && mapShell.requestFullscreen(); + } else { + document.exitFullscreen && document.exitFullscreen(); + } + }); + return c; + } + }); + nodelistMap.addControl(new FS()); + } + + function addLegend() { + const Legend = L.Control.extend({ + options: { position: 'bottomright' }, + onAdd: function() { + const div = L.DomUtil.create('div'); + div.style.cssText = 'background:var(--bs-body-bg,#fff);padding:6px 9px;border-radius:4px;' + + 'border:1px solid var(--bs-border-color,#ccc);font-size:12px;line-height:1.7;' + + 'color:var(--bs-body-color,#212529)'; + const entries = [ + [ZONE_COLOURS[1], 'Zone 1 — North America'], + [ZONE_COLOURS[2], 'Zone 2 — Europe'], + [ZONE_COLOURS[3], 'Zone 3 — Australasia'], + [ZONE_COLOURS[4], 'Zone 4 — Latin America'], + [ZONE_COLOURS[5], 'Zone 5 — Africa'], + [ZONE_COLOURS[6], 'Zone 6 — Asia/Pacific'], + [COLOUR_MULTI, 'Multiple networks'], + [COLOUR_UNKNOWN, 'Unknown zone'], + ]; + div.innerHTML = entries.map(function(e) { + return '' + + escapeHtml(e[1]); + }).join('
'); + return div; + } + }); + nodelistMap.addControl(new Legend()); + } + + function initMap(nodes) { + if (nodelistMap) { + nodelistMap.invalidateSize(); + return; + } + + if (nodes.length === 0) { + mapNotice.textContent = {{ t('ui.nodelist.map_no_coordinates', {}, locale, ['common'])|json_encode|raw }}; + mapNotice.classList.remove('d-none'); + document.getElementById('nodelistMap').style.display = 'none'; + return; + } + + nodelistMap = L.map('nodelistMap', { scrollWheelZoom: true }); + addFullscreenControl(); + addLegend(); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(nodelistMap); + + const cluster = L.markerClusterGroup({ + showCoverageOnHover: false, + spiderfyOnMaxZoom: true, + maxClusterRadius: 40 + }); + + const bounds = []; + const sendNetmailLabel = {{ t('ui.nodelist.map_send_netmail', {}, locale, ['common'])|json_encode|raw }}; + const userLoggedIn = {{ user ? 'true' : 'false' }}; + + nodes.forEach(function(node) { + const lat = Number(node.latitude); + const lon = Number(node.longitude); + if (!isFinite(lat) || !isFinite(lon)) return; + + const colour = zoneColour(node.zones); + const parts = ['
']; + + parts.push('' + escapeHtml(node.system_name || '(unnamed)') + ''); + if (node.sysop_name) parts.push('
' + escapeHtml(node.sysop_name) + '
'); + if (node.location) parts.push('
' + escapeHtml(node.location) + '
'); + + // One row per network entry + parts.push('
'); + (node.networks || []).forEach(function(net) { + const dot = ''; + let row = dot + '' + escapeHtml(net.address) + ''; + if (net.inet_host) { + row += ' ' + + escapeHtml(net.inet_host) + ''; + } + if (userLoggedIn) { + row += ' '; + } + parts.push('
' + row + '
'); + }); + + parts.push('
'); + + cluster.addLayer( + L.marker([lat, lon], { icon: makeIcon(colour) }).bindPopup(parts.join('')) + ); + bounds.push([lat, lon]); + }); + + nodelistMap.addLayer(cluster); + + if (bounds.length === 1) { + nodelistMap.setView(bounds[0], 6); + } else if (bounds.length > 1) { + nodelistMap.fitBounds(bounds, { padding: [30, 30] }); + } + + const countTpl = {{ t('ui.nodelist.map_node_count', {}, locale, ['common'])|json_encode|raw }}; + if (mapStatus) { + mapStatus.textContent = countTpl.replace('{count}', nodes.length); + } + } + + function loadMapData() { + if (mapLoaded) { + nodelistMap && nodelistMap.invalidateSize(); + return; + } + mapLoaded = true; + + if (mapNotice) { + mapNotice.textContent = {{ t('ui.nodelist.map_loading', {}, locale, ['common'])|json_encode|raw }}; + mapNotice.classList.remove('d-none'); + } + + fetch('/api/nodelist/map-data') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (mapNotice) mapNotice.classList.add('d-none'); + initMap(data.nodes || []); + }) + .catch(function() { + if (mapNotice) { + mapNotice.textContent = 'Failed to load map data.'; + mapNotice.classList.remove('d-none'); + } + }); + } + + if (mapTab) { + mapTab.addEventListener('shown.bs.tab', function() { + loadMapData(); + }); + } +})(); + + +{% endblock %} diff --git a/templates/fileareas.twig b/templates/fileareas.twig index 2270ab33..e2cf88b0 100644 --- a/templates/fileareas.twig +++ b/templates/fileareas.twig @@ -172,6 +172,19 @@
{{ t('ui.fileareas.gemini_public_help', {}, 'List and serve files in this area via the Gemini capsule server') }}
+
+ + +
{{ t('ui.fileareas.freq_enabled_help', {}, 'Allow any FidoNet node to request files from this area via FREQ') }}
+
+ +
+ +
{{ t('ui.files.freq_accessible_help', {}, 'Also allow this file to be requested via FidoNet FREQ') }}
+
-
+
-
+
+
+ + +
@@ -222,7 +248,7 @@ {% else %}
- {% if search or selectedZone or selectedNet %} + {% if search or selectedZone or selectedNet or selectedFlags|length > 0 %} {{ t('ui.nodelist.index.no_nodes_found_search', {}, locale, ['common']) }} {% else %} {{ t('ui.nodelist.index.use_search_form', {}, locale, ['common']) }} @@ -504,6 +530,18 @@ function uiT(key, fallback, params = {}) { return fallback; } document.addEventListener('DOMContentLoaded', function() { + const flagAnyLabel = {{ t('ui.nodelist.index.flag_filter_any', {}, locale, ['common'])|json_encode|raw }}; + const flagLabel = document.getElementById('flagPickerLabel'); + + function updateFlagLabel() { + const checked = Array.from(document.querySelectorAll('.flag-cb:checked')).map(cb => cb.value); + flagLabel.textContent = checked.length > 0 ? checked.join(', ') : flagAnyLabel; + } + + document.querySelectorAll('.flag-cb').forEach(function(cb) { + cb.addEventListener('change', updateFlagLabel); + }); + const zoneSelect = document.getElementById('zone'); const netSelect = document.getElementById('net'); if (zoneSelect) { diff --git a/templates/nodelist/view.twig b/templates/nodelist/view.twig index c0c214cd..994a0e65 100644 --- a/templates/nodelist/view.twig +++ b/templates/nodelist/view.twig @@ -128,10 +128,10 @@ {{ t('ui.nodelist.login_to_send_netmail') }} {% endif %} - {% if node.flags and node.flags['FREQ'] is defined %} - + {% if user %} + {% endif %}
@@ -198,6 +198,38 @@
+ + + {% endif %} +
+ + + +
@@ -311,13 +323,29 @@ document.addEventListener('DOMContentLoaded', function() { // ALLFILES FREQ functions window.showAllfilesModal = function() { + document.getElementById('allfilesToName').value = 'FileFix'; + document.getElementById('allfilesToNameOther').value = ''; + document.getElementById('allfilesToNameOther').classList.add('d-none'); document.getElementById('allfilesPassword').value = ''; document.getElementById('allfilesError').classList.add('d-none'); document.getElementById('allfilesSendBtn').disabled = false; new bootstrap.Modal(document.getElementById('allfilesModal')).show(); }; + window.toggleAllfilesToNameOther = function() { + const sel = document.getElementById('allfilesToName'); + const other = document.getElementById('allfilesToNameOther'); + if (sel.value === '') { + other.classList.remove('d-none'); + other.focus(); + } else { + other.classList.add('d-none'); + } + }; + window.sendAllfilesFreq = function() { + const sel = document.getElementById('allfilesToName'); + const toName = (sel.value === '' ? document.getElementById('allfilesToNameOther').value.trim() : sel.value) || 'FileFix'; const password = document.getElementById('allfilesPassword').value.trim(); const btn = document.getElementById('allfilesSendBtn'); const errorBox = document.getElementById('allfilesError'); @@ -333,7 +361,7 @@ document.addEventListener('DOMContentLoaded', function() { body: JSON.stringify({ type: 'netmail', to_address: '{{ node.full_address|e('js') }}', - to_name: 'FileFix', + to_name: toName, subject: subject, message_text: '', is_freq: true From c068869400f53e1f5c7efd259810d7bae5e4b6c8 Mon Sep 17 00:00:00 2001 From: awehttam Date: Sat, 14 Mar 2026 23:39:55 -0700 Subject: [PATCH 045/246] Add filename picker to FREQ modal with common names and free-text option Dropdown offers ALLFILES, FILES, FILELIST, NODELIST, NODEDIFF, and an Other option that reveals a text input for arbitrary filenames. The subject line is built from the chosen filename (+ password if set), so the modal can now send any FREQ request, not just ALLFILES. Co-Authored-By: Claude Sonnet 4.6 --- config/i18n/en/common.php | 2 ++ config/i18n/es/common.php | 2 ++ public_html/sw.js | 2 +- templates/nodelist/view.twig | 34 +++++++++++++++++++++++++++++++--- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index 99283621..b33685bd 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -2281,6 +2281,8 @@ 'ui.nodelist.freq_not_advertised' => 'This node does not advertise the FREQ flag. A request will still be sent, but the node may not support file requests.', 'ui.nodelist.request_allfiles_title' => 'Request File List from {node}', 'ui.nodelist.request_allfiles_body' => 'Sends an ALLFILES FREQ to this node. They will include their file listing on the next binkp session with you.', + 'ui.nodelist.request_allfiles_filename' => 'File to request', + 'ui.nodelist.request_allfiles_filename_placeholder' => 'e.g. README.TXT', 'ui.nodelist.request_allfiles_to' => 'Send to', 'ui.nodelist.request_allfiles_to_other' => 'Other...', 'ui.nodelist.request_allfiles_to_other_placeholder' => 'Enter service name', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index 213f3942..f68323e3 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -2281,6 +2281,8 @@ 'ui.nodelist.freq_not_advertised' => 'Este nodo no anuncia el indicador FREQ. Se enviará la solicitud igualmente, pero es posible que el nodo no admita solicitudes de archivos.', 'ui.nodelist.request_allfiles_title' => 'Solicitar lista de archivos de {node}', 'ui.nodelist.request_allfiles_body' => 'Envía un FREQ ALLFILES a este nodo. Incluirán su listado de archivos en la próxima sesión binkp contigo.', + 'ui.nodelist.request_allfiles_filename' => 'Archivo a solicitar', + 'ui.nodelist.request_allfiles_filename_placeholder' => 'p.ej. README.TXT', 'ui.nodelist.request_allfiles_to' => 'Enviar a', 'ui.nodelist.request_allfiles_to_other' => 'Otro...', 'ui.nodelist.request_allfiles_to_other_placeholder' => 'Ingrese nombre del servicio', diff --git a/public_html/sw.js b/public_html/sw.js index 0a800ff6..f9ced3bd 100644 --- a/public_html/sw.js +++ b/public_html/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'binkcache-v239'; +const CACHE_NAME = 'binkcache-v240'; // Static assets to precache const staticAssets = [ diff --git a/templates/nodelist/view.twig b/templates/nodelist/view.twig index dd6cc1bf..8401ea54 100644 --- a/templates/nodelist/view.twig +++ b/templates/nodelist/view.twig @@ -213,6 +213,18 @@ {{ t('ui.nodelist.freq_not_advertised') }}
{% endif %} +
+ + + +
+ {% endif %}
diff --git a/templates/nodelist/view.twig b/templates/nodelist/view.twig index 8d345d2e..8ff410e9 100644 --- a/templates/nodelist/view.twig +++ b/templates/nodelist/view.twig @@ -159,11 +159,11 @@ {{ t('ui.nodelist.login_to_send_netmail') }} {% endif %} - {# {% if user and user.is_admin %} + {% if freq_experimental_enabled and user and user.is_admin %} - {% endif %} #} + {% endif %}
@@ -277,6 +277,7 @@ {% endif %} +{% if freq_experimental_enabled and user and user.is_admin %} +{% endif %} +
+
+ + +
+
{{ t('ui.nodelist.request_allfiles_crashmail_help') }}
+