diff --git a/flow-typed/Noteplan.js b/flow-typed/Noteplan.js index 963407322..905dc0fe3 100644 --- a/flow-typed/Noteplan.js +++ b/flow-typed/Noteplan.js @@ -2713,7 +2713,7 @@ declare class HTMLView { * @param { Object } options - (optional) Configuration options: * - splitView: boolean - Show as split view (true) or in main content area (false, default) * - id/customId/customID: String - Unique identifier for reusing the same view - * - icon: string - FontAwesome icon string for the navigation bar. Note: currently doesn't support setting the font type, and always uses "fa-regular" + * - icon: string - FontAwesome icon string for the navigation bar. Note: currently doesn't support setting the font type, and always uses "fa-regular". Needs to be the full name, so will include the "fa-" prefix. * - iconColor: string - Tailwind color name (e.g., "blue-500") or hex color (e.g., "#3b82f6") * - autoTopPadding: boolean - Auto-add top padding for navigation bar (default: true) * - showReloadButton: boolean - Show a reload button in the navigation bar (default: false) diff --git a/helpers/HTMLView.js b/helpers/HTMLView.js index 1176f64d0..3dc21ddff 100644 --- a/helpers/HTMLView.js +++ b/helpers/HTMLView.js @@ -146,6 +146,7 @@ export async function getNoteContentAsHTML(content: string, note: TNote): Promis // Make some necessary changes before conversion to HTML for (let i = 0; i < lines.length; i++) { // remove any sync link markers (blockIds) + // TODO: there's a helper function for this, I think. lines[i] = lines[i].replace(/\^[A-z0-9]{6}([^A-z0-9]|$)/g, '').trimRight() // change open tasks to GFM-flavoured task syntax @@ -716,10 +717,8 @@ export async function sendToHTMLWindow(windowId: string, actionType: string, dat const windowExists = isHTMLWindowOpen(windowId) if (!windowExists) logWarn(`sendToHTMLWindow`, `Window ${windowId} does not exist; setting NPWindowID = undefined`) - // runJavaScript expects the window's internal id; resolve customId to actual id when present - // TEST: this change identified by Cursor // TEST: Not sure the comment about iphone/ipad is still relevant, but leaving it in for now. - const windowIdToSend = windowExists ? (getWindowIdFromCustomId(windowId) || windowId) : undefined // for iphone/ipad you have to send undefined + const windowIdToSend = windowExists ? windowId : undefined // for iphone/ipad you have to send undefined const dataWithUpdated = { ...data, @@ -831,9 +830,11 @@ export async function updateGlobalSharedData(windowId: string, data: any, mergeD newData = data } // logDebug(`updateGlobalSharedData`, `writing globalSharedData (merged=${String(mergeData)}) to ${JSON.stringify(newData)}`) - const code = `${varName} = JSON.parse(${JSON.stringify(newData)});` + // JSON.stringify output is valid JS literal syntax; do NOT use JSON.parse(${JSON.stringify(...)}) — that + // inlines an object literal so JSON.parse receives an Object and throws ("[object Object]" is not valid JSON). + const code = `${varName} = ${JSON.stringify(newData)};` logDebug(pluginJson, `updateGlobalSharedData code=\n${code}\n`) - logDebug(pluginJson, `updateGlobalSharedData FIXME: Is this still throwing an error? ^^^`) + logDebug(pluginJson, `updateGlobalSharedData: ${varName} assigned via JSON literal (not JSON.parse(interpolation))`) return await HTMLView.runJavaScript(code, windowId) } diff --git a/helpers/NPCalendar.js b/helpers/NPCalendar.js index 9db30e48f..b40406acb 100644 --- a/helpers/NPCalendar.js +++ b/helpers/NPCalendar.js @@ -17,6 +17,7 @@ import { addMinutes, differenceInMinutes } from 'date-fns' import { keepTodayPortionOnly, RE_EVENT_ID } from './calendar' import { + convertISOToYYYYMMDD, getDateFromYYYYMMDDString, getISODateStringFromYYYYMMDD, type HourMinObj, @@ -347,25 +348,27 @@ async function createEventFromDateRange(eventTitle: string, dateRange: DateRange } /** - * Get list of events for the given day (specified as YYYYMMDD). - * Now also filters out any that don't come from one of the calendars specified - * in calendarSet. + * Get list of events for the given day (specified as YYYYMMDD or YYYY-MM-DD). + * Now also filters out any that don't come from one of the calendars specified in calendarSet. * @author @jgclark * - * @param {string} dateStr YYYYMMDD date to use + * @param {string} dateStr YYYYMMDD or ISO date string or YYYY-Wnn date to use * @param {Array} calendarSet optional list of calendars * @param {HourMinObj} start optional start time in the day * @param {HourMinObj} end optional end time in the day + * @param {boolean} includeAllDayEvents optional include all-day events (default: false) * @return {Array} array of events as CalendarItems */ export async function getEventsForDay( - dateStr: string, + dateStrIn: string, calendarSet: Array = [], start: HourMinObj = { h: 0, m: 0 }, end: HourMinObj = { h: 23, m: 59 }, + includeAllDayEvents: boolean = false, ): Promise | null> { try { - // logDebug('NPCalendar / getEventsForDay', `starting with ${dateStr} ${calendarSet.toString()}`) + logDebug('NPCalendar / getEventsForDay', `starting with ${dateStrIn} ${calendarSet.toString()}`) + const dateStr = convertISOToYYYYMMDD(dateStrIn) clo(calendarSet) const y = parseInt(dateStr.slice(0, 4)) const m = parseInt(dateStr.slice(4, 6)) @@ -375,10 +378,16 @@ export async function getEventsForDay( // logDebug('NPCalendar / getEventsForDay', `starting for period ${startOfDay.toString()} - ${endOfDay.toString()}`) let eArr: Array = await Calendar.eventsBetween(startOfDay, endOfDay) const allEventCount = eArr.length + // logDebug('NPCalendar / getEventsForDay', `- ${allEventCount} events found (before filtering)`) // Filter out parts of multi-day events not in today eArr = keepTodayPortionOnly(eArr, getDateFromYYYYMMDDString(dateStr) ?? new Date()) + // Filter out all-day events if not wanted + if (!includeAllDayEvents) { + eArr = eArr.filter((e) => !e.isAllDay) + } + // logDebug('NPCalendar / getEventsForDay', `- ${eArr.length} events kept (after filtering out all-day events)`) // If we have a calendarSet list, use to weed out events that don't match .calendar if (calendarSet && calendarSet.length > 0) { eArr = eArr.filter((e) => calendarSet.some((c) => e.calendar === c)) diff --git a/helpers/NPThemeToCSS.js b/helpers/NPThemeToCSS.js index 246858e9d..77699c5b5 100644 --- a/helpers/NPThemeToCSS.js +++ b/helpers/NPThemeToCSS.js @@ -1,7 +1,7 @@ // @flow // --------------------------------------------------------- // HTML helper functions to create CSS from NP Themes -// by @jgclark +// by @jgclark, last updated 2026-05-12 // --------------------------------------------------------- import { clo, logDebug, logError, logInfo, logWarn, JSP } from '@helpers/dev' @@ -727,6 +727,26 @@ export function mixHexColors(color1: string, color2: string): string { return `#${mixedValues.join('')}` } +/** + * Convert a NotePlan theme font family segment (before the '-' weight/style suffix) + * to the string used in CSS `font-family`. Splits PascalCase / TitleCase into words + * (e.g. AvenirNext → Avenir Next) without inserting a space before every capital letter. + * IBM Plex theme ids (e.g. IBMPlexSans) map to spaced CSS names (e.g. IBM Plex Sans). + * IBMPlexSansCond maps to IBM Plex Sans Condensed (regex alone yields IBM Plex Sans Cond). + * @author @jgclark + * @param {string} namePartNoSpaces + * @returns {string} + */ +function notePlanFamilySegmentToCssFontFamily(namePartNoSpaces: string): string { + if (namePartNoSpaces === 'IBMPlexSansCond') { + return 'IBM Plex Sans Condensed' + } + return namePartNoSpaces + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .trim() +} + /** * Translate from the font name, as used in the NP Theme file, * to the form CSS is expecting. @@ -771,23 +791,14 @@ export function fontPropertiesFromNP(fontNameNP: string): Array { } // Not a special. So now split input string into parts either side of '-' - // and then insert spaces before capital letters + // and map the family segment to CSS `font-family` wording let translatedFamily: string let translatedWeight: string = '400' let translatedStyle: string = 'normal' const splitParts = fontNameNP.split('-') const namePartNoSpaces = splitParts[0] - let namePartSpaced = '' const modifierLC = splitParts.length > 0 ? splitParts[1]?.toLowerCase() : '' - for (let i = 0; i < namePartNoSpaces.length; i++) { - const c = namePartNoSpaces[i] - if (c.match(/[A-Z]/)) { - namePartSpaced += ` ${c}` - } else { - namePartSpaced += c - } - } - translatedFamily = namePartSpaced.trim() + translatedFamily = notePlanFamilySegmentToCssFontFamily(namePartNoSpaces) // logDebug('fontPropertiesFromNP', `family -> ${translatedFamily}`) // Using the numeric font-weight system @@ -801,8 +812,18 @@ export function fontPropertiesFromNP(fontNameNP: string): Array { translatedWeight = '300' break } + // No standard here; going for most popular non-bold weight: 400 case 'book': { - translatedWeight = '500' + translatedWeight = '400' + break + } + // No standard here; some are 375, others 450; use 400 as closest widely-supported weight + case 'text': { + translatedWeight = '400' + break + } + case 'medium': { + translatedWeight = '400' break } case 'demi-bold': { @@ -821,6 +842,11 @@ export function fontPropertiesFromNP(fontNameNP: string): Array { translatedWeight = '600' break } + case 'semibolditalic': { + translatedWeight = '600' + translatedStyle = 'italic' + break + } case 'bold': { translatedWeight = '700' break diff --git a/helpers/NPWindows.js b/helpers/NPWindows.js index 4a894a0a7..b8acbd04b 100644 --- a/helpers/NPWindows.js +++ b/helpers/NPWindows.js @@ -259,6 +259,9 @@ export function isHTMLWindowOpen(customId: string): boolean { /** * Is a given note open in a NP Editor window/split, based on its filename? + * Supports Teamspace notes. + * Note: see related isEditorWindowOpenByTitle() in same file. + * @test Manualy tested by JGC 31.3.2026 * @author @jgclark * @param {string} filename to look for * @returns {boolean} @@ -267,7 +270,28 @@ export function isEditorWindowOpen(filename: string): boolean { // Get list of open Editor windows/splits const allEditorWindows = NotePlan.editors for (const thisEditorWindow of allEditorWindows) { - if (thisEditorWindow.filename === filename) { + if (caseInsensitiveMatch(filename, thisEditorWindow.filename)) { + return true + } + } + return false +} + +/** + * Is a given note open in a NP Editor window/split, based on its title? + * Supports Calendar and Teamspace notes. + * Note: see related isEditorWindowOpen() in same file. + * @test Manualy tested by JGC 31.3.2026 + * @author @jgclark + * @param {string} wantedTitle to look for + * @returns {boolean} + */ +export function isEditorWindowOpenByTitle(wantedTitle: string): boolean { + // Get list of open Editor windows/splits + const allEditorWindows = NotePlan.editors ?? [] + logDebug('isEditorWindowOpenByTitle', `Looking for match in ${String(allEditorWindows.length)} Editors`) + for (const thisEditorWindow of allEditorWindows) { + if (caseInsensitiveMatch(wantedTitle, thisEditorWindow.title ?? '')) { return true } } @@ -687,17 +711,20 @@ export function getWindowFromId(windowId: string): TEditor | HTMLView | false { * @returns {TEditor | HTMLView | false} the matching window object or false if not found */ export function getWindowFromCustomId(windowCustomId: string): TEditor | HTMLView | false { - // First loop over all Editor windows + // First loop over all Editor windows (match rules aligned with getWindowIdFromCustomId) const allEditorWindows = NotePlan.editors for (const thisWindow of allEditorWindows) { - if (thisWindow.customId === windowCustomId) { + if ( + caseInsensitiveMatch(windowCustomId, thisWindow.customId) || + caseInsensitiveStartsWith(windowCustomId, thisWindow.customId) + ) { return thisWindow } } // And if not found so far, then all HTML windows const allHTMLWindows = NotePlan.htmlWindows for (const thisWindow of allHTMLWindows) { - if (thisWindow.customId === windowCustomId) { + if (caseInsensitiveMatch(windowCustomId, thisWindow.customId)) { return thisWindow } } @@ -710,18 +737,21 @@ export function getWindowFromCustomId(windowCustomId: string): TEditor | HTMLVie * @param {string} windowCustomId */ export function closeWindowFromCustomId(windowCustomId: string): void { - // First loop over all Editor windows + // First loop over all Editor windows (match rules aligned with getWindowIdFromCustomId) let thisWin: TEditor | HTMLView const allEditorWindows = NotePlan.editors for (const thisWindow of allEditorWindows) { - if (thisWindow.customId === windowCustomId) { + if ( + caseInsensitiveMatch(windowCustomId, thisWindow.customId) || + caseInsensitiveStartsWith(windowCustomId, thisWindow.customId) + ) { thisWin = thisWindow } } // And if not found so far, then all HTML windows const allHTMLWindows = NotePlan.htmlWindows for (const thisWindow of allHTMLWindows) { - if (thisWindow.customId === windowCustomId) { + if (caseInsensitiveMatch(windowCustomId, thisWindow.customId)) { thisWin = thisWindow } } diff --git a/helpers/__tests__/NPThemeToCSS.test.js b/helpers/__tests__/NPThemeToCSS.test.js index 9acff0e79..d6bde7772 100644 --- a/helpers/__tests__/NPThemeToCSS.test.js +++ b/helpers/__tests__/NPThemeToCSS.test.js @@ -94,7 +94,35 @@ describe(`${FILE}`, () => { }) test("input 'Charter-Book'", () => { const res = t.fontPropertiesFromNP('Charter-Book') - expect(res).toEqual(['font-family: "Charter"', 'font-weight: 500', 'font-style: "normal"']) + expect(res).toEqual(['font-family: "Charter"', 'font-weight: 400', 'font-style: "normal"']) + }) + test("input 'IBMPlexSans' keeps IBM Plex theme id (not spaced per-letter)", () => { + const res = t.fontPropertiesFromNP('IBMPlexSans') + expect(res).toEqual(['font-family: "IBM Plex Sans"', 'font-weight: 400', 'font-style: "normal"']) + }) + test("input 'IBMPlexSans-SemiBold' keeps family id; weight from suffix", () => { + const res = t.fontPropertiesFromNP('IBMPlexSans-SemiBold') + expect(res).toEqual(['font-family: "IBM Plex Sans"', 'font-weight: 600', 'font-style: "normal"']) + }) + test("input 'IBMPlexSerif-Medium' keeps family id; weight from suffix", () => { + const res = t.fontPropertiesFromNP('IBMPlexSerif-Medium') + expect(res).toEqual(['font-family: "IBM Plex Serif"', 'font-weight: 400', 'font-style: "normal"']) + }) + test("input 'IBMPlexSans-Text'", () => { + const res = t.fontPropertiesFromNP('IBMPlexSans-Text') + expect(res).toEqual(['font-family: "IBM Plex Sans"', 'font-weight: 400', 'font-style: "normal"']) + }) + test("input 'IBMPlexMono-BoldItalic'", () => { + const res = t.fontPropertiesFromNP('IBMPlexMono-BoldItalic') + expect(res).toEqual(['font-family: "IBM Plex Mono"', 'font-weight: 700', 'font-style: "italic"']) + }) + test("input 'IBMPlexSansCond-Regular'", () => { + const res = t.fontPropertiesFromNP('IBMPlexSansCond-Regular') + expect(res).toEqual(['font-family: "IBM Plex Sans Condensed"', 'font-weight: 400', 'font-style: "normal"']) + }) + test("input 'IBMPlexCondensed-SemiBoldItalic'", () => { + const res = t.fontPropertiesFromNP('IBMPlexSansCond-SemiBoldItalic') + expect(res).toEqual(['font-family: "IBM Plex Sans Condensed"', 'font-weight: 600', 'font-style: "italic"']) }) }) diff --git a/helpers/__tests__/dateTime.test.js b/helpers/__tests__/dateTime.test.js index bf739e4ac..a4d831744 100644 --- a/helpers/__tests__/dateTime.test.js +++ b/helpers/__tests__/dateTime.test.js @@ -1383,4 +1383,46 @@ describe(`${PLUGIN_NAME}`, () => { }) }) }) + + describe('getNextNPPeriodString()' /* function */, () => { + test('2024-W52 + week -> 2025-W01', () => { + expect(dt.getNextNPPeriodString('2024-W52', 'week')).toEqual('2025-W01') + }) + test('2026-03-27 + day -> 2026-03-28', () => { + expect(dt.getNextNPPeriodString('2026-03-27', 'day')).toEqual('2026-03-28') + }) + test('2026-12 + month -> 2027-01', () => { + expect(dt.getNextNPPeriodString('2026-12', 'month')).toEqual('2027-01') + }) + test('2024-Q4 + quarter -> 2025-Q1', () => { + expect(dt.getNextNPPeriodString('2024-Q4', 'quarter')).toEqual('2025-Q1') + }) + test('2024Q4 + quarter compact in -> 2025Q1 out', () => { + expect(dt.getNextNPPeriodString('2024Q4', 'quarter')).toEqual('2025Q1') + }) + test('2024 + year -> 2025', () => { + expect(dt.getNextNPPeriodString('2024', 'year')).toEqual('2025') + }) + }) + + describe('getPreviousNPPeriodString()' /* function */, () => { + test('2025-W01 - week -> 2024-W52', () => { + expect(dt.getPreviousNPPeriodString('2025-W01', 'week')).toEqual('2024-W52') + }) + test('2026-03-28 - day -> 2026-03-27', () => { + expect(dt.getPreviousNPPeriodString('2026-03-28', 'day')).toEqual('2026-03-27') + }) + test('2027-01 - month -> 2026-12', () => { + expect(dt.getPreviousNPPeriodString('2027-01', 'month')).toEqual('2026-12') + }) + test('2025-Q1 - quarter -> 2024-Q4', () => { + expect(dt.getPreviousNPPeriodString('2025-Q1', 'quarter')).toEqual('2024-Q4') + }) + test('2025Q1 - quarter compact in -> 2024Q4 out', () => { + expect(dt.getPreviousNPPeriodString('2025Q1', 'quarter')).toEqual('2024Q4') + }) + test('2025 - year -> 2024', () => { + expect(dt.getPreviousNPPeriodString('2025', 'year')).toEqual('2024') + }) + }) }) diff --git a/helpers/__tests__/npBridgeResolve.test.js b/helpers/__tests__/npBridgeResolve.test.js new file mode 100644 index 000000000..19469149e --- /dev/null +++ b/helpers/__tests__/npBridgeResolve.test.js @@ -0,0 +1,90 @@ +/* eslint-disable no-undef */ +/** + * @jest-environment node + */ +// @flow + +import { + awaitBridgedValue, + awaitDataStoreBridgeValue, + awaitDataStoreProp, + awaitTopLevelApiProp, + isDataStorePropSyncPlainObject, + isTopLevelApiPropSyncPlainObject, +} from '../npBridgeResolve' + +describe('npBridgeResolve', () => { + const originalDS = global.DataStore + const originalCal = global.Calendar + + afterEach(() => { + global.DataStore = originalDS + global.Calendar = originalCal + }) + + test('awaitTopLevelApiProp reads plain object from arbitrary namespace', async () => { + const Calendar = { events: [{ id: '1' }] } + const e = await awaitTopLevelApiProp(Calendar, 'events') + expect(e).toEqual([{ id: '1' }]) + }) + + test('awaitTopLevelApiProp invokes method with namespace as this', async () => { + const Calendar = { + items: function () { + return this._items + }, + _items: [1, 2], + } + const v = await awaitTopLevelApiProp(Calendar, 'items') + expect(v).toEqual([1, 2]) + }) + + test('awaitDataStoreProp delegates to generic helper', async () => { + global.DataStore = { settings: { _logLevel: 'DEBUG' } } + const s = await awaitDataStoreProp('settings') + expect(s._logLevel).toBe('DEBUG') + }) + + test('awaitDataStoreProp awaits thenable', async () => { + global.DataStore = { + folders: Promise.resolve(['a/']), + } + const f = await awaitDataStoreProp('folders') + expect(f).toEqual(['a/']) + }) + + test('awaitDataStoreProp invokes function accessor', async () => { + global.DataStore = { + teamspaces: function () { + return [{ title: 'T1' }] + }, + } + const t = await awaitDataStoreProp('teamspaces') + expect(t).toEqual([{ title: 'T1' }]) + }) + + test('awaitBridgedValue passes thisArg for nested function returning promise', async () => { + global.DataStore = {} + const api = { + x: function () { + return Promise.resolve(42) + }, + } + const v = await awaitBridgedValue(api.x, api) + expect(v).toBe(42) + }) + + test('awaitDataStoreBridgeValue follows function returning promise', async () => { + global.DataStore = {} + const v = await awaitDataStoreBridgeValue(() => Promise.resolve(42)) + expect(v).toBe(42) + }) + + test('isTopLevelApiPropSyncPlainObject / DataStore wrapper', () => { + global.DataStore = { settings: { a: 1 } } + expect(isDataStorePropSyncPlainObject('settings')).toBe(true) + expect(isTopLevelApiPropSyncPlainObject(DataStore, 'settings')).toBe(true) + global.DataStore = { x: Promise.resolve(1) } + expect(isDataStorePropSyncPlainObject('x')).toBe(false) + }) +}) diff --git a/helpers/dateTime.js b/helpers/dateTime.js index 7a612a79b..bcad377cd 100644 --- a/helpers/dateTime.js +++ b/helpers/dateTime.js @@ -1248,6 +1248,132 @@ export function calcOffsetDate(baseDateStrIn: string, interval: string): Date | } } +/** + * Calendar period immediately after the given NotePlan period (same string family as note titles). + * Uses `calcOffsetDate` so week rollover matches ISO week handling (e.g. 2024-W52 → 2025-W01). + * @param {string} periodStringIn - e.g. YYYY-MM-DD, YYYY-Www, YYYY-MM, YYYY-Qn (or compact version YYYYQn), YYYY + * @param {string} periodType - 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} next period title, or '' if parsing fails + */ +export function getNextNPPeriodString(periodStringIn: string, periodType: string): string { + try { + const trimmed = periodStringIn.trim() + const wantCompactQuarter = /^(\d{4})Q([1-4])$/i.test(trimmed) + let periodString = trimmed + const compactQ = trimmed.match(/^(\d{4})Q([1-4])$/i) + if (compactQ) { + periodString = `${compactQ[1]}-Q${compactQ[2]}` + } + const intervalByType: { [string]: string } = { + day: '+1d', + week: '+1w', + month: '+1m', + quarter: '+1q', + year: '+1y', + } + const interval = intervalByType[periodType] + if (!interval) { + logError('dateTime / getNextNPPeriodString', `Unknown periodType '${periodType}'`) + return '' + } + const newDate = calcOffsetDate(periodString, interval) + if (!newDate) { + return '' + } + let momentDateFormat = '' + if (periodString.match(RE_ISO_DATE)) { + momentDateFormat = 'YYYY-MM-DD' + } else if (periodString.match(RE_YYYYMMDD_DATE)) { + momentDateFormat = MOMENT_FORMAT_NP_DAY + } else if (periodString.match(RE_NP_WEEK_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_WEEK + } else if (periodString.match(RE_NP_MONTH_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_MONTH + } else if (periodString.match(RE_NP_QUARTER_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_QUARTER + } else if (periodString.match(RE_NP_YEAR_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_YEAR + } else { + logError('dateTime / getNextNPPeriodString', `Unrecognized period string '${periodString}'`) + return '' + } + let out = moment(newDate).format(momentDateFormat) + if (wantCompactQuarter && momentDateFormat === MOMENT_FORMAT_NP_QUARTER) { + const mq = out.match(/^(\d{4})-Q([1-4])$/i) + if (mq) { + out = `${mq[1]}Q${mq[2]}` + } + } + return out + } catch (e) { + logError('dateTime / getNextNPPeriodString', e.message) + return '' + } +} + +/** + * Calendar period immediately before the given NotePlan period (same string family as note titles). + * Uses `calcOffsetDate` so week rollover matches ISO week handling (e.g. 2025-W01 → 2024-W52). + * @param {string} periodStringIn - e.g. YYYY-MM-DD, YYYY-Www, YYYY-MM, YYYY-Qn (or compact version YYYYQn), YYYY + * @param {string} periodType - 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} previous period title, or '' if parsing fails + */ +export function getPreviousNPPeriodString(periodStringIn: string, periodType: string): string { + try { + const trimmed = periodStringIn.trim() + const wantCompactQuarter = /^(\d{4})Q([1-4])$/i.test(trimmed) + let periodString = trimmed + const compactQ = trimmed.match(/^(\d{4})Q([1-4])$/i) + if (compactQ) { + periodString = `${compactQ[1]}-Q${compactQ[2]}` + } + const intervalByType: { [string]: string } = { + day: '-1d', + week: '-1w', + month: '-1m', + quarter: '-1q', + year: '-1y', + } + const interval = intervalByType[periodType] + if (!interval) { + logError('dateTime / getPreviousNPPeriodString', `Unknown periodType '${periodType}'`) + return '' + } + const newDate = calcOffsetDate(periodString, interval) + if (!newDate) { + return '' + } + let momentDateFormat = '' + if (periodString.match(RE_ISO_DATE)) { + momentDateFormat = 'YYYY-MM-DD' + } else if (periodString.match(RE_YYYYMMDD_DATE)) { + momentDateFormat = MOMENT_FORMAT_NP_DAY + } else if (periodString.match(RE_NP_WEEK_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_WEEK + } else if (periodString.match(RE_NP_MONTH_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_MONTH + } else if (periodString.match(RE_NP_QUARTER_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_QUARTER + } else if (periodString.match(RE_NP_YEAR_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_YEAR + } else { + logError('dateTime / getPreviousNPPeriodString', `Unrecognized period string '${periodString}'`) + return '' + } + let out = moment(newDate).format(momentDateFormat) + if (wantCompactQuarter && momentDateFormat === MOMENT_FORMAT_NP_QUARTER) { + const mq = out.match(/^(\d{4})-Q([1-4])$/i) + if (mq) { + out = `${mq[1]}Q${mq[2]}` + } + } + return out + } catch (e) { + logError('dateTime / getPreviousNPPeriodString', e.message) + return '' + } +} + /** * Split an interval (e.g. '-3m') into number (e.g. -3) and type ('month') parts * If interval arrives with {...} around the terms, remove them first diff --git a/helpers/dev.js b/helpers/dev.js index 6d1444327..752652a38 100644 --- a/helpers/dev.js +++ b/helpers/dev.js @@ -1,12 +1,27 @@ // @flow -// Development-related helper functions -// Note: none of these rely on DataStore.* functions etc., _except_ for the logging functions. However, the DataStore.settings object _is_ available in React windows/components, through @DBW's wizardry. +/** + * Development-related helper functions. + * + * Logging vs sync/async `DataStore.settings` (plugin JS vs HTML/React WebView): + * - In the main plugin context, `DataStore.settings` is typically a plain object, so reading `_logLevel` for + * `shouldOutputForLogLevel` is synchronous and immediate. + * - In HTML/React windows, `DataStore.settings` may be thenable (Promise-like). Synchronous code cannot read + * `_logLevel` until it resolves. This file uses an in-memory cache filled by a one-shot background `await` + * (`getPluginSettingsForLogging`, `primePluginSettingsCacheViaAwait`). Until the cache is populated, + * log gating uses the default threshold (DEBUG) so early lines are not silenced. After settings resolve, + * `_logLevel` from the real object applies. If the first logs look noisier than afterward or the level + * seems to "kick in" shortly after load, that is this bootstrap window - not necessarily a wrong user setting. + * + * Aside from logging helpers, most functions here intentionally avoid `DataStore.*`. + */ import isEqual from 'lodash-es/isEqual' import isObject from 'lodash-es/isObject' import isArray from 'lodash-es/isArray' import moment from 'moment/min/moment-with-locales' +import { awaitTopLevelApiProp } from './npBridgeResolve' + /** * NotePlan API properties which should not be traversed when stringifying an object */ @@ -603,15 +618,111 @@ export const LOG_LEVEL_STRINGS = ['| DEBUG |', '| INFO |', '🥺 WARN 🥺', ' * Emitted as separate log lines (before + msg + after) so the dots appear. */ const LOG_BUFFER_BUSTER_PADDING = `${'.'.repeat(10000)}/` +/** Resolved settings for log-level checks (from sync object or after await DataStore.settings). */ +let cachedPluginSettingsForLog: any = null +/** True once background await has been scheduled (avoid duplicate work). */ +let pluginSettingsAwaitPrimeStartedForLog: boolean = false + +/** Trace settings resolution via console.log. Enable with env NP_INSTRUMENT_PLUGIN_SETTINGS=1 (Node/Jest only). */ +const ENABLE_GET_PLUGIN_SETTINGS_INSTRUMENTATION: boolean = typeof process !== 'undefined' && process.env && process.env.NP_INSTRUMENT_PLUGIN_SETTINGS === '1' +let getPluginSettingsInstrumentationResolutionLogged: boolean = false + +/** + * Emit diagnostics for plugin settings loading. Uses console.log so CLO/logDebug gating does not hide it. + * @param {string} phase + * @param {any} detail + * @returns {void} + */ +function logGetPluginSettingsInstrumentation(phase: string, detail?: any): void { + if (!ENABLE_GET_PLUGIN_SETTINGS_INSTRUMENTATION) { + return + } + try { + if (typeof console !== 'undefined' && typeof console.log === 'function') { + console.log(`[getPluginSettingsForLogging] ${phase}`, detail !== undefined ? detail : '') + } + } catch (_e) { + // ignore + } +} + +/** + * Fire-and-forget: `await DataStore.settings` and cache. Callers use sync getPluginSettingsForLogging / shouldOutputForLogLevel. + * @returns {void} + */ +function primePluginSettingsCacheViaAwait(): void { + if (pluginSettingsAwaitPrimeStartedForLog) { + return + } + pluginSettingsAwaitPrimeStartedForLog = true + void (async (): Promise => { + try { + if (typeof DataStore === 'undefined') { + return + } + const resolved = await awaitTopLevelApiProp(DataStore, 'settings') + if (resolved != null && typeof resolved === 'object') { + cachedPluginSettingsForLog = resolved + } + if (ENABLE_GET_PLUGIN_SETTINGS_INSTRUMENTATION && !getPluginSettingsInstrumentationResolutionLogged) { + getPluginSettingsInstrumentationResolutionLogged = true + logGetPluginSettingsInstrumentation('await DataStore.settings resolved', { + typeofResolved: typeof resolved, + hasLogLevel: resolved != null && typeof resolved === 'object' && '_logLevel' in resolved, + _logLevel: resolved != null && typeof resolved === 'object' ? resolved._logLevel : '(n/a)', + }) + } + } catch (err) { + pluginSettingsAwaitPrimeStartedForLog = false + logGetPluginSettingsInstrumentation('await DataStore.settings threw/rejected', err) + } + })() +} + +/** + * Return plugin settings for log-level checks. + * - If `DataStore.settings` is already a plain object (not a thenable), use it synchronously. + * - Otherwise schedule one background await and return null until cache fills (shouldOutputForLogLevel then defaults to DEBUG until settings apply). + * + * @returns {?Object} + */ +function getPluginSettingsForLogging(): any { + if (typeof DataStore === 'undefined') { + logGetPluginSettingsInstrumentation('DataStore undefined', { returning: null }) + return null + } + + if (cachedPluginSettingsForLog != null) { + return cachedPluginSettingsForLog + } + + const raw = DataStore.settings + if (raw == null) { + logGetPluginSettingsInstrumentation('DataStore.settings is null/undefined', { raw, returning: null }) + return null + } + + // Plugin / legacy: plain settings object (not Promise / thenable). Exclude null (typeof null === 'object'). + if (typeof raw === 'object' && raw !== null && typeof raw.then !== 'function') { + cachedPluginSettingsForLog = raw + return raw + } + + // WebView / async: await DataStore.settings once in the background. + primePluginSettingsCacheViaAwait() + return null +} + /** * Test _logLevel against logType to decide whether to output * @param {string} logType * @returns {boolean} */ export const shouldOutputForLogLevel = (logType: string): boolean => { - let userLogLevel = 1 + // Default DEBUG so early logs are not dropped while DataStore.settings is still unresolved or _logLevel is unset. + let userLogLevel = 0 const thisMessageLevel = LOG_LEVELS.indexOf(logType.toUpperCase()) - const pluginSettings = typeof DataStore !== 'undefined' ? DataStore.settings : null + const pluginSettings = getPluginSettingsForLogging() // Note: Performing a null change against a value that is `undefined` will be true // Sure wish NotePlan would not return `undefined` but instead null, then the previous implementataion would not have failed @@ -643,7 +754,7 @@ export const shouldOutputForLogLevel = (logType: string): boolean => { * @returns */ export const shouldOutputForFunctionName = (pluginInfo: any): boolean => { - const pluginSettings = typeof DataStore !== 'undefined' ? DataStore.settings : null + const pluginSettings = getPluginSettingsForLogging() if (pluginSettings && pluginSettings.hasOwnProperty('_logFunctionRE')) { const logFunctionRE = pluginSettings['_logFunctionRE'] if (logFunctionRE) { @@ -799,7 +910,7 @@ export function logTimer(functionName: string, startTime: Date, explanation: str // console.log(msg) log(functionName, msg, 'DEBUG') } else { - const pluginSettings = typeof DataStore !== 'undefined' ? DataStore.settings : null + const pluginSettings = getPluginSettingsForLogging() // const timerSetting = pluginSettings['_logTimer'] ?? false if (pluginSettings && pluginSettings.hasOwnProperty('_logTimer') && pluginSettings['_logTimer'] === true) { // const msg = `${dt().padEnd(19)} | ⏱️ ${functionName} | ${output}` diff --git a/helpers/headings.js b/helpers/headings.js index b33482549..466b4a51c 100644 --- a/helpers/headings.js +++ b/helpers/headings.js @@ -9,6 +9,7 @@ import { clo, clof, JSP, logDebug, logError, logInfo, logTimer, logWarn } from ' * Check whether a heading paragraph matches the given text at the specified level, * allowing an optional trailing ellipsis ("…"), which indicates that a heading has been folded. * It tolerates extra whitespace between the base text and the ellipsis. + * TODO: Write tests * @author Cursor, guided by @jgclark * @param {TParagraph} para * @param {string} headingName - base heading text to match (e.g. 'Done') @@ -106,3 +107,5 @@ export function getCurrentHeading(note: CoreNoteFields, para: TParagraph): TPara export function isTitleWithEqualOrLowerHeadingLevel(item: TParagraph, prevLowestLevel: number): boolean { return item.type === 'title' && item.headingLevel <= prevLowestLevel } + +// Note: findHeadingStartsWith(note, headingToFind) couuld/should live here. diff --git a/helpers/npBridgeResolve.js b/helpers/npBridgeResolve.js new file mode 100644 index 000000000..406fdf5d9 --- /dev/null +++ b/helpers/npBridgeResolve.js @@ -0,0 +1,129 @@ +// @flow +//-------------------------------------------------------------------------- +// npBridgeResolve.js — resolve top-level NotePlan API members (DataStore, +// Calendar, …) across plugin JSContext and HTML WebView. In WebView, many +// properties are awaitable (Thenable) or function accessors; in plugin code +// they are often plain values. +// +// Prefer: await awaitTopLevelApiProp(DataStore, 'folders') +// or: await awaitTopLevelApiProp(Calendar, 'someProp') +// +// Thin wrappers `awaitDataStoreProp` / `isDataStorePropSyncPlainObject` remain +// for call sites that only touch DataStore. +// +// Do not long-cache results for data that changes during the session unless you +// own invalidation. +//-------------------------------------------------------------------------- + +/** + * Normalize a value from an NP bridge (or nested result after invocation). + * - null / undefined: as-is + * - Thenable: awaited once + * - function: invoked as `fn.call(thisArg)` when thisArg is defined; else `fn()` + * - other: returned as-is + * + * @param {*} raw + * @param {*} [thisArg] - `this` for bridged methods (e.g. pass `DataStore` when resolving `DataStore.settings`). + * @returns {Promise<*>} + */ +export async function awaitBridgedValue(raw: any, thisArg?: any): Promise { + if (raw == null) { + return raw + } + if (typeof raw === 'object' && typeof raw.then === 'function') { + return await raw + } + if (typeof raw === 'function') { + try { + const invoked = thisArg !== undefined ? raw.call(thisArg) : raw() + return await awaitBridgedValue(invoked, undefined) + } catch (_e) { + return raw + } + } + return raw +} + +/** + * Read `api[prop]` and resolve it for plugin and WebView (Thenable / function accessor). + * + * @example + * const settings = await awaitTopLevelApiProp(DataStore, 'settings') + * const events = await awaitTopLevelApiProp(Calendar, 'someCollectionOrMethod') + * + * @param {*} api - Top-level namespace (DataStore, Calendar, …) + * @param {string} prop - Property name on that namespace + * @returns {Promise<*>} + */ +export async function awaitTopLevelApiProp(api: any, prop: string): Promise { + if (api == null) { + return undefined + } + const host: any = api + const raw = host[prop] + return await awaitBridgedValue(raw, api) +} + +/** + * True when `api[prop]` looks like a plain synchronous object (not a function, not a Thenable). + * Arrays qualify as objects. Use on plugin-only paths to skip an async hop. + * + * @param {*} api + * @param {string} prop + * @returns {boolean} + */ +export function isTopLevelApiPropSyncPlainObject(api: any, prop: string): boolean { + if (api == null) { + return false + } + const host: any = api + const raw = host[prop] + if (raw == null) { + return false + } + if (typeof raw === 'function') { + return false + } + if (typeof raw === 'object' && typeof raw.then === 'function') { + return false + } + return typeof raw === 'object' +} + +// ---- DataStore-specific conveniences (global DataStore) ---- + +/** + * @param {*} raw - Value read from DataStore (or nested); uses global `DataStore` as call `this` when defined. + * @returns {Promise<*>} + */ +export async function awaitDataStoreBridgeValue(raw: any): Promise { + if (typeof DataStore === 'undefined') { + if (typeof raw === 'function') { + return raw + } + return await awaitBridgedValue(raw, undefined) + } + return await awaitBridgedValue(raw, DataStore) +} + +/** + * @param {string} prop - e.g. 'settings', 'folders', 'teamspaces' + * @returns {Promise<*>} + */ +export async function awaitDataStoreProp(prop: string): Promise { + if (typeof DataStore === 'undefined') { + return undefined + } + return await awaitTopLevelApiProp(DataStore, prop) +} + +/** + * @param {string} prop + * @returns {boolean} + */ +export function isDataStorePropSyncPlainObject(prop: string): boolean { + if (typeof DataStore === 'undefined') { + return false + } + return isTopLevelApiPropSyncPlainObject(DataStore, prop) +} diff --git a/jgclark.DailyJournal/CHANGELOG.md b/jgclark.DailyJournal/CHANGELOG.md index d426d103a..acdb8a956 100644 --- a/jgclark.DailyJournal/CHANGELOG.md +++ b/jgclark.DailyJournal/CHANGELOG.md @@ -1,6 +1,8 @@ # What's changed in 💭 Journalling Helpers Plugin? _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark.DailyJournal/)._ +Note: I've replaced this plugin with the newer **Journalling & Reviews** one, which takes advantage of richer interfaces available from NP 3.20. This will no longer be updated, and will be retired in due course. + ## [1.16.0] - 2025-11-01 ### Added - Can now have multiple review questions per line. To separate the questions on the same line, use `||`. See documentation for more details. diff --git a/jgclark.DailyJournal/src/journal.js b/jgclark.DailyJournal/src/journal.js index ae045db8f..aec269fe9 100644 --- a/jgclark.DailyJournal/src/journal.js +++ b/jgclark.DailyJournal/src/journal.js @@ -106,7 +106,7 @@ export async function yearlyJournalQuestions(): Promise { * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' * @returns {Promise} true if we should continue, false if cancelled */ -async function ensureCorrectPeriodNote(period: string, periodAdjective: string): Promise { +async function ensureCorrectPeriodNoteIsOpen(period: string, periodAdjective: string): Promise { // Open current calendar note if wanted const { note } = Editor const currentNotePeriod = (note && note.type === 'Calendar') ? getPeriodOfNPDateStr(note.title ?? '') : '' @@ -337,7 +337,7 @@ async function processQuestion( // Look to see if this question has already been put into the note with something following it. // If so, skip this question. - const resAQ = returnAnsweredQuestion(parsedQuestion.question) + const resAQ = answerToQuestion(parsedQuestion.question) if (resAQ !== '') { logDebug(pluginJson, `- Found existing Q answer '${resAQ}', so won't ask again`) return '' @@ -405,7 +405,7 @@ function writeAnswersToNote(output: string, config: JournalConfigType): void { async function processJournalQuestions(period: string, periodAdjective: string = ''): Promise { try { // Ensure correct period note is open - const shouldContinue = await ensureCorrectPeriodNote(period, periodAdjective) + const shouldContinue = await ensureCorrectPeriodNoteIsOpen(period, periodAdjective) if (!shouldContinue) { return } @@ -483,7 +483,7 @@ async function processJournalQuestions(period: string, periodAdjective: string = * @param {string} question * @returns {string} found answered question, or empty string */ -function returnAnsweredQuestion(question: string): string { +function answerToQuestion(question: string): string { const RE_Q = `${question}.+` const { paragraphs } = Editor let result = '' diff --git a/jgclark.DailyJournal/src/templatesStartEnd.js b/jgclark.DailyJournal/src/templatesStartEnd.js index e50d0093c..b3388d1ae 100644 --- a/jgclark.DailyJournal/src/templatesStartEnd.js +++ b/jgclark.DailyJournal/src/templatesStartEnd.js @@ -7,7 +7,7 @@ import { type JournalConfigType, getJournalSettings } from './journalHelpers' import { isDailyNote, isMonthlyNote, isWeeklyNote } from '@helpers/dateTime' -import { logDebug, logError, logInfo } from '@helpers/dev' +import { logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' import { getAttributes } from '@helpers/NPFrontMatter' import { showMessage } from '@helpers/userInput' @@ -74,36 +74,46 @@ async function renderAndInsertTemplate( templateTitle: string, commandName: string, ): Promise { - // Render the template, using recommended decoupled method of invoking a different plugin - const result = await DataStore.invokePluginCommandByName('renderTemplate', 'np.Templating', [templateTitle]) - // TEST: turning off error message for now, as it fires on Templates that only do background work. - // if (result == null || result === '') { - // throw new Error(`No result from running Template '${templateTitle}'. Stopping.`) - // } - - // Work out where to insert it in the note, by reading the template, and checking - // the frontmatter attributes for a 'location' field (append/insert/cursor) - const attrs = getAttributes(templateData, true) - const requestedTemplateLocation = attrs.location ?? 'insert' - let pos = 0 - switch (requestedTemplateLocation) { - case 'insert': { - logDebug(commandName, `- Will insert to start of Editor`) - Editor.insertTextAtCharacterIndex(result, 0) - break + try { + if (!templateData || templateData === '') { + logWarn('renderAndInsertTemplate', `templateData is null or empty. Stopping.`) + return } - case 'append': { - pos = Editor.content?.length ?? 0 // end - logDebug(commandName, `- Will insert to end of Editor (pos ${pos})`) - Editor.insertTextAtCharacterIndex(result, pos) - break + // Render the template, using recommended decoupled method of invoking a different plugin + const resultingTextContent = await DataStore.invokePluginCommandByName('renderTemplate', 'np.Templating', [templateTitle]) + // TEST: turning off error message for now, as it fires on Templates that only do background work. + if (resultingTextContent == null || resultingTextContent === '') { + logDebug('renderAndInsertTemplate', `No resulting text from running Template '${templateTitle}'. Stopping.`) + return } - // Note: unsure if this works. - case 'cursor': { - logDebug(commandName, `- Will insert to Editor at cursor position`) - Editor.insertTextAtCursor(result) - break + + // Work out where to insert it in the note, by reading the template, and checking + // the frontmatter attributes for a 'location' field (append/insert/cursor) + const attrs = getAttributes(templateData, true) + const requestedTemplateLocation = attrs.location ?? 'insert' + let pos = 0 + switch (requestedTemplateLocation) { + case 'insert': { + logDebug(commandName, `- Will insert to start of Editor`) + Editor.insertTextAtCharacterIndex(resultingTextContent, 0) + break + } + case 'append': { + pos = Editor.content?.length ?? 0 // end + logDebug(commandName, `- Will insert to end of Editor (pos ${pos})`) + Editor.insertTextAtCharacterIndex(resultingTextContent, pos) + break + } + // Note: unsure if this works. + case 'cursor': { + logDebug(commandName, `- Will insert to Editor at cursor position`) + Editor.insertTextAtCursor(resultingTextContent) + break + } } + } catch (error) { + logError('renderAndInsertTemplate', error.message) + await showMessage(`Error: ${error.message}`) } } diff --git a/jgclark.PeriodicReviews/CHANGELOG.md b/jgclark.PeriodicReviews/CHANGELOG.md new file mode 100644 index 000000000..21cd5d6f2 --- /dev/null +++ b/jgclark.PeriodicReviews/CHANGELOG.md @@ -0,0 +1,97 @@ +# What's changed in Periodic Reviews Plugin? +_Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark.DailyJournal/)._ + +Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. + +## [2.0.0.b15] - 2026-08-11 +- fix: template lines like `Programming: @prog() ` now pre-fill and write back free-text when the note only has the string portion (e.g. `Programming: Things I've already noted.`). Blank earlier @token fields no longer drop the line label from the output. +- change: blank **Planned items heading** settings no longer fall back to built-in names; planned items are written to the next period note with no H2. Review-window planning labels fall back to generic "Planned" / "Planning for the next …" titles. + +## [2.0.0.b14] - 2026-05-18 +- dev: cherry pick helper updates from main branch to allow for "IBM Plex Sans" fonts in displays +- change: will now review the currently-open calendar note (if available), otherwise fall back to the current calendar note. + +## [2.0.0.b13] - 2026-04-26 +- New setting: **Big task marker style** to switch big-task/win markers between `>>` (priority 4), `!!!` (priority 3), and `!!` (priority 2). +- Big-task/win detection in review summaries now follows this setting (still counting `#win` / `#bigwin` as wins). +- Plan-item carry-over fallback matching now follows the configured marker priority (instead of always assuming `>>` / priority 4). +- Planning-line normalization now strips any of `>>`, `!!!`, or `!!` when pasted into the planning textarea. +- Removed **Planned items prefix** setting; planning lines written to the next-period note now always use the configured **Big task marker style** marker. + +## [2.0.0.b12] - 2026-04-26 +- Review write-back: for `` or mixed typed lines (for example duration/int/boolean combinations on one template line) now upsert to the existing matching line in the review section. Note: unchecked booleans explicitly clear previously written boolean tokens on that line. +- Review summary: completed-task lists now show only for daily/weekly reviews; monthly, quarterly, and yearly reviews no longer render completed-task blocks. +- Tweaks to layout in Summary areas. +- dev: Removed quarter-title normalization in review helpers and switched review flow to pass raw period titles directly for note matching and summary period boundary lookups. + +## [2.0.0.b11] - 2026-04-20 +- Make the details in the summary sections +collapsible +- Fix so that review commands no longer switch the editor to the “current” period’s note when you already have another calendar note of that same kind open (for example, yesterday’s daily note stays open instead of jumping to today). + +## [2.0.0.b10] - 2026-04-13 +- Review window **Summary**: completed-task list(s) and the calendar events list are each wrapped in HTML `
` / `` (expanded by default) so you can collapse the lists while keeping the headings visible. +- New optional settings **Planned items prefix** (default `>>`) and **Planned items suffix** (default `#win`) for text written with each planned item into the **next** period’s calendar note. +- Review window submit: do not log “no template question answers” when the user only filled the **planning** textarea (next-period plan lines still count as a substantive submit). +- Review window callback: `onReviewWindowAction` now **returns `{}`** on every path when invoked via `DataStore.invokePluginCommandByName` (required by NotePlan; missing return can stop the handler after the “Executing function” log). Normalize a single-array bridge payload `[actionName, payload]` when needed; bail out cleanly if settings fail to load. + +## [2.0.0.b9] - 2026-04-11 +- settled on name 'Periodic Reviews' not 'Journalling & Reviews' +- Summary / carry-over plan tasks: include **cancelled** `>>` lines (e.g. `* [-] >> …`); they show as **not** done like open items. Fix: plan-section extraction no longer stopped after the first task under a matching H2. + +## [2.0.0.b8] - 2026-04-10 +- Review window: Fix `` (and other `` markers) sometimes appearing before the textarea — label text now strips full angle-bracket tokens, not bare type names. +- Review question templates: `` is accepted as an alias for ``. Simplifying, dropped support for `

` / `

` question lines. +- Further layout improvements to Summary area +- + +## [2.0.0.b7] - 2026-04-09 +- Review summary: Done tasks whose body starts with `>>` (after the task marker and optional `!` priorities) count as **wins** for the period, same as `#win` / `#bigwin`, without needing a “Wins” section. They appear in the same **completed tasks** list as other done items (wins first), not duplicated. **Weekly/monthly/quarterly/yearly** summaries use the same win rules when listing done tasks for the period (even if there are no carry-over plan items). +- Review window: Fix daily summary layout when carry-over plan items exist. +- Review window: Fix heading-only template lines being split by the flex segment matcher, leaving text fragments. +- dev: Refactor review flow — `reviewQuestions.js` (parse / pre-fill / answer output), more helpers in `journalHelpers.js`, slimmer `periodReviews.js`; shared segment regex for HTML + parser; `writeAnswersToNote` is module-private. +- dev: Update Template handling in applyTemplateToNote() to not make any Editor inserts if there's nothing to insert. Aim: avoid race conditions. + +## [2.0.0.b6] - 2026-04-04 +- `` / `` / `` are now substituted in heading and label text taken from parsed questions (e.g. `## Weekly Review for `), not only in the raw template line—so the window matches the period title. +- **Planning vs reviewing:** New settings name planned items per period (daily through yearly, with defaults such as “Big 3 Rocks”, “Top 3 Wins”, etc.). The review window shows a **Summary** block (carry-over plan tasks from this note as open/complete icons, then the usual daily completed-task and event summary). +- A separate **planning** section after the main form writes an H2 and `>> …` tasks at the start of the **next** period’s calendar note, replacing any existing section with that title. Empty planning clears that section on the next note. That H2 uses `{planName} for {next period title}` (e.g. `Big Rocks for 2026-04-04`), distinct from the review-window “Planned:” / “Planning: … for the next …” labels. +- Added fuller translation of markdown to how its displayed in user's current theme in NP, particularly including Priority markers. +- Open and reference the quarterly calendar note using NotePlan’s title format `YYYY-Qn` not `YYYYQn`. +- Reorganised the settings + +## [2.0.0.b5] - 2026-03-31 +- Added a new review question type `` that accepts `[H]H:MM` input (for example `1:05` or `12:30`) in the review window and when writing answers to notes. +- Ensure Question strings are handled case-insensitively +- Added list of events in the day to the summary at the start +- dev: Rename journal.js to periodReviews.js + +## [2.0.0.b4] - 2026-03-28 +- Split section-heading settings: `dailyJournalSectionHeading` is now used by daily journal commands, and `reviewSectionHeading` is used by weekly/monthly/quarterly/yearly review commands. Existing installs migrate heading values to preserve prior behavior. +- **Review placeholders:** `` is replaced with the current review period’s calendar title (e.g. `2026-03-28`, `2026-W13`, `2026Q1`) in the review window and the output. +- Added `` (alias ``) that's similar to `` but gives the **following** period in the same format (e.g. weekly `2024-W52` → `2025-W01`). +- **Correct calendar note:** The open editor note is only reused when it matches the review command’s period type **and** the same period title (e.g. today for a daily review). Otherwise the plugin opens the intended note. + +## [2.0.0.b3] - 2026-03-25 +- New review question types: `` (each answer line written with a `- ` prefix), `` (`+ ` per line), and `` (`* ` per line). The review window uses a multi-line field; empty lines are skipped. Answers already in the calendar note are pre-filled with markers stripped. +- Headings in review settings are now output as HTML headings: `` outputs an `

...`, and literal `##` / `###` lines in settings are carried through as `

` / `

` with `review-subheading` classes. +- `` placeholder is now supported in review question lines and is substituted with the relevant calendar period title in the review window and in saved output. + +## [2.0.0.b2] - 2026-03-24 +- When opening the review window, answers already present in the calendar note are pre-filled in the matching controls (under your **Review section heading** when that heading exists; otherwise the whole note is scanned). Latest matching paragraph wins so you can edit the most recent review block. +- Added a period summary list above review questions: daily shows Dashboard-style completed tasks from changed notes for that day, and week/month/quarter/year show only in-period done items tagged `#win` or `#bigwin`, rendered with multi-column circle-check entries. Period boundaries use `getFirstDateInPeriod` / `getLastDateInPeriod`; task text uses the same HTMLView conversion helpers as note HTML export (hashtags, mentions, links, etc.). Summary lines omit the `@done(…)` stamp for readability. +- Calendar event counts and timed duration in the summary use **EventHelpers** settings (`getEventsSettings`) and the same per-day `getEventsForDay` loop as EventHelpers’ `listDaysEvents`, with deduping for multi-day items. + +## [2.0.0.b1] - 2026-03-24 +### New +- The **daily/weekly/monthly/...Review** commands now ask all their questions in a single window and writes answers to the review section in the usual format. It lays out the questions and spaces for answers as it will be added into the note, according to your settings. +- As usual for my plugins, this picks up colours and fonts from your current NP Theme. +- Added a `Review Window type` setting to choose the style of review window to use: 'New Window' for a separate window; 'Main Window' to take over the main window; 'Split View' for a split view in the main window. +- Added `Open the calendar note when reviewing it?` setting (default: `true`) so review commands no longer ask the opening question. +- The settings for review questions now no longer needs to have ` || ` delimiters. +- It will migrate settings from the old **Journalling Helpers** plugin on first install. + +### Fixed +- Fixed a single-window review callback bridge bug that generated extra quotes around `DataStore.invokePluginCommandByName(...)`, causing a runtime JavaScript `SyntaxError` when submitting or cancelling. +- Fixed duplicate/late review-window callbacks by making the HTML bridge one-shot and safely no-op when `DataStore.invokePluginCommandByName` is unavailable in the current JS context. +- Switched single-window review form callbacks to `noteplan://x-callback-url/runPlugin` and added payload JSON parsing in `onReviewWindowAction`, avoiding WebView `DataStore` runtime availability issues. diff --git a/jgclark.PeriodicReviews/README.md b/jgclark.PeriodicReviews/README.md new file mode 100644 index 000000000..67d5c9f20 --- /dev/null +++ b/jgclark.PeriodicReviews/README.md @@ -0,0 +1,166 @@ +# 💭 Periodic Reviews Plugin + +This plugin makes it easier for you to review your days, weeks, months, quarters, and years in NotePlan. It's designed to help you intentionally focus on whatever are the most important projects/goals/behaviours across all of your different endeavours in life. + +Many truly productive people suggest that regular reviews are the most important tool to help us focus on the most important outcomes in life. + +There is no single “right” way to review personal or work aims or goals. What matters is pausing to answer questions about what went well, what did not, goals, gratitude, mood, and so on. This is where this plugin fits in. + +First you need to configure the questions you want to use for each time period (some of daily, weekly, monthly, quarterly and yearly). Then at the end of the period, run the **/Daily Review** command (alias: 'dr'), or the similar one for the other review periods. + +The plugin then opens a window that shows **all** these questions in a window form (with colours and fonts follow your current NotePlan theme). When you submit the form, your answers are written under the correct section heading in the calendar note. + +Further, it will then ask you to decide your top few tasks/goals/priority work for the next period. There can only be a very few of these. If you give any, they will be written into the next period's note with your selected big-task marker (`>>`, `!!!`, or `!!`) to indicate these most important things to focus on. + +### Example (Daily Review) +Here's an example of the Daily Review Window: + + + +This is generated from the following settings: +- "Daily Review/Journal Questions": + +``` +## Stats for +Health: @sleep() @work() @fruitveg() #stretches #closedRings +Work: @work() + +### Journal +Mood: +Gratitude: +Wins: +Challenges: +``` + +- "Daily Review/Journal Questions": `Wins` + +Submitting the form will insert something like this into **today**'s note: + +```markdown +## Stats for 2026-04-10 +@sleep(6.8) @work(7) +@fruitveg(4) #stretches + +### Journal +Mood: 😇 Blessed +Gratitude: Went to great Nana's 100th birthday party -- result! +Wins: +- First win... +- Another one +``` +And if you enter items in the 'Planning' section, then it will prefix something like this into **tomorrow**'s note: +```markdown +## Wins for 2026-04-10 +* >> First win +* >> Second win +``` + +### Which period is reviewed? +If a daily note is currently open when **/Daily Review** is called, then that day is reviewed, otherwise today's note is used. The same goes for the other calendar periods. + +The window first shows a Summary section, that starts with a reminder of the main few tasks/aims/goals you set for that period, and whether they were completed or not. For Daily notes only, it includes alist of tasks completed that day, plus a list of calendar events. + +### Basic Configuration +To use weekly, monthly, quarterly, or yearly notes, turn them on in **NotePlan Settings** → Calendar: + + + + Open the **💭 Journalling & Reviews** card in Plugin Preferences, then use the gear button to edit settings. + +### Setting the Review Questions +The terms in angle brackets define both the input controls and how lines are written to the note. The available input controls are: + +- `` — ticked/unticked; if ticked, the surrounding text is included in the output +- `` — the same as `` above +- `` or `` — whole number (integer) +- `` — number, which may include a decimal part +- `` — `[H]H:MM` (e.g. `1:05`, `12:30`) +- `` — single-line text +- `` — multi-line; each non-empty line is prefixed with a markdown bullet (`- `) +- `` — same, with checklist markers (`+ `) +- `` — same, with task markers (`* `) +- `` — pick from your configured mood list. + +You can include headings and placeholders: + +- Literal `##` / `###` lines in settings (and legacy ``) — output as headings in the note/HTML. +- `` — current review period’s calendar title in the window and in saved output (e.g. `2026-03-28`, `2026-W13`, `2026-Q1`). Substituted in **parsed** heading and label text too (e.g. `## Weekly Review for ` matches the period title in the UI). +- `` or `` — the **following** period in the same format (e.g. weekly `2026-W52` → `2027-W01`). +- line breaks or `\n`. + +Notes: +- Multiple ``, ``, or `` items on one line are supported +- If matching answers already exist in the note, they will appear **pre-filled** in the form. The latest matching block wins. + +### Other Settings + +- **Window placement:** **Review Window type** — 'New Window' (the default), 'Main Window', or a 'Split View' within the main window. +- **Open the calendar note when reviewing it?** (default: on). +- **Calendars to include in review summaries:** optional filter list; leave empty to include all calendars. +- **Big task marker style:** choose whether major tasks/goals are indicated by `>>` (priority 4, the default), `!!!` (priority 3), or `!!` (priority 2). This is used when scanning summary/carry-over "big task" lines. +- **Planning vs reviewing:** For each period you can set a **planned items** name (defaults such as *Big Wins*, *Big Rocks*, *Key Outcomes*, *Goals*, *Theme*). After the main form, a **planning** area can write an **H2** and big-task lines (for example `>> …` or `!!! …`) at the **start** of the **next** period’s calendar note, replacing any existing section with that title. Empty planning clears that section on the next note. The heading uses `{planName} for {next period title}` (e.g. `Big Rocks for 2026-W15`), separate from the on-screen “Planned:” / “Planning: …” labels. The priority marker is always taken from **Big task marker style**. + +### Section headings + +- **Daily Journal Section Heading** — where **daily** review/journal answers are appended (default: `Journal`). +- **Review Section Heading** — where **weekly / monthly / quarterly / yearly** answers go (default: `Review`). + +If the heading does not already exist in a note, the content is added at the end of the note. + + +If a question is left empty, that line is omitted from the output. If a line in the note already starts with the same question text, it is treated as an existing answer, and prefilled. + + +### Moods + +Comma-separated list of labels (emoji optional). + +--- + +## FAQ +Q: What's the minimum version of NotePlan this runs with? +A: v3.20 (for the integrated HTML plugin windows). + +Q: How is this plugin related to your **Journalling Helpers** plugin? +A: This plugin replaces that older plugin for the review questions functionality, but not its start- and end-of-day template helpers. On first install, those settings will be migrated from that plugin automatically, if you'd used that before. + +Q: How is this different from your **Projects & Reviews** plugin? +A: That plugin is designed to be used for assisting track and review Projects or project-like activities. It works on regular notes, and helps you review work on many projects, and review each at its suitable review interval. This plugin is designed to help you intentionally focus on whatever are the most important projects/goals/behaviours across all of your different endeavours in life. + + + +--- + +## Support + +Issues and feature ideas: [NotePlan plugins on GitHub](https://github.com/NotePlan/plugins/issues). + +If you would like to support my late-night work extending NotePlan through writing these plugins, you can through: + +[Buy Me A Coffee](https://www.buymeacoffee.com/revjgc) + +Thanks! + +## History + +See the [CHANGELOG](https://noteplan.co/plugins/jgclark.PeriodicReviews/CHANGELOG.md) for release history for v2. diff --git a/jgclark.PeriodicReviews/__tests__/periodReviews.test.js b/jgclark.PeriodicReviews/__tests__/periodReviews.test.js new file mode 100644 index 000000000..08c157ed7 --- /dev/null +++ b/jgclark.PeriodicReviews/__tests__/periodReviews.test.js @@ -0,0 +1,1039 @@ +/* globals describe, expect, it, beforeAll, beforeEach */ + +// Last updated: 2026-04-13 for v2.0.0.b10 by @jgclark + +import { + buildNextPeriodNotePlanSectionHeadingTitle, + buildNextPlanSectionHeadingTitle, + formatPlannedItemLineForNextNote, + getBigTaskMarkerFromConfig, + getBigTaskPriorityFromConfig, + getEffectivePlannedItemAffixes, + getPeriodAdjectiveFromType, + getPlanItemsNameForPeriodType, + getReviewPeriodTitleStringFromCalendarNote, + mergeUniqueSummaryDoneTaskLines, + normalizePlanningTaskLinesFromForm, + shouldUseOpenEditorCalendarNote, + splitMergedSummaryDoneLinesIntoWinsAndOthers, + substituteReviewPeriodPlaceholders, + summaryTaskLineDedupeKey, +} from '../src/periodicReviewHelpers' +import { + extractPlanSectionItems, + partitionReviewAnswerLinesForMixedUpsert, + taskContentIsSummaryWin, +} from '../src/periodReviews' +import { buildReviewHTML } from '../src/reviewHTMLViewGenerator' +import { + buildInitialReviewAnswersByFieldName, + buildOutputFromReviewWindowAnswers, + getBooleanClearDirectivesFromAnswers, + getStringQuestionMatchKeyFromOutputLine, + getStringQuestionMatchKeyFromParsedQuestion, + getTemplateLineUpsertKey, + getTemplateLineUpsertKeyFromOutputLine, + parseQuestions, +} from '../src/reviewQuestions' +import { DataStore } from '@mocks/index' + +beforeAll(() => { + // global.Calendar = Calendar + // global.Clipboard = Clipboard + // global.CommandBar = CommandBar + global.DataStore = DataStore + global.Editor = { paragraphs: [] } + // global.NotePlan = NotePlan + DataStore.settings['_logLevel'] = 'none' //change this to DEBUG to get more logging +}) + +// Jest suite +describe('Reviews', () => { + describe('parseQuestions', () => { + it('should parse questions correctly (test 1)', () => { + const config = { + dailyReviewQuestions: `Health: @sleep() @fruitveg() #bible #stretches #closedRings +Work: @work() @1CB() @CRC() +Mood: +Gratitude: +God was: +Alive: +Not Great: +Learn: +Remember: ` + } + + const questions = parseQuestions(config.dailyReviewQuestions) + + expect(questions.length).toBe(15) + expect(questions[0].question).toBe('@sleep') + expect(questions[0].type).toBe('int') + expect(questions[0].lineIndex).toBe(0) + expect(questions[1].question).toBe('@fruitveg') + expect(questions[1].type).toBe('int') + expect(questions[1].lineIndex).toBe(0) + expect(questions[2].question).toBe('#bible') + expect(questions[2].type).toBe('boolean') + expect(questions[2].lineIndex).toBe(0) + expect(questions[3].question).toBe('#stretches') + expect(questions[3].type).toBe('boolean') + expect(questions[4].question).toBe('#closedRings') + expect(questions[4].type).toBe('boolean') + expect(questions[5].question).toBe('@work') + expect(questions[5].type).toBe('int') + expect(questions[6].question).toBe('@1CB') + expect(questions[6].type).toBe('int') + expect(questions[7].question).toBe('@CRC') + expect(questions[7].type).toBe('int') + expect(questions[8].question).toBe('Mood') + expect(questions[8].type).toBe('mood') + expect(questions[9].question).toBe('Gratitude') + expect(questions[9].type).toBe('string') + }) + + it('should parse bullets, checklists, and tasks types', () => { + const raw = `Wins: +Next: +Do: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(3) + expect(questions[0].type).toBe('bullets') + expect(questions[1].type).toBe('checklists') + expect(questions[2].type).toBe('tasks') + expect(questions[0].question).toBe('Wins') + }) + + it('should parse duration type', () => { + const raw = '@focus()' + const questions = parseQuestions(raw) + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('duration') + expect(questions[0].question).toBe('@focus') + }) + + it('should parse markdown headings (## / ###) into h2/h3', () => { + const raw = `## Top Heading\n### Sub Heading\nTitle: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(3) + expect(questions[0].type).toBe('h2') + expect(questions[0].question).toBe('Top Heading') + expect(questions[0].lineIndex).toBe(0) + expect(questions[1].type).toBe('h3') + expect(questions[1].question).toBe('Sub Heading') + expect(questions[1].lineIndex).toBe(1) + expect(questions[2].type).toBe('string') + expect(questions[2].question).toBe('Title') + expect(questions[2].lineIndex).toBe(2) + }) + + it('should parse the same as ', () => { + const raw = `Hours: || Count: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(2) + expect(questions[0].type).toBe('int') + expect(questions[0].question).toBe('Hours') + expect(questions[1].type).toBe('int') + expect(questions[1].question).toBe('Count') + }) + + it('should strip full tokens from label text (no stray marker for the review window)', () => { + const questions = parseQuestions('Gratitude: ') + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('string') + expect(questions[0].question).toBe('Gratitude') + expect(questions[0].question).not.toMatch(/[<>]/) + }) + + it('should cope with lines which are just / / / ', () => { + const config = { + dailyReviewQuestions: ` + +## H2 Heading + +### H3 Heading +`, + } + const questions = parseQuestions(config.dailyReviewQuestions) + expect(questions.length).toBe(6) + expect(questions[0].type).toBe('string') + expect(questions[0].question).toBe('') + expect(questions[0].lineIndex).toBe(0) + expect(questions[1].type).toBe('bullets') + expect(questions[1].question).toBe('') + expect(questions[1].lineIndex).toBe(1) + expect(questions[2].type).toBe('h2') + expect(questions[2].question).toBe('H2 Heading') + expect(questions[2].lineIndex).toBe(2) + expect(questions[3].type).toBe('checklists') + expect(questions[3].question).toBe('') + expect(questions[3].lineIndex).toBe(3) + expect(questions[4].type).toBe('h3') + expect(questions[4].question).toBe('H3 Heading') + expect(questions[4].lineIndex).toBe(4) + expect(questions[5].type).toBe('tasks') + expect(questions[5].question).toBe('') + expect(questions[5].lineIndex).toBe(5) + }) + + it('should ignore as a question token', () => { + const raw = `For: \nMood: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('mood') + expect(questions[0].question).toBe('Mood') + expect(questions[0].lineIndex).toBe(1) + }) + + it('should ignore as a question token', () => { + const raw = `Next: \nMood: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('mood') + expect(questions[0].question).toBe('Mood') + expect(questions[0].lineIndex).toBe(1) + }) + + it('should ignore as a question token', () => { + const raw = `Next: \nMood: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('mood') + }) + }) + + describe('getPeriodAdjectiveFromType', () => { + it('should return title-case adjectives for known period types', () => { + expect(getPeriodAdjectiveFromType('day')).toBe('Daily') + expect(getPeriodAdjectiveFromType('week')).toBe('Weekly') + expect(getPeriodAdjectiveFromType('month')).toBe('Monthly') + expect(getPeriodAdjectiveFromType('quarter')).toBe('Quarterly') + expect(getPeriodAdjectiveFromType('year')).toBe('Yearly') + }) + it('should return Calendar for unknown period type', () => { + expect(getPeriodAdjectiveFromType('unknown')).toBe('(error: unknown period type)') + }) + }) + + describe('open editor calendar note for review', () => { + const yesterdayDaily = { + type: 'Calendar', + filename: '20260517.md', + title: 'Friday thoughts', + } + + it('shouldUseOpenEditorCalendarNote should prefer open daily note over today when preferOpenSameKind', () => { + expect(shouldUseOpenEditorCalendarNote(yesterdayDaily, 'day', '2026-05-18', true)).toBe(true) + }) + + it('shouldUseOpenEditorCalendarNote should require title match when preferOpenSameKind is false', () => { + expect(shouldUseOpenEditorCalendarNote(yesterdayDaily, 'day', '2026-05-18', false)).toBe(false) + expect(shouldUseOpenEditorCalendarNote(yesterdayDaily, 'day', '2026-05-17', false)).toBe(true) + }) + + it('getReviewPeriodTitleStringFromCalendarNote should use filename when title is not a period string', () => { + expect(getReviewPeriodTitleStringFromCalendarNote(yesterdayDaily, 'day')).toBe('2026-05-17') + }) + + it('getReviewPeriodTitleStringFromCalendarNote should use parseable title when present', () => { + const note = { type: 'Calendar', filename: '20260517.md', title: '2026-05-17' } + expect(getReviewPeriodTitleStringFromCalendarNote(note, 'day')).toBe('2026-05-17') + }) + }) + + describe('substituteReviewPeriodPlaceholders', () => { + it('should expand , , and ', () => { + const s = 'A B C ' + expect(substituteReviewPeriodPlaceholders(s, '2024-W52', 'week')).toBe('A 2024-W52 B 2025-W01 C 2025-W01') + }) + }) + + describe('buildReviewHTML', () => { + it('should substitute in ## heading text (parsed question, not only raw line)', () => { + const raw = `## Weekly Review for +Wins: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + [], + [], + '2026-W13', + 'week', + [], + 'onReviewWindowAction', + 'Top Wins', + {}, + [], + ) + expect(html).toContain('Weekly Review for') + expect(html).toContain('2026-W13') + expect(html).not.toContain('<date>') + }) + + it('should render a ## heading line as one heading (segment regex must not split after the first word)', () => { + const raw = `## Stats for +Mood: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + [], + [], + '2026-04-06', + 'day', + [], + 'onReviewWindowAction', + 'Big Wins', + {}, + [], + ) + expect(html).toContain('Stats for 2026-04-06') + expect(html).not.toContain('review-line-text-fragment"> for 2026-04-06') + }) + + it('should keep daily completed-tasks and events inside #summary section-wrap when carry-over plan items exist', () => { + const raw = `## Stats for +Mood: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const eventsForPeriod = [ + { + title: 'Pray for Jill', + date: new Date('2026-04-07T10:00:00'), + endDate: new Date('2026-04-07T10:18:00'), + isAllDay: false, + }, + ] + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + [], + [], + '2026-04-07', + 'day', + eventsForPeriod, + 'onReviewWindowAction', + 'Wins', + {}, + [{ content: 'Rest day', isDone: false }], + ) + const summaryMarker = 'id="summary">' + const summaryStart = html.indexOf(summaryMarker) + expect(summaryStart).toBeGreaterThan(-1) + const afterSummary = html.slice(summaryStart + summaryMarker.length) + const formIdx = afterSummary.indexOf('
') + expect(summaryInner).toContain('
') + expect(summaryInner).toContain('
') + expect(summaryInner.indexOf('summary-details-carry-over-plan')).toBeLessThan(summaryInner.indexOf('summary-details-completed-tasks')) + expect(summaryInner.indexOf('summary-details-completed-tasks')).toBeLessThan(summaryInner.indexOf('summary-details-events')) + }) + + it('should list wins first then "other completed task(s)" heading and other dones (daily, each once)', () => { + const raw = `Mood: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + ['* >> Planned win @done(2026-04-07)'], + ['Plain task @done(2026-04-07)'], + '2026-04-07', + 'day', + [], + 'onReviewWindowAction', + 'Big Wins', + {}, + [], + ) + expect(html.indexOf('summary-content-wins')).toBe(-1) + expect(html).toContain('summary-content-completed-tasks') + expect(html).toMatch(/\bsummary-details-completed-wins\b/) + expect(html).toMatch(/\bsummary-details-completed-other\b/) + expect(html).toContain('
') + expect(html).toContain('
') + expect(html).toContain('
') + expect(html).toContain('1 other completed task') + expect(html).not.toMatch(/\b2 completed tasks\b/) + expect(html.indexOf('Planned win')).toBeLessThan(html.indexOf('other completed')) + expect(html.indexOf('other completed')).toBeLessThan(html.indexOf('Plain task')) + }) + + it('should not render completed-task summary blocks for quarterly reviews', () => { + const raw = `Mood: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + ['Quarter win @done(2026-04-07) #win'], + ['Quarter done @done(2026-04-07)'], + '2026-Q2', + 'quarter', + [], + 'onReviewWindowAction', + 'Goals', + {}, + [], + ) + expect(html).not.toMatch(/\bsummary-details-completed-tasks\b/) + }) + + it('should render single-item completed summary blocks as single-column', () => { + const raw = `Mood: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + ['* >> Planned win @done(2026-04-07)'], + [], + '2026-04-07', + 'day', + [], + 'onReviewWindowAction', + 'Big Wins', + {}, + [], + ) + expect(html).toContain('summary-content summary-content-single summary-content-completed-tasks') + }) + }) + + describe('splitMergedSummaryDoneLinesIntoWinsAndOthers', () => { + it('should split merged list after last leading win line', () => { + const merged = [ + '* >> Win @done(2026-04-08)', + 'Plain @done(2026-04-08)', + ] + const { wins, others } = splitMergedSummaryDoneLinesIntoWinsAndOthers(merged) + expect(wins).toEqual(['* >> Win @done(2026-04-08)']) + expect(others).toEqual(['Plain @done(2026-04-08)']) + }) + }) + + describe('mergeUniqueSummaryDoneTaskLines', () => { + it('should keep win order, drop duplicate keys, and omit lines matching carry-over text', () => { + const wins = ['* >> A @done(2026-04-08)', ' * >> A @done(2026-04-08) '] + const rest = ['B @done(2026-04-08)', 'B @done(2026-04-08)'] + expect(mergeUniqueSummaryDoneTaskLines(wins, rest, [])).toEqual(['* >> A @done(2026-04-08)', 'B @done(2026-04-08)']) + expect( + mergeUniqueSummaryDoneTaskLines(['x @done'], ['y'], [{ content: 'x @done', isDone: false }]), + ).toEqual(['y']) + }) + + it('should treat summaryTaskLineDedupeKey as trim-only', () => { + expect(summaryTaskLineDedupeKey(' hello ')).toBe('hello') + }) + }) + + describe('taskContentIsSummaryWin', () => { + it('should treat >> after optional task marker and priorities as a win', () => { + expect(taskContentIsSummaryWin('* >> Ship it @done(2026-04-08)')).toBe(true) + expect(taskContentIsSummaryWin('>> Ship it @done(2026-04-08)')).toBe(true) + expect(taskContentIsSummaryWin('! >> Ship @done(2026-04-08)')).toBe(true) + expect(taskContentIsSummaryWin('Regular task @done(2026-04-08)')).toBe(false) + }) + + it('should treat #win / #bigwin as wins regardless of >>', () => { + expect(taskContentIsSummaryWin('Launched #win @done(2026-04-08)')).toBe(true) + expect(taskContentIsSummaryWin('Big thing #bigwin @done(2026-04-08)')).toBe(true) + }) + + it('should respect configured big-task marker style for exclamation priorities', () => { + expect(taskContentIsSummaryWin('!!! Ship it @done(2026-04-08)', { bigTaskMarkerStyle: '!!! (priority 3)' })).toBe(true) + expect(taskContentIsSummaryWin('!! Ship it @done(2026-04-08)', { bigTaskMarkerStyle: '!! (priority 2)' })).toBe(true) + expect(taskContentIsSummaryWin('>> Ship it @done(2026-04-08)', { bigTaskMarkerStyle: '!! (priority 2)' })).toBe(false) + }) + }) + + describe('buildInitialReviewAnswersByFieldName', () => { + it('should extract int, duration, boolean, mood, and string answers from review-style lines', () => { + const config = { + dailyReviewQuestions: `Health: @sleep() @fruitveg() #bible #stretches #closedRings +Work: @work() @1CB() @CRC() +Mood: +Gratitude: +Not Great: `, + } + const questions = parseQuestions(config.dailyReviewQuestions) + const textLines = [ + 'Not great: something not great', + '### Journal', + 'Health: @sleep(7:32) @fruitveg(5) #bible #stretches #closedRings', + 'Work: @work(8) @1CB(1) @CRC(2)', + 'Mood: Calm', + 'Gratitude: Family time' + ] + const initial = buildInitialReviewAnswersByFieldName(questions, textLines) + expect(initial.q_0).toBe('7:32') + expect(initial.q_1).toBe('5') + expect(initial.q_2).toBe('yes') + expect(initial.q_3).toBe('yes') + expect(initial.q_4).toBe('yes') + expect(initial.q_5).toBe('8') + expect(initial.q_6).toBe('1') + expect(initial.q_7).toBe('2') + expect(initial.q_8).toBe('Calm') + expect(initial.q_9).toBe('Family time') + expect(initial.q_10).toBe('something not great') + }) + + it('should prefer the first matching line when several exist', () => { + const config = { dailyReviewQuestions: 'Gratitude: ' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['Gratitude: first', 'Gratitude: second'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('first') + }) + + it('should extract multiline bullets, checklists, and tasks for pre-fill', () => { + const config = { + dailyReviewQuestions: `Wins: +Shop: +Ship: `, + } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = [ + 'Wins: - a\n- b', + 'Shop: + eggs\n+ milk', + 'Ship: * task one\n* task two', + ] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('a\nb') + expect(initial.q_1).toBe('eggs\nmilk') + expect(initial.q_2).toBe('task one\ntask two') + }) + + it('should extract duration answers from review-style lines', () => { + const config = { dailyReviewQuestions: '@focus()' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['@focus(1:45)'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('1:45') + }) + + it('should extract token duration answers even when template segment has extra prefix text', () => { + const config = { dailyReviewQuestions: 'Health: @sleep() @fruitveg()' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['@sleep(7:52)'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('7:52') + }) + + it('should extract token int answers even when template segment has extra prefix text', () => { + const config = { dailyReviewQuestions: 'Health: @sleep() @fruitveg()' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['@fruitveg(5)'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_1).toBe('5') + }) + + it('should extract token number answers even when template segment has extra prefix text', () => { + const config = { dailyReviewQuestions: 'Stats: @weight()' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['@weight(6.8)'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('6.8') + }) + + it('should extract free-text after @token() when the note has only the string so far', () => { + // Template: first segment is "Programming: @prog()", second is bare "" with no prefix. + // Note line may exist before the user ever filled @prog(...). + const config = { dailyReviewQuestions: 'Programming: @prog() ' } + const questions = parseQuestions(config.dailyReviewQuestions) + expect(questions).toHaveLength(2) + expect(questions[0].type).toBe('number') + expect(questions[1].type).toBe('string') + + const lines = ["Programming: Things I've already noted."] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBeUndefined() + expect(initial.q_1).toBe("Things I've already noted.") + }) + + it('should extract both @prog number and trailing from a combined Programming line', () => { + const config = { dailyReviewQuestions: 'Programming: @prog() ' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ["Programming: @prog(2.5) Things I've already noted."] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('2.5') + expect(initial.q_1).toBe("Things I've already noted.") + }) + }) + + describe('buildOutputFromReviewWindowAnswers', () => { + beforeEach(() => { + global.Editor = { paragraphs: [] } + }) + + it('should build one line for a single string answer and append newline', () => { + const raw = 'Gratitude: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'Family' }) + expect(out).toBe('Gratitude: Family\n') + }) + + it('should substitute on template lines that have no questions', () => { + const raw = 'For: \nMood: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-W13', 'week', { q_0: 'Calm' }) + expect(out).toBe('For: 2026-W13\nMood: Calm\n') + }) + + it('should substitute for the following period (weekly rollover)', () => { + const raw = 'Next: \nMood: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2024-W52', 'week', { q_0: 'Calm' }) + expect(out).toBe('Next: 2025-W01\nMood: Calm\n') + }) + + it('should substitute like ', () => { + const raw = 'Next: \nMood: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2024-W52', 'week', { q_0: 'Calm' }) + expect(out).toBe('Next: 2025-W01\nMood: Calm\n') + }) + + it('should strip presentation delimiters before substituting ', () => { + const raw = 'Period: || (review)' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03', 'month', {}) + expect(out).toBe('Period: 2026-03 (review)\n') + }) + + it('should join multiple answers on the same line with a single space', () => { + const raw = 'Health: @sleep() @fruitveg()' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { + q_0: '7', + q_1: '3', + }) + expect(out).toBe('Health: @sleep(7) @fruitveg(3)\n') + }) + + it('should keep line label and free-text string when @prog number is empty', () => { + const raw = 'Programming: @prog() ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { + q_1: "Things I've already noted.", + }) + expect(out).toBe("Programming: Things I've already noted.\n") + }) + + it('should combine @prog number and free-text string on the Programming line', () => { + const raw = 'Programming: @prog() ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { + q_0: '2.5', + q_1: "Things I've already noted.", + }) + expect(out).toBe("Programming: @prog(2.5) Things I've already noted.\n") + }) + + it('should keep line label when only a later @token segment is answered', () => { + const raw = 'Health: @sleep() @fruitveg()' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { + q_1: '3', + }) + expect(out).toBe('Health: @fruitveg(3)\n') + }) + + it('should substitute answers into segments like ', () => { + const raw = 'Count: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: '42' }) + expect(out).toBe('Count: 42\n') + }) + + it('should output duration answers in [H]H:MM format', () => { + const raw = '@focus()' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: '1:30' }) + expect(out).toBe('@focus(1:30)\n') + }) + + it('should omit invalid duration answers', () => { + const raw = '@focus()' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: '1:75' }) + expect(out).toBe('') + }) + + it('should combine multiline bullet answers with newlines (inline tag replacement keeps prefix)', () => { + const raw = 'Wins: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'first win\nsecond win' }) + expect(out).toBe('Wins:\n- first win\n- second win\n') + }) + + it('should combine multiline checklist answers with newlines (inline tag replacement keeps prefix)', () => { + const raw = 'Shop: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'eggs\nmilk' }) + expect(out).toBe('Shop:\n+ eggs\n+ milk\n') + }) + + it('should combine multiline task answers with newlines (inline tag replacement keeps prefix)', () => { + const raw = 'Ship: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'task one\ntask two' }) + expect(out).toBe('Ship:\n* task one\n* task two\n') + }) + + it('should emit boolean tag when answer is true', () => { + const raw = '#bible' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: true }) + expect(out).toBe('#bible\n') + }) + + it('should omit line when boolean answer is false', () => { + const raw = '#bible' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: false }) + expect(out).toBe('') + }) + + it('should inject periodString after answers when the template line still contains ', () => { + const parsedQuestions = [ + { question: 'Report', type: 'string', originalLine: 'Report : ', lineIndex: 0 }, + ] + const rawLines = ['Report : '] + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-Q1', 'quarter', { q_0: 'done' }) + expect(out).toBe('Report 2026-Q1: done\n') + }) + + it('should use submitted window answers even when the open note contains similar text', () => { + const raw = 'Gratitude test: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + global.Editor = { + paragraphs: [{ content: 'Gratitude test: from note' }], + } + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'from window' }) + expect(out).toBe('Gratitude test: from window\n') + }) + + it('should return empty string when there are no answers and no lines', () => { + const raw = 'Note: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', {}) + expect(out).toBe('') + }) + }) + + describe('string upsert helpers', () => { + it('should derive stable match key from a parsed question', () => { + const parsed = parseQuestions('Gratitude: ') + expect(getStringQuestionMatchKeyFromParsedQuestion(parsed[0])).toBe('gratitude:') + }) + + it('should return a matching key from an output line', () => { + const parsed = parseQuestions('Gratitude: \nLearn: ') + const key = getStringQuestionMatchKeyFromOutputLine('Gratitude: new answer', parsed) + expect(key).toBe('gratitude:') + }) + + it('should return template-line key for mixed typed lines', () => { + const parsed = parseQuestions('Health: @sleep() @fruitveg() || #waterlitre ') + // Prefer the static line label so notes without every @token still match for upsert. + expect(getTemplateLineUpsertKey(parsed, 0)).toBe('health:') + expect(getTemplateLineUpsertKeyFromOutputLine('Health: @sleep(7:30) @fruitveg(5) #waterlitre', parsed)).toBe('health:') + expect(getTemplateLineUpsertKeyFromOutputLine("Programming: Things I've already noted.", parseQuestions('Programming: @prog() '))).toBe('programming:') + }) + + it('should return boolean clear directives for unchecked booleans', () => { + const parsed = parseQuestions('Health: @sleep() || #waterlitre || #bible ') + const directives = getBooleanClearDirectivesFromAnswers(parsed, { + q_0: '7:00', + q_1: false, + q_2: true, + }) + expect(directives.length).toBe(1) + expect(directives[0].lineKey).toBe('health:') + expect(directives[0].tokensToClear).toEqual(['#waterlitre']) + }) + + it('should return clear directives for unchecked non-hashtag boolean question text', () => { + const parsed = parseQuestions('Health: @sleep() || Did stretches') + const directives = getBooleanClearDirectivesFromAnswers(parsed, { + q_0: '7:00', + q_1: false, + }) + expect(directives.length).toBe(1) + expect(directives[0].lineKey).toBe('health:') + expect(directives[0].tokensToClear).toEqual(['Did stretches']) + }) + }) + + describe('partitionReviewAnswerLinesForMixedUpsert', () => { + it('should update existing matching line and append unmatched lines', () => { + const parsedQuestions = parseQuestions('Gratitude: \nLearn: ') + const paragraphs = [ + { type: 'title', content: 'Review', lineIndex: 2, headingLevel: 2 }, + { type: 'text', content: 'Gratitude: old', lineIndex: 3 }, + { type: 'text', content: 'Other note line', lineIndex: 4 }, + { type: 'title', content: 'Next Section', lineIndex: 5, headingLevel: 2 }, + ] + const answerLines = ['Gratitude: new', 'Learn: new'] + const { updates, appendLines } = partitionReviewAnswerLinesForMixedUpsert( + paragraphs, + 'Review', + answerLines, + parsedQuestions, + ) + expect(updates.length).toBe(1) + expect(updates[0].para.lineIndex).toBe(3) + expect(updates[0].content).toBe('Gratitude: new') + expect(appendLines).toEqual(['Learn: new']) + }) + + it('should upsert mixed Health line and clear unchecked boolean tokens, including || separators', () => { + const raw = 'Health: @sleep() @fruitveg() || #waterlitre || #bible ' + const parsedQuestions = parseQuestions(raw) + const directives = getBooleanClearDirectivesFromAnswers(parsedQuestions, { + q_0: '7:20', + q_1: '4', + q_2: false, + q_3: true, + }) + const paragraphs = [ + { type: 'title', content: 'Review', lineIndex: 2, headingLevel: 2 }, + { type: 'text', content: 'Health: @sleep(6:30) @fruitveg(2) #waterlitre #bible', lineIndex: 3 }, + { type: 'title', content: 'Next Section', lineIndex: 5, headingLevel: 2 }, + ] + const answerLines = ['Health: @sleep(7:20) @fruitveg(4) #bible'] + const { updates, appendLines } = partitionReviewAnswerLinesForMixedUpsert( + paragraphs, + 'Review', + answerLines, + parsedQuestions, + directives, + ) + expect(updates.length).toBe(1) + expect(updates[0].content).toBe('Health: @sleep(7:20) @fruitveg(4) #bible') + expect(appendLines).toEqual([]) + }) + + it('should upsert mixed Health line and clear unchecked boolean tokens, WITHOUT || separators, and varying order', () => { + const raw = 'Health: @sleep() @fruitveg() #waterlitre #bible' + const parsedQuestions = parseQuestions(raw) + const directives = getBooleanClearDirectivesFromAnswers(parsedQuestions, { + q_0: '7:20', + q_1: '4', + q_2: false, + q_3: true, + }) + const paragraphs = [ + { type: 'title', content: 'Review', lineIndex: 2, headingLevel: 2 }, + { type: 'text', content: 'Health: @sleep(6:30) @fruitveg(2) #waterlitre #bible', lineIndex: 3 }, + { type: 'title', content: 'Next Section', lineIndex: 5, headingLevel: 2 }, + ] + const answerLines = ['Health: @sleep(7:20) @fruitveg(4) #bible'] + const { updates, appendLines } = partitionReviewAnswerLinesForMixedUpsert( + paragraphs, + 'Review', + answerLines, + parsedQuestions, + directives, + ) + expect(updates.length).toBe(1) + expect(updates[0].content).toBe('Health: @sleep(7:20) @fruitveg(4) #bible') + expect(appendLines).toEqual([]) + }) + + it('should clear unchecked boolean token from existing mixed line even when no non-boolean answers are emitted', () => { + const raw = 'Health: @sleep() || #waterlitre ' + const parsedQuestions = parseQuestions(raw) + const directives = getBooleanClearDirectivesFromAnswers(parsedQuestions, { + q_1: false, + }) + const paragraphs = [ + { type: 'title', content: 'Review', lineIndex: 2, headingLevel: 2 }, + { type: 'text', content: 'Health: @sleep(6:30) #waterlitre', lineIndex: 3 }, + { type: 'title', content: 'Next Section', lineIndex: 5, headingLevel: 2 }, + ] + const { updates, appendLines } = partitionReviewAnswerLinesForMixedUpsert( + paragraphs, + 'Review', + [], + parsedQuestions, + directives, + ) + expect(updates.length).toBe(1) + expect(updates[0].content).toBe('Health: @sleep(6:30)') + expect(appendLines).toEqual([]) + }) + + it('should clear unchecked non-hashtag boolean phrase from existing mixed line', () => { + const raw = 'Health: @sleep() || Did stretches' + const parsedQuestions = parseQuestions(raw) + const directives = getBooleanClearDirectivesFromAnswers(parsedQuestions, { + q_1: false, + }) + const paragraphs = [ + { type: 'title', content: 'Review', lineIndex: 2, headingLevel: 2 }, + { type: 'text', content: 'Health: @sleep(6:30) Did stretches', lineIndex: 3 }, + { type: 'title', content: 'Next Section', lineIndex: 5, headingLevel: 2 }, + ] + const { updates } = partitionReviewAnswerLinesForMixedUpsert( + paragraphs, + 'Review', + [], + parsedQuestions, + directives, + ) + expect(updates.length).toBe(1) + expect(updates[0].content).toBe('Health: @sleep(6:30)') + }) + }) + + describe('planning helpers', () => { + it('buildNextPlanSectionHeadingTitle should format review-window planning block title', () => { + expect(buildNextPlanSectionHeadingTitle('Top 3 Wins', 'week')).toBe('Planning: Top 3 Wins for the next week') + expect(buildNextPlanSectionHeadingTitle('Big 3 Rocks', 'day')).toBe('Planning: Big 3 Rocks for the next day') + expect(buildNextPlanSectionHeadingTitle('Goals', 'quarter')).toBe('Planning: Goals for the next quarter') + expect(buildNextPlanSectionHeadingTitle('', 'day')).toBe('Planning for the next day') + expect(buildNextPlanSectionHeadingTitle(' ', 'week')).toBe('Planning for the next week') + }) + + it('buildNextPeriodNotePlanSectionHeadingTitle should use plan name and target period calendar title', () => { + expect(buildNextPeriodNotePlanSectionHeadingTitle('Top 3 Wins', '2026-W14')).toBe('Top 3 Wins for 2026-W14') + expect(buildNextPeriodNotePlanSectionHeadingTitle('Big Rocks', '2026-04-04')).toBe('Big Rocks for 2026-04-04') + expect(buildNextPeriodNotePlanSectionHeadingTitle('Goals', '2026-Q2')).toBe('Goals for 2026-Q2') + expect(buildNextPeriodNotePlanSectionHeadingTitle('', '2026-04-04')).toBe('') + expect(buildNextPeriodNotePlanSectionHeadingTitle(' ', '2026-W14')).toBe('') + }) + + it('getPlanItemsNameForPeriodType should use defaults when missing, blank when empty string', () => { + const minimal = {} + expect(getPlanItemsNameForPeriodType(minimal, 'day')).toBe('Big Rocks') + expect(getPlanItemsNameForPeriodType(minimal, 'week')).toBe('Top Wins') + const custom = { weekPlanItemsName: 'Wins' } + expect(getPlanItemsNameForPeriodType(custom, 'week')).toBe('Wins') + expect(getPlanItemsNameForPeriodType({ dayPlanItemsName: '' }, 'day')).toBe('') + expect(getPlanItemsNameForPeriodType({ dayPlanItemsName: ' ' }, 'day')).toBe('') + expect(getPlanItemsNameForPeriodType({ weekPlanItemsName: '' }, 'week')).toBe('') + }) + + it('normalizePlanningTaskLinesFromForm should strip only the configured marker', () => { + expect(normalizePlanningTaskLinesFromForm('', '>>')).toEqual([]) + expect(normalizePlanningTaskLinesFromForm(' a \n\n* >> b', '>>')).toEqual(['a', 'b']) + expect(normalizePlanningTaskLinesFromForm('>> solo', '>>')).toEqual(['solo']) + expect(normalizePlanningTaskLinesFromForm('!!! p3', '>>')).toEqual(['!!! p3']) + expect(normalizePlanningTaskLinesFromForm('!! p2', '>>')).toEqual(['!! p2']) + expect(normalizePlanningTaskLinesFromForm('!!! p3', '!!!')).toEqual(['p3']) + expect(normalizePlanningTaskLinesFromForm('!! p2', '!!')).toEqual(['p2']) + }) + + it('should resolve marker and numeric priority from bigTaskMarkerStyle setting', () => { + expect(getBigTaskMarkerFromConfig({})).toBe('>>') + expect(getBigTaskPriorityFromConfig({})).toBe(4) + expect(getBigTaskMarkerFromConfig({ bigTaskMarkerStyle: '!!! (priority 3)' })).toBe('!!!') + expect(getBigTaskPriorityFromConfig({ bigTaskMarkerStyle: '!!! (priority 3)' })).toBe(3) + expect(getBigTaskMarkerFromConfig({ bigTaskMarkerStyle: '!! (priority 2)' })).toBe('!!') + expect(getBigTaskPriorityFromConfig({ bigTaskMarkerStyle: '!! (priority 2)' })).toBe(2) + }) + + it('formatPlannedItemLineForNextNote should apply required marker prefix + optional suffix with sensible spacing', () => { + expect(formatPlannedItemLineForNextNote('foo', '>> ', '#win')).toBe('>> foo #win') + expect(formatPlannedItemLineForNextNote('foo', '>> ', null)).toBe('>> foo') + expect(formatPlannedItemLineForNextNote('foo', '>>', '#win')).toBe('>> foo #win') + expect(formatPlannedItemLineForNextNote('foo', '!!', '')).toBe('!! foo') + }) + + it('getEffectivePlannedItemAffixes should default missing suffix to empty and treat blank suffix as disabled', () => { + expect(getEffectivePlannedItemAffixes({})).toEqual({ suffix: '' }) + expect( + getEffectivePlannedItemAffixes({ + plannedItemsSuffix: '', + }), + ).toEqual({ + suffix: null, + }) + expect( + getEffectivePlannedItemAffixes({ + plannedItemsSuffix: '#win', + }), + ).toEqual({ + suffix: '#win', + }) + }) + + it('extractPlanSectionItems should read open and done tasks under matching H2', () => { + const heading = 'Top 3 Wins for the next week' + const note = { + paragraphs: [ + { type: 'title', headingLevel: 2, content: heading, lineIndex: 0 }, + { type: 'open', content: '* >> First', lineIndex: 1 }, + { type: 'done', content: '* >> Second @done(2026-03-30)', lineIndex: 2 }, + ], + } + const items = extractPlanSectionItems(note, heading) + expect(items.length).toBe(2) + expect(items[0].isDone).toBe(false) + expect(items[1].isDone).toBe(true) + expect(items[0].content).toContain('First') + }) + + it('extractPlanSectionItems should include cancelled >> tasks as not done (summary treats like open)', () => { + const note = { + paragraphs: [ + { type: 'title', headingLevel: 2, content: 'Home', lineIndex: 0 }, + { type: 'cancelled', content: '>> Sort new Santander account', lineIndex: 1 }, + { type: 'done', content: 'Update milk order again @done(2026-04-12 00:05)', lineIndex: 2 }, + ], + } + const items = extractPlanSectionItems(note, '') + expect(items.length).toBe(1) + expect(items[0].isDone).toBe(false) + expect(items[0].content).toContain('Sort new Santander') + }) + + it('extractPlanSectionItems should use configured priority for fallback marker matching', () => { + const note = { + paragraphs: [ + { type: 'title', headingLevel: 2, content: 'Home', lineIndex: 0 }, + { type: 'cancelled', content: '!!! Priority 3 goal', lineIndex: 1 }, + { type: 'done', content: 'Update milk order again @done(2026-04-12 00:05)', lineIndex: 2 }, + ], + } + const items = extractPlanSectionItems(note, '', { bigTaskMarkerStyle: '!!! (priority 3)' }) + expect(items.length).toBe(1) + expect(items[0].isDone).toBe(false) + expect(items[0].content).toContain('Priority 3 goal') + }) + }) +}) diff --git a/jgclark.PeriodicReviews/calendar-settings@2x.png b/jgclark.PeriodicReviews/calendar-settings@2x.png new file mode 100644 index 000000000..d857a0504 Binary files /dev/null and b/jgclark.PeriodicReviews/calendar-settings@2x.png differ diff --git a/jgclark.PeriodicReviews/daily-review-2.0.0.b7@2x.png b/jgclark.PeriodicReviews/daily-review-2.0.0.b7@2x.png new file mode 100644 index 000000000..233b49a1a Binary files /dev/null and b/jgclark.PeriodicReviews/daily-review-2.0.0.b7@2x.png differ diff --git a/jgclark.PeriodicReviews/plugin.json b/jgclark.PeriodicReviews/plugin.json new file mode 100644 index 000000000..12cbedc50 --- /dev/null +++ b/jgclark.PeriodicReviews/plugin.json @@ -0,0 +1,430 @@ +{ + "noteplan.minAppVersion": "3.20.0", + "macOS.minVersion": "10.13.0", + "plugin.id": "jgclark.PeriodicReviews", + "plugin.name": " Periodic Reviews", + "plugin.description": "This plugin makes it easier for you to review your days/weeks/months/quarters/years into NotePlan. It requires some configuration before use: please see the documentation for details.", + "plugin.icon": "calendar-days", + "plugin.iconColor": "purple-500", + "plugin.author": "Jonathan Clark", + "plugin.url": "https://noteplan.co/plugins/jgclark.PeriodicReviews/", + "plugin.changelog": "https://noteplan.co/plugins/jgclark.PeriodicReviews/CHANGELOG.md", + "plugin.version": "2.0.0.b15", + "plugin.releaseStatus": "beta", + "plugin.lastUpdateInfo": "v2.0.0: First release of the new Periodic Reviews plugin, building on earlier 'Journalling Helpers' plugin (v1).", + "plugin.requiredFiles": [ + "reviews.css" + ], + "plugin.requiredSharedFiles": [ + "fontawesome.css", + "regular.min.flat4NP.css", + "solid.min.flat4NP.css", + "fa-regular-400.woff2", + "fa-solid-900.woff2", + "pluginToHTMLCommsBridge.js" + ], + "plugin.script": "script.js", + "plugin.isRemote": "false", + "plugin.commands": [ + { + "name": "Daily Review", + "alias": [ + "dr", + "journal", + "review" + ], + "description": "Ask Review questions at end-of-day", + "jsFunction": "dailyReviewQuestions", + "sidebarView": { + "windowID": "jgclark.PeriodicReviews.dayReview", + "title": "Daily Review", + "icon": "fa-calendar-days", + "iconColor": "purple-500" + } + }, + { + "name": "Weekly Review", + "alias": [ + "wr", + "journal", + "review" + ], + "description": "Ask Review questions at end-of-week", + "jsFunction": "weeklyReviewQuestions", + "sidebarView": { + "windowID": "jgclark.PeriodicReviews.weekReview", + "title": "Weekly Review", + "icon": "fa-calendar-week", + "iconColor": "purple-500" + } + }, + { + "name": "Monthly Review", + "alias": [ + "mr", + "journal", + "review" + ], + "description": "Ask Review questions at end-of-month", + "jsFunction": "monthlyReviewQuestions" + }, + { + "name": "Quarterly Review", + "alias": [ + "qr", + "journal", + "review" + ], + "description": "Ask Review questions at end-of-quarter", + "jsFunction": "quarterlyReviewQuestions" + }, + { + "name": "Yearly Review", + "alias": [ + "yr", + "journal", + "review" + ], + "description": "Ask Review questions at end-of-year", + "jsFunction": "yearlyReviewQuestions" + }, + { + "hidden": true, + "name": "onReviewWindowAction", + "description": "Callback path for review UI", + "jsFunction": "onReviewWindowAction", + "parameters": [ + "actionName ('cancel' or 'submit')", + "payload (object or string)" + ] + } + ], + "plugin.commands_disabled": [ + { + "name": "PeriodicReviews: update plugin settings", + "description": "Settings interface (even for iOS)", + "jsFunction": "updateSettings" + }, + { + "hidden": true, + "name": "PeriodicReviews: onUpdateOrInstall", + "description": "onUpdateOrInstall", + "jsFunction": "onUpdateOrInstall" + }, + { + "name": "dayStart", + "alias": [ + "daily", + "ds", + "startDay" + ], + "description": "Apply Daily Note template to the current daily note", + "jsFunction": "dayStart" + }, + { + "name": "dayEnd", + "alias": [ + "daily", + "de", + "endDay" + ], + "description": "Apply Day End template to the current daily note", + "jsFunction": "dayEnd" + }, + { + "name": "todayStart", + "alias": [ + "day", + "today", + "ts" + ], + "description": "Apply Daily Note template to Today's note", + "jsFunction": "todayStart" + }, + { + "name": "todayEnd", + "alias": [ + "day", + "endToday", + "te" + ], + "description": "Apply Day End template to Today's note", + "jsFunction": "todayEnd" + }, + { + "name": "weekStart", + "alias": [ + "weekly", + "ws" + ], + "description": "Apply Week End template to the current weekly note", + "jsFunction": "weekStart" + }, + { + "name": "weekEnd", + "alias": [], + "description": "Apply Week End template to the current weekly note", + "jsFunction": "weekStart" + }, + { + "name": "monthStart", + "alias": [ + "monthly", + "ms" + ], + "description": "Apply Monthly Note template to the current monthly note", + "jsFunction": "monthStart" + }, + { + "name": "monthEnd", + "alias": [], + "description": "Apply Month End template to the current monthly note", + "jsFunction": "monthEnd" + } + ], + "plugin.settings": [ + { + "type": "heading", + "title": "Periodic Review Settings" + }, + { + "key": "preferredWindowType", + "title": "Review Window type", + "description": "Choose what style of window to use for the review questions: 'New Window' for a separate window; 'Main Window' to take over the main window; 'Split View' for a split view in the main window.", + "type": "string", + "choices": [ + "Main Window", + "New Window", + "Split View" + ], + "default": "New Window", + "required": true + }, + { + "key": "openCalendarNoteWhenReviewing", + "title": "Open the calendar note when reviewing it?", + "description": "If true, the plugin automatically opens the current daily/weekly/monthly/quarterly/yearly note before running review questions.", + "type": "bool", + "default": true, + "required": true + }, + { + "key": "calendarSet", + "title": "Calendars to include in review summaries", + "description": "Comma-separated list of calendar names to include in review summaries. If empty, no filtering will be done, and so all calendars will be included.", + "type": "[string]", + "default": [], + "required": false + }, + { + "key": "moods", + "title": "List of moods", + "description": "(Optional.) A comma-separated list of possible moods to select from.", + "type": "string", + "default": "🤩 Great,🙂 Good,😇 Blessed,🥱 Tired,😫 Stressed,😤 Frustrated,😔 Low,🥵 Sick", + "required": false + }, + { + "key": "dailyJournalSectionHeading", + "title": "Daily Journal Section Heading", + "description": "The name of a section heading after which Daily Review/Journal answers are added - if it doesn't exist, it is added at the end of the note.", + "type": "string", + "default": "Journal", + "required": true + }, + { + "key": "reviewSectionHeading", + "title": "Review Section Heading", + "description": "The name of a section heading after which weekly/monthly/quarterly/yearly Review answers are added - if it doesn't exist, it is added at the end of the note.", + "type": "string", + "default": "Review", + "required": true + }, + { + "key": "dailyReviewQuestions", + "title": "Daily Review/Journal Questions", + "description": "Multi-line string that includes both the Journal/Review questions and how to lay out the answers in the daily note.\nThe special codes that define the type of question asked are '' (or ''), '', '' ([H]H:MM), '', '', '', '', '', and '' (replaced with the review note title) and '' or '' (the following calendar period in the same format).\nUse '||' to separate multiple questions on a single line.\nFor s, its question text is the word or phrase that comes before it.\nHeadings: use literal '##'/'###' lines or ''.", + "type": "string", + "default": "@sleep()\n@work()\n@fruitveg()\nMood: \nGratitude: \nGod was: \nAlive: \nNot Great: \nLearn: \nRemember: ", + "required": false + }, + { + "key": "dayPlanItemsName", + "title": "Daily Planned items heading", + "description": "Used in the review window and as the H2 title prefix for planned tasks written to the next day's note (e.g. 'Big Wins' becomes 'Big Wins for YYYY-MM-DD'). Leave blank to write planned items only, with no heading.", + "type": "string", + "default": "Big Wins", + "required": false + }, + { + "key": "weeklyReviewQuestions", + "title": "Weekly Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the weekly note.\nSee 'Daily Review/ Journal Questions' above for details.", + "type": "string", + "default": "## Weekly Review for \nWins: \nNew or improved: \nChallenges: ", + "required": false + }, + { + "key": "weekPlanItemsName", + "title": "Weekly Planned items heading", + "description": "Used in the review window and as the H2 title prefix for planned tasks written to the next week's note (e.g. 'Big Rocks' becomes 'Big Rocks for 2026-W15'). Leave blank to write planned items only, with no heading.", + "type": "string", + "default": "Big Rocks", + "required": false + }, + { + "key": "monthlyReviewQuestions", + "title": "Monthly Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the monthly note.\nSee 'Daily Review/ Journal Questions' above for details.", + "type": "string", + "default": "## Monthly Review for \nWhat's working well in processes: \nProcesses that need work: \nPersonal Goals progress: ", + "required": false + }, + { + "key": "monthPlanItemsName", + "title": "Name for Monthly Planned items", + "description": "Used in the review window and as the H2 title prefix for planned items written to the next month's note (e.g. 'Key Outcomes' becomes 'Key Outcomes for 2026-04'). Leave blank to write planned items only, with no heading.", + "type": "string", + "default": "Key Outcomes", + "required": false + }, + { + "key": "quarterlyReviewQuestions", + "title": "Quarterly Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the quarterly note.\nSee 'Daily Review/ Journal Questions' above for details.", + "type": "string", + "default": "## Quarterly Review for \nGoals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", + "required": false + }, + { + "key": "quarterPlanItemsName", + "title": "Name for Quarterly Planned items", + "description": "Used in the review window and as the H2 title prefix for planned items written to the next quarter's note (e.g. 'Goals' becomes 'Goals for 2026-Q2'). Leave blank to write planned items only, with no heading.", + "type": "string", + "default": "Goals", + "required": false + }, + { + "key": "yearlyReviewQuestions", + "title": "Yearly Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the yearly note.\nSee 'Daily Review/ Journal Questions' above for details.", + "type": "string", + "default": "## Yearly Review for \nMajor events in the year: \nThemes/focus areas for next year: ", + "required": false + }, + { + "key": "yearPlanItemsName", + "title": "Name for Yearly Planned items", + "description": "Used in the review window and as the H2 title prefix for planned items written to the next year's note (e.g. 'Theme' becomes 'Theme for 2027'). Leave blank to write planned items only, with no heading.", + "type": "string", + "default": "Theme", + "required": false + }, + { + "key": "bigTaskMarkerStyle", + "title": "Big task marker style", + "description": "Choose which marker indicates major planned tasks/goals in notes and review summaries.", + "type": "string", + "choices": [ + ">> (priority 4)", + "!!! (priority 3)", + "!! (priority 2)" + ], + "default": ">> (priority 4)", + "required": true + }, + { + "key": "plannedItemsSuffix", + "title": "Planned items suffix (for next period note)", + "description": "String added to the end of each planned item when writing them into the next period's calendar note. Optional.", + "type": "string", + "default": "#win", + "required": false + }, + { + "type": "separator" + }, + { + "type": "heading", + "title": "Templates for Start/End of Period Notes" + }, + { + "key": "startDailyTemplateTitle", + "title": "Start-of-Day Template Title", + "description": "The name of the template that `/dayStart` and `/todayStart` commands will use.", + "type": "string", + "default": "Daily Note Template", + "required": false + }, + { + "key": "endDailyTemplateTitle", + "title": "End-of-Day Template Title", + "description": "The name of the template that `/dayEnd` and `/todayEnd` commands will use.", + "type": "string", + "default": "Daily Review Template", + "required": false + }, + { + "key": "startWeeklyTemplateTitle", + "title": "Start-of-Week Template Title", + "description": "Optional name of the template that `/weekStart` command will use.", + "type": "string", + "default": "Weekly Note Template", + "required": false + }, + { + "key": "endWeeklyTemplateTitle", + "title": "End-of-Week Template Title", + "description": "Optional name of the template that `/weekEnd` command will use.", + "type": "string", + "default": "Weekly Review Template", + "required": false + }, + { + "key": "startMonthlyTemplateTitle", + "title": "Start-of-Month Template Title", + "description": "Optional name of the template that `/monthStart` command will use.", + "type": "string", + "default": "Monthly Note Template", + "required": false + }, + { + "key": "endMonthlyTemplateTitle", + "title": "End-of-Month Template Title", + "description": "Optional name of the template that `/monthEnd` command will use.", + "type": "string", + "default": "Monthly Review Template", + "required": false + }, + { + "type": "separator" + }, + { + "type": "heading", + "title": "Debugging" + }, + { + "key": "_logLevel", + "title": "Log Level", + "description": "Set how much output will be displayed for this plugin in the NotePlan > Help > Plugin Console. DEBUG is the most verbose; NONE is the least (silent).", + "type": "string", + "choices": [ + "DEBUG", + "INFO", + "WARN", + "ERROR", + "none" + ], + "default": "INFO", + "required": true + }, + { + "key": "newInstall", + "title": "newInstall", + "description": "Indicates whether the plugin has been newly installed. Used by onUpdateOrInstall() to help migration.", + "type": "hidden", + "default": true, + "required": true + } + ] +} \ No newline at end of file diff --git a/jgclark.PeriodicReviews/requiredFiles/reviews.css b/jgclark.PeriodicReviews/requiredFiles/reviews.css new file mode 100644 index 000000000..eceb37600 --- /dev/null +++ b/jgclark.PeriodicReviews/requiredFiles/reviews.css @@ -0,0 +1,317 @@ +/* last update 2026-04-26 for v2.0.0.b12 by @jgclark */ + +body { + color: var(--fg-main-color, #4c4f69); + background-color: var(--bg-main-color, #eff1f5); + margin: 1.0rem; + + /* Basic common HTML 'resets' */ + scrollbar-gutter: stable; + interpolate-size: allow-keywords; +} + +.section-wrap { + padding: 0.6rem 1rem; + border: 1px solid var(--divider-color, #CDCFD0); + border-radius: 6px; + background: var(--bg-alt-color, #e6e9ef); + color: var(--fg-main-color, #4c4f69); + margin: 0.5rem 0rem; + + /* Override default markdown headings on descendants of .section-wrap */ + .h2 { + font-size: 1.22rem; + padding: 0rem 0.1rem 0.4rem; + background-color: unset; + border: none; + } + .h3 { + font-size: 1.1rem; + padding: 0rem 0.1rem 0.2rem; + background-color: unset; + border: none; + } +} + +.review-title-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin: 0 0 1rem 0; + padding: 0 0.25rem 0 0.75rem; +} + +.review-title-row-actions { + flex-shrink: 0; +} + +.review-title { + margin: 0; + flex: 1 1 auto; + min-width: 0; + color: var(--h2-color, #5c5f77); +} + +.review-title-period-with-nav { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.review-period-step-button { + padding: 0rem 0.2rem; + font-weight: 600; +} + +.summary { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.summary-title { + font-weight: 600; + color: var(--h3-color, #5c5f77); + padding: 0rem 0rem 0.4rem 0rem; +} + +/* Collapsible summary lists (`
` / `` in review HTML) */ +.summary-details { + margin: 0 0 0.35rem 0; +} + +.summary-details > summary.summary-title { + cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + width: 100%; + box-sizing: border-box; + list-style: none; +} + +/* Native triangle is tiny or missing in some WebViews; use a right-side chevron instead */ +.summary-details > summary.summary-title::-webkit-details-marker { + display: none; +} + +.summary-details > summary.summary-title::after { + content: '\25B8'; + flex-shrink: 0; + font-size: 1.5rem; + line-height: 1; + opacity: 0.75; + color: var(--item-icon-color, #1e66f5); + transition: transform 0.15s ease; +} + +.summary-details[open] > summary.summary-title::after { + transform: rotate(90deg); +} + +.summary-content { + column-count: 2; + column-gap: 1rem; + font-size: 0.85rem; + margin-left: 0rem; + margin-bottom: 0.35rem; +} + +.summary-content-single { + column-count: 1; +} + +.summary-item { + break-inside: avoid; + display: flex; + align-items: baseline; + gap: 0.4rem; + margin-bottom: 0.15rem; +} + +.summary-item-icon { + flex-shrink: 0; + width: 1.1rem; + text-align: center; +} + +.summary-item-incomplete-icon { + color: var(--item-icon-color); + line-height: 1.2; + flex-shrink: 0; +} + +.item-completed-icon { + color: var(--fg-done-color); + line-height: 1.2; + flex-shrink: 0; +} + +.event-icon { + color: var(--fg-placeholder-color); + line-height: 1.2; + flex-shrink: 0; +} + +.summary-item-text { + display: block; + flex: 1 1 auto; + min-width: 0; + line-height: 1.25; + white-space: normal; + word-break: break-word; +} + +.summary-item-text .hashtag { + color: var(--hashtag-color, inherit); +} + +.summary-item-text .attag { + color: var(--attag-color, inherit); +} + +.summary-empty { + color: var(--fg-placeholder-color, rgba(76, 79, 105, 0.7)); +} + +.plan-title { + /** placeholder for future use */ +} + +.review-form { + display: flex; + flex-direction: column; + /* gap: 0.7rem; */ +} + +/* Used by whole-row questions, which require the label coming before them */ +.review-question-line-block { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 0.1rem 0.5rem; + padding-bottom: 0.6rem; +} + +/* Used by question rows, where the label is inline */ +.review-question-line { + flex: 1 1 100%; +} + +/* .review-line-text-fragment { +} +*/ + +.review-line-segment { + align-items: center; +} + +.review-input-inline { + /* width: auto; + max-width: 6rem; + vertical-align: middle; */ + margin: 0rem 0.2rem; +} + +.review-row { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.25rem; +} +.review-row-inline { + flex-direction: row; + align-items: center; + gap: 0.5rem; +} +.review-label { + font-weight: 500; +} +.review-answer-inline { + flex: 0 0 auto; + display: flex; + align-items: center; +} +.review-answer { + width: 100%; +} +.review-input { + width: 100%; + resize: vertical; + font-family: "system-ui", sans-serif; + font-size: 0.9rem; +} +.review-input-short { + width: auto; + min-width: 2rem; + max-width: 4rem; + resize: none; +} +.review-input-fit { + width: fit-content; +} +.review-checkbox { + width: 1.1rem; + height: 1.1rem; + accent-color: var(--tint-color, #dc8a78); + cursor: pointer; +} +.review-actions { + display: flex; + justify-content: flex-end; + gap: 0.7rem; +} + +button, input, select, textarea { + font-size: 0.9rem; + font-family: "system-ui", sans-serif; + padding: 0.1rem 0.4rem; + color: var(--fg-main-color); + background-color: var(--bg-main-color); + border: 1px solid rgb(from var(--fg-main-color) r g b / 0.2); + border-radius: 4px; + white-space: nowrap; + cursor: pointer; + outline-offset: -1px; + outline-width: 1px; + box-sizing: border-box; + + /* set backgrounds a little lighter on hover */ + &:hover { + /* background-color: hsl(from var(--bg-sidebar-color) h s calc(l*1.4)); TODO(later): revert to this */ + /* background-color: color(var(--bg-sidebar-color) lightness(40%)); */ + filter: brightness(102%); + } +} + +/* Shared rule above uses white-space: nowrap for buttons/inputs; textareas must wrap. */ +textarea { + white-space: pre-wrap; + cursor: text; +} + +/* textarea, input, select { + border-radius: 4px; + border: 1px solid var(--divider-color, #CDCFD0); + background-color: var(--bg-apple-input-color, #fbfbfb); + color: var(--fg-main-color, #4c4f69); + padding: 0.2rem 0.2rem; +} */ + +.review-button { + font-size: 1.0rem; + font-weight: 500; + border-radius: 6px; + padding: 0.2rem 0.5rem; + box-shadow: 1px 1px 1px 0px rgb(from var(--fg-main-color) r g b / 0.2); +} +.review-button-primary { + border-color: var(--tint-color, #dc8a78); + color: var(--tint-color, #dc8a78); + font-weight: 600; +} diff --git a/jgclark.PeriodicReviews/review-window-questions@2x.png b/jgclark.PeriodicReviews/review-window-questions@2x.png new file mode 100644 index 000000000..552f63cd9 Binary files /dev/null and b/jgclark.PeriodicReviews/review-window-questions@2x.png differ diff --git a/jgclark.PeriodicReviews/settings-button@2x.png b/jgclark.PeriodicReviews/settings-button@2x.png new file mode 100644 index 000000000..21ab7d861 Binary files /dev/null and b/jgclark.PeriodicReviews/settings-button@2x.png differ diff --git a/jgclark.PeriodicReviews/src/index.js b/jgclark.PeriodicReviews/src/index.js new file mode 100644 index 000000000..3aa7ddf22 --- /dev/null +++ b/jgclark.PeriodicReviews/src/index.js @@ -0,0 +1,146 @@ +// @flow + +//--------------------------------------------------------------- +// Journalling commands +// Jonathan Clark +// last update 2026-04-11 for v2.0.0.b9 by @jgclark +//--------------------------------------------------------------- + +// allow changes in plugin.json to trigger recompilation +import pluginJson from '../plugin.json' +import { clo, compareObjects, JSP, logDebug, logInfo, logError } from "@helpers/dev" +import { backupSettings, getSettings, pluginUpdated, saveSettings } from '@helpers/NPConfiguration' +import { editSettings } from '@helpers/NPSettings' + +const pluginID = 'jgclark.PeriodicReviews' +const oldPluginID = 'jgclark.DailyJournal' + +export { + dailyReviewQuestions, + weeklyReviewQuestions, + monthlyReviewQuestions, + quarterlyReviewQuestions, + yearlyReviewQuestions, + onReviewWindowAction, +} from './periodReviews' + +export { + dayStart, + dayEnd, + todayStart, + todayEnd, + weekStart, + weekEnd, + monthStart, +} from './templatesStartEnd' + +// TODO(later): remove +// import { isEditorWindowOpen, isEditorWindowOpenByTitle } from '@helpers/NPWindows' +// export function testEditorOpen(): void { +// try { +// // Test 1 +// // const title = "2026-03-30" +// // const res = isEditorWindowOpenByTitle(title) +// // logInfo('testEditorOpen', `isEditorWindowOpenByTitle(${title}) => ${String(res)}`) + +// // Test 2 +// const title = "20260331.md" // "%%NotePlanCloud%%/1b91b194-4c76-4a48-8d4d-4c499d64a919/20260331.md" +// const res = isEditorWindowOpen(title) +// logInfo('testEditorOpen', `isEditorWindowOpen(${title}) => ${String(res)}`) +// } catch (error) { +// logError('testEditorOpen', error.message) +// } +// } + +export function init(): void { + try { + // Check for the latest version of the plugin, and if a minor update is available, install it and show a message + DataStore.installOrUpdatePluginsByID([pluginJson['plugin.id']], true, false, false) + } catch (error) { + logError(pluginJson, `init: ${JSP(error)}`) + } +} + +export async function onSettingsUpdated(): Promise { + // Placeholder only to stop error in logs +} + +export async function onUpdateOrInstall(): Promise { + try { + logDebug(pluginJson, `onUpdateOrInstall() ...`) + const initialNewPluginSettings = (await getSettings(pluginID, DataStore.settings)) || DataStore.settings + + if (initialNewPluginSettings.newInstall || initialNewPluginSettings.newInstall === undefined) { + logDebug(pluginID, `onUpdateOrInstall: first run after install or newInstall is undefined`) + // Deal with first run after install: if the old plugin exists, copy its matching settings. + const oldPluginSettings = await getSettings(oldPluginID, null) + const migratedSettings = { ...initialNewPluginSettings } + + if (oldPluginSettings && Object.keys(oldPluginSettings).length > 0) { + await backupSettings(pluginID, `before_migration_from_old_plugin_to_new_v${pluginJson['plugin.version']}`, true) + Object.keys(initialNewPluginSettings).forEach((key) => { + if (key !== 'newInstall' && oldPluginSettings.hasOwnProperty(key)) { + migratedSettings[key] = oldPluginSettings[key] + } + }) + clo(migratedSettings, `onUpdateOrInstall: migratedSettings from ${oldPluginID}:`) + } else { + logDebug(pluginID, `onUpdateOrInstall: no settings found to migrate from ${oldPluginID}`) + } + + // Ensure migration runs only once. + migratedSettings.newInstall = false + // Save any changes + const diff = compareObjects(migratedSettings, initialNewPluginSettings, [], true) + if (diff != null) { + logInfo(pluginID, `onUpdateOrInstall: first-run settings changes detected; saving`) + await saveSettings(pluginID, migratedSettings) + } else { + logDebug(pluginID, `onUpdateOrInstall: first-run settings unchanged`) + } + } + + // Migration safety for renamed heading setting: + // old "reviewSectionHeading" now maps to dailyJournalSectionHeading, + // while reviewSectionHeading is used for non-daily periods. + const latestSettings = (await getSettings(pluginID, DataStore.settings)) || DataStore.settings + const updatedSettings = { ...latestSettings } + const dailyHeading = String(updatedSettings.dailyJournalSectionHeading ?? '').trim() + const reviewHeading = String(updatedSettings.reviewSectionHeading ?? '').trim() + const needsDailyHeadingMigration = dailyHeading === '' && reviewHeading !== '' + const needsReviewHeadingDefault = reviewHeading === '' + if (needsDailyHeadingMigration || needsReviewHeadingDefault) { + if (needsDailyHeadingMigration) { + updatedSettings.dailyJournalSectionHeading = reviewHeading + } + if (needsReviewHeadingDefault) { + updatedSettings.reviewSectionHeading = dailyHeading !== '' ? dailyHeading : 'Review' + } + const migrationDiff = compareObjects(updatedSettings, latestSettings, [], true) + if (migrationDiff != null) { + logInfo(pluginID, 'onUpdateOrInstall: heading settings migration changes detected; saving') + await saveSettings(pluginID, updatedSettings) + } + } + + // Tell user the plugin has been updated + logInfo(pluginID, `... finished onUpdateOrInstall`) + await pluginUpdated(pluginJson, { code: 2, message: `Plugin Installed or Updated.` }) + } catch (error) { + logError(pluginID, `onUpdateOrInstall: ${JSP(error)}`) + } +} + +/** + * Update Settings/Preferences (for iOS etc) + * Plugin entrypoint for command: "/: Update Plugin Settings/Preferences" + * @author @dwertheimer + */ +export async function updateSettings() { + try { + logDebug(pluginJson, `updateSettings running`) + await editSettings(pluginJson) + } catch (error) { + logError(pluginJson, JSP(error)) + } +} diff --git a/jgclark.PeriodicReviews/src/periodReviews.js b/jgclark.PeriodicReviews/src/periodReviews.js new file mode 100644 index 000000000..2d09436dd --- /dev/null +++ b/jgclark.PeriodicReviews/src/periodReviews.js @@ -0,0 +1,921 @@ +// @flow +//--------------------------------------------------------------- +// Journalling commands +// Jonathan Clark +// last update 2026-08-11 for v2.0.0.b15 by @jgclark / @CursorAI +//--------------------------------------------------------------- + +import strftime from 'strftime' +import pluginJson from '../plugin.json' +import { + buildNextPeriodNotePlanSectionHeadingTitle, + formatPlannedItemLineForNextNote, + getBigTaskMarkerFromConfig, + getBigTaskPriorityFromConfig, + getEffectivePlannedItemAffixes, + getJournalSettings, + getPeriodAdjectiveFromType, + getPlanItemsNameForPeriodType, + getQuestionsForPeriod, + getReviewPeriodTitleStringFromCalendarNote, + getSectionHeadingForPeriod, + normalizePlanningTaskLinesFromForm, + shouldUseOpenEditorCalendarNote, + summaryTaskLineDedupeKey, + taskContentIsSummaryWin, +} from './periodicReviewHelpers' +export { taskContentIsSummaryWin } +import type { PeriodicReviewConfigType, ParsedQuestionType } from './periodicReviewHelpers' +import { + buildInitialReviewAnswersByFieldName, + buildOutputFromReviewWindowAnswers, + getBooleanClearDirectivesFromAnswers, + getTemplateLineUpsertKeyFromOutputLine, + parseQuestions, +} from './reviewQuestions' +import { stylesheetinksInHeader, faLinksInHeader, buildReviewHTML } from './reviewHTMLViewGenerator' +import { + // convertISOToYYYYMMDD, + getNextNPPeriodString, + getNPQuarterStr, + getPreviousNPPeriodString, + getWeek, + getPeriodOfNPDateStr, + RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, +} from '@helpers/dateTime' +import { clo, logDebug, logError, logWarn } from '@helpers/dev' +import { displayTitle } from '@helpers/general' +import { showHTMLV2 } from '@helpers/HTMLView' +import type { HtmlWindowOptions } from '@helpers/HTMLView' +import { getEventsForDay } from '@helpers/NPCalendar' +import { getFirstDateInPeriod, getLastDateInPeriod } from '@helpers/NPdateTime' +import { getNotesChangedInInterval } from '@helpers/NPnote' +import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' +import { closeWindowFromCustomId } from '@helpers/NPWindows' +import { isParaAMatchForHeading } from '@helpers/headings' +import { findEndOfActivePartOfNote, findHeading, findHeadingStartsWith, findStartOfActivePartOfNote } from '@helpers/paragraph' +import { escapeRegExp } from '@helpers/regex' +import { getNumericPriorityFromPara } from '@helpers/sorting' +import { getInput, showMessage } from '@helpers/userInput' + +//--------------------------------------------------------------- +// Constants & Types + +const REVIEW_WINDOW_CUSTOM_ID = 'jgclark.PeriodicReviews.period-review' +const REVIEW_WINDOW_CALLBACK_COMMAND = 'onReviewWindowAction' + +/** Paragraph types treated as tasks under a plan H2 (carry-over + rewrite). Includes cancelled so big-task plan lines stay in the summary as not done. */ +const PLAN_SECTION_PARA_TYPES: Set = new Set([ + 'open', + 'done', + 'scheduled', + 'cancelled', + 'checklist', + 'checklistDone', + 'checklistCancelled', + 'checklistScheduled', + 'list' +]) + +/** + * Find first matching H2 in the active part of the note. + * @param {TNote} note + * @param {string} headingTitle + * @returns {TParagraph | null} + */ +function findPlanSectionHeadingPara(note: TNote, headingTitle: string): TParagraph | null { + logDebug('findPlanSectionHeadingPara', `Looking for heading {${headingTitle}} ...`) + const paras = note.paragraphs ?? [] + const last = Math.min(findEndOfActivePartOfNote(note), paras.length - 1) + for (let i = 0; i <= last; i++) { + const p = paras[i] + if (p.type === 'title' && isParaAMatchForHeading(p, headingTitle, 2)) { + logDebug('', `- found line ${String(i)}: {${p.rawContent}} `) + return p + } + } + return null +} + +/** + * Heading plus body paragraphs until the next H1/H2-style break (level <= 2), for removal. + * @param {TNote} note + * @param {TParagraph} headingPara + * @returns {Array} + */ +function getParagraphsForPlanSection(note: TNote, headingPara: TParagraph): Array { + const toRemove: Array = [headingPara] + const paras = note.paragraphs ?? [] + const start = headingPara.lineIndex ?? 0 + for (let i = start + 1; i < paras.length; i++) { + const p = paras[i] + if (p.type === 'title' && (p.headingLevel ?? 99) <= 2) { + break + } + toRemove.push(p) + } + return toRemove +} + +/** + * Remove a plan H2 block (heading + body) if present. + * TODO: Probably can be simplified? + * @param {TNote} note + * @param {string} headingTitle + * @returns {void} + */ +function removePlanSectionFromNoteIfPresent(note: TNote, headingTitle: string): void { + const headingPara = findPlanSectionHeadingPara(note, headingTitle) + if (headingPara == null) { + return + } + const toRemove = getParagraphsForPlanSection(note, headingPara) + const sorted = [...toRemove].sort((a, b) => (b.lineIndex ?? 0) - (a.lineIndex ?? 0)) + for (const p of sorted) { + note.removeParagraph(p) + } +} + +/** + * Insert optional plan heading and open tasks at the start of the active body. + * Blank `headingTitle` skips the H2 and writes only task lines. + * Uses `insertParagraph(..., 'open')`, which adds the task `*` marker — pass full line text (prefix/suffix already applied), not a second task marker. + * @param {TNote} note + * @param {string} headingTitle empty string = no heading + * @param {Array} taskLines full paragraph content (not rawContent) per planned item (after prefix/suffix formatting) + * @returns {void} + */ +function insertPlanSectionAtActiveStart(note: TNote, headingTitle: string, taskLines: Array): void { + const startIdx = findStartOfActivePartOfNote(note) + if (Number.isNaN(startIdx)) { + logWarn(pluginJson, 'insertPlanSectionAtActiveStart: invalid start index') + return + } + const title = String(headingTitle ?? '').trim() + let taskInsertIdx = startIdx + if (title !== '') { + note.insertHeading(title, startIdx, 2) + taskInsertIdx = startIdx + 1 + } + for (let i = 0; i < taskLines.length; i++) { + note.insertParagraph(taskLines[i], taskInsertIdx + i, 'open') + } +} + +/** + * Calendar note whose title equals `title` (trimmed), if any. + * @param {string} title + * @returns {TNote | null} + */ +function getCalendarNoteByTitle(title: string): TNote | null { + const want = String(title).trim() + const notes = DataStore.calendarNotes ?? [] + for (const n of notes) { + if (String(n.title ?? '').trim() === want) { + return n + } + } + return null +} + +/** + * Return task lines for the review summary from: + * - the configured 'planName' section (if given) + * - any with the configured "big task marker" priority (if 'planName' is empty, or can't find the 'planName' section) + * Note: The heading match is partial + case insensitive. + * @tests in jest file + * @param {TNote} note + * @param {string} planName (e.g. 'Big Rocks', or empty) + * @returns {Array<{ content: string, isDone: boolean }>} + */ +export function extractPlanSectionItems( + note: TNote, + planName: string = '', + config?: any, +): Array<{ content: string, isDone: boolean }> { + const paras = note.paragraphs ?? [] + let start = findStartOfActivePartOfNote(note) + const end = findEndOfActivePartOfNote(note) + const out: Array<{ content: string, isDone: boolean }> = [] + + // Get relevant set of paras to parse + if (planName !== '') { + const headingPara = findHeading(note, planName, true) + if (headingPara != null) { + const heading = headingPara.content + logDebug('extractPlanSectionItems', `- matched heading '${heading}'`) + start = headingPara.lineIndex ?? 0 + logDebug('extractPlanSectionItems', `Found heading ${heading}, so processing lines ${String(start + 1)}-${String(end)}`) + for (let i = start + 1; i <= end; i++) { + const p = paras[i] + if (p.type === 'title' && (p.headingLevel ?? 99) <= 2) { + // We're now in a different section, so stop processing + break + } + if (!PLAN_SECTION_PARA_TYPES.has(String(p.type))) { + continue + } + const isDone = p.type === 'done' || p.type === 'checklistDone' + out.push({ content: p.content, isDone }) + } + return out + } else { + logDebug('extractPlanSectionItems', `Can't find a heading including '${planName}', so will now look for any other big-task items (${getBigTaskMarkerFromConfig(config)})`) + } + } + + const bigTaskPriority = getBigTaskPriorityFromConfig(config ?? {}) + logDebug('extractPlanSectionItems', `Will look for priority ${String(bigTaskPriority)} tasks in lines ${String(start + 1)}-${String(end)}`) + for (let i = start + 1; i <= end; i++) { + const p = paras[i] + if (!PLAN_SECTION_PARA_TYPES.has(String(p.type))) { + continue + } + if (getNumericPriorityFromPara(p) !== bigTaskPriority) { + continue + } + const isDone = p.type === 'done' || p.type === 'checklistDone' + out.push({ content: p.content, isDone }) + } + return out +} + +/** + * Write or clear planned tasks on the **next** calendar note. + * When a planned-items heading name is set: replace any existing H2 with that title, then insert heading + tasks at active start. + * When blank: write task lines only (no H2); does not remove a prior untitled block. + * @param {PeriodicReviewConfigType} config + * @param {string} periodString + * @param {string} periodType + * @param {string} planningFormText + * @returns {Promise} + */ +export async function writePlanningTasksToNextPeriodNote( + config: PeriodicReviewConfigType, + periodString: string, + periodType: string, + planningFormText: string, +): Promise { + try { + const nextTitle = getNextNPPeriodString(periodString, periodType) + if (nextTitle === '') { + logWarn(pluginJson, `writePlanningTasksToNextPeriodNote: empty next period for "${periodString}" (${periodType})`) + return + } + const planName = getPlanItemsNameForPeriodType(config, periodType) + const headingTitle = buildNextPeriodNotePlanSectionHeadingTitle(planName, nextTitle) + logDebug('writePlanningTasksToNextPeriodNote', `planName='${planName}' headingTitle='${headingTitle}' / nextTitle='${nextTitle}'`) + let nextNote: ?TNote = getCalendarNoteByTitle(nextTitle) + if (!nextNote) { + logDebug('writePlanningTasksToNextPeriodNote', `Note '${nextTitle}' not found, so opening it`) + await Editor.openNoteByTitle(nextTitle) + nextNote = getCalendarNoteByTitle(nextTitle) ?? Editor.note + } + if (!nextNote) { + logError(pluginJson, `writePlanningTasksToNextPeriodNote: could not open calendar note '${nextTitle}'`) + return + } + + const hasHeading = headingTitle !== '' + logDebug( + 'writePlanningTasksToNextPeriodNote', + `Note '${nextTitle}' opened; will now write (or replace) plan section${hasHeading ? ` heading '${headingTitle}'` : ' (no heading)'}`, + ) + const plannedPrefix = getBigTaskMarkerFromConfig(config) + const normalizedLines = normalizePlanningTaskLinesFromForm(planningFormText, plannedPrefix) + const { suffix: plannedSuffix } = getEffectivePlannedItemAffixes(config) + const formattedLines = normalizedLines.map((body) => formatPlannedItemLineForNextNote(body, plannedPrefix, plannedSuffix)) + // Only replace an existing section when we have a named H2 to match. + if (hasHeading) { + removePlanSectionFromNoteIfPresent(nextNote, headingTitle) + } + if (formattedLines.length > 0) { + insertPlanSectionAtActiveStart(nextNote, headingTitle, formattedLines) + } + DataStore.updateCache(nextNote, true) + } catch (err) { + logError(pluginJson, `writePlanningTasksToNextPeriodNote: ${err.message}`) + } +} + +//--------------------------------------------------------------- + +/** + * Shared entry for period review commands: resolve calendar title string, then open the review flow. + * @param {string} periodType + * @param {() => string} getPeriodString + * @returns {Promise} + */ +async function runReviewQuestionsForCurrentPeriod(periodType: string, getPeriodString: () => string): Promise { + try { + const thisPeriodStr = getPeriodString() + logDebug(pluginJson, `Starting for ${periodType} (currently ${thisPeriodStr})`) + await processReviewQuestions(thisPeriodStr, periodType, { preferOpenNoteOfMatchingPeriodType: true }) + } catch (error) { + logError(pluginJson, error.message) + } +} + +/** + * Gather answers to daily journal questions, and inserts at the cursor. + */ +export async function dailyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('day', () => strftime('%Y-%m-%d')) +} + +/** + * Gather answers to weekly journal questions, and inserts at the cursor. + */ +export async function weeklyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('week', () => { + const currentWeekNum = getWeek(new Date()) + return `${strftime('%Y')}-W${currentWeekNum}` + }) +} + +/** + * Gather answers to monthly journal questions, and inserts at the cursor. + */ +export async function monthlyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('month', () => strftime('%Y-%m')) +} + +/** + * Gather answers to quarterly journal questions, and inserts at the cursor. + */ +export async function quarterlyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('quarter', () => getNPQuarterStr(new Date())) +} + +/** + * Gather answers to yearly journal questions, and inserts at the cursor. + */ +export async function yearlyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('year', () => strftime('%Y')) +} + +//--------------------------------------------------------- +// Main review function, called by the plugin.json commands +//--------------------------------------------------------- + +/** + * Process questions for the given period, and write to the current note. + * If we're not in the correct note, offer to open it first. + * @author @jgclark + * @param {string} periodStringIn the calendar note title string for the review period + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @param {{ preferOpenNoteOfMatchingPeriodType?: boolean }} options when `preferOpenNoteOfMatchingPeriodType` is true (commands that target the “current” period), keep the editor focused if it already has any calendar note of that period type (e.g. do not switch a daily note from yesterday to today). When false or omitted, the open note is only reused if its title matches `periodStringIn` (needed for refresh / prev-next navigation). + */ +async function processReviewQuestions( + periodStringIn: string = '', + periodType: string, + options?: {| preferOpenNoteOfMatchingPeriodType?: boolean |}, +): Promise { + try { + const periodAdjective = getPeriodAdjectiveFromType(periodType) + const preferOpenSameKind = options?.preferOpenNoteOfMatchingPeriodType === true + // Get configuration + const config: PeriodicReviewConfigType = await getJournalSettings() + let reviewNote: ?TNote = null + + // Reuse the editor note when it is a calendar note of the requested period *kind* (day/week/…). + // For refresh / navigatePeriod we require an exact title match so we actually move to the requested period. + const openEditorNote = Editor.note + const useOpenNote = shouldUseOpenEditorCalendarNote(openEditorNote, periodType, periodStringIn, preferOpenSameKind) + // TODO: Check for Teamspace stuff here + if (useOpenNote && openEditorNote != null) { + reviewNote = openEditorNote + const openPeriodTitle = getReviewPeriodTitleStringFromCalendarNote(openEditorNote, periodType) + const wantTitle = String(periodStringIn).trim() + const titlesMatch = openPeriodTitle !== '' && openPeriodTitle === wantTitle + logDebug( + 'processReviewQuestions', + `Starting with open note '${displayTitle(openEditorNote)}' (${openPeriodTitle})` + + (preferOpenSameKind && !titlesMatch ? ` — keeping editor instead of '${String(periodStringIn)}'` : ''), + ) + } else { + // use the passed periodStringIn to open the correct note + logDebug('processReviewQuestions', `Starting by opening current ${periodAdjective} note '${String(periodStringIn)}'`) + reviewNote = await Editor.openNoteByTitle(periodStringIn) + } + if (!reviewNote) { + // Warn user and stop. + await showMessage(`Cannot open ${periodStringIn} note, so cannot continue. Please open the correct note first.`) + throw new Error(`Cannot open ${periodStringIn} note, so cannot continue.`) + } + + const periodFromNote = getReviewPeriodTitleStringFromCalendarNote(reviewNote, periodType) + const periodString = periodFromNote !== '' ? periodFromNote : periodStringIn !== '' ? periodStringIn : '' + logDebug('processReviewQuestions', `- Will use review note '${String(periodString)}' of period '${String(getPeriodOfNPDateStr(periodString))}'`) + + // Get questions and parse them + const questionLines = await getQuestionsForPeriod(config, periodType) + const numQs = questionLines.length + if (!questionLines || numQs === 0 || questionLines[0] === '') { + await showMessage(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) + throw new Error(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) + } + logDebug(pluginJson, `Found ${numQs} question lines for ${periodType}`) + // Parse questions (may result in multiple questions per line if '||' is used) + const parsedQuestions = parseQuestions(questionLines) + + await displayQuestionsWindow(parsedQuestions, periodString, periodType, config, questionLines, reviewNote) + + // Write answers to note + // Answers are applied in onReviewWindowAction when the user saves the HTML form. + } catch (err) { + if (err === 'cancelled') { + logDebug(pluginJson, `Asking questions cancelled by user: stopping.`) + } else { + logDebug(pluginJson, err.message) + } + } +} + +//------------------------------------------------------------- +// Private functions +//------------------------------------------------------------- + +/** + * Paragraph text lines to scan for existing review answers: active part of the note (from start through end of active region). + * @param {TNote} note + * @returns {Array} + */ +function getParagraphLineContentsForReviewScan(note: TNote): Array { + const endOfActiveLineIndex = findEndOfActivePartOfNote(note) + const paragraphTextLines = note.paragraphs.slice(0, endOfActiveLineIndex).map((p) => p.content) ?? [] + return paragraphTextLines +} + +/** + * Collect done task lines for the review summary: wins (#win / #bigwin / configured big-task marker ('>>', '!!!', or '!!')) vs other completed tasks. + * The HTML view merges them into one list (wins first; each line once). + * - day: wins and completed are split (completed excludes win lines). + * - week: only wins are returned; `completed` is empty. + * - month/quarter/year: no done-task summary lines are returned. + * @param {string} periodType + * @param {string} periodString + * @returns {{ wins: Array, completed: Array }} + */ +function getDoneTasksForSummary(periodType: string, periodString: string, config: PeriodicReviewConfigType): {| wins: Array < string >, completed: Array < string > |} { + try { + const supportsDoneTaskSummary = periodType === 'day' || periodType === 'week' + if (!supportsDoneTaskSummary) { + return { wins: [], completed: [] } + } + const startISO = getFirstDateInPeriod(periodString) + const endISO = getLastDateInPeriod(periodString) + if (startISO === '(error)' || endISO === '(error)') { + logWarn('getDoneTasksForSummary', `Could not parse period "${periodString}"`) + return { wins: [], completed: [] } + } + const periodStartMs = new Date(`${startISO}T12:00:00`).getTime() + const lookbackDaysRaw = Math.ceil((Date.now() - periodStartMs) / 86400000) + 1 + const lookbackDays = Math.min(Math.max(0, lookbackDaysRaw), 400) + const isDailyPeriod = periodType === 'day' + const notesToScan = getNotesChangedInInterval(lookbackDays, ['Calendar', 'Notes']) + const wins: Array = [] + const completed: Array = [] + /** Same task line can appear on multiple notes (or with different surrounding whitespace); show each logical line once. */ + const seenKeys = new Set < string > () + + for (const note of notesToScan) { + for (const para of note.paragraphs) { + if (para.type !== 'done') { + continue + } + const doneDateMatch = para.content.match(RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE) + const doneDate = doneDateMatch?.[1] ?? '' + if (doneDate === '') { + continue + } + const isInPeriod = doneDate >= startISO && doneDate <= endISO + if (!isInPeriod) { + continue + } + const dedupeKey = summaryTaskLineDedupeKey(para.content) + if (dedupeKey === '' || seenKeys.has(dedupeKey)) { + continue + } + seenKeys.add(dedupeKey) + const isWin = taskContentIsSummaryWin(para.content, config) + if (!isDailyPeriod) { + if (isWin) { + wins.push(para.content) + } + continue + } + if (isWin) { + wins.push(para.content) + } else { + completed.push(para.content) + } + } + } + + return { wins, completed } + } catch (error) { + logError('getDoneTasksForSummary', error.message) + return { wins: [], completed: [] } + } +} + +/** + * Display all questions in the window. + * Note: does not return the answers -- see separate function for that. + * @param {Array} parsedQuestions + * @param {string} periodString + * @param {string} periodType + * @param {PeriodicReviewConfigType} config + * @param {Array} rawQuestionLines lines from getQuestionsForPeriod (same array passed to parseQuestions) + * @param {TNote} calendarNote the calendar note to scan for answers + * @returns {void} + */ +async function displayQuestionsWindow( + parsedQuestions: Array, + periodString: string, + periodType: string, + config: PeriodicReviewConfigType, + rawQuestionLines: Array, + calendarNote: TNote, +): Promise { + const periodAdjective = getPeriodAdjectiveFromType(periodType) + // Get the data sources we need for the review window + const { wins: summaryWinTasks, completed: summaryCompletedTasks } = getDoneTasksForSummary(periodType, periodString, config) + const calendarSet: Array = config.calendarSet ?? [] + // logDebug(pluginJson, `calendarSet: [${String(calendarSet)}]`) + const eventsForPeriod: Array = (periodType === 'day') ? await getEventsForDay(periodString, calendarSet) ?? [] : [] + const scanLines = getParagraphLineContentsForReviewScan(calendarNote) + const initialAnswers = buildInitialReviewAnswersByFieldName(parsedQuestions, scanLines) + const planName = getPlanItemsNameForPeriodType(config, periodType) + const carryOverPlanItems = extractPlanSectionItems(calendarNote, '', config) // TEST: trying without sending planName parameter + + // Build the HTML body for the review window from this data + const htmlBody = buildReviewHTML( + config, + parsedQuestions, + rawQuestionLines, + summaryWinTasks, + summaryCompletedTasks, + periodString, + periodType, + eventsForPeriod, + REVIEW_WINDOW_CALLBACK_COMMAND, + planName, + initialAnswers, + carryOverPlanItems, + ) + + // Set the options and then open the review window + const preferredWindowType = config.preferredWindowType ?? 'New Window' + const windowOptions: HtmlWindowOptions = { + customId: REVIEW_WINDOW_CUSTOM_ID, + windowTitle: `${periodAdjective} Review`, + headerTags: `${faLinksInHeader}${stylesheetinksInHeader}`, + savedFilename: `../../jgclark.PeriodicReviews/period-review-${periodType}.html`, + showInMainWindow: preferredWindowType !== 'New Window', + splitView: preferredWindowType === 'Split View', + showReloadButton: true, + reloadPluginID: pluginJson['plugin.id'], + reloadCommandName: REVIEW_WINDOW_CALLBACK_COMMAND, + icon: 'clipboard-list', + iconColor: 'blue-600', + autoTopPadding: true, + makeModal: false, + reuseUsersWindowRect: true, + width: 600, + height: 700, + shouldFocus: true, + generalCSSIn: generateCSSFromTheme(''), + } + const openSuccess = await showHTMLV2(htmlBody, windowOptions) + if (!openSuccess) { + throw new Error('Unable to open single-window review form') + } + + // That's it. The answers will be written to the note by the callback function. +} + +/** + * Determine which answer lines should update existing lines in a review section, and which should append. + * @tests in jest file + * @param {Array} paragraphs + * @param {string} sectionHeading + * @param {Array} rawAnswerLines + * @param {Array} parsedQuestions + * @param {Array<{ lineKey: string, tokensToClear: Array }>} booleanClearDirectives + * @returns {{ updates: Array<{ para: TParagraph, content: string }>, appendLines: Array }} + */ +export function partitionReviewAnswerLinesForMixedUpsert( + paragraphs: Array, + sectionHeading: string, + rawAnswerLines: Array, + parsedQuestions: Array, + booleanClearDirectives: Array<{| lineKey: string, tokensToClear: Array |}> = [], +): {| updates: Array<{| para: TParagraph, content: string |}>, appendLines: Array |} { + const normalize = (input: string): string => String(input ?? '').trim().replace(/\s+/g, ' ').toLowerCase() + const headingLC = normalize(sectionHeading) + const sectionHeadingPara = paragraphs.find((p) => p.type === 'title' && normalize(String(p.content ?? '')).startsWith(headingLC)) + const headingLineIndex = sectionHeadingPara?.lineIndex ?? -1 + const sectionEndLineIndex = headingLineIndex >= 0 + ? paragraphs.find((p) => p.type === 'title' && (p.headingLevel ?? 99) <= 2 && (p.lineIndex ?? -1) > headingLineIndex)?.lineIndex ?? paragraphs.length + : -1 + const updates: Array<{| para: TParagraph, content: string |}> = [] + const appendLines: Array = [] + const usedLineIndexes: Set = new Set() + for (const answerLine of rawAnswerLines) { + const trimmedLine = String(answerLine ?? '').trim() + if (trimmedLine === '') { + continue + } + const lineMatchKey = getTemplateLineUpsertKeyFromOutputLine(trimmedLine, parsedQuestions) + if (lineMatchKey === '' || headingLineIndex < 0) { + appendLines.push(answerLine) + continue + } + const paraToUpdate = paragraphs.find((p) => { + const lineIndex = p.lineIndex ?? -1 + if (usedLineIndexes.has(lineIndex)) { + return false + } + if (lineIndex <= headingLineIndex || lineIndex >= sectionEndLineIndex || p.type === 'title') { + return false + } + return normalize(String(p.content ?? '')).startsWith(lineMatchKey) + }) + if (paraToUpdate) { + usedLineIndexes.add(paraToUpdate.lineIndex ?? -1) + updates.push({ para: paraToUpdate, content: answerLine }) + } else { + appendLines.push(answerLine) + } + } + for (const directive of booleanClearDirectives) { + const lineKeyNormalized = normalize(directive.lineKey) + if (lineKeyNormalized === '' || headingLineIndex < 0 || directive.tokensToClear.length === 0) { + continue + } + let updateEntry = updates.find((u) => normalize(String(u.content ?? '')).startsWith(lineKeyNormalized)) + if (updateEntry == null) { + const paraToUpdate = paragraphs.find((p) => { + const lineIndex = p.lineIndex ?? -1 + if (lineIndex <= headingLineIndex || lineIndex >= sectionEndLineIndex || p.type === 'title') { + return false + } + return normalize(String(p.content ?? '')).startsWith(lineKeyNormalized) + }) + if (paraToUpdate == null) { + continue + } + updateEntry = { para: paraToUpdate, content: String(paraToUpdate.content ?? '') } + updates.push(updateEntry) + } + let nextContent = String(updateEntry.content ?? '') + for (const token of directive.tokensToClear) { + const tokenRE = new RegExp(`(?:^|\\s)${escapeRegExp(token)}(?=\\s|$)`, 'g') + nextContent = nextContent.replace(tokenRE, ' ') + } + nextContent = nextContent.replace(/\s+/g, ' ').trim() + updateEntry.content = nextContent + } + return { updates, appendLines } +} + +/** + * Write the collected answers to the note: + * Add the finished review text to the current calendar note, appending after the configured heading for that period. + * If the heading doesn't exist, then append it first. + * @param {string} periodString the calendar note title string for the review period + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @param {string} answersText the text to insert into the journal + */ +async function writeAnswersToNote( + periodStringIn: string = '', + periodTypeIn: string = '', + answersTextIn: string = '', + parsedQuestionsIn: Array = [], + answersByIndexIn: { [string]: string | boolean } = {}, +): Promise { + try { + const config: PeriodicReviewConfigType = await getJournalSettings() + let periodString = periodStringIn ?? '' + if (periodString === '') { + periodString = Editor.note?.title ?? '' + } + const allowedPeriodTypes = ['day', 'week', 'month', 'quarter', 'year'] + const isRecognizedPeriodType = allowedPeriodTypes.includes(periodTypeIn) + const periodType = isRecognizedPeriodType ? periodTypeIn : '' + let answersText = answersTextIn ?? '' + // Backward-compatibility: earlier function signature was (periodString, answersText). + if (!isRecognizedPeriodType && periodTypeIn !== '' && answersText === '') { + answersText = periodTypeIn + } + if (answersText === '') { + const result = await getInput('No answers were collected from the review window. Please enter them manually here:', 'OK', 'Enter answers', '') + if (result === false) { + throw new Error('No answers were collected from the review window') + } else { + answersText = String(result) + } + } + + // Get the correct Editor for the calendar note + // TODO: find the right existing helper/dateTime.js or /NPdateTime.js function to use periodString to get the correct note + + // $FlowIgnore(incompatible-call) .note is a superset of CoreNoteFields + const outputNote = Editor + const resolvedPeriodType = periodType !== '' ? periodType : getPeriodOfNPDateStr(periodString) + const sectionHeading = getSectionHeadingForPeriod(config, resolvedPeriodType) + // $FlowIgnore[incompatible-call] .note is a superset of CoreNoteFields + logDebug(pluginJson, `Appending answers to heading '${sectionHeading}' in note ${displayTitle(outputNote)}`) + const matchedHeading = findHeadingStartsWith(outputNote, sectionHeading) + const headingToUse = matchedHeading ? matchedHeading : sectionHeading + // Get the boolean clear directives for unchecked boolean answers. + // Note: @Cursor says this more complex upsert functionality is needed as we want to be able to clear boolean tokens on existing lines. @jgclark doesn't understand this, but has left it as it works. + const booleanClearDirectives = getBooleanClearDirectivesFromAnswers(parsedQuestionsIn, answersByIndexIn) + const { updates, appendLines } = partitionReviewAnswerLinesForMixedUpsert( + outputNote.paragraphs ?? [], + headingToUse, + answersText.split(/\r?\n/), + parsedQuestionsIn, + booleanClearDirectives, + ) + for (const { para, content } of updates) { + para.content = content + outputNote.updateParagraph(para) + } + if (appendLines.length > 0) { + outputNote.addParagraphBelowHeadingTitle( + appendLines.join('\n'), + 'empty', + headingToUse, + true, + true) + } + if (outputNote.note) { + DataStore.updateCache(outputNote.note, true) + } + } catch (err) { + logError(pluginJson, `writeAnswersToNote: ${err.message}`) + } +} + +//--------------------------------------------------------- +// Callback function for HTML single-window review actions +//--------------------------------------------------------- + +/** + * Normalize review callback payload from the HTML bridge or x-callback-url (string or object per plugin.json). + * @param {mixed} payload + * @returns {any} + */ +function parseReviewWindowPayload(payload: mixed): any { + if (payload == null || payload === '') { + return {} + } + if (typeof payload === 'object' && !Array.isArray(payload)) { + return payload + } + if (typeof payload === 'string') { + const trimmed = payload.trim() + if (trimmed === '') { + return {} + } + return JSON.parse(trimmed) + } + return {} +} + +/** + * Normalize args from DataStore.invokePluginCommandByName: usually (actionName, payload), but some bridges pass one array [actionName, payload]. + * @param {mixed} actionNameIn + * @param {mixed} payloadIn + * @returns {{ actionName: string, payload: mixed }} + */ +function normalizeReviewWindowInvokeArgs(actionNameIn: mixed, payloadIn: mixed): { actionName: string, payload: mixed } { + if (Array.isArray(actionNameIn)) { + return { + actionName: String(actionNameIn[0] ?? ''), + payload: actionNameIn.length >= 2 ? actionNameIn[1] : payloadIn, + } + } + return { + actionName: typeof actionNameIn === 'string' ? actionNameIn : String(actionNameIn ?? ''), + payload: payloadIn, + } +} + +/** + * Callback function for HTML single-window review actions. + * Must return a value when invoked via DataStore.invokePluginCommandByName or NotePlan can fail silently after logging execution. + * @param {mixed} actionNameIn + * @param {mixed} payload — JSON string or object (NotePlan may pass either) + * @returns {Promise<{||}>} + */ +export async function onReviewWindowAction(actionNameIn: mixed, payload: mixed = ''): Promise<{||}> { + const { actionName, payload: payloadResolved } = normalizeReviewWindowInvokeArgs(actionNameIn, payload) + logDebug(pluginJson, `onReviewWindowAction action=${actionName}`) + // logDebug(pluginJson, `onReviewWindowAction payloadLength=${String(payload?.length ?? 0)} payloadPreview="${String(payload ?? '').slice(0, 100)}"`) + if (actionName === 'cancel') { + logDebug('Journalling/onReviewWindowAction', `Cancelled by user.`) + closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) + return {} + } + + if (actionName === 'refresh') { + let refreshPayload: any = {} + try { + refreshPayload = parseReviewWindowPayload(payloadResolved) + } catch (err) { + logError(pluginJson, `onReviewWindowAction: refresh could not parse payload: ${err.message}`) + return {} + } + const periodType = String(refreshPayload.periodType ?? '') + const periodString = String(refreshPayload.periodString ?? '') + if (periodType === '' || periodString === '') { + logWarn(pluginJson, 'onReviewWindowAction: refresh missing periodType or periodString') + return {} + } + logDebug('Journalling/onReviewWindowAction', `Refresh: reopening review for ${periodType} ${periodString}`) + await processReviewQuestions(periodString, periodType) + return {} + } + + if (actionName === 'navigatePeriod') { + let navPayload: any = {} + try { + navPayload = parseReviewWindowPayload(payloadResolved) + } catch (err) { + logError(pluginJson, `onReviewWindowAction: navigatePeriod could not parse payload: ${err.message}`) + return {} + } + const periodType = String(navPayload.periodType ?? '') + const periodString = String(navPayload.periodString ?? '') + const direction = String(navPayload.direction ?? '') + if (periodType === '' || periodString === '' || (direction !== 'prev' && direction !== 'next')) { + logWarn(pluginJson, 'onReviewWindowAction: navigatePeriod missing periodType, periodString, or valid direction') + return {} + } + const targetPeriodString = + direction === 'next' ? getNextNPPeriodString(periodString, periodType) : getPreviousNPPeriodString(periodString, periodType) + if (targetPeriodString === '') { + logWarn(pluginJson, `onReviewWindowAction: navigatePeriod could not compute ${direction} period from "${periodString}" (${periodType})`) + return {} + } + logDebug('Journalling/onReviewWindowAction', `Navigate ${direction}: reopening review for ${periodType} ${targetPeriodString}`) + await processReviewQuestions(targetPeriodString, periodType) + return {} + } + + const configMaybe: ?PeriodicReviewConfigType = await getJournalSettings() + if (configMaybe == null || typeof configMaybe !== 'object') { + logError(pluginJson, 'onReviewWindowAction: no journal settings loaded; cannot save review') + return {} + } + const config: PeriodicReviewConfigType = configMaybe + let safePayload: any = {} + try { + // Allow callback payloads as JSON string (x-callback / jsBridge) or as an object (native bridge). + safePayload = parseReviewWindowPayload(payloadResolved) + clo(safePayload, `onReviewWindowAction: parsed payload`) + let answers = safePayload.answers ?? {} + // Get the period type and string from the hidden fields in the payload + let periodType = safePayload.periodType ?? '' + let periodString = safePayload.periodString ?? '' + // Backward-compatibility: older bridges flattened q_* plus period fields at top level. + if ((periodType === '' || periodString === '') && typeof safePayload === 'object' && safePayload != null) { + periodType = periodType || String(safePayload.periodType ?? '') + periodString = periodString || String(safePayload.periodString ?? '') + } + if ((answers == null || Object.keys(answers).length === 0) && typeof safePayload === 'object' && safePayload != null) { + const extractedAnswers: { [string]: any } = {} + const keys = Object.keys(safePayload) + for (const key of keys) { + if (key.startsWith('q_')) { + extractedAnswers[key] = (safePayload: any)[key] + } + } + answers = extractedAnswers + } + const planningRaw = + answers.planning_tasks ?? (typeof safePayload === 'object' && safePayload != null ? (safePayload: any).planning_tasks : undefined) + const planningText = typeof planningRaw === 'string' ? planningRaw : String(planningRaw ?? '') + const currentBigTaskMarker = getBigTaskMarkerFromConfig(config) + const hasPlanningContent = normalizePlanningTaskLinesFromForm(planningText, currentBigTaskMarker).length > 0 + const questionLines = await getQuestionsForPeriod(config, periodType) + const parsedQuestions = parseQuestions(questionLines) + const output = buildOutputFromReviewWindowAnswers(parsedQuestions, questionLines, periodString, periodType, answers) + if (output !== '') { + await writeAnswersToNote(periodString, periodType, output, parsedQuestions, answers) + } else if (!hasPlanningContent) { + logWarn(pluginJson, 'No template question answers were collected from the review window') + } + await writePlanningTasksToNextPeriodNote(config, periodString, periodType, planningText) + logDebug('Journalling/onReviewWindowAction', `Finished.`) + closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) + return {} + } catch (err) { + logError(pluginJson, `onReviewWindowAction: ${err.message}`) + return {} + } +} diff --git a/jgclark.PeriodicReviews/src/periodicReviewHelpers.js b/jgclark.PeriodicReviews/src/periodicReviewHelpers.js new file mode 100644 index 000000000..96617327f --- /dev/null +++ b/jgclark.PeriodicReviews/src/periodicReviewHelpers.js @@ -0,0 +1,524 @@ +// @flow +//--------------------------------------------------------------- +// Helper functions for Journalling plugin for NotePlan +// Jonathan Clark +// last update 2026-08-11 for v2.0.0.b15 by @jgclark / @CursorAI +//--------------------------------------------------------------- + +import pluginJson from '../plugin.json' +import { + getCalendarNoteTimeframe, + getDateStringFromCalendarFilename, + getNextNPPeriodString, + getPeriodOfNPDateStr, + RE_DONE_DATE_OPT_TIME, +} from '@helpers/dateTime' +import { clo, logDebug, logError, logInfo, logWarn } from '@helpers/dev' +import { showMessage } from '@helpers/userInput' + +//--------------------------------------------------------------- +// Constants & Types + +const pluginID = 'jgclark.PeriodicReviews' +const BIG_TASK_MARKER_STYLE_DEFAULT = '>> (priority 4)' +const BIG_TASK_MARKER_STYLE_TO_MARKER: { [string]: string } = { + '>> (priority 4)': '>>', + '!!! (priority 3)': '!!!', + '!! (priority 2)': '!!', +} +const BIG_TASK_MARKER_TO_PRIORITY: { [string]: number } = { + '>>': 4, + '!!!': 3, + '!!': 2, +} + +export type PeriodicReviewConfigType = { + dailyJournalSectionHeading: string, + reviewSectionHeading: string, + dayPlanItemsName: string, + weekPlanItemsName: string, + monthPlanItemsName: string, + quarterPlanItemsName: string, + yearPlanItemsName: string, + startDailyTemplateTitle: string, + endDailyTemplateTitle: string, + startWeeklyTemplateTitle: string, + endWeeklyTemplateTitle: string, + startMonthlyTemplateTitle: string, + endMonthlyTemplateTitle: string, + openCalendarNoteWhenReviewing: boolean, + preferredWindowType: string, + dailyReviewQuestions: string, + weeklyReviewQuestions: string, + monthlyReviewQuestions: string, + quarterlyReviewQuestions: string, + yearlyReviewQuestions: string, + moods: string, + calendarSet: Array, + bigTaskMarkerStyle?: string, + plannedItemsSuffix?: string, +} + +/** One parsed segment from review settings; `` names are defined in `reviewQuestions.js` (`REVIEW_QUESTION_TYPE_NAMES_ALT`). */ +export type ParsedQuestionType = { + question: string, + type: string, + originalLine: string, + lineIndex: number +} + +//--------------------------------------------------------------- +// Settings + +/** + * Get or make config settings + * @author @jgclark + */ +export async function getJournalSettings(): Promise { // want to use Promise but too many flow errors result + try { + // Get settings using Config system + const config: PeriodicReviewConfigType = await DataStore.loadJSON(`../${pluginID}/settings.json`) + + if (config == null || Object.keys(config).length === 0) { + logError(pluginJson, `getJournalSettings() cannot find '${pluginID}' plugin settings. Stopping.`) + await showMessage(`Cannot find settings for the '${pluginID}' plugin. Please make sure you have installed it from the Plugin Preferences pane.`) + return + } else { + // clo(config, `${pluginID} settings:`) + return config + } + } + catch (error) { + logError(pluginJson, `getJournalSettings: ${error.message}`) + return // for completeness + } +} + +/** + * Get raw question lines for the given period from config. + * From v1.16, these may now contain multiple questions per line, separated by '||'. + * @param {JournalConfigType} config the journal configuration + * @param {string} period for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @returns {Promise>} array of question lines, or empty array if unsupported + */ +export async function getQuestionsForPeriod(config: PeriodicReviewConfigType, period: string): Promise> { + let rawQuestionLines: Array = [] + switch (period) { + case 'day': { + rawQuestionLines = config.dailyReviewQuestions.split('\n') + break + } + case 'week': { + rawQuestionLines = config.weeklyReviewQuestions.split('\n') + break + } + case 'month': { + rawQuestionLines = config.monthlyReviewQuestions.split('\n') + break + } + case 'quarter': { + rawQuestionLines = config.quarterlyReviewQuestions.split('\n') + break + } + case 'year': { + rawQuestionLines = config.yearlyReviewQuestions.split('\n') + break + } + default: { + logError(pluginJson, `${period} review questions aren't yet supported. Stopping.`) + await showMessage(`Sorry, ${period} review questions aren't yet supported.`) + return [] + } + } + logDebug(pluginJson, `rawQuestionLines: ${String(rawQuestionLines)}`) + return rawQuestionLines +} + +/** + * Get the configured section heading to use for a given review period. + * @param {JournalConfigType} config the journal configuration + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @returns {string} + */ +export function getSectionHeadingForPeriod(config: PeriodicReviewConfigType, periodType: string): string { + if (periodType === 'day') { + return config.dailyJournalSectionHeading + } + return config.reviewSectionHeading +} + +/** + * Normalize non-empty lines from the planning textarea for storage (strip task markers and leading configured big-task marker only). + * @tests in jest file + * @param {string} planningFormText + * @param {string} bigTaskMarker marker currently configured by `bigTaskMarkerStyle` + * @returns {Array} + */ +export function normalizePlanningTaskLinesFromForm(planningFormText: string, bigTaskMarker: string = '>>'): Array { + const raw = typeof planningFormText === 'string' ? planningFormText : String(planningFormText ?? '') + const escapedMarker = bigTaskMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const markerPrefixRE = new RegExp(`^${escapedMarker}\\s*`) + return raw + .split(/\r?\n/) + .map((l) => { + let t = l.trim() + t = t.replace(/^\*\s*/, '') + t = t.replace(markerPrefixRE, '') + return t + }) + .filter((t) => t !== '') +} + +/** + * Marker string used for "big" tasks/goals in this plugin (defaults to `>>` / priority 4). + * @param {PeriodicReviewConfigType} config + * @returns {string} + */ +export function getBigTaskMarkerFromConfig(config?: any): string { + const styleRaw = config?.bigTaskMarkerStyle + const style = typeof styleRaw === 'string' ? styleRaw.trim() : '' + return BIG_TASK_MARKER_STYLE_TO_MARKER[style] ?? BIG_TASK_MARKER_STYLE_TO_MARKER[BIG_TASK_MARKER_STYLE_DEFAULT] +} + +/** + * Numeric NotePlan task priority corresponding to the configured big-task marker. + * @param {PeriodicReviewConfigType} config + * @returns {number} + */ +export function getBigTaskPriorityFromConfig(config?: any): number { + const marker = getBigTaskMarkerFromConfig(config) + return BIG_TASK_MARKER_TO_PRIORITY[marker] ?? 4 +} + +/** + * Effective suffix for lines written to the next period note (after `normalizePlanningTaskLinesFromForm`). + * Defaults to empty string when not set; blank string disables suffix. + * @tests in jest file + * @param {PeriodicReviewConfigType} config + * @returns {{ suffix: ?string }} + */ +export function getEffectivePlannedItemAffixes(config: PeriodicReviewConfigType): { suffix: ?string } { + const affix = (raw: mixed, defaultValue: string): ?string => { + if (raw === undefined || raw === null || typeof raw !== 'string') return defaultValue + return raw.trim() === '' ? null : raw + } + return { + suffix: affix(config.plannedItemsSuffix, ''), + } +} + +/** + * Build one task line body for the next period note: optional suffix on `body`, then mandatory big-task prefix. + * @tests in jest file + * @param {string} body normalized plain text (no task marker) + * @param {string} prefix + * @param {?string} suffix + * @returns {string} + */ +export function formatPlannedItemLineForNextNote(body: string, prefix: string, suffix: ?string): string { + const join = (left: string, right: string): string => { + if (left === '') return right + if (right === '') return left + return /\s$/.test(left) || /^\s/.test(right) ? `${left}${right}` : `${left} ${right}` + } + let line = body + if (suffix != null) line = join(line, suffix) + line = join(prefix, line) + return line +} + +/** + * Replace `` with the calendar period title and `` / `` with the following period. + * Used for the review HTML window and for text written back to the note. + * @tests in jest file + * @param {string} input + * @param {string} periodString + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} + */ +export function substituteReviewPeriodPlaceholders(input: string, periodString: string, periodType: string): string { + const nextPeriodStr = getNextNPPeriodString(periodString, periodType) + return input + .replace(/<\s*date\s*>/gi, periodString) + .replace(/<\s*(?:datenext|nextdate)\s*>/gi, nextPeriodStr) +} + +/** + * Title-case adjective for UI strings (window title, review heading, messages). + * @tests in jest file + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} e.g. 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' + */ +export function getPeriodAdjectiveFromType(periodType: string): string { + switch (periodType) { + case 'day': + return 'Daily' + case 'week': + return 'Weekly' + case 'month': + return 'Monthly' + case 'quarter': + return 'Quarterly' + case 'year': + return 'Yearly' + default: + return '(error: unknown period type)' + } +} + +/** + * Calendar period title for reviews: prefer a parseable note title, else derive from filename. + * @tests in jest file + * @param {TNote} note + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} + */ +export function getReviewPeriodTitleStringFromCalendarNote(note: TNote, periodType: string): string { + const titleTrimmed = String(note.title ?? '').trim() + if (titleTrimmed !== '' && getPeriodOfNPDateStr(titleTrimmed) === periodType) { + return titleTrimmed + } + if (note.type === 'Calendar') { + const fromFilename = getDateStringFromCalendarFilename(note.filename ?? '', periodType === 'day') + if (fromFilename !== '' && fromFilename !== '(invalid date)') { + return fromFilename + } + } + return titleTrimmed +} + +/** + * Whether the note open in the editor should be used for a review command. + * @tests in jest file + * @param {?TNote} openNote + * @param {string} periodType + * @param {string} periodStringIn intended period when not reusing the open note (e.g. today for Daily Review) + * @param {boolean} preferOpenSameKind when true, any open calendar note of this period kind is reused + * @returns {boolean} + */ +export function shouldUseOpenEditorCalendarNote( + openNote: ?TNote, + periodType: string, + periodStringIn: string, + preferOpenSameKind: boolean, +): boolean { + if (openNote == null) { + return false + } + const openPeriodKind = getCalendarNoteTimeframe(openNote) + if (openPeriodKind === false || openPeriodKind !== periodType) { + return false + } + const wantTitle = String(periodStringIn).trim() + const openTitle = getReviewPeriodTitleStringFromCalendarNote(openNote, periodType) + const titlesMatch = openTitle !== '' && openTitle === wantTitle + return preferOpenSameKind || titlesMatch +} + +/** Default plan-item labels when the setting key is missing (not when intentionally blank). */ +// TODO: make this look at the plugin.json "default" for the "key" below +const PLAN_ITEMS_NAME_DEFAULTS: { [string]: string } = { + day: 'Big Rocks', + week: 'Top Wins', + month: 'Key Outcomes', + quarter: 'Goals', + year: 'Theme', +} + +/** Settings keys for `getPlanItemsNameForPeriodType`. */ +const PLAN_ITEMS_NAME_CONFIG_KEYS: { [string]: string } = { + day: 'dayPlanItemsName', + week: 'weekPlanItemsName', + month: 'monthPlanItemsName', + quarter: 'quarterPlanItemsName', + year: 'yearPlanItemsName', +} + +/** + * Configured label for planned items for a calendar period (e.g. "Big 3 Rocks"). + * Blank / whitespace means no heading name (planned items are written without an H2). + * Missing setting keys still use built-in defaults. + * @tests in jest file + * @param {JournalConfigType} config + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} + */ +export function getPlanItemsNameForPeriodType(config: PeriodicReviewConfigType, periodType: string): string { + const key = PLAN_ITEMS_NAME_CONFIG_KEYS[periodType] + const fallback = PLAN_ITEMS_NAME_DEFAULTS[periodType] ?? 'Plans' + if (key == null) { + return fallback + } + // $FlowFixMe[invalid-computed-prop] + const raw = (config: any)[key] + // Explicit string wins (including blank). Only non-string / missing uses fallback. + if (typeof raw !== 'string') { + return fallback + } + return raw.trim() +} + +/** + * Lowercase English noun for "the next …" in plan section titles. + * @param {string} periodType + * @returns {string} + */ +export function getPeriodNounForType(periodType: string): string { + const nouns: { [string]: string } = { + day: 'day', + week: 'week', + month: 'month', + quarter: 'quarter', + year: 'year', + } + return nouns[periodType] ?? 'period' +} + +/** + * H2 / UI title for the **current** period’s plan in the review summary: `Planned: {planName}` (or `Planned` if blank). + * @param {string} planName + * @returns {string} + */ +export function buildThisPlanSectionHeadingTitle(planName: string): string { + const name = String(planName ?? '').trim() + return name !== '' ? `Planned: ${name}` : 'Planned' +} + +/** + * H2 / UI title for the planning textarea in the review HTML: `Planning: {planName} for the next {noun}` + * (or `Planning for the next {noun}` if planName is blank). + * @tests in jest file + * @param {string} planName + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} + */ +export function buildNextPlanSectionHeadingTitle(planName: string, periodType: string): string { + const noun = getPeriodNounForType(periodType) + const name = String(planName ?? '').trim() + return name !== '' ? `Planning: ${name} for the next ${noun}` : `Planning for the next ${noun}` +} + +/** + * H2 written **on the next calendar note** when saving planned tasks: `{planName} for {periodString}`. + * Empty when `planName` is blank (items are written with no heading). + * `periodString` must be that note’s calendar title (e.g. from `getNextNPPeriodString`). + * @tests in jest file + * @param {string} planName + * @param {string} periodString — next period’s NotePlan calendar title + * @returns {string} + */ +export function buildNextPeriodNotePlanSectionHeadingTitle(planName: string, periodString: string): string { + const name = String(planName ?? '').trim() + if (name === '') { + return '' + } + return `${name} for ${periodString}` +} + +/** + * Normalize a task line for comparing duplicates (trim; used when scanning notes and when merging summary lists). + * @tests in jest file + * @param {string} content + * @returns {string} + */ +export function summaryTaskLineDedupeKey(content: string): string { + return content.trim() +} + +/** Global @done stripper for probing task body for configured big-task win marker (same pattern as review HTML summary). */ +const RE_STRIP_DONE_FOR_SUMMARY_PROBE: RegExp = new RegExp(RE_DONE_DATE_OPT_TIME.source, 'gi') + +/** + * True if task line includes #win / #bigwin anywhere (word boundary). + * @param {string} content + * @returns {boolean} + */ +function hasWinHashtag(content: string): boolean { + return /(?:^|\s)#(?:bigwin|win)\b/i.test(content) +} + +/** + * True when the task body uses the configured win / planning marker after optional list marker and `!` priorities. + * @param {string} content + * @returns {boolean} + */ +function taskLineHasConfiguredBigTaskWinPrefix(content: string, config?: any): boolean { + const bigMarker = getBigTaskMarkerFromConfig(config ?? {}) + const markerRE = new RegExp(`^${bigMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`) + let probe = content.replace(RE_STRIP_DONE_FOR_SUMMARY_PROBE, '').trim() + probe = probe.replace(/^\*+\s+/, '').replace(/^[-+]\s+\[[ x]\]\s*/i, '').replace(/^-\s+/, '').trim() + while (!markerRE.test(probe)) { + const next = probe.replace(/^!{1,3}\s+/, '').trim() + if (next === probe) { + return false + } + probe = next + } + return true +} + +/** + * A done task counts as a "win" for the review summary when tagged #win / #bigwin or prefixed with the configured marker on the task body. + * @tests in jest file + * @param {string} content + * @returns {boolean} + */ +export function taskContentIsSummaryWin(content: string, config?: any): boolean { + return hasWinHashtag(content) || taskLineHasConfiguredBigTaskWinPrefix(content, config) +} + +/** + * Split the merged summary list into a leading win block and the rest ("other completed"). + * Uses the same win rules as {@link taskContentIsSummaryWin} so runtime HTML matches note scanning. + * @tests in jest file + * @param {Array} mergedLines output of mergeUniqueSummaryDoneTaskLines (wins first, then others) + * @returns {{ wins: Array, others: Array }} + */ +export function splitMergedSummaryDoneLinesIntoWinsAndOthers(mergedLines: Array): {| wins: Array, others: Array |} { + const wins: Array = [] + let i = 0 + for (; i < mergedLines.length; i++) { + const line = mergedLines[i] + if (!taskContentIsSummaryWin(line)) { + break + } + wins.push(line) + } + return { wins, others: mergedLines.slice(i) } +} + +/** + * Merge win-first and other completed lines into one list: each normalized line at most once. + * Lines whose key matches a carry-over plan row are omitted (that row is already shown in the carry-over block). + * @tests in jest file + * @param {Array} winTasks + * @param {Array} completedTasks + * @param {Array<{ content: string }>} carryOverPlanItems + * @returns {Array} + */ +export function mergeUniqueSummaryDoneTaskLines( + winTasks: Array, + completedTasks: Array, + carryOverPlanItems: Array<{ content: string }> = [], +): Array { + const carryKeys = new Set(carryOverPlanItems.map((item) => summaryTaskLineDedupeKey(item.content))) + const seen = new Set() + const out: Array = [] + const tryAdd = (line: string) => { + const k = summaryTaskLineDedupeKey(line) + if (k === '') { + return + } + if (carryKeys.has(k)) { + return + } + if (seen.has(k)) { + return + } + seen.add(k) + out.push(line) + } + winTasks.forEach(tryAdd) + completedTasks.forEach(tryAdd) + return out +} \ No newline at end of file diff --git a/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js b/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js new file mode 100644 index 000000000..ec37e23ab --- /dev/null +++ b/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js @@ -0,0 +1,886 @@ +// @flow +//--------------------------------------------------------------- +// HTMLView generation helpers for single-window review mode +// Jonathan Clark + Cursor +// last update 2026-04-26 for v2.0.0.b13 by @jgclark + @Cursor +//--------------------------------------------------------------- + +import moment from 'moment' +import pluginJson from '../plugin.json' +import type { PeriodicReviewConfigType, ParsedQuestionType } from './periodicReviewHelpers' +import { + buildNextPlanSectionHeadingTitle, + buildThisPlanSectionHeadingTitle, + getPeriodAdjectiveFromType, + mergeUniqueSummaryDoneTaskLines, + splitMergedSummaryDoneLinesIntoWinsAndOthers, + substituteReviewPeriodPlaceholders, +} from './periodicReviewHelpers' +import { getReviewQuestionSegmentRegExpGi } from './reviewQuestions' +import { RE_DONE_DATE_OPT_TIME } from '@helpers/dateTime' +import { clo, logDebug, logInfo, logError, logWarn } from '@helpers/dev' +import { getTaskPriority } from '@helpers/paragraph' +import { + convertBoldAndItalicToHTML, + convertHashtagsToHTML, + convertHighlightsToHTML, + convertMentionsToHTML, + convertPreformattedToHTML, + convertStrikethroughToHTML, + convertUnderlinedToHTML, + makePluginCommandButton, + replaceMarkdownLinkWithHTMLLink, + simplifyInlineImagesForHTML, + simplifyNPEventLinksForHTML, +} from '@helpers/HTMLView.js' +import { RE_SYNC_MARKER } from '@helpers/regex' +import { + // changeBareLinksToHTMLLink, + // changeMarkdownLinksToHTMLLink, + stripBackwardsDateRefsFromString, + stripThisWeeksDateRefsFromString, + stripTodaysDateRefsFromString, + truncateHTML, +} from '@helpers/stringTransforms' + + +//----------------------------------------------------------------------------- +// Constants + +const useFlexbox = true + +// Types of questions that use a block layout in the review window. +const blockRowTypes = ['string', 'subheading', 'h2', 'h3', 'bullets', 'checklists', 'tasks'] + +/** Remove @done(…) from summary lines (date with optional time), global. */ +const RE_DONE_MENTION_STRIP_FOR_SUMMARY_G = new RegExp(RE_DONE_DATE_OPT_TIME.source, 'gi') + +//----------------------------------------------------------------------------- +// HTML template strings + +export const stylesheetinksInHeader: string = ` + + +` + +export const faLinksInHeader: string = ` + + + + + +` + +//----------------------------------------------------------------------------- +// Helper functions + +/** + * Escape text for safe insertion into HTML. + * @param {string} input + * @returns {string} + */ +function escapeHTML(input: string): string { + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * Remove display-only delimiter tokens from text before rendering. + * @param {string} input + * @returns {string} + */ +function stripPresentationDelimiters(input: string): string { + return input.replace(/ \|\| /g, ' ') +} + +/** + * Format one done-task line for HTML using the same NotePlan-oriented conversions as getNoteContentAsHTML (minus full-note showdown). + * @param {string} taskContent + * @returns {string} + */ +function formatTaskAsHTML(taskContent: string): string { + const taskPriority = getTaskPriority(taskContent) + let line = taskContent.replace(RE_DONE_MENTION_STRIP_FOR_SUMMARY_G, '').replace(/\s{2,}/g, ' ').trim() + line = line.replace(RE_SYNC_MARKER, '') + line = replaceMarkdownLinkWithHTMLLink(line) + line = simplifyNPEventLinksForHTML(line) + line = simplifyInlineImagesForHTML(line) + line = convertHashtagsToHTML(line) + line = convertMentionsToHTML(line) + line = convertPreformattedToHTML(line) + line = convertStrikethroughToHTML(line) + line = convertHighlightsToHTML(line) + line = stripTodaysDateRefsFromString(line) + line = stripThisWeeksDateRefsFromString(line) + line = stripBackwardsDateRefsFromString(line) + line = convertBoldAndItalicToHTML(line) + line = convertUnderlinedToHTML(line) + line = line.replace(/\[\[([^\]]+)\]\]/g, (_match, title) => `~${String(title)}~`) + line = line.trimRight() + line = truncateHTML(line, 120) + + // If priority > 0, add priorityN styling around the whole string. Where it is "working-on", it uses priority4. + if (taskPriority > 0) { + // remove the priority markers from the start of the line + const lineWithoutPriorityMarkers = line.replace(/^!{1,3}\s*/, '').replace(/^>>\s?/, '') + line = `${lineWithoutPriorityMarkers}` + } + return line +} + +/** + * Split a question segment at the typed marker (e.g. ``) so controls can sit inline with prefix/suffix text. + * @param {string} segment + * @param {string} questionType + * @returns {{ prefix: string, suffix: string }} + */ +function splitSegmentAtTypeMarker(segment: string, questionType: string): {| prefix: string, suffix: string |} { + const pattern = + questionType.toLowerCase() === 'int' + ? '<\\s*(?:integer|int)\\s*>' + : `<\\s*${questionType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*>` + const re = new RegExp(pattern, 'i') + const m = segment.match(re) + if (!m || m.index == null) { + return { prefix: segment, suffix: '' } + } + const idx = m.index + const tagLen = m[0].length + return { prefix: segment.slice(0, idx), suffix: segment.slice(idx + tagLen) } +} + +/** + * Build checkbox / short input / mood select for inline placement (same classes as makeReviewQuestionRowDiv). + * @param {ParsedQuestionType} parsedQuestion + * @param {number} globalIndex + * @param {JournalConfigType} config + * @returns {string} + */ +function makeReviewInlineControl( + parsedQuestion: ParsedQuestionType, + globalIndex: number, + config: PeriodicReviewConfigType, + initialValue: string = '', +): string { + const fieldName = `q_${globalIndex}` + const checkedAttr = initialValue === 'yes' ? ' checked' : '' + switch (parsedQuestion.type) { + case 'boolean': { + return `` + } + case 'int': + case 'number': { + return `` + } + case 'duration': { + return `` + } + case 'mood': { + const moodArray = typeof config.moods === 'string' ? config.moods.split(',').map((m) => m.trim()) : config.moods + const moodOptions = moodArray + .filter((m) => m !== '') + .map((mood) => { + const selectedAttr = mood === initialValue ? ' selected' : '' + return `` + }) + .join('') + return `` + } + default: { + return '' + } + } +} + +/** + * Build one flex row for a raw config line: literal text and inline controls in original order. + * String questions use the same block layout as makeReviewQuestionRowDiv(); other types use inline controls. + * @param {string} rawLine + * @param {number} lineIndex + * @param {Array} parsedQuestions + * @param {JournalConfigType} config + * @param {string} periodString calendar period title for placeholder substitution in labels/headings + * @param {string} periodType + * @returns {string} + */ +function makeQuestionLineDiv( + rawLine: string, + lineIndex: number, + parsedQuestions: Array, + config: PeriodicReviewConfigType, + initialAnswers: { [string]: string }, + periodString: string, + periodType: string, +): string { + const cleanRawLine = stripPresentationDelimiters(rawLine) + if (cleanRawLine.trim() === '') { + return '' + } + + const lineQuestionsOrdered: Array<{| q: ParsedQuestionType, globalIndex: number |}> = parsedQuestions + .map((q, globalIndex) => ({ q, globalIndex })) + .filter(({ q }) => q.lineIndex === lineIndex) + + const headingTypes = ['subheading', 'h2', 'h3'] + const lineHasOnlyHeadingQuestions = + lineQuestionsOrdered.length > 0 && lineQuestionsOrdered.every(({ q }) => headingTypes.includes(q.type)) + // `##` / `###` lines have no angle-bracket typed segments; use the heading row path so the flex + // segment matcher does not treat heading text as inline fragments. + const isMarkdownStyleHeadingLine = lineHasOnlyHeadingQuestions && !cleanRawLine.includes('<') + if (isMarkdownStyleHeadingLine) { + return lineQuestionsOrdered + .map(({ q, globalIndex }) => makeReviewQuestionRowDiv(q, globalIndex, config, '', periodString, periodType)) + .join('\n') + } + + const parts: Array = [] + let lastIndex = 0 + let segmentOrdinal = 0 + const segmentRe = getReviewQuestionSegmentRegExpGi() + segmentRe.lastIndex = 0 + let match = segmentRe.exec(cleanRawLine) + while (match !== null) { + if (match.index > lastIndex) { + parts.push(`${escapeHTML(cleanRawLine.slice(lastIndex, match.index))}`) + } + const segmentTrimmed = match[0].trim() + const pair = lineQuestionsOrdered[segmentOrdinal] + segmentOrdinal += 1 + if (!pair) { + parts.push(`${escapeHTML(match[0])}`) + lastIndex = match.index + match[0].length + match = segmentRe.exec(cleanRawLine) + continue + } + const { q: pq, globalIndex } = pair + const initialVal = initialAnswers[`q_${globalIndex}`] ?? '' + + if (blockRowTypes.includes(pq.type)) { + parts.push( + `
${makeReviewQuestionRowDiv(pq, globalIndex, config, initialVal, periodString, periodType)}
`, + ) + } else { + const { prefix, suffix } = splitSegmentAtTypeMarker(segmentTrimmed, pq.type) + const control = makeReviewInlineControl(pq, globalIndex, config, initialVal) + parts.push( + `${escapeHTML(prefix)}${control}${escapeHTML(suffix)}`, + ) + } + lastIndex = match.index + match[0].length + match = segmentRe.exec(cleanRawLine) + } + + if (lastIndex < cleanRawLine.length) { + parts.push(`${escapeHTML(cleanRawLine.slice(lastIndex))}`) + } + + if (parts.length === 0) { + return `${escapeHTML(cleanRawLine)}` + } + return `
${parts.join('')}
` +} + +/** + * Heading for the wins-only block when wins and other completed tasks are both shown. + * @param {number} count + * @returns {string} + */ +function formatWinsSummaryHeading(count: number): string { + return count === 1 ? '1 win' : `${count} wins` +} + +/** + * Wrap a summary subheading and body in `
` / `` for collapsible lists. + * @param {string} detailsExtraClassNames classes after base `summary-details` (e.g. `summary-details-events`) + * @param {string} summaryLabelPlainText visible heading (HTML-escaped) + * @param {string} bodyInnerHtml markup after `` + * @param {{ defaultOpen?: boolean, summaryExtraClasses?: string }=} opts first summary defaults open; later sections default closed + * @returns {string} + */ +function wrapSummaryDetailsBlock( + detailsExtraClassNames: string, + summaryLabelPlainText: string, + bodyInnerHtml: string, + opts?: {| defaultOpen?: boolean, summaryExtraClasses?: string |}, +): string { + const extra = detailsExtraClassNames.trim() + const classes = extra === '' ? 'summary-details' : `summary-details ${extra}` + const defaultOpen = opts?.defaultOpen ?? true + const summaryExtra = opts?.summaryExtraClasses != null ? opts.summaryExtraClasses.trim() : '' + const summaryClasses = summaryExtra === '' ? 'summary-title' : `summary-title ${summaryExtra}` + const openAttr = defaultOpen ? ' open' : '' + return `
+ ${escapeHTML(summaryLabelPlainText)} +${bodyInnerHtml} +
` +} + +/** + * Summary content wrapper class for one subsection: use multi-column only when there are at least 2 items. + * @param {number} itemCount + * @param {string=} extraClassNames optional additional classes + * @returns {string} + */ +function getSummaryContentClassNames(itemCount: number, extraClassNames: string = ''): string { + const isMultiColumn = itemCount >= 2 + const base = isMultiColumn ? 'summary-content' : 'summary-content summary-content-single' + const extra = extraClassNames.trim() + return extra === '' ? base : `${base} ${extra}` +} + +/** + * Build a summary of calendar events for the review period: count and total timed duration (all-day excluded from hours). + * @param {Array} eventsForPeriod + * @returns {string} HTML string for the summary block + */ +function makePeriodDaysSummaryDiv(eventsForPeriod: Array): string { + if (eventsForPeriod.length === 0) { + return '' + } + const totalDuration = eventsForPeriod.reduce((total, event) => total + getEventDurationHours(event), 0) + let title = `${eventsForPeriod.length} events` + if (totalDuration > 0) { + title += ` (${totalDuration.toFixed(1)} hours)` + } + const output = [] + output.push(`
`) + eventsForPeriod.forEach((e) => { + output.push(`\t
`) + output.push(`\t\t`) + output.push(`\t\t${e.title}`) + output.push('\t
') + }) + output.push(`
`) + return wrapSummaryDetailsBlock('summary-details-events', title, output.join('\n'), { defaultOpen: false }) +} + +/** + * Placeholder for the calendar-events block while the WebView loads events via `Calendar.*`. + * Client script replaces this with markup matching {@link makePeriodDaysSummaryDiv} (or removes it when there are no events). + * @returns {string} + */ +function makeCalendarEventsSummaryMountHTML(): string { + return `
+ Calendar events +
+
Loading calendar…
+
+
` +} + +/** + * Duration of a calendar item in hours (all-day => 0; uses `date` and `endDate` like EventHelpers). + * @param {TCalendarItem} event + * @returns {number} + */ +function getEventDurationHours(event: TCalendarItem): number { + if (event.isAllDay) { + return 0 + } + const end = event.endDate != null ? event.endDate : event.date + return Math.max(0, moment(end).diff(moment(event.date), 'minutes') / 60) +} + +/** + * First summary block: carried-over plan tasks from this note (open = hollow circle, done = check). + * Wrapped in `
` so the heading matches other summary sections; first section stays expanded by default. + * @param {Array<{ content: string, isDone: boolean }>} carryOverPlanItems + * @returns {string} HTML or empty when none + */ +function makeCarryOverPlanSummaryContentDiv( + planningSectionTitle: string, + carryOverPlanItems: Array<{ content: string, isDone: boolean }>, +): string { + const rows: Array = [] + rows.push(`
`) + if (carryOverPlanItems.length > 0) { + carryOverPlanItems.forEach((item) => { + if (item.isDone) { + rows.push(` +
+ + ${formatTaskAsHTML(item.content)} +
`) + } else { + rows.push(` +
+ + ${formatTaskAsHTML(item.content)} +
`) + } + }) + } else { + rows.push(`No planned items found for this period`) + } + rows.push(`
`) + return wrapSummaryDetailsBlock('summary-details-carry-over-plan', planningSectionTitle, rows.join('\n'), { + defaultOpen: true, + summaryExtraClasses: 'plan-title h3', + }) +} + +/** + * HTML list rows for done tasks in the summary (wins or completed — same markup). + * @param {Array} taskLines + * @returns {string} + */ +function formatSummaryTaskItemsHTML(taskLines: Array): string { + return taskLines + .map( + (taskLine) => ` +
+ + ${formatTaskAsHTML(taskLine)} +
`, + ) + .join('\n') +} + +/** + * Singular or plural "task" / "tasks" for summary counts (0 uses "tasks"). + * @param {number} count + * @returns {string} + */ +function pluralCompletedTaskWord(count: number): string { + return count === 1 ? 'task' : 'tasks' +} + +/** + * Title row for the done-task summary: plain "N completed task(s)", or "N other completed task(s)" after a wins block. + * @param {number} lineCount + * @param {'plain' | 'other'} variant + * @returns {string} + */ +function formatCompletedTasksSummaryHeading(lineCount: number, variant: 'plain' | 'other'): string { + const w = pluralCompletedTaskWord(lineCount) + if (variant === 'other') { + return `${lineCount} other completed ${w}` + } + return `${lineCount} completed ${w}` +} + +/** + * Summary card: optional carry-over plan tasks, then one completed-task list (wins first: #win / #bigwin / configured big-task marker, then other dones; each line once) and (daily only) calendar events. + * @param {string} periodType + * @param {Array<{ content: string, isDone: boolean }>} carryOverPlanItems + * @param {Array} winTasks + * @param {Array} completedTasks non-win completed tasks (daily only) + * @param {Array} eventsForPeriod server-built events when not using client summary + * @returns {string} HTML for section-wrap or '' + */ +function buildReviewSummarySectionHTML( + periodType: string, + carryOverPlanItems: Array<{ content: string, isDone: boolean }>, + planningSectionTitle: string, + winTasks: Array, + completedTasks: Array, + eventsForPeriod: Array +): string { + const hasCarryOver = carryOverPlanItems.length > 0 + const isDay = periodType === 'day' + const isWeek = periodType === 'week' + const shouldShowCompletedTaskBlocks = isDay || isWeek + const carryKeysOnly: Array<{ content: string }> = carryOverPlanItems.map((c) => ({ content: c.content })) + /** Full list: unique wins (not in carry) first, then unique non-wins — same order as mergeUniqueSummaryDoneTaskLines. */ + const mergedCompletedLines: Array = mergeUniqueSummaryDoneTaskLines(winTasks, completedTasks, carryKeysOnly) + /** Split by the same win rules as note scanning (not by merge prefix length — avoids runtime mismatch). */ + const { wins: mergedWinsLines, others: mergedOtherLines } = splitMergedSummaryDoneLinesIntoWinsAndOthers(mergedCompletedLines) + if (!hasCarryOver && !shouldShowCompletedTaskBlocks) { + return '' + } + const parts: Array = [ + '
', + ] + const carryBlock = makeCarryOverPlanSummaryContentDiv(planningSectionTitle, carryOverPlanItems) + parts.push(carryBlock) + + const pushDoneTasksSummaryBlocks = () => { + if (mergedCompletedLines.length === 0) { + parts.push( + wrapSummaryDetailsBlock( + 'summary-details-completed-tasks', + formatCompletedTasksSummaryHeading(0, 'plain'), + `
\n
No completed tasks found during the ${periodType}
\n
`, + { defaultOpen: false }, + ), + ) + return + } + if (mergedWinsLines.length > 0 && mergedOtherLines.length > 0) { + parts.push( + wrapSummaryDetailsBlock( + 'summary-details-completed-wins', + formatWinsSummaryHeading(mergedWinsLines.length), + `
\n${formatSummaryTaskItemsHTML(mergedWinsLines)}\n
`, + { defaultOpen: false }, + ), + ) + parts.push( + wrapSummaryDetailsBlock( + 'summary-details-completed-other', + formatCompletedTasksSummaryHeading(mergedOtherLines.length, 'other'), + `
\n${formatSummaryTaskItemsHTML(mergedOtherLines)}\n
`, + { defaultOpen: false }, + ), + ) + return + } + const singleBlockLines = mergedWinsLines.length > 0 ? mergedWinsLines : mergedOtherLines + parts.push( + wrapSummaryDetailsBlock( + 'summary-details-completed-tasks', + formatCompletedTasksSummaryHeading(singleBlockLines.length, 'plain'), + `
\n${formatSummaryTaskItemsHTML(singleBlockLines)}\n
`, + { defaultOpen: false }, + ), + ) + } + + if (isDay) { + pushDoneTasksSummaryBlocks() + parts.push(makePeriodDaysSummaryDiv(eventsForPeriod)) + } else if (isWeek && mergedCompletedLines.length > 0) { + pushDoneTasksSummaryBlocks() + } + parts.push('
') + return parts.join('\n') +} + +/** + * Planning block (outside the main review form): title + tasks textarea. + * @param {string} planningSectionTitle + * @returns {string} + */ +function makePlanningSectionHTML(planningSectionTitle: string): string { + return `
+
${escapeHTML(planningSectionTitle)}
+
+
+ +
+
+
` +} + +/** + * Build a single form row for one question (used for string, subheading, and legacy paths). + * Note: This assumes one question per line. *So no longer used for boolean/int/number/mood inline types (I hope).* + * @param {ParsedQuestionType} parsedQuestion + * @param {number} index + * @param {JournalConfigType} config + * @param {string} initialValue + * @param {string} periodString calendar period title for `` / `` in question text + * @param {string} periodType + * @returns {string} + */ +function makeReviewQuestionRowDiv( + parsedQuestion: ParsedQuestionType, + index: number, + config: PeriodicReviewConfigType, + initialValue: string = '', + periodString: string, + periodType: string, +): string { + const fieldName = `q_${index}` + /** Parsed question text still contains `` etc.; raw display lines are substituted earlier. */ + const questionForDisplay = substituteReviewPeriodPlaceholders(parsedQuestion.question, periodString, periodType) + if (parsedQuestion.type === 'subheading' || parsedQuestion.type === 'h2' || parsedQuestion.type === 'h3') { + const cleanHeading = stripPresentationDelimiters(questionForDisplay) + const tag = parsedQuestion.type === 'h2' ? 'h2' : 'h3' // `` defaults to h3 + const className = tag + return `
${escapeHTML(cleanHeading)}
` + } + + const questionText = stripPresentationDelimiters(questionForDisplay).trim() + const questionLabel = `` + let control = '' + const useInlineRow = useFlexbox && !blockRowTypes.includes(parsedQuestion.type) + const rowClass = useInlineRow ? 'review-row review-row-inline' : 'review-row' + const checkedAttr = initialValue === 'yes' ? ' checked' : '' + switch (parsedQuestion.type) { + case 'boolean': { + control = `` + break + } + case 'int': + case 'number': { + control = `` + break + } + case 'duration': { + control = `` + break + } + case 'mood': { + const moodArray = (typeof config.moods === 'string') ? config.moods.split(',').map((m) => m.trim()) : config.moods + const moodOptions = moodArray + .filter((m) => m !== '') + .map((mood) => { + const selectedAttr = mood === initialValue ? ' selected' : '' + return `` + }) + .join('') + control = `` + break + } + case 'string': { + control = `` + break + } + case 'bullets': + case 'checklists': + case 'tasks': { + control = `` + break + } + default: { + logWarn(`makeReviewQuestionRowDiv(): unknown question type: ${parsedQuestion.type} -- ignoring it.`) + break + } + } + return useInlineRow + ? `
${questionLabel}
${control}
` + : `
${questionLabel}
${control}
` +} + +/** + * Build HTML body for single-window review form. + * @tests in jest file + * @param {JournalConfigType} config + * @param {Array} parsedQuestions same order as parseQuestions(rawQuestionLines) (field names q_0 …) + * @param {Array} rawQuestionLines lines from getQuestionsForPeriod() + * @param {Array} summaryWinTasks done tasks tagged as wins (#win / #bigwin / configured big-task marker); listed first in the single summary list + * @param {Array} summaryCompletedTasks other done tasks (daily only; excludes win lines) + * @param {string} periodString the calendar note title string for the review period + * @param {string} periodType + * @param {Array} eventsForPeriod + * @param {string} callbackCommandName + * @param {{ [string]: string }=} initialAnswers field names q_0 … to pre-fill from the calendar note + * @param {{ carryOverPlanItems?: Array<{ content: string, isDone: boolean }>, planningSectionTitle?: string }=} reviewExtras carry-over plan tasks + planning block title + * @param {Array=} calendarSetForClientSummary calendars to include (empty = all), for WebView `Calendar.*` path + * @param {string=} reviewDayYyyymmdd YYYYMMDD from `convertISOToYYYYMMDD(periodString)` when period is daily + * @param {boolean=} overrideExperimentalClientCalendar override module flag: false forces server-rendered events; true forces client mount when daily + review day set + * @returns {string} + */ +export function buildReviewHTML( + config: PeriodicReviewConfigType, + parsedQuestions: Array, + rawQuestionLines: Array, + summaryWinTasks: Array, + summaryCompletedTasks: Array, + periodString: string, + periodType: string, + eventsForPeriod: Array, + callbackCommandName: string, + planName: string, + initialAnswers?: { [string]: string }, + carryOverPlanItems?: Array<{ content: string, isDone: boolean }>, + calendarSetForClientSummary?: Array, + reviewDayYyyymmdd?: string, + overrideExperimentalClientCalendar?: ?boolean, +): string { + const periodAdjective = getPeriodAdjectiveFromType(periodType) + const resolvedInitialAnswers = initialAnswers ?? {} + const resolvedCarryOver = carryOverPlanItems ?? [] + const calendarSetResolved: Array = calendarSetForClientSummary ?? [] + const reviewDayResolved: string = reviewDayYyyymmdd ?? '' + const plannedSectionTitle = buildThisPlanSectionHeadingTitle(planName) + const planningSectionTitle = buildNextPlanSectionHeadingTitle(planName, periodType) + const renderQuestionLines = rawQuestionLines.map((l) => substituteReviewPeriodPlaceholders(l, periodString, periodType)) + const questionRows = renderQuestionLines + .map((line, lineIndex) => + makeQuestionLineDiv(line, lineIndex, parsedQuestions, config, resolvedInitialAnswers, periodString, periodType), + ) + .filter((row) => row !== '') + .join('\n') + const summarySection = buildReviewSummarySectionHTML( + periodType, + resolvedCarryOver, + plannedSectionTitle, + summaryWinTasks, + summaryCompletedTasks, + eventsForPeriod + ) + const planningSectionHtml = planningSectionTitle !== '' ? makePlanningSectionHTML(planningSectionTitle) : '' + + return ` +
+
+ ${escapeHTML(periodAdjective)} Review for + + ${escapeHTML(periodString)} + +
+
+ +
+
+ + ${summarySection} + + +
+ ${questionRows} +
+ + ${planningSectionHtml} + + +
+ + + + +
+ + + ` +} diff --git a/jgclark.PeriodicReviews/src/reviewQuestions.js b/jgclark.PeriodicReviews/src/reviewQuestions.js new file mode 100644 index 000000000..42d6f4d4f --- /dev/null +++ b/jgclark.PeriodicReviews/src/reviewQuestions.js @@ -0,0 +1,748 @@ +// @flow +//--------------------------------------------------------------- +// Review question parsing, pre-fill extraction, and answer → note text. +// Jonathan Clark +// last update 2026-04-26 for v2.0.0.b12 by @jgclark + @Cursor +//--------------------------------------------------------------- + +import type { ParsedQuestionType } from './periodicReviewHelpers' +import { substituteReviewPeriodPlaceholders } from './periodicReviewHelpers' +import { escapeRegExp } from '@helpers/regex' +import { isInt } from '@helpers/userInput' + +/** `` names in review question templates — single source for parse + HTML segment splitting. (`integer` before `int` so `` matches as one token.) */ +export const REVIEW_QUESTION_TYPE_NAMES_ALT = + 'string|integer|int|number|duration|boolean|mood|subheading|bullets|checklists|tasks' + +/** + * Strip `:`, parentheses, and angle-bracket type tokens (``, ``, …) from a segment when deriving the human label. + * Must match full `` tags only — not bare names (e.g. `string` would wrongly match inside words and leave stray `<>`). + */ +const RE_SEGMENT_LABEL_STRIP = new RegExp( + `:|\\(|\\)|<\\s*(?:${REVIEW_QUESTION_TYPE_NAMES_ALT.split('|') + .map((t) => escapeRegExp(t)) + .join('|')})\\s*>`, + 'gi', +) + +/** + * TODO: pull these two to be just a constant. + * RegExp matching one typed segment (e.g. `Sleep: hours`) — same pattern as `parseQuestions` uses. + * Callers should use a fresh instance per scan or reset `lastIndex` to avoid `/g` state bugs. + * @returns {RegExp} global, case-insensitive + */ +export function getReviewQuestionSegmentRegExpGi(): RegExp { + return new RegExp(`[^<]*?<\\s*(?:${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>\\)?[^\\s]*`, 'gi') +} + +/** + * RegExp matching the type tag within a segment (first capture = type name). + * @returns {RegExp} + */ +export function getReviewQuestionTypeTagRegExp(): RegExp { + return new RegExp(`<\\s*(${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>`, 'i') +} + +const RE_DURATION_HHMM = /^(\d{1,2}):([0-5]\d)$/ + +/** + * Parse question lines to extract questions and their types. + * Supports multiple questions per line separated by '||'. + * @tests in __tests__/periodReviews.test.js + * @param {Array | string} questionLines raw question lines from config + * @returns {Array} + */ +export function parseQuestions(questionLines: Array | string): Array { + const parsed = [] + const typeRE = getReviewQuestionTypeTagRegExp() + const segmentRE = getReviewQuestionSegmentRegExpGi() + const linesToProcess = Array.isArray(questionLines) ? questionLines : String(questionLines ?? '').split('\n') + + for (let lineIndex = 0; lineIndex < linesToProcess.length; lineIndex++) { + const line = linesToProcess[lineIndex] + const mH2 = line.match(/^##\s+(.+)$/) + if (mH2) { + parsed.push({ question: String(mH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) + continue + } + const mH3 = line.match(/^###\s+(.+)$/) + if (mH3) { + parsed.push({ question: String(mH3[1] ?? '').trim(), type: 'h3', originalLine: line, lineIndex }) + continue + } + const questionParts = line.split(/\s*\|\|\s*/).map(part => part.trim()).filter(part => part !== '') + + for (const questionPart of questionParts) { + const segments = questionPart.match(segmentRE) ?? [] + for (const segmentRaw of segments) { + const segment = segmentRaw.trim() + const reArray = segment.match(typeRE) + let questionType = String(reArray?.[1] ?? '').toLowerCase() + if (questionType === 'integer') { + questionType = 'int' + } + const tokenMatch = segment.match(/([@#][^\s(<]+)/) + const question = tokenMatch?.[1] ?? segment.replace(RE_SEGMENT_LABEL_STRIP, '').trim() + parsed.push({ question, type: questionType, originalLine: segment, lineIndex }) + } + } + } + + return parsed +} + +/** + * Handle an HTML heading question type. + * @param {string} question the question text + * @param {string} headingType 'subheading' (legacy; subheading uses same marker as h3) + * @returns {string} the formatted markdown heading block + */ +function handleHeadingQuestion(question: string, headingType: string): string { + const cleanHeading = question.replace(/<\s*subheading\s*>\s*$/i, '').trim() + const headingMarker = headingType === 'h2' ? '##' : '###' + return `\n${headingMarker} ${cleanHeading}` +} + +/** Output prefix per line for multiline journal types (``, ``, ``). */ +const MULTILINE_ANSWER_PREFIX_BY_TYPE: { [string]: string } = { + bullets: '- ', + checklists: '+ ', + tasks: '* ', +} + +/** + * Markdown prefix written before each answer line for a multiline question type. + * @param {string} questionType + * @returns {string} + */ +function linePrefixForMultilineAnswerType(questionType: string): string { + return MULTILINE_ANSWER_PREFIX_BY_TYPE[questionType.toLowerCase()] ?? '' +} + +/** + * Remove leading line markers from saved note text so the review textarea shows plain lines. + * @param {string} rawBlock + * @param {string} linePrefix e.g. '- ' + * @returns {string} + */ +function stripMultilineAnswerPrefixes(rawBlock: string, linePrefix: string): string { + if (linePrefix === '') { + return rawBlock.trim() + } + return rawBlock + .split(/\r?\n/) + .map((l) => { + const trimmed = l.trim() + if (trimmed.startsWith(linePrefix)) { + return trimmed.slice(linePrefix.length).trim() + } + return trimmed + }) + .join('\n') + .trim() +} + +/** + * Split a question segment at the typed marker (same idea as reviewHTMLViewGenerator.splitSegmentAtTypeMarker). + * @param {string} segment + * @param {string} questionType + * @returns {{ prefix: string, suffix: string }} + */ +function splitParsedSegmentAtTypeMarker(segment: string, questionType: string): {| prefix: string, suffix: string |} { + const pattern = + questionType.toLowerCase() === 'int' + ? '<\\s*(?:integer|int)\\s*>' + : `<\\s*${escapeRegExp(questionType)}\\s*>` + const re = new RegExp(pattern, 'i') + const m = segment.match(re) + if (!m || m.index == null) { + return { prefix: segment, suffix: '' } + } + const idx = m.index + const tagLen = m[0].length + return { prefix: segment.slice(0, idx), suffix: segment.slice(idx + tagLen) } +} + +/** + * Leading static label of a template segment before the first `@`, `#`, or `<…>` type marker. + * e.g. `"Programming: @prog()"` → `"Programming: "`. + * @param {string} segment + * @returns {string} + */ +export function extractLeadingStaticLabelFromSegment(segment: string): string { + const s = String(segment ?? '') + const idx = s.search(/[@#<]/) + if (idx <= 0) { + return '' + } + const label = s.slice(0, idx) + return label.trim() === '' ? '' : label +} + +/** + * Strip a sibling field's token contribution from residual line text (used when extracting trailing free-text). + * @param {string} text + * @param {ParsedQuestionType} sibling + * @returns {string} + */ +function stripSiblingTokenFromResidualText(text: string, sibling: ParsedQuestionType): string { + const t = String(sibling.type ?? '').toLowerCase() + const token = String(sibling.question ?? '').trim() + if (t === 'boolean' && token !== '') { + return text.replace(new RegExp(`(?:^|\\s)${escapeRegExp(token)}(?=\\s|$)`, 'g'), ' ') + } + if (token.startsWith('@') || token.startsWith('#')) { + return text.replace(new RegExp(`${escapeRegExp(token)}\\s*\\([^)]*\\)`, 'gi'), ' ') + } + return text +} + +/** + * Extract trailing free-text for a bare `` segment that shares a template line with other fields. + * Example: template `Programming: @prog() ` and note line `Programming: Things I've already noted.` + * @param {string} line note line + * @param {ParsedQuestionType} stringQuestion + * @param {Array} siblings all questions on the same template line (including stringQuestion) + * @returns {string} + */ +function extractResidualStringOnMixedLine( + line: string, + stringQuestion: ParsedQuestionType, + siblings: Array, +): string { + if (siblings.length <= 1) { + return '' + } + const firstSeg = String(siblings[0]?.originalLine ?? '') + const label = extractLeadingStaticLabelFromSegment(firstSeg) + let rest = line + if (label !== '') { + const idx = line.toLowerCase().indexOf(label.toLowerCase()) + if (idx < 0) { + return '' + } + rest = line.slice(idx + label.length) + } + for (const sib of siblings) { + if (sib === stringQuestion) { + continue + } + rest = stripSiblingTokenFromResidualText(rest, sib) + } + return rest.replace(/\s+/g, ' ').trim() +} + +/** + * Normalize a line prefix used to match `` answers against existing note content. + * @param {string} input + * @returns {string} + */ +function normalizeStringMatchKey(input: string): string { + return String(input ?? '') + .trim() + .replace(/\s+/g, ' ') + .toLowerCase() +} + +/** + * Return normalized upsert key from one parsed segment prefix. + * @param {ParsedQuestionType} parsedQuestion + * @returns {string} + */ +function getSegmentPrefixUpsertKey(parsedQuestion: ParsedQuestionType): string { + const { prefix } = splitParsedSegmentAtTypeMarker(String(parsedQuestion.originalLine ?? ''), String(parsedQuestion.type ?? '')) + const key = normalizeStringMatchKey(prefix) + if (key === '' || key.startsWith('-')) { + return '' + } + return key +} + +/** + * Return stable match key for a parsed `` question segment (text before the `` tag). + * Empty key means this question should not attempt line upsert matching. + * @tests in jest file + * @param {ParsedQuestionType} parsedQuestion + * @returns {string} + */ +export function getStringQuestionMatchKeyFromParsedQuestion(parsedQuestion: ParsedQuestionType): string { + if (String(parsedQuestion.type).toLowerCase() !== 'string') { + return '' + } + const { prefix } = splitParsedSegmentAtTypeMarker(String(parsedQuestion.originalLine ?? ''), 'string') + const key = normalizeStringMatchKey(prefix) + return key.startsWith('-') ? '' : key +} + +/** + * Return the `` question match key corresponding to an output line, if any. + * @tests in jest file + * @param {string} outputLine + * @param {Array} parsedQuestions + * @returns {string} + */ +export function getStringQuestionMatchKeyFromOutputLine(outputLine: string, parsedQuestions: Array): string { + const normalizedOutput = normalizeStringMatchKey(outputLine) + if (normalizedOutput === '') { + return '' + } + const candidateKeys = parsedQuestions + .map((pq) => getStringQuestionMatchKeyFromParsedQuestion(pq)) + .filter((k) => k !== '') + .sort((a, b) => b.length - a.length) + const matchedKey = candidateKeys.find((k) => normalizedOutput.startsWith(k)) + return matchedKey ?? '' +} + +/** + * Return stable match key for a parsed template line (lineIndex group), used by mixed-line upsert. + * Prefers the leading static line label (e.g. "programming:") so note lines without filled @tokens still match. + * @tests in jest file + * @param {Array} parsedQuestions + * @param {number} lineIndex + * @returns {string} + */ +export function getTemplateLineUpsertKey(parsedQuestions: Array, lineIndex: number): string { + const lineQuestions = parsedQuestions.filter((pq) => pq.lineIndex === lineIndex) + if (lineQuestions.length === 0) { + return '' + } + const labelKey = normalizeStringMatchKey(extractLeadingStaticLabelFromSegment(String(lineQuestions[0].originalLine ?? ''))) + if (labelKey !== '') { + return labelKey + } + for (const pq of lineQuestions) { + const key = getSegmentPrefixUpsertKey(pq) + if (key !== '') { + return key + } + } + return '' +} + +/** + * Return the template-line upsert key corresponding to an output line, if any. + * @tests in jest file + * @param {string} outputLine + * @param {Array} parsedQuestions + * @returns {string} + */ +export function getTemplateLineUpsertKeyFromOutputLine(outputLine: string, parsedQuestions: Array): string { + const normalizedOutput = normalizeStringMatchKey(outputLine) + if (normalizedOutput === '') { + return '' + } + const lineIndexes = Array.from(new Set(parsedQuestions.map((pq) => pq.lineIndex))) + const candidateKeys = lineIndexes + .map((idx) => getTemplateLineUpsertKey(parsedQuestions, idx)) + .filter((k) => k !== '') + .sort((a, b) => b.length - a.length) + const matchedKey = candidateKeys.find((k) => normalizedOutput.startsWith(k)) + return matchedKey ?? '' +} + +/** + * For unchecked boolean answers present in payload, return question-text tokens to clear from existing upsert target lines. + * @tests in jest file + * @param {Array} parsedQuestions + * @param {{ [string]: string | boolean }} answersByIndex + * @returns {Array<{ lineKey: string, tokensToClear: Array }>} + */ +export function getBooleanClearDirectivesFromAnswers( + parsedQuestions: Array, + answersByIndex: { [string]: string | boolean }, +): Array<{| lineKey: string, tokensToClear: Array |}> { + const tokensByLineKey: { [string]: Array } = {} + for (let i = 0; i < parsedQuestions.length; i++) { + const pq = parsedQuestions[i] + if (String(pq.type).toLowerCase() !== 'boolean') { + continue + } + const fieldName = `q_${i}` + if (!(fieldName in answersByIndex)) { + continue + } + const raw = answersByIndex[fieldName] + const isChecked = raw === true || String(raw ?? '').toLowerCase() === 'yes' + if (isChecked) { + continue + } + const token = String(pq.question ?? '').trim() + if (token === '') { + continue + } + const lineKey = getTemplateLineUpsertKey(parsedQuestions, pq.lineIndex) + if (lineKey === '') { + continue + } + if (!tokensByLineKey[lineKey]) { + tokensByLineKey[lineKey] = [] + } + tokensByLineKey[lineKey].push(token) + } + return Object.keys(tokensByLineKey).map((lineKey) => ({ + lineKey, + tokensToClear: tokensByLineKey[lineKey], + })) +} + +/** + * Parse one line of note content for a single parsed question's answer (form-ready value). Match for question in case-insensitive way. + * @param {ParsedQuestionType} parsedQuestion + * @param {string} line + * @param {Array} [siblingsOnLine] other questions that share the same template lineIndex (including self) + * @returns {string} value for the HTML control, or '' if not found on this line + */ +function extractExistingAnswerOnLine( + parsedQuestion: ParsedQuestionType, + line: string, + siblingsOnLine: Array = [], +): string { + const t = parsedQuestion.type.toLowerCase() + const seg = parsedQuestion.originalLine.trim() + const token = parsedQuestion.question + if (t === 'subheading' || t === 'h2' || t === 'h3') { + return '' + } + if (t === 'boolean') { + const token = parsedQuestion.question + if (!token) { + return '' + } + const re = new RegExp(`(?:^|\\s)${escapeRegExp(token)}(?=\\s|$)`) + return re.test(line) ? 'yes' : '' + } + const { prefix, suffix } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) + if (t === 'int') { + if (token && token.startsWith('@')) { + const tokenIntRE = new RegExp(`${escapeRegExp(token)}\\s*\\(\\s*(\\d+)\\s*\\)`, 'i') + const tokenIntMatch = line.match(tokenIntRE) + if (tokenIntMatch?.[1] != null) { + return tokenIntMatch[1] + } + } + const re = new RegExp(`${escapeRegExp(prefix)}(\\d+)${escapeRegExp(suffix)}`) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } + if (t === 'number') { + if (token && token.startsWith('@')) { + const tokenNumberRE = new RegExp( + `${escapeRegExp(token)}\\s*\\(\\s*([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)\\s*\\)`, + 'i', + ) + const tokenNumberMatch = line.match(tokenNumberRE) + if (tokenNumberMatch?.[1] != null) { + return tokenNumberMatch[1] + } + } + const re = new RegExp( + `${escapeRegExp(prefix)}([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)${escapeRegExp(suffix)}`, + ) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } + if (t === 'duration') { + if (token && token.startsWith('@')) { + const tokenDurationRE = new RegExp(`${escapeRegExp(token)}\\s*\\(\\s*(\\d{1,2}:[0-5]\\d)\\s*\\)`, 'i') + const tokenDurationMatch = line.match(tokenDurationRE) + if (tokenDurationMatch?.[1] != null) { + return tokenDurationMatch[1] + } + } + const re = new RegExp(`${escapeRegExp(prefix)}(\\d{1,2}:[0-5]\\d)${escapeRegExp(suffix)}`) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } + if (t === 'bullets' || t === 'checklists' || t === 'tasks') { + const marker = linePrefixForMultilineAnswerType(t) + const { prefix: p2, suffix: s2 } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) + if (s2 === '') { + if (p2 === '') { + const trimmed = line.trim() + if (!trimmed.startsWith(marker.trim())) { + return '' + } + return stripMultilineAnswerPrefixes(trimmed, marker) + } + const idx = line.toLowerCase().indexOf(p2.toLowerCase()) + if (idx < 0) { + return '' + } + const raw = line.slice(idx + p2.length).trim() + if (raw === '') { + return '' + } + return stripMultilineAnswerPrefixes(raw, marker) + } + const re = new RegExp(`${escapeRegExp(p2)}([\\s\\S]*?)${escapeRegExp(s2)}`, 'i') + const m = line.match(re) + if (m?.[1] == null) { + return '' + } + return stripMultilineAnswerPrefixes(m[1].trim(), marker) + } + if (t === 'mood' || t === 'string') { + if (suffix === '') { + if (prefix === '') { + // Bare trailing `` on a multi-field line (e.g. after `@prog()`). + if (t === 'string') { + return extractResidualStringOnMixedLine(line, parsedQuestion, siblingsOnLine) + } + return '' + } + const idx = line.toLowerCase().indexOf(prefix.toLowerCase()) + if (idx < 0) { + return '' + } + let rest = line.slice(idx + prefix.length).trim() + // When the segment prefix includes earlier @tokens (unusual), still strip siblings if provided. + if (t === 'string' && siblingsOnLine.length > 1) { + for (const sib of siblingsOnLine) { + if (sib === parsedQuestion) { + continue + } + rest = stripSiblingTokenFromResidualText(rest, sib) + } + rest = rest.replace(/\s+/g, ' ').trim() + } + return rest + } + const re = new RegExp(`${escapeRegExp(prefix)}(.*?)${escapeRegExp(suffix)}`, 'i') + const m = line.match(re) + return m?.[1] != null ? m[1].trim() : '' + } + return '' +} + +/** + * Get first matching answer in the note for question + * @param {ParsedQuestionType} parsedQuestion + * @param {Array} textLines + * @param {Array} allParsedQuestions full parse list (to resolve same-line siblings) + * @returns {string} + */ +function extractExistingAnswerForReviewForm( + parsedQuestion: ParsedQuestionType, + textLines: Array, + allParsedQuestions: Array, +): string { + const siblingsOnLine = allParsedQuestions.filter((q) => q.lineIndex === parsedQuestion.lineIndex) + for (let i = 0; i <= textLines.length - 1; i++) { + const line = textLines[i] + const v = extractExistingAnswerOnLine(parsedQuestion, line, siblingsOnLine) + if (v !== '') { + return v + } + } + return '' +} + +/** + * Map field names q_0, q_1, … to existing answers in the calendar note for pre-filling the review HTML form. + * @tests in __tests__/periodReviews.test.js + * @param {Array} parsedQuestions + * @param {Array} textLines text of lines to scan + * @returns {{ [string]: string }} + */ +export function buildInitialReviewAnswersByFieldName( + parsedQuestions: Array, + textLines: Array, +): { [string]: string } { + const out: { [string]: string } = {} + for (let i = 0; i < parsedQuestions.length; i++) { + const pq = parsedQuestions[i] + const v = extractExistingAnswerForReviewForm(pq, textLines, parsedQuestions) + if (v !== '') { + out[`q_${i}`] = v + } + } + return out +} + +/** + * Convert parsed questions into line-indexed groups. + * @param {Array} parsedQuestions + * @returns {{ [number]: Array }} + */ +function groupQuestionsByLine(parsedQuestions: Array): { [number]: Array } { + const questionsByLine: { [number]: Array } = {} + for (let i = 0; i < parsedQuestions.length; i++) { + const q = parsedQuestions[i] + if (!questionsByLine[q.lineIndex]) { + questionsByLine[q.lineIndex] = [] + } + questionsByLine[q.lineIndex].push(q) + } + return questionsByLine +} + +/** + * Convert answer payload from single window into output line for one parsed question. + * @param {ParsedQuestionType} parsedQuestion + * @param {string | boolean} answerRaw + * @returns {string} + */ +function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answerRaw: string | boolean): string { + const t = parsedQuestion.type + if (t === 'boolean') { + const on = answerRaw === true || answerRaw === 'yes' + return on ? parsedQuestion.question : '' + } + const answer = (typeof answerRaw === 'string' ? answerRaw : String(answerRaw ?? '')).trim() + if (answer === '' && t !== 'subheading' && t !== 'h2' && t !== 'h3') { + return '' + } + switch (t) { + case 'int': { + if (isInt(answer)) { + return parsedQuestion.originalLine.startsWith('-') + ? `- ${answer}` + : parsedQuestion.originalLine.replace(/<\s*(?:integer|int)\s*>/i, answer) + } + return '' + } + case 'number': { + if (answer != null && Number(answer)) { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + return '' + } + case 'duration': { + if (RE_DURATION_HHMM.test(answer)) { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + return '' + } + case 'string': { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + case 'mood': { + return parsedQuestion.originalLine.replace(//, answer) + } + case 'bullets': + case 'checklists': + case 'tasks': { + const marker = linePrefixForMultilineAnswerType(t) + const lines = answer.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '') + if (lines.length === 0) { + return '' + } + const formatted = lines.map((l) => `${marker}${l}`).join('\n') + const ol = parsedQuestion.originalLine + if (ol.trimStart().startsWith('-')) { + return formatted + } + const { prefix, suffix } = splitParsedSegmentAtTypeMarker(ol, t) + const prefixTrimmed = prefix.trimEnd() + if (prefixTrimmed === '') { + return `${formatted}${suffix}` + } + return `${prefixTrimmed}\n${formatted}${suffix}` + } + case 'subheading': { + return handleHeadingQuestion(parsedQuestion.question, 'subheading') + } + case 'h2': { + return handleHeadingQuestion(parsedQuestion.question, 'h2') + } + case 'h3': { + return handleHeadingQuestion(parsedQuestion.question, 'h3') + } + default: { + return '' + } + } +} + +/** + * Join per-segment answer strings for one template line. + * When earlier token segments are empty, keep the leading static label (e.g. `Programming: `) so trailing free-text is not orphaned. + * @param {Array} lineQuestions + * @param {Array} renderedSegments non-empty rendered segment strings in template order + * @param {boolean} firstSegmentRendered whether the first template segment produced output + * @returns {string} + */ +function joinRenderedLineSegments( + lineQuestions: Array, + renderedSegments: Array, + firstSegmentRendered: boolean, +): string { + if (renderedSegments.length === 0) { + return '' + } + let combined = renderedSegments.join(' ') + if (!firstSegmentRendered && lineQuestions.length > 0) { + const label = extractLeadingStaticLabelFromSegment(String(lineQuestions[0].originalLine ?? '')) + if (label !== '') { + const labelNorm = normalizeStringMatchKey(label) + const combinedNorm = normalizeStringMatchKey(combined) + if (!combinedNorm.startsWith(labelNorm)) { + // label often already ends with a space (e.g. "Programming: ") + const joined = label.endsWith(' ') || label.endsWith(':') ? `${label}${combined}` : `${label} ${combined}` + combined = joined.replace(/\s+/g, ' ').trimEnd() + } + } + } + if (!combined.includes('\n')) { + combined = combined.replace(/\s+/g, ' ') + } + return combined.trimEnd() +} + +/** + * Build output from answers returned by single-window mode. + * @tests in __tests__/periodReviews.test.js + * @param {Array} parsedQuestions + * @param {Array} rawQuestionLines + * @param {string} periodString + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @param {{ [string]: string | boolean }} answersByIndex + * @returns {string} + */ +export function buildOutputFromReviewWindowAnswers( + parsedQuestions: Array, + rawQuestionLines: Array, + periodString: string, + periodType: string, + answersByIndex: { [string]: string | boolean }, +): string { + let output = '' + const questionsByLine = groupQuestionsByLine(parsedQuestions) + const lineCount = rawQuestionLines.length + const stripPresentationDelimiters = (input: string): string => input.replace(/ \|\| /g, ' ') + + for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { + const lineQuestions = questionsByLine[lineIndex] ?? [] + const lineAnswers: Array = [] + let firstSegmentRendered = false + for (let i = 0; i < lineQuestions.length; i++) { + const globalIndex = parsedQuestions.findIndex((q) => q === lineQuestions[i]) + const parsedQuestion = lineQuestions[i] + const answer = answerFromReviewWindowPayload(parsedQuestion, answersByIndex[`q_${globalIndex}`] ?? '') + if (answer !== '') { + if (i === 0) { + firstSegmentRendered = true + } + lineAnswers.push(answer) + } + } + if (lineAnswers.length > 0) { + const hasMultiline = lineAnswers.some((a) => a.includes('\n')) + let combinedLine = hasMultiline + ? lineAnswers.join('\n') + : joinRenderedLineSegments(lineQuestions, lineAnswers, firstSegmentRendered) + output += `${substituteReviewPeriodPlaceholders(combinedLine, periodString, periodType)}\n` + continue + } + + const rawLine = rawQuestionLines[lineIndex] ?? '' + if (/<\s*date\s*>/i.test(rawLine) || /<\s*(?:datenext|nextdate)\s*>/i.test(rawLine)) { + const substituted = substituteReviewPeriodPlaceholders(stripPresentationDelimiters(rawLine), periodString, periodType).trim() + if (substituted !== '') { + output += `${substituted}\n` + } + } + } + return output +} diff --git a/jgclark.PeriodicReviews/src/templatesStartEnd.js b/jgclark.PeriodicReviews/src/templatesStartEnd.js new file mode 100644 index 000000000..2d5283790 --- /dev/null +++ b/jgclark.PeriodicReviews/src/templatesStartEnd.js @@ -0,0 +1,238 @@ +// @flow +//--------------------------------------------------------------- +// Add Templates to Periodic notes at start and end of period +// Jonathan Clark +// last update 2026-04-05 for v0.1.0 by @jgclark +//--------------------------------------------------------------- + +import { type JournalConfigType, getJournalSettings } from './periodicReviewHelpers' +import { isDailyNote, isMonthlyNote, isWeeklyNote } from '@helpers/dateTime' +import { logDebug, logError, logInfo, logWarn } from '@helpers/dev' +import { displayTitle } from '@helpers/general' +import { getAttributes } from '@helpers/NPFrontMatter' +import { showMessage } from '@helpers/userInput' +import NPTemplating from 'NPTemplating' + +//--------------------------------------------------------------- + +// Configuration mapping for different note types +const NOTE_TYPE_CONFIG = { + day: { + noteType: 'day', + isNoteType: isDailyNote, + startTemplateKey: 'startDailyTemplateTitle', + endTemplateKey: 'endDailyTemplateTitle', + startCommandName: 'dayStart', + endCommandName: 'dayEnd' + }, + week: { + noteType: 'week', + isNoteType: isWeeklyNote, + startTemplateKey: 'startWeeklyTemplateTitle', + endTemplateKey: 'endWeeklyTemplateTitle', + startCommandName: 'weekStart', + endCommandName: 'weekEnd' + }, + month: { + noteType: 'month', + isNoteType: isMonthlyNote, + startTemplateKey: 'startMonthlyTemplateTitle', + endTemplateKey: 'endMonthlyTemplateTitle', + startCommandName: 'monthStart', + endCommandName: 'monthEnd' + } +} + +//--------------------------------------------------------------- + +/** + * Ensure the correct note type is open for template application + * @param {Function} isNoteType - Function to check if current note is correct type + * @param {string} noteType - Type of note ('day', 'week', 'month') + * @param {boolean} workToday - Whether to force opening today's note + */ +async function ensureCorrectNoteOpen(isNoteType: Function, noteType: string, workToday: boolean): Promise { + if (Editor.note && isNoteType(Editor.note) && !workToday) { + // $FlowIgnore(invalid-computed-property-type) .note is a superset of CoreNoteFields + logDebug('ensureCorrectNoteOpen', `Will work on the open ${noteType} note '${displayTitle(Editor.note)}'`) + } else { + logInfo('ensureCorrectNoteOpen', `Started without a ${noteType} note open, so will open and work in this ${noteType}'s note.`) + await Editor.openNoteByDate(new Date(), false, 0, 0, false, noteType) + // $FlowIgnore(invalid-computed-property-type) .note is a superset of CoreNoteFields + logDebug('ensureCorrectNoteOpen', `- for '${displayTitle(Editor.note)}'`) + } +} + +/** + * Render template and insert it into the current note + * @param {string} templateData - Raw template data + * @param {string} templateTitle - Name of the template + * @param {string} commandName - Name of the command for logging + */ +async function renderAndInsertTemplate( + templateData: string, + templateTitle: string, + commandName: string, +): Promise { + try { + if (!templateData || templateData === '') { + logWarn('renderAndInsertTemplate', `templateData is null or empty. Stopping.`) + return + } + // Render the template, using recommended decoupled method of invoking a different plugin + const resultingTextContent = (await DataStore.invokePluginCommandByName('renderTemplate', 'np.Templating', [templateTitle])).trim() + // If no resulting text, or just a newline, then stop early, to try to avoid race conditions for tag-only templates. + if (resultingTextContent == null || resultingTextContent === '' || resultingTextContent === '\n') { + logDebug('renderAndInsertTemplate', `No resulting text from running Template '${templateTitle}'. Stopping.`) + return + } else { + logDebug('renderAndInsertTemplate', `Successfully rendered Template '${templateTitle}' -> ${resultingTextContent.length} characters.`) + } + + // Work out where to insert it in the note, by reading the template, and checking + // the frontmatter attributes for a 'location' field (append/insert/cursor) + const attrs = getAttributes(templateData, true) + const requestedTemplateLocation = attrs.location ?? 'insert' + let pos = 0 + switch (requestedTemplateLocation) { + case 'insert': { + logDebug(commandName, `- Will insert to start of Editor`) + Editor.insertTextAtCharacterIndex(resultingTextContent, 0) + break + } + case 'append': { + pos = Editor.content?.length ?? 0 // end + logDebug(commandName, `- Will insert to end of Editor (pos ${pos})`) + Editor.insertTextAtCharacterIndex(resultingTextContent, pos) + break + } + // Note: unsure if this works. + case 'cursor': { + logDebug(commandName, `- Will insert to Editor at cursor position`) + Editor.insertTextAtCursor(resultingTextContent) + break + } + } + } catch (error) { + logError('renderAndInsertTemplate', error.message) + await showMessage(`Error: ${error.message}`) + } +} + +/** + * Generic template application function for different note types + * @param {string} noteType - Type of note ('day', 'week', 'month') + * @param {boolean} workToday - Whether to force opening today's note + * @param {boolean} isEnd - Whether to apply the end template + */ +async function applyTemplateToNote( + noteType: string, workToday: boolean = false, isEnd: boolean = false +): Promise { + try { + let config + switch (noteType) { + case 'day': + config = NOTE_TYPE_CONFIG.day + break + case 'week': + config = NOTE_TYPE_CONFIG.week + break + case 'month': + config = NOTE_TYPE_CONFIG.month + break + default: + throw new Error(`Unsupported note type: ${noteType}`) + } + + const journalConfig: JournalConfigType = await getJournalSettings() + + // First check we can get the Template + let templateTitle = '' + if (isEnd) { + switch (noteType) { + case 'day': + templateTitle = journalConfig.endDailyTemplateTitle + break + case 'week': + templateTitle = journalConfig.endWeeklyTemplateTitle + break + case 'month': + templateTitle = journalConfig.endMonthlyTemplateTitle + break + } + } else { + switch (noteType) { + case 'day': + templateTitle = journalConfig.startDailyTemplateTitle + break + case 'week': + templateTitle = journalConfig.startWeeklyTemplateTitle + break + case 'month': + templateTitle = journalConfig.startMonthlyTemplateTitle + break + } + } + if (!templateTitle || templateTitle === '') { + throw new Error(`There is no ${noteType} template specified in the plugin settings, so can't continue.`) + } + + const templateData = await NPTemplating.getTemplateContent(templateTitle) + if (templateData == null || templateData === '') { + throw new Error(`Cannot find Template '${templateTitle}' so can't continue.`) + } + + // Handle note opening + await ensureCorrectNoteOpen(config.isNoteType, config.noteType, workToday) + + // Render and insert template + const commandName = isEnd ? config.endCommandName : config.startCommandName + await renderAndInsertTemplate(templateData, templateTitle, commandName) + + } catch (error) { + logError('applyTemplateToNote', error.message) + await showMessage(`Error: ${error.message}`) + } +} + +//--------------------------------------------------------------- + +// Apply user's (start) Daily Note Template open daily note +export async function dayStart(workToday: boolean = false): Promise { + await applyTemplateToNote('day', workToday, false) +} + +// Apply user's (start) Daily Note Template to today's daily note +export async function todayStart(): Promise { + await dayStart(true) +} + +// Apply user's (start) Weekly Note Template to the open weekly note +export async function weekStart(): Promise { + await applyTemplateToNote('week', false, false) +} + +// Apply user's (start) Monthly Note Template to the open monthly note +export async function monthStart(): Promise { + await applyTemplateToNote('month', false, false) +} + +// Apply user's (end) Daily Note Template to the currently open daily note +export async function dayEnd(workToday: boolean = false): Promise { + await applyTemplateToNote('day', workToday, true) +} + +// Apply user's (end) Daily Note Template to today's daily note +export async function todayEnd(): Promise { + await dayEnd(true) +} + +// Apply user's (end) Weekly Note Template to the currently open weekly note +export async function weekEnd(): Promise { + await applyTemplateToNote('week', false, true) +} + +// Apply user's (end) Monthly Note Template to the currently open monthly note +export async function monthEnd(): Promise { + await applyTemplateToNote('month', false, true) +} diff --git a/jgclark.Reviews/src/projectsHTMLGenerator.js b/jgclark.Reviews/src/projectsHTMLGenerator.js index 0efa8fd7a..bc81db215 100644 --- a/jgclark.Reviews/src/projectsHTMLGenerator.js +++ b/jgclark.Reviews/src/projectsHTMLGenerator.js @@ -344,7 +344,7 @@ function formatProjectTitleForStyle(thisProject: Project, style: string, config: // Method 1: make [[notelinks]] via x-callbacks // Method 2: x-callback using note title // Method 3: x-callback using filename - // Note: using an "onclick="window.location.href='${noteOpenActionURL}'" handler instead of an anchor tag doesn't work in the NP constrained environment. + // Note: using an "onclick="window.location.href='${noteOpenActionURL}'" handler instead of an anchor tag doesn't work in the NP constrained environment. (Is this still true?) // Note: now using splitView if running in the main window on macOS const noteOpenActionURL = createOpenOrDeleteNoteCallbackUrl(thisProject.filename, "filename", "", "splitView", false) const extraClasses = (thisProject.isCompleted) ? 'checked' : (thisProject.isCancelled) ? 'cancelled' : (thisProject.isPaused) ? 'paused' : '' diff --git a/jgclark.SearchExtensions/README.md b/jgclark.SearchExtensions/README.md index 8f889ae1e..1fca79bb8 100644 --- a/jgclark.SearchExtensions/README.md +++ b/jgclark.SearchExtensions/README.md @@ -4,7 +4,7 @@ NotePlan can search over your notes, but it is currently not very flexible or ea - extends the search syntax to allow much more control, including wildcards - by default the search runs and **saves the results in a note that it opens as a split view** next to where you're working. - these saved searches can be refreshed automatically when you open the note to consult it. -- (v2) lets you **replace** as well as search. +- you can also **replace** as well as search. ![demo](qs+refresh-demo.gif) @@ -70,7 +70,6 @@ A saved search can be **automatically refreshed when opening it**. To enable thi - you can set default search terms in the 'Default Search terms' setting; if set you can still always override them. ## The Replace commands -v2.0 adds the following commands: - **/replace over all notes** does search and replaces across both calendar and regular notes. (Alias: **/repl**.) - **/replace over Regular notes** does search and replaces across all regular (non-calendar) notes. (Alias: **/replreg**.) - **/replace over Calendar notes** does search and replaces across calendar notes. (Alias: **/replcal**.) @@ -152,7 +151,7 @@ When commands are called this way, then it all works in the background without u ## Support If you find an issue with this plugin, or would like to suggest new features for it, please raise a [Bug or Feature 'Issue' in GitHub](https://github.com/NotePlan/plugins/issues). Note that it's particularly difficult to test, so please give as much context as possible. -I have spent at least 3.5 weeks of my time off on this plugin. If you would like to support my late-night work extending NotePlan through writing these plugins, you can through +I have spent at least 4 weeks building, improving and testing this plugin. If you would like to support my late-night work extending NotePlan through writing these plugins, you can through [Buy Me A Coffee](https://www.buymeacoffee.com/revjgc)