(分享)一篇介绍使用 Denote 以及 dblock 功能进行项目管理的好文

https://purplg.dev/posts/denote-project-tasks/

Denote Project Tasks 标注项目任务

Tracking project-specific tasks is something I’ve changed many times over the years. I’ve tried apps like Taskwarrior (which I loved), using in-line TODO comments throughout my code and collecting them with something like magit-todos, to now just a strategically-named plain org file I can just spawn when I need it. And recently I got a bug to try something new. I’m writing this post in parallel of me trying to implement it so it follows my train of thought and trials. 追踪项目特有的任务,这些年来我尝试过多种方法。我曾使用过 Taskwarrior 这样的应用(我非常喜欢),在代码中加入 TODO 注释,再用类似 magit-todos 的工具收集起来,到现在,我只需一个策略性命名的纯文本 org 文件,需要时随时调用。最近,我突然想尝试新方法。我边尝试边写这篇文章,记录我的思路和实验过程。

The idea 这个点子

Use features of Denote to track all tasks related to a project where each task gets its own file. Org has a cool feature that Denote supports called Dynamic Blocks (dblocks) which allow you collect content from various Org (Denote) files into a block. Very nifty. I wanted to utilize this to enumerate all the pending tasks on a project (or maybe other “views” as well) based on the project-specific tasks. 利用 Denote 的特性来追踪与项目相关的所有任务,每个任务都有独立的文件。Org 有一个很酷的功能,被 Denote 支持,叫做动态区块(dblocks),它能让你从不同的 Org(Denote)文件中收集内容到一个区块里。这非常巧妙。我想要利用这个功能来列举出项目中所有待办的任务(或者可能还有其他“视图”),基于项目特定的任务。

Implementation Thoughts 实现思路

I imagine that a main project file that contains the aforementioned dblock(s) could be tagged (keyword) with project and individual tasks simply task’s. But that begs the question… how do we know which task’s are associated with which project? I’ve thought a little bit on how I could do this and here are some rough ideas I’ve come up with before I have written any code: 我设想,一个主项目文件中包含上述提及的 dblock(s),可以被标记(关键词)为项目和各个任务。但这就引出了一个问题……我们怎么知道哪些任务与哪个项目相关联呢?我对此进行了一些初步的思考,在我编写任何代码之前,这里有一些我想到的粗略想法:

  • I could manually link them in the dblock by utilizing some capture functionality that can append entries to the dblock. This would be very complicated and I can already foresee a ton of problems and would require me to use specific entrypoints to manage my tasks. That’s not fun. The beauty of plain-text is being able to treat it as plain-text and everything still works. Doesn’t seem viable. 我可以手动在 dblock 中通过利用一些捕获功能来链接它们,这可以将条目添加到 dblock 中。但这会非常复杂,我已经预见到一大堆问题,而且我将不得不使用特定的入口点来管理我的任务。这可不怎么有趣。纯文本的魅力在于,你可以将它当作纯文本处理,而所有功能依然正常。这似乎并不可行。
  • I could create a bespoke silo for each project. I kinda of like this idea because it would simplify a lot of stuff (there should only be one project tag). A downside is it removes it from my main note-taking “database”. I don’t really like that since some of my random notes I’ve collected might be useful, but ultimately seems like a decent solution. 我可以为每个项目创建一个专属的资料库。我挺喜欢这个主意的,因为它能简化很多事务(理论上只会有一个 project 标签)。但这样做的缺点是,这些项目资料将与我的主要笔记库分离。我不太喜欢这样,因为我的一些随手记录的笔记可能很有价值。但整体来看,这似乎仍是一个不错的解决方案。
  • I could hack some “sub-tag” convention where a project could have a generated value associated with it, like project:1 and associated tasks would have task:1 to tie them into that project. One question I’d have to answer is how do I find this project:# file while I’m actively editing the project? I could use some dir-local to set that as a local var, use a hash of the project name instead of an integer id, or maybe use a symlink into the project. Ultimately, I like this idea because it allows me to keep my project notes in my main database! I think it’ll be my first attempt. 我可以采用一种“子标签”规则,让每个项目都关联一个生成的值,比如 project:1 ,而相关任务则会带有 task:1 ,以此将它们与项目关联起来。我需要解决的一个问题是,在我积极编辑项目时,如何找到这个 project:# 文件?我可以使用一些目录本地设置,将其作为本地变量;或者用项目名称的哈希值代替数字 ID;又或者在项目中使用符号链接。最终,我喜欢这个想法,因为它允许我将项目笔记保存在我的主数据库中!我认为这将是我第一次尝试。

