Obsidian 任务管理的新思路

:dart: 缘起

我之前一直犹豫是否要用Ob,犹豫关键点是Ob没有精细的块、没有notion类的数据表格。

我曾经试过用Obs管理10万个文件,虽然启动时有点卡,但其它时候都很流畅。
由此我突然想到,Ob性能这么好,为什么要一个任务记在一行里?
为什么不能一个任务就写成一个页面呢?
一个任务记一页,搞10年,任务页面也不会超过2万条吧?

大家用Ob做任务管理,一般都用Tasks插件。
这个插件非常好,但因为是一行一个任务,所以导致很多任务属性无法写入。
更加无法很好的管理子任务。
但如果一个任务一个页面,每个页面上加上丰富的属性,
然后就可以配合 Dataview 做出很复杂的查询了。

我理想中的任务管理软件是这样的,比如:
(1)能添加多层级的子任务,并以多层级列表的方式显示指定范围内的任务。
(2)手机或电脑上,能非常方便的添加新任务,特别是在指定任务下添加新的子任务。
(3)快速变更任务的状态。快速的搜索任务库内已有的任务。
(4)丰富多样的、不同角度的数据表格视图。
(5)数据统计。比如我想知道 A任务 下所有已完成的子任务,总共花了多长时间、最近一次完成的是哪个子任务?A任务下所有未完成的子任务,按重要性排?等等。

:green_book: 基本思路

每一条任务一个页面。
页面根据需要、用Templater配置笔记属性。
笔记属性就是任务的各种元数据信息,例如:

---
开始时间: 
结束时间: 
任务状态: 
持续时间: 
下级子任务所费时间:
总用时间:
重要性:
紧急性:
任务标签: 
截止时间: 
入库时间: <% tp.file.creation_date("YYYY-MM-DD HH:mm") %>
---

然用利用 Templater 和 QuickAdd 插件,快速输入新任务。
再利用 Dataview查出自己想找到的任务。

:credit_card: 已完成的工作

目前我利用 Cursor,做一个 QuickAdd 宏,完成了一小步。即:

【1】输入新任务时,用Quickadd 打开宏。

【2】下拉框显示任务文件夹中的所有文件夹,以及满足条件的文件(文件也就是页面,条件是页面的「任务状态」属性的值不是「已完成」)。

【3】你选择一个文件夹,或者文件。

当你选择一个文件夹时,新任务会放到这个文件夹下。
这时候要给文件夹新建一个同名文件,因为文件夹本身是一个有子任务的任务,所以它要有页面的笔记属性。
如果该文件夹下已经有同名文件,则不用新建。
这是把文件夹当作新任务的父任务。

当你选择一个文件时,Ob会在文件位置新建一个与文件同名的文件夹,并将选中的文件移动到这个文件夹下。
新任务就放到这个文件的同名文件夹下,以作为这个任务的子任务。

具体脚本见文尾。

:firecracker: 待完成的工作

【1】我想在上述「下级子任务所费时间」笔记属性中,自动填入此任务所有已完成的子任务(不包括子任务的子任务)所花费的时间。

假如「任务A」有3个已经完成的子任务,这3个已经完成的子任务所用的总时间,要自动填入「下级子任务所费时间」属性中。

同时,虽然理论上「任务A」的所有子任务时间的和就是「任务A」的时间,但在任务计划没有思考清楚或者其它情况下,「任务A」本身也会花费一定时间,这个时间就手动填入「持续时间」属性中。

然后把该任务所有子任务的「下级子任务所费时间」加起来,再加上该任务手动填入的「持续时间」,加到的和值,自动填入该「总用时间」属性中。

所以,统计当前任务的子任务所用的时间,是统计所有子任务的「总用时间」属性值的和。

这里都只是统计子任务,不统计子任务的子任务(即后代任务),每个层级都只统计子任务的话,会一级级传导上去。

这一步完成以后,我就能知道每个步骤我已经花费了多长时间。

【2】以层级列表的方式显示任务

Tasks最大的问题,在我看来,是子任务管理非常不方便。

Obs虽然有层级清楚的文件夹管理体系,但任务多了以后,每个文件夹下方会有很多已完成的任务杂在其中,且文件夹体系中,无法筛选显示满足某些条件的子任务。

所以, 这一步我希望能在Ob中显示 某个文件夹 下方所有满足条件的子任务和后代任务。 比如显示的任务的笔记属性「任务状态」要是未完成。

:art: 补充

以上是一些基本需求,其它的,重复任务之类的,一旦以页面的形式来管理任务,利用好 JS,就都会变得很简单。

