Skip to content

Commit 5e65d09

Browse files
committed
b54 Fix moving indented tasks
Turn off banner error if cancelling a large move operation
1 parent cf48d96 commit 5e65d09

8 files changed

Lines changed: 175 additions & 39 deletions

File tree

‎helpers/NPParagraph.js‎

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { displayTitle, rangeToString } from '@helpers/general'
1414
import { getNoteType } from '@helpers/note'
1515
import { getFirstDateInPeriod, getNPWeekData, getMonthData, getQuarterData, getYearData, nowDoneDateTimeString, toLocaleDateTimeString } from '@helpers/NPdateTime'
1616
import { endOfFrontmatterLineIndex, noteHasFrontMatter } from '@helpers/NPFrontMatter'
17-
import { findStartOfActivePartOfNote, isTermInMarkdownPath, isTermInURL } from '@helpers/paragraph'
17+
import { convertRawContentToContent, findStartOfActivePartOfNote, isTermInMarkdownPath, isTermInURL } from '@helpers/paragraph'
1818
import { RE_FIRST_SCHEDULED_DATE_CAPTURE } from '@helpers/regex'
1919
import { caseInsensitiveMatch, caseInsensitiveSubstringMatch, caseInsensitiveStartsWith, getLineMainContentPos } from '@helpers/search'
2020
import { stripTodaysDateRefsFromString } from '@helpers/stringTransforms'
@@ -1196,10 +1196,25 @@ export function createStaticParagraphsArray(arrayOfObjects: Array<any>, fields:
11961196
return staticArray
11971197
}
11981198

