科研项目管理/汇总——以x级标题为分类依据

之前看到过一些介绍用Obsidian进行科研项目管理的帖子/视频,但感觉都不是很符合个人需求:

  1. 需要每日实验记录,类似手写记录那样以日期为标题;
  2. 同时在做多个课题,即每日实验记录中会有多个课题的内容,但不希望分文件夹进行管理(否则不同项目的文件夹下会有大量同名日记);因为不同课题出现同一个实验记录中,所以也不能使用tag区分;
  3. 会议记录(主要指组会、和导师的单独讨论等)与实验记录分离;
  4. 希望每个课题的记录能汇总到各自的一个md文件中,方便速览;
  5. 希望能在主页看到所有课题的工作量记录。

如果你和我有类似需求,可以参考我目前的记录模式。该模式的核心为,使用二级标题(或你喜欢的级别)作为每日实验记录中课题分类的依据,使用三级标题(或你喜欢的子级别)作为各课题中具体工作的名称。下面详细介绍下

笔记结构按文件夹分为

  • 01-Projects
  • 02-Daily Notes
  • 03-Meetings
  • 10-Templates
  • Home (主页)

使用的插件除核心插件外主要包括Dataview, Templater, Better Word Count, Homepage。

1. 每日实验记录 & 会议记录

简单来说,使用Daily Notes插件在02-Daily Notes中创建每日实验记录。实验记录的一般格式为:

## [[Project 1]]
### job 1
...
### job 2
...

## [[Project 2]]
### job 1
...
### job 2
...

## [[Project x]] 对应不同的课题名称,### job x为具体的实验名称,比如“提取某个蛋白”、“某代码单元测试”。注意同一个课题标题是允许重复的,比如你可以写成

## [[Project 1]]
### job 1
...
### job 2
...

## [[Project 2]]
### job 1
...
### job 2
...

## [[Project 1]]
### job 1
...
### job 2
...

会议记录存放在03-Meetings中,格式相同,因为会议频率较低,所以不需要使用Daily Notes管理。命名比较自由,不过我仍喜欢和实验记录一样用日期命名,因为在汇总排序时会比较方便。

2. 每个课题各自汇总

从实验记录中可见,我对每个项目名称使用了双链,链接到的就是各个项目的汇总记录,存放在01-Projects中。其内容就是通过Dataviewjs提取所有实验记录和会议记录中 对应课题的二级标题下的内容生成的。示例页面如下所示:

除了元数据中的最后编辑时间和状态外,项目汇总主体分为

  1. Aim: 课题目标。
  2. Description: 概述,比如计划用什么方法做、模型流程图等。
  3. TODO: 从实验&会议记录中提取出的该课题下的未完成任务,如果完成了,可从本页面打勾,该任务会从列表中自动移除,原实验记录中的任务状态会随之一起改变。Date列中给出的都是对应实验/会议记录的双链,方便进入查看详情,下同。
  4. Tasks:这个名字起得有点混淆,其实应该是Jobs,也就是上面提到的三级标题### job x。用于快速浏览做过的实验/工作。数据仅从实验记录中获取,该课题下有几个三级标题就会提取出几个。
  5. Meeting Notes:会议记录汇总。数据仅从会议记录中获取,其余同Tasks。
    • PS: 不知道为什么该列表的两列距离这么远,如果有大佬知道的话敬请指教。

Project模板存放在10-Templates下,由核心插件Templates调用(注意不是Templater!),使用时在01-Projects中新建笔记,然后调用Templates: Insert templates(调用方法详见其他Templates插件使用说明),选择该模板,然后将标题修改为课题名称即可。markdown源码如下:

---
Last Modification Date: <% tp.file.last_modified_date("dddd Do MMMM YYYY HH:mm:ss") %>
Status:
- Todo
- Doing
- Done
---

### ※ Aim
- 

### ※ Description
- 

