diff --git a/README.md b/README.md index 88f0def..07d95b1 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,13 @@ npm run build The build runs type-aware linting, scheduler regression tests, TypeScript checking, and the Rollup bundle. Test plugin builds in a dedicated Obsidian vault before using them with personal notes. -## 1.6.0 +## 1.8.0 -- Fix external source audits that omit development-only type packages. -- Prevent rapid retry loops after failed periodic sync attempts. -- Correct project-heading precedence when project grouping is enabled. -- Add type-aware linting and scheduler regression tests to the build. +- Prevent repeated Things log sections in daily notes after changing the configured Section heading. +- Mark newly synced Things log sections so future syncs can replace them even if the visible heading changes again. +- Consolidate legacy duplicate generated Things sections on resync when they contain the same Things task IDs. +- Keep review-calendar task counts working after generated section heading changes. +- Keep the settings tab compatible with public Obsidian 1.12.x while deferring the 1.13-only `getSettingDefinitions()` API. ## 1.7.0 @@ -64,6 +65,13 @@ The build runs type-aware linting, scheduler regression tests, TypeScript checki - Keep Obsidian and Papa Parse declarations available to external source audits through registry-resolvable production dependencies. - Restore strict unsafe-value linting and add frontmatter parsing regression tests. +## 1.6.0 + +- Fix external source audits that omit development-only type packages. +- Prevent rapid retry loops after failed periodic sync attempts. +- Correct project-heading precedence when project grouping is enabled. +- Add type-aware linting and scheduler regression tests to the build. + ## Publishing For an Obsidian community plugin release, each GitHub release must include: @@ -74,6 +82,7 @@ For an Obsidian community plugin release, each GitHub release must include: The release tag must match the `version` in `manifest.json`. Attach these files directly to the release; do not attach `versions.json` or a zip file. +Release descriptions are read from `RELEASE_NOTES.md`. For a new release: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..409ba77 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,7 @@ +# Things Toolkit 1.8.0 + +- Prevent repeated Things log sections in daily notes after changing the configured Section heading. +- Mark newly synced Things log sections so future syncs can replace them even if the visible heading changes again. +- Consolidate legacy duplicate generated Things sections on resync when they contain the same Things task IDs. +- Keep review-calendar task counts working after generated section heading changes. +- Keep the settings tab compatible with public Obsidian 1.12.x while deferring the 1.13-only `getSettingDefinitions()` API. diff --git a/eslint.config.mjs b/eslint.config.mjs index 69dbb02..5b586f6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,8 +32,8 @@ export default tseslint.config( "@typescript-eslint/no-explicit-any": "warn", /* - * PluginSettingTab.display() is required for the declared Obsidian 1.12 - * minimum; the replacement settings API was introduced in 1.13. + * PluginSettingTab.display() is still required for public Obsidian 1.12.x. + * getSettingDefinitions() is available in 1.13, which is not public yet. */ "@typescript-eslint/no-deprecated": "off", "obsidianmd/ui/sentence-case": [ diff --git a/manifest.json b/manifest.json index c88a6fc..d39dcab 100644 --- a/manifest.json +++ b/manifest.json @@ -2,9 +2,9 @@ "id": "things-toolkit", "name": "Things Toolkit", "description": "Sync Things3 completions into daily notes with review stats and privacy-aware macOS access.", - "version": "1.7.1", + "version": "1.8.0", "author": "yangcht", "authorUrl": "https://github.com/yangcht", "isDesktopOnly": false, "minAppVersion": "1.12.0" -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 18028a4..07166c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-things-toolkit-plugin", - "version": "1.7.1", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-things-toolkit-plugin", - "version": "1.7.1", + "version": "1.8.0", "license": "MIT", "dependencies": { "@types/papaparse": "5.3.1", diff --git a/package.json b/package.json index 0fe7cc5..68228ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-things-toolkit-plugin", - "version": "1.7.1", + "version": "1.8.0", "type": "module", "description": "Sync Things3 completions into Obsidian daily notes with review stats", "author": "yangcht", @@ -13,8 +13,8 @@ "test:typecheck": "tsc --noEmit -p tsconfig.test.json", "build": "npm run lint && npm test && npm run typecheck && npm run test:typecheck && rollup -c", "build:nolint": "rollup -c", - "release:create": "npm run build && VERSION=$(node -p \"require('./manifest.json').version\") && gh release create \"$VERSION\" main.js manifest.json styles.css --title \"$VERSION\" --notes \"Release $VERSION\"", - "release:upload": "npm run build && VERSION=$(node -p \"require('./manifest.json').version\") && (gh release delete-asset \"$VERSION\" versions.json -y >/dev/null 2>&1 || true) && gh release upload \"$VERSION\" main.js manifest.json styles.css --clobber", + "release:create": "npm run build && VERSION=$(node -p \"require('./manifest.json').version\") && gh release create \"$VERSION\" main.js manifest.json styles.css --title \"$VERSION\" --notes-file RELEASE_NOTES.md", + "release:upload": "npm run build && VERSION=$(node -p \"require('./manifest.json').version\") && gh release edit \"$VERSION\" --notes-file RELEASE_NOTES.md && (gh release delete-asset \"$VERSION\" versions.json -y >/dev/null 2>&1 || true) && gh release upload \"$VERSION\" main.js manifest.json styles.css --clobber", "test:watch": "npm test -- --watch" }, "dependencies": { diff --git a/src/index.ts b/src/index.ts index 6782426..0a44c85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,6 @@ import { import { countThingsTasksInSection, groupBy, - isMacOS, updateSection, } from "./textUtils"; @@ -153,7 +152,7 @@ export default class ThingsToolkitPlugin extends Plugin { } isSyncSupported(): boolean { - return Platform.isDesktopApp && isMacOS(); + return Platform.isDesktopApp && Platform.isMacOS; } getSelectedReviewDate(): string { diff --git a/src/logbookSection.ts b/src/logbookSection.ts new file mode 100644 index 0000000..404bd3b --- /dev/null +++ b/src/logbookSection.ts @@ -0,0 +1,404 @@ +export function getHeadingLevel(line = ""): number | null { + const heading = line.match(/^(#{1,6})\s+\S/); + return heading ? heading[1].length : null; +} + +export const LOGBOOK_SECTION_START_MARKER = + ""; +export const LOGBOOK_SECTION_END_MARKER = + ""; + +const THINGS_TASK_LINE_REGEX = + /^- \[[ xXcC]\] .*things:\/\/\/show\?id=([^\s)]+)/; + +export interface SectionRange { + startLine: number; + endLine: number; + taskIds: Set; +} + +export interface SectionContentUpdate { + contents: string; + didChange: boolean; +} + +export interface LogbookSectionUpdate extends SectionContentUpdate { + managedSection: string; + rangesToUpdate: SectionRange[]; + replacementRange: SectionRange | null; +} + +export function updateSectionContents( + fileContents: string, + heading: string, + sectionContents: string +): SectionContentUpdate { + const update = buildLogbookSectionUpdate( + fileContents, + heading, + sectionContents + ); + + return { + contents: update.contents, + didChange: update.didChange, + }; +} + +export function buildLogbookSectionUpdate( + fileContents: string, + heading: string, + sectionContents: string +): LogbookSectionUpdate { + const headingLine = getLogbookHeadingLine(heading); + if (!getHeadingLevel(headingLine)) { + throw new Error(`Invalid logbook section heading: ${heading}`); + } + + const managedSection = toManagedLogbookSection(sectionContents); + const fileLines = fileContents.split("\n"); + const renderedTaskIds = extractThingsTaskIds(sectionContents.split("\n")); + const markedRanges = findMarkedLogbookSectionRanges(fileLines); + const exactHeadingRanges = findHeadingSectionRanges(fileLines, headingLine); + const legacyRanges = findGeneratedLogbookSectionRanges(fileLines).filter( + (range) => hasSharedTaskId(range.taskIds, renderedTaskIds) + ); + const targetRange = + markedRanges[0] || exactHeadingRanges[0] || legacyRanges[0]; + + if (!targetRange) { + const nextContents = appendLogbookSection(fileContents, managedSection); + return { + contents: nextContents, + didChange: nextContents !== fileContents, + managedSection, + rangesToUpdate: [], + replacementRange: null, + }; + } + + const rangesToUpdate = normalizeSectionRanges([ + ...markedRanges, + ...exactHeadingRanges, + ...legacyRanges, + ]); + const replacementRange = + rangesToUpdate.find((range) => isSameRange(range, targetRange)) || + rangesToUpdate[0]; + const nextContents = replaceSectionRanges( + fileLines, + rangesToUpdate, + replacementRange, + managedSection + ); + + return { + contents: nextContents, + didChange: nextContents !== fileContents, + managedSection, + rangesToUpdate, + replacementRange, + }; +} + +export function getSectionContents( + fileContents: string, + heading: string +): string { + const headingLine = getLogbookHeadingLine(heading); + if (!getHeadingLevel(headingLine)) { + return ""; + } + + const fileLines = fileContents.split("\n"); + const markedRange = findMarkedLogbookSectionRanges(fileLines)[0]; + const exactRange = findHeadingSectionRanges(fileLines, headingLine)[0]; + const legacyRange = findGeneratedLogbookSectionRanges(fileLines)[0]; + const sectionRange = markedRange || exactRange || legacyRange; + + return sectionRange ? getSectionRangeContents(fileLines, sectionRange) : ""; +} + +export function countThingsTasksInSection( + fileContents: string, + heading: string +): number { + const sectionContents = getSectionContents(fileContents, heading); + if (!sectionContents) { + return 0; + } + + return extractThingsTaskIds(sectionContents.split("\n")).size; +} + +export function getAppendText( + fileContents: string, + managedSection: string +): string { + if (!fileContents) { + return managedSection; + } + + const separator = fileContents.endsWith("\n\n") + ? "" + : fileContents.endsWith("\n") + ? "\n" + : "\n\n"; + return `${separator}${managedSection}`; +} + +export function isSameRange(left: SectionRange, right: SectionRange): boolean { + return left.startLine === right.startLine && left.endLine === right.endLine; +} + +function getLogbookHeadingLine(heading: string): string { + return ( + heading + .split("\n") + .map((line) => line.trim()) + .find((line) => getHeadingLevel(line) !== null) || heading.trim() + ); +} + +function toManagedLogbookSection(sectionContents: string): string { + const trimmedSectionContents = sectionContents.trimEnd(); + if ( + trimmedSectionContents.startsWith(LOGBOOK_SECTION_START_MARKER) && + trimmedSectionContents.endsWith(LOGBOOK_SECTION_END_MARKER) + ) { + return trimmedSectionContents; + } + + return [ + LOGBOOK_SECTION_START_MARKER, + trimmedSectionContents, + LOGBOOK_SECTION_END_MARKER, + ].join("\n"); +} + +function appendLogbookSection( + fileContents: string, + managedSection: string +): string { + return `${fileContents}${getAppendText(fileContents, managedSection)}`; +} + +function findMarkedLogbookSectionRanges(fileLines: string[]): SectionRange[] { + const ranges: SectionRange[] = []; + + for (let i = 0; i < fileLines.length; i++) { + if (fileLines[i].trim() !== LOGBOOK_SECTION_START_MARKER) { + continue; + } + + const endMarkerLine = fileLines.findIndex( + (line, index) => + index > i && line.trim() === LOGBOOK_SECTION_END_MARKER + ); + + if (endMarkerLine === -1) { + continue; + } + + ranges.push(createSectionRange(fileLines, i, endMarkerLine + 1)); + i = endMarkerLine; + } + + return ranges; +} + +function findHeadingSectionRanges( + fileLines: string[], + headingLine: string +): SectionRange[] { + const ranges: SectionRange[] = []; + + for (let i = 0; i < fileLines.length; i++) { + if (fileLines[i].trim() !== headingLine) { + continue; + } + + const headingLevel = getHeadingLevel(fileLines[i]); + if (!headingLevel) { + continue; + } + + ranges.push( + createSectionRange( + fileLines, + i, + findSectionEndLine(fileLines, i, headingLevel) + ) + ); + } + + return ranges; +} + +function findGeneratedLogbookSectionRanges(fileLines: string[]): SectionRange[] { + const ranges: SectionRange[] = []; + + for (let i = 0; i < fileLines.length; i++) { + const headingLevel = getHeadingLevel(fileLines[i]); + if (!headingLevel) { + continue; + } + + const endLine = findSectionEndLine(fileLines, i, headingLevel); + const sectionLines = fileLines.slice(i, endLine); + if (isGeneratedLogbookSection(sectionLines, headingLevel)) { + ranges.push(createSectionRange(fileLines, i, endLine)); + } + } + + return removeContainedRanges(ranges); +} + +function findSectionEndLine( + fileLines: string[], + startLine: number, + headingLevel: number +): number { + for (let i = startLine + 1; i < fileLines.length; i++) { + const currentHeadingLevel = getHeadingLevel(fileLines[i]); + if (currentHeadingLevel && currentHeadingLevel <= headingLevel) { + return i; + } + } + + return fileLines.length; +} + +function createSectionRange( + fileLines: string[], + startLine: number, + endLine: number +): SectionRange { + return { + startLine, + endLine, + taskIds: extractThingsTaskIds(fileLines.slice(startLine, endLine)), + }; +} + +function isGeneratedLogbookSection( + sectionLines: string[], + headingLevel: number +): boolean { + const taskIds = extractThingsTaskIds(sectionLines); + if (taskIds.size === 0) { + return false; + } + + return sectionLines.slice(1).every((line) => { + if (!line.trim()) { + return true; + } + + const currentHeadingLevel = getHeadingLevel(line); + if (currentHeadingLevel) { + return currentHeadingLevel > headingLevel; + } + + return THINGS_TASK_LINE_REGEX.test(line) || isIndentedLine(line); + }); +} + +function isIndentedLine(line: string): boolean { + return line.startsWith("\t") || line.startsWith(" "); +} + +function extractThingsTaskIds(lines: string[]): Set { + const taskIds = new Set(); + + lines.forEach((line) => { + const taskMatch = line.match(THINGS_TASK_LINE_REGEX); + if (taskMatch?.[1]) { + taskIds.add(taskMatch[1]); + } + }); + + return taskIds; +} + +function hasSharedTaskId(left: Set, right: Set): boolean { + if (left.size === 0 || right.size === 0) { + return false; + } + + for (const taskId of left) { + if (right.has(taskId)) { + return true; + } + } + + return false; +} + +function normalizeSectionRanges(ranges: SectionRange[]): SectionRange[] { + return [...ranges] + .sort( + (left, right) => + left.startLine - right.startLine || right.endLine - left.endLine + ) + .reduce((acc, range) => { + const previousRange = acc[acc.length - 1]; + if (!previousRange || range.startLine >= previousRange.endLine) { + acc.push(range); + return acc; + } + + if (range.endLine > previousRange.endLine) { + previousRange.endLine = range.endLine; + } + + return acc; + }, []); +} + +function removeContainedRanges(ranges: SectionRange[]): SectionRange[] { + return ranges.filter( + (range) => + !ranges.some( + (otherRange) => + otherRange.startLine < range.startLine && + range.endLine <= otherRange.endLine + ) + ); +} + +function replaceSectionRanges( + fileLines: string[], + ranges: SectionRange[], + replacementRange: SectionRange, + managedSection: string +): string { + const nextLines: string[] = []; + let cursor = 0; + + ranges.forEach((range) => { + nextLines.push(...fileLines.slice(cursor, range.startLine)); + + if (isSameRange(range, replacementRange)) { + nextLines.push(...managedSection.split("\n")); + } + + cursor = range.endLine; + }); + + nextLines.push(...fileLines.slice(cursor)); + return nextLines.join("\n"); +} + +function getSectionRangeContents( + fileLines: string[], + range: SectionRange +): string { + const isMarkedRange = + fileLines[range.startLine]?.trim() === LOGBOOK_SECTION_START_MARKER && + fileLines[range.endLine - 1]?.trim() === LOGBOOK_SECTION_END_MARKER; + const startLine = isMarkedRange ? range.startLine + 1 : range.startLine; + const endLine = isMarkedRange ? range.endLine - 1 : range.endLine; + + return fileLines.slice(startLine, endLine).join("\n"); +} diff --git a/src/textUtils.ts b/src/textUtils.ts index bfb0c83..7e87411 100644 --- a/src/textUtils.ts +++ b/src/textUtils.ts @@ -1,12 +1,18 @@ -import { Platform } from "obsidian"; import type { App, TFile } from "obsidian"; import { getEditorForFile } from "./fileUtils"; +import { + buildLogbookSectionUpdate, + getAppendText, + isSameRange, + type SectionRange, +} from "./logbookSection"; -export function getHeadingLevel(line = ""): number | null { - const heading = line.match(/^(#{1,6})\s+\S/); - return heading ? heading[1].length : null; -} +export { + countThingsTasksInSection, + getHeadingLevel, + updateSectionContents, +} from "./logbookSection"; export function toHeading( title: string, @@ -37,139 +43,78 @@ export function groupBy( }, {} as Record); } -export function isMacOS(): boolean { - return Platform.isMacOS; -} - export async function updateSection( app: App, file: TFile, heading: string, sectionContents: string ): Promise { - const headingLevel = getHeadingLevel(heading); - if (!headingLevel) { - throw new Error(`Invalid logbook section heading: ${heading}`); - } - const { vault } = app; const fileContents = await vault.read(file); - const fileLines = fileContents.split("\n"); + const update = buildLogbookSectionUpdate( + fileContents, + heading, + sectionContents + ); - let logbookSectionLineNum = -1; - let nextSectionLineNum = -1; - - for (let i = 0; i < fileLines.length; i++) { - if (fileLines[i].trim() === heading) { - logbookSectionLineNum = i; - } else if (logbookSectionLineNum !== -1) { - const currLevel = getHeadingLevel(fileLines[i]); - if (currLevel && currLevel <= headingLevel) { - nextSectionLineNum = i; - break; - } - } + if (!update.didChange) { + return false; } const editor = getEditorForFile(app, file); if (editor) { - if (logbookSectionLineNum !== -1) { - const currentSection = fileLines - .slice( - logbookSectionLineNum, - nextSectionLineNum !== -1 ? nextSectionLineNum : fileLines.length - ) - .join("\n") - .trimEnd(); - - if (currentSection === sectionContents.trimEnd()) { - return false; - } - - const from = { line: logbookSectionLineNum, ch: 0 }; - const to = - nextSectionLineNum !== -1 - ? { line: nextSectionLineNum, ch: 0 } - : { line: fileLines.length, ch: 0 }; - - editor.replaceRange(`${sectionContents}\n`, from, to); + const fileLines = fileContents.split("\n"); + if (update.rangesToUpdate.length === 0) { + const to = getEditorEndPosition(fileLines); + editor.replaceRange( + getAppendText(fileContents, update.managedSection), + to, + to + ); return true; } - const pos = { line: fileLines.length, ch: 0 }; - editor.replaceRange(`\n\n${sectionContents}`, pos, pos); + [...update.rangesToUpdate] + .sort((left, right) => right.startLine - left.startLine) + .forEach((range) => { + const replacementText = + update.replacementRange && isSameRange(range, update.replacementRange) + ? getReplacementText(fileLines, range, update.managedSection) + : ""; + editor.replaceRange( + replacementText, + { line: range.startLine, ch: 0 }, + getEditorRangeEndPosition(fileLines, range) + ); + }); return true; } - if (logbookSectionLineNum !== -1) { - const prefix = fileLines.slice(0, logbookSectionLineNum); - const suffix = - nextSectionLineNum !== -1 ? fileLines.slice(nextSectionLineNum) : []; - const currentSection = fileLines - .slice( - logbookSectionLineNum, - nextSectionLineNum !== -1 ? nextSectionLineNum : fileLines.length - ) - .join("\n") - .trimEnd(); - - if (currentSection === sectionContents.trimEnd()) { - return false; - } - - await vault.process(file, () => - [...prefix, sectionContents, ...suffix].join("\n") - ); - return true; - } - - await vault.process(file, () => [...fileLines, "", sectionContents].join("\n")); + await vault.process(file, () => update.contents); return true; } -export function getSectionContents( - fileContents: string, - heading: string +function getReplacementText( + fileLines: string[], + range: SectionRange, + managedSection: string ): string { - const headingLevel = getHeadingLevel(heading); - if (!headingLevel) { - return ""; - } - - const fileLines = fileContents.split("\n"); - let sectionLineNum = -1; - let nextSectionLineNum = fileLines.length; - - for (let i = 0; i < fileLines.length; i++) { - if (fileLines[i].trim() === heading) { - sectionLineNum = i; - } else if (sectionLineNum !== -1) { - const currLevel = getHeadingLevel(fileLines[i]); - if (currLevel && currLevel <= headingLevel) { - nextSectionLineNum = i; - break; - } - } - } - - if (sectionLineNum === -1) { - return ""; - } - - return fileLines.slice(sectionLineNum, nextSectionLineNum).join("\n"); + const suffix = range.endLine < fileLines.length ? "\n" : ""; + return `${managedSection}${suffix}`; } -export function countThingsTasksInSection( - fileContents: string, - heading: string -): number { - const sectionContents = getSectionContents(fileContents, heading); - if (!sectionContents) { - return 0; +function getEditorEndPosition(fileLines: string[]): { line: number; ch: number } { + const lastLine = Math.max(0, fileLines.length - 1); + return { line: lastLine, ch: fileLines[lastLine].length }; +} + +function getEditorRangeEndPosition( + fileLines: string[], + range: SectionRange +): { line: number; ch: number } { + if (range.endLine < fileLines.length) { + return { line: range.endLine, ch: 0 }; } - return sectionContents - .split("\n") - .filter((line) => /^- \[[ xXcC]\] .*things:\/\/\/show\?id=/.test(line)) - .length; -} \ No newline at end of file + return getEditorEndPosition(fileLines); +} diff --git a/test/textUtils.test.ts b/test/textUtils.test.ts new file mode 100644 index 0000000..dcde1ad --- /dev/null +++ b/test/textUtils.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countThingsTasksInSection, + LOGBOOK_SECTION_END_MARKER, + LOGBOOK_SECTION_START_MARKER, + updateSectionContents, +} from "../src/logbookSection"; + +const currentSection = [ + "### Things Daily Log", + "", + "#### Mindful", + "- [x] [Task A](things:///show?id=task-a) #things/mind", + "\t## Daily summary", + "\t-", + "", + "#### Body", + "- [x] [Task B](things:///show?id=task-b) #things/life", +].join("\n"); + +const oldSection = [ + "### Daily_Things3", + "", + "#### Mindful", + "- [x] [Task A](things:///show?id=task-a) #things/mind", + "\t## Daily summary", + "\t-", + "", + "#### Body", + "- [x] [Task B](things:///show?id=task-b) #things/life", +].join("\n"); + +const defaultSection = [ + "## Things", + "### Mindful", + "- [x] [Task A](things:///show?id=task-a) #things/mind", + "\t## Daily summary", + "\t-", + "### Body", + "- [x] [Task B](things:///show?id=task-b) #things/life", +].join("\n"); + +test("marks a newly appended logbook section", () => { + const result = updateSectionContents("# Journal", "### Things Daily Log", currentSection); + + assert.equal(result.didChange, true); + assert.equal( + result.contents, + [ + "# Journal", + "", + LOGBOOK_SECTION_START_MARKER, + currentSection, + LOGBOOK_SECTION_END_MARKER, + ].join("\n") + ); +}); + +test("replaces an existing marked logbook section after a heading rename", () => { + const fileContents = [ + "# Journal", + "", + LOGBOOK_SECTION_START_MARKER, + oldSection, + LOGBOOK_SECTION_END_MARKER, + "", + "Personal note", + ].join("\n"); + + const result = updateSectionContents( + fileContents, + "### Things Daily Log", + currentSection + ); + + assert.equal(result.didChange, true); + assert.match(result.contents, /### Things Daily Log/); + assert.doesNotMatch(result.contents, /### Daily_Things3/); + assert.match(result.contents, /Personal note/); + assert.equal(countOccurrences(result.contents, "things:///show?id=task-a"), 1); +}); + +test("leaves an unchanged marked logbook section untouched", () => { + const fileContents = [ + "# Journal", + "", + LOGBOOK_SECTION_START_MARKER, + currentSection, + LOGBOOK_SECTION_END_MARKER, + ].join("\n"); + + const result = updateSectionContents( + fileContents, + "### Things Daily Log", + currentSection + ); + + assert.equal(result.didChange, false); + assert.equal(result.contents, fileContents); +}); + +test("consolidates unmarked legacy duplicate logbook sections", () => { + const fileContents = [ + "# Journal", + "", + "## Things3", + "#_things3", + "", + currentSection, + "", + oldSection, + "", + defaultSection, + "", + "## Notes", + "Keep this.", + ].join("\n"); + + const result = updateSectionContents( + fileContents, + "### Things Daily Log", + currentSection + ); + + assert.equal(result.didChange, true); + assert.match(result.contents, /## Things3/); + assert.match(result.contents, /#_things3/); + assert.match(result.contents, /### Things Daily Log/); + assert.doesNotMatch(result.contents, /### Daily_Things3/); + assert.doesNotMatch(result.contents, /\n## Things\n### Mindful/); + assert.match(result.contents, /## Notes\nKeep this\./); + assert.equal(countOccurrences(result.contents, "things:///show?id=task-a"), 1); + assert.equal(countOccurrences(result.contents, "things:///show?id=task-b"), 1); +}); + +test("uses a legacy generated section as the rename target when the new heading is absent", () => { + const fileContents = ["# Journal", "", oldSection].join("\n"); + + const result = updateSectionContents( + fileContents, + "### Things Daily Log", + currentSection + ); + + assert.equal(result.didChange, true); + assert.match(result.contents, /### Things Daily Log/); + assert.doesNotMatch(result.contents, /### Daily_Things3/); + assert.equal(countOccurrences(result.contents, "things:///show?id=task-a"), 1); +}); + +test("does not treat user prose with a Things link as a generated section", () => { + const fileContents = [ + "# Journal", + "", + "## Related Things", + "Remember why this task mattered.", + "- [x] [Task A](things:///show?id=task-a)", + ].join("\n"); + + const result = updateSectionContents( + fileContents, + "### Things Daily Log", + currentSection + ); + + assert.equal(result.didChange, true); + assert.match(result.contents, /## Related Things/); + assert.match(result.contents, /Remember why this task mattered\./); + assert.equal(countOccurrences(result.contents, "things:///show?id=task-a"), 2); +}); + +test("counts tasks in the marked logbook section after a heading rename", () => { + const fileContents = [ + LOGBOOK_SECTION_START_MARKER, + oldSection, + LOGBOOK_SECTION_END_MARKER, + ].join("\n"); + + assert.equal(countThingsTasksInSection(fileContents, "### Things Daily Log"), 2); +}); + +function countOccurrences(text: string, pattern: string): number { + return text.split(pattern).length - 1; +} diff --git a/versions.json b/versions.json index 7081ef4..4b44d55 100644 --- a/versions.json +++ b/versions.json @@ -15,5 +15,6 @@ "1.6.7": "1.13.1", "1.6.8": "1.12.0", "1.7.0": "1.12.0", - "1.7.1": "1.12.0" -} \ No newline at end of file + "1.7.1": "1.12.0", + "1.8.0": "1.12.0" +}