diff --git a/.env.docker.example b/.env.docker.example index 0f6f06c1d..08cf7c707 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -28,6 +28,9 @@ DOSDOOR_WS_PORT=6001 # Credits System CREDITS_ENABLED=true +# Experimental FREQ UI controls +ENABLE_FREQ_EXPERIMENTAL=false + # DOS Door Configuration DOSDOOR_DEBUG_KEEP_FILES=false DOSDOOR_WS_BIND_HOST=0.0.0.0 diff --git a/.env.example b/.env.example index fe80c386c..43d35ed60 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,11 @@ SMTP_ENABLED=true # This setting is used by the 'Terminal' web door. TERMINAL_ENABLED=false +# Experimental FREQ UI controls. +# false = hide file-area FREQ controls and nodelist file-request actions +# true = expose file-area FREQ controls and sysop-only nodelist file requests +ENABLE_FREQ_EXPERIMENTAL=false + # The first host listed will be this host, and could be your BBS or other favourite system TERMINAL_HOST=your.ssh.server.com TERMINAL_PORT=22 @@ -89,6 +94,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 @@ -117,6 +127,12 @@ FILEAREA_RULE_ACTION_LOG=data/logs/filearea_rules.log # received = use date_received (default), written = use date_written # ECHOMAIL_ORDER_DATE=received +# Licensing +# Path to the license file (default: data/license.json) +# LICENSE_FILE=data/license.json +# Log invalid license states to PHP error_log (useful for troubleshooting) +# LICENSE_LOG_INVALID=false + # i18n missing key logging (QA hardening) # Set to true to log translation keys that are missing from catalogs. # I18N_LOG_MISSING_KEYS=false diff --git a/CLAUDE.md b/CLAUDE.md index df1769929..033620205 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,10 @@ A modern web interface and mailer tool that receives and sends Fidonet message p - data/ - runtime data (binkp.json, nodelists.json, logs, inbound/outbound packets) - telnet/ - the telnet BBS server (separate from the web interface) +## Credits + + - **CREDITS.md must be kept up to date**: When merging commits from a new contributor, add them to the Contributors table. When adding a new vendor library via composer, add it to the Third-Party Libraries section with its license and authors. + ## Important Notes - User authentication is simple username and password with long lived cookie - Both usernames and Real Names are considered unique. Two users cannot have the same username or real name @@ -40,12 +44,16 @@ A modern web interface and mailer tool that receives and sends Fidonet message p - This is for FTN style networks and forums - Always write out schema changes. A database will need to be created from scratch and schema/migrations are how it needs to be done. Migration scripts follow the naming convention v_.sql, eg: v1.7.5_description.sql - When adding features to netmail and echomail, keep in mind feature parity. Ask for clarification about whether a feature is appropriate to both + - **Premium features**: When implementing a feature that requires a valid license (`License::isValid()` or `License::hasFeature()`), update the "Currently Implemented Premium Features" table in `docs/proposals/PremiumFeatures.md` and remove it from the future ideas list if it was listed there. - 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 + - **Theme-safe background colors**: Never use Bootstrap 5.3+ utility classes like `bg-body-tertiary` or `bg-body-secondary` — they have no theme overrides and will render incorrectly on dark/amber/greenterm/cyberpunk themes. Use `bg-light` instead, which all themes override via `.bg-light { background-color: var(--theme-var) !important; }`. - 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). - **Migration version numbers**: Before creating a new migration, always check the highest existing version in `database/migrations/` (e.g., `ls database/migrations/ | sort -V | tail -5`). The new migration must be one increment higher than the highest version found — do NOT guess or use a version from a different branch of the version tree. For example, if the latest is `v1.11.0.5_*`, the next is `v1.11.0.6_*`, not `v1.10.19_*`. + - **No duplicate indexes**: Do NOT create an explicit `CREATE INDEX` on a column that already has a `UNIQUE` constraint — PostgreSQL automatically creates a unique index for every `UNIQUE` constraint, which serves lookups identically to a plain index. Adding a second index on the same column wastes disk space and doubles write overhead with no benefit. - setup.php must be called when upgrading - this is to ensure certain things like file permissions are correct. ### PHP Migration Patterns @@ -130,7 +138,10 @@ return function($db) { - Avoid duplicating code. Whenever possible centralize methods using a class. - **Git Workflow**: Do NOT stage or commit changes until explicitly instructed. Changes should be tested first before committing to git. - When writing out a proposal document state in the preamble that the proposal is a draft, was generated by AI and may not have been reviewed for accuracy. + - When writing proposal or other documentation files, use repo-relative paths like `src/Foo.php` or `docs/Bar.md` in the document text; do not use full filesystem paths. + - **Documentation index**: When adding a new documentation file to `docs/` (excluding `docs/proposals/`), update `docs/index.md` to include it in the appropriate section in operational priority order. - **Service Worker Cache**: When making changes to CSS or JavaScript files, or when updating i18n language strings in `config/i18n/`, increment the CACHE_NAME version in public_html/sw.js (e.g., 'binkcache-v2' to 'binkcache-v3') to force clients to download fresh copies. The service worker caches static assets and the i18n catalog (`/api/i18n/catalog`) to bypass aggressive browser caching on mobile devices. + - **Timezone Display**: Dates and times are generally stored as UTC in the database. When presenting them to users, translate them to the user's own timezone unless there is a specific reason to show raw UTC. - Write phpDoc blocks when possible ## Localization (i18n) Workflow diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 000000000..dd4bfd50f --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,42 @@ +# Credits + +## Project Author + +**Matthew Asham** — creator and primary developer of BinktermPHP. + +## Contributors + +| Contributor | Contribution | +|-------------|-------------| +| Agent 57951 | QWK/QWKE offline mail support | +| Errol Casey | Extensive testing | + +## Third-Party Libraries + +### pecee/simple-router +- **License:** MIT +- **Author:** Simon Sessingø +- PHP router library used for all web and API routing. + +### twig/twig +- **License:** BSD 3-Clause +- **Authors:** Fabien Potencier, Twig Team, Armin Ronacher +- Template engine used for all HTML rendering. + +### phpmailer/phpmailer +- **License:** LGPL 2.1 +- **Authors:** Marcus Bointon, Jim Jagielski, Andy Prevost, Brent R. Matzelle +- Email library used for password reset and notification emails. + +### phpunit/phpunit *(dev)* +- **License:** BSD 3-Clause +- **Author:** Sebastian Bergmann +- Unit testing framework. + +## AI Tooling + +BinktermPHP was developed with the assistance of AI language models. We gratefully acknowledge: + +- **Anthropic** — Claude (claude.ai / Claude Code) +- **OpenAI** — ChatGPT / GPT-4 +- **Google** — Gemini diff --git a/FAQ.md b/FAQ.md index 2e3efd697..78a7b41f5 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,5 +1,45 @@ # BinktermPHP Frequently Asked Questions +## Troubleshooting + +### Q: The page looks broken after an upgrade — missing features, broken menus, or "loadI18nNamespaces is not defined" errors + +**A:** This is a stale service worker cache issue. The old service worker is still serving cached JavaScript and CSS from before the upgrade. You need to unregister the service worker so the browser fetches fresh files. + +**Desktop browsers (Chrome / Edge)** + +1. Open DevTools — press `F12` or right-click → Inspect +2. Go to **Application** → **Service Workers** +3. Click **Unregister** next to the BinktermPHP service worker +4. Reload the page (`F5`) + +**Desktop browsers (Firefox)** + +1. Open `about:debugging#/runtime/this-firefox` in the address bar +2. Find the BinktermPHP worker and click **Unregister** +3. Reload the page + +**Desktop — quick alternative (all browsers)** + +A hard refresh bypasses the cache without unregistering the service worker: +- Windows/Linux: `Ctrl + Shift + R` +- Mac: `Cmd + Shift + R` + +**Mobile (Chrome on Android)** + +1. Open Chrome's menu (three dots) → **Settings** → **Privacy and security** → **Clear browsing data** +2. Select **Cached images and files** and **Cookies and site data** for the BinktermPHP site +3. Tap **Clear data**, then reload + +**Mobile (Safari on iOS)** + +1. Go to **Settings** → **Safari** → **Clear History and Website Data** +2. Reload the BinktermPHP site + +**Note:** After clearing, you will be logged out and will need to sign in again. This is normal. + +--- + ## Support ### Q: Where can I get support? @@ -436,6 +476,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? diff --git a/README.md b/README.md index 8a334343f..949806be2 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ BinktermPHP runs beautifully in any browser — here's a look at the interface a - **Address Book Support** A handy address book to keep track of your netmail contacts - **Message Sharing** - Share echomail messages via secure web links with privacy controls - **Message Saving** - Ability to save messages -- **Search Capabilities** - Full-text search across messages and echo areas +- **Search Capabilities** - Full-text trigram search across messages and echo areas, plus global cross-area file search - **Web Terminal** - SSH terminal access through the web interface with configurable proxy support - **Installable PWA** - Installable both on mobile and desktop for a more seamless application experience - **Gateway Tokens** - Provides remote and third party services a means to authenticate a BinktermPHP user for access @@ -147,7 +147,9 @@ BinktermPHP runs beautifully in any browser — here's a look at the interface a - **Gemini Browser** - Built-in Gemini protocol browser for exploring Geminispace - **Gemini Capsule Hosting** - Users can publish personal Gemini capsules accessible via `gemini://` - **DOS Door support** - Integration with dosbox-x for running DOS based doors -- **File Areas** - Networked and local file areas with optional automation rules (see `docs/FileAreas.md`) +- **File Areas** - Networked and local file areas with optional automation rules, subfolder navigation, inline file preview (ANSI art, PETSCII, D64 disk images, C64 PRG/SEQ via emulator), and ISO-backed virtual areas (see `docs/FileAreas.md`) +- **Advertising** - Built-in ANSI ad library with dashboard rotation, browser-based ANSI editing, and scheduled echomail ad campaigns (see [docs/Advertising.md](docs/Advertising.md)) +- **Outbound FREQ** - Users can request files from other FTN nodes directly from the nodelist browser - **ANSI Support** - Support for ANSI escape sequences and pipe codes (BBS color codes) in message readers. See [ANSI Support](docs/ANSI_Support.md) and [Pipe Code Support](docs/Pipe_Code_Support.md) for details. - **Credit System** - Support for credits and rewards ([details](docs/CreditSystem.md)) - **Voting Booth** - Voting Booth supports multiple polls. Users can submit new polls for credits @@ -157,6 +159,10 @@ BinktermPHP runs beautifully in any browser — here's a look at the interface a - **Echomail Robots** - Generic rule-based framework that watches echo areas for matching messages and dispatches them to configurable processors. Ships with a built-in processor for FSXNet `ibbslastcall-data` announcements that auto-populates the BBS Directory. Custom processors can be added in `src/Robots/Processors/`. See [docs/Robots.md](docs/Robots.md). - **Markup Support** - Echomail and netmail can be composed and rendered using Markdown or StyleCodes formatting on compatible networks - **Localization** - Full multi-language support across the web interface, admin panel, and API error messages. The active locale is resolved automatically from user preferences, browser settings, or a cookie — no configuration required for users. Sysops can add new languages by dropping catalog files in place with no code changes. Ships with English and Spanish out of the box. +- **Message Artwork Encoding Editor** - In-browser tool for correcting the character encoding of ANSI and PETSCII art in messages when automatic detection is wrong; available to sysops on any echomail and to senders/receivers on netmail +- **Email Notifications** - Registered feature: users can opt in to have incoming netmail forwarded to their email address (including FTN file attachments), and/or receive a periodic echomail digest summarising new activity in their subscribed areas (daily or weekly) +- **QWK/QWKE Offline Mail** - Download QWK or QWKE offline mail packets containing new netmail and echomail for reading in offline readers (MultiMail, OLX, etc.), then upload REP reply packets to post replies +- **Registration** - Optional registration unlocks premium features including custom login/registration splash pages, netmail email forwarding, echomail digest emails, economy viewer, and referral analytics. See [REGISTER.md](REGISTER.md) for details. ## Native Binkp Protocol Support @@ -211,7 +217,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. @@ -579,6 +585,7 @@ Individual versions with specific upgrade documentation: | Version | Date | Highlights | |----------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [1.8.7](docs/UPGRADING_1.8.7.md) | Mar 2026 | Registration/premium features; ISO-backed file areas; global file search; outbound FREQ; echomail digest emails; netmail forwarding to email; in-browser artwork encoding editor; enhanced message search; nodelist map; page position memory; file preview improvements; QWK/QWKE offline mail | | [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 | @@ -632,6 +639,7 @@ BinktermPHP includes a full suite of CLI tools for managing your system from the | `process_packets.php` | Process inbound packets manually | | `restart_daemons.sh` | Stop and restart all running daemons | | `send_activityreport.php` | Generate and send an activity digest as netmail | +| `send_echomail_digest.php` | Send per-user echomail digest emails (daily or weekly); registered feature — see [docs/EchoDigests.md](docs/EchoDigests.md) | | `subscribe_users.php` | Bulk subscribe users to echo areas | | `update_nodelists.php` | Download and import nodelists from configured URL feeds (optional — the recommended method is file area rules with the import_nodelist tool) | | `user-manager.php` | Manage user accounts | @@ -669,19 +677,23 @@ Run any script with `--help` for full usage. See **[docs/CLI.md](docs/CLI.md)** - Chat cleanup: `php scripts/chat_cleanup.php --limit=500 --max-age-days=30` ### BBS Advertising System -ANSI ads can be placed in the `bbs_ads/` directory (files ending in `.ans`). A random ad will be displayed on the main dashboard and can be viewed full-screen via `/ads/random`. +Advertising is now managed through the built-in ad library at **Admin -> Ads**. + +ANSI ads are stored in the database, can be previewed and edited in the browser, and can be used both for dashboard rotation and scheduled echomail posting. + +See [docs/Advertising.md](docs/Advertising.md) for full setup and operational details. Post a random ad to an echoarea using: ```bash php scripts/post_ad.php --echoarea=BBS_ADS --domain=fidonet --subject="BBS Advertisement" -php scripts/post_ad.php --echoarea=BBS_ADS --domain=fidonet --ad=claudes1.ans --subject="BBS Advertisement" +php scripts/post_ad.php --echoarea=BBS_ADS --domain=fidonet --ad=claudes1 --subject="BBS Advertisement" ``` -Weekly cron example (every Tuesday at 6:00 AM): +Manual or scheduled campaign processing is handled by: ```bash -0 6 * * 2 /usr/bin/php /path/to/binkterm/scripts/post_ad.php --echoarea=BBS_ADS --domain=fidonet --subject="BBS Advertisement" +php scripts/run_ad_campaigns.php ``` Generate ANSI ads from current system settings: @@ -717,6 +729,9 @@ The recommended approach is to start the core services at boot (systemd or `@reb # Optional: start DOS door multiplexing bridge on boot @reboot /usr/bin/node /path/to/binktest/scripts/dosbox-bridge/multiplexing-server.js --daemon +# Optional: send echomail digest emails (registered feature; hourly — script enforces per-user frequency) +0 * * * * /usr/bin/php /path/to/binktest/scripts/send_echomail_digest.php + # Rotate logs weekly 0 0 * * 0 find /path/to/binktest/data/logs -name "*.log" -mtime +7 -delete @@ -944,6 +959,14 @@ File areas are organized collections of downloadable files, similar to echo area Files uploaded or received via TIC are stored under a directory specific to the file area, and the web UI at `/fileareas` lets sysops manage area settings and browse files. This makes it easy to distribute nodelists, archives, and other content across FTN networks while keeping local areas isolated when needed. +**Subfolder navigation** — File areas support hierarchical subfolder browsing. The web interface and terminal server both allow navigating into subdirectories within an area. + +**File preview** — Files can be previewed in the browser without downloading: ANSI art renders inline, PETSCII files are decoded and displayed, D64 disk images show a gallery of PRG files found on the disk, and C64 PRG/SEQ files can be run in a built-in C64 emulator. + +**ISO-backed file areas** — A file area can be backed by a read-only ISO 9660 image instead of a regular directory. The ISO is mounted virtually; its contents are browsable and downloadable without extracting the archive. This is useful for distributing large CD-ROM archives or nodelist compilations. See [docs/FileAreas.md](docs/FileAreas.md) for setup details. + +**Global file search** — Users can search for files by name across all areas they have access to from the `/files` page. + BinktermPHP supports optional ClamAV virus scanning for uploaded and TIC-received files, configurable per area. See [docs/AntiVirus.md](docs/AntiVirus.md) for installation and configuration instructions. ## File Area Rules diff --git a/REGISTER.md b/REGISTER.md new file mode 100644 index 000000000..242f4d073 --- /dev/null +++ b/REGISTER.md @@ -0,0 +1,60 @@ +# Register BinktermPHP + +Thank you for using BinktermPHP! Registration supports continued development and unlocks premium features for your installation. + +## Why Register? + +BinktermPHP is open source and the community edition is fully functional — registration is never required to run a BBS. But if BinktermPHP has been valuable to your system, registering is the right thing to do. It keeps the project alive and gives you some genuinely useful extras: + +- **"Registered to [system name]"** badge in the site footer and admin dashboard +- **Custom footer branding** — replace the "Powered by BinktermPHP" line with your own text, or hide it entirely +- **Custom splash pages** — display your own Markdown or HTML content above the login and registration forms +- **Netmail email forwarding** — have incoming netmail forwarded to your users' email addresses +- **Echomail digest** — send users a daily or weekly email digest of new echomail from their subscribed areas +- **Message templates** — save and reuse subject/body templates in the compose form +- **Economy viewer** — admin dashboard for credit economy stats and transaction history +- **Referral analytics** — admin page showing top referrers, referral signups, and bonus credits earned +- **More features to come** — registered installations automatically receive new premium features as they are developed + +## How to Register + +**Step 1 — Pay for your license** + +Visit [https://paypal.me/awehttam](https://paypal.me/awehttam) and pay whatever feels right to you. In the PayPal note, include the name of your BBS so your payment can be matched to your license request. + +**Step 2 — Send your registration details** + +The easiest way is to run the included script from your BinktermPHP installation directory: + +```bash +php scripts/license_request.php +``` + +It will prompt you for your PayPal transaction number and system details, then send the request automatically. + +Alternatively, email **awehttam@gmail.com** with the subject line **BinktermPHP Registration - Your BBS Name** and include the following: + +--- + +**System Name:** *(the name of your BBS)* + +**Sysop Name:** *(your name or handle)* + +**Email Address:** *(the address to send your license file to)* + +**FidoNet Address:** *(optional — your node address if you have one)* + +--- + +**Step 3 — Install your license** + +Once your registration is received, a license file will be generated and sent to you. You can install it in either of two ways: + +- **Via the admin panel** — go to **Admin → Licensing**, paste the license JSON into the box, and click Install License. +- **Manually** — place the file at `data/license.json` in your BinktermPHP installation directory. + +No restart is required either way. + +## Questions? + +Open an issue at [https://github.com/awehttam/binkterm-php/issues](https://github.com/awehttam/binkterm-php/issues) or email **awehttam@gmail.com** directly. diff --git a/config/bbs.json.example b/config/bbs.json.example index 09fdebcdf..eb2783a47 100644 --- a/config/bbs.json.example +++ b/config/bbs.json.example @@ -7,10 +7,16 @@ "chat": true, "file_areas": true, "guest_doors_page": false, - "bbs_directory": true + "bbs_directory": true, + "public_files_index": false, + "qwk": true }, "default_echo_interface": "echolist", + "dashboard_ad_rotate_interval_seconds": 20, "max_cross_post_areas": 5, + "qwk": { + "max_messages_per_download": 2500 + }, "credits": { "enabled": true, "symbol": "CR", @@ -21,6 +27,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 a990fce0f..a12754411 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', @@ -67,11 +80,15 @@ 'ui.common.domain' => 'Domain', 'ui.common.ip_address' => 'IP Address', 'ui.common.status' => 'Status', + 'ui.common.time' => 'Time', + 'ui.common.subject' => 'Subject', 'ui.common.from' => 'From', 'ui.common.actions' => 'Actions', 'ui.common.view' => 'View', 'ui.common.active' => 'Active', 'ui.common.inactive' => 'Inactive', + 'ui.common.none' => 'None', + 'ui.common.disabled' => 'Disabled', 'ui.common.user' => 'User', 'ui.common.admin' => 'Admin', 'ui.common.created' => 'Created', @@ -87,7 +104,10 @@ 'ui.common.previous_message' => 'Previous message', 'ui.common.next_message' => 'Next message', 'ui.common.toggle_fullscreen' => 'Toggle fullscreen', + 'ui.common.print' => 'Print message', + 'ui.common.add' => 'Add', 'ui.common.cancel' => 'Cancel', + 'ui.common.back' => 'Back', 'ui.common.clear_search' => 'Clear Search', 'ui.common.save' => 'Save', 'ui.common.save_changes' => 'Save Changes', @@ -95,6 +115,7 @@ 'ui.common.saved_short' => 'Saved', 'ui.common.error_click_retry' => 'Error - click to retry', 'ui.common.share' => 'Share', + 'ui.common.browse' => 'Browse', 'ui.common.share_url' => 'Share URL:', 'ui.common.copy' => 'Copy', 'ui.common.optional' => 'optional', @@ -102,7 +123,9 @@ 'ui.common.unknown' => 'Unknown', 'ui.common.not_configured' => 'Not configured', 'ui.common.name' => 'Name', + 'ui.common.title' => 'Title', 'ui.common.description' => 'Description', + 'ui.common.enabled' => 'Enabled', 'ui.common.label' => 'Label', 'ui.common.url' => 'URL', 'ui.common.new_tab' => 'New Tab', @@ -158,6 +181,13 @@ 'ui.about.links' => 'Links', 'ui.about.house_rules' => 'House Rules', 'ui.footer.powered_by' => 'Powered by', + 'ui.footer.registered_to' => 'Registered to {name}', + + // 403 Page + 'ui.error403.title' => 'Registered Feature', + 'ui.error403.heading' => 'This feature requires registration', + 'ui.error403.description' => 'This page is only available to registered installations. Visit the Licensing page to learn more about registration and the benefits it provides.', + 'ui.error403.licensing_link' => 'Licensing', // 404 Page 'ui.error404.title' => 'Page Not Found', @@ -169,6 +199,7 @@ // Generic Error Page 'ui.error.title' => 'Error', + 'ui.error.not_found' => 'Not Found', 'ui.error.access_error' => 'Access Error', 'ui.error.processing_request_failed' => 'An error occurred while processing your request.', 'ui.error.return_to_dashboard' => 'Return to Dashboard', @@ -182,6 +213,7 @@ 'ui.web.errors.polls_disabled' => 'Voting booth is disabled.', 'ui.web.errors.shoutbox_disabled' => 'Shoutbox is disabled.', 'ui.web.errors.compose_type_invalid' => 'Invalid compose destination.', + 'ui.web.errors.not_found' => 'The page you requested could not be found.', 'ui.web.fallback.system_name' => 'BinktermPHP System', // Base Layout / Navigation @@ -204,17 +236,21 @@ 'ui.base.logout' => 'Logout', 'ui.base.login' => 'Login', 'ui.base.guest_doors' => 'Guest Doors', + 'ui.base.public_files' => 'Public Files', 'ui.base.admin.whos_online' => "Who's Online", 'ui.base.admin.dashboard' => 'Dashboard', 'ui.base.admin.binkp_status' => 'Binkp Status', 'ui.base.admin.manage_users' => 'Manage Users', 'ui.base.admin.ads' => 'Advertisements', + 'ui.base.admin.ads_menu' => 'Ads', + 'ui.base.admin.ad_campaigns' => 'Ad Campaigns', 'ui.base.admin.area_management' => 'Area Management', 'ui.base.admin.echo_areas' => 'Echo Areas', 'ui.base.admin.file_areas' => 'File Areas', 'ui.base.admin.file_area_rules' => 'File Area Rules', 'ui.base.admin.subscriptions' => 'Subscriptions', 'ui.base.admin.auto_feed' => 'Auto Feed', + 'ui.base.admin.chat' => 'Chat', 'ui.base.admin.chat_rooms' => 'Chat Rooms', 'ui.base.admin.mrc_settings' => 'MRC Settings', 'ui.base.admin.polls' => 'Polls', @@ -223,19 +259,58 @@ 'ui.base.admin.doors_dos' => 'DOS Doors', 'ui.base.admin.doors_native' => 'Native Doors', 'ui.base.admin.doors_web' => 'Web Doors', + 'ui.base.admin.analytics' => 'Analytics', + 'ui.base.admin.community' => 'Community', + 'ui.base.admin.registered_feature' => 'Registered Feature', 'ui.base.admin.activity_stats' => 'Activity Statistics', + 'ui.base.admin.sharing' => 'Sharing', 'ui.base.admin.economy_viewer' => 'Economy Viewer', + 'ui.base.admin.referrals' => 'Referral Analytics', + 'ui.base.admin.licensing' => 'Licensing', 'ui.base.admin.bbs_settings' => 'BBS Settings', 'ui.base.admin.appearance' => 'Appearance', 'ui.base.admin.binkp_configuration' => 'Binkp Configuration', 'ui.base.admin.template_editor' => 'Template Editor', 'ui.base.admin.i18n_overrides' => 'Language Overrides', + 'ui.base.admin.docs' => 'Documentation', + 'ui.admin.docs.title' => 'Documentation', + 'ui.admin.docs.back_to_index' => 'Back to Index', + 'ui.admin.docs.not_found' => 'Document not found.', 'ui.base.admin.help' => 'Help', 'ui.base.admin.readme' => 'README', 'ui.base.admin.faq' => 'FAQ', 'ui.base.admin.upgrade_notes' => 'Upgrade Notes v{version}', 'ui.base.admin.claudes_bbs' => "Claude's BBS", 'ui.base.admin.report_issue' => 'Report Issue', + 'ui.base.admin.register' => 'Register BinktermPHP', + + // Admin Licensing + 'ui.admin.licensing.page_title' => 'Licensing', + 'ui.admin.licensing.heading' => 'Licensing', + 'ui.admin.licensing.current_status' => 'Current License Status', + 'ui.admin.licensing.upload_heading' => 'Install or Replace License', + 'ui.admin.licensing.upload_help' => 'Paste the contents of your license.json file below, then click Install License. The license will be verified before being saved.', + 'ui.admin.licensing.license_json_label' => 'License JSON', + 'ui.admin.licensing.upload_btn' => 'Install License', + 'ui.admin.licensing.remove_btn' => 'Remove License', + 'ui.admin.licensing.tiers_heading' => 'License Tiers', + 'ui.admin.licensing.tier_col' => 'Tier', + 'ui.admin.licensing.access_col' => 'Description', + 'ui.admin.licensing.tier_community' => 'Community', + 'ui.admin.licensing.tier_community_desc' => 'Full BBS, netmail, echomail, and packet processing. Free for everyone.', + 'ui.admin.licensing.tier_registered' => 'Registered', + 'ui.admin.licensing.tier_registered_desc' => 'Advanced admin tools, supporter badge.', + 'ui.admin.licensing.tier_sponsor' => 'Sponsor', + 'ui.admin.licensing.tier_sponsor_desc' => 'All registered features plus priority support.', + 'ui.admin.licensing.how_to_heading' => 'Why Register?', + 'ui.admin.licensing.why_intro' => 'BinktermPHP is open source and the community edition is fully functional. Registration is how sysops who find value in the project can support its continued development.', + 'ui.admin.licensing.why_sustain' => 'Sustain development — registration directly supports bug fixes, new features, and protocol compatibility work.', + 'ui.admin.licensing.why_branding' => 'Unlock branding control — present a fully branded experience without BinktermPHP attribution in the footer.', + 'ui.admin.licensing.why_features' => 'Access premium tools — registered installations receive new premium features automatically as they are released.', + 'ui.admin.licensing.why_perpetual' => 'Perpetual license — register once. No subscription or recurring fee.', + 'ui.admin.licensing.remove_confirm' => 'Remove the current license file? The system will revert to Community Edition.', + 'ui.admin.licensing.how_to_register_link' => 'How to Register', + 'ui.admin.licensing.register_modal_title' => 'Register BinktermPHP', // Admin Users 'ui.admin_users.pending_users_error' => 'Pending users error:', @@ -361,6 +436,12 @@ 'ui.admin.appearance.branding.footer_text' => 'Footer Text', 'ui.admin.appearance.branding.footer_placeholder' => 'Leave blank for default node/sysop line', 'ui.admin.appearance.branding.footer_help' => 'Custom text for the footer. Leave blank for the default system information line.', + 'ui.admin.appearance.branding.hide_powered_by' => 'Hide "Powered by BinktermPHP" footer line', + 'ui.admin.appearance.branding.hide_powered_by_help' => 'Remove the BinktermPHP attribution line from the site footer. Requires a valid license.', + 'ui.admin.appearance.branding.show_registration_badge' => 'Show registration status in footer', + 'ui.admin.appearance.branding.show_registration_badge_help' => 'Display "Registered to [system name]" in the site footer. Requires a valid license with the registered_badge feature.', + 'ui.admin.appearance.branding.premium_branding_locked' => 'Custom footer text and branding options are available to registered installations.', + 'ui.admin.appearance.branding.premium_branding_register' => 'Register →', 'ui.admin.appearance.branding.save' => 'Save Branding', 'ui.admin.appearance.content.system_news_title' => 'System News (MOTD)', 'ui.admin.appearance.content.system_news_placeholder' => 'Enter system news in Markdown format...', @@ -440,6 +521,18 @@ 'ui.admin.appearance.message_reader.email_link_url_help' => 'Optional link shown in the Messaging menu directly under Echomail.', 'ui.admin.appearance.message_reader.save' => 'Save Message Reader Settings', + // Appearance - Splash Pages tab + 'ui.admin.appearance.tab_splash' => 'Splash Pages', + 'ui.admin.appearance.splash.title' => 'Custom Splash Pages', + 'ui.admin.appearance.splash.help' => 'Add custom content that appears above the login and registration forms. Supports Markdown. Leave blank to show nothing.', + 'ui.admin.appearance.splash.login_label' => 'Login Page Splash', + 'ui.admin.appearance.splash.login_help' => 'Displayed above the login form on /login.', + 'ui.admin.appearance.splash.register_label' => 'Registration Page Splash', + 'ui.admin.appearance.splash.register_help' => 'Displayed above the registration form on /register.', + 'ui.admin.appearance.splash.placeholder' => '## Welcome\n\nThis is a **custom splash** message.', + 'ui.admin.appearance.splash.locked_heading' => 'Registered Feature', + 'ui.admin.appearance.splash.locked_description' => 'Custom splash pages are available to registered installations.', + // Admin Binkp Config 'ui.admin.binkp_config.load_failed' => 'Failed to load config', 'ui.admin.binkp_config.save_failed' => 'Failed to save config', @@ -544,10 +637,40 @@ 'ui.admin.ads.unexpected_response' => 'Unexpected response ({status})', 'ui.admin.ads.page_title' => 'Advertisements', 'ui.admin.ads.heading' => 'Advertisements', + 'ui.admin.ads.library_info' => 'Manage ANSI advertisements in the library. Imported legacy ads remain available here for dashboard display and auto-post selection.', 'ui.admin.ads.info_text_prefix' => 'Upload ANSI ads (`.ans`) to the', 'ui.admin.ads.info_text_suffix' => 'directory. Ads are displayed randomly on the dashboard.', 'ui.admin.ads.upload_new' => 'Upload New Advertisement', 'ui.admin.ads.ansi_file' => 'ANSI File (.ans)', + 'ui.admin.ads.ansi_content' => 'ANSI Content', + 'ui.admin.ads.slug_optional' => 'Slug (optional)', + 'ui.admin.ads.slug' => 'Slug', + 'ui.admin.ads.tags' => 'Tags', + 'ui.admin.ads.tags_placeholder' => 'general, door, network', + 'ui.admin.ads.dashboard_weight' => 'Dashboard Weight', + 'ui.admin.ads.dashboard_priority' => 'Dashboard Priority', + 'ui.admin.ads.show_on_dashboard' => 'Show on Dashboard', + 'ui.admin.ads.allow_auto_post' => 'Allow Auto-Post', + 'ui.admin.ads.insert_escape_prefix' => 'Insert ESC[', + 'ui.admin.ads.insert_sequence' => 'Insert Sequence', + 'ui.admin.ads.select_sequence' => 'Select ANSI sequence', + 'ui.admin.ads.sequence_reset' => 'Reset (ESC[0m)', + 'ui.admin.ads.sequence_bold' => 'Bold (ESC[1m)', + 'ui.admin.ads.sequence_red' => 'Red (ESC[31m)', + 'ui.admin.ads.sequence_green' => 'Green (ESC[32m)', + 'ui.admin.ads.sequence_yellow' => 'Yellow (ESC[33m)', + 'ui.admin.ads.sequence_blue' => 'Blue (ESC[34m)', + 'ui.admin.ads.sequence_white' => 'White (ESC[37m)', + 'ui.admin.ads.sequence_clear_screen' => 'Clear Screen (ESC[2J)', + 'ui.admin.ads.sequence_clear_line' => 'Clear Line (ESC[K)', + 'ui.admin.ads.sequence_cursor_home' => 'Cursor Home (ESC[H)', + 'ui.admin.ads.escape_helper_help' => 'Insert a raw ESC[ prefix or a common ANSI sequence at the current cursor position.', + 'ui.admin.ads.library_list' => 'Advertisement Library', + 'ui.admin.ads.dashboard' => 'Dashboard', + 'ui.admin.ads.auto_post' => 'Auto-Post', + 'ui.admin.ads.edit_advertisement' => 'Edit Advertisement', + 'ui.admin.ads.legacy_filename' => 'Legacy Filename', + 'ui.admin.ads.manage_campaigns' => 'Manage Campaigns', 'ui.admin.ads.save_as_optional' => 'Save As (optional)', 'ui.admin.ads.save_as_placeholder' => 'retro-sale.ans', 'ui.admin.ads.save_as_help' => 'Only letters, numbers, dot, dash, underscore.', @@ -559,6 +682,87 @@ 'ui.admin.ads.loading_ads' => 'Loading ads...', 'ui.admin.ads.none_uploaded' => 'No advertisements uploaded.', 'ui.admin.ads.view' => 'View', + 'ui.admin.ads.saved' => 'Advertisement saved.', + 'ui.admin.ads.save_failed_with_status' => 'Save failed ({status})', + 'ui.admin.ads.load_one_failed' => 'Failed to load advertisement', + 'ui.admin.ads.duplicate_warning' => 'Matching ANSI content already exists: {items}', + 'ui.admin.ads.not_found' => 'Advertisement not found', + + // Admin Ad Campaigns + 'ui.admin.ad_campaigns.page_title' => 'Ad Campaigns', + 'ui.admin.ad_campaigns.heading' => 'Ad Campaigns', + 'ui.admin.ad_campaigns.back_to_ads' => 'Back to Ads', + 'ui.admin.ad_campaigns.add_campaign' => 'Add Campaign', + 'ui.admin.ad_campaigns.edit_campaign' => 'Edit Campaign', + 'ui.admin.ad_campaigns.campaigns' => 'Campaigns', + 'ui.admin.ad_campaigns.targets' => 'Targets', + 'ui.admin.ad_campaigns.ads' => 'Ads', + 'ui.admin.ad_campaigns.schedule' => 'Schedule', + 'ui.admin.ad_campaigns.schedules' => 'Schedules', + 'ui.admin.ad_campaigns.interval' => 'Interval', + 'ui.admin.ad_campaigns.last_posted' => 'Last Posted', + 'ui.admin.ad_campaigns.next_run' => 'Next Run', + 'ui.admin.ad_campaigns.not_scheduled' => 'Not scheduled', + 'ui.admin.ad_campaigns.loading' => 'Loading ad campaigns...', + 'ui.admin.ad_campaigns.none' => 'No ad campaigns configured.', + 'ui.admin.ad_campaigns.recent_history' => 'Recent Post History', + 'ui.admin.ad_campaigns.post_history' => 'Post History', + 'ui.admin.ad_campaigns.no_history' => 'No post history yet.', + 'ui.admin.ad_campaigns.no_history_rows' => 'No post history found for the current filters.', + 'ui.admin.ad_campaigns.post_as_user' => 'Post As User', + 'ui.admin.ad_campaigns.to_name' => 'To Name', + 'ui.admin.ad_campaigns.all_campaigns' => 'All Campaigns', + 'ui.admin.ad_campaigns.all_statuses' => 'All Statuses', + 'ui.admin.ad_campaigns.status_success' => 'Success', + 'ui.admin.ad_campaigns.status_failed' => 'Failed', + 'ui.admin.ad_campaigns.status_dry_run' => 'Dry Run', + 'ui.admin.ad_campaigns.status_skipped' => 'Skipped', + 'ui.admin.ad_campaigns.campaign' => 'Campaign', + 'ui.admin.ad_campaigns.ad' => 'Ad', + 'ui.admin.ad_campaigns.target' => 'Target', + 'ui.admin.ad_campaigns.posted_by' => 'Posted By', + 'ui.admin.ad_campaigns.error' => 'Error', + 'ui.admin.ad_campaigns.add_schedule' => 'Add Schedule', + 'ui.admin.ad_campaigns.schedule_days' => 'Days', + 'ui.admin.ad_campaigns.schedule_time' => 'Time', + 'ui.admin.ad_campaigns.schedule_timezone' => 'Timezone', + 'ui.admin.ad_campaigns.day_sun' => 'Sun', + 'ui.admin.ad_campaigns.day_mon' => 'Mon', + 'ui.admin.ad_campaigns.day_tue' => 'Tue', + 'ui.admin.ad_campaigns.day_wed' => 'Wed', + 'ui.admin.ad_campaigns.day_thu' => 'Thu', + 'ui.admin.ad_campaigns.day_fri' => 'Fri', + 'ui.admin.ad_campaigns.day_sat' => 'Sat', + 'ui.admin.ad_campaigns.post_interval_minutes' => 'Post Interval Minutes', + 'ui.admin.ad_campaigns.repeat_gap_minutes' => 'Repeat Gap Minutes', + 'ui.admin.ad_campaigns.selection_mode' => 'Selection Mode', + 'ui.admin.ad_campaigns.selection_weighted_random' => 'Weighted Random', + 'ui.admin.ad_campaigns.add_target' => 'Add Target', + 'ui.admin.ad_campaigns.include_tags' => 'Include Tags', + 'ui.admin.ad_campaigns.include_tags_help' => 'Only ads matching at least one selected include tag are eligible.', + 'ui.admin.ad_campaigns.exclude_tags' => 'Exclude Tags', + 'ui.admin.ad_campaigns.exclude_tags_help' => 'Ads matching any selected exclude tag are skipped.', + 'ui.admin.ad_campaigns.assigned_ads' => 'Assigned Ads', + 'ui.admin.ad_campaigns.no_ads_available' => 'No ads available yet.', + 'ui.admin.ad_campaigns.echoarea_domain' => 'Echoarea + Domain', + 'ui.admin.ad_campaigns.select_target' => 'Select target...', + 'ui.admin.ad_campaigns.subject_template' => 'Subject Template', + 'ui.admin.ad_campaigns.select_user' => 'Select user...', + 'ui.admin.ad_campaigns.run_now' => 'Run Now', + 'ui.admin.ad_campaigns.run_complete' => 'Campaign run complete.', + 'ui.admin.ad_campaigns.saved' => 'Ad campaign saved.', + 'ui.admin.ad_campaigns.created' => 'Ad campaign created.', + 'ui.admin.ad_campaigns.deleted' => 'Ad campaign deleted.', + 'ui.admin.ad_campaigns.delete_confirm' => 'Delete {name}?', + 'ui.admin.ad_campaigns.load_failed' => 'Failed to load ad campaigns', + 'ui.admin.ad_campaigns.meta_failed' => 'Failed to load campaign metadata', + 'ui.admin.ad_campaigns.list_failed' => 'Failed to load ad campaigns', + 'ui.admin.ad_campaigns.log_failed' => 'Failed to load ad campaign log', + 'ui.admin.ad_campaigns.load_one_failed' => 'Failed to load ad campaign', + 'ui.admin.ad_campaigns.save_failed' => 'Failed to save ad campaign', + 'ui.admin.ad_campaigns.delete_failed' => 'Failed to delete ad campaign', + 'ui.admin.ad_campaigns.run_failed' => 'Failed to run ad campaign', + 'ui.admin.ad_campaigns.manual_post' => 'Manual Post', // Admin Dashboard 'ui.admin.dashboard.page_title' => 'Admin Dashboard', @@ -573,6 +777,11 @@ 'ui.admin.dashboard.active_sessions' => 'Active Sessions:', 'ui.admin.dashboard.system_address' => 'System Address:', 'ui.admin.dashboard.version' => 'Version:', + 'ui.admin.dashboard.registration_status' => 'Registration:', + 'ui.admin.dashboard.registration_registered' => 'Registered', + 'ui.admin.dashboard.registration_to' => 'Registered to {system} / {licensee}', + 'ui.admin.dashboard.registration_unregistered' => 'Unregistered', + 'ui.admin.dashboard.registration_link' => 'Register →', 'ui.admin.dashboard.git_branch_commit' => 'Git Branch / Commit:', 'ui.admin.dashboard.database_version' => 'Database Version:', 'ui.admin.dashboard.service_status' => 'Service Status', @@ -580,9 +789,97 @@ 'ui.admin.dashboard.service.binkp_scheduler' => 'Binkp Scheduler', 'ui.admin.dashboard.service.binkp_server' => 'Binkp Server', 'ui.admin.dashboard.service.telnetd' => 'Telnet Server', + 'ui.admin.dashboard.service.ssh_daemon' => 'SSH Server', + 'ui.admin.dashboard.service.gemini_daemon' => 'Gemini Server', + 'ui.admin.dashboard.service.mrc_daemon' => 'MRC Daemon', + 'ui.admin.dashboard.service.multiplexing_server' => 'Multiplexing Server', 'ui.admin.dashboard.running' => 'Running', 'ui.admin.dashboard.stopped' => 'Stopped', + 'ui.admin.dashboard.not_configured' => 'Not configured', 'ui.admin.dashboard.pid' => 'PID', + 'ui.admin.dashboard.db_size' => 'Database Size', + 'ui.admin.dashboard.db_stats_link' => 'View Statistics', + + // Admin database statistics page + 'ui.admin.db_stats.page_title' => 'Database Statistics', + 'ui.admin.db_stats.heading' => 'Database Statistics', + 'ui.admin.db_stats.tab.size_growth' => 'Size & Growth', + 'ui.admin.db_stats.tab.activity' => 'Activity', + 'ui.admin.db_stats.tab.query_performance' => 'Query Performance', + 'ui.admin.db_stats.tab.replication' => 'Replication', + 'ui.admin.db_stats.tab.maintenance' => 'Maintenance', + 'ui.admin.db_stats.tab.index_health' => 'Index Health', + 'ui.admin.db_stats.db_total_size' => 'Total Database Size', + 'ui.admin.db_stats.table_sizes' => 'Table Sizes (top 20)', + 'ui.admin.db_stats.index_sizes' => 'Index Sizes (top 20)', + 'ui.admin.db_stats.bloat' => 'Bloat Estimates (dead tuples)', + 'ui.admin.db_stats.no_bloat' => 'No significant dead tuples detected.', + 'ui.admin.db_stats.connections' => 'Active Connections', + 'ui.admin.db_stats.used' => 'used', + 'ui.admin.db_stats.cache_hit_ratio' => 'Cache Hit Ratio', + 'ui.admin.db_stats.cache_hit_warning' => 'Below recommended 99%', + 'ui.admin.db_stats.transactions' => 'Transactions', + 'ui.admin.db_stats.committed' => 'committed', + 'ui.admin.db_stats.rolled_back' => 'rolled back', + 'ui.admin.db_stats.tuples' => 'Tuple Activity', + 'ui.admin.db_stats.inserted' => 'Inserted', + 'ui.admin.db_stats.updated' => 'Updated', + 'ui.admin.db_stats.deleted' => 'Deleted', + 'ui.admin.db_stats.connection_states' => 'Connection States', + 'ui.admin.db_stats.pg_stat_statements_unavailable' => 'The pg_stat_statements extension is not installed. Install it to enable slow query and frequent query analysis.', + 'ui.admin.db_stats.lock_waits' => 'Lock Waits', + 'ui.admin.db_stats.deadlocks' => 'Deadlocks (cumulative)', + 'ui.admin.db_stats.long_running' => 'Long-Running Queries (>5s)', + 'ui.admin.db_stats.slow_queries' => 'Slowest Queries (by mean time)', + 'ui.admin.db_stats.frequent_queries' => 'Most Frequently Called Queries', + 'ui.admin.db_stats.no_replication' => 'No replication is configured on this server.', + 'ui.admin.db_stats.replication_senders' => 'Replication Senders', + 'ui.admin.db_stats.wal_receiver' => 'WAL Receiver', + 'ui.admin.db_stats.vacuum_needed' => '{count} table(s) may need a VACUUM (>10k dead tuples or >5% dead).', + 'ui.admin.db_stats.autovacuum_active' => 'Autovacuum Workers (active)', + 'ui.admin.db_stats.maintenance_health' => 'Vacuum / Analyze Status', + 'ui.admin.db_stats.never' => 'Never', + 'ui.admin.db_stats.unused_indexes' => 'Unused Indexes', + 'ui.admin.db_stats.no_unused_indexes' => 'No unused indexes found.', + 'ui.admin.db_stats.duplicate_indexes' => 'Potentially Redundant Indexes', + 'ui.admin.db_stats.scan_ratios' => 'Index vs Sequential Scan Ratios', + 'ui.admin.db_stats.no_data' => 'No data available.', + 'ui.admin.db_stats.col.table' => 'Table', + 'ui.admin.db_stats.col.index' => 'Index', + 'ui.admin.db_stats.col.total_size' => 'Total', + 'ui.admin.db_stats.col.table_size' => 'Table', + 'ui.admin.db_stats.col.index_size' => 'Indexes', + 'ui.admin.db_stats.col.size' => 'Size', + 'ui.admin.db_stats.col.dead_tuples' => 'Dead Tuples', + 'ui.admin.db_stats.col.dead_pct' => 'Dead %', + 'ui.admin.db_stats.col.state' => 'State', + 'ui.admin.db_stats.col.count' => 'Count', + 'ui.admin.db_stats.col.pid' => 'PID', + 'ui.admin.db_stats.col.user' => 'User', + 'ui.admin.db_stats.col.duration' => 'Duration', + 'ui.admin.db_stats.col.query' => 'Query', + 'ui.admin.db_stats.col.mean_ms' => 'Mean (ms)', + 'ui.admin.db_stats.col.total_ms' => 'Total (ms)', + 'ui.admin.db_stats.col.calls' => 'Calls', + 'ui.admin.db_stats.col.client' => 'Client', + 'ui.admin.db_stats.col.lag_bytes' => 'Lag (bytes)', + 'ui.admin.db_stats.col.status' => 'Status', + 'ui.admin.db_stats.col.sender' => 'Sender', + 'ui.admin.db_stats.col.last_vacuum' => 'Last Vacuum', + 'ui.admin.db_stats.col.last_analyze' => 'Last Analyze', + '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', @@ -877,6 +1174,49 @@ 'ui.admin.filearea_rules.tip_1' => 'Rules run in order: global rules first, then area-specific rules.', 'ui.admin.filearea_rules.tip_2' => 'Area rules can be keyed as TAG or TAG@DOMAIN (domain-specific takes precedence).', 'ui.admin.filearea_rules.tip_3' => 'Use success_action and fail_action with +stop to halt further processing.', + 'ui.admin.filearea_rules.tab_gui' => 'Visual Editor', + 'ui.admin.filearea_rules.tab_json' => 'JSON Editor', + 'ui.admin.filearea_rules.global_rules_section' => 'Global Rules', + 'ui.admin.filearea_rules.area_rules_section' => 'Area Rules', + 'ui.admin.filearea_rules.add_rule' => 'Add Rule', + 'ui.admin.filearea_rules.add_area' => 'Add Area', + 'ui.admin.filearea_rules.add_rule_title' => 'Add Rule', + 'ui.admin.filearea_rules.edit_rule_title' => 'Edit Rule', + 'ui.admin.filearea_rules.add_area_title' => 'Add Area', + 'ui.admin.filearea_rules.no_rules' => 'No rules. Click Add Rule to create one.', + 'ui.admin.filearea_rules.no_areas' => 'No area rules. Click Add Area to create one.', + 'ui.admin.filearea_rules.col_enabled' => 'Enabled', + 'ui.admin.filearea_rules.col_name' => 'Name', + 'ui.admin.filearea_rules.col_pattern' => 'Pattern', + 'ui.admin.filearea_rules.col_domain' => 'Domain', + 'ui.admin.filearea_rules.col_success' => 'On Success', + 'ui.admin.filearea_rules.col_fail' => 'On Failure', + 'ui.admin.filearea_rules.field_name' => 'Rule Name', + 'ui.admin.filearea_rules.field_pattern' => 'Filename Pattern (regex)', + 'ui.admin.filearea_rules.field_pattern_hint' => 'PHP-style regex, e.g. /^NODELIST\\.\\d+$/i', + 'ui.admin.filearea_rules.field_script' => 'Script Command', + 'ui.admin.filearea_rules.field_timeout' => 'Timeout (seconds)', + 'ui.admin.filearea_rules.field_enabled' => 'Enabled', + 'ui.admin.filearea_rules.field_success_action' => 'On Success', + 'ui.admin.filearea_rules.field_fail_action' => 'On Failure', + 'ui.admin.filearea_rules.field_domain' => 'Domain Filter', + 'ui.admin.filearea_rules.field_domain_hint' => 'Optional. Restrict rule to a specific domain (e.g. fidonet).', + 'ui.admin.filearea_rules.action_move' => 'move to area:', + 'ui.admin.filearea_rules.area_tag_label' => 'Area Tag', + 'ui.admin.filearea_rules.area_tag_hint' => 'e.g. NODELIST or NODELIST@fidonet', + 'ui.admin.filearea_rules.delete_area_confirm' => 'Delete this area and all its rules?', + 'ui.admin.filearea_rules.clone_rule' => 'Clone rule', + 'ui.admin.filearea_rules.clone_rule_confirm' => 'Clone rule?', + 'ui.admin.filearea_rules.delete_rule_confirm' => 'Delete this rule?', + 'ui.admin.filearea_rules.json_parse_error' => 'JSON parse error — fix syntax before switching to the visual editor.', + 'ui.admin.filearea_rules.pattern_test_title' => 'Test pattern', + 'ui.admin.filearea_rules.pattern_test_placeholder' => 'Enter a filename to test...', + 'ui.admin.filearea_rules.pattern_match' => 'match', + 'ui.admin.filearea_rules.pattern_no_match' => 'no match', + 'ui.admin.filearea_rules.pattern_invalid' => 'invalid regex', + 'ui.admin.filearea_rules.pattern_test_area_not_found' => 'Area not found in database.', + 'ui.admin.filearea_rules.pattern_test_no_files' => 'No files in this area.', + 'ui.admin.filearea_rules.pattern_test_area_files' => 'Files in area:', // Admin DOS Doors Config 'ui.admin.dosdoors_config.load_config_failed' => 'Failed to load config', @@ -1043,13 +1383,22 @@ 'ui.admin.bbs_settings.features.guest_doors_page_help' => 'Shows a public /guest-doors page listing anonymous-accessible doors. Also shows a link on the login page.', 'ui.admin.bbs_settings.features.enable_bbs_directory' => 'Enable BBS Directory', 'ui.admin.bbs_settings.features.bbs_directory_help' => 'Shows the public /bbs-directory page and BBS Lists nav menu. When disabled, the page returns 404.', + 'ui.admin.bbs_settings.features.enable_qwk' => 'Enable QWK Offline Mail', + 'ui.admin.bbs_settings.features.qwk_help' => 'Allows users to download QWK packets and upload REP reply packets for offline mail reading.', + 'ui.qwk.download_failed_prefix' => 'Download failed: ', 'ui.admin.bbs_settings.features.default_echo_interface' => 'Default Echo Interface', 'ui.admin.bbs_settings.features.echo_list_forum' => 'Echo List (Forum view)', 'ui.admin.bbs_settings.features.reader_message_list' => 'Reader (Message list)', 'ui.admin.bbs_settings.features.default_echo_help' => 'Default interface for viewing echomail. Users can override this in their settings.', 'ui.admin.bbs_settings.features.max_cross_post_areas' => 'Max Cross-Post Areas', 'ui.admin.bbs_settings.features.max_cross_post_help' => 'Maximum number of additional areas a user can cross-post to (2-20).', + 'ui.admin.bbs_settings.features.dashboard_ad_rotate_interval' => 'Dashboard Ad Rotate Interval', + 'ui.admin.bbs_settings.features.dashboard_ad_rotate_interval_help' => 'How often dashboard ads auto-rotate, in seconds (5-300). Default is 20 seconds.', + 'ui.admin.bbs_settings.features.enable_public_files_index' => 'Enable Public Files Index', + 'ui.admin.bbs_settings.features.public_files_index_help' => 'Shows a public /public-files page listing all public file areas. Also adds a nav link for guests. Requires a registered license.', + 'ui.admin.bbs_settings.features.public_files_index_requires_license' => 'Public file area index requires a registered license.', 'ui.admin.bbs_settings.features.save' => 'Save Settings', + 'ui.admin.bbs_settings.validation.dashboard_ad_rotate_interval_range' => 'Dashboard ad rotation interval must be an integer between 5 and 300 seconds.', 'ui.admin.bbs_settings.credits.title' => 'Credits System Configuration', 'ui.admin.bbs_settings.credits.enabled' => 'Credits System Enabled', 'ui.admin.bbs_settings.credits.currency_symbol' => 'Currency Symbol', @@ -1070,6 +1419,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', @@ -1091,6 +1448,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.', @@ -1226,8 +1587,50 @@ 'ui.admin.activity_stats.chat' => 'Chat', 'ui.admin.activity_stats.auth' => 'Auth', 'ui.admin.activity_stats.anonymous' => '(anon)', + 'ui.admin.sharing.page_title' => 'Sharing', + 'ui.admin.sharing.heading' => 'Sharing', + 'ui.admin.sharing.dashboard' => 'Dashboard', + 'ui.admin.sharing.help' => 'Review active shared messages and files, ranked by view count.', + 'ui.admin.sharing.loading' => 'Loading sharing data...', + 'ui.admin.sharing.messages_tab' => 'Messages', + 'ui.admin.sharing.files_tab' => 'Files', + 'ui.admin.sharing.messages_heading' => 'Shared Messages', + 'ui.admin.sharing.files_heading' => 'Shared Files', + 'ui.admin.sharing.subject' => 'Subject', + 'ui.admin.sharing.file' => 'File', + 'ui.admin.sharing.area' => 'Area', + 'ui.admin.sharing.shared_by' => 'Shared By', + 'ui.admin.sharing.views' => 'Views', + 'ui.admin.sharing.last_accessed' => 'Last Accessed', + 'ui.admin.sharing.access' => 'Access', + 'ui.admin.sharing.open' => 'Open', + 'ui.admin.sharing.public' => 'Public', + 'ui.admin.sharing.private' => 'Private', + 'ui.admin.sharing.freq_enabled' => 'FREQ', + 'ui.admin.sharing.web_only' => 'Web only', + 'ui.admin.sharing.never' => 'Never', + 'ui.admin.sharing.no_messages' => 'No active shared messages', + 'ui.admin.sharing.no_files' => 'No active shared files', + 'ui.admin.sharing.load_failed' => 'Failed to load sharing data', // Admin Economy Viewer + 'ui.admin.referrals.page_title' => 'Referral Analytics', + 'ui.admin.referrals.heading' => 'Referral Analytics', + 'ui.admin.referrals.back' => 'Dashboard', + 'ui.admin.referrals.loading' => 'Loading referral data...', + 'ui.admin.referrals.load_failed' => 'Failed to load referral data', + 'ui.admin.referrals.top_referrers' => 'Top Referrers', + 'ui.admin.referrals.recent_signups' => 'Recent Referral Signups', + 'ui.admin.referrals.col_user' => 'User', + 'ui.admin.referrals.col_code' => 'Referral Code', + 'ui.admin.referrals.col_signups' => 'Signups', + 'ui.admin.referrals.col_bonus' => 'Bonus Earned', + 'ui.admin.referrals.col_referred_by' => 'Referred By', + 'ui.admin.referrals.col_joined' => 'Joined', + 'ui.admin.referrals.no_referrers' => 'No referrals recorded yet.', + 'ui.admin.referrals.no_recent' => 'No referral signups yet.', + 'ui.admin.referrals.stat_referred_users' => 'Referred Users', + 'ui.admin.referrals.stat_active_referrers' => 'Active Referrers', 'ui.admin.economy.page_title' => 'Economy Viewer', 'ui.admin.economy.heading' => 'Economy Viewer', 'ui.admin.economy.period' => 'Period:', @@ -1299,6 +1702,7 @@ 'ui.fileareas.versioning_help' => 'When disabled, files with duplicate names get version suffixes (_1, _2, etc.)', 'ui.fileareas.tag_required' => 'Tag *', 'ui.fileareas.tag_help' => 'File area tag (e.g., NODELIST, GENERAL_FILES)', + 'ui.fileareas.tag_readonly' => 'Tag cannot be changed after creation.', 'ui.fileareas.description_required' => 'Description *', 'ui.fileareas.description_help' => 'Brief description of the file area', 'ui.fileareas.network_domain' => 'Network domain', @@ -1318,6 +1722,21 @@ 'ui.fileareas.allow_duplicate_content_help' => 'Allow same file content (hash) with different filenames', 'ui.fileareas.local_only' => 'Local Only', 'ui.fileareas.local_only_help' => "Don't forward files to uplinks", + 'ui.fileareas.gemini_public' => 'Gemini Public', + 'ui.fileareas.gemini_public_help' => 'List and serve files in this area via the Gemini capsule server', + 'ui.fileareas.freq_enabled' => 'FREQ Enabled', + 'ui.fileareas.freq_enabled_help' => 'Allow any FidoNet node to request files from this area via FREQ', + 'ui.fileareas.freq_password' => 'FREQ Password', + 'ui.fileareas.freq_password_placeholder' => 'Optional password', + 'ui.fileareas.freq_password_help' => 'Leave blank for open access', + 'ui.fileareas.is_public' => 'Public File Area', + 'ui.fileareas.is_public_help' => 'Allow unauthenticated visitors to browse and download files in this area', + 'ui.fileareas.is_public_requires_license' => 'Public file areas require a registered license.', + 'ui.files.freq_accessible' => 'FREQ Accessible', + 'ui.files.freq_accessible_help' => 'Also allow this file to be requested via FidoNet FREQ', + 'ui.freq.status_pending' => 'FREQ Pending', + 'ui.freq.status_fulfilled' => 'FREQ Fulfilled', + 'ui.freq.status_denied' => 'FREQ Denied', 'ui.fileareas.upload_permission' => 'Upload Permission', 'ui.fileareas.upload_users_can_upload' => 'Users Can Upload', 'ui.fileareas.upload_admin_only' => 'Admin Only', @@ -1335,6 +1754,12 @@ 'ui.fileareas.size' => 'Size', 'ui.fileareas.local' => 'Local', 'ui.fileareas.replace' => 'Replace', + 'ui.fileareas.comment_area' => 'Comments Echo Area', + 'ui.fileareas.comment_area_help' => 'Select an echo area to enable file comments. Saved with the file area.', + 'ui.fileareas.comment_area_placeholder' => 'New echo area tag (e.g. MYFILES-COMMENTS)', + 'ui.fileareas.comment_area_linked' => 'Comment area linked', + 'ui.fileareas.comment_area_create_new' => 'Create new echo area', + 'ui.fileareas.comment_area_create_help' => 'A new local echo area will be created with this tag.', // Admin Auto Feed 'ui.admin.auto_feed.load_details_failed' => 'Failed to load feed details', @@ -1430,6 +1855,10 @@ 'ui.echoareas.tag_help' => 'Echo area tag (e.g., FIDONET.GEN, LOCAL.TEST)', 'ui.echoareas.description_required' => 'Description *', 'ui.echoareas.description_help' => 'Brief description of the echo area', + 'ui.echoareas.lovlynet_sync_button' => 'Sync', + 'ui.echoareas.lovlynet_sync_button_loading' => 'Syncing...', + 'ui.echoareas.lovlynet_sync_success' => 'Description updated from LovlyNet', + 'ui.echoareas.lovlynet_sync_failed' => 'Failed to sync description from LovlyNet', 'ui.echoareas.uplink_address' => 'Uplink Address', 'ui.echoareas.uplink_address_help' => 'Override Uplink FidoNet address', 'ui.echoareas.color' => 'Color', @@ -1735,6 +2164,13 @@ 'ui.echomail.viewing_prefix' => 'Viewing:', 'ui.echomail.viewing_all' => 'Viewing: All Messages', 'ui.echomail.echo_list' => 'Echo List', + 'ui.echomail.full_echo_list' => 'Full Echo List', + 'ui.echomail.manage_subscriptions' => 'Manage Subscriptions', + 'ui.echomail.save_to_ad_library' => 'Save Ad', + 'ui.echomail.save_to_ad_library_title' => 'Save to ad library', + 'ui.echomail.save_to_ad_library_saved' => 'Message saved to ad library.', + 'ui.echomail.save_to_ad_library_failed' => 'Failed to save message to ad library', + 'ui.echomail.save_to_ad_library_not_ansi' => 'This message is not eligible to save as an ANSI ad.', 'ui.echomail.search_areas_placeholder' => 'Search areas...', 'ui.echomail.loading_areas' => 'Loading areas...', 'ui.echomail.recent_messages' => 'Recent Messages', @@ -1777,6 +2213,7 @@ 'ui.files.total_size' => 'Total Size', 'ui.files.file_details' => 'File Details', 'ui.files.filename' => 'Filename', + 'ui.files.description' => 'Description', 'ui.files.size' => 'Size', 'ui.files.uploaded' => 'Uploaded', 'ui.files.from' => 'From', @@ -1787,13 +2224,19 @@ 'ui.files.share_link' => 'Share Link', 'ui.files.revoke_link' => 'Revoke Link', 'ui.files.create_share_link' => 'Create Share Link', + 'ui.files.share_access_stats' => 'Accessed {count} times. Last accessed: {last_accessed}', + 'ui.files.never_accessed' => 'Never', 'ui.files.select_file_required' => 'Select File *', 'ui.files.maximum_file_size' => 'Maximum file size', 'ui.files.short_description_required' => 'Short Description *', 'ui.files.short_description_help' => 'Brief description shown in file listings.', + 'ui.files.upload_descriptions' => 'Upload Descriptions', + 'ui.files.upload_descriptions_help' => 'When uploading multiple files, each file gets its own short description.', 'ui.files.long_description' => 'Long Description', 'ui.files.long_description_help' => 'Optional extended description (supports plain text).', 'ui.files.upload' => 'Upload', + 'ui.files.upload_success_multiple' => '{count} files uploaded successfully', + 'ui.files.upload_partial_failure' => 'Upload stopped after {count} file(s): {error}', 'ui.files.recent_uploads_load_failed' => 'Failed to load recent uploads', 'ui.files.no_recent_uploads' => 'No files have been uploaded yet', 'ui.files.area' => 'Area', @@ -1832,18 +2275,56 @@ 'ui.files.rename_file' => 'Rename File', 'ui.files.new_filename' => 'New Filename', 'ui.files.rename_success' => 'File renamed successfully', + 'ui.files.rehatch' => 'Rehatch', + 'ui.files.rehatch_ok' => 'File re-hatched successfully', + 'ui.files.rehatch_failed' => 'Rehatch failed', 'ui.files.edit' => 'Edit', 'ui.files.edit_file' => 'Edit File', + 'ui.files.edit_description' => 'Edit description', + 'ui.files.delete_subfolder' => 'Delete subfolder', + 'ui.files.delete_subfolder_confirm' => 'Are you sure you want to delete the subfolder "{subfolder}" and all its files? This cannot be undone.', + 'ui.files.subfolder_deleted' => 'Subfolder deleted', 'ui.files.short_description' => 'Short Description', 'ui.files.edit_success' => 'File updated successfully', 'ui.files.previous_file' => 'Previous file', 'ui.files.next_file' => 'Next file', + 'ui.files.my_files' => 'My Files', 'ui.files.move_to_area' => 'Move to Area', 'ui.files.active_share_exists' => 'This file already has an active share link.', 'ui.files.revoke_confirm' => 'Are you sure you want to revoke this share link? Anyone with the link will no longer be able to access it.', 'ui.files.share_revoked' => 'Share link revoked', 'ui.files.share_link_copied_clipboard' => 'Share link copied to clipboard', 'ui.files.share_link_copied' => 'Share link copied', + 'ui.files.preview_title' => 'File Preview', + 'ui.files.view_full_size' => 'View full size', + 'ui.files.video_not_supported' => 'Video format not supported by your browser', + 'ui.files.no_preview' => 'No preview available for this file type', + 'ui.files.preview_failed' => 'Failed to load preview', + 'ui.files.zip_empty' => 'ZIP archive is empty', + 'ui.files.zip_truncated' => 'Showing first {count} entries', + 'ui.files.zip_legacy_compression' => 'This file uses a legacy compression format that cannot be previewed.', + 'ui.files.zip_legacy_badge' => 'Legacy compression', + 'ui.files.download_zip' => 'Download full ZIP', + 'ui.files.file_info' => 'File Info', + 'ui.files.search_heading' => 'Search Files', + 'ui.files.search_global_placeholder' => 'Search filename or description…', + 'ui.files.search_result_count' => '{count} result(s)', + 'ui.files.search_no_results' => 'No files found', + 'ui.files.search_failed' => 'Search failed', + 'ui.files.prg_no_preview' => 'Preview unavailable — machine code program', + 'ui.files.prg_run_c64' => 'Run on C64', + 'ui.files.no_prgs_in_d64' => 'No PRG files found in disk image', + 'ui.files.comments' => 'Comments', + 'ui.files.leave_comment' => 'Leave a Comment', + 'ui.files.post_comment' => 'Post Comment', + 'ui.files.show_all_comments' => 'Show all {count} comments', + 'ui.files.show_fewer_comments' => 'Show fewer', + 'ui.files.no_comments_yet' => 'No comments yet. Be the first to leave one.', + 'ui.files.loading_comments' => 'Loading comments…', + 'ui.files.comments_load_failed' => 'Failed to load comments', + 'ui.files.comment_post_failed' => 'Failed to post comment', + 'ui.files.comment_posted' => 'Comment posted', + 'ui.files.login_to_comment' => 'Log in to read and post comments', // Polls Page 'ui.polls.title' => 'Polls', @@ -1934,7 +2415,32 @@ 'ui.binkp.status_tab' => 'Status', 'ui.binkp.uplinks_tab' => 'Uplinks', 'ui.binkp.queues_tab' => 'Queues', + 'ui.binkp.kept_packets_tab' => 'Kept Packets', + 'ui.binkp.kept_packets_locked' => 'This feature requires a registered license.', + 'ui.binkp.kept_packets_register' => 'Register to unlock', 'ui.binkp.logs_tab' => 'Logs', + 'ui.binkp.kept_inbound' => 'Inbound Keep', + 'ui.binkp.kept_outbound' => 'Outbound Keep', + 'ui.binkp.kept_packets_empty' => 'No kept packets found.', + 'ui.binkp.kept_packets_total' => '{count} packet(s)', + 'ui.binkp.kept_date_group' => '{date}', + 'ui.binkp.from_address' => 'From', + 'ui.binkp.to_address' => 'To', + 'ui.binkp.pkt_header' => 'Packet Header', + 'ui.binkp.pkt_version' => 'Packet Version', + 'ui.binkp.pkt_product' => 'Product Code', + 'ui.binkp.pkt_password' => 'Password', + 'ui.binkp.pkt_password_set' => 'Set', + 'ui.binkp.pkt_no_password' => 'None', + 'ui.binkp.pkt_messages' => 'Messages', + 'ui.binkp.pkt_no_messages' => 'No messages found in packet.', + 'ui.binkp.pkt_from' => 'From', + 'ui.binkp.pkt_to' => 'To', + 'ui.binkp.pkt_subject' => 'Subject', + 'ui.binkp.pkt_flags' => 'Flags', + 'ui.common.filename' => 'Filename', + 'ui.common.size' => 'Size', + 'ui.common.date' => 'Date', 'ui.binkp.system_information' => 'System Information', 'ui.binkp.loading_system_information' => 'Loading system information...', 'ui.binkp.uplink_status' => 'Uplink Status', @@ -1976,6 +2482,14 @@ 'ui.binkp.logs_heading' => 'Binkp Logs', 'ui.binkp.lines_option' => '{count} lines', 'ui.binkp.loading_logs' => 'Loading logs...', + 'ui.binkp.log_matches' => '{count} / {total} lines', + 'ui.binkp.advanced_log_search' => 'Advanced Search', + 'ui.binkp.advanced_log_search_help' => 'Searches the entire log. All lines from any session (PID) that contains a match are shown.', + 'ui.binkp.advanced_log_search_placeholder' => 'e.g. 1:123/456, FREQ, ERROR', + 'ui.binkp.log_search_summary' => '{matches} matches across {sessions} session(s)', + 'ui.binkp.log_search_no_results' => 'No results found.', + 'ui.binkp.log_search_legend_match' => 'Lines containing your search term', + 'ui.binkp.log_search_legend_context' => 'Other lines from the same session (same PID)', 'ui.binkp.add_new_uplink' => 'Add New Uplink', 'ui.binkp.ftn_address_required' => 'FTN Address *', 'ui.binkp.ftn_address_placeholder' => '1:123/456', @@ -2022,6 +2536,7 @@ 'ui.echolist.show_unread_only' => 'Show only areas with unread messages', 'ui.echolist.area_filter_placeholder' => 'Type to filter by name or description...', 'ui.echolist.area_filter_help' => 'Filters the list below in real-time', + 'ui.echolist.all_networks' => 'All Networks', 'ui.echolist.search_heading' => 'Search Messages', 'ui.echolist.search_placeholder' => 'Search message content...', 'ui.echolist.search_help' => 'Search all echomail message content', @@ -2100,6 +2615,8 @@ 'ui.nodelist.index.zone_prefix' => 'Zone', 'ui.nodelist.index.all_nets' => 'All Nets', 'ui.nodelist.index.net_prefix' => 'Net', + 'ui.nodelist.index.flag_filter_label' => 'Flags', + 'ui.nodelist.index.flag_filter_any' => 'Any flag', 'ui.nodelist.index.search_results' => 'Search Results ({count} nodes)', 'ui.nodelist.index.address' => 'Address', 'ui.nodelist.index.type' => 'Type', @@ -2143,6 +2660,29 @@ '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.no_coordinates' => 'Location not geocoded. Run scripts/geocode_nodelist.php to populate coordinates.', + '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.nodelist.request_allfiles' => 'Request File', + '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', + 'ui.nodelist.request_allfiles_password' => 'Password (optional)', + 'ui.nodelist.request_allfiles_password_help' => 'Supply a password if the remote system requires one to serve file listings.', + 'ui.nodelist.request_allfiles_crashmail' => 'Send as crashmail (connect directly to node)', + 'ui.nodelist.request_allfiles_crashmail_help' => 'Delivers the request directly to this node rather than routing it through your uplink.', + 'ui.nodelist.request_allfiles_sent' => 'ALLFILES request queued. The file listing will arrive on the next binkp session.', + 'ui.nodelist.request_allfiles_failed' => 'Failed to send ALLFILES request.', + 'ui.nodelist.send_request' => 'Send Request', 'ui.dosdoor_player.page_title' => 'DOS Door Player', 'ui.dosdoor_player.document_title_suffix' => 'DOS Door', 'ui.dosdoor_player.status_prefix' => 'Status:', @@ -2225,7 +2765,7 @@ 'ui.shared_file.register' => 'Register', 'ui.shared_file.login' => 'Login', 'ui.shared_file.about_system' => 'About {system_name}', - 'ui.shared_file.about_system_text' => '{system_name} is a FidoNet-connected BBS with public file areas. Members can upload, download, and share files from a growing archive.', + 'ui.shared_file.about_system_text' => '{system_name} is a {network}-connected BBS with file areas. Members can upload, download, and share files from a growing archive.', 'ui.shared_file.powered_by' => 'Powered by BinktermPHP', 'ui.shared_file.not_available_title' => 'File Not Available', 'ui.shared_file.not_available_body' => 'This shared file link is not available. It may have expired or been revoked.', @@ -2263,6 +2803,9 @@ 'ui.dashboard.unread_netmail' => 'Unread Netmail', 'ui.dashboard.unread_echomail' => 'Unread Echomail', 'ui.dashboard.system_news' => 'System News', + 'ui.dashboard.advertisement' => 'Advertisement', + 'ui.dashboard.advertisement_controls' => 'Advertisement controls', + 'ui.dashboard.advertisement_position' => 'Ad {current} of {total}', 'ui.dashboard.system_information' => 'System Information', 'ui.dashboard.sysop' => 'Sysop', 'ui.dashboard.user' => 'User', @@ -2338,9 +2881,20 @@ 'ui.settings.threaded_view_echomail_help' => 'Group echomail messages by conversation threads', 'ui.settings.threaded_view_netmail' => 'Enable threaded view for netmail', 'ui.settings.threaded_view_netmail_help' => 'Group netmail messages by conversation threads', + 'ui.settings.page_position_memory' => 'Page Position Memory', + 'ui.settings.remember_page_position' => 'Remember last page in echomail and netmail', + 'ui.settings.remember_page_position_help' => 'Automatically return to the page you were on when you come back to an area', 'ui.settings.quote_display' => 'Quote Display', 'ui.settings.quote_coloring' => 'Color quoted text by depth', 'ui.settings.quote_coloring_help' => 'Show quoted message lines (starting with >) in different colors based on nesting level', + 'ui.settings.notifications' => 'Notifications', + 'ui.settings.forward_netmail_email' => 'Forward netmail to email', + 'ui.settings.forward_netmail_email_help' => 'Send a copy of incoming netmail messages to your email address. Requires a valid email address in your profile and SMTP to be configured.', + 'ui.settings.echomail_digest' => 'Echomail Digest', + 'ui.settings.echomail_digest_none' => 'Off', + 'ui.settings.echomail_digest_daily' => 'Daily', + 'ui.settings.echomail_digest_weekly' => 'Weekly', + 'ui.settings.echomail_digest_help' => 'Receive a periodic email summarising new messages in your subscribed echo areas. Requires a valid email address and SMTP to be configured.', 'ui.settings.session_security' => 'Session & Security', 'ui.settings.active_sessions' => 'Active Sessions', 'ui.settings.active_sessions_help' => 'Manage your active login sessions', @@ -2480,6 +3034,27 @@ 'ui.compose.address' => 'Address', 'ui.compose.tagline_help' => 'Select a sysop-provided tagline to append below your signature.', 'ui.compose.save_draft' => 'Save Draft', + 'ui.compose.templates.button' => 'Templates', + 'ui.compose.templates.loading' => 'Loading...', + 'ui.compose.templates.none' => 'No saved templates', + 'ui.compose.templates.save_current' => 'Save current as template', + 'ui.compose.templates.manage' => 'Manage templates', + 'ui.compose.templates.save_modal_title' => 'Save as Template', + 'ui.compose.templates.manage_modal_title' => 'My Templates', + 'ui.compose.templates.name_label' => 'Template name', + 'ui.compose.templates.name_placeholder' => 'e.g. Monthly announcement', + 'ui.compose.templates.type_label' => 'Available for', + 'ui.compose.templates.type_both' => 'Netmail and Echomail', + 'ui.compose.templates.type_netmail' => 'Netmail only', + 'ui.compose.templates.type_echomail' => 'Echomail only', + 'ui.compose.templates.save_button' => 'Save Template', + 'ui.compose.templates.saved' => 'Template saved', + 'ui.compose.templates.deleted' => 'Template deleted', + 'ui.compose.templates.name_required' => 'Please enter a template name', + 'ui.compose.templates.save_failed' => 'Failed to save template', + 'ui.compose.templates.load_failed' => 'Failed to load template', + 'ui.compose.templates.delete_confirm' => 'Delete this template?', + 'ui.compose.templates.delete_failed' => 'Failed to delete template', 'ui.compose.send_prefix' => 'Send', 'ui.compose.sending' => 'Sending...', 'ui.compose.draft.empty_content' => 'Please add some content before saving draft', @@ -2526,6 +3101,7 @@ 'ui.netmail.received_insecure_badge_title' => 'This message was received via an insecure/unauthenticated binkp session', 'ui.netmail.received_insecurely' => 'Received Insecurely', 'ui.netmail.not_authenticated' => 'This message was not authenticated', + 'ui.netmail.next_page_title' => 'Load next page', // Echomail 'ui.echomail.search.failed' => 'Search failed', @@ -2554,6 +3130,8 @@ 'ui.echomail.press_a_to_cycle' => 'press A to cycle', 'ui.echomail.viewer_mode_prefix' => 'Viewer mode:', 'ui.echomail.viewer_mode_auto' => 'Auto', + 'ui.echomail.viewer_mode_rip' => 'RIPscrip', + 'ui.echomail.rip_render_failed' => 'Failed to render RIPscrip message', 'ui.echomail.viewer_mode_ansi' => 'ANSI', 'ui.echomail.viewer_mode_amiga_ansi' => 'Amiga ANSI', 'ui.echomail.viewer_mode_petscii' => 'PETSCII', @@ -2571,6 +3149,28 @@ '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.echomail.next_page_title' => 'Load next page', + 'ui.echomail.next_echo_title' => 'Next echo: {tag}', + 'ui.echomail.end_of_echo_title' => 'End of {echo}', + 'ui.echomail.end_of_echo_next_prompt' => 'Continue to {echo}?', + 'ui.echomail.end_of_echo_go' => 'Go to {echo}', + 'ui.echomail.end_of_echo_no_next' => 'You have no more unread messages.', + 'ui.echomail.end_of_echo_next_btn_title' => 'End of echo', + 'ui.echomail.subscribe' => 'Subscribe', + 'ui.echomail.unsubscribe' => 'Unsubscribe', + 'ui.echomail.fileref_label' => 'File comment:', + 'ui.common.db_id' => 'DB ID', + 'ui.common.message_id' => 'Message ID', // Admin subscriptions 'ui.admin_subscriptions.page_title' => 'Admin: Manage Subscriptions', @@ -2789,4 +3389,143 @@ 'ui.admin.bbs_directory.is_local_help' => 'Automated imports and robot processors will not modify this entry.', 'ui.admin.bbs_directory.robot_saved' => 'Robot rule saved successfully', 'ui.admin.bbs_directory.robot_deleted' => 'Robot rule deleted successfully', + + // ISO-backed file areas + 'ui.fileareas.area_type' => 'Area Type', + 'ui.fileareas.area_type_normal' => 'Normal', + 'ui.fileareas.area_type_iso' => 'ISO-backed', + 'ui.fileareas.iso_mount_point' => 'Mount Point', + 'ui.fileareas.iso_mount_point_help' => 'Path where the ISO is mounted on the server. Mount the ISO manually before using this area (e.g. sudo mount -o loop image.iso /mnt/point). On Windows enter the drive letter or path (e.g. D:\\).', + 'ui.fileareas.iso_mount_status' => 'Mount Status', + 'ui.fileareas.accessible' => 'Accessible', + 'ui.fileareas.not_accessible' => 'Not accessible', + 'ui.fileareas.reindex' => 'Re-index ISO', + 'ui.fileareas.reindexing' => 'Indexing…', + 'ui.fileareas.reindex_started' => 'Re-index started in background', + 'ui.fileareas.reindex_done' => 'Re-index complete', + 'ui.fileareas.last_indexed' => 'Last indexed', + 'ui.fileareas.flat_import' => 'Flat import (no subfolders)', + + // LovlyNet admin page + 'ui.base.admin.lovlynet' => 'LovlyNet Areas', + 'ui.admin.lovlynet.page_title' => 'LovlyNet Areas', + 'ui.admin.lovlynet.heading' => 'LovlyNet Subscriptions', + 'ui.admin.lovlynet.not_configured_title' => 'LovlyNet not configured.', + 'ui.admin.lovlynet.not_configured_body' => 'config/lovlynet.json is missing or incomplete.', + 'ui.admin.lovlynet.not_configured_synopsis' => 'LovlyNet is a FTN network and hub service that provides shared echo areas, file areas, and automated AreaFix/FileFix management for connected systems.', + 'ui.admin.lovlynet.not_configured_setup_intro' => 'To configure this system for LovlyNet, run the setup script from the project root:', + 'ui.admin.lovlynet.not_configured_docs_intro' => 'For setup details and background, see', + 'ui.admin.lovlynet.node_number' => 'Node Address', + 'ui.admin.lovlynet.hub_address' => 'Hub Address', + 'ui.admin.lovlynet.server' => 'Server', + 'ui.admin.lovlynet.subscribed_areas' => 'Subscribed Areas', + 'ui.admin.lovlynet.tab_echo' => 'Echo Areas', + 'ui.admin.lovlynet.tab_file' => 'File Areas', + 'ui.admin.lovlynet.tab_requests' => 'Requests', + 'ui.admin.lovlynet.no_areas' => 'No areas available', + 'ui.admin.lovlynet.no_requests' => 'No matching requests or responses found', + 'ui.admin.lovlynet.col_tag' => 'Tag', + 'ui.admin.lovlynet.col_description' => 'Description', + 'ui.admin.lovlynet.col_status' => 'Status', + 'ui.admin.lovlynet.edit_tag_title' => 'Edit {tag}', + 'ui.admin.lovlynet.subscribed' => 'Subscribed', + 'ui.admin.lovlynet.not_subscribed' => 'Not subscribed', + 'ui.admin.lovlynet.btn_subscribe' => 'Subscribe', + 'ui.admin.lovlynet.btn_unsubscribe' => 'Unsubscribe', + 'ui.admin.lovlynet.load_failed' => 'Failed to load areas from LovlyNet', + 'ui.admin.lovlynet.toggle_failed' => 'Subscription change failed', + 'ui.admin.lovlynet.subscribed_ok' => 'Subscribed to {tag}', + 'ui.admin.lovlynet.unsubscribed_ok' => 'Unsubscribed from {tag}', + 'ui.admin.lovlynet.request_button_echo' => 'Initiate AREAFIX Request', + 'ui.admin.lovlynet.request_button_file' => 'Initiate FILEFIX Request', + 'ui.admin.lovlynet.btn_rescan' => 'Rescan', + 'ui.admin.lovlynet.btn_rescan_title' => 'Request rescan of messages', + 'ui.admin.lovlynet.sync_title_description' => 'Sync description', + 'ui.admin.lovlynet.sync_title_create_and_description' => 'Create group and sync description', + 'ui.admin.lovlynet.request_modal_title_echo' => 'AreaFix Request', + 'ui.admin.lovlynet.request_modal_title_file' => 'FileFix Request', + 'ui.admin.lovlynet.request_modal_help_echo' => 'Enter the AreaFix command text to send. The destination and password are filled in automatically. A response will be sent to you by netmail.', + 'ui.admin.lovlynet.request_modal_help_file' => 'Enter the FileFix command text to send. The destination and password are filled in automatically. A response will be sent to you by netmail.', + 'ui.admin.lovlynet.rescan_modal_title' => 'AreaFix Rescan Request', + 'ui.admin.lovlynet.rescan_modal_help' => 'Choose the subscribed echo area to rescan and how much history to request from AreaFix.', + 'ui.admin.lovlynet.rescan_area_label' => 'Echo area', + 'ui.admin.lovlynet.rescan_mode_label' => 'Rescan scope', + 'ui.admin.lovlynet.rescan_mode_days' => 'Last N days', + 'ui.admin.lovlynet.rescan_mode_messages' => 'Last N messages', + 'ui.admin.lovlynet.rescan_mode_all' => 'All messages', + 'ui.admin.lovlynet.rescan_amount_days_label' => 'Number of days', + 'ui.admin.lovlynet.rescan_amount_days_help' => 'Send messages from the last N days from this echo area.', + 'ui.admin.lovlynet.rescan_amount_messages_label' => 'Number of messages', + 'ui.admin.lovlynet.rescan_amount_messages_help' => 'Send the last N messages from this echo area.', + 'ui.admin.lovlynet.rescan_area_required' => 'An echo area is required', + 'ui.admin.lovlynet.rescan_amount_required' => 'Enter a whole number greater than zero', + 'ui.admin.lovlynet.rescan_send' => 'Send rescan request', + 'ui.admin.lovlynet.rescan_send_failed' => 'Failed to send rescan request', + 'ui.admin.lovlynet.rescan_sent' => 'AreaFix rescan request sent', + 'ui.admin.lovlynet.request_help_button' => 'Help', + 'ui.admin.lovlynet.request_help_title' => 'Remote Help', + 'ui.admin.lovlynet.request_help_failed' => 'Failed to load help text', + 'ui.admin.lovlynet.request_help_empty' => 'No help text available.', + 'ui.admin.lovlynet.message_view_title' => 'Message', + 'ui.admin.lovlynet.message_view_title_request' => 'Request Message', + 'ui.admin.lovlynet.message_view_title_response' => 'Response Message', + 'ui.admin.lovlynet.request_message_label' => 'Request message', + 'ui.admin.lovlynet.request_message_placeholder' => '%HELP', + 'ui.admin.lovlynet.request_message_required' => 'Request message is required', + 'ui.admin.lovlynet.request_send' => 'Send request', + 'ui.admin.lovlynet.request_send_failed' => 'Failed to send request', + 'ui.admin.lovlynet.request_sent_echo' => 'AreaFix request sent', + 'ui.admin.lovlynet.request_sent_file' => 'FileFix request sent', + 'ui.admin.lovlynet.requests_load_failed' => 'Failed to load request status', + 'ui.admin.lovlynet.requests_col_type' => 'Type', + 'ui.admin.lovlynet.requests_col_direction' => 'Direction', + 'ui.admin.lovlynet.requests_col_status' => 'Status', + 'ui.admin.lovlynet.requests_col_message' => 'Message', + 'ui.admin.lovlynet.requests_col_date' => 'Date', + 'ui.admin.lovlynet.requests_no_subject' => '(no subject)', + 'ui.admin.lovlynet.requests_request_label' => 'Request sent', + 'ui.admin.lovlynet.requests_response_label' => 'Hub response', + 'ui.admin.lovlynet.type_areafix' => 'AREAFIX', + 'ui.admin.lovlynet.type_filefix' => 'FILEFIX', + 'ui.admin.lovlynet.direction_request' => 'Request', + 'ui.admin.lovlynet.direction_response' => 'Response', + 'ui.admin.lovlynet.status_pending' => 'Pending response', + 'ui.admin.lovlynet.status_responded' => 'Responded', + 'ui.admin.lovlynet.status_response' => 'Response received', + 'ui.admin.lovlynet.btn_resend' => 'Sync Files', + 'ui.admin.lovlynet.btn_resend_title' => 'Sync local and LovlyNet files', + 'ui.admin.lovlynet.resend_modal_title' => 'Sync Files', + 'ui.admin.lovlynet.resend_modal_help' => 'Hatch local files that are not yet in LovlyNet, or request a %RESEND for files you are missing locally.', + 'ui.admin.lovlynet.resend_area_label' => 'File area', + 'ui.admin.lovlynet.resend_files_loading' => 'Loading files...', + 'ui.admin.lovlynet.resend_files_none' => 'No files found in this area', + 'ui.admin.lovlynet.resend_files_error' => 'Failed to load files from LovlyNet', + 'ui.admin.lovlynet.resend_files_select_all' => 'Select all', + 'ui.admin.lovlynet.resend_files_deselect_all' => 'Deselect all', + 'ui.admin.lovlynet.resend_file_count_label' => 'file(s) available', + 'ui.admin.lovlynet.resend_selected_label' => 'selected', + 'ui.admin.lovlynet.resend_page_label' => 'Page', + 'ui.admin.lovlynet.resend_col_filename' => 'Filename', + 'ui.admin.lovlynet.resend_col_description' => 'Description', + 'ui.admin.lovlynet.resend_col_local' => 'Local', + 'ui.admin.lovlynet.resend_local_yes_title' => 'File is in your local file area', + 'ui.admin.lovlynet.resend_local_no_title' => 'File is not in your local file area', + 'ui.admin.lovlynet.resend_select_required' => 'Select at least one file', + 'ui.admin.lovlynet.resend_send' => 'Send resend request', + 'ui.admin.lovlynet.resend_send_failed' => 'Failed to send resend request', + 'ui.admin.lovlynet.resend_sent' => 'FileFix resend request sent', + 'ui.admin.lovlynet.sync_local_only_section' => 'Local Files (not in LovlyNet)', + 'ui.admin.lovlynet.sync_lovlynet_section' => 'LovlyNet Files', + 'ui.admin.lovlynet.sync_no_local_only' => 'All local files are already in LovlyNet', + 'ui.admin.lovlynet.sync_hatch_btn' => 'Hatch', + 'ui.admin.lovlynet.sync_hatch_btn_title' => 'Hatch this file to LovlyNet', + 'ui.admin.lovlynet.sync_hatch_success' => 'File hatched to LovlyNet', + 'ui.admin.lovlynet.sync_hatch_failed' => 'Failed to hatch file', + + // Public file areas index page + 'ui.public_files.title' => 'Public File Areas', + 'ui.public_files.heading' => 'Public File Areas', + 'ui.public_files.description' => 'Browse and download files without creating an account.', + 'ui.public_files.og_description' => 'Browse and download files from public file areas.', + 'ui.public_files.no_areas' => 'No public file areas are available.', ]; diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index 12d665681..dfe727c26 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -55,6 +55,14 @@ '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.echomail.save_ad.admin_required' => 'Admin privileges are required', + 'errors.messages.echomail.save_ad.not_ansi' => 'Only ANSI echomail messages can be saved to the ad library', + 'errors.messages.echomail.save_ad.failed' => 'Failed to save message to the ad library', + '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', @@ -127,6 +135,9 @@ 'errors.files.not_found' => 'File not found', 'errors.files.share_not_found_or_forbidden' => 'Share link not found or not permitted', 'errors.files.delete_failed' => 'Failed to delete file', + 'errors.files.rehatch_local' => 'Cannot rehatch a file in a local-only area', + 'errors.files.rehatch_private' => 'Cannot rehatch a file in a private area', + 'errors.files.rehatch_failed' => 'Rehatch failed', 'errors.files.scan_forbidden' => 'Admin access required to scan files', 'errors.files.scan_disabled' => 'Virus scanning is disabled', 'errors.files.scan_failed' => 'Virus scan failed', @@ -148,7 +159,15 @@ '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', + 'errors.files.comments_not_enabled' => 'Comments are not enabled for this file area', + 'errors.files.comments_forbidden' => 'You do not have permission to comment here', + 'errors.files.comment_body_required' => 'Comment body is required', + 'errors.files.comment_post_failed' => 'Failed to post comment', + 'errors.files.invalid_reply_target' => 'Invalid reply target', + 'errors.fileareas.comment_area_failed' => 'Failed to create or link comment area', // Admin Users 'errors.admin.users.not_found' => 'User not found', @@ -172,6 +191,12 @@ 'errors.messages.drafts.not_found' => 'Draft not found', 'errors.messages.drafts.get_failed' => 'Failed to load draft', 'errors.messages.drafts.delete_failed' => 'Failed to delete draft', + 'errors.messages.templates.not_licensed' => 'Message templates require a registered license', + 'errors.messages.templates.not_found' => 'Template not found', + 'errors.messages.templates.name_required' => 'Template name is required', + 'errors.messages.templates.name_too_long' => 'Template name must be 100 characters or less', + 'errors.economy.not_licensed' => 'Economy viewer requires a registered license', + 'errors.referrals.not_licensed' => 'Referral analytics require a registered license', 'errors.messages.netmail.get_failed' => 'Failed to load message', 'errors.messages.echomail.get_failed' => 'Failed to load message', @@ -225,6 +250,12 @@ 'errors.binkp.process_outbound_failed' => 'Failed to process outbound queue', 'errors.binkp.connection_test_failed' => 'Failed to test BinkP connection', 'errors.binkp.logs.failed' => 'Failed to load BinkP logs', + 'errors.binkp.logs.search_failed' => 'Log search failed', + 'errors.binkp.logs.search_query_too_short' => 'Search query must be at least 2 characters', + 'errors.binkp.kept_packets.failed' => 'Failed to load kept packets', + 'errors.binkp.kept_packets.invalid_type' => 'type must be inbound or outbound', + 'errors.binkp.kept_packets.license_required' => 'Viewing kept packets requires a registered license', + 'errors.binkp.kept_packets.inspect_failed' => 'Failed to inspect packet', 'errors.binkp.uplink.address_hostname_required' => 'Address and hostname are required', 'errors.binkp.uplink.poll_failed' => 'Failed to poll BinkP uplink', 'errors.binkp.uplink.poll_all_failed' => 'Failed to poll all BinkP uplinks', @@ -282,6 +313,8 @@ 'errors.admin.appearance.shell.save_failed' => 'Failed to save shell settings', 'errors.admin.appearance.message_reader.save_failed' => 'Failed to save message reader settings', 'errors.admin.appearance.markdown_preview.failed' => 'Failed to render markdown preview', + 'errors.admin.appearance.splash.license_required' => 'A valid license is required to configure splash pages', + 'errors.admin.appearance.splash.save_failed' => 'Failed to save splash settings', 'errors.admin.shell_art.list_failed' => 'Failed to list shell art files', 'errors.admin.shell_art.upload.no_file' => 'No shell art file uploaded', 'errors.admin.shell_art.upload.upload_error' => 'Shell art upload failed', @@ -315,7 +348,21 @@ 'errors.admin.ads.upload.file_too_large' => 'Advertisement file exceeds size limit', 'errors.admin.ads.upload.read_failed' => 'Failed to read uploaded advertisement file', 'errors.admin.ads.upload.failed' => 'Failed to upload advertisement', + 'errors.admin.ads.not_found' => 'Advertisement not found', + 'errors.admin.ads.load_one_failed' => 'Failed to load advertisement', + 'errors.admin.ads.invalid_payload' => 'Invalid advertisement payload', + 'errors.admin.ads.save_failed' => 'Failed to save advertisement', 'errors.admin.ads.delete_failed' => 'Failed to delete advertisement', + 'errors.admin.ad_campaigns.list_failed' => 'Failed to load ad campaigns', + 'errors.admin.ad_campaigns.log_failed' => 'Failed to load ad campaign log', + 'errors.admin.ad_campaigns.meta_failed' => 'Failed to load ad campaign metadata', + 'errors.admin.ad_campaigns.not_found' => 'Ad campaign not found', + 'errors.admin.ad_campaigns.load_one_failed' => 'Failed to load ad campaign', + 'errors.admin.ad_campaigns.invalid_payload' => 'Invalid ad campaign payload', + 'errors.admin.ad_campaigns.create_failed' => 'Failed to create ad campaign', + 'errors.admin.ad_campaigns.save_failed' => 'Failed to save ad campaign', + 'errors.admin.ad_campaigns.delete_failed' => 'Failed to delete ad campaign', + 'errors.admin.ad_campaigns.run_failed' => 'Failed to run ad campaign', 'errors.admin.chat_rooms.invalid_name_length' => 'Room name must be 1-64 characters', 'errors.admin.chat_rooms.create_failed' => 'Failed to create chat room', 'errors.admin.chat_rooms.not_found' => 'Chat room not found', @@ -472,4 +519,26 @@ 'errors.admin.bbs_directory.merge_missing_discard' => 'discard_id is required', 'errors.admin.bbs_directory.merge_failed' => 'Merge failed', 'errors.admin.echomail_robots.invalid_config_json' => 'Processor config is not valid JSON', + 'errors.files.iso_not_mounted' => 'File area is not mounted', + 'errors.files.iso_readonly' => 'ISO-backed files cannot be modified', + 'errors.fileareas.reindex_failed' => 'Failed to start ISO re-index', + 'errors.admin.lovlynet.invalid_json' => 'Invalid request payload', + 'errors.admin.lovlynet.invalid_area_type' => 'Invalid area type', + 'errors.admin.lovlynet.request_message_required' => 'Request message is required', + 'errors.admin.lovlynet.not_configured' => 'LovlyNet is not configured', + 'errors.admin.lovlynet.request_config_missing' => 'LovlyNet request settings are incomplete', + 'errors.admin.lovlynet.help_fetch_failed' => 'Failed to load help text', + 'errors.admin.lovlynet.request_send_failed' => 'Failed to send request netmail', + 'errors.admin.lovlynet.filearea_files_failed' => 'Failed to load file area files', + 'errors.admin.lovlynet.invalid_file_id' => 'Invalid file ID', + 'errors.admin.lovlynet.hatch_failed' => 'Failed to hatch file', + + // QWK Offline Mail + 'errors.qwk.disabled' => 'QWK offline mail is not enabled on this system', + 'errors.qwk.no_file' => 'No REP file received. Send the file in the "rep" field.', + 'errors.qwk.upload_error' => 'File upload error', + 'errors.qwk.invalid_extension' => 'Please upload a .REP or .ZIP file', + 'errors.qwk.processing_failed' => 'Failed to process REP packet', + 'errors.qwk.status_failed' => 'Failed to retrieve QWK status', + 'errors.qwk.invalid_format' => 'Format must be "qwk" or "qwke"', ]; diff --git a/config/i18n/en/terminalserver.php b/config/i18n/en/terminalserver.php index b5881fcb3..412ace259 100644 --- a/config/i18n/en/terminalserver.php +++ b/config/i18n/en/terminalserver.php @@ -188,6 +188,9 @@ 'ui.terminalserver.files.upload_duplicate' => 'This file already exists in this area.', 'ui.terminalserver.files.upload_readonly' => 'This area is read-only. Uploads are not permitted.', 'ui.terminalserver.files.upload_admin_only' => 'Only administrators can upload to this area.', + 'ui.terminalserver.files.files_back_hint' => 'B)ack to parent folder', + 'ui.terminalserver.files.not_a_file' => 'That entry is a folder, not a file.', + 'ui.terminalserver.files.enter_folder_or_file' => 'Enter a folder number to browse, or a file number to view details.', // --- Main menu: terminal settings --- 'ui.terminalserver.server.menu.terminal_settings' => 'T) Terminal Settings', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index 77a97c8f0..8ad0eb75d 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', @@ -67,10 +80,14 @@ 'ui.common.domain' => 'Dominio', 'ui.common.ip_address' => 'Direccion IP', 'ui.common.status' => 'Estado', + 'ui.common.time' => 'Hora', + 'ui.common.subject' => 'Asunto', 'ui.common.from' => 'De', 'ui.common.actions' => 'Acciones', 'ui.common.view' => 'Ver', 'ui.common.active' => 'Activo', + 'ui.common.none' => 'Ninguno', + 'ui.common.disabled' => 'Desactivado', 'ui.common.inactive' => 'Inactivo', 'ui.common.user' => 'Usuario', 'ui.common.admin' => 'Admin', @@ -87,7 +104,10 @@ 'ui.common.previous_message' => 'Mensaje anterior', 'ui.common.next_message' => 'Mensaje siguiente', 'ui.common.toggle_fullscreen' => 'Alternar pantalla completa', + 'ui.common.print' => 'Imprimir mensaje', + 'ui.common.add' => 'Agregar', 'ui.common.cancel' => 'Cancelar', + 'ui.common.back' => 'Volver', 'ui.common.clear_search' => 'Limpiar busqueda', 'ui.common.save' => 'Guardar', 'ui.common.save_changes' => 'Guardar cambios', @@ -95,6 +115,7 @@ 'ui.common.saved_short' => 'Guardado', 'ui.common.error_click_retry' => 'Error - haga clic para reintentar', 'ui.common.share' => 'Compartir', + 'ui.common.browse' => 'Explorar', 'ui.common.share_url' => 'URL para compartir:', 'ui.common.copy' => 'Copiar', 'ui.common.optional' => 'opcional', @@ -102,7 +123,9 @@ 'ui.common.unknown' => 'Desconocido', 'ui.common.not_configured' => 'No configurado', 'ui.common.name' => 'Nombre', + 'ui.common.title' => 'Titulo', 'ui.common.description' => 'Descripcion', + 'ui.common.enabled' => 'Activado', 'ui.common.label' => 'Etiqueta', 'ui.common.url' => 'URL', 'ui.common.new_tab' => 'Nueva pestana', @@ -158,6 +181,13 @@ 'ui.about.links' => 'Enlaces', 'ui.about.house_rules' => 'Reglas de la casa', 'ui.footer.powered_by' => 'Desarrollado por', + 'ui.footer.registered_to' => 'Registrado para {name}', + + // 403 Page + 'ui.error403.title' => 'Funcion registrada', + 'ui.error403.heading' => 'Esta funcion requiere registro', + 'ui.error403.description' => 'Esta pagina solo esta disponible para instalaciones registradas. Visite la pagina de licencias para obtener mas informacion sobre el registro y los beneficios que ofrece.', + 'ui.error403.licensing_link' => 'Licencias', // 404 Page 'ui.error404.title' => 'Pagina no encontrada', @@ -169,6 +199,7 @@ // Generic Error Page 'ui.error.title' => 'Error', + 'ui.error.not_found' => 'No encontrado', 'ui.error.access_error' => 'Error de acceso', 'ui.error.processing_request_failed' => 'Ocurrio un error mientras se procesaba su solicitud.', 'ui.error.return_to_dashboard' => 'Volver al panel', @@ -182,6 +213,7 @@ 'ui.web.errors.polls_disabled' => 'El modulo de votacion esta deshabilitado.', 'ui.web.errors.shoutbox_disabled' => 'El shoutbox esta deshabilitado.', 'ui.web.errors.compose_type_invalid' => 'Destino de composicion invalido.', + 'ui.web.errors.not_found' => 'La pagina solicitada no pudo encontrarse.', 'ui.web.fallback.system_name' => 'Sistema BinktermPHP', // Base Layout / Navigation @@ -204,17 +236,21 @@ 'ui.base.logout' => 'Cerrar sesion', 'ui.base.login' => 'Iniciar sesion', 'ui.base.guest_doors' => 'Puertas de invitados', + 'ui.base.public_files' => 'Archivos publicos', 'ui.base.admin.whos_online' => 'Quien esta en linea', 'ui.base.admin.dashboard' => 'Panel', 'ui.base.admin.binkp_status' => 'Estado de Binkp', 'ui.base.admin.manage_users' => 'Gestionar usuarios', 'ui.base.admin.ads' => 'Anuncios', + 'ui.base.admin.ads_menu' => 'Ads', + 'ui.base.admin.ad_campaigns' => 'Campanas de anuncios', 'ui.base.admin.area_management' => 'Gestion de areas', 'ui.base.admin.echo_areas' => 'Areas de eco', 'ui.base.admin.file_areas' => 'Areas de archivos', 'ui.base.admin.file_area_rules' => 'Reglas de areas de archivos', 'ui.base.admin.subscriptions' => 'Suscripciones', 'ui.base.admin.auto_feed' => 'Auto Feed', + 'ui.base.admin.chat' => 'Chat', 'ui.base.admin.chat_rooms' => 'Salas de chat', 'ui.base.admin.mrc_settings' => 'Configuracion de MRC', 'ui.base.admin.polls' => 'Encuestas', @@ -223,19 +259,58 @@ 'ui.base.admin.doors_dos' => 'Puertas DOS', 'ui.base.admin.doors_native' => 'Puertas nativas', 'ui.base.admin.doors_web' => 'Puertas web', + 'ui.base.admin.analytics' => 'Analitica', + 'ui.base.admin.community' => 'Comunidad', + 'ui.base.admin.registered_feature' => 'Funcion registrada', 'ui.base.admin.activity_stats' => 'Estadisticas de actividad', + 'ui.base.admin.sharing' => 'Sharing', 'ui.base.admin.economy_viewer' => 'Visor de economia', + 'ui.base.admin.referrals' => 'Analitica de referidos', + 'ui.base.admin.licensing' => 'Licencias', 'ui.base.admin.bbs_settings' => 'Configuracion del BBS', 'ui.base.admin.appearance' => 'Apariencia', 'ui.base.admin.binkp_configuration' => 'Configuracion de Binkp', 'ui.base.admin.template_editor' => 'Editor de plantillas', 'ui.base.admin.i18n_overrides' => 'Ajustes de idioma', + 'ui.base.admin.docs' => 'Documentación', + 'ui.admin.docs.title' => 'Documentación', + 'ui.admin.docs.back_to_index' => 'Volver al índice', + 'ui.admin.docs.not_found' => 'Documento no encontrado.', 'ui.base.admin.help' => 'Ayuda', 'ui.base.admin.readme' => 'README', 'ui.base.admin.faq' => 'FAQ', 'ui.base.admin.upgrade_notes' => 'Notas de actualizacion v{version}', 'ui.base.admin.claudes_bbs' => 'BBS de Claude', 'ui.base.admin.report_issue' => 'Reportar problema', + 'ui.base.admin.register' => 'Registrar BinktermPHP', + + // Admin Licensing + 'ui.admin.licensing.page_title' => 'Licencias', + 'ui.admin.licensing.heading' => 'Licencias', + 'ui.admin.licensing.current_status' => 'Estado actual de la licencia', + 'ui.admin.licensing.upload_heading' => 'Instalar o reemplazar licencia', + 'ui.admin.licensing.upload_help' => 'Pegue el contenido de su archivo license.json a continuacion y haga clic en Instalar licencia. La licencia se verificara antes de guardarse.', + 'ui.admin.licensing.license_json_label' => 'JSON de licencia', + 'ui.admin.licensing.upload_btn' => 'Instalar licencia', + 'ui.admin.licensing.remove_btn' => 'Eliminar licencia', + 'ui.admin.licensing.tiers_heading' => 'Niveles de licencia', + 'ui.admin.licensing.tier_col' => 'Nivel', + 'ui.admin.licensing.access_col' => 'Descripcion', + 'ui.admin.licensing.tier_community' => 'Comunidad', + 'ui.admin.licensing.tier_community_desc' => 'BBS completo, netmail, echomail y procesamiento de paquetes. Gratis para todos.', + 'ui.admin.licensing.tier_registered' => 'Registrado', + 'ui.admin.licensing.tier_registered_desc' => 'Herramientas administrativas avanzadas, insignia de simpatizante.', + 'ui.admin.licensing.tier_sponsor' => 'Patrocinador', + 'ui.admin.licensing.tier_sponsor_desc' => 'Todas las funciones registradas mas soporte prioritario.', + 'ui.admin.licensing.how_to_heading' => '¿Por que registrarse?', + 'ui.admin.licensing.why_intro' => 'BinktermPHP es de codigo abierto y la edicion comunitaria es completamente funcional. El registro es la forma en que los sysops que encuentran valor en el proyecto pueden apoyar su desarrollo continuo.', + 'ui.admin.licensing.why_sustain' => 'Sostener el desarrollo — el registro apoya directamente las correcciones de errores, nuevas funciones y el trabajo de compatibilidad de protocolos.', + 'ui.admin.licensing.why_branding' => 'Desbloquear control de marca — presente una experiencia completamente personalizada sin la atribucion de BinktermPHP en el pie de pagina.', + 'ui.admin.licensing.why_features' => 'Acceder a herramientas premium — las instalaciones registradas reciben nuevas funciones premium automaticamente a medida que se lanzan.', + 'ui.admin.licensing.why_perpetual' => 'Licencia perpetua — registrese una vez. Sin suscripcion ni tarifa recurrente.', + 'ui.admin.licensing.remove_confirm' => '¿Eliminar el archivo de licencia actual? El sistema volvera a la edicion comunitaria.', + 'ui.admin.licensing.how_to_register_link' => 'Como registrarse', + 'ui.admin.licensing.register_modal_title' => 'Registrar BinktermPHP', // Admin Users 'ui.admin_users.pending_users_error' => 'Error de usuarios pendientes:', @@ -361,6 +436,12 @@ 'ui.admin.appearance.branding.footer_text' => 'Texto del pie', 'ui.admin.appearance.branding.footer_placeholder' => 'Deje en blanco para la linea predeterminada de nodo/sysop', 'ui.admin.appearance.branding.footer_help' => 'Texto personalizado para el pie. Deje en blanco para la linea de informacion predeterminada del sistema.', + 'ui.admin.appearance.branding.hide_powered_by' => 'Ocultar la linea "Desarrollado por BinktermPHP" en el pie', + 'ui.admin.appearance.branding.hide_powered_by_help' => 'Elimina la atribucion de BinktermPHP del pie del sitio. Requiere una licencia valida.', + 'ui.admin.appearance.branding.show_registration_badge' => 'Mostrar estado de registro en el pie', + 'ui.admin.appearance.branding.show_registration_badge_help' => 'Muestra "Registrado para [nombre del sistema]" en el pie del sitio. Requiere una licencia valida con la funcion registered_badge.', + 'ui.admin.appearance.branding.premium_branding_locked' => 'El texto personalizado del pie y las opciones de marca estan disponibles para instalaciones registradas.', + 'ui.admin.appearance.branding.premium_branding_register' => 'Registrar →', 'ui.admin.appearance.branding.save' => 'Guardar marca', 'ui.admin.appearance.content.system_news_title' => 'Noticias del sistema (MOTD)', 'ui.admin.appearance.content.system_news_placeholder' => 'Ingrese noticias del sistema en formato Markdown...', @@ -440,6 +521,18 @@ 'ui.admin.appearance.message_reader.email_link_url_help' => 'Enlace opcional que se muestra en el menu Mensajeria justo debajo de Echomail.', 'ui.admin.appearance.message_reader.save' => 'Guardar ajustes del lector de mensajes', + // Appearance - Splash Pages tab + 'ui.admin.appearance.tab_splash' => 'Paginas de inicio', + 'ui.admin.appearance.splash.title' => 'Paginas de inicio personalizadas', + 'ui.admin.appearance.splash.help' => 'Agregue contenido personalizado que aparece encima de los formularios de inicio de sesion y registro. Compatible con Markdown. Deje en blanco para no mostrar nada.', + 'ui.admin.appearance.splash.login_label' => 'Inicio de sesion', + 'ui.admin.appearance.splash.login_help' => 'Se muestra encima del formulario de inicio de sesion en /login.', + 'ui.admin.appearance.splash.register_label' => 'Pagina de registro', + 'ui.admin.appearance.splash.register_help' => 'Se muestra encima del formulario de registro en /register.', + 'ui.admin.appearance.splash.placeholder' => '## Bienvenido\n\nEste es un mensaje de **inicio personalizado**.', + 'ui.admin.appearance.splash.locked_heading' => 'Funcion registrada', + 'ui.admin.appearance.splash.locked_description' => 'Las paginas de inicio personalizadas estan disponibles para instalaciones registradas.', + // Admin Binkp Config 'ui.admin.binkp_config.load_failed' => 'No se pudo cargar la configuracion', 'ui.admin.binkp_config.save_failed' => 'No se pudo guardar la configuracion', @@ -544,10 +637,40 @@ 'ui.admin.ads.unexpected_response' => 'Respuesta inesperada ({status})', 'ui.admin.ads.page_title' => 'Anuncios', 'ui.admin.ads.heading' => 'Anuncios', + 'ui.admin.ads.library_info' => 'Administre los anuncios ANSI en la biblioteca. Los anuncios heredados importados siguen disponibles aqui para el panel y la seleccion de auto-publicacion.', 'ui.admin.ads.info_text_prefix' => 'Cargue anuncios ANSI (`.ans`) en el directorio', 'ui.admin.ads.info_text_suffix' => '. Los anuncios se muestran aleatoriamente en el panel.', 'ui.admin.ads.upload_new' => 'Cargar nuevo anuncio', 'ui.admin.ads.ansi_file' => 'Archivo ANSI (.ans)', + 'ui.admin.ads.ansi_content' => 'Contenido ANSI', + 'ui.admin.ads.slug_optional' => 'Slug (opcional)', + 'ui.admin.ads.slug' => 'Slug', + 'ui.admin.ads.tags' => 'Etiquetas', + 'ui.admin.ads.tags_placeholder' => 'general, door, network', + 'ui.admin.ads.dashboard_weight' => 'Peso en panel', + 'ui.admin.ads.dashboard_priority' => 'Prioridad en panel', + 'ui.admin.ads.show_on_dashboard' => 'Mostrar en el panel', + 'ui.admin.ads.allow_auto_post' => 'Permitir auto-publicacion', + 'ui.admin.ads.insert_escape_prefix' => 'Insertar ESC[', + 'ui.admin.ads.insert_sequence' => 'Insertar secuencia', + 'ui.admin.ads.select_sequence' => 'Seleccione una secuencia ANSI', + 'ui.admin.ads.sequence_reset' => 'Restablecer (ESC[0m)', + 'ui.admin.ads.sequence_bold' => 'Negrita (ESC[1m)', + 'ui.admin.ads.sequence_red' => 'Rojo (ESC[31m)', + 'ui.admin.ads.sequence_green' => 'Verde (ESC[32m)', + 'ui.admin.ads.sequence_yellow' => 'Amarillo (ESC[33m)', + 'ui.admin.ads.sequence_blue' => 'Azul (ESC[34m)', + 'ui.admin.ads.sequence_white' => 'Blanco (ESC[37m)', + 'ui.admin.ads.sequence_clear_screen' => 'Limpiar pantalla (ESC[2J)', + 'ui.admin.ads.sequence_clear_line' => 'Limpiar linea (ESC[K)', + 'ui.admin.ads.sequence_cursor_home' => 'Cursor al inicio (ESC[H)', + 'ui.admin.ads.escape_helper_help' => 'Inserte un prefijo ESC[ sin procesar o una secuencia ANSI comun en la posicion actual del cursor.', + 'ui.admin.ads.library_list' => 'Biblioteca de anuncios', + 'ui.admin.ads.dashboard' => 'Panel', + 'ui.admin.ads.auto_post' => 'Auto-publicacion', + 'ui.admin.ads.edit_advertisement' => 'Editar anuncio', + 'ui.admin.ads.legacy_filename' => 'Nombre heredado', + 'ui.admin.ads.manage_campaigns' => 'Gestionar campanas', 'ui.admin.ads.save_as_optional' => 'Guardar como (opcional)', 'ui.admin.ads.save_as_placeholder' => 'retro-sale.ans', 'ui.admin.ads.save_as_help' => 'Solo letras, numeros, punto, guion y guion bajo.', @@ -559,6 +682,87 @@ 'ui.admin.ads.loading_ads' => 'Cargando anuncios...', 'ui.admin.ads.none_uploaded' => 'No hay anuncios cargados.', 'ui.admin.ads.view' => 'Ver', + 'ui.admin.ads.saved' => 'Anuncio guardado.', + 'ui.admin.ads.save_failed_with_status' => 'Fallo al guardar ({status})', + 'ui.admin.ads.load_one_failed' => 'No se pudo cargar el anuncio', + 'ui.admin.ads.duplicate_warning' => 'Ya existe contenido ANSI coincidente: {items}', + 'ui.admin.ads.not_found' => 'Anuncio no encontrado', + + // Admin Ad Campaigns + 'ui.admin.ad_campaigns.page_title' => 'Campanas de anuncios', + 'ui.admin.ad_campaigns.heading' => 'Campanas de anuncios', + 'ui.admin.ad_campaigns.back_to_ads' => 'Volver a anuncios', + 'ui.admin.ad_campaigns.add_campaign' => 'Agregar campana', + 'ui.admin.ad_campaigns.edit_campaign' => 'Editar campana', + 'ui.admin.ad_campaigns.campaigns' => 'Campanas', + 'ui.admin.ad_campaigns.targets' => 'Destinos', + 'ui.admin.ad_campaigns.ads' => 'Anuncios', + 'ui.admin.ad_campaigns.schedule' => 'Horario', + 'ui.admin.ad_campaigns.schedules' => 'Horarios', + 'ui.admin.ad_campaigns.interval' => 'Intervalo', + 'ui.admin.ad_campaigns.last_posted' => 'Ultima publicacion', + 'ui.admin.ad_campaigns.next_run' => 'Proxima ejecucion', + 'ui.admin.ad_campaigns.not_scheduled' => 'Sin programacion', + 'ui.admin.ad_campaigns.loading' => 'Cargando campanas de anuncios...', + 'ui.admin.ad_campaigns.none' => 'No hay campanas de anuncios configuradas.', + 'ui.admin.ad_campaigns.recent_history' => 'Historial reciente', + 'ui.admin.ad_campaigns.post_history' => 'Historial de publicaciones', + 'ui.admin.ad_campaigns.no_history' => 'Todavia no hay historial.', + 'ui.admin.ad_campaigns.no_history_rows' => 'No se encontro historial para los filtros actuales.', + 'ui.admin.ad_campaigns.post_as_user' => 'Publicar como usuario', + 'ui.admin.ad_campaigns.to_name' => 'Nombre destino', + 'ui.admin.ad_campaigns.all_campaigns' => 'Todas las campanas', + 'ui.admin.ad_campaigns.all_statuses' => 'Todos los estados', + 'ui.admin.ad_campaigns.status_success' => 'Exito', + 'ui.admin.ad_campaigns.status_failed' => 'Fallido', + 'ui.admin.ad_campaigns.status_dry_run' => 'Simulacion', + 'ui.admin.ad_campaigns.status_skipped' => 'Omitido', + 'ui.admin.ad_campaigns.campaign' => 'Campana', + 'ui.admin.ad_campaigns.ad' => 'Anuncio', + 'ui.admin.ad_campaigns.target' => 'Destino', + 'ui.admin.ad_campaigns.posted_by' => 'Publicado por', + 'ui.admin.ad_campaigns.error' => 'Error', + 'ui.admin.ad_campaigns.add_schedule' => 'Agregar horario', + 'ui.admin.ad_campaigns.schedule_days' => 'Dias', + 'ui.admin.ad_campaigns.schedule_time' => 'Hora', + 'ui.admin.ad_campaigns.schedule_timezone' => 'Zona horaria', + 'ui.admin.ad_campaigns.day_sun' => 'Dom', + 'ui.admin.ad_campaigns.day_mon' => 'Lun', + 'ui.admin.ad_campaigns.day_tue' => 'Mar', + 'ui.admin.ad_campaigns.day_wed' => 'Mie', + 'ui.admin.ad_campaigns.day_thu' => 'Jue', + 'ui.admin.ad_campaigns.day_fri' => 'Vie', + 'ui.admin.ad_campaigns.day_sat' => 'Sab', + 'ui.admin.ad_campaigns.post_interval_minutes' => 'Minutos entre publicaciones', + 'ui.admin.ad_campaigns.repeat_gap_minutes' => 'Minutos antes de repetir', + 'ui.admin.ad_campaigns.selection_mode' => 'Modo de seleccion', + 'ui.admin.ad_campaigns.selection_weighted_random' => 'Aleatorio ponderado', + 'ui.admin.ad_campaigns.add_target' => 'Agregar destino', + 'ui.admin.ad_campaigns.include_tags' => 'Incluir etiquetas', + 'ui.admin.ad_campaigns.include_tags_help' => 'Solo los anuncios que coincidan con al menos una etiqueta incluida seran elegibles.', + 'ui.admin.ad_campaigns.exclude_tags' => 'Excluir etiquetas', + 'ui.admin.ad_campaigns.exclude_tags_help' => 'Los anuncios que coincidan con cualquier etiqueta excluida se omitiran.', + 'ui.admin.ad_campaigns.assigned_ads' => 'Anuncios asignados', + 'ui.admin.ad_campaigns.no_ads_available' => 'Todavia no hay anuncios disponibles.', + 'ui.admin.ad_campaigns.echoarea_domain' => 'Echoarea + Dominio', + 'ui.admin.ad_campaigns.select_target' => 'Seleccione destino...', + 'ui.admin.ad_campaigns.subject_template' => 'Plantilla de asunto', + 'ui.admin.ad_campaigns.select_user' => 'Seleccione usuario...', + 'ui.admin.ad_campaigns.run_now' => 'Ejecutar ahora', + 'ui.admin.ad_campaigns.run_complete' => 'Ejecucion de campana completada.', + 'ui.admin.ad_campaigns.saved' => 'Campana guardada.', + 'ui.admin.ad_campaigns.created' => 'Campana creada.', + 'ui.admin.ad_campaigns.deleted' => 'Campana eliminada.', + 'ui.admin.ad_campaigns.delete_confirm' => 'Eliminar {name}?', + 'ui.admin.ad_campaigns.load_failed' => 'No se pudieron cargar las campanas', + 'ui.admin.ad_campaigns.meta_failed' => 'No se pudieron cargar los metadatos de la campana', + 'ui.admin.ad_campaigns.list_failed' => 'No se pudieron cargar las campanas', + 'ui.admin.ad_campaigns.log_failed' => 'No se pudo cargar el historial de campanas', + 'ui.admin.ad_campaigns.load_one_failed' => 'No se pudo cargar la campana', + 'ui.admin.ad_campaigns.save_failed' => 'No se pudo guardar la campana', + 'ui.admin.ad_campaigns.delete_failed' => 'No se pudo eliminar la campana', + 'ui.admin.ad_campaigns.run_failed' => 'No se pudo ejecutar la campana', + 'ui.admin.ad_campaigns.manual_post' => 'Publicacion manual', // Admin Dashboard 'ui.admin.dashboard.page_title' => 'Panel de administracion', @@ -573,6 +777,11 @@ 'ui.admin.dashboard.active_sessions' => 'Sesiones activas:', 'ui.admin.dashboard.system_address' => 'Direccion del sistema:', 'ui.admin.dashboard.version' => 'Version:', + 'ui.admin.dashboard.registration_status' => 'Registro:', + 'ui.admin.dashboard.registration_registered' => 'Registrado', + 'ui.admin.dashboard.registration_to' => 'Registrado para {system} / {licensee}', + 'ui.admin.dashboard.registration_unregistered' => 'Sin registro', + 'ui.admin.dashboard.registration_link' => 'Registrar →', 'ui.admin.dashboard.git_branch_commit' => 'Rama / Commit de Git:', 'ui.admin.dashboard.database_version' => 'Version de base de datos:', 'ui.admin.dashboard.service_status' => 'Estado de servicios', @@ -580,9 +789,97 @@ 'ui.admin.dashboard.service.binkp_scheduler' => 'Programador Binkp', 'ui.admin.dashboard.service.binkp_server' => 'Servidor Binkp', 'ui.admin.dashboard.service.telnetd' => 'Servidor Telnet', + 'ui.admin.dashboard.service.ssh_daemon' => 'Servidor SSH', + 'ui.admin.dashboard.service.gemini_daemon' => 'Servidor Gemini', + 'ui.admin.dashboard.service.mrc_daemon' => 'Daemon MRC', + 'ui.admin.dashboard.service.multiplexing_server' => 'Servidor de multiplexación', 'ui.admin.dashboard.running' => 'En ejecucion', 'ui.admin.dashboard.stopped' => 'Detenido', + 'ui.admin.dashboard.not_configured' => 'No configurado', 'ui.admin.dashboard.pid' => 'PID', + 'ui.admin.dashboard.db_size' => 'Tamaño de base de datos', + 'ui.admin.dashboard.db_stats_link' => 'Ver estadísticas', + + // Admin database statistics page + 'ui.admin.db_stats.page_title' => 'Estadísticas de base de datos', + 'ui.admin.db_stats.heading' => 'Estadísticas de base de datos', + 'ui.admin.db_stats.tab.size_growth' => 'Tamaño y crecimiento', + 'ui.admin.db_stats.tab.activity' => 'Actividad', + 'ui.admin.db_stats.tab.query_performance' => 'Rendimiento de consultas', + 'ui.admin.db_stats.tab.replication' => 'Replicación', + 'ui.admin.db_stats.tab.maintenance' => 'Mantenimiento', + 'ui.admin.db_stats.tab.index_health' => 'Salud de índices', + 'ui.admin.db_stats.db_total_size' => 'Tamaño total de la base de datos', + 'ui.admin.db_stats.table_sizes' => 'Tamaño de tablas (top 20)', + 'ui.admin.db_stats.index_sizes' => 'Tamaño de índices (top 20)', + 'ui.admin.db_stats.bloat' => 'Estimación de bloat (tuplas muertas)', + 'ui.admin.db_stats.no_bloat' => 'No se detectaron tuplas muertas significativas.', + 'ui.admin.db_stats.connections' => 'Conexiones activas', + 'ui.admin.db_stats.used' => 'usado', + 'ui.admin.db_stats.cache_hit_ratio' => 'Ratio de aciertos de caché', + 'ui.admin.db_stats.cache_hit_warning' => 'Por debajo del 99% recomendado', + 'ui.admin.db_stats.transactions' => 'Transacciones', + 'ui.admin.db_stats.committed' => 'confirmadas', + 'ui.admin.db_stats.rolled_back' => 'revertidas', + 'ui.admin.db_stats.tuples' => 'Actividad de tuplas', + 'ui.admin.db_stats.inserted' => 'Insertadas', + 'ui.admin.db_stats.updated' => 'Actualizadas', + 'ui.admin.db_stats.deleted' => 'Eliminadas', + 'ui.admin.db_stats.connection_states' => 'Estados de conexión', + 'ui.admin.db_stats.pg_stat_statements_unavailable' => 'La extensión pg_stat_statements no está instalada. Instálala para habilitar el análisis de consultas lentas y frecuentes.', + 'ui.admin.db_stats.lock_waits' => 'Esperas de bloqueo', + 'ui.admin.db_stats.deadlocks' => 'Interbloqueos (acumulado)', + 'ui.admin.db_stats.long_running' => 'Consultas de larga duración (>5s)', + 'ui.admin.db_stats.slow_queries' => 'Consultas más lentas (por tiempo medio)', + 'ui.admin.db_stats.frequent_queries' => 'Consultas más frecuentes', + 'ui.admin.db_stats.no_replication' => 'No hay replicación configurada en este servidor.', + 'ui.admin.db_stats.replication_senders' => 'Emisores de replicación', + 'ui.admin.db_stats.wal_receiver' => 'Receptor WAL', + 'ui.admin.db_stats.vacuum_needed' => '{count} tabla(s) pueden necesitar VACUUM (>10k tuplas muertas o >5% muertas).', + 'ui.admin.db_stats.autovacuum_active' => 'Trabajadores de autovacuum (activos)', + 'ui.admin.db_stats.maintenance_health' => 'Estado de Vacuum / Analyze', + 'ui.admin.db_stats.never' => 'Nunca', + 'ui.admin.db_stats.unused_indexes' => 'Índices sin uso', + 'ui.admin.db_stats.no_unused_indexes' => 'No se encontraron índices sin uso.', + 'ui.admin.db_stats.duplicate_indexes' => 'Índices potencialmente redundantes', + 'ui.admin.db_stats.scan_ratios' => 'Ratio de escaneo por índice vs secuencial', + 'ui.admin.db_stats.no_data' => 'No hay datos disponibles.', + 'ui.admin.db_stats.col.table' => 'Tabla', + 'ui.admin.db_stats.col.index' => 'Índice', + 'ui.admin.db_stats.col.total_size' => 'Total', + 'ui.admin.db_stats.col.table_size' => 'Tabla', + 'ui.admin.db_stats.col.index_size' => 'Índices', + 'ui.admin.db_stats.col.size' => 'Tamaño', + 'ui.admin.db_stats.col.dead_tuples' => 'Tuplas muertas', + 'ui.admin.db_stats.col.dead_pct' => 'Muertas %', + 'ui.admin.db_stats.col.state' => 'Estado', + 'ui.admin.db_stats.col.count' => 'Cantidad', + 'ui.admin.db_stats.col.pid' => 'PID', + 'ui.admin.db_stats.col.user' => 'Usuario', + 'ui.admin.db_stats.col.duration' => 'Duración', + 'ui.admin.db_stats.col.query' => 'Consulta', + 'ui.admin.db_stats.col.mean_ms' => 'Media (ms)', + 'ui.admin.db_stats.col.total_ms' => 'Total (ms)', + 'ui.admin.db_stats.col.calls' => 'Llamadas', + 'ui.admin.db_stats.col.client' => 'Cliente', + 'ui.admin.db_stats.col.lag_bytes' => 'Desfase (bytes)', + 'ui.admin.db_stats.col.status' => 'Estado', + 'ui.admin.db_stats.col.sender' => 'Emisor', + 'ui.admin.db_stats.col.last_vacuum' => 'Último vacuum', + 'ui.admin.db_stats.col.last_analyze' => 'Último analyze', + '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', @@ -877,6 +1174,49 @@ 'ui.admin.filearea_rules.tip_1' => 'Las reglas se ejecutan en orden: primero globales y luego las especificas del area.', 'ui.admin.filearea_rules.tip_2' => 'Las reglas de area pueden usar TAG o TAG@DOMAIN (la especifica por dominio tiene prioridad).', 'ui.admin.filearea_rules.tip_3' => 'Use success_action y fail_action con +stop para detener el procesamiento adicional.', + 'ui.admin.filearea_rules.tab_gui' => 'Editor visual', + 'ui.admin.filearea_rules.tab_json' => 'Editor JSON', + 'ui.admin.filearea_rules.global_rules_section' => 'Reglas globales', + 'ui.admin.filearea_rules.area_rules_section' => 'Reglas por área', + 'ui.admin.filearea_rules.add_rule' => 'Añadir regla', + 'ui.admin.filearea_rules.add_area' => 'Añadir área', + 'ui.admin.filearea_rules.add_rule_title' => 'Añadir regla', + 'ui.admin.filearea_rules.edit_rule_title' => 'Editar regla', + 'ui.admin.filearea_rules.add_area_title' => 'Añadir área', + 'ui.admin.filearea_rules.no_rules' => 'Sin reglas. Haga clic en Añadir regla para crear una.', + 'ui.admin.filearea_rules.no_areas' => 'Sin reglas de área. Haga clic en Añadir área para crear una.', + 'ui.admin.filearea_rules.col_enabled' => 'Activo', + 'ui.admin.filearea_rules.col_name' => 'Nombre', + 'ui.admin.filearea_rules.col_pattern' => 'Patrón', + 'ui.admin.filearea_rules.col_domain' => 'Dominio', + 'ui.admin.filearea_rules.col_success' => 'Al éxito', + 'ui.admin.filearea_rules.col_fail' => 'Al fallo', + 'ui.admin.filearea_rules.field_name' => 'Nombre de la regla', + 'ui.admin.filearea_rules.field_pattern' => 'Patrón de nombre de archivo (regex)', + 'ui.admin.filearea_rules.field_pattern_hint' => 'Regex estilo PHP, ej. /^NODELIST\\.\\d+$/i', + 'ui.admin.filearea_rules.field_script' => 'Comando de script', + 'ui.admin.filearea_rules.field_timeout' => 'Tiempo límite (segundos)', + 'ui.admin.filearea_rules.field_enabled' => 'Activo', + 'ui.admin.filearea_rules.field_success_action' => 'Al éxito', + 'ui.admin.filearea_rules.field_fail_action' => 'Al fallo', + 'ui.admin.filearea_rules.field_domain' => 'Filtro de dominio', + 'ui.admin.filearea_rules.field_domain_hint' => 'Opcional. Restringir la regla a un dominio específico (ej. fidonet).', + 'ui.admin.filearea_rules.action_move' => 'mover al área:', + 'ui.admin.filearea_rules.area_tag_label' => 'Etiqueta de área', + 'ui.admin.filearea_rules.area_tag_hint' => 'ej. NODELIST o NODELIST@fidonet', + 'ui.admin.filearea_rules.delete_area_confirm' => '¿Eliminar esta área y todas sus reglas?', + 'ui.admin.filearea_rules.clone_rule' => 'Clonar regla', + 'ui.admin.filearea_rules.clone_rule_confirm' => '¿Clonar regla?', + 'ui.admin.filearea_rules.delete_rule_confirm' => '¿Eliminar esta regla?', + 'ui.admin.filearea_rules.json_parse_error' => 'Error de análisis JSON — corrija la sintaxis antes de cambiar al editor visual.', + 'ui.admin.filearea_rules.pattern_test_title' => 'Probar patrón', + 'ui.admin.filearea_rules.pattern_test_placeholder' => 'Introduzca un nombre de archivo para probar...', + 'ui.admin.filearea_rules.pattern_match' => 'coincide', + 'ui.admin.filearea_rules.pattern_no_match' => 'no coincide', + 'ui.admin.filearea_rules.pattern_invalid' => 'regex inválida', + 'ui.admin.filearea_rules.pattern_test_area_not_found' => 'Área no encontrada en la base de datos.', + 'ui.admin.filearea_rules.pattern_test_no_files' => 'No hay archivos en esta área.', + 'ui.admin.filearea_rules.pattern_test_area_files' => 'Archivos en el área:', // Admin DOS Doors Config 'ui.admin.dosdoors_config.load_config_failed' => 'No se pudo cargar la configuracion', @@ -1043,13 +1383,26 @@ 'ui.admin.bbs_settings.features.guest_doors_page_help' => 'Muestra una pagina publica /guest-doors con puertas de acceso anonimo. Tambien muestra un enlace en la pagina de inicio de sesion.', 'ui.admin.bbs_settings.features.enable_bbs_directory' => 'Habilitar directorio BBS', 'ui.admin.bbs_settings.features.bbs_directory_help' => 'Muestra la pagina publica /bbs-directory y el menu de navegacion de listas BBS. Cuando esta deshabilitado, la pagina devuelve 404.', + 'ui.admin.bbs_settings.features.enable_qwk' => 'Habilitar correo sin conexion QWK', + 'ui.admin.bbs_settings.features.qwk_help' => 'Permite a los usuarios descargar paquetes QWK y subir paquetes de respuesta REP para leer correo sin conexion.', + 'ui.qwk.download_failed_prefix' => 'Descarga fallida: ', 'ui.admin.bbs_settings.features.default_echo_interface' => 'Interfaz de echo predeterminada', 'ui.admin.bbs_settings.features.echo_list_forum' => 'Lista de echo (vista foro)', 'ui.admin.bbs_settings.features.reader_message_list' => 'Lector (lista de mensajes)', 'ui.admin.bbs_settings.features.default_echo_help' => 'Interfaz predeterminada para ver echomail. Los usuarios pueden sobrescribirla en su configuracion.', + 'ui.admin.bbs_settings.features.message_reader_sender_node_display' => 'Visualizacion del nodo remitente en el lector', + 'ui.admin.bbs_settings.features.message_reader_sender_node_display_system_name' => 'Nombre del sistema', + 'ui.admin.bbs_settings.features.message_reader_sender_node_display_node_number' => 'Numero de nodo', + 'ui.admin.bbs_settings.features.message_reader_sender_node_display_help' => 'Elige si el encabezado De muestra el nombre del BBS remitente o la direccion del nodo FTN. Si no se encuentra un punto, se prueba automaticamente el nodo padre.', 'ui.admin.bbs_settings.features.max_cross_post_areas' => 'Maximo de areas de cross-post', 'ui.admin.bbs_settings.features.max_cross_post_help' => 'Numero maximo de areas adicionales a las que un usuario puede cross-postear (2-20).', + 'ui.admin.bbs_settings.features.dashboard_ad_rotate_interval' => 'Intervalo de rotacion de anuncios del panel', + 'ui.admin.bbs_settings.features.dashboard_ad_rotate_interval_help' => 'Con que frecuencia rotan automaticamente los anuncios del panel, en segundos (5-300). El valor predeterminado es 20 segundos.', + 'ui.admin.bbs_settings.features.enable_public_files_index' => 'Habilitar indice de archivos publicos', + 'ui.admin.bbs_settings.features.public_files_index_help' => 'Muestra una pagina publica /public-files con todas las areas de archivos publicas. Tambien agrega un enlace de navegacion para invitados. Requiere una licencia registrada.', + 'ui.admin.bbs_settings.features.public_files_index_requires_license' => 'El indice de archivos publicos requiere una licencia registrada.', 'ui.admin.bbs_settings.features.save' => 'Guardar configuracion', + 'ui.admin.bbs_settings.validation.dashboard_ad_rotate_interval_range' => 'El intervalo de rotacion de anuncios del panel debe ser un entero entre 5 y 300 segundos.', 'ui.admin.bbs_settings.credits.title' => 'Configuracion del sistema de creditos', 'ui.admin.bbs_settings.credits.enabled' => 'Sistema de creditos habilitado', 'ui.admin.bbs_settings.credits.currency_symbol' => 'Simbolo de moneda', @@ -1070,6 +1423,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', @@ -1091,6 +1452,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.', @@ -1226,6 +1591,50 @@ 'ui.admin.activity_stats.chat' => 'Chat', 'ui.admin.activity_stats.auth' => 'Autenticacion', 'ui.admin.activity_stats.anonymous' => '(anon)', + 'ui.admin.sharing.page_title' => 'Sharing', + 'ui.admin.sharing.heading' => 'Sharing', + 'ui.admin.sharing.dashboard' => 'Panel', + 'ui.admin.sharing.help' => 'Revise los mensajes y archivos compartidos activos, ordenados por numero de vistas.', + 'ui.admin.sharing.loading' => 'Cargando datos de compartidos...', + 'ui.admin.sharing.messages_tab' => 'Mensajes', + 'ui.admin.sharing.files_tab' => 'Archivos', + 'ui.admin.sharing.messages_heading' => 'Mensajes compartidos', + 'ui.admin.sharing.files_heading' => 'Archivos compartidos', + 'ui.admin.sharing.subject' => 'Asunto', + 'ui.admin.sharing.file' => 'Archivo', + 'ui.admin.sharing.area' => 'Area', + 'ui.admin.sharing.shared_by' => 'Compartido por', + 'ui.admin.sharing.views' => 'Vistas', + 'ui.admin.sharing.last_accessed' => 'Ultimo acceso', + 'ui.admin.sharing.access' => 'Acceso', + 'ui.admin.sharing.open' => 'Abrir', + 'ui.admin.sharing.public' => 'Publico', + 'ui.admin.sharing.private' => 'Privado', + 'ui.admin.sharing.freq_enabled' => 'FREQ', + 'ui.admin.sharing.web_only' => 'Solo web', + 'ui.admin.sharing.never' => 'Nunca', + 'ui.admin.sharing.no_messages' => 'No hay mensajes compartidos activos', + 'ui.admin.sharing.no_files' => 'No hay archivos compartidos activos', + 'ui.admin.sharing.load_failed' => 'No se pudieron cargar los datos de compartidos', + + // Admin Referral Analytics + 'ui.admin.referrals.page_title' => 'Analitica de referidos', + 'ui.admin.referrals.heading' => 'Analitica de referidos', + 'ui.admin.referrals.back' => 'Panel de administracion', + 'ui.admin.referrals.loading' => 'Cargando datos de referidos...', + 'ui.admin.referrals.load_failed' => 'Error al cargar los datos de referidos', + 'ui.admin.referrals.top_referrers' => 'Principales referidores', + 'ui.admin.referrals.recent_signups' => 'Registros recientes por referido', + 'ui.admin.referrals.col_user' => 'Usuario', + 'ui.admin.referrals.col_code' => 'Codigo de referido', + 'ui.admin.referrals.col_signups' => 'Registros', + 'ui.admin.referrals.col_bonus' => 'Bono ganado', + 'ui.admin.referrals.col_referred_by' => 'Referido por', + 'ui.admin.referrals.col_joined' => 'Se unio', + 'ui.admin.referrals.no_referrers' => 'Aun no hay referidos registrados.', + 'ui.admin.referrals.no_recent' => 'Aun no hay registros por referido.', + 'ui.admin.referrals.stat_referred_users' => 'Usuarios referidos', + 'ui.admin.referrals.stat_active_referrers' => 'Referidores activos', // Admin Economy Viewer 'ui.admin.economy.page_title' => 'Visor de economia', @@ -1299,6 +1708,7 @@ 'ui.fileareas.versioning_help' => 'Cuando esta deshabilitado, los archivos con nombres duplicados reciben sufijos de version (_1, _2, etc.)', 'ui.fileareas.tag_required' => 'Tag *', 'ui.fileareas.tag_help' => 'Tag de area de archivos (ej.: NODELIST, GENERAL_FILES)', + 'ui.fileareas.tag_readonly' => 'El tag no se puede cambiar despues de la creacion.', 'ui.fileareas.description_required' => 'Descripcion *', 'ui.fileareas.description_help' => 'Descripcion breve del area de archivos', 'ui.fileareas.network_domain' => 'Dominio de red', @@ -1318,6 +1728,21 @@ 'ui.fileareas.allow_duplicate_content_help' => 'Permitir el mismo contenido de archivo (hash) con nombres distintos', 'ui.fileareas.local_only' => 'Solo local', 'ui.fileareas.local_only_help' => 'No reenviar archivos a uplinks', + 'ui.fileareas.gemini_public' => 'Público en Gemini', + 'ui.fileareas.gemini_public_help' => 'Listar y servir archivos de esta área a través del servidor de cápsulas Gemini', + 'ui.fileareas.freq_enabled' => 'FREQ habilitado', + 'ui.fileareas.freq_enabled_help' => 'Permitir que cualquier nodo FidoNet solicite archivos de esta área mediante FREQ', + 'ui.fileareas.freq_password' => 'Contraseña FREQ', + 'ui.fileareas.freq_password_placeholder' => 'Contraseña opcional', + 'ui.fileareas.freq_password_help' => 'Dejar en blanco para acceso abierto', + 'ui.fileareas.is_public' => 'Area de archivos publica', + 'ui.fileareas.is_public_help' => 'Permite que visitantes no autenticados exploren y descarguen archivos de esta area', + 'ui.fileareas.is_public_requires_license' => 'Las areas de archivos publicas requieren una licencia registrada.', + 'ui.files.freq_accessible' => 'Accesible por FREQ', + 'ui.files.freq_accessible_help' => 'Permitir también que este archivo sea solicitado mediante FidoNet FREQ', + 'ui.freq.status_pending' => 'FREQ pendiente', + 'ui.freq.status_fulfilled' => 'FREQ cumplido', + 'ui.freq.status_denied' => 'FREQ denegado', 'ui.fileareas.upload_permission' => 'Permiso de carga', 'ui.fileareas.upload_users_can_upload' => 'Los usuarios pueden subir', 'ui.fileareas.upload_admin_only' => 'Solo admin', @@ -1335,6 +1760,12 @@ 'ui.fileareas.size' => 'Tamano', 'ui.fileareas.local' => 'Local', 'ui.fileareas.replace' => 'Reemplazar', + 'ui.fileareas.comment_area' => 'Área de echomail para comentarios', + 'ui.fileareas.comment_area_help' => 'Selecciona un área de echomail para habilitar comentarios en archivos. Se guarda junto con el área de archivos.', + 'ui.fileareas.comment_area_placeholder' => 'Nueva etiqueta de área de echomail (p.ej. MISARCH-COMENTARIOS)', + 'ui.fileareas.comment_area_linked' => 'Área de comentarios vinculada', + 'ui.fileareas.comment_area_create_new' => 'Crear nueva área de echomail', + 'ui.fileareas.comment_area_create_help' => 'Se creará una nueva área de echomail local con esta etiqueta.', // Admin Auto Feed 'ui.admin.auto_feed.load_details_failed' => 'No se pudieron cargar los detalles del feed', @@ -1473,6 +1904,10 @@ 'ui.echoareas.sync_not_implemented' => 'La funcionalidad de sincronizacion aun no esta implementada', 'ui.echoareas.export_not_implemented' => 'La funcionalidad de exportacion aun no esta implementada', 'ui.echoareas.validate_not_implemented' => 'La funcionalidad de validacion aun no esta implementada', + 'ui.echoareas.lovlynet_sync_button' => 'Sincronizar', + 'ui.echoareas.lovlynet_sync_button_loading' => 'Sincronizando...', + 'ui.echoareas.lovlynet_sync_success' => 'Descripción actualizada desde LovlyNet', + 'ui.echoareas.lovlynet_sync_failed' => 'No se pudo sincronizar la descripción desde LovlyNet', // BBS Menu Shell 'ui.shell.menu' => 'Menu', @@ -1735,6 +2170,13 @@ 'ui.echomail.viewing_prefix' => 'Viendo:', 'ui.echomail.viewing_all' => 'Viendo: Todos los mensajes', 'ui.echomail.echo_list' => 'Lista de ecos', + 'ui.echomail.full_echo_list' => 'Lista completa de ecos', + 'ui.echomail.manage_subscriptions' => 'Gestionar suscripciones', + 'ui.echomail.save_to_ad_library' => 'Guardar anuncio', + 'ui.echomail.save_to_ad_library_title' => 'Guardar en la biblioteca de anuncios', + 'ui.echomail.save_to_ad_library_saved' => 'Mensaje guardado en la biblioteca de anuncios.', + 'ui.echomail.save_to_ad_library_failed' => 'No se pudo guardar el mensaje en la biblioteca de anuncios', + 'ui.echomail.save_to_ad_library_not_ansi' => 'Este mensaje no se puede guardar como anuncio ANSI.', 'ui.echomail.search_areas_placeholder' => 'Buscar areas...', 'ui.echomail.loading_areas' => 'Cargando areas...', 'ui.echomail.recent_messages' => 'Mensajes recientes', @@ -1777,6 +2219,7 @@ 'ui.files.total_size' => 'Tamano total', 'ui.files.file_details' => 'Detalles del archivo', 'ui.files.filename' => 'Nombre de archivo', + 'ui.files.description' => 'Descripción', 'ui.files.size' => 'Tamano', 'ui.files.uploaded' => 'Subido', 'ui.files.from' => 'De', @@ -1787,13 +2230,19 @@ 'ui.files.share_link' => 'Enlace para compartir', 'ui.files.revoke_link' => 'Revocar enlace', 'ui.files.create_share_link' => 'Crear enlace para compartir', + 'ui.files.share_access_stats' => 'Accedido {count} veces. Ultimo acceso: {last_accessed}', + 'ui.files.never_accessed' => 'Nunca', 'ui.files.select_file_required' => 'Seleccionar archivo *', 'ui.files.maximum_file_size' => 'Tamano maximo de archivo', 'ui.files.short_description_required' => 'Descripcion corta *', 'ui.files.short_description_help' => 'Descripcion breve mostrada en la lista de archivos.', + 'ui.files.upload_descriptions' => 'Descripciones de subida', + 'ui.files.upload_descriptions_help' => 'Cuando suba varios archivos, cada archivo tendra su propia descripcion corta.', 'ui.files.long_description' => 'Descripcion larga', 'ui.files.long_description_help' => 'Descripcion extendida opcional (admite texto plano).', 'ui.files.upload' => 'Subir', + 'ui.files.upload_success_multiple' => '{count} archivos subidos correctamente', + 'ui.files.upload_partial_failure' => 'La subida se detuvo despues de {count} archivo(s): {error}', 'ui.files.recent_uploads_load_failed' => 'No se pudieron cargar las subidas recientes', 'ui.files.no_recent_uploads' => 'Todavia no se han subido archivos', 'ui.files.area' => 'Area', @@ -1832,18 +2281,56 @@ 'ui.files.rename_file' => 'Renombrar archivo', 'ui.files.new_filename' => 'Nuevo nombre de archivo', 'ui.files.rename_success' => 'Archivo renombrado correctamente', + 'ui.files.rehatch' => 'Re-hatch', + 'ui.files.rehatch_ok' => 'Archivo re-hatcheado correctamente', + 'ui.files.rehatch_failed' => 'Error al re-hatchear', 'ui.files.edit' => 'Editar', 'ui.files.edit_file' => 'Editar archivo', + 'ui.files.edit_description' => 'Editar descripción', + 'ui.files.delete_subfolder' => 'Eliminar subcarpeta', + 'ui.files.delete_subfolder_confirm' => '¿Está seguro de que desea eliminar la subcarpeta "{subfolder}" y todos sus archivos? Esta acción no se puede deshacer.', + 'ui.files.subfolder_deleted' => 'Subcarpeta eliminada', 'ui.files.short_description' => 'Descripción corta', 'ui.files.edit_success' => 'Archivo actualizado correctamente', 'ui.files.previous_file' => 'Archivo anterior', 'ui.files.next_file' => 'Archivo siguiente', + 'ui.files.my_files' => 'Mis archivos', 'ui.files.move_to_area' => 'Mover al área', 'ui.files.active_share_exists' => 'Este archivo ya tiene un enlace compartido activo.', 'ui.files.revoke_confirm' => 'Esta seguro de que desea revocar este enlace compartido? Cualquiera con el enlace ya no podra acceder.', 'ui.files.share_revoked' => 'Enlace compartido revocado', 'ui.files.share_link_copied_clipboard' => 'Enlace compartido copiado al portapapeles', 'ui.files.share_link_copied' => 'Enlace compartido copiado', + 'ui.files.preview_title' => 'Vista previa del archivo', + 'ui.files.view_full_size' => 'Ver tamaño completo', + 'ui.files.video_not_supported' => 'Formato de vídeo no compatible con su navegador', + 'ui.files.no_preview' => 'No hay vista previa disponible para este tipo de archivo', + 'ui.files.preview_failed' => 'Error al cargar la vista previa', + 'ui.files.zip_empty' => 'El archivo ZIP está vacío', + 'ui.files.zip_truncated' => 'Mostrando las primeras {count} entradas', + 'ui.files.zip_legacy_compression' => 'Este archivo usa un formato de compresión obsoleto que no se puede previsualizar.', + 'ui.files.zip_legacy_badge' => 'Compresión obsoleta', + 'ui.files.download_zip' => 'Descargar ZIP completo', + 'ui.files.file_info' => 'Información del archivo', + 'ui.files.search_heading' => 'Buscar Archivos', + 'ui.files.search_global_placeholder' => 'Buscar nombre de archivo o descripción…', + 'ui.files.search_result_count' => '{count} resultado(s)', + 'ui.files.search_no_results' => 'No se encontraron archivos', + 'ui.files.search_failed' => 'Búsqueda fallida', + 'ui.files.prg_no_preview' => 'Vista previa no disponible — programa en código máquina', + 'ui.files.prg_run_c64' => 'Ejecutar en C64', + 'ui.files.no_prgs_in_d64' => 'No se encontraron archivos PRG en la imagen de disco', + 'ui.files.comments' => 'Comentarios', + 'ui.files.leave_comment' => 'Dejar un comentario', + 'ui.files.post_comment' => 'Publicar comentario', + 'ui.files.show_all_comments' => 'Mostrar todos los {count} comentarios', + 'ui.files.show_fewer_comments' => 'Mostrar menos', + 'ui.files.no_comments_yet' => 'Aún no hay comentarios. ¡Sé el primero en dejar uno!', + 'ui.files.loading_comments' => 'Cargando comentarios…', + 'ui.files.comments_load_failed' => 'Error al cargar los comentarios', + 'ui.files.comment_post_failed' => 'Error al publicar el comentario', + 'ui.files.comment_posted' => 'Comentario publicado', + 'ui.files.login_to_comment' => 'Inicia sesión para leer y publicar comentarios', // Polls Page 'ui.polls.title' => 'Encuestas', @@ -1934,7 +2421,32 @@ 'ui.binkp.status_tab' => 'Estado', 'ui.binkp.uplinks_tab' => 'Uplinks', 'ui.binkp.queues_tab' => 'Colas', + 'ui.binkp.kept_packets_tab' => 'Paquetes guardados', + 'ui.binkp.kept_packets_locked' => 'Esta funcion requiere una licencia registrada.', + 'ui.binkp.kept_packets_register' => 'Registrarse para desbloquear', 'ui.binkp.logs_tab' => 'Logs', + 'ui.binkp.kept_inbound' => 'Entrantes guardados', + 'ui.binkp.kept_outbound' => 'Salientes guardados', + 'ui.binkp.kept_packets_empty' => 'No se encontraron paquetes guardados.', + 'ui.binkp.kept_packets_total' => '{count} paquete(s)', + 'ui.binkp.kept_date_group' => '{date}', + 'ui.binkp.from_address' => 'Origen', + 'ui.binkp.to_address' => 'Destino', + 'ui.binkp.pkt_header' => 'Encabezado del paquete', + 'ui.binkp.pkt_version' => 'Version del paquete', + 'ui.binkp.pkt_product' => 'Codigo de producto', + 'ui.binkp.pkt_password' => 'Contrasena', + 'ui.binkp.pkt_password_set' => 'Establecida', + 'ui.binkp.pkt_no_password' => 'Ninguna', + 'ui.binkp.pkt_messages' => 'Mensajes', + 'ui.binkp.pkt_no_messages' => 'No se encontraron mensajes en el paquete.', + 'ui.binkp.pkt_from' => 'De', + 'ui.binkp.pkt_to' => 'Para', + 'ui.binkp.pkt_subject' => 'Asunto', + 'ui.binkp.pkt_flags' => 'Atributos', + 'ui.common.filename' => 'Archivo', + 'ui.common.size' => 'Tamaño', + 'ui.common.date' => 'Fecha', 'ui.binkp.system_information' => 'Informacion del sistema', 'ui.binkp.loading_system_information' => 'Cargando informacion del sistema...', 'ui.binkp.uplink_status' => 'Estado de uplinks', @@ -1976,6 +2488,14 @@ 'ui.binkp.logs_heading' => 'Logs de Binkp', 'ui.binkp.lines_option' => '{count} lineas', 'ui.binkp.loading_logs' => 'Cargando logs...', + 'ui.binkp.log_matches' => '{count} / {total} lineas', + 'ui.binkp.advanced_log_search' => 'Busqueda avanzada', + 'ui.binkp.advanced_log_search_help' => 'Busca en el registro completo. Se muestran todas las lineas de cualquier sesion (PID) que contenga una coincidencia.', + 'ui.binkp.advanced_log_search_placeholder' => 'Ej: 1:123/456, FREQ, ERROR', + 'ui.binkp.log_search_summary' => '{matches} coincidencias en {sessions} sesion(es)', + 'ui.binkp.log_search_no_results' => 'No se encontraron resultados.', + 'ui.binkp.log_search_legend_match' => 'Lineas que contienen el termino buscado', + 'ui.binkp.log_search_legend_context' => 'Otras lineas de la misma sesion (mismo PID)', 'ui.binkp.add_new_uplink' => 'Agregar nuevo uplink', 'ui.binkp.ftn_address_required' => 'Direccion FTN *', 'ui.binkp.ftn_address_placeholder' => '1:123/456', @@ -2022,6 +2542,7 @@ 'ui.echolist.show_unread_only' => 'Mostrar solo areas con mensajes sin leer', 'ui.echolist.area_filter_placeholder' => 'Escriba para filtrar por nombre o descripcion...', 'ui.echolist.area_filter_help' => 'Filtra la lista de abajo en tiempo real', + 'ui.echolist.all_networks' => 'Todas las redes', 'ui.echolist.search_heading' => 'Buscar mensajes', 'ui.echolist.search_placeholder' => 'Buscar en el contenido del mensaje...', 'ui.echolist.search_help' => 'Buscar en todo el contenido de mensajes echomail', @@ -2100,6 +2621,8 @@ 'ui.nodelist.index.zone_prefix' => 'Zona', 'ui.nodelist.index.all_nets' => 'Todos los nets', 'ui.nodelist.index.net_prefix' => 'Net', + 'ui.nodelist.index.flag_filter_label' => 'Indicadores', + 'ui.nodelist.index.flag_filter_any' => 'Cualquier indicador', 'ui.nodelist.index.search_results' => 'Resultados de busqueda ({count} nodos)', 'ui.nodelist.index.address' => 'Direccion', 'ui.nodelist.index.type' => 'Tipo', @@ -2143,6 +2666,29 @@ '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.no_coordinates' => 'Ubicación no geocodificada. Ejecuta scripts/geocode_nodelist.php para poblar las coordenadas.', + '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.nodelist.request_allfiles' => 'Solicitar archivo', + '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', + 'ui.nodelist.request_allfiles_password' => 'Contraseña (opcional)', + 'ui.nodelist.request_allfiles_password_help' => 'Proporciona una contraseña si el sistema remoto la requiere para servir listados de archivos.', + 'ui.nodelist.request_allfiles_crashmail' => 'Enviar como correo directo (conectar directamente al nodo)', + 'ui.nodelist.request_allfiles_crashmail_help' => 'Entrega la solicitud directamente a este nodo en lugar de enrutarla a través de tu enlace ascendente.', + 'ui.nodelist.request_allfiles_sent' => 'Solicitud ALLFILES en cola. El listado de archivos llegará en la próxima sesión binkp.', + 'ui.nodelist.request_allfiles_failed' => 'Error al enviar la solicitud ALLFILES.', + 'ui.nodelist.send_request' => 'Enviar solicitud', 'ui.dosdoor_player.page_title' => 'Reproductor de puerta DOS', 'ui.dosdoor_player.document_title_suffix' => 'Puerta DOS', 'ui.dosdoor_player.status_prefix' => 'Estado:', @@ -2225,7 +2771,7 @@ 'ui.shared_file.register' => 'Registrarse', 'ui.shared_file.login' => 'Iniciar sesion', 'ui.shared_file.about_system' => 'Acerca de {system_name}', - 'ui.shared_file.about_system_text' => '{system_name} es un BBS conectado a FidoNet con areas publicas de archivos. Los miembros pueden subir, descargar y compartir archivos de un archivo en crecimiento.', + 'ui.shared_file.about_system_text' => '{system_name} es un BBS conectado a {network} con areas de archivos. Los miembros pueden subir, descargar y compartir archivos de un archivo en crecimiento.', 'ui.shared_file.powered_by' => 'Desarrollado por BinktermPHP', 'ui.shared_file.not_available_title' => 'Archivo no disponible', 'ui.shared_file.not_available_body' => 'Este enlace de archivo compartido no esta disponible. Puede haber expirado o sido revocado.', @@ -2263,6 +2809,9 @@ 'ui.dashboard.unread_netmail' => 'Netmail sin leer', 'ui.dashboard.unread_echomail' => 'Echomail sin leer', 'ui.dashboard.system_news' => 'Noticias del sistema', + 'ui.dashboard.advertisement' => 'Anuncio', + 'ui.dashboard.advertisement_controls' => 'Controles del anuncio', + 'ui.dashboard.advertisement_position' => 'Anuncio {current} de {total}', 'ui.dashboard.system_information' => 'Informacion del sistema', 'ui.dashboard.sysop' => 'Sysop', 'ui.dashboard.user' => 'Usuario', @@ -2338,9 +2887,20 @@ 'ui.settings.threaded_view_echomail_help' => 'Agrupa mensajes de echomail por hilos de conversacion', 'ui.settings.threaded_view_netmail' => 'Activar vista en hilos para netmail', 'ui.settings.threaded_view_netmail_help' => 'Agrupa mensajes de netmail por hilos de conversacion', + 'ui.settings.page_position_memory' => 'Memoria de posicion de pagina', + 'ui.settings.remember_page_position' => 'Recordar ultima pagina en echomail y netmail', + 'ui.settings.remember_page_position_help' => 'Vuelve automaticamente a la pagina donde estabas al regresar a un area', 'ui.settings.quote_display' => 'Visualizacion de citas', 'ui.settings.quote_coloring' => 'Colorear texto citado por profundidad', 'ui.settings.quote_coloring_help' => 'Muestra lineas citadas (que empiezan con >) en diferentes colores segun nivel de anidacion', + 'ui.settings.notifications' => 'Notificaciones', + 'ui.settings.forward_netmail_email' => 'Reenviar netmail al correo electronico', + 'ui.settings.forward_netmail_email_help' => 'Envia una copia de los mensajes de netmail entrantes a tu direccion de correo electronico. Requiere una direccion de correo electronico valida en tu perfil y SMTP configurado.', + 'ui.settings.echomail_digest' => 'Resumen de Echomail', + 'ui.settings.echomail_digest_none' => 'Desactivado', + 'ui.settings.echomail_digest_daily' => 'Diario', + 'ui.settings.echomail_digest_weekly' => 'Semanal', + 'ui.settings.echomail_digest_help' => 'Recibe un correo periodico con un resumen de los nuevos mensajes en tus areas de echo suscritas. Requiere una direccion de correo electronico valida y SMTP configurado.', 'ui.settings.session_security' => 'Sesion y seguridad', 'ui.settings.active_sessions' => 'Sesiones activas', 'ui.settings.active_sessions_help' => 'Administre sus sesiones de inicio de sesion activas', @@ -2480,6 +3040,27 @@ 'ui.compose.address' => 'Direccion', 'ui.compose.tagline_help' => 'Seleccione un tagline del sysop para agregar debajo de su firma.', 'ui.compose.save_draft' => 'Guardar borrador', + 'ui.compose.templates.button' => 'Plantillas', + 'ui.compose.templates.loading' => 'Cargando...', + 'ui.compose.templates.none' => 'No hay plantillas guardadas', + 'ui.compose.templates.save_current' => 'Guardar como plantilla', + 'ui.compose.templates.manage' => 'Administrar plantillas', + 'ui.compose.templates.save_modal_title' => 'Guardar como plantilla', + 'ui.compose.templates.manage_modal_title' => 'Mis plantillas', + 'ui.compose.templates.name_label' => 'Nombre de la plantilla', + 'ui.compose.templates.name_placeholder' => 'p. ej. Anuncio mensual', + 'ui.compose.templates.type_label' => 'Disponible para', + 'ui.compose.templates.type_both' => 'Netmail y Echomail', + 'ui.compose.templates.type_netmail' => 'Solo Netmail', + 'ui.compose.templates.type_echomail' => 'Solo Echomail', + 'ui.compose.templates.save_button' => 'Guardar plantilla', + 'ui.compose.templates.saved' => 'Plantilla guardada', + 'ui.compose.templates.deleted' => 'Plantilla eliminada', + 'ui.compose.templates.name_required' => 'Ingrese un nombre para la plantilla', + 'ui.compose.templates.save_failed' => 'Error al guardar la plantilla', + 'ui.compose.templates.load_failed' => 'Error al cargar la plantilla', + 'ui.compose.templates.delete_confirm' => '¿Eliminar esta plantilla?', + 'ui.compose.templates.delete_failed' => 'Error al eliminar la plantilla', 'ui.compose.send_prefix' => 'Enviar', 'ui.compose.sending' => 'Enviando...', 'ui.compose.draft.empty_content' => 'Agregue contenido antes de guardar el borrador', @@ -2526,6 +3107,7 @@ 'ui.netmail.received_insecure_badge_title' => 'Este mensaje se recibio mediante una sesion binkp insegura/no autenticada', 'ui.netmail.received_insecurely' => 'Recibido de forma insegura', 'ui.netmail.not_authenticated' => 'Este mensaje no fue autenticado', + 'ui.netmail.next_page_title' => 'Cargar siguiente pagina', // Echomail 'ui.echomail.search.failed' => 'La busqueda fallo', @@ -2554,6 +3136,8 @@ 'ui.echomail.press_a_to_cycle' => 'presione A para cambiar', 'ui.echomail.viewer_mode_prefix' => 'Modo del visor:', 'ui.echomail.viewer_mode_auto' => 'Auto', + 'ui.echomail.viewer_mode_rip' => 'RIPscrip', + 'ui.echomail.rip_render_failed' => 'No se pudo renderizar el mensaje RIPscrip', 'ui.echomail.viewer_mode_ansi' => 'ANSI', 'ui.echomail.viewer_mode_amiga_ansi' => 'ANSI Amiga', 'ui.echomail.viewer_mode_petscii' => 'PETSCII', @@ -2571,6 +3155,28 @@ '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.echomail.next_page_title' => 'Cargar siguiente pagina', + 'ui.echomail.next_echo_title' => 'Siguiente eco: {tag}', + 'ui.echomail.end_of_echo_title' => 'Fin de {echo}', + 'ui.echomail.end_of_echo_next_prompt' => '¿Continuar a {echo}?', + 'ui.echomail.end_of_echo_go' => 'Ir a {echo}', + 'ui.echomail.end_of_echo_no_next' => 'No tienes mas mensajes sin leer.', + 'ui.echomail.end_of_echo_next_btn_title' => 'Fin del eco', + 'ui.echomail.subscribe' => 'Suscribirse', + 'ui.echomail.unsubscribe' => 'Cancelar suscripción', + 'ui.echomail.fileref_label' => 'Comentario de archivo:', + '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', @@ -2789,4 +3395,143 @@ 'ui.admin.bbs_directory.is_local_help' => 'Las importaciones automáticas y los procesadores de robots no modificarán esta entrada.', 'ui.admin.bbs_directory.robot_saved' => 'Regla de robot guardada correctamente', 'ui.admin.bbs_directory.robot_deleted' => 'Regla de robot eliminada correctamente', + + // ISO-backed file areas + 'ui.fileareas.area_type' => 'Tipo de área', + 'ui.fileareas.area_type_normal' => 'Normal', + 'ui.fileareas.area_type_iso' => 'Respaldada por ISO', + 'ui.fileareas.iso_mount_point' => 'Punto de montaje', + 'ui.fileareas.iso_mount_point_help' => 'Ruta donde el ISO está montado en el servidor. Monte el ISO manualmente antes de usar esta área (p. ej. sudo mount -o loop imagen.iso /mnt/punto). En Windows ingrese la letra de unidad o ruta (p. ej. D:\\).', + 'ui.fileareas.iso_mount_status' => 'Estado de montaje', + 'ui.fileareas.accessible' => 'Accesible', + 'ui.fileareas.not_accessible' => 'No accesible', + 'ui.fileareas.reindex' => 'Re-indexar ISO', + 'ui.fileareas.reindexing' => 'Indexando…', + 'ui.fileareas.reindex_started' => 'Re-indexación iniciada en segundo plano', + 'ui.fileareas.reindex_done' => 'Re-indexación completada', + 'ui.fileareas.last_indexed' => 'Última indexación', + 'ui.fileareas.flat_import' => 'Importación plana (sin subcarpetas)', + + // LovlyNet admin page + 'ui.base.admin.lovlynet' => 'Áreas LovlyNet', + 'ui.admin.lovlynet.page_title' => 'Áreas LovlyNet', + 'ui.admin.lovlynet.heading' => 'Suscripciones LovlyNet', + 'ui.admin.lovlynet.not_configured_title' => 'LovlyNet no está configurado.', + 'ui.admin.lovlynet.not_configured_body' => 'config/lovlynet.json falta o está incompleto.', + 'ui.admin.lovlynet.not_configured_synopsis' => 'LovlyNet es una red FTN y servicio de hub que proporciona áreas de eco compartidas, áreas de archivos y gestión automatizada de AreaFix/FileFix para los sistemas conectados.', + 'ui.admin.lovlynet.not_configured_setup_intro' => 'Para configurar este sistema para LovlyNet, ejecute el script de configuración desde la raíz del proyecto:', + 'ui.admin.lovlynet.not_configured_docs_intro' => 'Para detalles de configuración y contexto, consulte', + 'ui.admin.lovlynet.node_number' => 'Número de Nodo', + 'ui.admin.lovlynet.server' => 'Servidor', + 'ui.admin.lovlynet.hub_address' => 'Dirección del hub', + 'ui.admin.lovlynet.subscribed_areas' => 'Áreas Suscritas', + 'ui.admin.lovlynet.tab_echo' => 'Áreas de Echo', + 'ui.admin.lovlynet.tab_file' => 'Áreas de Archivos', + 'ui.admin.lovlynet.tab_requests' => 'Solicitudes', + 'ui.admin.lovlynet.no_requests' => 'No se encontraron solicitudes ni respuestas coincidentes', + 'ui.admin.lovlynet.no_areas' => 'No hay áreas disponibles', + 'ui.admin.lovlynet.col_tag' => 'Etiqueta', + 'ui.admin.lovlynet.col_description' => 'Descripción', + 'ui.admin.lovlynet.col_status' => 'Estado', + 'ui.admin.lovlynet.edit_tag_title' => 'Editar {tag}', + 'ui.admin.lovlynet.subscribed' => 'Suscrito', + 'ui.admin.lovlynet.not_subscribed' => 'No suscrito', + 'ui.admin.lovlynet.btn_subscribe' => 'Suscribirse', + 'ui.admin.lovlynet.btn_unsubscribe' => 'Cancelar suscripción', + 'ui.admin.lovlynet.load_failed' => 'Error al cargar áreas desde LovlyNet', + 'ui.admin.lovlynet.toggle_failed' => 'Error al cambiar suscripción', + 'ui.admin.lovlynet.subscribed_ok' => 'Suscrito a {tag}', + 'ui.admin.lovlynet.unsubscribed_ok' => 'Suscripción cancelada de {tag}', + 'ui.admin.lovlynet.request_button_echo' => 'Iniciar solicitud AREAFIX', + 'ui.admin.lovlynet.request_button_file' => 'Iniciar solicitud FILEFIX', + 'ui.admin.lovlynet.btn_rescan' => 'Reescanear', + 'ui.admin.lovlynet.btn_rescan_title' => 'Solicitar reescaneo de mensajes', + 'ui.admin.lovlynet.rescan_modal_title' => 'Solicitud de reescaneo AreaFix', + 'ui.admin.lovlynet.rescan_modal_help' => 'Elija el área de eco suscrita para reescanear y cuánto historial solicitar a AreaFix.', + 'ui.admin.lovlynet.rescan_area_label' => 'Área de eco', + 'ui.admin.lovlynet.rescan_mode_label' => 'Alcance del reescaneo', + 'ui.admin.lovlynet.rescan_mode_days' => 'Últimos N días', + 'ui.admin.lovlynet.rescan_mode_messages' => 'Últimos N mensajes', + 'ui.admin.lovlynet.rescan_mode_all' => 'Todos los mensajes', + 'ui.admin.lovlynet.rescan_amount_days_label' => 'Número de días', + 'ui.admin.lovlynet.rescan_amount_days_help' => 'Enviar mensajes de los últimos N días de este área de eco.', + 'ui.admin.lovlynet.rescan_amount_messages_label' => 'Número de mensajes', + 'ui.admin.lovlynet.rescan_amount_messages_help' => 'Enviar los últimos N mensajes de este área de eco.', + 'ui.admin.lovlynet.rescan_area_required' => 'Se requiere un área de eco', + 'ui.admin.lovlynet.rescan_amount_required' => 'Ingrese un número entero mayor que cero', + 'ui.admin.lovlynet.rescan_send' => 'Enviar solicitud de reescaneo', + 'ui.admin.lovlynet.rescan_send_failed' => 'No se pudo enviar la solicitud de reescaneo', + 'ui.admin.lovlynet.rescan_sent' => 'Solicitud de reescaneo AreaFix enviada', + 'ui.admin.lovlynet.sync_title_description' => 'Sincronizar descripcion', + 'ui.admin.lovlynet.sync_title_create_and_description' => 'Crear grupo y sincronizar descripcion', + 'ui.admin.lovlynet.request_modal_title_echo' => 'Solicitud AreaFix', + 'ui.admin.lovlynet.requests_load_failed' => 'No se pudo cargar el estado de las solicitudes', + 'ui.admin.lovlynet.requests_col_type' => 'Tipo', + 'ui.admin.lovlynet.requests_col_direction' => 'Direcci�n', + 'ui.admin.lovlynet.requests_col_status' => 'Estado', + 'ui.admin.lovlynet.requests_col_message' => 'Mensaje', + 'ui.admin.lovlynet.requests_col_date' => 'Fecha', + 'ui.admin.lovlynet.requests_no_subject' => '(sin asunto)', + 'ui.admin.lovlynet.requests_request_label' => 'Solicitud enviada', + 'ui.admin.lovlynet.requests_response_label' => 'Respuesta del hub', + 'ui.admin.lovlynet.type_areafix' => 'AREAFIX', + 'ui.admin.lovlynet.type_filefix' => 'FILEFIX', + 'ui.admin.lovlynet.direction_request' => 'Solicitud', + 'ui.admin.lovlynet.direction_response' => 'Respuesta', + 'ui.admin.lovlynet.status_pending' => 'Respuesta pendiente', + 'ui.admin.lovlynet.status_responded' => 'Respondido', + 'ui.admin.lovlynet.status_response' => 'Respuesta recibida', + 'ui.admin.lovlynet.btn_resend' => 'Sincronizar archivos', + 'ui.admin.lovlynet.btn_resend_title' => 'Sincronizar archivos locales y de LovlyNet', + 'ui.admin.lovlynet.resend_modal_title' => 'Sincronizar archivos', + 'ui.admin.lovlynet.resend_modal_help' => 'Envíe archivos locales que aún no están en LovlyNet, o solicite un %RESEND para archivos que le falten localmente.', + 'ui.admin.lovlynet.resend_area_label' => 'Área de archivos', + 'ui.admin.lovlynet.resend_files_loading' => 'Cargando archivos...', + 'ui.admin.lovlynet.resend_files_none' => 'No se encontraron archivos en esta área', + 'ui.admin.lovlynet.resend_files_error' => 'No se pudieron cargar los archivos de LovlyNet', + 'ui.admin.lovlynet.resend_files_select_all' => 'Seleccionar todo', + 'ui.admin.lovlynet.resend_files_deselect_all' => 'Deseleccionar todo', + 'ui.admin.lovlynet.resend_file_count_label' => 'archivo(s) disponible(s)', + 'ui.admin.lovlynet.resend_selected_label' => 'seleccionado(s)', + 'ui.admin.lovlynet.resend_page_label' => 'Página', + 'ui.admin.lovlynet.resend_col_filename' => 'Nombre de archivo', + 'ui.admin.lovlynet.resend_col_description' => 'Descripción', + 'ui.admin.lovlynet.resend_col_local' => 'Local', + 'ui.admin.lovlynet.resend_local_yes_title' => 'El archivo está en tu área de archivos local', + 'ui.admin.lovlynet.resend_local_no_title' => 'El archivo no está en tu área de archivos local', + 'ui.admin.lovlynet.resend_select_required' => 'Seleccione al menos un archivo', + 'ui.admin.lovlynet.resend_send' => 'Enviar solicitud de reenvío', + 'ui.admin.lovlynet.resend_send_failed' => 'No se pudo enviar la solicitud de reenvío', + 'ui.admin.lovlynet.resend_sent' => 'Solicitud de reenvío FileFix enviada', + 'ui.admin.lovlynet.sync_local_only_section' => 'Archivos locales (no en LovlyNet)', + 'ui.admin.lovlynet.sync_lovlynet_section' => 'Archivos de LovlyNet', + 'ui.admin.lovlynet.sync_no_local_only' => 'Todos los archivos locales ya están en LovlyNet', + 'ui.admin.lovlynet.sync_hatch_btn' => 'Hatch', + 'ui.admin.lovlynet.sync_hatch_btn_title' => 'Enviar este archivo a LovlyNet', + 'ui.admin.lovlynet.sync_hatch_success' => 'Archivo enviado a LovlyNet', + 'ui.admin.lovlynet.sync_hatch_failed' => 'No se pudo enviar el archivo', + 'ui.admin.lovlynet.request_modal_title_file' => 'Solicitud FileFix', + 'ui.admin.lovlynet.request_modal_help_echo' => 'Ingrese el texto del comando AreaFix a enviar. El destino y la contraseña se completan automáticamente. Recibirá una respuesta por netmail.', + 'ui.admin.lovlynet.request_modal_help_file' => 'Ingrese el texto del comando FileFix a enviar. El destino y la contraseña se completan automáticamente. Recibirá una respuesta por netmail.', + 'ui.admin.lovlynet.request_help_button' => 'Ayuda', + 'ui.admin.lovlynet.request_help_title' => 'Ayuda remota', + 'ui.admin.lovlynet.request_help_failed' => 'No se pudo cargar el texto de ayuda', + 'ui.admin.lovlynet.request_help_empty' => 'No hay texto de ayuda disponible.', + 'ui.admin.lovlynet.message_view_title' => 'Mensaje', + 'ui.admin.lovlynet.message_view_title_request' => 'Mensaje de solicitud', + 'ui.admin.lovlynet.message_view_title_response' => 'Mensaje de respuesta', + 'ui.admin.lovlynet.request_message_label' => 'Mensaje de solicitud', + 'ui.admin.lovlynet.request_message_placeholder' => '%HELP', + 'ui.admin.lovlynet.request_message_required' => 'Se requiere un mensaje de solicitud', + 'ui.admin.lovlynet.request_send' => 'Enviar solicitud', + 'ui.admin.lovlynet.request_send_failed' => 'No se pudo enviar la solicitud', + 'ui.admin.lovlynet.request_sent_echo' => 'Solicitud AreaFix enviada', + 'ui.admin.lovlynet.request_sent_file' => 'Solicitud FileFix enviada', + + // Public file areas index page + 'ui.public_files.title' => 'Areas de archivos publicas', + 'ui.public_files.heading' => 'Areas de archivos publicas', + 'ui.public_files.description' => 'Explora y descarga archivos sin necesidad de crear una cuenta.', + 'ui.public_files.og_description' => 'Explora y descarga archivos de areas de archivos publicas.', + 'ui.public_files.no_areas' => 'No hay areas de archivos publicas disponibles.', ]; diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index 20c8cf006..5f6ae65d0 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -55,6 +55,14 @@ '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.echomail.save_ad.admin_required' => 'Se requieren privilegios de administrador', + 'errors.messages.echomail.save_ad.not_ansi' => 'Solo los mensajes echomail ANSI se pueden guardar en la biblioteca de anuncios', + 'errors.messages.echomail.save_ad.failed' => 'No se pudo guardar el mensaje en la biblioteca de anuncios', + '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', @@ -127,6 +135,9 @@ 'errors.files.not_found' => 'Archivo no encontrado', 'errors.files.share_not_found_or_forbidden' => 'Enlace compartido no encontrado o no permitido', 'errors.files.delete_failed' => 'No se pudo eliminar el archivo', + 'errors.files.rehatch_local' => 'No se puede re-hatchear un archivo en un área solo local', + 'errors.files.rehatch_private' => 'No se puede re-hatchear un archivo en un área privada', + 'errors.files.rehatch_failed' => 'Error al re-hatchear', 'errors.files.scan_forbidden' => 'Se requiere acceso de administrador para escanear archivos', 'errors.files.scan_disabled' => 'El análisis de virus está desactivado', 'errors.files.scan_failed' => 'Error al realizar el análisis de virus', @@ -148,7 +159,15 @@ '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', + 'errors.files.comments_not_enabled' => 'Los comentarios no están habilitados para esta área de archivos', + 'errors.files.comments_forbidden' => 'No tiene permiso para comentar aquí', + 'errors.files.comment_body_required' => 'El texto del comentario es obligatorio', + 'errors.files.comment_post_failed' => 'Error al publicar el comentario', + 'errors.files.invalid_reply_target' => 'Destino de respuesta no válido', + 'errors.fileareas.comment_area_failed' => 'Error al crear o vincular el área de comentarios', // Admin Users 'errors.admin.users.not_found' => 'Usuario no encontrado', @@ -172,6 +191,12 @@ 'errors.messages.drafts.not_found' => 'Borrador no encontrado', 'errors.messages.drafts.get_failed' => 'No se pudo cargar el borrador', 'errors.messages.drafts.delete_failed' => 'No se pudo eliminar el borrador', + 'errors.messages.templates.not_licensed' => 'Las plantillas de mensajes requieren una licencia registrada', + 'errors.messages.templates.not_found' => 'Plantilla no encontrada', + 'errors.messages.templates.name_required' => 'El nombre de la plantilla es obligatorio', + 'errors.messages.templates.name_too_long' => 'El nombre de la plantilla debe tener 100 caracteres o menos', + 'errors.economy.not_licensed' => 'El visor de economia requiere una licencia registrada', + 'errors.referrals.not_licensed' => 'La analitica de referidos requiere una licencia registrada', 'errors.messages.netmail.get_failed' => 'No se pudo cargar el mensaje', 'errors.messages.echomail.get_failed' => 'No se pudo cargar el mensaje', @@ -225,6 +250,12 @@ 'errors.binkp.process_outbound_failed' => 'No se pudo procesar la cola de salida', 'errors.binkp.connection_test_failed' => 'No se pudo probar la conexion BinkP', 'errors.binkp.logs.failed' => 'No se pudieron cargar los registros de BinkP', + 'errors.binkp.logs.search_failed' => 'La busqueda de registros fallo', + 'errors.binkp.logs.search_query_too_short' => 'La consulta debe tener al menos 2 caracteres', + 'errors.binkp.kept_packets.failed' => 'No se pudieron cargar los paquetes guardados', + 'errors.binkp.kept_packets.invalid_type' => 'el tipo debe ser inbound o outbound', + 'errors.binkp.kept_packets.license_required' => 'Ver paquetes guardados requiere una licencia registrada', + 'errors.binkp.kept_packets.inspect_failed' => 'No se pudo inspeccionar el paquete', 'errors.binkp.uplink.address_hostname_required' => 'Se requieren direccion y hostname', 'errors.binkp.uplink.poll_failed' => 'No se pudo consultar el uplink BinkP', 'errors.binkp.uplink.poll_all_failed' => 'No se pudieron consultar todos los uplinks BinkP', @@ -282,6 +313,8 @@ 'errors.admin.appearance.shell.save_failed' => 'No se pudo guardar la configuracion del shell', 'errors.admin.appearance.message_reader.save_failed' => 'No se pudo guardar la configuracion del lector de mensajes', 'errors.admin.appearance.markdown_preview.failed' => 'No se pudo renderizar la vista previa de markdown', + 'errors.admin.appearance.splash.license_required' => 'Se requiere una licencia valida para configurar las paginas de inicio', + 'errors.admin.appearance.splash.save_failed' => 'No se pudo guardar la configuracion de las paginas de inicio', 'errors.admin.shell_art.list_failed' => 'No se pudo listar los archivos de arte de shell', 'errors.admin.shell_art.upload.no_file' => 'No se subio ningun archivo de arte de shell', 'errors.admin.shell_art.upload.upload_error' => 'La carga del arte de shell fallo', @@ -315,7 +348,21 @@ 'errors.admin.ads.upload.file_too_large' => 'El archivo de anuncio excede el limite de tamano', 'errors.admin.ads.upload.read_failed' => 'No se pudo leer el archivo de anuncio subido', 'errors.admin.ads.upload.failed' => 'No se pudo subir el anuncio', + 'errors.admin.ads.not_found' => 'Anuncio no encontrado', + 'errors.admin.ads.load_one_failed' => 'No se pudo cargar el anuncio', + 'errors.admin.ads.invalid_payload' => 'Carga util de anuncio invalida', + 'errors.admin.ads.save_failed' => 'No se pudo guardar el anuncio', 'errors.admin.ads.delete_failed' => 'No se pudo eliminar el anuncio', + 'errors.admin.ad_campaigns.list_failed' => 'No se pudieron cargar las campanas de anuncios', + 'errors.admin.ad_campaigns.log_failed' => 'No se pudo cargar el historial de campanas', + 'errors.admin.ad_campaigns.meta_failed' => 'No se pudieron cargar los metadatos de las campanas', + 'errors.admin.ad_campaigns.not_found' => 'Campana de anuncios no encontrada', + 'errors.admin.ad_campaigns.load_one_failed' => 'No se pudo cargar la campana de anuncios', + 'errors.admin.ad_campaigns.invalid_payload' => 'Carga util de campana de anuncios invalida', + 'errors.admin.ad_campaigns.create_failed' => 'No se pudo crear la campana de anuncios', + 'errors.admin.ad_campaigns.save_failed' => 'No se pudo guardar la campana de anuncios', + 'errors.admin.ad_campaigns.delete_failed' => 'No se pudo eliminar la campana de anuncios', + 'errors.admin.ad_campaigns.run_failed' => 'No se pudo ejecutar la campana de anuncios', 'errors.admin.chat_rooms.invalid_name_length' => 'El nombre de la sala debe tener entre 1 y 64 caracteres', 'errors.admin.chat_rooms.create_failed' => 'No se pudo crear la sala de chat', 'errors.admin.chat_rooms.not_found' => 'Sala de chat no encontrada', @@ -472,5 +519,26 @@ 'errors.admin.bbs_directory.merge_missing_discard' => 'Se requiere discard_id', 'errors.admin.bbs_directory.merge_failed' => 'La fusión falló', 'errors.admin.echomail_robots.invalid_config_json' => 'La configuracion del procesador no es JSON valido', + 'errors.files.iso_not_mounted' => 'El área de archivos no está montada', + 'errors.files.iso_readonly' => 'Los archivos ISO no se pueden modificar', + 'errors.fileareas.reindex_failed' => 'No se pudo iniciar la re-indexación ISO', + 'errors.admin.lovlynet.invalid_json' => 'Carga de solicitud no válida', + 'errors.admin.lovlynet.invalid_area_type' => 'Tipo de área no válido', + 'errors.admin.lovlynet.request_message_required' => 'Se requiere un mensaje de solicitud', + 'errors.admin.lovlynet.not_configured' => 'LovlyNet no está configurado', + 'errors.admin.lovlynet.request_config_missing' => 'La configuración de solicitudes de LovlyNet está incompleta', + 'errors.admin.lovlynet.help_fetch_failed' => 'No se pudo cargar el texto de ayuda', + 'errors.admin.lovlynet.request_send_failed' => 'No se pudo enviar el netmail de solicitud', + 'errors.admin.lovlynet.filearea_files_failed' => 'No se pudieron cargar los archivos del área', + 'errors.admin.lovlynet.invalid_file_id' => 'ID de archivo no válido', + 'errors.admin.lovlynet.hatch_failed' => 'No se pudo enviar el archivo', + + // QWK Offline Mail + 'errors.qwk.disabled' => 'El correo sin conexion QWK no esta habilitado en este sistema', + 'errors.qwk.no_file' => 'No se recibio ningun archivo REP. Envie el archivo en el campo "rep".', + 'errors.qwk.upload_error' => 'Error al subir el archivo', + 'errors.qwk.invalid_extension' => 'Por favor suba un archivo .REP o .ZIP', + 'errors.qwk.processing_failed' => 'No se pudo procesar el paquete REP', + 'errors.qwk.status_failed' => 'No se pudo obtener el estado QWK', + 'errors.qwk.invalid_format' => 'El formato debe ser "qwk" o "qwke"', ]; - diff --git a/config/i18n/es/terminalserver.php b/config/i18n/es/terminalserver.php index 9198cf336..95388287a 100644 --- a/config/i18n/es/terminalserver.php +++ b/config/i18n/es/terminalserver.php @@ -186,6 +186,9 @@ 'ui.terminalserver.files.upload_duplicate' => 'Este archivo ya existe en esta área.', 'ui.terminalserver.files.upload_readonly' => 'Esta área es de solo lectura. No se permiten subidas.', 'ui.terminalserver.files.upload_admin_only' => 'Solo los administradores pueden subir a esta área.', + 'ui.terminalserver.files.files_back_hint' => 'R)egresar a la carpeta padre', + 'ui.terminalserver.files.not_a_file' => 'Esa entrada es una carpeta, no un archivo.', + 'ui.terminalserver.files.enter_folder_or_file' => 'Ingrese el número de una carpeta para explorar, o de un archivo para ver detalles.', // --- Main menu: terminal settings --- 'ui.terminalserver.server.menu.terminal_settings' => 'T) Configuración de Terminal', diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index 0d322eebf..c9834f6ef 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -84,6 +84,7 @@ 'ui.common.previous_message' => 'Message précédent', 'ui.common.next_message' => 'Message suivant', 'ui.common.toggle_fullscreen' => 'Basculer en plein écran', + 'ui.common.add' => 'Ajouter', 'ui.common.cancel' => 'Annuler', 'ui.common.clear_search' => 'Effacer la recherche', 'ui.common.save' => 'Enregistrer', @@ -143,6 +144,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', @@ -219,12 +235,20 @@ 'ui.base.admin.binkp_configuration' => 'Configuration Binkp', 'ui.base.admin.template_editor' => 'Éditeur de modèles', 'ui.base.admin.i18n_overrides' => 'Substitutions de langue', + 'ui.base.admin.docs' => 'Documentation', + 'ui.admin.docs.title' => 'Documentation', + 'ui.admin.docs.back_to_index' => 'Retour à l\'index', + 'ui.admin.docs.not_found' => 'Document introuvable.', 'ui.base.admin.help' => 'Aide', 'ui.base.admin.readme' => 'README', 'ui.base.admin.faq' => 'FAQ', '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 :', @@ -560,9 +584,97 @@ 'ui.admin.dashboard.service.binkp_scheduler' => 'Planificateur Binkp', 'ui.admin.dashboard.service.binkp_server' => 'Serveur Binkp', 'ui.admin.dashboard.service.telnetd' => 'Serveur Telnet', + 'ui.admin.dashboard.service.ssh_daemon' => 'Serveur SSH', + 'ui.admin.dashboard.service.gemini_daemon' => 'Serveur Gemini', + 'ui.admin.dashboard.service.mrc_daemon' => 'Démon MRC', + 'ui.admin.dashboard.service.multiplexing_server' => 'Serveur de multiplexage', 'ui.admin.dashboard.running' => 'En cours', 'ui.admin.dashboard.stopped' => 'Arrêté', + 'ui.admin.dashboard.not_configured' => 'Non configuré', 'ui.admin.dashboard.pid' => 'PID', + 'ui.admin.dashboard.db_size' => 'Taille de la base de données', + 'ui.admin.dashboard.db_stats_link' => 'Voir les statistiques', + + // Admin database statistics page + 'ui.admin.db_stats.page_title' => 'Statistiques de la base de données', + 'ui.admin.db_stats.heading' => 'Statistiques de la base de données', + 'ui.admin.db_stats.tab.size_growth' => 'Taille & Croissance', + 'ui.admin.db_stats.tab.activity' => 'Activité', + 'ui.admin.db_stats.tab.query_performance' => 'Performance des requêtes', + 'ui.admin.db_stats.tab.replication' => 'Réplication', + 'ui.admin.db_stats.tab.maintenance' => 'Maintenance', + 'ui.admin.db_stats.tab.index_health' => 'Santé des index', + 'ui.admin.db_stats.db_total_size' => 'Taille totale de la base de données', + 'ui.admin.db_stats.table_sizes' => 'Tailles des tables (top 20)', + 'ui.admin.db_stats.index_sizes' => 'Tailles des index (top 20)', + 'ui.admin.db_stats.bloat' => 'Estimations de bloat (tuples morts)', + 'ui.admin.db_stats.no_bloat' => 'Aucun tuple mort significatif détecté.', + 'ui.admin.db_stats.connections' => 'Connexions actives', + 'ui.admin.db_stats.used' => 'utilisé', + 'ui.admin.db_stats.cache_hit_ratio' => 'Taux de succès du cache', + 'ui.admin.db_stats.cache_hit_warning' => 'En dessous des 99% recommandés', + 'ui.admin.db_stats.transactions' => 'Transactions', + 'ui.admin.db_stats.committed' => 'validées', + 'ui.admin.db_stats.rolled_back' => 'annulées', + 'ui.admin.db_stats.tuples' => 'Activité des tuples', + 'ui.admin.db_stats.inserted' => 'Insérés', + 'ui.admin.db_stats.updated' => 'Mis à jour', + 'ui.admin.db_stats.deleted' => 'Supprimés', + 'ui.admin.db_stats.connection_states' => 'États des connexions', + 'ui.admin.db_stats.pg_stat_statements_unavailable' => 'L\'extension pg_stat_statements n\'est pas installée. Installez-la pour activer l\'analyse des requêtes lentes et fréquentes.', + 'ui.admin.db_stats.lock_waits' => 'Attentes de verrou', + 'ui.admin.db_stats.deadlocks' => 'Deadlocks (cumulatif)', + 'ui.admin.db_stats.long_running' => 'Requêtes longues (>5s)', + 'ui.admin.db_stats.slow_queries' => 'Requêtes les plus lentes (par temps moyen)', + 'ui.admin.db_stats.frequent_queries' => 'Requêtes les plus fréquentes', + 'ui.admin.db_stats.no_replication' => 'Aucune réplication n\'est configurée sur ce serveur.', + 'ui.admin.db_stats.replication_senders' => 'Émetteurs de réplication', + 'ui.admin.db_stats.wal_receiver' => 'Récepteur WAL', + 'ui.admin.db_stats.vacuum_needed' => '{count} table(s) pourraient nécessiter un VACUUM (>10k tuples morts ou >5% morts).', + 'ui.admin.db_stats.autovacuum_active' => 'Workers autovacuum (actifs)', + 'ui.admin.db_stats.maintenance_health' => 'État Vacuum / Analyze', + 'ui.admin.db_stats.never' => 'Jamais', + 'ui.admin.db_stats.unused_indexes' => 'Index inutilisés', + 'ui.admin.db_stats.no_unused_indexes' => 'Aucun index inutilisé trouvé.', + 'ui.admin.db_stats.duplicate_indexes' => 'Index potentiellement redondants', + 'ui.admin.db_stats.scan_ratios' => 'Ratio de scan par index vs séquentiel', + 'ui.admin.db_stats.no_data' => 'Aucune donnée disponible.', + 'ui.admin.db_stats.col.table' => 'Table', + 'ui.admin.db_stats.col.index' => 'Index', + 'ui.admin.db_stats.col.total_size' => 'Total', + 'ui.admin.db_stats.col.table_size' => 'Table', + 'ui.admin.db_stats.col.index_size' => 'Index', + 'ui.admin.db_stats.col.size' => 'Taille', + 'ui.admin.db_stats.col.dead_tuples' => 'Tuples morts', + 'ui.admin.db_stats.col.dead_pct' => 'Morts %', + 'ui.admin.db_stats.col.state' => 'État', + 'ui.admin.db_stats.col.count' => 'Nombre', + 'ui.admin.db_stats.col.pid' => 'PID', + 'ui.admin.db_stats.col.user' => 'Utilisateur', + 'ui.admin.db_stats.col.duration' => 'Durée', + 'ui.admin.db_stats.col.query' => 'Requête', + 'ui.admin.db_stats.col.mean_ms' => 'Moyenne (ms)', + 'ui.admin.db_stats.col.total_ms' => 'Total (ms)', + 'ui.admin.db_stats.col.calls' => 'Appels', + 'ui.admin.db_stats.col.client' => 'Client', + 'ui.admin.db_stats.col.lag_bytes' => 'Décalage (octets)', + 'ui.admin.db_stats.col.status' => 'Statut', + 'ui.admin.db_stats.col.sender' => 'Émetteur', + 'ui.admin.db_stats.col.last_vacuum' => 'Dernier vacuum', + 'ui.admin.db_stats.col.last_analyze' => 'Dernier analyze', + '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', @@ -837,6 +949,49 @@ 'ui.admin.filearea_rules.tip_1' => 'Les règles s\'exécutent dans l\'ordre : les règles globales d\'abord, puis les règles spécifiques à la zone.', 'ui.admin.filearea_rules.tip_2' => 'Les règles de zone peuvent être indexées par TAG ou TAG@DOMAIN (la spécificité du domaine est prioritaire).', 'ui.admin.filearea_rules.tip_3' => 'Utilisez success_action et fail_action avec +stop pour arrêter le traitement.', + 'ui.admin.filearea_rules.tab_gui' => 'Éditeur visuel', + 'ui.admin.filearea_rules.tab_json' => 'Éditeur JSON', + 'ui.admin.filearea_rules.global_rules_section' => 'Règles globales', + 'ui.admin.filearea_rules.area_rules_section' => 'Règles par zone', + 'ui.admin.filearea_rules.add_rule' => 'Ajouter une règle', + 'ui.admin.filearea_rules.add_area' => 'Ajouter une zone', + 'ui.admin.filearea_rules.add_rule_title' => 'Ajouter une règle', + 'ui.admin.filearea_rules.edit_rule_title' => 'Modifier la règle', + 'ui.admin.filearea_rules.add_area_title' => 'Ajouter une zone', + 'ui.admin.filearea_rules.no_rules' => 'Aucune règle. Cliquez sur Ajouter une règle pour en créer une.', + 'ui.admin.filearea_rules.no_areas' => 'Aucune règle de zone. Cliquez sur Ajouter une zone pour en créer une.', + 'ui.admin.filearea_rules.col_enabled' => 'Actif', + 'ui.admin.filearea_rules.col_name' => 'Nom', + 'ui.admin.filearea_rules.col_pattern' => 'Motif', + 'ui.admin.filearea_rules.col_domain' => 'Domaine', + 'ui.admin.filearea_rules.col_success' => 'En cas de succès', + 'ui.admin.filearea_rules.col_fail' => 'En cas d\'échec', + 'ui.admin.filearea_rules.field_name' => 'Nom de la règle', + 'ui.admin.filearea_rules.field_pattern' => 'Motif de nom de fichier (regex)', + 'ui.admin.filearea_rules.field_pattern_hint' => 'Regex style PHP, ex. /^NODELIST\\.\\d+$/i', + 'ui.admin.filearea_rules.field_script' => 'Commande de script', + 'ui.admin.filearea_rules.field_timeout' => 'Délai d\'expiration (secondes)', + 'ui.admin.filearea_rules.field_enabled' => 'Actif', + 'ui.admin.filearea_rules.field_success_action' => 'En cas de succès', + 'ui.admin.filearea_rules.field_fail_action' => 'En cas d\'échec', + 'ui.admin.filearea_rules.field_domain' => 'Filtre de domaine', + 'ui.admin.filearea_rules.field_domain_hint' => 'Optionnel. Restreindre la règle à un domaine spécifique (ex. fidonet).', + 'ui.admin.filearea_rules.action_move' => 'déplacer vers la zone :', + 'ui.admin.filearea_rules.area_tag_label' => 'Étiquette de zone', + 'ui.admin.filearea_rules.area_tag_hint' => 'ex. NODELIST ou NODELIST@fidonet', + 'ui.admin.filearea_rules.delete_area_confirm' => 'Supprimer cette zone et toutes ses règles ?', + 'ui.admin.filearea_rules.clone_rule' => 'Cloner la règle', + 'ui.admin.filearea_rules.clone_rule_confirm' => 'Cloner la règle ?', + 'ui.admin.filearea_rules.delete_rule_confirm' => 'Supprimer cette règle ?', + 'ui.admin.filearea_rules.json_parse_error' => 'Erreur d\'analyse JSON — corrigez la syntaxe avant de basculer vers l\'éditeur visuel.', + 'ui.admin.filearea_rules.pattern_test_title' => 'Tester le motif', + 'ui.admin.filearea_rules.pattern_test_placeholder' => 'Entrez un nom de fichier à tester...', + 'ui.admin.filearea_rules.pattern_match' => 'correspond', + 'ui.admin.filearea_rules.pattern_no_match' => 'ne correspond pas', + 'ui.admin.filearea_rules.pattern_invalid' => 'regex invalide', + 'ui.admin.filearea_rules.pattern_test_area_not_found' => 'Zone introuvable dans la base de données.', + 'ui.admin.filearea_rules.pattern_test_no_files' => 'Aucun fichier dans cette zone.', + 'ui.admin.filearea_rules.pattern_test_area_files' => 'Fichiers dans la zone :', 'ui.admin.dosdoors_config.load_config_failed' => 'Échec du chargement de la configuration', 'ui.admin.dosdoors_config.load_config_error_prefix' => 'Erreur lors du chargement de la configuration : ', 'ui.admin.dosdoors_config.load_doors_failed' => 'Échec du chargement des portes', @@ -1000,6 +1155,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', @@ -1020,6 +1177,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', @@ -1041,6 +1206,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.', @@ -1689,6 +1858,7 @@ 'ui.files.total_size' => 'Taille totale', 'ui.files.file_details' => 'Détails du fichier', 'ui.files.filename' => 'Nom du fichier', + 'ui.files.description' => 'Description', 'ui.files.size' => 'Taille', 'ui.files.uploaded' => 'Téléversé', 'ui.files.from' => 'De', @@ -1746,6 +1916,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...', @@ -2437,6 +2615,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', @@ -2554,11 +2742,702 @@ '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.', + + // Ad Campaigns + 'ui.admin.ad_campaigns.ad' => 'Annonce', + 'ui.admin.ad_campaigns.add_campaign' => 'Ajouter une campagne', + 'ui.admin.ad_campaigns.add_schedule' => 'Ajouter un planning', + 'ui.admin.ad_campaigns.add_target' => 'Ajouter une cible', + 'ui.admin.ad_campaigns.ads' => 'Annonces', + 'ui.admin.ad_campaigns.all_campaigns' => 'Toutes les campagnes', + 'ui.admin.ad_campaigns.all_statuses' => 'Tous les statuts', + 'ui.admin.ad_campaigns.assigned_ads' => 'Annonces assignées', + 'ui.admin.ad_campaigns.back_to_ads' => 'Retour aux annonces', + 'ui.admin.ad_campaigns.campaign' => 'Campagne', + 'ui.admin.ad_campaigns.campaigns' => 'Campagnes', + 'ui.admin.ad_campaigns.created' => 'Campagne publicitaire créée.', + 'ui.admin.ad_campaigns.day_fri' => 'Ven', + 'ui.admin.ad_campaigns.day_mon' => 'Lun', + 'ui.admin.ad_campaigns.day_sat' => 'Sam', + 'ui.admin.ad_campaigns.day_sun' => 'Dim', + 'ui.admin.ad_campaigns.day_thu' => 'Jeu', + 'ui.admin.ad_campaigns.day_tue' => 'Mar', + 'ui.admin.ad_campaigns.day_wed' => 'Mer', + 'ui.admin.ad_campaigns.delete_confirm' => 'Supprimer {name} ?', + 'ui.admin.ad_campaigns.delete_failed' => 'Échec de la suppression de la campagne publicitaire', + 'ui.admin.ad_campaigns.deleted' => 'Campagne publicitaire supprimée.', + 'ui.admin.ad_campaigns.echoarea_domain' => 'Zone echo + Domaine', + 'ui.admin.ad_campaigns.edit_campaign' => 'Modifier la campagne', + 'ui.admin.ad_campaigns.error' => 'Erreur', + 'ui.admin.ad_campaigns.exclude_tags' => 'Tags exclus', + 'ui.admin.ad_campaigns.exclude_tags_help' => 'Les annonces correspondant à l\'un des tags exclus sont ignorées.', + 'ui.admin.ad_campaigns.heading' => 'Campagnes publicitaires', + 'ui.admin.ad_campaigns.include_tags' => 'Tags inclus', + 'ui.admin.ad_campaigns.include_tags_help' => 'Seules les annonces correspondant à au moins un tag inclus sont éligibles.', + 'ui.admin.ad_campaigns.interval' => 'Intervalle', + 'ui.admin.ad_campaigns.last_posted' => 'Dernière publication', + 'ui.admin.ad_campaigns.list_failed' => 'Échec du chargement des campagnes publicitaires', + 'ui.admin.ad_campaigns.load_failed' => 'Échec du chargement des campagnes publicitaires', + 'ui.admin.ad_campaigns.load_one_failed' => 'Échec du chargement de la campagne publicitaire', + 'ui.admin.ad_campaigns.loading' => 'Chargement des campagnes publicitaires...', + 'ui.admin.ad_campaigns.log_failed' => 'Échec du chargement du journal de la campagne publicitaire', + 'ui.admin.ad_campaigns.manual_post' => 'Publication manuelle', + 'ui.admin.ad_campaigns.meta_failed' => 'Échec du chargement des métadonnées de la campagne', + 'ui.admin.ad_campaigns.next_run' => 'Prochaine exécution', + 'ui.admin.ad_campaigns.no_ads_available' => 'Aucune annonce disponible pour l\'instant.', + 'ui.admin.ad_campaigns.no_history' => 'Aucun historique de publication.', + 'ui.admin.ad_campaigns.no_history_rows' => 'Aucun historique trouvé pour les filtres actuels.', + 'ui.admin.ad_campaigns.none' => 'Aucune campagne publicitaire configurée.', + 'ui.admin.ad_campaigns.not_scheduled' => 'Non planifié', + 'ui.admin.ad_campaigns.page_title' => 'Campagnes publicitaires', + 'ui.admin.ad_campaigns.post_as_user' => 'Publier en tant qu\'utilisateur', + 'ui.admin.ad_campaigns.post_history' => 'Historique des publications', + 'ui.admin.ad_campaigns.post_interval_minutes' => 'Intervalle de publication (minutes)', + 'ui.admin.ad_campaigns.posted_by' => 'Publié par', + 'ui.admin.ad_campaigns.recent_history' => 'Historique récent des publications', + 'ui.admin.ad_campaigns.repeat_gap_minutes' => 'Délai de répétition (minutes)', + 'ui.admin.ad_campaigns.run_complete' => 'Exécution de la campagne terminée.', + 'ui.admin.ad_campaigns.run_failed' => 'Échec de l\'exécution de la campagne publicitaire', + 'ui.admin.ad_campaigns.run_now' => 'Exécuter maintenant', + 'ui.admin.ad_campaigns.save_failed' => 'Échec de l\'enregistrement de la campagne publicitaire', + 'ui.admin.ad_campaigns.saved' => 'Campagne publicitaire enregistrée.', + 'ui.admin.ad_campaigns.schedule' => 'Planning', + 'ui.admin.ad_campaigns.schedule_days' => 'Jours', + 'ui.admin.ad_campaigns.schedule_time' => 'Heure', + 'ui.admin.ad_campaigns.schedule_timezone' => 'Fuseau horaire', + 'ui.admin.ad_campaigns.schedules' => 'Plannings', + 'ui.admin.ad_campaigns.select_target' => 'Sélectionner une cible...', + 'ui.admin.ad_campaigns.select_user' => 'Sélectionner un utilisateur...', + 'ui.admin.ad_campaigns.selection_mode' => 'Mode de sélection', + 'ui.admin.ad_campaigns.selection_weighted_random' => 'Aléatoire pondéré', + 'ui.admin.ad_campaigns.status_dry_run' => 'Simulation', + 'ui.admin.ad_campaigns.status_failed' => 'Échoué', + 'ui.admin.ad_campaigns.status_skipped' => 'Ignoré', + 'ui.admin.ad_campaigns.status_success' => 'Réussi', + 'ui.admin.ad_campaigns.subject_template' => 'Modèle d\'objet', + 'ui.admin.ad_campaigns.target' => 'Cible', + 'ui.admin.ad_campaigns.targets' => 'Cibles', + 'ui.admin.ad_campaigns.to_name' => 'Nom du destinataire', + + // Ads + 'ui.admin.ads.allow_auto_post' => 'Autoriser la publication automatique', + 'ui.admin.ads.ansi_content' => 'Contenu ANSI', + 'ui.admin.ads.auto_post' => 'Publication automatique', + 'ui.admin.ads.dashboard' => 'Tableau de bord', + 'ui.admin.ads.dashboard_priority' => 'Priorité tableau de bord', + 'ui.admin.ads.dashboard_weight' => 'Poids tableau de bord', + 'ui.admin.ads.duplicate_warning' => 'Un contenu ANSI identique existe déjà : {items}', + 'ui.admin.ads.edit_advertisement' => 'Modifier l\'annonce', + 'ui.admin.ads.escape_helper_help' => 'Insérer un préfixe ESC[ brut ou une séquence ANSI courante à la position du curseur.', + 'ui.admin.ads.insert_escape_prefix' => 'Insérer ESC[', + 'ui.admin.ads.insert_sequence' => 'Insérer une séquence', + 'ui.admin.ads.legacy_filename' => 'Nom de fichier hérité', + 'ui.admin.ads.library_info' => 'Gérez les annonces ANSI dans la bibliothèque. Les annonces héritées importées restent disponibles ici pour l\'affichage sur le tableau de bord et la sélection automatique.', + 'ui.admin.ads.library_list' => 'Bibliothèque d\'annonces', + 'ui.admin.ads.load_one_failed' => 'Échec du chargement de l\'annonce', + 'ui.admin.ads.manage_campaigns' => 'Gérer les campagnes', + 'ui.admin.ads.not_found' => 'Annonce introuvable', + 'ui.admin.ads.save_failed_with_status' => 'Échec de l\'enregistrement ({status})', + 'ui.admin.ads.saved' => 'Annonce enregistrée.', + 'ui.admin.ads.select_sequence' => 'Sélectionner une séquence ANSI', + 'ui.admin.ads.sequence_blue' => 'Bleu (ESC[34m)', + 'ui.admin.ads.sequence_bold' => 'Gras (ESC[1m)', + 'ui.admin.ads.sequence_clear_line' => 'Effacer la ligne (ESC[K)', + 'ui.admin.ads.sequence_clear_screen' => 'Effacer l\'écran (ESC[2J)', + 'ui.admin.ads.sequence_cursor_home' => 'Curseur en début (ESC[H)', + 'ui.admin.ads.sequence_green' => 'Vert (ESC[32m)', + 'ui.admin.ads.sequence_red' => 'Rouge (ESC[31m)', + 'ui.admin.ads.sequence_reset' => 'Réinitialiser (ESC[0m)', + 'ui.admin.ads.sequence_white' => 'Blanc (ESC[37m)', + 'ui.admin.ads.sequence_yellow' => 'Jaune (ESC[33m)', + 'ui.admin.ads.show_on_dashboard' => 'Afficher sur le tableau de bord', + 'ui.admin.ads.slug' => 'Slug', + 'ui.admin.ads.slug_optional' => 'Slug (facultatif)', + 'ui.admin.ads.tags' => 'Tags', + 'ui.admin.ads.tags_placeholder' => 'general, door, network', + + // Appearance - Branding + 'ui.admin.appearance.branding.hide_powered_by' => 'Masquer la ligne "Propulsé par BinktermPHP" dans le pied de page', + 'ui.admin.appearance.branding.hide_powered_by_help' => 'Supprimer la mention BinktermPHP dans le pied de page du site. Nécessite une licence valide.', + 'ui.admin.appearance.branding.premium_branding_locked' => 'Le texte personnalisé du pied de page et les options de marque sont disponibles pour les installations enregistrées.', + 'ui.admin.appearance.branding.premium_branding_register' => 'S\'enregistrer →', + 'ui.admin.appearance.branding.show_registration_badge' => 'Afficher le statut d\'enregistrement dans le pied de page', + 'ui.admin.appearance.branding.show_registration_badge_help' => 'Afficher "Enregistré à [nom du système]" dans le pied de page du site. Nécessite une licence valide avec la fonctionnalité registered_badge.', + + // Appearance - Splash Pages + 'ui.admin.appearance.splash.help' => 'Ajoutez un contenu personnalisé qui apparaît au-dessus des formulaires de connexion et d\'inscription. Prend en charge Markdown. Laisser vide pour n\'afficher rien.', + 'ui.admin.appearance.splash.locked_description' => 'Les pages d\'accueil personnalisées sont disponibles pour les installations enregistrées.', + 'ui.admin.appearance.splash.locked_heading' => 'Fonctionnalité enregistrée', + 'ui.admin.appearance.splash.login_help' => 'Affiché au-dessus du formulaire de connexion sur /login.', + 'ui.admin.appearance.splash.login_label' => 'Page d\'accueil connexion', + 'ui.admin.appearance.splash.placeholder' => '## Bienvenue\n\nCeci est un message d\'**accueil personnalisé**.', + 'ui.admin.appearance.splash.register_help' => 'Affiché au-dessus du formulaire d\'inscription sur /register.', + 'ui.admin.appearance.splash.register_label' => 'Page d\'accueil inscription', + 'ui.admin.appearance.splash.title' => 'Pages d\'accueil personnalisées', + 'ui.admin.appearance.tab_splash' => 'Pages d\'accueil', + + // BBS Settings - Features + 'ui.admin.bbs_settings.features.dashboard_ad_rotate_interval' => 'Intervalle de rotation des annonces du tableau de bord', + 'ui.admin.bbs_settings.features.dashboard_ad_rotate_interval_help' => 'Fréquence de rotation automatique des annonces du tableau de bord, en secondes (5-300). La valeur par défaut est 20 secondes.', + 'ui.admin.bbs_settings.features.enable_public_files_index' => 'Activer l\'index des fichiers publics', + 'ui.admin.bbs_settings.features.enable_qwk' => 'Activer le courrier hors ligne QWK', + 'ui.admin.bbs_settings.features.message_reader_sender_node_display' => 'Affichage du nœud expéditeur dans le lecteur de messages', + 'ui.admin.bbs_settings.features.message_reader_sender_node_display_help' => 'Choisissez si l\'en-tête De affiche le nom du BBS expéditeur ou l\'adresse du nœud FTN. Si un point n\'est pas trouvé, le nœud parent est vérifié automatiquement.', + 'ui.admin.bbs_settings.features.message_reader_sender_node_display_node_number' => 'Numéro de nœud', + 'ui.admin.bbs_settings.features.message_reader_sender_node_display_system_name' => 'Nom du système', + 'ui.admin.bbs_settings.features.public_files_index_help' => 'Affiche une page publique /public-files listant toutes les zones de fichiers publiques. Ajoute également un lien de navigation pour les visiteurs. Nécessite une licence enregistrée.', + 'ui.admin.bbs_settings.features.public_files_index_requires_license' => 'L\'index des zones de fichiers publiques nécessite une licence enregistrée.', + 'ui.admin.bbs_settings.features.qwk_help' => 'Permet aux utilisateurs de télécharger des paquets QWK et d\'envoyer des paquets de réponses REP pour la lecture de courrier hors ligne.', + 'ui.admin.bbs_settings.validation.dashboard_ad_rotate_interval_range' => 'L\'intervalle de rotation des annonces du tableau de bord doit être un entier compris entre 5 et 300 secondes.', + + // Admin Dashboard + 'ui.admin.dashboard.registration_link' => 'S\'enregistrer →', + 'ui.admin.dashboard.registration_registered' => 'Enregistré', + 'ui.admin.dashboard.registration_status' => 'Enregistrement :', + 'ui.admin.dashboard.registration_to' => 'Enregistré à {system} / {licensee}', + 'ui.admin.dashboard.registration_unregistered' => 'Non enregistré', + + // Licensing + 'ui.admin.licensing.access_col' => 'Description', + 'ui.admin.licensing.current_status' => 'Statut actuel de la licence', + 'ui.admin.licensing.heading' => 'Licences', + 'ui.admin.licensing.how_to_heading' => 'Pourquoi s\'enregistrer ?', + 'ui.admin.licensing.how_to_register_link' => 'Comment s\'enregistrer', + 'ui.admin.licensing.license_json_label' => 'Licence JSON', + 'ui.admin.licensing.page_title' => 'Licences', + 'ui.admin.licensing.register_modal_title' => 'Enregistrer BinktermPHP', + 'ui.admin.licensing.remove_btn' => 'Supprimer la licence', + 'ui.admin.licensing.remove_confirm' => 'Supprimer le fichier de licence actuel ? Le système reviendra à l\'édition communautaire.', + 'ui.admin.licensing.tier_col' => 'Niveau', + 'ui.admin.licensing.tier_community' => 'Communauté', + 'ui.admin.licensing.tier_community_desc' => 'BBS complet, netmail, echomail et traitement des paquets. Gratuit pour tous.', + 'ui.admin.licensing.tier_registered' => 'Enregistré', + 'ui.admin.licensing.tier_registered_desc' => 'Outils d\'administration avancés, badge de supporter.', + 'ui.admin.licensing.tier_sponsor' => 'Sponsor', + 'ui.admin.licensing.tier_sponsor_desc' => 'Toutes les fonctionnalités enregistrées plus un support prioritaire.', + 'ui.admin.licensing.tiers_heading' => 'Niveaux de licence', + 'ui.admin.licensing.upload_btn' => 'Installer la licence', + 'ui.admin.licensing.upload_heading' => 'Installer ou remplacer la licence', + 'ui.admin.licensing.upload_help' => 'Collez le contenu de votre fichier license.json ci-dessous, puis cliquez sur Installer la licence. La licence sera vérifiée avant d\'être enregistrée.', + 'ui.admin.licensing.why_branding' => 'Contrôle de la marque — présentez une expérience entièrement personnalisée sans mention BinktermPHP dans le pied de page.', + 'ui.admin.licensing.why_features' => 'Accès aux outils premium — les installations enregistrées reçoivent automatiquement les nouvelles fonctionnalités premium au fur et à mesure de leur sortie.', + 'ui.admin.licensing.why_intro' => 'BinktermPHP est open source et l\'édition communautaire est entièrement fonctionnelle. L\'enregistrement est la façon dont les sysops qui trouvent de la valeur dans le projet peuvent soutenir son développement continu.', + 'ui.admin.licensing.why_perpetual' => 'Licence perpétuelle — s\'enregistrer une seule fois. Pas d\'abonnement ni de frais récurrents.', + 'ui.admin.licensing.why_sustain' => 'Soutenir le développement — l\'enregistrement soutient directement les corrections de bugs, les nouvelles fonctionnalités et le travail de compatibilité des protocoles.', + + // LovlyNet + 'ui.admin.lovlynet.btn_rescan' => 'Rescanner', + 'ui.admin.lovlynet.btn_rescan_title' => 'Demander un rescan des messages', + 'ui.admin.lovlynet.btn_resend' => 'Synchroniser les fichiers', + 'ui.admin.lovlynet.btn_resend_title' => 'Synchroniser les fichiers locaux et LovlyNet', + 'ui.admin.lovlynet.btn_subscribe' => 'S\'abonner', + 'ui.admin.lovlynet.btn_unsubscribe' => 'Se désabonner', + 'ui.admin.lovlynet.col_description' => 'Description', + 'ui.admin.lovlynet.col_status' => 'Statut', + 'ui.admin.lovlynet.col_tag' => 'Tag', + 'ui.admin.lovlynet.direction_request' => 'Demande', + 'ui.admin.lovlynet.direction_response' => 'Réponse', + 'ui.admin.lovlynet.edit_tag_title' => 'Modifier {tag}', + 'ui.admin.lovlynet.heading' => 'Abonnements LovlyNet', + 'ui.admin.lovlynet.hub_address' => 'Adresse du hub', + 'ui.admin.lovlynet.load_failed' => 'Échec du chargement des zones depuis LovlyNet', + 'ui.admin.lovlynet.message_view_title' => 'Message', + 'ui.admin.lovlynet.message_view_title_request' => 'Message de demande', + 'ui.admin.lovlynet.message_view_title_response' => 'Message de réponse', + 'ui.admin.lovlynet.no_areas' => 'Aucune zone disponible', + 'ui.admin.lovlynet.no_requests' => 'Aucune demande ou réponse correspondante trouvée', + 'ui.admin.lovlynet.node_number' => 'Adresse du nœud', + 'ui.admin.lovlynet.not_configured_body' => 'config/lovlynet.json est manquant ou incomplet.', + 'ui.admin.lovlynet.not_configured_docs_intro' => 'Pour les détails de configuration et le contexte, voir', + 'ui.admin.lovlynet.not_configured_setup_intro' => 'Pour configurer ce système pour LovlyNet, exécutez le script d\'installation depuis la racine du projet :', + 'ui.admin.lovlynet.not_configured_synopsis' => 'LovlyNet est un réseau FTN et un service de hub qui fournit des zones echo partagées, des zones de fichiers et la gestion automatisée AreaFix/FileFix pour les systèmes connectés.', + 'ui.admin.lovlynet.not_configured_title' => 'LovlyNet non configuré.', + 'ui.admin.lovlynet.not_subscribed' => 'Non abonné', + 'ui.admin.lovlynet.page_title' => 'Zones LovlyNet', + 'ui.admin.lovlynet.request_button_echo' => 'Initier une demande AREAFIX', + 'ui.admin.lovlynet.request_button_file' => 'Initier une demande FILEFIX', + 'ui.admin.lovlynet.request_help_button' => 'Aide', + 'ui.admin.lovlynet.request_help_empty' => 'Aucun texte d\'aide disponible.', + 'ui.admin.lovlynet.request_help_failed' => 'Échec du chargement du texte d\'aide', + 'ui.admin.lovlynet.request_help_title' => 'Aide distante', + 'ui.admin.lovlynet.request_message_label' => 'Message de demande', + 'ui.admin.lovlynet.request_message_placeholder' => '%HELP', + 'ui.admin.lovlynet.request_message_required' => 'Le message de demande est requis', + 'ui.admin.lovlynet.request_modal_help_echo' => 'Saisissez le texte de la commande AreaFix à envoyer. La destination et le mot de passe sont remplis automatiquement. Une réponse vous sera envoyée par netmail.', + 'ui.admin.lovlynet.request_modal_help_file' => 'Saisissez le texte de la commande FileFix à envoyer. La destination et le mot de passe sont remplis automatiquement. Une réponse vous sera envoyée par netmail.', + 'ui.admin.lovlynet.request_modal_title_echo' => 'Demande AreaFix', + 'ui.admin.lovlynet.request_modal_title_file' => 'Demande FileFix', + 'ui.admin.lovlynet.request_send' => 'Envoyer la demande', + 'ui.admin.lovlynet.request_send_failed' => 'Échec de l\'envoi de la demande', + 'ui.admin.lovlynet.request_sent_echo' => 'Demande AreaFix envoyée', + 'ui.admin.lovlynet.request_sent_file' => 'Demande FileFix envoyée', + 'ui.admin.lovlynet.requests_col_date' => 'Date', + 'ui.admin.lovlynet.requests_col_direction' => 'Direction', + 'ui.admin.lovlynet.requests_col_message' => 'Message', + 'ui.admin.lovlynet.requests_col_status' => 'Statut', + 'ui.admin.lovlynet.requests_col_type' => 'Type', + 'ui.admin.lovlynet.requests_load_failed' => 'Échec du chargement du statut des demandes', + 'ui.admin.lovlynet.requests_no_subject' => '(sans objet)', + 'ui.admin.lovlynet.requests_request_label' => 'Demande envoyée', + 'ui.admin.lovlynet.requests_response_label' => 'Réponse du hub', + 'ui.admin.lovlynet.rescan_amount_days_help' => 'Envoyer les messages des N derniers jours depuis cette zone echo.', + 'ui.admin.lovlynet.rescan_amount_days_label' => 'Nombre de jours', + 'ui.admin.lovlynet.rescan_amount_messages_help' => 'Envoyer les N derniers messages depuis cette zone echo.', + 'ui.admin.lovlynet.rescan_amount_messages_label' => 'Nombre de messages', + 'ui.admin.lovlynet.rescan_amount_required' => 'Saisissez un nombre entier supérieur à zéro', + 'ui.admin.lovlynet.rescan_area_label' => 'Zone echo', + 'ui.admin.lovlynet.rescan_area_required' => 'Une zone echo est requise', + 'ui.admin.lovlynet.rescan_modal_help' => 'Choisissez la zone echo abonnée à rescanner et la quantité d\'historique à demander via AreaFix.', + 'ui.admin.lovlynet.rescan_modal_title' => 'Demande de rescan AreaFix', + 'ui.admin.lovlynet.rescan_mode_all' => 'Tous les messages', + 'ui.admin.lovlynet.rescan_mode_days' => 'N derniers jours', + 'ui.admin.lovlynet.rescan_mode_label' => 'Portée du rescan', + 'ui.admin.lovlynet.rescan_mode_messages' => 'N derniers messages', + 'ui.admin.lovlynet.rescan_send' => 'Envoyer la demande de rescan', + 'ui.admin.lovlynet.rescan_send_failed' => 'Échec de l\'envoi de la demande de rescan', + 'ui.admin.lovlynet.rescan_sent' => 'Demande de rescan AreaFix envoyée', + 'ui.admin.lovlynet.resend_area_label' => 'Zone de fichiers', + 'ui.admin.lovlynet.resend_col_description' => 'Description', + 'ui.admin.lovlynet.resend_col_filename' => 'Nom de fichier', + 'ui.admin.lovlynet.resend_col_local' => 'Local', + 'ui.admin.lovlynet.resend_file_count_label' => 'fichier(s) disponible(s)', + 'ui.admin.lovlynet.resend_files_deselect_all' => 'Tout désélectionner', + 'ui.admin.lovlynet.resend_files_error' => 'Échec du chargement des fichiers depuis LovlyNet', + 'ui.admin.lovlynet.resend_files_loading' => 'Chargement des fichiers...', + 'ui.admin.lovlynet.resend_files_none' => 'Aucun fichier trouvé dans cette zone', + 'ui.admin.lovlynet.resend_files_select_all' => 'Tout sélectionner', + 'ui.admin.lovlynet.resend_local_no_title' => 'Ce fichier n\'est pas dans votre zone de fichiers locale', + 'ui.admin.lovlynet.resend_local_yes_title' => 'Ce fichier est dans votre zone de fichiers locale', + 'ui.admin.lovlynet.resend_modal_help' => 'Envoyez des fichiers locaux qui ne sont pas encore dans LovlyNet, ou demandez un %RESEND pour les fichiers manquants localement.', + 'ui.admin.lovlynet.resend_modal_title' => 'Synchroniser les fichiers', + 'ui.admin.lovlynet.resend_page_label' => 'Page', + 'ui.admin.lovlynet.resend_select_required' => 'Sélectionnez au moins un fichier', + 'ui.admin.lovlynet.resend_selected_label' => 'sélectionné(s)', + 'ui.admin.lovlynet.resend_send' => 'Envoyer la demande de renvoi', + 'ui.admin.lovlynet.resend_send_failed' => 'Échec de l\'envoi de la demande de renvoi', + 'ui.admin.lovlynet.resend_sent' => 'Demande de renvoi FileFix envoyée', + 'ui.admin.lovlynet.server' => 'Serveur', + 'ui.admin.lovlynet.status_pending' => 'Réponse en attente', + 'ui.admin.lovlynet.status_responded' => 'Répondu', + 'ui.admin.lovlynet.status_response' => 'Réponse reçue', + 'ui.admin.lovlynet.subscribed' => 'Abonné', + 'ui.admin.lovlynet.subscribed_areas' => 'Zones abonnées', + 'ui.admin.lovlynet.subscribed_ok' => 'Abonné à {tag}', + 'ui.admin.lovlynet.sync_hatch_btn' => 'Hatch', + 'ui.admin.lovlynet.sync_hatch_btn_title' => 'Envoyer ce fichier à LovlyNet via Hatch', + 'ui.admin.lovlynet.sync_hatch_failed' => 'Échec du Hatch du fichier', + 'ui.admin.lovlynet.sync_hatch_success' => 'Fichier envoyé à LovlyNet via Hatch', + 'ui.admin.lovlynet.sync_local_only_section' => 'Fichiers locaux (absents de LovlyNet)', + 'ui.admin.lovlynet.sync_lovlynet_section' => 'Fichiers LovlyNet', + 'ui.admin.lovlynet.sync_no_local_only' => 'Tous les fichiers locaux sont déjà dans LovlyNet', + 'ui.admin.lovlynet.sync_title_create_and_description' => 'Créer le groupe et synchroniser la description', + 'ui.admin.lovlynet.sync_title_description' => 'Synchroniser la description', + 'ui.admin.lovlynet.tab_echo' => 'Zones echo', + 'ui.admin.lovlynet.tab_file' => 'Zones de fichiers', + 'ui.admin.lovlynet.tab_requests' => 'Demandes', + 'ui.admin.lovlynet.toggle_failed' => 'Échec du changement d\'abonnement', + 'ui.admin.lovlynet.type_areafix' => 'AREAFIX', + 'ui.admin.lovlynet.type_filefix' => 'FILEFIX', + 'ui.admin.lovlynet.unsubscribed_ok' => 'Désabonné de {tag}', + + // Referral Analytics + 'ui.admin.referrals.back' => 'Tableau de bord', + 'ui.admin.referrals.col_bonus' => 'Bonus gagné', + 'ui.admin.referrals.col_code' => 'Code de parrainage', + 'ui.admin.referrals.col_joined' => 'Inscrit le', + 'ui.admin.referrals.col_referred_by' => 'Parrainé par', + 'ui.admin.referrals.col_signups' => 'Inscriptions', + 'ui.admin.referrals.col_user' => 'Utilisateur', + 'ui.admin.referrals.heading' => 'Analytiques de parrainage', + 'ui.admin.referrals.load_failed' => 'Échec du chargement des données de parrainage', + 'ui.admin.referrals.loading' => 'Chargement des données de parrainage...', + 'ui.admin.referrals.no_recent' => 'Aucune inscription via parrainage pour l\'instant.', + 'ui.admin.referrals.no_referrers' => 'Aucun parrainage enregistré pour l\'instant.', + 'ui.admin.referrals.page_title' => 'Analytiques de parrainage', + 'ui.admin.referrals.recent_signups' => 'Inscriptions récentes via parrainage', + 'ui.admin.referrals.stat_active_referrers' => 'Parrains actifs', + 'ui.admin.referrals.stat_referred_users' => 'Utilisateurs parrainés', + 'ui.admin.referrals.top_referrers' => 'Meilleurs parrains', + + // Admin Sharing + 'ui.admin.sharing.access' => 'Accès', + 'ui.admin.sharing.area' => 'Zone', + 'ui.admin.sharing.dashboard' => 'Tableau de bord', + 'ui.admin.sharing.file' => 'Fichier', + 'ui.admin.sharing.files_heading' => 'Fichiers partagés', + 'ui.admin.sharing.files_tab' => 'Fichiers', + 'ui.admin.sharing.freq_enabled' => 'FREQ', + 'ui.admin.sharing.heading' => 'Partage', + 'ui.admin.sharing.help' => 'Consultez les messages et fichiers partagés actifs, classés par nombre de vues.', + 'ui.admin.sharing.last_accessed' => 'Dernier accès', + 'ui.admin.sharing.load_failed' => 'Échec du chargement des données de partage', + 'ui.admin.sharing.loading' => 'Chargement des données de partage...', + 'ui.admin.sharing.messages_heading' => 'Messages partagés', + 'ui.admin.sharing.messages_tab' => 'Messages', + 'ui.admin.sharing.never' => 'Jamais', + 'ui.admin.sharing.no_files' => 'Aucun fichier partagé actif', + 'ui.admin.sharing.no_messages' => 'Aucun message partagé actif', + 'ui.admin.sharing.open' => 'Ouvrir', + 'ui.admin.sharing.page_title' => 'Partage', + 'ui.admin.sharing.private' => 'Privé', + 'ui.admin.sharing.public' => 'Public', + 'ui.admin.sharing.shared_by' => 'Partagé par', + 'ui.admin.sharing.subject' => 'Objet', + 'ui.admin.sharing.views' => 'Vues', + 'ui.admin.sharing.web_only' => 'Web uniquement', + + // Navigation - Admin menu + 'ui.base.admin.ad_campaigns' => 'Campagnes publicitaires', + 'ui.base.admin.ads_menu' => 'Annonces', + 'ui.base.admin.analytics' => 'Analytiques', + 'ui.base.admin.chat' => 'Chat', + 'ui.base.admin.community' => 'Communauté', + 'ui.base.admin.licensing' => 'Licences', + 'ui.base.admin.lovlynet' => 'Zones LovlyNet', + 'ui.base.admin.referrals' => 'Analytiques de parrainage', + 'ui.base.admin.register' => 'Enregistrer BinktermPHP', + 'ui.base.admin.registered_feature' => 'Fonctionnalité enregistrée', + 'ui.base.admin.sharing' => 'Partage', + 'ui.base.public_files' => 'Fichiers publics', + + // BinkP + 'ui.binkp.advanced_log_search' => 'Recherche avancée', + 'ui.binkp.advanced_log_search_help' => 'Recherche dans l\'ensemble du journal. Toutes les lignes de toute session (PID) contenant une correspondance sont affichées.', + 'ui.binkp.advanced_log_search_placeholder' => 'ex. 1:123/456, FREQ, ERROR', + 'ui.binkp.from_address' => 'De', + 'ui.binkp.kept_date_group' => '{date}', + 'ui.binkp.kept_inbound' => 'Entrant conservé', + 'ui.binkp.kept_outbound' => 'Sortant conservé', + 'ui.binkp.kept_packets_empty' => 'Aucun paquet conservé trouvé.', + 'ui.binkp.kept_packets_locked' => 'Cette fonctionnalité nécessite une licence enregistrée.', + 'ui.binkp.kept_packets_register' => 'S\'enregistrer pour débloquer', + 'ui.binkp.kept_packets_tab' => 'Paquets conservés', + 'ui.binkp.kept_packets_total' => '{count} paquet(s)', + 'ui.binkp.log_matches' => '{count} / {total} lignes', + 'ui.binkp.log_search_legend_context' => 'Autres lignes de la même session (même PID)', + 'ui.binkp.log_search_legend_match' => 'Lignes contenant votre terme de recherche', + 'ui.binkp.log_search_no_results' => 'Aucun résultat trouvé.', + 'ui.binkp.log_search_summary' => '{matches} correspondance(s) sur {sessions} session(s)', + 'ui.binkp.pkt_flags' => 'Indicateurs', + 'ui.binkp.pkt_from' => 'De', + 'ui.binkp.pkt_header' => 'En-tête du paquet', + 'ui.binkp.pkt_messages' => 'Messages', + 'ui.binkp.pkt_no_messages' => 'Aucun message trouvé dans le paquet.', + 'ui.binkp.pkt_no_password' => 'Aucun', + 'ui.binkp.pkt_password' => 'Mot de passe', + 'ui.binkp.pkt_password_set' => 'Défini', + 'ui.binkp.pkt_product' => 'Code produit', + 'ui.binkp.pkt_subject' => 'Objet', + 'ui.binkp.pkt_to' => 'À', + 'ui.binkp.pkt_version' => 'Version du paquet', + 'ui.binkp.to_address' => 'À', + + // Common UI + 'ui.common.back' => 'Retour', + 'ui.common.browse' => 'Parcourir', + 'ui.common.date' => 'Date', + 'ui.common.disabled' => 'Désactivé', + 'ui.common.enabled' => 'Activé', + 'ui.common.filename' => 'Nom de fichier', + 'ui.common.none' => 'Aucun', + 'ui.common.print' => 'Imprimer le message', + 'ui.common.size' => 'Taille', + 'ui.common.subject' => 'Objet', + 'ui.common.time' => 'Heure', + 'ui.common.title' => 'Titre', + + // Compose - Templates + 'ui.compose.templates.button' => 'Modèles', + 'ui.compose.templates.delete_confirm' => 'Supprimer ce modèle ?', + 'ui.compose.templates.delete_failed' => 'Échec de la suppression du modèle', + 'ui.compose.templates.deleted' => 'Modèle supprimé', + 'ui.compose.templates.load_failed' => 'Échec du chargement du modèle', + 'ui.compose.templates.loading' => 'Chargement...', + 'ui.compose.templates.manage' => 'Gérer les modèles', + 'ui.compose.templates.manage_modal_title' => 'Mes modèles', + 'ui.compose.templates.name_label' => 'Nom du modèle', + 'ui.compose.templates.name_placeholder' => 'ex. Annonce mensuelle', + 'ui.compose.templates.name_required' => 'Veuillez saisir un nom de modèle', + 'ui.compose.templates.none' => 'Aucun modèle enregistré', + 'ui.compose.templates.save_button' => 'Enregistrer le modèle', + 'ui.compose.templates.save_current' => 'Enregistrer comme modèle', + 'ui.compose.templates.save_failed' => 'Échec de l\'enregistrement du modèle', + 'ui.compose.templates.save_modal_title' => 'Enregistrer comme modèle', + 'ui.compose.templates.saved' => 'Modèle enregistré', + 'ui.compose.templates.type_both' => 'Netmail et Echomail', + 'ui.compose.templates.type_echomail' => 'Echomail uniquement', + 'ui.compose.templates.type_label' => 'Disponible pour', + 'ui.compose.templates.type_netmail' => 'Netmail uniquement', + + // Dashboard + 'ui.dashboard.advertisement' => 'Annonce', + 'ui.dashboard.advertisement_controls' => 'Contrôles d\'annonce', + 'ui.dashboard.advertisement_position' => 'Annonce {current} sur {total}', + + // Echo Areas + 'ui.echoareas.lovlynet_sync_button' => 'Synchroniser', + 'ui.echoareas.lovlynet_sync_button_loading' => 'Synchronisation...', + 'ui.echoareas.lovlynet_sync_failed' => 'Échec de la synchronisation de la description depuis LovlyNet', + 'ui.echoareas.lovlynet_sync_success' => 'Description mise à jour depuis LovlyNet', + + // Echo List + 'ui.echolist.all_networks' => 'Tous les réseaux', + + // Echomail + 'ui.echomail.end_of_echo_go' => 'Aller à {echo}', + 'ui.echomail.end_of_echo_next_btn_title' => 'Fin de la zone echo', + 'ui.echomail.end_of_echo_next_prompt' => 'Continuer vers {echo} ?', + 'ui.echomail.end_of_echo_no_next' => 'Vous n\'avez plus de messages non lus.', + 'ui.echomail.end_of_echo_title' => 'Fin de {echo}', + 'ui.echomail.fileref_label' => 'Commentaire de fichier :', + 'ui.echomail.full_echo_list' => 'Liste complète des zones echo', + 'ui.echomail.next_echo_title' => 'Prochaine zone echo : {tag}', + 'ui.echomail.next_page_title' => 'Charger la page suivante', + 'ui.echomail.rip_render_failed' => 'Échec du rendu du message RIPscrip', + 'ui.echomail.save_to_ad_library' => 'Enregistrer l\'annonce', + 'ui.echomail.save_to_ad_library_failed' => 'Échec de l\'enregistrement du message dans la bibliothèque d\'annonces', + 'ui.echomail.save_to_ad_library_not_ansi' => 'Ce message n\'est pas éligible à l\'enregistrement en tant qu\'annonce ANSI.', + 'ui.echomail.save_to_ad_library_saved' => 'Message enregistré dans la bibliothèque d\'annonces.', + 'ui.echomail.save_to_ad_library_title' => 'Enregistrer dans la bibliothèque d\'annonces', + 'ui.echomail.subscribe' => 'S\'abonner', + 'ui.echomail.unsubscribe' => 'Se désabonner', + 'ui.echomail.viewer_mode_rip' => 'RIPscrip', + + // Error Pages + 'ui.error.not_found' => 'Page introuvable', + 'ui.error403.description' => 'Cette page est uniquement disponible pour les installations enregistrées. Visitez la page des licences pour en savoir plus sur l\'enregistrement et ses avantages.', + 'ui.error403.heading' => 'Cette fonctionnalité nécessite un enregistrement', + 'ui.error403.licensing_link' => 'Licences', + 'ui.error403.title' => 'Fonctionnalité enregistrée', + + // File Areas + 'ui.fileareas.accessible' => 'Accessible', + 'ui.fileareas.area_type' => 'Type de zone', + 'ui.fileareas.area_type_iso' => 'Basée sur ISO', + 'ui.fileareas.area_type_normal' => 'Normale', + 'ui.fileareas.comment_area' => 'Zone echo de commentaires', + 'ui.fileareas.comment_area_create_help' => 'Une nouvelle zone echo locale sera créée avec ce tag.', + 'ui.fileareas.comment_area_create_new' => 'Créer une nouvelle zone echo', + 'ui.fileareas.comment_area_help' => 'Sélectionnez une zone echo pour activer les commentaires de fichiers. Enregistré avec la zone de fichiers.', + 'ui.fileareas.comment_area_linked' => 'Zone de commentaires liée', + 'ui.fileareas.comment_area_placeholder' => 'Nouveau tag de zone echo (ex. MYFILES-COMMENTS)', + 'ui.fileareas.flat_import' => 'Importation à plat (sans sous-dossiers)', + 'ui.fileareas.freq_enabled' => 'FREQ activé', + 'ui.fileareas.freq_enabled_help' => 'Autoriser tout nœud FidoNet à demander des fichiers depuis cette zone via FREQ', + 'ui.fileareas.freq_password' => 'Mot de passe FREQ', + 'ui.fileareas.freq_password_help' => 'Laisser vide pour un accès ouvert', + 'ui.fileareas.freq_password_placeholder' => 'Mot de passe facultatif', + 'ui.fileareas.gemini_public' => 'Public Gemini', + 'ui.fileareas.gemini_public_help' => 'Lister et servir les fichiers de cette zone via le serveur de capsule Gemini', + 'ui.fileareas.is_public' => 'Zone de fichiers publique', + 'ui.fileareas.is_public_help' => 'Autoriser les visiteurs non authentifiés à parcourir et télécharger les fichiers de cette zone', + 'ui.fileareas.is_public_requires_license' => 'Les zones de fichiers publiques nécessitent une licence enregistrée.', + 'ui.fileareas.iso_mount_point' => 'Point de montage', + 'ui.fileareas.iso_mount_point_help' => 'Chemin où l\'ISO est monté sur le serveur. Montez l\'ISO manuellement avant d\'utiliser cette zone (ex. sudo mount -o loop image.iso /mnt/point). Sous Windows, entrez la lettre de lecteur ou le chemin (ex. D:\\).', + 'ui.fileareas.iso_mount_status' => 'Statut de montage', + 'ui.fileareas.last_indexed' => 'Dernière indexation', + 'ui.fileareas.not_accessible' => 'Non accessible', + 'ui.fileareas.reindex' => 'Réindexer l\'ISO', + 'ui.fileareas.reindex_done' => 'Réindexation terminée', + 'ui.fileareas.reindex_started' => 'Réindexation démarrée en arrière-plan', + 'ui.fileareas.reindexing' => 'Indexation…', + 'ui.fileareas.tag_readonly' => 'Le tag ne peut pas être modifié après la création.', + + // Files + 'ui.files.comment_post_failed' => 'Échec de la publication du commentaire', + 'ui.files.comment_posted' => 'Commentaire publié', + 'ui.files.comments' => 'Commentaires', + 'ui.files.comments_load_failed' => 'Échec du chargement des commentaires', + 'ui.files.delete_subfolder' => 'Supprimer le sous-dossier', + 'ui.files.delete_subfolder_confirm' => 'Êtes-vous sûr de vouloir supprimer le sous-dossier « {subfolder} » et tous ses fichiers ? Cette action est irréversible.', + 'ui.files.download_zip' => 'Télécharger le ZIP complet', + 'ui.files.edit_description' => 'Modifier la description', + 'ui.files.file_info' => 'Informations sur le fichier', + 'ui.files.freq_accessible' => 'FREQ accessible', + 'ui.files.freq_accessible_help' => 'Autoriser également ce fichier à être demandé via FidoNet FREQ', + 'ui.files.leave_comment' => 'Laisser un commentaire', + 'ui.files.loading_comments' => 'Chargement des commentaires…', + 'ui.files.login_to_comment' => 'Connectez-vous pour lire et publier des commentaires', + 'ui.files.my_files' => 'Mes fichiers', + 'ui.files.never_accessed' => 'Jamais', + 'ui.files.no_comments_yet' => 'Aucun commentaire pour l\'instant. Soyez le premier à en laisser un.', + 'ui.files.no_preview' => 'Aucun aperçu disponible pour ce type de fichier', + 'ui.files.no_prgs_in_d64' => 'Aucun fichier PRG trouvé dans l\'image de disque', + 'ui.files.post_comment' => 'Publier un commentaire', + 'ui.files.preview_failed' => 'Échec du chargement de l\'aperçu', + 'ui.files.preview_title' => 'Aperçu du fichier', + 'ui.files.prg_no_preview' => 'Aperçu indisponible — programme en code machine', + 'ui.files.prg_run_c64' => 'Exécuter sur C64', + 'ui.files.rehatch' => 'Rehatch', + 'ui.files.rehatch_failed' => 'Échec du Rehatch', + 'ui.files.rehatch_ok' => 'Fichier re-hatché avec succès', + 'ui.files.search_failed' => 'Échec de la recherche', + 'ui.files.search_global_placeholder' => 'Rechercher un nom de fichier ou une description…', + 'ui.files.search_heading' => 'Rechercher des fichiers', + 'ui.files.search_no_results' => 'Aucun fichier trouvé', + 'ui.files.search_result_count' => '{count} résultat(s)', + 'ui.files.share_access_stats' => 'Accédé {count} fois. Dernier accès : {last_accessed}', + 'ui.files.show_all_comments' => 'Afficher tous les {count} commentaires', + 'ui.files.show_fewer_comments' => 'Afficher moins', + 'ui.files.subfolder_deleted' => 'Sous-dossier supprimé', + 'ui.files.upload_descriptions' => 'Descriptions d\'envoi', + 'ui.files.upload_descriptions_help' => 'Lors de l\'envoi de plusieurs fichiers, chaque fichier reçoit sa propre courte description.', + 'ui.files.upload_partial_failure' => 'Envoi interrompu après {count} fichier(s) : {error}', + 'ui.files.upload_success_multiple' => '{count} fichiers envoyés avec succès', + 'ui.files.video_not_supported' => 'Format vidéo non pris en charge par votre navigateur', + 'ui.files.view_full_size' => 'Afficher en taille réelle', + 'ui.files.zip_empty' => 'L\'archive ZIP est vide', + 'ui.files.zip_legacy_badge' => 'Compression héritée', + 'ui.files.zip_legacy_compression' => 'Ce fichier utilise un format de compression hérité qui ne peut pas être prévisualisé.', + 'ui.files.zip_truncated' => 'Affichage des {count} premières entrées', + + // Footer + 'ui.footer.registered_to' => 'Enregistré à {name}', + + // FREQ + 'ui.freq.status_denied' => 'FREQ refusé', + 'ui.freq.status_fulfilled' => 'FREQ exécuté', + 'ui.freq.status_pending' => 'FREQ en attente', + + // Netmail + 'ui.netmail.next_page_title' => 'Charger la page suivante', + + // Nodelist + 'ui.nodelist.freq_not_advertised' => 'Ce nœud n\'annonce pas l\'indicateur FREQ. Une demande sera quand même envoyée, mais le nœud ne prend peut-être pas en charge les demandes de fichiers.', + 'ui.nodelist.index.flag_filter_any' => 'N\'importe quel indicateur', + 'ui.nodelist.index.flag_filter_label' => 'Indicateurs', + 'ui.nodelist.map_loading' => 'Chargement des données cartographiques…', + 'ui.nodelist.map_no_coordinates' => 'Aucun nœud géocodé disponible. Exécutez le script geocode_nodelist pour renseigner les coordonnées.', + 'ui.nodelist.map_node_count' => '{count} nœud(s) géocodé(s)', + 'ui.nodelist.map_send_netmail' => 'Envoyer un netmail', + 'ui.nodelist.no_coordinates' => 'Emplacement non géocodé. Exécutez scripts/geocode_nodelist.php pour renseigner les coordonnées.', + 'ui.nodelist.request_allfiles' => 'Demander un fichier', + 'ui.nodelist.request_allfiles_body' => 'Envoie un FREQ ALLFILES à ce nœud. Il inclura sa liste de fichiers lors de la prochaine session binkp avec vous.', + 'ui.nodelist.request_allfiles_crashmail' => 'Envoyer en crashmail (connexion directe au nœud)', + 'ui.nodelist.request_allfiles_crashmail_help' => 'Livre la demande directement à ce nœud plutôt que de la router via votre lien montant.', + 'ui.nodelist.request_allfiles_failed' => 'Échec de l\'envoi de la demande ALLFILES.', + 'ui.nodelist.request_allfiles_filename' => 'Fichier à demander', + 'ui.nodelist.request_allfiles_filename_placeholder' => 'ex. README.TXT', + 'ui.nodelist.request_allfiles_password' => 'Mot de passe (facultatif)', + 'ui.nodelist.request_allfiles_password_help' => 'Fournissez un mot de passe si le système distant en requiert un pour servir les listes de fichiers.', + 'ui.nodelist.request_allfiles_sent' => 'Demande ALLFILES en file d\'attente. La liste de fichiers arrivera lors de la prochaine session binkp.', + 'ui.nodelist.request_allfiles_title' => 'Demander la liste de fichiers à {node}', + 'ui.nodelist.request_allfiles_to' => 'Envoyer à', + 'ui.nodelist.request_allfiles_to_other' => 'Autre...', + 'ui.nodelist.request_allfiles_to_other_placeholder' => 'Saisir le nom du service', + 'ui.nodelist.send_request' => 'Envoyer la demande', + 'ui.nodelist.tab_list' => 'Liste', + 'ui.nodelist.tab_map' => 'Carte', + + // Public Files + 'ui.public_files.description' => 'Parcourez et téléchargez des fichiers sans créer de compte.', + 'ui.public_files.heading' => 'Zones de fichiers publiques', + 'ui.public_files.no_areas' => 'Aucune zone de fichiers publique n\'est disponible.', + 'ui.public_files.og_description' => 'Parcourez et téléchargez des fichiers depuis les zones de fichiers publiques.', + 'ui.public_files.title' => 'Zones de fichiers publiques', + + // QWK + 'ui.qwk.download_failed_prefix' => 'Échec du téléchargement : ', + + // Settings + 'ui.settings.echomail_digest' => 'Digest echomail', + 'ui.settings.echomail_digest_daily' => 'Quotidien', + 'ui.settings.echomail_digest_help' => 'Recevez un e-mail périodique résumant les nouveaux messages dans vos zones echo abonnées. Nécessite une adresse e-mail valide et la configuration SMTP.', + 'ui.settings.echomail_digest_none' => 'Désactivé', + 'ui.settings.echomail_digest_weekly' => 'Hebdomadaire', + 'ui.settings.forward_netmail_email' => 'Transférer le netmail par e-mail', + 'ui.settings.forward_netmail_email_help' => 'Envoyer une copie des messages netmail entrants à votre adresse e-mail. Nécessite une adresse e-mail valide dans votre profil et la configuration SMTP.', + 'ui.settings.notifications' => 'Notifications', + 'ui.settings.page_position_memory' => 'Mémorisation de la position de page', + 'ui.settings.remember_page_position' => 'Mémoriser la dernière page dans echomail et netmail', + 'ui.settings.remember_page_position_help' => 'Retourner automatiquement à la page où vous étiez lors de votre retour dans une zone', + + // Web Errors + 'ui.web.errors.not_found' => 'La page que vous avez demandée est introuvable.', ]; diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index 13c04d7c8..b5ad7c598 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', @@ -370,4 +372,130 @@ 'errors.admin.i18n_overrides.missing_params' => 'Le paramètre régional et le nom du catalogue sont requis', 'errors.admin.i18n_overrides.load_failed' => 'Échec du chargement du catalogue', 'errors.admin.i18n_overrides.save_failed' => 'Échec de l\'enregistrement des substitutions', + + // Ad Campaigns + 'errors.admin.ad_campaigns.create_failed' => 'Échec de la création de la campagne publicitaire', + 'errors.admin.ad_campaigns.delete_failed' => 'Échec de la suppression de la campagne publicitaire', + 'errors.admin.ad_campaigns.invalid_payload' => 'Charge utile de campagne publicitaire invalide', + 'errors.admin.ad_campaigns.list_failed' => 'Échec du chargement des campagnes publicitaires', + 'errors.admin.ad_campaigns.load_one_failed' => 'Échec du chargement de la campagne publicitaire', + 'errors.admin.ad_campaigns.log_failed' => 'Échec du chargement du journal de la campagne publicitaire', + 'errors.admin.ad_campaigns.meta_failed' => 'Échec du chargement des métadonnées de la campagne publicitaire', + 'errors.admin.ad_campaigns.not_found' => 'Campagne publicitaire introuvable', + 'errors.admin.ad_campaigns.run_failed' => 'Échec de l\'exécution de la campagne publicitaire', + 'errors.admin.ad_campaigns.save_failed' => 'Échec de l\'enregistrement de la campagne publicitaire', + + // Advertisements + 'errors.admin.ads.invalid_payload' => 'Charge utile d\'annonce invalide', + 'errors.admin.ads.load_one_failed' => 'Échec du chargement de l\'annonce', + 'errors.admin.ads.not_found' => 'Annonce introuvable', + 'errors.admin.ads.save_failed' => 'Échec de l\'enregistrement de l\'annonce', + + // Appearance - Splash + 'errors.admin.appearance.splash.license_required' => 'Une licence valide est requise pour configurer les pages d\'accueil', + 'errors.admin.appearance.splash.save_failed' => 'Échec de l\'enregistrement des paramètres de page d\'accueil', + + // BBS Directory + 'errors.admin.bbs_directory.duplicate_name' => 'Un BBS portant ce nom existe déjà', + 'errors.admin.bbs_directory.invalid_processor_type' => 'Type de processeur inconnu ou non pris en charge', + 'errors.admin.bbs_directory.merge_failed' => 'Échec de la fusion', + 'errors.admin.bbs_directory.merge_missing_discard' => 'discard_id est requis', + 'errors.admin.bbs_directory.name_required' => 'Le nom du BBS est requis', + 'errors.admin.bbs_directory.not_found' => 'Entrée du répertoire BBS introuvable', + 'errors.admin.bbs_directory.robot_not_found' => 'Règle de robot introuvable', + 'errors.admin.bbs_directory.robot_required_fields' => 'Le nom, la zone echo et le type de processeur sont requis', + 'errors.admin.bbs_directory.run_failed' => 'Échec de l\'exécution du robot', + + // Echomail Robots + 'errors.admin.echomail_robots.invalid_config_json' => 'La configuration du processeur n\'est pas du JSON valide', + + // LovlyNet + 'errors.admin.lovlynet.filearea_files_failed' => 'Échec du chargement des fichiers de la zone de fichiers', + 'errors.admin.lovlynet.hatch_failed' => 'Échec du Hatch du fichier', + 'errors.admin.lovlynet.help_fetch_failed' => 'Échec du chargement du texte d\'aide', + 'errors.admin.lovlynet.invalid_area_type' => 'Type de zone invalide', + 'errors.admin.lovlynet.invalid_file_id' => 'Identifiant de fichier invalide', + 'errors.admin.lovlynet.invalid_json' => 'Charge utile de demande invalide', + 'errors.admin.lovlynet.not_configured' => 'LovlyNet n\'est pas configuré', + 'errors.admin.lovlynet.request_config_missing' => 'Les paramètres de demande LovlyNet sont incomplets', + 'errors.admin.lovlynet.request_message_required' => 'Le message de demande est requis', + 'errors.admin.lovlynet.request_send_failed' => 'Échec de l\'envoi du netmail de demande', + + // BBS Directory (user-facing) + 'errors.bbs_directory.duplicate_name' => 'Un BBS portant ce nom existe déjà', + 'errors.bbs_directory.name_required' => 'Le nom du BBS est requis', + 'errors.bbs_directory.submit_failed' => 'Échec de la soumission. Veuillez réessayer.', + + // BinkP - Kept Packets + 'errors.binkp.kept_packets.failed' => 'Échec du chargement des paquets conservés', + 'errors.binkp.kept_packets.inspect_failed' => 'Échec de l\'inspection du paquet', + 'errors.binkp.kept_packets.invalid_type' => 'Le type doit être inbound ou outbound', + 'errors.binkp.kept_packets.license_required' => 'La consultation des paquets conservés nécessite une licence enregistrée', + + // BinkP - Logs + 'errors.binkp.logs.search_failed' => 'Échec de la recherche dans les journaux', + 'errors.binkp.logs.search_query_too_short' => 'La requête de recherche doit comporter au moins 2 caractères', + + // Economy + 'errors.economy.not_licensed' => 'Le visualiseur d\'économie nécessite une licence enregistrée', + + // File Areas + 'errors.fileareas.comment_area_failed' => 'Échec de la création ou de la liaison de la zone de commentaires', + 'errors.fileareas.reindex_failed' => 'Échec du démarrage de la réindexation ISO', + + // Files + 'errors.files.comment_body_required' => 'Le corps du commentaire est requis', + 'errors.files.comment_post_failed' => 'Échec de la publication du commentaire', + 'errors.files.comments_forbidden' => 'Vous n\'avez pas la permission de commenter ici', + 'errors.files.comments_not_enabled' => 'Les commentaires ne sont pas activés pour cette zone de fichiers', + 'errors.files.edit_failed' => 'Échec de la mise à jour du fichier', + 'errors.files.edit_forbidden' => 'Vous n\'avez pas la permission de modifier ce fichier', + 'errors.files.invalid_reply_target' => 'Cible de réponse invalide', + 'errors.files.invalid_scan_status' => 'Valeur de statut d\'analyse invalide', + 'errors.files.iso_not_mounted' => 'La zone de fichiers n\'est pas montée', + 'errors.files.iso_readonly' => 'Les fichiers basés sur ISO ne peuvent pas être modifiés', + 'errors.files.move_conflict' => 'Un fichier portant ce nom existe déjà dans la zone cible', + 'errors.files.move_failed' => 'Échec du déplacement du fichier', + 'errors.files.move_forbidden' => 'Seuls les administrateurs peuvent déplacer des fichiers entre les zones', + 'errors.files.rehatch_failed' => 'Échec du Rehatch', + 'errors.files.rehatch_local' => 'Impossible de re-hatcher un fichier dans une zone locale uniquement', + 'errors.files.rehatch_private' => 'Impossible de re-hatcher un fichier dans une zone privée', + 'errors.files.short_description_required' => 'Une description courte est requise', + + // Messages - Echomail + 'errors.messages.echomail.edit.admin_required' => 'Des privilèges administrateur sont requis', + 'errors.messages.echomail.edit.invalid_art_format' => 'Valeur de format artistique invalide', + 'errors.messages.echomail.edit.nothing_to_update' => 'Aucun champ à mettre à jour', + 'errors.messages.echomail.edit.save_failed' => 'Échec de l\'enregistrement des modifications', + 'errors.messages.echomail.save_ad.admin_required' => 'Des privilèges administrateur sont requis', + 'errors.messages.echomail.save_ad.failed' => 'Échec de l\'enregistrement du message dans la bibliothèque d\'annonces', + 'errors.messages.echomail.save_ad.not_ansi' => 'Seuls les messages echomail ANSI peuvent être enregistrés dans la bibliothèque d\'annonces', + + // Messages - Netmail + 'errors.messages.netmail.edit.forbidden' => 'Vous n\'avez pas la permission de modifier ce message', + + // Messages - Templates + 'errors.messages.templates.name_required' => 'Le nom du modèle est requis', + 'errors.messages.templates.name_too_long' => 'Le nom du modèle ne doit pas dépasser 100 caractères', + 'errors.messages.templates.not_found' => 'Modèle introuvable', + 'errors.messages.templates.not_licensed' => 'Les modèles de messages nécessitent une licence enregistrée', + + // QWK + 'errors.qwk.disabled' => 'Le courrier hors ligne QWK n\'est pas activé sur ce système', + 'errors.qwk.invalid_extension' => 'Veuillez envoyer un fichier .REP ou .ZIP', + 'errors.qwk.invalid_format' => 'Le format doit être "qwk" ou "qwke"', + 'errors.qwk.no_file' => 'Aucun fichier REP reçu. Envoyez le fichier dans le champ "rep".', + 'errors.qwk.processing_failed' => 'Échec du traitement du paquet REP', + 'errors.qwk.status_failed' => 'Échec de la récupération du statut QWK', + 'errors.qwk.upload_error' => 'Erreur lors du téléversement du fichier', + + // Referrals + 'errors.referrals.not_licensed' => 'Les analytiques de parrainage nécessitent une licence enregistrée', + + // VirusTotal + 'errors.virustotal.analysis_pending' => 'Analyse VirusTotal toujours en cours ; réessayez plus tard', + 'errors.virustotal.api_error' => 'Erreur de l\'API VirusTotal', + 'errors.virustotal.file_too_large' => 'Fichier trop volumineux pour le téléversement VirusTotal (max 32 Mo)', + 'errors.virustotal.not_configured' => 'Clé API VirusTotal non configurée', + 'errors.virustotal.upload_failed' => 'Échec du téléversement du fichier vers VirusTotal', ]; diff --git a/config/i18n/fr/terminalserver.php b/config/i18n/fr/terminalserver.php index bf84e9b12..988bee9c6 100644 --- a/config/i18n/fr/terminalserver.php +++ b/config/i18n/fr/terminalserver.php @@ -191,4 +191,14 @@ 'ui.terminalserver.message.headers_title' => '=== En-têtes du message ===', 'ui.terminalserver.message.no_headers' => '(Aucun en-tête de message)', + + // Files - folder/file navigation + 'ui.terminalserver.files.enter_folder_or_file' => 'Saisissez un numéro de dossier pour le parcourir, ou un numéro de fichier pour afficher ses détails.', + 'ui.terminalserver.files.files_back_hint' => 'R)etour au dossier parent', + 'ui.terminalserver.files.not_a_file' => 'Cette entrée est un dossier, pas un fichier.', + + // Netmail - attachments + 'ui.terminalserver.netmail.attachment_download_prompt' => 'N° de la pièce jointe à télécharger (Entrée pour annuler) : ', + 'ui.terminalserver.netmail.attachments_header' => 'Pièces jointes :', + 'ui.terminalserver.netmail.attachments_none' => 'Aucune pièce jointe sur ce message.', ]; diff --git a/config/welcome.txt.example b/config/welcome.txt.example deleted file mode 100644 index eb8962a4c..000000000 --- a/config/welcome.txt.example +++ /dev/null @@ -1,5 +0,0 @@ -Welcome to BinktermPHP Point System! - -This is a modern web-based bulletin board system (BBS) connected to the Fidonet Technology Network (FTN). As a point system, we operate as a sub-node providing access to traditional Fidonet messaging and forums. - -Please create an account or log in to get started exploring the network! \ No newline at end of file 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 000000000..cfc1ee96f --- /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 000000000..504c0787e --- /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/database/migrations/v1.11.0.13_drop_redundant_indexes.sql b/database/migrations/v1.11.0.13_drop_redundant_indexes.sql new file mode 100644 index 000000000..f5d48fd2d --- /dev/null +++ b/database/migrations/v1.11.0.13_drop_redundant_indexes.sql @@ -0,0 +1,15 @@ +-- v1.11.0.13 - Drop explicit indexes that duplicate unique-constraint indexes +-- Each of these columns has a UNIQUE constraint, which PostgreSQL automatically +-- backs with a unique index (*_key). The explicit idx_* indexes below cover the +-- same columns and are redundant — they waste disk space and add write overhead. + +DROP INDEX IF EXISTS idx_binkp_insecure_nodes_address; +DROP INDEX IF EXISTS idx_gateway_tokens_token; +DROP INDEX IF EXISTS idx_nodelist_address; +DROP INDEX IF EXISTS idx_password_reset_tokens_token; +DROP INDEX IF EXISTS idx_shared_messages_key; +DROP INDEX IF EXISTS idx_users_referral_code; +DROP INDEX IF EXISTS idx_webdoor_sessions_session_id; +DROP INDEX IF EXISTS idx_dosbox_doors_door_id; +DROP INDEX IF EXISTS idx_door_sessions_session_id; +DROP INDEX IF EXISTS idx_shared_files_share_key; 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 000000000..9a33230f9 --- /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; 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 000000000..0672a6c2a --- /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 000000000..a0994ecf5 --- /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 000000000..c737c82cd --- /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/database/migrations/v1.11.0.18_nodelist_coordinates.sql b/database/migrations/v1.11.0.18_nodelist_coordinates.sql new file mode 100644 index 000000000..bb7552cf5 --- /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/database/migrations/v1.11.0.19_rename_geocode_cache.sql b/database/migrations/v1.11.0.19_rename_geocode_cache.sql new file mode 100644 index 000000000..85d9e91f3 --- /dev/null +++ b/database/migrations/v1.11.0.19_rename_geocode_cache.sql @@ -0,0 +1,7 @@ +-- Rename bbs_directory_geocode_cache to geocode_cache so it serves as a +-- shared geocoding service for both the BBS Directory and the Nodelist map. + +ALTER TABLE bbs_directory_geocode_cache RENAME TO geocode_cache; + +ALTER INDEX idx_bbs_directory_geocode_cache_cached_at + RENAME TO idx_geocode_cache_cached_at; diff --git a/database/migrations/v1.11.0.20_filearea_gemini_public.sql b/database/migrations/v1.11.0.20_filearea_gemini_public.sql new file mode 100644 index 000000000..556d037e3 --- /dev/null +++ b/database/migrations/v1.11.0.20_filearea_gemini_public.sql @@ -0,0 +1,12 @@ +-- Add gemini_public flag to file_areas, mirroring the same flag on echoareas. +-- When true, all approved files in the area are browsable and downloadable +-- via the Gemini capsule server. + +ALTER TABLE file_areas + ADD COLUMN gemini_public BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX idx_file_areas_gemini_public + ON file_areas (gemini_public) + WHERE gemini_public = TRUE; + +COMMENT ON COLUMN file_areas.gemini_public IS 'If true, files in this area are listed and served via the Gemini capsule server'; diff --git a/database/migrations/v1.11.0.21_freq_support.sql b/database/migrations/v1.11.0.21_freq_support.sql new file mode 100644 index 000000000..78bf2b0ba --- /dev/null +++ b/database/migrations/v1.11.0.21_freq_support.sql @@ -0,0 +1,62 @@ +-- FREQ (File REQuest) support +-- Adds freq access control to file_areas and shared_files, +-- a FREQ request log, an outbound delivery queue, and +-- outbound FREQ status tracking on netmail. + +-- File area FREQ settings +ALTER TABLE file_areas + ADD COLUMN freq_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN freq_password VARCHAR(255); + +CREATE INDEX idx_file_areas_freq + ON file_areas (freq_enabled) + WHERE freq_enabled = TRUE; + +COMMENT ON COLUMN file_areas.freq_enabled IS 'If true, all approved files in this area are FREQable by any node'; +COMMENT ON COLUMN file_areas.freq_password IS 'Optional password required to FREQ files from this area'; + +-- Per-share FREQ accessibility +ALTER TABLE shared_files + ADD COLUMN freq_accessible BOOLEAN NOT NULL DEFAULT TRUE; + +COMMENT ON COLUMN shared_files.freq_accessible IS 'If true, this shared file is also accessible via binkp FREQ'; + +-- FREQ request log +CREATE TABLE freq_log ( + id SERIAL PRIMARY KEY, + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + requesting_node VARCHAR(50) NOT NULL, + filename VARCHAR(255) NOT NULL, + resolved_file_id INTEGER REFERENCES files(id) ON DELETE SET NULL, + served BOOLEAN NOT NULL DEFAULT FALSE, + deny_reason VARCHAR(100), + file_size BIGINT, + source VARCHAR(20) NOT NULL DEFAULT 'binkp', -- 'binkp' | 'netmail' + session_id VARCHAR(64) +); + +CREATE INDEX idx_freq_log_node ON freq_log (requesting_node); +CREATE INDEX idx_freq_log_time ON freq_log (requested_at DESC); + +-- Outbound FREQ file delivery queue +CREATE TABLE freq_outbound ( + id SERIAL PRIMARY KEY, + to_address VARCHAR(50) NOT NULL, + file_path TEXT NOT NULL, + original_filename VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + freq_log_id INTEGER REFERENCES freq_log(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + status VARCHAR(20) NOT NULL DEFAULT 'pending' -- pending | sent | failed +); + +CREATE INDEX idx_freq_outbound_pending + ON freq_outbound (to_address, created_at) + WHERE status = 'pending'; + +-- Outbound FREQ status tracking on sent netmail +ALTER TABLE netmail + ADD COLUMN freq_status VARCHAR(20); + +COMMENT ON COLUMN netmail.freq_status IS 'FREQ fulfillment status: pending, fulfilled, denied (null = not a FREQ or pre-dates this feature)'; diff --git a/database/migrations/v1.11.0.22_file_subfolder.sql b/database/migrations/v1.11.0.22_file_subfolder.sql new file mode 100644 index 000000000..999f5128e --- /dev/null +++ b/database/migrations/v1.11.0.22_file_subfolder.sql @@ -0,0 +1,8 @@ +-- Migration: v1.11.0.22 - Add subfolder support to files table +-- Allows files to be organized in virtual subfolders within a file area. +-- NULL means the file lives at the root of the area; a non-null value (e.g. 'incoming') +-- places the file in that named subfolder. + +ALTER TABLE files ADD COLUMN IF NOT EXISTS subfolder VARCHAR(255) DEFAULT NULL; + +CREATE INDEX IF NOT EXISTS idx_files_area_subfolder ON files(file_area_id, subfolder); diff --git a/database/migrations/v1.11.0.23_files_hash_nonunique.sql b/database/migrations/v1.11.0.23_files_hash_nonunique.sql new file mode 100644 index 000000000..55e8806d1 --- /dev/null +++ b/database/migrations/v1.11.0.23_files_hash_nonunique.sql @@ -0,0 +1,15 @@ +-- Migration: v1.11.0.23 - Replace unique file hash constraint with plain index +-- +-- The UNIQUE(file_area_id, file_hash) constraint breaks local netmail attachment +-- delivery when the same file is sent more than once to the same recipient. +-- The INSERT for the second attachment throws a constraint violation after the +-- file has already been moved from the temp dir, leaving an orphaned file on +-- disk with no database record. +-- +-- Private areas legitimately receive the same file multiple times (e.g. the +-- same attachment from different netmail messages). Drop the unique constraint +-- and replace it with a plain index to keep lookup performance. + +ALTER TABLE files DROP CONSTRAINT IF EXISTS unique_file_hash_per_area; + +CREATE INDEX IF NOT EXISTS idx_files_area_hash ON files(file_area_id, file_hash); diff --git a/database/migrations/v1.11.0.24_freq_requests_outbound.sql b/database/migrations/v1.11.0.24_freq_requests_outbound.sql new file mode 100644 index 000000000..e0dad90a1 --- /dev/null +++ b/database/migrations/v1.11.0.24_freq_requests_outbound.sql @@ -0,0 +1,22 @@ +-- Migration: v1.11.0.24 - Outbound FREQ request tracking +-- +-- Persists FREQ requests initiated by freq_getfile.php so that a subsequent +-- binkp session (inbound or outbound) can route received response files to +-- the correct user's private file area, even when the remote node fulfils +-- the request asynchronously in a separate session. + +CREATE TABLE IF NOT EXISTS freq_requests_outbound ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + node_address VARCHAR(50) NOT NULL, + requested_files TEXT NOT NULL, -- JSON array of filenames / magic names + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + mode VARCHAR(10) NOT NULL DEFAULT 'req', -- 'req' | 'mget' + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending | complete + completed_at TIMESTAMPTZ +); + +-- Partial index: only pending requests need fast lookup by node address +CREATE INDEX IF NOT EXISTS idx_freq_req_out_pending + ON freq_requests_outbound (node_address, created_at DESC) + WHERE status = 'pending'; diff --git a/database/migrations/v1.11.0.25_iso_file_areas.sql b/database/migrations/v1.11.0.25_iso_file_areas.sql new file mode 100644 index 000000000..5c6e3b614 --- /dev/null +++ b/database/migrations/v1.11.0.25_iso_file_areas.sql @@ -0,0 +1,15 @@ +-- v1.11.0.25 ISO-backed file areas support +ALTER TABLE file_areas + ADD COLUMN IF NOT EXISTS area_type VARCHAR(20) NOT NULL DEFAULT 'normal', + ADD COLUMN IF NOT EXISTS iso_file_path TEXT, + ADD COLUMN IF NOT EXISTS iso_mount_point TEXT, + ADD COLUMN IF NOT EXISTS iso_mount_status VARCHAR(20), + ADD COLUMN IF NOT EXISTS iso_mount_error TEXT, + ADD COLUMN IF NOT EXISTS iso_last_indexed TIMESTAMPTZ; + +ALTER TABLE files + ADD COLUMN IF NOT EXISTS iso_rel_path TEXT; + +CREATE INDEX IF NOT EXISTS idx_files_iso_rel_path + ON files (file_area_id, iso_rel_path) + WHERE iso_rel_path IS NOT NULL; diff --git a/database/migrations/v1.11.0.26_drop_iso_mount_status.sql b/database/migrations/v1.11.0.26_drop_iso_mount_status.sql new file mode 100644 index 000000000..88b524902 --- /dev/null +++ b/database/migrations/v1.11.0.26_drop_iso_mount_status.sql @@ -0,0 +1,6 @@ +-- Migration: 1.11.0.26 - Drop ISO auto-mount columns +-- ISO mounting is now handled manually by the sysop; these columns are no longer used. + +ALTER TABLE file_areas DROP COLUMN IF EXISTS iso_mount_status; +ALTER TABLE file_areas DROP COLUMN IF EXISTS iso_mount_error; +ALTER TABLE file_areas DROP COLUMN IF EXISTS iso_file_path; diff --git a/database/migrations/v1.11.0.27_file_search_indexes.sql b/database/migrations/v1.11.0.27_file_search_indexes.sql new file mode 100644 index 000000000..20eb536fd --- /dev/null +++ b/database/migrations/v1.11.0.27_file_search_indexes.sql @@ -0,0 +1,13 @@ +-- Enable pg_trgm for fast ILIKE / similarity searches +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Trigram indexes on files for fast full-text search +CREATE INDEX IF NOT EXISTS idx_files_filename_trgm + ON files USING GIN (filename gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_files_short_description_trgm + ON files USING GIN (short_description gin_trgm_ops); + +-- Composite index for area-scoped status lookups (already common) +CREATE INDEX IF NOT EXISTS idx_files_area_status + ON files (file_area_id, status); diff --git a/database/migrations/v1.11.0.28_remember_page_position.sql b/database/migrations/v1.11.0.28_remember_page_position.sql new file mode 100644 index 000000000..024aa3dd5 --- /dev/null +++ b/database/migrations/v1.11.0.28_remember_page_position.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_settings + ADD COLUMN IF NOT EXISTS remember_page_position BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/database/migrations/v1.11.0.29_message_templates.sql b/database/migrations/v1.11.0.29_message_templates.sql new file mode 100644 index 000000000..c1ff94107 --- /dev/null +++ b/database/migrations/v1.11.0.29_message_templates.sql @@ -0,0 +1,13 @@ +-- Migration: 1.11.0.29 - Message templates (premium feature) +CREATE TABLE IF NOT EXISTS message_templates ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + type VARCHAR(10) NOT NULL DEFAULT 'both' CHECK (type IN ('netmail', 'echomail', 'both')), + subject VARCHAR(255) NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_message_templates_user_id ON message_templates(user_id); diff --git a/database/migrations/v1.11.0.30_forward_netmail_email.sql b/database/migrations/v1.11.0.30_forward_netmail_email.sql new file mode 100644 index 000000000..89708752b --- /dev/null +++ b/database/migrations/v1.11.0.30_forward_netmail_email.sql @@ -0,0 +1,3 @@ +-- Migration: v1.11.0.30 - Add forward_netmail_email to user_settings +ALTER TABLE user_settings + ADD COLUMN IF NOT EXISTS forward_netmail_email BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/database/migrations/v1.11.0.31_echomail_digest.sql b/database/migrations/v1.11.0.31_echomail_digest.sql new file mode 100644 index 000000000..d12fdf99f --- /dev/null +++ b/database/migrations/v1.11.0.31_echomail_digest.sql @@ -0,0 +1,4 @@ +-- Migration: v1.11.0.31 - Add echomail digest settings to user_settings +ALTER TABLE user_settings + ADD COLUMN IF NOT EXISTS echomail_digest VARCHAR(10) NOT NULL DEFAULT 'none', + ADD COLUMN IF NOT EXISTS echomail_digest_last_sent TIMESTAMP NULL; diff --git a/database/migrations/v1.11.0.32_file_area_comments.sql b/database/migrations/v1.11.0.32_file_area_comments.sql new file mode 100644 index 000000000..0be60ac30 --- /dev/null +++ b/database/migrations/v1.11.0.32_file_area_comments.sql @@ -0,0 +1,14 @@ +-- Migration: v1.11.0.32 - File area echomail comments +-- Adds a linked comment echoarea to file areas and a cached comment count to files. + +ALTER TABLE file_areas + ADD COLUMN IF NOT EXISTS comment_echoarea_id INTEGER REFERENCES echoareas(id) ON DELETE SET NULL; + +ALTER TABLE files + ADD COLUMN IF NOT EXISTS comment_count INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN file_areas.comment_echoarea_id IS + 'Optional linked echomail area for file comments. NULL = comments disabled for this area.'; + +COMMENT ON COLUMN files.comment_count IS + 'Cached count of echomail comments for this file. Updated when comments are posted via the web UI.'; diff --git a/database/migrations/v1.11.0.33_public_file_areas.sql b/database/migrations/v1.11.0.33_public_file_areas.sql new file mode 100644 index 000000000..e6689a75f --- /dev/null +++ b/database/migrations/v1.11.0.33_public_file_areas.sql @@ -0,0 +1,8 @@ +-- Migration: v1.11.0.33 - Add is_public flag to file areas +-- Allows individual file areas to be accessible to unauthenticated visitors. +-- Requires a valid license to enable (enforced in PHP). + +ALTER TABLE file_areas ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT FALSE; + +-- Partial index for efficient lookup of public areas (sparse - most areas are not public) +CREATE INDEX IF NOT EXISTS idx_file_areas_is_public ON file_areas(is_public) WHERE is_public = TRUE; diff --git a/database/migrations/v1.11.0.34_qwk_support.sql b/database/migrations/v1.11.0.34_qwk_support.sql new file mode 100644 index 000000000..60c81617a --- /dev/null +++ b/database/migrations/v1.11.0.34_qwk_support.sql @@ -0,0 +1,46 @@ +-- Migration: v1.12.0_qwk_support +-- Adds QWK offline mail packet download and REP upload support. +-- +-- qwk_conference_state — tracks the highest message id seen per user per +-- conference so successive downloads only include new +-- messages. Conference 0 (personal mail / netmail) +-- is represented by the row where is_netmail = TRUE. +-- +-- qwk_download_log — records every QWK packet download with a JSON map +-- of conference numbers to echo area metadata. +-- RepProcessor reads the most recent map to reverse- +-- map conference numbers when a REP upload arrives. + +CREATE TABLE IF NOT EXISTS qwk_conference_state ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + echoarea_id INTEGER REFERENCES echoareas(id) ON DELETE CASCADE, + is_netmail BOOLEAN NOT NULL DEFAULT FALSE, + last_msg_id INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Unique: one netmail-state row per user. +CREATE UNIQUE INDEX IF NOT EXISTS qwk_conf_state_netmail_unique + ON qwk_conference_state (user_id, is_netmail) + WHERE is_netmail = TRUE; + +-- Unique: one echomail-state row per user per area. +CREATE UNIQUE INDEX IF NOT EXISTS qwk_conf_state_echomail_unique + ON qwk_conference_state (user_id, echoarea_id) + WHERE is_netmail = FALSE AND echoarea_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_qwk_conf_state_user + ON qwk_conference_state (user_id); + +CREATE TABLE IF NOT EXISTS qwk_download_log ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + downloaded_at TIMESTAMP NOT NULL DEFAULT NOW(), + message_count INTEGER NOT NULL DEFAULT 0, + packet_size INTEGER NOT NULL DEFAULT 0, + conference_map JSONB NOT NULL DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_qwk_download_log_user + ON qwk_download_log (user_id, downloaded_at DESC); diff --git a/database/migrations/v1.11.0.35_qwk_message_index.sql b/database/migrations/v1.11.0.35_qwk_message_index.sql new file mode 100644 index 000000000..43e195dc3 --- /dev/null +++ b/database/migrations/v1.11.0.35_qwk_message_index.sql @@ -0,0 +1,18 @@ +-- Migration: v1.11.0.35_qwk_message_index +-- Per-user QWK message number index for reply threading and netmail address +-- resolution. +-- +-- Rows are replaced on every QWK download for a given user, so the table +-- always reflects the most recent packet built for that user. RepProcessor +-- reads this table when processing an uploaded REP to: +-- 1. Resolve reply threading: qwk_msg_num -> db_id +-- 2. Resolve netmail to-address: qwk_msg_num -> from_address of the original + +CREATE TABLE IF NOT EXISTS qwk_message_index ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + qwk_msg_num INTEGER NOT NULL, + type VARCHAR(8) NOT NULL CHECK (type IN ('netmail', 'echomail')), + db_id INTEGER NOT NULL, + from_address VARCHAR(50), + PRIMARY KEY (user_id, qwk_msg_num) +); diff --git a/database/migrations/v1.11.0.36_qwk_imported_hashes.sql b/database/migrations/v1.11.0.36_qwk_imported_hashes.sql new file mode 100644 index 000000000..96358a12d --- /dev/null +++ b/database/migrations/v1.11.0.36_qwk_imported_hashes.sql @@ -0,0 +1,19 @@ +-- Migration: v1.11.0.36_qwk_imported_hashes +-- Per-user content hash table for QWK REP import deduplication. +-- +-- Before each message is imported from an uploaded REP packet, RepProcessor +-- checks whether the hash of its authored content (conference + to + subject + +-- body) already exists for this user. If it does, the message is skipped as a +-- duplicate. On success the hash is recorded here. +-- +-- Entries older than 30 days are pruned on each upload so the table stays small. + +CREATE TABLE IF NOT EXISTS qwk_imported_hashes ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + msg_hash CHAR(64) NOT NULL, -- SHA-256 hex digest + imported_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, msg_hash) +); + +CREATE INDEX IF NOT EXISTS idx_qwk_imported_hashes_user_at + ON qwk_imported_hashes (user_id, imported_at); diff --git a/database/migrations/v1.11.0.37_set_lovlynet_tic_password.php b/database/migrations/v1.11.0.37_set_lovlynet_tic_password.php new file mode 100644 index 000000000..ab0e72d11 --- /dev/null +++ b/database/migrations/v1.11.0.37_set_lovlynet_tic_password.php @@ -0,0 +1,79 @@ +exec(" + CREATE TABLE IF NOT EXISTS advertisements ( + id SERIAL PRIMARY KEY, + slug VARCHAR(120) NOT NULL UNIQUE, + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + content TEXT NOT NULL, + content_hash VARCHAR(64) NOT NULL, + source_type VARCHAR(32) NOT NULL DEFAULT 'upload', + legacy_filename VARCHAR(255) DEFAULT NULL, + created_by_user_id INTEGER DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL, + updated_by_user_id INTEGER DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + show_on_dashboard BOOLEAN NOT NULL DEFAULT TRUE, + allow_auto_post BOOLEAN NOT NULL DEFAULT FALSE, + dashboard_weight INTEGER NOT NULL DEFAULT 1, + dashboard_priority INTEGER NOT NULL DEFAULT 0, + start_at TIMESTAMPTZ DEFAULT NULL, + end_at TIMESTAMPTZ DEFAULT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + "); + + $db->exec(" + CREATE INDEX IF NOT EXISTS idx_advertisements_dashboard + ON advertisements (is_active, show_on_dashboard, dashboard_priority, updated_at) + "); + + $db->exec(" + CREATE INDEX IF NOT EXISTS idx_advertisements_content_hash + ON advertisements (content_hash) + "); + + $db->exec(" + CREATE TABLE IF NOT EXISTS advertisement_tags ( + id SERIAL PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE, + slug VARCHAR(80) NOT NULL UNIQUE + ) + "); + + $db->exec(" + CREATE TABLE IF NOT EXISTS advertisement_tag_map ( + advertisement_id INTEGER NOT NULL REFERENCES advertisements(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES advertisement_tags(id) ON DELETE CASCADE, + PRIMARY KEY (advertisement_id, tag_id) + ) + "); + + $db->exec(" + CREATE TABLE IF NOT EXISTS advertisement_campaigns ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + from_user_id INTEGER DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL, + to_name VARCHAR(255) NOT NULL DEFAULT 'All', + selection_mode VARCHAR(32) NOT NULL DEFAULT 'weighted_random', + post_interval_minutes INTEGER NOT NULL DEFAULT 10080, + min_repeat_gap_minutes INTEGER NOT NULL DEFAULT 10080, + last_posted_at TIMESTAMPTZ DEFAULT NULL, + last_posted_ad_id INTEGER DEFAULT NULL REFERENCES advertisements(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + "); + + $db->exec(" + CREATE TABLE IF NOT EXISTS advertisement_campaign_targets ( + id SERIAL PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES advertisement_campaigns(id) ON DELETE CASCADE, + echoarea_tag VARCHAR(255) NOT NULL, + domain VARCHAR(100) NOT NULL, + subject_template VARCHAR(255) NOT NULL DEFAULT 'BBS Advertisement', + is_active BOOLEAN NOT NULL DEFAULT TRUE + ) + "); + + $db->exec(" + CREATE INDEX IF NOT EXISTS idx_ad_campaign_targets_campaign + ON advertisement_campaign_targets (campaign_id, is_active) + "); + + $db->exec(" + CREATE TABLE IF NOT EXISTS advertisement_campaign_ads ( + campaign_id INTEGER NOT NULL REFERENCES advertisement_campaigns(id) ON DELETE CASCADE, + advertisement_id INTEGER NOT NULL REFERENCES advertisements(id) ON DELETE CASCADE, + weight INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (campaign_id, advertisement_id) + ) + "); + + $db->exec(" + CREATE TABLE IF NOT EXISTS advertisement_post_log ( + id SERIAL PRIMARY KEY, + advertisement_id INTEGER DEFAULT NULL REFERENCES advertisements(id) ON DELETE SET NULL, + campaign_id INTEGER DEFAULT NULL REFERENCES advertisement_campaigns(id) ON DELETE SET NULL, + message_id INTEGER DEFAULT NULL, + echoarea_tag VARCHAR(255) NOT NULL, + domain VARCHAR(100) NOT NULL, + subject VARCHAR(255) NOT NULL, + posted_by_user_id INTEGER DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL, + post_mode VARCHAR(32) NOT NULL DEFAULT 'manual', + posted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status VARCHAR(32) NOT NULL DEFAULT 'success', + error_text TEXT DEFAULT NULL + ) + "); + + $db->exec(" + CREATE INDEX IF NOT EXISTS idx_ad_post_log_advertisement + ON advertisement_post_log (advertisement_id, posted_at DESC) + "); + + $adsDir = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'bbs_ads'; + if (!is_dir($adsDir)) { + return true; + } + + $selectByLegacy = $db->prepare("SELECT id FROM advertisements WHERE legacy_filename = ?"); + $selectBySlug = $db->prepare("SELECT id FROM advertisements WHERE slug = ?"); + $insertAd = $db->prepare(" + INSERT INTO advertisements ( + slug, + title, + description, + content, + content_hash, + source_type, + legacy_filename, + is_active, + show_on_dashboard, + allow_auto_post, + dashboard_weight, + dashboard_priority + ) VALUES (?, ?, '', ?, ?, 'legacy_import', ?, TRUE, TRUE, TRUE, 1, 0) + "); + + foreach (glob($adsDir . DIRECTORY_SEPARATOR . '*.ans') ?: [] as $path) { + $legacyFilename = basename($path); + + $selectByLegacy->execute([$legacyFilename]); + if ($selectByLegacy->fetch(\PDO::FETCH_ASSOC)) { + continue; + } + + $rawContent = @file_get_contents($path); + if ($rawContent === false) { + continue; + } + + $content = advertisingMigrationEnsureUtf8($rawContent); + $contentHash = hash('sha256', $content); + $title = pathinfo($legacyFilename, PATHINFO_FILENAME); + $baseSlug = advertisingMigrationSlugify($title !== '' ? $title : $legacyFilename); + $slug = $baseSlug; + $suffix = 2; + + while (true) { + $selectBySlug->execute([$slug]); + if (!$selectBySlug->fetch(\PDO::FETCH_ASSOC)) { + break; + } + $slug = substr($baseSlug, 0, 110) . '-' . $suffix; + $suffix++; + } + + $insertAd->execute([ + $slug, + $title !== '' ? $title : $legacyFilename, + $content, + $contentHash, + $legacyFilename + ]); + } + + return true; +}; diff --git a/database/migrations/v1.11.0.39_ad_campaign_schedules.php b/database/migrations/v1.11.0.39_ad_campaign_schedules.php new file mode 100644 index 000000000..2b8dca2a9 --- /dev/null +++ b/database/migrations/v1.11.0.39_ad_campaign_schedules.php @@ -0,0 +1,25 @@ +exec(" + CREATE TABLE IF NOT EXISTS advertisement_campaign_schedules ( + id SERIAL PRIMARY KEY, + campaign_id INTEGER NOT NULL REFERENCES advertisement_campaigns(id) ON DELETE CASCADE, + days_mask INTEGER NOT NULL DEFAULT 0, + time_of_day CHAR(5) NOT NULL DEFAULT '12:00', + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_triggered_at TIMESTAMPTZ DEFAULT NULL + ) + "); + + $db->exec(" + CREATE INDEX IF NOT EXISTS idx_ad_campaign_schedules_campaign + ON advertisement_campaign_schedules (campaign_id, is_active) + "); + + return true; +}; diff --git a/database/migrations/v1.11.0.40_ad_campaign_tag_filters.php b/database/migrations/v1.11.0.40_ad_campaign_tag_filters.php new file mode 100644 index 000000000..6e3945dad --- /dev/null +++ b/database/migrations/v1.11.0.40_ad_campaign_tag_filters.php @@ -0,0 +1,22 @@ +exec(" + CREATE TABLE IF NOT EXISTS advertisement_campaign_tag_filters ( + campaign_id INTEGER NOT NULL REFERENCES advertisement_campaigns(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES advertisement_tags(id) ON DELETE CASCADE, + filter_mode VARCHAR(16) NOT NULL, + PRIMARY KEY (campaign_id, tag_id, filter_mode) + ) + "); + + $db->exec(" + CREATE INDEX IF NOT EXISTS idx_ad_campaign_tag_filters_campaign + ON advertisement_campaign_tag_filters (campaign_id, filter_mode) + "); + + return true; +}; diff --git a/docs/Advertising.md b/docs/Advertising.md new file mode 100644 index 000000000..8065f741d --- /dev/null +++ b/docs/Advertising.md @@ -0,0 +1,136 @@ +# Advertising + +BinktermPHP includes a built-in ANSI advertising system that lets sysops promote services, events, and other BBSes directly within their node — both on the web dashboard and through scheduled echomail posts into active message areas. + +The system is designed around the way FTN networks actually work. Ads are authored in classic ANSI art, stored in a central library, and delivered through two complementary channels: a rotating carousel on the dashboard for visitors browsing the web interface, and automated campaign posts that reach users reading echomail across the network. A single ad can appear in both places simultaneously, or be restricted to one channel. + +Campaigns give sysops fine-grained control over timing and reach. Each campaign targets one or more echomail areas and fires on a configurable weekly schedule with per-timezone awareness. Weighted ad selection within a campaign means you can favor certain ads over others without manual intervention. Every post — whether triggered automatically by the scheduler or run manually — is recorded in a history log, making it easy to audit what was sent, when, and to which areas. + +The ad library tracks content hashes so duplicate uploads are flagged before they crowd out your rotation. Existing flat-file ads from the legacy `bbs_ads/` directory are migrated into the database automatically on upgrade, so you don't lose content switching from the old system. + +Ads are managed from **Admin -> Ads -> Advertisements**. Campaigns are managed from **Admin -> Ads -> Ad Campaigns**. + +## Features + +- Upload ANSI ads directly into the ad library +- Edit ad metadata and ANSI content in the browser +- Preview ANSI ads in a modal before saving +- Tag ads with freeform labels such as `general`, `door`, `network`, or `event` +- Choose which ads are eligible for the dashboard carousel +- Build auto-posting campaigns with multiple targets +- Schedule campaigns by day of week, time, and timezone +- Track post history for manual and automatic posts + +## Ad Library + +Each ad is stored in the database with: + +- title +- slug +- description +- ANSI content +- content hash for duplicate warning +- enabled/disabled state +- dashboard eligibility +- auto-post eligibility +- tags + +Duplicate uploads are allowed. If the ANSI payload matches an existing ad, the system warns but does not block the upload. + +## Tags + +Ads can be tagged with freeform labels such as `general`, `door`, `network`, or +`event`. + +Tags are used for organization in the library and can also be used by ad +campaigns to control eligibility. + +Campaign tag filtering supports two modes: + +- **Include tags** - the campaign will only consider ads matching at least one + selected include tag +- **Exclude tags** - the campaign will skip any ad matching an excluded tag + +Tag filters can be combined with explicitly assigned ads: + +- if a campaign has assigned ads and tag filters, the assigned ad list is + narrowed by the tag rules +- if a campaign has no assigned ads but does have tag filters, the campaign can + source eligible ads from the library by tag +- a campaign must have at least one assigned ad or at least one tag filter + +## Dashboard Ads + +The dashboard advertising window pulls from ads marked for dashboard display. + +- Rotation is per PHP session +- Left and right arrow controls move through eligible ads +- Keyboard left/right navigation is supported +- Duplicate ANSI payloads are de-duplicated in the displayed set + +If only one eligible ad exists, the dashboard simply shows that ad without carousel controls. + +## Campaigns + +Campaigns let the sysop post ads automatically into echomail areas. + +Each campaign can define: + +- a posting user +- one or more active schedules +- one or more active targets +- one or more assigned ads with weights +- optional include/exclude tag filters + +Each target contains: + +- echoarea tag +- domain +- subject template +- enabled/disabled state + +Each schedule contains: + +- selected days of the week +- time of day +- timezone +- enabled/disabled state + +The campaign runner chooses an eligible ad using weighted random selection. +Normal eligibility rules still apply, including active state, auto-post +eligibility, and any configured date window on the ad. + +## Scheduler Integration + +Campaigns are processed automatically by `scripts/binkp_scheduler.php`. + +The scheduler checks due schedule slots and attempts posts for each active campaign target. Matching is based on the configured local schedule time with a grace window for slightly late runs. + +For manual testing or one-off runs, you can also use: + +```bash +php scripts/run_ad_campaigns.php +php scripts/run_ad_campaigns.php --campaign-id=3 +php scripts/run_ad_campaigns.php --dry-run +``` + +## Post History + +The **Ad Campaigns** page includes a post history table showing: + +- post time +- campaign +- ad +- target +- status +- subject +- posting user +- error text when a post fails + +Both manual and automatic campaign runs are recorded in the same history log. + +## Echomail Posting Notes + +- Outbound ad posts strip SAUCE before the message body is posted +- Subject templates are stored per target +- Local-only areas are posted locally without uplink distribution diff --git a/docs/AntiVirus.md b/docs/AntiVirus.md index 3480ac01a..217bc9a8a 100644 --- a/docs/AntiVirus.md +++ b/docs/AntiVirus.md @@ -112,7 +112,7 @@ VirusTotal scanning is **disabled by default** and only activates when `VIRUSTOT ## Per-area configuration -Virus scanning is enabled or disabled per file area in the admin interface at **Admin → File Areas**. Each area has a **Scan for Viruses** toggle. Scanning is enabled by default for new areas. +Virus scanning is enabled or disabled per file area in the admin interface at **Admin → Area Management → File Areas**. Each area has a **Scan for Viruses** toggle. Scanning is enabled by default for new areas. When a file fails the virus scan it is rejected and deleted. The virus signature name is logged and recorded in the database alongside the file record. diff --git a/docs/C64Doors.md b/docs/C64Doors.md new file mode 100644 index 000000000..5259028d8 --- /dev/null +++ b/docs/C64Doors.md @@ -0,0 +1,180 @@ +# C64 Doors + +> **Draft** — This document was generated by AI and may not have been reviewed for accuracy. + +C64 Doors are WebDoors that run Commodore 64 programs inside a jsc64 emulator embedded +directly in the browser. They require no additional server-side components beyond PHP — no +bridge, no DOSBox, no Node.js. + +## Table of Contents + +- [How It Works](#how-it-works) +- [Creating a C64 Door](#creating-a-c64-door) + - [File Structure](#file-structure) + - [index.php](#indexphp) + - [webdoor.json](#webdoorjson) +- [Supported File Types](#supported-file-types) +- [Configuration Reference](#configuration-reference) +- [D64 Disk Images](#d64-disk-images) +- [Enabling the Door](#enabling-the-door) + +--- + +## How It Works + +Each C64 Door is a WebDoor whose `index.php` includes the shared +`_c64engine/player.php` template. The engine: + +1. Authenticates the user and checks the door is enabled. +2. Reads the game file (PRG, ROM, BIN, or D64) from the door's directory. +3. Base64-encodes the program bytes and embeds them directly in the HTML page. +4. Boots the jsc64 C64 emulator (Kernal, BASIC, and Character ROMs are in + `public_html/vendor/jsc64/js/assets/`). +5. After the 2-second BASIC boot, writes the program bytes into emulated memory + and executes them. + +No API calls are made at runtime — the program data travels with the page load. + +--- + +## Creating a C64 Door + +### File Structure + +``` +public_html/webdoors/ +└── mygame/ + ├── webdoor.json ← door manifest + ├── index.php ← sets $c64Config, includes the engine + └── mygame.prg ← the game (or .d64, .rom, .bin) +``` + +Copy `public_html/webdoors/_c64example/` as a starting point, rename the +folder to your game's slug, and replace `game.prg` with your actual file. + +### index.php + +```php + 'mygame', // must match webdoor.json "id" + 'title' => 'My C64 Game', // shown in the loading spinner + 'prg' => 'mygame.prg', // file in the same directory as index.php +]; +require __DIR__ . '/../_c64engine/player.php'; +``` + +That's the entire `index.php`. The engine resolves `prg` relative to the door's +own directory automatically. + +### webdoor.json + +```json +{ + "webdoor_version": "1.0", + "game": { + "id": "mygame", + "name": "My C64 Game", + "version": "1.0", + "author": "Author Name", + "description": "Short description shown in the games list.", + "entry_point": "index.php" + }, + "requirements": { + "min_host_version": "1.0", + "features": [] + }, + "config": { + "credits_cost_per_session": 0 + } +} +``` + +`game.id` must match `door_id` in `index.php`. The folder name is +conventionally the same value. + +--- + +## Supported File Types + +The engine detects the file type from the extension and handles it automatically: + +| Extension | Handling | +|-----------|----------| +| `.prg` | Standard C64 PRG — reads the 2-byte load address from the file header | +| `.p00` | P00 container — strips 26-byte header, then reads as PRG | +| `.d64` | C64 floppy disk image — extracts PRG files; loads the first one by default | +| `.rom` | Raw cartridge ROM — loaded at `$8000` (32768) | +| `.bin` | Raw binary — loaded at `$8000` (32768) | +| other | Treated as PRG (2-byte header read) | + +Use `load_address` in `$c64Config` to override the default for any extension +(see [Configuration Reference](#configuration-reference)). + +--- + +## Configuration Reference + +All keys for `$c64Config`: + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `door_id` | string | Yes | Door identifier — must match `webdoor.json` `id` | +| `title` | string | Yes | Game title shown in the loading spinner | +| `prg` | string | One of these | Filename of a PRG/ROM/BIN in the door's directory | +| `d64` | string | One of these | Filename of a D64 in the door's directory | +| `prg_path` | string | One of these | Absolute path to a PRG/ROM/BIN | +| `d64_path` | string | One of these | Absolute path to a D64 | +| `prg_name` | string | No | PRG name to auto-select from a D64 (default: first entry) | +| `load_address` | int | No | Override load address; file loaded as raw binary with no header | + +**`prg` vs `prg_path`:** Use `prg` for files that live next to `index.php` — +it's the shortest form. Use `prg_path` only when the file lives outside the +door's directory. + +**`load_address` example** — loading a cartridge at a non-standard address: + +```php +$c64Config = [ + 'door_id' => 'mygame', + 'title' => 'My Game', + 'prg' => 'mygame.rom', + 'load_address' => 0xC000, // override auto-detected $8000 default +]; +``` + +--- + +## D64 Disk Images + +A D64 door can contain a whole floppy's worth of programs. By default the +engine loads the first PRG found in the disk directory. Use `prg_name` to +select a specific one: + +```php +$c64Config = [ + 'door_id' => 'mygame', + 'title' => 'My Game', + 'd64' => 'mygame.d64', + 'prg_name' => 'LOADER', // exact name as it appears in the D64 directory +]; +``` + +PRG names in D64 images are uppercase — check them using the file preview in +the BBS file browser, which shows the disk directory. + +A `.d64` file may also be passed via the `prg` key; the engine recognises the +extension and handles it correctly. + +--- + +## Enabling the Door + +C64 Doors are WebDoors and go through the same activation flow: + +1. Drop the door folder into `public_html/webdoors/`. +2. In the BBS admin panel go to **WebDoors** and click **Refresh**. +3. Find the new door in the list and click **Enable**. +4. Adjust credits cost if desired and save. + +The door will appear in the WebDoors section of the BBS for all logged-in users. diff --git a/docs/CLI.md b/docs/CLI.md index 2048da3e7..c50bbb157 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -17,13 +17,18 @@ BinktermPHP includes a full suite of CLI tools for managing your system from the - [Admin Daemon](#admin-daemon) - [Admin Client](#admin-client) - [Nodelist Updates](#nodelist-updates) +- [Geocoding](#geocoding) - [Database Backup](#database-backup) - [Crashmail Poll](#crashmail-poll) +- [FREQ File Pickup](#freq-file-pickup) +- [Outbound FREQ (File Request)](#outbound-freq-file-request) - [Echomail Robots](#echomail-robots) - [Create Translation Catalog](#create-translation-catalog) - [Generate Ad](#generate-ad) - [Log Rotate](#log-rotate) - [Post Ad](#post-ad) +- [Post File to File Area](#post-file-to-file-area) +- [Re-Hatch File to Outbound](#re-hatch-file-to-outbound) - [Restart Daemons](#restart-daemons) - [Who](#who) @@ -318,6 +323,32 @@ php scripts/debug_binkp.php 1:153/149 php scripts/process_packets.php ``` +## Re-Hatch File to Outbound +Regenerate TIC file(s) for an existing stored file and queue the file plus TICs +into `data/outbound` for re-sending to the area's uplinks: + +```bash +# Re-hatch by file ID +php scripts/file_hatch.php --file-id=123 + +# Re-hatch by filename and area tag +php scripts/file_hatch.php CHEVY.RIP LVLY_RIPSCRIP --domain=lovlynet + +# Allow rehatching a file that is not in approved status +php scripts/file_hatch.php --file-id=123 --allow-nonapproved +``` + +Notes: + +- The script looks up an existing file record and resolves the current stored + file from disk; it does not re-upload the file. +- It only works for non-local, non-private file areas, because it generates + outbound TIC files for configured uplinks. +- Generated TIC passwords follow the normal TIC password precedence used by the + application. +- Output is written to `data/outbound/`, including the file copy and one TIC + per matching uplink. + By default, leftover unprocessed files in `data/inbound/` are moved to `data/inbound/unprocessed/` after they have been untouched for 24 hours. Set `BINKP_DELETE_UNPROCESSED_FILES=true` in `.env` to delete those stale files instead. @@ -425,6 +456,69 @@ php scripts/update_nodelists.php --force php scripts/update_nodelists.php --help ``` +## Geocoding + +Both the BBS Directory and the Nodelist map use coordinates resolved from location strings via the [Nominatim](https://nominatim.openstreetmap.org/) geocoding API. Results are permanently cached in the `geocode_cache` table, so a given location string is only ever looked up once regardless of which script processed it first. + +The Nominatim API is rate-limited to **one request per second**. Scripts enforce this automatically. + +Environment variables (all optional): + +| Variable | Default | Description | +|---|---|---| +| `BBS_DIRECTORY_GEOCODING_ENABLED` | `true` | Set to `false` to disable all geocoding | +| `BBS_DIRECTORY_GEOCODER_EMAIL` | _(none)_ | Contact email sent in API requests (good practice) | +| `BBS_DIRECTORY_GEOCODER_URL` | Nominatim endpoint | Override with a self-hosted instance | +| `BBS_DIRECTORY_GEOCODER_USER_AGENT` | Auto-generated | Custom `User-Agent` header | + +### Geocode Nodelist + +Populates `latitude`/`longitude` on nodelist entries that have a `location` field but no coordinates yet. + +```bash +# Geocode all pending nodelist entries +php scripts/geocode_nodelist.php + +# Limit to 100 entries per run (good for cron) +php scripts/geocode_nodelist.php --limit=100 + +# Re-geocode entries that already have coordinates +php scripts/geocode_nodelist.php --force + +# Preview without writing changes +php scripts/geocode_nodelist.php --dry-run +``` + +Options: +- `--limit=N` — Process at most N nodes (default: all pending) +- `--force` — Re-geocode nodes that already have coordinates +- `--dry-run` — Show what would be processed without making changes + +Cron example (nightly, 100 nodes at a time): + +``` +0 3 * * * /usr/bin/php /path/to/binkterm/scripts/geocode_nodelist.php --limit=100 +``` + +### Geocode BBS Directory + +Backfills coordinates for BBS Directory entries that have a location set but no coordinates. + +```bash +# Geocode all pending BBS directory entries +php scripts/geocode_bbs_directory.php + +# Limit to N entries +php scripts/geocode_bbs_directory.php --limit=50 + +# Preview without writing changes +php scripts/geocode_bbs_directory.php --dry-run +``` + +Options: +- `--limit=N` — Process at most N entries +- `--dry-run` — Show how many rows would be updated without writing changes + ## Database Backup Creates PostgreSQL database backups using `pg_dump` with connection settings from `.env`. Backups are saved to the `backups/` directory with a timestamp in the filename. @@ -469,6 +563,84 @@ Options: - `--verbose` — Show detailed output - `--dry-run` — Check queue without attempting delivery +## FREQ File Pickup + +Use this script when you have sent a FREQ request to a remote node that cannot +reach you via crashmail. The remote system queues the requested files for you; +run this script to connect outbound and collect them. + +```bash +# Basic pickup — hostname resolved from nodelist +php scripts/freq_pickup.php 1:123/456 + +# Specify hostname manually +php scripts/freq_pickup.php 1:123/456 --hostname=bbs.example.com + +# Custom port and session password +php scripts/freq_pickup.php 1:123/456 --hostname=bbs.example.com --port=24554 --password=secret + +# Verbose debug output +php scripts/freq_pickup.php 1:123/456 --log-level=DEBUG +``` + +Options: +- `--hostname=HOST` — Hostname or IP to connect to (auto-resolved from nodelist if omitted) +- `--port=PORT` — Port number (default: `24554`) +- `--password=PASS` — Session password +- `--log-level=LVL` — `DEBUG`, `INFO`, `WARNING`, or `ERROR` (default: `INFO`) + +The script resolves your local address from the same network as the destination +so the remote system recognises you by the correct AKA. Any outbound packets +queued for that node are also sent during the session. + +## Outbound FREQ (File Request) + +Requests one or more files from a remote binkp node. Two modes are supported: + +- **Default (.req file)** — builds a Bark-style `.req` file (FTS-0008) and + sends it to the remote node as a regular file transfer. The remote FREQ + handler processes the request and sends the files back, either in the same + session or the next time it connects. Use this with any FTN node. +- **-g (M_GET / live-session)** — sends binkp `M_GET` commands during the + active session (FSP-1011). The remote must support binkp M_GET FREQ natively. + Use this when connecting to another BinktermPHP node or a known-compatible + system. + +Received files that are not FidoNet infrastructure files (`.pkt`, `.tic`, +day-of-week bundles, etc.) are stored in the specified user's private file area +under the **FREQ Responses** (`incoming`) subfolder. Infrastructure files are +left in `data/inbound/` for `process_packets` to handle. + +```bash +# Request a file by magic name (default .req mode) +php scripts/freq_getfile.php 3:770/220@fidonet NZINTFAQ + +# Request multiple files +php scripts/freq_getfile.php 1:123/456 ALLFILES FILES + +# Store received files for a specific user +php scripts/freq_getfile.php --user=john 1:123/456 ALLFILES + +# Use a session password +php scripts/freq_getfile.php --password=SECRET 1:123/456 MYFILE.ZIP + +# Use binkp M_GET (live-session FREQ) +php scripts/freq_getfile.php -g 1:123/456 ALLFILES + +# Override hostname and port +php scripts/freq_getfile.php --hostname=bbs.example.com --port=24554 1:123/456 ALLFILES +``` + +Options: +- `-g` — Use binkp M_GET (live-session FREQ) instead of `.req` file +- `--user=USERNAME` — Store received files for this user (default: first admin) +- `--password=PASS` — Area password sent with the request +- `--hostname=HOST` — Override hostname (skip nodelist/DNS lookup) +- `--port=PORT` — Override port (default: `24554`) +- `--log-level=LVL` — `DEBUG`, `INFO`, `WARNING`, or `ERROR` (default: `INFO`) +- `--log-file=FILE` — Log file path (default: `data/logs/freq_getfile.log`) +- `--no-console` — Suppress console output + ## Echomail Robots Runs the echomail robot processors — a rule-based framework that watches echo areas for matching messages and dispatches them to configured processors. @@ -543,6 +715,42 @@ php scripts/post_ad.php --echoarea=BBS_ADS --domain=fidonet php scripts/post_ad.php --echoarea=BBS_ADS --domain=fidonet --ad=claudes1.ans --subject="BBS Advertisement" ``` +## Post File to File Area + +Posts a file into a file area from the command line, following the same path as a web upload: validation, deduplication, storage, and TIC distribution to configured uplinks. TIC generation is skipped automatically for local areas, private areas, and areas with no uplinks configured for their domain. + +```bash +# Basic upload +php scripts/post_file.php /path/to/file.zip NEWFILES "Cool new utility" + +# Specify domain for a networked area +php scripts/post_file.php /path/to/NODELIST.Z30 NODELIST "Weekly nodelist" --domain=fidonet + +# With long description and custom uploader name +php scripts/post_file.php /tmp/tool.zip UTILS "Useful tool" \ + --long-desc="A multi-line description of what this tool does." \ + --user=sysop +``` + +Arguments: +- `file` — Path to the file to upload +- `area-tag` — Tag of the destination file area (e.g. `NEWFILES`) +- `description` — Short description shown in file listings + +Options: +- `--domain=` — Domain of the file area. When omitted, the area is looked up by tag only (use this for local areas). +- `--long-desc=` — Long description appended to the listing +- `--user=` — Username recorded as the uploader (default: `sysop`) + +Output indicates how many TIC files were queued for outbound, or why distribution was skipped: + +``` +File added: ID 42, area NEWFILES +TIC distribution: 2 TIC file(s) queued for outbound + a3f1c2b0.tic + d4e9f7a1.tic +``` + ## Restart Daemons Stops and restarts BinktermPHP daemons (admin daemon, scheduler, BinkP server, telnet, SSH, MRC, DOS bridge, Gemini). Uses PID files in `data/run/` to manage processes. diff --git a/docs/CreditSystem.md b/docs/CreditSystem.md index ea32aaf81..7645249d7 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/Doors.md b/docs/Doors.md index 7630c22d8..16a308e33 100644 --- a/docs/Doors.md +++ b/docs/Doors.md @@ -7,8 +7,9 @@ BinktermPHP supports three types of door games, each suited to different use cas | **DOS Doors** | Classic DOS games (LORD, TradeWars, etc.) running under DOSBox-X | [DOSDoors.md](DOSDoors.md) | | **Native Doors** | Linux/Windows binaries or scripts running via PTY | [NativeDoors.md](NativeDoors.md) | | **WebDoors** | Browser-based HTML5/PHP games embedded in an iframe | [WebDoors.md](WebDoors.md) | +| **C64 Doors** | Commodore 64 PRG/D64/ROM programs running in the jsc64 emulator | [C64Doors.md](C64Doors.md) | -WebDoors run entirely in the browser and require no additional server-side components. DOS Doors and Native Doors both require the **multiplexing bridge** described below. +C64 Doors and WebDoors run entirely in the browser and require no additional server-side components. DOS Doors and Native Doors both require the **multiplexing bridge** described below. ## Table of Contents @@ -326,3 +327,4 @@ SSLProxyEngine on - [DOS Doors](DOSDoors.md) — Setup, DOSBox configuration, adding door games, drop file format, troubleshooting - [Native Doors](NativeDoors.md) — Manifest format, environment variables, platform notes, test doors - [WebDoors](WebDoors.md) — Manifest format, iframe integration, BBS API, credits system +- [C64 Doors](C64Doors.md) — PRG/D64/ROM support, shared engine, configuration reference diff --git a/docs/EchoDigests.md b/docs/EchoDigests.md new file mode 100644 index 000000000..890efde4c --- /dev/null +++ b/docs/EchoDigests.md @@ -0,0 +1,148 @@ +# Echomail Digest + +The echomail digest sends each opted-in user a periodic email summarising new +messages in their subscribed echo areas. Instead of receiving individual +notifications, users get a single email at a chosen frequency that lists every +echo area that had activity, with the subject and author of each new message. +Full message bodies are not included — the digest is a prompt to log in and +read, not a replacement for the BBS. + +This is a registered feature and requires a valid BinktermPHP license. + +## Requirements + +- Valid BinktermPHP license +- SMTP configured and enabled in `.env` (`SMTP_ENABLED=true`) +- User must have an email address set in their profile +- User must be subscribed to at least one active echo area +- User must opt in to digest emails (off by default) + +## How It Works + +The digest is driven by `scripts/send_echomail_digest.php`, which is designed +to run on a cron schedule. Each run: + +1. Finds all active users with digest set to `daily` or `weekly` + and a non-empty email address. +2. For each user, checks whether enough time has passed since the last digest + was sent (24 hours for daily, 7 days for weekly). A user who has never + received a digest is always due. +3. Queries new echomail received after `last_sent` across the user's active + subscriptions. +4. If there are new messages, groups them by echo area (subject and author + only) and sends the digest via PHPMailer. The digest is capped at the + **top 20 most active areas**, with up to **20 messages shown per area**. + The lookback window is always the user's chosen frequency — 24 hours for + daily, 7 days for weekly — even on the very first digest. +5. Updates `echomail_digest_last_sent` to the current time, even if there were + no new messages, so the next check is deferred correctly. + +## User Configuration + +Users configure their digest frequency in **Settings → Notifications**: + +| Option | Behaviour | +|---|---| +| Off (default) | No digest emails sent | +| Daily | One email per day when there is new activity | +| Weekly | One email per week when there is new activity | + +The setting is disabled with a "Registered Feature" badge on unlicensed +installations. + +## Cron Setup + +Run the script hourly. The per-user frequency is enforced inside the script, +so running it more often than the shortest frequency (daily) is safe and +ensures daily digests are delivered at a consistent time rather than drifting +by run intervals. + +```cron +# /etc/cron.d/binkterm-digest (or add to the user's crontab) +0 * * * * www-data php /home/claudebbs/binkterm-php/scripts/send_echomail_digest.php +``` + +Adjust the path and user (`www-data`) to match your installation. + +## Testing + +### Dry run — see what would be sent without sending anything + +```bash +php scripts/send_echomail_digest.php --dry-run --verbose +``` + +### Dry run for a specific user + +```bash +php scripts/send_echomail_digest.php --dry-run --verbose --user=3 +``` + +### Actually send for a specific user + +```bash +php scripts/send_echomail_digest.php --verbose --user=3 +``` + +### Force a re-send (reset last-sent timestamp) + +If a digest has already been sent and you want to test again without waiting +for the frequency window to expire, reset the timestamp in the database: + +```sql +UPDATE user_settings SET echomail_digest_last_sent = NULL WHERE user_id = 3; +``` + +Then run the script again. + +### Verify user configuration + +```sql +SELECT u.id, u.email, us.echomail_digest, us.echomail_digest_last_sent +FROM users u +JOIN user_settings us ON us.user_id = u.id +WHERE u.id = 3; +``` + +## Script Options + +| Flag | Description | +|---|---| +| `--dry-run` | Show what would be sent without sending or updating timestamps | +| `--verbose` | Print per-user status to stdout | +| `--user=ID` | Process only the specified user ID | +| `--help` | Show usage information | + +Exit code is `0` on success (including zero eligible users), `1` if any +individual sends failed. + +## Database Columns + +Both columns live on the `user_settings` table: + +| Column | Type | Default | Description | +|---|---|---|---| +| `echomail_digest` | `VARCHAR(10)` | `'none'` | Frequency: `none`, `daily`, or `weekly` | +| `echomail_digest_last_sent` | `TIMESTAMP` | `NULL` | When the last digest was sent; `NULL` means never | + +These are added by migration `v1.11.0.31`. + +## Troubleshooting + +**Script exits with "Echomail digest requires a valid BinktermPHP license."** +The install is not registered. See Admin → Help → Register BinktermPHP. + +**User is skipped with "not due yet"** +The last digest was sent within the frequency window. Reset +`echomail_digest_last_sent` to `NULL` to force a send (see above). + +**User is skipped with "no new messages"** +No echomail was received in the user's subscribed areas since the last digest. +Check that the user has active subscriptions (Settings → Subscriptions) and +that the echo areas are receiving traffic. + +**Send fails silently** +Check `data/logs/error.log` (or your PHP error log) for PHPMailer errors. +Confirm `SMTP_ENABLED=true` and that the SMTP credentials in `.env` are +correct. You can test SMTP independently by using the netmail forwarding +feature, which uses the same mail stack. diff --git a/docs/FileAreas.md b/docs/FileAreas.md index ed71fe4b0..2ff7f123e 100644 --- a/docs/FileAreas.md +++ b/docs/FileAreas.md @@ -2,15 +2,105 @@ This document explains file area configuration, storage layout, and file area rules. -## Overview - -File areas are categorized by: +## Table of Contents + +- [Overview](#overview) + - [Tags](#tags) +- [File Permissions](#file-permissions) +- [Storage Layout](#storage-layout) +- [File Area Rules](#file-area-rules) + - [Rule File Structure](#rule-file-structure) + - [Area Tag Syntax](#area-tag-syntax) + - [Rule Fields](#rule-fields) + - [Actions](#actions) + - [Macros](#macros) +- [Logging](#logging) +- [Admin UI](#admin-ui) +- [Notes](#notes) + - [Example: Automatic Nodelist Importing](#example-automatic-nodelist-importing) +- [ISO-Backed File Areas](#iso-backed-file-areas) + - [How It Works](#how-it-works) + - [Linux Setup](#linux-setup) + - [Windows Setup](#windows-setup) + - [Subfolder Navigation](#subfolder-navigation) + - [Indexing: Preview-Based Import](#indexing-preview-based-import) + - [Importing Files (CLI)](#importing-files-cli) + - [Description Catalogue Support](#description-catalogue-support) + - [Database Records](#database-records) + - [FILE_ID.DIZ Preview](#file_iddiz-preview) + - [Limitations](#limitations) +- [Public File Areas](#public-file-areas) + - [Enabling Public Access](#enabling-public-access) + - [Public File Area Index](#public-file-area-index) + - [What Guests Can and Cannot Do](#what-guests-can-and-cannot-do) + - [Security Notes](#security-notes) +- [FREQ (File REQuest)](#freq-file-request) + - [Enabling FREQ on a File Area](#enabling-freq-on-a-file-area) + - [Enabling FREQ on Individual Files](#enabling-freq-on-individual-files) + - [Access Control Summary](#access-control-summary) + - [Magic Names](#magic-names) + - [FREQ Response Delivery](#freq-response-delivery) + - [FREQ Log](#freq-log) +- [Re-Hatching Existing Files](#re-hatching-existing-files) -- `tag` (e.g., `NODELIST`, `GENERAL_FILES`) -- `domain` (e.g., `fidonet`, `localnet`) -- Flags such as `is_local`, `is_active`, and `scan_virus` +## Overview -File areas can be managed via `/fileareas` in the web UI. +File areas are BinktermPHP's file distribution system — the equivalent of +echomail areas but for files. They hold files that can be uploaded by users, +delivered automatically by FTN TIC processors, or imported from external +sources such as ISO disc images. Each area has its own access controls, +automation rules, and storage directory. + +File areas serve several purposes within the BBS: + +- **FTN file distribution** — areas receive files from uplinks via TIC + attachments and forward them to downlinks. A typical installation has a + `NODELIST` area that automatically receives and imports the network nodelist + on every update cycle. +- **User uploads** — users can upload files directly through the web interface. + Each user also has a private file area used for netmail attachments and FREQ + responses. +- **Sysop file libraries** — areas can be created to host curated collections + of files for download, including large shareware archives exposed via + ISO-backed areas without copying files to local storage. +- **Post-upload automation** — file area rules can trigger scripts automatically + when files arrive, enabling workflows like nodelist importing, virus scanning, + file routing, and custom notifications. + +Each file area is identified by: + +- `tag` (e.g., `NODELIST`, `GENERAL_FILES`) — a short uppercase identifier, + permanent after creation +- `domain` (e.g., `fidonet`, `localnet`) — the FTN network the area belongs to, + or blank for local areas +- Flags such as `is_local`, `is_active`, `is_public`, `freq_enabled`, `gemini_public`, and + `scan_virus` + +**Key features at a glance:** + +| Feature | Description | +|---|---| +| Upload permissions | Control whether uploads are open to all users, registered users only, or sysops only | +| Extension filters | Allowlists and blocklists for file extensions | +| Virus scanning | Optional ClamAV integration on upload and TIC import | +| File preview | Images, video, audio, and text files preview inline in the browser; ZIP files show `FILE_ID.DIZ` | +| Subfolder navigation | Virtual folder hierarchy within a single area | +| Automation rules | Pattern-matched scripts that fire on file arrival | +| ISO-backed areas | Mount a CD/DVD ISO image and expose its directory tree as a browsable area | +| FREQ support | Serve files to remote FTN nodes that send file requests via BinkP or netmail | +| Gemini support | Expose area contents to Gemini protocol clients | +| Public access | Allow unauthenticated visitors to browse and download files without an account (registered feature) | + +File areas can be managed via **Admin → Area Management → File Areas** in the web UI. + +### Tags + +The tag is a short uppercase identifier for the area (e.g. `NODELIST`, +`GENERAL_FILES`). Tags are **permanent** — once a file area is created its tag +cannot be changed. This is because the tag is baked into the storage directory +name (`TAGNAME-ID`) and into file area rule keys. Changing a tag would orphan +the storage directory on disk and break any automation rules keyed to the old +tag name. ## File Permissions @@ -44,18 +134,72 @@ Example: `NODELIST-6` +The database ID suffix ensures uniqueness even if two areas share the same tag +across different domains. The tag portion is included purely for human +readability when browsing the filesystem. + +Because the storage path is derived from the tag at upload time and then stored +as an absolute path in the `files` table, existing files are always found +correctly regardless of what happens to the area configuration. New uploads +always go to the directory matching the current tag, so renaming a tag (which is +not permitted via the UI or API) would split files across two directories. + New uploads and TIC imports will automatically create the directory if it does not exist. +## Re-Hatching Existing Files + +If you need to re-send a file that is already stored in a file area, use the +CLI rehatch helper instead of uploading it again: + +```bash +php scripts/file_hatch.php --file-id=123 +php scripts/file_hatch.php CHEVY.RIP LVLY_RIPSCRIP --domain=lovlynet +``` + +The script: + +- looks up the existing file record +- resolves the current stored file on disk +- copies the file back into `data/outbound/` +- regenerates TIC file(s) for the file area's configured uplinks + +Limitations: + +- local-only file areas cannot be rehatch targets +- private file areas cannot be rehatch targets +- by default the file must be in `approved` status unless you pass + `--allow-nonapproved` + ## File Area Rules -File area rules are configured in `config/filearea_rules.json` and allow post-upload automation. +File area rules are configured in `config/filearea_rules.json` and provide +**post-upload automation** — they trigger whenever a file arrives in a file +area, whether via web upload, TIC import, or BinkP inbound delivery. + +Rules are the primary mechanism for integrating BinktermPHP with external tools. +Common uses include: + +- **Automatic nodelist importing** — when a new nodelist file arrives in the + `NODELIST` area, a rule fires a script that parses and imports it into the + database immediately, with no manual intervention required. +- **Virus scanning** — run an external scanner on every uploaded file and + quarantine or delete infections. +- **File routing** — move specific file types to a different area after arrival + (e.g. send all `.ZIP` files to a separate archive area). +- **Custom notifications** — send a sysop netmail when a particular filename + pattern arrives. +- **Archive management** — automatically archive superseded files when new + versions arrive. -Rules are applied in this order: +Rules are evaluated in this order: -1. All `global_rules` -2. Area-specific `area_rules[TAG]` +1. All `global_rules` — applied to every file area +2. Area-specific `area_rules[TAG]` — applied only when the file arrived in a + matching area -Rules are evaluated by regex against the filename. Each matching rule runs its script in order. +Within each group, rules are checked in order. All matching rules run (unless a +`stop` action halts processing). Rules are evaluated against the filename using +a PHP regex pattern. ### Rule File Structure @@ -185,14 +329,549 @@ Debug logs are written to: ## Admin UI -The rule configuration editor is available at: +All file area management is accessible via **Admin → Area Management** in the +navigation menu. This includes: -- `/admin/filearea-rules` +- **Admin → Area Management → File Areas** — create, edit, and delete file areas +- **Admin → Area Management → File Area Rules** — edit the automation rules configuration -Changes are saved through the admin daemon. +Rule changes are saved through the admin daemon and take effect immediately for +subsequent file arrivals. ## Notes - Rules are applied after virus scanning. - Infected files are rejected and will not run rules. - Rule processing runs for both user uploads and TIC imports. + +### Example: Automatic Nodelist Importing + +The most common real-world use of file area rules is automatic nodelist +processing. FidoNet nodelists arrive as TIC file attachments from your uplink, +land in the `NODELIST` file area, and should be imported into the database +immediately without manual action. + +#### Setup + +1. Create a file area with tag `NODELIST` linked to your network domain + (e.g. `fidonet`). +2. Configure your uplink to TIC files to this area. +3. Add the following rule to `config/filearea_rules.json`: + +```json +{ + "global_rules": [], + "area_rules": { + "NODELIST@fidonet": [ + { + "name": "Import FidoNet Nodelist", + "pattern": "/^NODELIST\\.(Z|A|L|R|J)[0-9]{2}$/i", + "script": "php %basedir%/scripts/import_nodelist.php %filepath% %domain% --force", + "success_action": "delete", + "fail_action": "keep+notify", + "enabled": true, + "timeout": 300 + } + ] + } +} +``` + +#### How it works + +1. Your uplink sends a nodelist file (e.g. `NODELIST.Z53`) via BinkP with a + matching TIC file. +2. The TIC processor receives the file and places it in the `NODELIST` file + area. +3. The rule engine checks the filename against the pattern + `/^NODELIST\.(Z|A|L|R|J)[0-9]{2}$/i`. It matches. +4. `import_nodelist.php` is invoked with the file path and domain. It parses + the nodelist and updates the database. +5. On success (`exit 0`), the `delete` action removes the nodelist file from + disk and the database — you can opt to keep them or delete them depending + on your preference. +6. On failure (non-zero exit or timeout), `keep+notify` leaves the file in + place for inspection and sends a sysop netmail alert. + +The `%domain%` macro passes the domain string (`fidonet`) to the import script +so it knows which network's nodelist it is processing. This allows the same +script to handle nodelists from multiple networks using separate rules keyed by +`NODELIST@fsxnet`, `NODELIST@amiganet`, etc. + +#### Multiple networks + +If you carry nodelists for more than one network, add a rule group per network: + +```json +"area_rules": { + "NODELIST@fidonet": [ + { + "name": "Import FidoNet Nodelist", + "pattern": "/^NODELIST\\.(Z|A|L|R|J)[0-9]{2}$/i", + "script": "php %basedir%/scripts/import_nodelist.php %filepath% %domain% --force", + "success_action": "delete", + "fail_action": "keep+notify", + "enabled": true, + "timeout": 300 + } + ], + "NODELIST@fsxnet": [ + { + "name": "Import fsxNet Nodelist", + "pattern": "/^NODELIST\\.[0-9]{3}$/i", + "script": "php %basedir%/scripts/import_nodelist.php %filepath% %domain% --force", + "success_action": "delete", + "fail_action": "keep+notify", + "enabled": true, + "timeout": 300 + } + ] +} +``` + +Each network has its own file area and its own rule group. Even though both +areas use the tag `NODELIST`, the `@domain` suffix in the rule key ensures each +rule only fires for files from the correct network. + +## ISO-Backed File Areas + +An ISO-backed file area uses a CD/DVD ISO image as its file store instead of a +local upload directory. This is ideal for exposing large shareware CD collections +(Simtel, Walnut Creek, InfoMagic, etc.) whose directory trees already contain +`FILES.BBS` or `DESCRIPT.ION` description catalogues — importing thousands of +files takes seconds with no manual description entry required. + +ISO areas are **read-only**: uploads, deletes, and renames are blocked. Description +edits (short/long description fields) are stored in the database and always +permitted. + +### How It Works + +1. The sysop mounts the ISO on the server using any suitable method and notes + the resulting mount point path. +2. The sysop creates an ISO-backed file area and enters the mount point path + in the **Mount Point** field. The admin UI shows a green **Accessible** + badge when the path exists and is readable. +3. The sysop triggers a re-index from the admin UI or CLI. The importer walks + the ISO directory tree, reads any description catalogues it finds, and writes + file records to the database. Each record stores a path relative to the mount + point (`iso_rel_path`). +4. At download or preview time the server reconstructs the absolute path from + the current mount point and the stored relative path. If the path is not + accessible the server returns HTTP 503. + +--- + +### Linux Setup + +#### Mounting the ISO + +Mount the ISO to a directory of your choice. Using a loop device requires root +(or a sudoers rule): + +```bash +sudo mkdir -p /srv/iso_mounts/simtel +sudo mount -o loop,ro /srv/isos/simtel.iso /srv/iso_mounts/simtel +``` + +To remount automatically on reboot, add an entry to `/etc/fstab`: + +``` +/srv/isos/simtel.iso /srv/iso_mounts/simtel iso9660 loop,ro 0 0 +``` + +Alternatively, any method that produces a readable directory — FUSE tools, +udisksctl, or a loop device managed by your init system — works equally well. +The BBS only requires a readable directory path; it does not care how it was +mounted. + +#### Creating the area + +1. Mount the ISO as described above. +2. Go to **Admin → Area Management → File Areas** and click **Add File Area**. +3. Set **Area Type** to **ISO-backed**. +4. Enter the mount point path in **Mount Point** (e.g. `/srv/iso_mounts/simtel`). +5. Save. The status badge should show **Accessible**. +6. Click **Re-index ISO** to import the file catalogue. + +--- + +### Windows Setup + +Windows 10 and 11 can mount ISO files natively without third-party software. + +#### Creating the area + +1. Mount the ISO in Windows. The simplest way is to right-click the `.iso` file + and select **Mount**. Windows assigns a drive letter (e.g. `D:`). You can also + use PowerShell: + + ```powershell + Mount-DiskImage -ImagePath "C:\isos\simtel.iso" + # See which drive letter was assigned: + Get-DiskImage -ImagePath "C:\isos\simtel.iso" | Get-Volume + ``` + +2. Go to **Admin → Area Management → File Areas** and click **Add File Area**. +3. Set **Area Type** to **ISO-backed**. +4. Enter the mounted drive letter or path in **Mount Point** (e.g. `D:\`). +5. Save. The status badge should show **Accessible**. +6. Click **Re-index ISO** to import the file catalogue. + +#### Re-mounting after a reboot + +Windows drive letters can change between reboots when other removable media is +present. After remounting: + +1. Right-click the `.iso` → **Mount** (or use `Mount-DiskImage`). +2. Edit the file area in the admin UI and update the **Mount Point** field if the + drive letter changed. Save. + +No re-index is needed unless the ISO itself changed — the existing file records +remain valid and path resolution uses the updated mount point immediately. + +#### Dismounting + +Clear the **Mount Point** field in the admin UI before dismounting, then: + +```powershell +Dismount-DiskImage -ImagePath "C:\isos\simtel.iso" +``` + +Clearing the mount point prevents the system from attempting to serve files +while the ISO is not accessible. + +--- + +### Subfolder Navigation + +ISO images often contain a directory tree of files organised by category or +platform. BinktermPHP exposes this structure to users as browsable subfolders. + +#### How subfolders work + +- Each distinct subdirectory found on the ISO is represented in the database by a + special **`iso_subdir`** record (`source_type = 'iso_subdir'`). This is a row + in the `files` table with no physical file; it exists solely to carry a + human-readable label and long description for the folder. +- Regular imported files carry the ISO-relative path in `subfolder` + (e.g. `UTIL/DISK`) and `iso_rel_path` (e.g. `UTIL/DISK/PKUNZIP.ZIP`). +- The file listing page shows `iso_subdir` entries as clickable folder rows. The + folder label is the record's `short_description` when set, otherwise the + directory name taken from the ISO path. + +#### Editing subfolder descriptions + +Admins can set a human-readable label and longer description on any subfolder +from the file listing page: + +1. Browse to the file area and navigate to (or stay at) the root listing where + subfolder rows appear. +2. Click the pencil icon on the subfolder row. +3. Edit the **Short description** (used as the folder label in the UI) and + **Long description** (shown in the description column). +4. Save. + +Changes are stored in the `iso_subdir` record and take effect immediately. They +are preserved across re-indexes because `scanIsoDirectory` only overwrites the +description if it is still blank or matches the bare directory name — manually +set descriptions are never clobbered. + +#### Deleting a subfolder + +Admins can delete an entire subfolder (including all files and nested +sub-subfolders) from the file listing page by clicking the trash icon on a +subfolder row. This removes all database records for that path. Because ISO files +are read-only, no files are deleted from disk. + +--- + +### Indexing: Preview-Based Import + +Clicking **Re-index ISO** opens a preview modal rather than immediately writing +to the database. This lets the sysop review and customise the import before +committing. + +#### Preview modal workflow + +1. The UI fetches a dry-run scan from `GET /api/fileareas/{id}/preview-iso`. +2. A table is displayed with one row per directory found on the ISO. Each row + shows: + - **Include/exclude checkbox** — uncheck to skip that directory entirely. + - **Directory path** — relative path from the ISO mount root. + - **Description** — pre-filled from the catalogue entry for the directory + name (e.g. the `FILES.BBS` entry for `UTIL`), or the existing database + description if the subfolder has already been indexed. The sysop can edit + this before importing. + - **Files** — count of files that would be imported from that directory + (respects the current import options). + - **Status badge** — **New** (not yet in the database) or **Existing** + (already indexed). +3. Adjust descriptions, uncheck directories to skip, set import options (see + below), then click **Apply Import**. +4. The UI posts to `POST /api/fileareas/{id}/reindex-iso` with the overrides + and options. The importer runs synchronously and returns counters. + +Changing an import option checkbox while the modal is open automatically +re-fetches the preview so file counts and status stay accurate. + +#### Import options + +| Option | Description | +|---|---| +| **Flat import** | Strip all subdirectory structure — every file is stored at the root of the area with no subfolder. Useful for single-directory ISOs or when subdirectory grouping is not desired. | +| **Catalogue only** | Only import files that appear in a `FILES.BBS` / `DESCRIPT.ION` catalogue. Files present in the directory but absent from the catalogue are skipped. If a directory has no catalogue at all, all files in it are imported regardless. | + +These options can also be combined (e.g. flat + catalogue-only). + +--- + +### Importing Files (CLI) + +The importer can also be run directly from the command line, which is useful for +large ISOs or scripted re-imports: + +``` +php scripts/import_iso.php --area= [options] +``` + +| Option | Description | +|---|---| +| `--area=ID` | File area ID to import into **(required)** | +| `--dry-run` | Show what would be imported without writing to the database | +| `--update` | Re-import and update descriptions for files that already exist | +| `--no-descriptions` | Import using filename as description (skip catalogue files) | +| `--dir=PATH` | Only scan this subdirectory of the mount point | +| `--verbose` | Print each file as it is processed | + +The importer prints a summary at the end: imported, updated, skipped, +no-description, and error counts. + +--- + +### Description Catalogue Support + +The importer looks for a description catalogue in each directory it visits, in +this order: + +1. `FILES.BBS` (standard BBS multi-line format) +2. `DESCRIPT.ION` (4DOS/JPSOFT single-line format) +3. `FILE_LIST.BBS` +4. `00INDEX.TXT` / `INDEX.TXT` (common on FTP mirrors burned to CD) + +Matching is case-insensitive. Only the first catalogue found in a directory is +used. If no catalogue is found, files are imported with the filename as the +description (unless **Catalogue only** mode is active, in which case a missing +catalogue means all files in that directory are imported regardless). + +**`FILES.BBS` format:** + +``` +FILENAME.EXT Short description here +ANOTHER.ZIP Another description that + continues on the next line +; this is a comment line +``` + +Continuation lines start with leading whitespace. Lines starting with `;` or +consisting only of `-` characters are treated as comments and ignored. + +**`DESCRIPT.ION` format:** + +``` +filename.ext Description text here +another.zip "Quoted description" +``` + +One line per file. The first whitespace-delimited token is the filename; the +remainder is the description (surrounding double-quotes are stripped). + +--- + +### Database Records + +| `source_type` | Purpose | +|---|---| +| `iso_import` | An actual file imported from the ISO. `iso_rel_path` holds the full relative path from the mount root. `subfolder` holds the parent directory path (NULL for root-level files). | +| `iso_subdir` | A virtual record representing one ISO subdirectory. Carries `short_description` (folder label) and `long_description`. Not shown as a file; rendered as a folder row in the listing UI. | + +Both record types live in the `files` table. Queries that list downloadable files +exclude `iso_subdir` records with `source_type IS DISTINCT FROM 'iso_subdir'`. + +--- + +### FILE_ID.DIZ Preview + +When a user opens the preview modal for a `.zip` file, the server extracts +`FILE_ID.DIZ` from inside the archive (case-insensitive) and displays it in the +preview panel with CP437→UTF-8 conversion applied. No extraction to disk occurs. + +--- + +### Limitations + +| Item | Notes | +|---|---| +| Mounting | The sysop is responsible for mounting the ISO and keeping it mounted. BinktermPHP does not manage mount lifecycle. | +| ARJ/LZH `FILE_ID.DIZ` | Not extracted — PHP has no built-in ARJ reader. Shown as download prompt instead. | +| Uploads | Blocked. ISO areas are permanently read-only. | +| File deletion | Admin-only. Removes the database record; no disk change. Re-index with `--update` to refresh descriptions if the ISO changes. | +| Move / rename | Filename and area moves are blocked. Description edits are allowed. | +| ISO format | ISO 9660, Joliet, and Rock Ridge extensions are supported by the Linux kernel ISO driver. UDF discs can be mounted with `mount -t udf`. | + +--- + +## Public File Areas + +> **Registered feature.** Requires a valid BinktermPHP license. The checkbox is +> hidden and the setting is ignored on unlicensed installations. + +An individual file area can be marked **Public**, allowing unauthenticated +visitors to browse its file listing and download files without a BBS account. +All other interactive features — comments, uploads, and area navigation — remain +login-gated. + +This is intended for sysops running shareware libraries, FTN file echo mirrors, +or community software archives where open access to the files themselves is +desirable but full account-based interaction is not. + +### Enabling Public Access + +1. Go to **Admin → Area Management → File Areas** and edit the area. +2. Check **Public File Area** (visible only on registered installations). +3. Save. + +The area is now accessible at `/files/AREATAG` without login. Guests see the +file listing and can download files. The sidebar showing other areas, the upload +button, and the comment form are all suppressed for unauthenticated visitors. + +Private areas (`is_private = true`) cannot be made public — the access check +always rejects unauthenticated requests for private areas regardless of the +`is_public` flag. + +### Public File Area Index + +When the **Enable Public Files Index** toggle is turned on in **Admin → BBS +Settings → BBS Features**, a discoverable index page is available at +`/public-files`. This page lists all active public areas with their tag, +description, and file count. A **Public Files** navigation link is shown to +unauthenticated visitors in the site header. + +This setting is off by default. Enable it only if you want guests to be able to +discover all your public areas from a single landing page. Individual public +areas are always reachable via their direct URL (`/files/AREATAG`) whether or +not the index is enabled. + +### What Guests Can and Cannot Do + +| Action | Guest (public area) | +|---|---| +| Browse file listing | ✅ | +| Download files | ✅ | +| Preview files (images, text, ANSI, ZIP, etc.) | ✅ | +| View file comments | ✗ (login required) | +| Post file comments | ✗ (login required) | +| Upload files | ✗ (login required) | +| Navigate to other areas via sidebar | ✗ (sidebar hidden) | + +Credits are not charged or awarded for guest downloads. + +### Security Notes + +- Only non-private areas can be made public. `is_private = true` is always + enforced regardless of `is_public`. +- Private user areas (tag format `PRIVATE_USER_{id}`) always have + `is_private = true` and are never reachable by guests. +- The `is_public` flag is enforced server-side on every API request. There is no + client-side bypass. +- On unlicensed installations the `is_public` flag is silently forced to `false` + on every save, even if set directly via the API. +- Once an area is marked public, it remains accessible even if the license + subsequently lapses. This is intentional — it avoids breaking links shared + with visitors due to a lapsed license. + +--- + +## FREQ (File REQuest) + +BinktermPHP can serve files to remote FidoNet nodes that send FREQ requests. Two request mechanisms are supported: + +- **Binkp `M_GET`** — the remote node sends a `M_GET` command during a binkp session. Files are delivered in the same session. +- **Netmail FILE_REQUEST** — the remote node sends a netmail with the `FILE_REQUEST` attribute (0x0800). The subject line contains the requested filename(s). The response is delivered as a FILE_ATTACH netmail (see below). The request netmail itself is not stored in the inbox. + +### Enabling FREQ on a File Area + +1. Go to **Admin → Area Management → File Areas** and edit the area. +2. Check **Allow FREQ** to make all approved files in the area requestable. +3. Optionally set a **FREQ Password**. Remote nodes must supply this password in their `M_GET` command to receive files. Leave blank for open access. +4. Save. + +Only files with an `approved` status are served. Files in private areas are never served regardless of this setting. + +### Enabling FREQ on Individual Files + +A specific file can be made FREQable without opening its entire area: + +1. Go to **Files** and share the file using the share button. +2. The **FREQ Accessible** checkbox (checked by default) makes the file requestable via FREQ. +3. Uncheck it if you want a web-only share link. + +Shared file FREQ access respects expiration dates — an expired share is not served even if the file itself is still active. + +### Access Control Summary + +A file is served if **either** condition is true: + +| Condition | Required | +|---|---| +| File is in an area with **Allow FREQ** enabled | Area password must match if set; area must not be private | +| File has an active, non-expired share with **FREQ Accessible** checked | Area must not be private | + +Files with a status other than `approved` (pending, quarantined, rejected) are never served. + +### Magic Names + +Requesting a magic name returns a generated file listing rather than a literal file: + +| Requested filename | Response | +|---|---| +| `ALLFILES` or `FILES` | Combined listing of all FREQ-enabled areas in `FILES.BBS` format | +| `` | Listing for that specific area (if FREQ is enabled on it) | + +Magic name responses are generated at request time and staged for delivery like any other FREQ response. + +### FREQ Response Delivery + +When a FREQ request is resolved, the response file is delivered as a +FILE_ATTACH netmail using one of two methods: + +1. **Crashmail (direct)** — if the requesting node is resolvable in the nodelist + with a hostname (IBN/INA flag), BinktermPHP connects directly and delivers + the attachment. No action is needed from the requesting node. + +2. **Hold directory (reverse crash)** — if the requesting node cannot be reached + directly, the FILE_ATTACH netmail packet and the attached file are written to a + per-node hold directory (`data/outbound/hold/
/`). The files are + delivered during the next binkp session with that node, regardless of which + side initiates the connection. + + BinktermPHP also sends the requesting node a plain notification netmail (via + normal hub routing) informing them that their files are ready to collect. To + pick up queued files, the requesting node can run: + + ```bash + php scripts/freq_pickup.php + ``` + + See [CLI.md](CLI.md#freq-file-pickup) for full usage. + +> **Note:** Routed FILE_ATTACH netmail is intentionally not used because FTN +> hubs typically strip file attachments from forwarded messages. + +### FREQ Log + +All FREQ requests — served and denied — are recorded. View them at: + +- **Admin → FREQ Log** (`/admin/freq-log`) + +The log shows the requesting node address, filename, whether it was served, the deny reason if applicable, and the request source (`m_get` for binkp sessions, `netmail` for FILE_REQUEST netmails). You can filter by node, filename, served/denied status, and source. diff --git a/docs/LSC/LSC1 - Markup Kludge.txt b/docs/LSC/LSC1 - Markup Kludge.txt new file mode 100644 index 000000000..49b819a7b --- /dev/null +++ b/docs/LSC/LSC1 - Markup Kludge.txt @@ -0,0 +1,322 @@ +********************************************************************** +FIDONET STANDARDS PROPOSAL + +Title: MARKUP Kludge for FidoNet-Compatible Echomail and Netmail +Draft: 5 +Date: 2026-03-13 +Status: Community Draft - Proposed for FTSC Consideration +********************************************************************** + + +1. INTRODUCTION +--------------- + +This document proposes the MARKUP kludge line for use in FidoNet- +compatible echomail and netmail messages. Its purpose is to indicate +that the body of a message is formatted using a named markup syntax, +allowing capable reader software to render the message with appropriate +formatting while remaining fully readable by legacy software that does +not support this kludge. + +Unlike a Markdown-specific kludge, MARKUP provides a general mechanism +for identifying the body format of a message. This allows the same +extension point to be used for Markdown, BBCode, Gemtext, and other +formats without requiring a new kludge definition for each syntax. + +This proposal is submitted to the FidoNet community for discussion and +review, with the intent of submission to the FidoNet Technical +Standards Committee (FTSC) for consideration as a FidoNet Technical +Standard. + + +2. DEFINITIONS +-------------- + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", +"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this +document are to be interpreted as described in FTA-1006. + +"Kludge line": A line in a FidoNet message body beginning with the +ASCII SOH character (0x01), used to carry machine-readable metadata +not intended for direct display to the end user. + +"Markup syntax": A textual formatting language used within the visible +message body to express structure or presentation, such as Markdown, +BBCode, or Gemtext. + + +3. THE MARKUP KLUDGE +-------------------- + +3.1 Syntax + +The MARKUP kludge line has the following syntax: + + ^AMARKUP: + +where ^A represents the ASCII SOH character (0x01), is a +registered or otherwise well-known markup format identifier, and + is a format version string meaningful within that format. + +Examples: + + ^AMARKUP: Markdown 1.0 + ^AMARKUP: BBCode 1.0 + ^AMARKUP: Gemtext 1.0 + ^AMARKUP: StyleCodes 1.0 + +3.2 Placement + +The MARKUP kludge line SHOULD be placed with other kludge lines at +the beginning of the message body, before any visible text content, +in accordance with common FidoNet kludge conventions. + +A message body MUST NOT contain more than one MARKUP kludge line. +If multiple MARKUP kludge lines are present, implementations SHOULD +use the first one encountered and ignore the remainder. + +The MARKUP kludge applies to the entire visible message body +regardless of where in the message the kludge line appears. The +declared format is not limited to the text following the kludge; +the complete body content MUST be interpreted and rendered as a +single unit using the declared markup syntax. + +3.3 Format Identifier + +The format identifier MUST be a single token and SHOULD use a stable, +widely recognized name for the markup syntax. Implementations SHOULD +treat format identifiers case-insensitively for matching purposes, +though they MAY preserve the original casing for display or logging. + +3.4 Version String + +The version string MUST follow the format identifier and SHOULD be +included whenever the syntax has meaningful version distinctions. +Implementations MAY treat the version as an opaque string. Software +that recognizes a format but not its declared version SHOULD fall back +to best-effort rendering or plain text display. + + +4. BEHAVIOR OF CONFORMING SOFTWARE +---------------------------------- + +4.1 Message Authors and Editors + +A message editor that supports composition in a named markup syntax +MAY insert the MARKUP kludge line when the user composes a message in +that format. It SHOULD NOT insert the kludge unless the message body +actually contains content intended for that markup syntax. + +4.2 Message Readers + +A message reader that encounters the MARKUP kludge: + + - SHOULD inspect the declared format identifier before attempting + rendering. + - MAY render the body according to the declared format if that + format is supported. + - MUST apply the declared format to the entire visible message body, + not only the portion of the body following the kludge line. + - SHOULD provide plain text display when the declared format is not + supported, not recognized, or cannot be safely rendered. + - SHOULD provide a way for the user to view the raw source if + desired. + +4.3 Unknown or Unsupported Formats + +Software that does not recognize the declared markup format MUST +ignore the semantic meaning of the kludge and display the message body +as plain text. The original body content MUST be preserved unchanged. + +4.4 Tosser and Packer Behavior + +Tossers, packers, and other mail-handling software MUST NOT alter, +strip, or otherwise modify the MARKUP kludge line during normal +message handling. The kludge MUST be preserved through the normal +FidoNet message routing process. + +4.5 Reply Quoting + +When replying to a message declared with the MARKUP kludge, software +SHOULD preserve the original source structure as much as practical. + +Software SHOULD NOT mechanically convert the entire original body into +legacy inline quote prefixes if doing so would damage the structure or +readability of the declared markup syntax. + +For line-oriented or block-oriented formats such as Markdown and +Gemtext, software SHOULD prefer quoting mechanisms native to that +format. + +For Markdown specifically, implementations SHOULD prefer enclosing the +original body in a Markdown blockquote while keeping any reply +attribution line outside the quoted block. + +Example: + + On 13 Mar 2026, Jane Doe wrote: + + > # Heading + > + > - item one + > - item two + > + > ```text + > code block + > ``` + +This approach improves round-trip fidelity for marked-up replies while +remaining readable in plain-text environments. + + +5. FORMAT-SPECIFIC GUIDANCE +--------------------------- + +This document defines only the transport-level declaration mechanism. +It does not standardize the parsing rules of any specific markup +syntax. + +Implementors supporting a given format SHOULD document which variant +they support. For example: + + - Markdown implementations SHOULD identify which dialect or baseline + they follow, such as CommonMark. + - BBCode implementations SHOULD document which tags are supported. + - Gemtext implementations SHOULD follow Gemtext line-oriented rules. + - StyleCodes implementations SHOULD follow the convention established + by GoldEd and compatible software. StyleCodes is also known as + "Rich Text" (SemPoint), "Structured Text" (Mozilla/Thunderbird), + and "markup" (Synchronet). The format identifier "StyleCodes" is + RECOMMENDED over these alternatives to avoid ambiguity with + Microsoft Rich Text Format (RTF) and reStructuredText (RST). + +When possible, authors SHOULD choose markup syntaxes that degrade +gracefully in plain-text environments, since many FidoNet-compatible +systems may display the raw message body without rendering. + + +6. SECURITY CONSIDERATIONS +-------------------------- + +Rendering software MUST treat the declared markup format as untrusted +input. Implementations SHOULD sanitize or restrict active content, +embedded HTML, unsafe URLs, and any construct that could execute code, +load remote resources, or mislead the user interface. + +Software rendering marked-up messages MUST NOT automatically fetch +remote resources without explicit user consent, in order to protect +user privacy and avoid unexpected network activity. + + +7. IMPLEMENTATION NOTES +----------------------- + +The MARKUP kludge is intended as a general extension mechanism for +FidoNet-compatible software. A given implementation MAY support only +a subset of markup syntaxes and still conform to this specification, +provided unsupported formats are safely treated as plain text. + +An implementation that currently supports only Markdown may still emit: + + ^AMARKUP: Markdown 1.0 + +This preserves forward compatibility with other markup syntaxes while +allowing immediate use for Markdown-aware readers. + + +8. MARKUP FORMAT REGISTRY +------------------------- + +This section defines the initial set of registered format identifiers +for use with the MARKUP kludge. Additional identifiers MAY be used by +mutual agreement between implementations; authors of new identifiers +are ENCOURAGED to document them and submit them for inclusion here. + +Format identifiers are matched case-insensitively per section 3.3. +The canonical capitalisation shown below SHOULD be used when emitting +the kludge. + +8.1 Registered Identifiers + + Identifier Version Description + ---------- ------- ----------- + Markdown 1.0 CommonMark-compatible Markdown. Implementations + SHOULD follow the CommonMark specification [5]. + See section 5 for guidance on dialect disclosure. + + BBCode 1.0 Tag-based markup originating in bulletin board + systems, using [tag] and [/tag] delimiters. + Implementations SHOULD document which tag set + is supported, as no single normative BBCode + specification exists. + + Gemtext 1.0 Line-oriented markup used by the Gemini protocol. + Each line begins with an optional prefix character + that determines its type (link, heading, list + item, preformatted block, quote, or plain text). + Implementations SHOULD follow the Gemini + specification for line-type parsing. + + StyleCodes 1.0 Character-sequence-based inline formatting + originating in GoldEd and compatible FidoNet + editors. Also known as "Rich Text" (SemPoint), + "Structured Text" (Mozilla/Thunderbird), and + "markup" (Synchronet). The identifier StyleCodes + is RECOMMENDED over these alternatives; see + section 5. + +8.2 Reserved Identifiers + + The following identifiers are reserved and MUST NOT be used as + format identifiers in the MARKUP kludge, to avoid collision with + existing terminology: + + RTF (conflicts with Microsoft Rich Text Format) + RST (conflicts with reStructuredText) + HTML (conflicts with HyperText Markup Language; active content + rendering is explicitly discouraged by section 6) + +8.3 Registration Process + + At this time no formal registration authority exists. Authors wishing + to use a new identifier SHOULD: + + 1. Choose a stable, unambiguous token not already in use. + 2. Publish a definition of the format and the meaning of any + version strings used. + 3. Submit the identifier and definition to the FidoNet community + for inclusion in this registry. + + +9. REFERENCES +------------- + + [1] FTS-0001 - A Basic FidoNet(r) Technical Standard + [2] FTS-0009 - The MSGID and REPLY kludges + [3] FTS-5003 - Character set kludge (CHRS/CHARSET) + [4] FTA-1006 - Key words to indicate requirement levels + [5] CommonMark Specification + https://spec.commonmark.org/ + + +10. REVISION HISTORY +-------------------- + + 2026-02-27 Initial proposal for a Markdown-specific kludge. + 2026-03-01 Revised to use the general MARKUP kludge mechanism. + 2026-03-09 Clarified one-kludge-per-message rule and whole-body + scope of the declared markup format. Reframed as a + general FidoNet community proposal. + 2026-03-10 Added section 8: Markup Format Registry, defining + initial registered identifiers (Markdown, BBCode, + Gemtext, StyleCodes), reserved identifiers, and + registration process guidance. Renumbered References + and Revision History to sections 9 and 10. + 2026-03-13 Added reply-quoting guidance for marked-up messages, + recommending preservation of source structure and + format-native quoting where practical. + + +********************************************************************** +END OF DOCUMENT +********************************************************************** diff --git a/docs/LSC/LSC2 - FILEREF Kludge.txt b/docs/LSC/LSC2 - FILEREF Kludge.txt new file mode 100644 index 000000000..bf4b008fe --- /dev/null +++ b/docs/LSC/LSC2 - FILEREF Kludge.txt @@ -0,0 +1,227 @@ +********************************************************************** +LOVLYNET SPECIFICATION + +Title: FILEREF Kludge for File-Referenced Echomail Threads +Draft: 1 +Date: 2026-03-17 +Status: Draft - LovlyNet Standards Council +********************************************************************** + + +1. INTRODUCTION +--------------- + +This document defines the FILEREF kludge line for use in LovlyNet and +other FTN-compatible echomail messages that discuss a specific +distributed file. Its purpose is to provide a machine-readable +association between an echomail thread root and a file carried in a +networked file area. + +The FILEREF kludge allows participating software to display comments or +discussion threads alongside the referenced file without requiring any +out-of-band mapping database shared between systems. Legacy software +that does not recognize the kludge continues to see an ordinary +echomail message with a normal human-readable subject. + +This specification is issued as a LovlyNet Standards Council (LSC) +draft for implementation and review within LovlyNet-compatible +software. Other FTN-compatible software MAY adopt it independently. + + +2. DEFINITIONS +-------------- + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", +"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this +document are to be interpreted as described in FTA-1006. + +"Kludge line": A line in a FidoNet message body beginning with the +ASCII SOH character (0x01), used to carry machine-readable metadata +not intended for direct display to the end user. + +"File area identifier": A stable identifier naming a distributed FTN +file area, consisting of an area tag and domain joined by `@`. + +"Thread root": The top-level echomail message that begins a discussion +thread. Replies are descendants of that message via normal FidoNet +threading mechanisms such as MSGID/REPLY. + + +3. THE FILEREF KLUDGE +--------------------- + +3.1 Syntax + +The FILEREF kludge line has the following syntax: + + ^AFILEREF: + +where ^A represents the ASCII SOH character (0x01), is the +network-visible file area identifier in the form `@`, + is the canonical distributed filename, and is the +SHA-256 hash of the referenced file encoded as 64 hexadecimal +characters. + +Example: + + ^AFILEREF: NODELIST@fidonet NODELIST.Z53 3e7d2f4c0d0e7f7fe1b6a3f01f7a9a4a7b4b9b0c1d2e3f405162738495a6b7c + +3.2 Field Requirements + +The and fields MUST each be a single token and +therefore MUST NOT contain whitespace. + +The field MUST contain exactly one `@` separator, with the +area tag on the left and the network domain on the right. Both +components MUST be non-empty. + +The field MUST be exactly 64 hexadecimal characters. Emitting +software SHOULD use lowercase hexadecimal, though receiving software +SHOULD compare the value case-insensitively. + +This specification assumes the filename used on the FTN wire is already +compatible with token-based parsing. Software that permits local +filenames containing spaces or other transport-hostile characters +SHOULD normalize them before distribution and SHOULD emit FILEREF using +that canonical distributed filename. + +3.3 Placement + +The FILEREF kludge SHOULD be placed with other kludge lines at the +beginning of the message body, before any visible text content, in +accordance with common FidoNet kludge conventions. + +A message body SHOULD NOT contain more than one FILEREF kludge line. If +multiple FILEREF kludge lines are present, implementations SHOULD use +the first one encountered and ignore the remainder. + +3.4 Scope + +The FILEREF kludge identifies the file discussed by the message and its +thread descendants. It is primarily intended for use on the thread root. + +Reply messages in the thread SHOULD rely on normal MSGID/REPLY +threading rather than repeating the FILEREF kludge on every message. + + +4. MATCHING SEMANTICS +--------------------- + +Software matching a message thread to a local file MUST treat the +combination of , , and as the authoritative +reference tuple. + +Two FILEREF kludges match if and only if: + + - matches exactly as a fully qualified `@` + identifier + - matches the canonical distributed filename exactly + - matches case-insensitively as a 64-character hexadecimal + SHA-256 digest + +If the local system carries the same area identifier and filename but the +SHA-256 digest differs, the FILEREF MUST be treated as referring to a +different file revision or different file content. + + +5. BEHAVIOR OF CONFORMING SOFTWARE +---------------------------------- + +5.1 Message Authors and Editors + +Software creating a new top-level echomail discussion for a specific +file MAY insert the FILEREF kludge on that thread root. + +When the FILEREF kludge is used, the message subject SHOULD remain a +human-readable filename or other plain-text subject that is useful on +software that does not support FILEREF. + +5.2 Message Readers and File Browsers + +Software that encounters a FILEREF kludge: + + - SHOULD parse and preserve the referenced tuple exactly. + - MAY use the tuple to associate the thread root and its replies with + a local file record. + - SHOULD verify the SHA-256 value against the local file before + presenting the message as commentary on that file. + - SHOULD fall back to ordinary echomail display if no matching local + file is available. + +5.3 Tosser and Packer Behavior + +Tossers, packers, and other mail-handling software MUST NOT alter, +strip, or otherwise modify the FILEREF kludge line during normal +message handling. The kludge MUST be preserved through normal FidoNet +message routing processes. + +5.4 Unknown or Unsupported Implementations + +Software that does not recognize FILEREF MUST ignore its semantic +meaning and continue to process the message as ordinary echomail. + + +6. INTEROPERABILITY GUIDANCE +---------------------------- + +The FILEREF kludge is designed to coexist with software that has no +knowledge of file-comment linkage. To maximize interoperability: + + - Authors SHOULD use a normal visible subject, typically the filename. + - Readers MAY use ordinary subject matching as a non-authoritative + fallback when interacting with replies from software that preserved + the thread but did not originate or interpret FILEREF. + - Implementations SHOULD treat FILEREF as authoritative when present + and valid, and subject matching only as a best-effort heuristic. + +This allows discussion threads to remain readable and replyable across +mixed software environments while still enabling exact matching between +conforming implementations. + + +7. SECURITY AND ROBUSTNESS CONSIDERATIONS +----------------------------------------- + +Receiving software MUST treat FILEREF contents as untrusted input. +Implementations SHOULD validate all three fields before acting on them. + +Software MUST NOT assume that a matching area tag and filename alone are +sufficient for identity. The SHA-256 digest is required to distinguish +similarly named or superseded files and to avoid false associations. + +Implementations SHOULD avoid using FILEREF to expose inaccessible or +private files to unauthorized users. Authorization to view the file or +the related message thread remains a local policy decision. + + +8. IMPLEMENTATION NOTES +----------------------- + +FILEREF defines only the transport-level declaration mechanism. It does +not standardize how a user interface presents comments, how comment +counts are cached, or how local database schemas model file metadata. + +An implementation MAY choose to attach FILEREF only to the first +top-level message in a thread and then rely on normal threading for +replies. This is the RECOMMENDED model because it avoids redundant +metadata while preserving exact cross-node matching for the thread root. + + +9. REFERENCES +------------- + + [1] FTS-0001 - A Basic FidoNet(r) Technical Standard + [2] FTS-0009 - The MSGID and REPLY kludges + [3] FTA-1006 - Key words to indicate requirement levels + + +10. REVISION HISTORY +-------------------- + + 2026-03-17 Initial LovlyNet specification draft for the FILEREF + kludge. + + +********************************************************************** +END OF DOCUMENT +********************************************************************** diff --git a/docs/RIPScrip_Support.md b/docs/RIPScrip_Support.md new file mode 100644 index 000000000..a2f05af73 --- /dev/null +++ b/docs/RIPScrip_Support.md @@ -0,0 +1,76 @@ +# RIPScrip Support + +BinktermPHP renders RIPscrip (Remote Imaging Protocol) graphics embedded in echomail messages and uploaded as `.rip` files in file areas. + +## Overview + +RIPscrip is a vector graphics protocol developed by TeleGrafix Communications in the early 1990s. It was widely used on BBSes to display menus, splash screens, and graphical content over slow serial connections. BinktermPHP automatically detects and renders RIP graphics when viewing echomail messages and when previewing `.rip` files. + +RIPscrip rendering is available in **echomail and file areas** — netmail messages do not trigger RIP rendering. + +## Where RIPscrip Renders + +### In Messages (Echomail only) + +When the message reader loads an echomail message, the server-side `MessageHandler` scans the message body for RIPscrip signature lines (lines beginning with `!|`). If detected, the raw RIP script is passed to the client as `rip_script` data alongside the normal message text. + +Netmail messages do not trigger RIP rendering. + +### In File Areas + +Files with a `.rip` extension are automatically previewed as RIPscrip graphics in the file browser. The preview is rendered to a canvas element in the file detail panel using the same RIPterm.js library. + +## How It Works + +### Client-Side Rendering + +The browser uses the [RIPterm.js](https://github.com/dj-256/RIPterm.js) library (loaded from `/vendor/riptermjs/`) to render the RIP script on an HTML5 `` element. The library provides faithful RIPscrip rendering including: + +- Line, rectangle, and polygon drawing +- Filled and outline shapes (circles, ellipses, polygons) +- Text placement +- The 16-color RIPscrip palette + +The JS renderer is loaded lazily — `BGI.js` and `ripterm.js` are only fetched when a RIP graphic is actually being displayed. + +### Server-Side Fallback + +A server-side PHP renderer (`src/RipScriptRenderer.php`) also exists and renders RIPscrip to inline SVG. This handles a subset of RIPscrip commands: + +- Lines (`L`), rectangles (`R`/`B`), circles (`C`/`G`) +- Ellipses (`O`/`E`/`o`/`V`), ellipse arcs +- Polygons (`P`/`F`/`p`) +- Text placement (`@`) +- Color selection (`c`) + +Unsupported commands are silently ignored so partial output is always shown. + +## Supported RIPscrip Commands + +| Command | Description | +|---------|-------------| +| `cNN` | Set current color (0–15) | +| `Lxxxx` | Draw line between two points | +| `Rxxxx` | Draw outline rectangle | +| `Bxxxx` | Draw filled rectangle | +| `Cxxxxxx` | Draw outline circle | +| `Gxxxxxx` | Draw filled circle | +| `Oxxxxxxxx` | Draw outline ellipse or ellipse arc | +| `Exxxxxxxx` | Draw filled ellipse (bounding box) | +| `oxxxxxxxx` | Draw filled ellipse (center + radii) | +| `Vxxxxxxxx` | Draw outline ellipse (alternate form) | +| `P...` | Draw polygon outline | +| `F...` / `p...` | Draw filled polygon | +| `@xxxxText` | Place text at coordinate | + +Coordinates are encoded in base-36 (two characters per axis value). + +## Coordinate System + +The default RIPscrip canvas is 640×350 pixels, matching the EGA screen resolution used by most RIP-capable BBS terminals of the era. The base-36 coordinate encoding maps to this space. + +## Notes + +- RIPscrip rendering only activates for echomail messages, not netmail. +- Messages containing RIPscrip will show the graphical rendering in place of the raw message text. +- The RIPterm.js vendor library must be present in `public_html/vendor/riptermjs/` for client-side rendering to work. diff --git a/docs/Sixel_Support.md b/docs/Sixel_Support.md new file mode 100644 index 000000000..2f9c2699b --- /dev/null +++ b/docs/Sixel_Support.md @@ -0,0 +1,80 @@ +# Sixel Support + +BinktermPHP renders DEC Sixel graphics embedded in messages and uploaded as files. + +## Overview + +Sixel is a bitmap graphics format developed by Digital Equipment Corporation (DEC) for VT240/VT340 terminals. It encodes raster images as streams of six-pixel-tall columns using printable ASCII characters, and is still widely supported by modern terminals (xterm, iTerm2, mlterm, etc.). BinktermPHP decodes and renders sixel data entirely in the browser using an HTML5 `` element. + +## Where Sixel Renders + +### In Messages (Echomail & Netmail) + +When viewing an echomail or netmail message, the message reader scans the body for embedded sixel data (sequences beginning with `ESC P` / `ESC P ... q`). If found, the sixel segments are rendered to canvas inline with any surrounding plain text. + +The `renderSixelChunks()` function handles mixed content — a message may contain both plain text sections and one or more sixel image blocks, all rendered in order. + +### In File Areas + +Files with `.six` or `.sixel` extensions are automatically previewed as sixel images in the file browser. The preview renders the file content to a canvas element in the file detail panel. + +## Supported Features + +The sixel decoder in `public_html/js/sixel.js` supports: + +- **256-color palette** — default VT340 palette for registers 0–15, remainder default to black until defined by the stream +- **HLS and RGB color definition** (`#n;2;r;g;b` and `#n;1;h;l;s`) +- **Repeat introducer** (`!count char`) for run-length encoded rows +- **Carriage return** (`$`) and next-row (`-`) control characters +- **Raster attributes** (`"Pan;Pad;Ph;Pv`) for aspect ratio and canvas size hints +- **Transparent background** — background pixels default to transparent + +## Default Palette + +The first 16 color registers use the VT340 palette: + +| Register | Color | +|----------|-------| +| 0 | Black | +| 1 | Blue | +| 2 | Red | +| 3 | Green | +| 4 | Magenta | +| 5 | Cyan | +| 6 | Yellow | +| 7 | Gray 50% | +| 8 | Gray 33% | +| 9 | Light Blue | +| 10 | Light Red | +| 11 | Light Green | +| 12 | Light Magenta | +| 13 | Light Cyan | +| 14 | Light Yellow | +| 15 | White | + +Registers 16–255 default to opaque black until redefined by the stream. + +## Public JS API + +The sixel renderer exposes these functions globally: + +```javascript +// Returns true if the string contains a sixel DCS sequence +looksLikeSixel(text) + +// Decodes and renders sixel data, returns an HTMLCanvasElement or null +renderSixelToCanvas(sixelData) + +// Renders mixed text+sixel content into a container element +// textChunks are rendered using the provided renderTextFn callback +renderSixelChunks(container, rawText, renderTextFn) + +// Renders a sixel file preview into a jQuery container +renderSixelFilePreview($container, text) +``` + +## Notes + +- Sixel rendering is entirely client-side — no server processing is required. +- Very large sixel images may take a moment to decode depending on resolution and color depth. +- The canvas element scales with the container; actual pixel dimensions are determined by the sixel stream's raster attributes or by the decoded content size. diff --git a/docs/UPGRADING_1.8.7.md b/docs/UPGRADING_1.8.7.md new file mode 100644 index 000000000..8f07e1714 --- /dev/null +++ b/docs/UPGRADING_1.8.7.md @@ -0,0 +1,1974 @@ +# Upgrading to 1.8.7 + +⚠️ Make sure you've made a backup of your database and files before upgrading. + +⏳ This upgrade rebuilds trigram indexes on the message tables. On large +message databases the migration step may take several minutes or more. The +upgrade will appear to pause — this is normal. Do not interrupt it. + +## Table of Contents + +- [Summary of Changes](#summary-of-changes) +- [Enhanced Message Search](#enhanced-message-search) + - [Search Reindexing](#search-reindexing) +- [RIPscrip Detection and Rendering](#ripscrip-detection-and-rendering) +- [Database Statistics Page](#database-statistics-page) +- [Credits System Updates](#credits-system-updates) +- [Database Performance Improvements](#database-performance-improvements) +- [Nodelist Map](#nodelist-map) +- [Message Reader Improvements](#message-reader-improvements) +- [Echomail Reader Navigation](#echomail-reader-navigation) +- [Echomail Info Bar](#echomail-info-bar) +- [Gemini File Areas](#gemini-file-areas) +- [FREQ Enhancements](#freq-enhancements) +- [Nodelist Enhancements](#nodelist-enhancements) +- [Node Address Links](#node-address-links) +- [Outbound FREQ (File Request)](#outbound-freq-file-request) +- [Crashmail Logging and Packet Preservation](#crashmail-logging-and-packet-preservation) +- [File Area Subfolder Navigation](#file-area-subfolder-navigation) +- [File Preview](#file-preview) + - [ANSI Art Rendering](#ansi-art-rendering) + - [MOD Tracker Preview](#mod-tracker-preview) + - [PETSCII / PRG Rendering](#petscii--prg-rendering) + - [D64 Disk Image Preview](#d64-disk-image-preview) + - [C64 Emulator](#c64-emulator) + - [RIPscrip File Preview](#ripscrip-file-preview) + - [Sixel Graphics](#sixel-graphics) + - [ZIP File Browser](#zip-file-browser) + - [Shared File Preview](#shared-file-preview) + - [Maximize Button](#file-preview-maximize-button) +- [ISO-Backed File Areas](#iso-backed-file-areas) + - [Behaviour](#behaviour) + - [Creating an ISO area](#creating-an-iso-area) + - [Import preview](#import-preview) + - [Catalogue formats](#catalogue-formats) + - [Subfolder navigation](#subfolder-navigation) + - [Importing files (CLI)](#importing-files-cli) + - [Migration](#migration) +- [File Area Comments](#file-area-comments) +- [LovlyNet Subscriptions](#lovlynet-subscriptions) +- [Global File Search](#global-file-search) +- [Page Position Memory](#page-position-memory) +- [Netmail Attachment Improvements](#netmail-attachment-improvements) +- [BinkP Inbound File Collision Handling](#binkp-inbound-file-collision-handling) +- [Echo Area Management Improvements](#echo-area-management-improvements) +- [Comment Echo Area Dropdown Grouping](#comment-echo-area-dropdown-grouping) +- [File Upload Filename Sanitization](#file-upload-filename-sanitization) +- [Public File Areas](#public-file-areas) +- [Echo List Network Filter](#echo-list-network-filter) +- [BinkP Status Page Improvements](#binkp-status-page-improvements) + - [Log Search](#log-search) + - [Advanced Log Search](#advanced-log-search) + - [Kept Packets Viewer](#kept-packets-viewer) + - [Faster Poll Session Termination](#faster-poll-session-termination) +- [Bug Fixes](#bug-fixes) + - [Crashmail AKA Selection](#crashmail-aka-selection) + - [Maximized Message Reader Gap](#maximized-message-reader-gap) + - [Subscription Toggle in Echomail Reader](#subscription-toggle-in-echomail-reader) + - [BinkP Filenames with Spaces](#binkp-filenames-with-spaces) + - [TIC File Password Field](#tic-file-password-field) + - [File Comment Tearline Trimming](#file-comment-tearline-trimming) +- [Footer Registration Display](#footer-registration-display) +- [Premium Features and Registration](#premium-features-and-registration) + - [Registration Badge on Admin Dashboard](#registration-badge-on-admin-dashboard) + - [Branding Controls](#branding-controls) + - [Message Templates](#message-templates) + - [Economy Viewer Now Requires Registration](#economy-viewer-now-requires-registration) + - [Referral Analytics](#referral-analytics) + - [Custom Login and Registration Splash Pages](#custom-login-and-registration-splash-pages) + - [How to Register](#how-to-register) +- [Netmail Forwarding to Email](#netmail-forwarding-to-email) +- [Echomail Digest](#echomail-digest) +- [Shared File Preview for Unauthenticated Visitors](#shared-file-preview-for-unauthenticated-visitors) +- [Telnet and SSH File Area Fixes](#telnet-and-ssh-file-area-fixes) +- [Admin Menu Reorganization](#admin-menu-reorganization) +- [QWK/QWKE Offline Mail](#qwkqwke-offline-mail) +- [Advertising System](#advertising-system) +- [In-App Documentation Browser](#in-app-documentation-browser) +- [File Area Rules Visual Editor](#file-area-rules-visual-editor) +- [Service Worker PWA Deadlock Fix](#service-worker-pwa-deadlock-fix) +- [Upgrade Instructions](#upgrade-instructions) + - [From Git](#from-git) + - [Using the Installer](#using-the-installer) + - [After Upgrading: Clear Browser Cache](#after-upgrading-clear-browser-cache) + +## Summary of Changes + +**Message Reader** +- Kludge lines are now hidden by default and toggled via a small icon button in + the modal header toolbar. +- A print button opens the message in a clean popup window for printing. +- FTN node addresses in From:/To: fields are now clickable links to the nodelist + node view page. +- Echomail reader now transparently loads the next page when reaching the end of + a page. When the last message in an echo is reached, a prompt offers to + continue to the next subscribed echo with unread messages. +- Fixed: sticky message reader header was transparent in the default theme, + allowing message body text to bleed through. +- Fixed: maximized message reader had a visible gap on the left and top edges + due to Bootstrap's scrollbar-compensation padding. The modal now fills the + full viewport when maximized. +- Fixed: end-of-echo "next unread" prompt always showed "no more unread + messages" due to a tag comparison bug with `@domain` suffixes. +- Fixed: "Go to next echo" navigated to the wrong area (no messages shown) + because the bare tag was passed instead of `TAG@domain`. +- Message list pagination now shows first page, context window around the + current page, and last page with ellipsis gaps, so the total page count + is always visible. + +**Echomail / Netmail** +- Last-visited page is remembered per echo area (including All Messages) and + for the netmail inbox, and restored automatically on return. +- New **QWK/QWKE offline mail** feature: download a QWK packet of all new + netmail and echomail, read and reply offline, then upload the resulting REP + packet to post replies. Supports both standard QWK (MultiMail, OLX, etc.) + and QWKE extended format with full FidoNet metadata. + +**Echomail** +- An info bar now appears above the message list when an echo area is selected, + showing the area tag, domain, and description alongside Subscribe/Unsubscribe + and Post Message buttons. +- The echomail page title header has been replaced by the info bar. +- Advanced message search with per-field filtering (poster name, subject, body) + and date range support. +- Search performance significantly improved via trigram GIN indexes. +- RIPscrip messages are now detected in the echomail reader and rendered inline + using the built-in RIP renderer. + +**Netmail** +- Fixed: crashmail FILE_ATTACH netmails sent the staged path as the attachment + filename instead of the actual filename from the subject line. + +**File Areas** +- File area browser shows **Gemini** and **FREQ** capability badges next to each + area name, along with the area description. +- Users now have a **My Files** entry in the sidebar giving direct access to their + private file area. +- Virtual subfolder navigation within file areas, with named folders for netmail + attachments and FREQ responses. +- Clicking a filename in the file browser now opens an inline preview: images + display in a lightbox-style modal, video and audio play directly in the browser, + text and NFO files (including CP437-encoded ANSI art) render in a scrollable + panel. Unknown file types prompt a download. Navigation arrows and keyboard + shortcuts cycle through files without closing the modal. +- `.mod` tracker music files now play inline in the preview modal with + play/pause, stop, and volume controls. MOD files inside ZIP archives can be + previewed the same way. +- `.ans` files render inline in the preview modal using the ANSI decoder. +- `.six` and `.sixel` files render inline as pixel-accurate canvas images using + the built-in sixel decoder. Sixel sequences embedded directly in echomail and + netmail message bodies are also detected and rendered inline. +- `.prg` files and ZIP bundles containing `.prg` files render using the C64 + screen RAM decoder with the exact C64 16-colour palette. Multi-file bundles + show a gallery with previous/next navigation arrows. +- Shared file link pages (`/shared/file/…`) now display the same inline preview + as the file browser, so recipients can view images, read text/NFO files, and + see ANSI or PETSCII art without logging in. +- Netmail attachment delivery now stores a copy in the sender's private area as + well as the recipient's. +- Fixed: TIC file import with **Replace Existing Files** enabled was blocked by + the duplicate content hash check when the incoming file had the same content as + the file it was meant to replace. +- File areas can now be published to the Gemini capsule server. Gemini clients + can browse area listings and download files directly over the Gemini protocol. +- **ISO-backed file areas** — a file area can now be backed by a CD/DVD ISO + image, a physical CD/DVD drive, or any readable directory. The sysop enters + the mount point path in the file area properties. + Files are imported from the ISO's directory tree using `FILES.BBS`, + `DESCRIPT.ION`, `00INDEX.TXT`, `00_INDEX.TXT` (Simtel block format), or + `INDEX.TXT` catalogues. ISO areas are read-only — uploads, renames, and + moves are blocked; only descriptive information (short/long description, + subfolder labels) can be edited, and those edits are stored in the database. The directory tree is exposed as browsable subfolders + with editable labels. A preview modal lets sysops review and customise the + import before committing. ZIP files inside the ISO show `FILE_ID.DIZ` in the + preview modal. Partial imports (selecting child directories without their + parent) are supported; ancestor directories are created automatically so + navigation remains intact. +- **Global file search** — a search box in the file browser sidebar searches + filename and description across all accessible file areas. Results include + file size, upload date, a file-info button, and a direct link to the area + containing each result. +- Spaces in uploaded filenames are now replaced with underscores at upload time + so filenames are always compatible with FTN file transfer protocols. +- `.d64` Commodore 64 floppy disk images now render a PRG gallery in the + preview modal, showing all closed PRG files found on the disk with the disk + name displayed as a header. +- A **Run on C64** button appears on every C64 content preview — rendered PRGs + (as an icon in the nav bar), machine-code PRGs (as a fallback button that + launches the emulator inline), and PETSCII stream (`.seq`) files (in a bar + below the rendered art). Clicking it loads a jsc64-based C64 emulator + directly inside the preview panel without leaving the page. +- **File area comments** — each file area can now be linked to an echomail echo + area that serves as a comment thread for its files. Users can leave threaded + comments on individual files directly from the file detail panel and the file + preview modal. Comments are stored as standard FTN echomail messages with a + `^AFILEREF` kludge line, so they are visible to other FTN nodes that carry + the same echo. A back-reference banner appears in the echomail reader when + viewing a comment, linking back to the file it refers to. LovlyNet sysops + should link their file areas to the `LVLY_FILECHAT` echo area. +- **RIPscrip file preview** — `.rip` files now render inline in the preview + modal using the server-side `RipScriptRenderer`. RIP files inside ZIP + archives are also supported and shown as a gallery. +- **ZIP file browser** — opening a ZIP file in the preview modal now shows a + browsable listing of all entries. Previewable entries (images, video, audio, + MOD tracker music, text/NFO, ANSI, RIPscrip, PETSCII/PRG) open inline; all + entries have a download button. Entries using legacy DOS compression methods + (implode, shrink) are flagged with a `legacy` badge and extracted via + external archive tools on the server, falling back to a graceful notice if + extraction is unavailable. +- **Public file areas** *(registered feature)* — individual file areas can be + flagged as public, allowing unauthenticated visitors to browse and download + files without a BBS account. An optional index page (`/public-files`) lists + all public areas and can be enabled from BBS Settings. + +**LovlyNet** +- New admin page **LovlyNet Subscriptions** (`/admin/lovlynet`) shows all + available echo and file areas on LovlyNet with subscription status and + one-click subscribe/unsubscribe toggles. Credentials are read from + `config/lovlynet.json`. + +**Nodelist** +- New interactive map tab powered by Leaflet, with network colour coding and marker + clustering. Nodes are geocoded from their location field. +- Node view page shows an interactive dark map when the node has geocoded + coordinates. +- Nodelist search supports a multi-select flag filter to narrow results by + nodelist flags (CM, IBN, INA, FREQ, etc.). +- Flag badges and flag filter dropdown now show plain-English descriptions for + all standard flags. + +**BinkP / Crashmail** +- BinkP now advertises the closest network AKA when connecting to a node that is + not a configured uplink. +- Crashmail delivery now writes structured logs to `data/logs/crashmail.log` and + respects the **preserve sent packets** setting. +- Experimental FREQ support (see [FREQ Enhancements](#freq-enhancements)). +- Fixed: filenames containing spaces were sent unquoted in `M_FILE` commands, + causing remote binkd systems to misparse the command and reject the transfer. + Spaces are now replaced with underscores on the wire. + +**User Interface** +- The "Registered to" line in the page footer is now displayed inline with the + "Powered by BinktermPHP" line (e.g. *Powered by BinktermPHP 1.8.7 - Registered + to My BBS*) and no longer shows a separate badge icon. + +**Echo List** +- The `/echolist` page now includes a network filter dropdown. Select one or + more networks (including Local) to limit the listing to those networks only. + Selecting nothing shows all networks, matching the existing behaviour. + +**Admin** +- New **Database Statistics** page (`/admin/database-stats`) showing size and + growth, activity metrics, query performance, replication status, maintenance + health, and index health. +- Configurable file upload and download credit costs/rewards in the **Credits + System Configuration** page. +- Database performance improvements: new indexes on `mrc_outbound`, `users`, + `shared_messages`, and `saved_messages`. Chat notification polling rewritten to + use the primary key index instead of a full table count. +- Echo area management table now has sortable column headers and a **Subs** + column showing the subscriber count per area. +- The **Comment Echo Area** dropdown in the file area editor now lists echo areas + from the same network first, with a divider separating them from the rest. +- Admin menu reorganized: new **Analytics** and **Community** submenus; Auto + Feed moved into Area Management. +- New **Sharing** admin page (`/admin/sharing`) under **Admin → Analytics** + listing active shared messages and shared files sorted by view count, with + separate tabs for each. + +**Advertising** +- Legacy `bbs_ads/` ANSI ads are imported into a new database-backed ad library + and enabled by default. +- New **Advertisements** admin page for uploading, editing, previewing, tagging, + and managing ANSI ads. +- Dashboard advertising now uses the managed library with per-session rotation + and keyboard/arrow navigation. +- New **Ad Campaigns** admin page for schedule-based echomail ad posting with + per-target subject templates, weighted ad assignment, and post history. +- `binkp_scheduler` now processes due ad campaigns automatically. + +**Premium / Registration** +- Registration status row added to the admin dashboard. +- Registered sysops can now set custom footer text and hide the "Powered by + BinktermPHP" attribution line from the site footer. +- **Message Templates** — compose form now includes a Templates button for + registered installations. Save and reuse subject/body templates, filterable + by message type (netmail, echomail, or both). +- **Economy Viewer** now requires a valid license. +- **Referral Analytics** — new premium admin page showing top referrers, recent + signups, bonus credits earned, and summary totals. +- Admin licensing page now shows a **Why Register?** panel explaining the value + of registration, with a **How to Register** modal that renders `REGISTER.md`. +- **Custom splash pages** — registered sysops can configure custom Markdown + content that appears above the login and registration forms. +- **Netmail forwarding to email** — users can opt in to have incoming netmail + forwarded to their email address, including file attachments. + +**Admin** +- New **Documentation Browser** (`/admin/docs`) renders the `docs/` Markdown + files directly in the admin panel, with a curated index and inter-document + navigation. +- The **File Area Rules** editor now has a full visual interface (add/edit/delete + rules via a form modal, clone rules, toggle enabled/disabled) while keeping + the raw JSON editor as a second tab. An inline **pattern tester** shows which + filenames from the actual area match the rule's regex, and accepts free-text + input for quick manual testing. + +**Service Worker** +- Fixed a PWA white-screen deadlock in Microsoft Edge that occurred when a PWA + window and a regular browser window were open simultaneously. `caches.open()` + now times out after 3 seconds and falls back to direct network requests so the + page loads even when the Cache Storage lock is held by another process. + +**Telnet / SSH** +- Telnet file area browser now supports virtual subfolders. +- Telnet file area listing now shows all areas (pagination was previously capped + at one page). +- ISO-backed file areas can now be downloaded via ZMODEM over telnet. +- SSH server now correctly associates sessions with the authenticated user's account. + +**Shared Files** +- File preview on shared file pages now works for visitors who are not logged in, + including PETSCII/PRG rendering and the Run on C64 emulator button. + +## 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. + +## RIPscrip Detection and Rendering + +The echomail reader now detects RIPscrip content automatically and renders it +inline in the message modal instead of showing the raw RIP command stream. + +Detection is based on RIP command lines beginning with `!|` plus recognised +RIP drawing/text opcodes used by the bundled renderer. Messages that do not +match that command structure continue to use the existing ANSI/PETSCII/plain +text rendering path. + +No database migration is required for this feature. Existing messages are +eligible automatically after upgrade because detection happens when the message +is viewed. + +## Database Statistics Page + +A new admin page at `/admin/database-stats` provides a comprehensive view of +PostgreSQL internals, organized into six tabs: + +- **Size & Growth** — total database size, top tables and indexes by size, dead + tuple bloat estimates. +- **Activity** — active connections vs. maximum, cache hit ratio (warns if below + 99%), transaction counts, and tuple insert/update/delete totals. +- **Query Performance** — long-running queries, slowest and most-called queries + via `pg_stat_statements` (if installed), current lock waits, and cumulative + deadlock count. +- **Replication** — replication sender status with lag bytes, WAL receiver info + for replicas. +- **Maintenance** — per-table vacuum and analyze timestamps, dead tuple counts, + and a warning banner for tables that may need attention. +- **Index Health** — unused indexes, potentially redundant indexes (same column + set), and index vs. sequential scan ratios per table. + +A database size summary and link to this page appear on the admin dashboard. + +For query performance data, install the `pg_stat_statements` extension: + +```sql +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; +``` + +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 +``` + +## 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. + +## Nodelist Map + +The nodelist page now includes a **Map** tab alongside the existing list view. +Nodes are plotted on an interactive Leaflet map with marker clustering to keep +dense areas readable. + +**Key features:** + +- Nodes with the same system name (across multiple networks) are grouped into a + single marker. The popup shows all networks the system belongs to, including + the FTN address and a "Send Netmail" link. +- Markers are colour coded by the network the node belongs to. Systems on + multiple networks use a distinct colour. A legend is displayed in the + bottom-right corner of the map. +- Map data loads lazily — only fetched when the Map tab is first opened. + +**Geocoding:** + +Coordinates are populated by a new CLI script: + +```bash +php scripts/geocode_nodelist.php +``` + +Options: +- `--limit=N` — process at most N nodes (default: all pending) +- `--force` — re-geocode nodes that already have coordinates +- `--dry-run` — show what would be geocoded without making any changes + +The script calls the Nominatim geocoding API (rate-limited to one request per +second) and caches results permanently in the `geocode_cache` +table so the same location string is never looked up twice. + +Run it once after upgrading to seed initial coordinates, then add it to cron +to pick up newly imported nodes: + +``` +0 1 * * 6 php /path/to/scripts/geocode_nodelist.php +``` + +Geocoding requires the `BBS_DIRECTORY_GEOCODING_ENABLED` environment variable +to be `true` (the default). See `.env.example` for optional tuning variables +(`BBS_DIRECTORY_GEOCODER_EMAIL`, `BBS_DIRECTORY_GEOCODER_URL`, +`BBS_DIRECTORY_GEOCODER_USER_AGENT`). + +A new database migration (`v1.11.0.18`) adds `latitude` and `longitude` columns +to the `nodelist` table. This is applied automatically by `setup.php`. + +## Message Reader Improvements + +The kludge lines panel in the echomail and netmail message readers is now +hidden by default. A small **``** icon button in the modal header toolbar +toggles it open and closed — the button highlights when the panel is visible. + +The previous "Show Kludge Lines" button that appeared inside the message body +has been removed. + +A **print button** (printer icon) is also in the toolbar. Clicking it opens +the message in a minimal popup window and triggers the browser print dialog. +The popup closes automatically after printing or cancelling. + +## Gemini File Areas + +File areas can now be exposed via the Gemini capsule server. When a file area +has **Gemini Public** enabled, Gemini clients can browse the area and download +files directly over the Gemini protocol — including binary files such as ZIP +archives, executables, and images. + +**To enable a file area for Gemini access:** + +1. Go to **Admin → Area Management → File Areas** +2. Edit the file area +3. Check **Gemini Public** +4. Save + +Once enabled, the area appears in the Gemini capsule under: + +``` +gemini://your-host/files/AREA_TAG/ +``` + +and on the Gemini home page under a new **File Areas** section. Individual +files are served at: + +``` +gemini://your-host/files/AREA_TAG/filename.zip +``` + +Only files with an approved status are served. Private file areas are never +exposed regardless of this setting. + +A new database migration (`v1.11.0.20`) adds the `gemini_public` column to the +`file_areas` table. This is applied automatically by `setup.php`. + +## FREQ Enhancements + +> **Note:** FREQ support in this release is experimental and sysop-only. +> Compatibility with third-party BinkP implementations varies. + +### Response Delivery + +FREQ responses are now delivered as FILE_ATTACH netmail via two paths: + +1. **Crashmail (direct)** — if the requesting node has a hostname in the + nodelist (IBN/INA flag), BinktermPHP connects directly and delivers the + attachment immediately. No action required from the requesting node. + +2. **Hold directory (reverse crash)** — if the node cannot be reached directly, + the FILE_ATTACH packet and attachment are written to + `data/outbound/hold/
/`. They are delivered during the next binkp + session with that node, whichever side initiates. A notification netmail is + also sent to the requesting node via hub routing to let them know files are + ready. + +The previous approach of queuing raw files in `freq_outbound` for hub-routed +delivery has been removed. Hubs typically strip file attachments from forwarded +netmail, making that approach unreliable. + +`setup.php` creates the `data/outbound/hold/` directory automatically. + +### Outbound FREQ Response Routing + +When `freq_getfile.php` sends a file request, the request is now persisted to a +new `freq_requests_outbound` database table. When the remote node fulfils the +request — whether in the same session or a later one — the received files are +automatically matched against pending requests by node address and filename and +routed to the requesting user's private file area. + +This means FREQ responses are handled correctly across all session types: +inbound sessions (remote connects to deliver), outbound polls, and same-session +delivery. Only files whose names exactly match a requested filename are routed; +all other received files (netmail attachments, packets, etc.) are left in +`data/inbound/` untouched. + +A new database migration (`v1.11.0.24`) creates the `freq_requests_outbound` +table. This is applied automatically by `setup.php`. + +### BinkP AKA Selection Fix + +When connecting to a node that is not a configured uplink, BinktermPHP now +selects the uplink whose network covers the destination address and advertises +that uplink's `me` address in `M_ADR`. This ensures the remote system +identifies you by the correct AKA rather than your primary zone address. + +This fix now applies to **crashmail delivery** as well. Previously, crashmail +sessions created a raw BinkP session without an uplink context, causing the +primary AKA to be advertised to all remote hosts regardless of network. This +resulted in "Bad password" rejections when delivering to nodes that are uplinks +for one network but not for the zone associated with your primary address. + +## Nodelist Enhancements + +### Flag Filter + +The nodelist search page now includes a **multi-select flag filter**. Select one +or more flags (CM, IBN, INA, FREQ, MO, etc.) to narrow the results to nodes +that carry all of the chosen flags. + +### Node View Map + +The nodelist node detail page now includes a dark interactive map below the +info panels when the node has geocoded coordinates. If coordinates are missing, +a notice is shown with instructions to run `scripts/geocode_nodelist.php`. + +## Node Address Links + +FTN node addresses displayed in message headers are now clickable links to the +nodelist node view page: + +- **Echomail** — From: address +- **Netmail** — From: and To: addresses (both in the message reader and in the + folder list rows) + +## Outbound FREQ (File Request) + +### freq_getfile.php + +A new CLI script allows you to request files from a remote binkp system: + +```bash +php scripts/freq_getfile.php 1:123/456 ALLFILES +php scripts/freq_getfile.php 1:123/456 ALLFILES FILES --password=secret +php scripts/freq_getfile.php 1:123/456 ALLFILES --hostname=bbs.example.com --port=24554 +``` + +The script resolves the hostname automatically from the nodelist or binkp zone +DNS. Received files are stored in your private file area and are visible in the +file browser under **My Files → FREQ Responses**. See [CLI.md](CLI.md) for the +full option reference. + +### Nodelist File Request Dialog + +The file request dialog on the nodelist node view page now includes **AllFix** +as a selectable addressee alongside FileFix, FileMgr, FileReq, and Sysop. +AllFix is a file area manager robot name, not a magic filename. + +### ALLFILES.TXT Formatting + +The dynamically generated `ALLFILES.TXT` file listing now uses plain ASCII +(no UTF-8 em dashes) and formats columns dynamically based on the longest +filename in each area. Long descriptions wrap at 80 characters with continuation +lines aligned to the description column. + +## Crashmail Logging and Packet Preservation + +### Structured Log File + +Crashmail delivery now writes to `data/logs/crashmail.log` using the same +structured logger as the binkp server (timestamp, PID, level, message). Previously +all output went only to the PHP error log. + +### Preserve Sent Packets + +When **Preserve Sent Packets** is enabled in the binkp configuration, crashmail +packets are now moved to the preserved sent packets directory on successful +delivery instead of always being deleted. The preserved file is named +`crashmail__.pkt`. + +## Echomail Reader Navigation + +### Transparent Pagination + +When reading messages in an echo and reaching the last message on the current +page, clicking Next automatically loads the next page and opens its first +message — no manual page flipping required. + +### End-of-Echo Prompt + +When the last message in an echo is reached (all pages exhausted), clicking +Next now shows a confirmation panel inside the message reader: + +- **"End of [ECHONAME]"** with a prompt to continue to the next subscribed + echo that has unread messages. +- **Go to [NEXT ECHO]** and **Close** buttons. +- If there are no more unread echoes, the panel says so and offers only Close. + +The next/previous navigation buttons in the modal header now always display +descriptive tooltips ("Next message", "Previous message", "Load next page", +"End of echo"). + +## File Area Subfolder Navigation + +The file browser now supports virtual subfolders within file areas. Subfolders +appear as folder icons in the file listing and are navigable by clicking. A +breadcrumb trail shows the current location and lets you return to the area +root. + +**My Files sidebar entry:** + +Each user with a private file area now sees a **My Files** entry pinned at the +top of the file area sidebar. Clicking it opens their own private area directly, +without navigating the full area list. + +**Built-in subfolders:** + +- **`attachments`** — displayed as *Netmail Attachments*. Netmail file + attachments delivered to a user's private area are stored here automatically. +- **`incoming`** — displayed as *FREQ Responses*. Files received in response + to outbound FREQ requests (`freq_getfile.php`) land here. + +The `subfolder` column on the `files` table stores the virtual path for each +file record. + +A new database migration (`v1.11.0.22`) adds the `subfolder` column. This is +applied automatically by `setup.php`. + +## File Preview + +Clicking a filename in the file browser now opens a preview modal instead of +going straight to a download. + +**Supported types:** + +| Type | Extensions | Behaviour | +|------|-----------|-----------| +| Image | jpg, jpeg, png, gif, webp, svg, bmp, ico, tiff, avif | Displayed inline; click to open full size in a new tab | +| Video | mp4, webm, mov, ogv, m4v | Plays in a `