diff --git a/helpers/HTMLView.js b/helpers/HTMLView.js index a72f26dae..1176f64d0 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' @@ -716,7 +716,10 @@ 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`) - const windowIdToSend = windowExists ? windowId : undefined // for iphone/ipad you have to send undefined + // runJavaScript expects the window's internal id; resolve customId to actual id when present + // TEST: this change identified by Cursor + // TEST: Not sure the comment about iphone/ipad is still relevant, but leaving it in for now. + const windowIdToSend = windowExists ? (getWindowIdFromCustomId(windowId) || windowId) : undefined // for iphone/ipad you have to send undefined const dataWithUpdated = { ...data, diff --git a/helpers/NPFrontMatter.js b/helpers/NPFrontMatter.js index e90f9ff6e..c0cd6345a 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 @@ -572,7 +578,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 +596,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 +989,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 +1020,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 +1059,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 +1067,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 +1094,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 +1137,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/NPdateTime.js b/helpers/NPdateTime.js index 077c5cb75..f87fc87e8 100644 --- a/helpers/NPdateTime.js +++ b/helpers/NPdateTime.js @@ -1143,7 +1143,7 @@ export function getRelativeDates(useISODailyDates: boolean = false): Array<{ rel const relativeDates = [] const todayMom = moment() - logInfo('NPdateTime::getRelativeDates', `Starting, with DataStore: ${typeof DataStore}`) + // logInfo('NPdateTime::getRelativeDates', `Starting, with DataStore: ${typeof DataStore}`) if (!DataStore || typeof DataStore !== 'object') { // A further test for DataStore.calendarNoteByDateString, as that can sometimes fail even when DataStore is available if (!DataStore.calendarNoteByDateString) { 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..deddd0995 100644 --- a/helpers/__tests__/NPFrontMatter/NPFrontMatterManipulation.test.js +++ b/helpers/__tests__/NPFrontMatter/NPFrontMatterManipulation.test.js @@ -319,6 +319,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__/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/regex.js b/helpers/regex.js index 6ee5a401e..a84facfe6 100644 --- a/helpers/regex.js +++ b/helpers/regex.js @@ -189,7 +189,9 @@ export const NP_RE_code_right_backtick: RegExp = /(`)([^`]{1,})(`)/ export const RE_NP_HASHTAG: RegExp = /(?:^|[^A-Za-z0-9_])(#(?:[\w\d]+(?:[\/\-][\w\d]+)*))/ export const RE_NP_HASHTAG_G: RegExp = /(?:^|[^A-Za-z0-9_])(#(?:[\w\d]+(?:[\/\-][\w\d]+)*))/g -// FIXME: When above is fixed, fix this too +// This is what @jgclark thinks it should be: +export const RE_HASHTAG_G: RegExp = new RegExp(/(?:\s|^|\"|\(|\)|\')(#[A-Za-z][\w/_-]*)/g) + // const EM_ORIG_ATTAG_STR = `(\s|^|[\\"\'\(\[\{\*\_])(?!@[\d[:punct:]]+(\s|$))(@([^[:punct:]\s]|[\-_\/])+?\(.*?\)|@([^[:punct:]\s]|[\-_\/])+)` // const ATTAG_STR_FOR_JS = EM_ORIG_ATTAG_STR.replace(/\[:punct:\]/g, PUNCT_CLASS_STR_QUOTED) // export const NP_RE_attag_G: RegExp = new RegExp(ATTAG_STR_FOR_JS, 'g') 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/jgclark.Reviews/CHANGELOG.md b/jgclark.Reviews/CHANGELOG.md index e25d8b41e..f498aacfa 100644 --- a/jgclark.Reviews/CHANGELOG.md +++ b/jgclark.Reviews/CHANGELOG.md @@ -1,9 +1,88 @@ # 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 +## [2.0.0.b15] - 2026-03-29 +- 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 +- 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. - Fixed edge case with adding progress updates and frontmatter. +- Fixed malformed frontmatter mentions (e.g. `@review()` or `@due()`) causing repeated runtime processing; now logs at WARN level and safely ignores empty bracket values. ## [1.3.0] - 2026-02-20 ### Display Improvements diff --git a/jgclark.Reviews/README.md b/jgclark.Reviews/README.md index 0b64f462a..c11aaad5a 100644 --- a/jgclark.Reviews/README.md +++ b/jgclark.Reviews/README.md @@ -13,7 +13,7 @@ After each project name (the title of the note) is an edit icon, which when clic ![Edit dialog](edit-dialog-1.1.png) -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. @@ -32,7 +32,7 @@ Each **Project** is described by a separate note, and has a lifecycle something ![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 such project contains some **metadata** fields including `#project` hashtag, `@review(...)` and some others (see below for more details). For example: ```markdown # Secret Undertaking @@ -77,13 +77,89 @@ 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.) +## v2 changes +New Filter & Order options in a dropdown: + +![New Filter & Order options in a dropdown:](filter+order-v2.0.0.b11.png) + + +Each Project row show the following details: + +![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 + ## 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. +The plugin tries to be as flexible as possible about where project metadata can go. + +From **v2.0** it supports both: + +- **Body metadata line** (legacy and still supported), and +- **Frontmatter metadata**, which over time becomes the main source of truth. + +When looking for project metadata it checks, in order: + +- the first line starting `project:` or `metadata:` 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. +If these can't be found, then the plugin creates a new line after the title, or if the note has frontmatter, a new field in frontmatter under the configured key (see below). + +### Using frontmatter for project metadata + +If your note has a frontmatter block, the plugin can store project metadata there as well as (or instead of) in the body. There are two parts to this: + +- A **combined metadata field** containing the whole metadata line. +- Optional **separate fields** for individual dates/values. + +#### Combined frontmatter key (default `project`) + +The **Frontmatter metadata key** setting controls which frontmatter key is used to store the combined metadata line. By default this is `project:`, but you can set it to any string you like (for example `metadata`). + +Internally this combined field stores exactly the same content as the body metadata line, for example: + +```yaml +--- +title: Project Title +project: #project @review(2w) @reviewed(2021-07-20) @start(2021-04-05) @due(2021-11-30) +--- +``` + +When a note still has a metadata line in the body but **no** value in the combined frontmatter key, the plugin will migrate that body line into the configured frontmatter key and **remove the metadata line from the body**. When any command later updates that project note, it writes to frontmatter and removes the previous body metadata line if present. All tags such as `#project` are preserved during migration. + +#### Separate frontmatter fields (if present) + +If you prefer, you can also use separate frontmatter fields for the different dates and values. The names of these fields are derived from your **metadata @mention settings**, by stripping any leading `@` or `#`. The equivalent would then read: + +```yaml +--- +title: Project Title +project: #project +start: 2021-04-05 +due: 2021-11-30 +reviewed: 2021-07-20 +review: 2w +nextReview: 2021-08-03 +--- +``` + +The plugin: + +- **Reads** from these separate fields if they already exist in frontmatter (using whatever key names your current settings imply), and overlays them on top of what it finds in the combined line. +- **Writes back** to these fields **only if they already exist**. It will not create new separate keys on its own; it simply keeps any existing ones in sync when it updates metadata, again using the key names derived from your current `*MentionStr` settings. + +You can therefore: + +- Use only the combined frontmatter key, or +- Use both the combined key and any separate fields you choose to add, or +- Continue to use just the body metadata line (the plugin will migrate it into frontmatter and remove it from the body when it next needs to update metadata). 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. @@ -93,7 +169,7 @@ Other notes: - 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. -_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._ +_From v1.4.0.b5 the plugin migrates this metadata into the Frontmatter block (if present) and removes the body metadata line, leaving the note body cleaner._ ## Selecting notes to include There are 2 parts of this: @@ -153,6 +229,7 @@ The settings relating to Progress are: ## 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. +- **Automatic Update interval**: (from v1.4.0.b4) If set to any number > 0, the Rich Project Lists window will automatically refresh when it has been idle for that many minutes. Set to 0 to disable. When the list refreshes (manually or automatically), the current scroll position is preserved as closely as possible. - 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: @@ -211,6 +288,19 @@ Progress: @YYYY-MM-DD ``` It will also update the project's `@reviewed(date)`. +### "/heatmaps for weekly Projects Progress" command +The **/weeklyProjectsProgress heatmaps** command scans your Area/Project folders by week, and shows a pair of heatmaps in new windows: + +- one heatmap for notes progressed per week per folder of notes (where a project note counts as being progressed if one or more tasks are completed) +- one heatmap for 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. + + + ## 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). diff --git a/jgclark.Reviews/filter+order-v2.0.0.b11.png b/jgclark.Reviews/filter+order-v2.0.0.b11.png new file mode 100644 index 000000000..412003e34 Binary files /dev/null and b/jgclark.Reviews/filter+order-v2.0.0.b11.png differ diff --git a/jgclark.Reviews/plugin.json b/jgclark.Reviews/plugin.json index 65faa8c8c..b80f51c1e 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.b15", + "plugin.releaseStatus": "beta", + "plugin.lastUpdateInfo": "2.0.0: Frontmatter metadata support, including configurable combined key and migration from body.\nSignificantly modernised layout for Rich project list.\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", @@ -163,11 +169,6 @@ "description": "prompts for a short description and percentage completion number for the open project note, and writes it to the metadata area of the note", "jsFunction": "addProgressUpdate" }, - { - "name": "Projects: update plugin settings", - "description": "Settings interface (even for iOS)", - "jsFunction": "updateSettings" - }, { "hidden": false, "name": "weeklyProjectsProgress", @@ -175,6 +176,13 @@ "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": true, "name": "removeAllDueDates", @@ -233,6 +241,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 +294,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.\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.\nIf 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 +373,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 +402,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 +418,7 @@ "title": "Show projects grouped by folder?", "description": "Whether to group the projects by their folder.", "type": "bool", - "default": true, + "default": false, "required": true }, { @@ -455,6 +469,14 @@ "default": true, "required": true }, + { + "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 + }, { "key": "width", "title": "Window width", @@ -636,6 +658,23 @@ "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 + }, + { + "hidden": true, + "key": "writeDateMentionsInCombinedMetadata", + "title": "Also keep date mentions in combined metadata key?", + "description": "If set, date mentions (such as @due(...) and @reviewed(...)) are also written into the combined frontmatter metadata key. By default this is off, so date values are written to separate frontmatter keys instead.", + "type": "bool", + "default": false, + "required": true + }, { "type": "separator" }, @@ -673,6 +712,14 @@ "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 } ] } \ 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/projects-list-v1.4.0.b1 2@2x.png b/jgclark.Reviews/projects-list-v1.4.0.b1 2@2x.png new file mode 100644 index 000000000..53c4aa3c6 Binary files /dev/null and b/jgclark.Reviews/projects-list-v1.4.0.b1 2@2x.png differ diff --git a/jgclark.Reviews/projects-list-v2.0.0.b11.png b/jgclark.Reviews/projects-list-v2.0.0.b11.png new file mode 100644 index 000000000..42c87c5aa Binary files /dev/null and b/jgclark.Reviews/projects-list-v2.0.0.b11.png differ diff --git a/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js b/jgclark.Reviews/requiredFiles/HTMLWinCommsSwitchboard.js index 1e77bfeb1..af3686518 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-02-26 for v1.4.0.b4 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 setPercentRingJSFunc: string = ` + +` + +export const addToggleEvents: string = ` + +` + +export const displayFiltersDropdownScript: string = ` + +` + +export const tagTogglesVisibilityScript: string = ` + +` 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..25591a435 100644 --- a/jgclark.Reviews/src/reviewHelpers.js +++ b/jgclark.Reviews/src/reviewHelpers.js @@ -2,7 +2,7 @@ //----------------------------------------------------------------------------- // Helper functions for Review plugin // by Jonathan Clark -// Last updated 2026-02-26 for v1.3.1, @jgclark +// Last updated 2026-03-26 for v1.4.0.b13, @jgclark //----------------------------------------------------------------------------- //----------------------------------------------------------------------------- @@ -10,6 +10,7 @@ import { getActivePerspectiveDef, getAllowedFoldersInCurrentPerspective, getPerspectiveSettings } from '../../jgclark.Dashboard/src/perspectiveHelpers' import type { TPerspectiveDef } from '../../jgclark.Dashboard/src/types' import { type Progress } from './projectClass' +import { checkBoolean, checkString } from '@helpers/checkType' import { stringListOrArrayToArray } from '@helpers/dataManipulation' import { calcOffsetDate, @@ -22,8 +23,9 @@ import { } 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 { endOfFrontmatterLineIndex, ensureFrontmatter, getFrontmatterAttribute, noteHasFrontMatter, removeFrontMatterField, updateFrontMatterVars } from '@helpers/NPFrontMatter' import { getFieldParagraphsFromNote } from '@helpers/paragraph' +import { getHashtagsFromString } from '@helpers/stringTransforms' import { showMessage } from '@helpers/userInput' //------------------------------ @@ -58,23 +60,130 @@ export type ReviewConfig = { ignoreChecklistsInProgress: boolean, reviewedMentionStr: string, reviewIntervalMentionStr: string, + sequentialTag: string, + showFolderName: boolean, startMentionStr: string, nextReviewMentionStr: string, width: number, height: number, archiveUsingFolderStructure: boolean, archiveFolder: string, - removeDueDatesOnPause: boolean, + removeDueDatesOnPause?: boolean, nextActionTags: Array, preferredWindowType: string, - sequentialTag: string, + autoUpdateAfterIdleTime?: number, progressHeading?: string, progressHeadingLevel: number, writeMostRecentProgressToFrontmatter?: boolean, + projectMetadataFrontmatterKey?: string, + writeDateMentionsInCombinedMetadata?: boolean, + 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) + continue + } + + if (mentionName === intervalMentionName) { + if (reInterval.test(mentionParam)) fmAttrs[frontmatterKeyName] = mentionParam + else keysToRemove.push(frontmatterKeyName) + } else { + if (reISODate.test(mentionParam)) fmAttrs[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 @@ -109,6 +218,18 @@ export async function getReviewSettings(externalCall: boolean = false): Promise< DataStore.setPreference('numberDaysForFutureToIgnore', config.numberDaysForFutureToIgnore) DataStore.setPreference('ignoreChecklistsInProgress', config.ignoreChecklistsInProgress) + // Frontmatter metadata preferences + // 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) + config.writeDateMentionsInCombinedMetadata = checkBoolean(config.writeDateMentionsInCombinedMetadata ?? false) + DataStore.setPreference('writeDateMentionsInCombinedMetadata', config.writeDateMentionsInCombinedMetadata) + // 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 if (!config.usePerspectives) { @@ -120,15 +241,14 @@ export async function getReviewSettings(externalCall: boolean = false): Promise< 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)}]`) + config.includedTeamspaces = currentPerspective.dashboardSettings?.includedTeamspaces ?? ['private'] + // logDebug('getReviewSettings', `- includedTeamspaces: [${String(config.includedTeamspaces)}]`) const validFolders = getAllowedFoldersInCurrentPerspective(perspectiveSettings) logDebug('getReviewSettings', `-> validFolders for '${config.perspectiveName}': [${String(validFolders)}]`) @@ -139,6 +259,11 @@ export async function getReviewSettings(externalCall: boolean = false): Promise< config.displayPaused = true } + // Ensure autoUpdateAfterIdleTime has a sensible default if missing from settings + 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) if (config.reviewsTheme == null || config.reviewsTheme === undefined) { config.reviewsTheme = '' @@ -205,9 +330,10 @@ 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) @@ -301,7 +427,7 @@ export function processMostRecentProgressParagraph(progressParas: Array s.content) ?? [] // Find which line that project field is on for (let i = 1; i < updatedLines.length; i++) { - if (updatedLines[i].match(/^metadata:/i)) { + const re = new RegExp(`^${singleMetadataKeyName}:`, 'i') + if (updatedLines[i].match(re)) { lineNumber = i break } @@ -363,57 +509,388 @@ export function getOrMakeMetadataLineIndex(note: CoreNoteFields, metadataLinePla } } -//------------------------------------------------------------------------------- +//------------------------------ +// Migration message when body metadata has been moved to frontmatter + +export const PROJECT_METADATA_MIGRATED_MESSAGE = '_Project metadata has been migrated to frontmatter._' + /** - * 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. + * 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 +} + +/** + * 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 */ -export function updateMetadataInEditor(thisEditor: TEditor, updatedMetadataArr: Array): void { +export function migrateProjectMetadataLineInEditor(thisEditor: TEditor): void { 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.`) + // Bail if this isn't a valid project note (Notes type, at least 2 paragraphs). + 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 } - const thisNote = thisEditor // note: not thisEditor.note + const noteForFM = thisEditor.note + logDebug('migrateProjectMetadataLineInEditor', `Starting for '${displayTitle(noteForFM)}'`) - 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(Editor)}`) + // Check that project metadata is actually stored in frontmatter (configurable key or 'metadata'). + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project') + const metadataAttr = getFrontmatterAttribute(noteForFM, singleMetadataKeyName) + const metadataStrSavedFromBodyOfNote = typeof metadataAttr === 'string' ? metadataAttr.trim() : '' + + // Scan the body only (after the closing ---). Find either the migration message or the first metadata-style line. + const paras = thisEditor.paragraphs + // const initialLineCount: number = paras.length + const endFMIndex = endOfFrontmatterLineIndex(noteForFM) ?? -1 + + // First pass: handle migration message line (if present) + for (let i = endFMIndex + 1; i < paras.length; i++) { + const p = paras[i] + const content = p.content ?? '' + + // If we already left the migration message on a previous run, clear that line and we're done. + if (content === PROJECT_METADATA_MIGRATED_MESSAGE) { + logDebug('migrateProjectMetadataLineInEditor', `- Found existing migration message at line ${String(i)}; removing.`) + // v1: not working, and I can't see why + // thisEditor.removeParagraph(p) + // v2: also not working + // thisEditor.removeParagraphAtIndex(p.lineIndex) + // if (Editor.paragraphs.length === initialLineCount) { + // logWarn('migrateProjectMetadataLineInEditor', `- Line count didn't change from ${String(initialLineCount)} after removing migration message. This shouldn't happen.`) + // } + // v3: just clear the message instead TEST: + p.content = '' + thisEditor.updateParagraph(p) + return + } + } + + // Second pass: find the first metadata-style line in the body (if any). + const metadataInfo = findFirstMetadataBodyLine(paras, endFMIndex + 1) + + // If we found an old metadata line in the body, first merge its contents into frontmatter (to avoid dropping mentions), + // then replace it with the migration message. + if (metadataInfo != null) { + // Decide which frontmatter key we are using (always use the configured combined-metadata key here) + const existingFMValue = metadataStrSavedFromBodyOfNote + + // Strip any leading "project:" / "metadata:" / "review:" / "reviewed:" prefix from the body line + const bodyValue = metadataInfo.content.replace(/^(project|metadata|review|reviewed)\s*:\s*/i, '').trim() + + if (bodyValue !== '') { + const fmAttrs: { [string]: any } = {} + + // Invariant: combined key must contain ONLY hashtags. + fmAttrs[singleMetadataKeyName] = extractTagsOnly(`${existingFMValue !== '' ? `${existingFMValue} ` : ''}${bodyValue}`) + + // Parse date/interval mention tokens from the body line into separate frontmatter keys. + const mentionTokens = (`${bodyValue} `) + .split(' ') + .filter((f) => f[0] === '@') + + 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() : '' + } + + // Dates (including nextReview) are ISO dates. + 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)) { + fmAttrs[frontmatterKeyName] = bracketContent + } + } + + // Review interval: separate key, interval string (e.g. '1w') + 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)) { + fmAttrs[reviewIntervalKey] = intervalBracketContent + } + + // $FlowFixMe[incompatible-call] + const mergedOK = updateFrontMatterVars(noteForFM, fmAttrs) + if (!mergedOK) { + logError( + 'migrateProjectMetadataLineInEditor', + `Failed to merge body metadata line into frontmatter key '${singleMetadataKeyName}' for '${displayTitle(noteForFM)}'`, + ) + } else { + logDebug( + 'migrateProjectMetadataLineInEditor', + `- Merged body metadata into frontmatter key '${singleMetadataKeyName}' for '${displayTitle(noteForFM)}'`, + ) + } + } + + const metadataPara = paras[metadataInfo.index] + logDebug('migrateProjectMetadataLineInEditor', `- Replacing body metadata line at ${String(metadataInfo.index)} with migration message.`) + metadataPara.content = PROJECT_METADATA_MIGRATED_MESSAGE + thisEditor.updateParagraph(metadataPara) + } + } catch (error) { + logError('migrateProjectMetadataLineInEditor', error.message) + } +} + +/** +/** + * 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 + */ +export function migrateProjectMetadataLineInNote(noteToUse: CoreNoteFields): void { + try { + // Bail if this isn't a valid project note (Notes type, at least 2 paragraphs). + 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 + } + logDebug('migrateProjectMetadataLineInNote', `Starting for '${displayTitle(noteToUse)}'`) + + // Ensure we have a frontmatter section to write to. TEST: Is this needed? + if (!noteHasFrontMatter(noteToUse)) { + ensureFrontmatter(noteToUse) + } + + // Check that project metadata is actually stored in frontmatter (configurable key or 'metadata'). + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'project') + const metadataAttr = getFrontmatterAttribute((noteToUse: any), singleMetadataKeyName) + const metadataStrSavedFromBodyOfNote = typeof metadataAttr === 'string' ? metadataAttr.trim() : '' + + // Scan the body only (after the closing ---). Find either the migration message or the first metadata-style line. + const paras = noteToUse.paragraphs + const endFMIndex = endOfFrontmatterLineIndex(noteToUse) ?? -1 + + // First pass: handle migration message line (if present) + for (let i = endFMIndex + 1; i < paras.length; i++) { + const p = paras[i] + const content = p.content ?? '' + + // If we already left the migration message on a previous run, clear that line and we're done. + if (content === PROJECT_METADATA_MIGRATED_MESSAGE) { + logDebug('migrateProjectMetadataLineInNote', `- Found existing migration message at line ${String(i)}; clearing its content.`) + p.content = '' + noteToUse.updateParagraph(p) + return + } + } + + // Second pass: find the first metadata-style line in the body (if any). + const metadataInfo = findFirstMetadataBodyLine(paras, endFMIndex + 1) + + // If we found an old metadata line in the body, first merge its contents into frontmatter (to avoid dropping mentions), + // then replace it with the migration message. + if (metadataInfo != null) { + // Decide which frontmatter key we are using + const primaryKey = singleMetadataKeyName ?? 'metadata' + const existingFMValue = metadataStrSavedFromBodyOfNote + + // Strip any leading "project:" / "metadata:" / "review:" / "reviewed:" prefix from the body line + const bodyValue = metadataInfo.content.replace(/^(project|metadata|review|reviewed)\s*:\s*/i, '').trim() + + if (bodyValue !== '') { + logDebug('migrateProjectMetadataLineInNote', `- Merging body metadata into frontmatter key '${primaryKey}' with bodyValue '${bodyValue}'`) + const fmAttrs: { [string]: any } = {} + + // Invariant: combined key must contain ONLY hashtags. + fmAttrs[primaryKey] = extractTagsOnly(`${existingFMValue !== '' ? `${existingFMValue} ` : ''}${bodyValue}`) + + // Parse date/interval mention tokens from the body metadata line into separate frontmatter keys. + const mentionTokens = (`${bodyValue} `) + .split(' ') + .filter((f) => f[0] === '@') + + 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)) { + fmAttrs[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)) { + fmAttrs[reviewIntervalKey] = intervalBracketContent + } + + // $FlowFixMe[incompatible-call] + const mergedOK = updateFrontMatterVars((noteToUse: any), fmAttrs) + if (!mergedOK) { + logError('migrateProjectMetadataLineInNote',`Failed to merge body metadata line into frontmatter key '${primaryKey}' for '${displayTitle(noteToUse)}'`,) + } else { + logDebug('migrateProjectMetadataLineInNote',`- Merged body metadata into frontmatter key '${primaryKey}' for '${displayTitle(noteToUse)}'`,) + } + } + + const metadataPara = paras[metadataInfo.index] + logDebug('migrateProjectMetadataLineInNote', `- Replacing body metadata line at ${String(metadataInfo.index)} with migration message.`) + metadataPara.content = PROJECT_METADATA_MIGRATED_MESSAGE + noteToUse.updateParagraph(metadataPara) } + } catch (error) { + logError('migrateProjectMetadataLineInNote', error.message) + } +} + +//------------------------------------------------------------------------------- +// Other helpers (metadata mutation + delete) + +/** + * 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 { + 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 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 - const origLine: string = metadataPara.content - let updatedLine = origLine + logDebug( + logContext, + `starting for '${displayTitle(noteLike)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`, + ) - logDebug( - 'updateMetadataInEditor', - `starting for '${displayTitle(thisNote)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`, - ) + 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] - // 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}`) + noteLike.updateParagraph(metadataPara) + logDebug(logContext, `- After update ${metadataPara.content}`) + } +} + +/** + * 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 updateMetadataInEditor(thisEditor: TEditor, updatedMetadataArr: Array): void { + 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 + } + + const metadataLineIndex: number = getOrMakeMetadataLineIndex(thisEditor) + updateMetadataCore(thisEditor, metadataLineIndex, updatedMetadataArr, 'updateMetadataInEditor') } catch (error) { logError('updateMetadataInEditor', error.message) } @@ -436,117 +913,100 @@ export function updateMetadataInNote(note: CoreNoteFields, updatedMetadataArr: A } 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}`) - } - - // 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, 'updateMetadataInNote') } catch (error) { logError('updateMetadataInNote', `${error.message}`) - return } } -//------------------------------------------------------------------------------- -// 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 { + 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`) + } } /** - * 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,39 +1016,17 @@ 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 } - - const metadataLineIndex: number = getOrMakeMetadataLineIndex(noteToUse) - const metadataPara = noteToUse.paragraphs[metadataLineIndex] - if (!metadataPara) { - throw new Error(`Couldn't get or make metadataPara for ${displayTitle(noteToUse)}`) - } - - 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}`) - } - - // send update to noteToUse (removing multiple and trailing spaces) - metadataPara.content = newLine.replace(/\s{2,}/g, ' ').trimRight() - noteToUse.updateParagraph(metadataPara) - logDebug('deleteMetadataMentionInNote', `- Finished`) + deleteMetadataMentionCore(noteToUse, metadataLineIndex, mentionsToDeleteArr, 'deleteMetadataMentionInNote') } catch (error) { logError('deleteMetadataMentionInNote', `${error.message}`) } @@ -614,6 +1052,18 @@ export async function updateDashboardIfOpen(): Promise { const res = await DataStore.invokePluginCommandByName("refreshSectionsByCode", "jgclark.Dashboard", [['PROJACT', 'PROJREVIEW', 'PROJ']]) } +/** + * English plural for simple count labels (task/tasks, item/items). + * @param {'task' | 'item'} noun + * @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) @@ -623,8 +1073,8 @@ export async function updateDashboardIfOpen(): Promise { */ 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..94ca601f7 100644 --- a/jgclark.Reviews/src/reviews.js +++ b/jgclark.Reviews/src/reviews.js @@ -11,7 +11,7 @@ // 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-03-29 for v1.4.0.b15, @jgclark //----------------------------------------------------------------------------- import moment from 'moment/min/moment-with-locales' @@ -20,14 +20,18 @@ import { checkForWantedResources, logAvailableSharedResources, logProvidedShared import { deleteMetadataMentionInEditor, deleteMetadataMentionInNote, + getOrMakeMetadataLineIndex, getNextActionLineIndex, getReviewSettings, isProjectNoteIsMarkedSequential, + migrateProjectMetadataLineInEditor, + migrateProjectMetadataLineInNote, type ReviewConfig, updateMetadataInEditor, updateMetadataInNote, } from './reviewHelpers' import { + copyDemoDefaultToAllProjectsList, filterAndSortProjectsList, getNextNoteToReview, getSpecificProjectFromList, @@ -36,13 +40,24 @@ import { } from './allProjectsListHelpers.js' import { calcReviewFieldsForProject, Project } from './projectClass' 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, +} 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,6 +65,7 @@ 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 { getOrMakeRegularNoteInFolder } from '@helpers/NPnote' @@ -62,9 +78,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` //----------------------------------------------------------------------------- @@ -103,252 +121,8 @@ async function clearProjectReviewingInHTML(): Promise { } } -//------------------------------------------------------------------------------- -// 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. - */ -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 +147,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 +159,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,12 +206,19 @@ 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)) @@ -434,14 +254,19 @@ export async function renderProjectLists( } /** - * 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() + 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)) { @@ -449,12 +274,13 @@ export async function renderProjectListsIfOpen( renderProjectListsMarkdown(config, false) } if (config.outputStyle.match(/rich/i)) { - await renderProjectListsHTML(config, false) + await renderProjectListsHTML(config, false, scrollPos) } - // return {} just to avoid possibility of NP silently failing when called by invokePluginCommandByName - return {} + // 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 +298,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`) + 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,59 +329,98 @@ 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, _numberProjectsUnfiltered] = await filterAndSortProjectsList(config, '', [], true, useDemoData) + const wantedTags = config.projectTypeTags ?? [] + const tagActiveCounts = wantedTags.map((tag) => + projectsToReview.filter( + (p) => + !p.isPaused && + !p.isCancelled && + !p.isCompleted && + 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)) + // Generate top bar HTML (uses config.tagActiveCounts for dropdown tag counts) + outputArray.push(buildProjectListTopBarHtml(config)) // Start multi-col working (if space) outputArray.push(`
`) 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 = projectsToReview.length + if (useDemoData && noteCount === 0) { + outputArray.push('

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

') + } + if (noteCount > 0) { + let lastFolder = '' + for (const thisProject of projectsToReview) { + if (!useDemoData) { + const thisNote = DataStore.projectNoteByFilename(thisProject.filename) + if (!thisNote) { + logWarn('renderProjectListsHTML', `Can't find note for filename ${thisProject.filename}`) + continue + } + } + if (config.displayGroupedByFolder && lastFolder !== thisProject.folder) { + const folderDisplayName = getFolderDisplayNameForHTML(thisProject.folder) + let folderPart = folderDisplayName + if (config.hideTopLevelFolder) { + if (folderDisplayName.includes(']')) { + const match = folderDisplayName.match(/^(\[.*?\])\s*(.+)$/) + if (match) { + const pathPart = match[2] + const pathParts = pathPart.split('/').filter(p => p !== '') + folderPart = `${match[1]} ${pathParts.length > 0 ? pathParts[pathParts.length - 1] : pathPart}` + } else { + folderPart = folderDisplayName.split('/').slice(-1)[0] || folderDisplayName + } + } else { + const pathParts = folderDisplayName.split('/').filter(p => p !== '') + folderPart = pathParts.length > 0 ? pathParts[pathParts.length - 1] : folderDisplayName + } + } + if (thisProject.folder === '/') folderPart = '(root folder)' + outputArray.push(buildFolderGroupHeaderHtml(folderPart, config)) } + 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 winOptions = { - windowTitle: windowTitle, - customId: customRichWinId, - headerTags: `${faLinksInHeader}${stylesheetinksInHeader}\n`, + windowTitle: useDemoData ? windowTitleDemo : windowTitle, + customId: richWinId, + headerTags: `${faLinksInHeader}${stylesheetinksInHeader}\n\n`, 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 + ` + postBodyScript: checkboxHandlerJSFunc + setScrollPosJS + displayFiltersDropdownScript + tagTogglesVisibilityScript + autoRefreshScript + ` ` + commsBridgeScripts + shortcutsScript + addToggleEvents, // + collapseSection + resizeListenerScript + unloadListenerScript, @@ -570,7 +437,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) @@ -789,7 +656,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) { @@ -831,7 +698,7 @@ export async function generateReviewOutputLines(projectTag: string, style: strin // Handle root folder display - check if original folder was root, not the display name if (folder === '/') folderPart = '(root folder)' if (style.match(/rich/i)) { - outputArray.push(generateFolderHeaderHTML(folderPart, config)) + outputArray.push(buildFolderGroupHeaderHtml(folderPart, config)) } else if (style.match(/markdown/i)) { outputArray.push(`### ${folderPart}`) } @@ -888,20 +755,30 @@ async function finishReviewCoreLogic(note: CoreNoteFields): Promise { } const possibleThisEditor = getOpenEditorFromFilename(note.filename) - if (possibleThisEditor) { - logDebug('finishReviewCoreLogic', `Updating Editor '${displayTitle(possibleThisEditor)}' ...`) - // First update @review(date) on current open note - updateMetadataInEditor(possibleThisEditor, [reviewedTodayString]) + if (possibleThisEditor && possibleThisEditor.note != null) { + logDebug('finishReviewCoreLogic', `Updating EDITOR note '${displayTitle(possibleThisEditor.note)}' ...`) + // 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. + migrateProjectMetadataLineInEditor(possibleThisEditor) + const metadataLineIndex: number = getOrMakeMetadataLineIndex(possibleThisEditor) // Remove a @nextReview(date) if there is one, as that is used to skip a review, which is now done. - deleteMetadataMentionInEditor(possibleThisEditor, [config.nextReviewMentionStr]) + deleteMetadataMentionInEditor(possibleThisEditor, metadataLineIndex, [config.nextReviewMentionStr]) + // Update @review(date) on current open note + updateMetadataInEditor(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]) + // 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: number = getOrMakeMetadataLineIndex(note) // Remove a @nextReview(date) if there is one, as that is used to skip a review, which is now done. - deleteMetadataMentionInNote(note, [config.nextReviewMentionStr]) + deleteMetadataMentionInNote(note, metadataLineIndex, [config.nextReviewMentionStr]) + // Update @review(date) on the note + updateMetadataInNote(note, [reviewedTodayString]) // $FlowIgnore[prop-missing] DataStore.updateCache(note, true) } @@ -926,11 +803,13 @@ async function finishReviewCoreLogic(note: CoreNoteFields): Promise { // 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) + // TODO: How can we keep the scrollPos? + await renderProjectListsIfOpen(config) } 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.`) + // TODO: Split the following into just generate...(), and then move the renderProjectListsIfOpen() above to serve both if/else clauses await generateProjectListsAndRenderIfOpen() } @@ -1179,9 +1058,9 @@ 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) } 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() } @@ -1322,7 +1201,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) } } catch (error) { logError('setNewReviewInterval', error.message) @@ -1353,7 +1232,8 @@ export async function toggleDisplayFinished(): Promise { // 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) + // TODO: how to get scrollPos? + await renderProjectListsIfOpen(updatedConfig) } catch (error) { logError('toggleDisplayFinished', error.message) @@ -1376,7 +1256,8 @@ export async function toggleDisplayOnlyDue(): Promise { // 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) + // TODO: how to get scrollPos? + await renderProjectListsIfOpen(updatedConfig) } catch (error) { logError('toggleDisplayOnlyDue', error.message) @@ -1398,7 +1279,8 @@ export async function toggleDisplayNextActions(): Promise { // 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) + // TODO: how to get scrollPos? + await renderProjectListsIfOpen(updatedConfig) } catch (error) { logError('toggleDisplayNextActions', error.message) @@ -1407,13 +1289,14 @@ 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, + displayOrder?: string, }): Promise { try { const config: ReviewConfig = await getReviewSettings() @@ -1421,9 +1304,29 @@ export async function saveDisplayFilters(data: { 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) } catch (error) { logError('saveDisplayFilters', error.message) } } + +/** + * 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} identifier - the word to pluralise + * @param {number} count - the number of items + * @returns {string} the pluralised word + */ +export function pluralise(identifier: string, count: number): string { + // const localeLanguage = NotePlan.environment.languageCode ?? 'en' + const localeLanguage = 'en' + if (localeLanguage === 'en') { + return (count === 1) ? `${identifier}` : `${identifier}s` + } else { + return (count === 1) ? `${identifier}` : `${identifier}s` + } +} 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