293 lines
10 KiB
C++
293 lines
10 KiB
C++
#include "CompanionReader.h"
|
|
|
|
#if COMPANION_FEATURE_READER
|
|
#include <Arduino.h>
|
|
#include "bible/ReaderLookup.h"
|
|
|
|
#if defined(NRF52_PLATFORM)
|
|
#include "bible/ReaderStreamData.generated.h"
|
|
#else
|
|
#include "bible/ReaderData.generated.h"
|
|
#endif
|
|
|
|
namespace mesh {
|
|
bible::LookupResult readReaderVerse(bible::Reference ref, char* scratch,
|
|
size_t capacity, const char*& text) {
|
|
return bible::lookup(bible::generated::readerCorpus, ref, scratch, capacity, text);
|
|
}
|
|
|
|
namespace {
|
|
|
|
constexpr size_t kReplyPageTextBytes = 100;
|
|
constexpr const char* kUsage = "ERROR: use get reader <chapter>[:<verse>[-<last>]] [page]";
|
|
|
|
struct Selection {
|
|
bible::Reference first;
|
|
uint8_t last;
|
|
};
|
|
|
|
bible::ParseResult parseReaderRequest(const char* command, Selection& selection,
|
|
uint8_t& page) {
|
|
page = 0; // No page argument keeps text terminals' full-verse behavior.
|
|
if (!command) return bible::ParseResult::NoMatch;
|
|
bible::skipSpace(command);
|
|
if (!bible::token(command, "get")) return bible::ParseResult::NoMatch;
|
|
bible::skipSpace(command);
|
|
if (!bible::token(command, "reader")) return bible::ParseResult::NoMatch;
|
|
bible::skipSpace(command);
|
|
bible::Reference& ref = selection.first;
|
|
if (!bible::number(command, ref.chapter) || ref.chapter > sizeof(bible::kChapterVerses)) {
|
|
return bible::ParseResult::Invalid;
|
|
}
|
|
ref.verse = 1;
|
|
selection.last = bible::kChapterVerses[ref.chapter - 1];
|
|
if (*command == ':') {
|
|
++command;
|
|
if (!bible::number(command, ref.verse)) return bible::ParseResult::Invalid;
|
|
selection.last = ref.verse;
|
|
if (*command == '-') {
|
|
++command;
|
|
if (!bible::number(command, selection.last)) return bible::ParseResult::Invalid;
|
|
}
|
|
if (ref.verse > selection.last || selection.last > bible::kChapterVerses[ref.chapter - 1])
|
|
return bible::ParseResult::Invalid;
|
|
}
|
|
if (*command) {
|
|
if (!bible::space(*command)) return bible::ParseResult::Invalid;
|
|
bible::skipSpace(command);
|
|
if (*command && !bible::number(command, page)) return bible::ParseResult::Invalid;
|
|
bible::skipSpace(command);
|
|
}
|
|
return *command == 0 ? bible::ParseResult::Valid : bible::ParseResult::Invalid;
|
|
}
|
|
|
|
size_t nextReplyPage(const char* text, size_t start, size_t length, size_t limit) {
|
|
size_t count = length - start;
|
|
if (count > limit) {
|
|
count = limit;
|
|
// Keep the separating space in this page, so following every page
|
|
// reconstructs the supplied verse exactly without lost or repeated words.
|
|
for (size_t n = count; n > 0; --n) {
|
|
if (text[start + n - 1] == ' ') {
|
|
count = n;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return start + count;
|
|
}
|
|
|
|
void nextCommand(char* out, size_t capacity, Selection selection, uint8_t page) {
|
|
if (selection.first.verse == selection.last) {
|
|
snprintf(out, capacity, "\nNext: get reader %u:%u %u", selection.first.chapter,
|
|
selection.first.verse, page);
|
|
} else {
|
|
snprintf(out, capacity, "\nNext: get reader %u:%u-%u %u", selection.first.chapter,
|
|
selection.first.verse, selection.last, page);
|
|
}
|
|
}
|
|
|
|
void formatVerseReply(Selection selection, uint8_t page, const char* text,
|
|
char* reply, size_t capacity) {
|
|
const bible::Reference ref = selection.first;
|
|
char next_command[48] = {};
|
|
if (ref.verse < selection.last) {
|
|
Selection remaining = selection;
|
|
++remaining.first.verse;
|
|
nextCommand(next_command, sizeof(next_command), remaining, 1);
|
|
}
|
|
char header[40];
|
|
snprintf(header, sizeof(header), "Reader %u:%u (WEB)\n",
|
|
static_cast<unsigned>(ref.chapter), static_cast<unsigned>(ref.verse));
|
|
const size_t length = strlen(text);
|
|
const size_t header_len = strlen(header);
|
|
if (header_len + length + strlen(next_command) < capacity) {
|
|
if (page != 1) {
|
|
snprintf(reply, capacity, "ERROR: Reader page must be 1-1");
|
|
} else {
|
|
memcpy(reply, header, header_len);
|
|
memcpy(reply + header_len, text, length);
|
|
strcpy(reply + header_len + length, next_command);
|
|
}
|
|
return;
|
|
}
|
|
|
|
unsigned parts = 0;
|
|
size_t start = 0, end = 0;
|
|
for (size_t offset = 0; offset < length;) {
|
|
// A range needs a longer continuation command. Keep the tagged 157-byte
|
|
// app reply budget, without changing single-verse page boundaries.
|
|
const size_t next = nextReplyPage(text, offset, length,
|
|
ref.verse < selection.last ? 80 : kReplyPageTextBytes);
|
|
if (++parts == page) {
|
|
start = offset;
|
|
end = next;
|
|
}
|
|
offset = next;
|
|
}
|
|
if (page > parts) {
|
|
snprintf(reply, capacity, "ERROR: Reader page must be 1-%u", parts);
|
|
return;
|
|
}
|
|
snprintf(header, sizeof(header), "Reader %u:%u [%u/%u]\n",
|
|
static_cast<unsigned>(ref.chapter), static_cast<unsigned>(ref.verse),
|
|
static_cast<unsigned>(page), parts);
|
|
if (page < parts) {
|
|
nextCommand(next_command, sizeof(next_command), selection, page + 1);
|
|
}
|
|
const size_t prefix_len = strlen(header);
|
|
const size_t text_len = end - start;
|
|
const size_t suffix_len = strlen(next_command);
|
|
if (prefix_len + text_len + suffix_len >= capacity) {
|
|
snprintf(reply, capacity, "ERROR: Reader reply buffer too small");
|
|
return;
|
|
}
|
|
memcpy(reply, header, prefix_len);
|
|
memcpy(reply + prefix_len, text + start, text_len);
|
|
memcpy(reply + prefix_len + text_len, next_command, suffix_len + 1);
|
|
}
|
|
|
|
// Only a recognized Reader request enters this 4 KiB decode frame. Normal
|
|
// framed commands must not reserve it, including with link-time optimization.
|
|
__attribute__((noinline)) void replyVerse(Selection selection, uint8_t page,
|
|
char* reply, size_t capacity) {
|
|
char scratch[bible::kBlockSize];
|
|
const char* text = nullptr;
|
|
const bible::LookupResult result = readReaderVerse(selection.first, scratch, sizeof(scratch), text);
|
|
if (result == bible::LookupResult::Found) {
|
|
formatVerseReply(selection, page, text, reply, capacity);
|
|
} else if (result == bible::LookupResult::Missing) {
|
|
snprintf(reply, capacity, "This verse is not present in the supplied translation.");
|
|
} else {
|
|
snprintf(reply, capacity, "ERROR: invalid compressed Reader data");
|
|
}
|
|
}
|
|
|
|
__attribute__((noinline)) void printReplyPage(Selection selection, uint8_t page,
|
|
Stream& output) {
|
|
char reply[160];
|
|
replyVerse(selection, page, reply, sizeof(reply));
|
|
output.print(" ");
|
|
output.print(reply);
|
|
output.print("\r\n");
|
|
}
|
|
|
|
// Only a cursor is retained between loop calls: no chapter buffer or heap.
|
|
// A slow/unread USB endpoint must not overflow its fixed queue or hold the
|
|
// mesh loop waiting. A new command/session cancels the unfinished cursor.
|
|
struct PrintJob {
|
|
Stream* output = nullptr;
|
|
ReaderTryWrite try_write = nullptr;
|
|
Selection selection = {};
|
|
uint16_t offset = 0;
|
|
uint8_t phase = 0;
|
|
bool prompt = false;
|
|
} print_job;
|
|
|
|
bool writePart(const char* text) {
|
|
const size_t remaining = strlen(text) - print_job.offset;
|
|
const size_t count = remaining < 96 ? remaining : 96;
|
|
const uint8_t* data = reinterpret_cast<const uint8_t*>(text + print_job.offset);
|
|
const size_t written = print_job.try_write
|
|
? print_job.try_write(*print_job.output, data, count)
|
|
: print_job.output->write(data, count);
|
|
print_job.offset += written <= count ? written : count;
|
|
if (written < remaining) return false;
|
|
print_job.offset = 0;
|
|
return true;
|
|
}
|
|
|
|
// Keep decode scratch off the ordinary command/loop frame, including LTO.
|
|
__attribute__((noinline)) bool writeVersePart() {
|
|
char scratch[bible::kBlockSize];
|
|
const char* text = nullptr;
|
|
const bible::LookupResult result = readReaderVerse(
|
|
print_job.selection.first, scratch, sizeof(scratch), text);
|
|
if (result == bible::LookupResult::Missing)
|
|
text = "This verse is not present in the supplied translation.";
|
|
else if (result != bible::LookupResult::Found) {
|
|
text = "ERROR: invalid compressed Reader data";
|
|
print_job.selection.last = print_job.selection.first.verse;
|
|
}
|
|
return writePart(text);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void cancelReaderCommand(const Stream* output) {
|
|
if (!output || output == print_job.output) print_job.output = nullptr;
|
|
}
|
|
|
|
bool readerCommandActive(const Stream* output) {
|
|
return print_job.output && (!output || output == print_job.output);
|
|
}
|
|
|
|
void serviceReaderCommand() {
|
|
if (!print_job.output) return;
|
|
switch (print_job.phase) {
|
|
case 0: {
|
|
char header[48];
|
|
snprintf(header, sizeof(header), " Reader %u:%u (%s)\r\n",
|
|
print_job.selection.first.chapter, print_job.selection.first.verse,
|
|
bible::generated::readerCorpus.translation);
|
|
if (writePart(header)) ++print_job.phase;
|
|
break;
|
|
}
|
|
case 1:
|
|
if (writeVersePart()) ++print_job.phase;
|
|
break;
|
|
case 2:
|
|
if (writePart("\r\n")) {
|
|
if (print_job.selection.first.verse < print_job.selection.last) {
|
|
++print_job.selection.first.verse;
|
|
print_job.phase = 0;
|
|
} else ++print_job.phase;
|
|
}
|
|
break;
|
|
case 3:
|
|
if (writePart(bible::generated::readerCorpus.attribution)) ++print_job.phase;
|
|
break;
|
|
default:
|
|
if (writePart(print_job.prompt ? "\r\n> " : "\r\n")) cancelReaderCommand();
|
|
break;
|
|
}
|
|
}
|
|
|
|
bool handleReaderCommand(const char* command, Stream& output, bool prompt,
|
|
ReaderTryWrite try_write) {
|
|
cancelReaderCommand();
|
|
Selection selection = {};
|
|
uint8_t page;
|
|
const bible::ParseResult parsed = parseReaderRequest(command, selection, page);
|
|
if (parsed == bible::ParseResult::NoMatch) return false;
|
|
if (parsed == bible::ParseResult::Invalid) {
|
|
output.print(" "); output.print(kUsage); output.print("\r\n");
|
|
} else if (page != 0) {
|
|
printReplyPage(selection, page, output);
|
|
} else {
|
|
print_job.selection = selection;
|
|
print_job.offset = print_job.phase = 0;
|
|
print_job.prompt = prompt;
|
|
print_job.try_write = try_write;
|
|
print_job.output = &output;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool handleReaderReply(const char* command, char* reply, size_t capacity) {
|
|
if (reply == nullptr || capacity == 0) return false;
|
|
Selection selection = {};
|
|
uint8_t page;
|
|
const bible::ParseResult parsed = parseReaderRequest(command, selection, page);
|
|
if (parsed == bible::ParseResult::NoMatch) return false;
|
|
if (parsed == bible::ParseResult::Invalid) {
|
|
snprintf(reply, capacity, "%s", kUsage);
|
|
} else {
|
|
replyVerse(selection, page == 0 ? 1 : page, reply, capacity);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace mesh
|
|
#endif
|