### ※ TODO
```dataviewjs
let files = dv.pages('"02-Daily Notes" or "03-Meetings"')
.filter(p=>p.file.tasks.where(t => !t.completed).length!=0)
.sort(p => p.file.name)

// 用于判断某行是否在查询的二级标题覆盖范围内
function isWithinRanges(num, arr) {
  for (let i = 0; i < arr.length; i += 2) {
    if (num >= arr[i] && num <= arr[i + 1]) {
      return true;
    }
  }
  return false;
}

let targetHeader = `[[${dv.current().file.name}]]`
let tks = files.map(p => {
		let tf = app.vault.getAbstractFileByPath(p.file.path)
		let header = app.metadataCache.getFileCache(tf)
			.headings
		if (!header) return
		let hRange = []  // 目标标题覆盖范围的始末行数
		let t = []  // 目标标题下的task
		let b = false  // flag, 用于标记上一个二级标题是否是目标标题
		for (let i of header) {
			if (b && i.level == 2) {
				hRange.push(i.position.start.line)
				b = false
			}
			if (i.heading == targetHeader && i.level == 2) {
				hRange.push(i.position.start.line)
				b = true
			}
		}
		if (hRange.length > 0) {
			if (hRange.length % 2 == 1) hRange.push(100000000)
			let tasks = p.file.tasks
			for (let j of tasks) {
				if (isWithinRanges(j.line, hRange) && !j.completed) t.push(j)
			}
		}
		return t.length == 0 ? false : [p.file.link, t]
	})
	.filter(p => p)

dv.table(['Date','Tasks'],files.map(p=>{
	let div = dv.container.createEl('div');
	if (tks.length > 0) {
		for (let t of tks) {
			if (t[0] == p.file.link) {
			    dv.api.taskList(t[1], 0, div, this.component, this.currentFilePath);
			    return [p.file.link,div];
			}
		}
	}
}).filter(p => p)  // Otherwise .table cannot read .map result when return nothing
)
```

### ※ Tasks
```dataviewjs
let files = dv.pages(`"02-Daily Notes"`).sort(p => p.file.name)
let targetHeader = `[[${dv.current().file.name}]]`
let headers = files.map(p => {
		let tf = app.vault.getAbstractFileByPath(p.file.path)
		let header = app.metadataCache.getFileCache(tf)
			.headings
		if (!header) return
		let h = []
		let b = false   // flag, 用于标记上一个二级标题是否是目标标题
		for (let i of header) {
			if (b && i.level == 3) h.push(i.heading)
			if (b && i.level == 2) b = false
			if (i.heading == targetHeader && i.level == 2) b = true
		}
		return h.length == 0 ? false : [p.file.link, h]
	})
	.filter(p => p)
dv.table(['Date', 'Jobs'], headers)
```

### ※ Meetings Notes
```dataviewjs
let files = dv.pages(`"03-Meetings"`).sort(p => p.file.name)
let targetHeader = `[[${dv.current().file.name}]]`
let headers = files.map(p => {
		let tf = app.vault.getAbstractFileByPath(p.file.path)
		let header = app.metadataCache.getFileCache(tf)
			.headings
		if (!header) return
		let h = []
		let b = false  // flag, 用于标记上一个二级标题是否是目标标题
		for (let i of header) {
			if (b && i.level == 3) h.push(i.heading)
			if (b && i.level == 2) b = false
			if (i.heading == targetHeader && i.level == 2) b = true
		}
		return h.length == 0 ? false : [p.file.link, h]
	})
	.filter(p => p)
dv.table(['Date', 'Jobs'], headers)
```

Acknowledgement

作为一个只学过后端,对javascript一无所知的菜狗,非常感谢@lazyloong大佬的帮助,能让这部分代码最终跑通。
这里强烈安利一下大佬的教学帖Dataviewjs的奇技淫巧,从2022年开帖开始一直在高频回答大家的问题。虽然不是那种系统性教学,但是鉴于大家提过的几十上百种奇奇怪怪的需求都得到了不同程度的解决,私以为你总能找到适合借鉴的那一个。

3. 汇总所有课题到主界面,并统计工作量(词数&字符数)

可选项,玩票性质。其实我认为统计字词数意义不大,主要是在做的课题比较多之后可以快速回顾自己做了哪些课题,每个课题现在是什么状态,可能就会帮助你从故纸堆中发现一些可以整理下继续做甚至发表的东西。

注意,如果你很在意字词数统计结果,又在笔记中和我一样有大量中英文混杂的情况,那么强烈建议你安装Easy Typing插件,并在设置中把自动化格式设置内的“在中文和英文间空格”、“在中文和数字间空格”、“在中文字符间去除空格”、“在文本和标点间智能插入空格”选项打开,以免空格混乱造成字词数统计出现较大偏差。

效果如示例图所示:

这里同样是通过抓取每个课题二级标题下的内容实现的,不过抓的是全文而非某一类型数据。

首先,你需要新建一个Home页面,放在哪里、起什么名字都行,并在Homepage插件中将该页面设置为主界面。
确保你已经安装好Better Word Count插件后,复制粘贴以下代码到你的Homepage页面中:

### Project Tracking

