工作流 1-Templater 的模块化与路由化
1 前言
templater 的伟大无需多言,我平时用它创建了很多自动化流程模板。但是随着模板数量的增多,这些模板越来越难以管理,并且难以使用,因为他们都是零零散散的(主要是我的快捷键也不太够分配了)。
如果仅仅是这样 Templater 本身的 insert 与 create 两种命令足以勉强使用(这两个命令会将通一份模板都在各自的面板下列出来,但有些模板仅用来插入,有些模板仅用来新建)
关键问题在于,有些模板有极强的复用性,比如 获取 obsidian 库中所有标签在 templater 提示框中提供模糊查询
,获取让用户选则库中任意路径新建笔记的路径
等等,虽然已经在单个模板内将其封装为方法了,但我仍然不想在另一个模板中再复制一边这个方法(或者是前端叫函数)
或许可以将复用性强的模板封装为模块,而有了模块模板以后,或许我们就可以有一种更方便的调用模板方式,路由的思想就挺不错
2 两种模板
目前我的库中只有两种模板,tp_new(快捷键 Ctrl + N) 与 tp_enter (快捷键 Ctrl + Enter)
以及众多模块模板,_module
我目前所有的模板都会通过这两个模板文件实现
2.1 tp_new
tp_new 用来新建文件
动图演示如下
tp_new 模板代码
<%*
/* ==================== 配置区 ==================== */
const CONFIG = {
AUTHOR: "lspzc",
FOLDER_MAP: {
learn: "md/learn",
work: "md/work",
block: "md/block",
encrypt: "md/encrypt",
index: "index",
},
DOC_TYPES: ["learn", "work", "encrypt", "block", "index"],
STATE_OPTIONS: {
labels: ["进行中", "待办/未开始", "持续更新", "已完成"],
values: ["doing", "todo", "update", "done"],
},
};
/* ==================== 工具函数 ==================== */
// 生成 YYYYMMDD_ 格式日期前缀
const getDatePrefix = () => {
const pad = (n) => String(n).padStart(2, "0");
const now = new Date();
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_`;
};
// 构建 Frontmatter 的链式对象
const createFrontmatterBuilder = () => {
const properties = [];
let closed = false;
const builder = {
add(key, value, condition = true) {
if (!closed && condition) {
properties.push(`${key}: ${value}`);
}
return builder;
},
close() {
closed = true;
return `---\n${properties.join("\n")}\n---\n\n`;
},
};
return builder;
};
// 获取标题逻辑
const promptForTitle = async (mdType) => {
const isIndex = mdType === "index";
const isEncrypt = mdType === "encrypt";
const mainPrompt = isIndex ? "请输入索引分类" : "请输入笔记标题";
const defaultVal = isIndex ? "" : getDatePrefix();
const title = await tp.system.prompt(mainPrompt, defaultVal);
if (!title) {
new Notice("🛑 操作已取消");
return null;
}
if (isIndex) {
const subTitle = await tp.system.prompt("请输入索引名称", "");
return subTitle ? `${title}_${subTitle}` : null;
}
return isEncrypt ? `${title}_Encrypt` : title;
};
// 构建内容区域
const buildContent = (mdType, title) => {
let content = "";
if (mdType !== "index") {
const cleanTitle = title.replace(/^\d{8}_/, "");
content += `# ${cleanTitle}\n`;
}
if (mdType === "encrypt") {
content += `\n> [!tip] 注意\n> 不要忘记加密此文档\n`;
}
return content;
};
/* ==================== 主流程 ==================== */
try {
// 步骤1: 选择文档类型
const mdType = await tp.system.suggester(
CONFIG.DOC_TYPES,
CONFIG.DOC_TYPES,
true,
"新建文件类型"
);
// 步骤2: 获取标题
const title = await promptForTitle(mdType);
if (!title) return;
// 步骤3: 处理learn/work类型文档
if (["learn", "work"].includes(mdType)) {
const state = await tp.system.suggester(
CONFIG.STATE_OPTIONS.labels,
CONFIG.STATE_OPTIONS.values,
true,
"选择笔记状态"
);
const addNumbering = await tp.system.suggester(
["添加标题编号", "不添加编号"],
[true, false],
true,
"标题编号设置"
);
await tp.file.include("[[module_getTags]]");
const tags = tp.config.selectedTags;
// 构建 frontmatter
const frontmatter = createFrontmatterBuilder()
.add("author", CONFIG.AUTHOR)
.add("tags", JSON.stringify(tags))
.add("type", mdType)
.add("state", state)
.add("number headings", "auto, first-level 1, max 6, _.1.1", addNumbering)
.add("created", tp.date.now("yyyy-MM-DD HH:mm"))
.add("updated", tp.date.now("yyyy-MM-DD HH:mm"))
.close();
tR += frontmatter;
}
// 步骤4: 构建内容主体
tR += buildContent(mdType, title);
// 步骤5: 处理index类型文档
if (mdType === "index") {
await tp.file.include("[[module_getIndex]]");
tR += tp.config.indexTemplate;
}
// 步骤6: 移动文件
const targetFolder = CONFIG.FOLDER_MAP[mdType] || "";
await tp.file.move(`${targetFolder}/${title}`);
} catch (error) {
console.error("模板执行错误:", error);
new Notice("🛑 模板执行失败");
return;
}
-%>
<% tp.file.cursor() %>
2.2 tp_enter
tp_enter 用来创建插入文档中的一些模板
有时候,在这个模板中,使用了类似路由的思想,模块并不是说复用性的代表,我为了维护路由的纯净性,将实际逻辑放到模块中,也未尝不可
动图演示如下
tp_enter 模板代码
<%*
// 使用 switch 语句的纯路由模板
const choice = await tp.system.suggester(
["代码块","待办","outLink","callout"],
["codeBlock","TODO","outLink","callout"]
);
if(!choice){
new new Notice("🛑 操作已取消");
return
}
let output = "";
switch (choice) {
case "codeBlock":
output = await tp.file.include("[[module_getCodeBlock]]");
break;
case "TODO":
output = await tp.file.include("[[module_TODO]]");
break;
case "outLink":
output = await tp.file.include("[[module_outLink]]");
break;
case "callout":
output = await tp.file.include("[[module_getCallout]]");
break;
default:
new new Notice("操作已取消");
break;
}
// 3. 将捕获的输出添加到主模板
if(!output){
new new Notice("🛑 模板错误,请检查");
return;
}
tR += output;
%>
3 模块模板
tp 模板与 module 模板之间通过 tp.file.include("[[模块模板]]")
语法导入,具体的父子通信大家可以我代码或者在评论区询问。
这里分享 5 个模块模板,也希望各位大佬在评论区分享自己的模块,大家一起复用
3.1 module_getMdPath
- 此模块通过弹框让用户选则笔记创建的路径
- 用户可以新建文件夹
- 会对文件夹与笔记名称进行唯一性校验
该模块会返回用户新建的文件路径
调试演示
模块代码
<%*
/* ========== 配置区 - 可扩展的常量配置 ========== */
const CONFIG = {
BASE_FOLDER: "",
EXCLUDE_FOLDERS: new Set(["papers", "attachments"]),
FILE_EXT: ".md",
};
/* ========== 核心功能函数 ========== */
/* 获取分层文件夹 */
async function getHierarchicalFolders() {
let currentPath = CONFIG.BASE_FOLDER;
let finalPath = "";
while (true) {
const { folders } = await app.vault.adapter.list(currentPath);
const validFolders = folders
.map((fullPath) => fullPath.split("/").pop())
.filter((folder) => !CONFIG.EXCLUDE_FOLDERS.has(folder.toLowerCase()));
const options = [];
if (currentPath !== CONFIG.BASE_FOLDER) options.push("🔙");
options.push(...validFolders, "✔️");
const chosen = await tp.system.suggester(
options,
options,
false,
`选择目录 📁 当前路径: ${currentPath.replace(
CONFIG.BASE_FOLDER,
"根目录"
)}`
);
if (!chosen) return null;
if (chosen === "✔️") {
finalPath = currentPath.replace(CONFIG.BASE_FOLDER + "/", "");
break;
} else if (chosen === "🔙") {
currentPath = currentPath.split("/").slice(0, -1).join("/");
} else {
currentPath = `${currentPath}/${chosen}`;
}
}
return finalPath;
}
/* 判读文件是否存在 */
async function isFileExists(folderPath, fileName) {
const fullPath =
`${CONFIG.BASE_FOLDER}/${folderPath}/${fileName}${CONFIG.FILE_EXT}`.replace(
/\/+/g,
"/"
);
return await app.vault.adapter.exists(fullPath);
}
/* 获取唯一文件名 */
async function getUniqueFileName(chosenFolder) {
while (true) {
const titleName = await tp.system.prompt("请输入笔记标题 🗒️", "未命名笔记");
if (!titleName) return false;
if (!(await isFileExists(chosenFolder, titleName))) return titleName;
new Notice(`⚠️ "${titleName}" 已存在,请换用其他名称`);
}
}
/* 新增文件夹流程 */
async function createNewFolder() {
const basePath = await getHierarchicalFolders();
if (!basePath) return null;
while (true) {
const folderName = await tp.system.prompt(
"请输入新增文件夹名称 📁",
"新文件夹"
);
if (!folderName) return null;
const fullPath = `${CONFIG.BASE_FOLDER}/${basePath}/${folderName}`.replace(
/\/+/g,
"/"
);
if (!(await app.vault.adapter.exists(fullPath))) {
await app.vault.createFolder(fullPath);
return `${basePath}/${folderName}`;
}
new Notice(`⚠️ 文件夹 "${folderName}" 已存在!`);
}
}
/* 主流程 */
// 判断使用现有目录还是新建目录
let isNewFolder;
try {
isNewFolder = await tp.system.suggester(
["使用现有目录", "创建新文件夹"],
[false, true],
true,
"选择文件夹操作 🗃️"
);
} catch (error) {
new Notice("🛑 操作已取消");
return;
}
// 获取新建笔记的路径
let chosenFolder;
if (isNewFolder) {
// 新建目录,调用新建目录方法
chosenFolder = await createNewFolder();
} else {
// 选择原有目录,调用获取分层文件夹方法
chosenFolder = await getHierarchicalFolders();
}
if (!chosenFolder) {
new Notice("🛑 操作已取消");
return;
}
// 获取唯一笔记标题
const titleName = await getUniqueFileName(chosenFolder);
if (!titleName) {
new Notice("🛑 操作已取消");
return;
}
const mdPath = `${
CONFIG.BASE_FOLDER ? CONFIG.BASE_FOLDER + "/" : ""
}${chosenFolder}/${titleName}`;
// 最终输出格式 BASE_FOLDER/md/learn/未命名笔记
tp.config.mdPath = mdPath;
// 调试信息
// tR += mdPath;
// console.log("唯一文件夹名称:", chosenFolder);
// console.log("唯一笔记标题:", titleName);
// console.log("md文件路径: ", mdPath);
-%>
3.2 module_getTags
- 空回车会获取库中所有标签并显示标签使用次数
- 用户可以模糊查询标签
- 用户可以新建单个标签或多个标签
- 当未查询到用户输入的标签时会提示用户是否新建标签
该模块会返回用户选择的标签数组
调试演示
模块代码
<%*
// 获取并排序所有标签(缓存优化)
const allTags = Object.keys(app.metadataCache.getTags()).sort();
// 主操作选择(图标+文字提示)
const action = await tp.system.suggester(
["搜索旧标签", "创建新标签"],
["search", "create"],
true,
"获取库中标签"
);
let selectedTags = [];
// 动态预览函数(实时更新)
const getPreview = (tags) => {
if (tags.length === 0) return "(无)";
if (tags.length > 5)
return `${tags.slice(0,5).join(' ')}...等${tags.length}个标签`;
return tags.join(' ');
};
if (action === "search") {
// 标签搜索流程
while (true) {
// 实时预览当前已选标签
const previewStatus = selectedTags.length > 0
? `${getPreview(selectedTags)}`
: "未选择标签";
// 智能搜索提示(带实时预览)
const keyword = await tp.system.prompt(
`已选标签 | ${previewStatus}`,
"",
false
);
// 高效搜索(支持多关键词)
const keywords = keyword.toLowerCase().trim().split(/\s+/).filter(Boolean);
const filteredTags = keyword
? allTags.filter((tag) => {
const lowerTag = tag.toLowerCase();
return keywords.some((kw) => lowerTag.includes(kw));
})
: [...allTags];
// 没有搜索到标签的处理
if (filteredTags.length === 0) {
const choice = await tp.system.suggester(
[`新建标签: #${keyword}-继续`,`新建标签: #${keyword}-完成`, "重新搜索"],
["create","ok","retry"],
);
if (choice === "create") {
const newTag = `#${keyword.trim()}`
const cleanTag = newTag.startsWith("#") ? newTag.slice(1) : newTag;
// 去重检查
if (!selectedTags.includes(cleanTag)) {
selectedTags.push(cleanTag);
}
continue;
}
if (choice === "ok") {
const newTag = `#${keyword.trim()}`
const cleanTag = newTag.startsWith("#") ? newTag.slice(1) : newTag;
// 去重检查
if (!selectedTags.includes(cleanTag)) {
selectedTags.push(cleanTag);
}
break;
}
if (choice === "retry") continue;
}
// 标签选择界面(带使用频率)
const selection = await tp.system.suggester(
(tag) => `${tag} → ${app.metadataCache.getTags()[tag]}次使用`,
filteredTags,
{ placeholder: `找到 ${filteredTags.length} 个标签 | ${previewStatus}` }
);
if (!selection) break;
// 添加标签并去重
const cleanTag = selection.startsWith("#") ? selection.slice(1) : selection;
if (!selectedTags.includes(cleanTag)) {
selectedTags.push(cleanTag);
}
// 添加后即时状态提示(带实时预览)
const continueChoice = await tp.system.suggester(
["继续添加", `完成 (${selectedTags.length} 个)`],
[false, true],
);
if (continueChoice) break;
}
} else {
// 批量创建流程
const userInput = await tp.system.prompt(
"创建标签(多个标签空格分隔)",
"",
true
);
if (userInput) {
// 智能分割并去重
const newTags = [...new Set(userInput.split(/[\s,,、]+/))]
.filter(tag => tag.trim())
.map(tag => {
tag = tag.trim().replace(/^#+/, ''); // 移除所有前缀#
return tag;
})
.filter(tag => tag !== ""); // 过滤空标签
// 合并并全局去重
const uniqueTags = [...new Set([...selectedTags, ...newTags])];
selectedTags = uniqueTags;
}
}
// 最终输出格式 ['tag1','tag2','tag3']
tp.config.selectedTags = selectedTags;
%>
3.3 module_getCallout
- 用户选择 Callout 样式插入
在插入模板演示中有效果演示
模块代码
<%*
// 定义callout类型映射
const callouts = {
note: '🔵 Note',
info: '🔵 Info',
todo: '🔵 Todo',
tip: '🟢 Tip / Hint / Important',
abstract: '🟢 Abstract / Summary / TLDR',
success: '🟢 Success / Check / Done',
question: '🟠 Question / Help / FAQ',
warning: '🟠 Warning / Caution / Attention',
quote: '⚪ Quote / Cite',
example: '🟣 Example',
failure: '🔴 Failure / Fail / Missing',
danger: '🔴 Danger / Error',
bug: '🔴 Bug',
};
// 1. 使用更直观的折叠选项描述
const foldOptions = [
{ text: '不折叠', value: '' },
{ text: '展开状态', value: '+' },
{ text: '折叠状态', value: '-' }
];
// 2. 添加取消处理
let type = await tp.system.suggester(
Object.values(callouts),
Object.keys(callouts),
true,
'选择提示框类型'
);
// 3. 改进折叠选项选择
let fold = await tp.system.suggester(
foldOptions.map(opt => opt.text),
foldOptions.map(opt => opt.value),
true,
'选择折叠状态'
);
if (fold === undefined) {
new Notice("操作已取消");
return;
}
// 4. 添加默认标题
let title = await tp.system.prompt(
'标题',
'',
true
);
if (!title) {
new Notice("操作已取消");
return;
}
// 5. 改进多行输入处理
let content = await tp.system.prompt(
'内容 (换行使用 Shift+Enter):',
'',
true,
true
);
if (!content) {
new Notice("操作已取消");
return;
}
// 6. 构建callout内容
let output = "";
// 添加callout头部
if (title) {
output += `> [!${type}]${fold} ${title}\n`;
} else {
output += `> [!${type}]${fold}\n`;
}
// 添加内容
if (content) {
// 处理多行内容
const contentLines = content.split('\n');
// 为每行添加callout格式
contentLines.forEach(line => {
output += `> ${line}\n`;
});
} else {
// 没有内容时添加空行保持格式
output += `>\n`;
}
// 7. 输出结果
tR += output + "\n";
%>
3.4 module_outLink
- 收集用户输入外链
在插入模板演示中有效果演示
模块代码
<%*
const linkName = await tp.system.prompt("请输入链接名称");
if(!linkName){
new new Notice("🛑 操作已取消");
return
}
const link = await tp.system.prompt("请输入链接地址");
if(!link){
new new Notice("🛑 操作已取消");
return
}
tR += `[${linkName}](${link})`
%>
3.5 module_getIndex
- 创建笔记索引文件
这个模板需要配合两个插件:Dataview 与 Tabs
在插入模板演示中有效果演示
模块代码
<%*
await tp.file.include("[[module_getTags]]");
const selectedTags = tp.config.selectedTags;
let tags;
if (selectedTags.length === 1) {
tags = `#${selectedTags[0]}`;
} else {
const hashedTags = selectedTags.map((tag) => `#${tag}`);
tags = `${hashedTags.join(" and ")}`;
}
const states = [
{ name: "已完成", value: "done" },
{ name: "进行中", value: "doing" },
{ name: "待办/未开始", value: "todo" },
{ name: "持续更新", value: "update" },
];
let content = "````tabs\n";
states.forEach((state) => {
content += `\ntab: ${state.name}\n`;
content += "```dataview\n";
content += `table updated\n`;
content += `from ${tags}\n`;
content += `where state = "${state.value}"\n`;
content += `sort updated desc\n`;
content += "```\n";
});
content += "````\n";
// 输出
tp.config.indexTemplate = content;
%>
4 结束语
后边有新增模块会更新到评论区,也希望各位大佬一起充实模板库