{% 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&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/<BBSID>.QWK</code></pre>
<p class="mb-2">
Upload your reply packet to:
</p>
<pre><code>/qwk/upload/<BBSID>.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><BBSID></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&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 %}
|