mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 22:57:50 +03:00
Compare commits
15 Commits
116338b1aa
...
dba447fda4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dba447fda4 | ||
|
|
e5c0ddc9fa | ||
|
|
b1dcb7733b | ||
|
|
0d82b03981 | ||
|
|
5a97334ace | ||
|
|
8d791b6e07 | ||
|
|
3ce1f1a09a | ||
|
|
2a164f100c | ||
|
|
8ec6e6b574 | ||
|
|
8e603346fa | ||
|
|
4deeeb8813 | ||
|
|
a7dee81106 | ||
|
|
07ccf3acc0 | ||
|
|
b483b46b40 | ||
|
|
7753c71dc0 |
14
README.md
14
README.md
@ -95,6 +95,20 @@ Connect your Xteink X4 to your computer via USB-C and run the following command.
|
||||
```sh
|
||||
pio run --target upload
|
||||
```
|
||||
### Debugging
|
||||
|
||||
After flashing the new features, it’s recommended to capture detailed logs from the serial port.
|
||||
|
||||
First, make sure all required Python packages are installed:
|
||||
|
||||
```python
|
||||
python3 -m pip install serial colorama matplotlib
|
||||
```
|
||||
after that run the script:
|
||||
```sh
|
||||
python3 scripts/debugging_monitor.py
|
||||
```
|
||||
This was tested on Debian and should work on most Linux systems. Minor adjustments may be required for Windows or macOS.
|
||||
|
||||
## Internals
|
||||
|
||||
|
||||
@ -415,13 +415,21 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
|
||||
|
||||
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||
const EpdFontFamily::Style style) const {
|
||||
if (!text || maxWidth <= 0) return "";
|
||||
|
||||
std::string item = text;
|
||||
int itemWidth = getTextWidth(fontId, item.c_str(), style);
|
||||
while (itemWidth > maxWidth && item.length() > 8) {
|
||||
item.replace(item.length() - 5, 5, "...");
|
||||
itemWidth = getTextWidth(fontId, item.c_str(), style);
|
||||
const char* ellipsis = "...";
|
||||
int textWidth = getTextWidth(fontId, item.c_str(), style);
|
||||
if (textWidth <= maxWidth) {
|
||||
// Text fits, return as is
|
||||
return item;
|
||||
}
|
||||
return item;
|
||||
|
||||
while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) {
|
||||
utf8RemoveLastChar(item);
|
||||
}
|
||||
|
||||
return item.empty() ? ellipsis : item + ellipsis;
|
||||
}
|
||||
|
||||
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation
|
||||
|
||||
@ -29,3 +29,20 @@ uint32_t utf8NextCodepoint(const unsigned char** string) {
|
||||
|
||||
return cp;
|
||||
}
|
||||
|
||||
size_t utf8RemoveLastChar(std::string& str) {
|
||||
if (str.empty()) return 0;
|
||||
size_t pos = str.size() - 1;
|
||||
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
|
||||
--pos;
|
||||
}
|
||||
str.resize(pos);
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Truncate string by removing N UTF-8 characters from the end
|
||||
void utf8TruncateChars(std::string& str, const size_t numChars) {
|
||||
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
|
||||
utf8RemoveLastChar(str);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <string>
|
||||
#define REPLACEMENT_GLYPH 0xFFFD
|
||||
|
||||
uint32_t utf8NextCodepoint(const unsigned char** string);
|
||||
// Remove the last UTF-8 codepoint from a std::string and return the new size.
|
||||
size_t utf8RemoveLastChar(std::string& str);
|
||||
// Truncate string by removing N UTF-8 codepoints from the end.
|
||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit bd4e6707503ab9c97d13ee0d8f8c69e9ff03cd12
|
||||
Subproject commit c39f253a7dabbc193a8d7d310fb8777dca0ab8f1
|
||||
214
scripts/debugging_monitor.py
Executable file
214
scripts/debugging_monitor.py
Executable file
@ -0,0 +1,214 @@
|
||||
import sys
|
||||
import argparse
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
import time
|
||||
|
||||
# Try to import potentially missing packages
|
||||
try:
|
||||
import serial
|
||||
from colorama import init, Fore, Style
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.animation as animation
|
||||
except ImportError as e:
|
||||
missing_package = e.name
|
||||
print("\n" + "!" * 50)
|
||||
print(f" Error: The required package '{missing_package}' is not installed.")
|
||||
print("!" * 50)
|
||||
|
||||
print(f"\nTo fix this, please run the following command in your terminal:\n")
|
||||
|
||||
install_cmd = "pip install "
|
||||
packages = []
|
||||
if 'serial' in str(e): packages.append("pyserial")
|
||||
if 'colorama' in str(e): packages.append("colorama")
|
||||
if 'matplotlib' in str(e): packages.append("matplotlib")
|
||||
|
||||
print(f" {install_cmd}{' '.join(packages)}")
|
||||
|
||||
print("\nExiting...")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Global Variables for Data Sharing ---
|
||||
# Store last 50 data points
|
||||
MAX_POINTS = 50
|
||||
time_data = deque(maxlen=MAX_POINTS)
|
||||
free_mem_data = deque(maxlen=MAX_POINTS)
|
||||
total_mem_data = deque(maxlen=MAX_POINTS)
|
||||
data_lock = threading.Lock() # Prevent reading while writing
|
||||
|
||||
# Initialize colors
|
||||
init(autoreset=True)
|
||||
|
||||
def get_color_for_line(line):
|
||||
"""
|
||||
Classify log lines by type and assign appropriate colors.
|
||||
"""
|
||||
line_upper = line.upper()
|
||||
|
||||
if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]):
|
||||
return Fore.RED
|
||||
if "[MEM]" in line_upper or "FREE:" in line_upper:
|
||||
return Fore.CYAN
|
||||
if any(keyword in line_upper for keyword in ["[GFX]", "[ERS]", "DISPLAY", "RAM WRITE", "RAM COMPLETE", "REFRESH", "POWERING ON", "FRAME BUFFER", "LUT"]):
|
||||
return Fore.MAGENTA
|
||||
if any(keyword in line_upper for keyword in ["[EBP]", "[BMC]", "[ZIP]", "[PARSER]", "[EHP]", "LOADING EPUB", "CACHE", "DECOMPRESSED", "PARSING"]):
|
||||
return Fore.GREEN
|
||||
if "[ACT]" in line_upper or "ENTERING ACTIVITY" in line_upper or "EXITING ACTIVITY" in line_upper:
|
||||
return Fore.YELLOW
|
||||
if any(keyword in line_upper for keyword in ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"]):
|
||||
return Fore.BLUE
|
||||
if any(keyword in line_upper for keyword in ["[CPS]", "SETTINGS", "[CLEAR_CACHE]"]):
|
||||
return Fore.LIGHTYELLOW_EX
|
||||
if any(keyword in line_upper for keyword in ["ESP-ROM", "BUILD:", "RST:", "BOOT:", "SPIWP:", "MODE:", "LOAD:", "ENTRY", "[SD]", "STARTING CROSSPOINT", "VERSION"]):
|
||||
return Fore.LIGHTBLACK_EX
|
||||
if "[RBS]" in line_upper:
|
||||
return Fore.LIGHTCYAN_EX
|
||||
if "[KRS]" in line_upper:
|
||||
return Fore.LIGHTMAGENTA_EX
|
||||
if any(keyword in line_upper for keyword in ["EINKDISPLAY:", "STATIC FRAME", "INITIALIZING", "SPI INITIALIZED", "GPIO PINS", "RESETTING", "SSD1677", "E-INK"]):
|
||||
return Fore.LIGHTMAGENTA_EX
|
||||
if any(keyword in line_upper for keyword in ["[FNS]", "FOOTNOTE"]):
|
||||
return Fore.LIGHTGREEN_EX
|
||||
if any(keyword in line_upper for keyword in ["[CHAP]", "[OPDS]", "[COF]"]):
|
||||
return Fore.LIGHTYELLOW_EX
|
||||
|
||||
return Fore.WHITE
|
||||
|
||||
def parse_memory_line(line):
|
||||
"""
|
||||
Extracts Free and Total bytes from the specific log line.
|
||||
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
|
||||
"""
|
||||
# Regex to find 'Free: <digits>' and 'Total: <digits>'
|
||||
match = re.search(r"Free:\s*(\d+).*Total:\s*(\d+)", line)
|
||||
if match:
|
||||
try:
|
||||
free_bytes = int(match.group(1))
|
||||
total_bytes = int(match.group(2))
|
||||
return free_bytes, total_bytes
|
||||
except ValueError:
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
def serial_worker(port, baud):
|
||||
"""
|
||||
Runs in a background thread. Handles reading serial, printing to console,
|
||||
and updating the data lists.
|
||||
"""
|
||||
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}")
|
||||
|
||||
try:
|
||||
ser = serial.Serial(port, baud, timeout=0.1)
|
||||
ser.dtr = False
|
||||
ser.rts = False
|
||||
except serial.SerialException as e:
|
||||
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
raw_data = ser.readline().decode('utf-8', errors='replace')
|
||||
|
||||
if not raw_data:
|
||||
continue
|
||||
|
||||
clean_line = raw_data.strip()
|
||||
if not clean_line:
|
||||
continue
|
||||
|
||||
# Add PC timestamp
|
||||
pc_time = datetime.now().strftime("%H:%M:%S")
|
||||
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
|
||||
|
||||
# Check for Memory Line
|
||||
if "[MEM]" in formatted_line:
|
||||
free_val, total_val = parse_memory_line(formatted_line)
|
||||
if free_val is not None:
|
||||
with data_lock:
|
||||
time_data.append(pc_time)
|
||||
free_mem_data.append(free_val / 1024) # Convert to KB
|
||||
total_mem_data.append(total_val / 1024) # Convert to KB
|
||||
|
||||
# Print to console
|
||||
line_color = get_color_for_line(formatted_line)
|
||||
print(f"{line_color}{formatted_line}")
|
||||
|
||||
except OSError:
|
||||
print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}")
|
||||
break
|
||||
except Exception as e:
|
||||
# If thread is killed violently (e.g. main exit), silence errors
|
||||
pass
|
||||
finally:
|
||||
if 'ser' in locals() and ser.is_open:
|
||||
ser.close()
|
||||
|
||||
def update_graph(frame):
|
||||
"""
|
||||
Called by Matplotlib animation to redraw the chart.
|
||||
"""
|
||||
with data_lock:
|
||||
if not time_data:
|
||||
return
|
||||
|
||||
# Convert deques to lists for plotting
|
||||
x = list(time_data)
|
||||
y_free = list(free_mem_data)
|
||||
y_total = list(total_mem_data)
|
||||
|
||||
plt.cla() # Clear axis
|
||||
|
||||
# Plot Total RAM
|
||||
plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--')
|
||||
|
||||
# Plot Free RAM
|
||||
plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o')
|
||||
|
||||
# Fill area under Free RAM
|
||||
plt.fill_between(x, y_free, color='green', alpha=0.1)
|
||||
|
||||
plt.title("ESP32 Memory Monitor")
|
||||
plt.ylabel("Memory (KB)")
|
||||
plt.xlabel("Time")
|
||||
plt.legend(loc='upper left')
|
||||
plt.grid(True, linestyle=':', alpha=0.6)
|
||||
|
||||
# Rotate date labels
|
||||
plt.xticks(rotation=45, ha='right')
|
||||
plt.tight_layout()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port")
|
||||
parser.add_argument("--baud", type=int, default=115200, help="Baud rate")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 1. Start the Serial Reader in a separate thread
|
||||
# Daemon=True means this thread dies when the main program closes
|
||||
t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True)
|
||||
t.start()
|
||||
|
||||
# 2. Set up the Graph (Main Thread)
|
||||
try:
|
||||
plt.style.use('light_background')
|
||||
except:
|
||||
pass
|
||||
|
||||
fig = plt.figure(figsize=(10, 6))
|
||||
|
||||
# Update graph every 1000ms
|
||||
ani = animation.FuncAnimation(fig, update_graph, interval=1000)
|
||||
|
||||
try:
|
||||
print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}")
|
||||
plt.show()
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
|
||||
plt.close('all') # Force close any lingering plot windows
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -104,3 +104,21 @@ bool RecentBooksStore::loadFromFile() {
|
||||
Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), recentBooks.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void RecentBooksStore::updatePath(const std::string& oldPath, const std::string& newPath) {
|
||||
bool changed = false;
|
||||
for (auto& book : recentBooks) {
|
||||
if (book.path == oldPath) {
|
||||
book.path = newPath;
|
||||
changed = true;
|
||||
} else if (book.path.find(oldPath + "/") == 0) {
|
||||
// It's a directory move/rename
|
||||
book.path = newPath + book.path.substr(oldPath.length());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,9 +32,18 @@ class RecentBooksStore {
|
||||
int getCount() const { return static_cast<int>(recentBooks.size()); }
|
||||
|
||||
bool saveToFile() const;
|
||||
|
||||
bool loadFromFile();
|
||||
|
||||
/**
|
||||
* Update the path of a book in the recent list.
|
||||
* Useful when moving/renaming files or entire directories.
|
||||
* If oldPath is a directory, all books within will have their paths updated.
|
||||
*
|
||||
* @param oldPath Original absolute path
|
||||
* @param newPath New absolute path
|
||||
*/
|
||||
void updatePath(const std::string& oldPath, const std::string& newPath);
|
||||
};
|
||||
|
||||
// Helper macro to access recent books store
|
||||
#define RECENT_BOOKS RecentBooksStore::getInstance()
|
||||
#define RECENT_BOOKS RecentBooksStore::getInstance()
|
||||
@ -4,6 +4,7 @@
|
||||
#include <Epub.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Utf8.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <cstring>
|
||||
@ -366,7 +367,7 @@ void HomeActivity::render() {
|
||||
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
|
||||
// Remove "..." first, then remove one UTF-8 char, then add "..." back
|
||||
lines.back().resize(lines.back().size() - 3); // Remove "..."
|
||||
StringUtils::utf8RemoveLastChar(lines.back());
|
||||
utf8RemoveLastChar(lines.back());
|
||||
lines.back().append("...");
|
||||
}
|
||||
break;
|
||||
@ -375,7 +376,7 @@ void HomeActivity::render() {
|
||||
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
||||
while (wordWidth > maxLineWidth && !i.empty()) {
|
||||
// Word itself is too long, trim it (UTF-8 safe)
|
||||
StringUtils::utf8RemoveLastChar(i);
|
||||
utf8RemoveLastChar(i);
|
||||
// Check if we have room for ellipsis
|
||||
std::string withEllipsis = i + "...";
|
||||
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
|
||||
@ -428,7 +429,7 @@ void HomeActivity::render() {
|
||||
if (!lastBookAuthor.empty()) {
|
||||
std::string trimmedAuthor = lastBookAuthor;
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||
utf8RemoveLastChar(trimmedAuthor);
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
|
||||
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
|
||||
@ -462,14 +463,14 @@ void HomeActivity::render() {
|
||||
// Trim author if too long (UTF-8 safe)
|
||||
bool wasTrimmed = false;
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||
utf8RemoveLastChar(trimmedAuthor);
|
||||
wasTrimmed = true;
|
||||
}
|
||||
if (wasTrimmed && !trimmedAuthor.empty()) {
|
||||
// Make room for ellipsis
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
|
||||
!trimmedAuthor.empty()) {
|
||||
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
||||
utf8RemoveLastChar(trimmedAuthor);
|
||||
}
|
||||
trimmedAuthor.append("...");
|
||||
}
|
||||
|
||||
@ -570,8 +570,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
||||
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
|
||||
titleMarginLeftAdjusted = titleMarginLeft;
|
||||
}
|
||||
while (titleWidth > availableTitleSpace && title.length() > 11) {
|
||||
title.replace(title.length() - 8, 8, "...");
|
||||
if (titleWidth > availableTitleSpace) {
|
||||
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace);
|
||||
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
@ -533,8 +533,8 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
|
||||
|
||||
std::string title = txt->getTitle();
|
||||
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||
while (titleWidth > availableTextWidth && title.length() > 11) {
|
||||
title.replace(title.length() - 8, 8, "...");
|
||||
if (titleWidth > availableTextWidth) {
|
||||
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTextWidth);
|
||||
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
#include "html/FilesPageHtml.generated.h"
|
||||
#include "html/HomePageHtml.generated.h"
|
||||
#include "util/BookCacheManager.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
@ -112,6 +113,10 @@ void CrossPointWebServer::begin() {
|
||||
// Delete file/folder endpoint
|
||||
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
|
||||
|
||||
// Move and Rename endpoints (stubs)
|
||||
server->on("/move", HTTP_POST, [this] { handleMove(); });
|
||||
server->on("/rename", HTTP_POST, [this] { handleRename(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
@ -787,6 +792,89 @@ void CrossPointWebServer::handleDelete() const {
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleMove() const {
|
||||
if (!server->hasArg("oldPath") || !server->hasArg("newPath")) {
|
||||
server->send(400, "text/plain", "Missing oldPath or newPath");
|
||||
return;
|
||||
}
|
||||
|
||||
String oldPath = server->arg("oldPath");
|
||||
String newPath = server->arg("newPath");
|
||||
|
||||
if (oldPath.isEmpty() || newPath.isEmpty() || oldPath == "/" || newPath == "/") {
|
||||
server->send(400, "text/plain", "Invalid paths");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oldPath.startsWith("/")) oldPath = "/" + oldPath;
|
||||
if (!newPath.startsWith("/")) newPath = "/" + newPath;
|
||||
|
||||
if (!SdMan.exists(oldPath.c_str())) {
|
||||
server->send(404, "text/plain", "Source not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SdMan.exists(newPath.c_str())) {
|
||||
server->send(400, "text/plain", "Destination already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate cache first (or parts of it if it's a directory)
|
||||
BookCacheManager::migrateCache(oldPath, newPath);
|
||||
|
||||
if (SdMan.rename(oldPath.c_str(), newPath.c_str())) {
|
||||
Serial.printf("[%lu] [WEB] Moved %s to %s\n", millis(), oldPath.c_str(), newPath.c_str());
|
||||
server->send(200, "text/plain", "Moved successfully");
|
||||
} else {
|
||||
server->send(500, "text/plain", "Move failed");
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleRename() const {
|
||||
if (!server->hasArg("oldPath") || !server->hasArg("newPath")) {
|
||||
server->send(400, "text/plain", "Missing oldPath or newPath");
|
||||
return;
|
||||
}
|
||||
|
||||
String oldPath = server->arg("oldPath");
|
||||
String newPath = server->arg("newPath");
|
||||
|
||||
if (oldPath.isEmpty() || newPath.isEmpty() || oldPath == "/" || newPath == "/") {
|
||||
server->send(400, "text/plain", "Invalid paths");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oldPath.startsWith("/")) oldPath = "/" + oldPath;
|
||||
if (!newPath.startsWith("/")) newPath = "/" + newPath;
|
||||
|
||||
// Security check: prevent renaming system files
|
||||
if (oldPath.substring(oldPath.lastIndexOf('/') + 1).startsWith(".") ||
|
||||
newPath.substring(newPath.lastIndexOf('/') + 1).startsWith(".")) {
|
||||
server->send(403, "text/plain", "Cannot rename system files");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SdMan.exists(oldPath.c_str())) {
|
||||
server->send(404, "text/plain", "Source not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SdMan.exists(newPath.c_str())) {
|
||||
server->send(400, "text/plain", "Destination already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate cache
|
||||
BookCacheManager::migrateCache(oldPath, newPath);
|
||||
|
||||
if (SdMan.rename(oldPath.c_str(), newPath.c_str())) {
|
||||
Serial.printf("[%lu] [WEB] Renamed %s to %s\n", millis(), oldPath.c_str(), newPath.c_str());
|
||||
server->send(200, "text/plain", "Renamed successfully");
|
||||
} else {
|
||||
server->send(500, "text/plain", "Rename failed");
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
|
||||
@ -78,4 +78,6 @@ class CrossPointWebServer {
|
||||
void handleUploadPost() const;
|
||||
void handleCreateFolder() const;
|
||||
void handleDelete() const;
|
||||
void handleMove() const;
|
||||
void handleRename() const;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
103
src/util/BookCacheManager.cpp
Normal file
103
src/util/BookCacheManager.cpp
Normal file
@ -0,0 +1,103 @@
|
||||
#include "BookCacheManager.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include "../CrossPointState.h"
|
||||
#include "../RecentBooksStore.h"
|
||||
#include "StringUtils.h"
|
||||
|
||||
bool BookCacheManager::migrateCache(const String& oldPath, const String& newPath) {
|
||||
if (oldPath == newPath) return true;
|
||||
|
||||
// Update Recent Books list
|
||||
RECENT_BOOKS.updatePath(oldPath.c_str(), newPath.c_str());
|
||||
|
||||
// Update last opened book state if matches
|
||||
if (CrossPointState::getInstance().openEpubPath == oldPath.c_str()) {
|
||||
CrossPointState::getInstance().openEpubPath = newPath.c_str();
|
||||
CrossPointState::getInstance().saveToFile();
|
||||
}
|
||||
|
||||
if (!SdMan.exists(oldPath.c_str())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile item = SdMan.open(oldPath.c_str());
|
||||
if (!item) return false;
|
||||
|
||||
bool isDir = item.isDirectory();
|
||||
item.close();
|
||||
|
||||
if (isDir) {
|
||||
// Recursively migrate contents of the directory
|
||||
FsFile dir = SdMan.open(oldPath.c_str());
|
||||
FsFile entry = dir.openNextFile();
|
||||
char nameBuf[512];
|
||||
bool success = true;
|
||||
|
||||
while (entry) {
|
||||
entry.getName(nameBuf, sizeof(nameBuf));
|
||||
String fileName = String(nameBuf);
|
||||
entry.close();
|
||||
|
||||
String subOldPath = oldPath + "/" + fileName;
|
||||
String subNewPath = newPath + "/" + fileName;
|
||||
|
||||
if (!migrateCache(subOldPath, subNewPath)) {
|
||||
success = false;
|
||||
}
|
||||
entry = dir.openNextFile();
|
||||
}
|
||||
dir.close();
|
||||
return success;
|
||||
}
|
||||
|
||||
// It's a file. check if it's a supported book type
|
||||
if (!isSupportedFile(oldPath)) {
|
||||
return true; // Not a book, nothing to migrate
|
||||
}
|
||||
|
||||
String oldCache = getCachePath(oldPath);
|
||||
String newCache = getCachePath(newPath);
|
||||
|
||||
if (oldCache.isEmpty() || newCache.isEmpty() || oldCache == newCache) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (SdMan.exists(oldCache.c_str())) {
|
||||
if (SdMan.exists(newCache.c_str())) {
|
||||
Serial.printf("[%lu] [BCM] New cache already exists for %s, removing old cache\n", millis(), newPath.c_str());
|
||||
SdMan.removeDir(oldCache.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [BCM] Migrating cache: %s -> %s\n", millis(), oldCache.c_str(), newCache.c_str());
|
||||
if (SdMan.rename(oldCache.c_str(), newCache.c_str())) {
|
||||
return true;
|
||||
} else {
|
||||
Serial.printf("[%lu] [BCM] Failed to rename cache directory\n", millis());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true; // No old cache to migrate
|
||||
}
|
||||
|
||||
String BookCacheManager::getCachePath(const String& path) {
|
||||
if (!isSupportedFile(path)) return "";
|
||||
|
||||
auto hash = std::hash<std::string>{}(path.c_str());
|
||||
return String("/.crosspoint/") + getCachePrefix(path) + "_" + String(hash);
|
||||
}
|
||||
|
||||
bool BookCacheManager::isSupportedFile(const String& path) {
|
||||
return StringUtils::checkFileExtension(path, ".epub") || StringUtils::checkFileExtension(path, ".txt") ||
|
||||
StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtg") ||
|
||||
StringUtils::checkFileExtension(path, ".xth");
|
||||
}
|
||||
|
||||
String BookCacheManager::getCachePrefix(const String& path) {
|
||||
if (StringUtils::checkFileExtension(path, ".epub")) return "epub";
|
||||
if (StringUtils::checkFileExtension(path, ".txt")) return "txt";
|
||||
return "xtc"; // .xtc, .xtg, .xth
|
||||
}
|
||||
31
src/util/BookCacheManager.h
Normal file
31
src/util/BookCacheManager.h
Normal file
@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDCardManager.h>
|
||||
#include <WString.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
class BookCacheManager {
|
||||
public:
|
||||
/**
|
||||
* Migrate cache data for a file or directory.
|
||||
* If path is a directory, it recursively migrates all files within.
|
||||
*
|
||||
* @param oldPath Original absolute path
|
||||
* @param newPath New absolute path
|
||||
* @return true if migration was successful or no cache existed
|
||||
*/
|
||||
static bool migrateCache(const String& oldPath, const String& newPath);
|
||||
|
||||
/**
|
||||
* Get the cache directory path for a given book file.
|
||||
*
|
||||
* @param path Absolute path to the book file
|
||||
* @return Full path to the cache directory, or empty string if not a supported book type
|
||||
*/
|
||||
static String getCachePath(const String& path);
|
||||
|
||||
private:
|
||||
static bool isSupportedFile(const String& path);
|
||||
static String getCachePrefix(const String& path);
|
||||
};
|
||||
@ -61,23 +61,4 @@ bool checkFileExtension(const String& fileName, const char* extension) {
|
||||
return localFile.endsWith(localExtension);
|
||||
}
|
||||
|
||||
size_t utf8RemoveLastChar(std::string& str) {
|
||||
if (str.empty()) return 0;
|
||||
size_t pos = str.size() - 1;
|
||||
// Walk back to find the start of the last UTF-8 character
|
||||
// UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
|
||||
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
|
||||
--pos;
|
||||
}
|
||||
str.resize(pos);
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Truncate string by removing N UTF-8 characters from the end
|
||||
void utf8TruncateChars(std::string& str, const size_t numChars) {
|
||||
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
|
||||
utf8RemoveLastChar(str);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace StringUtils
|
||||
|
||||
@ -19,10 +19,4 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
|
||||
bool checkFileExtension(const std::string& fileName, const char* extension);
|
||||
bool checkFileExtension(const String& fileName, const char* extension);
|
||||
|
||||
// UTF-8 safe string truncation - removes one character from the end
|
||||
// Returns the new size after removing one UTF-8 character
|
||||
size_t utf8RemoveLastChar(std::string& str);
|
||||
|
||||
// Truncate string by removing N UTF-8 characters from the end
|
||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
||||
} // namespace StringUtils
|
||||
|
||||
Loading…
Reference in New Issue
Block a user