feat: Add bookmark management

- Add BookmarkManager class for bookmark CRUD operations
- Store bookmarks in JSON format at ~/.config/tut/bookmarks.json
- Add keyboard shortcuts: B (add), D (remove)
- Add :bookmarks/:bm command to view bookmark list
- Bookmarks page shows clickable links
- Auto-save on add/remove, auto-load on startup
This commit is contained in:
m1ngsama 2025-12-27 15:29:44 +08:00
parent c6b1a9ac41
commit a4c95a6527
7 changed files with 472 additions and 17 deletions

View file

@ -88,6 +88,7 @@ add_executable(tut2
src/browser_v2.cpp src/browser_v2.cpp
src/http_client.cpp src/http_client.cpp
src/input_handler.cpp src/input_handler.cpp
src/bookmark.cpp
src/render/terminal.cpp src/render/terminal.cpp
src/render/renderer.cpp src/render/renderer.cpp
src/render/layout.cpp src/render/layout.cpp

View file

@ -1,9 +1,9 @@
# TUT 2.0 - 下次继续从这里开始 # TUT 2.0 - 下次继续从这里开始
## 当前位置 ## 当前位置
- **阶段**: Phase 4 - 图片支持 (已完成!) - **阶段**: Phase 5 - 书签管理 (已完成!)
- **进度**: 图片 ASCII Art 渲染已集成到浏览器 - **进度**: 书签添加/删除/持久化存储已完成
- **最后提交**: `feat: Add image ASCII art rendering support` - **最后提交**: `feat: Add bookmark management`
## 立即可做的事 ## 立即可做的事
@ -15,21 +15,25 @@ curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \
# 重新编译 # 重新编译
cmake --build build_v2 cmake --build build_v2
# 编译后会自动支持 PNG/JPEG/GIF/BMP 图片格式
``` ```
### 2. 测试图片渲染 ### 2. 使用书签功能
```bash - **B** - 添加当前页面到书签
# 访问有图片的网页 - **D** - 从书签中移除当前页面
./build_v2/tut2 https://httpbin.org/html - **:bookmarks** 或 **:bm** - 查看书签列表
# 或访问包含图片的任意网页 书签存储在 `~/.config/tut/bookmarks.json`
./build_v2/tut2 https://example.com
```
## 已完成的功能清单 ## 已完成的功能清单
### Phase 5 - 书签管理
- [x] 书签数据结构 (URL, 标题, 添加时间)
- [x] JSON 持久化存储 (~/.config/tut/bookmarks.json)
- [x] 添加书签 (B 键)
- [x] 删除书签 (D 键)
- [x] 书签列表页面 (:bookmarks 命令)
- [x] 书签链接可点击跳转
### Phase 4 - 图片支持 ### Phase 4 - 图片支持
- [x] `<img>` 标签解析 (src, alt, width, height) - [x] `<img>` 标签解析 (src, alt, width, height)
- [x] 图片占位符显示 `[alt text]``[Image: filename]` - [x] 图片占位符显示 `[alt text]``[Image: filename]`
@ -127,10 +131,10 @@ cmake --build build_v2
## 下一步功能优先级 ## 下一步功能优先级
1. **书签管理** - 添加/删除书签,书签列表页面,持久化存储 1. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
2. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求 2. **更多表单交互** - 文本输入编辑,下拉选择
3. **更多表单交互** - 文本输入编辑,下拉选择 3. **图片缓存** - 避免重复下载相同图片
4. **图片缓存** - 避免重复下载相同图片 4. **历史记录管理** - 持久化历史记录,历史页面
## 恢复对话时说 ## 恢复对话时说

248
src/bookmark.cpp Normal file
View file

@ -0,0 +1,248 @@
#include "bookmark.h"
#include <fstream>
#include <sstream>
#include <algorithm>
#include <sys/stat.h>
#include <cstdlib>
namespace tut {
BookmarkManager::BookmarkManager() {
load();
}
BookmarkManager::~BookmarkManager() {
save();
}
std::string BookmarkManager::get_config_dir() {
const char* home = std::getenv("HOME");
if (!home) {
home = "/tmp";
}
return std::string(home) + "/.config/tut";
}
std::string BookmarkManager::get_bookmarks_path() {
return get_config_dir() + "/bookmarks.json";
}
bool BookmarkManager::ensure_config_dir() {
std::string dir = get_config_dir();
// 检查目录是否存在
struct stat st;
if (stat(dir.c_str(), &st) == 0) {
return S_ISDIR(st.st_mode);
}
// 创建 ~/.config 目录
std::string config_dir = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") + "/.config";
mkdir(config_dir.c_str(), 0755);
// 创建 ~/.config/tut 目录
return mkdir(dir.c_str(), 0755) == 0 || errno == EEXIST;
}
// 简单的 JSON 转义
static std::string json_escape(const std::string& s) {
std::string result;
result.reserve(s.size() + 10);
for (char c : s) {
switch (c) {
case '"': result += "\\\""; break;
case '\\': result += "\\\\"; break;
case '\n': result += "\\n"; break;
case '\r': result += "\\r"; break;
case '\t': result += "\\t"; break;
default: result += c; break;
}
}
return result;
}
// 简单的 JSON 反转义
static std::string json_unescape(const std::string& s) {
std::string result;
result.reserve(s.size());
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] == '\\' && i + 1 < s.size()) {
switch (s[i + 1]) {
case '"': result += '"'; ++i; break;
case '\\': result += '\\'; ++i; break;
case 'n': result += '\n'; ++i; break;
case 'r': result += '\r'; ++i; break;
case 't': result += '\t'; ++i; break;
default: result += s[i]; break;
}
} else {
result += s[i];
}
}
return result;
}
bool BookmarkManager::load() {
bookmarks_.clear();
std::ifstream file(get_bookmarks_path());
if (!file) {
return false; // 文件不存在,这是正常的
}
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
file.close();
// 简单的 JSON 解析
// 格式: [{"url":"...","title":"...","time":123}, ...]
size_t pos = content.find('[');
if (pos == std::string::npos) return false;
pos++; // 跳过 '['
while (pos < content.size()) {
// 查找对象开始
pos = content.find('{', pos);
if (pos == std::string::npos) break;
pos++;
Bookmark bm;
// 解析字段
while (pos < content.size() && content[pos] != '}') {
// 跳过空白
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\n' ||
content[pos] == '\r' || content[pos] == '\t' || content[pos] == ',')) {
pos++;
}
if (content[pos] == '}') break;
// 读取键名
if (content[pos] != '"') { pos++; continue; }
pos++; // 跳过 '"'
size_t key_end = content.find('"', pos);
if (key_end == std::string::npos) break;
std::string key = content.substr(pos, key_end - pos);
pos = key_end + 1;
// 跳过 ':'
pos = content.find(':', pos);
if (pos == std::string::npos) break;
pos++;
// 跳过空白
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\n' ||
content[pos] == '\r' || content[pos] == '\t')) {
pos++;
}
if (content[pos] == '"') {
// 字符串值
pos++; // 跳过 '"'
size_t val_end = pos;
while (val_end < content.size()) {
if (content[val_end] == '"' && content[val_end - 1] != '\\') break;
val_end++;
}
std::string value = json_unescape(content.substr(pos, val_end - pos));
pos = val_end + 1;
if (key == "url") bm.url = value;
else if (key == "title") bm.title = value;
} else {
// 数字值
size_t val_end = pos;
while (val_end < content.size() && content[val_end] >= '0' && content[val_end] <= '9') {
val_end++;
}
std::string value = content.substr(pos, val_end - pos);
pos = val_end;
if (key == "time") {
bm.added_time = std::stoll(value);
}
}
}
if (!bm.url.empty()) {
bookmarks_.push_back(bm);
}
// 跳到下一个对象
pos = content.find('}', pos);
if (pos == std::string::npos) break;
pos++;
}
return true;
}
bool BookmarkManager::save() const {
if (!ensure_config_dir()) {
return false;
}
std::ofstream file(get_bookmarks_path());
if (!file) {
return false;
}
file << "[\n";
for (size_t i = 0; i < bookmarks_.size(); ++i) {
const auto& bm = bookmarks_[i];
file << " {\n";
file << " \"url\": \"" << json_escape(bm.url) << "\",\n";
file << " \"title\": \"" << json_escape(bm.title) << "\",\n";
file << " \"time\": " << bm.added_time << "\n";
file << " }";
if (i + 1 < bookmarks_.size()) {
file << ",";
}
file << "\n";
}
file << "]\n";
return true;
}
bool BookmarkManager::add(const std::string& url, const std::string& title) {
// 检查是否已存在
if (contains(url)) {
return false;
}
bookmarks_.emplace_back(url, title);
return save();
}
bool BookmarkManager::remove(const std::string& url) {
auto it = std::find_if(bookmarks_.begin(), bookmarks_.end(),
[&url](const Bookmark& bm) { return bm.url == url; });
if (it == bookmarks_.end()) {
return false;
}
bookmarks_.erase(it);
return save();
}
bool BookmarkManager::remove_at(size_t index) {
if (index >= bookmarks_.size()) {
return false;
}
bookmarks_.erase(bookmarks_.begin() + index);
return save();
}
bool BookmarkManager::contains(const std::string& url) const {
return std::find_if(bookmarks_.begin(), bookmarks_.end(),
[&url](const Bookmark& bm) { return bm.url == url; })
!= bookmarks_.end();
}
} // namespace tut

