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

🤔 插件开发目的

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

✨ 插件功能介绍

插件需要的思源笔记最低版本: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;
    }
}

  • 思源笔记

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

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

    22784 引用 • 91470 回帖 • 3 关注
2 操作
Achuan-2 在 2024-12-08 15:32:56 更新了该帖
Achuan-2 在 2024-12-08 14:03:00 更新了该帖

相关帖子

欢迎来到这里!

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

注册 关于
请输入回帖内容 ...
  • 阿川高产啊

    1 回复
  • achuan 大彻大悟 神功大成了

    1 回复
  • Achuan-2

    只能说 ai 太强大了哈哈,这插件 99% 代码都是 ai 写的,我只负责提供思路哈哈,毕竟我并不会 js

  • Achuan-2

    插件还有很多不会的哈哈,我只会写批量处理,因为有其他语言基础,而与界面相关的前端部分,我就头疼了

  • dorauemon

    想要移动数据库中的文档,选中数据库块右键的时候并没有找到插件,是我的操作哪里不对吗?截屏 2024121117.33.20.png

    其他功能没有问题。