PHP Classes

File: templates/qwk.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/qwk.twig   Download  
File: templates/qwk.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 48,963 bytes
 

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}QWK Offline Mail - {{ parent() }}{% endblock %} {% block content %} <div class="d-flex justify-content-between align-items-center mb-4"> <h2> <i class="fas fa-envelope-open-text"></i> QWK Offline Mail </h2> <span class="badge bg-secondary fs-6" id="newMessagesBadge"> <i class="fas fa-spinner fa-spin"></i> Loading? </span> </div> <div class="row g-4"> {# ---- Left column: Download + Upload ---- #} <div class="col-lg-7"> {# Download card #} <div class="card mb-4"> <div class="card-header"> <h5 class="mb-0"><i class="fas fa-download me-2"></i>Download QWK Packet</h5> </div> <div class="card-body"> <p class="card-text text-muted mb-3"> Download a QWK packet containing all new messages since your last download. Open it in your offline mail reader, read and reply at your leisure, then upload the resulting <code>.REP</code> file below. </p> <p class="card-text text-muted mb-3"> The packet includes your <strong>Personal Mail</strong> (netmail) and your <strong>selected echo areas</strong>. Use the <i class="fas fa-sliders-h"></i> button on the Conferences panel to customise which areas are included. </p> <div class="mb-3"> <label class="form-label fw-semibold">Packet format</label> <div class="form-check"> <input class="form-check-input" type="radio" name="qwkFormat" id="fmtQwk" value="qwk"> <label class="form-check-label" for="fmtQwk"> <strong>QWK</strong> ? compatible with most offline readers (MultiMail, OLX, etc.) </label> </div> <div class="form-check"> <input class="form-check-input" type="radio" name="qwkFormat" id="fmtQwke" value="qwke"> <label class="form-check-label" for="fmtQwke"> <strong>QWKE</strong> ? extended format with full FidoNet metadata (NeoQWK<sup class="text-warning">Coming Soon</sup>, Synchronet, etc.) </label> </div> </div> <div class="mb-4"> <label class="form-label fw-semibold">Messages per download</label> <select class="form-select form-select-sm w-auto" id="qwkLimit"> <option value="500">500</option> <option value="2500">2,500</option> <option value="5000">5,000</option> <option value="10000">10,000 (max)</option> </select> </div> <div class="d-flex align-items-center gap-3 flex-wrap"> <button id="downloadBtn" class="btn btn-fidonet btn-lg" onclick="doDownload()"> <i class="fas fa-download me-2"></i>Download <span id="bbsIdLabel"></span>.QWK </button> <span class="text-muted small" id="lastDownloadLabel"> <i class="fas fa-clock me-1"></i>Checking last download? </span> </div> <div class="alert alert-info mt-3 d-none" id="pendingNotice"> <i class="fas fa-info-circle me-2"></i> <span id="pendingNoticeText"></span> Download again to get the next batch. </div> </div> </div> {# Upload card #} <div class="card"> <div class="card-header"> <h5 class="mb-0"><i class="fas fa-upload me-2"></i>Upload REP Packet</h5> </div> <div class="card-body"> <p class="card-text text-muted mb-3"> After composing replies in your offline reader, export the reply packet (<code>.REP</code> or <code>.ZIP</code>) and upload it here. Replies will be posted to the appropriate echo areas and your outbound netmail queue automatically. </p> <div class="alert alert-warning d-none" id="noDownloadWarning"> <i class="fas fa-exclamation-triangle me-2"></i> You must download a QWK packet at least once before uploading replies. </div> <form id="repUploadForm" enctype="multipart/form-data"> <div class="mb-3"> <label for="repFile" class="form-label">REP packet file</label> <input class="form-control" type="file" id="repFile" name="rep" accept=".rep,.REP,.zip,.ZIP" disabled> <div class="form-text">Accepts <code>.REP</code> or <code>.ZIP</code> files.</div> </div> <button type="submit" class="btn btn-fidonet" id="uploadBtn"> <i class="fas fa-upload me-2"></i>Upload Replies </button> </form> {# Result area (hidden until upload completes) #} <div id="uploadResult" class="mt-3 d-none"> <div id="uploadResultAlert" class="alert mb-2"></div> <ul id="uploadErrorList" class="list-unstyled small text-danger mb-0 d-none"></ul> </div> </div> </div> <div class="accordion mt-4" id="qwkHelp"> <div class="accordion-item"> <h2 class="accordion-header" id="helpHeading"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#helpBody" aria-expanded="false" aria-controls="helpBody"> <i class="fas fa-question-circle me-2"></i>What is QWK offline mail? </button> </h2> <div id="helpBody" class="accordion-collapse collapse" aria-labelledby="helpHeading" data-bs-parent="#qwkHelp"> <div class="accordion-body text-muted"> <p> <strong>QWK</strong> is an offline mail format from the BBS era (1987â??). Instead of reading messages online, you download a packet, disconnect, read everything in a local reader application, write replies, then reconnect and upload the reply packet. It is efficient, pleasant on slow connections, and supported by a wide range of readers. </p> <p> <strong>QWKE</strong> (QWK Extended) is a backward-compatible extension that carries full FidoNet metadata (MSGID, REPLY, address information) inside the packet so your reader can properly thread replies and address netmail. </p> <p class="mb-0"> Recommended readers: <a href="https://github.com/enckse/neoqwk" target="_blank" rel="noopener">NeoQWK</a> (cross-platform, full QWKE support). OLX and Yarn also work well. </p> </div> </div> </div> <div class="accordion-item"> <h2 class="accordion-header" id="automationHeading"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#automationBody" aria-expanded="false" aria-controls="automationBody"> <i class="fas fa-terminal me-2"></i>{{ t('ui.qwk.basic_auth.title', {}, 'common') }} </button> </h2> <div id="automationBody" class="accordion-collapse collapse" aria-labelledby="automationHeading" data-bs-parent="#qwkHelp"> <div class="accordion-body text-muted"> <p> {{ t('ui.qwk.basic_auth.description', {}, 'common') }} </p> <p> {{ t('ui.qwk.basic_auth.auth', {}, 'common') }} </p> <div class="card mb-3 border-secondary"> <div class="card-header py-2 fw-semibold text-body"> <i class="fas fa-plug me-2"></i>Reader Connection Settings </div> <div class="card-body py-2"> <table class="table table-sm mb-0"> <tbody> <tr> <td class="text-nowrap fw-semibold" style="width:130px">Download URL</td> <td> <div class="input-group input-group-sm"> <input type="text" class="form-control font-monospace" readonly value="{{ site_url }}/qwk/download" id="qwkDownloadUrl"> <button class="btn btn-outline-secondary" type="button" onclick="copyQwkUrl('qwkDownloadUrl', this)" title="Copy"> <i class="fas fa-copy"></i> </button> </div> </td> </tr> <tr> <td class="fw-semibold">Upload URL</td> <td> <div class="input-group input-group-sm"> <input type="text" class="form-control font-monospace" readonly value="{{ site_url }}/qwk/upload" id="qwkUploadUrl"> <button class="btn btn-outline-secondary" type="button" onclick="copyQwkUrl('qwkUploadUrl', this)" title="Copy"> <i class="fas fa-copy"></i> </button> </div> </td> </tr> <tr> <td class="fw-semibold">Username</td> <td class="text-muted small align-middle">Your BBS username</td> </tr> <tr> <td class="fw-semibold">Password</td> <td class="text-muted small align-middle">Your BBS password</td> </tr> </tbody> </table> </div> </div> <p class="mb-2 fw-semibold">{{ t('ui.qwk.basic_auth.download_label', {}, 'common') }}</p> <pre><code>curl -u username:password -o mail.qwk "{{ site_url }}/qwk/download?format=qwk&amp;limit=2500"</code></pre> <p class="mb-2 fw-semibold">{{ t('ui.qwk.basic_auth.upload_label', {}, 'common') }}</p> <pre><code>curl -u username:password -F "rep=@BBSID.REP" "{{ site_url }}/qwk/upload"</code></pre> <p class="mb-2 fw-semibold">{{ t('ui.qwk.basic_auth.response_label', {}, 'common') }}</p> <p>{{ t('ui.qwk.basic_auth.response_description', {}, 'common') }}</p> <pre><code>{ "success": true, "imported": 3, "skipped": 1, "errors": [] }</code></pre> </div> </div> </div> {% if ftp_enabled %} <div class="accordion-item"> <h2 class="accordion-header" id="ftpHeading"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#ftpBody" aria-expanded="false" aria-controls="ftpBody"> <i class="fas fa-network-wired me-2"></i>FTP Access </button> </h2> <div id="ftpBody" class="accordion-collapse collapse" aria-labelledby="ftpHeading" data-bs-parent="#qwkHelp"> <div class="accordion-body text-muted"> <p> The FTP daemon is enabled on this system, so you can also move QWK packets with a normal FTP client. </p> <div class="card mb-3 border-secondary"> <div class="card-header py-2 fw-semibold text-body"> <i class="fas fa-plug me-2"></i>FTP Connection Settings </div> <div class="card-body py-2"> <table class="table table-sm mb-0"> <tbody> <tr> <td class="text-nowrap fw-semibold" style="width:130px">Host</td> <td class="font-monospace">{{ ftp_host ?: 'your-bbs-hostname' }}</td> </tr> <tr> <td class="fw-semibold">Port</td> <td class="font-monospace">{{ ftp_port }}</td> </tr> <tr> <td class="fw-semibold">Username</td> <td class="text-muted small align-middle">Your BBS username</td> </tr> <tr> <td class="fw-semibold">Password</td> <td class="text-muted small align-middle">Your BBS password</td> </tr> </tbody> </table> </div> </div> <p class="mb-2"> Download your packet from: </p> <pre><code>/qwk/download/&lt;BBSID&gt;.QWK</code></pre> <p class="mb-2"> Upload your reply packet to: </p> <pre><code>/qwk/upload/&lt;BBSID&gt;.REP</code></pre> <p class="mb-2"> A <code>.ZIP</code> reply packet can also be uploaded into the same <code>/qwk/upload/</code> directory. </p> <p class="mb-0"> Replace <code>&lt;BBSID&gt;</code> with your configured QWK BBS ID. The download filename on this page uses the same value. </p> </div> </div> </div> {% endif %} </div> </div> {# ---- Right column: Conference status ---- #} <div class="col-lg-5"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0"> <i class="fas fa-list me-2"></i>Conferences <span id="customSelBadge" class="badge bg-info text-dark ms-2 d-none" title="Custom area selection active">custom</span> </h5> <div class="d-flex gap-2"> <button class="btn btn-outline-secondary btn-sm" onclick="toggleAreaPicker()" id="manageBtn" title="Manage conference areas"> <i class="fas fa-sliders-h"></i> </button> <button class="btn btn-outline-secondary btn-sm" onclick="loadStatus()"> <i class="fas fa-sync-alt"></i> </button> </div> </div> <div class="card-body p-0"> <div id="conferenceList"> <div class="p-3 text-center text-muted"> <i class="fas fa-spinner fa-spin me-2"></i>Loading? </div> </div> </div> </div> </div> </div>{# /row #} {# ---- Area picker modal ---- #} <div class="modal fade" id="areaPickerModal" tabindex="-1" aria-labelledby="areaPickerModalLabel" aria-hidden="true"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="areaPickerModalLabel"> <i class="fas fa-sliders-h me-2"></i>QWK Conference Areas </h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <p class="text-muted small mb-3"> Choose which echo areas to include in your QWK packets. Your subscribed areas are shown below. Changes take effect on the next download. </p> <div class="d-flex justify-content-between align-items-center mb-2"> <span class="fw-semibold" id="selCount"></span> <div class="d-flex gap-2"> <button class="btn btn-outline-secondary btn-sm" onclick="selectAll()">All</button> <button class="btn btn-outline-secondary btn-sm" onclick="selectNone()">None</button> <button class="btn btn-outline-secondary btn-sm" onclick="resetToSubscribed()">Reset</button> </div> </div> <div id="areaCheckList" style="max-height:320px;overflow-y:auto;border:1px solid var(--bs-border-color);border-radius:.375rem;padding:.5rem;" class="mb-3"></div> <label class="form-label fw-semibold">Add an area not in your subscriptions</label> <div class="input-group mb-1"> <input type="text" id="areaSearchInput" class="form-control" placeholder="Search by tag or description?" autocomplete="off"> <span class="input-group-text"><i class="fas fa-search"></i></span> </div> <div id="areaSearchResults" class="list-group mb-2 d-none"></div> </div> <div class="modal-footer"> <span id="saveSelStatus" class="text-muted small me-auto"></span> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-primary" onclick="saveSelections()"> <i class="fas fa-save me-1"></i>Save </button> </div> </div> </div> </div> {# <div class="accordion-item"> <h2 class="accordion-header" id="helpHeading"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#helpBody" aria-expanded="false" aria-controls="helpBody"> <i class="fas fa-question-circle me-2"></i>What is QWK offline mail? </button> </h2> <div id="helpBody" class="accordion-collapse collapse" aria-labelledby="helpHeading" data-bs-parent="#qwkHelp"> <div class="accordion-body text-muted"> <p> <strong>QWK</strong> is an offline mail format from the BBS era (1987?). Instead of reading messages online, you download a packet, disconnect, read everything in a local reader application, write replies, then reconnect and upload the reply packet. It is efficient, pleasant on slow connections, and supported by a wide range of readers. </p> <p> <strong>QWKE</strong> (QWK Extended) is a backward-compatible extension that carries full FidoNet metadata (MSGID, REPLY, address information) inside the packet so your reader can properly thread replies and address netmail. </p> <p class="mb-0"> Recommended readers: <a href="https://github.com/enckse/neoqwk" target="_blank" rel="noopener">NeoQWK</a> (cross-platform, full QWKE support). OLX and Yarn also work well. </p> </div> </div> </div> <div class="accordion-item"> <h2 class="accordion-header" id="automationHeading"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#automationBody" aria-expanded="false" aria-controls="automationBody"> <i class="fas fa-terminal me-2"></i>{{ t('ui.qwk.basic_auth.title', {}, 'common') }} </button> </h2> <div id="automationBody" class="accordion-collapse collapse" aria-labelledby="automationHeading" data-bs-parent="#qwkHelp"> <div class="accordion-body text-muted"> <p> {{ t('ui.qwk.basic_auth.description', {}, 'common') }} </p> <p> {{ t('ui.qwk.basic_auth.auth', {}, 'common') }} </p> <div class="card mb-3 border-secondary"> <div class="card-header py-2 fw-semibold text-body"> <i class="fas fa-plug me-2"></i>Reader Connection Settings </div> <div class="card-body py-2"> <table class="table table-sm mb-0"> <tbody> <tr> <td class="text-nowrap fw-semibold" style="width:130px">Download URL</td> <td> <div class="input-group input-group-sm"> <input type="text" class="form-control font-monospace" readonly value="{{ site_url }}/qwk/download" id="qwkDownloadUrl"> <button class="btn btn-outline-secondary" type="button" onclick="copyQwkUrl('qwkDownloadUrl', this)" title="Copy"> <i class="fas fa-copy"></i> </button> </div> </td> </tr> <tr> <td class="fw-semibold">Upload URL</td> <td> <div class="input-group input-group-sm"> <input type="text" class="form-control font-monospace" readonly value="{{ site_url }}/qwk/upload" id="qwkUploadUrl"> <button class="btn btn-outline-secondary" type="button" onclick="copyQwkUrl('qwkUploadUrl', this)" title="Copy"> <i class="fas fa-copy"></i> </button> </div> </td> </tr> <tr> <td class="fw-semibold">Username</td> <td class="text-muted small align-middle">Your BBS username</td> </tr> <tr> <td class="fw-semibold">Password</td> <td class="text-muted small align-middle">Your BBS password</td> </tr> </tbody> </table> </div> </div> <p class="mb-2 fw-semibold">{{ t('ui.qwk.basic_auth.download_label', {}, 'common') }}</p> <pre><code>curl -u username:password -o mail.qwk "{{ site_url }}/qwk/download?format=qwk&amp;limit=2500"</code></pre> <p class="mb-2 fw-semibold">{{ t('ui.qwk.basic_auth.upload_label', {}, 'common') }}</p> <pre><code>curl -u username:password -F "rep=@BBSID.REP" "{{ site_url }}/qwk/upload"</code></pre> <p class="mb-2 fw-semibold">{{ t('ui.qwk.basic_auth.response_label', {}, 'common') }}</p> <p>{{ t('ui.qwk.basic_auth.response_description', {}, 'common') }}</p> <pre><code>{ "success": true, "imported": 3, "skipped": 1, "errors": [] }</code></pre> </div> </div> </div> </div> </div> </div> #} {# ---- Dev tools (only shown when IS_DEV=true) ---- #} <div class="row mt-4 d-none" id="devToolsRow"> <div class="col-12"> <div class="card border-warning"> <div class="card-header bg-warning text-dark"> <h5 class="mb-0"><i class="fas fa-flask me-2"></i>Dev Tools</h5> </div> <div class="card-body"> <p class="card-text text-muted mb-3"> Purge all QWK state for your account (conference pointers, download log, message index, and import hashes) so the next download starts fresh. </p> <button class="btn btn-warning" id="resetBtn" onclick="doReset()"> <i class="fas fa-trash-alt me-2"></i>Reset QWK Data </button> <div id="resetResult" class="mt-3 d-none"> <div id="resetResultAlert" class="alert mb-0"></div> </div> </div> </div> </div> </div> {% endblock %} {% block scripts %} <script> document.addEventListener('DOMContentLoaded', function() { loadStatus(); initUploadForm(); initFormatRadios(); document.getElementById('areaPickerModal')?.addEventListener('hidden.bs.modal', function() { document.getElementById('areaSearchInput').value = ''; document.getElementById('areaSearchResults').classList.add('d-none'); document.getElementById('saveSelStatus').textContent = ''; }); }); function updateUploadAvailability(hasDownloaded) { const fileInput = document.getElementById('repFile'); const warningEl = document.getElementById('noDownloadWarning'); const uploadBtn = document.getElementById('uploadBtn'); if (fileInput) { fileInput.disabled = !hasDownloaded; if (!hasDownloaded) { fileInput.value = ''; } } if (uploadBtn) { uploadBtn.disabled = !hasDownloaded; } if (warningEl) { warningEl.classList.toggle('d-none', hasDownloaded); } } async function loadStatus() { const listEl = document.getElementById('conferenceList'); const badgeEl = document.getElementById('newMessagesBadge'); const lastDlEl = document.getElementById('lastDownloadLabel'); const bbsLabel = document.getElementById('bbsIdLabel'); listEl.innerHTML = '<div class="p-3 text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Loading?</div>'; try { const resp = await fetch('/api/qwk/status'); if (!resp.ok) { throw new Error('HTTP ' + resp.status); } const data = await resp.json(); // Update badge. const total = data.total_new_messages ?? 0; badgeEl.innerHTML = total > 0 ? '<i class="fas fa-envelope me-1"></i>' + total + ' new message' + (total !== 1 ? 's' : '') : '<i class="fas fa-check me-1"></i>No new messages'; badgeEl.className = total > 0 ? 'badge bg-success fs-6' : 'badge bg-secondary fs-6'; // Packet format preference. const fmt = data.format ?? 'qwk'; const radio = document.querySelector('input[name="qwkFormat"][value="' + fmt + '"]'); if (radio) radio.checked = true; updateDownloadHref(fmt); // Limit preference. const limitEl = document.getElementById('qwkLimit'); if (limitEl && data.limit) { // Select the closest option that is <= saved limit. const opts = Array.from(limitEl.options).map(o => parseInt(o.value)); const best = opts.filter(v => v <= data.limit).pop() || opts[0]; limitEl.value = String(best); } // Last download. if (data.last_download) { const dt = new Date(data.last_download + 'Z'); lastDlEl.innerHTML = '<i class="fas fa-clock me-1"></i>Last download: ' + dt.toLocaleString(); updateUploadAvailability(true); } else { lastDlEl.innerHTML = '<i class="fas fa-info-circle me-1"></i>No prior download'; updateUploadAvailability(false); } // Conference table. const conferences = data.conferences ?? []; if (conferences.length === 0) { listEl.innerHTML = '<div class="p-3 text-muted">No conferences available. Subscribe to echo areas first.</div>'; return data; } let html = '<table class="table table-sm table-hover mb-0">' + '<thead><tr><th>#</th><th>Conference</th><th class="text-end">New</th></tr></thead><tbody>'; for (const c of conferences) { const newBadge = c.new_messages > 0 ? '<span class="badge bg-success">' + c.new_messages + '</span>' : '<span class="text-muted">?</span>'; html += '<tr>' + '<td class="text-muted small">' + c.number + '</td>' + '<td>' + escapeHtml(c.name) + (c.is_netmail ? ' <span class="badge bg-secondary" style="font-size:.7em">mail</span>' : '') + '</td>' + '<td class="text-end">' + newBadge + '</td>' + '</tr>'; } html += '</tbody></table>'; listEl.innerHTML = html; // Custom selection badge. const custBadge = document.getElementById('customSelBadge'); if (custBadge) custBadge.classList.toggle('d-none', !data.has_custom_selection); // Dev tools. document.getElementById('devToolsRow').classList.toggle('d-none', !data.is_dev); return data; } catch (err) { listEl.innerHTML = '<div class="p-3 text-danger"><i class="fas fa-exclamation-triangle me-2"></i>Failed to load status: ' + escapeHtml(err.message) + '</div>'; badgeEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i>'; badgeEl.className = 'badge bg-danger fs-6'; } } async function doReset() { const btn = document.getElementById('resetBtn'); const resultDiv = document.getElementById('resetResult'); const alertDiv = document.getElementById('resetResultAlert'); btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Resetting?'; resultDiv.classList.add('d-none'); try { const resp = await fetch('/api/qwk/reset', { method: 'POST' }); const data = await resp.json(); resultDiv.classList.remove('d-none'); if (data.success) { alertDiv.className = 'alert alert-success mb-0'; alertDiv.innerHTML = '<i class="fas fa-check me-2"></i>QWK data reset. Next download will start fresh.'; loadStatus(); } else { alertDiv.className = 'alert alert-danger mb-0'; alertDiv.textContent = data.error ?? 'Reset failed.'; } } catch (err) { resultDiv.classList.remove('d-none'); alertDiv.className = 'alert alert-danger mb-0'; alertDiv.textContent = 'Reset failed: ' + err.message; } finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-trash-alt me-2"></i>Reset QWK Data'; } } function initUploadForm() { const form = document.getElementById('repUploadForm'); const uploadBtn = document.getElementById('uploadBtn'); const resultDiv = document.getElementById('uploadResult'); const alertDiv = document.getElementById('uploadResultAlert'); const errorList = document.getElementById('uploadErrorList'); form.addEventListener('submit', async function(e) { e.preventDefault(); const fileInput = document.getElementById('repFile'); if (!fileInput.files.length) { alertDiv.className = 'alert alert-warning mb-2'; alertDiv.textContent = 'Please select a .REP or .ZIP file first.'; resultDiv.classList.remove('d-none'); errorList.classList.add('d-none'); return; } uploadBtn.disabled = true; uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Uploading?'; resultDiv.classList.add('d-none'); const formData = new FormData(); formData.append('rep', fileInput.files[0]); try { const resp = await fetch('/api/qwk/upload', { method: 'POST', body: formData, }); const data = await resp.json(); resultDiv.classList.remove('d-none'); if (data.imported > 0 || (data.errors && data.errors.length === 0)) { alertDiv.className = 'alert alert-success mb-2'; alertDiv.innerHTML = '<i class="fas fa-check me-2"></i>' + data.imported + ' message' + (data.imported !== 1 ? 's' : '') + ' imported successfully.' + (data.skipped > 0 ? ' (' + data.skipped + ' skipped)' : ''); } else { alertDiv.className = 'alert alert-warning mb-2'; alertDiv.innerHTML = '<i class="fas fa-exclamation-triangle me-2"></i>' + 'No messages were imported.' + (data.skipped > 0 ? ' (' + data.skipped + ' skipped)' : ''); } if (data.errors && data.errors.length > 0) { errorList.innerHTML = data.errors.map(e => '<li><i class="fas fa-times-circle me-1"></i>' + escapeHtml(e) + '</li>').join(''); errorList.classList.remove('d-none'); } else { errorList.classList.add('d-none'); } // Refresh conference status. loadStatus(); fileInput.value = ''; } catch (err) { resultDiv.classList.remove('d-none'); alertDiv.className = 'alert alert-danger mb-2'; alertDiv.innerHTML = '<i class="fas fa-exclamation-triangle me-2"></i>Upload failed: ' + escapeHtml(err.message); errorList.classList.add('d-none'); } finally { uploadBtn.disabled = false; uploadBtn.innerHTML = '<i class="fas fa-upload me-2"></i>Upload Replies'; } }); } let currentDownloadFormat = 'qwk'; function updateDownloadHref(format) { currentDownloadFormat = format; } async function doDownload() { const btn = document.getElementById('downloadBtn'); const origHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Building packet?'; document.getElementById('pendingNotice')?.classList.add('d-none'); const limit = document.getElementById('qwkLimit')?.value ?? '2500'; const params = new URLSearchParams({ format: currentDownloadFormat, limit: limit, }); try { const resp = await fetch('/api/qwk/download?' + params.toString()); if (!resp.ok) { throw new Error('Server error ' + resp.status); } // Derive filename from Content-Disposition or fall back to a sane default. let filename = 'mail.qwk'; const disposition = resp.headers.get('Content-Disposition') || ''; const utf8Match = disposition.match(/filename\*\s*=\s*UTF-8''([^;\n]+)/i); const plainMatch = disposition.match(/filename\s*=\s*(?:"([^"]+)"|([^;\n]+))/i); if (utf8Match && utf8Match[1]) { filename = decodeURIComponent(utf8Match[1].trim()); } else if (plainMatch) { filename = (plainMatch[1] || plainMatch[2] || filename).trim(); } const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // Refresh conferences now that a download has been recorded. loadStatus().then(statusData => { const pending = statusData?.total_new_messages ?? 0; const noticeEl = document.getElementById('pendingNotice'); const noticeTextEl = document.getElementById('pendingNoticeText'); if (noticeEl && noticeTextEl) { if (pending > 0) { noticeTextEl.textContent = pending.toLocaleString() + ' message' + (pending !== 1 ? 's' : '') + ' still pending.'; noticeEl.classList.remove('d-none'); } else { noticeEl.classList.add('d-none'); } } }); } catch (err) { alert(window.t ? window.t('ui.qwk.download_failed_prefix', {}, 'Download failed: ') + err.message : 'Download failed: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = origHtml; } } function initFormatRadios() { document.querySelectorAll('input[name="qwkFormat"]').forEach(function(radio) { radio.addEventListener('change', function() { const format = this.value; updateDownloadHref(format); fetch('/api/qwk/format', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ format: format }) }); }); }); } function copyQwkUrl(inputId, btn) { const val = document.getElementById(inputId)?.value; if (!val) return; navigator.clipboard.writeText(val).then(() => { const orig = btn.innerHTML; btn.innerHTML = '<i class="fas fa-check text-success"></i>'; setTimeout(() => { btn.innerHTML = orig; }, 1500); }); } function escapeHtml(text) { const d = document.createElement('div'); d.appendChild(document.createTextNode(String(text))); return d.innerHTML; } // ?? Area picker ??????????????????????????????????????????????????????????????? let pickerSubscribed = []; // [{id, tag, domain, description}] let pickerSelected = new Set(); // echoarea_id integers let pickerLoaded = false; function toggleAreaPicker() { const modal = new bootstrap.Modal(document.getElementById('areaPickerModal')); if (!pickerLoaded) loadAreaPicker(); modal.show(); } async function loadAreaPicker() { document.getElementById('areaCheckList').innerHTML = '<div class="text-center text-muted py-2"><i class="fas fa-spinner fa-spin"></i></div>'; try { const resp = await fetch('/api/qwk/area-selections'); const data = await resp.json(); pickerSubscribed = data.subscribed || []; if (data.has_custom) { // Pre-tick the saved selection. pickerSelected = new Set(data.selections.map(a => a.id)); // Also ensure any selection IDs not in subscribed are kept (extras). data.selections.forEach(a => { if (!pickerSubscribed.find(s => s.id === a.id)) { pickerSubscribed.push(a); } }); } else { // Default: all subscribed ticked. pickerSelected = new Set(pickerSubscribed.map(a => a.id)); } renderCheckList(); pickerLoaded = true; } catch(e) { document.getElementById('areaCheckList').innerHTML = '<div class="text-danger small py-2">Failed to load areas.</div>'; } } function renderCheckList() { const list = document.getElementById('areaCheckList'); if (!pickerSubscribed.length) { list.innerHTML = '<div class="text-muted small">No subscribed areas.</div>'; updateSelCount(); return; } list.innerHTML = pickerSubscribed.map(a => { const checked = pickerSelected.has(a.id) ? 'checked' : ''; const label = escapeHtml(a.tag.toUpperCase() + (a.domain ? '@' + a.domain.toUpperCase() : '')); const desc = a.description ? ' <span class="text-muted">? ' + escapeHtml(a.description) + '</span>' : ''; return `<div class="form-check form-check-sm"> <input class="form-check-input" type="checkbox" id="ac_${a.id}" value="${a.id}" ${checked} onchange="toggleArea(${a.id}, this.checked)"> <label class="form-check-label small" for="ac_${a.id}">${label}${desc}</label> </div>`; }).join(''); updateSelCount(); } function toggleArea(id, checked) { if (checked) pickerSelected.add(id); else pickerSelected.delete(id); updateSelCount(); } function updateSelCount() { const el = document.getElementById('selCount'); const n = pickerSelected.size; el.textContent = n === 0 ? 'All subscribed (default)' : n + ' area' + (n !== 1 ? 's' : '') + ' selected'; } function selectAll() { pickerSubscribed.forEach(a => pickerSelected.add(a.id)); renderCheckList(); } function selectNone() { pickerSelected.clear(); renderCheckList(); } async function resetToSubscribed() { // Clear custom mode entirely ? revert to all-subscribed default. if (!confirm(window.t('ui.qwk.reset_confirm', {}, 'Reset to all subscribed areas? Your custom selection will be cleared.'))) return; const statusEl = document.getElementById('saveSelStatus'); statusEl.textContent = 'Resetting?'; try { const resp = await fetch('/api/qwk/area-selections', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ echoarea_ids: [], reset: true }), }); const data = await resp.json(); if (data.success) { pickerLoaded = false; bootstrap.Modal.getInstance(document.getElementById('areaPickerModal'))?.hide(); loadStatus(); } else { statusEl.textContent = 'Reset failed.'; } } catch(e) { statusEl.textContent = 'Reset failed.'; } } async function saveSelections() { const statusEl = document.getElementById('saveSelStatus'); statusEl.textContent = 'Saving?'; const ids = [...pickerSelected]; try { const resp = await fetch('/api/qwk/area-selections', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ echoarea_ids: ids }), }); const data = await resp.json(); if (data.success) { pickerLoaded = false; // force reload on next open bootstrap.Modal.getInstance(document.getElementById('areaPickerModal'))?.hide(); loadStatus(); } else { statusEl.textContent = 'Save failed.'; } } catch(e) { statusEl.textContent = 'Save failed.'; } } // Area search (add non-subscribed areas) (function() { let searchTimer = null; document.addEventListener('DOMContentLoaded', function() { const inp = document.getElementById('areaSearchInput'); if (!inp) return; inp.addEventListener('input', function() { clearTimeout(searchTimer); const q = inp.value.trim(); if (q.length < 2) { document.getElementById('areaSearchResults').classList.add('d-none'); return; } searchTimer = setTimeout(() => runAreaSearch(q), 280); }); }); })(); async function runAreaSearch(q) { const resultsEl = document.getElementById('areaSearchResults'); try { const resp = await fetch('/api/qwk/area-search?q=' + encodeURIComponent(q)); const data = await resp.json(); const areas = data.areas || []; if (!areas.length) { resultsEl.innerHTML = '<div class="list-group-item list-group-item-action disabled small text-muted">No areas found.</div>'; } else { resultsEl.innerHTML = areas.map(a => { const label = escapeHtml(a.tag.toUpperCase() + (a.domain ? '@' + a.domain.toUpperCase() : '')); const desc = a.description ? ' ? ' + escapeHtml(a.description) : ''; return `<button type="button" class="list-group-item list-group-item-action small py-1" onclick="addAreaFromSearch(${a.id}, ${JSON.stringify(a)})"> <strong>${label}</strong>${escapeHtml(desc)} </button>`; }).join(''); } resultsEl.classList.remove('d-none'); } catch(e) { resultsEl.classList.add('d-none'); } } function addAreaFromSearch(id, area) { // Add to the picker list if not already there, then tick it. if (!pickerSubscribed.find(a => a.id === id)) { pickerSubscribed.push(area); } pickerSelected.add(id); document.getElementById('areaSearchInput').value = ''; document.getElementById('areaSearchResults').classList.add('d-none'); renderCheckList(); // Scroll the newly added item into view. const el = document.getElementById('ac_' + id); if (el) el.closest('.form-check')?.scrollIntoView({block: 'nearest'}); } </script> {% endblock %}