diff --git a/CMakeLists.txt b/CMakeLists.txt index 360c9ed..bd6ddcb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,7 @@ add_executable(tut2 src/browser_v2.cpp src/http_client.cpp src/input_handler.cpp + src/bookmark.cpp src/render/terminal.cpp src/render/renderer.cpp src/render/layout.cpp diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index a0f4a06..445cde1 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,9 +1,9 @@ # TUT 2.0 - 下次继续从这里开始 ## 当前位置 -- **阶段**: Phase 4 - 图片支持 (已完成!) -- **进度**: 图片 ASCII Art 渲染已集成到浏览器 -- **最后提交**: `feat: Add image ASCII art rendering support` +- **阶段**: Phase 5 - 书签管理 (已完成!) +- **进度**: 书签添加/删除/持久化存储已完成 +- **最后提交**: `feat: Add bookmark management` ## 立即可做的事 @@ -15,21 +15,25 @@ curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \ # 重新编译 cmake --build build_v2 - -# 编译后会自动支持 PNG/JPEG/GIF/BMP 图片格式 ``` -### 2. 测试图片渲染 -```bash -# 访问有图片的网页 -./build_v2/tut2 https://httpbin.org/html +### 2. 使用书签功能 +- **B** - 添加当前页面到书签 +- **D** - 从书签中移除当前页面 +- **:bookmarks** 或 **:bm** - 查看书签列表 -# 或访问包含图片的任意网页 -./build_v2/tut2 https://example.com -``` +书签存储在 `~/.config/tut/bookmarks.json` ## 已完成的功能清单 +### Phase 5 - 书签管理 +- [x] 书签数据结构 (URL, 标题, 添加时间) +- [x] JSON 持久化存储 (~/.config/tut/bookmarks.json) +- [x] 添加书签 (B 键) +- [x] 删除书签 (D 键) +- [x] 书签列表页面 (:bookmarks 命令) +- [x] 书签链接可点击跳转 + ### Phase 4 - 图片支持 - [x] `` 标签解析 (src, alt, width, height) - [x] 图片占位符显示 `[alt text]` 或 `[Image: filename]` @@ -127,10 +131,10 @@ cmake --build build_v2 ## 下一步功能优先级 -1. **书签管理** - 添加/删除书签,书签列表页面,持久化存储 -2. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求 -3. **更多表单交互** - 文本输入编辑,下拉选择 -4. **图片缓存** - 避免重复下载相同图片 +1. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求 +2. **更多表单交互** - 文本输入编辑,下拉选择 +3. **图片缓存** - 避免重复下载相同图片 +4. **历史记录管理** - 持久化历史记录,历史页面 ## 恢复对话时说 diff --git a/src/bookmark.cpp b/src/bookmark.cpp new file mode 100644 index 0000000..ffe3720 --- /dev/null +++ b/src/bookmark.cpp @@ -0,0 +1,248 @@ +#include "bookmark.h" +#include +#include +#include +#include +#include + +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(file)), + std::istreambuf_iterator()); + 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 diff --git a/src/bookmark.h b/src/bookmark.h new file mode 100644 index 0000000..d1ae912 --- /dev/null +++ b/src/bookmark.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include + +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& 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 bookmarks_; + + // 确保配置目录存在 + static bool ensure_config_dir(); +}; + +} // namespace tut diff --git a/src/browser_v2.cpp b/src/browser_v2.cpp index fb11a79..644f45b 100644 --- a/src/browser_v2.cpp +++ b/src/browser_v2.cpp @@ -1,5 +1,6 @@ #include "browser_v2.h" #include "dom_tree.h" +#include "bookmark.h" #include "render/colors.h" #include "render/decorations.h" #include "render/image.h" @@ -33,6 +34,7 @@ public: HttpClient http_client; HtmlParser html_parser; InputHandler input_handler; + tut::BookmarkManager bookmark_manager; // 新渲染系统 Terminal terminal; @@ -407,6 +409,18 @@ public: show_help(); 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: break; // 在main loop处理 @@ -589,9 +603,17 @@ public:
  • N - Previous match
  • +

    Bookmarks

    +
      +
    • B - Add bookmark
    • +
    • D - Remove bookmark
    • +
    • :bookmarks - Show bookmarks
    • +
    +

    Commands

    • :o URL - Open URL
    • +
    • :bookmarks - Show bookmarks
    • :q - Quit
    • ? - Show this help
    @@ -613,6 +635,79 @@ public: active_link = current_tree.links.empty() ? -1 : 0; status_message = "Help - Press any key to continue"; } + + void show_bookmarks() { + std::ostringstream html; + html << R"( + + +Bookmarks + +

    Bookmarks

    +)"; + + const auto& bookmarks = bookmark_manager.get_all(); + + if (bookmarks.empty()) { + html << "

    No bookmarks yet.

    \n"; + html << "

    Press B on any page to add a bookmark.

    \n"; + } else { + html << "\n"; + html << "
    \n"; + html << "

    " << bookmarks.size() << " bookmark(s). Press D on any page to remove its bookmark.

    \n"; + } + + html << R"( + + +)"; + + 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()) { diff --git a/src/input_handler.cpp b/src/input_handler.cpp index 63717b8..4a4c683 100644 --- a/src/input_handler.cpp +++ b/src/input_handler.cpp @@ -174,6 +174,12 @@ public: case '?': result.action = Action::HELP; break; + case 'B': + result.action = Action::ADD_BOOKMARK; + break; + case 'D': + result.action = Action::REMOVE_BOOKMARK; + break; default: buffer.clear(); break; @@ -201,6 +207,8 @@ public: result.action = Action::OPEN_URL; 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])) { try { result.action = Action::GOTO_LINE; diff --git a/src/input_handler.h b/src/input_handler.h index 9f7625a..379bc0a 100644 --- a/src/input_handler.h +++ b/src/input_handler.h @@ -38,7 +38,10 @@ enum class Action { QUIT, HELP, 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 {