96
src/bookmark.h Normal file
View file

@ -0,0 +1,96 @@
#pragma once
#include <string>
#include <vector>
#include <ctime>
namespace tut {
/**
*
*/
struct Bookmark {
std::string url;
std::string title;
std::time_t added_time;
Bookmark() : added_time(0) {}
Bookmark(const std::string& url, const std::string& title)
: url(url), title(title), added_time(std::time(nullptr)) {}
};
/**
*
*
* ~/.config/tut/bookmarks.json
*/
class BookmarkManager {
public:
BookmarkManager();
~BookmarkManager();
/**
*
*/
bool load();
/**
*
*/
bool save() const;
/**
*
* @return true false
*/
bool add(const std::string& url, const std::string& title);
/**
*
* @return true
*/
bool remove(const std::string& url);
/**
*
*/
bool remove_at(size_t index);
/**
* URL是否已收藏
*/
bool contains(const std::string& url) const;
/**
*
*/
const std::vector<Bookmark>& get_all() const { return bookmarks_; }
/**
*
*/
size_t count() const { return bookmarks_.size(); }
/**
*
*/
void clear() { bookmarks_.clear(); }
/**
*
*/
static std::string get_config_dir();
/**
*
*/
static std::string get_bookmarks_path();
private:
std::vector<Bookmark> bookmarks_;
// 确保配置目录存在
static bool ensure_config_dir();
};
} // namespace tut

