diff --git a/CMakeLists.txt b/CMakeLists.txt
index ff669ab..6bbcae1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.25)
project(UltraLightBrowser
- VERSION 2.4.0
+ VERSION 2.4.1
DESCRIPTION "Minimal, Ultra-Fast Windows 11 Native Browser Shell (macOS Safari UI Edition)"
LANGUAGES CXX C
)
diff --git a/README.md b/README.md
index c959c3b..c754616 100644
--- a/README.md
+++ b/README.md
@@ -294,6 +294,7 @@ tests/ 单元测试、页面资源检查、Windows 界面
## 最近更新
+- **v2.4.1**:修复书签 HTML 导入:正确解析属性空格、制表符和完整属性名,避免把 DATA-HREF 或标题中的文字误当网址;按标准标记识别不同语言的收藏栏;超长网址不再虚报导入成功。新增覆盖以上情况的回归测试。
- **v2.4.0**:新增「显卡视频增强」(设置 → 高级,默认开):让 NVIDIA RTX 显卡做视频超分辨率和 SDR 转 HDR、Intel 显卡做超分;需在 NVIDIA App 里开启,接电源、画面被放大时生效。「播放诊断」显示视频放大倍数。
- **v2.3.0**:音频增强的最后一级换成真峰值限幅器:采样点和采样点之间的峰值都压在 -1 dBTP 以下,音量放大到 300% 也不会削波破音;约增加 4ms 延迟。少数安全策略严格、不允许加载的网站自动改用原来的压缩保护,「播放诊断」会显示用的是哪一种。
- **v2.2.3**:我的线路未连接时显示原因(本机没有 IPv6 / 连不到入口服务器 / 链接失效),便于排查。
diff --git a/resources/resource.rc b/resources/resource.rc
index 738879d..de7c2fc 100644
--- a/resources/resource.rc
+++ b/resources/resource.rc
@@ -34,8 +34,8 @@ IDI_APP_ICON ICON "app.ico"
// Version information
VS_VERSION_INFO VERSIONINFO
-FILEVERSION 2,4,0,0
-PRODUCTVERSION 2,4,0,0
+FILEVERSION 2,4,1,0
+PRODUCTVERSION 2,4,1,0
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
FILEFLAGS 0
FILEOS VOS_NT_WINDOWS32
@@ -48,12 +48,12 @@ BEGIN
BEGIN
VALUE "CompanyName", "UltraLightBrowser"
VALUE "FileDescription", "UltraLight Browser - Minimal, Ultra-Fast Windows 11 Native Browser (Safari-style UI)"
- VALUE "FileVersion", "2.4.0.0"
+ VALUE "FileVersion", "2.4.1.0"
VALUE "InternalName", "UltraLightBrowser"
VALUE "LegalCopyright", "Copyright (C) 2026 Freecode100Year"
VALUE "OriginalFilename", "UltraLightBrowser.exe"
VALUE "ProductName", "UltraLight Browser"
- VALUE "ProductVersion", "2.4.0.0"
+ VALUE "ProductVersion", "2.4.1.0"
END
END
BLOCK "VarFileInfo"
diff --git a/src/Library.cpp b/src/Library.cpp
index bd41f8d..d4189ad 100644
--- a/src/Library.cpp
+++ b/src/Library.cpp
@@ -133,18 +133,31 @@ std::size_t FindCI(const std::string& hay, const std::string& needle, std::size_
}
std::string AttrValue(const std::string& tag, const std::string& name) {
- auto pos = FindCI(tag, name + "=", 0);
- if (pos == std::string::npos) return {};
- pos += name.size() + 1;
- if (pos >= tag.size()) return {};
- const char quote = tag[pos];
- if (quote == '"' || quote == '\'') {
- const auto end = tag.find(quote, pos + 1);
- if (end == std::string::npos) return {};
- return tag.substr(pos + 1, end - pos - 1);
+ constexpr const char* whitespace = " \t\r\n\f";
+ // Walk complete attributes, skipping quoted values so DATA-HREF and text
+ // such as TITLE="href=..." cannot be mistaken for the actual link.
+ auto pos = tag.find_first_of(whitespace);
+ while (pos != std::string::npos && pos < tag.size()) {
+ pos = tag.find_first_not_of(whitespace, pos);
+ if (pos == std::string::npos) break;
+ const auto start = pos;
+ pos = tag.find_first_of("= \t\r\n\f", pos);
+ if (pos == std::string::npos) break;
+ const auto attribute = Lower(tag.substr(start, pos - start));
+ pos = tag.find_first_not_of(whitespace, pos);
+ if (pos == std::string::npos) break;
+ if (tag[pos] != '=') continue; // Boolean attribute; parse the next one.
+ pos = tag.find_first_not_of(whitespace, pos + 1);
+ if (pos == std::string::npos) break;
+ const char quote = tag[pos];
+ const bool quoted = quote == '"' || quote == '\'';
+ const auto valueStart = pos + (quoted ? 1 : 0);
+ const auto end = quoted ? tag.find(quote, valueStart) : tag.find_first_of(whitespace, valueStart);
+ if (quoted && end == std::string::npos) break;
+ if (attribute == name) return tag.substr(valueStart, end == std::string::npos ? end : end - valueStart);
+ pos = end == std::string::npos ? end : end + (quoted ? 1 : 0);
}
- const auto end = tag.find_first_of(" >", pos);
- return tag.substr(pos, end == std::string::npos ? std::string::npos : end - pos);
+ return {};
}
} // namespace
@@ -394,8 +407,25 @@ int Library::ImportNetscapeHtml(const std::string& html, std::int64_t now) {
while (pos < html.size()) {
const auto lt = html.find('<', pos);
if (lt == std::string::npos) break;
- const auto gt = html.find('>', lt);
- if (gt == std::string::npos) break;
+ if (html.compare(lt, 4, "", lt + 4);
+ if (end == std::string::npos) break;
+ pos = end + 3;
+ continue;
+ }
+ auto gt = lt + 1;
+ char quote = 0;
+ for (; gt < html.size(); ++gt) {
+ const char c = html[gt];
+ if (quote) {
+ if (c == quote) quote = 0;
+ } else if (c == '"' || c == '\'') {
+ quote = c;
+ } else if (c == '>') {
+ break;
+ }
+ }
+ if (gt == html.size()) break;
std::string tag = html.substr(lt + 1, gt - lt - 1);
std::string name = Lower(tag.substr(0, tag.find_first_of(" \t\r\n")));
pos = gt + 1;
@@ -403,6 +433,7 @@ int Library::ImportNetscapeHtml(const std::string& html, std::int64_t now) {
const auto close = FindCI(html, "", pos);
if (close == std::string::npos) break;
pendingFolder = Clip(Trim(HtmlUnescape(html.substr(pos, close - pos))), 128);
+ if (Lower(AttrValue(tag, "personal_toolbar_folder")) == "true") pendingFolder = kFavoritesFolder;
pos = close + 5;
} else if (name == "dl") {
folderStack.push_back(pendingFolder);
@@ -415,7 +446,7 @@ int Library::ImportNetscapeHtml(const std::string& html, std::int64_t now) {
const std::string url = HtmlUnescape(AttrValue(tag, "href"));
const std::string title = Trim(HtmlUnescape(html.substr(pos, close - pos)));
pos = close + 4;
- if (!IsWebUrl(url)) continue;
+ if (!IsWebUrl(url) || url.size() > kMaxUrl) continue;
std::string folder = kBookmarksFolder;
for (auto it = folderStack.rbegin(); it != folderStack.rend(); ++it) {
if (!it->empty()) { folder = *it; break; }
@@ -427,8 +458,7 @@ int Library::ImportNetscapeHtml(const std::string& html, std::int64_t now) {
}
AddFolder(folder);
if (!existing.insert(folder + "\n" + url).second) continue;
- AddBookmark(title, url, folder, now);
- ++imported;
+ if (!AddBookmark(title, url, folder, now).empty()) ++imported;
}
}
return imported;
diff --git a/tests/library_test.cpp b/tests/library_test.cpp
index 430fd6e..996fe5d 100644
--- a/tests/library_test.cpp
+++ b/tests/library_test.cpp
@@ -7,10 +7,41 @@
using namespace UltraLight;
+static void TestBookmarkImport(const std::filesystem::path& dir) {
+ struct Case { const char* name; std::string html; int count; std::string url; std::string folder; };
+ const Case cases[] = {
+ {"attribute whitespace", R"(Space)", 1, "https://space.example/", kBookmarksFolder},
+ {"attribute boundary", R"(Right)", 1, "https://right.example/", kBookmarksFolder},
+ {"quoted attribute content", R"(Right)", 1, "https://right.example/", kBookmarksFolder},
+ {"quoted tag delimiter", R"(Right)", 1, "https://right.example/", kBookmarksFolder},
+ {"comment text is not markup", R"(Right)", 1, "https://right.example/", kBookmarksFolder},
+ {"tab-delimited unquoted URL", "Tab", 1, "https://tab.example/", kBookmarksFolder},
+ {"boolean and mixed-case attributes", R"(Case)", 1, "https://case.example/", kBookmarksFolder},
+ {"data attribute alone is not a link", R"(Not a link)", 0, "", kBookmarksFolder},
+ {"unterminated attribute rejected", R"(Meine FavoritenBar
)", 1, "https://bar.example/", kFavoritesFolder},
+ {"non-toolbar folder preserved", R"(Meine Favoriten
Folder
)", 1, "https://folder.example/", "Meine Favoriten"},
+ {"URL size boundary accepted", "Large", 1, "https://large.example/" + std::string(8170, 'a'), kBookmarksFolder},
+ {"oversized URL rejected", "Large", 0, "", kBookmarksFolder},
+ };
+ int failures = 0;
+ for (const auto& c : cases) {
+ Library lib(dir / "import-cases");
+ const int count = lib.ImportNetscapeHtml(c.html, 1);
+ if (count != c.count || lib.Bookmarks().size() != static_cast(c.count) ||
+ (c.count && (lib.Bookmarks().front().url != c.url || lib.Bookmarks().front().folder != c.folder))) {
+ std::cerr << "FAIL: bookmark import " << c.name << '\n';
+ ++failures;
+ }
+ }
+ assert(failures == 0);
+}
+
int main() {
const auto dir = std::filesystem::temp_directory_path() / "ulb-library-test";
std::filesystem::remove_all(dir);
std::filesystem::create_directories(dir);
+ TestBookmarkImport(dir);
std::ofstream(dir / "history.json") << R"([{"url":"https://old.example/","title":"x","last":1,"visits":1}])";
std::ofstream(dir / "session.json") << "[]";
{