Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion helpers/NPExtendedRepeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import {
RE_DONE_DATE_TIME_CAPTURES,
RE_DONE_DATE_OPT_TIME,
RE_ISO_DATE,
RE_NP_WEEK_SPEC,
RE_NP_MONTH_SPEC,
RE_NP_QUARTER_SPEC,
RE_NP_YEAR_SPEC,
} from '@helpers/dateTime'
import { JSP, logDebug, logError, logInfo, logWarn } from '@helpers/dev'
import { calcOffsetDateStr, getFirstDateInPeriod } from '@helpers/NPdateTime'
Expand All @@ -41,7 +45,10 @@ const LOG_CONTEXT = 'extendedRepeat'
//------------------------------------------------------------------
// Regexes + config type + settings + date math

const EXTENDED_REPEAT_STR: string = `@repeat\\(${RE_DATE_INTERVAL}\\)` // find @repeat()
// A concrete @repeat(...) base date can be given in any NP calendar-note date-spec: day, week, month, quarter or year
const CONCRETE_REPEAT_DATE_STR: string = `(?:${RE_ISO_DATE}|${RE_NP_WEEK_SPEC}|${RE_NP_MONTH_SPEC}|${RE_NP_QUARTER_SPEC}|${RE_NP_YEAR_SPEC})`
const RE_CONCRETE_REPEAT_DATE_FULL: RegExp = new RegExp(`^${CONCRETE_REPEAT_DATE_STR}$`) // validate a standalone date-spec string
const EXTENDED_REPEAT_STR: string = `@repeat\\(${RE_DATE_INTERVAL}(?:,\\s*${CONCRETE_REPEAT_DATE_STR})?\\)` // find @repeat()
export const RE_EXTENDED_REPEAT: RegExp = new RegExp(EXTENDED_REPEAT_STR) // find @repeat()
const EXTENDED_REPEAT_CAPTURE_STR: string = `@repeat\\((.*?)\\)` // find @repeat() and return part inside brackets
export const RE_EXTENDED_REPEAT_CAPTURE: RegExp = new RegExp(EXTENDED_REPEAT_CAPTURE_STR) // find @repeat() and return part inside brackets
Expand Down Expand Up @@ -88,6 +95,18 @@ export function generateNewRepeatDate(noteToUse: CoreNoteFields, currentContent:
const reRepeatArray = currentContent.match(RE_EXTENDED_REPEAT_CAPTURE) ?? []
let dateIntervalString: string = reRepeatArray.length > 0 ? reRepeatArray[1] : ''

let concreteBaseDate: string = ''
if (dateIntervalString.includes(',')) {
const [intervalPart, datePart] = dateIntervalString.split(',').map((s) => s.trim())
// Only accept the split if the interval part is a valid interval (guards against malformed @repeat tags)
if (new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalPart)) {
dateIntervalString = intervalPart
if (datePart && RE_CONCRETE_REPEAT_DATE_FULL.test(datePart)) {
concreteBaseDate = datePart
}
}
}