View file

@ -1,5 +1,6 @@
#include "browser_v2.h" #include "browser_v2.h"
#include "dom_tree.h" #include "dom_tree.h"
#include "bookmark.h"
#include "render/colors.h" #include "render/colors.h"
#include "render/decorations.h" #include "render/decorations.h"
#include "render/image.h" #include "render/image.h"
@ -33,6 +34,7 @@ public:
HttpClient http_client; HttpClient http_client;
HtmlParser html_parser; HtmlParser html_parser;
InputHandler input_handler; InputHandler input_handler;
tut::BookmarkManager bookmark_manager;
// 新渲染系统 // 新渲染系统
Terminal terminal; Terminal terminal;
@ -407,6 +409,18 @@ public:
show_help(); show_help();
break; break;
case Action::ADD_BOOKMARK:
add_bookmark();
break;
case Action::REMOVE_BOOKMARK:
remove_bookmark();
break;
case Action::SHOW_BOOKMARKS:
show_bookmarks();
break;
case Action::QUIT: case Action::QUIT:
break; // 在main loop处理 break; // 在main loop处理
@ -589,9 +603,17 @@ public:
<li>N - Previous match</li> <li>N - Previous match</li>
</ul> </ul>
<h2>Bookmarks</h2>
<ul>
<li>B - Add bookmark</li>
<li>D - Remove bookmark</li>
<li>:bookmarks - Show bookmarks</li>
</ul>
<h2>Commands</h2> <h2>Commands</h2>
<ul> <ul>
<li>:o URL - Open URL</li> <li>:o URL - Open URL</li>
<li>:bookmarks - Show bookmarks</li>
<li>:q - Quit</li> <li>:q - Quit</li>
<li>? - Show this help</li> <li>? - Show this help</li>
</ul> </ul>
@ -613,6 +635,79 @@ public:
active_link = current_tree.links.empty() ? -1 : 0; active_link = current_tree.links.empty() ? -1 : 0;
status_message = "Help - Press any key to continue"; status_message = "Help - Press any key to continue";
} }
void show_bookmarks() {
std::ostringstream html;
html << R"(
<!DOCTYPE html>
<html>
<head><title>Bookmarks</title></head>
<body>
<h1>Bookmarks</h1>
)";
const auto& bookmarks = bookmark_manager.get_all();
if (bookmarks.empty()) {
html << "<p>No bookmarks yet.</p>\n";
html << "<p>Press <b>B</b> on any page to add a bookmark.</p>\n";
} else {
html << "<ul>\n";
for (const auto& bm : bookmarks) {
html << "<li><a href=\"" << bm.url << "\">"
<< (bm.title.empty() ? bm.url : bm.title)
<< "</a></li>\n";
}
html << "</ul>\n";
html << "<hr>\n";
html << "<p>" << bookmarks.size() << " bookmark(s). Press D on any page to remove its bookmark.</p>\n";
}
html << R"(
</body>
</html>
)";
current_tree = html_parser.parse_tree(html.str(), "bookmarks://");
current_layout = layout_engine->layout(current_tree);
scroll_pos = 0;
active_link = current_tree.links.empty() ? -1 : 0;
status_message = "Bookmarks";
}
void add_bookmark() {
if (current_url.empty() || current_url.find("://") == std::string::npos) {
status_message = "Cannot bookmark this page";
return;
}
// 不要书签特殊页面
if (current_url.find("help://") == 0 || current_url.find("bookmarks://") == 0) {
status_message = "Cannot bookmark special pages";
return;
}
std::string title = current_tree.title.empty() ? current_url : current_tree.title;
if (bookmark_manager.add(current_url, title)) {
status_message = "Bookmarked: " + title;
} else {
status_message = "Already bookmarked";
}
}
void remove_bookmark() {
if (current_url.empty()) {
status_message = "No page to unbookmark";
return;
}
if (bookmark_manager.remove(current_url)) {
status_message = "Bookmark removed";
} else {
status_message = "Not bookmarked";
}
}
}; };
BrowserV2::BrowserV2() : pImpl(std::make_unique<Impl>()) { BrowserV2::BrowserV2() : pImpl(std::make_unique<Impl>()) {

View file

@ -174,6 +174,12 @@ public:
case '?': case '?':
result.action = Action::HELP; result.action = Action::HELP;
break; break;
case 'B':
result.action = Action::ADD_BOOKMARK;
break;
case 'D':
result.action = Action::REMOVE_BOOKMARK;
break;
default: default:
buffer.clear(); buffer.clear();
break; break;
@ -201,6 +207,8 @@ public:
result.action = Action::OPEN_URL; result.action = Action::OPEN_URL;
result.text = command.substr(space_pos + 1); result.text = command.substr(space_pos + 1);
} }
} else if (command == "bookmarks" || command == "bm" || command == "b") {
result.action = Action::SHOW_BOOKMARKS;
} else if (!command.empty() && std::isdigit(command[0])) { } else if (!command.empty() && std::isdigit(command[0])) {
try { try {
result.action = Action::GOTO_LINE; result.action = Action::GOTO_LINE;

View file

@ -38,7 +38,10 @@ enum class Action {
QUIT, QUIT,
HELP, HELP,
SET_MARK, // Set a mark (m + letter) SET_MARK, // Set a mark (m + letter)
GOTO_MARK // Jump to mark (' + letter) GOTO_MARK, // Jump to mark (' + letter)
ADD_BOOKMARK, // Add current page to bookmarks (B)
REMOVE_BOOKMARK, // Remove current page from bookmarks (D)
SHOW_BOOKMARKS // Show bookmarks page (:bookmarks)
}; };
struct InputResult { struct InputResult {