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..05ff42f24 100644 --- a/helpers/NPFrontMatter.js +++ b/helpers/NPFrontMatter.js @@ -572,7 +572,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 +590,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++ 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/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/jgclark.Reviews/CHANGELOG.md b/jgclark.Reviews/CHANGELOG.md index e25d8b41e..780e490eb 100644 --- a/jgclark.Reviews/CHANGELOG.md +++ b/jgclark.Reviews/CHANGELOG.md @@ -1,6 +1,67 @@ # 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.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. diff --git a/jgclark.Reviews/README.md b/jgclark.Reviews/README.md index 0b64f462a..9e25c6837 100644 --- a/jgclark.Reviews/README.md +++ b/jgclark.Reviews/README.md @@ -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..22445fdc2 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.b12", + "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", @@ -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": [ @@ -404,7 +417,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 +468,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.", + "type": "number", + "default": 0, + "required": true + }, { "key": "width", "title": "Window width", @@ -636,6 +657,14 @@ "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 + }, { "type": "separator" }, @@ -673,6 +702,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..359c75fd3 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-22 for v1.4.0.b12, @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 { checkString } from '@helpers/checkType' import { stringListOrArrayToArray } from '@helpers/dataManipulation' import { calcOffsetDate, @@ -22,7 +23,7 @@ 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, updateFrontMatterVars } from '@helpers/NPFrontMatter' import { getFieldParagraphsFromNote } from '@helpers/paragraph' import { showMessage } from '@helpers/userInput' @@ -58,19 +59,20 @@ export type ReviewConfig = { ignoreChecklistsInProgress: boolean, reviewedMentionStr: string, reviewIntervalMentionStr: string, + showFolderName: boolean, startMentionStr: string, nextReviewMentionStr: string, width: number, height: number, archiveUsingFolderStructure: boolean, archiveFolder: string, - removeDueDatesOnPause: boolean, nextActionTags: Array, preferredWindowType: string, - sequentialTag: string, + autoUpdateAfterIdleTime?: number, progressHeading?: string, progressHeadingLevel: number, writeMostRecentProgressToFrontmatter?: boolean, + projectMetadataFrontmatterKey?: string, _logLevel: string, _logTimer: boolean, } @@ -109,6 +111,16 @@ 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) + // 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 +132,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 +150,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 +221,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') || 'metadata') + 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) @@ -335,16 +352,18 @@ export function getOrMakeMetadataLineIndex(note: CoreNoteFields, metadataLinePla // If no metadataPara found, then insert one either after title, or in the frontmatter if present. if (Number.isNaN(lineNumber)) { + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata') if (noteHasFrontMatter(note)) { logWarn('getOrMakeMetadataLineIndex', `I couldn't find an existing metadata line, so have added a placeholder at the top of the note. Please review it.`) - // $FlowIgnore[incompatible-call] - const res = updateFrontMatterVars(note, { - metadata: metadataLinePlaceholder, - }) + const fmAttrs: { [string]: any } = {} + fmAttrs[singleMetadataKeyName] = metadataLinePlaceholder + // $FlowFixMe[incompatible-call] + const res = updateFrontMatterVars(note, fmAttrs) const updatedLines = note.paragraphs?.map((s) => s.content) ?? [] // Find which line that project field is on for (let i = 1; i < updatedLines.length; i++) { - if (updatedLines[i].match(/^metadata:/i)) { + const re = new RegExp(`^${singleMetadataKeyName}:`, 'i') + if (updatedLines[i].match(re)) { lineNumber = i break } @@ -363,57 +382,296 @@ 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)}'`) + + // Check that project metadata is actually stored in frontmatter (configurable key or 'metadata'). + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata') + 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 + } + } - 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)}`) + // 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 mergedValue = (existingFMValue !== '' ? `${existingFMValue} ${bodyValue}` : bodyValue).replace(/\s{2,}/g, ' ').trim() + const fmAttrs: { [string]: any } = {} + fmAttrs[singleMetadataKeyName] = mergedValue + // $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) + } +} - const origLine: string = metadataPara.content - let updatedLine = origLine +/** + * 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 note or cache; callers must handle persistence. + * @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) + } - logDebug( - 'updateMetadataInEditor', - `starting for '${displayTitle(thisNote)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`, - ) + // Check that project metadata is actually stored in frontmatter (configurable key or 'metadata'). + const singleMetadataKeyName = checkString(DataStore.preference('projectMetadataFrontmatterKey') || 'metadata') + 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 mergedValue = (existingFMValue !== '' ? `${existingFMValue} ${bodyValue}` : bodyValue).replace(/\s{2,}/g, ' ').trim() + const fmAttrs: { [string]: any } = {} + fmAttrs[primaryKey] = mergedValue + // $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') || 'metadata') + const frontmatterPrefixRe = new RegExp(`^${singleMetadataKeyName}:\\s*`, 'i') + const isFrontmatterLine = metadataLineIndex <= endFMIndex + + logDebug( + logContext, + `starting for '${displayTitle(noteLike)}' for new metadata ${String(updatedMetadataArr)} with metadataLineIndex ${metadataLineIndex} ('${origLine}')`, + ) + + if (isFrontmatterLine) { + let valueOnly = origLine.replace(frontmatterPrefixRe, '') + for (const item of updatedMetadataArr) { + const mentionName = item.split('(', 1)[0] + const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}\\([\\w\\-\\.]+\\)`, 'gi') + valueOnly = valueOnly.replace(RE_THIS_MENTION_ALL, '') + valueOnly += ` ${item}` + } + const finalValue = valueOnly.replace(/\s{2,}/g, ' ').trim() + const fmAttrs: { [string]: any } = {} + fmAttrs[singleMetadataKeyName] = finalValue + // $FlowFixMe[incompatible-call] + const success = updateFrontMatterVars(noteLike, fmAttrs) + if (!success) { + logError(logContext, `Failed to update frontmatter ${singleMetadataKeyName} for '${displayTitle(noteLike)}'`) + } else { + logDebug(logContext, `- After update frontmatter ${singleMetadataKeyName}='${finalValue}'`) + } + } 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 +694,86 @@ 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') || 'metadata') + 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, '') + for (const mentionName of mentionsToDeleteArr) { + const RE_THIS_MENTION_ALL = new RegExp(`${mentionName}(\\([\\d\\-\\.]+\\))?`, 'gi') + valueOnly = valueOnly.replace(RE_THIS_MENTION_ALL, '') + logDebug(logContext, `-> ${valueOnly}`) + } + const finalValue = valueOnly.replace(/\s{2,}/g, ' ').trim() + const fmAttrs: { [string]: any } = {} + fmAttrs[singleMetadataKeyName] = finalValue + // $FlowFixMe[incompatible-call] + const success = updateFrontMatterVars(noteLike, fmAttrs) + if (!success) { + logError(logContext, `Failed to update frontmatter ${singleMetadataKeyName} for '${displayTitle(noteLike)}'`) + } else { + logDebug(logContext, `- Finished frontmatter ${singleMetadataKeyName}='${finalValue}'`) + } + } 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 +783,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}`) } @@ -623,8 +828,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..2de4bbc45 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-17 for v1.4.0.b8, @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,25 @@ import { } from './allProjectsListHelpers.js' import { calcReviewFieldsForProject, Project } from './projectClass' import { - generateProjectOutputLine, - generateTopBarHTML, - generateHTMLForProjectTagSectionHeader, - generateTableStructureHTML, - generateProjectControlDialogHTML, - generateFolderHeaderHTML, + buildProjectLineForStyle, + buildProjectListTopBarHtml, + buildProjectListGridPrefixHtml, + 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 +66,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 +79,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 +122,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 +148,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 +160,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 +207,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 +255,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 +275,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 +299,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 +330,99 @@ 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 + outputArray.push(buildProjectListGridPrefixHtml(config)) + 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 +439,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 +658,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 +700,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 +757,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 +805,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 +1060,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 +1203,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 +1234,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 +1258,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 +1281,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 +1291,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,8 +1306,11 @@ 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) } 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