diff --git a/__mocks__/Note.mock.js b/__mocks__/Note.mock.js index d361b28c9..53911bb4d 100644 --- a/__mocks__/Note.mock.js +++ b/__mocks__/Note.mock.js @@ -13,6 +13,8 @@ import { textWithoutSyncedCopyTag } from '@helpers/syncedCopies' export class Note { // Explicitly define properties that are dynamically assigned content: string + /** Full note markdown when tests construct notes with `{ rawContent }` only */ + rawContent: string = '' paragraphs: any[] // Properties backlinks: any[] = [] /* sample: [ SOMETHING ], */ @@ -237,6 +239,12 @@ export class Note { this._content = value this.paragraphs = makeParagraphsFromContent(this._content) } + } else if (key === 'rawContent') { + this.rawContent = value + if (typeof value === 'string' && value !== '' && (data.content === undefined || data.content === '')) { + this._content = value + this.paragraphs = makeParagraphsFromContent(this._content) + } } else { this[key] = data[key] } diff --git a/helpers/HTMLView.js b/helpers/HTMLView.js index a72f26dae..1cc8c686d 100644 --- a/helpers/HTMLView.js +++ b/helpers/HTMLView.js @@ -8,7 +8,7 @@ import showdown from 'showdown' // for Markdown -> HTML from https://github.com/ import { hasFrontMatter } from '@helpers/NPFrontMatter' import { getFolderFromFilename } from '@helpers/folders' import { clo, logDebug, logError, logInfo, logWarn, JSP, timer } from '@helpers/dev' -import { getStoredWindowRect, getWindowFromCustomId, isHTMLWindowOpen, storeWindowRect } from '@helpers/NPWindows' +import { getStoredWindowRect, getWindowFromCustomId, getWindowIdFromCustomId, isHTMLWindowOpen, storeWindowRect } from '@helpers/NPWindows' import { generateCSSFromTheme, RGBColourConvert } from '@helpers/NPThemeToCSS' import { isTermInEventLinkHiddenPart, isTermInNotelinkOrURI, isTermInMarkdownPath } from '@helpers/paragraph' import { RE_EVENT_LINK, RE_SYNC_MARKER, formRegExForUsersOpenTasks } from '@helpers/regex' @@ -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,6 +717,7 @@ 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`) + // TEST: Not sure the comment about iphone/ipad is still relevant, but leaving it in for now. const windowIdToSend = windowExists ? windowId : undefined // for iphone/ipad you have to send undefined const dataWithUpdated = { diff --git a/helpers/NPConfiguration.js b/helpers/NPConfiguration.js index c135f5acb..53de6dbf2 100644 --- a/helpers/NPConfiguration.js +++ b/helpers/NPConfiguration.js @@ -55,46 +55,70 @@ export async function initConfiguration(pluginJsonData: any): Promise { return migrateData } +/** + * Return the Default for a setting from plugin.json. + * Only return empty string when default is null/undefined, so false and 0 are preserved. + * @param {any} setting - single entry from plugin.settings + * @returns {any} + */ +function getDefaultValueForNewSetting(setting: any): any { + const d = setting?.default + return d === undefined || d === null ? '' : d +} + /** * Add new top-level keys to setting.json's data in the event plugin.settings object has been updated in a Plugin's plugin.json file. * @author @codedungeon * @param {any} pluginJsonData - plugin.json data for which plugin is being migrated - * @return {number} update result (1 settings updated, 0 no update necessary, -1 update failed) + * @return {number} update result (1 settings updated, 0 no update necessary, -1 update failed or invalid pluginJsonData) */ export function updateSettingData(pluginJsonData: any): number { let updateResult = 0 + if (pluginJsonData == null || typeof pluginJsonData !== 'object' || Array.isArray(pluginJsonData)) { + logWarn( + 'NPConfiguration/updateSettingData', + 'Invalid pluginJsonData: expected a non-null object (not an array). Skipping settings migration.', + ) + return -1 + } + const newSettings = {} const currentSettingData = DataStore.settings const pluginSettings = pluginJsonData.hasOwnProperty('plugin.settings') ? pluginJsonData['plugin.settings'] : [] + // clo(pluginSettings, `pluginSettings`) pluginSettings.forEach((setting) => { - const key: any = setting?.key || null - if (key) { + const key: any = setting?.key + const hasValidKey = key != null && key !== '' + // For each setting with a key (i.e. ignoring headings) check if it is in the current settings, and if not, add it with the default value. + if (hasValidKey) { if (!currentSettingData.hasOwnProperty(key)) { - newSettings[key] = setting?.default || '' + newSettings[key] = getDefaultValueForNewSetting(setting) + logInfo('updateSettingData', `- Added new setting: ${key} = ${newSettings[key]}`) updateResult = 1 // we have made at least one update, change result code accordingly } else { newSettings[key] = currentSettingData[key] } } }) + // logDebug(`NPConfiguration/updateSettingData: Object.keys(DataStore): ${Object.keys(DataStore).join(',')}`) // logDebug('currentSettingData:', JSP(currentSettingData, 2)) // logDebug('newSettings:', JSP(newSettings, 2)) // logDebug('DataStore.settings:', JSP(DataStore.settings, 2)) try { - if (DataStore && typeof DataStore.settings === 'object' && updateResult > 0) { + if (updateResult > 0 && DataStore && typeof DataStore.settings === 'object') { // WARNING: @jgclark at least once saw an 'undefined is not an object' error, which appeared to be for this line. // dbw added the following logging to try to track it down but it looks like, JS thinks that DataStore is not an object at times. // And yet, somehow the migration actually does work and migrates new settings. So, I'm not sure what's going on here. // We are going to leave this alone for the time being, but if you see this error again, please uncomment the following to keep hunting. - logDebug( - `NPConfiguration/updateSettingData for ${pluginJsonData['plugin.id']} updateResult: ${updateResult}`, - `typeof DataStore: ${typeof DataStore} isArray:${String( - Array.isArray(DataStore), - )} typeof DataStore.settings: ${typeof DataStore?.settings} typeof newSettings: ${typeof newSettings}`, - ) + // logDebug( + // `NPConfiguration/updateSettingData for ${pluginJsonData['plugin.id']} updateResult: ${updateResult}`, + // `typeof DataStore: ${typeof DataStore} isArray:${String( + // Array.isArray(DataStore), + // )} typeof DataStore.settings: ${typeof DataStore?.settings} typeof newSettings: ${typeof newSettings}`, + // ) logDebug( 'NPConfiguration/updateSettingData', `About to update DataStore.settings to newSettings after an update. If you see a TypeError right after this, please ignore it. It's a known NP bug that doesn't seem to matter.`, @@ -102,8 +126,8 @@ export function updateSettingData(pluginJsonData: any): number { DataStore.settings = newSettings } } catch (error) { - console.log( - 'NPConfiguration/updateSettingData/Plugin Settings Migration Failed. Was not able to automatically migrate your plugin settings to the new version. Please open the plugin settings and save in order to update your settings.', + logError('updateSettingData', + 'Plugin Settings Migration Failed. Was not able to automatically migrate your plugin settings to the new version. Please open the plugin settings, check them, and then save in order to update your settings.', ) updateResult = -1 } @@ -568,6 +592,7 @@ export async function getSettingFromAnotherPlugin(pluginID: string, settingName: /** * Backup the settings.json file for 'pluginID' to a dated version in the plugin data folder. + * Note: this fails if the file is not valid JSON, unfortunately. @jgclark can't find a way around this. * @author @jgclark * @param {string} pluginID * @param {string} reason @@ -579,12 +604,15 @@ export async function backupSettings(pluginID: string, reason: string = 'backup' const pluginSettings = await DataStore.loadJSON(`../${pluginID}/settings.json`) const backupFilename = `settings_${reason}_${moment().format('YYYYMMDDHHmmss')}.json` const backupPath = `../${pluginID}/${backupFilename}` - await DataStore.saveJSON(pluginSettings, backupPath) + const res = await DataStore.saveJSON(pluginSettings, backupPath) + if (!res) { + throw new Error(`Error saving backup to ${backupPath}`) + } if (!suppressMessage) { await showMessage(`Backup of ${pluginID} settings saved to ${backupPath}`, 'OK', `${pluginID} Settings Backup`) } logInfo('backupSettings', `Backup of ${pluginID} settings saved to ${backupPath}`) - return true + return res } catch (error) { if (!suppressMessage) { await showMessage(`Error trying to Backup ${pluginID} settings. Please see Plugin Console log for details.`, 'OK', `${pluginID} Settings Backup`) diff --git a/helpers/NPEditor.js b/helpers/NPEditor.js index 061411a03..5c999a9e6 100644 --- a/helpers/NPEditor.js +++ b/helpers/NPEditor.js @@ -262,22 +262,57 @@ export function isNoteOpenInEditor(filename: string): boolean { /** * Returns the first open Editor window that matches a given filename (if any). * If 'getLastOpenEditor' is true, then return the last matching open Editor window (which is the most recently opened one) instead. + * TEST: Changes 30.3.2026 * @author @jgclark * @param {string} openNoteFilename to find in list of open Editor windows * @param {boolean} getLastOpenEditor - whether to return the last open Editor window (which is the most recently opened one) instead of the first one that matches the filename (the default) * @returns {TEditor | false} the matching open Editor window or false if not found */ export function getOpenEditorFromFilename(openNoteFilename: string, getLastOpenEditor: boolean = false): TEditor | false { - const allEditorWindows = NotePlan.editors - const matchingEditorWindows = allEditorWindows.filter(ew => ew.filename === openNoteFilename) - if (matchingEditorWindows.length === 0) { - logDebug('getOpenEditorFromFilename', `No open Editor window found for filename '${openNoteFilename}'`) + try { + const allEditorWindows = NotePlan.editors + const matchingEditorWindows = allEditorWindows?.filter(ew => ew.filename === openNoteFilename) ?? [] + if (matchingEditorWindows.length === 0) { + logDebug('getOpenEditorFromFilename', `No open Editor window found for filename '${openNoteFilename}'`) + return false + } + if (getLastOpenEditor) { + return matchingEditorWindows[matchingEditorWindows.length - 1] + } + return matchingEditorWindows[0] + } catch (error) { + logError('getOpenEditorFromFilename', error.message) return false } - if (getLastOpenEditor) { - return matchingEditorWindows[matchingEditorWindows.length - 1] +} + +/** + * Find a regular (folder) note that is open in some Editor pane — not only the focused one. + * Prefers the globally focused `Editor` when its note is already type 'Notes'; otherwise scans + * `NotePlan.editors` so split views with a calendar note focused still expose an open project note. + * @author @jgclark + * @returns {?TNote} the note, or null if no Editor pane shows a regular note + */ +export function getFirstRegularNoteAmongOpenEditors(): ?TNote { + try { + const focusedNote = Editor?.note + if (focusedNote && focusedNote.type === 'Notes') { + return focusedNote + } + const allEditorWindows = NotePlan.editors ?? [] + for (const thisEditorWindow of allEditorWindows) { + const candidate = thisEditorWindow?.note + if (candidate && candidate.type === 'Notes') { + logDebug('getFirstRegularNoteAmongOpenEditors', `Using open editor pane for '${candidate.filename || candidate.title || '?'}' (focused pane was not a regular note)`) + return candidate + } + } + logDebug('getFirstRegularNoteAmongOpenEditors', `No open Editor pane contains a regular (Notes) note`) + return null + } catch (error) { + logError('getFirstRegularNoteAmongOpenEditors', error.message) + return null } - return matchingEditorWindows[0] } /** diff --git a/helpers/NPFrontMatter.js b/helpers/NPFrontMatter.js index e90f9ff6e..014714906 100644 --- a/helpers/NPFrontMatter.js +++ b/helpers/NPFrontMatter.js @@ -299,8 +299,9 @@ export function removeFrontMatterField(note: CoreNoteFields, fieldToRemove: stri return false } let removed = false + const normalizedFieldToRemove = fieldToRemove.toLowerCase() Object.keys(fmFields).forEach((thisKey) => { - if (thisKey === fieldToRemove) { + if (thisKey.toLowerCase() === normalizedFieldToRemove) { const thisValue = fmFields[thisKey] // logDebug('rFMF', `- for thisKey ${thisKey}, looking for <${fieldToRemove}:${value ?? " to remove. thisValue=${thisValue}`) if (!value || thisValue === value) { @@ -312,7 +313,12 @@ export function removeFrontMatterField(note: CoreNoteFields, fieldToRemove: stri for (let i = 1; i < fmParas.length; i++) { // ignore first and last paras which are separators const para = fmParas[i] - if ((!value && para.content.startsWith(fieldToRemove)) || (value && para.content === `${fieldToRemove}: ${quoteTextIfNeededForFM(value)}`)) { + const colonPos = para.content.indexOf(':') + const paraKey = colonPos > -1 ? para.content.slice(0, colonPos).trim() : '' + const paraValue = colonPos > -1 ? para.content.slice(colonPos + 1).trim() : '' + const keyMatches = paraKey.toLowerCase() === normalizedFieldToRemove + const valueMatches = !value || paraValue === quoteTextIfNeededForFM(value) + if (keyMatches && valueMatches) { // logDebug('rFMF', `- will delete fmPara ${String(i)}`) fmParas.splice(i, 1) // delete this item removed = true @@ -324,7 +330,7 @@ export function removeFrontMatterField(note: CoreNoteFields, fieldToRemove: stri // logDebug('rFMF', `- now ${fmParas.length} FM paras remain`) removeFrontMatter(note, false) // logDebug('rFMF', `removeFrontMatter -> ${String(res1)}`) - writeFrontMatter(note, fmFields, false) // don't mind if there isn't a title; that's not relevant to this operation + writeFrontMatter(note, fmFields) // logDebug('rFMF', `writeFrontMatter -> ${String(res2)}`) } } @@ -473,7 +479,7 @@ export function setFrontMatterVars(note: CoreNoteFields, varObj: { [string]: str * If optional title is given, it overrides any existing title in the note for the frontmatter title. * @author @dwertheimer based on @jgclark's convertNoteToFrontmatter code * @param {TNote} note - * @param {boolean?} alsoEnsureTitle - if true then fail if a title can't be set. Default: true. For calendar notes this wants to be false. + * @param {boolean?} alsoEnsureTitle - If true (default), then set title in frontmatter, and fail if it can't be set. For calendar notes this wants to be false. * @param {string?} title - optional override text that will be added to the frontmatter as the note title (regardless of whether it already had for a title) * @returns {boolean} true if front matter existed or was added, false if failed for some reason * @author @dwertheimer @@ -529,7 +535,9 @@ export function ensureFrontmatter(note: CoreNoteFields, alsoEnsureTitle: boolean logError('ensureFrontmatter', `Cannot find title for '${note.filename}'. Stopping conversion.`) } - if (firstLineIsTitle) note.removeParagraph(note.paragraphs[0]) // remove the heading line now that we set it to fm title + // Keep the body H1 line even when writing title to frontmatter. + // Several plugins rely on visible H1 titles in note content. + // (Callers that want to remove a duplicate heading should do so explicitly.) fm = `---\ntitle: ${quoteTextIfNeededForFM(newTitle)}\n---` } else { logDebug('ensureFrontmatter', `- just adding empty frontmatter to this calendar note`) @@ -572,7 +580,7 @@ export function endOfFrontmatterLineIndex(note: CoreNoteFields): number { try { const paras = note.paragraphs const lineCount = paras.length - logDebug(`paragraph/endOfFrontmatterLineIndex`, `total paragraphs in note (lineCount) = ${lineCount}`) + // logDebug(`paragraph/endOfFrontmatterLineIndex`, `total paragraphs in note (lineCount) = ${lineCount}`) // Can't have frontmatter as less than 2 separators if (paras.filter((p) => p.type === 'separator').length < 2) { return 0 @@ -590,7 +598,7 @@ export function endOfFrontmatterLineIndex(note: CoreNoteFields): number { while (lineIndex < lineCount) { const p = paras[lineIndex] if (p.type === 'separator') { - logDebug(`paragraph/endOfFrontmatterLineIndex`, `-> line ${lineIndex} of ${lineCount}`) + // logDebug(`paragraph/endOfFrontmatterLineIndex`, `-> line ${lineIndex} of ${lineCount}`) return lineIndex } lineIndex++ @@ -983,8 +991,12 @@ export function determineAttributeChanges( * @param {string} value - The attribute value to normalize. * @returns {string} - The normalized value. */ -export function normalizeValue(value: string): string { - return value.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1') +export function normalizeValue(value: mixed): string { + if (value == null) { + return '' + } + const asString = typeof value === 'string' ? value : String(value) + return asString.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1') } /** @@ -1010,13 +1022,35 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s logDebug('updateFrontMatterVars', `updateFrontMatterVars: note has ${note.paragraphs.length} paragraphs after ensureFrontmatter`) const existingAttributes = { ...getFrontmatterAttributes(note) } || {} + const existingKeyByLowercase: { [string]: string } = {} + // Build lookup from raw frontmatter lines to preserve original key casing. + const existingFrontmatterParas = getFrontmatterParagraphs(note, false) + if (existingFrontmatterParas && existingFrontmatterParas.length > 0) { + existingFrontmatterParas.forEach((para) => { + const colonIndex = para.content.indexOf(':') + if (colonIndex > 0) { + const rawKey = para.content.slice(0, colonIndex).trim() + if (rawKey !== '') { + existingKeyByLowercase[rawKey.toLowerCase()] = rawKey + } + } + }) + } + // Fallback to parsed attributes map when needed. + Object.keys(existingAttributes).forEach((existingKey) => { + const lcKey = existingKey.toLowerCase() + if (!existingKeyByLowercase[lcKey]) { + existingKeyByLowercase[lcKey] = existingKey + } + }) // Normalize newAttributes before comparison clo(existingAttributes, `updateFrontMatterVars: existingAttributes`) const normalizedNewAttributes: { [string]: any } = {} clo(Object.keys(newAttributes), `updateFrontMatterVars: Object.keys(newAttributes) = ${JSON.stringify(Object.keys(newAttributes))}`) - Object.keys(newAttributes).forEach((key: string) => { - const value = newAttributes[key] - logDebug('updateFrontMatterVars', `newAttributes key: ${key}, value: ${value}`) // ✅ + Object.keys(newAttributes).forEach((rawKey: string) => { + const canonicalKey = existingKeyByLowercase[rawKey.toLowerCase()] || rawKey + const value = newAttributes[rawKey] + logDebug('updateFrontMatterVars', `newAttributes key: ${rawKey}, value: ${value}`) // ✅ // Handle null/undefined - skip them (they won't be in normalizedNewAttributes, // so if deleteMissingAttributes is true, they will be deleted) @@ -1027,7 +1061,7 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s let normalizedValue: string if (typeof value === 'object') { normalizedValue = JSON.stringify(value) - } else if (key === 'triggers') { + } else if (canonicalKey.toLowerCase() === 'triggers') { normalizedValue = value.trim() } else { const trimmedValue = value.trim() @@ -1035,10 +1069,10 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s // quoteTextIfNeededForFM will handle empty strings correctly (returns '' without quotes) normalizedValue = quoteTextIfNeededForFM(trimmedValue) } - logDebug('updateFrontMatterVars', `normalizedValue for key: ${key} = ${normalizedValue}`) + logDebug('updateFrontMatterVars', `normalizedValue for key: ${canonicalKey} = ${normalizedValue}`) // $FlowIgnore - normalizedNewAttributes[key] = normalizedValue + normalizedNewAttributes[canonicalKey] = normalizedValue }) const { keysToAdd, keysToUpdate, keysToDelete } = determineAttributeChanges(existingAttributes, normalizedNewAttributes, deleteMissingAttributes) @@ -1062,7 +1096,8 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s keysToUpdate.forEach((key: string) => { // $FlowIgnore const attributeLine = `${key}: ${normalizedNewAttributes[key]}` - const paragraph = note.paragraphs.find((para) => para.content.startsWith(`${key}:`)) + const keyPrefixRe = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`, 'i') + const paragraph = note.paragraphs.find((para) => keyPrefixRe.test(para.content)) if (paragraph) { logDebug('updateFrontMatterVars', `updateFrontMatterVars: updating paragraph "${paragraph.content}" with "${attributeLine}"`) paragraph.content = attributeLine @@ -1104,18 +1139,19 @@ export function updateFrontMatterVars(note: TEditor | TNote, newAttributes: { [s return false } } else { - throw new Error(`Failed to find closing '---' in note "${note.filename || ''}" could not add new attribute "${key}".`) + logError('updateFrontMatterVars', `Failed to find closing '---' in note "${note.filename || ''}" for new attribute "${key}".`) } }) // Delete attributes that are no longer present const paragraphsToDelete = [] keysToDelete.forEach((key) => { - const paragraph = note.paragraphs.find((para) => para.content.startsWith(`${key}:`)) + const keyPrefixRe = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`, 'i') + const paragraph = note.paragraphs.find((para) => keyPrefixRe.test(para.content)) if (paragraph) { paragraphsToDelete.push(paragraph) } else { - throw new Error(`Failed to find paragraph for key "${key}".`) + logWarn('updateFrontMatterVars', `Couldn't find paragraph for key "${key}" while deleting; will continue.`) } }) if (paragraphsToDelete.length > 0) { diff --git a/helpers/NPThemeToCSS.js b/helpers/NPThemeToCSS.js index 0399db361..246858e9d 100644 --- a/helpers/NPThemeToCSS.js +++ b/helpers/NPThemeToCSS.js @@ -37,7 +37,8 @@ function loadThemeData(themeNameIn: string = ''): { themeName: string, themeJSON themeName = themeNameIn logDebug('loadThemeData', `Reading theme '${themeName}'`) themeJSON = matchingThemeObjs[0].values - currentThemeMode = Editor.currentTheme.mode + currentThemeMode = themeJSON?.mode ?? 'light' + logDebug('loadThemeData', `-> mode '${currentThemeMode}'`) } else { logWarn('loadThemeData', `Theme '${themeNameIn}' is not in list of available themes. Will try to use current theme instead.`) } @@ -51,6 +52,7 @@ function loadThemeData(themeNameIn: string = ''): { themeName: string, themeJSON if (themeName !== '') { themeJSON = Editor.currentTheme.values currentThemeMode = Editor.currentTheme.mode + logDebug('loadThemeData', `-> mode '${currentThemeMode}'`) } else { logWarn('loadThemeData', `Cannot get settings for your current theme '${themeName}'`) } @@ -65,6 +67,7 @@ function loadThemeData(themeNameIn: string = ''): { themeName: string, themeJSON logDebug('loadThemeData', `Reading your dark theme '${themeName}'`) themeJSON = matchingThemeObjs[0].values currentThemeMode = 'dark' + logDebug('loadThemeData', `-> mode '${currentThemeMode}'`) } else { logWarn('loadThemeData', `Cannot get settings for your dark theme '${themeName}'`) } @@ -158,10 +161,10 @@ export function generateCSSFromTheme(themeNameIn: string = ''): string { rootSel.push(`--fg-warn-color: color-mix(in oklch, var(--fg-main-color), orange 20%)`) rootSel.push(`--fg-error-color: color-mix(in oklch, var(--fg-main-color), red 20%)`) rootSel.push(`--fg-ok-color: color-mix(in oklch, var(--fg-main-color), green 20%)`) - rootSel.push(`--bg-info-color: color-mix(in oklch, var(--bg-main-color), blue 20%)`) + rootSel.push(`--bg-info-color: color-mix(in oklch, var(--bg-main-color), blue 10%)`) rootSel.push(`--bg-warn-color: color-mix(in oklch, var(--bg-main-color), orange 20%)`) - rootSel.push(`--bg-error-color: color-mix(in oklch, var(--bg-main-color), red 20%)`) - rootSel.push(`--bg-ok-color: color-mix(in oklch, var(--bg-main-color), green 20%)`) + rootSel.push(`--bg-error-color: color-mix(in oklch, var(--bg-main-color), red 15%)`) + rootSel.push(`--bg-ok-color: color-mix(in oklch, var(--bg-main-color), green 15%)`) rootSel.push(`--bg-disabled-color: color-mix(in oklch, var(--bg-main-color), gray 20%)`) rootSel.push(`--fg-disabled-color: color-mix(in oklch, var(--fg-main-color), gray 20%)`) } diff --git a/helpers/NPVersions.js b/helpers/NPVersions.js index 5b494c91e..e927f0869 100644 --- a/helpers/NPVersions.js +++ b/helpers/NPVersions.js @@ -12,7 +12,7 @@ import { semverVersionToNumber } from './utils' * @returns {boolean} true if the user's version of NotePlan has the feature, false otherwise */ export function usersVersionHas(feature: string): boolean { - logDebug('usersVersionHas', `NotePlan v${NotePlan.environment.version}`) + // logDebug('usersVersionHas', `NotePlan v${NotePlan.environment.version}`) // Note: this ignores any non-numeric, non-period characters (e.g., "-beta3") const userVersionNumber: number = semverVersionToNumber(NotePlan.environment.version) || 0 // logDebug('usersVersionHas', `userVersionNumber: ${String(userVersionNumber)}`) @@ -38,6 +38,7 @@ export function usersVersionHas(feature: string): boolean { showInMainWindowOniOS: '3.20.1', // Jan 2026, iOS build 1380 reuseSplitView: '3.20.1', // Jan 2026, macOS build 1479ish windowIsVisible: '3.20.2', // Mar 2026, macOS build 1494 + commandBarForms: '3.21.0', // Apr 2026, macOS build 1502 } // Check if the user's version meets the requirement for the requested feature diff --git a/helpers/NPWindows.js b/helpers/NPWindows.js index 4a894a0a7..114b42764 100644 --- a/helpers/NPWindows.js +++ b/helpers/NPWindows.js @@ -47,12 +47,12 @@ export function logWindowsList(): void { let c = 0 for (const win of NotePlan.editors) { - outputLines.push(`- ${String(c)}: ${win.windowType}: customId:'${win.customId ?? '-'}' filename:${win.filename ?? '-'} ID:${win.id} Rect:${rectToString(win.windowRect)}`) + outputLines.push(`- E ${String(c)}: ${win.windowType}: customId:'${win.customId ?? '-'}' filename:${win.filename ?? '-'} ID:${win.id} Rect:${rectToString(win.windowRect)}`) c++ } c = 0 for (const win of NotePlan.htmlWindows) { - outputLines.push(`- ${String(c)}: ${win.type}: customId:'${win.customId ?? '-'}' ${win.isVisible ? '' : '❌ INVISIBLE'} ID:${win.id} Rect:${rectToString(win.windowRect)}`) + outputLines.push(`- H ${String(c)}: ${win.type}: customId:'${win.customId ?? '-'}' ${win.isVisible ? '' : '❌ INVISIBLE'} ID:${win.id} Rect:${rectToString(win.windowRect)}`) c++ } logInfo('logWindowsList', outputLines.join('\n')) @@ -200,7 +200,7 @@ export function getNonMainWindowIds(windowType: TWindowType = 'Editor'): Array ew.filename).join(', ')}]`) return false } @@ -615,18 +616,25 @@ export async function openNoteInNewSplitIfNeeded(filename: string): Promise ({ + ...jest.requireActual('@helpers/dev'), + logWarn: jest.fn(), + logDebug: jest.fn(), + logError: jest.fn(), +})) + +const PLUGIN_ID = 'test.plugin' + +/** + * @param {Array} settingsEntries + * @returns {any} + */ +function makePluginJson(settingsEntries) { + return { + 'plugin.id': PLUGIN_ID, + 'plugin.settings': settingsEntries, + } +} + +describe('NPConfiguration', () => { + describe('updateSettingData', () => { + beforeAll(() => { + global.DataStore = DataStore + global.NotePlan = new NotePlan() + global.console = new CustomConsole(process.stdout, process.stderr, simpleFormatter) + }) + + beforeEach(() => { + jest.clearAllMocks() + DataStore.settings = { + _logLevel: 'none', + } + }) + + it('returns -1 and logs when pluginJsonData is null', () => { + expect(updateSettingData(null)).toBe(-1) + expect(logWarn).toHaveBeenCalledWith( + 'NPConfiguration/updateSettingData', + 'Invalid pluginJsonData: expected a non-null object (not an array). Skipping settings migration.', + ) + }) + + it('returns -1 when pluginJsonData is undefined', () => { + expect(updateSettingData(undefined)).toBe(-1) + expect(logWarn).toHaveBeenCalled() + }) + + it('returns -1 when pluginJsonData is an array', () => { + expect(updateSettingData([])).toBe(-1) + expect(logWarn).toHaveBeenCalled() + }) + + it('returns -1 when pluginJsonData is a string', () => { + expect(updateSettingData('np.Shared')).toBe(-1) + expect(logWarn).toHaveBeenCalled() + }) + + it('returns 0 and does not replace DataStore.settings when no new keys are required', () => { + DataStore.settings = { _logLevel: 'none', existing: 'kept' } + const beforeRef = DataStore.settings + const result = updateSettingData( + makePluginJson([{ key: 'existing', default: 'ignored', title: 'Existing' }]), + ) + expect(result).toBe(0) + expect(DataStore.settings).toBe(beforeRef) + expect(DataStore.settings.existing).toBe('kept') + }) + + it('adds missing keys with string defaults and preserves existing values', () => { + DataStore.settings = { _logLevel: 'none', keep: 'K' } + const result = updateSettingData( + makePluginJson([ + { key: 'keep', default: 'shouldNotApply', title: 'Keep' }, + { key: 'brandNew', default: 'NEW', title: 'New' }, + ]), + ) + expect(result).toBe(1) + expect(DataStore.settings).toEqual({ + keep: 'K', + brandNew: 'NEW', + }) + }) + + it('uses empty string when default is null or undefined', () => { + const result = updateSettingData( + makePluginJson([ + { key: 'fromNull', default: null, title: 'A' }, + { key: 'fromUndef', title: 'B' }, + ]), + ) + expect(result).toBe(1) + expect(DataStore.settings.fromNull).toBe('') + expect(DataStore.settings.fromUndef).toBe('') + }) + + it('preserves boolean false and numeric 0 as defaults for new keys', () => { + const result = updateSettingData( + makePluginJson([ + { key: 'flag', default: false, type: 'bool' }, + { key: 'count', default: 0, type: 'number' }, + ]), + ) + expect(result).toBe(1) + expect(DataStore.settings.flag).toBe(false) + expect(DataStore.settings.count).toBe(0) + }) + + it('logs a warning for plugin.settings entries that are objects but lack a valid key', () => { + const result = updateSettingData( + makePluginJson([{ title: 'missing key', type: 'string' }, { key: 'ok', default: 'yes', title: 'Ok' }]), + ) + expect(result).toBe(1) + expect(logWarn).toHaveBeenCalledWith( + 'NPConfiguration/updateSettingData', + `plugin.settings[0] has no valid key; skipping. plugin.id=${PLUGIN_ID}`, + ) + }) + + it('logs a warning when key is an empty string', () => { + updateSettingData(makePluginJson([{ key: '', default: 'x', title: 'Bad' }])) + expect(logWarn).toHaveBeenCalledWith( + 'NPConfiguration/updateSettingData', + expect.stringMatching(/plugin\.settings\[0\].*plugin\.id=/), + ) + }) + + it('does not warn for null or non-object entries in plugin.settings', () => { + const result = updateSettingData( + makePluginJson([null, { key: 'onlyValid', default: '1', title: 'T' }]), + ) + expect(result).toBe(1) + expect(logWarn).not.toHaveBeenCalled() + }) + + it('returns -1 and calls logError when assigning DataStore.settings throws', () => { + const fakeStore = {} + Object.defineProperty(fakeStore, 'settings', { + configurable: true, + enumerable: true, + get() { + return { _logLevel: 'none' } + }, + set() { + throw new Error('simulated write failure') + }, + }) + global.DataStore = fakeStore + + const result = updateSettingData(makePluginJson([{ key: 'fresh', default: 'v', title: 'Fresh' }])) + + global.DataStore = DataStore + DataStore.settings = { _logLevel: 'none' } + + expect(result).toBe(-1) + expect(logError).toHaveBeenCalledWith( + 'updateSettingData', + expect.stringContaining('Plugin Settings Migration Failed'), + ) + }) + + it('calls logDebug when migration writes settings', () => { + updateSettingData(makePluginJson([{ key: 'x', default: '1', title: 'X' }])) + expect(logDebug).toHaveBeenCalled() + }) + }) +}) diff --git a/helpers/__tests__/NPFrontMatter/NPFrontMatterAttributes.test.js b/helpers/__tests__/NPFrontMatter/NPFrontMatterAttributes.test.js index 536d7a338..edd32acc7 100644 --- a/helpers/__tests__/NPFrontMatter/NPFrontMatterAttributes.test.js +++ b/helpers/__tests__/NPFrontMatter/NPFrontMatterAttributes.test.js @@ -341,6 +341,49 @@ describe(`${PLUGIN_NAME}`, () => { expect(barParagraph).toBeDefined() expect(barParagraph.content).toEqual('bar: newBaz') }) + + test('should update existing key regardless of case and not add duplicate key', () => { + const note = new Note({ + content: '---\ntitle: foo\nDue: 2026-03-01\n---\n', + paragraphs: [ + { type: 'separator', content: '---', lineIndex: 0 }, + { content: 'title: foo', lineIndex: 1 }, + { content: 'Due: 2026-03-01', lineIndex: 2 }, + { type: 'separator', content: '---', lineIndex: 3 }, + ], + title: 'foo', + }) + + const result = f.updateFrontMatterVars(note, { title: 'foo', due: '2026-03-09' }) + expect(result).toEqual(true) + + const dueParagraphs = note.paragraphs.filter((p) => /^due:/i.test(p.content)) + expect(dueParagraphs.length).toEqual(1) + expect(dueParagraphs[0].content).toEqual('Due: 2026-03-09') + }) + + test('should treat lower-case incoming key as matching existing mixed-case key when deleting missing attributes', () => { + const note = new Note({ + content: '---\ntitle: foo\nDue: 2026-03-01\nOld: remove_me\n---\n', + paragraphs: [ + { type: 'separator', content: '---', lineIndex: 0 }, + { content: 'title: foo', lineIndex: 1 }, + { content: 'Due: 2026-03-01', lineIndex: 2 }, + { content: 'Old: remove_me', lineIndex: 3 }, + { type: 'separator', content: '---', lineIndex: 4 }, + ], + title: 'foo', + }) + + const result = f.updateFrontMatterVars(note, { title: 'foo', due: '2026-03-09' }, true) + expect(result).toEqual(true) + + const dueParagraph = note.paragraphs.find((p) => /^due:/i.test(p.content)) + expect(dueParagraph).toBeDefined() + expect(dueParagraph?.content).toEqual('Due: 2026-03-09') + const oldParagraph = note.paragraphs.find((p) => /^old:/i.test(p.content)) + expect(oldParagraph).toBeUndefined() + }) }) }) }) diff --git a/helpers/__tests__/NPFrontMatter/NPFrontMatterManipulation.test.js b/helpers/__tests__/NPFrontMatter/NPFrontMatterManipulation.test.js index cb3f75fc6..f101bf93f 100644 --- a/helpers/__tests__/NPFrontMatter/NPFrontMatterManipulation.test.js +++ b/helpers/__tests__/NPFrontMatter/NPFrontMatterManipulation.test.js @@ -54,6 +54,18 @@ describe(`${PLUGIN_NAME}`, () => { expect(result).toEqual(true) expect(note.content).toMatch(/---\n---/) }) + test('should set title in frontmatter and not remove from body if alsoEnsureTitle is true', () => { + const note = new Note({ + content: '# Test Project note\n#project frontmatter', type: 'Notes', paragraphs: [ + { content: '# Test Project note', headingLevel: 1, type: 'title', lineIndex: 0 }, + { content: '#project frontmatter', headingLevel: 1, type: 'text', lineIndex: 1 }, + ], + title: 'Test Project note', + }) + const result = f.ensureFrontmatter(note, false) + expect(result).toEqual(true) + expect(note.content).toMatch(/---\n---\n# Test Project note\n#project frontmatter/) + }) test('should set note title in frontmatter if had title in document', () => { const note = new Note({ paragraphs: [{ content: 'foo', headingLevel: 1, type: 'title' }], content: '# foo', title: 'foo' }) const result = f.ensureFrontmatter(note) @@ -319,6 +331,18 @@ describe(`${PLUGIN_NAME}`, () => { expect(note.paragraphs[1].content).toEqual(allParas[1].content) expect(note.paragraphs[2].content).toEqual(allParas[3].content) }) + test('should remove matching field case-insensitively', () => { + const allParas = [ + { type: 'separator', content: '---' }, + { content: 'Due: [[2023-04-24]]' }, + { content: 'title: note title' }, + { type: 'separator', content: '---' }, + ] + const note = new Note({ paragraphs: allParas, content: '' }) + const result = f.removeFrontMatterField(note, 'due', '', true) + expect(result).toEqual(true) + expect(note.paragraphs.find((p) => /^due:/i.test(p.content))).toBeUndefined() + }) }) }) }) diff --git a/helpers/__tests__/general.test.js b/helpers/__tests__/general.test.js index 0f1dc1700..22b37e1fe 100644 --- a/helpers/__tests__/general.test.js +++ b/helpers/__tests__/general.test.js @@ -503,6 +503,32 @@ describe(`${FILE}`, () => { }) }) + describe('getContentFromBrackets()' /* function */, () => { + test('returns undefined for empty string', () => { + expect(g.getContentFromBrackets('')).toBeUndefined() + }) + test('returns first parenthesized segment (non-greedy)', () => { + expect(g.getContentFromBrackets('@review(2w)')).toEqual('2w') + expect(g.getContentFromBrackets('prefix @mention(abc) suffix')).toEqual('abc') + }) + test('returns undefined when there are no parentheses', () => { + expect(g.getContentFromBrackets('@review')).toBeUndefined() + expect(g.getContentFromBrackets('plain text')).toBeUndefined() + }) + test('returns undefined when parentheses are empty', () => { + expect(g.getContentFromBrackets('@review()')).toBeUndefined() + }) + test('returns content including inner parentheses up to first closing paren', () => { + expect(g.getContentFromBrackets('x(a(b)c)')).toEqual('a(b') + }) + test('returns whitespace-only capture when brackets contain only spaces', () => { + expect(g.getContentFromBrackets('x( )')).toEqual(' ') + }) + test('returns a date string from a particular user error case', () => { + expect(g.getContentFromBrackets('@start(3/14/26)')).toEqual('3/14/26') + }) + }) + /* * getTagParamsFromString() * NB: an async function @@ -540,5 +566,25 @@ describe(`${FILE}`, () => { const result = await g.getTagParamsFromString('{area:NaN, template:"BAR",}', 'area', 'default') expect(result).toEqual(NaN) }) + test('returns error string for malformed JSON5', async () => { + const result = await g.getTagParamsFromString('{area:broken', 'area', 'default') + expect(result).toEqual('❗️error') + }) + test('returns error string when parse result is not an object (e.g. string)', async () => { + const result = await g.getTagParamsFromString('"only-a-string"', 'area', 'default') + expect(result).toEqual('❗️error') + }) + test('returns error string when root JSON value is null', async () => { + const result = await g.getTagParamsFromString('null', 'area', 'default') + expect(result).toEqual('❗️error') + }) + test('returns default when key is missing on a parsed array root', async () => { + const result = await g.getTagParamsFromString('[1,2,3]', 'area', 'default') + expect(result).toEqual('default') + }) + test('returns boolean and number defaults unchanged when key missing', async () => { + expect(await g.getTagParamsFromString('{}', 'missing', false)).toEqual(false) + expect(await g.getTagParamsFromString('{}', 'missing', 0)).toEqual(0) + }) }) }) diff --git a/helpers/__tests__/stringTransforms.test.js b/helpers/__tests__/stringTransforms.test.js index 0659e1744..8f3e72ac8 100644 --- a/helpers/__tests__/stringTransforms.test.js +++ b/helpers/__tests__/stringTransforms.test.js @@ -753,6 +753,11 @@ describe(`${PLUGIN_NAME}`, () => { const result = st.stripHashtagsFromString(input) expect(result).toEqual('Text here') }) + test('should strip hashtags containing dashes', () => { + const input = 'Text #tag-123 #bob-12-oh here' + const result = st.stripHashtagsFromString(input) + expect(result).toEqual('Text here') + }) test('should not strip hashtag starting with number', () => { const input = 'Text #123tag should remain' const result = st.stripHashtagsFromString(input) @@ -770,6 +775,77 @@ describe(`${PLUGIN_NAME}`, () => { }) }) + + /* + * getHashtagsFromString() + */ + describe('getHashtagsFromString()', () => { + test('should be empty from empty', () => { + const result = st.getHashtagsFromString('') + expect(result).toEqual([]) + }) + test('should get single hashtag at start', () => { + const input = '#tag at the beginning' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#tag']) + }) + test('should get single hashtag in middle', () => { + const input = 'This has #tag in the middle' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#tag']) + }) + test('should get single hashtag at end', () => { + const input = 'Text at the end #tag' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#tag']) + }) + test('should get multiple hashtags', () => { + const input = 'This has #tag1 and #tag2 and #tag3 here' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#tag1', '#tag2', '#tag3']) + }) + test('should get hashtag after space', () => { + const input = 'Text #hashtag more text' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#hashtag']) + }) + test('should get hashtag in quotes', () => { + const input = 'quoted "#hashtag" text' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#hashtag']) + }) + test('should get hashtag in parenthesis', () => { + const input = 'Text (#hashtag) more' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#hashtag']) + }) + test('should get multi-part hashtag', () => { + const input = 'Text #Ephesians/3/20 more' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#Ephesians/3/20']) + }) + test('should get hashtag with underscores', () => { + const input = 'Text #tag_with_underscores here' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#tag_with_underscores']) + }) + test('should get hashtag with numbers', () => { + const input = 'Text #tag123 here' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#tag123']) + }) + test('should get hashtags containing dashes', () => { + const input = 'Text #tag-123 #bob-12-oh here' + const result = st.getHashtagsFromString(input) + expect(result).toEqual(['#tag-123', '#bob-12-oh']) + }) + test('should not get hashtag starting with number', () => { + const input = 'Text #123tag should get nothing' + const result = st.getHashtagsFromString(input) + expect(result).toEqual([]) + }) + }) + /* * stripMentionsFromString() */ diff --git a/helpers/dateTime.js b/helpers/dateTime.js index 79ccf2884..7a612a79b 100644 --- a/helpers/dateTime.js +++ b/helpers/dateTime.js @@ -331,6 +331,11 @@ export function convertISODateFilenameToNPDayFilename(dailyNoteFilename: string) // Note: ? This does not work to get reliable date string from note.date for daily notes export function toISODateString(dateObj: Date): string { + // Guard against null/invalid Date objects to avoid runtime errors + if (dateObj == null || !(dateObj instanceof Date) || isNaN(dateObj.getTime())) { + logDebug('dateTime / toISODateString', `Invalid Date object passed: ${String(dateObj)}`) + return '' + } // logDebug('dateTime / toISODateString', `${dateObj.toISOString()} // ${toLocaleDateTimeString(dateObj)}`) return dateObj.toISOString().slice(0, 10) } @@ -1233,7 +1238,7 @@ export function calcOffsetDate(baseDateStrIn: string, interval: string): Date | // calc offset (Note: library functions cope with negative nums, so just always use 'add' function) const baseDateMoment = moment(baseDateStrIn, momentDateFormat) - const newDate = unit !== 'b' ? baseDateMoment.add(num, unitForMoment) : momentBusiness(baseDateMoment).businessAdd(num).toDate() + const newDate = unit !== 'b' ? baseDateMoment.add(num, unitForMoment).toDate() : momentBusiness(baseDateMoment).businessAdd(num).toDate() // logDebug('dateTime / cOD', `for '${baseDateStrIn}' interval ${num} / ${unitForMoment} -> ${String(newDate)}`) return newDate diff --git a/helpers/general.js b/helpers/general.js index 27cf05665..d3e9145d5 100644 --- a/helpers/general.js +++ b/helpers/general.js @@ -468,22 +468,24 @@ export function getStringFromList(list: $ReadOnlyArray, search: string): /** * Extract contents of bracketed part of a string (e.g. '@mention(something)'). + * Note: doesn't handle nested parentheses. * @author @jgclark + * @tests in jest file, written by @jgclark + @Cursor * @param {string} - string that contains a bracketed mention e.g. @review(2w) * @return {?string} - string from between the brackets, if found (e.g. '2w') */ export function getContentFromBrackets(mention: string): ?string { - const RE_BRACKETS_STRING_CAPTURE = '\\((.*?)\\)' // capture string inside parantheses - if (mention === '') { return // no text, so return nothing } - const res = mention.match(RE_BRACKETS_STRING_CAPTURE) ?? [] - if (res[1].length > 0) { + const RE_BRACKETS_STRING_CAPTURE = '\\((.*?)\\)' // capture string inside parantheses + + const res = mention.match(RE_BRACKETS_STRING_CAPTURE) + // When there is no match, match() is null — do not default to [] or res[1] is undefined and .length throws. + if (res != null && res[1] != null && res[1].length > 0) { return res[1] - } else { - return } + return } type Replacement = { key: string, value: string } @@ -508,7 +510,8 @@ export function stringReplace(inputString: string = '', replacementArray: Array< * Get a particular parameter setting from a JSON5 parameter string * Note: Replaces an earlier version called getTagParams * @author @dwertheimer - * + * @tests in jest file, written by @Cursor + * * @param {string} paramString - the contents of the template tag as a JSON5 string (e.g. either '{"template":"FOO", "area":"BAR"}' or '{template:"FOO", area:"BAR"}') * @param {string} wantedParam - the name of the parameter to get (e.g. 'template') * @param {any} defaultValue - default value to use if parameter not found diff --git a/helpers/note.js b/helpers/note.js index c618a6078..ee0022bb3 100644 --- a/helpers/note.js +++ b/helpers/note.js @@ -796,18 +796,6 @@ export function isNoteFromAllowedFolder(note: TNote, allowedFolderList: Array]\] ))(?:\[\s\] )?/ // open checklist item + +export const NP_RE_checklist: RegExp = /^\h*\+\s(?:(?!\[[x\-\>]\] ))(?:\[\s\] )?/ // open checklist item (from EM) +export const ANY_TYPE_OF_INITIAL_TASK_INDICATOR = `^\s*(\*|\-|\+|\d+\.])` export const RE_ANY_TYPE_OF_OPEN_TASK_OR_CHECKLIST_MARKER: RegExp = /^\s*(\[[ \>]\]|[\*\-\+]\s[^\[])/ export const RE_ANY_TYPE_OF_OPEN_TASK_OR_CHECKLIST_MARKER_MULTI_LINE: RegExp = /[\n^]\s*(\[[ \>]\]|[\*\-\+]\s[^\[])/g export const RE_ANY_TYPE_OF_CLOSED_TASK_OR_CHECKLIST_MARKER: RegExp = /^\s*[\*\-\+]\s*(\[[x\-]\]|s[^\[])/ @@ -207,7 +211,8 @@ export const RE_ANY_TYPE_OF_CLOSED_TASK_OR_CHECKLIST_MARKER_MULTI_LINE: RegExp = /** * Make regex to find open tasks or checklist items string, that takes account of the user's preference for what counts as a todo. - * Parameter controls whether this searches all items in a multi-line string, or just the first natch in a single-line string. + * Parameter controls whether this searches all items in a multi-line string, or just the first match in a single-line string. + * Note: uses DataStore call * @param {boolean} multiLine? * @returns {RegExp} */ diff --git a/helpers/stringTransforms.js b/helpers/stringTransforms.js index 85cca374b..2b8c85866 100644 --- a/helpers/stringTransforms.js +++ b/helpers/stringTransforms.js @@ -20,6 +20,7 @@ import { } from '@helpers/dateTime' import { clo, JSP, logDebug, logError, logInfo } from '@helpers/dev' import { + RE_HASHTAG_G, RE_MARKDOWN_LINKS_CAPTURE_G, RE_BARE_URI_MATCH_G, RE_SYNC_MARKER, @@ -331,14 +332,13 @@ export function stripWikiLinksFromString(original: string): string { */ export function stripHashtagsFromString(original: string): string { let output = original - // Note: the regex from @EduardMe's file is /(\s|^|\"|\'|\(|\[|\{)(?!#[\d[:punct:]]+(\s|$))(#([^[:punct:]\s]|[\-_\/])+?\(.*?\)|#([^[:punct:]\s]|[\-_\/])+)/ but :punct: doesn't work in JS, so here's my simplified version // TODO: matchAll? - const captures = output.match(/(?:\s|^|\"|\(|\)|\')(#[A-Za-z][\w\/]*)/g) + const captures = output.match(RE_HASHTAG_G) if (captures) { // clo(captures, 'results from hashtag matches:') for (const capture of captures) { // Extract the full hashtag including #, handling both cases where capture starts with prefix or just the hashtag - const hashtagMatch = capture.match(/#[A-Za-z][\w\/]*/) + const hashtagMatch = capture.match(/#[A-Za-z][\w/_-]*/) if (hashtagMatch) { const fullHashtag = hashtagMatch[0] // Check if the hashtag is at the start of the string (after removing any prefix from capture) @@ -355,6 +355,22 @@ export function stripHashtagsFromString(original: string): string { return output } +/** + * Get all #hashtags from string + * @tests in jest file + * @author @jgclark + * @param {string} original + * @returns {Array} array of hashtags + */ +export function getHashtagsFromString(original: string): Array { + const captures = original.matchAll(RE_HASHTAG_G) + const hashtags: Array = [] + for (const c of captures) { + hashtags.push(c[1]) + } + return hashtags +} + /** * Strip all @mentions from string, * @tests in jest file diff --git a/helpers/utils.js b/helpers/utils.js index 66baff1be..ed28a42ee 100644 --- a/helpers/utils.js +++ b/helpers/utils.js @@ -202,7 +202,7 @@ export function semverVersionToNumber(version: string): number { }) if (parts.length === 2) { - logDebug('semverVersionToNumber', `checking version=${version}; adding a .0 to make 3 parts`) + // logDebug('semverVersionToNumber', `checking version=${version}; adding a .0 to make 3 parts`) parts.push(0) } else if (parts.length < 2) { diff --git a/jgclark.Dashboard/CHANGELOG.md b/jgclark.Dashboard/CHANGELOG.md index 5974d4335..39e431a78 100644 --- a/jgclark.Dashboard/CHANGELOG.md +++ b/jgclark.Dashboard/CHANGELOG.md @@ -8,9 +8,15 @@ For more details see the [plugin's documentation](https://github.com/NotePlan/pl - TODO: fix long-standing layout bug where some tooltips were getting clipped - TODO: fix isNoteFromAllowedFolder() for teamspace or possibly 2025-W21.md --> +## [2.4.0.b23] 2026-03-30 +- add check for window visibility before running any of the refreshes. (Caused by Dashboard timers still operating even when the Dashboard window is closed by NP.) + ## [2.4.0.b22] 2026-02-27 +- fix: avoid runtime error when opening Dashboard if Reviews plugin triggers a refresh before React has sent pluginData — guard in refreshSomeSections and setPluginData when shared data is not ready yet +- fix: when switching perspective, only refresh the Projects List (Reviews plugin) if that window is actually open; use exact window ID match so we don't trigger when another Reviews window is open +- UX: reduce multi-step redraw when switching perspective via dropdown — no optimistic UI for sections; sections refresh in one batch instead of per-section updates - fix (hopefully): work around indents API bug that stopped indented tasks being moved to different calendar notes -- fix: ensure numeric dashboard settings (for example `maxItemsToShowInSection` and `newTaskSectionHeadingLevel`) are always stored and loaded as numbers, not strings +- dev: ensure numeric dashboard settings (for example `maxItemsToShowInSection` and `newTaskSectionHeadingLevel`) are always stored and loaded as numbers, not strings - dev: normalise number-type settings in both the React settings dialog and `setSetting`/`setSettings` x-callback paths to avoid subtle type mismatches in future ## [2.4.0.b21] 2026-02-19 diff --git a/jgclark.Dashboard/plugin.json b/jgclark.Dashboard/plugin.json index 717f4214e..35f68cbb7 100644 --- a/jgclark.Dashboard/plugin.json +++ b/jgclark.Dashboard/plugin.json @@ -8,10 +8,10 @@ "plugin.description": "A Dashboard for NotePlan, that in one place shows:\n- a compact list of open tasks and checklists from today's note\n- scheduled open tasks and checklists from other notes.\n- similarly for yesterday's note, tomorrow's note, and the weekly, monthly and quarterly notes too (if used)\n- all overdue tasks\n- all open tasks and checklists that contain particular @tags or #mentions of your choosing\n- the next notes ready to review (if you use the 'Projects and Reviews' plugin).\nIt includes many other ways of speeding up managing your tasks: see the website for more details.", "plugin.author": "@jgclark", "plugin.comment": "TODO: On full release, change minAppVersion down to 3.7?", - "plugin.version": "2.4.0.b22", + "plugin.version": "2.4.0.b23", "plugin.releaseStatus": "beta", "plugin.hidden": false, - "plugin.lastUpdateInfo": "2.4.0: new 'Spaces to Include' setting which controls which (Team)Spaces you wish to include, plus whether or not to include the Private 'Space' (all notes not in a Space)\n2.3.3: new 'Year' section available.\n2.3.2: fix display when there are no priority items shown.\n2.3.1: fix for possible loss of settings error when upgrading.\n2.3.0: Support for NotePlan (Team)Spaces. Can re-order display of Sections.New '/backupSettings' command. Added 'noteTags' feature. Speeded up Tag/Mention sections. Layout improvements. Lots of other small fixes and improvements.\n2.2.1: Add new sorting option for Tag and Overdue sections.\n2.2.0: Add 'Search' section. New keyboard shortcuts. Plus many small improvements, bug fixes and performance improvements. See documentation for details.\n2.1.10: More move-under-heading options. Bug fixes and performance improvements.\n2.1.9: performance improvements and better UI for iPhone users.\n2.1.8: various fixes and small improvements.\n2.1.7: various fixes and small improvements.\n2.1.6: allow all current timeblocks to be shown, not just the first. Add new @repeat()s if using the extended syntax from the Repeat Extensions plugin. Bug fixes.\n2.1.5: fixes to time blocks and scheduling items.\n2.1.4: fix to Interactive Processing, and Edit All Perspectives dialog now shows unsaved changes.", + "plugin.lastUpdateInfo": "2.4.0: new 'Active Projects' Section. New 'Spaces to Include' setting which controls which (Team)Spaces you wish to include, plus whether or not to include the Private 'Space' (all notes not in a Space)\n2.3.3: new 'Year' section available.\n2.3.2: fix display when there are no priority items shown.\n2.3.1: fix for possible loss of settings error when upgrading.\n2.3.0: Support for NotePlan (Team)Spaces. Can re-order display of Sections.New '/backupSettings' command. Added 'noteTags' feature. Speeded up Tag/Mention sections. Layout improvements. Lots of other small fixes and improvements.\n2.2.1: Add new sorting option for Tag and Overdue sections.\n2.2.0: Add 'Search' section. New keyboard shortcuts. Plus many small improvements, bug fixes and performance improvements. See documentation for details.\n2.1.10: More move-under-heading options. Bug fixes and performance improvements.\n2.1.9: performance improvements and better UI for iPhone users.\n2.1.8: various fixes and small improvements.\n2.1.7: various fixes and small improvements.\n2.1.6: allow all current timeblocks to be shown, not just the first. Add new @repeat()s if using the extended syntax from the Repeat Extensions plugin. Bug fixes.\n2.1.5: fixes to time blocks and scheduling items.\n2.1.4: fix to Interactive Processing, and Edit All Perspectives dialog now shows unsaved changes.", "plugin.dependencies": [], "plugin.requiredFiles": [ "react.c.WebView.bundle.dev.js" diff --git a/jgclark.Dashboard/src/dashboardHelpers.js b/jgclark.Dashboard/src/dashboardHelpers.js index 410a9e091..1a68cabc1 100644 --- a/jgclark.Dashboard/src/dashboardHelpers.js +++ b/jgclark.Dashboard/src/dashboardHelpers.js @@ -1154,9 +1154,12 @@ export function handlerResult(success: boolean, actionsOnSuccess?: Array { const reactWindowData = await getGlobalSharedData(WEBVIEW_WINDOW_ID) - - reactWindowData.pluginData = { ...reactWindowData.pluginData, ...changeObject } - + if (!reactWindowData) { + logDebug('setPluginData', 'Dashboard shared data not ready yet; skipping update') + return + } + reactWindowData.pluginData = { ...(reactWindowData.pluginData || {}), ...changeObject } + logInfo('setPluginData', `Sending changeMessage: "${changeMessage}"`) await sendToHTMLWindow(WEBVIEW_WINDOW_ID, 'UPDATE_DATA', reactWindowData, changeMessage) } diff --git a/jgclark.Dashboard/src/dashboardHooks.js b/jgclark.Dashboard/src/dashboardHooks.js index 0e2274ecc..5ccf0ddeb 100644 --- a/jgclark.Dashboard/src/dashboardHooks.js +++ b/jgclark.Dashboard/src/dashboardHooks.js @@ -2,7 +2,7 @@ // @flow //----------------------------------------------------------------------------- // Dashboard triggers and other hooks -// Last updated for v2.1.0 +// Last updated 2026-04-28 for v2.4.0.31, @jgclark //----------------------------------------------------------------------------- import moment from 'moment/min/moment-with-locales' @@ -10,7 +10,7 @@ import pluginJson from '../plugin.json' import { incrementallyRefreshSomeSections, refreshSomeSections } from './refreshClickHandlers' import { allSectionCodes, WEBVIEW_WINDOW_ID } from './constants' // import { getSomeSectionsData } from './dataGeneration' -import type { MessageDataObject, TSectionCode } from './types' +import type { MessageDataObject, TBridgeClickHandlerResult, TSectionCode } from './types' import { clo, JSP, logDebug, logError, logInfo, logWarn, timer } from '@helpers/dev' import { getNPMonthStr, @@ -123,7 +123,7 @@ export async function onEditorWillSave(): Promise { } // Only proceed if the dashboard window is open - if (!isHTMLWindowOpen(`${pluginJson['plugin.id']}.main`)) { + if (!isHTMLWindowOpen(WEBVIEW_WINDOW_ID)) { logDebug('decideWhetherToUpdateDashboard', `Dashboard window not open, so stopping.`) return } @@ -179,34 +179,38 @@ export async function onEditorWillSave(): Promise { /** * Refresh a section given by its code -- if the Dashboard is open already. + * Note: as called by DataStore.invokePluginCommandByName (from jgclark.Reviews) there needs to be a return value. */ -export async function refreshSectionByCode(sectionCode: TSectionCode): Promise { +export async function refreshSectionByCode(sectionCode: TSectionCode): Promise { if (!isHTMLWindowOpen(WEBVIEW_WINDOW_ID)) { logDebug('refreshSectionByCode', `Dashboard not open, so won't proceed ...`) - return + return true } - logDebug('refreshSectionByCode', `Dashboard is open, so will refresh section ${sectionCode} ...`) + logDebug('refreshSectionByCode', `Dashboard is open, so will refreshSomeSections for ${sectionCode} ...`) const data: MessageDataObject = { sectionCodes: [sectionCode], actionType: 'refreshSomeSections', } - const res = await refreshSomeSections(data, true) - logDebug('refreshSectionByCode', `done.`) + const res: TBridgeClickHandlerResult = await refreshSomeSections(data, true) + logDebug('refreshSectionByCode', `- result was ${res.success ? 'Success' : 'Failed'}`) + return res.success } /** * Refresh a section given by its code -- if the Dashboard is open already. + * Note: as called by DataStore.invokePluginCommandByName (from jgclark.Reviews) there needs to be a return value. */ -export async function refreshSectionsByCode(sectionCodes: Array): Promise { +export async function refreshSectionsByCode(sectionCodes: Array): Promise { if (!isHTMLWindowOpen(WEBVIEW_WINDOW_ID)) { logDebug('refreshSectionsByCode', `Dashboard not open, so won't proceed ...`) - return + return true } - logDebug('refreshSectionsByCode', `Dashboard is open, so will refresh sections ${String(sectionCodes)} ...`) + logDebug('refreshSectionsByCode', `Dashboard is open, so will refreshSomeSections for ${String(sectionCodes)} ...`) const data: MessageDataObject = { sectionCodes: sectionCodes, actionType: 'refreshSomeSections', } - const res = await refreshSomeSections(data, true) - logDebug('refreshSectionsByCode', `done.`) + const res: TBridgeClickHandlerResult = await refreshSomeSections(data, true) + logDebug('refreshSectionsByCode', `- result was ${res.success ? 'Success' : 'Failed'}`) + return res.success } diff --git a/jgclark.Dashboard/src/perspectiveHelpers.js b/jgclark.Dashboard/src/perspectiveHelpers.js index b3267c6e2..6427160d8 100644 --- a/jgclark.Dashboard/src/perspectiveHelpers.js +++ b/jgclark.Dashboard/src/perspectiveHelpers.js @@ -68,6 +68,8 @@ Named perspectives const pluginID = 'jgclark.Dashboard' // pluginJson['plugin.id'] +const PROJECT_LIST_WINDOW_ID = `jgclark.Reviews.rich-review-list` // not imported, as that would create a circular dependency + const standardSettings = cleanDashboardSettingsInAPerspective( // $FlowIgnore[incompatible-call] [...dashboardSettingDefs, ...dashboardFilterDefs, ...showSectionSettingItems].reduce((acc, s) => { @@ -513,26 +515,23 @@ export async function switchToPerspective(name: string, allDefs: Array ({ name: p.name, isModified: p.isModified })), - // 'switchToPerspective: newPerspectiveSettings saved to DataStore.settings', - // ) // Send message to Reviews (if that window is open) to re-generate the Projects list and render it // TEST: Now not await-ing this, because it can take a long time and we don't want to block the main thread. // FIXME: Even so, is still taking a long time, and appears to be blocking the main thread. + // V2 // logTimer('switchToPerspective', startTime, `Sending message to Reviews to regenerate the Projects List and render it.`) - // const _promise = DataStore.invokePluginCommandByName('generateProjectListsAndRenderIfOpen', 'jgclark.Reviews', []) - // const _promise = generateProjectListsAndRenderIfOpen() + const _promise = DataStore.invokePluginCommandByName('generateProjectListsAndRenderIfOpen', 'jgclark.Reviews', []) // logTimer('switchToPerspective', startTime, `Sending message to Reviews to regenerate the Projects List and render it.`) // v3: Work out whether Project list window is open, and if so, re-render it - if (isHTMLWindowOpen('jgclark.Reviews.rich-review-list')) { + if (isHTMLWindowOpen(PROJECT_LIST_WINDOW_ID)) { // TEST: still not convinced that this is firing and forgetting. At least we're not doing anything if the Project List window is not open. + // FIXME(Eduard): invalid window list problem reported at https://discord.com/channels/763107030223290449/1477085092915708050/1477085095717376213 - logTimer('switchToPerspective', startTime, `Sending message to Reviews to render project list as it is open.`) const _promise = DataStore.invokePluginCommandByName('renderProjectListsIfOpen', 'jgclark.Reviews', []) - logTimer('switchToPerspective', startTime, `Sent message to Reviews`) // Note: never seems to get here } + logTimer('switchToPerspective', startTime, `End of switchToPerspective`) // Note: never seems to get here return newPerspectiveSettings } catch (error) { diff --git a/jgclark.Dashboard/src/pluginToHTMLBridge.js b/jgclark.Dashboard/src/pluginToHTMLBridge.js index 71cf9b712..c54346e5e 100644 --- a/jgclark.Dashboard/src/pluginToHTMLBridge.js +++ b/jgclark.Dashboard/src/pluginToHTMLBridge.js @@ -38,7 +38,7 @@ import { doSwitchToPerspective, doPerspectiveSettingsChanged, } from './perspectiveClickHandlers' -import { incrementallyRefreshSomeSections, refreshSomeSections } from './refreshClickHandlers' +import { incrementallyRefreshSomeSections, refreshSectionsBatch, refreshSomeSections } from './refreshClickHandlers' import { doAddProgressUpdate, doCancelProject, @@ -612,9 +612,9 @@ async function processActionOnReturn(handlerResultIn: TBridgeClickHandlerResult, logDebug('processActionOnReturn', `REFRESH_ALL_ENABLED_SECTIONS: calling incrementallyRefreshSomeSections (for ${String(enabledSections)}) ...`) await incrementallyRefreshSomeSections({ ...data, sectionCodes: enabledSections }) } else if (actionsOnSuccess.includes('PERSPECTIVE_CHANGED')) { - logDebug('processActionOnReturn', `PERSPECTIVE_CHANGED: calling incrementallyRefreshSomeSections (for ${String(enabledSections)}) ...`) + logDebug('processActionOnReturn', `PERSPECTIVE_CHANGED: calling refreshSectionsBatch (for ${String(enabledSections)}) ...`) await setPluginData({ perspectiveChanging: true }, `Starting perspective change`) - await incrementallyRefreshSomeSections({ ...data, sectionCodes: enabledSections }) + await refreshSectionsBatch({ ...data, sectionCodes: enabledSections }) logDebug('processActionOnReturn', `PERSPECTIVE_CHANGED finished (should hide modal spinner)`) await setPluginData({ perspectiveChanging: false }, `Ending perspective change`) } else if (actionsOnSuccess.includes('REFRESH_ALL_SECTIONS')) { diff --git a/jgclark.Dashboard/src/react/components/Header/PerspectiveSelector.jsx b/jgclark.Dashboard/src/react/components/Header/PerspectiveSelector.jsx index b929622f6..dd2434883 100644 --- a/jgclark.Dashboard/src/react/components/Header/PerspectiveSelector.jsx +++ b/jgclark.Dashboard/src/react/components/Header/PerspectiveSelector.jsx @@ -12,7 +12,7 @@ import React, { useReducer, useEffect, useCallback } from 'react' import type { TPerspectiveDef } from '../../../types.js' import { PERSPECTIVE_ACTIONS } from '../../reducers/actionTypes' -import { cleanDashboardSettingsInAPerspective, endsWithStar, setActivePerspective } from '../../../perspectiveHelpers' +import { cleanDashboardSettingsInAPerspective, endsWithStar } from '../../../perspectiveHelpers' import { getDisplayListOfPerspectiveNames, getPerspectiveNamed, @@ -382,12 +382,7 @@ const PerspectiveSelector = (): React$Node => { logDebug('PerspectiveSelector/handlePerspectiveChange', `Switch selected`) } } - // The perspectives ground truth is set by the plugin and will be returned in pluginData - // but for now, we will do an optimistic update so the UI is updated immediately - logDebug(`PerspectiveSelector/handlePerspectiveChange optimistic update to activePerspectiveName: "${selectedOption.value}"`) - const newPerspectiveSettings = setActivePerspective(selectedOption.value, perspectiveSettings) - dispatchPerspectiveSettings({ type: PERSPECTIVE_ACTIONS.SET_PERSPECTIVE_SETTINGS, payload: newPerspectiveSettings }) - dispatchPerspectiveSelector({ type: 'SET_ACTIVE_PERSPECTIVE', payload: selectedOption.value }) + // Avoid optimistic UI: let the plugin drive the update so we never show the new perspective name with old sections. logDebug('PerspectiveSelector/handlePerspectiveChange', `Switching to perspective "${selectedOption.value}" sendActionToPlugin: "switchToPerspective"`) sendActionToPlugin( 'switchToPerspective', diff --git a/jgclark.Dashboard/src/reactMain.js b/jgclark.Dashboard/src/reactMain.js index 66a26e53e..653ac3b66 100644 --- a/jgclark.Dashboard/src/reactMain.js +++ b/jgclark.Dashboard/src/reactMain.js @@ -419,11 +419,11 @@ async function getDashboardSettingsFromPerspective(perspectiveSettings: TPerspec if (!activeDef) throw new Error(`getDashboardSettingsFromPerspective: getActivePerspectiveDef failed`) const prevDashboardSettings = await getDashboardSettings() if (!prevDashboardSettings) throw new Error(`getDashboardSettingsFromPerspective: getDashboardSettings failed`) - + // Get defaults to ensure all section show settings are included // $FlowIgnore[incompatible-call] - getDashboardSettingsDefaults is exported from dashboardHelpers const defaults = getDashboardSettingsDefaults() - + // apply the new perspective's settings to the main dashboard settings // Merge order: defaults -> prevDashboardSettings -> perspective settings // This ensures that if a perspective doesn't have a section show setting (like showProjectActiveSection), diff --git a/jgclark.Dashboard/src/refreshClickHandlers.js b/jgclark.Dashboard/src/refreshClickHandlers.js index db61437db..619710cbc 100644 --- a/jgclark.Dashboard/src/refreshClickHandlers.js +++ b/jgclark.Dashboard/src/refreshClickHandlers.js @@ -3,7 +3,7 @@ // clickHandlers.js // Handler functions for refresh-related dashboard clicks that come over the bridge. // The routing is in pluginToHTMLBridge.js/bridgeClickDashboardItem() -// Last updated 2026-01-04 for v2.4.0.b by @jgclark +// Last updated 2026-03-30 for v2.4.0.b23 by @jgclark //----------------------------------------------------------------------------- import { WEBVIEW_WINDOW_ID } from './constants' @@ -14,6 +14,7 @@ import { isTagMentionCacheGenerationScheduled, generateTagMentionCache } from '. import type { MessageDataObject, TBridgeClickHandlerResult, TPluginData } from './types' import { clo, JSP, logDebug, logError, logInfo, logTimer, logWarn, timer } from '@helpers/dev' import { getGlobalSharedData, sendBannerMessage } from '@helpers/HTMLView' +import { isHTMLWindowOpen } from '@helpers/NPWindows' /******************************************************************************** * Data types + constants @@ -34,6 +35,11 @@ import { getGlobalSharedData, sendBannerMessage } from '@helpers/HTMLView' export async function refreshDashboard(): Promise { try { logInfo('refreshDashboard', `Starting to refresh Dashboard...`) + + if (!isHTMLWindowOpen(WEBVIEW_WINDOW_ID)) { + logInfo('refreshDashboard', `- my window is not visible, so not refreshing`) + return + } const startTime = new Date() // show refreshing message until done @@ -100,6 +106,12 @@ export async function incrementallyRefreshSomeSections( if (!sectionCodes) { throw new Error('No sections to incrementally refresh. If this happens again, please report it to the developer.') } + + if (!isHTMLWindowOpen(WEBVIEW_WINDOW_ID)) { + logInfo('incrementallyRefreshSomeSections', `- my window is not visible, so not refreshing`) + return handlerResult(false, [], { errorMsg: 'Dashboard window not visible, so not refreshing', errorMessageLevel: 'INFO' }) + } + logDebug('incrementallyRefreshSomeSections', `Starting incremental refresh for sections [${String(sectionCodes)}]`) await setPluginData({ refreshing: true }, `Starting incremental refresh for sections ${String(sectionCodes)}`) @@ -142,6 +154,63 @@ export async function incrementallyRefreshSomeSections( } } +/** + * Refresh the given sections in one batch and send a single setPluginData. + * Used for perspective switch to avoid multiple redraws (one update instead of N). + * @param {MessageDataObject} data - must include sectionCodes + * @returns {TBridgeClickHandlerResult} + */ +export async function refreshSectionsBatch(data: MessageDataObject): Promise { + try { + const start = new Date() + const { sectionCodes } = data + if (!sectionCodes) { + throw new Error('No sections to refresh. If this happens again, please report it to the developer.') + } + + // - add check for window visibility to prevent errors when window is not visible + if (!isHTMLWindowOpen(WEBVIEW_WINDOW_ID)) { + logInfo('refreshSectionsBatch', `- my window is not visible, so not refreshing`) + return handlerResult(false, [], { errorMsg: 'Dashboard window not visible, so not refreshing', errorMessageLevel: 'INFO' }) + } + + logDebug('refreshSectionsBatch', `Starting batch refresh for sections [${String(sectionCodes)}]`) + await setPluginData({ refreshing: true }, `Starting batch refresh for sections ${String(sectionCodes)}`) + + const reactWindowData = await getGlobalSharedData(WEBVIEW_WINDOW_ID) + const demoMode = reactWindowData?.pluginData?.demoMode ?? false + const newSections = await getSomeSectionsData(sectionCodes, demoMode, false) + + await setPluginData( + { sections: newSections, refreshing: false, firstRun: false }, + `Finished batch refresh for [${String(sectionCodes)}] (${timer(start)})`, + ) + logTimer('refreshSectionsBatch', start, `- ${sectionCodes.length} sections: ${sectionCodes.toString()}`) + + const NPSettings = await getNotePlanSettings() + if (NPSettings.doneDatesAvailable) { + const startTime = new Date() + const config: any = await getDashboardSettings() + const totalDoneCount = await updateDoneCountsFromChangedNotes( + `update done counts at end of refreshSectionsBatch (for [${sectionCodes.join(',')}])`, + config.FFlag_ShowSectionTimings === true, + ) + await setPluginData({ totalDoneCount, firstRun: false }, 'Updating doneCounts at end of refreshSectionsBatch') + logTimer('refreshSectionsBatch', startTime, `- done counts`, 200) + } + if (isTagMentionCacheGenerationScheduled()) { + logInfo('refreshSectionsBatch', `- generating scheduled tag mention cache`) + const _promise = generateTagMentionCache() + } + return handlerResult(true) + } + catch (error) { + await setPluginData({ refreshing: false, firstRun: false }, `Error in refreshSectionsBatch; closing modal spinner`) + logError('refreshSectionsBatch', error) + return handlerResult(false, [], { errorMsg: error.message, errorMessageLevel: 'ERROR' }) + } +} + /** * Tell the React window to update by re-generating a subset of Sections. * Returns them all in one shot vs incrementallyRefreshSomeSections which updates one at a time. @@ -159,6 +228,10 @@ export async function refreshSomeSections(data: MessageDataObject, calledByTrigg logDebug('refreshSomeSections', `Starting for ${String(sectionCodes)}`) const reactWindowData = await getGlobalSharedData(WEBVIEW_WINDOW_ID) + if (!reactWindowData?.pluginData) { + logDebug('refreshSomeSections', 'Dashboard shared data not ready yet (no pluginData); skipping refresh') + return handlerResult(true) + } const pluginData: TPluginData = reactWindowData.pluginData // show refreshing message until done if (!pluginData.refreshing === true) await setPluginData({ refreshing: sectionCodes, currentMaxPriorityFromAllVisibleSections: 0 }, `Starting refresh for sections ${sectionCodes.toString()}`) diff --git a/jgclark.Reviews/CHANGELOG.md b/jgclark.Reviews/CHANGELOG.md index e25d8b41e..c610ecfb3 100644 --- a/jgclark.Reviews/CHANGELOG.md +++ b/jgclark.Reviews/CHANGELOG.md @@ -1,5 +1,186 @@ # What's changed in 🔬 Projects + Reviews plugin? -See [website README for more details](https://github.com/NotePlan/plugins/tree/main/jgclark.Reviews), and how to configure.under-the-hood fixes for integration with Dashboard plugin +See [website documentation for more details](https://noteplan.co/plugins/jgclark.Reviews), and how to configure it to suit your workflow. + +## [2.0.0.b31] - 2026-05-10 +- add offer to migrate all metadata when updating to v2.0.0, with details of how to run it later if needed. +- update Documentation ready for v2.0.0 +- change defaults for the *MentionStr settings to not mention leading `@` characters. + +## [2.0.0.b30] - 2026-05-03 +- fix the per-tag counts in the Filter + Order dropdown +- fix Rich project list top-bar count disagreeing with the number of rows shown. +- reduce opacity of paused projects + +## [2.0.0.b29] - 2026-05-02 +- fix "finish review" operations failing to find the project note open in a split window. [dev: `finishReview` now resolves the note via `getFirstRegularNoteAmongOpenEditors` (scans `NotePlan.editors`).] +- dev: fixed Rollup circular dependency: moved TSV migration logging to `migrationLog.js` so `reviewHelpers` no longer imports `migration.js`. +- Clicking on a note title in the Rich Project List now re-uses an existing split view wherever possible. [dev: opening a project from the title link, dialog note name, review icon, or content link now goes through `openNoteInSplitViewIfNotOpenAlready` (focus if already open; version-aware `reuseSplitView` / `splitView` when opening a new split).] + +## [2.0.0.b28a] - 2026-05-02 +- fix embedded-metadata migration: combined `project`/`metadata` lines with `@start` / `@review` / `@reviewed` etc. now write separate YAML keys even when mention prefs are unset or values were already read from `note.mentions` (previously the combined line could become hashtags-only and drop dates). + +## [2.0.0.b28] - 2026-05-01 +- new command **migrate all projects**: batch-runs `Project` constructor migration on every note that matches current list settings; appends rows to `migration_log.tsv` in the plugin data directory. +- New **convert to project** command which converts any regular note into a project. It shows user a form to fill in, asking for project tag, start date, due date, last reviewed date, review interval, aim, etc. It updates the note adding the answers into the frontmatter. (Requires NotePlan v3.21+.) + +## [2.0.0.b27] - 2026-04-30 (released) +- added 'N projects' count to the top bar +- dev: simplify `projectClass` by extracting reusable helpers to `projectClassHelpers.js` and immutable calculation logic to `projectClassCalculations.js` +- dev: simplify `projects.js` complete/cancel closeout flow by extracting shared action logic, normalizing closeout defaults/parsing, and fixing a `submitted` form-result typo +- dev: simplify `reviews.js` by extracting shared folder-heading formatting, centralizing output-style render dispatch, and consolidating display-filter toggle handlers + +## [2.0.0.b26] - 2026-04-30 +- fix finish review flow to always remove the `nextReview` frontmatter field when a review is completed +- fix complete/cancel project flow to remove legacy body metadata line from the writable note instance after frontmatter update, avoiding stale duplicate metadata and runtime errors +- allow complete/cancel project form to be dismissed without stopping the rest of the processing. + +## [2.0.0.b25] - 2026-04-29 (released) +- change: Project metadata precedence now prefers YAML frontmatter (separate keys and embedded mentions in the combined `project`/`metadata` key) over legacy body metadata lines when constructing `Project` instances. +- update "skip review" and "set new review interval" logic to use the newer FM-preferring updaters +- fix to unpause not removing `#pause` tag from FM + +## [2.0.0.b24] - 2026-04-29 +- fix race in `finishReview`: when migrating metadata in an open editor, frontmatter/body updates now use the same editor object so `Editor.save()` no longer wipes frontmatter +- fix frontmatter migration side-effect: adding YAML `title:` no longer removes the note's body H1 heading +- address cause of "can't update dashboard for some reason" log error +- remove hidden setting `writeDateMentionsInCombinedMetadata`; date/interval metadata now persists via separate YAML keys, while `projectMetadataFrontmatterKey` remains tags-only +- add stronger one-time migration logging and behavior for legacy metadata: + - migrate embedded `@mentions` from combined tags key into separate YAML keys, then normalize combined key to hashtags-only + - support multi-line body metadata blocks during migration + - when metadata is duplicated in both body and frontmatter, frontmatter wins and body mention lines are cleared + +## [2.0.0.b23] - 2026-04-26 +- When **completing** or **cancelling** a project a new form is shown that asks: + - whether to archive the project note? + - should a note of the completion/cancellation be made in the current Quarterly or Yearly note? + - is there any final 'progress' comment to make? +- When the displayed Rich project list is updated, it now keeps as close to its current scroll position as possible. +- Fixed race conditions when pausing/completing/cancelling a project that meant the update to the frontmatter was undone. + +## [2.0.0.b22] - 2026-04-20 (released) +- fix: setting the 'currently reviewing' state in the Project List stopped the review from starting (thanks, @Garba) +- dev: failed attempt to delete settings.json file if found to be invalid. Discovered there's no need to write a default copy, as the app does this anyway if the file is missing. +- dev: turn down some logging +- dev: revert change to HTMLView::sendToHTMLWindow which Cursor made, and broke things. + +## [2.0.0.b21] - 2026-04-19 (released) +- New cache to significantly speed up display of the Project List when a project note hasn't changed since the last run. + - dev: regenerating `allProjectsList` reuses cached JSON rows when `note.changedDate` matches stored `noteChangedAtMs` (skips `Project` constructor; still runs `calcReviewFieldsForProject`). +- dev: Attempted to speed up the Project constructor: + - `Project` constructor batches `DataStore.preference` reads for mention strings and separate frontmatter key names; `parseDateMention` accepts optional resolved mention names to avoid duplicate lookups + - reuse first `readRawFrontmatterField` result for primary project tag resolution (same combined key) +- dev: confirmed that note's title is not migrated from H1 to frontmatter. + +## [2.0.0.b20] - 2026-04-18 +- dev: fix small issues found by Cursor +- dev: avoid two calls to getMetadataLineIndexFromBody() in Project constructors +- dev: removed editSettings for iOS (no longer needed) +- add more info to user if settings.json cannot be found +- tighten detection of body metadata to exclude lines starting `#` +- ??? think about a better time to do the migration of files + +## [2.0.0.b19] - 2026-04-16 +- **Finish review** uses the focused editor (`Editor.note`), not the first window in `NotePlan.editors`, so the correct note is updated when multiple editors are open. +- Fix error clearing next-review fields +- Stop saving plugin settings from opening the Project List window if it wasn't already open. +- Fix **Finish review** (and other metadata updates) when project metadata lives only in YAML frontmatter: `@reviewed(...)` and related edits now target the frontmatter `project:` line, not only a body metadata line. +- dev: `isProjectNoteIsMarkedSequential()` now uses `getProjectMetadataLineIndex()` when scanning the metadata line so `#sequential` is detected on the YAML `project:` line when there is no body metadata line. + +## [2.0.0.b18] - 2026-04-15 +- dev: In `Project` construction, when metadata exists in both frontmatter and note body, the body metadata line is now logged at INFO level and removed so frontmatter remains authoritative. +- dev: When metadata exists only in the note body, this is now logged at INFO level and migrated using the standard note/editor migration helpers. +- dev: Rename helper `getOrMakeMetadataLineIndex()` to `getMetadataLineIndexFromBody()`: it now only searches the note body and returns `false` when not found; callers now log DEBUG when body metadata is absent. + +## [2.0.0.b17] - 2026-03-14 +- **Add progress update** now uses the new Command Bar Form capability to ask for details in one step; older NotePlan versions keep the two separate prompts and always uses today's date in the progress line. + +## [2.0.0.b16] - 2026-03-13 +- dev: now pauses/unpauses the auto refresh timers when the rich window is hidden by NP +- further layout improvements to top bar and edit dialog when project list displayed in a very narrow window +- remove `nextReview` frontmatter when pausing, completing, or cancelling a project +- change the sorting order for "(first) project tag" to come in the order that they're defined in setting "Project Display order", rather than simple alphabetical order (for @Doug) +- dev: extract `migrateProjectMetadataLineCore` in reviewHelpers.js for Editor vs Note migration paths +- dev: extract `startReviewCoreLogic` in reviews.js for `startReviews`, `startReviewForNote`, and `finishReviewAndStartNextReview` +- dev: when pausing, update reviewed date and remove `nextReview` only; leave other separate frontmatter keys unchanged (full sync still used for complete/cancel/migration). Always apply frontmatter key removals after `updateFrontMatterVars` so `nextReview` is removed even if that helper returns false. +- dev: consolidate `updateProjectMetadata` and `updateFrontmatterMetadataFromFields` into a single method (structured frontmatter + optional plain body paragraph update) + +## [2.0.0.b15] - 2026-03-29 (released) +- add "(first) Project tag" as a sort order +- dev: remove .projectTag and instead always use .allProjectTags. +- fix `null% done` when no completed or open tasks. + +## [2.0.0.b14] - 2026-03-26 +- change default metadata write behavior: project date fields now write to separate frontmatter keys (`start`, `due`, `reviewed`, `completed`, `cancelled`, `nextReview`) instead of being embedded in the combined `project`/`metadata` value. +- nudge base font size down 1pt, to be closer to the NP interface +- tweak the timing on "due soon" and "review soon" indicators +- dev: removed remaining TSV logic + +## [2.0.0.b13] - 2026-03-26 (released) +- when invalid frontmatter metadata values are detected (like `review: @review()` or `due: @due()`), automatically remove the affected frontmatter key. +- normalize mention-style date frontmatter values (e.g. `due: @due(2026-03-09)`) to plain date values (`due: 2026-03-09`) during Project constructor processing. +- Handle frontmatter fields in a case-insensitive manner. +- Fix gap at start of topbar if not showing Perspective. + +## [2.0.0.b12] - 2026-03-22 +- improve multi-column layout +- remove two config settings that should have been removed earlier. +- dev: streamline CSS definitions + +## [2.0.0.b11] - 2026-03-20 +### Project Metadata & Frontmatter +Project metadata can now be fully stored in frontmatter, either as a single configurable key (project:) or as separate keys for individual fields (start, due, reviewed, etc.). Migration is automatic — when any command updates a note with body-based metadata, it moves it to frontmatter and cleans up the body line. After a review is finished, any leftover body metadata line is replaced with a migration notice, then removed on the next finish. +### Modernised Project List Design +The Rich project list has been significantly modernised with a more compact, calmer layout showing more metadata at a glance. +### New Controls +An "Order by" control has been added to the top bar (completed/cancelled/paused projects sort last unless ordering by title). Automatic refresh for the Rich project list is available via a new "Automatic Update interval" setting (in minutes; 0 to disable). +### Progress Reporting +Weekly per-folder progress CSVs now use full folder paths consistently and include a totals row. This data can also be visualised as two heatmaps — notes progressed per week and tasks completed per week. +### Other +The "Group by folder" now defaults to off. + ## [1.3.1] - 2026-02-26 - New setting "Theme to use for Project Lists": if set to a valid installed Theme name, the Rich project list window uses that theme instead of your current NotePlan theme. Leave blank to use your current theme. diff --git a/jgclark.Reviews/README.md b/jgclark.Reviews/README.md index 0b64f462a..c8a6a4a60 100644 --- a/jgclark.Reviews/README.md +++ b/jgclark.Reviews/README.md @@ -1,25 +1,33 @@ # 🔬 Projects + Reviews plugin -Unlike many task or project management apps, NotePlan has very little enforced structure, and is entirely text/markdown based. This makes it much more flexible, but makes it less obvious how to use it for tracking and managing complex work, loosely referred to here as 'Projects'. +Unlike most task or project management apps, NotePlan has very little enforced structure, and is entirely text/markdown based. This makes it much more flexible, but makes it less obvious how to use it for managing and tracking complex work, loosely referred to here as 'Projects'. -This plugin lets you easily a single list of active projects, and their progress towards completion. It helps regularly **review** Project notes -- an approach that will be familiar to people who use David Allen's **Getting Things Done** methodology, or any other where **regular reviews** are important. +This plugin lets you easily a single list of active **Projects**, and their progress towards completion. It helps regularly **review** Project notes -- an approach that will be familiar to people who use David Allen's **Getting Things Done** methodology, or any other where **regular reviews** are important. +## Overview The **/project lists** command shows the Project Review List screen, showing the projects due for review from various different NotePlan folders: -![Project Lists: example in 'Rich' style](review-list-rich-1.1.0.png) +![Project Lists (v2): example in 'Rich' style](review-list-rich-2.0b.png) -If, like me, you're using the helpful [PARA Approach](https://fortelabs.co/blog/series/para/), then your **Areas** are also a form of Project, at least as far as Reviewing them goes. I have another 60 of these. +Each Project row show the following details: -After each project name (the title of the note) is an edit icon, which when clicked opens a dialog with helpful controls for that particular project. The dialog title includes the folder and a clickable project note name. - -![Edit dialog](edit-dialog-1.1.png) +![Each Project row show the following details:](project-detail-numbered.png) +1. Title, with its icon +2. Edit button, brings up edit dialog +3. Any hashtags defined on the project +4. Folder it lives in +5. The review interval +6. Notes if the project or reviews are overdue or due soon. +7. % completion (as before, but now shown in a more compact way) +8. Latest 'progress' you've noted for the project +9. Any 'next action' on the project -User George (@george65) has recorded two video walkthroughs that show most of what the plugin does (recorded using an earlier version of the plugin, so the UI is different): +User George (@george65) has recorded two video walkthroughs that show most of what the plugin does (recorded using a rather earlier version of the plugin, so the UI is different): - [Inside Look: How George, CMO of Verge.io, Uses NotePlan for Effective Project Management](https://www.youtube.com/watch?v=J-FlyffE9iA) featuring this and my Dashboard plugin. [![thumbnail](effective-PM-with-George-thumbnail.jpg)](https://www.youtube.com/watch?v=J-FlyffE9iA) -- [Walk-through of Reviews in NotePlan with Project + Reviews Plugin](https://youtu.be/R-3qn6wdDLk) (Note: this was using v0.10, and there have been important improvements since then.) +- [Walk-through of Reviews in NotePlan with Project + Reviews Plugin](https://youtu.be/R-3qn6wdDLk) (Note: this was using v0.10, and there have been many important improvements since then.) [![thumbnail](georgec-video2-thumbnail.jpg)](https://youtu.be/R-3qn6wdDLk) @@ -28,16 +36,23 @@ You might also like: - [Antony's description of his process which includes this and other plugins](https://noteplan.co/n/381AC6DF-FB8F-49A5-AF8D-1B43B3092922). ## Using NotePlan for Projects (or Project-like work) -Each **Project** is described by a separate note, and has a lifecycle something like this: -![project lifecycle](project-flowchart_bordered.jpg) -Each such project contains the `#project` hashtag, `@review(...)` and some other **metadata** fields (see below for where to put them). For example: +Each **Project** is described by a separate note. If, like me, you're using the helpful [PARA Approach](https://fortelabs.co/blog/series/para/), then your **Areas** are also a form of Project, at least as far as Reviewing them goes. + +Each such project/area note contains some **metadata** fields including a hashtag (e.g. `#project`), a `review: `, and a number of optional dates. For example: ```markdown +--- +title: Secret Undertaking +project: #project +start: 2021-04-05 +due: 2021-11-30 +reviewed: 2021-07-20 +review: 2w +aim: Stop SPECTRE from world domination +--- # Secret Undertaking -#project @review(2w) @reviewed(2021-07-20) @start(2021-04-05) @due(2021-11-30) -Aim: Stop SPECTRE from world domination ## Details * [x] Get briefing from 'M' at HQ @@ -47,24 +62,30 @@ Aim: Stop SPECTRE from world domination ... ``` -The fields I use are: -- `@review(...)`: interval to use between reviews, of form [number][bdwmqy]: +The fields it uses are: +- `project`: a set of one or more hashtags that help you know what sort of project this is. At simplest this can be `#project`, but it can be anything else that's useful, for example `#goal`. +- `review`: interval to use between reviews, of form `[number][bdwmqy]`: - After the [number] is a character, which is one of: **b**usiness days (ignore weekends, but doesn't ignore public holidays, as they're different for each country), **d**ays, **w**eeks, **m**onths, **q**uarters, **y**ears. -- `@reviewed(YYYY-MM-DD)`: last time this project was reviewed, using this plugin -- `@nextReview(YYY-MM-DD)`: specific date for next review (if wanted) -- `@start(YYY-MM-DD)`: project's start date -- `@due(YYY-MM-DD)`: project's due date -- `@completed(YYY-MM-DD)`: date project was completed (if relevant) -- `@cancelled(YYY-MM-DD)`: date project was cancelled (if relevant) -- `Aim: free text`: optional line, and not used by this plugin +- `reviewed`: the last date this project was reviewed using this plugin +- `nextReview`: specific date for next review (if wanted) +- `start`: project's start date (optional) +- `due`: project's due date (optional; not normally relevant for Areas) +- `completed`: date project was completed (if relevant) +- `cancelled`: date project was cancelled (if relevant) +- `Aim`: optional. The plugin doesn't read or display the Aim, but the `/convert to project` form will write it to an `aim:` frontmatter field if you supply one. - `Progress: N@YYYY-MM-DD one-line description`: your latest summary of progress for this N% (optional). If present this is shown in the projects list; if not, the % completion is calculated as the number of open and closed tasks. (From v1.3 the default format omits the colon after the date; older lines with a colon are still parsed.) -Similarly, if you follow the **PARA method**, then you will also have "**Areas** of responsibility" to maintain, and I use a `#area` tag to mark these. These don't normally have start/end/completed dates, but they also need reviewing. For example: +An example of an Area-type note: ```markdown -# Car maintenance -#area @review(1m) @reviewed(2021-06-25) +--- +title: Car Maintenance +project: #area +review: 1m +reviewed: 2021-06-25 Aim: Make sure 007's Aston Martin continues to run well, is legal etc. +--- +# Car Maintenance ## One-off tasks * [x] patch up bullet holes after last mission @done(2021-06-20) @@ -77,60 +98,95 @@ Aim: Make sure 007's Aston Martin continues to run well, is legal etc. ``` (Note: This example uses my related [Repeat Extensions plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.RepeatExtensions/) to give more flexibility than the built-in repeats.) -## Where you can put the project data (metadata fields) -The plugin tries to be as flexible as possible about where project metadata can go. It looks in order for: -- the first line starting 'project:' or 'medadata:' in the note or its frontmatter -- the first line containing a @review() or @reviewed() mention -- the first line starting with a #hashtag. - -If these can't be found, then the plugin creates a new line after the title, or if the note has frontmatter, a 'metadata:' line in the frontmatter. -The first hashtag in the note defines its type, so as well as `#project`, `#area` you could have a `#goal` or whatever makes most sense for you. - -Other notes: -- If you also add the `#paused` tag to the metadata line, then that stops that note from being included in active reviews, but can show up in the lists. Pausing or un-pausing also updates the `@reviewed()` date. +Other details about the metadata: +- You can change the name of the Frontmatter key for the hashtags to use, by changing the "Frontmatter metadata key" setting (see below). Another good option might be `metadata`. +- If you also add the `#paused` tag to the metadata line, then that stops that note from being included in active reviews, but can show up in the lists. Pausing or un-pausing also updates the metadata `reviewed: `. - From v1.3 you can add `project: #sequential` in the frontmatter: the plugin then treats the first open task/checklist in the note as the 'next action', without needing to use next-action tags on individual tasks. - If there are multiple copies of a metadata field, only the first one is used. -- I'm sometimes asked why I use `@reviewed(2021-06-25)` rather than `@reviewed/2021-06-25`. The answer is that while the latter form is displayed in a neater way in the sidebar, the date part isn't available in the NotePlan API as the part after the slash is not a valid @tag as it doesn't contain an alphabetic character. +- You can of course use any other frontmatter keys and values you wish. For example, you might want to use `status: started` or `status: complete` and use `status` as part of the ['Cards' Kanban-style folder view definition](https://help.noteplan.co/article/239-card-kanban-view). + +## Project lifecycles +Here's the underlying lifecycle that this plugin supports: + +![project lifecycle](project-flowchart_bordered.jpg) + +(An Area tends not to have a Due date, and so rarely get Completed.) -_The next major release of the plugin will make it possible to migrate all this metadata to the Frontmatter block that has become properly supported since NotePlan 3.16.3._ + + ## Selecting notes to include There are 2 parts of this: -1. Use the '**Hashtags to review**' setting to control which notes are included in the review lists. If it is set (e.g. `#project, #area, #goal`), then it will include just those notes which also have one or more of those tags. If this setting is empty, then it will include all notes for review that include a `@review(...)` string. -2. Then specify which folders you want to include and/or exclude notes from. There are 2 ways to do this: - 1. Use the 'Folders to Include' and 'Folders to Exclude' settings to put a comma-separated list of folders to include and exclude. I have this set to `Summaries, Reviews, Saved Searches`. Any sub-folders of these will also be ignored. (Note that the special Templates, Archive and Trash are always excluded.) - 2. Or turn on the '**Use Perspectives**' setting to control which folders are included. From v1.3 this also supports projects in (Team)Space notes: the Perspective (from Dashboard v2.4) lets you choose which (Team)Spaces to include and whether to include the Private "Space" (notes not in a Space). This requires the [Dashboard plugin](https://github.com/NotePlan/plugins/blob/main/jgclark.Dashboard/) to be installed. If you change the active Perspective in the Dashboard, the Project Lists window will also automatically update (from Dashboard v2.4). +1. Use the '**Hashtags to review**' setting to control which notes are included in the review lists. If it is set (e.g. `#project, #area, #goal`), then it will include just those notes which also have one or more of those tags. If this setting is empty, then it will include all notes for review that include `review: ` metadata. +2. Then specify which **folders** you want to include and/or exclude notes from. There are 2 ways to do this: + + - Use the '**Folders to Include**' and '**Folders to Exclude**' settings to put a comma-separated list of folders to include and exclude. Good folders to exclude include `Summaries, Reviews, Saved Searches`. Any sub-folders of these will also be ignored. This match is done anywhere in the folder name, so you could simply say `Project` which would match for `Client A/Projects` as well as `Client B/Projects`. Note also: + - if you specify the root folder `/` this only includes the root folder itself, and not all its sub-folders. + - If 'Folders to Include' setting is empty, then all folders will be used apart from those in the 'Folders to Exclude' setting. + - The special Templates, Archive and Trash are always excluded. + + - Or if you use my separate **[Dashboard plugin](https://noteplan.co/plugins/jgclark.Dashboard/)**, turn on the '**Use Perspectives**' setting to inherit its definitions of what folders (and (Team)Space notes, and even note sections) are included and excluded. to be installed. If you change the active Perspective in the Dashboard, the Project Lists window will also automatically update (from Dashboard v2.4). When you have [configured the plugin](#configuration), and added suitable metadata to notes, you're then ready to use some or all of the following commands: ## The main /project lists command This shows a list of project notes, including basic tasks statistics and time until next review, and time until the project is due to complete. -It defaults to a colourful 'Rich' style , shown above. From v1.3 the list opens by default in the main NotePlan window. (On macOS only you can use the new setting "Open Project Lists in what sort of window?" to set it to a separate window.) The plugin also appears in the NotePlan Sidebar. +It defaults to a colourful '**Rich**' style, shown above. The window opens by default in a new window; use the "Open 'Rich' Project List in what sort of window?" setting to switch to opening in the main window or a split view of the main window instead. The plugin also appears in the NotePlan Sidebar. -You can set the '**Output style to use**'. This is either the 'Rich' style or original '**Markdown**' (normal NotePlan) output style, shown here: +Or you can use '**Output style to use**' setting to the original '**Markdown**' (normal NotePlan) output style, shown here: ![Example of 'Markdown' style of Project Lists](review-list-markdown-0.11@2x.png) ### Project Lists: 2 styles of display -- the **Rich style** is an HTML window that picks up the NotePlan Theme you use (though see below on how to override this). In this style there's a heading row that 'sticks' to the top of the window as you scroll the list. -- in the Rich style, all your different `#tag`s to review get shown one after the other in a single window. These can be collapsed and expanded as a group using the triangle icons ▼ or ▶. -- if you can make the window wide enough it will display in 2 (or even 3!) columns; layout adapts at narrower widths. -- Display toggles (e.g. show next actions, show paused, show dates) are in a **Filter…** menu in the top bar. The top bar also has a **Next** review button; the edit dialog has **Start** (review) and **Add Task** (prompts for task details and which heading to add it under). +The **Rich style** presents a list of all your matching projects, ordered and further filtered according to controls in the Filter & Order... dropdown: ![New Filter & Order options in a dropdown:](filter+order-v2.0b.png) + +There's a top bar that 'sticks' to the top of the window as you scroll the list. It grows/shrinks depending how wide the window is. It includes a Refresh button, and at the right end are buttons to control running Reviews: + +![review buttons](topbar-review-controls-2.0b.png) + +The narrower version of the top bar looks like this: ![narrower window](topbar-narrower-2.0b.png) + +After each project name (the title of the note) is an edit icon, which when clicked opens a dialog with helpful controls for that particular project. The dialog title includes the folder and a clickable project note name. + +![Edit dialog](edit-dialog-2.0.png) + +Other notes: +- If you can make the window wide enough it will display in 2 (or even 3!) columns; layout adapts at narrower widths. - Each project row can show a **count badge** (grey square) with the number of open, non-future items; badges only appear for active projects when the count is greater than zero. - Long 'next action' lines are truncated when needed. If a project note has an icon set in its frontmatter, that icon is shown in the list. -- the **Markdown style** list is stored as summary note(s) in the 'Reviews' folder (or whatever you set the 'Folder to store' setting to be). _Note: this style is now deprecated, and I expect to remove support in v2._ +- This HTML window that picks up the NotePlan Theme you use (though see below on how to override this). + +The **Markdown style** list is quite different: it is stored as summary note(s) in the 'Reviews' folder (or whatever you set the 'Folder to store' setting to be). It creates one note per project tag (for example, `#project` separate from `#area`). Other notes: - the button 'Start reviews' / 'Start reviewing notes ready for review' is a shortcut to the '/start reviews' command (described below). - each project title is also an active link which can be clicked to take you to that project note. (Or Option-click to open that in a new split window, which keeps the review list open.) +- _Note: this style is now deprecated, and I expect to remove support after v2._ -### Progress Summaries +## Progress Summaries In a project/area note you can, if you wish, include a **one-line summary** of your view on its current **overall progress**. If given, the latest one is shown in the project lists. To continue the example above, here's the start of the note a few weeks later, showing I think it's only 10% complete: ```markdown # Secret Undertaking -#project @review(1w) @reviewed(2021-05-20) @start(2021-04-05) @due(2021-11-30) -Aim: Do this amazing secret thing Progress: 10@2021-05-20 Tracked down 007 and got him on the case Progress: 0@2021-04-05 Project started with a briefing from M about SPECTRE's dastardly plan @@ -145,35 +201,34 @@ Progress: 0@2021-04-05 Project started with a briefing from M about SPECTRE's da The starting percentage number doesn't have to be given; if it's not it is _calculated from the % of open and completed tasks_ found in the note. -The settings relating to Progress are: +To add a progress comment, either run the **/add progress update** command, or click the "Add Progress" button in the edit dialog. Note: Adding a comment also automatically updates the "reviewed" date on the project. + +The settings relating to Progress calculations and comments are: - Ignore tasks set more than these days in the future: If set more than 0, then when the progress percentage is calculated it will ignore items scheduled more than this number of days in the future. (Default is 1 day: all items with future scheduled dates are ignored.) - Ignore checklists in progress? If set, then checklists in progress will not be counted as part of the project's completion percentage. -- Progress Heading: (from v1.3) Optional heading name under which `Progress: ...` lines are stored in the project note. If you set this when the note already has progress lines, the plugin finds them and inserts the heading above. +- Progress Heading: (from v1.3) Optional heading name under which `Progress: ...` lines are stored in the project note. If you set this when the note already has progress lines, the plugin finds them and inserts the heading above. Tip: if this ends with `…` the section will start folded. +- Progress Heading level: heading level (1–5) used when the Progress heading is created (default `2`). - Also write most recent Progress line to frontmatter?: (from v1.3) When on, the current progress line is also written to frontmatter so it can be used in Folder Views (default: off). ## Other Plugin settings -- Open Project Lists in what sort of macOS window?: (from v1.3) Choose whether the Rich project list opens in NotePlan's main window or in a separate window. -- Next action tag(s): optional list of #hashtags to include in a task or checklist to indicate its the next action in this project (comma-separated; default '#next'). If there are no tagged items and the note has `project: #sequential` in frontmatter, the first open task/checklist is shown as the next action. Only the first matching item is shown. -- Display next actions in output? This requires the previous setting to be set (or use #sequential). Toggle is in the Filter… menu as "Show next actions?". -- Folders to Include (optional): Specify which folders to include (which includes any of their sub-folders) as a comma-separated list. This match is done anywhere in the folder name, so you could simply say `Project` which would match for `Client A/Projects` as well as `Client B/Projects`. Note also: - - if you specify the root folder `/` this only includes the root folder itself, and not all its sub-folders. - - If empty, all folders will be used apart from those in the next setting. -- Folders to Ignore (optional): If 'Folders to use in reviews' above is empty, then this setting specifies folders to ignore (which includes any of their sub-folders too) as a comma-separated list. This match is also done anywhere in the folder name. Can be empty. Note also: - - if you specify the root folder `/` this only ignores the root folder, and not all sub-folders. - - the special @Trash, @Templates and @Archive folders are always excluded. -- Display order for projects: The sort options are by 'due' date, by 'review' date or 'title'. +- Open 'Rich' Project List in what sort of window?: Choose how the Rich project list opens on NotePlan v3.20+. The options are `New Window` (default — separate window), `Main Window` (take over the main window), or `Split View` (a split view in the main window). +- Automatic Update interval: If set to any number > 0, the Rich Project Lists window will automatically refresh after that many minutes. The current scroll position is preserved as closely as possible. Set to 0 to disable. +- Next action tag(s): optional list of #hashtags to include in a task or checklist to indicate it's the next action in this project (comma-separated; default `#na`). If there are no tagged items and the note has `#sequential` in the frontmatter `project:` field, the first open task/checklist is shown as the next action. Only the first matching item is shown. (Also see the next setting.) +- Sequential project marker: the marker to identify sequential projects (default `#sequential`). +- Display next actions in output? This requires the 'Next action tag(s)' setting to be set or use `#sequential` markers. There is also a 'Show next actions?' toggle control for this in the Filter… menu. +- Display order for projects: The sort options are by `due` date, `review` date, `title`, or `firstTag` (the first project tag, in the order they're listed in 'Hashtags to Review'). - Show projects grouped by folder? Whether to group the projects by their folder. -- Hide higher-level folder names in headings? If 'Display projects grouped by folder?' (above) is set, this hides all but the lowest-level subfolder name in headings. +- Hide higher-level folder names in headings? If 'Show projects grouped by folder?' (above) is set, this hides all but the lowest-level subfolder name in headings. - Show completed/cancelled projects? If set, then completed/cancelled projects will be shown at the end of the list of active projects. -- How to show completed/cancelled projects?: The options are 'display at end', 'display' or 'hide'. - Only show projects/areas ready for review?: If true then it will only show project/area notes ready for review (plus paused ones). -- Show project dates? Whether to show the project's review and due dates (where set). -- Show project's latest progress? Whether to show the project's latest progress summary text. These are only shown where there are specific 'Progress:' field(s) in the note. (See above for details.) +- Show project dates? Whether to show the project's review and due dates (where set). +- Show project's latest progress? Whether to show the project's latest progress summary text. These are only shown where there are specific `Progress:` field(s) in the note. (See above for details.) - Confirm next Review?: When running '/next project review' it asks whether to start the next review. -- Theme to use in rich project lists: if set to a valid installed Theme name, then that will always be used in place of the currently active theme for the rest of NotePlan. -- Folder to Archive completed/cancelled project notes to: By default this is the built-in Archive folder (shown in the sidebar) which has the special name '@Archive', but it can be set to any other folder name. -- Archive using folder structure? When you complete or cancel a project, and you opt to move it to the Archive, if set this will replicating the project note's existing folder structure inside your chosen Archive folder (set above). (This is the same thing that the Filer plugin's "/archive note using folder structure" command does, though Filer does not need to be installed to use this.) - +- Theme to use for Rich project list: if set to a valid installed Theme name, then that will always be used in place of the currently active theme for the Rich project list window. +- Folder to Archive completed/cancelled project notes to: By default this is the built-in Archive folder (shown in the sidebar) which has the special name `@Archive`, but it can be set to any other folder name. +- Archive using folder structure? When you complete or cancel a project, and you opt to move it to the Archive, if set this will replicate the project note's existing folder structure inside your chosen Archive folder (set above). (This is the same thing that the Filer plugin's "/archive note using folder structure" command does, though Filer does not need to be installed to use this.) +- Remove due dates when pausing a project?: If set, all open tasks/checklists in the project will have any `>date` removed when the project is paused (default: on). +- Frontmatter metadata key: the YAML key used for the combined project metadata value (default `project`; `metadata` is a common alternative). The value of this key holds only hashtags (e.g. `#project`, `#sequential`); date/interval values live in their own separate keys. ## The other Commands @@ -181,22 +236,40 @@ Each command is described in turn. If you have a Rich style project list open, t ### "/start reviews" command This kicks off the most overdue review by opening that project's note in the editor. When you have finished the review run one of the next two commands ... +(There is a button for this in the top bar of the project list window.) ### "/finish project review" command -This updates the current open project's `@reviewed(date)`, and if a Rich style project list is open, it is refreshed. +This updates the current open project's `reviewed: date` metadata, and if a Rich style project list is open, it is refreshed. If the 'Next action tag(s)' setting is set, then it will warn if it finds no example of those tags on all open tasks/checklists. +(There is a button for this in the top bar of the project list window.) + +### "/finish project review and start next" command +This is a convenience combination of "/finish project review" and "/next project review": it updates the current project's `reviewed: date` metadata and then jumps straight to the next project ready for review. If there are none left, it shows you a congratulations message instead. +(There is a button for this in the top bar of the project list window.) ### "/next project review" command -This updates this project's `@reviewed(date)`, and jumps to the next project to review. If there are none left ready for review it will show a congratulations message. +This updates this project's `reviewed: date` metadata, and jumps to the next project to review. If there are none left ready for review it will show a congratulations message. +(There is a button for this in the top bar of the project list window.) ### "/skip project review" command -This overrides (or skips) the normal review interval for a project, by adding a `@nextReview(...)` date of your choosing to the current project note. It also jumps to the next project to review. The next time "finish review" command is used on the project note, the `@nextReview(date)` is removed. +This overrides (or skips) the normal review interval for a project, by adding `nextReview: ` metadata of your choosing to the current project note. (Why? This avoids changing the `review: `, or giving a misleading impression by setting the `reviewed: ` metadata to today.) It also jumps to the next project to review. The next time "finish review" command is used on the project note, the `nextReview` metadata is removed. + +### "/set new review interval" command +This prompts you for a new review interval (e.g. `1w`, `2m`, `3q`, `1y`) and writes it back to the current project's `review:` metadata value. This is the right command to use when you want to permanently change how often a project is reviewed; use `/skip project review` instead if you only want to push out the *next* review without changing the interval. ### "/complete project" command -This adds a `@completed(date)` to the metadata line of the open project note, adds its details to a yearly note in Summaries folder (if the folder exists), and removes the project/area from the review list. It also offers to move it to NotePlan's separate Archive folder (or alternative folder you set in the settings). +This sets a completion date on the open project note and will update the review list. + +It also opens a single **closeout form** (from NotePlan v3.21+) asking three things: + +1. **Archive project note?** — if yes, the note is moved to NotePlan's `@Archive` folder (or whatever folder you've set in the **Folder to Archive completed/cancelled project notes to** setting). If "Archive using folder structure?" is on, the note's existing folder structure is replicated under the Archive folder. +2. **Add summary line to a calendar note?** — choose `Quarterly`, `Yearly`, or `none`. A summary line is appended under the **Finished List Heading** (default `Finished Projects/Areas`) in the current quarterly or yearly calendar note. +3. **Final progress comment (optional)** — if you supply text, it is added as a `Progress: ...` line on today's date before the project closes out. + +On older versions of NotePlan (without Command Bar forms) the same three questions are asked as separate prompts. ### "/cancel project" command -This adds a `@cancelled(date)` to the metadata line of the open project note, adds its details to a yearly note in Summaries folder (if the folder exists), and removes the project/area from the review list. It also offers to move it to NotePlan's separate Archive folder (or alternative folder you set in the settings). +This is the same flow as `/complete project`, but it sets the `cancelled` frontmatter key (derived from your `cancelled` mention setting) instead of `completed`, and the closeout form is titled "Cancel Project". The same archive / summary-destination / final-progress-comment options apply. ### "/pause project toggle" command This is a toggle that adds or removes a `#paused` tag to the metadata line of the open project note. When paused it stops the note being offered with '/next review'. However, it keeps showing it in the review list, so you don't forget about it entirely. @@ -209,26 +282,61 @@ This prompts for a short description of latest progress (as short text string) a ```markdown Progress: @YYYY-MM-DD ``` -It will also update the project's `@reviewed(date)`. +It will also update the project's `reviewed: date` metadata. -## Capturing and Displaying 'Next Actions' -Part of the "Getting Things Done" methodology is to be clear what your 'next action' is. If you put a standard tag on such actionable tasks/checklists (e.g. `#next` or `#na`) and set that in the plugin settings, the project list shows that next action after the progress summary. Only the first matching item is shown; if there are no tagged items and the note has `project: #sequential` in frontmatter, the first open task/checklist is shown instead. You can set several next-action tags (e.g. `#na` for things you can do, `#waiting` for things you're waiting on others). +### "/convert to project" command +(New in v2, and requires NotePlan v3.21+.) This takes an **existing** regular note and turns it into a project note by showing you a form to gather the metadata, then writing the answers to the note's frontmatter. (This is designed to supplement [Creating a new Project/Area note](#creating-a-new-projectarea-note) below.) + +![Example of Convert form](convert-2.0.png) + +The fields on the form are: +- **Project type tag** — a choice from your **Hashtags to review** setting (e.g. `#project`, `#area`). This becomes the value of your configured **Frontmatter metadata key** (default `project:`), which must contain **only hashtags** (and optional markers such as `#sequential` — see below). +- **Start date**, **Due date** (optional), **Last reviewed date** — written to the separate frontmatter fields derived from your mention settings (e.g. `start`, `due`, `reviewed`). +- **Review interval** — e.g. `1w`, `2m`; stored in the separate field derived from your review-interval mention setting (e.g. `review`). +- **Aim** (optional) — if you enter text, it is written to an `aim:` frontmatter field. +- **Treat project as sequential?** (optional checkbox) — only shown if your **Sequential project marker** setting is non-empty. If you turn it on, that marker (default `#sequential`) is added to the `project:` metadata field so the first open task/checklist is treated as the next action, as described [above](#capturing-and-displaying-next-actions). + +### "/weeklyProjectsProgress" command +This scans your Area/Project folders and writes two CSV files into the plugin's hidden data folder (`NotePlan/Plugins/Data/jgclark.Reviews/`): + +- one with the number of distinct notes progressed per folder per week (a project note counts as progressed if one or more tasks were completed that week) +- one with the total number of completed tasks per folder per week + +### "/heatmaps for weekly Projects Progress" command +This first runs the same scan as `/weeklyProjectsProgress` (so the CSVs are kept fresh), and then shows a pair of heatmaps in new windows: + +- notes progressed per week per folder of notes (where a project note counts as being progressed if one or more tasks are completed) +- tasks completed per week per folder of notes + +For those with lots of different projects or project groups, this is a handy way of seeing over time which of them are getting more or less attention. + +### "/migrate all projects" command +(New for v2.) This runs a **batch metadata migration** on every project note that matches your current set of relevant project-like notes. This is the same command that was offered for you to use when upgrading from v1.x to v2.0. -The **Dashboard Plugin** has the ability to set up Sections for tags/mentions, that show all open tasks/checklists with those tags/mentions. This is a different way to see all such 'next actions'. +When the command finishes, a dialog reports how many notes **actually** had a successful metadata migration (`ok` in the log), how many had migration issues logged, how many needed no migration, and how many failed in the constructor. -Alternatively, you could use the "/searchOpenTasks" command (from the [Search Extensions plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.SearchExtensions)) with search term `#next` to sync _all_ your open `#next` tasks to your `#next Search Results` note. You can then use this as the source to drag'n'drop tasks into daily/weekly/monthly notes. +**Migration log (`migration_log.tsv`):** Rows are appended to `NotePlan/Plugins/Data/jgclark.Reviews/migration_log.tsv` (same folder as `allProjectsList.json`). Columns are **`filename`**, **`title`**, **`date`** (ISO timestamp when the row was written), and **`detail`** (`ok` or an error message). The file is append-only. + +- **During `/migrate all projects`:** you get **at most one row per project note/tag pair** in that run. A row is written only when a migration step actually changed the note (or reported an error), or when the `Project` constructor throws — **notes that needed no migration do not get a log row.** Nested migration steps still do not add extra or duplicate rows. +- **During normal plugin use** (e.g. opening a project or finishing a review when body metadata is merged into frontmatter), a row is written when that migration runs, independently of the batch command. + + +## Capturing and Displaying 'Next Actions' +Part of the "Getting Things Done" methodology is to be clear what your **next action** is. If you put a standard tag on such actionable tasks/checklists (e.g. `#na` or `#next` — default is `#na`) and set that in the plugin settings, the project list shows that next action after the progress summary. Only the first matching item is shown; if there are no tagged items and the note has `project: #sequential` in frontmatter, the first open task/checklist in the note is shown instead. You can set several next-action tags (e.g. `#na` for things you can do, `#waiting` for things you're waiting on others). + +The **Dashboard Plugin** has 2 possible Project Sections, and these can also show the 'next actions' for a project. Another approach comes from user George C: -- when reviewing notes I use the "/add sync'd copy to note" command (from the [Filer plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.Filer)) to 'sync' actionable tasks to the current weekly note. (Or, if I know I don't need to get to it until the next week, then it goes into the following week or whatever. If it is actionable but I don't need to get to it until the next month I sync it into that next months task.) +- when reviewing notes I use the **add sync'd copy to note** command (from the [Filer plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.Filer)) to 'sync' actionable tasks to the current weekly note. (Or, if I know I don't need to get to it until the next week, then it goes into the following week or whatever. If it is actionable but I don't need to get to it until the next month I sync it into that next months task.) - in essence this recreates the GTD 30 day, and monthly folders, but with the advantage that all these tasks are synced back to their projects. - each day I drag out from the reference area's week's note any actions I want to do that day, maintaining the Sync line status. - I also will copy over any tasks I didn't do from the previous day. ## Creating a new Project/Area note -There are a variety of tools to help you create a new Project or Area note. +There are a variety of tools to help you create a new Project or Area note ... -### Templating system -Use the `/np:new` (new note from template) or `/np:qtn` (Quick template note) command from the built-in Templating system. Here is what I use as my New Project Template: +### Templates +Use the `/np:new` (new note from template) or `/np:qtn` (Quick template note) command from the built-in Templating system, to apply a pre-set Template. For example here's a basic Template that will prompt you with 6 questions: ```markdown --- @@ -242,31 +350,57 @@ Aim: <%- prompt('aim') %> Context: <%- prompt('context') %> ``` +For more details, see [Templating including frontmatter](https://noteplan.co/templates/docs/advanced-features/templating-examples-frontmatter). + ### Template Forms [Template Forms](https://noteplan.co/plugins/dwertheimer.Forms) is a separate powerful plugin which provides a visual form builder, that works with a 'processing template'. It ships with an example New Project form; you can customise your own form(s) from this. +### Converting an existing note +To add project metadata to a note you _already have_, use the ["convert to project" command](#convert-to-project-command) above. + ## Using with Dashboard plugin -My separate [Dashboard plugin](https://github.com/NotePlan/plugins/blob/main/jgclark.Dashboard/) shows a simpler version of the data from the Projects Review List in its 'Projects' section. It has the same type of edit dialog to complete/cancel/finish review/skip review, and also shows progress indicators. From v1.3, when the Project Lists window is open it automatically refreshes when you change data (requires Dashboard v2.4.0 or later). +My separate [Dashboard plugin](https://github.com/NotePlan/plugins/blob/main/jgclark.Dashboard/) shows a simpler version of the data from the Projects Review List in its 2 'Projects' sections: +- **Projects to Review Section**: shows just the Projects that are ready for review today, or are overdue for review +- **Active Projects Section**: shows just the Projects that have a defined ['next action' task](#capturing-and-displaying-next-actions). + +The individual Project lines that are shown have the same type of edit dialog to complete/cancel/finish review/skip review, and also shows progress indicators. + +When the Project Lists window is open it automatically refreshes when you change data (requires Dashboard v2.4.0 or later). ## Running from an x-callback call -Most of these commands can be run from an x-callback call: +All of the commands can be run from an x-callback call, of this form: `noteplan://x-callback-url/runPlugin?pluginID=jgclark.Reviews&command=project%20lists` The `command` parameter is the command name (as above), but needs to be 'percent encoded' (i.e. with any spaces changed to `%20`). -If you wish to override your current settings for this call, add `&arg0=` followed by a JSON version of the keys and values e.g. +If you wish to override your current settings for the call, add `&arg0=` followed by a JSON version of the keys and values e.g. `arg0={"foldersToInclude":["CCC Projects"],"displayDates":true,"displayProgress":false,"displayGroupedByFolder":false,"displayOrder":"title"}` that then needs to be URL encoded e.g. `arg0=%7B%22foldersToInclude%22%3A%5B%22CCC%20Projects%22%5D%2C%22displayDates%22%3Atrue%2C%22displayProgress%22%3Afalse%2C%22displayGroupedByFolder%22%3Afalse%2C%22displayOrder%22%3A%22title%22%7D` The name of the settings are taken from the `key`s from the plugin's `plugin.json` file, which are mostly the names shown in the settings dialog without spaces. +## For the record: How v1 specified the project 'metadata' +In v1 you could only write it as a line in the body of a project note. This is what the example above looked like in v1: +```md +# Secret Undertaking +#project @review(2w) @reviewed(2021-07-20) @start(2021-04-05) @due(2021-11-30) +Aim: Stop SPECTRE from world domination +... +``` + +Note each date/interval is enclosed in a `@mention(...)`. + +Since then, frontmatter support has been added to NotePlan, and now **v2** of the plugin uses that instead. When you first run v2, it will offer to migrate the metadata in all project notes in a single operation. If you decline, then it will migrate the metadata on each individual note any time the metadata changes. + ## Thanks -Particular thanks to George C, 'John1' and David W for their suggestions and beta testing. +Particular thanks to George C, 'John1' and David W for their suggestions and beta testing, plus others on the NotePlan Discord server. ## Known issues -Due to limitations in the NotePlan API for plugins, it's generally not possible to control which split window a note is opened in, when you click on a project note in the Project List window. +Due to limitations in the NotePlan API for plugins: +- it's generally not possible to control which split window a note is opened in, when you click on a project note in the Project List window. +- the ordering of metadata fields in the frontmatter is not stable, and normally changes at random when its updated. ## Support If you find an issue with this plugin, or would like to suggest new features for it, please raise an ['Issue' of a Bug or Feature Request](https://github.com/NotePlan/plugins/issues). diff --git a/jgclark.Reviews/convert-2.0.png b/jgclark.Reviews/convert-2.0.png new file mode 100644 index 000000000..22d15b449 Binary files /dev/null and b/jgclark.Reviews/convert-2.0.png differ diff --git a/jgclark.Reviews/edit-dialog-2.0.png b/jgclark.Reviews/edit-dialog-2.0.png new file mode 100644 index 000000000..89d276c98 Binary files /dev/null and b/jgclark.Reviews/edit-dialog-2.0.png differ diff --git a/jgclark.Reviews/filter+order-v2.0b.png b/jgclark.Reviews/filter+order-v2.0b.png new file mode 100644 index 000000000..412003e34 Binary files /dev/null and b/jgclark.Reviews/filter+order-v2.0b.png differ diff --git a/jgclark.Reviews/plugin.json b/jgclark.Reviews/plugin.json index 65faa8c8c..48b57fd49 100644 --- a/jgclark.Reviews/plugin.json +++ b/jgclark.Reviews/plugin.json @@ -9,9 +9,9 @@ "plugin.author": "Jonathan Clark", "plugin.url": "https://noteplan.com/plugins/jgclark.Reviews", "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.Reviews/CHANGELOG.md", - "plugin.version": "1.3.1", - "plugin.releaseStatus": "full", - "plugin.lastUpdateInfo": "1.3.1: Fixed edge case with adding progress updates and frontmatter.\n1.3.0: Please see CHANGELOG.md for details of the many Display improvements, Processing improvements and fixes.", + "plugin.version": "2.0.0.b31", + "plugin.releaseStatus": "beta", + "plugin.lastUpdateInfo": "2.0.0: Significantly modernised layout for Rich project list.\nFrontmatter metadata support, including configurable combined key and migration from body.\n1.3.1: Fixed edge case with adding progress updates and frontmatter.\n1.3.0: Please see CHANGELOG.md for details of the many Display improvements, Processing improvements and fixes.", "plugin.script": "script.js", "plugin.dependsOn": [ { @@ -57,6 +57,12 @@ "iconColor": "orange-600" } }, + { + "hidden": true, + "name": "toggle demo mode for project lists", + "description": "Toggle demo mode for project lists. When true, '/project lists' shows fixed demo data (allProjectsDemoList.json), without recalculating from notes", + "jsFunction": "toggleDemoModeForProjectLists" + }, { "hidden": true, "name": "generateProjectListsAndRenderIfOpen", @@ -164,9 +170,17 @@ "jsFunction": "addProgressUpdate" }, { - "name": "Projects: update plugin settings", - "description": "Settings interface (even for iOS)", - "jsFunction": "updateSettings" + "name": "convert to project", + "alias": [ + "ctp", + "convert", + "project" + ], + "description": "Convert the current (or supplied) note into a Project by adding standard project metadata to its frontmatter, including an optional Aim. Requires NotePlan v3.21+ for the form.", + "jsFunction": "convertToProject", + "parameters": [ + "optional note to convert (type: CoreNoteFields; falls back to the current Editor)" + ] }, { "hidden": false, @@ -175,6 +189,20 @@ "description": "Generate per-folder Area/Project progress stats as CSV files in the plugin data folder", "jsFunction": "writeProjectsWeeklyProgressToCSV" }, + { + "hidden": false, + "name": "heatmaps for weekly Projects Progress", + "alias": [], + "description": "Show per-folder Area/Project progress as two weekly heatmaps (notes progressed and tasks completed) in HTML windows", + "jsFunction": "showProjectsWeeklyProgressHeatmaps" + }, + { + "hidden": false, + "name": "migrate all projects", + "alias": [], + "description": "Run metadata migration (same project notes as the project list) on each matching note; appends results to migration_log.tsv in the plugin data folder", + "jsFunction": "migrateAllProjects" + }, { "hidden": true, "name": "removeAllDueDates", @@ -224,6 +252,18 @@ "name": "renderProjectListsIfOpen", "description": "render current allProjects list in current style(s) if already open", "jsFunction": "renderProjectListsIfOpen" + }, + { + "hidden": true, + "name": "test:onUpdateOrInstall", + "description": "onUpdateOrInstall", + "jsFunction": "onUpdateOrInstall" + }, + { + "hidden": true, + "name": "test:getReviewSettings", + "description": "getReviewSettings", + "jsFunction": "getReviewSettings" } ], "plugin.inactiveCommands": [ @@ -233,6 +273,11 @@ "description": "no operation - testing way to stop losing plugin context", "jsFunction": "NOP" }, + { + "name": "Projects: update plugin settings", + "description": "Settings interface (even for iOS)", + "jsFunction": "updateSettings" + }, { "name": "test:redToGreenInterpolation", "description": "test red - green interpolation", @@ -281,12 +326,12 @@ }, { "key": "projectTypeTags", - "title": "Hashtags to review", - "description": "A comma-separated list of hashtags to indicate notes to include in this review system.\nIf this setting is empty, then it will include all notes for review that include a '@review(...)' string.\nIf it is set (e.g. '#project, #area'), then it will include just those notes which also have one or more of those tags.", + "title": "Hashtags to Review", + "description": "A comma-separated list of hashtags to indicate notes of interest to this Plugin. The Plugin will only review notes which have one or more of these tags in its frontmatter (or metadata line for backwards compatibility).\nIf it is set (e.g. '#project, #area'), then it will include just those notes which also have one or more of those tags in its frontmatter or metadata line. If it is empty, then the plugin will include all notes for review that include a review interval string in its frontmatter or metadata line.", "type": "[string]", "default": [ - "#area", - "#project" + "#project", + "#area" ], "required": false }, @@ -360,7 +405,7 @@ { "key": "preferredWindowType", "title": "Open 'Rich' Project List in what sort of window?", - "description": "On NotePlan v3.20+ on macOS only, you can open the 'Rich' output window in different ways: 'New Window' for a separate window; 'Main Window' to take over the main window; 'Split View' for a split view in the main window.", + "description": "On NotePlan v3.20+, you can open the 'Rich' output window in different ways: '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", "default": "New Window", "choices": [ @@ -389,10 +434,11 @@ { "key": "displayOrder", "title": "Project Display order", - "description": "The ordering options are by 'due' date, by next 'review' date or 'title'.", + "description": "Order projects by next review date, due date, title, or first project tag (primary hashtag) then review date.", "type": "string", "choices": [ "due", + "firstTag", "review", "title" ], @@ -404,7 +450,7 @@ "title": "Show projects grouped by folder?", "description": "Whether to group the projects by their folder.", "type": "bool", - "default": true, + "default": false, "required": true }, { @@ -456,19 +502,11 @@ "required": true }, { - "key": "width", - "title": "Window width", - "description": "Width of the Review List window (pixels)", - "type": "hidden", - "default": 800, - "required": true - }, - { - "key": "height", - "title": "Window height", - "description": "Height of the Review List window (pixels)", - "type": "hidden", - "default": 1200, + "key": "autoUpdateAfterIdleTime", + "title": "Automatic Update interval", + "description": "If set to any number > 0, the Project List will automatically refresh when the window is idle for a certain number of minutes. Set to 0 to disable.\nNote: this only works for the 'Rich' style of list.", + "type": "number", + "default": 0, "required": true }, { @@ -496,7 +534,7 @@ { "key": "progressHeading", "title": "Progress Heading", - "description": "Optional heading name to organize Progress lines under. If set, all Progress lines will be added under this heading. If the heading doesn't exist, it will be created automatically.", + "description": "Optional heading name to organize Progress lines under. If set, all Progress lines will be added under this heading. If the heading doesn't exist, it will be created automatically.\nTip: if this ends with '…' then the section will start folded.", "type": "string", "default": "", "required": false @@ -578,62 +616,70 @@ }, { "type": "heading", - "title": "Customise the metadata @strings" + "title": "Customise the metadata terms" }, { "key": "startMentionStr", "title": "Project start string", - "description": "@string indicating date a project/area was started (default: '@start')", + "description": "Your name for the date a project/area was started (default: 'start'). This, and the others below, are used as the metadata key name in the frontmatter.", "type": "string", - "default": "@start", + "default": "start", "required": true }, { "key": "completedMentionStr", "title": "Project completed string", - "description": "@string indicating date a project/area was completed (default: '@completed')", + "description": "Your name for the date a project/area was completed (default: 'completed')", "type": "string", - "default": "@completed", + "default": "completed", "required": true }, { "key": "cancelledMentionStr", "title": "Project cancelled string", - "description": "@string indicating date a project/area was cancelled (default: '@cancelled')", + "description": "Your name for the date a project/area was cancelled (default: 'cancelled')", "type": "string", - "default": "@cancelled", + "default": "cancelled", "required": true }, { "key": "dueMentionStr", "title": "Project due string", - "description": "@string indicating date a project/area is due to be finished (default: '@due')", + "description": "Your name for the date a project/area is due to be finished (default: 'due')", "type": "string", - "default": "@due", + "default": "due", "required": true }, { "key": "reviewIntervalMentionStr", "title": "Project review interval string", - "description": "@string indicating review interval for project/area (default: '@review')", + "description": "Your name for the review interval for project/area (default: 'review')", "type": "string", - "default": "@review", + "default": "review", "required": true }, { "key": "reviewedMentionStr", "title": "Project reviewed string", - "description": "@string indicating date a project/area was last reviewed (default: '@reviewed')", + "description": "Your name for the date a project/area was last reviewed (default: 'reviewed')", "type": "string", - "default": "@reviewed", + "default": "reviewed", "required": true }, { "key": "nextReviewMentionStr", "title": "Project next review string", - "description": "@string indicating date you next want a project/area to be reviewed (default: '@nextReview')", + "description": "Your name for the date you next want a project/area to be reviewed (default: 'nextReview')", "type": "string", - "default": "@nextReview", + "default": "nextReview", + "required": true + }, + { + "key": "projectMetadataFrontmatterKey", + "title": "Frontmatter metadata key", + "description": "Frontmatter key used to store the combined project metadata string (defaults to 'project'; 'metadata' is a common alternative).", + "type": "string", + "default": "project", "required": true }, { @@ -655,7 +701,7 @@ "ERROR", "none" ], - "default": "INFO", + "default": "DEBUG", "required": true }, { @@ -673,6 +719,32 @@ "type": "bool", "default": false, "required": true + }, + { + "key": "useDemoData", + "title": "Use demo data?", + "description": "If set, then the project lists will use demo data instead of live data.", + "type": "bool", + "default": false, + "required": true + } + ], + "plugin.settings_disabled": [ + { + "key": "width", + "title": "Window width", + "description": "Width of the Review List window (pixels)", + "type": "hidden", + "default": 800, + "required": true + }, + { + "key": "height", + "title": "Window height", + "description": "Height of the Review List window (pixels)", + "type": "hidden", + "default": 1200, + "required": true } ] } \ No newline at end of file diff --git a/jgclark.Reviews/project-detail-numbered.png b/jgclark.Reviews/project-detail-numbered.png new file mode 100644 index 000000000..f1cab5056 Binary files /dev/null and b/jgclark.Reviews/project-detail-numbered.png differ diff --git a/jgclark.Reviews/remove_combined_fm_metadata_8323fd97.plan.md b/jgclark.Reviews/remove_combined_fm_metadata_8323fd97.plan.md new file mode 100644 index 000000000..eda099d1f --- /dev/null +++ b/jgclark.Reviews/remove_combined_fm_metadata_8323fd97.plan.md @@ -0,0 +1,130 @@ +--- +name: Remove combined FM metadata +overview: "The Reviews plugin currently treats the configurable `projectMetadataFrontmatterKey` (default `project`) as a **combined** field: it must end up tags-only on write, but still supports legacy embedded `@mentions`, optional duplicate date serialization via `writeDateMentionsInCombinedMetadata` in persistence code, and several code paths merge body metadata into that one key. Goal: structured fields in **separate** YAML keys and **only hashtags** under `projectMetadataFrontmatterKey`; keep the **one-time migration** from legacy combined values and the existing **`PROJECT_METADATA_MIGRATED_MESSAGE`** body placeholder pipeline; reduce reliance on legacy **body** metadata lines where safe; without conflating `Project.generateMarkdownOutputLine` (project-list summary output) with note metadata writes." +todos: + - id: remove-pref-ui + content: Remove writeDateMentionsInCombinedMetadata from plugin.json, ReviewConfig, getReviewSettings; adjust descriptions + status: completed + - id: simplify-project-class + content: "Persistence only: remove embedded-mention parse in constructor; rename/clarify getCombinedProjectTags*; fix hasFrontmatterMetadata if needed. Do not change generateMarkdownOutputLine." + status: completed + - id: tighten-review-helpers + content: Trim updateMetadataCore/deleteMetadataMentionCore combined-value handling; keep migration path consistent with tags-only key + status: completed + - id: remove-body-metadata + content: Multi-line body metadata block migration + FM-wins on duplicate mentions; retain PROJECT_METADATA_MIGRATED_MESSAGE; audit getProjectMetadataLineIndex / constructor + status: pending + - id: tests-changelog + content: Update Jest tests and CHANGELOG for the release in plugin.json + status: in_progress +isProject: false +--- + +# Remove combined single-key metadata (keep tags-only key) + +## Current architecture (what “combined key” means today) + +- **Preference** `[projectMetadataFrontmatterKey](jgclark.Reviews/plugin.json)` names the YAML field used as the “combined” line; default `project`. A second preference, `**writeDateMentionsInCombinedMetadata`**, exists in settings and is referenced from persistence-related code paths (not from `[Project.generateMarkdownOutputLine](jgclark.Reviews/src/projectClass.js)`, which is for **markdown / project-list summary output** and must be left out of this metadata migration work). +- **Write path**: `[Project.updateProjectMetadata](jgclark.Reviews/src/projectClass.js)` builds separate keys for dates/interval/nextReview, then sets `attrs[singleKeyName] = this.getCombinedProjectTagsFrontmatterValue(singleKeyName)` so the named key holds **only hashtags** (invariant comment ~957). +- **Read path**: `[Project` constructor](jgclark.Reviews/src/projectClass.js) uses `readRawFrontmatterField` / `getFrontmatterAttribute` on that same key to detect “has frontmatter metadata” (~~447), derive `primaryProjectTag` (~~488–494), and **back-compat** parses embedded `@mentions` from the combined string (~531–570). +- **Helpers**: `[reviewHelpers.js](jgclark.Reviews/src/reviewHelpers.js)` — `getReviewSettings` syncs both prefs (~~227–238); `[isProjectNoteIsMarkedSequential](jgclark.Reviews/src/reviewHelpers.js)` reads sequential tag from that attribute (~~346–351); `[getProjectMetadataLineIndex](jgclark.Reviews/src/reviewHelpers.js)` still locates a pseudo-paragraph line inside YAML for `project:` / configurable key / `metadata:` (~~491–498); `[migrateProjectMetadataLineCore](jgclark.Reviews/src/reviewHelpers.js)` merges body metadata into `primaryKey` with tags via `extractTagsOnly` and dates into separate keys (~~560–631), then replaces the body line with `[PROJECT_METADATA_MIGRATED_MESSAGE](jgclark.Reviews/src/reviewHelpers.js)` (~~633–636); `[Project.updateProjectMetadata](jgclark.Reviews/src/projectClass.js)` scans the body to clear that placeholder (~~982–1017); `[updateMetadataCore](jgclark.Reviews/src/reviewHelpers.js)` / `[deleteMetadataMentionCore](jgclark.Reviews/src/reviewHelpers.js)` rewrite that frontmatter line and use `populateSeparateDateKeysFromCombinedValue` + `extractTagsOnly` (~728–775, ~872–908). +- **Other reads**: `[buildAllProjectTags](jgclark.Reviews/src/projectClass.js)` ~816–821; `[generateNextActionComments](jgclark.Reviews/src/projectClass.js)` ~1228–1232 — both use the same key for hashtag content. +- **Tests**: `[projectClass.frontmatterParsing.test.js](jgclark.Reviews/src/__tests__/projectClass.frontmatterParsing.test.js)`, `[projectClass.embeddedCombinedMentions.test.js](jgclark.Reviews/src/__tests__/projectClass.embeddedCombinedMentions.test.js)`, `[getMetadataLineIndexFromBody.test.js](jgclark.Reviews/src/__tests__/getMetadataLineIndexFromBody.test.js)`, etc. + +```mermaid +flowchart LR + subgraph today [Current write split] + sepKeys[Separate YAML keys dates interval] + combinedKey[projectMetadataFrontmatterKey tags only] + sepKeys --> updateFM[updateFrontMatterVars] + combinedKey --> updateFM + end +``` + + + +## Target behavior (per your direction) + +- **Keep** `[projectMetadataFrontmatterKey](jgclark.Reviews/plugin.json)` as the **single place for project-related hashtags** (default `project`). +- **Remove** the “combined metadata string” concept: no dates or other `@mentions` in that field; remove `**writeDateMentionsInCombinedMetadata`** and all code that writes or **parses** embedded mentions from that key. +- **Structured data** stays in the existing separate keys (already derived from mention prefs in constructor / `updateProjectMetadata`). +- **Body migration**: keep existing behavior—after merging body metadata into frontmatter, the anchor body line becomes `[PROJECT_METADATA_MIGRATED_MESSAGE](jgclark.Reviews/src/reviewHelpers.js)`; later saves clear it. **Conflict rule**: if the same date/interval `@mention` exists in both body and frontmatter, **prefer the value already in frontmatter** (do not overwrite FM from body); still remove that mention from the body so it does not linger as a duplicate. +- **Multi-line body metadata**: migration must treat a **block** of consecutive early-body paragraphs as one logical metadata region when users split hashtags and `@mentions` across lines (see **Body metadata migration** below)—and remove absorbed `@mention` lines (or strip mentions) from the body after merge. + +## Migration path: single tags key with embedded date mentions → multiple YAML keys + +This is the intended **one-time** (or first-touch) transition when a note still has dates or intervals **inside** the value of `projectMetadataFrontmatterKey` (e.g. `project: "#project @start(2026-01-01) @due(2026-02-01) @review(1w)"`) instead of only hashtags. + +1. **Read the raw tags-key value** (the string after `project:` / configured key), without requiring the user to edit the note manually. +2. **Scan for embedded `@mention(...)` tokens** in that string (same family as body metadata: start, due, reviewed, completed, cancelled, next review date, review interval—names come from plugin mention prefs). +3. **Map each token to the correct separate frontmatter key** using `[getDateMentionNameToFrontmatterKeyMap](jgclark.Reviews/src/reviewHelpers.js)` / the same key names used in `[Project.updateProjectMetadata](jgclark.Reviews/src/projectClass.js)` (`start`, `due`, `reviewed`, … derived from prefs). Write ISO dates or interval strings **only** into those keys via `[updateFrontMatterVars](/Users/jonathan/GitHub/NP-plugins/helpers/NPFrontMatter.js)` (and `[removeFrontMatterField](/Users/jonathan/GitHub/NP-plugins/helpers/NPFrontMatter.js)` when a value should be cleared). +4. **Normalize the tags key** to **hashtags only**: strip all `@...(...)` segments from that value and rewrite the key using `[extractTagsOnly](jgclark.Reviews/src/reviewHelpers.js)` (or equivalent logic in `[getCombinedProjectTagsFrontmatterValue](jgclark.Reviews/src/projectClass.js)`) so the invariant “tags key = project hashtags only” holds. +5. **Where this runs today (keep these as the migration path)**: + - `**[populateSeparateDateKeysFromCombinedValue](jgclark.Reviews/src/reviewHelpers.js)`** — shared implementation for step 3–4 from a **value-only** substring; must remain available for legacy combined strings. + - `**[migrateProjectMetadataLineCore](jgclark.Reviews/src/reviewHelpers.js)`** — when merging **body** metadata (single- or **multi-line block**; see **Body metadata migration** below) into frontmatter: builds separate keys from mention tokens, sets tags key via `extractTagsOnly`, applies **FM wins** for duplicate mentions, strips/removes body mention lines, then replaces the anchor body line with `PROJECT_METADATA_MIGRATED_MESSAGE`. + - `**[updateMetadataCore](jgclark.Reviews/src/reviewHelpers.js)` / `[deleteMetadataMentionCore](jgclark.Reviews/src/reviewHelpers.js)`** — when the “metadata line” is the **in-YAML** pseudo-paragraph for the tags key: they call `populateSeparateDateKeysFromCombinedValue` before rewriting `fmAttrs[singleMetadataKeyName]` to tags-only so embedded dates are not lost. + - **Constructor / first open** (optional hardening): if step 3 is removed from the constructor for steady-state reads, ensure **either** a dedicated first-open normalizer **or** the above paths still run once so old notes get rewritten before the tags key is read-only for mentions. + +After migration, the note has **multiple** YAML keys for structured fields plus **one** key (still named by `projectMetadataFrontmatterKey`) whose value is **only** hashtags. + +## Body metadata migration (multi-line blocks and FM-vs-body conflicts) + +Today `[findFirstMetadataBodyLine](jgclark.Reviews/src/reviewHelpers.js)` / `[migrateProjectMetadataLineCore](jgclark.Reviews/src/reviewHelpers.js)` effectively assume a **single** first matching paragraph. Extend migration to support the common variants: + +1. **Single-line** (majority): one paragraph, e.g. `#project #test @review(1m) @reviewed(2024-07-30)`. +2. **Multi-line block**: e.g. first line `#project #test`, following lines `@review(1m)` and `@reviewed(2024-07-30)` (possibly with blank lines only between—define rules explicitly in code, e.g. absorb consecutive paragraphs after the anchor while each line is “metadata-shaped”: only hashtags, only `@mention(...)`, whitespace, or combinations that do not look like normal note content; stop at a heading, list item, or non-metadata line). + +**Merge behavior** + +- Build one **synthetic merged string** (e.g. join absorbed paragraphs with spaces) for the same tokenization used today (`mentionTokens`, `extractTagsOnly`, etc.). +- When writing separate frontmatter keys from body mentions, **for each structured field** (start, due, reviewed, interval, next review, …): if frontmatter **already has a valid non-empty value** for that key, **keep the frontmatter value** and do not replace it from the body; **do not** add a duplicate mention back into the body—**delete or strip** the corresponding `@mention` tokens from every absorbed body paragraph as part of cleanup. +- After successful merge, **remove migrated content from the body**: at minimum strip all date/interval mentions that were imported or intentionally skipped (FM won); collapse or delete continuation paragraphs so `@review` / `@reviewed` lines do not remain. Replace the **anchor** line with `PROJECT_METADATA_MIGRATED_MESSAGE` as today; continuation lines in the block should be **removed** (or cleared and paragraphs removed) so the note does not leave orphan `@mention` lines below the placeholder. + +**Implementation touchpoints** (extend, do not replace, the existing placeholder pipeline) + +- `[findFirstMetadataBodyLine](jgclark.Reviews/src/reviewHelpers.js)` → return either a range `{ startIndex, endIndex }` or a list of paragraph indices plus merged content string. +- `[migrateProjectMetadataLineCore](jgclark.Reviews/src/reviewHelpers.js)` → iterate `updateParagraph` / `removeParagraph` (or equivalent) for the full block; apply FM-wins when building `fmAttrs`. +- `[Project` constructor](jgclark.Reviews/src/projectClass.js) paths that call migration when “body only” should trigger the same block-aware behavior. + +## Implementation plan + +1. **Remove the preference and UI** + - Delete `writeDateMentionsInCombinedMetadata` from `[plugin.json](jgclark.Reviews/plugin.json)` (and any `settings.json` template if present in repo). + - Remove from `ReviewConfig` and from `[getReviewSettings](jgclark.Reviews/src/reviewHelpers.js)` the load/sync of that field (~84–85, ~237–238). + - Update `[plugin.lastUpdateInfo` / setting descriptions in `script.js](jgclark.Reviews/script.js)` only if you normally edit bundled script in this workflow (otherwise note for your Rollup build). +2. **Exclude `Project.generateMarkdownOutputLine` from this work** + - This function (formerly `generateMetadataOutputLine`) produces **summary / markdown output** for projects; it does **not** define how note YAML is updated. **Do not** treat it as a metadata-write path or fold `writeDateMentionsInCombinedMetadata` cleanup into it. + - After step 1 removes the setting from `plugin.json` / `getReviewSettings`, strip any remaining references to `writeDateMentionsInCombinedMetadata` from **persistence paths only** (`updateProjectMetadata`, constructor, `reviewHelpers`, tests). Leave `generateMarkdownOutputLine` unchanged unless a later pass explicitly refactors summary formatting. +3. **Remove steady-state read of embedded mentions in the tags key (coordinate with migration)** + - Delete or narrow the constructor block ~531–570 that scans `getFrontmatterAttribute(note, singleKeyName)` for embedded `@...(...)`, **only if** separate keys + `[populateSeparateDateKeysFromCombinedValue](jgclark.Reviews/src/reviewHelpers.js)` / first-touch normalization (see **Migration path** above) still populate `Project` fields before any code relies on them. + - Keep reading **hashtags** from that key for `primaryProjectTag` / `hasFrontmatterMetadata` as today. +4. **Rename / clarify in code (optional but reduces confusion)** + - Rename `getCombinedProjectTagsFrontmatterValue` → something like `getProjectTagsFrontmatterValue` and update JSDoc to state it is **tags-only** for `projectMetadataFrontmatterKey` (not “combined metadata”). + - Update comments in `updateProjectMetadata`, `migrateProjectMetadataLineCore`, and `getProjectMetadataLineIndex` that still say “combined” where they mean “tags key” or “legacy `metadata:` alias”. +5. **Tighten `reviewHelpers` mutation paths (keep one-time migration)** + - **Keep** `[populateSeparateDateKeysFromCombinedValue](jgclark.Reviews/src/reviewHelpers.js)` and all call sites that serve **legacy** notes whose tags-key value still embeds date/interval `@mentions`—especially `[migrateProjectMetadataLineCore](jgclark.Reviews/src/reviewHelpers.js)`, `[updateMetadataCore](jgclark.Reviews/src/reviewHelpers.js)`, and `[deleteMetadataMentionCore](jgclark.Reviews/src/reviewHelpers.js)`. That is the **one-time migration path** from “single string with mentions” to “separate keys + tags-only key”; do not remove it as part of tightening. + - You may still **reduce redundant calls** in steady-state paths where the value is provably already tags-only and separate keys are already populated—**provided** migration entry points above remain unchanged and tested. +6. **Body metadata line: keep `PROJECT_METADATA_MIGRATED_MESSAGE`; extend migration** + - **Do not remove or replace** the `[PROJECT_METADATA_MIGRATED_MESSAGE](jgclark.Reviews/src/reviewHelpers.js)` constant, the body-line replacement in `[migrateProjectMetadataLineCore](jgclark.Reviews/src/reviewHelpers.js)`, or the placeholder-clearing loops in `[Project.updateProjectMetadata](jgclark.Reviews/src/projectClass.js)` / `[Project.updateMetadataAndSave](jgclark.Reviews/src/projectClass.js)`—keep this behavior as-is unless a future, explicitly scoped change says otherwise. + - **Multi-line body metadata**: implement the **Body metadata migration** section—block detection, merged string for parsing, **remove continuation paragraphs** (or strip mentions) so date mentions do not remain in the body, anchor line still becomes the migration message. + - **Frontmatter wins on duplicates**: when building `fmAttrs` from body tokens, skip overwriting any separate FM key that already has a valid value; still strip those body mentions during cleanup. + - **Constructor** (`[Project` constructor](jgclark.Reviews/src/projectClass.js) ~447–463): retain “both FM + body → drop body” and “body only → migrate then cache” behavior that flows through the existing migration helpers, updated to use block-aware migration. + - **Indexing helpers**: where the plan called for simplifying `[getMetadataLineIndexFromBody](jgclark.Reviews/src/reviewHelpers.js)` / `[getProjectMetadataLineIndex](jgclark.Reviews/src/reviewHelpers.js)`, do so only in ways **compatible** with notes that still have a body metadata line or a placeholder paragraph after partial migration. + - Audit call sites of `getProjectMetadataLineIndex` / `getMetadataLineIndexFromBody` across `[jgclark.Reviews/src](jgclark.Reviews/src)` for assumptions about `metadataParaLineIndex` / `NaN` (multi-line may mean “first line of block” vs entire block for callers—document or adjust). +7. **Detection and sequential / paused** + - `[isProjectNoteIsMarkedSequential](jgclark.Reviews/src/reviewHelpers.js)` and `[generateNextActionComments](jgclark.Reviews/src/projectClass.js)`: continue reading the **hashtag** key (`projectMetadataFrontmatterKey`); ensure no code path still expects date mentions there. + - Revisit `**hasFrontmatterMetadata`** (~447): today it is “combined field non-empty”. With tags-only, consider treating “any structured project frontmatter present” (e.g. `review` interval key or any date key) as true so empty `project:` does not force body migration incorrectly—adjust logic and tests accordingly. +8. **Tests** + - Update `[projectClass.frontmatterParsing.test.js](jgclark.Reviews/src/__tests__/projectClass.frontmatterParsing.test.js)` only where tests assert **metadata persistence / frontmatter** behavior tied to `writeDateMentionsInCombinedMetadata`; leave tests for `generateMarkdownOutputLine` summary behavior unchanged unless the pref removal forces a trivial import-only fix. + - Update or replace `[projectClass.embeddedCombinedMentions.test.js](jgclark.Reviews/src/__tests__/projectClass.embeddedCombinedMentions.test.js)`: either delete tests for embedded mentions in the tags key, or convert them to assert **migration** strips mentions into separate keys and leaves tags-only. + - Keep tests that cover `**PROJECT_METADATA_MIGRATED_MESSAGE`** and body→frontmatter migration; adjust only if helper signatures or indexing behavior changes. + - Add migration tests: **multi-line** body metadata (hashtags + `@mentions` on following lines) merges into FM and **removes** those `@mention` lines from the body; **duplicate** date mention in body and FM keeps **frontmatter** value and still strips the body mention. + - Run Jest for `jgclark.Reviews/src/__tests__` with `--no-watch`. +9. **CHANGELOG** + - Per repo rules, add a short entry under the current version’s first H2 in `[jgclark.Reviews/CHANGELOG.md](jgclark.Reviews/CHANGELOG.md)`: removed `writeDateMentionsInCombinedMetadata`; tags key is hashtags-only for new writes; document **one-time migration** from embedded mentions in the tags key to separate YAML keys (see migration path section); note placeholder/body migration behavior **unchanged** unless implementation later alters it; call out **multi-line body metadata** migration and **frontmatter wins** when body duplicates a date mention. + +## Out of scope / non-goals + +- `**Project.generateMarkdownOutputLine`** — summary/list markdown only; not part of this metadata migration. +- Removing the `**metadata:`** alias matching in `[getProjectMetadataLineIndex](jgclark.Reviews/src/reviewHelpers.js)` — optional cleanup for legacy notes; not required to drop “combined string” semantics. + diff --git a/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js b/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js index 1e77bfeb1..b428840b8 100644 --- a/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js +++ b/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js @@ -1,6 +1,6 @@ //-------------------------------------------------------------------------------------- // HTMLWinCommsSwitchboard.js - in the HTMLWindow process data and logic to/from the plugin -// Last updated: 2026-02-07 for v1.3.0.b8 by @jgclark +// Last updated: 2026-04-20 for v2.0.0.b21 by @jgclark //-------------------------------------------------------------------------------------- /** * This file is loaded by the browser via +` + +/** + * Functions to get/set scroll position of the project list content. + * Helped by https://stackoverflow.com/questions/9377951/how-to-remember-scroll-position-and-scroll-back + * But need to find a different approach to store the position, as cookies not available. + */ +export const scrollPreLoadJSFuncs: string = ` + +` + +export const autoRefreshScript: string = ` + +` + +export const commsBridgeScripts: string = ` + + + + + +` + +/** + * Script to add some keyboard shortcuts to control the dashboard. (Meta=Cmd here.) + */ +export const shortcutsScript: string = ` + + + +` + +export const addToggleEvents: string = ` + +` + +export const displayFiltersDropdownScript: string = ` + +` + +export const tagTogglesVisibilityScript: string = ` + +` + +export const windowCloseAndReopenScripts: string = ` + +` \ No newline at end of file diff --git a/jgclark.Reviews/src/projectsWeeklyProgress.js b/jgclark.Reviews/src/projectsWeeklyProgress.js index 4948f1ca9..7c87bb792 100644 --- a/jgclark.Reviews/src/projectsWeeklyProgress.js +++ b/jgclark.Reviews/src/projectsWeeklyProgress.js @@ -7,7 +7,7 @@ // Columns: successive week labels (e.g. 2026-W06) // Rows: folder names in alphabetical order // -// Last updated 2026-02-06 for v1.3.0.b5 by @jgclark (spec) + @cursor (implementation) +// Last updated 2026-03-12 for v1.4.0.b6 by @jgclark (spec) + @cursor (implementation) //----------------------------------------------------------------------------- import pluginJson from '../plugin.json' @@ -22,6 +22,8 @@ import { getNPWeekData, pad } from '@helpers/NPdateTime' import { clo, JSP, logDebug, logError, logInfo, logTimer, timer } from '@helpers/dev' import { getRegularNotesFromFilteredFolders, getFolderFromFilename } from '@helpers/folders' import { isDone } from '@helpers/utils' +import { showHTMLV2 } from '@helpers/HTMLView' +import { showMessage } from '@helpers/userInput' //----------------------------------------------------------------------------- // Constants @@ -31,6 +33,7 @@ const DEFAULT_NUM_WEEKS: number = 26 const PROJECT_FOLDER_MATCHERS: Array = ['area', 'project'] const PROGRESS_PER_FOLDER_FILENAME: string = 'progress-per-folder.csv' const TASK_COMPLETION_PER_FOLDER_FILENAME: string = 'task-completion-per-folder.csv' +const PLUGIN_ID: string = 'jgclark.Reviews' //----------------------------------------------------------------------------- // Types @@ -154,26 +157,25 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array, Ar } const weekLabels: Array = weeks.map((w) => w.label) - // 2. Get all regular notes from filtered folders (respecting existing Summaries exclusions) + // 2. Get all regular notes from filtered folders (respecting existing Projects exclusions) const allNotes = getRegularNotesFromFilteredFolders(foldersToExclude, true) - logDebug(pluginJson, `projectsWeeklyProgressCSV: considering ${String(allNotes.length)} regular notes`) + logDebug('generateProjectsWeeklyProgressLines', `considering ${String(allNotes.length)} regular notes`) - // 3. Filter notes to those whose folder name contains 'Area' or 'Project' + // 3. Filter notes to those whose folder name contains 'Area' or 'Project', and doesn't start or end with 'index' or 'MOC' (case-insensitive) const folderSet: Set = new Set() const notesInTargetFolders = allNotes.filter((n) => { const folderPath = getFolderFromFilename(n.filename) - // const baseFolder = folderPath === '/' ? '/' : folderPath.split('/').pop() ?? folderPath - if (isAreaOrProjectFolder(folderPath)) { + if (isAreaOrProjectFolder(folderPath) && !n.title?.match(/^index $/i) && !n.title?.match(/ index$/i) && !n.title?.match(/^moc $/i) && !n.title?.match(/ moc$/i)) { folderSet.add(folderPath) return true } return false }) const folders: Array = Array.from(folderSet).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) - logInfo(pluginJson, `projectsWeeklyProgressCSV: found ${String(folders.length)} Area/Project folders and ${String(notesInTargetFolders.length)} notes in them`) + logInfo('generateProjectsWeeklyProgressLines', `found ${String(folders.length)} Area/Project folders and ${String(notesInTargetFolders.length)} notes in them`) if (folders.length === 0) { - logInfo(pluginJson, `projectsWeeklyProgressCSV: no Area/Project folders found – nothing to write`) + logInfo('generateProjectsWeeklyProgressLines', `no Area/Project folders found – nothing to write`) return [[], []] } @@ -184,8 +186,6 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array, Ar // 5. Scan notes and paragraphs for (const note of notesInTargetFolders) { const folderPath = getFolderFromFilename(note.filename) - const baseFolder = folderPath === '/' ? '/' : folderPath.split('/').pop() ?? folderPath - for (const p of note.paragraphs) { if (!isDone(p)) continue const doneISO = getDoneISODateFromContent(p.content) @@ -194,7 +194,7 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array, Ar const weekLabel = getWeekLabelForISODate(doneISO, weeks) if (!weekLabel) continue - const key = makeFolderWeekKey(baseFolder, weekLabel) + const key = makeFolderWeekKey(folderPath, weekLabel) // tasks-per-week const currentTasks = tasksPerWeekMap.get(key) ?? 0 @@ -209,15 +209,17 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array, Ar // 6. Build CSV tables const notesRows: Array = [ - ['Folder / Notes progressed per week', ...weekLabels].join(','), + ['Folder / Notes progressed per week', ...weekLabels, 'total'].join(','), ] const tasksRows: Array = [ - ['Folder / Tasks completed per week', ...weekLabels].join(','), + ['Folder / Tasks completed per week', ...weekLabels, 'total'].join(','), ] for (const folderName of folders) { const noteCounts: Array = [] + let noteCountTotal = 0 const taskCounts: Array = [] + let taskCountTotal = 0 for (const weekLabel of weekLabels) { const key = makeFolderWeekKey(folderName, weekLabel) @@ -225,17 +227,44 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array, Ar const noteCount = noteSet ? noteSet.size : 0 const taskCount = tasksPerWeekMap.get(key) ?? 0 noteCounts.push(String(noteCount)) + noteCountTotal += noteCount taskCounts.push(String(taskCount)) + taskCountTotal += taskCount } // Note: surround folder name with quotes in case folder name contains commas - notesRows.push([`"${folderName}"`].concat(noteCounts).join(',')) - tasksRows.push([`"${folderName}"`].concat(taskCounts).join(',')) + notesRows.push([`"${folderName}"`].concat(noteCounts).concat(String(noteCountTotal)).join(',')) + tasksRows.push([`"${folderName}"`].concat(taskCounts).concat(String(taskCountTotal)).join(',')) + } + + // Add totals row (sum of each column across all folders) + if (folders.length > 0) { + const notesColumnTotals: Array = new Array(weekLabels.length + 1).fill(0) + const tasksColumnTotals: Array = new Array(weekLabels.length + 1).fill(0) + + for (const folderName of folders) { + const rowPartsNotes = notesRows.find((r) => r.startsWith(`"${folderName}"`)) + const rowPartsTasks = tasksRows.find((r) => r.startsWith(`"${folderName}"`)) + if (!rowPartsNotes || !rowPartsTasks) { + continue + } + const colsNotes = rowPartsNotes.split(',').slice(1).map((v) => Number(v) || 0) + const colsTasks = rowPartsTasks.split(',').slice(1).map((v) => Number(v) || 0) + colsNotes.forEach((val, idx) => { + notesColumnTotals[idx] += val + }) + colsTasks.forEach((val, idx) => { + tasksColumnTotals[idx] += val + }) + } + + notesRows.push(['"TOTAL"', ...notesColumnTotals.map((n) => String(n))].join(',')) + tasksRows.push(['"TOTAL"', ...tasksColumnTotals.map((n) => String(n))].join(',')) } - logInfo(pluginJson, `projectsWeeklyProgressCSV: generated ${String(notesRows.length)} notes rows and ${String(tasksRows.length)} tasks rows in ${timer(startTime)}`) + logInfo('projectsWeeklyProgressCSV', `Generated ${String(notesRows.length)} notes rows and ${String(tasksRows.length)} tasks rows in ${timer(startTime)}`) return [notesRows, tasksRows] } catch (error) { - logError(pluginJson, `projectsWeeklyProgressCSV: ${error.message}`) + logError('projectsWeeklyProgressCSV', error.message) throw error } } @@ -254,7 +283,7 @@ async function generateProjectsWeeklyProgressLines(): Promise<[Array, Ar */ export async function writeProjectsWeeklyProgressToCSV(): Promise { try { - logDebug(pluginJson, `projectsWeeklyProgressCSV: starting`) + logDebug(pluginJson, `writeProjectsWeeklyProgressToCSV: starting`) const [notesRows, tasksRows] = await generateProjectsWeeklyProgressLines() @@ -266,9 +295,235 @@ export async function writeProjectsWeeklyProgressToCSV(): Promise { const tasksCsvString = tasksRows.join('\n') await DataStore.saveData(tasksCsvString, TASK_COMPLETION_PER_FOLDER_FILENAME, true) - logInfo(pluginJson, `projectsWeeklyProgressCSV: written weekly progress CSV to '${PROGRESS_PER_FOLDER_FILENAME}' and '${TASK_COMPLETION_PER_FOLDER_FILENAME}'`) + logInfo('writeProjectsWeeklyProgressToCSV', `Written weekly progress CSV to '${PROGRESS_PER_FOLDER_FILENAME}' and '${TASK_COMPLETION_PER_FOLDER_FILENAME}'`) + } catch (error) { + logError('writeProjectsWeeklyProgressToCSV', error.message) + throw error + } +} + +//----------------------------------------------------------------------------- +// Heatmap visualisation + +/** + * Convert the CSV-style rows returned by generateProjectsWeeklyProgressLines() + * into the data structure expected by AnyChart's heatMap chart. + * The header row is expected to be: + * label,week1,week2,...,weekN,total + * Subsequent rows are: + * "folder name",v1,v2,...,vN,total + * The TOTAL row is ignored. + * @param {Array} rows + * @returns {Array<{x: string, y: string, heat: number}>} + */ +function buildHeatmapDataFromCSVRows(rows: Array): Array<{ x: string, y: string, heat: number }> { + if (rows.length < 2) { + return [] + } + + const headerParts = rows[0].split(',') + if (headerParts.length < 3) { + return [] + } + + const weekLabels = headerParts.slice(1, -1) + const data = [] + + for (let i = 1; i < rows.length; i++) { + const line = rows[i] + if (!line || line.trim() === '') { + continue + } + const parts = line.split(',') + if (parts.length < weekLabels.length + 2) { + continue + } + + const rawFolder = parts[0] + const folderName = rawFolder.startsWith('"') && rawFolder.endsWith('"') + ? rawFolder.slice(1, -1) + : rawFolder + + if (folderName.toUpperCase() === 'TOTAL') { + continue + } + + for (let w = 0; w < weekLabels.length; w++) { + const valStr = parts[1 + w] + const heat = Number(valStr) || 0 + data.push({ + x: weekLabels[w], + y: folderName, + heat, + }) + } + } + + return data +} + +/** + * Render a heatmap for the given per-folder / per-week CSV rows in an HTML window. + * Uses AnyChart's heatMap chart in the same way as the Summaries plugin's heatmap generator. + * @param {Array} rows + * @param {string} windowTitle + * @param {string} chartTitle + * @param {string} filenameToSave + * @param {string} windowID + * @returns {Promise} + */ +async function showProjectsWeeklyProgressHeatmap( + rows: Array, + windowTitle: string, + chartTitle: string, + filenameToSave: string, + windowID: string, +): Promise { + try { + const data = buildHeatmapDataFromCSVRows(rows) + if (data.length === 0) { + logInfo('showProjectsWeeklyProgressHeatmap', 'No heatmap data to display') + return + } + + const dataAsString = JSON.stringify(data) + + const heatmapCSS = `html, body, #container { + width: 100%; + height: 100%; + margin: 0px; + padding: 0px; + color: var(--fg-main-color); + background-color: var(--bg-main-color); +} +` + + const preScript = ` + + +` + + const body = ` +
+ +` + + const winOpts = { + windowTitle, + width: 800, + height: 500, + generalCSSIn: '', + specificCSS: heatmapCSS, + preBodyScript: preScript, + postBodyScript: '', + customId: windowID, + savedFilename: filenameToSave, + makeModal: false, + reuseUsersWindowRect: true, + shouldFocus: true, + } + + await showHTMLV2(body, winOpts) + logInfo('showProjectsWeeklyProgressHeatmap', `Shown window titled '${windowTitle}'`) + } catch (error) { + logError('showProjectsWeeklyProgressHeatmap', error.message) + } +} + +/** + * Generate weekly Area/Project folder progress stats and display them + * as two heatmaps: + * - Notes progressed per week + * - Tasks completed per week + * This reuses the HTML heatmap pattern from the Summaries plugin. + * @returns {Promise} + */ +export async function showProjectsWeeklyProgressHeatmaps(): Promise { + try { + logDebug(pluginJson, `showProjectsWeeklyProgressHeatmaps: starting`) + + const [notesRows, tasksRows] = await generateProjectsWeeklyProgressLines() + + if (notesRows.length === 0 && tasksRows.length === 0) { + logInfo('showProjectsWeeklyProgressHeatmaps', 'No weekly progress data available to visualise') + await showMessage('No weekly progress data available to visualise', 'OK', 'Weekly Progress Heatmaps') + return + } + + // FIXME: Why does this not work if the following chart is also shown? + if (notesRows.length > 0) { + await showProjectsWeeklyProgressHeatmap( + notesRows, + 'Projects Weekly Progress – Notes', + 'Area/Project Notes progressed per week', + 'projects-notes-weekly-progress-heatmap.html', + `${PLUGIN_ID}.projects-notes-weekly-progress-heatmap`, + ) + } + + if (tasksRows.length > 0) { + await showProjectsWeeklyProgressHeatmap( + tasksRows, + 'Projects Weekly Progress – Tasks', + 'Area/Project Tasks completed per week', + 'projects-tasks-weekly-progress-heatmap.html', + `${PLUGIN_ID}.projects-tasks-weekly-progress-heatmap`, + ) + } } catch (error) { - logError(pluginJson, `projectsWeeklyProgressCSV: ${error.message}`) + logError('showProjectsWeeklyProgressHeatmaps', error.message) throw error } } diff --git a/jgclark.Reviews/src/reviewHelpers.js b/jgclark.Reviews/src/reviewHelpers.js index d33d0bdd0..c7fc87818 100644 --- a/jgclark.Reviews/src/reviewHelpers.js +++ b/jgclark.Reviews/src/reviewHelpers.js @@ -2,14 +2,18 @@ //----------------------------------------------------------------------------- // Helper functions for Review plugin // by Jonathan Clark -// Last updated 2026-02-26 for v1.3.1, @jgclark +// Last updated 2026-04-30 for v2.0.0.b26, @Cursor //----------------------------------------------------------------------------- //----------------------------------------------------------------------------- // Import Helper functions -import { getActivePerspectiveDef, getAllowedFoldersInCurrentPerspective, getPerspectiveSettings } from '../../jgclark.Dashboard/src/perspectiveHelpers' +import { getActivePerspectiveDef, getPerspectiveSettings } from '../../jgclark.Dashboard/src/perspectiveHelpers' import type { TPerspectiveDef } from '../../jgclark.Dashboard/src/types' +import { WEBVIEW_WINDOW_ID as DASHBOARD_WINDOW_ID} from '../../jgclark.Dashboard/src/constants' +import pluginJson from '../plugin.json' +import { appendMigrationLogRow } from './migrationLog.js' import { type Progress } from './projectClass' +import { checkString } from '@helpers/checkType' import { stringListOrArrayToArray } from '@helpers/dataManipulation' import { calcOffsetDate, @@ -18,12 +22,17 @@ import { getJSDateStartOfToday, RE_ISO_DATE, RE_YYYYMMDD_DATE, + todaysDateISOString, toISODateString, } from '@helpers/dateTime' import { clo, JSP, logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' -import { getFrontmatterAttribute, noteHasFrontMatter, updateFrontMatterVars } from '@helpers/NPFrontMatter' +import { backupSettings } from '@helpers/NPConfiguration' +import { endOfFrontmatterLineIndex, ensureFrontmatter, getFrontmatterAttribute, noteHasFrontMatter, removeFrontMatterField, updateFrontMatterVars } from '@helpers/NPFrontMatter' +import { isHTMLWindowOpen } from '@helpers/NPWindows' import { getFieldParagraphsFromNote } from '@helpers/paragraph' +import { escapeRegExp } from '@helpers/regex' +import { getHashtagsFromString } from '@helpers/stringTransforms' import { showMessage } from '@helpers/userInput' //------------------------------ @@ -43,46 +52,172 @@ export type ReviewConfig = { cancelledMentionStr: string, completedMentionStr: string, confirmNextReview: boolean, + displayArchivedProjects: boolean, displayDates: boolean, displayPaused: boolean, - dueMentionStr: string, - displayArchivedProjects: boolean, displayFinished: boolean, displayGroupedByFolder: boolean, displayNextActions: boolean, displayOrder: string, displayOnlyDue: boolean, displayProgress: boolean, + dueMentionStr: string, finishedListHeading: string, hideTopLevelFolder: boolean, ignoreChecklistsInProgress: boolean, reviewedMentionStr: string, reviewIntervalMentionStr: string, + sequentialTag: string, + showFolderName: boolean, startMentionStr: string, nextReviewMentionStr: string, - width: number, - height: number, + // width: number, // TEST: removing -- can't have hidden numeric settings, unfortunately + // height: number, // TEST: removing archiveUsingFolderStructure: boolean, archiveFolder: string, - removeDueDatesOnPause: boolean, + removeDueDatesOnPause?: boolean, nextActionTags: Array, - preferredWindowType: string, - sequentialTag: string, + preferredWindowType: string, // "New Window" |"Main Window" | "Split View" + autoUpdateAfterIdleTime?: number, progressHeading?: string, progressHeadingLevel: number, writeMostRecentProgressToFrontmatter?: boolean, + projectMetadataFrontmatterKey?: string, + useDemoData: boolean, _logLevel: string, _logTimer: boolean, } +/** + * Convert mention preference string into a frontmatter key name. + * @param {string} prefName + * @param {string} defaultKey + * @returns {string} + */ +function getFrontmatterFieldKeyFromMentionPreference(prefName: string, defaultKey: string): string { + return checkString(DataStore.preference(prefName) || '').replace(/^[@#]/, '') || defaultKey +} + +/** + * Map date mention names (e.g. '@reviewed') to separate frontmatter keys (e.g. 'reviewed'), taking account that user may localise the mention strings. + * @returns {{ [string]: string }} + */ +function getDateMentionNameToFrontmatterKeyMap(): { [string]: string } { + const map: { [string]: string } = {} + map[checkString(DataStore.preference('startMentionStr') || '@start')] = getFrontmatterFieldKeyFromMentionPreference('startMentionStr', 'start') + map[checkString(DataStore.preference('dueMentionStr') || '@due')] = getFrontmatterFieldKeyFromMentionPreference('dueMentionStr', 'due') + map[checkString(DataStore.preference('reviewedMentionStr') || '@reviewed')] = getFrontmatterFieldKeyFromMentionPreference('reviewedMentionStr', 'reviewed') + map[checkString(DataStore.preference('completedMentionStr') || '@completed')] = getFrontmatterFieldKeyFromMentionPreference('completedMentionStr', 'completed') + map[checkString(DataStore.preference('cancelledMentionStr') || '@cancelled')] = getFrontmatterFieldKeyFromMentionPreference('cancelledMentionStr', 'cancelled') + map[checkString(DataStore.preference('nextReviewMentionStr') || '@nextReview')] = getFrontmatterFieldKeyFromMentionPreference('nextReviewMentionStr', 'nextReview') + map[checkString(DataStore.preference('reviewIntervalMentionStr') || '@review')] = getFrontmatterFieldKeyFromMentionPreference('reviewIntervalMentionStr', 'review') + return map +} + +/** + * Extract only hashtags from a string and de-duplicate (preserving first-seen order). + * Invariant: combined frontmatter key values must contain ONLY hashtags. + * @param {string} text + * @returns {string} + */ +function extractTagsOnly(text: string): string { + const seen = new Set < string > () + const ordered: Array = [] + const candidates = getHashtagsFromString(checkString(text)) + for (const tag of candidates) { + if (!tag || !tag.startsWith('#') || tag.length <= 1) continue + if (!seen.has(tag)) { + seen.add(tag) + ordered.push(tag) + } + } + return ordered.join(' ') +} + +/** + * Populate separate frontmatter keys from embedded mentions inside the combined metadata value. + * This prevents losing embedded `@start(...)`, `@due(...)`, `@review(...)`, etc. when the combined key + * is rewritten tags-only. + * @param {string} combinedValueOnly - value-only part of the combined key (no `project:` prefix) + * @param {{ [string]: any }} fmAttrs - attributes bag to update + * @param {Array} keysToRemove - keys to remove if the embedded mention param is empty/invalid + * @returns {void} + */ +function populateSeparateDateKeysFromCombinedValue( + combinedValueOnly: string, + fmAttrs: { [string]: any }, + keysToRemove: Array, +): void { + const mentionToFrontmatterKeyMap = getDateMentionNameToFrontmatterKeyMap() + const intervalMentionName = checkString(DataStore.preference('reviewIntervalMentionStr') || '@review') + + const reISODate = new RegExp(`^${RE_ISO_DATE}$`) + const reInterval = /^[+\-]?\d+[BbDdWwMmQqYy]$/ + + const embeddedMentions = combinedValueOnly != null ? combinedValueOnly.match(/@[\w\-\.]+\([^)]*\)/g) ?? [] : [] + + for (const embeddedMention of embeddedMentions) { + const mentionName = embeddedMention.split('(', 1)[0] + const frontmatterKeyName = mentionToFrontmatterKeyMap[mentionName] + if (!frontmatterKeyName) continue + + const mentionParamMatch = embeddedMention.match(/\(([^)]*)\)\s*$/) + const mentionParam = mentionParamMatch && mentionParamMatch[1] != null ? mentionParamMatch[1].trim() : '' + if (mentionParam === '') { + keysToRemove.push(frontmatterKeyName) + logDebug( + 'populateSeparateDateKeysFromCombinedValue', + `Found empty embedded mention '${embeddedMention}' in combined value; scheduling frontmatter key '${frontmatterKeyName}' for removal`, + ) + continue + } + + if (mentionName === intervalMentionName) { + if (reInterval.test(mentionParam)) { + fmAttrs[frontmatterKeyName] = mentionParam + logDebug( + 'populateSeparateDateKeysFromCombinedValue', + `Mapped embedded interval mention '${embeddedMention}' to frontmatter '${frontmatterKeyName}=${mentionParam}'`, + ) + } else { + keysToRemove.push(frontmatterKeyName) + } + } else { + if (reISODate.test(mentionParam)) { + fmAttrs[frontmatterKeyName] = mentionParam + logDebug( + 'populateSeparateDateKeysFromCombinedValue', + `Mapped embedded date mention '${embeddedMention}' to frontmatter '${frontmatterKeyName}=${mentionParam}'`, + ) + } else { + keysToRemove.push(frontmatterKeyName) + } + } + } +} + +/** + * Resolve a note-like object into a CoreNoteFields for frontmatter removals. + * @param {CoreNoteFields | TEditor} noteLike + * @returns {CoreNoteFields} + */ +function getNoteFromNoteLike(noteLike: CoreNoteFields | TEditor): CoreNoteFields { + // Note: TEditor in tests includes a `.note`, but we treat it generically here. + const maybeAny: any = (noteLike: any) + if (maybeAny.note != null) return maybeAny.note + return (noteLike: any) +} + /** * Get config settings * @author @jgclark - * @return {ReviewConfig} object with configuration + * @return {?ReviewConfig} object with configuration, or null if no settings found */ -export async function getReviewSettings(externalCall: boolean = false): Promise { +export async function getReviewSettings(externalCall: boolean = false): ?Promise { try { - logDebug('getReviewSettings', `Starting${externalCall ? ' from a different plugin' : ''} ...`) + if (externalCall) { + logInfo(pluginJson, `getReviewSettings() Starting from a different plugin ...`) + } // Get settings const config: ReviewConfig = await DataStore.loadJSON('../jgclark.Reviews/settings.json') @@ -90,13 +225,11 @@ export async function getReviewSettings(externalCall: boolean = false): Promise< // Otherwise complain, as there should be settings. if (config == null || Object.keys(config).length === 0) { if (!externalCall) { - await showMessage(`Cannot find settings for the 'Projects & Reviews' plugin. Please make sure you have installed it from the Plugin Preferences pane.`) - throw new Error(`Can't find settings file '../jgclark.Reviews/settings.json', so stopping.`) + // Throw an error to trigger the backupSettings call in the catch block + throw new Error } - // $FlowFixMe[incompatible-return] as we're returning null if no settings found - return null } - // clo(config, `Review settings`) + // clo(config, `Review settings for '${pluginJson['plugin.version']}' version:`) // Need to store some things in the Preferences API mechanism, in order to pass things to the Project class DataStore.setPreference('startMentionStr', config.startMentionStr) @@ -109,45 +242,57 @@ export async function getReviewSettings(externalCall: boolean = false): Promise< DataStore.setPreference('numberDaysForFutureToIgnore', config.numberDaysForFutureToIgnore) DataStore.setPreference('ignoreChecklistsInProgress', config.ignoreChecklistsInProgress) + // Frontmatter metadata preferences + // Set a preference for the key name to use for project metadata in the frontmatter. (Dev Note: This is to make the setting available in the Project class.) + // Allow any frontmatter key name, defaulting to 'project' + const rawSingleMetadataKeyName: string = + config.projectMetadataFrontmatterKey && typeof config.projectMetadataFrontmatterKey === 'string' + ? config.projectMetadataFrontmatterKey.trim() + : '' + const singleMetadataKeyName: string = rawSingleMetadataKeyName !== '' ? rawSingleMetadataKeyName : 'project' + config.projectMetadataFrontmatterKey = singleMetadataKeyName + DataStore.setPreference('projectMetadataFrontmatterKey', singleMetadataKeyName) // Set default for includedTeamspaces if not using Perspectives // Note: This value is only used when Perspectives are enabled, so the default doesn't affect filtering when Perspectives are off + // TODO: Review if this still makes sense. if (!config.usePerspectives) { config.includedTeamspaces = ['private'] // Default value (not used when Perspectives are off) } - // If we want to use Perspectives, get all perspective settings + // If we want to use Perspectives, get all perspective settings from Dashboard plugin. if (config.usePerspectives) { const perspectiveSettings: Array = await getPerspectiveSettings(false) // Get the current Perspective const currentPerspective: any = getActivePerspectiveDef(perspectiveSettings) - // clo(currentPerspective, `currentPerspective`) config.perspectiveName = currentPerspective.name - logInfo('getReviewSettings', `Will use Perspective '${config.perspectiveName}', and will override any foldersToInclude, foldersToIgnore, and includedTeamspaces settings`) + logInfo('getReviewSettings', `Will use Perspective '${config.perspectiveName}', and its folder & teamspace settings`) config.foldersToInclude = stringListOrArrayToArray(currentPerspective.dashboardSettings?.includedFolders ?? '', ',') - config.foldersToIgnore = stringListOrArrayToArray(currentPerspective.dashboardSettings?.excludedFolders ?? '', ',') - config.includedTeamspaces = currentPerspective.dashboardSettings?.includedTeamspaces ?? ['private'] // logDebug('getReviewSettings', `- foldersToInclude: [${String(config.foldersToInclude)}]`) + config.foldersToIgnore = stringListOrArrayToArray(currentPerspective.dashboardSettings?.excludedFolders ?? '', ',') // logDebug('getReviewSettings', `- foldersToIgnore: [${String(config.foldersToIgnore)}]`) - logDebug('getReviewSettings', `- includedTeamspaces: [${String(config.includedTeamspaces)}]`) - - const validFolders = getAllowedFoldersInCurrentPerspective(perspectiveSettings) - logDebug('getReviewSettings', `-> validFolders for '${config.perspectiveName}': [${String(validFolders)}]`) + config.includedTeamspaces = currentPerspective.dashboardSettings?.includedTeamspaces ?? ['private'] + // logDebug('getReviewSettings', `- includedTeamspaces: [${String(config.includedTeamspaces)}]`) } - // Ensure displayPaused has a sensible default if missing from settings + // Ensure following have sensible defaults if missing from settings if (config.displayPaused == null) { config.displayPaused = true } + if (config.autoUpdateAfterIdleTime == null) { + config.autoUpdateAfterIdleTime = 0 + } - // Ensure reviewsTheme has a default if missing (e.g. before 'Theme to use for Project Lists' setting existed) + // Ensure reviewsTheme has a default if missing (e.g. before 'Theme to use for Project Lists' setting existed from v1.3.1) if (config.reviewsTheme == null || config.reviewsTheme === undefined) { config.reviewsTheme = '' } return config } catch (err) { - logError('getReviewSettings', `${err.name}: ${err.message}`) - // $FlowFixMe[incompatible-return] as we're returning null if no settings found + logError(pluginJson, `getReviewSettings() error: ${err.name}: ${err.message}`) + await backupSettings('jgclark.Reviews', 'error_in_file') + await showMessage(`Sorry, there's been an error getting the settings for this plugin.\nI have tried to make a copy of the settings file to send to the plugin author on Discord if you wish.\n\nnNow please delete your NotePlan/Plugins/data/jgclark.Reviews/settings.json file. Then re-run the command, which should create a new settings file from the plugin defaults. If the issue persists, please raise an issue on Discord.`, 'OK, thanks', 'Settings Error') + // FlowFixMe[incompatible-return] as we're returning null if no settings found return null } } @@ -163,9 +308,17 @@ export async function getReviewSettings(externalCall: boolean = false): Promise< * @return {?string} - ISO date string (YYYY-MM-DD) or null if calculation fails */ export function calcNextReviewDate(lastReviewDate: string | Date, interval: string): ?string { - const lastReviewDateStr: string = typeof lastReviewDate === 'string' ? lastReviewDate : toISODateString(lastReviewDate) - const reviewDate: Date | null = lastReviewDate != null ? calcOffsetDate(lastReviewDateStr, interval) : getJSDateStartOfToday() - return reviewDate != null ? toISODateString(reviewDate) : null + try { + if (typeof lastReviewDate === 'string' && lastReviewDate === '') { + return todaysDateISOString + } + const lastReviewDateStr: string = lastReviewDate instanceof Date ? toISODateString(lastReviewDate) : lastReviewDate !== '' ? String(lastReviewDate) : todaysDateISOString + const reviewDate: Date | null = lastReviewDate != null ? calcOffsetDate(lastReviewDateStr, interval) : getJSDateStartOfToday() + return reviewDate != null ? toISODateString(reviewDate) : null + } catch (error) { + logError('calcNextReviewDate', error.message) + return null + } } /** @@ -190,7 +343,7 @@ export function getParamMentionFromList(mentionList: $ReadOnlyArray, men */ export function getNextActionLineIndex(note: CoreNoteFields, naTag: string): number { // logDebug('getNextActionLineIndex', `Checking for @${naTag} in ${displayTitle(note)} with ${note.paragraphs.length} paras`) - const NAParas = note.paragraphs.filter((p) => p.content.includes(naTag)) ?? [] + const NAParas = note.paragraphs.filter((p) => p.content.includes(naTag)) logDebug('getNextActionLineIndex', `Found ${NAParas.length} matching ${naTag} paras`) const result = NAParas.length > 0 ? NAParas[0].lineIndex : NaN return result @@ -205,13 +358,18 @@ export function getNextActionLineIndex(note: CoreNoteFields, naTag: string): num */ export function isProjectNoteIsMarkedSequential(note: TNote, sequentialTag: string): boolean { if (!sequentialTag) return false - const projectAttribute = getFrontmatterAttribute(note, 'project') ?? '' + const combinedKey = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project') + const projectAttribute = getFrontmatterAttribute(note, combinedKey) ?? '' if (projectAttribute.includes(sequentialTag)) { - logDebug('isProjectNoteIsMarkedSequential', `found sequential tag '${sequentialTag}' in frontmatter 'project' attribute`) + logDebug('isProjectNoteIsMarkedSequential', `found sequential tag '${sequentialTag}' in frontmatter '${combinedKey}' attribute`) return true } - const metadataLineIndex = getOrMakeMetadataLineIndex(note) + const metadataLineIndex = getProjectMetadataLineIndex(note) const paras = note.paragraphs ?? [] + if (metadataLineIndex === false) { + // logDebug('isProjectNoteIsMarkedSequential', `No project metadata line found (body or frontmatter) for '${displayTitle(note)}'`) + return false + } const metadataLine = paras.length > metadataLineIndex ? paras[metadataLineIndex].content : '' const hashtags = (`${metadataLine} `).split(' ').filter((f) => f[0] === '#') if (hashtags.some((tag) => tag === sequentialTag)) { @@ -233,14 +391,8 @@ export function isProjectNoteIsMarkedSequential(note: TNote, sequentialTag: stri */ export function processMostRecentProgressParagraph(progressParas: Array): Progress { try { - let lastDate = new Date('0000-01-01') // earliest possible YYYY-MM-DD date - let outputProgress: Progress = { - lineIndex: 1, - percentComplete: NaN, - date: new Date('0001-01-01'), - comment: '(no comment found)', - } - + let maxDate: Date = new Date('0000-01-01') // earliest possible YYYY-MM-DD date + let outputProgress: ?Progress = null for (const progressPara of progressParas) { const progressLine = progressPara.content // logDebug('processMostRecentProgressParagraph', progressLine) @@ -271,7 +423,7 @@ export function processMostRecentProgressParagraph(progressParas: Array ${String(percent)}`) - if (thisDate > lastDate) { + if (thisDate > maxDate) { // logDebug('Project::processMostRecentProgressParagraph', `Found latest datePart ${thisDatePart}`) outputProgress = { lineIndex: progressPara.lineIndex, @@ -279,11 +431,16 @@ export function processMostRecentProgressParagraph(progressParas: Array') - return outputProgress + return outputProgress ?? { + lineIndex: 1, + percentComplete: NaN, + date: new Date('0001-01-01'), + comment: '(no comment found)', + } } catch (e) { logError('Project::processMostRecentProgressParagraph', e.message) return { @@ -296,126 +453,563 @@ export function processMostRecentProgressParagraph(progressParas: Array} + */ +function getParagraphRawLinesForMetadataScan(note: CoreNoteFields | TEditor): Array { + const paras = note.paragraphs ?? [] + if (paras.length > 0) { + return paras.map((p) => paragraphRawLineForMetadataScan((p: any))) + } + const noteAny: any = note + if (typeof noteAny.rawContent === 'string' && noteAny.rawContent.length > 0) { + const parts = noteAny.rawContent.split('\n') + if (parts.length > 0 && parts[parts.length - 1] === '') { + parts.pop() + } + return parts + } + return [] +} + +/** + * Find closing YAML `---` line index from plain strings when separator paragraph types are unavailable. + * @param {Array} lines + * @returns {number} index of closing `---`, or -1 + */ +function endOfFrontmatterLineIndexFromRawLines(lines: Array): number { + if (lines.length < 3) { + return -1 + } + if (String(lines[0]).trim() !== '---') { + return -1 + } + for (let i = 1; i < lines.length; i += 1) { + if (String(lines[i]).trim() === '---') { + return i + } + } + return -1 +} + +/** + * Works out which body line (if any) of the current note is project-style metadata line. + * This scans the note body only (after any YAML frontmatter) and is used as a legacy/fallback + * signal for where project metadata used to live in plain text. + * Callers should treat YAML frontmatter as the canonical source of structured metadata + * (dates, intervals, tags) and use the returned body index mainly for migration or mutation. + * + * A body line (using `rawContent` not `content`) is considered metadata-like when it is: + * - a line starting 'project:' or 'metadata:' + * - the first line containing an '@review()' or '@reviewed()' mention + * - the first line starting with a single leading hashtag (project tag line). * @author @jgclark * * @param {TNote} note to use - * @param {string} metadataLinePlaceholder optional to use if we need to make a new metadata line - * @returns {number} the line number for the existing or new metadata line + * @returns {number | false} the line number for an existing body metadata line, else false */ -export function getOrMakeMetadataLineIndex(note: CoreNoteFields, metadataLinePlaceholder: string = '#project @review(1w) <-- _update your tag and your review interval here_'): number { +export function getMetadataLineIndexFromBody(note: CoreNoteFields | TEditor): number | false { try { - const lines = note.paragraphs?.map((s) => s.content) ?? [] - logDebug('getOrMakeMetadataLineIndex', `Starting with ${lines.length} lines for ${displayTitle(note)}`) - - // Belt-and-Braces: deal with empty or almost-empty notes - if (lines.length === 0) { - note.appendParagraph('', 'title') - note.appendParagraph(metadataLinePlaceholder, 'text') - logInfo('getOrMakeMetadataLineIndex', `- Finishing after appending placeholder title and metadata placeholder line`) - return 1 - } else if (lines.length === 1) { - note.appendParagraph(metadataLinePlaceholder, 'text') - logInfo('getOrMakeMetadataLineIndex', `- Finishing after appending metadata placeholder line`) - return 1 - } - - let lineNumber: number = NaN - for (let i = 1; i < lines.length; i++) { - if (lines[i].match(/^(project|metadata|review|reviewed):/i) || lines[i].match(/(@review|@reviewed)\(.+\)/)) { + const lines = getParagraphRawLinesForMetadataScan(note) + logDebug('getMetadataLineIndexFromBody', `Starting with ${String(lines.length)} lines for ${displayTitle(note)}`) + let lineNumber: number | false = false + let endFMIndex = -1 + if (noteHasFrontMatter(note)) { + const e = endOfFrontmatterLineIndex(note) + endFMIndex = e == null || isNaN(e) ? -1 : e + } else { + const fromRaw = endOfFrontmatterLineIndexFromRawLines(lines) + endFMIndex = fromRaw >= 0 ? fromRaw : -1 + } + for (let i = endFMIndex + 1; i < lines.length; i++) { + const thisLine = lines[i] ?? '' + if ( + thisLine.match(/^(project|metadata|review|reviewed):/i) || + thisLine.match(/^@\w[\w\-.]*\([^)]*\)\s*$/) || + thisLine.match(/^#(?!#)\S/) + ) { lineNumber = i + logDebug('getMetadataLineIndexFromBody', `Found body metadata-like line ${String(i)}: '${thisLine}'`) break } } + return lineNumber + } catch (error) { + logError('getMetadataLineIndexFromBody', error.message) + return false + } +} + +/** + * Line index for the combined project metadata line used for mutation. + * Prefers a legacy body metadata line when present (for migration/updates), otherwise falls back + * to the `project:` / `metadata:` line inside YAML frontmatter. + * + * Callers should treat YAML frontmatter as the canonical source of project metadata; this helper + * is primarily for finding the best paragraph location to update when synchronising metadata, + * not for deciding precedence between body and frontmatter values. + * TODO(later): remove the body part of this entirely (and getMetadataLineIndexFromBody()) once + * all callers have been migrated to frontmatter-only flows. + * @param {CoreNoteFields | TEditor} note + * @param {number | false | void} cachedBodyMetadataLineIndex - If `false`, skip `getMetadataLineIndexFromBody` (caller already knows there is no body metadata line). If a number, use as body line index without rescanning (only when note was not mutated since that scan). If omitted, scan the body as usual. + * @returns {number | false} + */ +export function getProjectMetadataLineIndex( + note: CoreNoteFields | TEditor, + cachedBodyMetadataLineIndex?: number | false, +): number | false { + try { + const bodyIdx = + cachedBodyMetadataLineIndex === undefined ? getMetadataLineIndexFromBody(note) : cachedBodyMetadataLineIndex + if (bodyIdx !== false) return bodyIdx + if (!noteHasFrontMatter(note)) return false + const endFMIndex = endOfFrontmatterLineIndex(note) + if (endFMIndex == null || isNaN(endFMIndex) || endFMIndex < 2) return false + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project') + const primaryRe = new RegExp(`^${escapeRegExp(singleMetadataKeyName)}:\\s*`, 'i') + const metadataAliasRe = /^metadata:\s*/i + const paras = note.paragraphs ?? [] + for (let i = 1; i < endFMIndex; i++) { + const lineText = paragraphRawLineForMetadataScan((paras[i]: any)) + if (primaryRe.test(lineText) || metadataAliasRe.test(lineText)) { + return i + } + } + return false + } catch (error) { + logError('getProjectMetadataLineIndex', error.message) + return false + } +} + +//------------------------------ +// Migration message when body metadata has been moved to frontmatter + +export const PROJECT_METADATA_MIGRATED_MESSAGE = '_Project metadata has been migrated to frontmatter._' + +/** + * Find the first body line that looks like project metadata, and return its index and content. + * Metadata-style lines are defined as lines that: + * - start with 'project:', 'metadata:', 'review:', or 'reviewed:' + * - or contain an '@review(...)' / '@reviewed(...)' mention + * - or start with a hashtag. + * @param {Array} paras - all paragraphs in the note/editor + * @param {number} startIndex - index to start scanning from (usually after frontmatter) + * @returns {?{ index: number, content: string }} first matching line info, or null if none found + */ +function findFirstMetadataBodyLine(paras: Array, startIndex: number): ?{ index: number, content: string } { + for (let i = startIndex; i < paras.length; i++) { + const p = paras[i] + const content = p.content ?? '' + const isMetadataStyleLine = + content.match(/^(project|metadata|review|reviewed):/i) != null || + content.match(/(@review|@reviewed)\(.+\)/) != null || + content.match(/^#(?!#)\S/) != null + + if (isMetadataStyleLine) { + return { index: i, content } + } + } + return null +} + +type MetadataBodyBlock = { + startIndex: number, + paragraphIndices: Array, + mergedContent: string, +} + +/** + * Return true if line looks like a body metadata starter or continuation. + * @param {string} content + * @returns {boolean} + */ +function isMetadataBodyLikeLine(content: string): boolean { + const trimmed = content.trim() + if (trimmed === '') return true + if (trimmed === PROJECT_METADATA_MIGRATED_MESSAGE) return true + return ( + trimmed.match(/^(project|metadata|review|reviewed):/i) != null || + trimmed.match(/^@\w[\w\-.]*\([^)]*\)\s*$/) != null || + trimmed.match(/^#(?!#)\S/) != null + ) +} + +/** + * Find the first metadata block in note body and return merged content + paragraph indices. + * This supports both single-line and multi-line body metadata layouts. + * @param {Array} paras + * @param {number} startIndex + * @returns {?MetadataBodyBlock} + */ +function findMetadataBodyBlock(paras: Array, startIndex: number): ?MetadataBodyBlock { + const first = findFirstMetadataBodyLine(paras, startIndex) + if (first == null) return null + + const paragraphIndices: Array = [first.index] + for (let i = first.index + 1; i < paras.length; i++) { + const content = paras[i]?.content ?? '' + if (!isMetadataBodyLikeLine(content)) { + break + } + paragraphIndices.push(i) + } + + const mergedParts: Array = [] + for (const paraIndex of paragraphIndices) { + const raw = paras[paraIndex]?.content ?? '' + if (raw === PROJECT_METADATA_MIGRATED_MESSAGE) continue + const normalized = raw.replace(/^(project|metadata|review|reviewed)\s*:\s*/i, '').trim() + if (normalized !== '') { + mergedParts.push(normalized) + } + } + return { + startIndex: first.index, + paragraphIndices, + mergedContent: mergedParts.join(' ').trim(), + } +} + +/** + * Shared migration: clear migration placeholder line, or merge first body metadata line into frontmatter then replace with placeholder. + * @param {CoreNoteFields | TEditor} note - note/editor used for frontmatter reads/writes and endOfFrontmatterLineIndex + * @param {Array} paras - paragraphs to scan (Editor or Note) + * @param {(p: TParagraph) => void} updateParagraph - persist paragraph edits (Editor.updateParagraph / note.updateParagraph) + * @param {string} logContext - log tag (migrateProjectMetadataLineInEditor | migrateProjectMetadataLineInNote) + * @param {boolean} ensureFrontmatterFirst - if true, create empty frontmatter when missing (Note path) + * @returns {?string} detail string for migration_log when work ran or failed; null when nothing to migrate + * @private + */ +function migrateProjectMetadataLineCore( + note: CoreNoteFields | TEditor, + paras: Array, + updateParagraph: (p: TParagraph) => void, + logContext: string, + ensureFrontmatterFirst: boolean, +): ?string { + try { + if (ensureFrontmatterFirst && !noteHasFrontMatter(note)) { + ensureFrontmatter(note, false) // don't migrate title to frontmatter + } + + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project') + const primaryKey = singleMetadataKeyName + // $FlowFixMe[prop-missing] CoreNoteFields vs Note for NP frontmatter helpers + const metadataAttr = getFrontmatterAttribute((note: any), primaryKey) + const metadataStrSavedFromBodyOfNote = typeof metadataAttr === 'string' ? metadataAttr.trim() : '' + + const endFMIndex = endOfFrontmatterLineIndex(note) ?? -1 + + for (let i = endFMIndex + 1; i < paras.length; i++) { + const p = paras[i] + const content = p.content ?? '' + if (content === PROJECT_METADATA_MIGRATED_MESSAGE) { + logDebug(logContext, `- Found existing migration message at line ${String(i)}; clearing line.`) + p.content = '' + updateParagraph(p) + return 'ok' + } + } + + const metadataBlock = findMetadataBodyBlock(paras, endFMIndex + 1) + if (metadataBlock == null) { + return null + } + logDebug( + logContext, + `- Found body metadata block start=${String(metadataBlock.startIndex)} indices=[${metadataBlock.paragraphIndices.join(', ')}] merged='${metadataBlock.mergedContent}'`, + ) + + const existingFMValue = metadataStrSavedFromBodyOfNote + const bodyValue = metadataBlock.mergedContent + let mergeFailedDetail: ?string = null + + if (bodyValue !== '') { + logDebug(logContext, `- Merging body metadata into frontmatter key '${primaryKey}' with bodyValue '${bodyValue}'`) + const fmAttrs: { [string]: any } = {} + fmAttrs[primaryKey] = extractTagsOnly(`${existingFMValue !== '' ? `${existingFMValue} ` : ''}${bodyValue}`) + + const mentionTokens = (`${bodyValue} `) + .split(' ') + .filter((f) => f[0] === '@') - // If no metadataPara found, then insert one either after title, or in the frontmatter if present. - if (Number.isNaN(lineNumber)) { - if (noteHasFrontMatter(note)) { - logWarn('getOrMakeMetadataLineIndex', `I couldn't find an existing metadata line, so have added a placeholder at the top of the note. Please review it.`) - // $FlowIgnore[incompatible-call] - const res = updateFrontMatterVars(note, { - metadata: metadataLinePlaceholder, - }) - const updatedLines = note.paragraphs?.map((s) => s.content) ?? [] - // Find which line that project field is on - for (let i = 1; i < updatedLines.length; i++) { - if (updatedLines[i].match(/^metadata:/i)) { - lineNumber = i - break + const reISODate = new RegExp(`^${RE_ISO_DATE}$`) + const reInterval = /^[+\-]?\d+[BbDdWwMmQqYy]$/ + + const readBracketContent = (mentionTokenStr: string): string => { + const match = mentionTokenStr.match(/\(([^)]*)\)$/) + return match && match[1] != null ? match[1].trim() : '' + } + + const dateMentionToFrontmatterKeyMap = getDateMentionNameToFrontmatterKeyMap() + for (const mentionName of Object.keys(dateMentionToFrontmatterKeyMap)) { + const frontmatterKeyName = dateMentionToFrontmatterKeyMap[mentionName] + const mentionTokenStr = getParamMentionFromList(mentionTokens, mentionName) + if (!mentionTokenStr) continue + const bracketContent = readBracketContent(mentionTokenStr) + if (bracketContent !== '' && reISODate.test(bracketContent)) { + const existingValueRaw = getFrontmatterAttribute((note: any), frontmatterKeyName) + const existingParsed = existingValueRaw != null ? String(existingValueRaw).trim() : '' + if (existingParsed !== '' && reISODate.test(existingParsed)) { + logDebug( + logContext, + `- Keeping existing frontmatter '${frontmatterKeyName}=${existingParsed}' and ignoring body token '${mentionTokenStr}'`, + ) + } else { + fmAttrs[frontmatterKeyName] = bracketContent + logDebug(logContext, `- Migrating body token '${mentionTokenStr}' to frontmatter '${frontmatterKeyName}=${bracketContent}'`) } } + } + + const reviewIntervalMentionName = checkString(DataStore.preference('reviewIntervalMentionStr')) + const reviewIntervalTokenStr = reviewIntervalMentionName ? getParamMentionFromList(mentionTokens, reviewIntervalMentionName) : '' + const intervalBracketContent = reviewIntervalTokenStr ? readBracketContent(reviewIntervalTokenStr) : '' + const reviewIntervalKey = checkString(DataStore.preference('reviewIntervalMentionStr') || '').replace(/^[@#]/, '') || 'review' + if (intervalBracketContent !== '' && reInterval.test(intervalBracketContent)) { + const existingReviewRaw = getFrontmatterAttribute((note: any), reviewIntervalKey) + const existingReviewParsed = existingReviewRaw != null ? String(existingReviewRaw).trim() : '' + if (existingReviewParsed !== '' && reInterval.test(existingReviewParsed)) { + logDebug( + logContext, + `- Keeping existing frontmatter '${reviewIntervalKey}=${existingReviewParsed}' and ignoring body interval '${reviewIntervalTokenStr}'`, + ) + } else { + fmAttrs[reviewIntervalKey] = intervalBracketContent + logDebug(logContext, `- Migrating body interval '${intervalBracketContent}' to '${reviewIntervalKey}'`) + } + } + + // $FlowFixMe[incompatible-call] + const mergedOK = updateFrontMatterVars((note: any), fmAttrs) + if (!mergedOK) { + mergeFailedDetail = `frontmatter merge failed (${logContext})` + logError(logContext, `Failed to merge body metadata line into frontmatter key '${primaryKey}' for '${displayTitle(note)}'`) } else { - logWarn('getOrMakeMetadataLineIndex', `Warning: Can't find an existing metadata line, so will insert one after title`) - note.insertParagraph(metadataLinePlaceholder, 1, 'text') - lineNumber = 1 + logDebug(logContext, `- Merged body metadata into frontmatter key '${primaryKey}' for '${displayTitle(note)}'`) } } - // logDebug('getOrMakeMetadataLineIndex', `Metadata line = ${String(lineNumber)}`) - return lineNumber + + const metadataPara = paras[metadataBlock.startIndex] + logDebug(logContext, `- Replacing body metadata anchor line at ${String(metadataBlock.startIndex)} with migration message.`) + metadataPara.content = PROJECT_METADATA_MIGRATED_MESSAGE + updateParagraph(metadataPara) + + for (const paraIndex of metadataBlock.paragraphIndices) { + if (paraIndex === metadataBlock.startIndex) continue + const continuationPara = paras[paraIndex] + if (!continuationPara) continue + if (continuationPara.content.trim() !== '') { + logDebug(logContext, `- Clearing migrated continuation line ${String(paraIndex)} content='${continuationPara.content}'`) + } + continuationPara.content = '' + updateParagraph(continuationPara) + } + const finalDetail = mergeFailedDetail != null ? mergeFailedDetail : 'ok' + appendMigrationLogRow(note.filename ?? '(unknown)', note.title ?? '(unknown)', finalDetail) + return finalDetail } catch (error) { - logError('getOrMakeMetadataLineIndex', error.message) - return 0 + const errDetail = `exception (${logContext}): ${error.message}` + appendMigrationLogRow(note.filename ?? '(unknown)', note.title ?? '(unknown)', errDetail) + logError(logContext, error.message) + return errDetail } } -//------------------------------------------------------------------------------- /** - * Update project metadata @mentions (e.g. @reviewed(date)) in the metadata line of the note in the Editor. - * It takes each mention in the array (e.g. '@reviewed(2023-06-23)') and all other versions of it will be removed first, before that string is appended. + * If project metadata is now stored in frontmatter, then: + * - replace any existing project metadata line in the body with a short migration message, or + * - remove that migration message if it already exists. + * NOTE: This helper does not save/update the Editor; callers must handle persistence. * @author @jgclark * @param {TEditor} thisEditor - the Editor window to update - * @param {Array} mentions to update: - * @returns { ?TNote } current note + * @returns {?string} migration log detail when migration ran or failed; null when skipped or nothing to do */ -export function updateMetadataInEditor(thisEditor: TEditor, updatedMetadataArr: Array): void { +export function migrateProjectMetadataLineInEditor(thisEditor: TEditor): ?string { try { - logDebug('updateMetadataInEditor', `Starting for '${displayTitle(Editor)}' with metadata ${String(updatedMetadataArr)}`) - - // Only proceed if we're in a valid Project note (with at least 2 lines) - if (thisEditor.note == null || thisEditor.note.type === 'Calendar' || thisEditor.note.paragraphs.length < 2) { - logWarn('updateMetadataInEditor', `- We're not in a valid Project note (and with at least 2 lines). Stopping.`) - return + if (thisEditor.note == null || thisEditor.note.type === 'Calendar' || thisEditor.paragraphs.length < 2) { + logWarn('migrateProjectMetadataLineInEditor', `- We're not in a valid Project note (and with at least 2 lines). Stopping.`) + return null + } + logDebug('migrateProjectMetadataLineInEditor', `Starting for '${displayTitle(thisEditor)}'`) + return migrateProjectMetadataLineCore( + thisEditor, + thisEditor.paragraphs, + (p) => { + thisEditor.updateParagraph(p) + }, + 'migrateProjectMetadataLineInEditor', + false, + ) + } catch (error) { + logError('migrateProjectMetadataLineInEditor', error.message) + return null + } +} + +/** + * Migrates any old-style single-line project metadata remaining in the note body into the appropriate frontmatter key, and, if migrated, + * replaces that body metadata line with a short migration message. + * If the migration message already exists, it is removed. + * NOTE: This helper does not update the cache. + * @author @jgclark + * @param {CoreNoteFields} noteToUse - the note to update + * @returns {?string} migration log detail when migration ran or failed; null when skipped or nothing to do + */ +export function migrateProjectMetadataLineInNote(noteToUse: CoreNoteFields): ?string { + try { + if (noteToUse == null || noteToUse.type === 'Calendar' || noteToUse.paragraphs.length < 2) { + logWarn('migrateProjectMetadataLineInNote', `- We've not been passed a valid Project note (and with at least 2 lines). Stopping.`) + return null } - const thisNote = thisEditor // note: not thisEditor.note + logDebug('migrateProjectMetadataLineInNote', `Starting for '${displayTitle(noteToUse)}'`) + return migrateProjectMetadataLineCore( + noteToUse, + noteToUse.paragraphs, + (p) => { + noteToUse.updateParagraph(p) + }, + 'migrateProjectMetadataLineInNote', + true, + ) + } catch (error) { + logError('migrateProjectMetadataLineInNote', error.message) + return null + } +} + +//------------------------------------------------------------------------------- +// Other helpers (metadata mutation + delete) - const metadataLineIndex: number = getOrMakeMetadataLineIndex(thisEditor) - // Re-read paragraphs, as they might have changed - const metadataPara = thisEditor.paragraphs[metadataLineIndex] +/** + * Core helper to update project metadata @mentions in a metadata line. + * Shared by updateMetadataInEditor and updateMetadataInNote. + * @param {CoreNoteFields | TEditor} noteLike - the note/editor to update + * @param {number} metadataLineIndex - index of the metadata line to use + * @param {Array} updatedMetadataArr - full @mention strings to apply (e.g. '@reviewed(2023-06-23)') + * @param {string} logContext - name to use in log messages + */ +function updateMetadataCore( + noteLike: CoreNoteFields | TEditor, + metadataLineIndex: number, + updatedMetadataArr: Array, + logContext: string, +): void { + try { + const metadataPara = noteLike.paragraphs[metadataLineIndex] if (!metadataPara) { - throw new Error(`Couldn't get or make metadataPara for ${displayTitle(Editor)}`) + throw new Error(`Couldn't get metadata line ${metadataLineIndex} from ${displayTitle(noteLike)}`) } const origLine: string = metadataPara.content let updatedLine = origLine + const endFMIndex = endOfFrontmatterLineIndex(noteLike) ?? -1 + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project') + const frontmatterPrefixRe = new RegExp(`^${singleMetadataKeyName}:\\s*`, 'i') + const isFrontmatterLine = metadataLineIndex <= endFMIndex + logDebug( - 'updateMetadataInEditor', - `starting for '${displayTitle(thisNote)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`, + logContext, + `starting for '${displayTitle(noteLike)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`, ) - for (const item of updatedMetadataArr) { - const mentionName = item.split('(', 1)[0] - // logDebug('updateMetadataInEditor', `Processing ${item} for ${mentionName}`) - // Start by removing all instances of this @mention - const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}\\([\\w\\-\\.]+\\)`, 'gi') - updatedLine = updatedLine.replace(RE_THIS_MENTION_ALL, '') - // Then append this @mention - updatedLine += ` ${item}` - // logDebug('updateMetadataInEditor', `-> ${updatedLine}`) - } - - // send update to Editor (removing multiple and trailing spaces) - metadataPara.content = updatedLine.replace(/\s{2,}/g, ' ').trimRight() - thisEditor.updateParagraph(metadataPara) - // await saveEditorToCache() // might be stopping code execution here for unknown reasons - logDebug('updateMetadataInEditor', `- After update ${metadataPara.content}`) + if (isFrontmatterLine) { + let valueOnly = origLine.replace(frontmatterPrefixRe, '') + const dateMentionToFrontmatterKeyMap = getDateMentionNameToFrontmatterKeyMap() + const fmAttrs: { [string]: any } = {} + const keysToRemove: Array = [] + + // Move any embedded date/interval mentions from the combined key into their separate keys. + // This ensures they aren't lost when we rewrite the combined key tags-only. + populateSeparateDateKeysFromCombinedValue(valueOnly, fmAttrs, keysToRemove) + + for (const item of updatedMetadataArr) { + const mentionName = item.split('(', 1)[0] + const mentionParamMatch = item.match(/\(([^)]*)\)$/) + const mentionParam = mentionParamMatch && mentionParamMatch[1] != null ? mentionParamMatch[1].trim() : '' + const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}\\([\\w\\-\\.]+\\)`, 'gi') + valueOnly = valueOnly.replace(RE_THIS_MENTION_ALL, '') + const separateDateKey = dateMentionToFrontmatterKeyMap[mentionName] + if (separateDateKey) { + if (mentionParam !== '') { + fmAttrs[separateDateKey] = mentionParam + } else { + keysToRemove.push(separateDateKey) + } + } else { + valueOnly += ` ${item}` + } + } + fmAttrs[singleMetadataKeyName] = extractTagsOnly(valueOnly) + // $FlowFixMe[incompatible-call] + const success = updateFrontMatterVars(noteLike, fmAttrs) + if (!success) { + logError(logContext, `Failed to update frontmatter ${singleMetadataKeyName} for '${displayTitle(noteLike)}'`) + } else { + const noteForRemoval = getNoteFromNoteLike(noteLike) + for (const keyToRemove of keysToRemove) { + removeFrontMatterField(noteForRemoval, keyToRemove) + } + logDebug(logContext, `- After update frontmatter ${singleMetadataKeyName}='${fmAttrs[singleMetadataKeyName]}'`) + } + } else { + for (const item of updatedMetadataArr) { + const mentionName = item.split('(', 1)[0] + const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}\\([\\w\\-\\.]+\\)`, 'gi') + updatedLine = updatedLine.replace(RE_THIS_MENTION_ALL, '') + updatedLine += ` ${item}` + } + metadataPara.content = updatedLine.replace(/\s{2,}/g, ' ').trimRight() + noteLike.updateParagraph(metadataPara) + logDebug(logContext, `- After update ${metadataPara.content}`) + } + } catch (error) { + logError(logContext, error.message) + } +} + +/** + * Update project metadata @mentions (e.g. @reviewed(date)) in the metadata line of the note in the Editor. + * It takes each mention in the array (e.g. '@reviewed(2023-06-23)') and all other versions of it will be removed first, before that string is appended. + * @author @jgclark + * @param {TEditor} thisEditor - the Editor window to update + * @param {Array} mentions to update: + * @returns { ?TNote } current note + */ +export function updateBodyMetadataInEditor(thisEditor: TEditor, updatedMetadataArr: Array): void { + try { + logDebug('updateBodyMetadataInEditor', `Starting for '${displayTitle(thisEditor)}' with metadata ${String(updatedMetadataArr)}`) + + // Only proceed if we're in a valid Project note (with at least 2 lines) + if (thisEditor.note == null || thisEditor.note.type === 'Calendar' || thisEditor.note.paragraphs.length < 2) { + logWarn('updateBodyMetadataInEditor', `- We're not in a valid Project note (and with at least 2 lines). Stopping.`) + return + } + + const metadataLineIndex = getProjectMetadataLineIndex(thisEditor) + if (metadataLineIndex === false) { + logDebug('updateBodyMetadataInEditor', `No project metadata line found (body or frontmatter) for '${displayTitle(thisEditor)}'`) + return + } + updateMetadataCore(thisEditor, metadataLineIndex, updatedMetadataArr, 'updateBodyMetadataInEditor') } catch (error) { - logError('updateMetadataInEditor', error.message) + logError('updateBodyMetadataInEditor', error.message) } } @@ -427,126 +1021,117 @@ export function updateMetadataInEditor(thisEditor: TEditor, updatedMetadataArr: * @param {TNote} noteToUse * @param {Array} mentions to update: */ -export function updateMetadataInNote(note: CoreNoteFields, updatedMetadataArr: Array): void { +export function updateBodyMetadataInNote(note: CoreNoteFields, updatedMetadataArr: Array): void { try { // only proceed if we're in a valid Project note (with at least 2 lines) if (note == null || note.type === 'Calendar' || note.paragraphs.length < 2) { - logWarn('updateMetadataInEditor', `- We don't have a valid Project note (and with at least 2 lines). Stopping.`) + logWarn('updateBodyMetadataInNote', `- We don't have a valid Project note (and with at least 2 lines). Stopping.`) return } - const metadataLineIndex: number = getOrMakeMetadataLineIndex(note) - // Re-read paragraphs, as they might have changed - const metadataPara = note.paragraphs[metadataLineIndex] - if (!metadataPara) { - throw new Error(`Couldn't get or make metadataPara for ${displayTitle(note)}`) - } - - const origLine: string = metadataPara.content - let updatedLine = origLine - - logDebug( - 'updateMetadataInNote', - `starting for '${displayTitle(note)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`, - ) - - for (const item of updatedMetadataArr) { - const mentionName = item.split('(', 1)[0] - logDebug('updateMetadataInNote', `Processing ${item} for ${mentionName}`) - // Start by removing all instances of this @mention - const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}\\([\\w\\-\\.]+\\)`, 'gi') - updatedLine = updatedLine.replace(RE_THIS_MENTION_ALL, '') - // Then append this @mention - updatedLine += ` ${item}` - logDebug('updateMetadataInNote', `-> ${updatedLine}`) + const metadataLineIndex = getProjectMetadataLineIndex(note) + if (metadataLineIndex === false) { + logDebug('updateBodyMetadataInNote', `No project metadata line found (body or frontmatter) for '${displayTitle(note)}'`) + return } - - // update the note (removing multiple and trailing spaces) - metadataPara.content = updatedLine.replace(/\s{2,}/g, ' ').trimRight() - note.updateParagraph(metadataPara) - logDebug('updateMetadataInNote', `- After update ${metadataPara.content}`) - - return + updateMetadataCore(note, metadataLineIndex, updatedMetadataArr, 'updateBodyMetadataInNote') } catch (error) { - logError('updateMetadataInNote', `${error.message}`) - return + logError('updateBodyMetadataInNote', `${error.message}`) } } -//------------------------------------------------------------------------------- -// Other helpers - -export type IntervalDueStatus = { - color: string, - text: string -} /** - * Map a review interval (days until/since due) to a display color and label. - * @param {number} interval - days until due (negative = overdue, positive = due in future) - * @returns {{ color: string, text: string }} + * Internal helper to delete specific metadata mentions from a metadata line in a note-like object. + * Shared by deleteMetadataMentionInEditor and deleteMetadataMentionInNote. + * @param {CoreNoteFields | TEditor} noteLike - the note or editor to update + * @param {number} metadataLineIndex - index of the metadata line to use + * @param {Array} mentionsToDeleteArr - mentions to delete (just the @mention name, not any bracketed date) + * @param {string} logContext - name to use in log messages */ -export function getIntervalDueStatus(interval: number): IntervalDueStatus { - if (interval < -90) return { color: 'red', text: 'project very overdue' } - if (interval < -14) return { color: 'red', text: 'project overdue' } - if (interval < 0) return { color: 'orange', text: 'project slightly overdue' } - if (interval > 30) return { color: 'blue', text: 'project due >month' } - return { color: 'green', text: 'due soon' } -} +function deleteMetadataMentionCore( + noteLike: CoreNoteFields | TEditor, + metadataLineIndex: number, + mentionsToDeleteArr: Array, + logContext: string, +): void { + try { + const metadataPara = noteLike.paragraphs[metadataLineIndex] + if (!metadataPara) { + throw new Error(`Couldn't get metadata line ${metadataLineIndex} from ${displayTitle(noteLike)}`) + } + const origLine: string = metadataPara.content + let newLine = origLine -/** - * Map a review interval (days until/since next review) to a display color and label. - * @param {number} interval - days until next review (negative = overdue, positive = due in future) - * @returns {{ color: string, text: string }} - */ -export function getIntervalReviewStatus(interval: number): IntervalDueStatus { - if (interval < -14) return { color: 'red', text: 'review overdue' } - if (interval < 0) return { color: 'orange', text: 'review slightly overdue' } - if (interval > 30) return { color: 'blue', text: 'review in >month' } - return { color: 'green', text: 'review soon' } + const endOfFrontmatterIndex = endOfFrontmatterLineIndex(noteLike) ?? -1 + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project') + const frontmatterPrefixRe = new RegExp(`^${singleMetadataKeyName}:\\s*`, 'i') + const isFrontmatterLine = metadataLineIndex <= endOfFrontmatterIndex + + logDebug(logContext, `starting for '${displayTitle(noteLike)}' with metadataLineIndex ${metadataLineIndex} to remove [${String(mentionsToDeleteArr)}]`) + + if (isFrontmatterLine) { + let valueOnly = origLine.replace(frontmatterPrefixRe, '') + const dateMentionToFrontmatterKeyMap = getDateMentionNameToFrontmatterKeyMap() + const fmAttrs: { [string]: any } = {} + const keysToRemove: Array = [] + + // Move any embedded date/interval mentions from the combined key into their separate keys + // before rewriting the combined key tags-only. + populateSeparateDateKeysFromCombinedValue(valueOnly, fmAttrs, keysToRemove) + + for (const mentionName of mentionsToDeleteArr) { + const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi') + valueOnly = valueOnly.replace(RE_THIS_MENTION_ALL, '') + const separateDateKey = dateMentionToFrontmatterKeyMap[mentionName] + if (separateDateKey) { + keysToRemove.push(separateDateKey) + } + logDebug(logContext, `-> ${valueOnly}`) + } + fmAttrs[singleMetadataKeyName] = extractTagsOnly(valueOnly) + // $FlowFixMe[incompatible-call] + const success = updateFrontMatterVars(noteLike, fmAttrs) + if (!success) { + logError(logContext, `Failed to update frontmatter ${singleMetadataKeyName} for '${displayTitle(noteLike)}'`) + } else { + const noteForRemoval = getNoteFromNoteLike(noteLike) + for (const keyToRemove of keysToRemove) { + removeFrontMatterField(noteForRemoval, keyToRemove) + } + logDebug(logContext, `- Finished frontmatter ${singleMetadataKeyName}='${fmAttrs[singleMetadataKeyName]}'`) + } + } else { + for (const mentionName of mentionsToDeleteArr) { + const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi') + newLine = newLine.replace(RE_THIS_MENTION_ALL, '') + logDebug(logContext, `-> ${newLine}`) + } + metadataPara.content = newLine.replace(/\s{2,}/g, ' ').trimRight() + noteLike.updateParagraph(metadataPara) + logDebug(logContext, `- Finished`) + } + } catch (error) { + logError(logContext, error.message) + } } /** - * Update project metadata @mentions (e.g. @reviewed(date)) in the note in the Editor + * Delete specific metadata @mentions (e.g. @reviewed(date)) from the metadata line of the note in the Editor * @author @jgclark * @param {TEditor} thisEditor - the Editor window to update + * @param {number} metadataLineIndex - index of the metadata line to use * @param {Array} mentions to update (just the @mention name, not and bracketed date) * @returns { ?TNote } current note */ -export function deleteMetadataMentionInEditor(thisEditor: TEditor, mentionsToDeleteArr: Array): void { +export function deleteMetadataMentionInEditor(thisEditor: TEditor, metadataLineIndex: number, mentionsToDeleteArr: Array): void { try { // only proceed if we're in a valid Project note (with at least 2 lines) if (thisEditor.note == null || thisEditor.note.type === 'Calendar' || thisEditor.note.paragraphs.length < 2) { logWarn('deleteMetadataMentionInEditor', `- We're not in a valid Project note (and with at least 2 lines). Stopping.`) return } - const thisNote = thisEditor // note: not thisEditor.note - - const metadataLineIndex: number = getOrMakeMetadataLineIndex(thisEditor) - // Re-read paragraphs, as they might have changed - const metadataPara = thisEditor.paragraphs[metadataLineIndex] - if (!metadataPara) { - throw new Error(`Couldn't get or make metadataPara for ${displayTitle(thisEditor)}`) - } - - const origLine: string = metadataPara.content - let newLine = origLine - - logDebug('deleteMetadataMentionInEditor', `starting for '${displayTitle(thisEditor)}' with metadataLineIndex ${metadataLineIndex} to remove [${String(mentionsToDeleteArr)}]`) - - for (const mentionName of mentionsToDeleteArr) { - // logDebug('deleteMetadataMentionInEditor', `Processing ${item} for ${mentionName}`) - // Start by removing all instances of this @mention - const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi') - newLine = newLine.replace(RE_THIS_MENTION_ALL, '') - logDebug('deleteMetadataMentionInEditor', `-> ${newLine}`) - } - - // send update to Editor (removing multiple and trailing spaces) - metadataPara.content = newLine.replace(/\s{2,}/g, ' ').trimRight() - thisEditor.updateParagraph(metadataPara) - // await saveEditorToCache() // seems to stop here but without error - logDebug('deleteMetadataMentionInEditor', `- Finished`) + deleteMetadataMentionCore(thisEditor, metadataLineIndex, mentionsToDeleteArr, 'deleteMetadataMentionInEditor') } catch (error) { logError('deleteMetadataMentionInEditor', `${error.message}`) } @@ -556,75 +1141,102 @@ export function deleteMetadataMentionInEditor(thisEditor: TEditor, mentionsToDel * Update project metadata @mentions (e.g. @reviewed(date)) in the note in the Editor * @author @jgclark * @param {TNote} noteToUse + * @param {number} metadataLineIndex - index of the metadata line to use * @param {Array} mentions to update (just the @mention name, not and bracketed date) */ -export function deleteMetadataMentionInNote(noteToUse: CoreNoteFields, mentionsToDeleteArr: Array): void { +export function deleteMetadataMentionInNote(noteToUse: CoreNoteFields, metadataLineIndex: number, mentionsToDeleteArr: Array): void { try { // only proceed if we're in a valid Project note (with at least 2 lines) if (noteToUse == null || noteToUse.type === 'Calendar' || noteToUse.paragraphs.length < 2) { logWarn('deleteMetadataMentionInNote', `- We've not been passed a valid Project note (and with at least 2 lines). Stopping.`) return } + deleteMetadataMentionCore(noteToUse, metadataLineIndex, mentionsToDeleteArr, 'deleteMetadataMentionInNote') + } catch (error) { + logError('deleteMetadataMentionInNote', `${error.message}`) + } +} - const metadataLineIndex: number = getOrMakeMetadataLineIndex(noteToUse) - const metadataPara = noteToUse.paragraphs[metadataLineIndex] - if (!metadataPara) { - throw new Error(`Couldn't get or make metadataPara for ${displayTitle(noteToUse)}`) +/** + * Remove any frontmatter field that stores next-review date override. + * This clears both the configured localised key and the legacy `nextReview` key. + * @param {CoreNoteFields | TEditor} noteLike + */ +export function clearNextReviewFrontmatterField(noteLike: CoreNoteFields | TEditor): void { + try { + const noteForRemoval = getNoteFromNoteLike(noteLike) + const configuredKey = getFrontmatterFieldKeyFromMentionPreference('nextReviewMentionStr', 'nextReview') + removeFrontMatterField(noteForRemoval, configuredKey) + if (configuredKey !== 'nextReview') { + removeFrontMatterField(noteForRemoval, 'nextReview') } - const origLine: string = metadataPara.content - let newLine = origLine - - logDebug('deleteMetadataMentionInNote', `starting for '${displayTitle(noteToUse)}' with metadataLineIndex ${metadataLineIndex} to remove [${String(mentionsToDeleteArr)}]`) - - for (const mentionName of mentionsToDeleteArr) { - // logDebug('deleteMetadataMentionInNote', `Processing ${item} for ${mentionName}`) - // Start by removing all instances of this @mention - const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi') - newLine = newLine.replace(RE_THIS_MENTION_ALL, '') - logDebug('deleteMetadataMentionInNote', `-> ${newLine}`) + // For Editor paths, also clear the in-memory editor frontmatter bag so later editor writes + // don't accidentally re-introduce `nextReview` from stale attributes. + const maybeEditor: any = (noteLike: any) + if (maybeEditor.note != null) { + const currentFM = maybeEditor.frontmatterAttributes || maybeEditor.note.frontmatterAttributes || {} + const updatedFM = { ...currentFM } + delete updatedFM[configuredKey] + delete updatedFM.nextReview + maybeEditor.frontmatterAttributes = updatedFM } - - // send update to noteToUse (removing multiple and trailing spaces) - metadataPara.content = newLine.replace(/\s{2,}/g, ' ').trimRight() - noteToUse.updateParagraph(metadataPara) - logDebug('deleteMetadataMentionInNote', `- Finished`) } catch (error) { - logError('deleteMetadataMentionInNote', `${error.message}`) + logError('clearNextReviewFrontmatterField', error.message) } } /** * Update Dashboard if it is open. - * Note: Designed to fail silently if it isn't installed, or open. * It is called automatically whenever the allProjectsList is updated, regardless of which function triggers it: * - generateAllProjectsList → writeAllProjectsList → updateDashboardIfOpen * - updateProjectInAllProjectsList → writeAllProjectsList → updateDashboardIfOpen * - updateAllProjectsListAfterChange → writeAllProjectsList → updateDashboardIfOpen + * Note: Designed to fail silently if it isn't installed, or open. + * WARNING: Be careful of causing race conditions with Perspective changes in Dashboard. * @author @jgclark */ export async function updateDashboardIfOpen(): Promise { - // Finally, refresh Dashboard. Note: Designed to fail silently if it isn't installed, or open. - // WARNING: Be careful of causing race conditions with Perspective changes in Dashboard. + try { + if (!isHTMLWindowOpen(DASHBOARD_WINDOW_ID)) { + logDebug('updateDashboardIfOpen', `Dashboard not open, so won't proceed ...`) + return + } + // v2 (internal invoke plugin command) + logInfo('updateDashboardIfOpen', `About to run Dashboard:refreshSectionByCode(...)`) + // Note: This covers codes from before and after Dashboard v2.4.0.b18. TODO(Later): remove the 'PROJ' code when v2.5.0 is released + // Note: Wrap array in another array because invokePluginCommandByName spreads the array as individual arguments. This avoids only the first array item being used. + const _res = await DataStore.invokePluginCommandByName("refreshSectionsByCode", "jgclark.Dashboard", [['PROJACT', 'PROJREVIEW', 'PROJ']]) + } catch (error) { + logError('updateDashboardIfOpen', `${error.message}`) + } +} - // v2 (internal invoke plugin command) - logInfo('updateDashboardIfOpen', `About to run Dashboard:refreshSectionByCode(...)`) - // Note: This covers codes from before and after Dashboard v2.4.0.b18. TODO(Later): remove the 'PROJ' code when v2.5.0 is released - // Note: Wrap array in another array because invokePluginCommandByName spreads the array as individual arguments. This avoids only the first array item being used. - const res = await DataStore.invokePluginCommandByName("refreshSectionsByCode", "jgclark.Dashboard", [['PROJACT', 'PROJREVIEW', 'PROJ']]) +/** + * Pluralise a word based on the count. + * Note: Currently only supports English, but designed to be extended to other languages with different rule sets, by adding rulesets. + * @param {string} noun - the word to pluralise (e.g. 'task', 'item') + * @param {number | string} count - numeric string (e.g. locale-formatted) is parsed + * @returns {string} + */ +export function pluralise(noun: string, count: number | string): string { + const n = typeof count === 'number' ? count : parseInt(String(count).replace(/,/g, ''), 10) + const num = Number.isFinite(n) ? n : 0 + return num === 1 ? noun : `${noun}s` } /** * Insert a fontawesome icon in given color. - * Other styling comes from CSS for 'circle-icon' (just sets size) + * Other styling comes from CSS for 'circle-icon' (just sets size). + * Note: parameters are all generated internally, so don't need to be escaped into the HTML. * @param {string} faClasses CSS class name(s) to use for FA icons * @param {string} colorStr optional, any valid CSS color value or var(...) * @returns HTML string to insert */ export function addFAIcon(faClasses: string, colorStr: string = ''): string { if (colorStr !== '') { - return `` + return `` } else { - return `` + return `` } } diff --git a/jgclark.Reviews/src/reviews.js b/jgclark.Reviews/src/reviews.js index 3e7060786..a2d26a436 100644 --- a/jgclark.Reviews/src/reviews.js +++ b/jgclark.Reviews/src/reviews.js @@ -11,38 +11,56 @@ // It draws its data from an intermediate 'full review list' CSV file, which is (re)computed as necessary. // // by @jgclark -// Last updated 2026-02-26 for v1.3.1, @jgclark +// Last updated 2026-05-02 for v2.0.0.b29, @jgclark + @CursorAI //----------------------------------------------------------------------------- import moment from 'moment/min/moment-with-locales' import pluginJson from '../plugin.json' import { checkForWantedResources, logAvailableSharedResources, logProvidedSharedResources } from '../../np.Shared/src/index.js' import { + clearNextReviewFrontmatterField, deleteMetadataMentionInEditor, deleteMetadataMentionInNote, + getProjectMetadataLineIndex, getNextActionLineIndex, getReviewSettings, isProjectNoteIsMarkedSequential, + migrateProjectMetadataLineInEditor, + migrateProjectMetadataLineInNote, type ReviewConfig, - updateMetadataInEditor, - updateMetadataInNote, + updateBodyMetadataInEditor, + updateBodyMetadataInNote, } from './reviewHelpers' import { + copyDemoDefaultToAllProjectsList, filterAndSortProjectsList, getNextNoteToReview, getSpecificProjectFromList, generateAllProjectsList, updateProjectInAllProjectsList, } from './allProjectsListHelpers.js' -import { calcReviewFieldsForProject, Project } from './projectClass' +import { clearNextReviewMetadataFields, Project } from './projectClass' +import { calcReviewFieldsForProject } from './projectClassCalculations.js' import { - generateProjectOutputLine, - generateTopBarHTML, - generateHTMLForProjectTagSectionHeader, - generateTableStructureHTML, - generateProjectControlDialogHTML, - generateFolderHeaderHTML, + buildProjectLineForStyle, + buildProjectListTopBarHtml, + buildProjectControlDialogHtml, + buildFolderGroupHeaderHtml, } from './projectsHTMLGenerator.js' +import { + stylesheetinksInHeader, + faLinksInHeader, + checkboxHandlerJSFunc, + scrollPreLoadJSFuncs, + commsBridgeScripts, + shortcutsScript, + autoRefreshScript, + // setPercentRingJSFunc, + addToggleEvents, + displayFiltersDropdownScript, + tagTogglesVisibilityScript, + windowCloseAndReopenScripts, +} from './projectsHTMLTemplates.js' import { checkString } from '@helpers/checkType' import { getTodaysDateHyphenated, RE_DATE, RE_DATE_INTERVAL, todaysDateISOString } from '@helpers/dateTime' import { clo, JSP, logDebug, logError, logInfo, logTimer, logWarn, overrideSettingsWithEncodedTypedArgs } from '@helpers/dev' @@ -50,11 +68,16 @@ import { getFolderDisplayName, getFolderDisplayNameForHTML } from '@helpers/fold import { createRunPluginCallbackUrl, displayTitle } from '@helpers/general' import { showHTMLV2, sendToHTMLWindow } from '@helpers/HTMLView' import { numberOfOpenItemsInNote } from '@helpers/note' +import { saveSettings } from '@helpers/NPConfiguration' import { calcOffsetDateStr, nowLocaleShortDateTime } from '@helpers/NPdateTime' -import { getOrOpenEditorFromFilename, getOpenEditorFromFilename, isNoteOpenInEditor, saveEditorIfNecessary } from '@helpers/NPEditor' +import { getFirstRegularNoteAmongOpenEditors, getOrOpenEditorFromFilename, getOpenEditorFromFilename, isNoteOpenInEditor, saveEditorIfNecessary } from '@helpers/NPEditor' import { getOrMakeRegularNoteInFolder } from '@helpers/NPnote' import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' -import { isHTMLWindowOpen, logWindowsList, setEditorWindowId } from '@helpers/NPWindows' +import { + isHTMLWindowOpen, logWindowsList, + openNoteInSplitViewIfNotOpenAlready, + setEditorWindowId +} from '@helpers/NPWindows' import { encodeRFC3986URIComponent } from '@helpers/stringTransforms' import { getInputTrimmed, showMessage, showMessageYesNo } from '@helpers/userInput' @@ -62,9 +85,11 @@ import { getInputTrimmed, showMessage, showMessageYesNo } from '@helpers/userInp // Constants const pluginID = 'jgclark.Reviews' -const windowTitle = `Project Review List` -const filenameHTMLCopy = '../../jgclark.Reviews/review_list.html' +const windowTitle = `Projects List` +const windowTitleDemo = 'Projects List (Demo)' +const filenameHTMLCopy = 'projects_list.html' const customRichWinId = `${pluginID}.rich-review-list` +const customRichWinIdDemo = `${pluginID}.rich-review-list-demo` const customMarkdownWinId = `markdown-review-list` //----------------------------------------------------------------------------- @@ -72,13 +97,14 @@ const customMarkdownWinId = `markdown-review-list` //----------------------------------------------------------------------------- /** - * Tell the Project List HTML window which project is currently being reviewed. - * Adds or removes the 'reviewing' class on the matching projectRow, if the window is open. + * Tell the Project List HTML window which project is currently being reviewed (if the window is open). + * Adds or removes the 'reviewing' class on the matching projectRow. + * TODO: this is OK on 'start review' but not on 'next review'. Is it the wrong windowID? * @param {CoreNoteFields | TNote | any} note - note being reviewed - * @param {boolean} isReviewing - whether this note is now being reviewed */ -async function setReviewingProjectInHTML(note: any, isReviewing: boolean): Promise { +async function setReviewingProjectInHTML(note: any): Promise { try { + logDebug('setReviewingProjectInHTML', `Setting 'reviewing' state for note '${displayTitle(note)}' for window ${customRichWinId}`) if (!note || note.type !== 'Notes') { return } @@ -86,269 +112,110 @@ async function setReviewingProjectInHTML(note: any, isReviewing: boolean): Promi return } const encodedFilename = encodeRFC3986URIComponent(note.filename) - await sendToHTMLWindow(customRichWinId, 'SET_REVIEWING_PROJECT', { encodedFilename, isReviewing }) + await sendToHTMLWindow(customRichWinId, 'SET_REVIEWING_PROJECT', { encodedFilename }) } catch (error) { logError('setReviewingProjectInHTML', error.message) } } +/** + * Clear the 'reviewing' state from all project rows in the Project List HTML window. + * @author @jgclark + */ async function clearProjectReviewingInHTML(): Promise { try { - await sendToHTMLWindow(customRichWinId, 'CLEAR_REVIEWING_PROJECT') if (!isHTMLWindowOpen(customRichWinId)) { return } + await sendToHTMLWindow(customRichWinId, 'CLEAR_REVIEWING_PROJECT') } catch (error) { logError('clearProjectReviewingInHTML', error.message) } } -//------------------------------------------------------------------------------- -// JS scripts - -const stylesheetinksInHeader = ` - - - -` -const faLinksInHeader = ` - - - - - -` - -export const checkboxHandlerJSFunc: string = ` - -` /** - * Functions to get/set scroll position of the project list content. - * Helped by https://stackoverflow.com/questions/9377951/how-to-remember-scroll-position-and-scroll-back - * But need to find a different approach to store the position, as cookies not available. + * Render markdown and/or rich project list outputs from config.outputStyle. + * @param {ReviewConfig} config + * @param {boolean} shouldOpen + * @param {number} scrollPos + * @returns {Promise} + * @private */ -export const scrollPreLoadJSFuncs: string = ` - -` - -const commsBridgeScripts = ` - - - - - -` -/** - * Script to add some keyboard shortcuts to control the dashboard. (Meta=Cmd here.) - */ -const shortcutsScript = ` - - - -` - -export const setPercentRingJSFunc: string = ` - -` - -const addToggleEvents: string = ` - -` - -const displayFiltersDropdownScript: string = ` - -` //----------------------------------------------------------------------------- // Main functions @@ -373,9 +240,10 @@ export async function displayProjectLists(argsIn?: string | null = null, scrollP // clo(config, 'Review settings with no args:') } - // Re-calculate the allProjects list (in foreground) - await generateAllProjectsList(config, true) - + if (!(config.useDemoData ?? false)) { + // Re-calculate the allProjects list (in foreground) + await generateAllProjectsList(config, true) + } // Call the relevant rendering function with the updated config await renderProjectLists(config, true, scrollPos) } catch (error) { @@ -384,7 +252,45 @@ export async function displayProjectLists(argsIn?: string | null = null, scrollP } /** - * Internal version of above that doesn't open window if not already open. + * Demo variant of project lists. + * Reads from fixed demo JSON (copied into allProjectsList.json) without regenerating from live notes. + * @param {string? | null} argsIn as JSON (optional) + * @param {number?} scrollPos in pixels (optional, for HTML only) + */ +export async function toggleDemoModeForProjectLists(): Promise { + try { + const config = await getReviewSettings() + if (!config) throw new Error('No config found. Stopping.') + const isCurrentlyDemoMode = config.useDemoData ?? false + logInfo('toggleDemoModeForProjectLists', `Demo mode is currently ${isCurrentlyDemoMode ? 'ON' : 'off'}.`) + const willBeDemoMode = !isCurrentlyDemoMode + // Save a plain object so the value persists (loaded config may be frozen or a proxy) + const toSave = { ...config, useDemoData: willBeDemoMode } + const saved = await saveSettings(pluginJson['plugin.id'], toSave, false) + if (!saved) throw new Error('Failed to save demo mode setting.') + + if (willBeDemoMode) { + // Copy the fixed demo list into allProjectsList.json (first time after switching to demo) + const copied = await copyDemoDefaultToAllProjectsList() + if (!copied) { + throw new Error('Failed to copy demo list. Please check that allProjectsDemoListDefault.json exists in data/jgclark.Reviews, and try again.') + } + logInfo('toggleDemoModeForProjectLists', 'Demo mode is now ON; project list copied from demo default.') + } else { + // First time after switching away from demo: re-generate list from live notes + logInfo('toggleDemoModeForProjectLists', 'Demo mode now off; regenerating project list from notes.') + await generateAllProjectsList(toSave, true) + } + + // Now run the project lists display + await renderProjectLists(toSave, true) + } catch (error) { + logError('toggleDemoModeForProjectLists', JSP(error)) + } +} + +/** + * Internal version of earlier function that doesn't open window if not already open. * @param {number?} scrollPos */ export async function generateProjectListsAndRenderIfOpen(scrollPos: number = 0): Promise { @@ -393,15 +299,22 @@ export async function generateProjectListsAndRenderIfOpen(scrollPos: number = 0) if (!config) throw new Error('No config found. Stopping.') logDebug(pluginJson, `generateProjectListsAndRenderIfOpen() starting with scrollPos ${String(scrollPos)}`) - // Re-calculate the allProjects list (in foreground) - await generateAllProjectsList(config, true) - logDebug('generateProjectListsAndRenderIfOpen', `generatedAllProjectsList() called, and now will call renderProjectLists() if open`) + if (config.useDemoData ?? false) { + const copied = await copyDemoDefaultToAllProjectsList() + if (!copied) { + logWarn('generateProjectListsAndRenderIfOpen', 'Demo mode on but copy of demo list failed.') + } + } else { + // Re-calculate the allProjects list (in foreground) + await generateAllProjectsList(config, true) + logDebug('generateProjectListsAndRenderIfOpen', `generatedAllProjectsList() called, and now will call renderProjectListsIfOpen()`) + } // Call the relevant rendering function, but only continue if relevant window is open - await renderProjectLists(config, false, scrollPos) + await renderProjectListsIfOpen(config, scrollPos) return {} // just to avoid NP silently failing when called by invokePluginCommandByName } catch (error) { - logError('displayProjectLists', JSP(error)) + logError('generateProjectListsAndRenderIfOpen', JSP(error)) } } @@ -419,42 +332,39 @@ export async function renderProjectLists( ): Promise { try { const config = (configIn) ? configIn : await getReviewSettings() - - // If we want Markdown display, call the relevant function with config, but don't open up the display window unless already open. - if (config.outputStyle.match(/markdown/i)) { - // eslint-disable-next-line no-floating-promise/no-floating-promise -- no need to wait here - renderProjectListsMarkdown(config, shouldOpen) - } - if (config.outputStyle.match(/rich/i)) { - await renderProjectListsHTML(config, shouldOpen, scrollPos) + if (config == null) { + await showMessage('No Projects & Reviews settings found. Stopping. Please try deleting and re-installing the plugin.') + throw new Error('No config found. Stopping.') } + + await runProjectListRenderers(config, shouldOpen, scrollPos) } catch (error) { - logError('renderProjectLists', `Error: ${error.message}. configIn: ${JSP(configIn, 2)}`) + logError('renderProjectLists', `Error: ${error.message}.\nconfigIn: ${JSP(configIn, 2)}`) } } /** - * Render the project list, according to the chosen output style. Note: this does *not* re-calculate the project list. + * Render the project list, according to the chosen output style. This does *not* re-calculate the project list. + * Note: Called by Dashboard, as well as internally. + * @param {any} configIn (optional; will look up if not given) + * @param {number} scrollPos for HTML view (optional; defaults to 0) * @author @jgclark */ export async function renderProjectListsIfOpen( -): Promise { + configIn?: any, + scrollPos?: number = 0 +): Promise { try { - logInfo(pluginJson, `renderProjectListsIfOpen ----------------------------------------`) - const config = await getReviewSettings() + logDebug(pluginJson, `renderProjectListsIfOpen starting...`) + const config = configIn ? configIn : await getReviewSettings() - // If we want Markdown display, call the relevant function with config, but don't open up the display window unless already open. - if (config.outputStyle.match(/markdown/i)) { - // eslint-disable-next-line no-floating-promise/no-floating-promise -- no need to wait here - renderProjectListsMarkdown(config, false) - } - if (config.outputStyle.match(/rich/i)) { - await renderProjectListsHTML(config, false) - } - // return {} just to avoid possibility of NP silently failing when called by invokePluginCommandByName - return {} + if (!config) throw new Error('No config found. Stopping.') + await runProjectListRenderers(config, false, scrollPos) + // return true to avoid possibility of NP silently failing when called by invokePluginCommandByName + return true } catch (error) { logError('renderProjectListsIfOpen', error.message) + return false } } @@ -472,21 +382,23 @@ export async function renderProjectListsIfOpen( export async function renderProjectListsHTML( config: any, shouldOpen: boolean = true, - scrollPos: number = 0 + scrollPos: number = 0, ): Promise { try { + const useDemoData = config.useDemoData ?? false if (config.projectTypeTags.length === 0) { throw new Error('No projectTypeTags configured to display') } - if (!shouldOpen && !isHTMLWindowOpen(customRichWinId)) { + const richWinId = useDemoData ? customRichWinIdDemo : customRichWinId + if (!shouldOpen && !isHTMLWindowOpen(richWinId)) { logDebug('renderProjectListsHTML', `not continuing, as HTML window isn't open and 'shouldOpen' is false.`) return } const funcTimer = new moment().toDate() // use moment instead of `new Date` to ensure we get a date in the local timezone - logInfo(pluginJson, `renderProjectLists ----------------------------------------`) - logDebug('renderProjectListsHTML', `Starting for ${String(config.projectTypeTags)} tags`) + logInfo(pluginJson, `renderProjectLists ------------------------------------`) + logDebug('renderProjectListsHTML', `Starting for ${String(config.projectTypeTags)} tags${useDemoData ? ' (demo)' : ''}`) // Test to see if we have the font resources we want const res = await checkForWantedResources(pluginID) @@ -501,67 +413,100 @@ export async function renderProjectListsHTML( // Ensure projectTypeTags is an array before proceeding if (typeof config.projectTypeTags === 'string') config.projectTypeTags = [config.projectTypeTags] + // Fetch project list first so we can compute per-tag active counts for the Filters dropdown + const [projectsToReview, _countAfterTagFilterOnly] = await filterAndSortProjectsList(config, '', [], true, useDemoData) + + // Omit stale JSON entries whose note no longer exists so the top-bar count matches rendered rows + const projectsForDisplay: Array = useDemoData + ? projectsToReview + : projectsToReview.filter((p) => { + const note = DataStore.projectNoteByFilename(p.filename) + if (!note) { + logWarn('renderProjectListsHTML', `Can't find note for filename ${p.filename}; omitting from Rich list`) + } + return !!note + }) + + const wantedTags = config.projectTypeTags ?? [] + // Counts must match rows in this list (same perspective as the grid); do not strip paused/finished here - those may still be shown. + const tagActiveCounts = wantedTags.map((tag) => + projectsForDisplay.filter((p) => p.allProjectTags != null && p.allProjectTags.includes(tag)).length + ) + config.tagActiveCounts = tagActiveCounts + // String array to save all output const outputArray = [] - // Generate top bar HTML - outputArray.push(generateTopBarHTML(config)) - - // Start multi-col working (if space) - outputArray.push(`
`) + // Generate top bar HTML (uses config.tagActiveCounts for dropdown tag counts) + config.projectsShownCount = projectsForDisplay.length + outputArray.push(buildProjectListTopBarHtml(config)) logTimer('renderProjectListsHTML', funcTimer, `before main loop`) - - // Make the Summary list, for each projectTag in turn - for (const thisTag of config.projectTypeTags) { - // Get the summary line for each revelant project - const [thisSummaryLines, noteCount, due] = await generateReviewOutputLines(thisTag, 'Rich', config) - - // Generate project tag section header - outputArray.push(generateHTMLForProjectTagSectionHeader(thisTag, noteCount, due, config, config.projectTypeTags.length > 1)) - - if (noteCount > 0) { - outputArray.push(generateTableStructureHTML(config, noteCount)) - outputArray.push(thisSummaryLines.join('\n')) - outputArray.push('
') - outputArray.push(' ') // details-content div - if (config.projectTypeTags.length > 1) { - outputArray.push(``) + const noteCount = projectsForDisplay.length + if (useDemoData && noteCount === 0) { + outputArray.push('

Demo file (allProjectsDemoList.json) not found or empty.

') + } + if (noteCount > 0) { + // Start multi-col working (if space) + outputArray.push(`
`) + + let lastFolder = '' + for (const thisProject of projectsForDisplay) { + if (config.displayGroupedByFolder && lastFolder !== thisProject.folder) { + const folderPart = getGroupedFolderDisplayLabel(thisProject.folder, true, config.hideTopLevelFolder) + outputArray.push(buildFolderGroupHeaderHtml(folderPart)) } + const wantedTagsForRow = (thisProject.allProjectTags != null && wantedTags.length > 0) + ? thisProject.allProjectTags.filter(t => wantedTags.includes(t)) + : [] + outputArray.push(buildProjectLineForStyle(thisProject, config, 'Rich', wantedTagsForRow)) + lastFolder = thisProject.folder } - logTimer('renderProjectListsHTML', funcTimer, `end of loop for ${thisTag}`) + outputArray.push('
') } + logTimer('renderProjectListsHTML', funcTimer, `end single section (${noteCount} projects)`) // Generate project control dialog HTML - outputArray.push(generateProjectControlDialogHTML()) + outputArray.push(buildProjectControlDialogHtml()) const body = outputArray.join('\n') logTimer('renderProjectListsHTML', funcTimer, `end of main loop`) const setScrollPosJS: string = ` ` + const headerTags = `${faLinksInHeader}${stylesheetinksInHeader} + + ` + const winOptions = { - windowTitle: windowTitle, - customId: customRichWinId, - headerTags: `${faLinksInHeader}${stylesheetinksInHeader}\n`, + windowTitle: useDemoData ? windowTitleDemo : windowTitle, + customId: richWinId, + headerTags: headerTags, generalCSSIn: generateCSSFromTheme(config.reviewsTheme), // either use dashboard-specific theme name, or get general CSS set automatically from current theme - specificCSS: '', // now in requiredFiles/reviewListCSS instead + specificCSS: '', // now in requiredFiles/projectList.css instead makeModal: false, // = not modal window bodyOptions: 'onload="showTimeAgo()"', - preBodyScript: setPercentRingJSFunc + scrollPreLoadJSFuncs, - postBodyScript: checkboxHandlerJSFunc + setScrollPosJS + displayFiltersDropdownScript + ` + preBodyScript: /* setPercentRingJSFunc + */ scrollPreLoadJSFuncs, + postBodyScript: checkboxHandlerJSFunc + setScrollPosJS + displayFiltersDropdownScript + tagTogglesVisibilityScript + autoRefreshScript + ` - ` + commsBridgeScripts + shortcutsScript + addToggleEvents, // + collapseSection + resizeListenerScript + unloadListenerScript, + ` + commsBridgeScripts + shortcutsScript + addToggleEvents + windowCloseAndReopenScripts, // + collapseSection + resizeListenerScript + unloadListenerScript, savedFilename: filenameHTMLCopy, reuseUsersWindowRect: true, // do try to use user's position for this window, otherwise use following defaults ... - width: 800, // = default width of window (px) + width: 660, // = default width of window (px) height: 1200, // = default height of window (px) - shouldFocus: false, // shouuld not focus, if Window already exists + shouldFocus: false, // should not focus, if Window already exists // If we should open in main/split view, or the default new window showInMainWindow: config.preferredWindowType !== 'New Window', splitView: config.preferredWindowType === 'Split View', @@ -570,7 +515,7 @@ export async function renderProjectListsHTML( iconColor: pluginJson['plugin.iconColor'], autoTopPadding: true, showReloadButton: true, - reloadCommandName: 'displayProjectLists', + reloadCommandName: useDemoData ? 'displayProjectListsDemo' : 'displayProjectLists', reloadPluginID: 'jgclark.Reviews', } const thisWindow = await showHTMLV2(body, winOptions) @@ -631,10 +576,10 @@ export async function renderProjectListsMarkdown(config: any, shouldOpen: boolea if (note != null) { const refreshXCallbackURL = createRunPluginCallbackUrl('jgclark.Reviews', 'project lists', encodeURIComponent(`{"projectTypeTags":["${tag}"]}`)) - // Get the summary line for each revelant project + // Get the summary line for each relevant project const [outputArray, noteCount, due] = await generateReviewOutputLines(tag, 'Markdown', config) - logTimer('renderProjectListsHTML', funcTimer, `after generateReviewOutputLines(${tag}) for ${String(due)} projects`) - if (isNaN(noteCount)) logWarn('renderProjectListsHTML', `Warning: noteCount is NaN`) + logTimer('renderProjectListsMarkdown', funcTimer, `after generateReviewOutputLines(${tag}) for ${String(due)} projects`) + if (isNaN(noteCount)) logWarn('renderProjectListsMarkdown', `Warning: noteCount is NaN`) // print header info just the once (if any notes) const startReviewButton = `[Start reviewing ${due} ready for review](${startReviewXCallbackURL})` @@ -678,7 +623,7 @@ export async function renderProjectListsMarkdown(config: any, shouldOpen: boolea // Calculate the Summary list(s) const [outputArray, noteCount, due] = await generateReviewOutputLines('', 'Markdown', config) const startReviewButton = `[Start reviewing ${due} ready for review](${startReviewXCallbackURL})` - logTimer('renderProjectListsHTML', funcTimer, `after generateReviewOutputLines`) + logTimer('renderProjectListsMarkdown', funcTimer, `after generateReviewOutputLines`) const refreshXCallbackURL = createRunPluginCallbackUrl('jgclark.Reviews', 'project lists', '') //`noteplan://x-callback-url/runPlugin?pluginID=jgclark.Reviews&command=project%20lists&arg0=` const refreshXCallbackButton = `[🔄 Refresh](${refreshXCallbackURL})` @@ -713,7 +658,7 @@ export async function renderProjectListsMarkdown(config: any, shouldOpen: boolea } /** - * Re-display the project list from saved HTML file, if available, or if not then render the current all projects list. + * Re-display the project list from saved HTML file, if available. * Note: this is a test function that does not re-calculate the data. * @author @jgclark */ @@ -729,19 +674,19 @@ export async function redisplayProjectListHTML(): Promise { if (savedHTML !== '') { const winOptions = { windowTitle: windowTitle, - headerTags: '', // don't set as it is already in the saved file - generalCSSIn: '', // don't set as it is already in the saved file - specificCSS: '', // now provided by separate projectList.css - makeModal: false, // = not modal window - bodyOptions: '', // don't set as it is already in the saved file - preBodyScript: '', // don't set as it is already in the saved file - postBodyScript: '', // don't set as it is already in the saved file - savedFilename: '', // don't re-save it - reuseUsersWindowRect: true, // do try to use user's position for this window, otherwise use following defaults ... - width: 800, // = default width of window (px) - height: 1200, // = default height of window (px) + headerTags: '', + generalCSSIn: '', + specificCSS: '', + makeModal: false, + bodyOptions: '', + preBodyScript: '', + postBodyScript: '', + savedFilename: '', + reuseUsersWindowRect: true, + width: 800, + height: 1200, customId: customRichWinId, - shouldFocus: true, // shouuld focus + shouldFocus: true, } const _thisWindow = await showHTMLV2(savedHTML, winOptions) // clo(_thisWindow, 'created window') @@ -749,6 +694,7 @@ export async function redisplayProjectListHTML(): Promise { return } else { logWarn('redisplayProjectListHTML', `Couldn't read from saved HTML file ${filenameHTMLCopy}.`) + await showMessage('Sorry, I can\'t find the saved HTML file for Project Lists.') } } catch (error) { logError('redisplayProjectListHTML', error.message) @@ -765,7 +711,7 @@ export async function redisplayProjectListHTML(): Promise { * @param {string} projectTag - the current hashtag of interest * @param {string} style - 'Markdown' or 'Rich' * @param {ReviewConfig} config - from settings (and any passed args) - * @returns {[Array, number, number]} [output summary lines, number of notes, number of due notes (ready to review)] + * @returns {[Array, number, number]} [output summary lines, number of lines emitted (excludes missing notes), number of due notes (ready to review)] */ export async function generateReviewOutputLines(projectTag: string, style: string, config: ReviewConfig): Promise<[Array, number, number]> { try { @@ -773,14 +719,12 @@ export async function generateReviewOutputLines(projectTag: string, style: strin logDebug('generateReviewOutputLines', `Starting for tag(s) '${projectTag}' in ${style} style`) // Get all wanted projects (in useful order and filtered) - const [projectsToReview, numberProjectsUnfiltered] = await filterAndSortProjectsList(config, projectTag) + const [projectsToReview, countAfterTagFilterOnly] = await filterAndSortProjectsList(config, projectTag) let lastFolder = '' let noteCount = 0 let due = 0 const outputArray: Array = [] - // TEST: Now use numberProjectsUnfiltered by passing it up to the display. - // Process each project for (const thisProject of projectsToReview) { const thisNote = DataStore.projectNoteByFilename(thisProject.filename) @@ -789,7 +733,7 @@ export async function generateReviewOutputLines(projectTag: string, style: strin continue } // Make the output line for this project - const out = generateProjectOutputLine(thisProject, config, style) + const out = buildProjectLineForStyle(thisProject, config, style) // Add to number of notes to review (if appropriate) if (!thisProject.isPaused && thisProject.nextReviewDays != null && !isNaN(thisProject.nextReviewDays) && thisProject.nextReviewDays <= 0) { @@ -799,39 +743,10 @@ export async function generateReviewOutputLines(projectTag: string, style: strin // Write new folder header (if change of folder) const folder = thisProject.folder if (config.displayGroupedByFolder && lastFolder !== folder) { - // Get display name with teamspace name if applicable - const folderDisplayName = ((style.match(/rich/i)) - ? getFolderDisplayNameForHTML(folder) - : getFolderDisplayName(folder, true)) - let folderPart: string - if (config.hideTopLevelFolder) { - // Extract just the last part of the folder path - // Handle teamspace format: [👥 TeamspaceName] /folder/path -> [👥 TeamspaceName] path - // Handle regular format: folder/path -> path - if (folderDisplayName.includes(']')) { - // Teamspace folder: extract prefix and last part of path - const match = folderDisplayName.match(/^(\[.*?\])\s*(.+)$/) - if (match) { - const teamspacePrefix = match[1] - const pathPart = match[2] - const pathParts = pathPart.split('/').filter(p => p !== '') - const lastPart = pathParts.length > 0 ? pathParts[pathParts.length - 1] : pathPart - folderPart = `${teamspacePrefix} ${lastPart}` - } else { - folderPart = folderDisplayName.split('/').slice(-1)[0] || folderDisplayName - } - } else { - // Regular folder: just get last part - const pathParts = folderDisplayName.split('/').filter(p => p !== '') - folderPart = pathParts.length > 0 ? pathParts[pathParts.length - 1] : folderDisplayName - } - } else { - folderPart = folderDisplayName - } - // Handle root folder display - check if original folder was root, not the display name - if (folder === '/') folderPart = '(root folder)' + const isRichStyle = style.match(/rich/i) != null + const folderPart = getGroupedFolderDisplayLabel(folder, isRichStyle, config.hideTopLevelFolder) if (style.match(/rich/i)) { - outputArray.push(generateFolderHeaderHTML(folderPart, config)) + outputArray.push(buildFolderGroupHeaderHtml(folderPart)) } else if (style.match(/markdown/i)) { outputArray.push(`### ${folderPart}`) } @@ -842,8 +757,12 @@ export async function generateReviewOutputLines(projectTag: string, style: strin lastFolder = folder } - logTimer('generateReviewOutputLines', startTime, `Generated for ${String(noteCount)} notes (and ${numberProjectsUnfiltered} unfiltered) for tag(s) '${projectTag}' in ${style} style`) - return [outputArray, numberProjectsUnfiltered, due] + logTimer( + 'generateReviewOutputLines', + startTime, + `Generated ${String(noteCount)} lines for tag(s) '${projectTag}' in ${style} style (${String(countAfterTagFilterOnly)} after tag filter, before missing-note skips)`, + ) + return [outputArray, noteCount, due] } catch (error) { logError('generateReviewOutputLines', `${error.message}`) return [[], NaN, NaN] // for completeness @@ -856,9 +775,9 @@ export async function generateReviewOutputLines(projectTag: string, style: strin * Finish a project review -- private core logic used by 2 functions. * @param (CoreNoteFields) note - The note to finish */ -async function finishReviewCoreLogic(note: CoreNoteFields): Promise { +async function finishReviewCoreLogic(note: CoreNoteFields, scrollPos: number = 0): Promise { try { - const config: ReviewConfig = await getReviewSettings() + const config: ?ReviewConfig = await getReviewSettings() if (!config) throw new Error('No config found. Stopping.') const reviewedMentionStr = checkString(DataStore.preference('reviewedMentionStr')) @@ -882,32 +801,70 @@ async function finishReviewCoreLogic(note: CoreNoteFields): Promise { } } } + // For sequential projects, just make a log note if there are no open tasks if (isSequential && numOpenItems === 0) { logDebug('finishReviewCoreLogic', `Note: no open tasks found for sequential project '${displayTitle(note)}'.`) } const possibleThisEditor = getOpenEditorFromFilename(note.filename) - if (possibleThisEditor) { - logDebug('finishReviewCoreLogic', `Updating Editor '${displayTitle(possibleThisEditor)}' ...`) - // First update @review(date) on current open note - updateMetadataInEditor(possibleThisEditor, [reviewedTodayString]) - // Remove a @nextReview(date) if there is one, as that is used to skip a review, which is now done. - deleteMetadataMentionInEditor(possibleThisEditor, [config.nextReviewMentionStr]) + if (possibleThisEditor && possibleThisEditor !== false) { + const thisEditorNote: ?CoreNoteFields = possibleThisEditor.note + if (!thisEditorNote) { + logDebug('finishReviewCoreLogic', `No editor note found for '${displayTitle(note)}'; falling back to datastore note update path.`) + migrateProjectMetadataLineInNote(note) + const metadataLineIndex = getProjectMetadataLineIndex(note) + if (metadataLineIndex === false) { + logDebug('finishReviewCoreLogic', `No project metadata line found (body or frontmatter) for '${displayTitle(note)}'`) + } else { + deleteMetadataMentionInNote(note, metadataLineIndex, [config.nextReviewMentionStr]) + } + clearNextReviewFrontmatterField(note) + updateBodyMetadataInNote(note, [reviewedTodayString]) + // $FlowIgnore[prop-missing] + DataStore.updateCache(note, true) + return + } + logDebug('finishReviewCoreLogic', `Updating EDITOR note '${displayTitle(thisEditorNote)}' ...`) + // If project metadata is in frontmatter, replace any body metadata line with migration message (or remove that message) + // before we recalculate the metadata line index and update mentions. This ensures that when both frontmatter and + // body metadata are present, we first migrate/merge them and then clean up @nextReview/@reviewed mentions once. + // FIXME: The following 3 calls get "Warning: The editor is not open! 'Editor' values will be undefined and functions not working. Open a note to fix this." errors + migrateProjectMetadataLineInEditor(possibleThisEditor) + const metadataLineIndex = getProjectMetadataLineIndex(possibleThisEditor) + if (metadataLineIndex === false) { + logDebug('finishReviewCoreLogic', `No project metadata line found (body or frontmatter) for '${displayTitle(thisEditorNote)}'`) + } else { + // Remove a @nextReview(date) if there is one, as that is used to skip a review, which is now done. + deleteMetadataMentionInEditor(possibleThisEditor, metadataLineIndex, [config.nextReviewMentionStr]) + } + clearNextReviewFrontmatterField(possibleThisEditor) + // Update @review(date) on current open note + updateBodyMetadataInEditor(possibleThisEditor, [reviewedTodayString]) await possibleThisEditor.save() // Note: no longer seem to need to update cache } else { logDebug('finishReviewCoreLogic', `Updating note '${displayTitle(note)}' ...`) - // First update @review(date) on the note - updateMetadataInNote(note, [reviewedTodayString]) - // Remove a @nextReview(date) if there is one, as that is used to skip a review, which is now done. - deleteMetadataMentionInNote(note, [config.nextReviewMentionStr]) + // If project metadata is in frontmatter, replace any body metadata line with migration message (or remove that message) + // before we recalculate the metadata line index and update mentions. This ensures that when both frontmatter and + // body metadata are present, we first migrate/merge them and then clean up @nextReview/@reviewed mentions once. + migrateProjectMetadataLineInNote(note) + const metadataLineIndex = getProjectMetadataLineIndex(note) + if (metadataLineIndex === false) { + logDebug('finishReviewCoreLogic', `No project metadata line found (body or frontmatter) for '${displayTitle(note)}'`) + } else { + // Remove a @nextReview(date) if there is one, as that is used to skip a review, which is now done. + deleteMetadataMentionInNote(note, metadataLineIndex, [config.nextReviewMentionStr]) + } + clearNextReviewFrontmatterField(note) + // Update @review(date) on the note + updateBodyMetadataInNote(note, [reviewedTodayString]) // $FlowIgnore[prop-missing] DataStore.updateCache(note, true) } - logDebug('finishReviewCoreLogic', `- done`) // Then update the Project instance + logDebug('finishReviewCoreLogic', `- updating Project instance`) // v1: // const thisNoteAsProject = new Project(noteToUse) // v2: Try to find this project in allProjects, and update that as well @@ -924,20 +881,24 @@ async function finishReviewCoreLogic(note: CoreNoteFields): Promise { logDebug('finishReviewCoreLogic', `- PI now shows next review due in ${String(thisNoteAsProject.nextReviewDays)} days (${String(thisNoteAsProject.nextReviewDateStr)})`) } + // Clear next-review fields on the project list entry TEST: + clearNextReviewMetadataFields(thisNoteAsProject) + // Save changes to allProjects list await updateProjectInAllProjectsList(thisNoteAsProject) - // Update display for user (but don't open if it isn't already) - await renderProjectLists(config, false) + // Update display for user (if window is already open) + await renderProjectListsIfOpen(config, scrollPos) } else { // Regenerate whole list (and display if window is already open) logInfo('finishReviewCoreLogic', `- In allProjects list couldn't find project '${note.filename}'. So regenerating whole list and will display if list is open.`) - await generateProjectListsAndRenderIfOpen() + // TODO: Split the following into just generate...(), and then move the renderProjectListsIfOpen() above to serve both if/else clauses + await generateProjectListsAndRenderIfOpen(scrollPos) } // Ensure the Project List window (if open) no longer shows this project as being actively reviewed await clearProjectReviewingInHTML() - logDebug('finishReviewCoreLogic', `- finished successfully`) + logDebug('finishReviewCoreLogic', `- done`) } catch (error) { logError('finishReviewCoreLogic', error.message) @@ -946,6 +907,60 @@ async function finishReviewCoreLogic(note: CoreNoteFields): Promise { // -------------------------------------------------------------------- +/** + * Core of the logic for starting a project review: optionally confirm with user, open note in Editor, highlight as active review in Project List HTML. + * @param {TNote} noteToReview + * @param {ReviewConfig} config + * @param {boolean} offerConfirm - If true and config.confirmNextReview, prompt before opening (startReviews / finish-and-next). If false, open immediately (startReviewForNote). + * @param {string} logContext - Log tag (e.g. startReviews, startReviewForNote, finishReviewAndStartNextReview) + * @returns {Promise} true if the note was opened, false if user cancelled confirmation + * @private + */ +async function startReviewCoreLogic( + noteToReview: TNote, + config: ReviewConfig, + offerConfirm: boolean, + logContext: string, +): Promise { + if (offerConfirm && config.confirmNextReview) { + const res = await showMessageYesNo(`Ready to review '${displayTitle(noteToReview)}'?`, ['OK', 'Cancel']) + if (res !== 'OK') { + logDebug(logContext, `- User didn't want to continue.`) + return false + } + } + + // Show that this project is now being reviewed, if the 'Rich' Project List is open + logInfo(logContext, `🔍 Opening '${displayTitle(noteToReview)}' note to review ...`) + await setReviewingProjectInHTML(noteToReview) + + // Check if note is already open in one of the Editor windows: + // - If so, just focus it. + // - Otherwise open it in the Editor (if running from 'New Window' or 'Split View' mode), or a new split view if not. + // V1 + // const possibleEditor: TEditor | false = findEditorWindowByFilename(noteToReview.filename) + // etc. + // V2 + if (config.preferredWindowType === 'Main Window') { + // Open in split view + const res = openNoteInSplitViewIfNotOpenAlready(noteToReview.filename) + if (res) { + logInfo(logContext, `- Note '${displayTitle(noteToReview)}' was opened in a new split view.`) + } else { + logInfo(logContext, `- Note '${displayTitle(noteToReview)}' was already open in an Editor window. Focusing it.`) + } + } else { + // Open in main Editor window + const openedNote = await Editor.openNoteByFilename(noteToReview.filename) + if (openedNote) { + logInfo(logContext, `- Note '${displayTitle(noteToReview)}' was opened in the main Editor.`) + } else { + logWarn(logContext, `- Note '${displayTitle(noteToReview)}' couldn't be opened in the main Editor window.`) + } + } + return true +} + /** * Start a series of project reviews.. * Then offers to load the first note to review, based on allProjectsList, ordered by most overdue for review. @@ -954,29 +969,18 @@ async function finishReviewCoreLogic(note: CoreNoteFields): Promise { */ export async function startReviews(): Promise { try { - const config: ReviewConfig = await getReviewSettings() + const config: ?ReviewConfig = await getReviewSettings() if (!config) throw new Error('No config found. Stopping.') // Get the next note to review, based on allProjectsList, ordered by most overdue for review. - const noteToReview = await getNextNoteToReview() - // Open that note in an Editor, confirming with the user if necessary. + const noteToReview: ?TNote = await getNextNoteToReview() if (!noteToReview) { logInfo('startReviews', '🎉 No notes to review!') await showMessage('🎉 No notes to review!', 'Great', 'Reviews') return + } else { + await startReviewCoreLogic(noteToReview, config, true, 'startReviews') } - - if (config.confirmNextReview) { - const res = await showMessageYesNo(`Ready to review '${displayTitle(noteToReview)}'?`, ['OK', 'Cancel']) - if (res !== 'OK') { - logDebug('startReviews', `- User didn't want to continue.`) - return - } - } - logInfo('startReviews', `🔍 Opening '${displayTitle(noteToReview)}' note to review ...`) - await Editor.openNoteByFilename(noteToReview.filename) - // Highlight this project in the Project List window (if open) - await setReviewingProjectInHTML(noteToReview, true) } catch (error) { logError('startReviews', error.message) } @@ -984,22 +988,22 @@ export async function startReviews(): Promise { /** * Start a single project review. - * Note: Used by Project List dialog (and Dashboard in future?) + * Note: Used by Project List dialog (and Dashboard in future?). So bypasses startReviewCoreLogic() but should remain very similar. * @param {TNote} noteToReview - the note to start reviewing * @author @jgclark */ export async function startReviewForNote(noteToReview: TNote): Promise { try { - const config: ReviewConfig = await getReviewSettings() + const config: ?ReviewConfig = await getReviewSettings() if (!config) throw new Error('No config found. Stopping.') - logInfo('startReviews', `🔍 Opening '${displayTitle(noteToReview)}' note to review ...`) + logInfo('startReviewForNote', `🔍 Opening '${displayTitle(noteToReview)}' note to review ...`) await Editor.openNoteByFilename(noteToReview.filename) // Highlight this project in the Project List window (if open) - await setReviewingProjectInHTML(noteToReview, true) + await setReviewingProjectInHTML(noteToReview) } catch (error) { - logError('startReviews', error.message) + logError('startReviewForNote', error.message) } } @@ -1024,14 +1028,15 @@ export async function nextReview(): Promise { */ export async function finishReview(): Promise { try { - const currentNote = Editor // note: not Editor.note - if (currentNote && currentNote.type === 'Notes') { - logInfo('finishReview', `Starting with Editor '${displayTitle(currentNote)}'`) - await finishReviewCoreLogic(currentNote) - } else { - logWarn('finishReview', `- There's no project note in the Editor to finish reviewing.`) - await showMessage(`The current Editor note doesn't contain a project note to finish reviewing.`, 'OK, thanks', 'Reviews') + // Prefer focused Editor when it is a project note; otherwise any open split with a regular note (calendar may have focus). + const currentNote = getFirstRegularNoteAmongOpenEditors() + if (!currentNote) { + logWarn('finishReview', `- There's no project note in any open Editor pane to finish reviewing.`) + await showMessage(`No open editor pane has a project note to finish reviewing. Open the project note (or focus it) and try again.`, 'OK, thanks', 'Reviews') + return } + logInfo('finishReview', `Starting with Editor note '${displayTitle(currentNote)}'`) + await finishReviewCoreLogic(currentNote) } catch (error) { logError('finishReview', error.message) } @@ -1043,7 +1048,7 @@ export async function finishReview(): Promise { * @author @jgclark * @param {TNote} noteIn */ -export async function finishReviewForNote(noteToUse: TNote): Promise { +export async function finishReviewForNote(noteToUse: TNote, scrollPos: number = 0): Promise { try { if (!noteToUse || noteToUse.type !== 'Notes') { logWarn('finishReviewForNote', `- Not passed a valid project note to finish reviewing. Stopping.`) @@ -1051,7 +1056,7 @@ export async function finishReviewForNote(noteToUse: TNote): Promise { } logInfo('finishReviewForNote', `Starting for passed note '${displayTitle(noteToUse)}'`) - await finishReviewCoreLogic(noteToUse) + await finishReviewCoreLogic(noteToUse, scrollPos) } catch (error) { logError('finishReviewForNote', error.message) @@ -1066,7 +1071,7 @@ export async function finishReviewForNote(noteToUse: TNote): Promise { export async function finishReviewAndStartNextReview(): Promise { try { logDebug('finishReviewAndStartNextReview', `Starting`) - const config: ReviewConfig = await getReviewSettings() + const config: ?ReviewConfig = await getReviewSettings() if (!config) throw new Error('No config found. Stopping.') // Finish review of the current project @@ -1075,22 +1080,12 @@ export async function finishReviewAndStartNextReview(): Promise { // Read review list to work out what's the next one to review const noteToReview: ?TNote = await getNextNoteToReview() - if (noteToReview != null) { - logDebug('finishReviewAndStartNextReview', `- Opening '${displayTitle(noteToReview)}' as nextReview note ...`) - if (config.confirmNextReview) { - // Check whether to open that note in editor - const res = await showMessageYesNo(`Ready to review '${displayTitle(noteToReview)}'?`, ['OK', 'Cancel']) - if (res !== 'OK') { - return - } - } - await Editor.openNoteByFilename(noteToReview.filename) - - // Highlight this as the newly active review in the Project List window (if open) - await setReviewingProjectInHTML(noteToReview, true) - } else { + if (!noteToReview) { logInfo('finishReviewAndStartNextReview', `- 🎉 No more notes to review!`) await showMessage('🎉 No notes to review!', 'Great', 'Reviews') + } else { + logDebug('finishReviewAndStartNextReview', `- Opening '${displayTitle(noteToReview)}' as nextReview note ...`) + await startReviewCoreLogic(noteToReview, config, true, 'finishReviewAndStartNextReview') } } catch (error) { logError('finishReviewAndStartNextReview', error.message) @@ -1105,11 +1100,11 @@ export async function finishReviewAndStartNextReview(): Promise { * @param (CoreNoteFields) note * @param (string?) skipIntervalOrDate (optional) */ -async function skipReviewCoreLogic(note: CoreNoteFields, skipIntervalOrDate: string = ''): Promise { +async function skipReviewCoreLogic(note: CoreNoteFields, skipIntervalOrDate: string = '', scrollPos: number = 0): Promise { try { - const config: ReviewConfig = await getReviewSettings() - if (!config) throw new Error('No config found. Stopping.') - logDebug('skipReviewForNote', `Starting for note '${displayTitle(note)}' with ${skipIntervalOrDate}`) + const config: ?ReviewConfig = await getReviewSettings() + if (config == null) throw new Error('No config found. Stopping.') + logDebug('skipReviewCoreLogic', `Starting for note '${displayTitle(note)}' with ${skipIntervalOrDate}`) let newDateStr: string = '' // Calculate new date from param 'skipIntervalOrDate' (if given) or ask user @@ -1121,7 +1116,7 @@ async function skipReviewCoreLogic(note: CoreNoteFields, skipIntervalOrDate: str ? skipIntervalOrDate : '' if (newDateStr === '') { - logWarn('skipReviewForNote', `${skipIntervalOrDate} is not a valid interval, so will stop.`) + logWarn('skipReviewCoreLogic', `${skipIntervalOrDate} is not a valid interval, so will stop.`) return } } @@ -1149,18 +1144,27 @@ async function skipReviewCoreLogic(note: CoreNoteFields, skipIntervalOrDate: str const possibleThisEditor = getOpenEditorFromFilename(note.filename) if (possibleThisEditor) { + // If project metadata is in frontmatter, replace any body metadata line with migration message (or remove that message) + // before we recalculate the metadata line index and update mentions. This ensures that when both frontmatter and + // body metadata are present, we first migrate/merge them and then update @nextReview() in the canonical place. + migrateProjectMetadataLineInEditor(possibleThisEditor) + // Update metadata in the current open note logDebug('skipReviewCoreLogic', `Updating Editor ...`) - updateMetadataInEditor(possibleThisEditor, [nextReviewMetadataStr]) + updateBodyMetadataInEditor(possibleThisEditor, [nextReviewMetadataStr]) // Save Editor, so the latest changes can be picked up elsewhere // Putting the Editor.save() here, rather than in the above functions, seems to work await saveEditorIfNecessary() logDebug('skipReviewCoreLogic', `- done`) } else { + // If project metadata is in frontmatter, replace any body metadata line with migration message (or remove that message) + // before we recalculate the metadata line index and update mentions. + migrateProjectMetadataLineInNote(note) + // add/update metadata on the note logDebug('skipReviewCoreLogic', `Updating note ...`) - updateMetadataInNote(note, [nextReviewMetadataStr]) + updateBodyMetadataInNote(note, [nextReviewMetadataStr]) } logDebug('skipReviewCoreLogic', `- done`) @@ -1179,11 +1183,11 @@ async function skipReviewCoreLogic(note: CoreNoteFields, skipIntervalOrDate: str // Write changes to allProjects list await updateProjectInAllProjectsList(thisNoteAsProject) // Update display for user (but don't open window if not open already) - await renderProjectLists(config, false) + await renderProjectListsIfOpen(config, scrollPos) } else { - // Regenerate whole list (and display if window is already open) + // Regenerate whole list (and display if window is already open) logWarn('skipReviewCoreLogic', `- Couldn't find project '${note.filename}' in allProjects list. So regenerating whole list and display.`) - await generateProjectListsAndRenderIfOpen() + await generateProjectListsAndRenderIfOpen(scrollPos) } } catch (error) { @@ -1198,7 +1202,7 @@ async function skipReviewCoreLogic(note: CoreNoteFields, skipIntervalOrDate: str */ export async function skipReview(): Promise { try { - const config: ReviewConfig = await getReviewSettings() + const config: ?ReviewConfig = await getReviewSettings() if (!config) throw new Error('No config found. Stopping.') const currentNote = Editor if (!currentNote || currentNote.type !== 'Notes') { @@ -1212,7 +1216,12 @@ export async function skipReview(): Promise { // Then move to nextReview // Read review list to work out what's the next one to review const noteToReview: ?TNote = await getNextNoteToReview() - if (noteToReview != null) { + if (!noteToReview) { + logInfo('skipReview', `- 🎉 No more notes to review!`) + await showMessage('🎉 No notes to review!', 'Great', 'Reviews') + return + } + else { if (config.confirmNextReview) { // Check whether to open that note in editor const res = await showMessageYesNo(`Ready to review '${displayTitle(noteToReview)}'?`, ['OK', 'Cancel']) @@ -1222,9 +1231,6 @@ export async function skipReview(): Promise { } logDebug('skipReview', `- opening '${displayTitle(noteToReview)}' as next note ...`) await Editor.openNoteByFilename(noteToReview.filename) - } else { - logInfo('skipReview', `- 🎉 No more notes to review!`) - await showMessage('🎉 No notes to review!', 'Great', 'Reviews') } } catch (error) { logError('skipReview', error.message) @@ -1236,16 +1242,17 @@ export async function skipReview(): Promise { * Note: skipReview() is an interactive version of this for Editor.note * @author @jgclark */ -export async function skipReviewForNote(note: TNote, skipIntervalOrDate: string): Promise { +export async function skipReviewForNote(note: TNote, skipIntervalOrDate: string, scrollPos: number = 0): Promise { try { - const config: ReviewConfig = await getReviewSettings() + const config: ?ReviewConfig = await getReviewSettings() if (!config) throw new Error('No config found. Stopping.') if (!note || note.type !== 'Notes') { logWarn('skipReviewForNote', `- There's no project note in the Editor to finish reviewing, so will just go to next review.`) + return } logDebug('skipReviewForNote', `Starting for note '${displayTitle(note)}' with ${skipIntervalOrDate}`) - await skipReviewCoreLogic(note, skipIntervalOrDate) + await skipReviewCoreLogic(note, skipIntervalOrDate, scrollPos) } catch (error) { logError('skipReviewForNote', error.message) @@ -1260,10 +1267,10 @@ export async function skipReviewForNote(note: TNote, skipIntervalOrDate: string) * @author @jgclark * @param {TNote?} noteArg */ -export async function setNewReviewInterval(noteArg?: TNote): Promise { +export async function setNewReviewInterval(noteArg?: TNote, scrollPos: number = 0): Promise { try { - const config: ReviewConfig = await getReviewSettings() - if (!config) throw new Error('No config found. Stopping.') + const config: ?ReviewConfig = await getReviewSettings() + if (config == null) throw new Error('No config found. Stopping.') logDebug('setNewReviewInterval', `Starting for ${noteArg ? 'passed note (' + noteArg.filename + ')' : 'Editor'}`) const note: CoreNoteFields = noteArg ? noteArg : Editor if (!note || note.type !== 'Notes') { @@ -1291,10 +1298,13 @@ export async function setNewReviewInterval(noteArg?: TNote): Promise { logDebug('setNewReviewInterval', `Updating metadata in Editor`) const possibleThisEditor = getOpenEditorFromFilename(note.filename) if (possibleThisEditor) { - updateMetadataInEditor(possibleThisEditor, [`@review(${newIntervalStr})`]) + // Ensure any legacy body metadata is migrated into frontmatter before updating @review() + migrateProjectMetadataLineInEditor(possibleThisEditor) + updateBodyMetadataInEditor(possibleThisEditor, [`@review(${newIntervalStr})`]) } else { logDebug('setNewReviewInterval', `- Couldn't find open Editor for note '${note.filename}', so will update note directly.`) - updateMetadataInNote(note, [`@review(${newIntervalStr})`]) + migrateProjectMetadataLineInNote(note) + updateBodyMetadataInNote(note, [`@review(${newIntervalStr})`]) } // Save Editor, so the latest changes can be picked up elsewhere // Putting the Editor.save() here, rather than in the above functions, seems to work @@ -1302,7 +1312,8 @@ export async function setNewReviewInterval(noteArg?: TNote): Promise { } else { // update metadata on the note logDebug('setNewReviewInterval', `Updating metadata in note`) - updateMetadataInNote(note, [`@review(${newIntervalStr})`]) + migrateProjectMetadataLineInNote(note) + updateBodyMetadataInNote(note, [`@review(${newIntervalStr})`]) } logDebug('setNewReviewInterval', `- done`) @@ -1322,7 +1333,7 @@ export async function setNewReviewInterval(noteArg?: TNote): Promise { // Write changes to allProjects list await updateProjectInAllProjectsList(thisNoteAsProject) // Update display for user (but don't focus) - await renderProjectLists(config, false) + await renderProjectListsIfOpen(config, scrollPos) } } catch (error) { logError('setNewReviewInterval', error.message) @@ -1332,28 +1343,13 @@ export async function setNewReviewInterval(noteArg?: TNote): Promise { //------------------------------------------------------------------------------- /** - * Toggle displayFinished setting, held as a NP preference, as it is shared between frontend and backend + * Toggle displayFinished setting, held as a setting in the `settings.json` file. */ -export async function toggleDisplayFinished(): Promise { +export async function toggleDisplayFinished(scrollPos: number = 0): Promise { try { // v1 used NP Preference mechanism, but not ideal as it can't be used from frontend // v2 directly update settings.json instead - const config: ReviewConfig = await getReviewSettings() - const savedValue = config.displayFinished ?? 'hide' - // const newValue = (savedValue === 'display') - // ? 'display at end' - // : (savedValue === 'display at end') - // ? 'hide' - // : 'display' - const newValue = !savedValue - logDebug('toggleDisplayOnlyDue', `displayOnlyDue? now '${String(newValue)}' (was '${String(savedValue)}')`) - - const updatedConfig = config - updatedConfig.displayFinished = newValue - // logDebug('toggleDisplayFinished', `updatedConfig.displayFinished? now is '${String(updatedConfig.displayFinished)}'`) - const res = await DataStore.saveJSON(updatedConfig, '../jgclark.Reviews/settings.json', true) - // clo(updatedConfig, 'updatedConfig at end of toggle...()') - await renderProjectLists(updatedConfig, false) + await toggleDisplayFilterKey('displayFinished', true, 'toggleDisplayFinished', scrollPos) } catch (error) { logError('toggleDisplayFinished', error.message) @@ -1361,22 +1357,13 @@ export async function toggleDisplayFinished(): Promise { } /** - * Toggle displayFinished setting, held as a NP preference, as it is shared between frontend and backend + * Toggle displayOnlyDue setting, held as a setting in the `settings.json` file. */ -export async function toggleDisplayOnlyDue(): Promise { +export async function toggleDisplayOnlyDue(scrollPos: number = 0): Promise { try { // v1 used NP Preference mechanism, but not ideal as it can't be used from frontend // v2 directly update settings.json instead - const config: ReviewConfig = await getReviewSettings() - const savedValue = config.displayOnlyDue ?? true - const newValue = !savedValue - logDebug('toggleDisplayOnlyDue', `displayOnlyDue? now '${String(newValue)}' (was '${String(savedValue)}')`) - const updatedConfig = config - updatedConfig.displayOnlyDue = newValue - // logDebug('toggleDisplayOnlyDue', `updatedConfig.displayOnlyDue? now is '${String(updatedConfig.displayOnlyDue)}'`) - const res = await DataStore.saveJSON(updatedConfig, '../jgclark.Reviews/settings.json', true) - // clo(updatedConfig, 'updatedConfig at end of toggle...()') - await renderProjectLists(updatedConfig, false) + await toggleDisplayFilterKey('displayOnlyDue', true, 'toggleDisplayOnlyDue', scrollPos) } catch (error) { logError('toggleDisplayOnlyDue', error.message) @@ -1384,21 +1371,12 @@ export async function toggleDisplayOnlyDue(): Promise { } /** - * Toggle displayNextActions setting, held as a NP preference, as it is shared between frontend and backend + * Toggle displayNextActions setting, held as a setting in the `settings.json` file. */ -export async function toggleDisplayNextActions(): Promise { +export async function toggleDisplayNextActions(scrollPos: number = 0): Promise { try { // v2 directly update settings.json - const config: ReviewConfig = await getReviewSettings() - const savedValue = config.displayNextActions ?? false - const newValue = !savedValue - logDebug('toggleDisplayNextActions', `displayNextActions? now '${String(newValue)}' (was '${String(savedValue)}')`) - const updatedConfig = config - updatedConfig.displayNextActions = newValue - // logDebug('toggleDisplayNextActions', `updatedConfig.displayNextActions? now is '${String(updatedConfig.displayNextActions)}'`) - const res = await DataStore.saveJSON(updatedConfig, '../jgclark.Reviews/settings.json', true) - // clo(updatedConfig, 'updatedConfig at end of toggle...()') - await renderProjectLists(updatedConfig, false) + await toggleDisplayFilterKey('displayNextActions', false, 'toggleDisplayNextActions', scrollPos) } catch (error) { logError('toggleDisplayNextActions', error.message) @@ -1407,22 +1385,28 @@ export async function toggleDisplayNextActions(): Promise { /** * Save all display filter settings at once (used by Display filters dropdown). - * @param {{ displayOnlyDue: boolean, displayFinished: boolean, displayPaused: boolean, displayNextActions: boolean }} data + * @param {{ displayOnlyDue: boolean, displayFinished: boolean, displayPaused: boolean, displayNextActions: boolean, displayOrder?: string }} data */ export async function saveDisplayFilters(data: { displayOnlyDue: boolean, displayFinished: boolean, displayPaused: boolean, displayNextActions: boolean, -}): Promise { + displayOrder?: string, +}, scrollPos: number = 0): Promise { try { - const config: ReviewConfig = await getReviewSettings() + const config: ?ReviewConfig = await getReviewSettings() + if (!config) throw new Error('No config found. Stopping.') + config.displayOnlyDue = data.displayOnlyDue config.displayFinished = data.displayFinished config.displayPaused = data.displayPaused config.displayNextActions = data.displayNextActions + if (typeof data.displayOrder === 'string' && data.displayOrder !== '') { + config.displayOrder = data.displayOrder + } await DataStore.saveJSON(config, '../jgclark.Reviews/settings.json', true) - await renderProjectLists(config, false) + await renderProjectListsIfOpen(config, scrollPos) } catch (error) { logError('saveDisplayFilters', error.message) } diff --git a/jgclark.Reviews/topbar-narrower-2.0b.png b/jgclark.Reviews/topbar-narrower-2.0b.png new file mode 100644 index 000000000..84f209842 Binary files /dev/null and b/jgclark.Reviews/topbar-narrower-2.0b.png differ diff --git a/jgclark.Reviews/topbar-review-controls-2.0b.png b/jgclark.Reviews/topbar-review-controls-2.0b.png new file mode 100644 index 000000000..8063cc4da Binary files /dev/null and b/jgclark.Reviews/topbar-review-controls-2.0b.png differ diff --git a/jgclark.Reviews/topbar-wider-2.0b.png b/jgclark.Reviews/topbar-wider-2.0b.png new file mode 100644 index 000000000..8f2c81aae Binary files /dev/null and b/jgclark.Reviews/topbar-wider-2.0b.png differ diff --git a/jgclark.Reviews/webfonts/fa-duotone-900.woff2 b/jgclark.Reviews/webfonts/fa-duotone-900.woff2 deleted file mode 100644 index 3f214a047..000000000 Binary files a/jgclark.Reviews/webfonts/fa-duotone-900.woff2 and /dev/null differ diff --git a/jgclark.Reviews/webfonts/fa-regular-400.woff2 b/jgclark.Reviews/webfonts/fa-regular-400.woff2 deleted file mode 100644 index f08e2a2f5..000000000 Binary files a/jgclark.Reviews/webfonts/fa-regular-400.woff2 and /dev/null differ diff --git a/jgclark.Reviews/webfonts/fa-solid-900.woff2 b/jgclark.Reviews/webfonts/fa-solid-900.woff2 deleted file mode 100644 index d75f8f7f4..000000000 Binary files a/jgclark.Reviews/webfonts/fa-solid-900.woff2 and /dev/null differ