mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 23:27:38 +03:00
Merge branch 'master' into marginsettings
This commit is contained in:
commit
6029e24740
@ -25,7 +25,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
|
|||||||
|
|
||||||
## Features & Usage
|
## Features & Usage
|
||||||
|
|
||||||
- [x] EPUB parsing and rendering
|
- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
|
||||||
- [ ] Image support within EPUB
|
- [ ] Image support within EPUB
|
||||||
- [x] Saved reading position
|
- [x] Saved reading position
|
||||||
- [x] File explorer with file picker
|
- [x] File explorer with file picker
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "Epub/parsers/ContainerParser.h"
|
#include "Epub/parsers/ContainerParser.h"
|
||||||
#include "Epub/parsers/ContentOpfParser.h"
|
#include "Epub/parsers/ContentOpfParser.h"
|
||||||
|
#include "Epub/parsers/TocNavParser.h"
|
||||||
#include "Epub/parsers/TocNcxParser.h"
|
#include "Epub/parsers/TocNcxParser.h"
|
||||||
|
|
||||||
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
||||||
@ -80,6 +81,10 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
|||||||
tocNcxItem = opfParser.tocNcxPath;
|
tocNcxItem = opfParser.tocNcxPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!opfParser.tocNavPath.empty()) {
|
||||||
|
tocNavItem = opfParser.tocNavPath;
|
||||||
|
}
|
||||||
|
|
||||||
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
|
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -141,6 +146,60 @@ bool Epub::parseTocNcxFile() const {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Epub::parseTocNavFile() const {
|
||||||
|
// the nav file should have been specified in the content.opf file (EPUB 3)
|
||||||
|
if (tocNavItem.empty()) {
|
||||||
|
Serial.printf("[%lu] [EBP] No nav file specified\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [EBP] Parsing toc nav file: %s\n", millis(), tocNavItem.c_str());
|
||||||
|
|
||||||
|
const auto tmpNavPath = getCachePath() + "/toc.nav";
|
||||||
|
FsFile tempNavFile;
|
||||||
|
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
|
||||||
|
tempNavFile.close();
|
||||||
|
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto navSize = tempNavFile.size();
|
||||||
|
|
||||||
|
TocNavParser navParser(contentBasePath, navSize, bookMetadataCache.get());
|
||||||
|
|
||||||
|
if (!navParser.setup()) {
|
||||||
|
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||||
|
if (!navBuffer) {
|
||||||
|
Serial.printf("[%lu] [EBP] Could not allocate memory for toc nav parser\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (tempNavFile.available()) {
|
||||||
|
const auto readSize = tempNavFile.read(navBuffer, 1024);
|
||||||
|
const auto processedSize = navParser.write(navBuffer, readSize);
|
||||||
|
|
||||||
|
if (processedSize != readSize) {
|
||||||
|
Serial.printf("[%lu] [EBP] Could not process all toc nav data\n", millis());
|
||||||
|
free(navBuffer);
|
||||||
|
tempNavFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(navBuffer);
|
||||||
|
tempNavFile.close();
|
||||||
|
SdMan.remove(tmpNavPath.c_str());
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// load in the meta data for the epub file
|
// load in the meta data for the epub file
|
||||||
bool Epub::load(const bool buildIfMissing) {
|
bool Epub::load(const bool buildIfMissing) {
|
||||||
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
||||||
@ -184,15 +243,31 @@ bool Epub::load(const bool buildIfMissing) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOC Pass
|
// TOC Pass - try EPUB 3 nav first, fall back to NCX
|
||||||
if (!bookMetadataCache->beginTocPass()) {
|
if (!bookMetadataCache->beginTocPass()) {
|
||||||
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
|
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!parseTocNcxFile()) {
|
|
||||||
Serial.printf("[%lu] [EBP] Could not parse toc\n", millis());
|
bool tocParsed = false;
|
||||||
return false;
|
|
||||||
|
// Try EPUB 3 nav document first (preferred)
|
||||||
|
if (!tocNavItem.empty()) {
|
||||||
|
Serial.printf("[%lu] [EBP] Attempting to parse EPUB 3 nav document\n", millis());
|
||||||
|
tocParsed = parseTocNavFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to NCX if nav parsing failed or wasn't available
|
||||||
|
if (!tocParsed && !tocNcxItem.empty()) {
|
||||||
|
Serial.printf("[%lu] [EBP] Falling back to NCX TOC\n", millis());
|
||||||
|
tocParsed = parseTocNcxFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tocParsed) {
|
||||||
|
Serial.printf("[%lu] [EBP] Warning: Could not parse any TOC format\n", millis());
|
||||||
|
// Continue anyway - book will work without TOC
|
||||||
|
}
|
||||||
|
|
||||||
if (!bookMetadataCache->endTocPass()) {
|
if (!bookMetadataCache->endTocPass()) {
|
||||||
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
|
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -12,8 +12,10 @@
|
|||||||
class ZipFile;
|
class ZipFile;
|
||||||
|
|
||||||
class Epub {
|
class Epub {
|
||||||
// the ncx file
|
// the ncx file (EPUB 2)
|
||||||
std::string tocNcxItem;
|
std::string tocNcxItem;
|
||||||
|
// the nav file (EPUB 3)
|
||||||
|
std::string tocNavItem;
|
||||||
// where is the EPUBfile?
|
// where is the EPUBfile?
|
||||||
std::string filepath;
|
std::string filepath;
|
||||||
// the base path for items in the EPUB file
|
// the base path for items in the EPUB file
|
||||||
@ -26,6 +28,7 @@ class Epub {
|
|||||||
bool findContentOpfFile(std::string* contentOpfFile) const;
|
bool findContentOpfFile(std::string* contentOpfFile) const;
|
||||||
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
||||||
bool parseTocNcxFile() const;
|
bool parseTocNcxFile() const;
|
||||||
|
bool parseTocNavFile() const;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 8;
|
constexpr uint8_t SECTION_FILE_VERSION = 9;
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint16_t) +
|
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint32_t);
|
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint32_t);
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||||
@ -30,19 +30,21 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||||
|
const uint16_t viewportHeight) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis());
|
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
||||||
sizeof(extraParagraphSpacing) + sizeof(viewportWidth) + sizeof(viewportHeight) +
|
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
||||||
sizeof(pageCount) + sizeof(uint32_t),
|
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(uint32_t),
|
||||||
"Header size mismatch");
|
"Header size mismatch");
|
||||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||||
serialization::writePod(file, fontId);
|
serialization::writePod(file, fontId);
|
||||||
serialization::writePod(file, lineCompression);
|
serialization::writePod(file, lineCompression);
|
||||||
serialization::writePod(file, extraParagraphSpacing);
|
serialization::writePod(file, extraParagraphSpacing);
|
||||||
|
serialization::writePod(file, paragraphAlignment);
|
||||||
serialization::writePod(file, viewportWidth);
|
serialization::writePod(file, viewportWidth);
|
||||||
serialization::writePod(file, viewportHeight);
|
serialization::writePod(file, viewportHeight);
|
||||||
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
|
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
|
||||||
@ -50,7 +52,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||||
|
const uint16_t viewportHeight) {
|
||||||
if (!SdMan.openFileForRead("SCT", filePath, file)) {
|
if (!SdMan.openFileForRead("SCT", filePath, file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -70,15 +73,17 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
uint16_t fileViewportWidth, fileViewportHeight;
|
uint16_t fileViewportWidth, fileViewportHeight;
|
||||||
float fileLineCompression;
|
float fileLineCompression;
|
||||||
bool fileExtraParagraphSpacing;
|
bool fileExtraParagraphSpacing;
|
||||||
|
uint8_t fileParagraphAlignment;
|
||||||
serialization::readPod(file, fileFontId);
|
serialization::readPod(file, fileFontId);
|
||||||
serialization::readPod(file, fileLineCompression);
|
serialization::readPod(file, fileLineCompression);
|
||||||
serialization::readPod(file, fileExtraParagraphSpacing);
|
serialization::readPod(file, fileExtraParagraphSpacing);
|
||||||
|
serialization::readPod(file, fileParagraphAlignment);
|
||||||
serialization::readPod(file, fileViewportWidth);
|
serialization::readPod(file, fileViewportWidth);
|
||||||
serialization::readPod(file, fileViewportHeight);
|
serialization::readPod(file, fileViewportHeight);
|
||||||
|
|
||||||
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
||||||
extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth ||
|
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
|
||||||
viewportHeight != fileViewportHeight) {
|
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight) {
|
||||||
file.close();
|
file.close();
|
||||||
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
|
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
|
||||||
clearCache();
|
clearCache();
|
||||||
@ -109,8 +114,8 @@ bool Section::clearCache() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint16_t viewportWidth, const uint16_t viewportHeight,
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||||
const std::function<void()>& progressSetupFn,
|
const uint16_t viewportHeight, const std::function<void()>& progressSetupFn,
|
||||||
const std::function<void(int)>& progressFn) {
|
const std::function<void(int)>& progressFn) {
|
||||||
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
||||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||||
@ -166,11 +171,13 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
|
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight);
|
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
|
viewportHeight);
|
||||||
std::vector<uint32_t> lut = {};
|
std::vector<uint32_t> lut = {};
|
||||||
|
|
||||||
ChapterHtmlSlimParser visitor(
|
ChapterHtmlSlimParser visitor(
|
||||||
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight,
|
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
||||||
progressFn);
|
progressFn);
|
||||||
success = visitor.parseAndBuildPages();
|
success = visitor.parseAndBuildPages();
|
||||||
|
|||||||
@ -14,8 +14,8 @@ class Section {
|
|||||||
std::string filePath;
|
std::string filePath;
|
||||||
FsFile file;
|
FsFile file;
|
||||||
|
|
||||||
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth,
|
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||||
uint16_t viewportHeight);
|
uint16_t viewportWidth, uint16_t viewportHeight);
|
||||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@ -28,11 +28,12 @@ class Section {
|
|||||||
renderer(renderer),
|
renderer(renderer),
|
||||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||||
~Section() = default;
|
~Section() = default;
|
||||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth,
|
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||||
uint16_t viewportHeight);
|
uint16_t viewportWidth, uint16_t viewportHeight);
|
||||||
bool clearCache() const;
|
bool clearCache() const;
|
||||||
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint16_t viewportWidth,
|
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||||
uint16_t viewportHeight, const std::function<void()>& progressSetupFn = nullptr,
|
uint16_t viewportWidth, uint16_t viewportHeight,
|
||||||
|
const std::function<void()>& progressSetupFn = nullptr,
|
||||||
const std::function<void(int)>& progressFn = nullptr);
|
const std::function<void(int)>& progressFn = nullptr);
|
||||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -97,7 +97,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
if (strcmp(name, "br") == 0) {
|
if (strcmp(name, "br") == 0) {
|
||||||
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
||||||
} else {
|
} else {
|
||||||
self->startNewTextBlock(TextBlock::JUSTIFIED);
|
self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment);
|
||||||
}
|
}
|
||||||
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
||||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||||
@ -137,6 +137,21 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip soft-hyphen with UTF-8 representation (U+00AD) = 0xC2 0xAD
|
||||||
|
const XML_Char SHY_BYTE_1 = static_cast<XML_Char>(0xC2);
|
||||||
|
const XML_Char SHY_BYTE_2 = static_cast<XML_Char>(0xAD);
|
||||||
|
// 1. Check for the start of the 2-byte Soft Hyphen sequence
|
||||||
|
if (s[i] == SHY_BYTE_1) {
|
||||||
|
// 2. Check if the next byte exists AND if it completes the sequence
|
||||||
|
// We must check i + 1 < len to prevent reading past the end of the buffer.
|
||||||
|
if ((i + 1 < len) && (s[i + 1] == SHY_BYTE_2)) {
|
||||||
|
// Sequence 0xC2 0xAD found!
|
||||||
|
// Skip the current byte (0xC2) and the next byte (0xAD)
|
||||||
|
i++; // Increment 'i' one more time to skip the 0xAD byte
|
||||||
|
continue; // Skip the rest of the loop and move to the next iteration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If we're about to run out of space, then cut the word off and start a new one
|
// If we're about to run out of space, then cut the word off and start a new one
|
||||||
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
|
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
|
||||||
self->partWordBuffer[self->partWordBufferIndex] = '\0';
|
self->partWordBuffer[self->partWordBufferIndex] = '\0';
|
||||||
@ -206,7 +221,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||||
startNewTextBlock(TextBlock::JUSTIFIED);
|
startNewTextBlock((TextBlock::Style)this->paragraphAlignment);
|
||||||
|
|
||||||
const XML_Parser parser = XML_ParserCreate(nullptr);
|
const XML_Parser parser = XML_ParserCreate(nullptr);
|
||||||
int done;
|
int done;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ class ChapterHtmlSlimParser {
|
|||||||
int fontId;
|
int fontId;
|
||||||
float lineCompression;
|
float lineCompression;
|
||||||
bool extraParagraphSpacing;
|
bool extraParagraphSpacing;
|
||||||
|
uint8_t paragraphAlignment;
|
||||||
uint16_t viewportWidth;
|
uint16_t viewportWidth;
|
||||||
uint16_t viewportHeight;
|
uint16_t viewportHeight;
|
||||||
|
|
||||||
@ -46,7 +47,8 @@ class ChapterHtmlSlimParser {
|
|||||||
public:
|
public:
|
||||||
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
|
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
|
||||||
const float lineCompression, const bool extraParagraphSpacing,
|
const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint16_t viewportWidth, const uint16_t viewportHeight,
|
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||||
|
const uint16_t viewportHeight,
|
||||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
||||||
const std::function<void(int)>& progressFn = nullptr)
|
const std::function<void(int)>& progressFn = nullptr)
|
||||||
: filepath(filepath),
|
: filepath(filepath),
|
||||||
@ -54,6 +56,7 @@ class ChapterHtmlSlimParser {
|
|||||||
fontId(fontId),
|
fontId(fontId),
|
||||||
lineCompression(lineCompression),
|
lineCompression(lineCompression),
|
||||||
extraParagraphSpacing(extraParagraphSpacing),
|
extraParagraphSpacing(extraParagraphSpacing),
|
||||||
|
paragraphAlignment(paragraphAlignment),
|
||||||
viewportWidth(viewportWidth),
|
viewportWidth(viewportWidth),
|
||||||
viewportHeight(viewportHeight),
|
viewportHeight(viewportHeight),
|
||||||
completePageFn(completePageFn),
|
completePageFn(completePageFn),
|
||||||
|
|||||||
@ -161,6 +161,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
|||||||
std::string itemId;
|
std::string itemId;
|
||||||
std::string href;
|
std::string href;
|
||||||
std::string mediaType;
|
std::string mediaType;
|
||||||
|
std::string properties;
|
||||||
|
|
||||||
for (int i = 0; atts[i]; i += 2) {
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
if (strcmp(atts[i], "id") == 0) {
|
if (strcmp(atts[i], "id") == 0) {
|
||||||
@ -169,6 +170,8 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
|||||||
href = self->baseContentPath + atts[i + 1];
|
href = self->baseContentPath + atts[i + 1];
|
||||||
} else if (strcmp(atts[i], "media-type") == 0) {
|
} else if (strcmp(atts[i], "media-type") == 0) {
|
||||||
mediaType = atts[i + 1];
|
mediaType = atts[i + 1];
|
||||||
|
} else if (strcmp(atts[i], "properties") == 0) {
|
||||||
|
properties = atts[i + 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,6 +191,15 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
|||||||
href.c_str());
|
href.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EPUB 3: Check for nav document (properties contains "nav")
|
||||||
|
if (!properties.empty() && self->tocNavPath.empty()) {
|
||||||
|
// Properties is space-separated, check if "nav" is present as a word
|
||||||
|
if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) {
|
||||||
|
self->tocNavPath = href;
|
||||||
|
Serial.printf("[%lu] [COF] Found EPUB 3 nav document: %s\n", millis(), href.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,7 @@ class ContentOpfParser final : public Print {
|
|||||||
std::string title;
|
std::string title;
|
||||||
std::string author;
|
std::string author;
|
||||||
std::string tocNcxPath;
|
std::string tocNcxPath;
|
||||||
|
std::string tocNavPath; // EPUB 3 nav document path
|
||||||
std::string coverItemHref;
|
std::string coverItemHref;
|
||||||
std::string textReferenceHref;
|
std::string textReferenceHref;
|
||||||
|
|
||||||
|
|||||||
184
lib/Epub/Epub/parsers/TocNavParser.cpp
Normal file
184
lib/Epub/Epub/parsers/TocNavParser.cpp
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
#include "TocNavParser.h"
|
||||||
|
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
|
||||||
|
#include "../BookMetadataCache.h"
|
||||||
|
|
||||||
|
bool TocNavParser::setup() {
|
||||||
|
parser = XML_ParserCreate(nullptr);
|
||||||
|
if (!parser) {
|
||||||
|
Serial.printf("[%lu] [NAV] Couldn't allocate memory for parser\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
XML_SetUserData(parser, this);
|
||||||
|
XML_SetElementHandler(parser, startElement, endElement);
|
||||||
|
XML_SetCharacterDataHandler(parser, characterData);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
TocNavParser::~TocNavParser() {
|
||||||
|
if (parser) {
|
||||||
|
XML_StopParser(parser, XML_FALSE);
|
||||||
|
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||||
|
XML_SetCharacterDataHandler(parser, nullptr);
|
||||||
|
XML_ParserFree(parser);
|
||||||
|
parser = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t TocNavParser::write(const uint8_t data) { return write(&data, 1); }
|
||||||
|
|
||||||
|
size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
|
||||||
|
if (!parser) return 0;
|
||||||
|
|
||||||
|
const uint8_t* currentBufferPos = buffer;
|
||||||
|
auto remainingInBuffer = size;
|
||||||
|
|
||||||
|
while (remainingInBuffer > 0) {
|
||||||
|
void* const buf = XML_GetBuffer(parser, 1024);
|
||||||
|
if (!buf) {
|
||||||
|
Serial.printf("[%lu] [NAV] Couldn't allocate memory for buffer\n", millis());
|
||||||
|
XML_StopParser(parser, XML_FALSE);
|
||||||
|
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||||
|
XML_SetCharacterDataHandler(parser, nullptr);
|
||||||
|
XML_ParserFree(parser);
|
||||||
|
parser = nullptr;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto toRead = remainingInBuffer < 1024 ? remainingInBuffer : 1024;
|
||||||
|
memcpy(buf, currentBufferPos, toRead);
|
||||||
|
|
||||||
|
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
|
||||||
|
Serial.printf("[%lu] [NAV] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
|
||||||
|
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||||
|
XML_StopParser(parser, XML_FALSE);
|
||||||
|
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||||
|
XML_SetCharacterDataHandler(parser, nullptr);
|
||||||
|
XML_ParserFree(parser);
|
||||||
|
parser = nullptr;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBufferPos += toRead;
|
||||||
|
remainingInBuffer -= toRead;
|
||||||
|
remainingSize -= toRead;
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
||||||
|
auto* self = static_cast<TocNavParser*>(userData);
|
||||||
|
|
||||||
|
// Track HTML structure loosely - we mainly care about finding <nav epub:type="toc">
|
||||||
|
if (strcmp(name, "html") == 0) {
|
||||||
|
self->state = IN_HTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->state == IN_HTML && strcmp(name, "body") == 0) {
|
||||||
|
self->state = IN_BODY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for <nav epub:type="toc"> anywhere in body (or nested elements)
|
||||||
|
if (self->state >= IN_BODY && strcmp(name, "nav") == 0) {
|
||||||
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
|
if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) {
|
||||||
|
self->state = IN_NAV_TOC;
|
||||||
|
Serial.printf("[%lu] [NAV] Found nav toc element\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process ol/li/a if we're inside the toc nav
|
||||||
|
if (self->state < IN_NAV_TOC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "ol") == 0) {
|
||||||
|
self->olDepth++;
|
||||||
|
self->state = IN_OL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->state == IN_OL && strcmp(name, "li") == 0) {
|
||||||
|
self->state = IN_LI;
|
||||||
|
self->currentLabel.clear();
|
||||||
|
self->currentHref.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->state == IN_LI && strcmp(name, "a") == 0) {
|
||||||
|
self->state = IN_ANCHOR;
|
||||||
|
// Get href attribute
|
||||||
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
|
if (strcmp(atts[i], "href") == 0) {
|
||||||
|
self->currentHref = atts[i + 1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void XMLCALL TocNavParser::characterData(void* userData, const XML_Char* s, const int len) {
|
||||||
|
auto* self = static_cast<TocNavParser*>(userData);
|
||||||
|
|
||||||
|
// Only collect text when inside an anchor within the TOC nav
|
||||||
|
if (self->state == IN_ANCHOR) {
|
||||||
|
self->currentLabel.append(s, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
|
||||||
|
auto* self = static_cast<TocNavParser*>(userData);
|
||||||
|
|
||||||
|
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
|
||||||
|
// Create TOC entry when closing anchor tag (we have all data now)
|
||||||
|
if (!self->currentLabel.empty() && !self->currentHref.empty()) {
|
||||||
|
std::string href = self->baseContentPath + self->currentHref;
|
||||||
|
std::string anchor;
|
||||||
|
|
||||||
|
const size_t pos = href.find('#');
|
||||||
|
if (pos != std::string::npos) {
|
||||||
|
anchor = href.substr(pos + 1);
|
||||||
|
href = href.substr(0, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->cache) {
|
||||||
|
// olDepth gives us the nesting level (1-based from the outer ol)
|
||||||
|
self->cache->createTocEntry(self->currentLabel, href, anchor, self->olDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
self->currentLabel.clear();
|
||||||
|
self->currentHref.clear();
|
||||||
|
}
|
||||||
|
self->state = IN_LI;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "li") == 0 && (self->state == IN_LI || self->state == IN_OL)) {
|
||||||
|
self->state = IN_OL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "ol") == 0 && self->state >= IN_NAV_TOC) {
|
||||||
|
self->olDepth--;
|
||||||
|
if (self->olDepth == 0) {
|
||||||
|
self->state = IN_NAV_TOC;
|
||||||
|
} else {
|
||||||
|
self->state = IN_LI; // Back to parent li
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) {
|
||||||
|
self->state = IN_BODY;
|
||||||
|
Serial.printf("[%lu] [NAV] Finished parsing nav toc\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/Epub/Epub/parsers/TocNavParser.h
Normal file
47
lib/Epub/Epub/parsers/TocNavParser.h
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Print.h>
|
||||||
|
#include <expat.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class BookMetadataCache;
|
||||||
|
|
||||||
|
// Parser for EPUB 3 nav.xhtml navigation documents
|
||||||
|
// Parses HTML5 nav elements with epub:type="toc" to extract table of contents
|
||||||
|
class TocNavParser final : public Print {
|
||||||
|
enum ParserState {
|
||||||
|
START,
|
||||||
|
IN_HTML,
|
||||||
|
IN_BODY,
|
||||||
|
IN_NAV_TOC, // Inside <nav epub:type="toc">
|
||||||
|
IN_OL, // Inside <ol>
|
||||||
|
IN_LI, // Inside <li>
|
||||||
|
IN_ANCHOR, // Inside <a>
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::string& baseContentPath;
|
||||||
|
size_t remainingSize;
|
||||||
|
XML_Parser parser = nullptr;
|
||||||
|
ParserState state = START;
|
||||||
|
BookMetadataCache* cache;
|
||||||
|
|
||||||
|
// Track nesting depth for <ol> elements to determine TOC depth
|
||||||
|
uint8_t olDepth = 0;
|
||||||
|
// Current entry data being collected
|
||||||
|
std::string currentLabel;
|
||||||
|
std::string currentHref;
|
||||||
|
|
||||||
|
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||||
|
static void characterData(void* userData, const XML_Char* s, int len);
|
||||||
|
static void endElement(void* userData, const XML_Char* name);
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit TocNavParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
|
||||||
|
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
|
||||||
|
~TocNavParser() override;
|
||||||
|
|
||||||
|
bool setup();
|
||||||
|
|
||||||
|
size_t write(uint8_t) override;
|
||||||
|
size_t write(const uint8_t* buffer, size_t size) override;
|
||||||
|
};
|
||||||
@ -327,6 +327,148 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const {
|
||||||
|
const int screenWidth = getScreenWidth();
|
||||||
|
constexpr int buttonWidth = 40; // Width on screen (height when rotated)
|
||||||
|
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
|
||||||
|
constexpr int buttonX = 5; // Distance from right edge
|
||||||
|
// Position for the button group - buttons share a border so they're adjacent
|
||||||
|
constexpr int topButtonY = 345; // Top button position
|
||||||
|
|
||||||
|
const char* labels[] = {topBtn, bottomBtn};
|
||||||
|
|
||||||
|
// Draw the shared border for both buttons as one unit
|
||||||
|
const int x = screenWidth - buttonX - buttonWidth;
|
||||||
|
|
||||||
|
// Draw top button outline (3 sides, bottom open)
|
||||||
|
if (topBtn != nullptr && topBtn[0] != '\0') {
|
||||||
|
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
|
||||||
|
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
|
||||||
|
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw shared middle border
|
||||||
|
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
|
||||||
|
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw bottom button outline (3 sides, top is shared)
|
||||||
|
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
|
||||||
|
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
|
||||||
|
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
|
||||||
|
topButtonY + 2 * buttonHeight - 1); // Right
|
||||||
|
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw text for each button
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
if (labels[i] != nullptr && labels[i][0] != '\0') {
|
||||||
|
const int y = topButtonY + i * buttonHeight;
|
||||||
|
|
||||||
|
// Draw rotated text centered in the button
|
||||||
|
const int textWidth = getTextWidth(fontId, labels[i]);
|
||||||
|
const int textHeight = getTextHeight(fontId);
|
||||||
|
|
||||||
|
// Center the rotated text in the button
|
||||||
|
const int textX = x + (buttonWidth - textHeight) / 2;
|
||||||
|
const int textY = y + (buttonHeight + textWidth) / 2;
|
||||||
|
|
||||||
|
drawTextRotated90CW(fontId, textX, textY, labels[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int GfxRenderer::getTextHeight(const int fontId) const {
|
||||||
|
if (fontMap.count(fontId) == 0) {
|
||||||
|
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||||
|
const EpdFontFamily::Style style) const {
|
||||||
|
// Cannot draw a NULL / empty string
|
||||||
|
if (text == nullptr || *text == '\0') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontMap.count(fontId) == 0) {
|
||||||
|
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto font = fontMap.at(fontId);
|
||||||
|
|
||||||
|
// No printable characters
|
||||||
|
if (!font.hasPrintableChars(text, style)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 90° clockwise rotation:
|
||||||
|
// Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX)
|
||||||
|
// Text reads from bottom to top
|
||||||
|
|
||||||
|
int yPos = y; // Current Y position (decreases as we draw characters)
|
||||||
|
|
||||||
|
uint32_t cp;
|
||||||
|
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||||
|
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||||
|
if (!glyph) {
|
||||||
|
glyph = font.getGlyph('?', style);
|
||||||
|
}
|
||||||
|
if (!glyph) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int is2Bit = font.getData(style)->is2Bit;
|
||||||
|
const uint32_t offset = glyph->dataOffset;
|
||||||
|
const uint8_t width = glyph->width;
|
||||||
|
const uint8_t height = glyph->height;
|
||||||
|
const int left = glyph->left;
|
||||||
|
const int top = glyph->top;
|
||||||
|
|
||||||
|
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
|
||||||
|
|
||||||
|
if (bitmap != nullptr) {
|
||||||
|
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||||
|
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||||
|
const int pixelPosition = glyphY * width + glyphX;
|
||||||
|
|
||||||
|
// 90° clockwise rotation transformation:
|
||||||
|
// screenX = x + (ascender - top + glyphY)
|
||||||
|
// screenY = yPos - (left + glyphX)
|
||||||
|
const int screenX = x + (font.getData(style)->ascender - top + glyphY);
|
||||||
|
const int screenY = yPos - left - glyphX;
|
||||||
|
|
||||||
|
if (is2Bit) {
|
||||||
|
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||||
|
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||||
|
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||||
|
|
||||||
|
if (renderMode == BW && bmpVal < 3) {
|
||||||
|
drawPixel(screenX, screenY, black);
|
||||||
|
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||||
|
drawPixel(screenX, screenY, false);
|
||||||
|
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||||
|
drawPixel(screenX, screenY, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||||
|
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||||
|
|
||||||
|
if ((byte >> bit_index) & 1) {
|
||||||
|
drawPixel(screenX, screenY, black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next character position (going up, so decrease Y)
|
||||||
|
yPos -= glyph->advanceX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||||
|
|
||||||
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
|
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
|
||||||
|
|||||||
@ -82,7 +82,15 @@ class GfxRenderer {
|
|||||||
|
|
||||||
// UI Components
|
// UI Components
|
||||||
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
|
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
|
||||||
|
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
|
||||||
|
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
||||||
|
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||||
|
int getTextHeight(int fontId) const;
|
||||||
|
|
||||||
|
public:
|
||||||
// Grayscale functions
|
// Grayscale functions
|
||||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||||
void copyGrayscaleLsbBuffers() const;
|
void copyGrayscaleLsbBuffers() const;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
[platformio]
|
[platformio]
|
||||||
crosspoint_version = 0.11.2
|
crosspoint_version = 0.12.0
|
||||||
default_envs = default
|
default_envs = default
|
||||||
|
|
||||||
[base]
|
[base]
|
||||||
|
|||||||
@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance;
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||||
// Increment this when adding new persisted settings fields
|
// Increment this when adding new persisted settings fields
|
||||||
constexpr uint8_t SETTINGS_COUNT = 11;
|
constexpr uint8_t SETTINGS_COUNT = 14;
|
||||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@ -37,7 +37,11 @@ bool CrossPointSettings::saveToFile() const {
|
|||||||
serialization::writePod(outputFile, fontFamily);
|
serialization::writePod(outputFile, fontFamily);
|
||||||
serialization::writePod(outputFile, fontSize);
|
serialization::writePod(outputFile, fontSize);
|
||||||
serialization::writePod(outputFile, lineSpacing);
|
serialization::writePod(outputFile, lineSpacing);
|
||||||
|
serialization::writePod(outputFile, paragraphAlignment);
|
||||||
|
serialization::writePod(outputFile, sleepTimeout);
|
||||||
|
serialization::writePod(outputFile, refreshFrequency);
|
||||||
serialization::writePod(outputFile, screenMargin);
|
serialization::writePod(outputFile, screenMargin);
|
||||||
|
|
||||||
outputFile.close();
|
outputFile.close();
|
||||||
|
|
||||||
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
||||||
@ -84,8 +88,16 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, lineSpacing);
|
serialization::readPod(inputFile, lineSpacing);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
serialization::readPod(inputFile, paragraphAlignment);
|
||||||
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
serialization::readPod(inputFile, sleepTimeout);
|
||||||
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
serialization::readPod(inputFile, refreshFrequency);
|
||||||
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, screenMargin);
|
serialization::readPod(inputFile, screenMargin);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
|
||||||
|
|
||||||
} while (false);
|
} while (false);
|
||||||
|
|
||||||
inputFile.close();
|
inputFile.close();
|
||||||
@ -129,6 +141,38 @@ float CrossPointSettings::getReaderLineCompression() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsigned long CrossPointSettings::getSleepTimeoutMs() const {
|
||||||
|
switch (sleepTimeout) {
|
||||||
|
case SLEEP_1_MIN:
|
||||||
|
return 1UL * 60 * 1000;
|
||||||
|
case SLEEP_5_MIN:
|
||||||
|
return 5UL * 60 * 1000;
|
||||||
|
case SLEEP_10_MIN:
|
||||||
|
default:
|
||||||
|
return 10UL * 60 * 1000;
|
||||||
|
case SLEEP_15_MIN:
|
||||||
|
return 15UL * 60 * 1000;
|
||||||
|
case SLEEP_30_MIN:
|
||||||
|
return 30UL * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int CrossPointSettings::getRefreshFrequency() const {
|
||||||
|
switch (refreshFrequency) {
|
||||||
|
case REFRESH_1:
|
||||||
|
return 1;
|
||||||
|
case REFRESH_5:
|
||||||
|
return 5;
|
||||||
|
case REFRESH_10:
|
||||||
|
return 10;
|
||||||
|
case REFRESH_15:
|
||||||
|
default:
|
||||||
|
return 15;
|
||||||
|
case REFRESH_30:
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int CrossPointSettings::getReaderFontId() const {
|
int CrossPointSettings::getReaderFontId() const {
|
||||||
switch (fontFamily) {
|
switch (fontFamily) {
|
||||||
case BOOKERLY:
|
case BOOKERLY:
|
||||||
|
|||||||
@ -43,6 +43,13 @@ class CrossPointSettings {
|
|||||||
// Font size options
|
// Font size options
|
||||||
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
|
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
|
||||||
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
|
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
|
||||||
|
enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 };
|
||||||
|
|
||||||
|
// Auto-sleep timeout options (in minutes)
|
||||||
|
enum SLEEP_TIMEOUT { SLEEP_1_MIN = 0, SLEEP_5_MIN = 1, SLEEP_10_MIN = 2, SLEEP_15_MIN = 3, SLEEP_30_MIN = 4 };
|
||||||
|
|
||||||
|
// E-ink refresh frequency (pages between full refreshes)
|
||||||
|
enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 };
|
||||||
|
|
||||||
// Reader screen margin options
|
// Reader screen margin options
|
||||||
enum SCREEN_MARGIN { S = 5, M = 10, L = 20, XL = 30 };
|
enum SCREEN_MARGIN { S = 5, M = 10, L = 20, XL = 30 };
|
||||||
@ -66,6 +73,11 @@ class CrossPointSettings {
|
|||||||
uint8_t fontFamily = BOOKERLY;
|
uint8_t fontFamily = BOOKERLY;
|
||||||
uint8_t fontSize = MEDIUM;
|
uint8_t fontSize = MEDIUM;
|
||||||
uint8_t lineSpacing = NORMAL;
|
uint8_t lineSpacing = NORMAL;
|
||||||
|
uint8_t paragraphAlignment = JUSTIFIED;
|
||||||
|
// Auto-sleep timeout setting (default 10 minutes)
|
||||||
|
uint8_t sleepTimeout = SLEEP_10_MIN;
|
||||||
|
// E-ink refresh frequency (default 15 pages)
|
||||||
|
uint8_t refreshFrequency = REFRESH_15;
|
||||||
|
|
||||||
// Reader screen margin settings
|
// Reader screen margin settings
|
||||||
uint8_t screenMargin = M;
|
uint8_t screenMargin = M;
|
||||||
@ -82,6 +94,8 @@ class CrossPointSettings {
|
|||||||
bool loadFromFile();
|
bool loadFromFile();
|
||||||
|
|
||||||
float getReaderLineCompression() const;
|
float getReaderLineCompression() const;
|
||||||
|
unsigned long getSleepTimeoutMs() const;
|
||||||
|
int getRefreshFrequency() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper macro to access settings
|
// Helper macro to access settings
|
||||||
|
|||||||
@ -22,4 +22,5 @@ class Activity {
|
|||||||
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); }
|
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); }
|
||||||
virtual void loop() {}
|
virtual void loop() {}
|
||||||
virtual bool skipLoopDelay() { return false; }
|
virtual bool skipLoopDelay() { return false; }
|
||||||
|
virtual bool preventAutoSleep() { return false; }
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
@ -42,16 +40,16 @@ void SleepActivity::onEnter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void SleepActivity::renderPopup(const char* message) const {
|
void SleepActivity::renderPopup(const char* message) const {
|
||||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message);
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
|
||||||
constexpr int margin = 20;
|
constexpr int margin = 20;
|
||||||
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
|
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
|
||||||
constexpr int y = 117;
|
constexpr int y = 117;
|
||||||
const int w = textWidth + margin * 2;
|
const int w = textWidth + margin * 2;
|
||||||
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
|
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
|
||||||
// renderer.clearScreen();
|
// renderer.clearScreen();
|
||||||
|
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
|
||||||
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
|
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
|
||||||
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message);
|
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
|
||||||
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -70,4 +70,5 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
|||||||
void onExit() override;
|
void onExit() override;
|
||||||
void loop() override;
|
void loop() override;
|
||||||
bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
|
bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
|
||||||
|
bool preventAutoSleep() override { return webServer && webServer->isRunning(); }
|
||||||
};
|
};
|
||||||
|
|||||||
@ -447,7 +447,16 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi
|
|||||||
|
|
||||||
void WifiSelectionActivity::displayTaskLoop() {
|
void WifiSelectionActivity::displayTaskLoop() {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
// If a subactivity is active, yield CPU time but don't render
|
||||||
if (subActivity) {
|
if (subActivity) {
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render if we're in PASSWORD_ENTRY state - we're just transitioning
|
||||||
|
// from the keyboard subactivity back to the main activity
|
||||||
|
if (state == WifiSelectionState::PASSWORD_ENTRY) {
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int pagesPerRefresh = 15;
|
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||||
constexpr unsigned long skipChapterMs = 700;
|
constexpr unsigned long skipChapterMs = 700;
|
||||||
constexpr unsigned long goHomeMs = 1000;
|
constexpr unsigned long goHomeMs = 1000;
|
||||||
constexpr int topPadding = 5;
|
constexpr int topPadding = 5;
|
||||||
@ -267,7 +267,8 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
|
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
|
||||||
|
|
||||||
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
SETTINGS.extraParagraphSpacing, viewportWidth, viewportHeight)) {
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
|
viewportHeight)) {
|
||||||
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
|
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
|
||||||
|
|
||||||
// Progress bar dimensions
|
// Progress bar dimensions
|
||||||
@ -311,8 +312,8 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
SETTINGS.extraParagraphSpacing, viewportWidth, viewportHeight, progressSetup,
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
progressCallback)) {
|
viewportHeight, progressSetup, progressCallback)) {
|
||||||
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
|
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
|
||||||
section.reset();
|
section.reset();
|
||||||
return;
|
return;
|
||||||
@ -378,7 +379,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = pagesPerRefresh;
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
pagesUntilFullRefresh--;
|
pagesUntilFullRefresh--;
|
||||||
|
|||||||
@ -11,13 +11,13 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "XtcReaderChapterSelectionActivity.h"
|
#include "XtcReaderChapterSelectionActivity.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int pagesPerRefresh = 15;
|
|
||||||
constexpr unsigned long skipPageMs = 700;
|
constexpr unsigned long skipPageMs = 700;
|
||||||
constexpr unsigned long goHomeMs = 1000;
|
constexpr unsigned long goHomeMs = 1000;
|
||||||
} // namespace
|
} // namespace
|
||||||
@ -266,7 +266,7 @@ void XtcReaderActivity::renderPage() {
|
|||||||
// Display BW with conditional refresh based on pagesUntilFullRefresh
|
// Display BW with conditional refresh based on pagesUntilFullRefresh
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = pagesPerRefresh;
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
pagesUntilFullRefresh--;
|
pagesUntilFullRefresh--;
|
||||||
@ -346,7 +346,7 @@ void XtcReaderActivity::renderPage() {
|
|||||||
// Display with appropriate refresh
|
// Display with appropriate refresh
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = pagesPerRefresh;
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
pagesUntilFullRefresh--;
|
pagesUntilFullRefresh--;
|
||||||
|
|||||||
@ -41,4 +41,5 @@ class OtaUpdateActivity : public ActivityWithSubactivity {
|
|||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
void loop() override;
|
void loop() override;
|
||||||
|
bool preventAutoSleep() override { return state == CHECKING_FOR_UPDATE || state == UPDATE_IN_PROGRESS; }
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
// Define the static settings list
|
// Define the static settings list
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int settingsCount = 12;
|
constexpr int settingsCount = 15;
|
||||||
const SettingInfo settingsList[settingsCount] = {
|
const SettingInfo settingsList[settingsCount] = {
|
||||||
// Should match with SLEEP_SCREEN_MODE
|
// Should match with SLEEP_SCREEN_MODE
|
||||||
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
|
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
|
||||||
@ -35,6 +35,18 @@ const SettingInfo settingsList[settingsCount] = {
|
|||||||
{"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}},
|
{"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}},
|
||||||
{"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}},
|
{"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}},
|
||||||
{"Reader Screen Margin", SettingType::ENUM, &CrossPointSettings::screenMargin, {"S", "M", "L", "XL"}},
|
{"Reader Screen Margin", SettingType::ENUM, &CrossPointSettings::screenMargin, {"S", "M", "L", "XL"}},
|
||||||
|
{"Reader Paragraph Alignment",
|
||||||
|
SettingType::ENUM,
|
||||||
|
&CrossPointSettings::paragraphAlignment,
|
||||||
|
{"Justify", "Left", "Center", "Right"}},
|
||||||
|
{"Time to Sleep",
|
||||||
|
SettingType::ENUM,
|
||||||
|
&CrossPointSettings::sleepTimeout,
|
||||||
|
{"1 min", "5 min", "10 min", "15 min", "30 min"}},
|
||||||
|
{"Refresh Frequency",
|
||||||
|
SettingType::ENUM,
|
||||||
|
&CrossPointSettings::refreshFrequency,
|
||||||
|
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}},
|
||||||
{"Check for updates", SettingType::ACTION, nullptr, {}},
|
{"Check for updates", SettingType::ACTION, nullptr, {}},
|
||||||
};
|
};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|||||||
@ -329,9 +329,13 @@ void KeyboardEntryActivity::render() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw help text at absolute bottom of screen (consistent with other screens)
|
// Draw help text
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
|
||||||
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
|
// Draw side button hints for Up/Down navigation
|
||||||
|
renderer.drawSideButtonHints(UI_10_FONT_ID, "Up", "Down");
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
src/main.cpp
18
src/main.cpp
@ -126,8 +126,6 @@ EpdFont ui12RegularFont(&ubuntu_12_regular);
|
|||||||
EpdFont ui12BoldFont(&ubuntu_12_bold);
|
EpdFont ui12BoldFont(&ubuntu_12_bold);
|
||||||
EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont);
|
EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont);
|
||||||
|
|
||||||
// Auto-sleep timeout (10 minutes of inactivity)
|
|
||||||
constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
||||||
// measurement of power button press duration calibration value
|
// measurement of power button press duration calibration value
|
||||||
unsigned long t1 = 0;
|
unsigned long t1 = 0;
|
||||||
unsigned long t2 = 0;
|
unsigned long t2 = 0;
|
||||||
@ -150,8 +148,10 @@ void verifyWakeupLongPress() {
|
|||||||
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
|
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
|
||||||
const auto start = millis();
|
const auto start = millis();
|
||||||
bool abort = false;
|
bool abort = false;
|
||||||
// It takes us some time to wake up from deep sleep, so we need to subtract that from the duration
|
// Subtract the current time, because inputManager only starts counting the HeldTime from the first update()
|
||||||
constexpr uint16_t calibration = 29;
|
// This way, we remove the time we already took to reach here from the duration,
|
||||||
|
// assuming the button was held until now from millis()==0 (i.e. device start time).
|
||||||
|
const uint16_t calibration = start;
|
||||||
const uint16_t calibratedPressDuration =
|
const uint16_t calibratedPressDuration =
|
||||||
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
||||||
|
|
||||||
@ -316,14 +316,16 @@ void loop() {
|
|||||||
lastMemPrint = millis();
|
lastMemPrint = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for any user activity (button press or release)
|
// Check for any user activity (button press or release) or active background work
|
||||||
static unsigned long lastActivityTime = millis();
|
static unsigned long lastActivityTime = millis();
|
||||||
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) {
|
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() ||
|
||||||
|
(currentActivity && currentActivity->preventAutoSleep())) {
|
||||||
lastActivityTime = millis(); // Reset inactivity timer
|
lastActivityTime = millis(); // Reset inactivity timer
|
||||||
}
|
}
|
||||||
|
|
||||||
if (millis() - lastActivityTime >= AUTO_SLEEP_TIMEOUT_MS) {
|
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
|
||||||
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), AUTO_SLEEP_TIMEOUT_MS);
|
if (millis() - lastActivityTime >= sleepTimeoutMs) {
|
||||||
|
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
|
||||||
enterDeepSleep();
|
enterDeepSleep();
|
||||||
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <Update.h>
|
#include <Update.h>
|
||||||
#include <WiFiClientSecure.h>
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
|
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
|
||||||
@ -69,44 +68,41 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
|
|||||||
return OK;
|
return OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool OtaUpdater::isUpdateNewer() {
|
bool OtaUpdater::isUpdateNewer() const {
|
||||||
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
|
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int currentMajor, currentMinor, currentPatch;
|
||||||
|
int latestMajor, latestMinor, latestPatch;
|
||||||
|
|
||||||
|
const auto currentVersion = CROSSPOINT_VERSION;
|
||||||
|
|
||||||
// semantic version check (only match on 3 segments)
|
// semantic version check (only match on 3 segments)
|
||||||
const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.')));
|
sscanf(latestVersion.c_str(), "%d.%d.%d", &latestMajor, &latestMinor, &latestPatch);
|
||||||
const auto updateMinor = stoi(
|
sscanf(currentVersion, "%d.%d.%d", ¤tMajor, ¤tMinor, ¤tPatch);
|
||||||
latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1));
|
|
||||||
const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1));
|
|
||||||
|
|
||||||
std::string currentVersion = CROSSPOINT_VERSION;
|
/*
|
||||||
const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.')));
|
* Compare major versions.
|
||||||
const auto currentMinor = stoi(currentVersion.substr(
|
* If they differ, return true if latest major version greater than current major version
|
||||||
currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1));
|
* otherwise return false.
|
||||||
const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1));
|
*/
|
||||||
|
if (latestMajor != currentMajor) return latestMajor > currentMajor;
|
||||||
|
|
||||||
if (updateMajor > currentMajor) {
|
/*
|
||||||
return true;
|
* Compare minor versions.
|
||||||
}
|
* If they differ, return true if latest minor version greater than current minor version
|
||||||
if (updateMajor < currentMajor) {
|
* otherwise return false.
|
||||||
return false;
|
*/
|
||||||
}
|
if (latestMinor != currentMinor) return latestMinor > currentMinor;
|
||||||
|
|
||||||
if (updateMinor > currentMinor) {
|
/*
|
||||||
return true;
|
* Check patch versions.
|
||||||
}
|
*/
|
||||||
if (updateMinor < currentMinor) {
|
return latestPatch > currentPatch;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatePatch > currentPatch) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string& OtaUpdater::getLatestVersion() { return latestVersion; }
|
const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; }
|
||||||
|
|
||||||
OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) {
|
OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) {
|
||||||
if (!isUpdateNewer()) {
|
if (!isUpdateNewer()) {
|
||||||
|
|||||||
@ -23,8 +23,8 @@ class OtaUpdater {
|
|||||||
size_t totalSize = 0;
|
size_t totalSize = 0;
|
||||||
|
|
||||||
OtaUpdater() = default;
|
OtaUpdater() = default;
|
||||||
bool isUpdateNewer();
|
bool isUpdateNewer() const;
|
||||||
const std::string& getLatestVersion();
|
const std::string& getLatestVersion() const;
|
||||||
OtaUpdaterError checkForUpdate();
|
OtaUpdaterError checkForUpdate();
|
||||||
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
|
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -341,6 +341,90 @@
|
|||||||
width: 60px;
|
width: 60px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
/* Failed uploads banner */
|
||||||
|
.failed-uploads-banner {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.failed-uploads-banner.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.failed-uploads-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.failed-uploads-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #856404;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.dismiss-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #856404;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.dismiss-btn:hover {
|
||||||
|
color: #533f03;
|
||||||
|
}
|
||||||
|
.failed-file-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #ffe69c;
|
||||||
|
}
|
||||||
|
.failed-file-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.failed-file-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.failed-file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
.failed-file-error {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #856404;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.retry-btn {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #533f03;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.retry-btn:hover {
|
||||||
|
background-color: #e0a800;
|
||||||
|
}
|
||||||
|
.retry-all-btn {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #533f03;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.retry-all-btn:hover {
|
||||||
|
background-color: #e0a800;
|
||||||
|
}
|
||||||
/* Delete modal */
|
/* Delete modal */
|
||||||
.delete-warning {
|
.delete-warning {
|
||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
@ -505,6 +589,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Failed Uploads Banner -->
|
||||||
|
<div class="failed-uploads-banner" id="failedUploadsBanner">
|
||||||
|
<div class="failed-uploads-header">
|
||||||
|
<h3 class="failed-uploads-title">⚠️ Some files failed to upload</h3>
|
||||||
|
<button class="dismiss-btn" onclick="dismissFailedUploads()" title="Dismiss">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="failedFilesList"></div>
|
||||||
|
<button class="retry-all-btn" onclick="retryAllFailedUploads()">Retry All Failed Uploads</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="contents-header">
|
<div class="contents-header">
|
||||||
<h2 class="contents-title">Contents</h2>
|
<h2 class="contents-title">Contents</h2>
|
||||||
@ -531,7 +625,7 @@
|
|||||||
<h3>📤 Upload file</h3>
|
<h3>📤 Upload file</h3>
|
||||||
<div class="upload-form">
|
<div class="upload-form">
|
||||||
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
|
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
|
||||||
<input type="file" id="fileInput" onchange="validateFile()">
|
<input type="file" id="fileInput" onchange="validateFile()" multiple>
|
||||||
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
|
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
|
||||||
<div id="progress-container">
|
<div id="progress-container">
|
||||||
<div id="progress-bar"><div id="progress-fill"></div></div>
|
<div id="progress-bar"><div id="progress-fill"></div></div>
|
||||||
@ -717,65 +811,183 @@
|
|||||||
function validateFile() {
|
function validateFile() {
|
||||||
const fileInput = document.getElementById('fileInput');
|
const fileInput = document.getElementById('fileInput');
|
||||||
const uploadBtn = document.getElementById('uploadBtn');
|
const uploadBtn = document.getElementById('uploadBtn');
|
||||||
const file = fileInput.files[0];
|
const files = fileInput.files;
|
||||||
uploadBtn.disabled = !file;
|
uploadBtn.disabled = !(files.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadFile() {
|
let failedUploadsGlobal = [];
|
||||||
const fileInput = document.getElementById('fileInput');
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
|
|
||||||
if (!file) {
|
function uploadFile() {
|
||||||
alert('Please select a file first!');
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const files = Array.from(fileInput.files);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
alert('Please select at least one file!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
const progressFill = document.getElementById('progress-fill');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const uploadBtn = document.getElementById('uploadBtn');
|
||||||
|
|
||||||
|
progressContainer.style.display = 'block';
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
|
||||||
|
let currentIndex = 0;
|
||||||
|
const failedFiles = [];
|
||||||
|
|
||||||
|
function uploadNextFile() {
|
||||||
|
if (currentIndex >= files.length) {
|
||||||
|
// All files processed - show summary
|
||||||
|
if (failedFiles.length === 0) {
|
||||||
|
progressFill.style.backgroundColor = '#4caf50';
|
||||||
|
progressText.textContent = 'All uploads complete!';
|
||||||
|
setTimeout(() => {
|
||||||
|
closeUploadModal();
|
||||||
|
hydrate(); // Refresh file list instead of reloading
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
progressFill.style.backgroundColor = '#e74c3c';
|
||||||
|
const failedList = failedFiles.map(f => f.name).join(', ');
|
||||||
|
progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
|
||||||
|
|
||||||
|
// Store failed files globally and show banner
|
||||||
|
failedUploadsGlobal = failedFiles;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
closeUploadModal();
|
||||||
|
showFailedUploadsBanner();
|
||||||
|
hydrate(); // Refresh file list to show successfully uploaded files
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const file = files[currentIndex];
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressFill = document.getElementById('progress-fill');
|
|
||||||
const progressText = document.getElementById('progress-text');
|
|
||||||
const uploadBtn = document.getElementById('uploadBtn');
|
|
||||||
|
|
||||||
progressContainer.style.display = 'block';
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
// Include path as query parameter since multipart form data doesn't make
|
// Include path as query parameter since multipart form data doesn't make
|
||||||
// form fields available until after file upload completes
|
// form fields available until after file upload completes
|
||||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||||||
|
|
||||||
xhr.upload.onprogress = function(e) {
|
progressFill.style.width = '0%';
|
||||||
|
progressFill.style.backgroundColor = '#4caf50';
|
||||||
|
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`;
|
||||||
|
|
||||||
|
xhr.upload.onprogress = function (e) {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
const percent = Math.round((e.loaded / e.total) * 100);
|
const percent = Math.round((e.loaded / e.total) * 100);
|
||||||
progressFill.style.width = percent + '%';
|
progressFill.style.width = percent + '%';
|
||||||
progressText.textContent = 'Uploading: ' + percent + '%';
|
progressText.textContent =
|
||||||
|
`Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onload = function() {
|
xhr.onload = function () {
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
progressText.textContent = 'Upload complete!';
|
currentIndex++;
|
||||||
setTimeout(function() {
|
uploadNextFile(); // upload next file
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
} else {
|
||||||
progressText.textContent = 'Upload failed: ' + xhr.responseText;
|
// Track failure and continue with next file
|
||||||
progressFill.style.backgroundColor = '#e74c3c';
|
failedFiles.push({ name: file.name, error: xhr.responseText, file: file });
|
||||||
uploadBtn.disabled = false;
|
currentIndex++;
|
||||||
|
uploadNextFile();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = function() {
|
xhr.onerror = function () {
|
||||||
progressText.textContent = 'Upload failed - network error';
|
// Track network error and continue with next file
|
||||||
progressFill.style.backgroundColor = '#e74c3c';
|
failedFiles.push({ name: file.name, error: 'network error', file: file });
|
||||||
uploadBtn.disabled = false;
|
currentIndex++;
|
||||||
|
uploadNextFile();
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploadNextFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFailedUploadsBanner() {
|
||||||
|
const banner = document.getElementById('failedUploadsBanner');
|
||||||
|
const filesList = document.getElementById('failedFilesList');
|
||||||
|
|
||||||
|
filesList.innerHTML = '';
|
||||||
|
|
||||||
|
failedUploadsGlobal.forEach((failedFile, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'failed-file-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="failed-file-info">
|
||||||
|
<div class="failed-file-name">📄 ${escapeHtml(failedFile.name)}</div>
|
||||||
|
<div class="failed-file-error">Error: ${escapeHtml(failedFile.error)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="retry-btn" onclick="retrySingleUpload(${index})">Retry</button>
|
||||||
|
`;
|
||||||
|
filesList.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure retry all button is visible
|
||||||
|
const retryAllBtn = banner.querySelector('.retry-all-btn');
|
||||||
|
if (retryAllBtn) retryAllBtn.style.display = '';
|
||||||
|
|
||||||
|
banner.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissFailedUploads() {
|
||||||
|
const banner = document.getElementById('failedUploadsBanner');
|
||||||
|
banner.classList.remove('show');
|
||||||
|
failedUploadsGlobal = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrySingleUpload(index) {
|
||||||
|
const failedFile = failedUploadsGlobal[index];
|
||||||
|
if (!failedFile) return;
|
||||||
|
|
||||||
|
// Create a DataTransfer to set the file input
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(failedFile.file);
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
|
||||||
|
// Remove this file from failed list
|
||||||
|
failedUploadsGlobal.splice(index, 1);
|
||||||
|
|
||||||
|
// If no more failed files, hide banner
|
||||||
|
if (failedUploadsGlobal.length === 0) {
|
||||||
|
dismissFailedUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modal and trigger upload
|
||||||
|
openUploadModal();
|
||||||
|
validateFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryAllFailedUploads() {
|
||||||
|
if (failedUploadsGlobal.length === 0) return;
|
||||||
|
|
||||||
|
// Create a DataTransfer with all failed files
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
failedUploadsGlobal.forEach(failedFile => {
|
||||||
|
dt.items.add(failedFile.file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
|
||||||
|
// Clear failed files list
|
||||||
|
failedUploadsGlobal = [];
|
||||||
|
dismissFailedUploads();
|
||||||
|
|
||||||
|
// Open modal and trigger upload
|
||||||
|
openUploadModal();
|
||||||
|
validateFile();
|
||||||
|
}
|
||||||
|
|
||||||
function createFolder() {
|
function createFolder() {
|
||||||
const folderName = document.getElementById('folderName').value.trim();
|
const folderName = document.getElementById('folderName').value.trim();
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user