mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 09:04:04 +00:00
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:
parent
c6b1a9ac41
commit
a4c95a6527
7 changed files with 472 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] `<img>` 标签解析 (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. **历史记录管理** - 持久化历史记录,历史页面
|
||||
|
||||
## 恢复对话时说
|
||||
|
||||
|
|
|
|||
248
src/bookmark.cpp
Normal file
248
src/bookmark.cpp
Normal 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
96
src/bookmark.h
Normal 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
|
||||
|
|
@ -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:
|
|||
<li>N - Previous match</li>
|
||||
</ul>
|
||||
|
||||
<h2>Bookmarks</h2>
|
||||
<ul>
|
||||
<li>B - Add bookmark</li>
|
||||
<li>D - Remove bookmark</li>
|
||||
<li>:bookmarks - Show bookmarks</li>
|
||||
</ul>
|
||||
|
||||
<h2>Commands</h2>
|
||||
<ul>
|
||||
<li>:o URL - Open URL</li>
|
||||
<li>:bookmarks - Show bookmarks</li>
|
||||
<li>:q - Quit</li>
|
||||
<li>? - Show this help</li>
|
||||
</ul>
|
||||
|
|
@ -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"(
|
||||
<!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>()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue