Fix duplicated synced notes.

This commit is contained in:
Chentao Yang 2026-06-25 22:19:21 +02:00
parent 44d5b6a304
commit e092248ee2
11 changed files with 683 additions and 132 deletions

View file

@ -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:

7
RELEASE_NOTES.md Normal file
View file

@ -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.

View file

@ -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": [

View file

@ -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"
}
}

4
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -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 {

404
src/logbookSection.ts Normal file
View file

@ -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 =
"<!-- things-toolkit-logbook:start -->";
export const LOGBOOK_SECTION_END_MARKER =
"<!-- things-toolkit-logbook:end -->";
const THINGS_TASK_LINE_REGEX =
/^- \[[ xXcC]\] .*things:\/\/\/show\?id=([^\s)]+)/;
export interface SectionRange {
startLine: number;
endLine: number;
taskIds: Set<string>;
}
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<string> {
const taskIds = new Set<string>();
lines.forEach((line) => {
const taskMatch = line.match(THINGS_TASK_LINE_REGEX);
if (taskMatch?.[1]) {
taskIds.add(taskMatch[1]);
}
});
return taskIds;
}
function hasSharedTaskId(left: Set<string>, right: Set<string>): 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<SectionRange[]>((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");
}

View file

@ -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<T>(
}, {} as Record<string | number, T[]>);
}
export function isMacOS(): boolean {
return Platform.isMacOS;
}
export async function updateSection(
app: App,
file: TFile,
heading: string,
sectionContents: string
): Promise<boolean> {
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;
}
return getEditorEndPosition(fileLines);
}

186
test/textUtils.test.ts Normal file
View file

@ -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;
}

View file

@ -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"
}
"1.7.1": "1.12.0",
"1.8.0": "1.12.0"
}