From 40008799cf04ab27a6d2f5ed3b7cb0dad8b287b7 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sat, 2 May 2026 22:53:22 +0200 Subject: [PATCH] Add download feature to fonts page Co-authored-by: Copilot --- src/network/CrossPointWebServer.cpp | 280 ++++++++++++++++++++++++++++ src/network/CrossPointWebServer.h | 2 + src/network/html/FontsPage.html | 169 ++++++++++++++--- 3 files changed, 426 insertions(+), 25 deletions(-) diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index dcc169e1..bc29291a 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -26,6 +26,8 @@ #include "html/SettingsPageHtml.generated.h" #include "html/WelcomePageHtml.generated.h" #include "html/js/jszip_minJs.generated.h" +#include "network/HttpDownloader.h" + namespace { // Folders/files to hide from the web interface file browser @@ -35,6 +37,10 @@ constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; constexpr uint16_t LOCAL_UDP_PORT = 8134; +#ifndef FONT_MANIFEST_URL +#define FONT_MANIFEST_URL "https://github.com/jpirnay/crosspoint-reader/assets/sd-fonts/fonts.json" +#endif + // Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) CrossPointWebServer* wsInstance = nullptr; @@ -203,6 +209,8 @@ void CrossPointWebServer::begin() { // Font management endpoints server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); }); server->on("/api/fonts", HTTP_GET, [this] { handleFontList(); }); + server->on("/api/fonts/manifest", HTTP_GET, [this] { handleFontManifest(); }); + server->on("/api/fonts/download", HTTP_POST, [this] { handleFontDownload(); }); server->on("/api/fonts/upload", HTTP_POST, [this] { handleFontUpload(); }, [this] { handleFontUploadData(); }); server->on("/api/fonts/delete", HTTP_POST, [this] { handleFontDelete(); }); @@ -1411,6 +1419,166 @@ void CrossPointWebServer::handlePostSettings() { // ---- Font Management API ---- +namespace { +struct RemoteManifestFile { + std::string name; + size_t size = 0; +}; + +struct RemoteManifestFamily { + std::string name; + std::string description; + std::vector files; + size_t totalSize = 0; + bool installed = false; + bool hasUpdate = false; +}; + +bool fetchRemoteFontManifest(FontInstaller& installer, std::vector& outFamilies, + std::string& outBaseUrl, std::string& outError) { + static constexpr const char* MANIFEST_TMP = "/fonts_manifest_web.tmp"; + + auto result = HttpDownloader::downloadToFile(FONT_MANIFEST_URL, MANIFEST_TMP, nullptr); + if (result != HttpDownloader::OK) { + outError = "Failed to fetch font manifest"; + Storage.remove(MANIFEST_TMP); + return false; + } + + FsFile manifestFile; + if (!Storage.openFileForRead("WEB", MANIFEST_TMP, manifestFile)) { + outError = "Failed to open downloaded manifest"; + Storage.remove(MANIFEST_TMP); + return false; + } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, manifestFile); + manifestFile.close(); + Storage.remove(MANIFEST_TMP); + if (err) { + outError = "Failed to parse font manifest"; + return false; + } + + const int version = doc["version"] | 0; + if (version != 1) { + outError = "Unsupported manifest version"; + return false; + } + + outBaseUrl = doc["baseUrl"] | ""; + outFamilies.clear(); + + JsonArray familiesArr = doc["families"].as(); + outFamilies.reserve(familiesArr.size()); + + for (JsonObject fObj : familiesArr) { + RemoteManifestFamily family; + family.name = fObj["name"] | ""; + family.description = fObj["description"] | ""; + + for (JsonObject fileObj : fObj["files"].as()) { + RemoteManifestFile file; + file.name = fileObj["name"] | ""; + file.size = static_cast(fileObj["size"] | 0); + family.totalSize += file.size; + family.files.push_back(std::move(file)); + } + + family.installed = installer.isFamilyInstalled(family.name.c_str()); + family.hasUpdate = false; + if (family.installed) { + for (const auto& file : family.files) { + char path[128]; + FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), path, sizeof(path)); + FsFile f; + if (Storage.openFileForRead("WEB", path, f)) { + const size_t actual = static_cast(f.size()); + f.close(); + if (actual != file.size) { + family.hasUpdate = true; + break; + } + } else { + family.hasUpdate = true; + break; + } + } + } + + outFamilies.push_back(std::move(family)); + } + + return true; +} + +bool installRemoteFamily(const RemoteManifestFamily& family, const std::string& baseUrl, FontInstaller& installer, + std::string& outError) { + char liveDir[128]; + char stagingDir[128]; + char backupDir[128]; + snprintf(liveDir, sizeof(liveDir), "%s/%s", SdCardFontRegistry::FONTS_DIR, family.name.c_str()); + snprintf(stagingDir, sizeof(stagingDir), "%s/%s__staging", SdCardFontRegistry::FONTS_DIR, family.name.c_str()); + snprintf(backupDir, sizeof(backupDir), "%s/%s__backup", SdCardFontRegistry::FONTS_DIR, family.name.c_str()); + + if (Storage.exists(stagingDir) && !Storage.removeDir(stagingDir)) { + outError = "Failed to prepare staging area"; + return false; + } + if (!Storage.mkdir(stagingDir)) { + outError = "Failed to create staging area"; + return false; + } + + for (const auto& file : family.files) { + esp_task_wdt_reset(); + + char stagedPath[128]; + snprintf(stagedPath, sizeof(stagedPath), "%s/%s", stagingDir, file.name.c_str()); + const std::string url = baseUrl + file.name; + + auto result = HttpDownloader::downloadToFile(url, stagedPath, nullptr); + if (result != HttpDownloader::OK) { + Storage.removeDir(stagingDir); + outError = std::string("Download failed: ") + file.name; + return false; + } + + if (!installer.validateCpfontFile(stagedPath)) { + Storage.removeDir(stagingDir); + outError = std::string("Invalid font file: ") + file.name; + return false; + } + } + + const bool hadLiveDir = Storage.exists(liveDir); + if (Storage.exists(backupDir) && !Storage.removeDir(backupDir)) { + Storage.removeDir(stagingDir); + outError = "Failed to prepare backup area"; + return false; + } + if (hadLiveDir && !Storage.rename(liveDir, backupDir)) { + Storage.removeDir(stagingDir); + outError = "Failed to replace installed font"; + return false; + } + if (!Storage.rename(stagingDir, liveDir)) { + if (hadLiveDir && Storage.exists(backupDir)) { + Storage.rename(backupDir, liveDir); + } + Storage.removeDir(stagingDir); + outError = "Failed to finalize font install"; + return false; + } + if (Storage.exists(backupDir)) { + Storage.removeDir(backupDir); + } + + return true; +} +} // namespace + void CrossPointWebServer::handleFontsPage() const { sendHtmlContent(server.get(), FontsPageHtml, sizeof(FontsPageHtml)); LOG_DBG("WEB", "Served fonts page"); @@ -1455,6 +1623,118 @@ void CrossPointWebServer::handleFontList() { server->send(200, "application/json", json); } +void CrossPointWebServer::handleFontManifest() { + FontInstaller installer(sdFontSystem.registry()); + installer.refreshRegistry(); + + std::vector families; + std::string baseUrl; + std::string error; + if (!fetchRemoteFontManifest(installer, families, baseUrl, error)) { + JsonDocument errDoc; + errDoc["ok"] = false; + errDoc["error"] = error; + String out; + serializeJson(errDoc, out); + server->send(500, "application/json", out); + return; + } + + JsonDocument doc; + doc["ok"] = true; + doc["baseUrl"] = baseUrl; + JsonArray arr = doc["families"].to(); + for (const auto& family : families) { + JsonObject obj = arr.add(); + obj["name"] = family.name; + obj["description"] = family.description; + obj["installed"] = family.installed; + obj["hasUpdate"] = family.hasUpdate; + obj["totalSize"] = static_cast(family.totalSize); + obj["fileCount"] = static_cast(family.files.size()); + } + String out; + serializeJson(doc, out); + server->send(200, "application/json", out); +} + +void CrossPointWebServer::handleFontDownload() { + String body = server->arg("plain"); + JsonDocument req; + if (deserializeJson(req, body)) { + server->send(400, "application/json", "{\"ok\":false,\"error\":\"Invalid request\"}"); + return; + } + + const bool installAll = req["all"] | false; + const std::string requestedFamily = req["family"] | ""; + if (!installAll && requestedFamily.empty()) { + server->send(400, "application/json", "{\"ok\":false,\"error\":\"Missing family\"}"); + return; + } + + FontInstaller installer(sdFontSystem.registry()); + installer.refreshRegistry(); + + std::vector families; + std::string baseUrl; + std::string error; + if (!fetchRemoteFontManifest(installer, families, baseUrl, error)) { + JsonDocument errDoc; + errDoc["ok"] = false; + errDoc["error"] = error; + String out; + serializeJson(errDoc, out); + server->send(500, "application/json", out); + return; + } + + std::vector targets; + if (installAll) { + for (auto& family : families) { + if (!family.installed || family.hasUpdate) { + targets.push_back(&family); + } + } + } else { + for (auto& family : families) { + if (family.name == requestedFamily) { + targets.push_back(&family); + break; + } + } + if (targets.empty()) { + server->send(404, "application/json", "{\"ok\":false,\"error\":\"Family not found in manifest\"}"); + return; + } + } + + size_t installedCount = 0; + for (auto* family : targets) { + esp_task_wdt_reset(); + if (!installRemoteFamily(*family, baseUrl, installer, error)) { + JsonDocument errDoc; + errDoc["ok"] = false; + errDoc["error"] = error; + errDoc["family"] = family->name; + errDoc["installedCount"] = static_cast(installedCount); + String out; + serializeJson(errDoc, out); + server->send(500, "application/json", out); + return; + } + installedCount++; + } + + installer.refreshRegistry(); + JsonDocument res; + res["ok"] = true; + res["installedCount"] = static_cast(installedCount); + String out; + serializeJson(res, out); + server->send(200, "application/json", out); +} + void CrossPointWebServer::handleFontUploadData() { HTTPUpload& up = server->upload(); diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 78421280..e73dce97 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -113,6 +113,8 @@ class CrossPointWebServer { // Font management handlers void handleFontsPage() const; void handleFontList(); + void handleFontManifest(); + void handleFontDownload(); void handleFontUpload(); void handleFontUploadData(); void handleFontDelete(); diff --git a/src/network/html/FontsPage.html b/src/network/html/FontsPage.html index a3023fcb..cb5b772f 100644 --- a/src/network/html/FontsPage.html +++ b/src/network/html/FontsPage.html @@ -104,6 +104,10 @@ color: white; } .btn-primary:hover { background: var(--accent-hover-color); } + .btn[disabled] { + opacity: 0.5; + cursor: default; + } .upload-form { display: flex; gap: 10px; @@ -137,6 +141,13 @@ font-size: 0.95em; } .notice a { color: inherit; font-weight: 600; } + .toolbar { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 12px; + } @@ -154,6 +165,16 @@

