mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 22:57:50 +03:00
Compare commits
6 Commits
a01f2d6461
...
b8c275ded5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c275ded5 | ||
|
|
e5c0ddc9fa | ||
|
|
b1dcb7733b | ||
|
|
0d82b03981 | ||
|
|
5a97334ace | ||
|
|
d968f5fe2f |
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
|
```sh
|
||||||
pio run --target upload
|
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
|
## 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,
|
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||||
const EpdFontFamily::Style style) const {
|
const EpdFontFamily::Style style) const {
|
||||||
|
if (!text || maxWidth <= 0) return "";
|
||||||
|
|
||||||
std::string item = text;
|
std::string item = text;
|
||||||
int itemWidth = getTextWidth(fontId, item.c_str(), style);
|
const char* ellipsis = "...";
|
||||||
while (itemWidth > maxWidth && item.length() > 8) {
|
int textWidth = getTextWidth(fontId, item.c_str(), style);
|
||||||
item.replace(item.length() - 5, 5, "...");
|
if (textWidth <= maxWidth) {
|
||||||
itemWidth = getTextWidth(fontId, item.c_str(), style);
|
// 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
|
// 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;
|
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
|
#pragma once
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
#define REPLACEMENT_GLYPH 0xFFFD
|
#define REPLACEMENT_GLYPH 0xFFFD
|
||||||
|
|
||||||
uint32_t utf8NextCodepoint(const unsigned char** string);
|
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);
|
||||||
|
|||||||
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()
|
||||||
@ -4,6 +4,7 @@
|
|||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
#include <Utf8.h>
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@ -366,7 +367,7 @@ void HomeActivity::render() {
|
|||||||
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
|
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
|
// Remove "..." first, then remove one UTF-8 char, then add "..." back
|
||||||
lines.back().resize(lines.back().size() - 3); // Remove "..."
|
lines.back().resize(lines.back().size() - 3); // Remove "..."
|
||||||
StringUtils::utf8RemoveLastChar(lines.back());
|
utf8RemoveLastChar(lines.back());
|
||||||
lines.back().append("...");
|
lines.back().append("...");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -375,7 +376,7 @@ void HomeActivity::render() {
|
|||||||
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
||||||
while (wordWidth > maxLineWidth && !i.empty()) {
|
while (wordWidth > maxLineWidth && !i.empty()) {
|
||||||
// Word itself is too long, trim it (UTF-8 safe)
|
// Word itself is too long, trim it (UTF-8 safe)
|
||||||
StringUtils::utf8RemoveLastChar(i);
|
utf8RemoveLastChar(i);
|
||||||
// Check if we have room for ellipsis
|
// Check if we have room for ellipsis
|
||||||
std::string withEllipsis = i + "...";
|
std::string withEllipsis = i + "...";
|
||||||
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
|
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
|
||||||
@ -428,7 +429,7 @@ void HomeActivity::render() {
|
|||||||
if (!lastBookAuthor.empty()) {
|
if (!lastBookAuthor.empty()) {
|
||||||
std::string trimmedAuthor = lastBookAuthor;
|
std::string trimmedAuthor = lastBookAuthor;
|
||||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
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()) <
|
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
|
||||||
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.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)
|
// Trim author if too long (UTF-8 safe)
|
||||||
bool wasTrimmed = false;
|
bool wasTrimmed = false;
|
||||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||||
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
utf8RemoveLastChar(trimmedAuthor);
|
||||||
wasTrimmed = true;
|
wasTrimmed = true;
|
||||||
}
|
}
|
||||||
if (wasTrimmed && !trimmedAuthor.empty()) {
|
if (wasTrimmed && !trimmedAuthor.empty()) {
|
||||||
// Make room for ellipsis
|
// Make room for ellipsis
|
||||||
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
|
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
|
||||||
!trimmedAuthor.empty()) {
|
!trimmedAuthor.empty()) {
|
||||||
StringUtils::utf8RemoveLastChar(trimmedAuthor);
|
utf8RemoveLastChar(trimmedAuthor);
|
||||||
}
|
}
|
||||||
trimmedAuthor.append("...");
|
trimmedAuthor.append("...");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -570,8 +570,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
|||||||
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
|
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
|
||||||
titleMarginLeftAdjusted = titleMarginLeft;
|
titleMarginLeftAdjusted = titleMarginLeft;
|
||||||
}
|
}
|
||||||
while (titleWidth > availableTitleSpace && title.length() > 11) {
|
if (titleWidth > availableTitleSpace) {
|
||||||
title.replace(title.length() - 8, 8, "...");
|
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace);
|
||||||
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
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();
|
std::string title = txt->getTitle();
|
||||||
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
while (titleWidth > availableTextWidth && title.length() > 11) {
|
if (titleWidth > availableTextWidth) {
|
||||||
title.replace(title.length() - 8, 8, "...");
|
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTextWidth);
|
||||||
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,36 @@ void clearEpubCacheIfNeeded(const String& filePath) {
|
|||||||
Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str());
|
Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String normalizeWebPath(const String& inputPath) {
|
||||||
|
if (inputPath.isEmpty() || inputPath == "/") {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
std::string normalized = FsHelpers::normalisePath(inputPath.c_str());
|
||||||
|
String result = normalized.c_str();
|
||||||
|
if (result.isEmpty()) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
if (!result.startsWith("/")) {
|
||||||
|
result = "/" + result;
|
||||||
|
}
|
||||||
|
if (result.length() > 1 && result.endsWith("/")) {
|
||||||
|
result = result.substring(0, result.length() - 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isProtectedItemName(const String& name) {
|
||||||
|
if (name.startsWith(".")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||||
|
if (name.equals(HIDDEN_ITEMS[i])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
// File listing page template - now using generated headers:
|
// File listing page template - now using generated headers:
|
||||||
@ -109,6 +139,12 @@ void CrossPointWebServer::begin() {
|
|||||||
// Create folder endpoint
|
// Create folder endpoint
|
||||||
server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
|
server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
|
||||||
|
|
||||||
|
// Rename file endpoint
|
||||||
|
server->on("/rename", HTTP_POST, [this] { handleRename(); });
|
||||||
|
|
||||||
|
// Move file endpoint
|
||||||
|
server->on("/move", HTTP_POST, [this] { handleMove(); });
|
||||||
|
|
||||||
// Delete file/folder endpoint
|
// Delete file/folder endpoint
|
||||||
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
|
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
|
||||||
|
|
||||||
@ -705,6 +741,181 @@ void CrossPointWebServer::handleCreateFolder() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServer::handleRename() const {
|
||||||
|
if (!server->hasArg("path") || !server->hasArg("name")) {
|
||||||
|
server->send(400, "text/plain", "Missing path or new name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String itemPath = normalizeWebPath(server->arg("path"));
|
||||||
|
String newName = server->arg("name");
|
||||||
|
newName.trim();
|
||||||
|
|
||||||
|
if (itemPath.isEmpty() || itemPath == "/") {
|
||||||
|
server->send(400, "text/plain", "Invalid path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newName.isEmpty()) {
|
||||||
|
server->send(400, "text/plain", "New name cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newName.indexOf('/') >= 0 || newName.indexOf('\\') >= 0) {
|
||||||
|
server->send(400, "text/plain", "Invalid file name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isProtectedItemName(newName)) {
|
||||||
|
server->send(403, "text/plain", "Cannot rename to protected name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
|
||||||
|
if (isProtectedItemName(itemName)) {
|
||||||
|
server->send(403, "text/plain", "Cannot rename protected item");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newName == itemName) {
|
||||||
|
server->send(200, "text/plain", "Name unchanged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SdMan.exists(itemPath.c_str())) {
|
||||||
|
server->send(404, "text/plain", "Item not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile file = SdMan.open(itemPath.c_str());
|
||||||
|
if (!file) {
|
||||||
|
server->send(500, "text/plain", "Failed to open file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
file.close();
|
||||||
|
server->send(400, "text/plain", "Only files can be renamed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String parentPath = itemPath.substring(0, itemPath.lastIndexOf('/'));
|
||||||
|
if (parentPath.isEmpty()) {
|
||||||
|
parentPath = "/";
|
||||||
|
}
|
||||||
|
String newPath = parentPath;
|
||||||
|
if (!newPath.endsWith("/")) {
|
||||||
|
newPath += "/";
|
||||||
|
}
|
||||||
|
newPath += newName;
|
||||||
|
|
||||||
|
if (SdMan.exists(newPath.c_str())) {
|
||||||
|
file.close();
|
||||||
|
server->send(409, "text/plain", "Target already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearEpubCacheIfNeeded(itemPath);
|
||||||
|
const bool success = file.rename(newPath.c_str());
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Serial.printf("[%lu] [WEB] Renamed file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||||
|
server->send(200, "text/plain", "Renamed successfully");
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [WEB] Failed to rename file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||||
|
server->send(500, "text/plain", "Failed to rename file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServer::handleMove() const {
|
||||||
|
if (!server->hasArg("path") || !server->hasArg("dest")) {
|
||||||
|
server->send(400, "text/plain", "Missing path or destination");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String itemPath = normalizeWebPath(server->arg("path"));
|
||||||
|
String destPath = normalizeWebPath(server->arg("dest"));
|
||||||
|
|
||||||
|
if (itemPath.isEmpty() || itemPath == "/") {
|
||||||
|
server->send(400, "text/plain", "Invalid path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (destPath.isEmpty()) {
|
||||||
|
server->send(400, "text/plain", "Invalid destination");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
|
||||||
|
if (isProtectedItemName(itemName)) {
|
||||||
|
server->send(403, "text/plain", "Cannot move protected item");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (destPath != "/") {
|
||||||
|
const String destName = destPath.substring(destPath.lastIndexOf('/') + 1);
|
||||||
|
if (isProtectedItemName(destName)) {
|
||||||
|
server->send(403, "text/plain", "Cannot move into protected folder");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SdMan.exists(itemPath.c_str())) {
|
||||||
|
server->send(404, "text/plain", "Item not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile file = SdMan.open(itemPath.c_str());
|
||||||
|
if (!file) {
|
||||||
|
server->send(500, "text/plain", "Failed to open file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
file.close();
|
||||||
|
server->send(400, "text/plain", "Only files can be moved");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SdMan.exists(destPath.c_str())) {
|
||||||
|
file.close();
|
||||||
|
server->send(404, "text/plain", "Destination not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FsFile destDir = SdMan.open(destPath.c_str());
|
||||||
|
if (!destDir || !destDir.isDirectory()) {
|
||||||
|
if (destDir) {
|
||||||
|
destDir.close();
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
server->send(400, "text/plain", "Destination is not a folder");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
destDir.close();
|
||||||
|
|
||||||
|
String newPath = destPath;
|
||||||
|
if (!newPath.endsWith("/")) {
|
||||||
|
newPath += "/";
|
||||||
|
}
|
||||||
|
newPath += itemName;
|
||||||
|
|
||||||
|
if (newPath == itemPath) {
|
||||||
|
file.close();
|
||||||
|
server->send(200, "text/plain", "Already in destination");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (SdMan.exists(newPath.c_str())) {
|
||||||
|
file.close();
|
||||||
|
server->send(409, "text/plain", "Target already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearEpubCacheIfNeeded(itemPath);
|
||||||
|
const bool success = file.rename(newPath.c_str());
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Serial.printf("[%lu] [WEB] Moved file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||||
|
server->send(200, "text/plain", "Moved successfully");
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [WEB] Failed to move file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str());
|
||||||
|
server->send(500, "text/plain", "Failed to move file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void CrossPointWebServer::handleDelete() const {
|
void CrossPointWebServer::handleDelete() const {
|
||||||
// Get path from form data
|
// Get path from form data
|
||||||
if (!server->hasArg("path")) {
|
if (!server->hasArg("path")) {
|
||||||
|
|||||||
@ -77,5 +77,7 @@ class CrossPointWebServer {
|
|||||||
void handleUpload() const;
|
void handleUpload() const;
|
||||||
void handleUploadPost() const;
|
void handleUploadPost() const;
|
||||||
void handleCreateFolder() const;
|
void handleCreateFolder() const;
|
||||||
|
void handleRename() const;
|
||||||
|
void handleMove() const;
|
||||||
void handleDelete() const;
|
void handleDelete() const;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -322,25 +322,47 @@
|
|||||||
.folder-btn:hover {
|
.folder-btn:hover {
|
||||||
background-color: #d68910;
|
background-color: #d68910;
|
||||||
}
|
}
|
||||||
/* Delete button styles */
|
/* Action button styles */
|
||||||
.delete-btn {
|
.delete-btn,
|
||||||
|
.rename-btn,
|
||||||
|
.move-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #95a5a6;
|
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
.delete-btn {
|
||||||
|
color: #95a5a6;
|
||||||
|
}
|
||||||
.delete-btn:hover {
|
.delete-btn:hover {
|
||||||
background-color: #fee;
|
background-color: #fee;
|
||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
}
|
}
|
||||||
|
.rename-btn {
|
||||||
|
color: #2980b9;
|
||||||
|
}
|
||||||
|
.rename-btn:hover {
|
||||||
|
background-color: #e8f4fd;
|
||||||
|
}
|
||||||
|
.move-btn {
|
||||||
|
color: #16a085;
|
||||||
|
}
|
||||||
|
.move-btn:hover {
|
||||||
|
background-color: #e6f7f4;
|
||||||
|
}
|
||||||
.actions-col {
|
.actions-col {
|
||||||
width: 60px;
|
width: 140px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.action-icon-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
/* Failed uploads banner */
|
/* Failed uploads banner */
|
||||||
.failed-uploads-banner {
|
.failed-uploads-banner {
|
||||||
background-color: #fff3cd;
|
background-color: #fff3cd;
|
||||||
@ -463,6 +485,32 @@
|
|||||||
.delete-btn-cancel:hover {
|
.delete-btn-cancel:hover {
|
||||||
background-color: #7f8c8d;
|
background-color: #7f8c8d;
|
||||||
}
|
}
|
||||||
|
.rename-btn-confirm {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.rename-btn-confirm:hover {
|
||||||
|
background-color: #2e86c1;
|
||||||
|
}
|
||||||
|
.move-btn-confirm {
|
||||||
|
background-color: #16a085;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.move-btn-confirm:hover {
|
||||||
|
background-color: #138d75;
|
||||||
|
}
|
||||||
.loader-container {
|
.loader-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -558,12 +606,17 @@
|
|||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
.actions-col {
|
.actions-col {
|
||||||
width: 40px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
.delete-btn {
|
.delete-btn,
|
||||||
|
.rename-btn,
|
||||||
|
.move-btn {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
|
.action-icon-group {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
.no-files {
|
.no-files {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@ -665,6 +718,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename Modal -->
|
||||||
|
<div class="modal-overlay" id="renameModal">
|
||||||
|
<div class="modal">
|
||||||
|
<button class="modal-close" onclick="closeRenameModal()">×</button>
|
||||||
|
<h3>✏️ Rename File</h3>
|
||||||
|
<div class="folder-form">
|
||||||
|
<p class="file-info">Renaming <strong id="renameItemName"></strong></p>
|
||||||
|
<input type="text" id="renameNewName" class="folder-input" placeholder="New file name...">
|
||||||
|
<input type="hidden" id="renameItemPath">
|
||||||
|
<button class="rename-btn-confirm" onclick="confirmRename()">Rename</button>
|
||||||
|
<button class="delete-btn-cancel" onclick="closeRenameModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Move Modal -->
|
||||||
|
<div class="modal-overlay" id="moveModal">
|
||||||
|
<div class="modal">
|
||||||
|
<button class="modal-close" onclick="closeMoveModal()">×</button>
|
||||||
|
<h3>📂 Move File</h3>
|
||||||
|
<div class="folder-form">
|
||||||
|
<p class="file-info">Moving <strong id="moveItemName"></strong></p>
|
||||||
|
<input type="text" id="moveDestPath" class="folder-input" list="moveFolderOptions" placeholder="/Destination/Folder">
|
||||||
|
<datalist id="moveFolderOptions"></datalist>
|
||||||
|
<input type="hidden" id="moveItemPath">
|
||||||
|
<button class="move-btn-confirm" onclick="confirmMove()">Move</button>
|
||||||
|
<button class="delete-btn-cancel" onclick="closeMoveModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// get current path from query parameter
|
// get current path from query parameter
|
||||||
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
||||||
@ -760,7 +844,7 @@
|
|||||||
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`;
|
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`;
|
||||||
fileTableContent += '<td>Folder</td>';
|
fileTableContent += '<td>Folder</td>';
|
||||||
fileTableContent += '<td>-</td>';
|
fileTableContent += '<td>-</td>';
|
||||||
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button></td>`;
|
fileTableContent += `<td class="actions-col"><div class="action-icon-group"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button></div></td>`;
|
||||||
fileTableContent += '</tr>';
|
fileTableContent += '</tr>';
|
||||||
} else {
|
} else {
|
||||||
let filePath = currentPath;
|
let filePath = currentPath;
|
||||||
@ -773,7 +857,11 @@
|
|||||||
fileTableContent += '</td>';
|
fileTableContent += '</td>';
|
||||||
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
||||||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||||
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button></td>`;
|
fileTableContent += `<td class="actions-col"><div class="action-icon-group">`;
|
||||||
|
fileTableContent += `<button class="move-btn" onclick="openMoveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}' )" title="Move file">📂</button>`;
|
||||||
|
fileTableContent += `<button class="rename-btn" onclick="openRenameModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}' )" title="Rename file">✏️</button>`;
|
||||||
|
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button>`;
|
||||||
|
fileTableContent += `</div></td>`;
|
||||||
fileTableContent += '</tr>';
|
fileTableContent += '</tr>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1175,6 +1263,170 @@ function retryAllFailedUploads() {
|
|||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename functions
|
||||||
|
function openRenameModal(name, path) {
|
||||||
|
document.getElementById('renameItemName').textContent = '📄 ' + name;
|
||||||
|
document.getElementById('renameItemPath').value = path;
|
||||||
|
document.getElementById('renameNewName').value = name;
|
||||||
|
document.getElementById('renameModal').classList.add('open');
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById('renameNewName');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRenameModal() {
|
||||||
|
document.getElementById('renameModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRename() {
|
||||||
|
const path = document.getElementById('renameItemPath').value;
|
||||||
|
const newName = document.getElementById('renameNewName').value.trim();
|
||||||
|
|
||||||
|
if (!newName) {
|
||||||
|
alert('Please enter a new name.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newName.includes('/') || newName.includes('\\')) {
|
||||||
|
alert('File name cannot include slashes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('path', path);
|
||||||
|
formData.append('name', newName);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/rename', true);
|
||||||
|
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to rename: ' + xhr.responseText);
|
||||||
|
}
|
||||||
|
closeRenameModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
alert('Failed to rename - network error');
|
||||||
|
closeRenameModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move functions
|
||||||
|
function normalizePath(path) {
|
||||||
|
if (!path) return '/';
|
||||||
|
let normalized = path.trim();
|
||||||
|
if (!normalized.startsWith('/')) normalized = '/' + normalized;
|
||||||
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||||
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParentPath(path) {
|
||||||
|
const normalized = normalizePath(path);
|
||||||
|
if (normalized === '/') return '/';
|
||||||
|
const idx = normalized.lastIndexOf('/');
|
||||||
|
return idx <= 0 ? '/' : normalized.slice(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoveFolderOptions() {
|
||||||
|
const options = new Set();
|
||||||
|
options.add('/');
|
||||||
|
const parent = getParentPath(currentPath);
|
||||||
|
if (parent) options.add(parent);
|
||||||
|
|
||||||
|
async function fetchFolders(path) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/files?path=' + encodeURIComponent(path));
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootFiles = await fetchFolders('/');
|
||||||
|
rootFiles.forEach(file => {
|
||||||
|
if (file.isDirectory) {
|
||||||
|
options.add('/' + file.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentPath !== '/') {
|
||||||
|
const currentFiles = await fetchFolders(currentPath);
|
||||||
|
currentFiles.forEach(file => {
|
||||||
|
if (file.isDirectory) {
|
||||||
|
let folderPath = currentPath;
|
||||||
|
if (!folderPath.endsWith('/')) folderPath += '/';
|
||||||
|
folderPath += file.name;
|
||||||
|
options.add(folderPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataList = document.getElementById('moveFolderOptions');
|
||||||
|
dataList.innerHTML = '';
|
||||||
|
Array.from(options).sort().forEach(path => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = path;
|
||||||
|
dataList.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveModal(name, path) {
|
||||||
|
document.getElementById('moveItemName').textContent = '📄 ' + name;
|
||||||
|
document.getElementById('moveItemPath').value = path;
|
||||||
|
document.getElementById('moveDestPath').value = currentPath === '/' ? '/' : currentPath;
|
||||||
|
document.getElementById('moveModal').classList.add('open');
|
||||||
|
loadMoveFolderOptions();
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('moveDestPath').focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMoveModal() {
|
||||||
|
document.getElementById('moveModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMove() {
|
||||||
|
const path = document.getElementById('moveItemPath').value;
|
||||||
|
const destPath = normalizePath(document.getElementById('moveDestPath').value);
|
||||||
|
|
||||||
|
if (!destPath) {
|
||||||
|
alert('Please enter a destination folder.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('path', path);
|
||||||
|
formData.append('dest', destPath);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/move', true);
|
||||||
|
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to move: ' + xhr.responseText);
|
||||||
|
}
|
||||||
|
closeMoveModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
alert('Failed to move - network error');
|
||||||
|
closeMoveModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete functions
|
// Delete functions
|
||||||
function openDeleteModal(name, path, isFolder) {
|
function openDeleteModal(name, path, isFolder) {
|
||||||
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
|
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
|
||||||
|
|||||||
@ -61,23 +61,4 @@ bool checkFileExtension(const String& fileName, const char* extension) {
|
|||||||
return localFile.endsWith(localExtension);
|
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
|
} // 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 std::string& fileName, const char* extension);
|
||||||
bool checkFileExtension(const 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
|
} // namespace StringUtils
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user