思源笔记插件丨子文档整理

🤔 插件开发目的

简化思源笔记子文档创建、子文档归档、子文档排序等步骤

✨ 插件功能介绍

插件需要的思源笔记最低版本:v3.1.15

  • ✨ 功能 1:支持对当前文档所有块引的文档、对当前选中的块(支持多选块、支持数据库)中块引的文档,进行移动到当前文档的子文档路径(备注:要移动数据库中的绑定文档作为子文档,需要只选中数据库块,文档块和多选块的移动功能会忽略数据库块)。

    因为我习惯用 moc 法来管理主题相关的笔记链接(一般用列表或数据库),在积累阶段确实不需要关注文档的路径在哪,但是到了整理输出阶段,一个主题的相关文档如果是子文档,查看、管理、检索起来就会方便很多。另外我的文献笔记是用数据库管理的,文献笔记创建的时候零散在各处,也希望能有一个功能,把这些文献笔记全放在一起。

    1733597165660 测试块引移动.webp

  • ✨ 功能 2:可以根据父文档对子文档块引的顺序,来排序子文档。

    之前我开发的自定义排序增强插件可以根据名称来自动排序文档,还对自然排序进行了增强,支持对中文数字标题的排序,但我觉得还不够。于是让这个插件可以根据父文档对子文档的块引顺序进行排序!排序子文档更加自由!(备注:当数据库的排序会独立于块引文档的排序)

    1733597029195 测试子文档排序.webp

  • ✨ 功能 3:可以多选块内容,快速块引创建子文档。你可以用列表块整理好子文档层级,然后用该插件用该列表快速创建子文档,子文档的层级关系会根据列表的层级关系来创建!注意,插件创建子文档的规则是一个段落块,创建一个文档,所以一个块放多行,也只是创建一个文档。
    有了这个功能,就可以让 gpt 生成笔记层级列表,或者直接用网络资料现有的列表,然后快速根据列表的层级创建子文档了,比如你可以让 gpt 生成各个朝代的皇帝列表,各个洲有哪些国家列表,然后用插件快速生成文档,之后就可以一个个完善这些文档

    1733596765158 测试根据列表快速生成子文档层级.webp

📝 插件添加的按钮

  • 文档树右键菜单

    • 移动引用文档为子文档并排序
    • 排序引用的子文档 (一级)
    • 排序引用的子文档(所有层级)
  • 文档标题右键菜单

    • 移动引用文档为子文档并排序
    • 排序引用的子文档 (一级)
    • 排序引用的子文档(所有层级)
  • 普通块右键菜单

    • 从段落创建子文档
    • 移动引用文档为子文档并排序
  • 数据库块右键菜单

    • 移动引用文档为子文档并排序
    • 排序引用的子文档 (一级)

❤️ 用爱发电

穷苦研究生在读ing,如果喜欢我的插件,欢迎给 GitHub 仓库点 star 和捐赠,这会激励我继续完善此插件和开发新插件。

开发笔记

给文档右键菜单添加按钮

打开文档右键菜单的事件是 this.eventBus.on('click-editortitleicon', yourFun);

async onload() { this.eventBus.on('click-editortitleicon', this.handleDocumentMenu.bind(this)); } private async handleDocumentMenu({detail}) { detail.menu.addItem({ icon:"iconMove", label: "Move referenced docs here", click: async () => { await this.moveReferencedDocs(detail.protyle.block.rootID); } }); }

获取当前文档的 DOM 结构,找出所有块引,整理为 id 列表

注意事项:

  • 如果当前文档已经是该文档的子孙文档了,应该要忽略!
  • 需要去重 id
  • 思路 1:使用 getBlockDOM 获取当前文档的 DOM,获取的是 string 结构,还需要 parser 为 html 结构

  • ✅ 思路 2:

    可以用 sql 查询 refs 表,获取所有符合条件的引用块的 id

    SELECT * FROM refs WHERE root_id ='20241207155953-3tz39w8';
    • 需要是当前文档引用 id:root_id=当前文档 id
    • 需要是文档块:def_block_id 字段等于 def_block_root_id 字段
    • 如果该文档已经在当前文档路径下则不需要移动:def_block_path 字段的内容,比如 /20240101014300-fvksm5v/20241201011713-54vhqh1/20241207155953-3tz39w8.sy 没有当前文档的 id

