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 }