Let’s start coding 我们开始编码吧

Throughout this post I use the terms “workspace” and “project” a lot. While they technically have a distinct meaning in my workflow, as I briefly describe below, a “workspace” could be any project or repository on your system, as long they are uniquely named on your system (e.g. in the same directory). 在这篇博文中,我频繁使用了“工作区”和“项目”这两个术语。虽然在技术层面上,它们在我的工作流程中有着明确的含义,但“工作区”可以指系统上的任何项目或代码库,只要它们在系统上(例如在同一目录下)有唯一的名称。

I already have a command (pg/open-project-notes) I use to open the notes for my project. It’s just a plain Org file, like I mentioned previously, so I need to tweak it a bit to use the new scheme. I generally create “workspace” directories with potentially multiple git repositories within them using my own treebundel package. Since this root workspace directory is flat, I can be pretty sure that the directory names within there are unique, at least as much as (denote-sluggify-title) will let me. I think I’ll use this workspace name as my unique identifier… But now that I’m actually hacking on the code, I realize that I don’t have any way to create a name for the note. Previously, I use the workspace name for the title, so I could keep that and leave the project tag the same… and nothing actually changes! Here’s a snippet directly from my dotfiles. 我已经拥有一个命令 (pg/open-project-notes) ,用来打开我的项目笔记。正如我之前提到的,它只是一个简单的 Org 文件,因此我需要稍作调整,以适应新的方案。我通常使用我自己的 treebundel 包来创建“工作区”目录,其中可能包含多个 git 仓库。由于这个根工作区目录结构扁平,我可以相当确定,其中的目录名称是唯一的,至少在 (denote-sluggify-title) 的限制下是这样。我想我会用这个工作区名称作为我的唯一标识符……但现在我开始实际编写代码,我意识到我没有办法为笔记创建一个名称。以前,我使用工作区名称作为标题,所以我可以保持这一点不变,让 project 标签也保持不变……实际上没有任何改变! 这是我从个人配置文件中直接摘取的一个小段。