Loading...

+
+

Download Fonts

+
+ + +
+

Catalog not loaded

+
+
+

Upload Font

@@ -176,6 +197,13 @@ return bytes + ' B'; } + function setStatus(id, text, ok) { + const el = document.getElementById(id); + el.className = ok === undefined ? '' : (ok ? 'status-ok' : 'status-err'); + el.style.display = 'block'; + el.textContent = text; + } + async function loadFonts() { try { const res = await fetch('/api/fonts'); @@ -230,10 +258,7 @@ async function deleteFamily(name) { if (!confirm('Delete font family "' + name + '"?')) return; - const status = document.getElementById('status'); - status.className = ''; - status.style.display = 'block'; - status.textContent = 'Deleting ' + name + '...'; + setStatus('status', 'Deleting ' + name + '...'); try { const res = await fetch('/api/fonts/delete', { method: 'POST', @@ -241,19 +266,116 @@ body: JSON.stringify({family: name}) }); if (res.ok) { - status.className = 'status-ok'; - status.textContent = 'Deleted "' + name + '".'; + setStatus('status', 'Deleted "' + name + '".', true); } else { - status.className = 'status-err'; - status.textContent = 'Failed to delete "' + name + '".'; + setStatus('status', 'Failed to delete "' + name + '".', false); } } catch (err) { - status.className = 'status-err'; - status.textContent = 'Delete error: ' + err.message; + setStatus('status', 'Delete error: ' + err.message, false); } await loadFonts(); } + async function loadRemoteManifest() { + const el = document.getElementById('remoteFamilies'); + el.innerHTML = '

Loading catalog...

'; + try { + const res = await fetch('/api/fonts/manifest'); + const data = await res.json(); + if (!res.ok || !data.ok) { + throw new Error(data.error || 'catalog unavailable'); + } + + el.innerHTML = ''; + if (!Array.isArray(data.families) || data.families.length === 0) { + el.innerHTML = '

No remote fonts found

'; + return; + } + + data.families.forEach(f => { + const row = document.createElement('div'); + row.className = 'family'; + + const info = document.createElement('div'); + info.className = 'family-info'; + + const title = document.createElement('h3'); + title.textContent = f.name; + + const meta = document.createElement('span'); + meta.className = 'family-meta'; + const state = f.installed ? (f.hasUpdate ? 'Installed, update available' : 'Installed, up to date') : 'Not installed'; + const details = state + ' - ' + (f.fileCount || 0) + ' file(s), ' + formatSize(f.totalSize || 0); + meta.textContent = f.description ? (f.description + ' - ' + details) : details; + + info.appendChild(title); + info.appendChild(meta); + + const btn = document.createElement('button'); + btn.className = 'btn btn-primary'; + btn.dataset.family = f.name; + btn.textContent = f.installed ? (f.hasUpdate ? 'Update' : 'Reinstall') : 'Download'; + + row.appendChild(info); + row.appendChild(btn); + el.appendChild(row); + }); + + el.querySelectorAll('button[data-family]').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; + await downloadRemoteFamily(btn.dataset.family); + btn.disabled = false; + }); + }); + } catch (err) { + el.innerHTML = '

Failed to load catalog: ' + err.message + '

'; + } + } + + async function downloadRemoteFamily(family) { + setStatus('remoteStatus', 'Downloading "' + family + '"...'); + try { + const res = await fetch('/api/fonts/download', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({family}) + }); + const data = await res.json(); + if (!res.ok || !data.ok) { + throw new Error(data.error || 'download failed'); + } + setStatus('remoteStatus', 'Installed "' + family + '".', true); + await loadFonts(); + await loadRemoteManifest(); + } catch (err) { + setStatus('remoteStatus', 'Install failed for "' + family + '": ' + err.message, false); + } + } + + async function downloadAllRemoteUpdates() { + setStatus('remoteStatus', 'Downloading all available updates...'); + const btn = document.getElementById('downloadAllRemote'); + btn.disabled = true; + try { + const res = await fetch('/api/fonts/download', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({all: true}) + }); + const data = await res.json(); + if (!res.ok || !data.ok) { + throw new Error(data.error || 'download failed'); + } + setStatus('remoteStatus', 'Installed/updated ' + (data.installedCount || 0) + ' family(s).', true); + await loadFonts(); + await loadRemoteManifest(); + } catch (err) { + setStatus('remoteStatus', 'Download-all failed: ' + err.message, false); + } + btn.disabled = false; + } + // Derive family name from a .cpfont filename: take everything before the // last '-' or '_' (separator that precedes the size suffix, e.g. Bookerly_12.cpfont). function familyFromFilename(name) { @@ -293,25 +415,20 @@ const status = document.getElementById('status'); const files = cpfontFilesOnly(document.getElementById('fontFiles').files); if (files.length === 0) { - status.className = 'status-err'; - status.style.display = 'block'; - status.textContent = 'No .cpfont files selected.'; + setStatus('status', 'No .cpfont files selected.', false); return; } const families = files.map(f => sanitizeFamily(familyFromFilename(f.name))); const uniqueFamilies = new Set(families); if (uniqueFamilies.size !== 1) { - status.className = 'status-err'; - status.style.display = 'block'; - status.textContent = 'Selected files belong to multiple families.'; + setStatus('status', 'Selected files belong to multiple families.', false); return; } const family = families[0]; - status.className = ''; - status.style.display = 'block'; + setStatus('status', 'Preparing upload...'); let uploaded = 0; for (const file of files) { @@ -323,27 +440,29 @@ const res = await fetch('/api/fonts/upload', { method: 'POST', body: formData }); const data = await res.json(); if (!data.ok) { - status.className = 'status-err'; - status.textContent = 'Failed on ' + file.name + ': ' + (data.error || 'unknown error'); + setStatus('status', 'Failed on ' + file.name + ': ' + (data.error || 'unknown error'), false); await loadFonts(); return; } } catch (err) { - status.className = 'status-err'; - status.textContent = 'Upload error on ' + file.name + ': ' + err.message; + setStatus('status', 'Upload error on ' + file.name + ': ' + err.message, false); await loadFonts(); return; } uploaded++; } - status.className = 'status-ok'; - status.textContent = 'Uploaded ' + uploaded + ' file' + (uploaded === 1 ? '' : 's') + - ' to family "' + family + '".'; + setStatus('status', 'Uploaded ' + uploaded + ' file' + (uploaded === 1 ? '' : 's') + + ' to family "' + family + '".', true); await loadFonts(); + await loadRemoteManifest(); }); + document.getElementById('refreshManifest').addEventListener('click', loadRemoteManifest); + document.getElementById('downloadAllRemote').addEventListener('click', downloadAllRemoteUpdates); + loadFonts(); + loadRemoteManifest();