思源笔记插件丨文本处理插件:减少无谓的修改操作,改善复制粘贴体验

插件地址:https://github.com/Achuan-2/siyuan-plugin-text-process

🤔 开发背景

从外部粘贴内容到思源笔记时,需要进行处理,希望这些处理能自动化,省去重复琐碎的操作,节省时间,有更多时间去思考创作。

比如很多 ai 默认生成的数学公式是 LaTeX 格式,粘贴到思源笔记不会渲染为公式

比如从 PPT、word 复制列表到思源,列表结构会丢失,需要自己重新写一个列表

比如从 pdf 复制的文字有多余的换行和空格,总是要手动删除

比如从维基百科复制到文字总是有很多链接和上标
……

✨ 插件功能

插件主要有两个功能

  • 粘贴时自动处理
  • 对块进行处理

粘贴时自动处理

在思源笔记的顶栏添加一个按钮,可以选择开启或关闭某个处理功能。

目前具有的功能:

  • LaTeX 行间数学公式(\[...\])转为 $$...$$ 格式,行内数学公式(\(...\))转为 $...$ 格式

  • 列表样式保留

    • 用途:支持将 PPT、word 的列表样式粘贴思源笔记依然保留列表样式和层级结构,支持将•○▪▫◆◇►▻❖✦✴✿❀⚪☐ 符号的列表变为思源笔记列表格式)
  • 去除上标

    • 用途:去除维基百科等网站多余的上标
  • 去除链接

    • 用途:去除维基百科等网站多余的关键词链接
  • 去除换行

    • 用途:去除 pdf 复制的多余换行
  • 去除空格

    • 用途:去除 pdf 复制的多余空格
  • 去除空行

    • 用途:让粘贴内容全部都在一个块里
  • 添加空行

    • 用途:让粘贴内容一段一个块

注意:插件只影响外部纯文本粘贴和部分 html 粘贴,可能不影响 html 复制和思源笔记内部的富文本粘贴,如果发现不生效可以用右键菜单的纯文本粘贴来实现自动处理(虽然会丢格式,但暂时没法解决)。

对块进行处理

目前插件会给块菜单添加如下按钮

  • 合并块【选中两个块及以上出现】

  • 拆分块

  • 列表符号转 markdown 列表

    • 用途:将•○▪▫◆◇►▻❖✦✴✿❀⚪☐ 符号的列表变为思源笔记列表格式
  • 仅复制一级列表(带符号)【仅选中一个列表块时出现】

    • 用途:仅需要复制一级列表,分享给别人时使用
  • 复制到小红书

    • 用途:发小红书、发微信等纯文本场景,会将列表符号替换为指定符号
    • 备注:有序列表使用数字 emoji1️⃣2️⃣3️⃣,无序列表可以在设置里指定符号,默认为 ■○

  • 去除链接

  • 去除上标

❤️ 用爱发电

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

捐赠者列表见:https://www.yuque.com/achuan-2

开发笔记

加载插件数据和保存插件数据

加载插件数据

// 加载插件数据
await this.loadData(STORAGE_NAME);
console.log(this.data[STORAGE_NAME]);

保存插件数据

// 在前面设置配置默认值
this.data[STORAGE_NAME] = {
    LaTeXConversion: false,
    removeNewlines: false,
    removeSpaces: false,
    removeEmptyLines: false, 
    addEmptyLines: false,
    pptList: false
}/


// 保存数据
this.saveData(STORAGE_NAME, this.data[STORAGE_NAME]);

LaTeX 行内公式和行间公式

if (this.data[STORAGE_NAME].LaTeXConversion) {
    text = text.replace(/\\$$(.*?)\\$$/gs, '$$$$$1$$$$'); // LaTeX 行间数学公式块,允许中间有换行
    text = text.replace(/\\$(.*?)\\$/g, '$$$1$$'); // LaTeX 行内数学公式
    siyuan = siyuan.replace(/\\$$(.*?)\\$$/gs, '$$$$$1$$$$'); // LaTeX 行间数学公式块,允许中间有换行
    siyuan = siyuan.replace(/\\$(.*?)\\$/g, '$$$1$$'); // LaTeX 行内数学公式

}

合并块

只有当前选中的块 detail.blockElements.length>1 时才出现,获取第一个 detail.blockElements 的 data-node-id,用 getBlockKramdown API 获取所有 detail.blockElements 的文本内容,去除每个块的 id 后,内容添加换行拼接在一起,用 updateBlock API 更新第一个块内容,之后需要删除其余的所有块