(defun pg/open-project-notes ()
  (interactive)
  (when-let* ((workspace-name (or (when current-prefix-arg
                                    (treebundel-read-workspace))
                                  (treebundel-current-workspace)
                                  (treebundel-read-workspace))))
    (if-let ((project-notes (denote-directory-files
                             (concat "^.*" workspace-name "__project.org$"))))
        (find-file-other-window (car project-notes))
      (denote workspace-name '("project")))))

Dynamic Blocks 动态模块

Now I need to populate the tasks with dblocks. For now, I’ll just manually create some dummy tasks for my “dotfiles” workspace for now. The files look like this and each have a simple Task # line in them: 现在我得用 d 块来填充这些任务。暂时,我会为我的“dotfiles”工作区手动创建一些示例任务。这些文件的结构如下,并且每个文件中都包含一行简单的 Task # 代码:

20241030T200925–dotfiles__project.org 20241030T200935–dotfiles__task.org 20241030T200941–dotfiles__task.org 20241030T200946–dotfiles__task.org

Simple enough. Let’s try to insert a dblock using (org-dynamic-block-insert-dblock). I select denote-files as the first option, --dotfiles.*__task as the regex match, and sort with identifier and it generates this: 够简单了。我们来试试用 (org-dynamic-block-insert-dblock) 插入一个 d 块。我选择 denote-files 作为第一个选项, --dotfiles.*__task 作为正则表达式的匹配项,然后用 identifier 进行排序,这样就生成了下面的内容:

#+BEGIN: denote-files :regexp "--dotfiles.*__task" :excluded-dirs-regexp nil :sort-by-component identifier :reverse-sort nil :no-front-matter nil :file-separator nil :add-links nil
#+title:      dotfiles
#+date:       [2024-10-30 Wed 20:09]
#+filetags:   :task:
#+identifier: 20241030T200935

Task 1
#+title:      dotfiles
#+date:       [2024-10-30 Wed 20:09]
#+filetags:   :task:
#+identifier: 20241030T200941

Task 2
#+title:      dotfiles
#+date:       [2024-10-30 Wed 20:09]
#+filetags:   :task:
#+identifier: 20241030T200946

Task 3
#+END

Whoa, that was easy, but it’s a little too verbose. Let’s condense it a bit by removing the front matter by setting no-front-matter to t: 哇,这太简单了,但有点啰嗦。我们可以通过将 no-front-matter 设置为 t ,去掉前面的冗余内容,让它更简洁些:

#+BEGIN: denote-files :regexp "--dotfiles.*__task" :excluded-dirs-regexp nil :sort-by-component identifier :reverse-sort nil :no-front-matter t
Task 1
Task 2
Task 3
#+END

Much better, but there’s a problem. I can’t edit the tasks here since the changes don’t get replicated back to the original file. I can add links to the original file though by adding the add-links parameter to the dblock. I also set the file-separator here to be a newline character just to make it look a little nicer. 情况大有改善,不过存在一个问题。我无法在此处编辑任务,因为所做的更改无法回传至原始文件。不过,我可以通过在 dblock 中添加 add-links 参数,来添加指向原始文件的链接。此外,我将 file-separator 设置为换行符,这样看起来会更美观。

#+BEGIN: denote-files :regexp "--dotfiles.*__task" :excluded-dirs-regexp nil :sort-by-component identifier :reverse-sort nil :no-front-matter t :add-links t :file-separator "\n"
- [[denote:20241030T200935][dotfiles]]

  Task 1

- [[denote:20241030T200941][dotfiles]]

  Task 2

- [[denote:20241030T200946][dotfiles]]

  Task 3
#+END

Well, that was a fun experiment, but the title of the task file being the same as the workspace kinda… sucks. All the links look the same. I think I’ll revisit the “sub-tag” idea so I can free up the title field for actual task content. Then I could open the linked file for more information on that task. 嗯,这确实是一次有趣的实验,但任务文件名和工作区名称相同,这确实挺糟糕的,所有链接看起来都一样。我想我会重新考虑“子标签”的想法,这样就可以释放标题字段,用于实际的任务内容。这样,我可以打开链接的文件,获取更多关于该任务的信息。

Sub-tagging 子级标签

Well, after a little sleuthing, sub-tagging won’t work. The (denote-sluggify-keyword) function will remove any good special character to use as a separator. But in hindsight, it isn’t necessary. We can just use multiple tags. I’ve renamed my task files have a proper title and tagged the files with task and the name of the project, dotfiles, using the (denote-rename-file-keywords) command: 好了,经过一番调查,子标签功能行不通。 (denote-sluggify-keyword) 函数会移除任何可用作分隔符的特殊字符。但事后看来,这其实并不必要。我们可以直接使用多个标签。我已经重新命名了我的任务文件,让它们有了一个恰当的标题,并使用 (denote-rename-file-keywords) 命令将文件标记为 task 和项目名称 dotfiles

20241030T200925–dotfiles__project.org 20241030T200935–Task 1__dotfiles_task.org 20241030T200941–Task 2__dotfiles_task.org 20241030T200946–Task 3__dotfiles_task.org

Now let’s look at the dblock again with an new regex _dotfiles.*_task: 现在,让我们用一个新的正则表达式 _dotfiles.*_task 再次查看 dblock:

#+BEGIN: denote-files :regexp "_dotfiles.*_task" :excluded-dirs-regexp nil :sort-by-component identifier :reverse-sort nil :no-front-matter t :add-links t :file-separator "\n"
- [[denote:20241030T200935][Task 1]]

  The content of task 1!

- [[denote:20241030T200941][Task 2]]

  The content of task 2!

- [[denote:20241030T200946][Task 3]]

  The content of task 3!
#+END

Uhh… well that’s cool but the problem with this approach is that the keywords are sorted alphabetically which would fail the regex is the project name started with a letter after t. I think we’ll need some fancier regex to match in any order. Here we use a the same capture group twice so we can match it twice. This is better, but in theory it would still match something like _dotfiles_dotfiles or _task_task. That should be fine since Denote shouldn’t let you duplicate keywords. 嗯…,这样确实挺酷,但这种方法的问题在于关键词是按字母顺序排列的,如果项目名称以 t 之后的字母开头,正则表达式就会失效。我认为我们需要更复杂的正则表达式来匹配任何顺序的关键词。在这里,我们使用相同的捕获组两次,以便能够匹配两次。这样确实更好,但在理论上,它仍然可能匹配像 _dotfiles_dotfiles_task_task 这样的内容。不过这应该没问题,因为 Denote 应该不会让你创建重复的关键词。

#+BEGIN: denote-files :regexp "\\(_dotfiles\\|_task\\).*\\(_dotfiles\\|_task\\)" :no-front-matter t :add-links t :file-separator "\n"
- [[denote:20241030T200935][Task 1]]

  The content of task 1!

- [[denote:20241030T200941][Task 2]]

  The content of task 2!

- [[denote:20241030T200946][Task 3]]

  The content of task 3!
#+END

At this moment, I had a realization that I probably should be using backlinks, but that would require me to include a link to the main project file in my task. I think I’ll shelve this for now because I’m kinda having fun with this idea. 就在这一刻,我突然意识到,我或许应该使用回链。但这意味着我得在任务中加入主项目文件的链接。不过,我决定暂时先放一放,因为我现在对这个想法还挺感兴趣的。

There’s also the option to use denote-links instead. I like this better. 也可以选择使用 denote-links ,我更喜欢这种方式。

#+BEGIN: denote-links :regexp "\\(_dotfiles\\|_task\\).*\\(_dotfiles\\|_task\\)"
- [[denote:20241030T200935][Task 1]]
- [[denote:20241030T200941][Task 2]]
- [[denote:20241030T200946][Task 3]]
#+END:

On that note, maybe there’s a better way

从这个角度来看,或许有更好的途径呢

Now onto marking a task as complete. Adding a done tag seems silly at this point. Maybe, I could tweak denote-links with my own implementation to do what I want to do in Elisp instead of complicated regular expressions. It looks like these callbacks are defined with the org-dblock-write: prefix. Let’s try to make our own to say “hello”. 现在来标记任务为已完成。此时添加一个 done 标签似乎有些多余。或许,我可以按照自己的方式调整 denote-links ,在 Elisp 中实现我想要的功能,而不是使用复杂的正则表达式。看来这些回调函数都是以 org-dblock-write: 为前缀定义的。我们尝试自定义一个,让它能说“你好”。

(defun org-dblock-write:pg-hello (params)
  "hello")
#+BEGIN: pg-hello
hello
#+END:

Pretty simple, but really, I want the denote-links functionality, but with an extra parameter to match the files I want. Let’s wrap denote-links and inject our own regexp dynamically 确实很简单,不过我真正需要的是 denote-links 的功能,同时还要增加一个参数,以便匹配我想要的文件。让我们来封装 denote-links ,并动态地加入我们自定义的正则表达式。

(defun org-dblock-write:pg-tasks (params)
  (let ((project-name (plist-get params :project-name)))
    (plist-put params :regexp (format "\\(_%1$s\\|_task\\).*\\(_%1$s\\|_task\\)" project-name)))
  (org-dblock-write:denote-links params))

#+BEGIN: pg-tasks :project-name dotfiles - [[denote:20241030T200935][Task 1]] - [[denote:20241030T200941][Task 2]] - [[denote:20241030T200946][Task 3]] #+END:

Perfect! That was easy. I love Elisp. Now that we have a single place to edit our pattern matching and the power of Elisp, let’s make it hide tasks marked as done by simply handling the done tag. I’ve added the done tag to Task 3. 太好了!这很简单。我爱 Elisp。现在我们有了一个集中的地方来编辑模式匹配,并且有了 Elisp 的强大功能,让我们通过处理 done 标签,让已完成的任务被隐藏。我在任务 3 中添加了 done 标签。

20241030T200925–dotfiles__project.org 20241030T200935–task-1__dotfiles_task.org 20241030T200941–task-2__dotfiles_task.org 20241030T200946–task-3__done_dotfiles_task.org

But as I begin writing the regex, I realize I need to negatively match it and ensure the tag doesn’t exist. I don’t think the regex is robust enough yet. It seems like the problem I mentioned about Denote not allowing duplicate keywords might actually pose a problem. I should try to exhaustively match on the tags I want. But after trying to write a regular expression to exclude done but also include dotfiles and task, it doesn’t seem reasonably doable. So instead I think I’ll exclude the regex and try to provide a list of files to a lower-level function, (denote-link--insert-links), myself. Unfortunately, this is marked as “private” but it will have to do. 然而,当我开始编写正则表达式时,我意识到我需要进行否定匹配,确保该标签不存在。我觉得这个正则表达式还不够完善。我之前提到的 Denote 不允许重复关键词的问题,似乎真的会带来麻烦。我应该尝试尽可能地匹配我想要的标签。但在尝试编写一个正则表达式来排除 done ,同时包含 dotfilestask 后,似乎无法合理地实现。因此,我打算放弃使用正则表达式,尝试自己向一个较低级别的函数 (denote-link--insert-links) 提供文件列表。虽然这被标记为“私有”,但我也只能这样做了。

I decided to separate the regex match into 3 separate matches since it makes it significantly simpler. And since we’re using less regex, I decided to tag all the project files with project. Here are the rules I setup for myself: 我决定将正则表达式的匹配拆分为三个独立的匹配,因为这样会让事情变得简单很多。既然我们使用了更少的正则表达式,我决定用 project 来标记所有项目文件。以下是我为自己设定的规则:

  1. All project-related notes have the project tag. 所有与项目相关的笔记都标有 project 标签。
  2. All project-related notes also have that sluggified project name as a tag. 所有与项目相关的笔记也都带有经过转换的项目名称作为标签。
  3. The main project file has its title as the project name. 主项目文件的标题即为项目名称。
  4. Tasks have have the task tag. 任务应具有 task 标签。

Here are the notes for my “dotfiles” repo. 这里是关于我的"dotfiles"仓库的一些说明。

20241030T200925–dotfiles__dotfiles_project.org 20241030T200935–task-1__dotfiles_project_task.org 20241030T200941–task-2__dotfiles_project_task.org 20241030T200946–task-3__done_dotfiles_project_task.org

And some Elisp to handle all of this. 并且用一些 Elisp 来处理所有这些事情。

(defun pg/denote-find-project-files (project-name)
  "Find all notes related to PROJECT-NAME."
  (seq-filter (lambda (file)
                (and
                 ;; Find only project files
                 (string-match "_project" file)
                 ;; And everything tagged with project name
                 (string-match (concat "_" (denote-sluggify-keyword project-name)) file)))
              (denote-directory-files)))

(defun org-dblock-write:pg-tasks (params)
  (let* ((project-name (symbol-name (plist-get params :project-name)))
         ;; First, find ALL notes related to this project.
         (files (pg/denote-find-project-files project-name))
         (files (seq-filter (lambda (file)
                              (and
                               ;; Then filter for tasks.
                               (string-match "_task" file)
                               ;; But are not done
                               (not (string-match "_done" file))
                               ;; or cancelled
                               (not (string-match "_cancelled" file))))
                            files)))
    (denote-link--insert-links files 'org nil t)))

#+BEGIN: pg-tasks :project-name dotfiles - [[denote:20241030T200935][Task 1]] - [[denote:20241030T200941][Task 2]] #+END:

It works! 它真的有效!

Automation! 这是自动化!

I think the basic framework is there, but setting this up is kinda cumbersome to use. What I need now is a way to automatically generate the main project file and be able to create tasks. I briefly experiment with org-capture templates since Denote supports them, but it seemed a little redundant for this use-case. Instead, I’ll just use the (denote) function directly. 我觉得基本框架已经有了,但这个设置用起来有点繁琐。我现在需要的是能自动生成主项目文件,并且能创建任务的功能。我简单尝试了一下 org-capture 模板,因为 Denote 支持这个,但感觉在这个场景下有点多余。相反,我打算直接使用 (denote) 函数。

(defun pg/denote-task-capture ()
  "Create a task for a treebundel workspace."
  (interactive)
  (let ((workspace (or (when current-prefix-arg (treebundel-read-workspace))
                       (treebundel-current-workspace)
                       (treebundel-read-workspace))))
    (denote (denote-title-prompt nil "New task")
            `(,workspace "task" "project"))))

Then I Created a new task called “Captured task!” #+BEGIN: pg-tasks :project-name dotfiles - [[denote:20241030T200935][Task 1]] - [[denote:20241030T200941][Task 2]] - [[denote:20241102T113732][Captured task!]] #+END:

The main project file 主项目文件

Now to generate this project file automatically. This should be easy enough with a Denote template. It looks like the VALUE in the denote-templates variable accepts a function. I’ll need this to detect the appropriate workspace. Normally, a template should return a string to be inserted into the document. I don’t do that here and instead (insert …) the content manually, because I need to update the dblock after inserting so it’s prefilled when I open the buffer. This has a potential break in the future so I’ll probably revisit this 现在要自动生成这个项目文件。这应该可以通过 Denote 模板轻松实现。看起来 VALUEdenote-templates 变量中可以接受一个函数。我需要这个功能来检测合适的 workspace。通常,模板应该返回一个字符串,以便插入到文档中。但在这里我没有这样做,而是手动插入内容,因为我需要在插入后更新 d 块,以便在我打开缓冲区时,它已经被预填充。这在未来可能会有潜在的中断,所以我可能需要重新审视这个问题。

(defun pg/denote-project-template ()
  (insert "#+BEGIN: pg-tasks :project-name "
          (denote-sluggify-keyword (denote-retrieve-filename-title buffer-file-name))
          "\n"
          "#+END:")
  (org-update-all-dblocks)
  "")

(add-to-list 'denote-templates
             '(pg-project . pg/denote-project-template))

Now I need to update the original (pg/open-project-notes) command to use this new template. 现在我得更新原先的 (pg/open-project-notes) 指令,来使用这个新模板。

(defun pg/open-project-notes ()
  "Open the main notes file for the current or specified project."
  (interactive)
  (when-let* ((workspace-name (or (when current-prefix-arg
                                    (treebundel-read-workspace))
                                  (treebundel-current-workspace)
                                  (treebundel-read-workspace))))
    ;; Find the workspace-name as a tag instead
    (if-let ((project-notes (seq-find (lambda (file)
                                        (string-match (concat "--" workspace-name) file))
                                      (pg/denote-find-project-files workspace-name))))
        ;; Open it if it exists
        (find-file-other-window project-notes)
      ;; Otherwise, create a new one with the new template
      (denote workspace-name `(,workspace-name "project") nil nil nil 'pg-project))))

Changing the state of a task

更改任务状态

To change the state of a task it should be as simple as adding the done or cancelled tag. I’d like to just use (denote-rename-file-keywords), but that has too many prompts. I wrote a couple functions for toggling whether a task is done or cancelled. These functions are basically the same, but when they toggle the respective state on, then it removes the conflicting state first. If I had more than 2 state tags (which I might one day), I’ll create a more generic function to handle all of this, but for right now, this is good enough. 要改变任务状态,应如同添加 donecancelled 标签那样简单。我更倾向于只使用 (denote-rename-file-keywords) ,但它的提示信息过多。我编写了几个函数,用于切换任务是否已完成或已取消的状态。这些函数基本相同,但在切换至相应状态时,会先移除冲突状态。如果我有超过 2 个状态标签(未来可能会有),我会创建一个更通用的函数来处理所有情况,但就目前而言,这样已经足够了。

(defun pg/denote-task-toggle-done ()
  (interactive)
  (when (denote-file-is-note-p buffer-file-name)
    (let ((denote-rename-confirmations nil)
          (keywords (denote-extract-keywords-from-path buffer-file-name)))
      (setq keywords (remove "cancelled" keywords))
      (if (member "done" keywords)
          (setq keywords (remove "done" keywords))
        (setq keywords (append keywords '("done"))))
      (denote-rename-file buffer-file-name
                          'keep-current
                          keywords
                          'keep-current
                          'keep-current))))
(defun pg/denote-task-toggle-cancelled ()
  (interactive)
  (when (denote-file-is-note-p buffer-file-name)
    (let ((denote-rename-confirmations nil)
          (keywords (denote-extract-keywords-from-path buffer-file-name)))
      (setq keywords (remove "done" keywords))
      (if (member "cancelled" keywords)
          (setq keywords (remove "cancelled" keywords))
        (setq keywords (append keywords '("cancelled"))))
      (denote-rename-file buffer-file-name
                          'keep-current
                          keywords
                          'keep-current
                          'keep-current))))

Altogether 完全地

With all of this together, I’m able to create or open a project file with (pg/open-project-notes), see the lists of pending tasks, open any of those tasks, and change their state to done or cancelled to remove them from the main project file. Here’s all the code in a single snippet. 有了所有这些功能,我能够使用 (pg/open-project-notes) 创建或打开项目文件,查看待办任务列表,打开这些任务中的任何一个,并将它们的状态更改为 donecancelled ,从而将它们从主项目文件中移除。以下是所有代码的单一代码段。

(defun pg/denote-find-project-files (project-name)
  "Find all notes related to PROJECT-NAME."
  (seq-filter (lambda (file)
                (and
                 ;; Find only project files
                 (string-match "_project" file)
                 ;; And everything tagged with project name
                 (string-match (concat "_" (denote-sluggify-keyword project-name)) file)))
              (denote-directory-files)))

(defun org-dblock-write:pg-tasks (params)
  (let* ((project-name (symbol-name (plist-get params :project-name)))
         ;; First, find ALL notes related to this project.
         (files (pg/denote-find-project-files project-name))
         (files (seq-filter (lambda (file)
                              (and
                               ;; Then filter for tasks.
                               (string-match "_task" file)
                               ;; But are not done
                               (not (string-match "_done" file))
                               ;; or cancelled
                               (not (string-match "_cancelled" file))))
                            files)))
    (denote-link--insert-links files 'org nil t)))

(defun pg/denote-task-capture ()
  "Create a new task for the current or specified treebundel
workspace."
  (interactive)
  (let ((workspace (or (when current-prefix-arg (treebundel-read-workspace))
                       (treebundel-current-workspace)
                       (treebundel-read-workspace))))
    (denote (denote-title-prompt nil "New task")
            `(,workspace "task" "project"))))

(defun pg/denote-project-template ()
  (insert "#+BEGIN: pg-tasks :project-name "
          (denote-sluggify-keyword (denote-retrieve-filename-title buffer-file-name))
          "\n"
          "#+END:")
  (org-update-all-dblocks)
  "")

(add-to-list 'denote-templates
             '(pg-project . pg/denote-project-template))

(defun pg/open-project-notes ()
  "Open the main notes file for the current or specified project."
  (interactive)
  (when-let* ((workspace-name (or (when current-prefix-arg
                                    (treebundel-read-workspace))
                                  (treebundel-current-workspace)
                                  (treebundel-read-workspace))))
    ;; Find the workspace-name as a tag instead
    (if-let ((project-notes (seq-find (lambda (file)
                                        (string-match (concat "--" workspace-name) file))
                                      (pg/denote-find-project-files workspace-name))))
        ;; Open it if it exists
        (find-file-other-window project-notes)
      ;; Otherwise, create a new one with the new template
      (denote workspace-name `(,workspace-name "project") nil nil nil 'pg-project))))

(defun pg/denote-task-toggle-done ()
  (interactive)
  (when (denote-file-is-note-p buffer-file-name)
    (let ((denote-rename-confirmations nil)
          (keywords (denote-extract-keywords-from-path buffer-file-name)))
      (setq keywords (remove "cancelled" keywords))
      (if (member "done" keywords)
          (setq keywords (remove "done" keywords))
        (setq keywords (append keywords '("done"))))
      (denote-rename-file buffer-file-name
                          'keep-current
                          keywords
                          'keep-current
                          'keep-current))))

(defun pg/denote-task-toggle-cancelled ()
  (interactive)
  (when (denote-file-is-note-p buffer-file-name)
    (let ((denote-rename-confirmations nil)
          (keywords (denote-extract-keywords-from-path buffer-file-name)))
      (setq keywords (remove "done" keywords))
      (if (member "cancelled" keywords)
          (setq keywords (remove "cancelled" keywords))
        (setq keywords (append keywords '("cancelled"))))
      (denote-rename-file buffer-file-name
                          'keep-current
                          keywords
                          'keep-current
                          'keep-current))))

Final thoughts 最后的想法

This was a fun experiment. I’m going to be trying this out for a while but with only little experimental use, I’m not sure if it’s robust enough for my needs (see next section). I still can’t help but get the feeling that I might as well be using backlinks, but trying something new is always worth the time. Maybe as I try to use this I’ll improve it and eventually make a part 2 to make it even better. 这真是一次有趣的实验。我打算继续尝试一段时间,但仅限于小范围的实验性使用,我还不确定它是否足够稳定,以满足我的需求(详情请看下文)。我总有一种感觉,我可能只是在重复使用回链,但尝试新事物总是值得的。或许在使用过程中,我会不断改进它,最终制作出第二部分,让它变得更好。

Future improvements 未来的改进措施

More states 更多的州份 It would be nice in the future to include more states, like whether a task has been delegate to someone else or if I’m actively working on it so I can quickly remind myself of what my current goal is. 未来若能增加更多状态标识就好了,比如任务是否已指派给他人,或是我正在积极处理中,这样能让我快速回顾当前的目标。

Related tasks 相关任务 Often, larger tasks need to be broken down into smaller tasks. Not sure how this could be done in this system (maybe backlinks?), but it would nice to include related tasks somehow. Maybe this could be done by adding custom front-matter to the note. 经常,大的任务需要被拆分成小任务。在这个系统中如何实现这一点尚不清楚(也许是通过回链?),但能以某种方式关联相关任务会很好。或许可以通过在笔记中添加自定义的前置事项来实现。

Age sorting 按年龄排序 I mentioned earlier that I loved Taskwarrior. The thing I loved about it was that is will slowly increase the priority of your tasks depending on their age. This was great to encourage me to work on other tasks I’ve been ignoring. With this system, this seems possible to do. 我之前说过,我非常喜欢 Taskwarrior。我特别喜欢它的一点是,它会根据任务的持续时间逐渐提升任务的优先级。这能很好地激励我处理那些一直被我忽视的任务。有了这个系统,这样的功能似乎是可以实现的。

Project note detection 项目笔记检测功能 In the (pg/denote-task-toggle…) functions, I only check if the file is a Denote note, but not if it is also a project note. I should add my own predicate that checks if it also contains the project tag. 在 (pg/denote-task-toggle…) 函数里,我仅确认文件是否为 Denote 笔记,而没有进一步判断是否属于项目笔记。我应当增加一个自定义的条件判断,确保文件同时带有 project 标签。

Consistent naming scheme 一致的命名方案 Since this was just an adventure to hack some code together, I wasn’t very consistent with the names of the functions (or writing docstrings). A little polish there could help the readability a bit. Maybe I could even write a small package for it. 因为这仅仅是一次冒险,为了凑合着写些代码,我对函数命名并不严谨(也没写文档说明)。如果在这方面稍作改进,代码的可读性会更好。或许我还可以为它编写一个小工具包。

Auto-update buffer 自动更新缓冲区 When opening a project buffer, you have to manually update the dblock (C-c C-x C-u by default). Should be easy enough to add a hook for this to automatically update all dblocks in project notes. 打开项目缓冲区时,你必须手动更新 dblock(默认为 C-c C-x C-u )。应该很容易添加一个钩子,以便自动更新项目笔记中的所有 dblock。为了使项目笔记保持最新,自动更新所有 dblock 将更加便捷。

Task title completion 任务标题的完成 When creating a new task, Denote will try auto-complete the title for you. Handy, but annoying in this case. Title completion should be turned off 当创建新任务时,Denote 会尝试自动补全标题,这虽方便,但在此情况下却有些烦人。应关闭标题自动补全功能。

Meta 元数据

Writing this post at the same time as actually developing the subject was a neat experience. It helped me think more clearly about my direction and forced myself to do a little more research and dive in deeper before writing something down. I highly recommend it to anyone to give it a shot and I might do it more in the future. 在实际开发主题的同时撰写这篇帖子,是一次非常棒的体验。它帮助我更清晰地思考我的方向,并促使我深入研究,在动笔前做更多的功课。我强烈建议每个人都可以尝试一下,未来我可能会更多地采用这种方式。

2 个赞

这个翻译排版能不能改成那种前面全英文,后面全中文的?有些词先翻译,然后又不继续翻译,中英混合,真的不好读……

这就是沉浸式阅读啊,你自己把全中文的贴到另外一个地方不就行了。。。

看着都心累 zsbd

项目管理就用 PARA 就可以。没必要非得跟笔记管理扯上。