工作流 1-Templater 的模块化与路由化

工作流 1-Templater 的模块化与路由化

1 前言

templater 的伟大无需多言,我平时用它创建了很多自动化流程模板。但是随着模板数量的增多,这些模板越来越难以管理,并且难以使用,因为他们都是零零散散的(主要是我的快捷键也不太够分配了)。

如果仅仅是这样 Templater 本身的 insert 与 create 两种命令足以勉强使用(这两个命令会将通一份模板都在各自的面板下列出来,但有些模板仅用来插入,有些模板仅用来新建)

关键问题在于,有些模板有极强的复用性,比如 获取 obsidian 库中所有标签在 templater 提示框中提供模糊查询获取让用户选则库中任意路径新建笔记的路径 等等,虽然已经在单个模板内将其封装为方法了,但我仍然不想在另一个模板中再复制一边这个方法(或者是前端叫函数)

或许可以将复用性强的模板封装为模块,而有了模块模板以后,或许我们就可以有一种更方便的调用模板方式,路由的思想就挺不错

2 两种模板

目前我的库中只有两种模板,tp_new(快捷键 Ctrl + N) 与 tp_enter (快捷键 Ctrl + Enter)

以及众多模块模板,_module

image

我目前所有的模板都会通过这两个模板文件实现

2.1 tp_new

tp_new 用来新建文件

动图演示如下

7d1915266ae542379598196aa533042c

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 用来创建插入文档中的一些模板

有时候,在这个模板中,使用了类似路由的思想,模块并不是说复用性的代表,我为了维护路由的纯净性,将实际逻辑放到模块中,也未尝不可

动图演示如下

66419b5cd6acfb0239357a14e5cba9a6

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

  • 此模块通过弹框让用户选则笔记创建的路径
  • 用户可以新建文件夹
  • 会对文件夹与笔记名称进行唯一性校验

该模块会返回用户新建的文件路径

调试演示

3d9d072c12f8181d79a87e39d0f2f6f7

模块代码

<%*
/* ========== 配置区 - 可扩展的常量配置 ========== */
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

  • 空回车会获取库中所有标签并显示标签使用次数
  • 用户可以模糊查询标签
  • 用户可以新建单个标签或多个标签
  • 当未查询到用户输入的标签时会提示用户是否新建标签

该模块会返回用户选择的标签数组

调试演示

dc887e9c2f15c11b5890d7a8ad612fd6

模块代码

<%*
// 获取并排序所有标签(缓存优化)
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 结束语

后边有新增模块会更新到评论区,也希望各位大佬一起充实模板库

2 个赞

tp_enter 优化,进一步简化路由配置,用户只需要关心用户选项与模块模板路径

<%*
// 路由配置区(用户只需维护用户选项与模块模板路径)
const userOptions = ["代码块", "待办", "外链", "提示框"];
const modulePaths = [
  "[[module_getCodeBlock]]",
  "[[module_TODO]]",
  "[[module_outLink]]",
  "[[module_getCallout]]",
];

// 执行区
const choice = await tp.system.suggester(userOptions, userOptions);
if (!choice) {
  new Notice("🛑 操作已取消");
  return;
}

const index = userOptions.indexOf(choice);
const output = await tp.file.include(`${modulePaths[index]}`);

if (!output) {
  new Notice("🛑 插入错误,请检查模块:" + modulePaths[index]);
  return;
}
tR += output;
%>