const firstBlockId = detail.blockElements[0].dataset.nodeId;
    let mergedContent = '';

    // Gather content from all blocks using SQL
    for (const block of detail.blockElements) {
        const blockId = block.dataset.nodeId;
        const content = (await getBlockKramdown(blockId)).kramdown;
        // Split content into lines
        function cleanText(text) {
            let lines = text.split('\n');
            lines.pop(); // Remove last line
            return lines.join('\n');
        }

        let contentClean = cleanText(content);
        if (contentClean && contentClean.length > 0) {
            console.log(contentClean)
            mergedContent += contentClean + '\n';
        }
    }

    // Update first block with merged content
    await updateBlock('markdown', mergedContent.trim(), firstBlockId);

    // Delete other blocks
    for (let i = 1; i < detail.blockElements.length; i++) {
        const blockId = detail.blockElements[i].dataset.nodeId;
        await deleteBlock(blockId);
    }

拆分块

获取每一个块 detail.blockElement 的 data-node-id 属性,根据 id 用 getBlockKramdown API 获取文本内容,过滤掉每个块的 id 之后,根据换行拆成一个个块

for (const block of detail.blockElements) {
    const blockId = block.dataset.nodeId;
    const content = (await getBlockKramdown(blockId)).kramdown;
    console.log(content)
    if (content && content.length > 0) {
        // Split content into lines
        function cleanText(text) {
            return text
                .split('\n')
                .map(line => line.replace(/^[\s]*\{:[^}]*id="[^"]*"[^}]*\}/g, '').trim())
                .filter(line => line) // 移除空行
                .join('\n');
        }

        let contentClean = cleanText(content);
        const lines = contentClean.split('\n');
        console.log(lines);
        if (lines.length > 1) {
            // Update original block with first line
            await updateBlock('markdown', lines[0], blockId);
            // Insert remaining lines as new blocks
            let previousId = blockId;
            for (let i = 1; i < lines.length; i++) {
                if (lines[i].trim()) { // Skip empty lines
                    await refreshSql();
                    const newBlock = await insertBlock('markdown', lines[i], null, previousId,null)
                    if (newBlock) {
                        previousId = newBlock[0].doOperations[0].id;
                    }
                }
            }
        }
    }
}

纯文本列表转 markdown 列表

for (const block of detail.blockElements) {
    const blockId = block.dataset.nodeId;
    const content = (await getBlockKramdown(blockId)).kramdown;
    console.log(content)
    if (content && content.length > 0) {
        // Replace bullet points with markdown list syntax
        const updatedContent = content.replace(/(^|\n)[•○▪▫◆◇►▻❖✦✴✿❀⚪☐][\s]*/g, '$1- ');
        await updateBlock('markdown', updatedContent, blockId);
    }
}

只复制列表第一层级

在 handleBlockMenu 的子菜单里在添加一个功能,如果选中的块只有一个,且是列表块(data.type="NodeList")只复制列表第一层级,先用 sql 获取 content,才通过正则,只提取第一层级的列表结构

word、ppt 列表粘贴保留样式

全部代码

private convertOfficeListToHtml(htmlString, type = 'auto') {
    // 自动检测文档类型
    const isWord = htmlString.includes('mso-list:l0 level');
    const isPpt = htmlString.includes('mso-special-format');

    // 如果没有检测到任何列表结构,直接返回原始HTML
    if (!isWord && !isPpt) {
        return htmlString;
    }

    // 自动判断类型
    if (type === 'auto') {
        if (isWord) type = 'word';
        else if (isPpt) type = 'ppt';
    }

    // 根据类型调用对应的处理函数
    switch (type.toLowerCase()) {
        case 'word':
            return isWord ? this.convertWordListToHtml(htmlString) : htmlString;
        case 'ppt':
            return isPpt ? this.convertPPTListToHtml(htmlString) : htmlString;
        default:
            return htmlString;
    }
}

private convertWordListToHtml(htmlString) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(htmlString, 'text/html');

    const elements = Array.from(doc.body.children);
    const result = [];
    let listElements = [];

    // 判断列表类型
    function determineListType(element) {
        const listMarker = element.querySelector('span[style*="mso-list:Ignore"]');
        if (!listMarker) return 'ul'; // 默认无序列表

        // 获取列表标记的实际文本内容
        const markerText = listMarker.textContent.trim();

        // 检查是否为有序列表的常见标记
        // const isOrderedList = /^[0-9]+[.)]|^[a-zA-Z][.)]/.test(markerText);
        const isOrderedList = markerText.length > 1;
        return isOrderedList ? 'ol' : 'ul';
    }

    // 处理连续的列表组
    function processListGroup(elements) {
        if (elements.length === 0) return '';

        const fragment = document.createDocumentFragment();
        let currentList = null;
        let previousLevel = 0;
        let listStack = []; // 用于跟踪列表类型

        elements.forEach(p => {
            const style = p.getAttribute('style') || '';
            const levelMatch = style.match(/level(\d+)/);
            const currentLevel = parseInt(levelMatch[1]);
            const listType = determineListType(p);

            if (!currentList) {
                // 创建第一个列表
                currentList = document.createElement(listType);
                fragment.appendChild(currentList);
                listStack.push({ element: currentList, type: listType });
            } else if (currentLevel > previousLevel) {
                // 创建新的嵌套列表
                const newList = document.createElement(listType);
                currentList.lastElementChild.appendChild(newList);
                currentList = newList;
                listStack.push({ element: currentList, type: listType });
            } else if (currentLevel < previousLevel) {
                // 返回上层列表
                for (let i = 0; i < previousLevel - currentLevel; i++) {
                    listStack.pop();
                    currentList = listStack[listStack.length - 1].element;
                }
            } else if (currentLevel === previousLevel && listType !== listStack[listStack.length - 1].type) {
                // 同级但列表类型不同,创建新列表
                const newList = document.createElement(listType);
                if (listStack.length > 1) {
                    // 如果在嵌套中,添加到父列表项
                    currentList.parentElement.parentElement.appendChild(newList);
                } else {
                    // 顶层列表,直接添加到片段
                    fragment.appendChild(newList);
                }
                currentList = newList;
                listStack[listStack.length - 1] = { element: currentList, type: listType };
            }

            // 创建列表项
            const li = document.createElement('li');
            const pClone = p.cloneNode(true);
            // 删除Word特有的列表标记
            pClone.querySelectorAll('span[style*="mso-list:Ignore"]').forEach(span => {
                span.remove();
            });
            li.innerHTML = pClone.innerHTML;
            currentList.appendChild(li);

            previousLevel = currentLevel;
        });

        const wrapper = document.createElement('div');
        wrapper.appendChild(fragment);
        return wrapper.innerHTML;
    }

    // 遍历所有元素
    elements.forEach((element) => {
        const style = element.getAttribute('style') || '';
        const isListItem = style.includes('level') && style.includes('mso-list:');

        if (isListItem) {
            listElements.push(element);
        } else {
            if (listElements.length > 0) {
                result.push(processListGroup(listElements));
                listElements = [];
            }
            result.push(element.outerHTML);
        }
    });

    // 处理最后一组列表元素
    if (listElements.length > 0) {
        result.push(processListGroup(listElements));
    }

    return result.join('\n');
}