1199+
/**
1200+
* Compare two rawContent strings, allowing for leading-indent differences.
1201+
* Needed because NotePlan backlinks return indented paras with indent stripped from .rawContent (indent always 0).
1202+
* @param {string} a
1203+
* @param {string} b
1204+
* @returns {boolean}
1205+
*/
1206+
export function rawContentMatchesIgnoringLeadingIndent(a: string, b: string): boolean {
1207+
if (a === b) return true
1208+
// trimStart is ES2019; use replace for broader compatibility in NP's JSContext
1209+
const stripLeading = (s: string): string => s.replace(/^\s+/, '')
1210+
return stripLeading(a) === stripLeading(b)
1211+
}
1212+
11991213
/**
12001214
* Check a paragraph object against a plain object of fields to see if they match.
12011215
* Does an explicit match for specified fields but if the content is truncated with "..." it will match if the truncated version is the same
12021216
* (this works around a bug in DataStore.listOverdueTasks where it was truncating the paragraph content at 300 chars)
1217+
* For rawContent, also allows leading-indent differences (as backlinks API unhelpfully returns indent-stripped rawContent).
12031218
* @param {TParagraph} paragraph object to check
12041219
* @param {any} fieldsObject object with some fields
12051220
* @param {Array<string>} fields list of field names to check in fieldsObject
@@ -1216,12 +1231,26 @@ export function paragraphMatches(paragraph: TParagraph, fieldsObject: any, field
12161231

12171232
fields.forEach((field) => {
12181233
if (field === 'rawContent' && rawWasEdited) {
1219-
if (paragraph[field] !== fieldsObject['originalRawContent']) {
1234+
if (!rawContentMatchesIgnoringLeadingIndent(paragraph[field], fieldsObject['originalRawContent'])) {
12201235
match = false
12211236
}
12221237
} else if (field === 'rawContent' && isTruncated) {
1223-
// Use startsWith for truncated rawContent
1224-
if (!paragraph[field].startsWith(truncatedContent)) {
1238+
// Use startsWith for truncated rawContent (also try after stripping leading indent on the note para)
1239+
const paraRaw = paragraph[field]
1240+
const paraRawStripped = paraRaw.replace(/^\s+/, '')
1241+
if (!paraRaw.startsWith(truncatedContent) && !paraRawStripped.startsWith(truncatedContent.replace(/^\s+/, ''))) {
1242+
match = false
1243+
}
1244+
} else if (field === 'rawContent') {
1245+
// $FlowIgnore[prop-missing]
1246+
if (typeof paragraph[field] === 'undefined') {
1247+
throw `paragraphMatches: paragraph.${field} is undefined. You must pass in the correct fields to match. 'fields' is set to ${JSP(fields)}, but paragraph=${JSP(
1248+
paragraph,
1249+
)}, which does not have all the fields`
1250+
}
1251+
// Exact match, or match ignoring leading indent (backlinks strip indent from rawContent)
1252+
// $FlowIgnore[prop-missing]
1253+
if (!rawContentMatchesIgnoringLeadingIndent(paragraph[field], fieldsObject[field])) {
12251254
match = false
12261255
}
12271256
} else {
@@ -1303,13 +1332,12 @@ export function findParagraph(
13031332

13041333
/**
13051334
* Take a static object with a subset of Paragraph fields from HTML or wherever and return the actual paragraph in the note
1335+
* Note: rawContent matching allows leading-indent differences (as backlinks API unhelpfully returns indent-stripped rawContent for indented paras).
13061336
* @param {any} staticObject - the static object from the HTML must have fields:
13071337
* filename, lineIndex, noteType
13081338
* @param {Array<string>} fieldsToMatch - (optional) array of fields to match (e.g. filename, lineIndex) -- these two fields are required. default is ['filename', 'rawContent']
13091339
* @returns {TParagraph|null} - the paragraph or null if not found
13101340
* @author @dwertheimer
1311-
* TODO(@dwertheimer): is the fieldsToMatch default and passing down to findParagraph correct?
1312-
* FIXME: fails because indents is 0 not 1
13131341
*/
13141342
export function getParagraphFromStaticObject(staticObject: any, fieldsToMatch: Array<string> = ['filename', 'rawContent']): TParagraph | null {
13151343
const { filename } = staticObject
@@ -1385,6 +1413,7 @@ export function findParaFromStringAndFilename(filenameIn: string, content: strin
13851413
* Return a TParagraph object by an exact match to 'rawContent' in file 'filenameIn'. If it fails to find a match, it returns false.
13861414
* If the rawContent is truncated with "..." it will match if the truncated version is the same as the start of the rawContent in a line in the note
13871415
* (this works around a bug in DataStore.listOverdueTasks where it was truncating the paragraph rawContent at 300 chars).
1416+
* Also falls back to matching ignoring leading indent (as backlinks API unhelpfully strips indent from .rawContent), then to .content match.
13881417
* Designed to be called when you're not in an Editor (e.g. an HTML Window).
13891418
* Works on both Regular ('Project') and Calendar notes.
13901419
* Note: updated in Nov 2025 to work for Teamspace notes as well.
@@ -1410,16 +1439,40 @@ export function findParaFromRawContentAndFilename(filenameIn: string, rawContent
14101439
if (thisNote.paragraphs.length > 0) {
14111440
const isTruncated = rawContentIn.endsWith('...')
14121441
const truncatedContent = isTruncated ? rawContentIn.slice(0, -3) : rawContentIn // only slice if truncated
1442+
const searchRawStripped = rawContentIn.replace(/^\s+/, '')
1443+
const contentFallback = convertRawContentToContent(rawContentIn)
14131444

1414-
// let c = 0
1445+
// Pass 1: exact (or truncated prefix) match
14151446
for (const para of thisNote.paragraphs) {
14161447
if (isTruncated ? para.rawContent.startsWith(truncatedContent) : para.rawContent === rawContentIn) {
1417-
// logDebug('NPP/findParaFromRawContentAndFilename', `found matching para #${c} of type ${para.type}: {${rawContentIn}}`)
14181448
return para
14191449
}
1420-
// c++
14211450
}
1422-
logWarn('NPP/findParaFromRawContentAndFilename', `Couldn't find paragraph {${rawContentIn}} in note '${filename}'`)
1451+
// Pass 2: match ignoring leading indent (backlinks API strips indent from .rawContent)
1452+
for (const para of thisNote.paragraphs) {
1453+
if (rawContentMatchesIgnoringLeadingIndent(para.rawContent, rawContentIn)) {
1454+
logDebug('NPP/findParaFromRawContentAndFilename', `matched via leading-indent-insensitive rawContent for {${rawContentIn}}`)
1455+
return para
1456+
}
1457+
if (isTruncated) {
1458+
const paraStripped = para.rawContent.replace(/^\s+/, '')
1459+
const truncatedStripped = truncatedContent.replace(/^\s+/, '')
1460+
if (paraStripped.startsWith(truncatedStripped) || para.rawContent.startsWith(truncatedStripped)) {
1461+
logDebug('NPP/findParaFromRawContentAndFilename', `matched truncated rawContent ignoring leading indent for {${rawContentIn}}`)
1462+
return para
1463+
}
1464+
}
1465+
}
1466+
// Pass 3: fall back to .content match (derived from rawContent)
1467+
if (contentFallback && contentFallback !== '<error>') {
1468+
for (const para of thisNote.paragraphs) {
1469+
if (para.content === contentFallback) {
1470+
logDebug('NPP/findParaFromRawContentAndFilename', `matched via content fallback {${contentFallback}} for rawContent {${rawContentIn}}`)
1471+
return para
1472+
}
1473+
}
1474+
}
1475+
logWarn('NPP/findParaFromRawContentAndFilename', `Couldn't find paragraph {${rawContentIn}} (stripped:{${searchRawStripped}}) in note '${filename}'`)
14231476
return false
14241477
} else {
14251478
logInfo('NPP/findParaFromRawContentAndFilename', `Note '${filename}' appears to be empty?`)
@@ -1831,13 +1884,6 @@ export function getSelectedParagraphsWithCorrectLineIndex(): Array<TParagraph> {
18311884
const selectedParagraphs = Editor.selectedParagraphs.map((p: TParagraph) => note.paragraphs[p.lineIndex + numberOfFrontmatterLines]) ?? []
18321885
logDebug('getSelectedParagraphsWithCorrectLineIndex', `Editor.selectedParagraphs:\n${String(Editor.selectedParagraphs.map((p) => `- #${p.lineIndex}: ${p.content}`).join('\n'))}`)
18331886

1834-
// TEST: removing this, as the necessary change has been made above
1835-
// const correctedSelectedParagraphs: Array<TParagraph> = selectedParagraphs.slice()
1836-
// correctedSelectedParagraphs.forEach((p: TParagraph) => {
1837-
// p.lineIndex += numberOfFrontmatterLines
1838-
// })
1839-
// logDebug('getSelectedParagraphsWithCorrectLineIndex', `${correctedSelectedParagraphs.length} Corrected selected paragraph(s) with lineIndex taking into account frontmatter lines:\n${String(correctedSelectedParagraphs.map((p) => `- ${p.lineIndex}: ${p.content}`).join('\n'))}`)
1840-
18411887
return selectedParagraphs
18421888
} catch (error) {
18431889
logError('getSelectedParagraphsWithCorrectLineIndex', error.message)

‎helpers/NPnote.js‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,42 @@ export function getFlatListOfBacklinks(note: TNote): Array<TParagraph> {
750750
}
751751
}
752752

753+
/**
754+
* Resolve a backlink paragraph to the real paragraph in its source note.
755+
* NotePlan's backlinks API deliberately, but unhelpfully, returns indented paras with indent stripped from .rawContent / .indents always 0.
756+
* Looking up by lineIndex (preferring content match) or by content alone restores correct rawContent/indents.
757+
* @author @jgclark + @Cursor
758+
* @param {TParagraph} backlinkPara
759+
* @returns {TParagraph} real note paragraph when found, otherwise the original backlink para
760+
*/
761+
export function resolveBacklinkParaToRealPara(backlinkPara: TParagraph): TParagraph {
762+
try {
763+
if (!backlinkPara) return backlinkPara
764+
const note: ?TNote = backlinkPara.note
765+
|| (backlinkPara.filename
766+
? DataStore.noteByFilename(backlinkPara.filename, backlinkPara.noteType || 'Notes')
767+
|| DataStore.noteByFilename(backlinkPara.filename, 'Calendar')
768+
: null)
769+
if (!note || !note.paragraphs?.length) return backlinkPara
770+
771+
// Prefer lineIndex when content still matches (line may have moved)
772+
if (typeof backlinkPara.lineIndex === 'number' && note.paragraphs[backlinkPara.lineIndex]) {
773+
const candidate = note.paragraphs[backlinkPara.lineIndex]
774+
if (candidate.content === backlinkPara.content) {
775+
return candidate
776+
}
777+
}
778+
// Fall back to exact content match
779+
const byContent = note.paragraphs.find((p) => p.content === backlinkPara.content)
780+
if (byContent) return byContent
781+
782+
return backlinkPara
783+
} catch (err) {
784+
logWarn('resolveBacklinkParaToRealPara', `falling back to backlink para: ${err.message}`)
785+
return backlinkPara
786+
}
787+
}
788+
753789
/**
754790
* Get the paragraphs in 'note' which are scheduled for date of the *private* calendar note 'calNote'
755791
* TEST: Is this working in or from Teamspace notes? Initial testing on 20.8.25 by @jgclark implies not.
@@ -765,7 +801,7 @@ export function getReferencedParagraphs(calNote: Note, includeHeadings: boolean
765801

766802
// Use .backlinks, which is described as "Get all backlinks pointing to the current note as Paragraph objects. In this array, the toplevel items are all notes linking to the current note and the 'subItems' attributes (of the paragraph objects) contain the paragraphs with a link to the current note. The headings of the linked paragraphs are also listed here, although they don't have to contain a link."
767803
// Note: @jgclark reckons that the subItem.headingLevel data returned by this might be wrong.
768-
// Note: @EM confirms that this returns backlinks only with indent 0, by design. @jgclark has added a helper to help with this issue.
804+
// Note: @EM confirms that this returns backlinks only with indent 0, by design. resolveBacklinkParaToRealPara() restores correct rawContent/indents.
769805
const backlinkParas: Array<TParagraph> = getFlatListOfBacklinks(calNote) // an array of notes which link to this note
770806
logDebug(`getReferencedParagraphs`, `found ${String(backlinkParas.length)} backlinked paras for ${displayTitle(calNote)}:`)
771807

@@ -776,21 +812,24 @@ export function getReferencedParagraphs(calNote: Note, includeHeadings: boolean
776812
'getReferencedParagraphs',
777813
` - referenced para is null. Found in one of ${backlinkParas.length} backlink paras for note '${displayTitle(calNote)}' at ${calNote.filename}.`,
778814
)
815+
return
779816
}
780817
if (includeHeadings) {
781818
// logDebug(`getReferencedParagraphs`, `- adding "${para.content}" as we want headings`)
819+
wantedParas.push(resolveBacklinkParaToRealPara(para))
782820
} else if (para.content.includes(`>${thisDateStr}`) || para.content.includes(`>today`)) {
783-
logDebug(`getReferencedParagraphs`, `- adding #${para.lineIndex}: '${para.content}' as it includes >${thisDateStr} or >today from ${para.note?.filename ?? '<no note>'}`)
784-
if (!para.note) {
821+
const resolved = resolveBacklinkParaToRealPara(para)
822+
logDebug(`getReferencedParagraphs`, `- adding #${resolved.lineIndex}: '${resolved.content}' as it includes >${thisDateStr} or >today from ${resolved.note?.filename ?? '<no note>'}`)
823+
if (!resolved.note) {
785824
logWarn(`getReferencedParagraphs`, ` - this backlink para.note is null. Para:\n${JSON.stringify(para, null, 2)}`)
786825
}
787826

788827
// Log if content contains TEST. TODO(later): remove after testing backlinks workaround
789-
if (para.content.includes('TEST')) {
790-
logInfo(`getReferencedParagraphs`, `FYI 👉 found TEST in #${para.lineIndex}: '${calNote.filename}' [${para.type}] {${para.rawContent}}`)
828+
if (resolved.content.includes('TEST')) {
829+
logInfo(`getReferencedParagraphs`, `FYI 👉 found TEST in #${resolved.lineIndex}: '${calNote.filename}' [${resolved.type}] {${resolved.rawContent}}`)
791830
}
792831

793-
wantedParas.push(para)
832+
wantedParas.push(resolved)
794833
} else {
795834
// logDebug(`getReferencedParagraphs`, `- skipping "${para.content}" as it doesn't include >${thisDateStr}`)
796835
}

‎helpers/__tests__/NPParagraph.test.js‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,27 @@ describe('NPParagraphs()', () => {
10021002
expect(p.paragraphMatches(paragraph, fieldsObject, fields)).toBe(true)
10031003
})
10041004

1005+
test('should return true when rawContent differs only by leading indent (as backlinks strip indent)', () => {
1006+
const paragraph = { rawContent: '\t* Watch something', filename: 'test.md' }
1007+
const fieldsObject = { rawContent: '* Watch something', filename: 'test.md' }
1008+
const fields = ['rawContent', 'filename']
1009+
expect(p.paragraphMatches(paragraph, fieldsObject, fields)).toBe(true)
1010+
})
1011+
1012+
test('should return true when search rawContent has indent and note does not', () => {
1013+
const paragraph = { rawContent: '* Watch something', filename: 'test.md' }
1014+
const fieldsObject = { rawContent: '\t* Watch something', filename: 'test.md' }
1015+
const fields = ['rawContent', 'filename']
1016+
expect(p.paragraphMatches(paragraph, fieldsObject, fields)).toBe(true)
1017+
})
1018+
1019+
test('should return false when rawContent differs beyond leading indent', () => {
1020+
const paragraph = { rawContent: '\t* Watch something', filename: 'test.md' }
1021+
const fieldsObject = { rawContent: '* Different task', filename: 'test.md' }
1022+
const fields = ['rawContent', 'filename']
1023+
expect(p.paragraphMatches(paragraph, fieldsObject, fields)).toBe(false)
1024+
})
1025+
10051026
test('should throw an error if a field in fields is not present in paragraph', () => {
10061027
const paragraph = { rawContent: 'Test content', filename: 'test.md' }
10071028
const fieldsObject = { rawContent: 'Test content', filename: 'test.md' }
@@ -1029,6 +1050,24 @@ describe('NPParagraphs()', () => {
10291050
expect(p.paragraphMatches(paragraph, fieldsObject, fields)).toBe(true)
10301051
})
10311052
})
1053+
1054+
describe('rawContentMatchesIgnoringLeadingIndent()', () => {
1055+
test('should match identical strings', () => {
1056+
expect(p.rawContentMatchesIgnoringLeadingIndent('* Task', '* Task')).toBe(true)
1057+
})
1058+
1059+
test('should match when one side has leading tab', () => {
1060+
expect(p.rawContentMatchesIgnoringLeadingIndent('\t* Watch something', '* Watch something')).toBe(true)
1061+
})
1062+
1063+
test('should match when one side has leading spaces', () => {
1064+
expect(p.rawContentMatchesIgnoringLeadingIndent(' * Watch something', '* Watch something')).toBe(true)
1065+
})
1066+
1067+
test('should not match different content', () => {
1068+
expect(p.rawContentMatchesIgnoringLeadingIndent('\t* Watch something', '* Other')).toBe(false)
1069+
})
1070+
})
10321071
})
10331072

10341073
// ----------------------------------------------------------------------------

‎jgclark.Dashboard/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ For more details see the [plugin's documentation](https://github.com/NotePlan/pl
88
- TODO: fix long-standing layout bug where some tooltips were getting clipped
99
- TODO: fix isNoteFromAllowedFolder() for teamspace or possibly 2025-W21.md
1010
-->
11+
## [2.4.0.b54] 2026-07-22
12+
- Fix: moving / rescheduling **indented** tasks and checklists no longer fails to find the paragraph. Matching now allows leading-indent differences (NotePlan backlinks return indent-stripped rawContent), and referenced paras are resolved back to the real note paragraph so "Move All → ..." and dialog actions keep correct indent.
13+
- Fix: saying **No** to the bulk-move confirmation (e.g. Move All This Week → Next Week) no longer shows a "something's gone wrong" banner; it only logs that the user cancelled.
14+
1115
## [2.4.0.b53] 2026-07-21
1216
- Fix to layout that was pushing the notelinks down to a third line in some cases
1317
- Change: mid-task and source note links now show a shorter label -- use NotePlan `[alias]([[title]])` when present, drop `#heading`, and truncate titles over 50 characters with `…`

0 commit comments

Comments
 (0)