根据 API /api/filetree/moveDocsByID 来移动文档

  • 参数

    { "fromIDs": ["20210917220056-yxtyl7i"], "toID": "20210817205410-2kvfpfn"}
    • fromIDs:源文档 ID
    • toID:目标父文档 ID
  • 返回值

    { "code": 0, "msg": "", "data": null}
private async moveReferencedDocs(currentDocID: string) { // Query to get referenced document blocks that: // 1. Are referenced in current document (root_id) // 2. Are document blocks (def_block_id = def_block_root_id) // 3. Are not already in current document's path const query = ` SELECT DISTINCT def_block_id FROM refs WHERE root_id = '${currentDocID}' AND def_block_id = def_block_root_id AND def_block_path NOT LIKE '%${currentDocID}%' `; const results = await sql(query); const docIDs = results.map(row => row.def_block_id); if (docIDs.length === 0) { showMessage("No referenced documents found to move"); return; } console.log(docIDs, currentDocID) await moveDocsByID(docIDs, currentDocID); showMessage(`Successfully moved ${docIDs.length} documents`); }

我希望移动文档后,能按照文档块引的顺序来设置子文档的排序

  • 信息

    • 笔记本的 sort.json 文件设置了文档的排序,修改这个文件就能更改文档排序

    • refs 表获取的 id 顺序,就是块引的顺序

    • 获取并更新 sort.json 文件

      // 获取现有的sort.json文件 const sortJson = await getFile(`/data/${boxID}/.siyuan/sort.json`); // 根据sortedResult变量更新排序值 for (let id in sortedResult) { sortJson[id] = sortedResult[id]; } // 保存更新后的sort.json文件 await putFile(`/data/${boxID}/.siyuan/sort.json`, sortJson);
  • 注意:只对已有的子文档和移动的文档进行排序,不对孙子文档进行排序

  • 代码

    private async moveReferencedDocs(currentDocID: string) { showMessage("Processing..."); const moveQuery = ` SELECT DISTINCT def_block_id FROM refs WHERE root_id = '${currentDocID}' AND def_block_id = def_block_root_id AND def_block_path NOT LIKE '%${currentDocID}%' `; const docToMove_sql = await sql(moveQuery); const docsToMove = docToMove_sql.map(row => row.def_block_id); // Get both moved docs and existing child docs in one query const sortQuery = ` SELECT DISTINCT def_block_id FROM refs WHERE root_id = '${currentDocID}' AND def_block_id = def_block_root_id AND ( def_block_id IN (${docsToMove.map(id => `'${id}'`).join(',') || "''"}) OR ( def_block_path LIKE '%/${currentDocID}/%' AND def_block_path NOT LIKE '%/${currentDocID}/%/%' ) ) `; if (docsToMove.length > 0) { console.log(docToMove_sql) await moveDocsByID(docsToMove, currentDocID); } const docToSort_sql = await sql(sortQuery); const docsToSort = docToSort_sql.map(row => row.def_block_id); // Sort all referenced documents if (docsToSort.length > 0) { const currentDoc = await getBlockByID(currentDocID); const boxID = currentDoc.box; const sortJson = await getFile(`/data/${boxID}/.siyuan/sort.json`); const sortedResult = {}; docsToSort.forEach((id, index) => { sortedResult[id] = index + 1; }); for (let id in sortedResult) { sortJson[id] = sortedResult[id]; } await putFile(`/data/${boxID}/.siyuan/sort.json`, sortJson); // 排序完之后需要刷新,刷新方式就是把文档树的当前文档子文档折叠再展开 let element = document.querySelector(`.file-tree li[data-node-id="${currentDocID}"] > .b3-list-item__toggle--hl`); if (element) { element.click(); element.click(); } } // Show message const message = docsToMove.length > 0 ? `Moved ${docsToMove.length} documents and sorted ${docsToSort.length} documents` : `Sorted ${docsToSort.length} documents`; showMessage(message); }

能不能移动后即时刷新 sqlite 数据库

给块菜单也添加上 Move referenced docs and sort 菜单按钮

  • 信息

    • 块菜单的 eventbus 是 this.eventBus.on("click-blockicon", ({ detail }) => {}))
    • refs 数据表有 block_id,代表存放块引用的 id,可以获取选中所有块的 DOM 内容,提取 ids,然后再用 block_ids in ids 来查询,获取所有块引 id
  • 代码

    async onload() { // 文档块标添加菜单 this.eventBus.on('click-editortitleicon', this.handleDocumentMenu.bind(this)); // 块菜单添加菜单 this.eventBus.on('click-blockicon', this.handleBlockMenu.bind(this)); } private async handleBlockMenu({ detail }) { detail.menu.addItem({ icon: "iconMove", label: "Move referenced docs and sort", click: async () => { const blockIds = []; for (const blockElement of detail.blockElements) { const refs = Array.from(blockElement.querySelectorAll('span[data-type="block-ref"]')) .map(el => el.getAttribute('data-id')); blockIds.push(...refs); } console.log(blockIds); if (blockIds.length === 0) { showMessage("No references found"); return; } await this.moveReferencedDocs(detail.protyle.block.rootID, blockIds); } }); }

click-blockicon 需要支持数据库中的绑定块移动为子文档并排序

  • 信息

    • 如何判断选中的块是数据库 div[data-type="NodeAttributeView"]

    • 获取数据库块中的块引 id,绑定块的 data-block-id 为块 id

      document.querySelectorAll(`div.av__cell[data-block-id]:not([data-detached="true"])`)
    • 数据库绑定块的数据不存在于 refs 表里

  • 开发

    • 文档块标不考虑移动数据库中的绑定块了,只有单独选中数据库块才能移动(div[data-type="NodeAttributeView"]),这样开发逻辑简单一些

    • 数据库如何获取全部的绑定 ids,因为分页动态加载,只能获取一部分

      数据库需要获取全部的绑定 ids,因为分页动态加载,attributeView.querySelectorAll 只能获取一部分,改用 API 获取
      avID 获取 attributeView 的 data-av-id 属性
      viewID 获取 attributeView 的 custom-sy-av-view 属性
      然后调用 API

      let res = await fetchSyncPost("/api/av/renderAttributeView", { id: '20241207205647-baw0ri8', // avID, viewID: '20241207205639-5bgrvv0', pageSize:9999999, page:1 }); const blockIDs = res.data.view.rows.map(item => item.id); console.log(blockIDs)

文档块标添加仅排序

如何添加子菜单

detail.menu.addItem({ icon: "iconSort", label: "ChildDoc Organizer", submenu: [ { icon: "iconMove", label: "Move referenced docs as childdocs and sort", click: async () => { const blockIds = await this.getBoundBlockIds(block); if (blockIds.length === 0) { showMessage("No referenced blocks found"); return; } await this.moveReferencedDocs(detail.protyle.block.rootID, blockIds, true); } }, ]

根据段落块批量新建子文档

click-blockicon 添加一个菜单按钮,功能是快速创建子文档,获取 blockElement 中每个 div.p>div:first-child 的文本内容 content,依次新建文档,并把其文本替换为块引 <span data-type="block-ref" data-id="{blockID}" data-subtype="d">content</span>

  • 如何根据文本进行创建文档 createDocWithMd

  • 如何根据列表层级创建文档

    private async processListItem( item: HTMLElement, parentDocID: string, boxID: string, parentPath: string ): Promise<string | null> { const paragraph = item.querySelector('div.p'); if (!paragraph) return null; let currentDocID = parentDocID; let currentPath = parentPath; // If no block reference exists, create new doc and reference if (!this.hasBlockRef(paragraph)) { const content = paragraph.querySelector('div:first-child')?.textContent?.trim() || ''; const paragraphId = paragraph.getAttribute('data-node-id'); if (content && paragraphId) { currentDocID = await createDocWithMd(boxID, `${parentPath}/${content}`, ""); const refMd = `<span data-type="block-ref" data-id="${currentDocID}" data-subtype="d">${content}</span>`; await updateBlock("markdown", refMd, paragraphId); currentPath = `${parentPath}/${content}`; } } else { // If block reference exists, get its target doc ID for nested processing const blockRef = paragraph.querySelector('span[data-type="block-ref"]'); const refDocID = blockRef?.getAttribute('data-id'); if (refDocID) { currentDocID = refDocID; const refDoc = await getBlockByID(refDocID); currentPath = refDoc.hpath; } } // Always process nested list if it exists, using either new doc ID or referenced doc ID const nestedList = item.querySelector(':scope > div.list'); if (nestedList) { const listItems = Array.from(nestedList.querySelectorAll(':scope > div.li')); for (const nestedItem of listItems) { await this.processListItem(nestedItem, currentDocID, boxID, currentPath); } } return currentDocID; }

文档树需要添加插件按钮

this.eventBus.on("open-menu-doctree", this.addSortButton.bind(this));

refs 表的 def_block_path 刷新不及时

引用的文档移动之后,def_block_path 刷新不及时

PixPin_2024-12-08_11-52-48

PixPin_2024-12-08_11-53-23

PixPin_2024-12-08_11-51-43

改用 blocks 表来查询 path

SELECT DISTINCT r.def_block_id FROM refs r JOIN blocks b ON r.def_block_id = b.id WHERE r.root_id = '${currentDocID}' AND r.def_block_id = r.def_block_root_id AND b.path NOT LIKE '%${currentDocID}%'

支持多级排序

  • 给文档树排序新增一个按钮,支持对多层子文档进行排序,重写一个排序函数,对多层子文档排序的方法就是根据 blocks 表里的 path,根据最后一个斜杆获取前面的字符,字符一样的为一组进行排序
  • 根据列表层级创建文档,需要对多层文档一起排序
private async multiLevelSort(parentDocID: string) { await refreshSql(); // Get all child documents and their paths const childDocsQuery = ` SELECT b.id, b.path, r.root_id as parent_id FROM refs r JOIN blocks b ON b.id = r.def_block_id WHERE b.type = 'd' AND b.path LIKE '%/${parentDocID}/%' AND r.root_id = '${parentDocID}' `; const childDocs = await sql(childDocsQuery); // Group documents by their parent path const groupedDocs = new Map<string, Array<{id: string, path: string}>>(); childDocs.forEach(doc => { const pathParts = doc.path.split('/'); const parentPath = pathParts.slice(0, -1).join('/'); if (!groupedDocs.has(parentPath)) { groupedDocs.set(parentPath, []); } groupedDocs.get(parentPath).push({ id: doc.id, path: doc.path }); }); // Sort each group and update sort.json const currentDoc = await getBlockByID(parentDocID); const boxID = currentDoc.box; const sortJson = await getFile(`/data/${boxID}/.siyuan/sort.json`); let sortIndex = 1; for (const [parentPath, docs] of groupedDocs) { // Update sort values docs.forEach(doc => { sortJson[doc.id] = sortIndex++; }); } await putFile(`/data/${boxID}/.siyuan/sort.json`, sortJson); // Refresh file tree let element = document.querySelector(`.file-tree li[data-node-id="${parentDocID}"] > .b3-list-item__toggle--hl`); if (element) { element.click(); element.click(); } showMessage(this.i18n.sortedDocs .replace("{count}", childDocs.length.toString()) .replace("{affected}", childDocs.length.toString()) .replace("{unaffected}", "0")); }

自定义处理弹窗

由于移动和排序文档很耗时间,而使用 showMessage 一段时间就消失了,体验很不好,不知道到底处理完没处理完,想写一个 Dialog,在上面显示文字,处理完之后就关闭

svelte 设计样式

<script lang="ts"> export let message: string; </script> <div class="loading-dialog"> <div class="loading-spinner"></div> <div class="loading-message">{message}</div> </div> <style> .loading-dialog { padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 12px; } .loading-spinner { width: 32px; height: 32px; border: 3px solid var(--b3-theme-background); border-top: 3px solid var(--b3-theme-primary); border-radius: 50%; animation: spin 1s linear infinite; } .loading-message { font-size: 14px; color: var(--b3-theme-on-background); } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } </style>

显示和关闭弹窗

private showLoadingDialog(message: string) { if (this.loadingDialog) { this.loadingDialog.destroy(); } this.loadingDialog = new Dialog({ title: "Processing", content: `<div id="loadingDialogContent"></div>`, width: "300px", height: "150px", disableClose: true, // 禁止点击外部关闭 destroyCallback: null // 禁止自动关闭 }); new LoadingDialog({ target: this.loadingDialog.element.querySelector('#loadingDialogContent'), props: { message } }); } private closeLoadingDialog() { if (this.loadingDialog) { this.loadingDialog.destroy(); this.loadingDialog = null; } }
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    24922 引用 • 102646 回帖
2 操作
Achuan-2 在 2024-12-08 15:32:56 更新了该帖
Achuan-2 在 2024-12-08 14:03:00 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...