private convertPPTListToHtml(htmlString) {
    // 创建一个DOM解析器
    const parser = new DOMParser();
    const doc = parser.parseFromString(htmlString, 'text/html');

    // 找到所有元素
    const elements = Array.from(doc.body.children);
    const result = [];
    let listElements = [];

    // 判断列表类型
    function determineListType(element) {
        const bulletSpan = element.querySelector('span[style*="mso-special-format"]');
        if (!bulletSpan) return 'ul'; // 默认无序列表

        const style = bulletSpan.getAttribute('style') || '';
        // PPT中有序列表通常包含"numbullet"
        const isOrderedList = style.includes('numbullet');
        console.log(isOrderedList);
        return isOrderedList ? 'ol' : 'ul';
    }

    // 处理连续的列表组
    function processListGroup(elements) {
        if (elements.length === 0) return '';

        const fragment = document.createDocumentFragment();
        let currentList = null;
        let previousMargin = 0;
        let listStack = []; // 用于跟踪列表类型

        elements.forEach(div => {
            const style = div.getAttribute('style') || '';
            const marginMatch = style.match(/margin-left:([.\d]+)in/);
            const currentMargin = parseFloat(marginMatch[1]);
            const listType = determineListType(div);

            if (!currentList) {
                // 创建第一个列表
                currentList = document.createElement(listType);
                fragment.appendChild(currentList);
                listStack.push({ element: currentList, type: listType, margin: currentMargin });
            } else if (currentMargin > previousMargin) {
                // 创建新的嵌套列表
                const newList = document.createElement(listType);
                currentList.lastElementChild.appendChild(newList);
                currentList = newList;
                listStack.push({ element: currentList, type: listType, margin: currentMargin });
            } else if (currentMargin < previousMargin) {
                // 返回上层列表
                while (listStack.length > 0 && listStack[listStack.length - 1].margin > currentMargin) {
                    listStack.pop();
                }
                currentList = listStack[listStack.length - 1].element;
            } else if (currentMargin === previousMargin && listType !== listStack[listStack.length - 1].type) {
                // 同级但列表类型不同,创建新列表
                const newList = document.createElement(listType);
                if (listStack.length > 1) {
                    // 如果在嵌套中,添加到父列表项
                    currentList.parentElement.parentElement.appendChild(newList);
                } else {
                    // 顶层列表,直接添加到片段
                    fragment.appendChild(newList);
                }
                currentList = newList;
                listStack[listStack.length - 1] = { element: currentList, type: listType, margin: currentMargin };
            }

            // 创建列表项
            const li = document.createElement('li');
            const divClone = div.cloneNode(true);
            // 删除PPT特有的列表标记
            divClone.querySelectorAll('span[style*="mso-special-format"]').forEach(span => {
                span.remove();
            });
            li.innerHTML = divClone.innerHTML;
            currentList.appendChild(li);

            previousMargin = currentMargin;
        });

        const wrapper = document.createElement('div');
        wrapper.appendChild(fragment);
        return wrapper.innerHTML;
    }

    // 遍历所有元素
    elements.forEach((element) => {
        const style = element.getAttribute('style') || '';
        const hasBullet = element.querySelector('span[style*="mso-special-format"]');

        if (hasBullet && style.includes('margin-left')) {
            // 收集列表元素
            listElements.push(element);
        } else {
            // 如果有待处理的列表元素,先处理它们
            if (listElements.length > 0) {
                result.push(processListGroup(listElements));
                listElements = [];
            }
            // 保持非列表元素不变
            result.push(element.outerHTML);
        }
    });

    // 处理最后一组列表元素
    if (listElements.length > 0) {
        result.push(processListGroup(listElements));
    }

    return result.join('\n');
}

