/ lib / Markdown / Markdown.cpp
Markdown.cpp
  1  /**
  2   * Markdown.cpp
  3   *
  4   * Markdown file handler implementation for DWS SignalOS
  5   */
  6  
  7  #include "Markdown.h"
  8  
  9  #include <CoverHelpers.h>
 10  #include <FsHelpers.h>
 11  #include <HardwareSerial.h>
 12  #include <SDCardManager.h>
 13  
 14  Markdown::Markdown(std::string filepath, const std::string& cacheDir)
 15      : filepath(std::move(filepath)), fileSize(0), loaded(false) {
 16    // Create cache key based on filepath (same as Epub/Xtc/Txt)
 17    cachePath = cacheDir + "/md_" + std::to_string(std::hash<std::string>{}(this->filepath));
 18  
 19    // Extract title from filename
 20    size_t lastSlash = this->filepath.find_last_of('/');
 21    size_t lastDot = this->filepath.find_last_of('.');
 22  
 23    if (lastSlash == std::string::npos) {
 24      lastSlash = 0;
 25    } else {
 26      lastSlash++;
 27    }
 28  
 29    if (lastDot == std::string::npos || lastDot <= lastSlash) {
 30      title = this->filepath.substr(lastSlash);
 31    } else {
 32      title = this->filepath.substr(lastSlash, lastDot - lastSlash);
 33    }
 34  }
 35  
 36  bool Markdown::load() {
 37    Serial.printf("[%lu] [MD ] Loading Markdown: %s\n", millis(), filepath.c_str());
 38  
 39    if (!SdMan.exists(filepath.c_str())) {
 40      Serial.printf("[%lu] [MD ] File does not exist\n", millis());
 41      return false;
 42    }
 43  
 44    FsFile file;
 45    if (!SdMan.openFileForRead("MD ", filepath, file)) {
 46      Serial.printf("[%lu] [MD ] Failed to open file\n", millis());
 47      return false;
 48    }
 49  
 50    fileSize = file.size();
 51    file.close();
 52  
 53    loaded = true;
 54  
 55    // Try to extract title from content (updates title member if found)
 56    extractTitleFromContent();
 57  
 58    Serial.printf("[%lu] [MD ] Loaded Markdown: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize);
 59    return true;
 60  }
 61  
 62  bool Markdown::clearCache() const {
 63    if (!SdMan.exists(cachePath.c_str())) {
 64      Serial.printf("[%lu] [MD ] Cache does not exist, no action needed\n", millis());
 65      return true;
 66    }
 67  
 68    if (!SdMan.removeDir(cachePath.c_str())) {
 69      Serial.printf("[%lu] [MD ] Failed to clear cache\n", millis());
 70      return false;
 71    }
 72  
 73    Serial.printf("[%lu] [MD ] Cache cleared successfully\n", millis());
 74    return true;
 75  }
 76  
 77  void Markdown::setupCacheDir() const {
 78    if (SdMan.exists(cachePath.c_str())) {
 79      return;
 80    }
 81  
 82    // Create directories recursively
 83    for (size_t i = 1; i < cachePath.length(); i++) {
 84      if (cachePath[i] == '/') {
 85        SdMan.mkdir(cachePath.substr(0, i).c_str());
 86      }
 87    }
 88    SdMan.mkdir(cachePath.c_str());
 89  }
 90  
 91  std::string Markdown::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
 92  
 93  std::string Markdown::findCoverImage() const {
 94    // Extract directory path
 95    size_t lastSlash = filepath.find_last_of('/');
 96    std::string dirPath = (lastSlash == std::string::npos) ? "/" : filepath.substr(0, lastSlash);
 97    if (dirPath.empty()) dirPath = "/";
 98  
 99    return CoverHelpers::findCoverImage(dirPath, title);
100  }
101  
102  bool Markdown::generateCoverBmp(bool use1BitDithering) const {
103    const auto coverPath = getCoverBmpPath();
104    const auto failedMarkerPath = cachePath + "/.cover.failed";
105  
106    // Already generated
107    if (SdMan.exists(coverPath.c_str())) {
108      return true;
109    }
110  
111    // Previously failed, don't retry
112    if (SdMan.exists(failedMarkerPath.c_str())) {
113      return false;
114    }
115  
116    // Find a cover image
117    std::string coverImagePath = findCoverImage();
118    if (coverImagePath.empty()) {
119      Serial.printf("[%lu] [MD ] No cover image found\n", millis());
120      // Create failure marker
121      FsFile marker;
122      if (SdMan.openFileForWrite("MD ", failedMarkerPath, marker)) {
123        marker.close();
124      }
125      return false;
126    }
127  
128    // Setup cache directory
129    setupCacheDir();
130  
131    // Convert to BMP using shared helper
132    const bool success = CoverHelpers::convertImageToBmp(coverImagePath, coverPath, "MD ", use1BitDithering);
133    if (!success) {
134      // Create failure marker
135      FsFile marker;
136      if (SdMan.openFileForWrite("MD ", failedMarkerPath, marker)) {
137        marker.close();
138      }
139    }
140    return success;
141  }
142  
143  std::string Markdown::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
144  
145  bool Markdown::generateThumbBmp() const {
146    const auto thumbPath = getThumbBmpPath();
147    const auto failedMarkerPath = cachePath + "/.thumb.failed";
148  
149    if (SdMan.exists(thumbPath.c_str())) return true;
150  
151    // Previously failed, don't retry
152    if (SdMan.exists(failedMarkerPath.c_str())) {
153      return false;
154    }
155  
156    if (!SdMan.exists(getCoverBmpPath().c_str()) && !generateCoverBmp(true)) {
157      // Create failure marker
158      FsFile marker;
159      if (SdMan.openFileForWrite("MD ", failedMarkerPath, marker)) {
160        marker.close();
161      }
162      return false;
163    }
164  
165    setupCacheDir();
166  
167    const bool success = CoverHelpers::generateThumbFromCover(getCoverBmpPath(), thumbPath, "MD ");
168    if (!success) {
169      // Create failure marker
170      FsFile marker;
171      if (SdMan.openFileForWrite("MD ", failedMarkerPath, marker)) {
172        marker.close();
173      }
174    }
175    return success;
176  }
177  
178  size_t Markdown::readContent(uint8_t* buffer, size_t offset, size_t length) const {
179    if (!loaded) {
180      return 0;
181    }
182  
183    FsFile file;
184    if (!SdMan.openFileForRead("MD ", filepath, file)) {
185      return 0;
186    }
187  
188    if (offset > 0) {
189      file.seek(offset);
190    }
191  
192    size_t bytesRead = file.read(buffer, length);
193    file.close();
194  
195    return bytesRead;
196  }
197  
198  bool Markdown::extractTitleFromContent() {
199    // Check cache first
200    std::string titleCachePath = getTitleCachePath();
201    if (SdMan.exists(titleCachePath.c_str())) {
202      FsFile file;
203      if (SdMan.openFileForRead("MD ", titleCachePath, file)) {
204        char buf[128];
205        int len = file.read(buf, sizeof(buf) - 1);
206        file.close();
207        if (len > 0) {
208          buf[len] = '\0';
209          title = buf;
210          return true;
211        }
212      }
213    }
214  
215    // Read first 4KB - use heap instead of stack to avoid overflow on ESP32-C3
216    constexpr size_t SCAN_SIZE = 4096;
217    std::unique_ptr<uint8_t[]> buffer(new (std::nothrow) uint8_t[SCAN_SIZE]);
218    if (!buffer) return false;
219  
220    size_t bytesRead = readContent(buffer.get(), 0, SCAN_SIZE);
221    if (bytesRead == 0) return false;
222  
223    // Scan for ATX header (# Title)
224    std::string extracted;
225    const char* p = reinterpret_cast<const char*>(buffer.get());
226    const char* end = p + bytesRead;
227  
228    while (p < end) {
229      // Skip to start of line
230      while (p < end && (*p == '\n' || *p == '\r')) p++;
231      if (p >= end) break;
232  
233      const char* lineStart = p;
234      // Find end of line
235      while (p < end && *p != '\n' && *p != '\r') p++;
236      size_t lineLen = static_cast<size_t>(p - lineStart);
237  
238      // Check for ATX header
239      if (lineLen > 1 && lineStart[0] == '#') {
240        size_t hashCount = 0;
241        while (hashCount < lineLen && lineStart[hashCount] == '#') hashCount++;
242  
243        if (hashCount <= 6 && hashCount < lineLen && lineStart[hashCount] == ' ') {
244          // Extract title text - skip all leading whitespace after #
245          size_t start = hashCount;
246          while (start < lineLen && lineStart[start] == ' ') start++;
247          size_t titleEnd = lineLen;
248          // Strip trailing # and spaces
249          while (titleEnd > start && (lineStart[titleEnd - 1] == '#' || lineStart[titleEnd - 1] == ' ')) titleEnd--;
250  
251          if (titleEnd > start) {
252            extracted = std::string(lineStart + start, titleEnd - start);
253            break;
254          }
255        }
256      }
257    }
258  
259    if (extracted.empty()) return false;
260  
261    // Truncate to fit buffer
262    if (extracted.length() > 127) extracted.resize(127);
263  
264    // Update title
265    title = extracted;
266  
267    // Cache to SD
268    setupCacheDir();
269    FsFile file;
270    if (SdMan.openFileForWrite("MD ", titleCachePath, file)) {
271      file.write(reinterpret_cast<const uint8_t*>(title.c_str()), title.length());
272      file.close();
273    }
274  
275    return true;
276  }