From afc6fdb93b9ed6c0da92fd4b16f7e2a3bdb6dc1c Mon Sep 17 00:00:00 2001 From: Chentao Yang Date: Wed, 24 Jun 2026 00:17:30 +0200 Subject: [PATCH] Persist daily reviews in daily note frontmatter --- manifest.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 139 ++++++++++++++++++++++++++++++++------- src/reviewPersistence.ts | 94 ++++++++++++++++++++++++++ src/reviewView.ts | 6 +- versions.json | 3 +- 7 files changed, 218 insertions(+), 32 deletions(-) create mode 100644 src/reviewPersistence.ts diff --git a/manifest.json b/manifest.json index 25338d5..9b7e14a 100644 --- a/manifest.json +++ b/manifest.json @@ -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, diff --git a/package-lock.json b/package-lock.json index 4748cbd..ba4378a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index de0b633..113b8a1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 63d10d3..f881c82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { + await this.migrateDailyReviewsToFrontmatter(); + await this.refreshRecentDailyStats(); + await this.refreshRecentDailyReviews(); + } + + async refreshRecentDailyReviews( + dayCount = this.getReviewWindowDayCount() + ): Promise { + const moment = getMoment(); + const dailyReviews: Record = { + ...(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 { + 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 { + 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 { - 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); } -} +} \ No newline at end of file diff --git a/src/reviewPersistence.ts b/src/reviewPersistence.ts new file mode 100644 index 0000000..7b0f79d --- /dev/null +++ b/src/reviewPersistence.ts @@ -0,0 +1,94 @@ +import type { DayReviewRating, IDailyLogbookReview } from "./settings"; + +export const REVIEW_FRONTMATTER_KEY = "things_toolkit_review"; + +const VALID_RATINGS = new Set(["good", "steady", "improve"]); + +type FrontmatterRecord = Record; + +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; +} \ No newline at end of file diff --git a/src/reviewView.ts b/src/reviewView.ts index d9926a1..457eb60 100644 --- a/src/reviewView.ts +++ b/src/reviewView.ts @@ -34,9 +34,7 @@ export class ThingsToolkitReviewView extends ItemView { } async onOpen(): Promise { - 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; } -} +} \ No newline at end of file diff --git a/versions.json b/versions.json index eb9c273..f3e2b21 100644 --- a/versions.json +++ b/versions.json @@ -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" } \ No newline at end of file