```dataviewjs
const bwc = app.plugins.plugins["better-word-count"].api;
const excludeComments = false;  // 是否在统计时不考虑注释
const includeFootnotes = false;  //是否在统计是考虑脚注
let projects = dv.pages(`"01-Projects"`).where(page => page.file.ext === "md").sort(p => p.file.name);
const projectNames = projects.map(p => {return `[[${p.file.name}]]`});  // 课题名称出现在二级标题中时的显示方式
let pjs = projects.map(p => {
	return [p.file.link, 0, 0, "<span style='white-space: nowrap'>"+ p.status + "</span>"]
}) // 最终汇总的总表数据


//----------------------------------------------------
// Functions
//----------------------------------------------------

// 清理文本中的markdown标记
function removeMarkdown (text) {
	let plaintext = text
		.replace(/`\$?=[^`]+`/g, "") // inline dataview
		.replace(/^---\n.*?\n---\n/s, "") // YAML Header
		.replace(/!?\[(.+)\]\(.+\)/g, "$1") // URLs & Image Captions
		.replace(/\*|_|\[\[|\]\]|\||==|~~|---|#|> |`/g, ""); // Markdown Syntax

	if (excludeComments) {
		plaintext = plaintext
			.replace(/<!--.*?-->/sg, "")
			.replace(/%%.*?%%/sg, "");
	}
	else {
		plaintext = plaintext
			.replace(/%%|<!--|-->/g, ""); // remove only comment syntax
	}

	return plaintext;
}

// 清理脚注
function removeFootnotes (text) {
	return text
		.replace(/^\[\^[A-Za-z0-9-]+\]:.*$/gm, "") // footnote at the end
		.replace(/\[\^[A-Za-z0-9-]+\]/g, ""); // footnote reference inline
}

async function getTableContents (pjs) {

	// Get all pages in the folder
	let pages = dv.pages('"02-Daily Notes" or "03-Meetings"').where(page => page.file.ext === "md");

	// 将字符串转换为正则表达形式
	const escapeRegExp = (string) => {
	    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
	};
	// 将多个字符串,即课题名称,以OR逻辑连接,输出对应的正则表达式
	const createRegexFromStrings = (names) => {
		// Escape each string in the array
		const protectedNames = names.map(str => escapeRegExp(str));
		// Join the escaped strings with non-capturing groups and OR operator
		const regexPattern = `(?:${names.join(')|(?:')})`;
		// Return a new RegExp object with the constructed pattern
		return new RegExp(regexPattern);
	};
	const targetHeadings = createRegexFromStrings(projectNames);
	
	//-------------------------------------------------
	// SECTIONS LOOP
	//-------------------------------------------------
	for (const page of pages) {
		const sectionCache = app.metadataCache.getFileCache(page.file);
		const headingCache = sectionCache.headings?.filter(h => {
	        return targetHeadings.test(h.heading)
	    })  // 获取所有课题对应的二级标题

		if(headingCache?.length > 0) {
			// read page content
			let content = await dv.io.load(page.file.path); // eslint-disable-line no-await-in-loop
			if (!content) continue;
			
			for (let h of headingCache) {
				if (h.level == 2) {
					const headingRange = {
			            start: h.position.start.offset,
			            end: h.position.end.offset,
			        };
			        // 截取二级标题下内容
			        const headingInRange = content.slice(headingRange.start, headingRange.end);
			        const textInNextRange = content.slice(headingRange.end);
			        const nextHeadingRegex = new RegExp(`(^|\\n)#{1,2}\\s`);
			        const position = textInNextRange.match(nextHeadingRegex);
			        let contentRange;
			        let positionEnd;
			
			        if(position) {
			            positionEnd = headingRange.end + position?.index;
			            contentRange = content.slice(headingRange.end, positionEnd);
			        }else {
			            contentRange = content.slice(headingRange.end);
			        }

					// clean up
					contentRange = removeMarkdown(contentRange);
					if (!includeFootnotes) contentRange = removeFootnotes(contentRange);
					contentRange = contentRange
						.replace(/(^\s*)|(\s*$)/g, "") // remove the start and end spaces of the given string
						.replace(/ {2,}/g, " "); // reduce multiple spaces to a single space
			
					// 统计字词数
					let wcCount = bwc.getWordCount(contentRange);
					let charCount = bwc.getCharacterCount(contentRange);
					
					let projectIdx = (name) => name == h.heading;
					pjs[projectNames.findIndex(projectIdx)][1] += charCount;
					pjs[projectNames.findIndex(projectIdx)][2] += wcCount;
				}
			}
		}
	}
return pjs;
}

const tcontent = await getTableContents(pjs);
dv.table(["Project", "Chars", "Words", "Status"], tcontent);

```
  • 碎碎念:似乎有不少人喜欢设定一个目标字词数,根据它展示一个完成进度。但感觉这种东西在科研里只适用于专门码字的场景,没错就是写论文,但写论文倒也不至于用Obsidian……
  • PS: 这个统计数据目前看起来似乎还有些奇怪,不知道是代码的问题还是我笔记的问题,欢迎各位捉虫。

Acknowledgement

4 个赞