🤔 插件开发目的
简化思源笔记子文档创建、子文档归档、子文档排序等步骤
✨ 插件功能介绍
插件需要的思源笔记最低版本:v3.1.15
-
✨ 功能 1:支持对当前文档所有块引的文档、对当前选中的块(支持多选块、支持数据库)中块引的文档,进行移动到当前文档的子文档路径(备注:要移动数据库中的绑定文档作为子文档,需要只选中数据库块,文档块和多选块的移动功能会忽略数据库块)。
因为我习惯用 moc 法来管理主题相关的笔记链接(一般用列表或数据库),在积累阶段确实不需要关注文档的路径在哪,但是到了整理输出阶段,一个主题的相关文档如果是子文档,查看、管理、检索起来就会方便很多。另外我的文献笔记是用数据库管理的,文献笔记创建的时候零散在各处,也希望能有一个功能,把这些文献笔记全放在一起。
-
✨ 功能 2:可以根据父文档对子文档块引的顺序,来排序子文档。
之前我开发的自定义排序增强插件可以根据名称来自动排序文档,还对自然排序进行了增强,支持对中文数字标题的排序,但我觉得还不够。于是让这个插件可以根据父文档对子文档的块引顺序进行排序!排序子文档更加自由!(备注:当数据库的排序会独立于块引文档的排序)
-
✨ 功能 3:可以多选块内容,快速块引创建子文档。你可以用列表块整理好子文档层级,然后用该插件用该列表快速创建子文档,子文档的层级关系会根据列表的层级关系来创建!注意,插件创建子文档的规则是一个段落块,创建一个文档,所以一个块放多行,也只是创建一个文档。
有了这个功能,就可以让 gpt 生成笔记层级列表,或者直接用网络资料现有的列表,然后快速根据列表的层级创建子文档了,比如你可以让 gpt 生成各个朝代的皇帝列表,各个洲有哪些国家列表,然后用插件快速生成文档,之后就可以一个个完善这些文档
📝 插件添加的按钮
-
文档树右键菜单
- 移动引用文档为子文档并排序
- 排序引用的子文档 (一级)
- 排序引用的子文档(所有层级)
-
文档标题右键菜单
- 移动引用文档为子文档并排序
- 排序引用的子文档 (一级)
- 排序引用的子文档(所有层级)
-
普通块右键菜单
- 从段落创建子文档
- 移动引用文档为子文档并排序
-
数据库块右键菜单
- 移动引用文档为子文档并排序
- 排序引用的子文档 (一级)
❤️ 用爱发电
穷苦研究生在读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
:源文档 IDtoID
:目标父文档 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 数据库
- 信息:
/api/sqlite/flushTransaction
,等待当前所有 DB 任务都写盘后才返回。见 Add internal kernel API/api/sqlite/flushTransaction
· Issue #10005 · siyuan-note/siyuan - 代码:在移动排序前刷新一次,在移动排序后刷新一次
给块菜单也添加上 Move referenced docs and sort
菜单按钮
-
信息
- 块菜单的 eventbus 是
this.eventBus.on("click-blockicon", ({ detail }) => {}))
- refs 数据表有 block_id,代表存放块引用的 id,可以获取选中所有块的 DOM 内容,提取 ids,然后再用 block_ids in ids 来查询,获取所有块引 id
- 块菜单的 eventbus 是
-
代码
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 属性
然后调用 APIlet 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 刷新不及时
、
改用 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;
}
}
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于