let outputTimeframe = 'day'
if (currentContent.match(RE_SCHEDULED_DAILY_NOTE_LINK) || isDailyNote(noteToUse)) {
outputTimeframe = 'day'
Expand All @@ -110,6 +129,18 @@ export function generateNewRepeatDate(noteToUse: CoreNoteFields, currentContent:
return completedDate
}

if (concreteBaseDate.length > 0) {
if (dateIntervalString.startsWith('+')) {
// + prefix takes precedence: concrete date is immaterial, fall through to completion-date branch
logWarn('generateNewRepeatDate', `- concrete base date ${concreteBaseDate} ignored because + prefix takes precedence; scheduling from completion date`)
} else {
// No + prefix: concrete date wins over scheduled/completion date
newRepeatDateStr = calcOffsetDateStr(concreteBaseDate, dateIntervalString, outputTimeframe)
logDebug('generateNewRepeatDate', `- adding from concrete base date ${concreteBaseDate} -> ${newRepeatDateStr}`)
return newRepeatDateStr
}
}

if (dateIntervalString.startsWith('+')) {
dateIntervalString = dateIntervalString.substring(1, dateIntervalString.length)
newRepeatDateStr = calcOffsetDateStr(completedDate, dateIntervalString, outputTimeframe)
Expand Down Expand Up @@ -218,6 +249,21 @@ export async function generateRepeatForPara(
newRepeatContent = newRepeatContent.replace(new RegExp(RE_DONE_DATE_OPT_TIME.source, 'gi'), '')
newRepeatContent = stripTaskMarkersFromString(newRepeatContent)
newRepeatContent = textWithoutSyncedCopyTag(newRepeatContent).trim()

// If the @repeat tag contains a concrete base date, advance it by the same interval so future
// completions continue to calculate from the correct anchor (as a daily ISO date, independent
// of the outputTimeframe used for the scheduled >date marker).
const capturedRepeat = origPara.content.match(RE_EXTENDED_REPEAT_CAPTURE)
if (capturedRepeat && capturedRepeat[1].includes(',')) {
const [intervalOnly, oldConcreteDate] = capturedRepeat[1].split(',').map((s) => s.trim())
if (oldConcreteDate && RE_CONCRETE_REPEAT_DATE_FULL.test(oldConcreteDate) && new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalOnly.replace(/^\+/, ''))) {
const intervalForCalc = intervalOnly.startsWith('+') ? intervalOnly.substring(1) : intervalOnly
const newConcreteDate = calcOffsetDateStr(oldConcreteDate, intervalForCalc, 'base')
newRepeatContent = newRepeatContent.replace(`@repeat(${capturedRepeat[1]})`, `@repeat(${intervalOnly}, ${newConcreteDate})`)
logDebug('generateRepeatForPara', `- advanced concrete base date from ${oldConcreteDate} to ${newConcreteDate}`)
}
}

logDebug('generateRepeatForPara', `- newRepeatContent: "${newRepeatContent}"`)

const insertInOpenEditor =
Expand Down Expand Up @@ -350,6 +396,18 @@ export async function generateRepeatForCancelledPara(
.replace(/^\s*?\+\s\[+\]\s/, '')
newRepeatContent = textWithoutSyncedCopyTag(newRepeatContent).trim()

// Advance the concrete base date in @repeat(...) if present
const capturedRepeat = origPara.content.match(RE_EXTENDED_REPEAT_CAPTURE)
if (capturedRepeat && capturedRepeat[1].includes(',')) {
const [intervalOnly, oldConcreteDate] = capturedRepeat[1].split(',').map((s) => s.trim())
if (oldConcreteDate && RE_CONCRETE_REPEAT_DATE_FULL.test(oldConcreteDate) && new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalOnly.replace(/^\+/, ''))) {
const intervalForCalc = intervalOnly.startsWith('+') ? intervalOnly.substring(1) : intervalOnly
const newConcreteDate = calcOffsetDateStr(oldConcreteDate, intervalForCalc, 'base')
newRepeatContent = newRepeatContent.replace(`@repeat(${capturedRepeat[1]})`, `@repeat(${intervalOnly}, ${newConcreteDate})`)
logDebug('generateRepeatForCancelledPara', `- advanced concrete base date from ${oldConcreteDate} to ${newConcreteDate}`)
}
}

let newPara: TParagraph
if (noteIsOpenInEditor) {
await Editor.insertParagraphBeforeParagraph(`${newRepeatContent} >${newRepeatDateStr}`, origPara, 'open')
Expand Down
96 changes: 95 additions & 1 deletion helpers/__tests__/NPExtendedRepeat.generateRepeatForPara.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* global describe, expect, test, beforeAll, beforeEach, jest */
import moment from 'moment'
import { DataStore, Editor, CommandBar, NotePlan, Note, Paragraph } from '@mocks/index'
import { generateRepeatForPara } from '../NPExtendedRepeat'
import { generateRepeatForPara, generateRepeatForCancelledPara } from '../NPExtendedRepeat'

const repeatConfig = {
deleteCompletedRepeat: false,
Expand Down Expand Up @@ -233,3 +233,97 @@ describe('NPExtendedRepeat generateRepeatForPara', () => {
expect(Editor.save).not.toHaveBeenCalled()
})
})

describe('NPExtendedRepeat generateRepeatForPara: concrete @repeat(...) base date is advanced in the new repeat', () => {
async function expectAdvancedConcreteDate(repeatTag: string, expectedRepeatTag: string) {
const taskLine = `* [x] Task @done(2026-05-20 09:00 AM) @repeat(${repeatTag})`
const origPara = new Paragraph({
type: 'done',
content: taskLine.slice(5),
lineIndex: 1,
rawContent: taskLine,
})
const origNote = new Note({
type: 'Notes',
filename: 'Projects/chores.md',
paragraphs: [
new Paragraph({ type: 'title', content: 'Chores', headingLevel: 1, lineIndex: 0 }),
origPara,
],
})
origPara.note = origNote
origNote.resetLineIndexesAndContent()

const newPara = await generateRepeatForPara(origPara, origNote, repeatConfig, false)
expect(newPara).not.toBeNull()
const inserted = origNote.paragraphs[1]
expect(inserted.content).toContain(`@repeat(${expectedRepeatTag})`)
}

test('day-spec: @repeat(1m, 2024-01-15) -> @repeat(1m, 2024-02-15)', async () => {
await expectAdvancedConcreteDate('1m, 2024-01-15', '1m, 2024-02-15')
})

test('week-spec: @repeat(1w, 2026-W19) -> @repeat(1w, 2026-W20)', async () => {
await expectAdvancedConcreteDate('1w, 2026-W19', '1w, 2026-W20')
})

test('month-spec: @repeat(1m, 2026-05) -> @repeat(1m, 2026-06)', async () => {
await expectAdvancedConcreteDate('1m, 2026-05', '1m, 2026-06')
})

test('quarter-spec: @repeat(1q, 2026-Q2) -> @repeat(1q, 2026-Q3)', async () => {
await expectAdvancedConcreteDate('1q, 2026-Q2', '1q, 2026-Q3')
})

test('year-spec: @repeat(1y, 2026) -> @repeat(1y, 2027)', async () => {
await expectAdvancedConcreteDate('1y, 2026', '1y, 2027')
})

test('week-spec year end: @repeat(1w, 2026-W53) -> @repeat(1w, 2027-W01)', async () => {
await expectAdvancedConcreteDate('1w, 2026-W53', '1w, 2027-W01')
})

test('+ prefix is retained and the concrete date still advances: @repeat(+1w, 2026-W19) -> @repeat(+1w, 2026-W20)', async () => {
await expectAdvancedConcreteDate('+1w, 2026-W19', '+1w, 2026-W20')
})
})

describe('NPExtendedRepeat generateRepeatForCancelledPara: concrete @repeat(...) base date is advanced in the new repeat', () => {
async function expectAdvancedConcreteDate(repeatTag: string, expectedRepeatTag: string) {
const taskLine = `* [-] Task @repeat(${repeatTag})`
const origPara = new Paragraph({
type: 'cancelled',
content: taskLine,
lineIndex: 1,
rawContent: taskLine,
})
const origNote = new Note({
type: 'Notes',
filename: 'Projects/chores.md',
paragraphs: [
new Paragraph({ type: 'title', content: 'Chores', headingLevel: 1, lineIndex: 0 }),
origPara,
],
})
origPara.note = origNote
origNote.resetLineIndexesAndContent()

const newPara = await generateRepeatForCancelledPara(origPara, origNote, false)
expect(newPara).not.toBeNull()
const inserted = origNote.paragraphs.find((p) => p.content.includes('@repeat('))
expect(inserted?.content).toContain(`@repeat(${expectedRepeatTag})`)
}

test('day-spec: @repeat(1m, 2024-01-15) -> @repeat(1m, 2024-02-15)', async () => {
await expectAdvancedConcreteDate('1m, 2024-01-15', '1m, 2024-02-15')
})

test('week-spec: @repeat(1w, 2026-W19) -> @repeat(1w, 2026-W20)', async () => {
await expectAdvancedConcreteDate('1w, 2026-W19', '1w, 2026-W20')
})

test('month-spec: @repeat(1m, 2026-05) -> @repeat(1m, 2026-06)', async () => {
await expectAdvancedConcreteDate('1m, 2026-05', '1m, 2026-06')
})
})
4 changes: 4 additions & 0 deletions jgclark.RepeatExtensions/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# What's changed in 🔁 Repeat Extensions plugin?
Please see the [Readme for this plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.RepeatExtensions) for more details, including the available settings. For this plugin to work, **you need to have the 'Append Completion Date' setting turned on in Preferences > Todo**.

## [1.2.0] - 2026-07-30
- New: `@repeat(interval, <date>)` now supports a concrete base date, written using any of NotePlan's calendar-note date formats — day (`YYYY-MM-DD`), week (`YYYY-Wnn`), month (`YYYY-MM`), quarter (`YYYY-Qn`) or year (`YYYY`). This works the same whether the task is in a project note or a calendar note. When present, the next repeat is calculated from that fixed date rather than from the task's scheduled date or completion date. The `+` prefix (e.g. `@repeat(+1m, 2026-05-12)`) still takes precedence, in which case the concrete date is ignored for scheduling.
- After each completion or cancellation, the concrete date is advanced by the same interval and keeps its original format (e.g. `2026-W19` → `2026-W20`), including correctly across 52- and 53-week year boundaries.

## [1.1.4] - 2026-05-21
- Now runs task sorter after repeats are generated from trigger.
- When task sort cannot run because there is no `##` section heading above the repeat, logs an INFO message explaining why.
Expand Down
29 changes: 29 additions & 0 deletions jgclark.RepeatExtensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@ You can specify scheduled dates to all the other calendar note types supported b

The resulting repeat lines will also be specified using that same note type, and will write to the appropriate new calendar note (unless it came from a project note, in which case it will stay in the same project note).

## Specifying a Concrete Base Date
As well as an interval, `@repeat(...)` can also take a **concrete base date** to repeat from: `@repeat(interval, <date>)`. When present, the next repeat is calculated from that fixed date, rather than from the task's scheduled date or completion date. After each completion (or cancellation) the date is automatically advanced by the interval, so it keeps in step for future cycles.

`<date>` can be given in any of NotePlan's calendar-note date formats, and works the same whether the task is in a project note or a calendar note:
- day: `YYYY-MM-DD`
- week: `YYYY-Wnn`
- month: `YYYY-MM`
- quarter: `YYYY-Qn`
- year: `YYYY`

For example, the next repeat is always calculated from the base date — not the completion date, and not the scheduled `>date` either, even if the task had been rescheduled away from its base date:
```
* project reporting @repeat(1m, 2026-05-13) >2026-05-17
(completed on 2026-05-20)
* project reporting @repeat(1m, 2026-06-13) >2026-06-13
```
```
* do expenses @repeat(1m, 2026-05-12)
(completed on 2026-05-20)
* do expenses @repeat(1m, 2026-06-12) >2026-06-12
```
```
* review backlog @repeat(1w, 2026-W19)
(completed on 2026-05-20)
* review backlog @repeat(1w, 2026-W20) >2026-W20
```

If the interval has a `+` prefix (e.g. `@repeat(+1m, 2026-05-12)`), the `+` takes precedence as usual: the next repeat is scheduled from the completion/cancellation date, and the concrete date is ignored for scheduling (though it's still advanced in the new task).

## Configuration
For Repeat Extensions plugin to work, **you need to have the 'Append Completion Date' setting turned on in Preferences > Todo**, and not to mind the time portion of the `@done(...)` tag being removed, as a sign that the line has been processed.

Expand Down
Loading
Loading