F1 step 1: names, read-only rules and the packed listing (host-tested)

lib/files: path parts; names checked for rename and new folder; why
something can't be renamed, moved or deleted (the Gemini cache, a file
being written or a folder holding one, the firmware's top-level
folders), and why it can't go into a folder; the viewer for a file by
its name, with a sniff for text. FileList keeps a folder's entries
packed, 256 at most, the first 256 by name whatever order the card lists
them in, and sorts by name, date or size with folders first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 07:37:36 +02:00
co-authored by Claude Opus 5.5
parent 63bae576ef
commit 7ab8f043d0
6 changed files with 557 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
#include "file_names.h"
#include <cctype>
namespace roro::files {
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes"};
const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0];
std::string parentOf(const std::string& path) {
size_t slash = path.rfind('/');
return slash == std::string::npos || slash == 0 ? "/" : path.substr(0, slash);
}
std::string baseName(const std::string& path) {
size_t slash = path.rfind('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
std::string joinPath(const std::string& folder, const std::string& name) {
return folder == "/" ? "/" + name : folder + "/" + name;
}
std::string extensionOf(const std::string& name) {
size_t dot = name.rfind('.');
if (dot == std::string::npos || dot == 0) return "";
std::string ext = name.substr(dot + 1);
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return ext;
}
bool isInside(const std::string& path, const std::string& folder) {
if (folder == "/") return true;
if (path.compare(0, folder.size(), folder) != 0) return false;
return path.size() == folder.size() || path[folder.size()] == '/';
}
std::string checkName(const std::string& name) {
if (name.empty()) return "A name can't be empty";
if (name == "." || name == "..") return "\".\" and \"..\" aren't names";
if (name.size() > 64) return "A name can be 64 characters at most";
for (char c : name) {
if (static_cast<unsigned char>(c) < 0x20) return "A name can't contain control characters";
for (char bad : std::string("/\\:*?\"<>|"))
if (c == bad) return std::string("A name can't contain ") + c;
}
if (name.back() == '.' || name.back() == ' ') return "A name can't end with a dot or a space";
return "";
}
std::string whyReadOnly(const std::string& path, const std::vector<std::string>& inUse) {
if (path == "/" || path.empty()) return "That's the card itself";
if (isInside(path, kGeminiCache)) return std::string(kGeminiCache) + " is the Gemini App's working space";
for (const std::string& open : inUse) {
if (open == path) return "It's being written right now";
if (isInside(open, path)) return "It holds a file that's being written right now";
}
for (size_t i = 0; i < kFirmwareFolderCount; i++)
if (path == kFirmwareFolders[i]) return std::string("The firmware keeps its files in ") + path;
return "";
}
std::string whyNotInto(const std::string& source, const std::string& into) {
if (isInside(into, kGeminiCache)) return std::string(kGeminiCache) + " is the Gemini App's working space";
if (isInside(into, source)) return "A folder can't go inside itself";
if (parentOf(source) == into) return "It's already there";
return "";
}
std::string copyName(const std::string& name, int n) {
size_t dot = name.rfind('.');
std::string suffix = " (" + std::to_string(n) + ")";
if (dot == std::string::npos || dot == 0) return name + suffix;
return name.substr(0, dot) + suffix + name.substr(dot);
}
FileKind kindOf(const std::string& name) {
std::string ext = extensionOf(name);
if (ext == "gpx") return FileKind::Gpx;
if (ext == "pcap") return FileKind::Pcap;
if (ext == "ota") return FileKind::Ota;
for (const char* text : {"txt", "log", "gmi", "csv", "md", "json", "ini", "conf", "ir"})
if (ext == text) return FileKind::Text;
return FileKind::Unknown;
}
bool opensAtEnd(const std::string& name) { return extensionOf(name) == "log"; }
bool looksLikeText(const uint8_t* data, size_t len) {
size_t odd = 0;
for (size_t i = 0; i < len; i++) {
uint8_t b = data[i];
if (b == 0) return false;
if (b < 0x20 && b != '\n' && b != '\r' && b != '\t') odd++;
}
return odd * 20 <= len; // a stray control character or two is still text
}
} // namespace roro::files