mirror of
https://github.com/yangcht/obsidian-things-toolkit.git
synced 2026-07-22 07:45:44 +00:00
Fix duplicated synced notes.
This commit is contained in:
parent
44d5b6a304
commit
e092248ee2
11 changed files with 683 additions and 132 deletions
19
README.md
19
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:
|
||||
|
||||
|
|
|
|||
7
RELEASE_NOTES.md
Normal file
7
RELEASE_NOTES.md
Normal 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.
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
404
src/logbookSection.ts
Normal 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");
|
||||
}
|
||||
173
src/textUtils.ts
173
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<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
186
test/textUtils.test.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue