文章目录
- AI书签管理工具开发全记录(十八):书签导入导出
- 1.前言 📝
- 2.书签结构分析 📖
- 3.书签示例 📑
- 4.书签文件结构定义描述 🔣
- 4.1. 整体文档结构
- 4.2. 核心元素类型
- 4.3. 层级关系
- 4.4. 嵌套规则
- 4.5. 属性规范
- 4.6. 特殊结构
- 4.7. 文档终止
- 5. 导出 🚀
- 5.1 定义导出方法
- 5.2 前端调用
- 6. 导入 📥
- 6.1 添加书签处理方法
- 6.2 添加后端处理方法
AI书签管理工具开发全记录(十八):书签导入导出
1.前言 📝
在上一篇文章中,我们完成了sun-panel同步功能的实现。本篇文章将聚焦于书签的导入导出功能。
2.书签结构分析 📖
这里我们以chrome书签格式作为参考标准。
我们可以在chrome://bookmarks/
中点击导出书签
3.书签示例 📑
4.书签文件结构定义描述 🔣
4.1. 整体文档结构
- 文档类型声明:
<!DOCTYPE NETSCAPE-Bookmark-file-1>
- 元数据:
- 注释说明
- 字符集声明:
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
- 标题:
<TITLE>Bookmarks</TITLE>
- 主容器:所有书签内容包裹在顶级
<DL><p>
标签中
4.2. 核心元素类型
-
文件夹 (Folder):
- 定义标签:
<DT><H3>
- 关键属性:
ADD_DATE
:创建时间戳(Unix时间戳)LAST_MODIFIED
:最后修改时间戳(可选)PERSONAL_TOOLBAR_FOLDER
:标记是否为书签栏(布尔值,可选)
- 子容器:后跟
<DL><p>
包裹下级内容
- 定义标签:
-
书签项 (Bookmark):
- 定义标签:
<DT><A>
- 关键属性:
HREF
:URL地址(必需)ADD_DATE
:添加时间戳(Unix时间戳,必需)
- 定义标签:
4.3. 层级关系
<DL><p> <!-- 容器开始 -->
<DT><H3 属性>文件夹名称</H3> <!-- 文件夹定义 -->
<DL><p> <!-- 文件夹子容器开始 -->
<DT><A 属性>书签名</A> <!-- 书签项 -->
<DT><H3 属性>子文件夹</H3> <!-- 嵌套文件夹 -->
<DL><p>...</DL><p> <!-- 嵌套文件夹子容器 -->
</DL><p> <!-- 文件夹子容器结束 -->
</DL><p> <!-- 容器结束 -->
4.4. 嵌套规则
- 任意层级可包含混合内容(文件夹 + 书签项)
- 文件夹必须包含子容器
<DL><p>
(即使为空) - 最大嵌套深度无限制(支持无限层级)
- 同级元素按顺序排列,不依赖特殊分组标签
4.5. 属性规范
- 时间戳格式:所有时间戳均为 Unix 时间戳(10位整数)
- 保留属性:
- 文件夹:
ADD_DATE
,LAST_MODIFIED
,PERSONAL_TOOLBAR_FOLDER
- 书签项:
HREF
,ADD_DATE
- 文件夹:
4.6. 特殊结构
- 空文件夹:包含完整的
<DT><H3>
和空<DL><p>
标签对 - 根级混合内容:顶级容器可直接包含书签项(不强制要求文件夹包裹)
- 文本节点:仅作为元素内容(如书签名/文件夹名),无独立标签
4.7. 文档终止
- 以闭合标签
</DL><p>
结束所有层级 - 无额外文档结构结束标签(如
</HTML>
)
5. 导出 🚀
5.1 定义导出方法
// internal/api/api.go
// ExportBookmarks godoc
// @Summary 导出书签
// @Description 导出书签为HTML格式
// @Tags bookmarks
// @Accept json
// @Produce text/html
// @Param request body models.ExportBookmarkRequest true "导出参数"
// @Success 200 {string} string "HTML格式的书签"
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/bookmarks/export [post]
func (s *Server) exportBookmarks(c *gin.Context) {
var req models.ExportBookmarkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "无效的请求数据"})
return
}
// 构建查询
query := s.db.Model(&models.Bookmark{}).Preload("Category")
// 按分类过滤
if req.CategoryID != 0 {
query = query.Where("category_id = ?", req.CategoryID)
}
// 关键词搜索
if req.Keyword != "" {
query = query.Where("title LIKE ? OR url LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%")
}
// 获取书签
var bookmarks []models.Bookmark
if err := query.Find(&bookmarks).Error; err != nil {
c.JSON(500, gin.H{"error": "获取书签失败"})
return
}
// 生成Chrome书签HTML
now := time.Now().Unix()
html := `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<!-- This is an automatically generated file.
It will be read and overwritten.
DO NOT EDIT! -->
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><H3>AI书签</H3>
<DL><p>`
// 按分类分组书签
categoryBookmarks := make(map[string][]models.Bookmark)
for _, b := range bookmarks {
categoryName := b.Category.Name
if categoryName == "" {
categoryName = "未分类"
}
categoryBookmarks[categoryName] = append(categoryBookmarks[categoryName], b)
}
// 生成HTML
for category, bookmarks := range categoryBookmarks {
html += fmt.Sprintf("\n <DT><H3>%s</H3>\n <DL><p>", category)
for _, b := range bookmarks {
html += fmt.Sprintf("\n <DT><A HREF=\"%s\" ADD_DATE=\"%d\">%s</A>", b.URL, now, b.Title)
if b.Description != "" {
html += fmt.Sprintf("\n <DD>%s</DD>", b.Description)
}
}
html += "\n </DL><p>"
}
html += "\n </DL><p>\n</DL><p>"
// 设置响应头
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename=bookmarks.html")
c.String(200, html)
}
5.2 前端调用
添加导出按钮
<el-button type="success" @click="handleExport">导出书签</el-button>
定义导出方法
// 导出书签
const handleExport = async () => {
try {
loading.value = true
const response = await exportBookmarks({
category_id: selectedCategory.value || undefined,
keyword: searchKeyword.value || undefined
})
// 生成带时间戳的文件名
const now = new Date()
const timestamp = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0')
const filename = `bookmarks_${timestamp}.html`
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response]))
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('书签导出成功')
} catch (error) {
console.error('导出书签失败:', error)
ElMessage.error('导出书签失败')
} finally {
loading.value = false
}
}
6. 导入 📥
6.1 添加书签处理方法
添加书签处理方法,处理书签节点。
// 处理文件选择
const handleFileChange = async (file) => {
if (!file) return
try {
// 使用FileReader读取文件内容
const content = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target.result)
reader.onerror = (e) => reject(e)
reader.readAsText(file.raw)
})
console.log('文件内容:', content)
// 创建DOM解析器
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/html')
console.log('解析后的DOM:', doc)
const bookmarks = []
let currentCategory = '未分类'
// 递归处理节点
const processNode = (node) => {
if (!node) return
// 处理H3标签(分类)
if (node.tagName === 'H3') {
currentCategory = node.textContent.trim()
console.log('找到分类:', currentCategory)
}
// 处理A标签(书签)
if (node.tagName === 'A') {
const bookmark = {
title: node.textContent.trim(),
url: node.getAttribute('href'),
category: currentCategory,
description: '',
icon: node.getAttribute('icon') || ''
}
console.log('创建书签:', bookmark)
bookmarks.push(bookmark)
}
// 递归处理子节点
for (const child of node.children) {
processNode(child)
}
}
// 从根节点开始处理
const rootDL = doc.querySelector('DL')
if (rootDL) {
processNode(rootDL)
}
console.log('解析完成,书签数量:', bookmarks.length)
// 调用导入API
const response = await importBookmarks({ bookmarks })
console.log('导入响应:', response)
if (response.stats) {
ElMessage.success(`导入完成:成功 ${response.stats.success} 个,重复 ${response.stats.duplicate} 个,失败 ${response.stats.failed} 个`)
// 刷新列表
fetchBookmarks()
} else {
ElMessage.error(response.msg || '导入失败')
}
} catch (error) {
console.error('解析文件失败:', error)
ElMessage.error('解析文件失败')
}
}
6.2 添加后端处理方法
不存在的分类则自动创建分类。
// ImportBookmarks godoc
// @Summary 导入书签
// @Description 批量导入书签
// @Tags bookmarks
// @Accept json
// @Produce json
// @Param request body models.ImportBookmarkRequest true "书签导入信息"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/bookmarks/import [post]
func (s *Server) importBookmarks(c *gin.Context) {
var req models.ImportBookmarkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "无效的请求数据"})
return
}
// 用于统计导入结果
stats := map[string]int{
"total": len(req.Bookmarks),
"success": 0,
"duplicate": 0,
"failed": 0,
}
// 获取所有现有书签用于去重
var existingBookmarks []models.Bookmark
if err := s.db.Find(&existingBookmarks).Error; err != nil {
c.JSON(500, gin.H{"error": "获取现有书签失败"})
return
}
// 创建URL映射用于快速查找
urlMap := make(map[string]bool)
for _, b := range existingBookmarks {
urlMap[b.URL] = true
}
// 获取所有分类
var categories []models.Category
if err := s.db.Find(&categories).Error; err != nil {
c.JSON(500, gin.H{"error": "获取分类失败"})
return
}
// 创建分类名称到ID的映射
categoryMap := make(map[string]uint)
for _, cat := range categories {
categoryMap[cat.Name] = cat.ID
}
// 处理每个书签
for _, b := range req.Bookmarks {
// 检查URL是否已存在
if urlMap[b.URL] {
stats["duplicate"]++
continue
}
// 获取或创建分类
categoryID := categoryMap[b.Category]
if categoryID == 0 && b.Category != "" {
// 创建新分类
newCategory := models.Category{
Name: b.Category,
Description: "从导入的书签创建",
}
if err := s.db.Create(&newCategory).Error; err != nil {
stats["failed"]++
continue
}
categoryID = newCategory.ID
categoryMap[b.Category] = categoryID
}
// 创建书签
bookmark := models.Bookmark{
Title: b.Title,
URL: b.URL,
Description: b.Description,
CategoryID: categoryID,
}
if err := s.db.Create(&bookmark).Error; err != nil {
stats["failed"]++
continue
}
stats["success"]++
urlMap[b.URL] = true
}
c.JSON(200, gin.H{
"message": "导入完成",
"stats": stats,
})
}
往期系列
- Ai书签管理工具开发全记录(一):项目总览与技术蓝图
- Ai书签管理工具开发全记录(二):项目基础框架搭建
- AI书签管理工具开发全记录(三):配置及数据系统设计
- AI书签管理工具开发全记录(四):日志系统设计与实现
- AI书签管理工具开发全记录(五):后端服务搭建与API实现
- AI书签管理工具开发全记录(六):前端管理基础框框搭建 Vue3+Element Plus
- AI书签管理工具开发全记录(七):页面编写与接口对接
- AI书签管理工具开发全记录(八):Ai创建书签功能实现
- AI书签管理工具开发全记录(九):用户端页面集成与展示
- AI书签管理工具开发全记录(十):命令行中结合ai高效添加书签
- AI书签管理工具开发全记录(十一):MCP集成
- AI书签管理工具开发全记录(十二):MCP集成查询
- AI书签管理工具开发全记录(十三):TUI基本框架搭建
- AI书签管理工具开发全记录(十四):TUI基本界面完善
- AI书签管理工具开发全记录(十五):TUI基本逻辑实现与数据展示
- AI书签管理工具开发全记录(十六):Sun-Panel接口分析
- AI书签管理工具开发全记录(十七):Sun-Panel书签同步实现