image

/******************************************************************
 * task‑inbox.js —— QuickAdd / Templater 通用(onChooseItem 修正版)
 ******************************************************************/
module.exports = async (params, tp = null) => {
  const { app, obsidian } = params;
  const { TFolder, TFile, FuzzySuggestModal, Notice } = obsidian;

  const ROOT        = "任务管理";
  const TEMPLATE_MD = "Files/模板库/任务.md";
  const DONE_STATUS = new Set(["[[已完成]]"]);

  const fm   = f => app.metadataCache.getFileCache(f)?.frontmatter || {};
  const rel  = p => p.startsWith(ROOT + "/") ? p.slice(ROOT.length + 1) : p;
  const hasSameNamedFolder = f =>
    app.vault.getAbstractFileByPath(`${f.parent.path}/${f.basename}`) instanceof TFolder;
  const allow = f =>
    f instanceof TFile &&
    f.extension === "md" &&
    ![...DONE_STATUS].some(s => (fm(f)["任务状态"] || "").includes(s)) &&
    !hasSameNamedFolder(f) &&
    f.parent?.name !== f.basename;
  const walk = folder => folder?.children?.flatMap?.(c => {
    if (!c) return [];
    if (c instanceof TFolder) return c.name.includes("@") ? [] : [c, ...walk(c)];
    return allow(c) ? [c] : [];
  }) || [];

  const sanitize = s => s.replace(/[\/\\:\*\?"<>\|]/g, "_").trim();
  const createTask = async (dir, title) => {
    const safe = sanitize(title);
    const tgt  = `${dir}/${safe}.md`;
    if (app.vault.getAbstractFileByPath(tgt)) { new Notice("已存在同名任务"); return; }
    const tplFile = app.vault.getAbstractFileByPath(TEMPLATE_MD);
    const tpl     = tplFile ? await app.vault.read(tplFile) : "---\ntitle: \n---\n";
    await app.vault.create(tgt, tpl.replace(/title:\s*.*/i, `title: ${safe}`));
    await app.workspace.getLeaf(false).openFile(app.vault.getAbstractFileByPath(tgt));
  };
  const moveIntoFolder = async file => {
    if (!file?.path) return null;
    const dst = `${file.parent.path}/${file.basename}`;
    if (!app.vault.getAbstractFileByPath(dst)) await app.vault.createFolder(dst);
    await app.fileManager.renameFile(file, `${dst}/${file.basename}.md`);
    return dst;
  };

  const title = await (
    params.quickAddApi?.inputPrompt?.("新任务标题")
    ?? tp?.system.prompt?.("新任务标题")
  );
  if (!title) return;

  class Picker extends FuzzySuggestModal {
    constructor(dir) { super(app); this.dir = dir; }
    getItems()     { return walk(this.dir); }
    getItemText(i) { return `${i instanceof TFolder ? "📁" : "📝"} ${rel(i.path)}`; }

    async onChooseItem(it, evt) {                          /* ← 修正名称 */
      const enterChild = it instanceof TFolder &&
        ((evt && evt.detail === 2) || (!evt && window.event?.ctrlKey));
      if (enterChild) { this.close(); setTimeout(() => new Picker(it).open(), 0); return; }

      if (it instanceof TFolder) {
        await createTask(it.path, title);                  /* 【3】 */
      } else {
        const dir = await moveIntoFolder(it);              /* 【4】 */
        if (dir) await createTask(dir, title);
      }
      this.close();
    }
  }

  new Picker(app.vault.getAbstractFileByPath(ROOT)).open();
};

module.exports.task_inbox = module.exports;

我是编程小白,用 Cursor 编程只能解决很简单的问题。
希望有大佬能帮助实现这个功能,相信有很多人会有需要。

1 个赞

挺好的思路,如果能写成插件,就完美了

插件设置好存放任务文件的目录,然后定义好一些常用查询(复杂的个性化查询可以结合dataview完成)

是的。
这个思路基本是以滴答清单的模式为基础的,
然后加了一些滴答没有的数据统计、多视图展示等功能。

我还写了一个小脚本,
当某个任务完成以后,就一键把这个任务发送嵌入链接到对应日期的日记中。
脚本内容如下。

脚本的意思是,把「速记」文件夹下的页面,发送嵌入链接到其笔记属性「开始时间」日期参对应的日记中。「任务」文件夹同理,不过「文件」文件夹下的页面,必须要「任务状态」属性的值为「完全完成」或者「基本完成」。

你还可以根据需要,把其它文件夹下的页面发送嵌入链接到日记中。
这样就不用记录专门的日记了。

在这种思路下,你至少可以把:flomo的随手记、任务管理、日记、柳比歇夫的时间管理、财务管理,等多个软件要做的事情,都放到ob中。然后都发送到日记中。

只有一个前提:一个条目一个页面,用Quickadd迅速添加页面。不要担心页面太多。

module.exports = async (params) => {
  const { app } = params;
  const vault = app.vault;
  const moment = window.moment;

  // 固定日期为 2000 年 1 月 1 日
  const today = moment('2000-01-01').startOf('day');

  // 定义要检查的文件夹及其对应的时间字段
  const folders = [
    { path: '速记', field: '开始时间' },
    {
      path: '任务',
      field: '开始时间',
      extraCheck: (frontmatter) => {
        const status = frontmatter['任务状态'];
        return Array.isArray(status) && (status.includes('[[完全完成]]') || status.includes('[[基本完成]]'));
      },
    },
  ];

  // 获取所有 Markdown 文件
  const allFiles = vault.getMarkdownFiles();

  // 存储需要嵌入的笔记信息
  const notesToEmbed = [];

  for (const file of allFiles) {
    for (const folder of folders) {
      if (file.path.startsWith(folder.path)) {
        const metadata = app.metadataCache.getFileCache(file);
        if (metadata && metadata.frontmatter && metadata.frontmatter[folder.field]) {
          const dateStr = metadata.frontmatter[folder.field];
          const noteDate = moment(dateStr);
          if (noteDate.isAfter(today)) {
            if (folder.extraCheck && !folder.extraCheck(metadata.frontmatter)) {
              continue;
            }
            notesToEmbed.push({
              file,
              date: noteDate.format('YYYY-MM-DD'),
              timestamp: noteDate.valueOf(),
            });
          }
        }
        break;
      }
    }
  }

  // 按时间排序
  notesToEmbed.sort((a, b) => a.timestamp - b.timestamp);

  let totalEmbedded = 0;

  for (const note of notesToEmbed) {
    const dailyNotePath = `Daily/Now/${note.date}.md`;
    let dailyFile = vault.getAbstractFileByPath(dailyNotePath);

    // 如果日记文件不存在,则创建
    if (!dailyFile) {
      const templatePath = 'Files/模板库/01-1-日记.md';
      const templateFile = vault.getAbstractFileByPath(templatePath);
      if (templateFile) {
        const templateContent = await vault.read(templateFile);
        await vault.create(dailyNotePath, templateContent);
        dailyFile = vault.getAbstractFileByPath(dailyNotePath);
      } else {
        // 如果模板文件不存在,则创建一个空的日记文件
        await vault.create(dailyNotePath, '');
        dailyFile = vault.getAbstractFileByPath(dailyNotePath);
      }
    }

    // 读取日记文件内容
    const dailyContent = await vault.read(dailyFile);

    // 检查是否已经嵌入了该笔记
    const embedLink = `![[${note.file.basename}]]`;
    if (!dailyContent.includes(embedLink)) {
      let newContent = dailyContent.trimEnd();
      if (newContent.length > 0 && !newContent.endsWith('\n')) {
        newContent += '\n';
      }
      newContent += `\n${embedLink}\n`;
      await vault.modify(dailyFile, newContent);
      totalEmbedded++;
    }
  }

  // 统计全部日记中的嵌入链接总数
  const dailyNotes = allFiles.filter(file => file.path.startsWith('Daily/Now/'));
  let totalEmbeds = 0;

  for (const dailyFile of dailyNotes) {
    const content = await vault.read(dailyFile);
    const lines = content.split('\n');
    for (const line of lines) {
      if (line.trim().startsWith('![[') && line.trim().endsWith(']]')) {
        totalEmbeds++;
      }
    }
  }

  // 显示提示信息
  new Notice(`嵌入链接完成,全部日记中的嵌入链接为 ${totalEmbeds} 条,本次新增嵌入链接 ${totalEmbedded} 条。`);
};

一个条目一个页面,是我从2020年开始使用这些双链笔记以来,最后决定 all in ob 的一个底层原因。因为太方便了、扩展性太强了。

点赞,社区看到ob的属性动态表格在弄了,期待今年能等到发布,all in ob想想就很爽! :face_with_hand_over_mouth:

不用激动,去年年底这个动态视图,就已经是这个状态了。
英文论坛里,有人问今年 9 月能不能出,
开发者说:我们不给时间表。