Obsidian 开发案例:30 分钟和 GPT 写一个 OB 插件
前言
如果说你想要编写第一个 Obsidian 插件,使用 ChatGPT 也许是最好、最快的选择。
开发背景
昨天晚上,我因为本职工作的原因,我发现 Obsidian 中只能对 Markdown 的标准有序列表进行自增,例如:
1. 这是有序列表1
2. 这是有序列表2
3. 这是有序列表3
也不存在有插件支持以下的几种形式的自增:
一、这是有序列表1
二、这是有序列表2
三、这是有序列表3
或者
(1)、这是有序列表1
(2)、这是有序列表2
(3)、这是有序列表3
于是,我找来了 ChatGPT,让它临时给我开发一个小插件来解决这个问题。如果你之前了解过 Obsidian 插件的编写,那你可能会知道和编辑器相关的插件都是基于 Codemirror 6 实现的,以下是我与 ChatGPT 对话的全流程。
而最开始,你需要先确定自己的所期望的流程:
你是一名 CodeMirror 技术专家,请你根据以下的需求进行插件编写:
1、当用户在有序列表中 newline 时;
2、下一行的开始会自动增加有序的下一个序号;
3、且下一个序号会匹配第一行的模式,因为存在 A、B、C 的模式的序号,也存在 a. b. c. 的模式的需要,还会存在罗马数字开始的序号,也会存在中文数字开始的序号; 匹配了第一行的模式后,自动在序号数组中寻找下一个序号,然后填充到 newLine 的开始位置;
注意,这个实现可以利用 Transaction 和 Changes 来直接插入到其中
我们的这个插件的核心就是能够对非标准的有序列表进行自增,而详细的功能实现流程也是为了让 ChatGPT 能够读懂我们的核心需求。
然后 ChatGPT 将我们的功能进一步分析得到:
Prompt
1. 识别当前行的有序列表模式
- 监听用户的回车键事件。
- 检查当前行是否为有序列表的一部分。这可以通过正则表达式来判断,例如识别行首的序号模式。
2. 确定下一个序号
- 根据第一行的序号模式,计算下一个序号。
- 这可能涉及到识别多种模式,如阿拉伯数字、字母(大写或小写)、罗马数字和中文数字。
- 实现一个函数来根据当前序号推断下一个序号。
3. 使用 Transaction 和 Changes 插入新的序号
- 创建一个
Transaction
来应用更改。- 使用
Changes
对象在新行的开始位置插入计算出的下一个序号。- 确保新插入的序号保持与前一行相同的格式。
4. 处理特殊情况
- 考虑用户可能会删除列表的某一行,这种情况下需要更新后续所有行的序号。
- 处理跨越多行文本的情况。
然后,ChatGPT 会给出第一次的实现,但是由于仅仅需求的分析就已经占满了回复的字数长度,所以它只浅浅给出了大纲似的回答。而后我逐步要求它根据要求 1、2,以及根据要求 3 来分别改进内容,最后要求它给出要求 4 的相关代码。
本来我打算在聊天线程中直接要求它将以上的所有代码进行总结,但是,我担心它单次总结后就丢失了绝大部分上边已经回答过的细节。所以最后我将全文拷贝了一次,找来 GPT 4-1101-Preview 模型来询问且要求总结,使用了以下很简单的 Prompt:
你是一名 Codemirror 6 专家,请根据以下对话,整理成一个完整的 Codemirror 6 插件的实现:
得到了一个完整的插件代码,但是它给出的只是以下的没有合理注释,且存在部分缺失的代码:
// 以下是部分代码
// 函数:识别当前行的有序列表模式
function identifyPattern(lineText) {
const patterns = {
arabic: /^\d+\./, // 例如 "1."
uppercaseLetter: /^[A-Z]\./, // 例如 "A."
lowercaseLetter: /^[a-z]\./, // 例如 "a."
romanNumeral: /^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\./, // 罗马数字
chineseNumeral: /^[\u4e00-\u9fa5]+\./ // 中文数字
};
for (let type in patterns) {
if (patterns[type].test(lineText)) {
return type;
}
}
return null;
}
// 函数:根据当前序号计算下一个序号
function getNextNumber(currentNumber, patternType) {
switch (patternType) {
case 'arabic':
return parseInt(currentNumber) + 1 + '.';
case 'uppercaseLetter':
return String.fromCharCode(currentNumber.charCodeAt(0) + 1) + '.';
case 'lowercaseLetter':
return String.fromCharCode(currentNumber.charCodeAt(0) + 1) + '.';
// TODO: 处理罗马数字和中文数字的转换
// ...
default:
return null;
}
}
但是,到此我已经可以开始将它移入到一个插件模板去了,然后,我初次尝试了以后,发现它并没有正确生成我想要的序号,紧接着,我发现是它最开始采用的方案出现了误差,利用 Keymap.of
的方式监控 Enter 会被 Obsidian 拦截。更优的方式是采用 ViewPlugin(当然中间我还打算使用 Statefield 的方式,但是 State field 的方式更偏向于更新当前的页面的状态,而不是用于插入新的内容),关于两者的不同,官方的开发文档其实有说明:ViewPlugin 和 State field。
于是,我开始让它分别按照 State field 和 ViewPlugin 的形式进行插件编写,而后,我依次在 IDE 中用 Typescript 验证了他们的可行性。注意,当使用 ChatGPT 编写 Typescript 插件的时候,你一定要记得让它将其中的类型补充完整,这种情况下当你将对应的代码移入 IDE 的时候,一旦有存在的问题,都会警示你,而这种警示已经是一种最快的验证方式。
此前我创建过一个极简的插件模板:GitHub - Quorafind/Obsidian-Custom-Plugin-Starter: A custom template for personal use. Added github workflow.
当你在 Vscode 或者 WebStorm 中将其 Git Clone 下来后,你可以在运行 npm install
以后直接开始插件编写,你需要建立一个独立的 myfunction.ts
文件,然后将上述的所有代码拷贝进去,当你出现依赖、类型问题时,IDE 会合理提醒你需要进行修改。
在代码就绪后,你会(第二个链接和第三个链接都是解决相关问题的)突然发现这个插件并没有按你预期地去更新序号,例如当在行中间进行更新的时候,它并不会在下一行的起始位置插入新的序号;又例如当当前行除了序号以外没有任何的内容时,它也没有很精明地去删除当前行内容。
在上述地基础上,你需要反复地去让 ChatGPT 进行修改,注意,这里上文所说的注释就起到了一定的作用,你可以基于每个函数的作用,来推敲究竟是哪个函数出了问题;最后得到了以下的代码(在文末):
总结
可以说,用 ChatGPT 来编写 Obsidian 是一个非常愉快的过程,除了部分 BUG 需要自己去理解以及读懂问题出在哪里以外,它无愧于最好且最尽责的代码老师。
相关代码【安装好依赖后,运行即可】
main.ts
import { Plugin } from 'obsidian';
import { enterPressPlugin } from "./line";
export default class MyPlugin extends Plugin {
async onload() {
this.registerEditorExtension(enterPressPlugin());
}
onunload() {
}
}
line.ts
import { EditorView, keymap, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { ChangeSet, ChangeSpec, EditorSelection, EditorState, StateField, Transaction } from "@codemirror/state";
type PatternType = 'arabic' | 'uppercaseLetter' | 'lowercaseLetter' | 'romanNumeral' | 'chineseNumeral';
/**
* 识别给定文本行的列表模式。
* @param lineText 要识别的文本行。
* @returns 返回识别出的列表模式类型,如果没有匹配,则返回 null。
*/
function identifyPattern(lineText: string): PatternType | null {
const patterns: { [key in PatternType]: RegExp } = {
arabic: /^\d+[\.|、]/,
uppercaseLetter: /^[A-Z][\.|、]/,
lowercaseLetter: /^[a-z][\.|、]/,
romanNumeral: /^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})[\.|、]/,
chineseNumeral: /^[\u4e00-\u9fa5]+[\.|、]/
};
for (let type in patterns) {
if (patterns[type as PatternType].test(lineText)) {
return type as PatternType;
}
}
return null;
}
/**
* 根据当前编号和模式类型获取下一个编号。
* @param currentNumber 当前编号。
* @param patternType 列表模式类型。
* @returns 返回计算后的下一个编号,如果无法处理则返回 null。
*/
function getNextNumber(currentNumber: string, punctuation: string, patternType: PatternType): string | null {
switch (patternType) {
case "arabic":
return parseInt(currentNumber) + 1 + punctuation;
case "uppercaseLetter":
return String.fromCharCode(currentNumber.charCodeAt(0) + 1) + punctuation;
case "lowercaseLetter":
return String.fromCharCode(currentNumber.charCodeAt(0) + 1) + punctuation;
case "romanNumeral":
let arabicNum = romanToArabic(currentNumber);
return arabicToRoman(arabicNum + 1) + punctuation;
case "chineseNumeral":
let arabicNumChinese = chineseToArabic(currentNumber);
return arabicToChinese(arabicNumChinese + 1) + punctuation;
default:
return null;
}
}
// 罗马数字转阿拉伯数字
function romanToArabic(roman: string): number {
const romanNumerals: { [key: string]: number } = {
M: 1000, CM: 900, D: 500, CD: 400, C: 100,
XC: 90, L: 50, XL: 40, X: 10, IX: 9, V: 5,
IV: 4, I: 1
};
let arabic = 0;
let i = roman.length;
while (i--) {
if (romanNumerals[roman[i]] < romanNumerals[roman[i + 1]]) {
arabic -= romanNumerals[roman[i]];
} else {
arabic += romanNumerals[roman[i]];
}
}
return arabic;
}
/**
* 将阿拉伯数字转换为罗马数字。
* @param number 要转换的阿拉伯数字。
* @returns 转换后的罗马数字字符串。
*/
function arabicToRoman(number: number): string {
const romanNumerals = {M: 1000, CM: 900, D: 500, CD: 400, C: 100, XC: 90, L: 50, XL: 40, X: 10, IX: 9, V: 5, IV: 4, I: 1};
let roman = '';
for (let key in romanNumerals) {
// @ts-ignore
while (number >= romanNumerals[key]) {
roman += key;
// @ts-ignore
number -= romanNumerals[key];
}
}
return roman;
}
/**
* 将单个中文数字字符转换为相应的阿拉伯数字。
* @param chinese 要转换的单个中文数字字符。
* @returns 转换后的阿拉伯数字,如果无法识别则返回 0。
*/
function chineseToArabic(chinese: string): number {
const chineseNumerals: { [key: string]: number } = {
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10
};
return chineseNumerals[chinese] || 0;
}
/**
* 将阿拉伯数字转换为中文数字字符。
* @param number 要转换的阿拉伯数字。
* @returns 转换后的中文数字字符,如果无法识别则返回空字符串。
*/
function arabicToChinese(number: number): string {
const chineseNumerals = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
return chineseNumerals[number] || '';
}
function calculateNewCursorPosition(forEnterChanges: ChangeSpec[], state: EditorState): number {
// 取最后一个更改的结束位置作为新的光标位置
let lastChangeEnd = 0;
if(forEnterChanges.length === 0) return lastChangeEnd;
if(forEnterChanges.length === 1) {
if('insert' in forEnterChanges[0] && (forEnterChanges[0].insert as string)?.trim() === '') {
return (forEnterChanges[0] as any).from || 0;
}
}
forEnterChanges.forEach(change => {
if ('from' in change && 'to' in change) {
lastChangeEnd = (change?.to || 0) + (change?.insert?.length as number) || 0;
}
});
return lastChangeEnd;
}
/**
* 检测并处理回车字符。
* @param transaction 当前事务。
* @param state 编辑器状态。
* @returns 更新后的状态。
*/
function checkForEnter(transaction: Transaction, state: EditorState) {
const changes: ChangeSpec[] = [];
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
let text = inserted.toString();
if (text.includes('\n')) { // 检测到回车
const currentLineStart = state.doc.lineAt(fromA).from;
const currentLineEnd = state.doc.lineAt(fromA).to;
const behindLineStart = state.doc.lineAt(toB).from;
const behindLineEnd = state.doc.lineAt(toB).to;
const behindLineText = state.doc.sliceString(behindLineStart, behindLineEnd);
const currentLineText = state.doc.sliceString(currentLineStart, currentLineEnd);
if(currentLineText.trim() === '') return; // 如果当前行为空行,不进行处理
const reg = /^([\(\[\【\(])?([^\)\]\】\)]*)([\)\]\】\)])?([\.、](.*))/;
if(new RegExp(reg, 'gm').exec(currentLineText)?.[5]?.trim() === '' && behindLineText.trim() === '') {
// 因为现在是第二次回车,但是除了标号以外的内容为空,所以需要删除这一行
const removePosStart = state.doc.lineAt(fromA).from; // 新行的开始位置
const removePosEnd = state.doc.lineAt(fromA).to + 1; // 新行的结束位置
const tChanges = { from: removePosStart, to: removePosEnd, insert: '' };
changes.push(tChanges);
return;
}
// 检测行首的成对括号
let bracketPattern = new RegExp(reg, 'gm'); // 检测成对括号
const bracketMatch = bracketPattern.exec(currentLineText);
const hasBrackets = bracketMatch !== null && bracketMatch[3] != undefined;
const textWithoutBrackets = hasBrackets ? currentLineText.replace(bracketPattern, '$2$4') : currentLineText;
let pattern = identifyPattern(textWithoutBrackets);
if(pattern === 'arabic' && !hasBrackets) return;
if (pattern) {
const currentNumber = textWithoutBrackets.match(/^[\w\u4e00-\u9fa5]+/)?.[0]; // 提取序号部分
const punctuation = textWithoutBrackets.match(/^[\w\u4e00-\u9fa5]+[\.\、]/)?.[0].slice(-1) || '.';
if (!currentNumber) return; // 如果没有序号,不进行处理
let nextNumber = getNextNumber(currentNumber, punctuation, pattern);
if (nextNumber !== null) {
// 如果存在成对括号,还原它们
nextNumber = hasBrackets && bracketMatch[1] !== undefined ? ((bracketMatch[1] + nextNumber.slice(0, -1) + bracketMatch[3]) + nextNumber.slice(-1)) : nextNumber;
const insertPosition = state.doc.lineAt(toB).from; // 新行的开始位置
const tChanges = { from: insertPosition, to: insertPosition, insert: nextNumber + (punctuation === "、" ? "" : " ") };
changes.push(tChanges);
}
}
}
});
return changes;
}
/**
* 注册成 Codemirror 插件。
* @returns View plugin 插件实例。
*/
export const enterPressPlugin = () => {
return ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (!update.docChanged) return;
if (
update.transactions.some(
(tr) =>
tr.annotation(Transaction.userEvent) === "undo" ||
tr.annotation(Transaction.userEvent) === "redo" ||
tr.annotation(Transaction.userEvent) === "plugin-update" ||
tr.annotation(Transaction.userEvent) === "set",
)
)
return;
if (update.docChanged) {
update.transactions.forEach((tr) => {
if (tr.docChanged) {
const forEnterChanges: ChangeSpec[] = checkForEnter(tr, update.view.state);
if(forEnterChanges.length === 0) return;
if (forEnterChanges.length > 0) {
setTimeout(() => {
const newCursorPosition = calculateNewCursorPosition(forEnterChanges, update.view.state);
const tr = update.view.state.update({
changes: ChangeSet.of(forEnterChanges, update.view.state.doc.length),
selection: EditorSelection.cursor(newCursorPosition)
});
update.view.dispatch(tr);
});
}
}
});
}
}
}
);
};
写在最后
编译后的成品插件 obsidian-new-list 请在 Pkmer 官方 Q 群获取。
讨论
若阁下有独到的见解或新颖的想法,诚邀您在文章下方留言,与大家共同探讨。
反馈交流
其他渠道
版权声明
版权声明:所有 PKMer 文章如果需要转载,请附上原文出处链接。