word、ppt 列表转换

由于 word 的 html 没有结构,复制到思源笔记里会丢失无序列表层级,我希望把 style 中含有 level 的 p 节点替换为 html 列表(ul>li 结构),并根据 level 后面的数字判断列表层级,完美还原 word 里的列表层级,并不影响列表前后的内容,这样我就可以把 word 的内容无损粘贴到思源笔记里了

word 的列表层级怎么区分

根据属性 mso-list: 中 level 后面的数字

  • 第一层级就是 level1
  • 第二层级就是 level2

PPT 的列表层级怎么区分

根据列表的 margin-left 来区别

word 有序列表和无序列表的区别

  • 有序列表:里数字,比如 1.、1)、a.,都为两位以上字符
  • 无序列表:里是字符,都只有两个字符

PPT 有序列表和无序列表结构的区别

  • 无序列表:'span[style*="mso-special-format"]mso-special-format 的值为 bullet
  • 有序列表:'span[style*="mso-special-format"]mso-special-format 的值包含 numbullet
  • 思源笔记

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

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

    23020 引用 • 92597 回帖
4 操作
Achuan-2 在 2024-12-16 08:59:52 更新了该帖
Achuan-2 在 2024-12-15 19:54:36 更新了该帖
Achuan-2 在 2024-12-15 19:54:07 更新了该帖
Achuan-2 在 2024-12-15 19:53:09 更新了该帖

相关帖子

欢迎来到这里!

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

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

    膜拜大佬

  • 其他回帖
  • tongzi 1 赞同

    这个思路非常好!就像文本工具里的功能。

    把一些粘贴操作需要常用的后期处理简化,像红一样批处理,很棒

  • Achuan-2

    改进了下列表复制纯文本的效果

    PixPin20241215105315.png

    可以在设置里设置用什么符号,有序列表就不提供设置,默认用 1️⃣2️⃣3️⃣

    PixPin20241215105328.png

  • Achuan-2

    @participants

    粘贴时自动处理新增

    • 去除上标
    • 去除链接

    块菜单新增:复制到小红书

    PixPin20241215175020.png

    ⭐ 标题 2

    💡 标题 3

    ■ 内容
    ○ 内容
    ■ 内容
    ○ 内容

    ⭐ 标题 2

    💡 标题 3

    ■ 内容
    ○ 内容
    ■ 内容
    ○ 内容

  • 查看全部回帖