/ src / library / LibraryIndex.cpp
LibraryIndex.cpp
  1  #include "LibraryIndex.h"
  2  
  3  #include <Arduino.h>
  4  #include <SdFat.h>
  5  #include <Serialization.h>
  6  
  7  #include <algorithm>
  8  #include <cctype>
  9  #include <cstring>
 10  
 11  #include "../config.h"
 12  #include "../content/ContentHandle.h"
 13  #include "../content/ProgressManager.h"
 14  #include "../core/Core.h"
 15  
 16  namespace signalos {
 17  
 18  // Library index file path
 19  static constexpr const char* LIBRARY_INDEX_PATH = SIGNALOS_DIR "/library.idx";
 20  
 21  // ============================================================================
 22  // Hash and Key Extraction
 23  // ============================================================================
 24  
 25  uint32_t LibraryIndex::hashPath(const char* path) {
 26    if (!path) return 0;
 27  
 28    uint32_t hash = FNV_OFFSET_BASIS;
 29    while (*path) {
 30      hash ^= static_cast<uint8_t>(*path);
 31      hash *= FNV_PRIME;
 32      path++;
 33    }
 34    return hash;
 35  }
 36  
 37  void LibraryIndex::extractTitleKey(const char* title, uint8_t* key6) {
 38    memset(key6, 0, 6);
 39    if (!title) return;
 40  
 41    // Skip leading "The ", "A ", "An " for better sorting
 42    if (strncasecmp(title, "the ", 4) == 0)
 43      title += 4;
 44    else if (strncasecmp(title, "a ", 2) == 0)
 45      title += 2;
 46    else if (strncasecmp(title, "an ", 3) == 0)
 47      title += 3;
 48  
 49    size_t i = 0;
 50    while (i < 6 && *title) {
 51      key6[i++] = static_cast<uint8_t>(std::tolower(static_cast<unsigned char>(*title)));
 52      title++;
 53    }
 54  }
 55  
 56  void LibraryIndex::extractAuthorKey(const char* author, uint8_t* key4) {
 57    memset(key4, 0, 4);
 58    if (!author) return;
 59  
 60    // Try to find last name (last space before end)
 61    const char* lastName = author;
 62    const char* p = author;
 63    while (*p) {
 64      if (*p == ' ' && *(p + 1)) {
 65        lastName = p + 1;
 66      }
 67      p++;
 68    }
 69  
 70    size_t i = 0;
 71    while (i < 4 && *lastName) {
 72      key4[i++] = static_cast<uint8_t>(std::tolower(static_cast<unsigned char>(*lastName)));
 73      lastName++;
 74    }
 75  }
 76  
 77  // ============================================================================
 78  // Comparison Functions
 79  // ============================================================================
 80  
 81  int LibraryEntry::compareTitleKey(const LibraryEntry& a, const LibraryEntry& b) {
 82    return memcmp(a.titleKey, b.titleKey, 6);
 83  }
 84  
 85  int LibraryEntry::compareAuthorKey(const LibraryEntry& a, const LibraryEntry& b) {
 86    return memcmp(a.authorKey, b.authorKey, 4);
 87  }
 88  
 89  // ============================================================================
 90  // Load/Save Index
 91  // ============================================================================
 92  
 93  bool LibraryIndex::load(Core& core) {
 94    FsFile file;
 95    auto result = core.storage.openRead(LIBRARY_INDEX_PATH, file);
 96    if (!result.ok()) {
 97      Serial.println("[LIBIDX] No index file, will rebuild");
 98      return false;
 99    }
100  
101    // Read and validate header
102    LibraryIndexHeader header;
103    if (file.read(reinterpret_cast<uint8_t*>(&header), sizeof(header)) != sizeof(header)) {
104      Serial.println("[LIBIDX] Failed to read header");
105      file.close();
106      return false;
107    }
108  
109    if (header.magic != LIBRARY_MAGIC || header.version != LIBRARY_VERSION) {
110      Serial.printf("[LIBIDX] Invalid magic/version: 0x%08X v%d\n", header.magic, header.version);
111      file.close();
112      return false;
113    }
114  
115    if (header.entryCount > MAX_LIBRARY_ENTRIES) {
116      Serial.printf("[LIBIDX] Too many entries: %u\n", header.entryCount);
117      file.close();
118      return false;
119    }
120  
121    // Read entries
122    size_t bytesToRead = header.entryCount * sizeof(LibraryEntry);
123    if (file.read(reinterpret_cast<uint8_t*>(entries_), bytesToRead) != static_cast<int>(bytesToRead)) {
124      Serial.println("[LIBIDX] Failed to read entries");
125      file.close();
126      return false;
127    }
128  
129    entryCount_ = header.entryCount;
130    sortValid_ = false;
131    file.close();
132  
133    Serial.printf("[LIBIDX] Loaded %zu entries\n", entryCount_);
134    return true;
135  }
136  
137  bool LibraryIndex::save(Core& core) {
138    FsFile file;
139    auto result = core.storage.openWrite(LIBRARY_INDEX_PATH, file);
140    if (!result.ok()) {
141      Serial.println("[LIBIDX] Failed to open for write");
142      return false;
143    }
144  
145    // Write header
146    LibraryIndexHeader header;
147    header.magic = LIBRARY_MAGIC;
148    header.version = LIBRARY_VERSION;
149    memset(header.reserved, 0, sizeof(header.reserved));
150    header.entryCount = entryCount_;
151    header.checksum = 0;  // TODO: compute if needed
152  
153    file.write(reinterpret_cast<const uint8_t*>(&header), sizeof(header));
154  
155    // Write entries
156    if (entryCount_ > 0) {
157      file.write(reinterpret_cast<const uint8_t*>(entries_), entryCount_ * sizeof(LibraryEntry));
158    }
159  
160    file.close();
161    Serial.printf("[LIBIDX] Saved %zu entries\n", entryCount_);
162    return true;
163  }
164  
165  // ============================================================================
166  // Rebuild Index
167  // ============================================================================
168  
169  size_t LibraryIndex::rebuild(Core& core) {
170    Serial.println("[LIBIDX] Rebuilding index...");
171  
172    entryCount_ = 0;
173    sortValid_ = false;
174  
175    // Open cache directory
176    FsFile cacheDir;
177    auto result = core.storage.openDir(SIGNALOS_CACHE_DIR, cacheDir);
178    if (!result.ok()) {
179      Serial.println("[LIBIDX] Failed to open cache dir");
180      return 0;
181    }
182  
183    // Iterate through cache subdirectories
184    FsFile entry;
185    char entryName[64];
186  
187    while (entry.openNext(&cacheDir, O_RDONLY)) {
188      if (!entry.isDir()) {
189        entry.close();
190        continue;
191      }
192  
193      entry.getName(entryName, sizeof(entryName));
194      entry.close();
195  
196      // Skip . and ..
197      if (entryName[0] == '.') {
198        continue;
199      }
200  
201      // Check if there's a progress.bin or stats.bin (indicating a cached book)
202      char checkPath[280];
203      snprintf(checkPath, sizeof(checkPath), "%s/%s/progress.bin", SIGNALOS_CACHE_DIR, entryName);
204  
205      FsFile checkFile;
206      bool hasProgress = core.storage.openRead(checkPath, checkFile).ok();
207      if (hasProgress) {
208        checkFile.close();
209      } else {
210        // Try stats.bin
211        snprintf(checkPath, sizeof(checkPath), "%s/%s/stats.bin", SIGNALOS_CACHE_DIR, entryName);
212        hasProgress = core.storage.openRead(checkPath, checkFile).ok();
213        if (hasProgress) {
214          checkFile.close();
215        }
216      }
217  
218      if (!hasProgress) {
219        // No progress file means we don't have metadata for this book yet
220        continue;
221      }
222  
223      if (entryCount_ >= MAX_LIBRARY_ENTRIES) {
224        Serial.println("[LIBIDX] Max entries reached");
225        break;
226      }
227  
228      // Parse the directory name as the path hash
229      uint32_t pathHash = strtoul(entryName, nullptr, 16);
230      if (pathHash == 0) {
231        continue;
232      }
233  
234      // Create entry
235      LibraryEntry& libEntry = entries_[entryCount_];
236      memset(&libEntry, 0, sizeof(LibraryEntry));
237      libEntry.pathHash = pathHash;
238  
239      // Load metadata from cache dir
240      char cacheEntryDir[280];
241      snprintf(cacheEntryDir, sizeof(cacheEntryDir), "%s/%s", SIGNALOS_CACHE_DIR, entryName);
242      loadEntryMetadata(core, cacheEntryDir, libEntry);
243  
244      entryCount_++;
245    }
246  
247    cacheDir.close();
248  
249    // Save the rebuilt index
250    save(core);
251  
252    Serial.printf("[LIBIDX] Rebuilt with %zu entries\n", entryCount_);
253    return entryCount_;
254  }
255  
256  bool LibraryIndex::loadEntryMetadata(Core& core, const char* cacheDir, LibraryEntry& entry) {
257    // Load stats to get flags and timestamps
258    BookStats stats = ProgressManager::loadStats(core, cacheDir, ContentType::Epub);  // Type doesn't matter for flags
259    entry.flags = stats.flags;
260    entry.bookmarkCount = stats.bookmarkCount;
261  
262    // For title/author, we need to read the book's metadata
263    // This would require opening the book, which is expensive
264    // For now, use the path hash as a fallback and rely on FileListState to provide actual names
265  
266    // Try to read a metadata.txt file if it exists (created when book is first opened)
267    char metaPath[280];
268    snprintf(metaPath, sizeof(metaPath), "%s/meta.txt", cacheDir);
269  
270    FsFile metaFile;
271    if (core.storage.openRead(metaPath, metaFile).ok()) {
272      char buffer[200];
273      int bytesRead = metaFile.read(reinterpret_cast<uint8_t*>(buffer), sizeof(buffer) - 1);
274      metaFile.close();
275  
276      if (bytesRead > 0) {
277        buffer[bytesRead] = '\0';
278  
279        // Parse simple format: "title\nauthor\ntimestamp"
280        char* newline = strchr(buffer, '\n');
281        if (newline) {
282          *newline = '\0';
283          extractTitleKey(buffer, entry.titleKey);
284  
285          char* author = newline + 1;
286          char* newline2 = strchr(author, '\n');
287          if (newline2) {
288            *newline2 = '\0';
289            extractAuthorKey(author, entry.authorKey);
290  
291            char* timestamp = newline2 + 1;
292            entry.dateAddedTimestamp = strtoul(timestamp, nullptr, 10);
293          }
294        }
295      }
296    }
297  
298    return true;
299  }
300  
301  // ============================================================================
302  // Update Operations
303  // ============================================================================
304  
305  void LibraryIndex::updateLastRead(uint32_t pathHash, uint32_t timestamp) {
306    LibraryEntry* entry = find(pathHash);
307    if (entry) {
308      entry->lastReadTimestamp = timestamp;
309      entry->setHasProgress(true);
310      sortValid_ = false;
311    }
312  }
313  
314  void LibraryIndex::updateFlags(uint32_t pathHash, uint8_t flags, uint8_t bookmarkCount) {
315    LibraryEntry* entry = find(pathHash);
316    if (entry) {
317      entry->flags = flags;
318      entry->bookmarkCount = bookmarkCount;
319    }
320  }
321  
322  LibraryEntry* LibraryIndex::find(uint32_t pathHash) {
323    for (size_t i = 0; i < entryCount_; i++) {
324      if (entries_[i].pathHash == pathHash) {
325        return &entries_[i];
326      }
327    }
328    return nullptr;
329  }
330  
331  const LibraryEntry* LibraryIndex::find(uint32_t pathHash) const {
332    for (size_t i = 0; i < entryCount_; i++) {
333      if (entries_[i].pathHash == pathHash) {
334        return &entries_[i];
335      }
336    }
337    return nullptr;
338  }
339  
340  LibraryEntry* LibraryIndex::at(size_t index) {
341    if (index >= entryCount_) return nullptr;
342    return &entries_[index];
343  }
344  
345  const LibraryEntry* LibraryIndex::at(size_t index) const {
346    if (index >= entryCount_) return nullptr;
347    return &entries_[index];
348  }
349  
350  // ============================================================================
351  // Sorting
352  // ============================================================================
353  
354  void LibraryIndex::sort(SortOrder order) {
355    if (sortValid_ && currentSort_ == order) {
356      return;  // Already sorted
357    }
358  
359    // Initialize sorted indices
360    for (size_t i = 0; i < entryCount_; i++) {
361      sortedIndices_[i] = static_cast<uint16_t>(i);
362    }
363  
364    // Sort based on order
365    switch (order) {
366      case SortOrder::RecentlyRead:
367        std::sort(sortedIndices_, sortedIndices_ + entryCount_, [this](uint16_t a, uint16_t b) {
368          // Never-read books go last, then sort by most recent first
369          if (entries_[a].lastReadTimestamp == 0 && entries_[b].lastReadTimestamp == 0) {
370            return entries_[a].dateAddedTimestamp > entries_[b].dateAddedTimestamp;  // Newest added first
371          }
372          if (entries_[a].lastReadTimestamp == 0) return false;
373          if (entries_[b].lastReadTimestamp == 0) return true;
374          return entries_[a].lastReadTimestamp > entries_[b].lastReadTimestamp;
375        });
376        break;
377  
378      case SortOrder::DateAdded:
379        std::sort(sortedIndices_, sortedIndices_ + entryCount_, [this](uint16_t a, uint16_t b) {
380          return entries_[a].dateAddedTimestamp > entries_[b].dateAddedTimestamp;  // Newest first
381        });
382        break;
383  
384      case SortOrder::TitleAZ:
385        std::sort(sortedIndices_, sortedIndices_ + entryCount_, [this](uint16_t a, uint16_t b) {
386          return LibraryEntry::compareTitleKey(entries_[a], entries_[b]) < 0;
387        });
388        break;
389  
390      case SortOrder::AuthorAZ:
391        std::sort(sortedIndices_, sortedIndices_ + entryCount_, [this](uint16_t a, uint16_t b) {
392          int cmp = LibraryEntry::compareAuthorKey(entries_[a], entries_[b]);
393          if (cmp == 0) {
394            return LibraryEntry::compareTitleKey(entries_[a], entries_[b]) < 0;
395          }
396          return cmp < 0;
397        });
398        break;
399    }
400  
401    currentSort_ = order;
402    sortValid_ = true;
403  }
404  
405  size_t LibraryIndex::getSortedEntries(const LibraryEntry** out, size_t maxCount) const {
406    if (!out || maxCount == 0) return 0;
407  
408    size_t count = std::min(entryCount_, maxCount);
409    for (size_t i = 0; i < count; i++) {
410      out[i] = &entries_[sortedIndices_[i]];
411    }
412    return count;
413  }
414  
415  }  // namespace signalos