Persist daily reviews in daily note frontmatter

This commit is contained in:
Chentao Yang 2026-06-24 00:17:30 +02:00
parent 48dfbf866c
commit afc6fdb93b
7 changed files with 218 additions and 32 deletions

View file

@ -2,7 +2,7 @@
"id": "things-toolkit",
"name": "Things Toolkit",
"description": "Sync Things3 completions into daily notes with review stats and privacy-aware macOS access.",
"version": "1.6.8",
"version": "1.7.0",
"author": "yangcht",
"authorUrl": "https://github.com/yangcht",
"isDesktopOnly": false,

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-things-toolkit-plugin",
"version": "1.6.8",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-things-toolkit-plugin",
"version": "1.6.8",
"version": "1.7.0",
"license": "MIT",
"dependencies": {
"obsidian": "1.13.1",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-things-toolkit-plugin",
"version": "1.6.8",
"version": "1.7.0",
"type": "module",
"description": "Sync Things3 completions into Obsidian daily notes with review stats",
"author": "yangcht",

View file

@ -15,8 +15,13 @@ import {
import { getMoment, type MomentLike } from "./moment";
import { ConfirmationModal } from "./modal";
import { ToolkitRenderer } from "./renderer";
import { getNextSyncDelayMs } from "./scheduler";
import { ToolkitRenderer } from "./renderer";
import {
isDailyReviewEmpty,
readDailyReviewFromFrontmatter,
writeDailyReviewToFrontmatter,
} from "./reviewPersistence";
import {
ThingsToolkitReviewView,
VIEW_TYPE_THINGS_TOOLKIT_REVIEW,
@ -126,14 +131,12 @@ export default class ThingsToolkitPlugin extends Plugin {
this.selectReviewDateFromFile(this.app.workspace.getActiveFile());
});
if (this.isSyncSupported()) {
this.app.workspace.onLayoutReady(() => {
void this.refreshRecentDailyStats().then(() => {
this.updateStatusBar();
this.refreshReviewViews();
});
this.app.workspace.onLayoutReady(() => {
void this.refreshDailyReviewStateFromVault().then(() => {
this.updateStatusBar();
this.refreshReviewViews();
});
}
});
this.settingsTab = new ThingsToolkitSettingsTab(this.app, this);
this.addSettingTab(this.settingsTab);
@ -375,7 +378,7 @@ export default class ThingsToolkitPlugin extends Plugin {
new Notice("Things Toolkit sync failed");
} finally {
await this.refreshRecentDailyStats();
await this.refreshDailyReviewStateFromVault();
this.updateStatusBar();
this.refreshReviewViews();
this.settingsTab?.display();
@ -457,6 +460,97 @@ export default class ThingsToolkitPlugin extends Plugin {
await this.writeOptions({ dailyStats });
}
async refreshDailyReviewStateFromVault(): Promise<void> {
await this.migrateDailyReviewsToFrontmatter();
await this.refreshRecentDailyStats();
await this.refreshRecentDailyReviews();
}
async refreshRecentDailyReviews(
dayCount = this.getReviewWindowDayCount()
): Promise<void> {
const moment = getMoment();
const dailyReviews: Record<string, IDailyLogbookReview> = {
...(this.options.dailyReviews || {}),
};
const dailyNotes = getAllDailyNotes();
const end = moment().startOf("day");
const start = end.clone().subtract(dayCount - 1, "days");
for (
let date = start.clone();
date.isSameOrBefore(end);
date.add(1, "day")
) {
const dateKey = getDateKey(date);
const dailyNote = getDailyNote(toDailyNoteDate(date), dailyNotes);
if (!isTFile(dailyNote)) {
delete dailyReviews[dateKey];
continue;
}
const review = this.readDailyReviewFromFile(dailyNote);
if (review) {
dailyReviews[dateKey] = review;
} else {
delete dailyReviews[dateKey];
}
}
await this.writeOptions({ dailyReviews });
}
private readDailyReviewFromFile(file: TFile): IDailyLogbookReview | null {
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
return readDailyReviewFromFrontmatter(frontmatter);
}
private async migrateDailyReviewsToFrontmatter(): Promise<void> {
const dailyReviews = this.options.dailyReviews || {};
const entries = Object.entries(dailyReviews).filter(
([, review]) => !isDailyReviewEmpty(review)
);
for (const [dateKey, review] of entries) {
const dailyNote = await this.getOrCreateDailyNoteForDateKey(dateKey);
const existingReview = this.readDailyReviewFromFile(dailyNote);
if (existingReview) {
continue;
}
await this.app.fileManager.processFrontMatter(dailyNote, (frontmatter) => {
if (readDailyReviewFromFrontmatter(frontmatter)) {
return;
}
writeDailyReviewToFrontmatter(frontmatter, review);
});
}
}
private async getOrCreateDailyNoteForDateKey(dateKey: string): Promise<TFile> {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) {
throw new Error(`Invalid daily review date: ${dateKey}`);
}
const moment = getMoment();
const date = moment(dateKey, "YYYY-MM-DD");
const dailyNotes = getAllDailyNotes();
let dailyNote = getDailyNote(toDailyNoteDate(date), dailyNotes);
if (!dailyNote) {
dailyNote = await createDailyNote(toDailyNoteDate(date));
}
if (!isTFile(dailyNote)) {
throw new Error("Daily note could not be resolved as a file");
}
return dailyNote;
}
getCurrentCompletionStreak(): number {
const moment = getMoment();
let streak = 0;
@ -487,7 +581,17 @@ export default class ThingsToolkitPlugin extends Plugin {
updatedAt: moment().unix(),
};
if (!nextReview.rating && !nextReview.reflection) {
if (nextReview.reflection !== undefined) {
nextReview.reflection = nextReview.reflection.trim();
}
const dailyNote = await this.getOrCreateDailyNoteForDateKey(dateKey);
await this.app.fileManager.processFrontMatter(dailyNote, (frontmatter) => {
writeDailyReviewToFrontmatter(frontmatter, nextReview);
});
if (isDailyReviewEmpty(nextReview)) {
delete dailyReviews[dateKey];
} else {
dailyReviews[dateKey] = nextReview;
@ -498,18 +602,7 @@ export default class ThingsToolkitPlugin extends Plugin {
}
async openDailyNote(dateKey: string): Promise<void> {
const moment = getMoment();
const date = moment(dateKey, "YYYY-MM-DD");
const dailyNotes = getAllDailyNotes();
let dailyNote = getDailyNote(toDailyNoteDate(date), dailyNotes);
if (!dailyNote) {
dailyNote = await createDailyNote(toDailyNoteDate(date));
}
if (!isTFile(dailyNote)) {
throw new Error("Daily note could not be opened as a file");
}
const dailyNote = await this.getOrCreateDailyNoteForDateKey(dateKey);
const leaf = this.getDailyNoteOpenLeaf();
await leaf.openFile(dailyNote, { active: true });
@ -622,4 +715,4 @@ export default class ThingsToolkitPlugin extends Plugin {
await this.saveData(this.options);
}
}
}

94
src/reviewPersistence.ts Normal file
View file

@ -0,0 +1,94 @@
import type { DayReviewRating, IDailyLogbookReview } from "./settings";
export const REVIEW_FRONTMATTER_KEY = "things_toolkit_review";
const VALID_RATINGS = new Set<DayReviewRating>(["good", "steady", "improve"]);
type FrontmatterRecord = Record<string, unknown>;
function isRecord(value: unknown): value is FrontmatterRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeRating(value: unknown): DayReviewRating | undefined {
return typeof value === "string" && VALID_RATINGS.has(value as DayReviewRating)
? (value as DayReviewRating)
: undefined;
}
function normalizeReflection(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function normalizeUpdatedAt(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const timestamp = Date.parse(value);
if (Number.isFinite(timestamp)) {
return Math.floor(timestamp / 1000);
}
}
return undefined;
}
export function isDailyReviewEmpty(review: IDailyLogbookReview): boolean {
return !review.rating && !normalizeReflection(review.reflection);
}
export function readDailyReviewFromFrontmatter(
frontmatter: FrontmatterRecord | null | undefined
): IDailyLogbookReview | null {
if (!frontmatter) {
return null;
}
const rawReview = frontmatter[REVIEW_FRONTMATTER_KEY];
if (!isRecord(rawReview)) {
return null;
}
const review: IDailyLogbookReview = {
rating: normalizeRating(rawReview.rating),
reflection: normalizeReflection(rawReview.reflection),
updatedAt: normalizeUpdatedAt(rawReview.updatedAt ?? rawReview.updated),
};
return isDailyReviewEmpty(review) ? null : review;
}
export function writeDailyReviewToFrontmatter(
frontmatter: FrontmatterRecord,
review: IDailyLogbookReview
): void {
if (isDailyReviewEmpty(review)) {
delete frontmatter[REVIEW_FRONTMATTER_KEY];
return;
}
const nextReview: FrontmatterRecord = {};
if (review.rating) {
nextReview.rating = review.rating;
}
const reflection = normalizeReflection(review.reflection);
if (reflection) {
nextReview.reflection = reflection;
}
if (typeof review.updatedAt === "number" && Number.isFinite(review.updatedAt)) {
nextReview.updatedAt = review.updatedAt;
nextReview.updated = new Date(review.updatedAt * 1000).toISOString();
}
frontmatter[REVIEW_FRONTMATTER_KEY] = nextReview;
}

View file

@ -34,9 +34,7 @@ export class ThingsToolkitReviewView extends ItemView {
}
async onOpen(): Promise<void> {
if (this.plugin.isSyncSupported()) {
await this.plugin.refreshRecentDailyStats();
}
await this.plugin.refreshDailyReviewStateFromVault();
this.display();
}
@ -394,4 +392,4 @@ export class ThingsToolkitReviewView extends ItemView {
}
return total;
}
}
}

View file

@ -13,5 +13,6 @@
"1.6.5": "1.13.1",
"1.6.6": "1.13.1",
"1.6.7": "1.13.1",
"1.6.8": "1.27.0"
"1.6.8": "1.12.0",
"1.7.0": "1.12.0"
}