From ccef060634f3fd863c1931b121936f5682b5bdd0 Mon Sep 17 00:00:00 2001 From: jgclark Date: Tue, 24 Mar 2026 14:40:54 +0000 Subject: [PATCH 01/19] 2.0.0b1 Window-based Questions done. Buttons not working. --- jgclark.DailyJournal/CHANGELOG.md | 2 + jgclark.DailyJournal/src/journal.js | 8 +- jgclark.Journalling/CHANGELOG.md | 17 + jgclark.Journalling/README.md | 108 ++++ jgclark.Journalling/__tests__/journal.test.js | 62 ++ jgclark.Journalling/calendar-settings@2x.png | Bin 0 -> 73359 bytes jgclark.Journalling/plugin.json | 347 +++++++++++ jgclark.Journalling/requiredFiles/reviews.css | 152 +++++ jgclark.Journalling/src/index.js | 105 ++++ jgclark.Journalling/src/journal.js | 542 ++++++++++++++++++ jgclark.Journalling/src/journalHelpers.js | 68 +++ .../src/reviewHTMLViewGenerator.js | 356 ++++++++++++ jgclark.Journalling/src/templatesStartEnd.js | 226 ++++++++ 13 files changed, 1989 insertions(+), 4 deletions(-) create mode 100644 jgclark.Journalling/CHANGELOG.md create mode 100644 jgclark.Journalling/README.md create mode 100644 jgclark.Journalling/__tests__/journal.test.js create mode 100644 jgclark.Journalling/calendar-settings@2x.png create mode 100644 jgclark.Journalling/plugin.json create mode 100644 jgclark.Journalling/requiredFiles/reviews.css create mode 100644 jgclark.Journalling/src/index.js create mode 100644 jgclark.Journalling/src/journal.js create mode 100644 jgclark.Journalling/src/journalHelpers.js create mode 100644 jgclark.Journalling/src/reviewHTMLViewGenerator.js create mode 100644 jgclark.Journalling/src/templatesStartEnd.js diff --git a/jgclark.DailyJournal/CHANGELOG.md b/jgclark.DailyJournal/CHANGELOG.md index d426d103a..acdb8a956 100644 --- a/jgclark.DailyJournal/CHANGELOG.md +++ b/jgclark.DailyJournal/CHANGELOG.md @@ -1,6 +1,8 @@ # What's changed in 💭 Journalling Helpers Plugin? _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark.DailyJournal/)._ +Note: I've replaced this plugin with the newer **Journalling & Reviews** one, which takes advantage of richer interfaces available from NP 3.20. This will no longer be updated, and will be retired in due course. + ## [1.16.0] - 2025-11-01 ### Added - Can now have multiple review questions per line. To separate the questions on the same line, use `||`. See documentation for more details. diff --git a/jgclark.DailyJournal/src/journal.js b/jgclark.DailyJournal/src/journal.js index ae045db8f..aec269fe9 100644 --- a/jgclark.DailyJournal/src/journal.js +++ b/jgclark.DailyJournal/src/journal.js @@ -106,7 +106,7 @@ export async function yearlyJournalQuestions(): Promise { * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' * @returns {Promise} true if we should continue, false if cancelled */ -async function ensureCorrectPeriodNote(period: string, periodAdjective: string): Promise { +async function ensureCorrectPeriodNoteIsOpen(period: string, periodAdjective: string): Promise { // Open current calendar note if wanted const { note } = Editor const currentNotePeriod = (note && note.type === 'Calendar') ? getPeriodOfNPDateStr(note.title ?? '') : '' @@ -337,7 +337,7 @@ async function processQuestion( // Look to see if this question has already been put into the note with something following it. // If so, skip this question. - const resAQ = returnAnsweredQuestion(parsedQuestion.question) + const resAQ = answerToQuestion(parsedQuestion.question) if (resAQ !== '') { logDebug(pluginJson, `- Found existing Q answer '${resAQ}', so won't ask again`) return '' @@ -405,7 +405,7 @@ function writeAnswersToNote(output: string, config: JournalConfigType): void { async function processJournalQuestions(period: string, periodAdjective: string = ''): Promise { try { // Ensure correct period note is open - const shouldContinue = await ensureCorrectPeriodNote(period, periodAdjective) + const shouldContinue = await ensureCorrectPeriodNoteIsOpen(period, periodAdjective) if (!shouldContinue) { return } @@ -483,7 +483,7 @@ async function processJournalQuestions(period: string, periodAdjective: string = * @param {string} question * @returns {string} found answered question, or empty string */ -function returnAnsweredQuestion(question: string): string { +function answerToQuestion(question: string): string { const RE_Q = `${question}.+` const { paragraphs } = Editor let result = '' diff --git a/jgclark.Journalling/CHANGELOG.md b/jgclark.Journalling/CHANGELOG.md new file mode 100644 index 000000000..a3b3d7bc6 --- /dev/null +++ b/jgclark.Journalling/CHANGELOG.md @@ -0,0 +1,17 @@ +# What's changed in 💭 Journalling & Reviews Plugin? +_Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark.DailyJournal/)._ + +Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. + +## [2.0.0.b1] - 2026-03-23 +### Added +- The **daily/weekly/monthly/...Review** commands now ask all their questions in a single window and writes answers to the review section in the usual format. It lays out the questions and spaces for answers as it will be added into the note, according to your settings. +- As usual for my plugins, this picks up colours and fonts from your current NP Theme. +- Added a `Review Window type` setting to choose the style of review window to use: 'New Window' for a separate window; 'Main Window' to take over the main window; 'Split View' for a split view in the main window. +- Added `Open the calendar note when reviewing it?` setting (default: `true`) so review commands no longer ask the opening question. +- The settings for review questions now no longer needs to have ` || ` delimiters. + +### Fixed +- Fixed a single-window review callback bridge bug that generated extra quotes around `DataStore.invokePluginCommandByName(...)`, causing a runtime JavaScript `SyntaxError` when submitting or cancelling. +- Fixed duplicate/late review-window callbacks by making the HTML bridge one-shot and safely no-op when `DataStore.invokePluginCommandByName` is unavailable in the current JS context. +- Switched single-window review form callbacks to `noteplan://x-callback-url/runPlugin` and added payload JSON parsing in `onReviewWindowAction`, avoiding WebView `DataStore` runtime availability issues. diff --git a/jgclark.Journalling/README.md b/jgclark.Journalling/README.md new file mode 100644 index 000000000..b903c49e8 --- /dev/null +++ b/jgclark.Journalling/README.md @@ -0,0 +1,108 @@ +# 💭 Journalling Helpers Plugin +This plugin gives support for journalling in NotePlan, including making it easier to apply start-of-day Templates, and end-of-day/week Reviews, Summaries or Tidy Up. + +### Configuration +To use this plugin on weekly/monthly/quarterly/yearly notes, you first need to have them turned on in **NotePlan Settings** > Calendar pane: + + + +All the available commands require some **configuration** first. On Mac click the gear button on the 'Journalling' line in the Plugin Preferences panel, and fill in the settings according to which of the following commands you want to use. If you only use iPhone or iPad, you'll need to use the separate **/Journalling: update plugin settings** command instead. + +## Quickly applying Templates at the start of each Day/Week +The NotePlan website has good [articles on getting started with Templates](https://help.noteplan.co/article/136-templates), and a helpful [Template Gallery](https://noteplan.co/templates), to build from. + +For more details of the tag commands you can use in a Template, including a list of events, a quote-of-the-day or summary weather forecast, see the [full Templating Documentation](https://noteplan.co/templates/docs). + +### /dayStart & /todayStart commands +These commands make it quicker to apply a Template at the start or end of a day or week. The names of the Templates to use are set in the Plugin Settings pane, referring to Template names stored in the special NotePlan `Templates` folder. (This command has become less necessary since about NotePlan v3.17, which introduced [auto-inserting of templates into new calendar notes](https://help.noteplan.co/article/229-auto-insert-templates).) + +- **/todayStart**: applies your 'Daily Note' Template only to _today's_ calendar note, no matter what note you're editing. +- **/dayStart** appends your 'Daily Note' Template to the _currently open daily note_ (or today's note if you're not editing a daily note). Therefore, be careful using it on another calendar note than today using template tag commands like `<%- date... / formattedDate... %>` or `<%- weather() %>` -> because this renders the TODAY content! + +### /weekStart command +This acts very similarly to the /dayStart command above. + +### /dayEnd, /todayEnd, /weekEnd commands +These act in the same way as above, but can be tailored to adding items at the end of a day, perhaps like a Habit or Stats summary from the separate [**Habits & Summaries plugin**](https://noteplan.co/plugins/jgclark.Summaries). + +This is also a quick way of regularly running one or more commands from the separate [**Tidy Up plugin**](https://noteplan.co/plugins/np.Tidy). + +## Helping with periodic Reviews +There's no right or wrong way to do reviews, and you'll no doubt change what you find helpful over time. But the key is to be taking some time to answer questions to help you pause and review what has and hasn't gone well over the last day/week/month/quarter. Some use it as a way of capturing their main **emotions**; others to track **goals**; others to write a simple **gratitude** journal. + +### Using Templates you fill in +One way to do this is to configure an end-of-day/week Template -- for example this [Mental Health Journal](https://noteplan.co/templates/mental-health-journal-template) -- which you then apply to your current note using the /dayEnd or /weekEnd command, and then fill in your responses. + +### Interactively +Alternatively, the **/dayReview**, **/weekReview**, **/monthReview**, **/quarterReview**, and/or **/yearReview** commands present you with a set of questions interactively. You first need to configure the sets of questions to ask in the plugin settings. In each case a default set of questions is provided to get you started. Each setting is explained: + +#### Journal Section Heading +The name of an existing markdown heading after which the review answers are added. If it doesn't exist, it is added at the end of the note. + +#### Daily / Weekly / Monthly / Quarterly / Year Journal Questions +This string includes both the questions and how to lay out the answers in the note. There are several possible question types: +- `` shows the text as a question, and if you answer "Yes" to the question, the text is included. If you answer "No" it isn't included. +- `` asks for an integer number +- `` asks for a number (which may include fractional part) +- `` asks for a string +- ``select one of the configured moods +- `` will include the rest of the text in the line and will be included in the output if you answer "Yes" to the "Yes"/"No" question about it +- `` includes the given string as `### Subheading`. + +Other notes: +- You can includes line breaks ('new lines') with `\n` characters. +- You can have multiple questions on a line by separating them with `||` (with or without extra spaces) in the settings string. The answers will also be placed on the one line, but with the `||` removed, and with a space separating them. +- You can hide the question text by starting it with a dash e.g. `-(thoughts) `. This will just output the entered text. (The identifier is there to work out which question the user currently is on.) ??? +- If a particular question isn't answered (i.e. no input entered), then that question isn't included in the output. +- If a particular question has already been answered in the note (i.e. if a line starts with the same question text), it won't be asked again. + +#### Moods +A comma-separated list of possible moods to select from. They don't have to have emoji, but I rather like them. + +### Example for /dayReview +The following `reviewQuestions` string: +``` +@sleep() || @work() +@fruitveg() || #stretches || #closedRings +Mood: + +Significant Thoughts +- (Thought 1/3) +- (Thought 2/3) +- (Thought 3/3) + +Gratitude +- (Gratitude 1/3) +- (Gratitude 2/3) +- (Gratitude 3/3) +``` +after answering the questions, would produce something like this in today's note: + +```markdown +## Journal +@sleep(6.8) @work(7) +@fruitveg(4) #stretches +Mood: 😇 Blessed + +### Significant Thoughts +- Entered thought 1 +- Another thought + +### Gratitude +- Thankful item 1 +- Thankful item 2 +- Isn't there lots to be thankful for! +``` +Tip: you can also avoid answering like in Thought 3/3 - then there is also no bullet point in the final note. + +## Support +If you find an issue with this plugin, or would like to suggest new features for it, please raise a [Bug or Feature 'Issue' in GitHub](https://github.com/NotePlan/plugins/issues). + +If you would like to support my late-night work extending NotePlan through writing these plugins, you can through: + +[Buy Me A Coffee](https://www.buymeacoffee.com/revjgc) + +Thanks! + +## History +Please see the [CHANGELOG](https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md). diff --git a/jgclark.Journalling/__tests__/journal.test.js b/jgclark.Journalling/__tests__/journal.test.js new file mode 100644 index 000000000..ef2e96c7d --- /dev/null +++ b/jgclark.Journalling/__tests__/journal.test.js @@ -0,0 +1,62 @@ +/* globals describe, expect, test, it, jest, beforeAll, beforeEach, afterEach */ + +import { parseQuestions } from '../src/journal' +import { DataStore } from '@mocks/index' + +beforeAll(() => { + // global.Calendar = Calendar + // global.Clipboard = Clipboard + // global.CommandBar = CommandBar + global.DataStore = DataStore + // global.Editor = Editor + // global.NotePlan = NotePlan + DataStore.settings['_logLevel'] = 'none' //change this to DEBUG to get more logging +}) + +const pluginJson = 'jgclark.Journalling' + +// Jest suite +describe('Journal', () => { + describe('parseQuestions', () => { + it('should parse questions correctly', () => { + const config = { + dailyReviewQuestions: `Health: @sleep() @fruitveg() #bible #stretches #closedRings +Work: @work() @1CB() @CRC() +Mood: +Gratitude: +God was: +Alive: +Not Great: +Learn: +Remember: ` + } + + const questions = parseQuestions(config.dailyReviewQuestions) + + expect(questions.length).toBe(15) + expect(questions[0].question).toBe('@sleep') + expect(questions[0].type).toBe('int') + expect(questions[0].lineIndex).toBe(0) + expect(questions[1].question).toBe('@fruitveg') + expect(questions[1].type).toBe('int') + expect(questions[1].lineIndex).toBe(0) + expect(questions[2].question).toBe('#bible') + expect(questions[2].type).toBe('boolean') + expect(questions[2].lineIndex).toBe(0) + expect(questions[3].question).toBe('#stretches') + expect(questions[3].type).toBe('boolean') + expect(questions[4].question).toBe('#closedRings') + expect(questions[4].type).toBe('boolean') + expect(questions[5].question).toBe('@work') + expect(questions[5].type).toBe('int') + expect(questions[6].question).toBe('@1CB') + expect(questions[6].type).toBe('int') + expect(questions[7].question).toBe('@CRC') + expect(questions[7].type).toBe('int') + expect(questions[8].question).toBe('Mood') + expect(questions[8].type).toBe('mood') + expect(questions[9].question).toBe('Gratitude') + expect(questions[9].type).toBe('string') + }) + }) +}) diff --git a/jgclark.Journalling/calendar-settings@2x.png b/jgclark.Journalling/calendar-settings@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..224686acedaa9eb9b61f7d95891686b9b8e8ef8b GIT binary patch literal 73359 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 WN$v0NaxaP*fWXt$&t;ucLK6Usx-O^y literal 0 HcmV?d00001 diff --git a/jgclark.Journalling/plugin.json b/jgclark.Journalling/plugin.json new file mode 100644 index 000000000..9f59d87cc --- /dev/null +++ b/jgclark.Journalling/plugin.json @@ -0,0 +1,347 @@ +{ + "noteplan.minAppVersion": "3.20.0", + "macOS.minVersion": "10.13.0", + "plugin.id": "jgclark.Journalling", + "plugin.name": "💭 Journalling & Reviews", + "plugin.description": "Supports you reviewing your days/weeks/months/quarters/years into NotePlan in a 'Journal' section. Also makes it easier to apply start-of-day/week/month/quarter/year templates. Before use it requires some configuration: please see the documentation for details.", + "plugin.icon": "fa-clipboard-list", + "plugin.iconColor": "blue-600", + "plugin.author": "Jonathan Clark", + "plugin.url": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/README.md", + "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md", + "plugin.version": "2.0.0.b1", + "plugin.releaseStatus": "beta", + "plugin.lastUpdateInfo": "v2.0.0: First release of the new Journalling & Reviews plugin, but building on earlier 'Journalling Helpers' plugin (v1).", + "plugin.requiredFiles": [ + "reviews.css" + ], + "plugin.requiredSharedFiles": [ + "fontawesome.css", + "regular.min.flat4NP.css", + "solid.min.flat4NP.css", + "fa-regular-400.woff2", + "fa-solid-900.woff2", + "pluginToHTMLCommsBridge.js" + ], + "plugin.script": "script.js", + "plugin.isRemote": "false", + "plugin.commands": [ + { + "name": "Daily Review", + "alias": [ + "dr", + "journal", + "review" + ], + "description": "Ask Journal questions for an end-of-day review (requires configuring)", + "jsFunction": "dailyJournalQuestions", + "sidebarView": { + "windowID": "jgclark.Journalling.dayReview", + "title": "Daily Review", + "icon": "fa-regular fa-clipboard-list", + "iconColor": "blue-600" + } + }, + { + "name": "Weekly Review", + "alias": [ + "wr", + "journal", + "review" + ], + "description": "Ask Journal questions for an end-of-week review (requires configuring)", + "jsFunction": "weeklyJournalQuestions" + }, + { + "name": "Monthly Review", + "alias": [ + "mr", + "journal", + "review" + ], + "description": "Ask Journal questions for an end-of-month review (requires configuring)", + "jsFunction": "monthlyJournalQuestions" + }, + { + "name": "Quarterly Review", + "alias": [ + "qr", + "journal", + "review" + ], + "description": "Ask Journal questions for an end-of-quarter review (requires configuring)", + "jsFunction": "quarterlyJournalQuestions" + }, + { + "name": "Yearly Review", + "alias": [ + "yr", + "journal", + "review" + ], + "description": "Ask Journal questions for an end-of-year review (requires configuring)", + "jsFunction": "yearlyJournalQuestions" + }, + { + "name": "dayStart", + "alias": [ + "daily", + "ds", + "startDay" + ], + "description": "Apply Daily Note Template", + "jsFunction": "dayStart" + }, + { + "name": "dayEnd", + "alias": [ + "daily", + "de", + "endDay" + ], + "description": "Apply Day End Template", + "jsFunction": "dayEnd" + }, + { + "name": "todayStart", + "alias": [ + "day", + "today", + "ts" + ], + "description": "Apply Daily Note Template to Today's Calendar Note", + "jsFunction": "todayStart" + }, + { + "name": "todayEnd", + "alias": [ + "day", + "endToday", + "te" + ], + "description": "Apply Day End Template to Today's Calendar Note", + "jsFunction": "todayEnd" + }, + { + "name": "weekStart", + "alias": [ + "weekly", + "ws" + ], + "description": "Apply Weekly Note Template", + "jsFunction": "weekStart" + }, + { + "name": "weekEnd", + "alias": [], + "description": "Apply Week End Template", + "jsFunction": "weekStart" + }, + { + "name": "monthStart", + "alias": [ + "monthly", + "ms" + ], + "description": "Apply Monthly Note Template (requires configuring)", + "jsFunction": "monthStart" + }, + { + "hidden": true, + "name": "onReviewWindowAction", + "description": "Callback path for review UI", + "jsFunction": "onReviewWindowAction", + "parameters": [ + "actionName ('cancel' or 'submit')", + "payload (object or string)" + ] + }, + { + "name": "Journalling: Test onUpdateOrInstall", + "description": "onUpdateOrInstall", + "jsFunction": "onUpdateOrInstall" + } + ], + "plugin.commands_disabled": [ + { + "name": "Journalling: update plugin settings", + "description": "Settings interface (even for iOS)", + "jsFunction": "updateSettings" + } + ], + "plugin.settings": [ + { + "type": "heading", + "title": "General settings" + }, + { + "key": "reviewSectionHeading", + "title": "Journal Section Heading", + "description": "The name of an existing markdown heading after which the Journal/Review answers are added - if it doesn't exist, it is added at the end of the note.", + "type": "string", + "default": "Journal", + "required": true + }, + { + "type": "separator" + }, + { + "type": "heading", + "title": "Journalling Review settings" + }, + { + "key": "preferredWindowType", + "title": "Review Window type", + "description": "Choose what style of window to use for the review questions: 'New Window' for a separate window; 'Main Window' to take over the main window; 'Split View' for a split view in the main window.", + "type": "string", + "choices": [ + "Main Window", + "New Window", + "Split View" + ], + "default": "New Window", + "required": true + }, + { + "key": "openCalendarNoteWhenReviewing", + "title": "Open the calendar note when reviewing it?", + "description": "If true, the plugin automatically opens the current daily/weekly/monthly/quarterly/yearly note before running review questions.", + "type": "bool", + "default": true, + "required": true + }, + { + "key": "moods", + "title": "List of moods", + "description": "(Optional.) A comma-separated list of possible moods to select from.", + "type": "string", + "default": "🤩 Great,🙂 Good,😇 Blessed,🥱 Tired,😫 Stressed,😤 Frustrated,😔 Low,🥵 Sick", + "required": false + }, + { + "key": "dailyReviewQuestions", + "title": "Daily Journal Questions", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "type": "string", + "default": "@sleep()\n@work()\n@fruitveg()\nMood: \nGratitude: \nGod was: \nAlive: \nNot Great: \nLearn: \nRemember: ", + "required": false + }, + { + "key": "weeklyReviewQuestions", + "title": "Weekly Journal Questions", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the weekly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "type": "string", + "default": "Big win: \nNew/improved: \nRegret: \nFocus for next week: ", + "required": false + }, + { + "key": "monthlyReviewQuestions", + "title": "Monthly Journal Questions", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the monthly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "type": "string", + "default": "What's working well in processes: \nProcesses that need work: Personal Goals progress: ", + "required": false + }, + { + "key": "quarterlyReviewQuestions", + "title": "Quarterly Journal Questions", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the quarterly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "type": "string", + "default": "Goals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", + "required": false + }, + { + "key": "yearlyReviewQuestions", + "title": "Yearly Journal Questions", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the yearly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "type": "string", + "default": "Goals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", + "required": false + }, + { + "type": "separator" + }, + { + "type": "heading", + "title": "Templates for Start/End of Period Notes" + }, + { + "key": "startDailyTemplateTitle", + "title": "Start-of-Day Template Title", + "description": "The name of the template that `/dayStart` and `/todayStart` commands will use.", + "type": "string", + "default": "Daily Note Template", + "required": false + }, + { + "key": "endDailyTemplateTitle", + "title": "End-of-DayTemplate Title", + "description": "The name of the template that `/dayEnd` and `/todayEnd` commands will use.", + "type": "string", + "default": "Daily Review Template", + "required": false + }, + { + "key": "startWeeklyTemplateTitle", + "title": "Start-of-Week Template Title", + "description": "Optional name of the template that `/weekStart` command will use.", + "type": "string", + "default": "Weekly Note Template", + "required": false + }, + { + "key": "endWeeklyTemplateTitle", + "title": "End-of-Week Template Title", + "description": "Optional name of the template that `/weekEnd` command will use.", + "type": "string", + "default": "Weekly Review Template", + "required": false + }, + { + "key": "startMonthlyTemplateTitle", + "title": "Start-of-Month Template Title", + "description": "Optional name of the template that `/monthStart` command will use.", + "type": "string", + "default": "Monthly Note Template", + "required": false + }, + { + "key": "endMonthlyTemplateTitle", + "title": "End-of-Month Template Title", + "description": "Optional name of the template that `/monthEnd` command will use.", + "type": "string", + "default": "Monthly Review Template", + "required": false + }, + { + "type": "separator" + }, + { + "type": "heading", + "title": "Debugging" + }, + { + "key": "_logLevel", + "title": "Log Level", + "description": "Set how much output will be displayed for this plugin in the NotePlan > Help > Plugin Console. DEBUG is the most verbose; NONE is the least (silent).", + "type": "string", + "choices": [ + "DEBUG", + "INFO", + "WARN", + "ERROR", + "none" + ], + "default": "INFO", + "required": true + }, + { + "key": "newInstall", + "title": "newInstall", + "description": "Indicates whether the plugin has been newly installed. Used by onUpdateOrInstall() to help migration.", + "type": "hidden", + "default": true, + "required": true + } + ] +} \ No newline at end of file diff --git a/jgclark.Journalling/requiredFiles/reviews.css b/jgclark.Journalling/requiredFiles/reviews.css new file mode 100644 index 000000000..2db3b4194 --- /dev/null +++ b/jgclark.Journalling/requiredFiles/reviews.css @@ -0,0 +1,152 @@ +body { + color: var(--fg-main-color, #4c4f69); + background-color: var(--bg-main-color, #eff1f5); + margin: 1.0rem; +} + +.section-wrap { + padding: 0.8rem 1.2rem; + border: 1px solid var(--divider-color, #CDCFD0); + border-radius: 6px; + background: var(--bg-alt-color, #e6e9ef); + color: var(--fg-main-color, #4c4f69); + margin: 1.0rem 0rem; +} + +.review-title { + margin: 0 0 1rem 1rem; + color: var(--h2-color, #5c5f77); +} + +.review-form { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.review-raw-question-line { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem 0.5rem; +} + +.review-raw-question-line-block { + flex: 1 1 100%; +} + +/* .review-line-text-fragment { +} +*/ + +.review-line-segment { + align-items: center; +} + +.review-input-inline { + /* width: auto; + max-width: 6rem; + vertical-align: middle; */ + margin: 0rem 0.2rem; +} +.review-subheading { + font-size: 1.3rem; + font-weight: 600; + color: var(--h3-color, #5c5f77); + margin-top: 0.9rem; + /* margin-left: 0.3rem; */ +} +.review-row { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.25rem; +} +.review-row-inline { + flex-direction: row; + align-items: center; + gap: 0.5rem; +} +.review-label { + /* margin-left: 0.4rem; */ + /* font-weight: 500; */ + /* color: var(--h3-color, #5c5f77); */ +} +.review-answer-inline { + flex: 0 0 auto; + display: flex; + align-items: center; +} +.review-answer { + width: 100%; +} +.review-input { + width: 100%; + resize: vertical; + font-family: "system-ui", sans-serif; + font-size: 0.9rem; +} +.review-input-short { + width: auto; + min-width: 2rem; + max-width: 4rem; + resize: none; +} +.review-input-fit { + width: fit-content; +} +.review-checkbox { + width: 1.1rem; + height: 1.1rem; + accent-color: var(--tint-color, #dc8a78); + cursor: pointer; +} +.review-actions { + display: flex; + justify-content: flex-end; + gap: 0.7rem; +} + +button, input, select, textarea { + font-size: 0.9rem; + font-family: "system-ui", sans-serif; + padding: 0.1rem 0.4rem; + color: var(--fg-main-color); + background-color: var(--bg-main-color); + border: 1px solid rgb(from var(--fg-main-color) r g b / 0.2); + border-radius: 4px; + white-space: nowrap; + cursor: pointer; + outline-offset: -1px; + outline-width: 1px; + box-sizing: border-box; + + /* set backgrounds a little lighter on hover */ + &:hover { + /* background-color: hsl(from var(--bg-sidebar-color) h s calc(l*1.4)); TODO(later): revert to this */ + /* background-color: color(var(--bg-sidebar-color) lightness(40%)); */ + filter: brightness(102%); + } +} + +/* textarea, input, select { + border-radius: 4px; + border: 1px solid var(--divider-color, #CDCFD0); + background-color: var(--bg-apple-input-color, #fbfbfb); + color: var(--fg-main-color, #4c4f69); + padding: 0.2rem 0.2rem; +} */ + +.review-button { + font-size: 1.0rem; + font-weight: 500; + border-radius: 6px; + padding: 0.25rem 0.5rem; + box-shadow: 1px 1px 1px 0px rgb(from var(--fg-main-color) r g b / 0.2); +} +.review-button-primary { + border-color: var(--tint-color, #dc8a78); + color: var(--tint-color, #dc8a78); + font-weight: 600; +} diff --git a/jgclark.Journalling/src/index.js b/jgclark.Journalling/src/index.js new file mode 100644 index 000000000..6eab37d62 --- /dev/null +++ b/jgclark.Journalling/src/index.js @@ -0,0 +1,105 @@ +// @flow + +//--------------------------------------------------------------- +// Journalling commands +// Jonathan Clark +// last update 2025-10-10 for v1.15.0 by @jgclark +//--------------------------------------------------------------- + +// allow changes in plugin.json to trigger recompilation +import pluginJson from '../plugin.json' +import { clo, compareObjects, JSP, logDebug, logInfo, logError } from "@helpers/dev" +import { backupSettings, getSettings, pluginUpdated, saveSettings } from '@helpers/NPConfiguration' +import { editSettings } from '@helpers/NPSettings' + +const pluginID = 'jgclark.Journalling' +const oldPluginID = 'jgclark.DailyJournal' + +export { + dailyJournalQuestions, + onReviewWindowAction, + weeklyJournalQuestions, + monthlyJournalQuestions, + quarterlyJournalQuestions, + yearlyJournalQuestions, +} from './journal' + +export { + dayStart, + dayEnd, + todayStart, + todayEnd, + weekStart, + weekEnd, + monthStart, +} from './templatesStartEnd' + +export function init(): void { + try { + // Check for the latest version of the plugin, and if a minor update is available, install it and show a message + DataStore.installOrUpdatePluginsByID([pluginJson['plugin.id']], true, false, false) + } catch (error) { + logError(pluginJson, `init: ${JSP(error)}`) + } +} + +export async function onSettingsUpdated(): Promise { + // Placeholder only to stop error in logs +} + +export async function onUpdateOrInstall(): Promise { + try { + logDebug(pluginJson, `onUpdateOrInstall() ...`) + const initialNewPluginSettings = (await getSettings(pluginID, DataStore.settings)) || DataStore.settings + + if (initialNewPluginSettings.newInstall || initialNewPluginSettings.newInstall === undefined) { + logDebug(pluginID, `onUpdateOrInstall: first run after install or newInstall is undefined`) + // Deal with first run after install: if the old plugin exists, copy its matching settings. + const oldPluginSettings = await getSettings(oldPluginID, null) + const migratedSettings = { ...initialNewPluginSettings } + + if (oldPluginSettings && Object.keys(oldPluginSettings).length > 0) { + await backupSettings(pluginID, `before_migration_from_old_plugin_to_new_v${pluginJson['plugin.version']}`, true) + Object.keys(initialNewPluginSettings).forEach((key) => { + if (key !== 'newInstall' && oldPluginSettings.hasOwnProperty(key)) { + migratedSettings[key] = oldPluginSettings[key] + } + }) + clo(migratedSettings, `onUpdateOrInstall: migratedSettings from ${oldPluginID}:`) + } else { + logDebug(pluginID, `onUpdateOrInstall: no settings found to migrate from ${oldPluginID}`) + } + + // Ensure migration runs only once. + migratedSettings.newInstall = false + // Save any changes + const diff = compareObjects(migratedSettings, initialNewPluginSettings, [], true) + if (diff != null) { + logInfo(pluginID, `onUpdateOrInstall: first-run settings changes detected; saving`) + await saveSettings(pluginID, migratedSettings) + } else { + logDebug(pluginID, `onUpdateOrInstall: first-run settings unchanged`) + } + } + + // Tell user the plugin has been updated + logInfo(pluginID, `... finished onUpdateOrInstall`) + await pluginUpdated(pluginJson, { code: 2, message: `Plugin Installed or Updated.` }) + } catch (error) { + logError(pluginID, `onUpdateOrInstall: ${JSP(error)}`) + } +} + +/** + * Update Settings/Preferences (for iOS etc) + * Plugin entrypoint for command: "/: Update Plugin Settings/Preferences" + * @author @dwertheimer + */ +export async function updateSettings() { + try { + logDebug(pluginJson, `updateSettings running`) + await editSettings(pluginJson) + } catch (error) { + logError(pluginJson, JSP(error)) + } +} diff --git a/jgclark.Journalling/src/journal.js b/jgclark.Journalling/src/journal.js new file mode 100644 index 000000000..6879664ac --- /dev/null +++ b/jgclark.Journalling/src/journal.js @@ -0,0 +1,542 @@ +// @flow +//--------------------------------------------------------------- +// Journalling commands +// Jonathan Clark +// last update 2026-03-23 for v2.0.0.b1 by @jgclark + @Cursor +//--------------------------------------------------------------- + +import strftime from 'strftime' +import pluginJson from '../plugin.json' +import { getJournalSettings } from './journalHelpers' +import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' +import { stylesheetinksInHeader, faLinksInHeader, buildReviewHTML } from './reviewHTMLViewGenerator' +import { showHTMLV2 } from '@helpers/HTMLView' +import type { HtmlWindowOptions } from '@helpers/HTMLView' +import { displayTitle } from '@helpers/general' +import { closeWindowFromCustomId } from '@helpers/NPWindows' +import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' +import { findHeadingStartsWith } from '@helpers/paragraph' +import { getWeek, getPeriodOfNPDateStr, isDailyNote, isWeeklyNote, isMonthlyNote, isQuarterlyNote, isYearlyNote } from '@helpers/dateTime' +import { clo, logDebug, logError, logWarn } from '@helpers/dev' +import { isInt, showMessage } from '@helpers/userInput' + +//--------------------------------------------------------------- +// Constants & Types + +const REVIEW_WINDOW_CUSTOM_ID = 'jgclark.Journalling.period-review' +const REVIEW_WINDOW_CALLBACK_COMMAND = 'onReviewWindowAction' + +//--------------------------------------------------------------- + +/** + * Gather answers to daily journal questions, and inserts at the cursor. + */ +export async function dailyJournalQuestions(): Promise { + try { + const thisPeriodStr = strftime(`%Y-%m-%d`) + logDebug(pluginJson, `Starting for day ${thisPeriodStr}`) + + await processJournalQuestions('day', 'Daily') + } catch (error) { + logError(pluginJson, error.message) + } +} + +/** + * Gather answers to weekly journal questions, and inserts at the cursor. + */ +export async function weeklyJournalQuestions(): Promise { + try { + const currentWeekNum = getWeek(new Date()) + const thisPeriodStr = `${strftime(`%Y`)}-W${currentWeekNum}` + logDebug(pluginJson, `Starting for week ${thisPeriodStr}`) + + await processJournalQuestions('week', 'Weekly') + } catch (error) { + logError(pluginJson, error.message) + } +} + +/** + * Gather answers to monthly journal questions, and inserts at the cursor. + */ +export async function monthlyJournalQuestions(): Promise { + try { + const thisPeriodStr = strftime(`%Y-%m`) + logDebug(pluginJson, `Starting for month ${thisPeriodStr}`) + + await processJournalQuestions('month', 'Monthly') + } catch (error) { + logError(pluginJson, error.message) + } +} + +/** + * Gather answers to quarterly journal questions, and inserts at the cursor. + */ +export async function quarterlyJournalQuestions(): Promise { + try { + const todaysDate = new Date() + const m = todaysDate.getMonth() // counting from 0 + const thisQ = Math.floor(m / 3) + 1 + const thisPeriodStr = `${strftime(`%Y`)}Q${String(thisQ)}` + logDebug(pluginJson, `Starting for quarter ${thisPeriodStr}`) + + await processJournalQuestions('quarter', 'Quarterly') + } catch (error) { + logError(pluginJson, error.message) + } +} + +/** + * Gather answers to yearly journal questions, and inserts at the cursor. + */ +export async function yearlyJournalQuestions(): Promise { + try { + const thisPeriodStr = strftime(`%Y`) + logDebug(pluginJson, `Starting for year ${thisPeriodStr}`) + + await processJournalQuestions('year', 'Yearly') + } catch (error) { + logError(pluginJson, error.message) + } +} + +//--------------------------------------------------------------- +// Private functions +//--------------------------------------------------------------- + +/** + * Ensure the correct period note is open, or open it automatically if configured. + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' + * @returns {TNote | null} the note, or null if not found + */ +function ensureCorrectPeriodNoteIsOpen( + periodType: string, + periodAdjective: string +): TNote | null { + // Open current calendar note if wanted + const { note } = Editor + logDebug('ensureCorrectPeriodNoteIsOpen', `current note=${String(note?.title ?? 'unknown')}`) + const currentNotePeriod = (note && note.type === 'Calendar') ? getPeriodOfNPDateStr(note.title ?? '') : '' + logDebug('ensureCorrectPeriodNoteIsOpen', `currentNotePeriod=${String(currentNotePeriod)}. Wanted: ${periodType}`) + if (currentNotePeriod === '' || currentNotePeriod === 'error' || currentNotePeriod !== periodType) { + Editor.openNoteByDate(new Date(), false, 0, 0, false, periodType) + logDebug('ensureCorrectPeriodNoteIsOpen', `Opened current ${periodAdjective} note automatically because openCalendarNoteWhenReviewing is true`) + return Editor.note ?? null + } else { + return note + } +} + +/** + * Get raw question lines for the given period from config. + * From v1.16, these may now contain multiple questions per line, separated by '||'. + * @param {JournalConfigType} config the journal configuration + * @param {string} period for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @returns {Promise>} array of question lines, or empty array if unsupported + */ +async function getQuestionsForPeriod(config: JournalConfigType, period: string): Promise> { + let rawQuestionLines: Array = [] + switch (period) { + case 'day': { + rawQuestionLines = config.dailyReviewQuestions.split('\n') + break + } + case 'week': { + rawQuestionLines = config.weeklyReviewQuestions.split('\n') + break + } + case 'month': { + rawQuestionLines = config.monthlyReviewQuestions.split('\n') + break + } + case 'quarter': { + rawQuestionLines = config.quarterlyReviewQuestions.split('\n') + break + } + case 'year': { + rawQuestionLines = config.yearlyReviewQuestions.split('\n') + break + } + default: { + logError(pluginJson, `${period} review questions aren't yet supported. Stopping.`) + await showMessage(`Sorry, ${period} review questions aren't yet supported.`) + return [] + } + } + logDebug(pluginJson, `rawQuestionLines: ${String(rawQuestionLines)}`) + return rawQuestionLines +} + +/** + * Parse question lines to extract questions and their types. + * Supports multiple questions per line separated by '||'. + * @param {Array | string} questionLines raw question lines from config + * @returns {Array<{question: string, type: string, originalLine: string, lineIndex: number}>} parsed questions with types and line index + */ +export function parseQuestions(questionLines: Array | string): Array { + const parsed = [] + const typeRE = new RegExp('<\\s*(string|int|number|boolean|mood|subheading)\\s*>', 'i') + const segmentRE = new RegExp('[^<]*?<\\s*(?:string|int|number|boolean|mood|subheading)\\s*>\\)?[^\\s]*', 'gi') + const linesToProcess = Array.isArray(questionLines) ? questionLines : String(questionLines ?? '').split('\n') + + // Process each line, splitting by '||' to support multiple questions per line + for (let lineIndex = 0; lineIndex < linesToProcess.length; lineIndex++) { + const line = linesToProcess[lineIndex] + // Split the line by '||' to get individual questions (allowing optional whitespace around ||) + const questionParts = line.split(/\s*\|\|\s*/).map(part => part.trim()).filter(part => part !== '') + + for (const questionPart of questionParts) { + const segments = questionPart.match(segmentRE) ?? [] + for (const segmentRaw of segments) { + const segment = segmentRaw.trim() + const reArray = segment.match(typeRE) + const questionType = reArray?.[1] ?? '' + // Prefer #/@ tokens when present (e.g. #bible, @sleep()) + const tokenMatch = segment.match(/([@#][^\s(<]+)/) + const question = tokenMatch?.[1] + ?? segment.replace(/\(|\)||||||/gi, '').trim() + logDebug(pluginJson, `- Q#${String(lineIndex)}: Line ${lineIndex}, type:${questionType} "${question}"`) + parsed.push({ question, type: questionType, originalLine: segment, lineIndex }) + } + } + } + + return parsed +} + +/** + * Handle a subheading question type. + * @param {string} question the question text + * @returns {string} the formatted subheading line + */ +function handleSubheadingQuestion(question: string): string { + return '\n### '.concat(question.replace(//, '')) +} + +/** + * Convert parsed questions into line-indexed groups. + * @param {Array} parsedQuestions + * @returns {{ [number]: Array }} + */ +function groupQuestionsByLine(parsedQuestions: Array): { [number]: Array } { + const questionsByLine: { [number]: Array } = {} + for (let i = 0; i < parsedQuestions.length; i++) { + const q = parsedQuestions[i] + if (!questionsByLine[q.lineIndex]) { + questionsByLine[q.lineIndex] = [] + } + questionsByLine[q.lineIndex].push(q) + } + return questionsByLine +} + +/** + * Wait for submit/cancel payload from single-window HTML form. + * @returns {Promise} + */ +function waitForReviewWindowPayload(): Promise { + return new Promise((resolve) => { + reviewWindowResolve = resolve + }) +} + +/** + * Convert answer payload from single window into output line for one parsed question. + * @param {ParsedQuestionType} parsedQuestion + * @param {string} answerRaw + * @returns {string} + */ +function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answerRaw: string): string { + const answer = answerRaw.trim() + if (answer === '') { + return '' + } + switch (parsedQuestion.type) { + case 'boolean': { + return answer === 'yes' ? parsedQuestion.question : '' + } + case 'int': { + if (isInt(answer)) { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + return '' + } + case 'number': { + if (answer != null && Number(answer)) { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + return '' + } + case 'string': { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + case 'mood': { + return parsedQuestion.originalLine.replace(//, answer) + } + case 'subheading': { + return handleSubheadingQuestion(parsedQuestion.question) + } + default: { + return '' + } + } +} + +/** + * Build output from answers returned by single-window mode. + * @param {Array} parsedQuestions + * @param {{ [string]: string }} answersByIndex + * @returns {string} + */ +function buildOutputFromReviewWindowAnswers(parsedQuestions: Array, answersByIndex: { [string]: string }): string { + let output = '' + const questionsByLine = groupQuestionsByLine(parsedQuestions) + const lineIndices = Object.keys(questionsByLine).map(Number).sort((a, b) => a - b) + + for (const lineIndex of lineIndices) { + const lineQuestions = questionsByLine[lineIndex] + const lineAnswers: Array = [] + for (let i = 0; i < lineQuestions.length; i++) { + const globalIndex = parsedQuestions.findIndex((q) => q === lineQuestions[i]) + const parsedQuestion = lineQuestions[i] + const resAQ = answerToQuestion(parsedQuestion.question) + if (resAQ !== '') { + logDebug(pluginJson, `- Found existing Q answer '${resAQ}', so won't ask again`) + continue + } + const answer = answerFromReviewWindowPayload(parsedQuestion, answersByIndex[`q_${globalIndex}`] ?? '') + if (answer !== '') { + lineAnswers.push(answer) + } + } + if (lineAnswers.length > 0) { + let combinedLine = lineAnswers.join(' ') + combinedLine = combinedLine.replace(/\s+/g, ' ') + output += `${combinedLine}\n` + } + } + return output +} + +/** + * Get the current calendar title string for a review period. + * @param {string} period + * @returns {string} + */ +function getReviewPeriodTitle(period: string): string { + const now = new Date() + switch (period) { + case 'day': + return strftime('%Y-%m-%d', now) + case 'week': + return `${strftime('%Y', now)}-W${getWeek(now)}` + case 'month': + return strftime('%Y-%m', now) + case 'quarter': { + const quarter = Math.floor(now.getMonth() / 3) + 1 + return `${strftime('%Y', now)}Q${quarter}` + } + case 'year': + return strftime('%Y', now) + default: + return Editor.note?.title ?? '' + } +} + +/** + * Display all questions in the window and return the answers. + * @param {Array} parsedQuestions + * @param {string} period + * @param {string} periodAdjective + * @param {JournalConfigType} config + * @param {Array} rawQuestionLines lines from getQuestionsForPeriod (same array passed to parseQuestions) + * @returns {Promise} + */ +async function displayQuestionsWindow( + parsedQuestions: Array, + period: string, + periodAdjective: string, + config: JournalConfigType, + rawQuestionLines: Array, +): Promise { + const periodTitle = getReviewPeriodTitle(period) + const htmlBody = buildReviewHTML(config, parsedQuestions, rawQuestionLines, periodAdjective, period, periodTitle, REVIEW_WINDOW_CALLBACK_COMMAND) + const preferredWindowType = config.preferredWindowType ?? 'New Window' + const windowOptions: HtmlWindowOptions = { + customId: REVIEW_WINDOW_CUSTOM_ID, + windowTitle: `${periodAdjective} Review for ${periodTitle}`, + headerTags: `${faLinksInHeader}${stylesheetinksInHeader}`, + savedFilename: `../../jgclark.Journalling/period-review-${period}.html`, + showInMainWindow: preferredWindowType !== 'New Window', + splitView: preferredWindowType === 'Split View', + showReloadButton: true, + reloadPluginID: pluginJson['plugin.id'], + reloadCommandName: REVIEW_WINDOW_CALLBACK_COMMAND, + icon: 'fa-regular fa-clipboard-list', + iconColor: 'blue-600', + autoTopPadding: true, + makeModal: false, + reuseUsersWindowRect: true, + width: 600, + height: 700, + shouldFocus: true, + generalCSSIn: generateCSSFromTheme(''), + } + const openSuccess = await showHTMLV2(htmlBody, windowOptions) + if (!openSuccess) { + throw new Error('Unable to open single-window review form') + } + const payload = await waitForReviewWindowPayload() + await closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) + if ((payload.action ?? '') === 'cancel') { + throw 'cancelled' + } + const answers = payload.answers ?? {} + return buildOutputFromReviewWindowAnswers(parsedQuestions, answers) +} + +/** + * Write the collected answers to the note: + * Add the finished review text to the current calendar note, appending after the line found in config.reviewSectionHeading. + * If the heading doesn't exist, then append it first. + * @param {JournalConfigType} config the journal configuration + * @param {string} periodString the calendar note title string for the review period + * @param {string} answersText the text to insert into the journal + */ +function writeAnswersToNote( + config: JournalConfigType, + periodString: string, + answersText: string, +): void { + // Get the correct Editor for the calendar note + // TODO: find the right existing helper function to use periodString to get the correct note + + // $FlowIgnore(incompatible-call) .note is a superset of CoreNoteFields + const outputNote = Editor + // $FlowIgnore[incompatible-call] .note is a superset of CoreNoteFields + logDebug(pluginJson, `Appending answers to heading '${config.reviewSectionHeading}' in note ${displayTitle(Editor.note)}`) + const matchedHeading = findHeadingStartsWith(outputNote, config.reviewSectionHeading) + outputNote.addParagraphBelowHeadingTitle(answersText, + 'empty', + matchedHeading ? matchedHeading : config.reviewSectionHeading, + true, + true) +} + +/** + * Process questions for the given period, and write to the current note. + * If we're not in the correct note, offer to open it first. + * @author @jgclark + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' + */ +async function processJournalQuestions( + periodType: string, + periodAdjective: string = '' +): Promise { + try { + // Get configuration and questions + const config: JournalConfigType = await getJournalSettings() + + // Ensure correct period note is open + const reviewNote = ensureCorrectPeriodNoteIsOpen(periodType, periodAdjective) + if (!reviewNote) { + await showMessage(`No ${periodAdjective} note found, so cannot continue.`) + throw new Error(`No ${periodAdjective} note found, so cannot continue.`) + } + + const periodString = reviewNote.title ?? '' + + const questionLines = await getQuestionsForPeriod(config, periodType) + + // Only continue if we have some questions + const numQs = questionLines.length + if (!questionLines || numQs === 0 || questionLines[0] === '') { + await showMessage(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) + throw new Error(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) + } + + logDebug(pluginJson, `Found ${numQs} question lines for ${periodType}`) + + // Parse questions (may result in multiple questions per line if '||' is used) + const parsedQuestions = parseQuestions(questionLines) + + const output = await displayQuestionsWindow(parsedQuestions, periodType, periodAdjective, config, questionLines) + + // Write answers to note + if (output !== '') { + writeAnswersToNote(config, periodString, output) + } else { + logWarn(pluginJson, 'No answers were collected from the review window') + } + } catch (err) { + if (err === 'cancelled') { + logDebug(pluginJson, `Asking questions cancelled by user: stopping.`) + } else { + logDebug(pluginJson, err.message) + } + } +} + +/** + * Callback function for HTML single-window review actions. + * @param {string} actionName + * @param {ReviewWindowPayloadType} payload + */ +export async function onReviewWindowAction(actionName: string, payload: string = ''): Promise { + logDebug(pluginJson, `onReviewWindowAction action=${actionName}`) + const config: JournalConfigType = await getJournalSettings() + if (actionName === 'cancel') { + logDebug('Journalling/onReviewWindowAction', `Cancelled by user.`) + closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) + return + } + + let safePayload: any = {} + try { + // Allow callback payloads sent via x-callback-url arg1 JSON string. + safePayload = JSON.parse(payload) + clo(safePayload, `onReviewWindowAction: parsed payload`) + const answers = safePayload.answers ?? {} + // Get the period type and string from the hidden fields in the payload + const periodType = safePayload.periodType ?? '' + const periodString = safePayload.periodString ?? '' + const questionLines = await getQuestionsForPeriod(config, periodType) + const parsedQuestions = parseQuestions(questionLines) + const output = buildOutputFromReviewWindowAnswers(parsedQuestions, answers) + if (output !== '') { + writeAnswersToNote(config, periodString, output) + } else { + logWarn(pluginJson, 'No answers were collected from the review window') + } + logDebug('Journalling/onReviewWindowAction', `Finished.`) + closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) + + } catch (err) { + logError(pluginJson, `onReviewWindowAction: couldn't parse payload JSON string: ${err.message}`) + } +} + +/** + * Look to see if this question has already been answered. + * If so return the line's content -- or empty string. + * @author @jgclark + * + * @param {string} question + * @returns {string} found answered question, or empty string + */ +function answerToQuestion(question: string): string { + const RE_Q = `${question}.+` + const { paragraphs } = Editor + let result = '' + for (const p of paragraphs) { + const m = p.content.match(RE_Q) + if (m != null) { + result = m[0] + } + } + return result +} diff --git a/jgclark.Journalling/src/journalHelpers.js b/jgclark.Journalling/src/journalHelpers.js new file mode 100644 index 000000000..3ad9213f2 --- /dev/null +++ b/jgclark.Journalling/src/journalHelpers.js @@ -0,0 +1,68 @@ +// @flow +//--------------------------------------------------------------- +// Helper functions for Journalling plugin for NotePlan +// Jonathan Clark +// last update 2025-10-16 for v1.0.1 by @jgclark +//--------------------------------------------------------------- + +import pluginJson from '../plugin.json' +import { clo, logDebug, logError, logInfo, logWarn } from '@helpers/dev' +import { showMessage } from '@helpers/userInput' + +//--------------------------------------------------------------- +// Constants & Types + +const pluginID = 'jgclark.Journalling' + +export type JournalConfigType = { + startDailyTemplateTitle: string, + endDailyTemplateTitle: string, + startWeeklyTemplateTitle: string, + endWeeklyTemplateTitle: string, + startMonthlyTemplateTitle: string, + endMonthlyTemplateTitle: string, + reviewSectionHeading: string, + openCalendarNoteWhenReviewing: boolean, + preferredWindowType: string, + reviewUIMode?: string, // legacy setting kept for backward compatibility + dailyReviewQuestions: string, + weeklyReviewQuestions: string, + monthlyReviewQuestions: string, + quarterlyReviewQuestions: string, + yearlyReviewQuestions: string, + moods: string +} + +export type ParsedQuestionType = { + question: string, + type: string, + originalLine: string, + lineIndex: number +} + +//--------------------------------------------------------------- +// Settings + +/** + * Get or make config settings + * @author @jgclark + */ +export async function getJournalSettings(): Promise { // want to use Promise but too many flow errors result + try { + // Get settings using Config system + const config: JournalConfigType = await DataStore.loadJSON(`../${pluginID}/settings.json`) + + if (config == null || Object.keys(config).length === 0) { + logError(pluginJson, `getJournalSettings() cannot find '${pluginID}' plugin settings. Stopping.`) + await showMessage(`Cannot find settings for the '${pluginID}' plugin. Please make sure you have installed it from the Plugin Preferences pane.`) + return + } else { + // clo(config, `${pluginID} settings:`) + return config + } + } + catch (error) { + logError(pluginJson, `getJournalSettings: ${error.message}`) + return // for completeness + } +} diff --git a/jgclark.Journalling/src/reviewHTMLViewGenerator.js b/jgclark.Journalling/src/reviewHTMLViewGenerator.js new file mode 100644 index 000000000..755ff1354 --- /dev/null +++ b/jgclark.Journalling/src/reviewHTMLViewGenerator.js @@ -0,0 +1,356 @@ +// @flow +//--------------------------------------------------------------- +// HTMLView generation helpers for single-window review mode +// Jonathan Clark + Cursor +// last update 2026-03-23 for v2.0.0.b1 by @jgclark + @Cursor +//--------------------------------------------------------------- + +import pluginJson from '../plugin.json' +import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' +import { makePluginCommandButton } from '@helpers/HTMLView.js' + +//----------------------------------------------------------------------------- +// Constants + +const useFlexbox = true + +// Keep in sync with parseQuestions() in journal.js (segment extraction). +const REVIEW_SEGMENT_RE = new RegExp('[^<]*?<\\s*(?:string|int|number|boolean|mood|subheading)\\s*>\\)?[^\\s]*', 'gi') + +//----------------------------------------------------------------------------- +// HTML template strings + +export const stylesheetinksInHeader: string = ` + + +` + +export const faLinksInHeader: string = ` + + + + + +` + +//----------------------------------------------------------------------------- +// Helper functions + +/** + * Escape text for safe insertion into HTML. + * @param {string} input + * @returns {string} + */ +function escapeHTML(input: string): string { + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * Remove display-only delimiter tokens from text before rendering. + * @param {string} input + * @returns {string} + */ +function stripPresentationDelimiters(input: string): string { + return input.replace(/ \|\| /g, ' ') +} + +/** + * Split a question segment at the typed marker (e.g. ``) so controls can sit inline with prefix/suffix text. + * @param {string} segment + * @param {string} questionType + * @returns {{ prefix: string, suffix: string }} + */ +function splitSegmentAtTypeMarker(segment: string, questionType: string): {| prefix: string, suffix: string |} { + const safeType = questionType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const re = new RegExp(`<\\s*${safeType}\\s*>`, 'i') + const m = segment.match(re) + if (!m || m.index == null) { + return { prefix: segment, suffix: '' } + } + const idx = m.index + const tagLen = m[0].length + return { prefix: segment.slice(0, idx), suffix: segment.slice(idx + tagLen) } +} + +/** + * Build checkbox / short input / mood select for inline placement (same classes as makeReviewQuestionRowDiv). + * @param {ParsedQuestionType} parsedQuestion + * @param {number} globalIndex + * @param {JournalConfigType} config + * @returns {string} + */ +function makeReviewInlineControl(parsedQuestion: ParsedQuestionType, globalIndex: number, config: JournalConfigType): string { + const fieldName = `q_${globalIndex}` + switch (parsedQuestion.type) { + case 'boolean': { + return `` + } + case 'int': + case 'number': { + return `` + } + case 'mood': { + const moodArray = typeof config.moods === 'string' ? config.moods.split(',').map((m) => m.trim()) : config.moods + const moodOptions = moodArray + .filter((m) => m !== '') + .map((mood) => ``) + .join('') + return `` + } + default: { + return '' + } + } +} + +/** + * Build one flex row for a raw config line: literal text and inline controls in original order. + * String questions use the same block layout as makeReviewQuestionRowDiv(); other types use inline controls. + * @param {string} rawLine + * @param {number} lineIndex + * @param {Array} parsedQuestions + * @param {JournalConfigType} config + * @returns {string} + */ +function makeReviewRawQuestionLineDiv( + rawLine: string, + lineIndex: number, + parsedQuestions: Array, + config: JournalConfigType, +): string { + const cleanRawLine = stripPresentationDelimiters(rawLine) + if (cleanRawLine.trim() === '') { + return '' + } + + const lineQuestionsOrdered: Array<{| q: ParsedQuestionType, globalIndex: number |}> = parsedQuestions + .map((q, globalIndex) => ({ q, globalIndex })) + .filter(({ q }) => q.lineIndex === lineIndex) + + const parts: Array = [] + let lastIndex = 0 + let segmentOrdinal = 0 + REVIEW_SEGMENT_RE.lastIndex = 0 + let match = REVIEW_SEGMENT_RE.exec(cleanRawLine) + while (match !== null) { + if (match.index > lastIndex) { + parts.push(`${escapeHTML(cleanRawLine.slice(lastIndex, match.index))}`) + } + const segmentTrimmed = match[0].trim() + const pair = lineQuestionsOrdered[segmentOrdinal] + segmentOrdinal += 1 + if (!pair) { + parts.push(`${escapeHTML(match[0])}`) + lastIndex = match.index + match[0].length + match = REVIEW_SEGMENT_RE.exec(cleanRawLine) + continue + } + const { q: pq, globalIndex } = pair + if (pq.type === 'string' || pq.type === 'subheading') { + parts.push(`
${makeReviewQuestionRowDiv(pq, globalIndex, config)}
`) + } else { + const { prefix, suffix } = splitSegmentAtTypeMarker(segmentTrimmed, pq.type) + const control = makeReviewInlineControl(pq, globalIndex, config) + parts.push( + `${escapeHTML(prefix)}${control}${escapeHTML(suffix)}`, + ) + } + lastIndex = match.index + match[0].length + match = REVIEW_SEGMENT_RE.exec(cleanRawLine) + } + + if (lastIndex < cleanRawLine.length) { + parts.push(`${escapeHTML(cleanRawLine.slice(lastIndex))}`) + } + + if (parts.length === 0) { + return `
${escapeHTML(cleanRawLine)}
` + } + return `
${parts.join('')}
` +} + +/** + * Build a single form row for one question (used for string, subheading, and legacy paths). + * Note: This assumes one question per line. *So no longer used for boolean/int/number/mood inline types (I hope).* + * @param {ParsedQuestionType} parsedQuestion + * @param {number} index + * @param {JournalConfigType} config + * @returns {string} + */ +function makeReviewQuestionRowDiv( + parsedQuestion: ParsedQuestionType, + index: number, + config: JournalConfigType +): string { + const fieldName = `q_${index}` + if (parsedQuestion.type === 'subheading') { + const cleanSubheading = stripPresentationDelimiters(parsedQuestion.question) + return `
${escapeHTML(cleanSubheading)}
` + } + + const questionText = stripPresentationDelimiters(parsedQuestion.question).trim() + const questionLabel = `` + let control = '' + const useInlineRow = useFlexbox && parsedQuestion.type !== 'string' && parsedQuestion.type !== 'subheading' + const rowClass = useInlineRow ? 'review-row review-row-inline' : 'review-row' + switch (parsedQuestion.type) { + case 'boolean': { + control = `` + break + } + case 'int': + case 'number': { + control = `` + break + } + case 'mood': { + const moodArray = (typeof config.moods === 'string') ? config.moods.split(',').map((m) => m.trim()) : config.moods + const moodOptions = moodArray + .filter((m) => m !== '') + .map((mood) => ``) + .join('') + control = `` + break + } + case 'string': + default: { + control = `` + break + } + } + return useInlineRow + ? `
${questionLabel}
${control}
` + : `
${questionLabel}
${control}
` +} + +/** + * Build HTML body for single-window review form. + * @param {JournalConfigType} config + * @param {Array} parsedQuestions same order as parseQuestions(rawQuestionLines) (field names q_0 …) + * @param {Array} rawQuestionLines lines from getQuestionsForPeriod() + * @param {string} periodAdjective + * @param {string} periodString the calendar note title string for the review period + * @param {string} callbackCommandName + * @returns {string} + */ +export function buildReviewHTML( + config: JournalConfigType, + parsedQuestions: Array, + rawQuestionLines: Array, + periodAdjective: string, + periodType: string, + periodString: string, + callbackCommandName: string, +): string { + const questionRows = rawQuestionLines + .map((line, lineIndex) => makeReviewRawQuestionLineDiv(line, lineIndex, parsedQuestions, config)) + .filter((row) => row !== '') + .join('\n') + return ` +

${escapeHTML(periodAdjective)} Review for ${periodString}

+ +
+
+ ${questionRows} +
+ + + + +
+
+
+ + + ` +} + diff --git a/jgclark.Journalling/src/templatesStartEnd.js b/jgclark.Journalling/src/templatesStartEnd.js new file mode 100644 index 000000000..e50d0093c --- /dev/null +++ b/jgclark.Journalling/src/templatesStartEnd.js @@ -0,0 +1,226 @@ +// @flow +//--------------------------------------------------------------- +// Add Templates to Periodic notes at start and end of period +// Jonathan Clark +// last update 2025-11-01 for v1.15.2 by @jgclark +//--------------------------------------------------------------- + +import { type JournalConfigType, getJournalSettings } from './journalHelpers' +import { isDailyNote, isMonthlyNote, isWeeklyNote } from '@helpers/dateTime' +import { logDebug, logError, logInfo } from '@helpers/dev' +import { displayTitle } from '@helpers/general' +import { getAttributes } from '@helpers/NPFrontMatter' +import { showMessage } from '@helpers/userInput' +import NPTemplating from 'NPTemplating' + +//--------------------------------------------------------------- + +// Configuration mapping for different note types +const NOTE_TYPE_CONFIG = { + day: { + noteType: 'day', + isNoteType: isDailyNote, + startTemplateKey: 'startDailyTemplateTitle', + endTemplateKey: 'endDailyTemplateTitle', + startCommandName: 'dayStart', + endCommandName: 'dayEnd' + }, + week: { + noteType: 'week', + isNoteType: isWeeklyNote, + startTemplateKey: 'startWeeklyTemplateTitle', + endTemplateKey: 'endWeeklyTemplateTitle', + startCommandName: 'weekStart', + endCommandName: 'weekEnd' + }, + month: { + noteType: 'month', + isNoteType: isMonthlyNote, + startTemplateKey: 'startMonthlyTemplateTitle', + endTemplateKey: 'endMonthlyTemplateTitle', + startCommandName: 'monthStart', + endCommandName: 'monthEnd' + } +} + +//--------------------------------------------------------------- + +/** + * Ensure the correct note type is open for template application + * @param {Function} isNoteType - Function to check if current note is correct type + * @param {string} noteType - Type of note ('day', 'week', 'month') + * @param {boolean} workToday - Whether to force opening today's note + */ +async function ensureCorrectNoteOpen(isNoteType: Function, noteType: string, workToday: boolean): Promise { + if (Editor.note && isNoteType(Editor.note) && !workToday) { + // $FlowIgnore(invalid-computed-property-type) .note is a superset of CoreNoteFields + logDebug('ensureCorrectNoteOpen', `Will work on the open ${noteType} note '${displayTitle(Editor.note)}'`) + } else { + logInfo('ensureCorrectNoteOpen', `Started without a ${noteType} note open, so will open and work in this ${noteType}'s note.`) + await Editor.openNoteByDate(new Date(), false, 0, 0, false, noteType) + // $FlowIgnore(invalid-computed-property-type) .note is a superset of CoreNoteFields + logDebug('ensureCorrectNoteOpen', `- for '${displayTitle(Editor.note)}'`) + } +} + +/** + * Render template and insert it into the current note + * @param {string} templateData - Raw template data + * @param {string} templateTitle - Name of the template + * @param {string} commandName - Name of the command for logging + */ +async function renderAndInsertTemplate( + templateData: string, + templateTitle: string, + commandName: string, +): Promise { + // Render the template, using recommended decoupled method of invoking a different plugin + const result = await DataStore.invokePluginCommandByName('renderTemplate', 'np.Templating', [templateTitle]) + // TEST: turning off error message for now, as it fires on Templates that only do background work. + // if (result == null || result === '') { + // throw new Error(`No result from running Template '${templateTitle}'. Stopping.`) + // } + + // Work out where to insert it in the note, by reading the template, and checking + // the frontmatter attributes for a 'location' field (append/insert/cursor) + const attrs = getAttributes(templateData, true) + const requestedTemplateLocation = attrs.location ?? 'insert' + let pos = 0 + switch (requestedTemplateLocation) { + case 'insert': { + logDebug(commandName, `- Will insert to start of Editor`) + Editor.insertTextAtCharacterIndex(result, 0) + break + } + case 'append': { + pos = Editor.content?.length ?? 0 // end + logDebug(commandName, `- Will insert to end of Editor (pos ${pos})`) + Editor.insertTextAtCharacterIndex(result, pos) + break + } + // Note: unsure if this works. + case 'cursor': { + logDebug(commandName, `- Will insert to Editor at cursor position`) + Editor.insertTextAtCursor(result) + break + } + } +} + +/** + * Generic template application function for different note types + * @param {string} noteType - Type of note ('day', 'week', 'month') + * @param {boolean} workToday - Whether to force opening today's note + * @param {boolean} isEnd - Whether to apply the end template + */ +async function applyTemplateToNote( + noteType: string, workToday: boolean = false, isEnd: boolean = false +): Promise { + try { + let config + switch (noteType) { + case 'day': + config = NOTE_TYPE_CONFIG.day + break + case 'week': + config = NOTE_TYPE_CONFIG.week + break + case 'month': + config = NOTE_TYPE_CONFIG.month + break + default: + throw new Error(`Unsupported note type: ${noteType}`) + } + + const journalConfig: JournalConfigType = await getJournalSettings() + + // First check we can get the Template + let templateTitle = '' + if (isEnd) { + switch (noteType) { + case 'day': + templateTitle = journalConfig.endDailyTemplateTitle + break + case 'week': + templateTitle = journalConfig.endWeeklyTemplateTitle + break + case 'month': + templateTitle = journalConfig.endMonthlyTemplateTitle + break + } + } else { + switch (noteType) { + case 'day': + templateTitle = journalConfig.startDailyTemplateTitle + break + case 'week': + templateTitle = journalConfig.startWeeklyTemplateTitle + break + case 'month': + templateTitle = journalConfig.startMonthlyTemplateTitle + break + } + } + if (!templateTitle || templateTitle === '') { + throw new Error(`There is no ${noteType} template specified in the plugin settings, so can't continue.`) + } + + const templateData = await NPTemplating.getTemplateContent(templateTitle) + if (templateData == null || templateData === '') { + throw new Error(`Cannot find Template '${templateTitle}' so can't continue.`) + } + + // Handle note opening + await ensureCorrectNoteOpen(config.isNoteType, config.noteType, workToday) + + // Render and insert template + const commandName = isEnd ? config.endCommandName : config.startCommandName + await renderAndInsertTemplate(templateData, templateTitle, commandName) + + } catch (error) { + logError('applyTemplateToNote', error.message) + await showMessage(`Error: ${error.message}`) + } +} + +//--------------------------------------------------------------- + +// Apply user's (start) Daily Note Template open daily note +export async function dayStart(workToday: boolean = false): Promise { + await applyTemplateToNote('day', workToday, false) +} + +// Apply user's (start) Daily Note Template to today's daily note +export async function todayStart(): Promise { + await dayStart(true) +} + +// Apply user's (start) Weekly Note Template to the open weekly note +export async function weekStart(): Promise { + await applyTemplateToNote('week', false, false) +} + +// Apply user's (start) Monthly Note Template to the open monthly note +export async function monthStart(): Promise { + await applyTemplateToNote('month', false, false) +} + +// Apply user's (end) Daily Note Template to the currently open daily note +export async function dayEnd(workToday: boolean = false): Promise { + await applyTemplateToNote('day', workToday, true) +} + +// Apply user's (end) Daily Note Template to today's daily note +export async function todayEnd(): Promise { + await dayEnd(true) +} + +// Apply user's (end) Weekly Note Template to the currently open weekly note +export async function weekEnd(): Promise { + await applyTemplateToNote('week', false, true) +} + +// Apply user's (end) Monthly Note Template to the currently open monthly note +export async function monthEnd(): Promise { + await applyTemplateToNote('month', false, true) +} From 4e2511d86d1709b1e3035181a1569aa8b9963b30 Mon Sep 17 00:00:00 2001 From: jgclark Date: Wed, 25 Mar 2026 00:12:38 +0000 Subject: [PATCH 02/19] b2: add pre-existing answers. Started period summary section for daily review. --- helpers/NPCalendar.js | 11 +- jgclark.Journalling/CHANGELOG.md | 14 +- jgclark.Journalling/__tests__/journal.test.js | 42 +- jgclark.Journalling/plugin.json | 9 +- jgclark.Journalling/requiredFiles/reviews.css | 60 ++- jgclark.Journalling/src/index.js | 3 +- jgclark.Journalling/src/journal.js | 449 +++++++++++++++--- .../src/reviewHTMLViewGenerator.js | 167 ++++++- jgclark.Reviews/src/projectsHTMLGenerator.js | 2 +- 9 files changed, 645 insertions(+), 112 deletions(-) diff --git a/helpers/NPCalendar.js b/helpers/NPCalendar.js index 9db30e48f..19d3a9706 100644 --- a/helpers/NPCalendar.js +++ b/helpers/NPCalendar.js @@ -17,6 +17,7 @@ import { addMinutes, differenceInMinutes } from 'date-fns' import { keepTodayPortionOnly, RE_EVENT_ID } from './calendar' import { + convertISOToYYYYMMDD, getDateFromYYYYMMDDString, getISODateStringFromYYYYMMDD, type HourMinObj, @@ -347,25 +348,25 @@ async function createEventFromDateRange(eventTitle: string, dateRange: DateRange } /** - * Get list of events for the given day (specified as YYYYMMDD). - * Now also filters out any that don't come from one of the calendars specified - * in calendarSet. + * Get list of events for the given day (specified as YYYYMMDD or YYYY-MM-DD). + * Now also filters out any that don't come from one of the calendars specified in calendarSet. * @author @jgclark * - * @param {string} dateStr YYYYMMDD date to use + * @param {string} dateStr YYYYMMDD or ISO date string or YYYY-Wnn date to use * @param {Array} calendarSet optional list of calendars * @param {HourMinObj} start optional start time in the day * @param {HourMinObj} end optional end time in the day * @return {Array} array of events as CalendarItems */ export async function getEventsForDay( - dateStr: string, + dateStrIn: string, calendarSet: Array = [], start: HourMinObj = { h: 0, m: 0 }, end: HourMinObj = { h: 23, m: 59 }, ): Promise | null> { try { // logDebug('NPCalendar / getEventsForDay', `starting with ${dateStr} ${calendarSet.toString()}`) + const dateStr = convertISOToYYYYMMDD(dateStrIn) clo(calendarSet) const y = parseInt(dateStr.slice(0, 4)) const m = parseInt(dateStr.slice(4, 6)) diff --git a/jgclark.Journalling/CHANGELOG.md b/jgclark.Journalling/CHANGELOG.md index a3b3d7bc6..74d2cd327 100644 --- a/jgclark.Journalling/CHANGELOG.md +++ b/jgclark.Journalling/CHANGELOG.md @@ -3,13 +3,23 @@ _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark. Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. -## [2.0.0.b1] - 2026-03-23 -### Added +## [2.0.0.b2] - 2026-03-24 +### Fixed +- Review window `textarea` controls now wrap text correctly (`reviews.css` had inherited `white-space: nowrap` from the shared form-control rule). + +### New +- When opening the review window, answers already present in the calendar note are pre-filled in the matching controls (under your **Review section heading** when that heading exists; otherwise the whole note is scanned). Latest matching paragraph wins so you can edit the most recent review block. +- Added a period summary list above review questions: daily shows Dashboard-style completed tasks from changed notes for that day, and week/month/quarter/year show only in-period done items tagged `#win` or `#bigwin`, rendered with multi-column circle-check entries. Period boundaries use `getFirstDateInPeriod` / `getLastDateInPeriod`; task text uses the same HTMLView conversion helpers as note HTML export (hashtags, mentions, links, etc.). Summary lines omit the `@done(…)` stamp for readability. +- Calendar event counts and timed duration in the summary use **EventHelpers** settings (`getEventsSettings`) and the same per-day `getEventsForDay` loop as EventHelpers’ `listDaysEvents`, with deduping for multi-day items. + +## [2.0.0.b1] - 2026-03-24 +### New - The **daily/weekly/monthly/...Review** commands now ask all their questions in a single window and writes answers to the review section in the usual format. It lays out the questions and spaces for answers as it will be added into the note, according to your settings. - As usual for my plugins, this picks up colours and fonts from your current NP Theme. - Added a `Review Window type` setting to choose the style of review window to use: 'New Window' for a separate window; 'Main Window' to take over the main window; 'Split View' for a split view in the main window. - Added `Open the calendar note when reviewing it?` setting (default: `true`) so review commands no longer ask the opening question. - The settings for review questions now no longer needs to have ` || ` delimiters. +- It will migrate settings from the old **Journalling Helpers** plugin on first install. ### Fixed - Fixed a single-window review callback bridge bug that generated extra quotes around `DataStore.invokePluginCommandByName(...)`, causing a runtime JavaScript `SyntaxError` when submitting or cancelling. diff --git a/jgclark.Journalling/__tests__/journal.test.js b/jgclark.Journalling/__tests__/journal.test.js index ef2e96c7d..7993f7b19 100644 --- a/jgclark.Journalling/__tests__/journal.test.js +++ b/jgclark.Journalling/__tests__/journal.test.js @@ -1,6 +1,6 @@ /* globals describe, expect, test, it, jest, beforeAll, beforeEach, afterEach */ -import { parseQuestions } from '../src/journal' +import { buildInitialReviewAnswersByFieldName, parseQuestions } from '../src/journal' import { DataStore } from '@mocks/index' beforeAll(() => { @@ -59,4 +59,44 @@ Remember: ` expect(questions[9].type).toBe('string') }) }) + + describe('buildInitialReviewAnswersByFieldName', () => { + it('should extract int, boolean, mood, and string answers from review-style lines', () => { + const config = { + dailyReviewQuestions: `Health: @sleep() @fruitveg() #bible #stretches #closedRings +Work: @work() @1CB() @CRC() +Mood: +Gratitude: +God was: `, + } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = [ + 'Health: @sleep(7) @fruitveg(5) #bible #stretches #closedRings', + 'Work: @work(8) @1CB(1) @CRC(2)', + 'Mood: Calm', + 'Gratitude: Family time', + 'God was: present', + ] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('7') + expect(initial.q_1).toBe('5') + expect(initial.q_2).toBe('yes') + expect(initial.q_3).toBe('yes') + expect(initial.q_4).toBe('yes') + expect(initial.q_5).toBe('8') + expect(initial.q_6).toBe('1') + expect(initial.q_7).toBe('2') + expect(initial.q_8).toBe('Calm') + expect(initial.q_9).toBe('Family time') + expect(initial.q_10).toBe('present') + }) + + it('should prefer the last matching line when several exist', () => { + const config = { dailyReviewQuestions: 'Gratitude: ' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['Gratitude: first', 'Gratitude: second'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('second') + }) + }) }) diff --git a/jgclark.Journalling/plugin.json b/jgclark.Journalling/plugin.json index 9f59d87cc..efe5d2056 100644 --- a/jgclark.Journalling/plugin.json +++ b/jgclark.Journalling/plugin.json @@ -9,7 +9,7 @@ "plugin.author": "Jonathan Clark", "plugin.url": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/README.md", "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md", - "plugin.version": "2.0.0.b1", + "plugin.version": "2.0.0.b2", "plugin.releaseStatus": "beta", "plugin.lastUpdateInfo": "v2.0.0: First release of the new Journalling & Reviews plugin, but building on earlier 'Journalling Helpers' plugin (v1).", "plugin.requiredFiles": [ @@ -157,7 +157,12 @@ ] }, { - "name": "Journalling: Test onUpdateOrInstall", + "name": "Journalling: writeAnswersToNote", + "description": "writeAnswersToNote", + "jsFunction": "writeAnswersToNote" + }, + { + "name": "Journalling: onUpdateOrInstall", "description": "onUpdateOrInstall", "jsFunction": "onUpdateOrInstall" } diff --git a/jgclark.Journalling/requiredFiles/reviews.css b/jgclark.Journalling/requiredFiles/reviews.css index 2db3b4194..922af4f78 100644 --- a/jgclark.Journalling/requiredFiles/reviews.css +++ b/jgclark.Journalling/requiredFiles/reviews.css @@ -18,6 +18,56 @@ body { color: var(--h2-color, #5c5f77); } +.summary { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.summary-title { + font-weight: 600; + color: var(--h3-color, #5c5f77); +} + +.summary-content { + column-count: 2; + column-gap: 1rem; + font-size: 0.85rem; +} + +.summary-item { + break-inside: avoid; + display: flex; + align-items: flex-start; + gap: 0.4rem; + margin-bottom: 0.35rem; +} + +.summary-item-icon { + color: #098308A0; /* TODO: set this to a theme colour */ + line-height: 1.2; + margin-top: 0.04rem; + flex-shrink: 0; +} + +.summary-item-text { + line-height: 1.25; + white-space: normal; + word-break: break-word; +} + +.summary-item-text .hashtag { + color: var(--hashtag-color, inherit); +} + +.summary-item-text .attag { + color: var(--attag-color, inherit); +} + +.summary-empty { + color: var(--fg-placeholder-color, rgba(76, 79, 105, 0.7)); +} + .review-form { display: flex; flex-direction: column; @@ -69,9 +119,7 @@ body { gap: 0.5rem; } .review-label { - /* margin-left: 0.4rem; */ - /* font-weight: 500; */ - /* color: var(--h3-color, #5c5f77); */ + font-weight: 500; } .review-answer-inline { flex: 0 0 auto; @@ -130,6 +178,12 @@ button, input, select, textarea { } } +/* Shared rule above uses white-space: nowrap for buttons/inputs; textareas must wrap. */ +textarea { + white-space: pre-wrap; + cursor: text; +} + /* textarea, input, select { border-radius: 4px; border: 1px solid var(--divider-color, #CDCFD0); diff --git a/jgclark.Journalling/src/index.js b/jgclark.Journalling/src/index.js index 6eab37d62..609e05e23 100644 --- a/jgclark.Journalling/src/index.js +++ b/jgclark.Journalling/src/index.js @@ -3,7 +3,7 @@ //--------------------------------------------------------------- // Journalling commands // Jonathan Clark -// last update 2025-10-10 for v1.15.0 by @jgclark +// last update 2026-03-24 for v2.0.0.b2 by @jgclark //--------------------------------------------------------------- // allow changes in plugin.json to trigger recompilation @@ -22,6 +22,7 @@ export { monthlyJournalQuestions, quarterlyJournalQuestions, yearlyJournalQuestions, + writeAnswersToNote // TODO(later): remove } from './journal' export { diff --git a/jgclark.Journalling/src/journal.js b/jgclark.Journalling/src/journal.js index 6879664ac..f46c36192 100644 --- a/jgclark.Journalling/src/journal.js +++ b/jgclark.Journalling/src/journal.js @@ -10,15 +10,18 @@ import pluginJson from '../plugin.json' import { getJournalSettings } from './journalHelpers' import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' import { stylesheetinksInHeader, faLinksInHeader, buildReviewHTML } from './reviewHTMLViewGenerator' +import { RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, getWeek, getPeriodOfNPDateStr, isDailyNote, isWeeklyNote, isMonthlyNote, isQuarterlyNote, isYearlyNote } from '@helpers/dateTime' +import { clo, logDebug, logError, logWarn } from '@helpers/dev' +import { displayTitle } from '@helpers/general' import { showHTMLV2 } from '@helpers/HTMLView' import type { HtmlWindowOptions } from '@helpers/HTMLView' -import { displayTitle } from '@helpers/general' -import { closeWindowFromCustomId } from '@helpers/NPWindows' +import { getEventsForDay } from '@helpers/NPCalendar' +import { getFirstDateInPeriod, getLastDateInPeriod } from '@helpers/NPdateTime' +import { getNotesChangedInInterval } from '@helpers/NPnote' import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' +import { closeWindowFromCustomId } from '@helpers/NPWindows' import { findHeadingStartsWith } from '@helpers/paragraph' -import { getWeek, getPeriodOfNPDateStr, isDailyNote, isWeeklyNote, isMonthlyNote, isQuarterlyNote, isYearlyNote } from '@helpers/dateTime' -import { clo, logDebug, logError, logWarn } from '@helpers/dev' -import { isInt, showMessage } from '@helpers/userInput' +import { getInput, isInt, showMessage } from '@helpers/userInput' //--------------------------------------------------------------- // Constants & Types @@ -36,7 +39,7 @@ export async function dailyJournalQuestions(): Promise { const thisPeriodStr = strftime(`%Y-%m-%d`) logDebug(pluginJson, `Starting for day ${thisPeriodStr}`) - await processJournalQuestions('day', 'Daily') + await processJournalQuestions(thisPeriodStr, 'day', 'Daily') } catch (error) { logError(pluginJson, error.message) } @@ -51,7 +54,7 @@ export async function weeklyJournalQuestions(): Promise { const thisPeriodStr = `${strftime(`%Y`)}-W${currentWeekNum}` logDebug(pluginJson, `Starting for week ${thisPeriodStr}`) - await processJournalQuestions('week', 'Weekly') + await processJournalQuestions(thisPeriodStr, 'week', 'Weekly') } catch (error) { logError(pluginJson, error.message) } @@ -65,7 +68,7 @@ export async function monthlyJournalQuestions(): Promise { const thisPeriodStr = strftime(`%Y-%m`) logDebug(pluginJson, `Starting for month ${thisPeriodStr}`) - await processJournalQuestions('month', 'Monthly') + await processJournalQuestions(thisPeriodStr, 'month', 'Monthly') } catch (error) { logError(pluginJson, error.message) } @@ -82,7 +85,7 @@ export async function quarterlyJournalQuestions(): Promise { const thisPeriodStr = `${strftime(`%Y`)}Q${String(thisQ)}` logDebug(pluginJson, `Starting for quarter ${thisPeriodStr}`) - await processJournalQuestions('quarter', 'Quarterly') + await processJournalQuestions(thisPeriodStr, 'quarter', 'Quarterly') } catch (error) { logError(pluginJson, error.message) } @@ -96,7 +99,7 @@ export async function yearlyJournalQuestions(): Promise { const thisPeriodStr = strftime(`%Y`) logDebug(pluginJson, `Starting for year ${thisPeriodStr}`) - await processJournalQuestions('year', 'Yearly') + await processJournalQuestions(thisPeriodStr, 'year', 'Yearly') } catch (error) { logError(pluginJson, error.message) } @@ -188,7 +191,9 @@ export function parseQuestions(questionLines: Array | string): Array part.trim()).filter(part => part !== '') + let questionNum = 0 for (const questionPart of questionParts) { + questionNum++ const segments = questionPart.match(segmentRE) ?? [] for (const segmentRaw of segments) { const segment = segmentRaw.trim() @@ -197,8 +202,8 @@ export function parseQuestions(questionLines: Array | string): Array, @sleep()) const tokenMatch = segment.match(/([@#][^\s(<]+)/) const question = tokenMatch?.[1] - ?? segment.replace(/\(|\)||||||/gi, '').trim() - logDebug(pluginJson, `- Q#${String(lineIndex)}: Line ${lineIndex}, type:${questionType} "${question}"`) + ?? segment.replace(/:|\(|\)||||||/gi, '').trim() + // logDebug(pluginJson, `- Q#${questionNum}: Line ${lineIndex}, type:${questionType} "${question}"`) parsed.push({ question, type: questionType, originalLine: segment, lineIndex }) } } @@ -216,6 +221,149 @@ function handleSubheadingQuestion(question: string): string { return '\n### '.concat(question.replace(//, '')) } +/** + * Escape a string for use inside a RegExp. + * @param {string} s + * @returns {string} + */ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Split a question segment at the typed marker (same idea as reviewHTMLViewGenerator.splitSegmentAtTypeMarker). + * @param {string} segment + * @param {string} questionType + * @returns {{ prefix: string, suffix: string }} + */ +function splitParsedSegmentAtTypeMarker(segment: string, questionType: string): {| prefix: string, suffix: string |} { + const safeType = questionType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const re = new RegExp(`<\\s*${safeType}\\s*>`, 'i') + const m = segment.match(re) + if (!m || m.index == null) { + return { prefix: segment, suffix: '' } + } + const idx = m.index + const tagLen = m[0].length + return { prefix: segment.slice(0, idx), suffix: segment.slice(idx + tagLen) } +} + +/** + * Paragraph text lines to scan for existing review answers: after review heading if present, else whole note. + * @param {TNote} note + * @param {string} reviewSectionHeading + * @returns {Array} + */ +function getParagraphLineContentsForReviewScan(note: TNote, reviewSectionHeading: string): Array { + const paragraphs = note.paragraphs ?? [] + if (!reviewSectionHeading || reviewSectionHeading.trim() === '') { + return paragraphs.map((p) => p.content) + } + const headingToFindLC = reviewSectionHeading.toLowerCase() + let startIdx = -1 + for (let i = 0; i < paragraphs.length; i++) { + const paragraph = paragraphs[i] + if (paragraph.type !== 'title') { + continue + } + const c = paragraph.content.toLowerCase() + if (c.startsWith(headingToFindLC) || headingToFindLC === c || headingToFindLC.startsWith(c)) { + startIdx = i + break + } + } + if (startIdx < 0) { + return paragraphs.map((p) => p.content) + } + return paragraphs.slice(startIdx + 1).map((p) => p.content) +} + +/** + * Parse one line of note content for a single parsed question's answer (form-ready value). + * @param {ParsedQuestionType} parsedQuestion + * @param {string} line + * @returns {string} value for the HTML control, or '' if not found on this line + */ +function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: string): string { + const t = parsedQuestion.type.toLowerCase() + const seg = parsedQuestion.originalLine.trim() + if (t === 'subheading') { + return '' + } + if (t === 'boolean') { + const token = parsedQuestion.question + if (!token) { + return '' + } + const re = new RegExp(`(?:^|\\s)${escapeRegExp(token)}(?=\\s|$)`) + return re.test(line) ? 'yes' : '' + } + const { prefix, suffix } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) + if (t === 'int') { + const re = new RegExp(`${escapeRegExp(prefix)}(\\d+)${escapeRegExp(suffix)}`) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } + if (t === 'number') { + const re = new RegExp( + `${escapeRegExp(prefix)}([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)${escapeRegExp(suffix)}`, + ) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } + if (t === 'mood' || t === 'string') { + if (suffix === '') { + const idx = line.indexOf(prefix) + if (idx < 0) { + return '' + } + return line.slice(idx + prefix.length).trim() + } + const re = new RegExp(`${escapeRegExp(prefix)}(.*?)${escapeRegExp(suffix)}`) + const m = line.match(re) + return m?.[1] != null ? m[1].trim() : '' + } + return '' +} + +/** + * Latest matching answer in the note for one question (scans newest-to-oldest by paragraph order). + * @param {ParsedQuestionType} parsedQuestion + * @param {Array} paragraphLines + * @returns {string} + */ +function extractExistingAnswerForReviewForm(parsedQuestion: ParsedQuestionType, paragraphLines: Array): string { + for (let i = paragraphLines.length - 1; i >= 0; i--) { + const line = paragraphLines[i] + const v = extractExistingAnswerOnLine(parsedQuestion, line) + if (v !== '') { + return v + } + } + return '' +} + +/** + * Map field names q_0, q_1, … to existing answers in the calendar note for pre-filling the review HTML form. + * @param {Array} parsedQuestions + * @param {Array} paragraphLines lines to scan (e.g. from getParagraphLineContentsForReviewScan) + * @returns {{ [string]: string }} + */ +export function buildInitialReviewAnswersByFieldName( + parsedQuestions: Array, + paragraphLines: Array, +): { [string]: string } { + const out: { [string]: string } = {} + for (let globalIndex = 0; globalIndex < parsedQuestions.length; globalIndex++) { + const pq = parsedQuestions[globalIndex] + const v = extractExistingAnswerForReviewForm(pq, paragraphLines) + if (v !== '') { + out[`q_${globalIndex}`] = v + } + } + return out +} + /** * Convert parsed questions into line-indexed groups. * @param {Array} parsedQuestions @@ -233,31 +381,23 @@ function groupQuestionsByLine(parsedQuestions: Array): { [nu return questionsByLine } -/** - * Wait for submit/cancel payload from single-window HTML form. - * @returns {Promise} - */ -function waitForReviewWindowPayload(): Promise { - return new Promise((resolve) => { - reviewWindowResolve = resolve - }) -} - /** * Convert answer payload from single window into output line for one parsed question. * @param {ParsedQuestionType} parsedQuestion - * @param {string} answerRaw + * @param {string | boolean} answerRaw * @returns {string} */ -function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answerRaw: string): string { - const answer = answerRaw.trim() +function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answerRaw: string | boolean): string { + const t = parsedQuestion.type + if (t === 'boolean') { + const on = answerRaw === true || answerRaw === 'yes' + return on ? parsedQuestion.question : '' + } + const answer = (typeof answerRaw === 'string' ? answerRaw : String(answerRaw ?? '')).trim() if (answer === '') { return '' } - switch (parsedQuestion.type) { - case 'boolean': { - return answer === 'yes' ? parsedQuestion.question : '' - } + switch (t) { case 'int': { if (isInt(answer)) { return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) @@ -288,10 +428,10 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe /** * Build output from answers returned by single-window mode. * @param {Array} parsedQuestions - * @param {{ [string]: string }} answersByIndex + * @param {{ [string]: string | boolean }} answersByIndex * @returns {string} */ -function buildOutputFromReviewWindowAnswers(parsedQuestions: Array, answersByIndex: { [string]: string }): string { +function buildOutputFromReviewWindowAnswers(parsedQuestions: Array, answersByIndex: { [string]: string | boolean }): string { let output = '' const questionsByLine = groupQuestionsByLine(parsedQuestions) const lineIndices = Object.keys(questionsByLine).map(Number).sort((a, b) => a - b) @@ -347,29 +487,175 @@ function getReviewPeriodTitle(period: string): string { } /** - * Display all questions in the window and return the answers. + * Map review title strings to forms understood by getFirstDateInPeriod / getLastDateInPeriod (e.g. `2026Q1` -> `2026-Q1`). + * TODO: Revisit why this is necessary. + * @param {string} periodTitle + * @returns {string} + */ +function normalizeReviewPeriodTitleForNPDateHelpers(periodTitle: string): string { + const compactQuarter = periodTitle.match(/^(\d{4})Q([1-4])$/i) + if (compactQuarter) { + return `${compactQuarter[1]}-Q${compactQuarter[2]}` + } + return periodTitle +} + +// /** +// * Stable key for deduping calendar items returned on multiple days (e.g. multi-day events). +// * @param {TCalendarItem} ev +// * @returns {string} +// */ +// function calendarItemDedupeKey(ev: TCalendarItem): string { +// if (ev.id != null && String(ev.id) !== '') { +// return String(ev.id) +// } +// const t = ev.date instanceof Date ? ev.date.getTime() : 0 +// return `${ev.title ?? ''}\0${String(t)}\0${ev.calendar ?? ''}` +// } + +// /** +// * Calendar events for [startISO..endISO] using EventHelpers settings + the same per-day fetch as `listDaysEvents`. +// * @param {string} periodType +// * @param {string} periodString +// * @returns {Promise>} +// */ +// async function getListOfEventsForPeriod(_periodType: string, periodString: string): Promise> { +// const npPeriodKey = normalizeReviewPeriodTitleForNPDateHelpers(periodString) +// const startISO = getFirstDateInPeriod(npPeriodKey) +// const endISO = getLastDateInPeriod(npPeriodKey) +// if (startISO === '(error)' || endISO === '(error)') { +// logWarn(pluginJson, `getListOfEventsForPeriod: could not parse period "${periodString}"`) +// return [] +// } +// try { +// logDebug(pluginJson, `getListOfEventsForPeriod: ${_periodType} / ${periodString}`) +// const eventsConfig = await getEventsSettings() +// const calendarSet: Array = Array.isArray(eventsConfig.calendarSet) ? eventsConfig.calendarSet : [] +// let yyyymmdd = startISO.replace(/-/g, '') +// const endYYYYMMDD = endISO.replace(/-/g, '') +// const collected: Array = [] +// const seen = new Set() +// while (yyyymmdd <= endYYYYMMDD) { +// const dayEvents = (await getEventsForDay(yyyymmdd, calendarSet)) ?? [] +// for (const ev of dayEvents) { +// const k = calendarItemDedupeKey(ev) +// if (seen.has(k)) { +// continue +// } +// seen.add(k) +// collected.push(ev) +// } +// if (yyyymmdd === endYYYYMMDD) { +// break +// } +// yyyymmdd = calcOffsetDateStr(yyyymmdd, '+1d') +// } +// return collected +// } catch (err) { +// logError(pluginJson, `getListOfEventsForPeriod: ${err.message}`) +// return [] +// } +// } + +/** + * Collect completed task lines for review summary, according to period rules. + * - day: include all done lines whose @done date matches the day + * - week/month/quarter/year: include done lines in period with #win or #bigwin + * @param {string} periodType + * @param {string} periodString + * @returns {Array} + */ +function getSummaryCompletedTasks(periodType: string, periodString: string): Array { + const npPeriodKey = normalizeReviewPeriodTitleForNPDateHelpers(periodString) + const startISO = getFirstDateInPeriod(npPeriodKey) + const endISO = getLastDateInPeriod(npPeriodKey) + if (startISO === '(error)' || endISO === '(error)') { + logWarn(pluginJson, `getSummaryCompletedTasks: could not parse period "${periodString}"`) + return [] + } + const periodStartMs = new Date(`${startISO}T12:00:00`).getTime() + const lookbackDaysRaw = Math.ceil((Date.now() - periodStartMs) / 86400000) + 1 + const lookbackDays = Math.min(Math.max(0, lookbackDaysRaw), 400) + const isDailyPeriod = periodType === 'day' + const hasWinTag = (content: string): boolean => /(?:^|\s)#(?:bigwin|win)\b/i.test(content) + const notesToScan = getNotesChangedInInterval(lookbackDays, ['Calendar', 'Notes']) + const matchingLines: Array = [] + + for (const note of notesToScan) { + for (const para of note.paragraphs) { + if (para.type !== 'done') { + continue + } + const doneDateMatch = para.content.match(RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE) + const doneDate = doneDateMatch?.[1] ?? '' + if (doneDate === '') { + continue + } + const isInPeriod = doneDate >= startISO && doneDate <= endISO + if (!isInPeriod) { + continue + } + if (!isDailyPeriod && !hasWinTag(para.content)) { + continue + } + matchingLines.push(para.content) + } + } + + return matchingLines +} + +/** + * Display all questions in the window. + * Note: does not return the answers -- see separate function for that. * @param {Array} parsedQuestions - * @param {string} period - * @param {string} periodAdjective + * @param {string} periodString + * @param {string} periodType + * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' * @param {JournalConfigType} config * @param {Array} rawQuestionLines lines from getQuestionsForPeriod (same array passed to parseQuestions) - * @returns {Promise} + * @param {TNote} calendarNote the calendar note to scan for answers + * @returns {void} */ async function displayQuestionsWindow( parsedQuestions: Array, - period: string, + periodString: string, + periodType: string, periodAdjective: string, config: JournalConfigType, rawQuestionLines: Array, -): Promise { - const periodTitle = getReviewPeriodTitle(period) - const htmlBody = buildReviewHTML(config, parsedQuestions, rawQuestionLines, periodAdjective, period, periodTitle, REVIEW_WINDOW_CALLBACK_COMMAND) + calendarNote: TNote, +): Promise { + // Get the data sources we need for the review window + // const periodTitle = getReviewPeriodTitle(periodType) + const summaryCompletedTasks = (periodAdjective === 'Day') ? getSummaryCompletedTasks(periodType, periodString) : [] + const calendarSet: Array = ['Jonathan (iCloud)', 'Us (iCloud)'] + const eventsForPeriod: Array = (periodType === 'Day') ? await getEventsForDay('2026-03-28', calendarSet) ?? [] : [] + clo(eventsForPeriod, 'eventsForPeriod') + const scanLines = getParagraphLineContentsForReviewScan(calendarNote, config.reviewSectionHeading ?? '') + const initialAnswers = buildInitialReviewAnswersByFieldName(parsedQuestions, scanLines) + + // Build the HTML body for the review window from this data + const htmlBody = buildReviewHTML( + config, + parsedQuestions, + rawQuestionLines, + summaryCompletedTasks, + periodString, + periodType, + periodAdjective, + eventsForPeriod, + REVIEW_WINDOW_CALLBACK_COMMAND, + initialAnswers, + ) + + // Set the options and then open the review window const preferredWindowType = config.preferredWindowType ?? 'New Window' const windowOptions: HtmlWindowOptions = { customId: REVIEW_WINDOW_CUSTOM_ID, - windowTitle: `${periodAdjective} Review for ${periodTitle}`, + windowTitle: `${periodAdjective} Review for ${periodString}`, headerTags: `${faLinksInHeader}${stylesheetinksInHeader}`, - savedFilename: `../../jgclark.Journalling/period-review-${period}.html`, + savedFilename: `../../jgclark.Journalling/period-review-${periodType}.html`, showInMainWindow: preferredWindowType !== 'New Window', splitView: preferredWindowType === 'Split View', showReloadButton: true, @@ -389,13 +675,8 @@ async function displayQuestionsWindow( if (!openSuccess) { throw new Error('Unable to open single-window review form') } - const payload = await waitForReviewWindowPayload() - await closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) - if ((payload.action ?? '') === 'cancel') { - throw 'cancelled' - } - const answers = payload.answers ?? {} - return buildOutputFromReviewWindowAnswers(parsedQuestions, answers) + + // That's it. The answers will be written to the note by the callback function. } /** @@ -405,37 +686,59 @@ async function displayQuestionsWindow( * @param {JournalConfigType} config the journal configuration * @param {string} periodString the calendar note title string for the review period * @param {string} answersText the text to insert into the journal + * TODO(later): revert to being private, and take out of index.js & plugin.json */ -function writeAnswersToNote( - config: JournalConfigType, - periodString: string, - answersText: string, -): void { - // Get the correct Editor for the calendar note - // TODO: find the right existing helper function to use periodString to get the correct note - - // $FlowIgnore(incompatible-call) .note is a superset of CoreNoteFields - const outputNote = Editor - // $FlowIgnore[incompatible-call] .note is a superset of CoreNoteFields - logDebug(pluginJson, `Appending answers to heading '${config.reviewSectionHeading}' in note ${displayTitle(Editor.note)}`) - const matchedHeading = findHeadingStartsWith(outputNote, config.reviewSectionHeading) - outputNote.addParagraphBelowHeadingTitle(answersText, - 'empty', - matchedHeading ? matchedHeading : config.reviewSectionHeading, - true, - true) +export async function writeAnswersToNote( + periodStringIn: string = '', + answersTextIn: string = '', +): Promise { + try { + const config: JournalConfigType = await getJournalSettings() + let periodString = periodStringIn ?? '' + if (periodString === '') { + periodString = Editor.note?.title ?? '' + } + let answersText = answersTextIn ?? '' + if (answersText === '') { + const result = await getInput('No answers were collected from the review window. Please enter them manually here:', 'OK', 'Enter answers', '') + if (result === false) { + throw new Error('No answers were collected from the review window') + } else { + answersText = String(result) + } + } + + // Get the correct Editor for the calendar note + // TODO: find the right existing helper function to use periodString to get the correct note + + // $FlowIgnore(incompatible-call) .note is a superset of CoreNoteFields + const outputNote = Editor + // $FlowIgnore[incompatible-call] .note is a superset of CoreNoteFields + logDebug(pluginJson, `Appending answers to heading '${config.reviewSectionHeading}' in note ${displayTitle(Editor.note)}`) + const matchedHeading = findHeadingStartsWith(outputNote, config.reviewSectionHeading) + outputNote.addParagraphBelowHeadingTitle( + answersText, + 'empty', + matchedHeading ? matchedHeading : config.reviewSectionHeading, + true, + true) + } catch (err) { + logError(pluginJson, `writeAnswersToNote: ${err.message}`) + } } /** * Process questions for the given period, and write to the current note. * If we're not in the correct note, offer to open it first. * @author @jgclark + * @param {string} periodString the calendar note title string for the review period * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' */ async function processJournalQuestions( + periodString: string, periodType: string, - periodAdjective: string = '' + periodAdjective: string ): Promise { try { // Get configuration and questions @@ -448,8 +751,6 @@ async function processJournalQuestions( throw new Error(`No ${periodAdjective} note found, so cannot continue.`) } - const periodString = reviewNote.title ?? '' - const questionLines = await getQuestionsForPeriod(config, periodType) // Only continue if we have some questions @@ -464,14 +765,10 @@ async function processJournalQuestions( // Parse questions (may result in multiple questions per line if '||' is used) const parsedQuestions = parseQuestions(questionLines) - const output = await displayQuestionsWindow(parsedQuestions, periodType, periodAdjective, config, questionLines) + await displayQuestionsWindow(parsedQuestions, periodString, periodType, periodAdjective, config, questionLines, reviewNote) // Write answers to note - if (output !== '') { - writeAnswersToNote(config, periodString, output) - } else { - logWarn(pluginJson, 'No answers were collected from the review window') - } + // Answers are applied in onReviewWindowAction when the user saves the HTML form. } catch (err) { if (err === 'cancelled') { logDebug(pluginJson, `Asking questions cancelled by user: stopping.`) @@ -508,7 +805,7 @@ export async function onReviewWindowAction(actionName: string, payload: string = const parsedQuestions = parseQuestions(questionLines) const output = buildOutputFromReviewWindowAnswers(parsedQuestions, answers) if (output !== '') { - writeAnswersToNote(config, periodString, output) + await writeAnswersToNote(periodString, output) } else { logWarn(pluginJson, 'No answers were collected from the review window') } diff --git a/jgclark.Journalling/src/reviewHTMLViewGenerator.js b/jgclark.Journalling/src/reviewHTMLViewGenerator.js index 755ff1354..d048eb74b 100644 --- a/jgclark.Journalling/src/reviewHTMLViewGenerator.js +++ b/jgclark.Journalling/src/reviewHTMLViewGenerator.js @@ -5,9 +5,23 @@ // last update 2026-03-23 for v2.0.0.b1 by @jgclark + @Cursor //--------------------------------------------------------------- +import moment from 'moment' import pluginJson from '../plugin.json' import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' -import { makePluginCommandButton } from '@helpers/HTMLView.js' +import { RE_DONE_DATE_OPT_TIME } from '@helpers/dateTime' +import { clo, logDebug, logInfo, logError, logWarn } from '@helpers/dev' +import { + convertBoldAndItalicToHTML, + convertHashtagsToHTML, + convertHighlightsToHTML, + convertMentionsToHTML, + convertUnderlinedToHTML, + makePluginCommandButton, + replaceMarkdownLinkWithHTMLLink, + simplifyInlineImagesForHTML, + simplifyNPEventLinksForHTML, +} from '@helpers/HTMLView.js' +import { RE_SYNC_MARKER } from '@helpers/regex' //----------------------------------------------------------------------------- // Constants @@ -17,6 +31,9 @@ const useFlexbox = true // Keep in sync with parseQuestions() in journal.js (segment extraction). const REVIEW_SEGMENT_RE = new RegExp('[^<]*?<\\s*(?:string|int|number|boolean|mood|subheading)\\s*>\\)?[^\\s]*', 'gi') +/** Remove @done(…) from summary lines (date with optional time), global. */ +const RE_DONE_MENTION_STRIP_FOR_SUMMARY_G = new RegExp(RE_DONE_DATE_OPT_TIME.source, 'gi') + //----------------------------------------------------------------------------- // HTML template strings @@ -59,6 +76,27 @@ function stripPresentationDelimiters(input: string): string { return input.replace(/ \|\| /g, ' ') } +/** + * Format one done-task line for HTML using the same NotePlan-oriented conversions as getNoteContentAsHTML (minus full-note showdown). + * @param {string} taskLine + * @returns {string} + */ +function formatTaskAsHTML(taskLine: string): string { + let line = taskLine.replace(RE_DONE_MENTION_STRIP_FOR_SUMMARY_G, '').replace(/\s{2,}/g, ' ').trim() + line = line.replace(RE_SYNC_MARKER, '') + line = line.trimRight() + line = convertBoldAndItalicToHTML(line) + line = replaceMarkdownLinkWithHTMLLink(line) + line = simplifyNPEventLinksForHTML(line) + line = simplifyInlineImagesForHTML(line) + line = convertHashtagsToHTML(line) + line = convertMentionsToHTML(line) + line = convertHighlightsToHTML(line) + line = line.replace(/\[\[([^\]]+)\]\]/g, (_match, title) => `~${String(title)}~`) + line = convertUnderlinedToHTML(line) + return line +} + /** * Split a question segment at the typed marker (e.g. ``) so controls can sit inline with prefix/suffix text. * @param {string} segment @@ -84,21 +122,30 @@ function splitSegmentAtTypeMarker(segment: string, questionType: string): {| pre * @param {JournalConfigType} config * @returns {string} */ -function makeReviewInlineControl(parsedQuestion: ParsedQuestionType, globalIndex: number, config: JournalConfigType): string { +function makeReviewInlineControl( + parsedQuestion: ParsedQuestionType, + globalIndex: number, + config: JournalConfigType, + initialValue: string = '', +): string { const fieldName = `q_${globalIndex}` + const checkedAttr = initialValue === 'yes' ? ' checked' : '' switch (parsedQuestion.type) { case 'boolean': { - return `` + return `` } case 'int': case 'number': { - return `` + return `` } case 'mood': { const moodArray = typeof config.moods === 'string' ? config.moods.split(',').map((m) => m.trim()) : config.moods const moodOptions = moodArray .filter((m) => m !== '') - .map((mood) => ``) + .map((mood) => { + const selectedAttr = mood === initialValue ? ' selected' : '' + return `` + }) .join('') return `` + control = `` break } case 'int': case 'number': { - control = `` + control = `` break } case 'mood': { const moodArray = (typeof config.moods === 'string') ? config.moods.split(',').map((m) => m.trim()) : config.moods const moodOptions = moodArray .filter((m) => m !== '') - .map((mood) => ``) + .map((mood) => { + const selectedAttr = mood === initialValue ? ' selected' : '' + return `` + }) .join('') control = `` + control = `` break } } @@ -239,31 +356,39 @@ function makeReviewQuestionRowDiv( * @param {JournalConfigType} config * @param {Array} parsedQuestions same order as parseQuestions(rawQuestionLines) (field names q_0 …) * @param {Array} rawQuestionLines lines from getQuestionsForPeriod() + * @param {Array} summaryCompletedTasks * @param {string} periodAdjective + * @param {string} periodType * @param {string} periodString the calendar note title string for the review period + * @param {Array} eventsForPeriod * @param {string} callbackCommandName + * @param {{ [string]: string }=} initialAnswers field names q_0 … to pre-fill from the calendar note * @returns {string} */ export function buildReviewHTML( config: JournalConfigType, parsedQuestions: Array, rawQuestionLines: Array, - periodAdjective: string, - periodType: string, + summaryCompletedTasks: Array, periodString: string, + periodType: string, + periodAdjective: string, + eventsForPeriod: Array, callbackCommandName: string, + initialAnswers?: { [string]: string }, ): string { + const resolvedInitialAnswers = initialAnswers ?? {} const questionRows = rawQuestionLines - .map((line, lineIndex) => makeReviewRawQuestionLineDiv(line, lineIndex, parsedQuestions, config)) + .map((line, lineIndex) => makeReviewRawQuestionLineDiv(line, lineIndex, parsedQuestions, config, resolvedInitialAnswers)) .filter((row) => row !== '') .join('\n') + const possibleSummarySection = (periodType === 'day') ? makePeriodSummaryDiv(periodType, summaryCompletedTasks, eventsForPeriod) : '' + return ` -

${escapeHTML(periodAdjective)} Review for ${periodString}

- +

${escapeHTML(periodAdjective)} Review for ${escapeHTML(periodString)}

+ + ${possibleSummarySection} +
${questionRows} diff --git a/jgclark.Reviews/src/projectsHTMLGenerator.js b/jgclark.Reviews/src/projectsHTMLGenerator.js index 0efa8fd7a..bc81db215 100644 --- a/jgclark.Reviews/src/projectsHTMLGenerator.js +++ b/jgclark.Reviews/src/projectsHTMLGenerator.js @@ -344,7 +344,7 @@ function formatProjectTitleForStyle(thisProject: Project, style: string, config: // Method 1: make [[notelinks]] via x-callbacks // Method 2: x-callback using note title // Method 3: x-callback using filename - // Note: using an "onclick="window.location.href='${noteOpenActionURL}'" handler instead of an anchor tag doesn't work in the NP constrained environment. + // Note: using an "onclick="window.location.href='${noteOpenActionURL}'" handler instead of an anchor tag doesn't work in the NP constrained environment. (Is this still true?) // Note: now using splitView if running in the main window on macOS const noteOpenActionURL = createOpenOrDeleteNoteCallbackUrl(thisProject.filename, "filename", "", "splitView", false) const extraClasses = (thisProject.isCompleted) ? 'checked' : (thisProject.isCancelled) ? 'cancelled' : (thisProject.isPaused) ? 'paused' : '' From 3a89cd7308e4c96707b313a96a7e395c04b201ef Mon Sep 17 00:00:00 2001 From: jgclark Date: Wed, 25 Mar 2026 23:58:31 +0000 Subject: [PATCH 03/19] b3 add

--- helpers/NPCalendar.js | 10 +- jgclark.Journalling/CHANGELOG.md | 9 +- jgclark.Journalling/README.md | 33 ++- jgclark.Journalling/__tests__/journal.test.js | 104 ++++++++- jgclark.Journalling/plugin.json | 24 +- jgclark.Journalling/requiredFiles/reviews.css | 13 +- jgclark.Journalling/src/index.js | 1 + jgclark.Journalling/src/journal.js | 214 ++++++++++++++++-- jgclark.Journalling/src/journalHelpers.js | 9 +- .../src/reviewHTMLViewGenerator.js | 61 +++-- 10 files changed, 406 insertions(+), 72 deletions(-) diff --git a/helpers/NPCalendar.js b/helpers/NPCalendar.js index 19d3a9706..b40406acb 100644 --- a/helpers/NPCalendar.js +++ b/helpers/NPCalendar.js @@ -356,6 +356,7 @@ async function createEventFromDateRange(eventTitle: string, dateRange: DateRange * @param {Array} calendarSet optional list of calendars * @param {HourMinObj} start optional start time in the day * @param {HourMinObj} end optional end time in the day + * @param {boolean} includeAllDayEvents optional include all-day events (default: false) * @return {Array} array of events as CalendarItems */ export async function getEventsForDay( @@ -363,9 +364,10 @@ export async function getEventsForDay( calendarSet: Array = [], start: HourMinObj = { h: 0, m: 0 }, end: HourMinObj = { h: 23, m: 59 }, + includeAllDayEvents: boolean = false, ): Promise | null> { try { - // logDebug('NPCalendar / getEventsForDay', `starting with ${dateStr} ${calendarSet.toString()}`) + logDebug('NPCalendar / getEventsForDay', `starting with ${dateStrIn} ${calendarSet.toString()}`) const dateStr = convertISOToYYYYMMDD(dateStrIn) clo(calendarSet) const y = parseInt(dateStr.slice(0, 4)) @@ -376,10 +378,16 @@ export async function getEventsForDay( // logDebug('NPCalendar / getEventsForDay', `starting for period ${startOfDay.toString()} - ${endOfDay.toString()}`) let eArr: Array = await Calendar.eventsBetween(startOfDay, endOfDay) const allEventCount = eArr.length + // logDebug('NPCalendar / getEventsForDay', `- ${allEventCount} events found (before filtering)`) // Filter out parts of multi-day events not in today eArr = keepTodayPortionOnly(eArr, getDateFromYYYYMMDDString(dateStr) ?? new Date()) + // Filter out all-day events if not wanted + if (!includeAllDayEvents) { + eArr = eArr.filter((e) => !e.isAllDay) + } + // logDebug('NPCalendar / getEventsForDay', `- ${eArr.length} events kept (after filtering out all-day events)`) // If we have a calendarSet list, use to weed out events that don't match .calendar if (calendarSet && calendarSet.length > 0) { eArr = eArr.filter((e) => calendarSet.some((c) => e.calendar === c)) diff --git a/jgclark.Journalling/CHANGELOG.md b/jgclark.Journalling/CHANGELOG.md index 74d2cd327..b5801df53 100644 --- a/jgclark.Journalling/CHANGELOG.md +++ b/jgclark.Journalling/CHANGELOG.md @@ -3,11 +3,12 @@ _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark. Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. -## [2.0.0.b2] - 2026-03-24 -### Fixed -- Review window `textarea` controls now wrap text correctly (`reviews.css` had inherited `white-space: nowrap` from the shared form-control rule). +## [2.0.0.b3] - 2026-03-25 +- New review question types: `` (each answer line written with a `- ` prefix), `` (`+ ` per line), and `` (`* ` per line). The review window uses a multi-line field; empty lines are skipped. Answers already in the calendar note are pre-filled with markers stripped. +- Headings in review settings are now output as HTML headings: `` outputs an `

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

` / `

` with `review-subheading` classes. +- `` placeholder is now supported in review question lines and is substituted with the relevant review note title string in both the window and output. -### New +## [2.0.0.b2] - 2026-03-24 - When opening the review window, answers already present in the calendar note are pre-filled in the matching controls (under your **Review section heading** when that heading exists; otherwise the whole note is scanned). Latest matching paragraph wins so you can edit the most recent review block. - Added a period summary list above review questions: daily shows Dashboard-style completed tasks from changed notes for that day, and week/month/quarter/year show only in-period done items tagged `#win` or `#bigwin`, rendered with multi-column circle-check entries. Period boundaries use `getFirstDateInPeriod` / `getLastDateInPeriod`; task text uses the same HTMLView conversion helpers as note HTML export (hashtags, mentions, links, etc.). Summary lines omit the `@done(…)` stamp for readability. - Calendar event counts and timed duration in the summary use **EventHelpers** settings (`getEventsSettings`) and the same per-day `getEventsForDay` loop as EventHelpers’ `listDaysEvents`, with deduping for multi-day items. diff --git a/jgclark.Journalling/README.md b/jgclark.Journalling/README.md index b903c49e8..63dc67836 100644 --- a/jgclark.Journalling/README.md +++ b/jgclark.Journalling/README.md @@ -45,13 +45,18 @@ This string includes both the questions and how to lay out the answers in the no - `` asks for an integer number - `` asks for a number (which may include fractional part) - `` asks for a string +- `` asks for one or more lines; each non-empty line is written to the note as a markdown bullet (`- `) +- `` same, but each line is written with a checklist marker (`+ `) +- `` same, but each line is written with a task marker (`* `) - ``select one of the configured moods - `` will include the rest of the text in the line and will be included in the output if you answer "Yes" to the "Yes"/"No" question about it -- `` includes the given string as `### Subheading`. +- `` is not a question: it is replaced in the window and output with the review note title (e.g. `2026-03-25`, `2026-03` or `2026-W13`). +- `

` and `

` (or the legacy equivalent ``) includes the given string as a usual H2 or H3 markdown heading. +- You can also use markdown heading lines directly in the settings, for example `## Heading` and `### Subheading`. Other notes: - You can includes line breaks ('new lines') with `\n` characters. -- You can have multiple questions on a line by separating them with `||` (with or without extra spaces) in the settings string. The answers will also be placed on the one line, but with the `||` removed, and with a space separating them. +- You can have multiple questions of type ``, `` or `` on a line, with any text around them. The answers will also be placed on the one line, but with the `||` removed, and with a space separating them. - You can hide the question text by starting it with a dash e.g. `-(thoughts) `. This will just output the entered text. (The identifier is there to work out which question the user currently is on.) ??? - If a particular question isn't answered (i.e. no input entered), then that question isn't included in the output. - If a particular question has already been answered in the note (i.e. if a line starts with the same question text), it won't be asked again. @@ -62,19 +67,15 @@ A comma-separated list of possible moods to select from. They don't have to hav ### Example for /dayReview The following `reviewQuestions` string: ``` -@sleep() || @work() -@fruitveg() || #stretches || #closedRings +@sleep() @work() +@fruitveg() #stretches #closedRings Mood: Significant Thoughts -- (Thought 1/3) -- (Thought 2/3) -- (Thought 3/3) - -Gratitude -- (Gratitude 1/3) -- (Gratitude 2/3) -- (Gratitude 3/3) + + +Focus areas for next week

+ ``` after answering the questions, would produce something like this in today's note: @@ -88,12 +89,10 @@ Mood: 😇 Blessed - Entered thought 1 - Another thought -### Gratitude -- Thankful item 1 -- Thankful item 2 -- Isn't there lots to be thankful for! +## Focus areas for next week ++ Area 1 ++ Area 2 ``` -Tip: you can also avoid answering like in Thought 3/3 - then there is also no bullet point in the final note. ## Support If you find an issue with this plugin, or would like to suggest new features for it, please raise a [Bug or Feature 'Issue' in GitHub](https://github.com/NotePlan/plugins/issues). diff --git a/jgclark.Journalling/__tests__/journal.test.js b/jgclark.Journalling/__tests__/journal.test.js index 7993f7b19..7dcbebe16 100644 --- a/jgclark.Journalling/__tests__/journal.test.js +++ b/jgclark.Journalling/__tests__/journal.test.js @@ -1,5 +1,7 @@ /* globals describe, expect, test, it, jest, beforeAll, beforeEach, afterEach */ +// Last updated: 2026-03-25 for v2.0.0.b3 by @Cursor + import { buildInitialReviewAnswersByFieldName, parseQuestions } from '../src/journal' import { DataStore } from '@mocks/index' @@ -18,7 +20,7 @@ const pluginJson = 'jgclark.Journalling' // Jest suite describe('Journal', () => { describe('parseQuestions', () => { - it('should parse questions correctly', () => { + it('should parse questions correctly (test 1)', () => { const config = { dailyReviewQuestions: `Health: @sleep() @fruitveg() #bible #stretches #closedRings Work: @work() @1CB() @CRC() @@ -58,6 +60,88 @@ Remember: ` expect(questions[9].question).toBe('Gratitude') expect(questions[9].type).toBe('string') }) + + it('should parse bullets, checklists, and tasks types', () => { + const raw = `Wins: +Next: +Do: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(3) + expect(questions[0].type).toBe('bullets') + expect(questions[1].type).toBe('checklists') + expect(questions[2].type).toBe('tasks') + expect(questions[0].question).toBe('Wins') + }) + + it('should parse markdown headings (## / ###) into h2/h3', () => { + const raw = `## Top Heading\n### Sub Heading\nTitle: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(3) + expect(questions[0].type).toBe('h2') + expect(questions[0].question).toBe('Top Heading') + expect(questions[0].lineIndex).toBe(0) + expect(questions[1].type).toBe('h3') + expect(questions[1].question).toBe('Sub Heading') + expect(questions[1].lineIndex).toBe(1) + expect(questions[2].type).toBe('string') + expect(questions[2].question).toBe('Title') + expect(questions[2].lineIndex).toBe(2) + }) + + it('should parse h2/h3 types in various positions', () => { + const raw = `

Top Heading\n

Sub Heading\nTitle

` + const questions = parseQuestions(raw) + expect(questions.length).toBe(3) + expect(questions[0].type).toBe('h2') + expect(questions[0].question).toBe('Top Heading') + expect(questions[0].lineIndex).toBe(0) + expect(questions[1].type).toBe('h3') + expect(questions[1].question).toBe('Sub Heading') + expect(questions[1].lineIndex).toBe(1) + expect(questions[2].type).toBe('string') + expect(questions[2].question).toBe('Title') + expect(questions[2].lineIndex).toBe(2) + }) + + it('should cope with lines which are just / / / ', () => { + const config = { + dailyReviewQuestions: ` + +

H2 Heading + +### H3 Heading +`, + } + const questions = parseQuestions(config.dailyReviewQuestions) + expect(questions.length).toBe(6) + expect(questions[0].type).toBe('string') + expect(questions[0].question).toBe('') + expect(questions[0].lineIndex).toBe(0) + expect(questions[1].type).toBe('bullets') + expect(questions[1].question).toBe('') + expect(questions[1].lineIndex).toBe(1) + expect(questions[2].type).toBe('h2') + expect(questions[2].question).toBe('H2 Heading') + expect(questions[2].lineIndex).toBe(2) + expect(questions[3].type).toBe('checklists') + expect(questions[3].question).toBe('') + expect(questions[3].lineIndex).toBe(3) + expect(questions[4].type).toBe('h3') + expect(questions[4].question).toBe('H3 Heading') + expect(questions[4].lineIndex).toBe(4) + expect(questions[5].type).toBe('tasks') + expect(questions[5].question).toBe('') + expect(questions[5].lineIndex).toBe(5) + }) + + it('should ignore as a question token', () => { + const raw = `For: \nMood: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('mood') + expect(questions[0].question).toBe('Mood') + expect(questions[0].lineIndex).toBe(1) + }) }) describe('buildInitialReviewAnswersByFieldName', () => { @@ -98,5 +182,23 @@ God was: `, const initial = buildInitialReviewAnswersByFieldName(questions, lines) expect(initial.q_0).toBe('second') }) + + it('should extract multiline bullets, checklists, and tasks for pre-fill', () => { + const config = { + dailyReviewQuestions: `Wins: +Shop: +Ship: `, + } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = [ + 'Wins: - a\n- b', + 'Shop: + eggs\n+ milk', + 'Ship: * task one\n* task two', + ] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('a\nb') + expect(initial.q_1).toBe('eggs\nmilk') + expect(initial.q_2).toBe('task one\ntask two') + }) }) }) diff --git a/jgclark.Journalling/plugin.json b/jgclark.Journalling/plugin.json index efe5d2056..3c1375eda 100644 --- a/jgclark.Journalling/plugin.json +++ b/jgclark.Journalling/plugin.json @@ -4,12 +4,12 @@ "plugin.id": "jgclark.Journalling", "plugin.name": "💭 Journalling & Reviews", "plugin.description": "Supports you reviewing your days/weeks/months/quarters/years into NotePlan in a 'Journal' section. Also makes it easier to apply start-of-day/week/month/quarter/year templates. Before use it requires some configuration: please see the documentation for details.", - "plugin.icon": "fa-clipboard-list", + "plugin.icon": "clipboard-list", "plugin.iconColor": "blue-600", "plugin.author": "Jonathan Clark", "plugin.url": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/README.md", "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md", - "plugin.version": "2.0.0.b2", + "plugin.version": "2.0.0.b3", "plugin.releaseStatus": "beta", "plugin.lastUpdateInfo": "v2.0.0: First release of the new Journalling & Reviews plugin, but building on earlier 'Journalling Helpers' plugin (v1).", "plugin.requiredFiles": [ @@ -38,7 +38,7 @@ "sidebarView": { "windowID": "jgclark.Journalling.dayReview", "title": "Daily Review", - "icon": "fa-regular fa-clipboard-list", + "icon": "clipboard-list", "iconColor": "blue-600" } }, @@ -215,6 +215,14 @@ "default": true, "required": true }, + { + "key": "calendarSet", + "title": "Calendars to include in review summaries", + "description": "Comma-separated list of calendar names to include in review summaries. If empty, no filtering will be done, and so all calendars will be included.", + "type": "[string]", + "default": [], + "required": false + }, { "key": "moods", "title": "List of moods", @@ -226,7 +234,7 @@ { "key": "dailyReviewQuestions", "title": "Daily Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", "type": "string", "default": "@sleep()\n@work()\n@fruitveg()\nMood: \nGratitude: \nGod was: \nAlive: \nNot Great: \nLearn: \nRemember: ", "required": false @@ -234,7 +242,7 @@ { "key": "weeklyReviewQuestions", "title": "Weekly Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the weekly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the weekly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", "type": "string", "default": "Big win: \nNew/improved: \nRegret: \nFocus for next week: ", "required": false @@ -242,7 +250,7 @@ { "key": "monthlyReviewQuestions", "title": "Monthly Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the monthly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the monthly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", "type": "string", "default": "What's working well in processes: \nProcesses that need work: Personal Goals progress: ", "required": false @@ -250,7 +258,7 @@ { "key": "quarterlyReviewQuestions", "title": "Quarterly Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the quarterly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the quarterly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", "type": "string", "default": "Goals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", "required": false @@ -258,7 +266,7 @@ { "key": "yearlyReviewQuestions", "title": "Yearly Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the yearly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '' and ''.", + "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the yearly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", "type": "string", "default": "Goals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", "required": false diff --git a/jgclark.Journalling/requiredFiles/reviews.css b/jgclark.Journalling/requiredFiles/reviews.css index 922af4f78..5a7e28629 100644 --- a/jgclark.Journalling/requiredFiles/reviews.css +++ b/jgclark.Journalling/requiredFiles/reviews.css @@ -74,7 +74,8 @@ body { gap: 0.7rem; } -.review-raw-question-line { +/* Used by whole-row questions, which require the label coming before them */ +.review-raw-question-line-block { display: flex; flex-direction: row; flex-wrap: wrap; @@ -82,7 +83,8 @@ body { gap: 0.3rem 0.5rem; } -.review-raw-question-line-block { +/* Used by question rows, where the label is inline */ +.review-raw-question-line { flex: 1 1 100%; } @@ -105,7 +107,12 @@ body { font-weight: 600; color: var(--h3-color, #5c5f77); margin-top: 0.9rem; - /* margin-left: 0.3rem; */ +} + +/* Used when settings contain markdown `##` headings or typed `

` markers. */ +.review-subheading.h2, .review-subheading.h3 { + color: var(--h2-color, #5c5f77); + background-color: unset; } .review-row { display: flex; diff --git a/jgclark.Journalling/src/index.js b/jgclark.Journalling/src/index.js index 609e05e23..57eb320d1 100644 --- a/jgclark.Journalling/src/index.js +++ b/jgclark.Journalling/src/index.js @@ -11,6 +11,7 @@ import pluginJson from '../plugin.json' import { clo, compareObjects, JSP, logDebug, logInfo, logError } from "@helpers/dev" import { backupSettings, getSettings, pluginUpdated, saveSettings } from '@helpers/NPConfiguration' import { editSettings } from '@helpers/NPSettings' +import { getEventsForDay } from '@helpers/NPCalendar' const pluginID = 'jgclark.Journalling' const oldPluginID = 'jgclark.DailyJournal' diff --git a/jgclark.Journalling/src/journal.js b/jgclark.Journalling/src/journal.js index f46c36192..d5c9a9dd6 100644 --- a/jgclark.Journalling/src/journal.js +++ b/jgclark.Journalling/src/journal.js @@ -7,7 +7,7 @@ import strftime from 'strftime' import pluginJson from '../plugin.json' -import { getJournalSettings } from './journalHelpers' +import { getJournalSettings, REVIEW_QUESTION_TYPE_NAMES_ALT } from './journalHelpers' import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' import { stylesheetinksInHeader, faLinksInHeader, buildReviewHTML } from './reviewHTMLViewGenerator' import { RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, getWeek, getPeriodOfNPDateStr, isDailyNote, isWeeklyNote, isMonthlyNote, isQuarterlyNote, isYearlyNote } from '@helpers/dateTime' @@ -181,13 +181,47 @@ async function getQuestionsForPeriod(config: JournalConfigType, period: string): */ export function parseQuestions(questionLines: Array | string): Array { const parsed = [] - const typeRE = new RegExp('<\\s*(string|int|number|boolean|mood|subheading)\\s*>', 'i') - const segmentRE = new RegExp('[^<]*?<\\s*(?:string|int|number|boolean|mood|subheading)\\s*>\\)?[^\\s]*', 'gi') + const typeRE = new RegExp(`<\\s*(${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>`, 'i') + const segmentRE = new RegExp(`[^<]*?<\\s*(?:${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>\\)?[^\\s]*`, 'gi') const linesToProcess = Array.isArray(questionLines) ? questionLines : String(questionLines ?? '').split('\n') // Process each line, splitting by '||' to support multiple questions per line for (let lineIndex = 0; lineIndex < linesToProcess.length; lineIndex++) { const line = linesToProcess[lineIndex] + // Support explicit typed heading lines, e.g. `

Heading` / `

Heading`. + // These are not questions: they are carried through as heading blocks in the output. + const mTypedH2 = line.match(/^\s*<\s*h2\s*>\s*(.+)$/i) + if (mTypedH2) { + parsed.push({ question: String(mTypedH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) + continue + } + const mTypedH3 = line.match(/^\s*<\s*h3\s*>\s*(.+)$/i) + if (mTypedH3) { + parsed.push({ question: String(mTypedH3[1] ?? '').trim(), type: 'h3', originalLine: line, lineIndex }) + continue + } + // Support markdown heading lines in the settings, e.g. `## Heading` or `### Subheading`. + // These are carried through as HTML heading tags in the output. + const mH2 = line.match(/^##\s+(.+)$/) + if (mH2) { + parsed.push({ question: String(mH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) + continue + } + const mH3 = line.match(/^###\s+(.+)$/) + if (mH3) { + parsed.push({ question: String(mH3[1] ?? '').trim(), type: 'h3', originalLine: line, lineIndex }) + continue + } + // Support `

` / `

` markers as trailing decorators (e.g. `Title

`). + // In this case, treat the line as a plain string question (marker is ignored for typing). + const mTrailingHeadingDecorator = line.match(/^(.*?)\s*<\s*(h2|h3)\s*>\s*$/i) + if (mTrailingHeadingDecorator) { + const base = String(mTrailingHeadingDecorator[1] ?? '').trim() + if (base !== '') { + parsed.push({ question: base, type: 'string', originalLine: `${base}: `, lineIndex }) + continue + } + } // Split the line by '||' to get individual questions (allowing optional whitespace around ||) const questionParts = line.split(/\s*\|\|\s*/).map(part => part.trim()).filter(part => part !== '') @@ -202,7 +236,12 @@ export function parseQuestions(questionLines: Array | string): Array, @sleep()) const tokenMatch = segment.match(/([@#][^\s(<]+)/) const question = tokenMatch?.[1] - ?? segment.replace(/:|\(|\)||||||/gi, '').trim() + ?? segment + .replace( + /:|\(|\)|||||||

|

|||/gi, + '', + ) + .trim() // logDebug(pluginJson, `- Q#${questionNum}: Line ${lineIndex}, type:${questionType} "${question}"`) parsed.push({ question, type: questionType, originalLine: segment, lineIndex }) } @@ -213,12 +252,24 @@ export function parseQuestions(questionLines: Array | string): Array\s*$/i, '').trim() + const tag = headingType === 'h2' ? 'h2' : 'h3' + return `\n<${tag} class="review-subheading ${tag}">${cleanHeading}` +} + +/** + * Handle a legacy `` question type. * @param {string} question the question text * @returns {string} the formatted subheading line */ function handleSubheadingQuestion(question: string): string { - return '\n### '.concat(question.replace(//, '')) + return handleHeadingQuestion(question, 'subheading') } /** @@ -230,6 +281,55 @@ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +/** Output prefix per line for multiline journal types (``, ``, ``). */ +const MULTILINE_ANSWER_PREFIX_BY_TYPE: { [string]: string } = { + bullets: '- ', + checklists: '+ ', + tasks: '* ', +} + +/** + * Markdown prefix written before each answer line for a multiline question type. + * @param {string} questionType + * @returns {string} + */ +function linePrefixForMultilineAnswerType(questionType: string): string { + return MULTILINE_ANSWER_PREFIX_BY_TYPE[questionType.toLowerCase()] ?? '' +} + +/** + * Remove leading line markers from saved note text so the review textarea shows plain lines. + * @param {string} rawBlock + * @param {string} linePrefix e.g. '- ' + * @returns {string} + */ +function stripMultilineAnswerPrefixes(rawBlock: string, linePrefix: string): string { + if (linePrefix === '') { + return rawBlock.trim() + } + return rawBlock + .split(/\r?\n/) + .map((l) => { + const trimmed = l.trim() + if (trimmed.startsWith(linePrefix)) { + return trimmed.slice(linePrefix.length).trim() + } + return trimmed + }) + .join('\n') + .trim() +} + +/** + * Replace `` placeholders with the review note title string. + * @param {string} input + * @param {string} periodString + * @returns {string} + */ +function substituteDateToken(input: string, periodString: string): string { + return input.replace(/<\s*date\s*>/gi, periodString) +} + /** * Split a question segment at the typed marker (same idea as reviewHTMLViewGenerator.splitSegmentAtTypeMarker). * @param {string} segment @@ -287,7 +387,7 @@ function getParagraphLineContentsForReviewScan(note: TNote, reviewSectionHeading function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: string): string { const t = parsedQuestion.type.toLowerCase() const seg = parsedQuestion.originalLine.trim() - if (t === 'subheading') { + if (t === 'subheading' || t === 'h2' || t === 'h3') { return '' } if (t === 'boolean') { @@ -311,8 +411,41 @@ function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: s const m = line.match(re) return m?.[1] != null ? m[1] : '' } + if (t === 'bullets' || t === 'checklists' || t === 'tasks') { + const marker = linePrefixForMultilineAnswerType(t) + const { prefix, suffix } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) + if (suffix === '') { + // If the template line is just ``/``/``, only treat lines that actually begin with that marker as a match. + if (prefix === '') { + const trimmed = line.trim() + if (!trimmed.startsWith(marker.trim())) { + return '' + } + return stripMultilineAnswerPrefixes(trimmed, marker) + } + const idx = line.indexOf(prefix) + if (idx < 0) { + return '' + } + const raw = line.slice(idx + prefix.length).trim() + if (raw === '') { + return '' + } + return stripMultilineAnswerPrefixes(raw, marker) + } + const re = new RegExp(`${escapeRegExp(prefix)}([\\s\\S]*?)${escapeRegExp(suffix)}`) + const m = line.match(re) + if (m?.[1] == null) { + return '' + } + return stripMultilineAnswerPrefixes(m[1].trim(), marker) + } if (t === 'mood' || t === 'string') { if (suffix === '') { + // If template is just `` (no prefix), it's too ambiguous to pre-fill reliably (it would match every line). + if (prefix === '') { + return '' + } const idx = line.indexOf(prefix) if (idx < 0) { return '' @@ -394,7 +527,7 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe return on ? parsedQuestion.question : '' } const answer = (typeof answerRaw === 'string' ? answerRaw : String(answerRaw ?? '')).trim() - if (answer === '') { + if (answer === '' && t !== 'subheading' && t !== 'h2' && t !== 'h3') { return '' } switch (t) { @@ -416,9 +549,31 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe case 'mood': { return parsedQuestion.originalLine.replace(//, answer) } + case 'bullets': + case 'checklists': + case 'tasks': { + const marker = linePrefixForMultilineAnswerType(t) + const lines = answer.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '') + if (lines.length === 0) { + return '' + } + const formatted = lines.map((l) => `${marker}${l}`).join('\n') + const ol = parsedQuestion.originalLine + if (ol.trimStart().startsWith('-')) { + return formatted + } + const tagRe = new RegExp(`<\\s*${escapeRegExp(t)}\\s*>`, 'i') + return ol.replace(tagRe, formatted) + } case 'subheading': { return handleSubheadingQuestion(parsedQuestion.question) } + case 'h2': { + return handleHeadingQuestion(parsedQuestion.question, 'h2') + } + case 'h3': { + return handleHeadingQuestion(parsedQuestion.question, 'h3') + } default: { return '' } @@ -431,13 +586,19 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe * @param {{ [string]: string | boolean }} answersByIndex * @returns {string} */ -function buildOutputFromReviewWindowAnswers(parsedQuestions: Array, answersByIndex: { [string]: string | boolean }): string { +function buildOutputFromReviewWindowAnswers( + parsedQuestions: Array, + rawQuestionLines: Array, + periodString: string, + answersByIndex: { [string]: string | boolean }, +): string { let output = '' const questionsByLine = groupQuestionsByLine(parsedQuestions) - const lineIndices = Object.keys(questionsByLine).map(Number).sort((a, b) => a - b) + const lineCount = rawQuestionLines.length + const stripPresentationDelimiters = (input: string): string => input.replace(/ \|\| /g, ' ') - for (const lineIndex of lineIndices) { - const lineQuestions = questionsByLine[lineIndex] + for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { + const lineQuestions = questionsByLine[lineIndex] ?? [] const lineAnswers: Array = [] for (let i = 0; i < lineQuestions.length; i++) { const globalIndex = parsedQuestions.findIndex((q) => q === lineQuestions[i]) @@ -453,9 +614,22 @@ function buildOutputFromReviewWindowAnswers(parsedQuestions: Array 0) { - let combinedLine = lineAnswers.join(' ') - combinedLine = combinedLine.replace(/\s+/g, ' ') - output += `${combinedLine}\n` + const hasMultiline = lineAnswers.some((a) => a.includes('\n')) + let combinedLine = hasMultiline ? lineAnswers.join('\n') : lineAnswers.join(' ') + if (!hasMultiline) { + combinedLine = combinedLine.replace(/\s+/g, ' ') + } + output += `${substituteDateToken(combinedLine, periodString)}\n` + continue + } + + // If there are no questions/answers on this line, still carry `` through into output. + const rawLine = rawQuestionLines[lineIndex] ?? '' + if (/<\s*date\s*>/i.test(rawLine)) { + const substituted = substituteDateToken(stripPresentationDelimiters(rawLine), periodString).trim() + if (substituted !== '') { + output += `${substituted}\n` + } } } return output @@ -627,10 +801,10 @@ async function displayQuestionsWindow( calendarNote: TNote, ): Promise { // Get the data sources we need for the review window - // const periodTitle = getReviewPeriodTitle(periodType) const summaryCompletedTasks = (periodAdjective === 'Day') ? getSummaryCompletedTasks(periodType, periodString) : [] - const calendarSet: Array = ['Jonathan (iCloud)', 'Us (iCloud)'] - const eventsForPeriod: Array = (periodType === 'Day') ? await getEventsForDay('2026-03-28', calendarSet) ?? [] : [] + const calendarSet: Array = config.calendarSet ?? [] + logDebug(pluginJson, `calendarSet: [${String(calendarSet)}]`) + const eventsForPeriod: Array = (periodType === 'day') ? await getEventsForDay(periodString, calendarSet) ?? [] : [] clo(eventsForPeriod, 'eventsForPeriod') const scanLines = getParagraphLineContentsForReviewScan(calendarNote, config.reviewSectionHeading ?? '') const initialAnswers = buildInitialReviewAnswersByFieldName(parsedQuestions, scanLines) @@ -661,7 +835,7 @@ async function displayQuestionsWindow( showReloadButton: true, reloadPluginID: pluginJson['plugin.id'], reloadCommandName: REVIEW_WINDOW_CALLBACK_COMMAND, - icon: 'fa-regular fa-clipboard-list', + icon: 'clipboard-list', iconColor: 'blue-600', autoTopPadding: true, makeModal: false, @@ -803,7 +977,7 @@ export async function onReviewWindowAction(actionName: string, payload: string = const periodString = safePayload.periodString ?? '' const questionLines = await getQuestionsForPeriod(config, periodType) const parsedQuestions = parseQuestions(questionLines) - const output = buildOutputFromReviewWindowAnswers(parsedQuestions, answers) + const output = buildOutputFromReviewWindowAnswers(parsedQuestions, questionLines, periodString, answers) if (output !== '') { await writeAnswersToNote(periodString, output) } else { diff --git a/jgclark.Journalling/src/journalHelpers.js b/jgclark.Journalling/src/journalHelpers.js index 3ad9213f2..59edc0469 100644 --- a/jgclark.Journalling/src/journalHelpers.js +++ b/jgclark.Journalling/src/journalHelpers.js @@ -2,7 +2,7 @@ //--------------------------------------------------------------- // Helper functions for Journalling plugin for NotePlan // Jonathan Clark -// last update 2025-10-16 for v1.0.1 by @jgclark +// last update 2026-03-25 for v2.0.0.b3 by @jgclark + @Cursor //--------------------------------------------------------------- import pluginJson from '../plugin.json' @@ -30,7 +30,8 @@ export type JournalConfigType = { monthlyReviewQuestions: string, quarterlyReviewQuestions: string, yearlyReviewQuestions: string, - moods: string + moods: string, + calendarSet: Array, } export type ParsedQuestionType = { @@ -40,6 +41,10 @@ export type ParsedQuestionType = { lineIndex: number } +/** `` names in review question templates — keep in sync with `parseQuestions` and `REVIEW_SEGMENT_RE`. */ +export const REVIEW_QUESTION_TYPE_NAMES_ALT = + 'string|int|number|boolean|mood|subheading|h2|h3|bullets|checklists|tasks' + //--------------------------------------------------------------- // Settings diff --git a/jgclark.Journalling/src/reviewHTMLViewGenerator.js b/jgclark.Journalling/src/reviewHTMLViewGenerator.js index d048eb74b..53e046c03 100644 --- a/jgclark.Journalling/src/reviewHTMLViewGenerator.js +++ b/jgclark.Journalling/src/reviewHTMLViewGenerator.js @@ -2,12 +2,13 @@ //--------------------------------------------------------------- // HTMLView generation helpers for single-window review mode // Jonathan Clark + Cursor -// last update 2026-03-23 for v2.0.0.b1 by @jgclark + @Cursor +// last update 2026-03-25 for v2.0.0.b3 by @jgclark + @Cursor //--------------------------------------------------------------- import moment from 'moment' import pluginJson from '../plugin.json' import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' +import { REVIEW_QUESTION_TYPE_NAMES_ALT } from './journalHelpers' import { RE_DONE_DATE_OPT_TIME } from '@helpers/dateTime' import { clo, logDebug, logInfo, logError, logWarn } from '@helpers/dev' import { @@ -28,8 +29,14 @@ import { RE_SYNC_MARKER } from '@helpers/regex' const useFlexbox = true +// Types of questions that use a block layout in the review window. +const blockRowTypes = ['string', 'subheading', 'h2', 'h3', 'bullets', 'checklists', 'tasks'] + // Keep in sync with parseQuestions() in journal.js (segment extraction). -const REVIEW_SEGMENT_RE = new RegExp('[^<]*?<\\s*(?:string|int|number|boolean|mood|subheading)\\s*>\\)?[^\\s]*', 'gi') +const REVIEW_SEGMENT_RE = new RegExp( + `[^<]*?<\\s*(?:${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>\\)?[^\\s]*`, + 'gi', +) /** Remove @done(…) from summary lines (date with optional time), global. */ const RE_DONE_MENTION_STRIP_FOR_SUMMARY_G = new RegExp(RE_DONE_DATE_OPT_TIME.source, 'gi') @@ -183,6 +190,16 @@ function makeReviewRawQuestionLineDiv( .map((q, globalIndex) => ({ q, globalIndex })) .filter(({ q }) => q.lineIndex === lineIndex) + const headingTypes = ['subheading', 'h2', 'h3'] + const isHeadingOnlyLine = !cleanRawLine.includes('<') + && lineQuestionsOrdered.length > 0 + && lineQuestionsOrdered.every(({ q }) => headingTypes.includes(q.type)) + if (isHeadingOnlyLine) { + return lineQuestionsOrdered + .map(({ q, globalIndex }) => makeReviewQuestionRowDiv(q, globalIndex, config, '')) + .join('\n') + } + const parts: Array = [] let lastIndex = 0 let segmentOrdinal = 0 @@ -203,8 +220,9 @@ function makeReviewRawQuestionLineDiv( } const { q: pq, globalIndex } = pair const initialVal = initialAnswers[`q_${globalIndex}`] ?? '' - if (pq.type === 'string' || pq.type === 'subheading') { - parts.push(`
${makeReviewQuestionRowDiv(pq, globalIndex, config, initialVal)}
`) + + if (blockRowTypes.includes(pq.type)) { + parts.push(`
${makeReviewQuestionRowDiv(pq, globalIndex, config, initialVal)}
`) } else { const { prefix, suffix } = splitSegmentAtTypeMarker(segmentTrimmed, pq.type) const control = makeReviewInlineControl(pq, globalIndex, config, initialVal) @@ -221,9 +239,9 @@ function makeReviewRawQuestionLineDiv( } if (parts.length === 0) { - return `
${escapeHTML(cleanRawLine)}
` + return `
${escapeHTML(cleanRawLine)}
` } - return `
${parts.join('')}
` + return `
${parts.join('')}
` } /** @@ -233,7 +251,7 @@ function makeReviewRawQuestionLineDiv( * @param {Array} eventsForPeriod * @returns {string} HTML string for the summary block */ -function makePeriodDaysSummaryDiv(periodType: string, eventsForPeriod: Array): string { +function makePeriodDaysSummaryDiv(_periodType: string, eventsForPeriod: Array): string { clo(eventsForPeriod, 'eventsForPeriod') const totalDuration = eventsForPeriod.reduce((total, event) => total + getEventDurationHours(event), 0) return ` @@ -264,7 +282,7 @@ function getEventDurationHours(event: TCalendarItem): number { */ function makePeriodSummaryDiv( periodType: string, - periodString: string, + _periodString: string, completedTasks: Array, eventsForPeriod: Array ): string { @@ -279,12 +297,14 @@ function makePeriodSummaryDiv( : `
No completed tasks found during the ${periodType}
` const outputHTML = ` +
${completedTasks.length} completed tasks
${summaryItems}
${makePeriodDaysSummaryDiv(periodType, eventsForPeriod)} -
` +
+

` return outputHTML } @@ -304,15 +324,17 @@ function makeReviewQuestionRowDiv( initialValue: string = '', ): string { const fieldName = `q_${index}` - if (parsedQuestion.type === 'subheading') { - const cleanSubheading = stripPresentationDelimiters(parsedQuestion.question) - return `
${escapeHTML(cleanSubheading)}
` + if (parsedQuestion.type === 'subheading' || parsedQuestion.type === 'h2' || parsedQuestion.type === 'h3') { + const cleanHeading = stripPresentationDelimiters(parsedQuestion.question) + const tag = parsedQuestion.type === 'h2' ? 'h2' : 'h3' // `` defaults to h3 + const className = tag + return `
${escapeHTML(cleanHeading)}
` } const questionText = stripPresentationDelimiters(parsedQuestion.question).trim() const questionLabel = `` let control = '' - const useInlineRow = useFlexbox && parsedQuestion.type !== 'string' && parsedQuestion.type !== 'subheading' + const useInlineRow = useFlexbox && !blockRowTypes.includes(parsedQuestion.type) const rowClass = useInlineRow ? 'review-row review-row-inline' : 'review-row' const checkedAttr = initialValue === 'yes' ? ' checked' : '' switch (parsedQuestion.type) { @@ -341,10 +363,16 @@ function makeReviewQuestionRowDiv( break } case 'string': - default: { + case 'bullets': + case 'checklists': + case 'tasks': { control = `` break } + default: { + logWarn(`makeReviewQuestionRowDiv(): unknown question type: ${parsedQuestion.type} -- ignoring it.`) + break + } } return useInlineRow ? `
${questionLabel}
${control}
` @@ -378,11 +406,12 @@ export function buildReviewHTML( initialAnswers?: { [string]: string }, ): string { const resolvedInitialAnswers = initialAnswers ?? {} - const questionRows = rawQuestionLines + const renderQuestionLines = rawQuestionLines.map((l) => l.replace(/<\s*date\s*>/gi, periodString)) + const questionRows = renderQuestionLines .map((line, lineIndex) => makeReviewRawQuestionLineDiv(line, lineIndex, parsedQuestions, config, resolvedInitialAnswers)) .filter((row) => row !== '') .join('\n') - const possibleSummarySection = (periodType === 'day') ? makePeriodSummaryDiv(periodType, summaryCompletedTasks, eventsForPeriod) : '' + const possibleSummarySection = (periodType === 'day') ? makePeriodSummaryDiv(periodType, periodString, summaryCompletedTasks, eventsForPeriod) : '' return `

${escapeHTML(periodAdjective)} Review for ${escapeHTML(periodString)}

From 1ce9fe8fb5c747cf692d27ea32b643a82de658d2 Mon Sep 17 00:00:00 2001 From: jgclark Date: Sat, 28 Mar 2026 14:10:35 +0000 Subject: [PATCH 04/19] Add getNextNPPeriodString() dateTime helper --- helpers/__tests__/dateTime.test.js | 21 ++++++++++ helpers/dateTime.js | 63 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/helpers/__tests__/dateTime.test.js b/helpers/__tests__/dateTime.test.js index bf739e4ac..c2533d366 100644 --- a/helpers/__tests__/dateTime.test.js +++ b/helpers/__tests__/dateTime.test.js @@ -1383,4 +1383,25 @@ describe(`${PLUGIN_NAME}`, () => { }) }) }) + + describe('getNextNPPeriodString()' /* function */, () => { + test('2024-W52 + week -> 2025-W01', () => { + expect(dt.getNextNPPeriodString('2024-W52', 'week')).toEqual('2025-W01') + }) + test('2026-03-27 + day -> 2026-03-28', () => { + expect(dt.getNextNPPeriodString('2026-03-27', 'day')).toEqual('2026-03-28') + }) + test('2026-12 + month -> 2027-01', () => { + expect(dt.getNextNPPeriodString('2026-12', 'month')).toEqual('2027-01') + }) + test('2024-Q4 + quarter -> 2025-Q1', () => { + expect(dt.getNextNPPeriodString('2024-Q4', 'quarter')).toEqual('2025-Q1') + }) + test('2024Q4 + quarter compact in -> 2025Q1 out', () => { + expect(dt.getNextNPPeriodString('2024Q4', 'quarter')).toEqual('2025Q1') + }) + test('2024 + year -> 2025', () => { + expect(dt.getNextNPPeriodString('2024', 'year')).toEqual('2025') + }) + }) }) diff --git a/helpers/dateTime.js b/helpers/dateTime.js index 7a612a79b..6f0c87d01 100644 --- a/helpers/dateTime.js +++ b/helpers/dateTime.js @@ -1248,6 +1248,69 @@ export function calcOffsetDate(baseDateStrIn: string, interval: string): Date | } } +/** + * Calendar period immediately after the given NotePlan period (same string family as note titles). + * Uses `calcOffsetDate` so week rollover matches ISO week handling (e.g. 2024-W52 → 2025-W01). + * @param {string} periodStringIn - e.g. YYYY-MM-DD, YYYY-Www, YYYY-MM, YYYY-Qn / YYYYQn, YYYY + * @param {string} periodType - 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} next period title, or '' if parsing fails + */ +export function getNextNPPeriodString(periodStringIn: string, periodType: string): string { + try { + const trimmed = periodStringIn.trim() + const wantCompactQuarter = /^(\d{4})Q([1-4])$/i.test(trimmed) + let periodString = trimmed + const compactQ = trimmed.match(/^(\d{4})Q([1-4])$/i) + if (compactQ) { + periodString = `${compactQ[1]}-Q${compactQ[2]}` + } + const intervalByType: { [string]: string } = { + day: '+1d', + week: '+1w', + month: '+1m', + quarter: '+1q', + year: '+1y', + } + const interval = intervalByType[periodType] + if (!interval) { + logError('dateTime / getNextNPPeriodString', `Unknown periodType '${periodType}'`) + return '' + } + const newDate = calcOffsetDate(periodString, interval) + if (!newDate) { + return '' + } + let momentDateFormat = '' + if (periodString.match(RE_ISO_DATE)) { + momentDateFormat = 'YYYY-MM-DD' + } else if (periodString.match(RE_YYYYMMDD_DATE)) { + momentDateFormat = MOMENT_FORMAT_NP_DAY + } else if (periodString.match(RE_NP_WEEK_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_WEEK + } else if (periodString.match(RE_NP_MONTH_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_MONTH + } else if (periodString.match(RE_NP_QUARTER_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_QUARTER + } else if (periodString.match(RE_NP_YEAR_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_YEAR + } else { + logError('dateTime / getNextNPPeriodString', `Unrecognized period string '${periodString}'`) + return '' + } + let out = moment(newDate).format(momentDateFormat) + if (wantCompactQuarter && momentDateFormat === MOMENT_FORMAT_NP_QUARTER) { + const mq = out.match(/^(\d{4})-Q([1-4])$/i) + if (mq) { + out = `${mq[1]}Q${mq[2]}` + } + } + return out + } catch (e) { + logError('dateTime / getNextNPPeriodString', e.message) + return '' + } +} + /** * Split an interval (e.g. '-3m') into number (e.g. -3) and type ('month') parts * If interval arrives with {...} around the terms, remove them first From 8df0611c70194b5740c2033e7afd333c1dc690a6 Mon Sep 17 00:00:00 2001 From: jgclark Date: Sat, 28 Mar 2026 14:47:45 +0000 Subject: [PATCH 05/19] b4 --- flow-typed/Noteplan.js | 2 +- jgclark.Journalling/CHANGELOG.md | 8 +- jgclark.Journalling/__tests__/journal.test.js | 168 +++++++- jgclark.Journalling/plugin.json | 64 ++-- jgclark.Journalling/src/index.js | 23 ++ jgclark.Journalling/src/journal.js | 358 ++++++++++-------- jgclark.Journalling/src/journalHelpers.js | 45 ++- .../src/reviewHTMLViewGenerator.js | 84 ++-- 8 files changed, 530 insertions(+), 222 deletions(-) diff --git a/flow-typed/Noteplan.js b/flow-typed/Noteplan.js index 963407322..905dc0fe3 100644 --- a/flow-typed/Noteplan.js +++ b/flow-typed/Noteplan.js @@ -2713,7 +2713,7 @@ declare class HTMLView { * @param { Object } options - (optional) Configuration options: * - splitView: boolean - Show as split view (true) or in main content area (false, default) * - id/customId/customID: String - Unique identifier for reusing the same view - * - icon: string - FontAwesome icon string for the navigation bar. Note: currently doesn't support setting the font type, and always uses "fa-regular" + * - icon: string - FontAwesome icon string for the navigation bar. Note: currently doesn't support setting the font type, and always uses "fa-regular". Needs to be the full name, so will include the "fa-" prefix. * - iconColor: string - Tailwind color name (e.g., "blue-500") or hex color (e.g., "#3b82f6") * - autoTopPadding: boolean - Auto-add top padding for navigation bar (default: true) * - showReloadButton: boolean - Show a reload button in the navigation bar (default: false) diff --git a/jgclark.Journalling/CHANGELOG.md b/jgclark.Journalling/CHANGELOG.md index b5801df53..25f2c555b 100644 --- a/jgclark.Journalling/CHANGELOG.md +++ b/jgclark.Journalling/CHANGELOG.md @@ -3,10 +3,16 @@ _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark. Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. +## [2.0.0.b4] - 2026-03-28 +- Split section-heading settings: `dailyJournalSectionHeading` is now used by daily journal commands, and `reviewSectionHeading` is used by weekly/monthly/quarterly/yearly review commands. Existing installs migrate heading values to preserve prior behavior. +- **Review placeholders:** `` is replaced with the current review period’s calendar title (e.g. `2026-03-28`, `2026-W13`, `2026Q1`) in the review window and the output. +- Added `` (alias ``) that's similar to `` but gives the **following** period in the same format (e.g. weekly `2024-W52` → `2025-W01`). +- **Correct calendar note:** The open editor note is only reused when it matches the review command’s period type **and** the same period title (e.g. today for a daily review). Otherwise the plugin opens the intended note. + ## [2.0.0.b3] - 2026-03-25 - New review question types: `` (each answer line written with a `- ` prefix), `` (`+ ` per line), and `` (`* ` per line). The review window uses a multi-line field; empty lines are skipped. Answers already in the calendar note are pre-filled with markers stripped. - Headings in review settings are now output as HTML headings: `` outputs an `

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

` / `

` with `review-subheading` classes. -- `` placeholder is now supported in review question lines and is substituted with the relevant review note title string in both the window and output. +- `` placeholder is now supported in review question lines and is substituted with the relevant calendar period title in the review window and in saved output. ## [2.0.0.b2] - 2026-03-24 - When opening the review window, answers already present in the calendar note are pre-filled in the matching controls (under your **Review section heading** when that heading exists; otherwise the whole note is scanned). Latest matching paragraph wins so you can edit the most recent review block. diff --git a/jgclark.Journalling/__tests__/journal.test.js b/jgclark.Journalling/__tests__/journal.test.js index 7dcbebe16..056b530b6 100644 --- a/jgclark.Journalling/__tests__/journal.test.js +++ b/jgclark.Journalling/__tests__/journal.test.js @@ -2,7 +2,8 @@ // Last updated: 2026-03-25 for v2.0.0.b3 by @Cursor -import { buildInitialReviewAnswersByFieldName, parseQuestions } from '../src/journal' +import { buildInitialReviewAnswersByFieldName, buildOutputFromReviewWindowAnswers, parseQuestions } from '../src/journal' +import { getPeriodAdjectiveFromType, substituteReviewPeriodPlaceholders } from '../src/journalHelpers' import { DataStore } from '@mocks/index' beforeAll(() => { @@ -10,13 +11,11 @@ beforeAll(() => { // global.Clipboard = Clipboard // global.CommandBar = CommandBar global.DataStore = DataStore - // global.Editor = Editor + global.Editor = { paragraphs: [] } // global.NotePlan = NotePlan DataStore.settings['_logLevel'] = 'none' //change this to DEBUG to get more logging }) -const pluginJson = 'jgclark.Journalling' - // Jest suite describe('Journal', () => { describe('parseQuestions', () => { @@ -142,6 +141,42 @@ Do: ` expect(questions[0].question).toBe('Mood') expect(questions[0].lineIndex).toBe(1) }) + + it('should ignore as a question token', () => { + const raw = `Next: \nMood: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('mood') + expect(questions[0].question).toBe('Mood') + expect(questions[0].lineIndex).toBe(1) + }) + + it('should ignore as a question token', () => { + const raw = `Next: \nMood: ` + const questions = parseQuestions(raw) + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('mood') + }) + }) + + describe('getPeriodAdjectiveFromType', () => { + it('should return title-case adjectives for known period types', () => { + expect(getPeriodAdjectiveFromType('day')).toBe('Daily') + expect(getPeriodAdjectiveFromType('week')).toBe('Weekly') + expect(getPeriodAdjectiveFromType('month')).toBe('Monthly') + expect(getPeriodAdjectiveFromType('quarter')).toBe('Quarterly') + expect(getPeriodAdjectiveFromType('year')).toBe('Yearly') + }) + it('should return Calendar for unknown period type', () => { + expect(getPeriodAdjectiveFromType('unknown')).toBe('Calendar') + }) + }) + + describe('substituteReviewPeriodPlaceholders', () => { + it('should expand , , and ', () => { + const s = 'A B C ' + expect(substituteReviewPeriodPlaceholders(s, '2024-W52', 'week')).toBe('A 2024-W52 B 2025-W01 C 2025-W01') + }) }) describe('buildInitialReviewAnswersByFieldName', () => { @@ -201,4 +236,129 @@ Ship: `, expect(initial.q_2).toBe('task one\ntask two') }) }) + + describe('buildOutputFromReviewWindowAnswers', () => { + beforeEach(() => { + global.Editor = { paragraphs: [] } + }) + + it('should build one line for a single string answer and append newline', () => { + const raw = 'Gratitude: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'Family' }) + expect(out).toBe('Gratitude: Family\n') + }) + + it('should substitute on template lines that have no questions', () => { + const raw = 'For: \nMood: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-W13', 'week', { q_0: 'Calm' }) + expect(out).toBe('For: 2026-W13\nMood: Calm\n') + }) + + it('should substitute for the following period (weekly rollover)', () => { + const raw = 'Next: \nMood: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2024-W52', 'week', { q_0: 'Calm' }) + expect(out).toBe('Next: 2025-W01\nMood: Calm\n') + }) + + it('should substitute like ', () => { + const raw = 'Next: \nMood: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2024-W52', 'week', { q_0: 'Calm' }) + expect(out).toBe('Next: 2025-W01\nMood: Calm\n') + }) + + it('should strip presentation delimiters before substituting ', () => { + const raw = 'Period: || (review)' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03', 'month', {}) + expect(out).toBe('Period: 2026-03 (review)\n') + }) + + it('should join multiple answers on the same line with a single space', () => { + const raw = 'Health: @sleep() @fruitveg()' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { + q_0: '7', + q_1: '3', + }) + expect(out).toBe('Health: @sleep(7) @fruitveg(3)\n') + }) + + it('should combine multiline bullet answers with newlines (inline tag replacement keeps prefix)', () => { + const raw = 'Wins: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'first win\nsecond win' }) + expect(out).toBe('Wins:\n- first win\n- second win\n') + }) + + it('should combine multiline checklist answers with newlines (inline tag replacement keeps prefix)', () => { + const raw = 'Shop: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'eggs\nmilk' }) + expect(out).toBe('Shop:\n+ eggs\n+ milk\n') + }) + + it('should combine multiline task answers with newlines (inline tag replacement keeps prefix)', () => { + const raw = 'Ship: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'task one\ntask two' }) + expect(out).toBe('Ship:\n* task one\n* task two\n') + }) + + it('should emit boolean tag when answer is true', () => { + const raw = '#bible' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: true }) + expect(out).toBe('#bible\n') + }) + + it('should omit line when boolean answer is false', () => { + const raw = '#bible' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: false }) + expect(out).toBe('') + }) + + it('should inject periodString after answers when the template line still contains ', () => { + const parsedQuestions = [ + { question: 'Report', type: 'string', originalLine: 'Report : ', lineIndex: 0 }, + ] + const rawLines = ['Report : '] + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-Q1', 'quarter', { q_0: 'done' }) + expect(out).toBe('Report 2026-Q1: done\n') + }) + + it('should use submitted window answers even when the open note contains similar text', () => { + const raw = 'Gratitude test: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + global.Editor = { + paragraphs: [{ content: 'Gratitude test: from note' }], + } + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: 'from window' }) + expect(out).toBe('Gratitude test: from window\n') + }) + + it('should return empty string when there are no answers and no lines', () => { + const raw = 'Note: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', {}) + expect(out).toBe('') + }) + }) }) diff --git a/jgclark.Journalling/plugin.json b/jgclark.Journalling/plugin.json index 3c1375eda..f8d947634 100644 --- a/jgclark.Journalling/plugin.json +++ b/jgclark.Journalling/plugin.json @@ -9,7 +9,7 @@ "plugin.author": "Jonathan Clark", "plugin.url": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/README.md", "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md", - "plugin.version": "2.0.0.b3", + "plugin.version": "2.0.0.b4", "plugin.releaseStatus": "beta", "plugin.lastUpdateInfo": "v2.0.0: First release of the new Journalling & Reviews plugin, but building on earlier 'Journalling Helpers' plugin (v1).", "plugin.requiredFiles": [ @@ -33,12 +33,12 @@ "journal", "review" ], - "description": "Ask Journal questions for an end-of-day review (requires configuring)", + "description": "Ask Review/Journal questions at end-of-day", "jsFunction": "dailyJournalQuestions", "sidebarView": { "windowID": "jgclark.Journalling.dayReview", "title": "Daily Review", - "icon": "clipboard-list", + "icon": "fa-clipboard-list", "iconColor": "blue-600" } }, @@ -49,7 +49,7 @@ "journal", "review" ], - "description": "Ask Journal questions for an end-of-week review (requires configuring)", + "description": "Ask Review/Journal questions at end-of-week", "jsFunction": "weeklyJournalQuestions" }, { @@ -59,7 +59,7 @@ "journal", "review" ], - "description": "Ask Journal questions for an end-of-month review (requires configuring)", + "description": "Ask Review/Journal questions at end-of-month", "jsFunction": "monthlyJournalQuestions" }, { @@ -69,7 +69,7 @@ "journal", "review" ], - "description": "Ask Journal questions for an end-of-quarter review (requires configuring)", + "description": "Ask Review/Journal questions at end-of-quarter", "jsFunction": "quarterlyJournalQuestions" }, { @@ -79,7 +79,7 @@ "journal", "review" ], - "description": "Ask Journal questions for an end-of-year review (requires configuring)", + "description": "Ask Review/Journal questions at end-of-year", "jsFunction": "yearlyJournalQuestions" }, { @@ -157,11 +157,7 @@ ] }, { - "name": "Journalling: writeAnswersToNote", - "description": "writeAnswersToNote", - "jsFunction": "writeAnswersToNote" - }, - { + "hidden": true, "name": "Journalling: onUpdateOrInstall", "description": "onUpdateOrInstall", "jsFunction": "onUpdateOrInstall" @@ -180,13 +176,21 @@ "title": "General settings" }, { - "key": "reviewSectionHeading", - "title": "Journal Section Heading", - "description": "The name of an existing markdown heading after which the Journal/Review answers are added - if it doesn't exist, it is added at the end of the note.", + "key": "dailyJournalSectionHeading", + "title": "Daily Journal Section Heading", + "description": "The name of a section heading after which Daily Journal answers are added - if it doesn't exist, it is added at the end of the note.", "type": "string", "default": "Journal", "required": true }, + { + "key": "reviewSectionHeading", + "title": "Review Section Heading", + "description": "The name of a section heading after which weekly/monthly/quarterly/yearly Review answers are added - if it doesn't exist, it is added at the end of the note.", + "type": "string", + "default": "Review", + "required": true + }, { "type": "separator" }, @@ -233,42 +237,42 @@ }, { "key": "dailyReviewQuestions", - "title": "Daily Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", + "title": "Daily Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title) and '' or '' (the following calendar period in the same format).", "type": "string", "default": "@sleep()\n@work()\n@fruitveg()\nMood: \nGratitude: \nGod was: \nAlive: \nNot Great: \nLearn: \nRemember: ", "required": false }, { "key": "weeklyReviewQuestions", - "title": "Weekly Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the weekly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", + "title": "Weekly Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the weekly note.\nSee 'Daily Review/ Journal Questions' above for details.", "type": "string", - "default": "Big win: \nNew/improved: \nRegret: \nFocus for next week: ", + "default": "## Weekly Review for \nWins: \nNew or improved: \nChallenges: \n### 'Big Rocks' for \n", "required": false }, { "key": "monthlyReviewQuestions", - "title": "Monthly Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the monthly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", + "title": "Monthly Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the monthly note.\nSee 'Daily Review/ Journal Questions' above for details.", "type": "string", - "default": "What's working well in processes: \nProcesses that need work: Personal Goals progress: ", + "default": "## Monthly Review for \nWhat's working well in processes: \nProcesses that need work: \nPersonal Goals progress: ", "required": false }, { "key": "quarterlyReviewQuestions", - "title": "Quarterly Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the quarterly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", + "title": "Quarterly Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the quarterly note.\nSee 'Daily Review/ Journal Questions' above for details.", "type": "string", - "default": "Goals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", + "default": "## Quarterly Review for \nGoals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", "required": false }, { "key": "yearlyReviewQuestions", - "title": "Yearly Journal Questions", - "description": "Optional string that includes both the Journal/Review questions and how to lay out the answers in the yearly note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title).", + "title": "Yearly Review/Journal Questions", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the yearly note.\nSee 'Daily Review/ Journal Questions' above for details.", "type": "string", - "default": "Goals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", + "default": "## Yearly Review for \nMajor events in the year: \nThemes/focus areas for next year: ", "required": false }, { @@ -288,7 +292,7 @@ }, { "key": "endDailyTemplateTitle", - "title": "End-of-DayTemplate Title", + "title": "End-of-Day Template Title", "description": "The name of the template that `/dayEnd` and `/todayEnd` commands will use.", "type": "string", "default": "Daily Review Template", diff --git a/jgclark.Journalling/src/index.js b/jgclark.Journalling/src/index.js index 57eb320d1..8796107ef 100644 --- a/jgclark.Journalling/src/index.js +++ b/jgclark.Journalling/src/index.js @@ -84,6 +84,29 @@ export async function onUpdateOrInstall(): Promise { } } + // Migration safety for renamed heading setting: + // old "reviewSectionHeading" now maps to dailyJournalSectionHeading, + // while reviewSectionHeading is used for non-daily periods. + const latestSettings = (await getSettings(pluginID, DataStore.settings)) || DataStore.settings + const updatedSettings = { ...latestSettings } + const dailyHeading = String(updatedSettings.dailyJournalSectionHeading ?? '').trim() + const reviewHeading = String(updatedSettings.reviewSectionHeading ?? '').trim() + const needsDailyHeadingMigration = dailyHeading === '' && reviewHeading !== '' + const needsReviewHeadingDefault = reviewHeading === '' + if (needsDailyHeadingMigration || needsReviewHeadingDefault) { + if (needsDailyHeadingMigration) { + updatedSettings.dailyJournalSectionHeading = reviewHeading + } + if (needsReviewHeadingDefault) { + updatedSettings.reviewSectionHeading = dailyHeading !== '' ? dailyHeading : 'Review' + } + const migrationDiff = compareObjects(updatedSettings, latestSettings, [], true) + if (migrationDiff != null) { + logInfo(pluginID, 'onUpdateOrInstall: heading settings migration changes detected; saving') + await saveSettings(pluginID, updatedSettings) + } + } + // Tell user the plugin has been updated logInfo(pluginID, `... finished onUpdateOrInstall`) await pluginUpdated(pluginJson, { code: 2, message: `Plugin Installed or Updated.` }) diff --git a/jgclark.Journalling/src/journal.js b/jgclark.Journalling/src/journal.js index d5c9a9dd6..0ee0d514f 100644 --- a/jgclark.Journalling/src/journal.js +++ b/jgclark.Journalling/src/journal.js @@ -2,15 +2,29 @@ //--------------------------------------------------------------- // Journalling commands // Jonathan Clark -// last update 2026-03-23 for v2.0.0.b1 by @jgclark + @Cursor +// last update 2026-03-28 for v2.0.0.b4 by @jgclark + @Cursor //--------------------------------------------------------------- import strftime from 'strftime' import pluginJson from '../plugin.json' -import { getJournalSettings, REVIEW_QUESTION_TYPE_NAMES_ALT } from './journalHelpers' +import { + getJournalSettings, + getPeriodAdjectiveFromType, + REVIEW_QUESTION_TYPE_NAMES_ALT, + substituteReviewPeriodPlaceholders, +} from './journalHelpers' import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' import { stylesheetinksInHeader, faLinksInHeader, buildReviewHTML } from './reviewHTMLViewGenerator' -import { RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, getWeek, getPeriodOfNPDateStr, isDailyNote, isWeeklyNote, isMonthlyNote, isQuarterlyNote, isYearlyNote } from '@helpers/dateTime' +import { + RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, + getWeek, + getPeriodOfNPDateStr, + isDailyNote, + isWeeklyNote, + isMonthlyNote, + isQuarterlyNote, + isYearlyNote, +} from '@helpers/dateTime' import { clo, logDebug, logError, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' import { showHTMLV2 } from '@helpers/HTMLView' @@ -39,7 +53,7 @@ export async function dailyJournalQuestions(): Promise { const thisPeriodStr = strftime(`%Y-%m-%d`) logDebug(pluginJson, `Starting for day ${thisPeriodStr}`) - await processJournalQuestions(thisPeriodStr, 'day', 'Daily') + await processJournalQuestions(thisPeriodStr, 'day') } catch (error) { logError(pluginJson, error.message) } @@ -54,7 +68,7 @@ export async function weeklyJournalQuestions(): Promise { const thisPeriodStr = `${strftime(`%Y`)}-W${currentWeekNum}` logDebug(pluginJson, `Starting for week ${thisPeriodStr}`) - await processJournalQuestions(thisPeriodStr, 'week', 'Weekly') + await processJournalQuestions(thisPeriodStr, 'week') } catch (error) { logError(pluginJson, error.message) } @@ -68,7 +82,7 @@ export async function monthlyJournalQuestions(): Promise { const thisPeriodStr = strftime(`%Y-%m`) logDebug(pluginJson, `Starting for month ${thisPeriodStr}`) - await processJournalQuestions(thisPeriodStr, 'month', 'Monthly') + await processJournalQuestions(thisPeriodStr, 'month') } catch (error) { logError(pluginJson, error.message) } @@ -85,7 +99,7 @@ export async function quarterlyJournalQuestions(): Promise { const thisPeriodStr = `${strftime(`%Y`)}Q${String(thisQ)}` logDebug(pluginJson, `Starting for quarter ${thisPeriodStr}`) - await processJournalQuestions(thisPeriodStr, 'quarter', 'Quarterly') + await processJournalQuestions(thisPeriodStr, 'quarter') } catch (error) { logError(pluginJson, error.message) } @@ -99,38 +113,126 @@ export async function yearlyJournalQuestions(): Promise { const thisPeriodStr = strftime(`%Y`) logDebug(pluginJson, `Starting for year ${thisPeriodStr}`) - await processJournalQuestions(thisPeriodStr, 'year', 'Yearly') + await processJournalQuestions(thisPeriodStr, 'year') } catch (error) { logError(pluginJson, error.message) } } -//--------------------------------------------------------------- +//--------------------------------------------------------- +// Main review function, called by the plugin.json commands +//--------------------------------------------------------- + +/** + * Process questions for the given period, and write to the current note. + * If we're not in the correct note, offer to open it first. + * @author @jgclark + * @param {string} periodString the calendar note title string for the review period + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + */ +async function processJournalQuestions(periodStringIn: string = '', periodType: string): Promise { + try { + const periodAdjective = getPeriodAdjectiveFromType(periodType) + // Get configuration and questions + const config: JournalConfigType = await getJournalSettings() + const questionLines = await getQuestionsForPeriod(config, periodType) + // Only continue if we have some questions + const numQs = questionLines.length + if (!questionLines || numQs === 0 || questionLines[0] === '') { + await showMessage(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) + throw new Error(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) + } + logDebug(pluginJson, `Found ${numQs} question lines for ${periodType}`) + // Parse questions (may result in multiple questions per line if '||' is used) + const parsedQuestions = parseQuestions(questionLines) + + // Either: check that we have a correct period note open; + // Or: open the current period note (if configured). + // Else: warn user and stop. + const { note } = Editor // TODO: use a helper to check all open editors + const currentNotePeriod = (note && note.type === 'Calendar') ? getPeriodOfNPDateStr(note.title ?? '') : '' + logDebug('processJournalQuestions', `Starting with open note:${String(note?.title ?? 'unknown')} is of period '${String(currentNotePeriod)}', and we want '${periodType}' period`) + const reviewNote: ?TNote = ensureCorrectPeriodNoteIsOpen(periodType, periodStringIn) + if (!reviewNote) { + await showMessage(`No ${periodAdjective} note found, so cannot continue.`) + throw new Error(`No ${periodAdjective} note found, so cannot continue.`) + } + const titleFromNote = reviewNote.title != null ? String(reviewNote.title).trim() : '' + const periodString = periodStringIn.trim() !== '' ? periodStringIn.trim() : titleFromNote + logDebug('processJournalQuestions', `- Will use review note:${String(periodString)}`) + + await displayQuestionsWindow(parsedQuestions, periodString, periodType, config, questionLines, reviewNote) + + // Write answers to note + // Answers are applied in onReviewWindowAction when the user saves the HTML form. + } catch (err) { + if (err === 'cancelled') { + logDebug(pluginJson, `Asking questions cancelled by user: stopping.`) + } else { + logDebug(pluginJson, err.message) + } + } +} + +//------------------------------------------------------------- // Private functions -//--------------------------------------------------------------- +//------------------------------------------------------------- + +/** + * Map review title strings to forms understood by getFirstDateInPeriod / getLastDateInPeriod (e.g. `2026Q1` -> `2026-Q1`). + * TODO: Revisit why this is necessary. + * @param {string} periodTitle + * @returns {string} + */ +function normalizeReviewPeriodTitleForNPDateHelpers(periodTitle: string): string { + const compactQuarter = periodTitle.match(/^(\d{4})Q([1-4])$/i) + if (compactQuarter) { + return `${compactQuarter[1]}-Q${compactQuarter[2]}` + } + return periodTitle +} /** - * Ensure the correct period note is open, or open it automatically if configured. + * True when the editor’s open note is the calendar note for this review (same period type and same period as `periodStringIn` when it is non-empty). + * @param {TNote | void} note + * @param {string} periodType + * @param {string} periodStringIn + * @returns {boolean} + */ +function openEditorNoteMatchesReviewCommand(note: ?TNote, periodType: string, periodStringIn: string): boolean { + if (note == null || note.type !== 'Calendar') { + return false + } + const notePeriod = getPeriodOfNPDateStr(note.title ?? '') + if (notePeriod !== periodType) { + return false + } + const want = periodStringIn.trim() + if (want === '') { + return true + } + const a = normalizeReviewPeriodTitleForNPDateHelpers(String(note.title ?? '').trim()) + const b = normalizeReviewPeriodTitleForNPDateHelpers(want) + return a === b +} + +/** + * Reuse the open calendar note when it matches this review; otherwise open the calendar note for the current period. * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' - * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' + * @param {string} periodStringIn calendar title from the command (must match the open note to reuse it) * @returns {TNote | null} the note, or null if not found */ -function ensureCorrectPeriodNoteIsOpen( - periodType: string, - periodAdjective: string -): TNote | null { - // Open current calendar note if wanted +function ensureCorrectPeriodNoteIsOpen(periodType: string, periodStringIn: string = ''): TNote | null { const { note } = Editor + const periodAdjective = getPeriodAdjectiveFromType(periodType) logDebug('ensureCorrectPeriodNoteIsOpen', `current note=${String(note?.title ?? 'unknown')}`) - const currentNotePeriod = (note && note.type === 'Calendar') ? getPeriodOfNPDateStr(note.title ?? '') : '' - logDebug('ensureCorrectPeriodNoteIsOpen', `currentNotePeriod=${String(currentNotePeriod)}. Wanted: ${periodType}`) - if (currentNotePeriod === '' || currentNotePeriod === 'error' || currentNotePeriod !== periodType) { - Editor.openNoteByDate(new Date(), false, 0, 0, false, periodType) - logDebug('ensureCorrectPeriodNoteIsOpen', `Opened current ${periodAdjective} note automatically because openCalendarNoteWhenReviewing is true`) - return Editor.note ?? null - } else { - return note + if (openEditorNoteMatchesReviewCommand(note, periodType, periodStringIn)) { + logDebug('ensureCorrectPeriodNoteIsOpen', `Reusing open editor calendar note (matches ${periodType} / "${periodStringIn}")`) + return note ?? null } + logDebug('ensureCorrectPeriodNoteIsOpen', `Opening current ${periodAdjective} note (${periodType}); command period "${periodStringIn}"`) + Editor.openNoteByDate(new Date(), false, 0, 0, false, periodType) + return Editor.note ?? null } /** @@ -173,9 +275,24 @@ async function getQuestionsForPeriod(config: JournalConfigType, period: string): return rawQuestionLines } +/** + * Get the configured section heading to use for a given review period. + * @param {JournalConfigType} config the journal configuration + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @returns {string} + */ +function getSectionHeadingForPeriod(config: JournalConfigType, periodType: string): string { + if (periodType === 'day') { + return config.dailyJournalSectionHeading + } else { + return config.reviewSectionHeading + } +} + /** * Parse question lines to extract questions and their types. * Supports multiple questions per line separated by '||'. + * @tests in __tests__/journal.test.js * @param {Array | string} questionLines raw question lines from config * @returns {Array<{question: string, type: string, originalLine: string, lineIndex: number}>} parsed questions with types and line index */ @@ -255,12 +372,12 @@ export function parseQuestions(questionLines: Array | string): Array\s*$/i, '').trim() - const tag = headingType === 'h2' ? 'h2' : 'h3' - return `\n<${tag} class="review-subheading ${tag}">${cleanHeading}` + const headingMarker = headingType === 'h2' ? '##' : '###' + return `\n${headingMarker} ${cleanHeading}` } /** @@ -320,16 +437,6 @@ function stripMultilineAnswerPrefixes(rawBlock: string, linePrefix: string): str .trim() } -/** - * Replace `` placeholders with the review note title string. - * @param {string} input - * @param {string} periodString - * @returns {string} - */ -function substituteDateToken(input: string, periodString: string): string { - return input.replace(/<\s*date\s*>/gi, periodString) -} - /** * Split a question segment at the typed marker (same idea as reviewHTMLViewGenerator.splitSegmentAtTypeMarker). * @param {string} segment @@ -478,6 +585,7 @@ function extractExistingAnswerForReviewForm(parsedQuestion: ParsedQuestionType, /** * Map field names q_0, q_1, … to existing answers in the calendar note for pre-filling the review HTML form. + * @tests in __tests__/journal.test.js * @param {Array} parsedQuestions * @param {Array} paragraphLines lines to scan (e.g. from getParagraphLineContentsForReviewScan) * @returns {{ [string]: string }} @@ -562,8 +670,12 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe if (ol.trimStart().startsWith('-')) { return formatted } - const tagRe = new RegExp(`<\\s*${escapeRegExp(t)}\\s*>`, 'i') - return ol.replace(tagRe, formatted) + const { prefix, suffix } = splitParsedSegmentAtTypeMarker(ol, t) + const prefixTrimmed = prefix.trimEnd() + if (prefixTrimmed === '') { + return `${formatted}${suffix}` + } + return `${prefixTrimmed}\n${formatted}${suffix}` } case 'subheading': { return handleSubheadingQuestion(parsedQuestion.question) @@ -582,14 +694,19 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe /** * Build output from answers returned by single-window mode. + * @tests in __tests__/journal.test.js * @param {Array} parsedQuestions + * @param {Array} rawQuestionLines + * @param {string} periodString + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' * @param {{ [string]: string | boolean }} answersByIndex * @returns {string} */ -function buildOutputFromReviewWindowAnswers( +export function buildOutputFromReviewWindowAnswers( parsedQuestions: Array, rawQuestionLines: Array, periodString: string, + periodType: string, answersByIndex: { [string]: string | boolean }, ): string { let output = '' @@ -603,11 +720,6 @@ function buildOutputFromReviewWindowAnswers( for (let i = 0; i < lineQuestions.length; i++) { const globalIndex = parsedQuestions.findIndex((q) => q === lineQuestions[i]) const parsedQuestion = lineQuestions[i] - const resAQ = answerToQuestion(parsedQuestion.question) - if (resAQ !== '') { - logDebug(pluginJson, `- Found existing Q answer '${resAQ}', so won't ask again`) - continue - } const answer = answerFromReviewWindowPayload(parsedQuestion, answersByIndex[`q_${globalIndex}`] ?? '') if (answer !== '') { lineAnswers.push(answer) @@ -619,14 +731,14 @@ function buildOutputFromReviewWindowAnswers( if (!hasMultiline) { combinedLine = combinedLine.replace(/\s+/g, ' ') } - output += `${substituteDateToken(combinedLine, periodString)}\n` + output += `${substituteReviewPeriodPlaceholders(combinedLine, periodString, periodType)}\n` continue } - // If there are no questions/answers on this line, still carry `` through into output. + // If there are no questions/answers on this line, still carry `` / `` through into output. const rawLine = rawQuestionLines[lineIndex] ?? '' - if (/<\s*date\s*>/i.test(rawLine)) { - const substituted = substituteDateToken(stripPresentationDelimiters(rawLine), periodString).trim() + if (/<\s*date\s*>/i.test(rawLine) || /<\s*(?:datenext|nextdate)\s*>/i.test(rawLine)) { + const substituted = substituteReviewPeriodPlaceholders(stripPresentationDelimiters(rawLine), periodString, periodType).trim() if (substituted !== '') { output += `${substituted}\n` } @@ -660,20 +772,6 @@ function getReviewPeriodTitle(period: string): string { } } -/** - * Map review title strings to forms understood by getFirstDateInPeriod / getLastDateInPeriod (e.g. `2026Q1` -> `2026-Q1`). - * TODO: Revisit why this is necessary. - * @param {string} periodTitle - * @returns {string} - */ -function normalizeReviewPeriodTitleForNPDateHelpers(periodTitle: string): string { - const compactQuarter = periodTitle.match(/^(\d{4})Q([1-4])$/i) - if (compactQuarter) { - return `${compactQuarter[1]}-Q${compactQuarter[2]}` - } - return periodTitle -} - // /** // * Stable key for deduping calendar items returned on multiple days (e.g. multi-day events). // * @param {TCalendarItem} ev @@ -785,7 +883,6 @@ function getSummaryCompletedTasks(periodType: string, periodString: string): Arr * @param {Array} parsedQuestions * @param {string} periodString * @param {string} periodType - * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' * @param {JournalConfigType} config * @param {Array} rawQuestionLines lines from getQuestionsForPeriod (same array passed to parseQuestions) * @param {TNote} calendarNote the calendar note to scan for answers @@ -795,18 +892,18 @@ async function displayQuestionsWindow( parsedQuestions: Array, periodString: string, periodType: string, - periodAdjective: string, config: JournalConfigType, rawQuestionLines: Array, calendarNote: TNote, ): Promise { + const periodAdjective = getPeriodAdjectiveFromType(periodType) // Get the data sources we need for the review window - const summaryCompletedTasks = (periodAdjective === 'Day') ? getSummaryCompletedTasks(periodType, periodString) : [] + const summaryCompletedTasks = periodType === 'day' ? getSummaryCompletedTasks(periodType, periodString) : [] const calendarSet: Array = config.calendarSet ?? [] - logDebug(pluginJson, `calendarSet: [${String(calendarSet)}]`) + // logDebug(pluginJson, `calendarSet: [${String(calendarSet)}]`) const eventsForPeriod: Array = (periodType === 'day') ? await getEventsForDay(periodString, calendarSet) ?? [] : [] - clo(eventsForPeriod, 'eventsForPeriod') - const scanLines = getParagraphLineContentsForReviewScan(calendarNote, config.reviewSectionHeading ?? '') + const sectionHeading = getSectionHeadingForPeriod(config, periodType) + const scanLines = getParagraphLineContentsForReviewScan(calendarNote, sectionHeading) const initialAnswers = buildInitialReviewAnswersByFieldName(parsedQuestions, scanLines) // Build the HTML body for the review window from this data @@ -817,7 +914,6 @@ async function displayQuestionsWindow( summaryCompletedTasks, periodString, periodType, - periodAdjective, eventsForPeriod, REVIEW_WINDOW_CALLBACK_COMMAND, initialAnswers, @@ -855,15 +951,16 @@ async function displayQuestionsWindow( /** * Write the collected answers to the note: - * Add the finished review text to the current calendar note, appending after the line found in config.reviewSectionHeading. + * Add the finished review text to the current calendar note, appending after the configured heading for that period. * If the heading doesn't exist, then append it first. - * @param {JournalConfigType} config the journal configuration * @param {string} periodString the calendar note title string for the review period + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' * @param {string} answersText the text to insert into the journal * TODO(later): revert to being private, and take out of index.js & plugin.json */ export async function writeAnswersToNote( periodStringIn: string = '', + periodTypeIn: string = '', answersTextIn: string = '', ): Promise { try { @@ -872,7 +969,14 @@ export async function writeAnswersToNote( if (periodString === '') { periodString = Editor.note?.title ?? '' } + const allowedPeriodTypes = ['day', 'week', 'month', 'quarter', 'year'] + const isRecognizedPeriodType = allowedPeriodTypes.includes(periodTypeIn) + const periodType = isRecognizedPeriodType ? periodTypeIn : '' let answersText = answersTextIn ?? '' + // Backward-compatibility: earlier function signature was (periodString, answersText). + if (!isRecognizedPeriodType && periodTypeIn !== '' && answersText === '') { + answersText = periodTypeIn + } if (answersText === '') { const result = await getInput('No answers were collected from the review window. Please enter them manually here:', 'OK', 'Enter answers', '') if (result === false) { @@ -887,13 +991,15 @@ export async function writeAnswersToNote( // $FlowIgnore(incompatible-call) .note is a superset of CoreNoteFields const outputNote = Editor + const resolvedPeriodType = periodType !== '' ? periodType : getPeriodOfNPDateStr(periodString) + const sectionHeading = getSectionHeadingForPeriod(config, resolvedPeriodType) // $FlowIgnore[incompatible-call] .note is a superset of CoreNoteFields - logDebug(pluginJson, `Appending answers to heading '${config.reviewSectionHeading}' in note ${displayTitle(Editor.note)}`) - const matchedHeading = findHeadingStartsWith(outputNote, config.reviewSectionHeading) + logDebug(pluginJson, `Appending answers to heading '${sectionHeading}' in note ${displayTitle(Editor.note)}`) + const matchedHeading = findHeadingStartsWith(outputNote, sectionHeading) outputNote.addParagraphBelowHeadingTitle( answersText, 'empty', - matchedHeading ? matchedHeading : config.reviewSectionHeading, + matchedHeading ? matchedHeading : sectionHeading, true, true) } catch (err) { @@ -901,56 +1007,9 @@ export async function writeAnswersToNote( } } -/** - * Process questions for the given period, and write to the current note. - * If we're not in the correct note, offer to open it first. - * @author @jgclark - * @param {string} periodString the calendar note title string for the review period - * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' - * @param {string} periodAdjective adjective for period: 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' - */ -async function processJournalQuestions( - periodString: string, - periodType: string, - periodAdjective: string -): Promise { - try { - // Get configuration and questions - const config: JournalConfigType = await getJournalSettings() - - // Ensure correct period note is open - const reviewNote = ensureCorrectPeriodNoteIsOpen(periodType, periodAdjective) - if (!reviewNote) { - await showMessage(`No ${periodAdjective} note found, so cannot continue.`) - throw new Error(`No ${periodAdjective} note found, so cannot continue.`) - } - - const questionLines = await getQuestionsForPeriod(config, periodType) - - // Only continue if we have some questions - const numQs = questionLines.length - if (!questionLines || numQs === 0 || questionLines[0] === '') { - await showMessage(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) - throw new Error(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) - } - - logDebug(pluginJson, `Found ${numQs} question lines for ${periodType}`) - - // Parse questions (may result in multiple questions per line if '||' is used) - const parsedQuestions = parseQuestions(questionLines) - - await displayQuestionsWindow(parsedQuestions, periodString, periodType, periodAdjective, config, questionLines, reviewNote) - - // Write answers to note - // Answers are applied in onReviewWindowAction when the user saves the HTML form. - } catch (err) { - if (err === 'cancelled') { - logDebug(pluginJson, `Asking questions cancelled by user: stopping.`) - } else { - logDebug(pluginJson, err.message) - } - } -} +//--------------------------------------------------------- +// Callback function for HTML single-window review actions +//--------------------------------------------------------- /** * Callback function for HTML single-window review actions. @@ -959,6 +1018,7 @@ async function processJournalQuestions( */ export async function onReviewWindowAction(actionName: string, payload: string = ''): Promise { logDebug(pluginJson, `onReviewWindowAction action=${actionName}`) + // logDebug(pluginJson, `onReviewWindowAction payloadLength=${String(payload?.length ?? 0)} payloadPreview="${String(payload ?? '').slice(0, 100)}"`) const config: JournalConfigType = await getJournalSettings() if (actionName === 'cancel') { logDebug('Journalling/onReviewWindowAction', `Cancelled by user.`) @@ -971,15 +1031,30 @@ export async function onReviewWindowAction(actionName: string, payload: string = // Allow callback payloads sent via x-callback-url arg1 JSON string. safePayload = JSON.parse(payload) clo(safePayload, `onReviewWindowAction: parsed payload`) - const answers = safePayload.answers ?? {} + let answers = safePayload.answers ?? {} // Get the period type and string from the hidden fields in the payload - const periodType = safePayload.periodType ?? '' - const periodString = safePayload.periodString ?? '' + let periodType = safePayload.periodType ?? '' + let periodString = safePayload.periodString ?? '' + // Backward-compatibility: older bridges flattened q_* plus period fields at top level. + if ((periodType === '' || periodString === '') && typeof safePayload === 'object' && safePayload != null) { + periodType = periodType || String(safePayload.periodType ?? '') + periodString = periodString || String(safePayload.periodString ?? '') + } + if ((answers == null || Object.keys(answers).length === 0) && typeof safePayload === 'object' && safePayload != null) { + const extractedAnswers = {} + const keys = Object.keys(safePayload) + for (const key of keys) { + if (key.startsWith('q_')) { + extractedAnswers[key] = safePayload[key] + } + } + answers = extractedAnswers + } const questionLines = await getQuestionsForPeriod(config, periodType) const parsedQuestions = parseQuestions(questionLines) - const output = buildOutputFromReviewWindowAnswers(parsedQuestions, questionLines, periodString, answers) + const output = buildOutputFromReviewWindowAnswers(parsedQuestions, questionLines, periodString, periodType, answers) if (output !== '') { - await writeAnswersToNote(periodString, output) + await writeAnswersToNote(periodString, periodType, output) } else { logWarn(pluginJson, 'No answers were collected from the review window') } @@ -990,24 +1065,3 @@ export async function onReviewWindowAction(actionName: string, payload: string = logError(pluginJson, `onReviewWindowAction: couldn't parse payload JSON string: ${err.message}`) } } - -/** - * Look to see if this question has already been answered. - * If so return the line's content -- or empty string. - * @author @jgclark - * - * @param {string} question - * @returns {string} found answered question, or empty string - */ -function answerToQuestion(question: string): string { - const RE_Q = `${question}.+` - const { paragraphs } = Editor - let result = '' - for (const p of paragraphs) { - const m = p.content.match(RE_Q) - if (m != null) { - result = m[0] - } - } - return result -} diff --git a/jgclark.Journalling/src/journalHelpers.js b/jgclark.Journalling/src/journalHelpers.js index 59edc0469..9a2e498d5 100644 --- a/jgclark.Journalling/src/journalHelpers.js +++ b/jgclark.Journalling/src/journalHelpers.js @@ -2,10 +2,11 @@ //--------------------------------------------------------------- // Helper functions for Journalling plugin for NotePlan // Jonathan Clark -// last update 2026-03-25 for v2.0.0.b3 by @jgclark + @Cursor +// last update 2026-03-28 for v2.0.0.b4 by @jgclark + @Cursor //--------------------------------------------------------------- import pluginJson from '../plugin.json' +import { getNextNPPeriodString } from '@helpers/dateTime' import { clo, logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { showMessage } from '@helpers/userInput' @@ -15,16 +16,16 @@ import { showMessage } from '@helpers/userInput' const pluginID = 'jgclark.Journalling' export type JournalConfigType = { + dailyJournalSectionHeading: string, + reviewSectionHeading: string, startDailyTemplateTitle: string, endDailyTemplateTitle: string, startWeeklyTemplateTitle: string, endWeeklyTemplateTitle: string, startMonthlyTemplateTitle: string, endMonthlyTemplateTitle: string, - reviewSectionHeading: string, openCalendarNoteWhenReviewing: boolean, preferredWindowType: string, - reviewUIMode?: string, // legacy setting kept for backward compatibility dailyReviewQuestions: string, weeklyReviewQuestions: string, monthlyReviewQuestions: string, @@ -71,3 +72,41 @@ export async function getJournalSettings(): Promise { // want to use Promis return // for completeness } } + + +/** + * Replace `` with the calendar period title and `` / `` with the following period. + * Used for the review HTML window and for text written back to the note. + * @param {string} input + * @param {string} periodString + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} + */ +export function substituteReviewPeriodPlaceholders(input: string, periodString: string, periodType: string): string { + const nextPeriodStr = getNextNPPeriodString(periodString, periodType) + return input + .replace(/<\s*date\s*>/gi, periodString) + .replace(/<\s*(?:datenext|nextdate)\s*>/gi, nextPeriodStr) +} + +/** + * Title-case adjective for UI strings (window title, review heading, messages). + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} e.g. 'Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly' + */ +export function getPeriodAdjectiveFromType(periodType: string): string { + switch (periodType) { + case 'day': + return 'Daily' + case 'week': + return 'Weekly' + case 'month': + return 'Monthly' + case 'quarter': + return 'Quarterly' + case 'year': + return 'Yearly' + default: + return '(error: unknown period type)' + } +} \ No newline at end of file diff --git a/jgclark.Journalling/src/reviewHTMLViewGenerator.js b/jgclark.Journalling/src/reviewHTMLViewGenerator.js index 53e046c03..8986738a2 100644 --- a/jgclark.Journalling/src/reviewHTMLViewGenerator.js +++ b/jgclark.Journalling/src/reviewHTMLViewGenerator.js @@ -2,13 +2,17 @@ //--------------------------------------------------------------- // HTMLView generation helpers for single-window review mode // Jonathan Clark + Cursor -// last update 2026-03-25 for v2.0.0.b3 by @jgclark + @Cursor +// last update 2026-03-28 for v2.0.0.b4 by @jgclark + @Cursor //--------------------------------------------------------------- import moment from 'moment' import pluginJson from '../plugin.json' import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' -import { REVIEW_QUESTION_TYPE_NAMES_ALT } from './journalHelpers' +import { + getPeriodAdjectiveFromType, + REVIEW_QUESTION_TYPE_NAMES_ALT, + substituteReviewPeriodPlaceholders, +} from './journalHelpers' import { RE_DONE_DATE_OPT_TIME } from '@helpers/dateTime' import { clo, logDebug, logInfo, logError, logWarn } from '@helpers/dev' import { @@ -385,9 +389,8 @@ function makeReviewQuestionRowDiv( * @param {Array} parsedQuestions same order as parseQuestions(rawQuestionLines) (field names q_0 …) * @param {Array} rawQuestionLines lines from getQuestionsForPeriod() * @param {Array} summaryCompletedTasks - * @param {string} periodAdjective - * @param {string} periodType * @param {string} periodString the calendar note title string for the review period + * @param {string} periodType * @param {Array} eventsForPeriod * @param {string} callbackCommandName * @param {{ [string]: string }=} initialAnswers field names q_0 … to pre-fill from the calendar note @@ -400,13 +403,13 @@ export function buildReviewHTML( summaryCompletedTasks: Array, periodString: string, periodType: string, - periodAdjective: string, eventsForPeriod: Array, callbackCommandName: string, initialAnswers?: { [string]: string }, ): string { + const periodAdjective = getPeriodAdjectiveFromType(periodType) const resolvedInitialAnswers = initialAnswers ?? {} - const renderQuestionLines = rawQuestionLines.map((l) => l.replace(/<\s*date\s*>/gi, periodString)) + const renderQuestionLines = rawQuestionLines.map((l) => substituteReviewPeriodPlaceholders(l, periodString, periodType)) const questionRows = renderQuestionLines .map((line, lineIndex) => makeReviewRawQuestionLineDiv(line, lineIndex, parsedQuestions, config, resolvedInitialAnswers)) .filter((row) => row !== '') @@ -422,10 +425,10 @@ export function buildReviewHTML( ${questionRows}
- + - +
@@ -438,10 +441,28 @@ export function buildReviewHTML( console.log("sendToPlugin: hasSentReviewAction is true; stopping.") return } - hasSentReviewAction = true - // Use x-callback-url to avoid relying on DataStore in the WebView JS runtime. const actionName = String(commandArgs?.[0] ?? '') const payload = commandArgs?.[1] ?? {} + + // Primary path: use NotePlan's jsBridge to invoke DataStore from the native side. + // This avoids URL length limits for large review payloads. + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.jsBridge) { + const commandArgsArray = [actionName, JSON.stringify(payload)] + const code = '(async function() { await DataStore.invokePluginCommandByName(%%commandName%%, %%pluginID%%, %%commandArgs%%);})()' + .replace('%%commandName%%', JSON.stringify(commandName)) + .replace('%%pluginID%%', JSON.stringify(pluginID)) + .replace('%%commandArgs%%', JSON.stringify(commandArgsArray)) + console.log("window.sendToPlugin: Sending via jsBridge:", commandName, pluginID) + window.webkit.messageHandlers.jsBridge.postMessage({ + code: code, + onHandle: '', + id: '1', + }) + hasSentReviewAction = true + return + } + + // Fallback path: x-callback-url for contexts without jsBridge. const callbackUrl = 'noteplan://x-callback-url/runPlugin?pluginID=' + encodeURIComponent(pluginID) + '&command=' @@ -452,9 +473,11 @@ export function buildReviewHTML( + encodeURIComponent(JSON.stringify(payload)) console.log("window.sendToPlugin: Sending callbackURL: "+callbackUrl) window.location.href = callbackUrl + hasSentReviewAction = true } const reviewForm = document.getElementById('review-form') const cancelButton = document.getElementById('review-cancel') + const submitButton = document.getElementById('review-submit') /** * Collect answers from the review form and return as a JSON string. @@ -470,11 +493,13 @@ export function buildReviewHTML( for (const checkbox of checkboxes) { answers[checkbox.name] = checkbox.checked } - // Add a special 'answer's for the period type (day, week, month, quarter, year) and string (e.g. '2026-03-23') - answers['periodType'] = '${periodType}' - answers['periodString'] = '${periodString}' - console.log("collectAnswers(): -> "+JSON.stringify(answers)) - return answers + const payload = { + answers, + periodType: ${JSON.stringify(periodType)}, + periodString: ${JSON.stringify(periodString)}, + } + console.log("collectAnswers(): -> "+JSON.stringify(payload)) + return payload } function cancel() { @@ -482,9 +507,9 @@ export function buildReviewHTML( sendToPlugin('${callbackCommandName}', '${pluginJson['plugin.id']}', ['cancel', { }]) } - function submit() { - console.log("HTMLView: submit() called") - sendToPlugin('${callbackCommandName}', '${pluginJson['plugin.id']}', ['submit', JSON.stringify(collectAnswers())]) + function submitReview() { + console.log("HTMLView: submitReview() called") + sendToPlugin('${callbackCommandName}', '${pluginJson['plugin.id']}', ['submit', collectAnswers()]) } const firstInputControl = reviewForm.querySelector('textarea, input:not([type="hidden"]), select') @@ -492,19 +517,16 @@ export function buildReviewHTML( firstInputControl.focus() } - + if (cancelButton) { + cancelButton.addEventListener('click', function () { + cancel() + }) + } + if (submitButton) { + submitButton.addEventListener('click', function () { + submitReview() + }) + } ` } - From 63b8c422dc2b9eba758624f81c3370d6cc3cb17c Mon Sep 17 00:00:00 2001 From: jgclark Date: Tue, 31 Mar 2026 15:15:56 +0100 Subject: [PATCH 06/19] Added isEditorWindowOpenByTitle() helper --- helpers/NPWindows.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/helpers/NPWindows.js b/helpers/NPWindows.js index 4a894a0a7..78647eda3 100644 --- a/helpers/NPWindows.js +++ b/helpers/NPWindows.js @@ -259,6 +259,9 @@ export function isHTMLWindowOpen(customId: string): boolean { /** * Is a given note open in a NP Editor window/split, based on its filename? + * Supports Teamspace notes. + * Note: see related isEditorWindowOpenByTitle() in same file. + * @test Manualy tested by JGC 31.3.2026 * @author @jgclark * @param {string} filename to look for * @returns {boolean} @@ -267,7 +270,28 @@ export function isEditorWindowOpen(filename: string): boolean { // Get list of open Editor windows/splits const allEditorWindows = NotePlan.editors for (const thisEditorWindow of allEditorWindows) { - if (thisEditorWindow.filename === filename) { + if (caseInsensitiveMatch(filename, thisEditorWindow.filename)) { + return true + } + } + return false +} + +/** + * Is a given note open in a NP Editor window/split, based on its title? + * Supports Calendar and Teamspace notes. + * Note: see related isEditorWindowOpen() in same file. + * @test Manualy tested by JGC 31.3.2026 + * @author @jgclark + * @param {string} wantedTitle to look for + * @returns {boolean} + */ +export function isEditorWindowOpenByTitle(wantedTitle: string): boolean { + // Get list of open Editor windows/splits + const allEditorWindows = NotePlan.editors ?? [] + logDebug('isEditorWindowOpenByTitle', `Looking for match in ${String(allEditorWindows.length)} Editors`) + for (const thisEditorWindow of allEditorWindows) { + if (caseInsensitiveMatch(wantedTitle, thisEditorWindow.title ?? '')) { return true } } From 4af7e534a826698d7618c32befb55ae75413576e Mon Sep 17 00:00:00 2001 From: jgclark Date: Tue, 31 Mar 2026 22:50:55 +0100 Subject: [PATCH 07/19] b5 improvements --- jgclark.Journalling/CHANGELOG.md | 6 + jgclark.Journalling/README.md | 79 ++++++---- ...{journal.test.js => periodReviews.test.js} | 63 ++++++-- jgclark.Journalling/plugin.json | 33 ++-- jgclark.Journalling/requiredFiles/reviews.css | 18 ++- jgclark.Journalling/src/index.js | 24 ++- jgclark.Journalling/src/journalHelpers.js | 2 +- .../src/{journal.js => periodReviews.js} | 143 ++++++++++-------- .../src/reviewHTMLViewGenerator.js | 32 ++-- 9 files changed, 259 insertions(+), 141 deletions(-) rename jgclark.Journalling/__tests__/{journal.test.js => periodReviews.test.js} (86%) rename jgclark.Journalling/src/{journal.js => periodReviews.js} (90%) diff --git a/jgclark.Journalling/CHANGELOG.md b/jgclark.Journalling/CHANGELOG.md index 25f2c555b..da32f6f7f 100644 --- a/jgclark.Journalling/CHANGELOG.md +++ b/jgclark.Journalling/CHANGELOG.md @@ -3,6 +3,12 @@ _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark. Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. +## [2.0.0.b5] - 2026-03-31 +- Added a new review question type `` that accepts `[H]H:MM` input (for example `1:05` or `12:30`) in the review window and when writing answers to notes. +- Ensure Question strings are handled case-insensitively +- Added list of events in the day to the summary at the start +- dev: Rename journal.js to periodReviews.js + ## [2.0.0.b4] - 2026-03-28 - Split section-heading settings: `dailyJournalSectionHeading` is now used by daily journal commands, and `reviewSectionHeading` is used by weekly/monthly/quarterly/yearly review commands. Existing installs migrate heading values to preserve prior behavior. - **Review placeholders:** `` is replaced with the current review period’s calendar title (e.g. `2026-03-28`, `2026-W13`, `2026Q1`) in the review window and the output. diff --git a/jgclark.Journalling/README.md b/jgclark.Journalling/README.md index 63dc67836..5169024f9 100644 --- a/jgclark.Journalling/README.md +++ b/jgclark.Journalling/README.md @@ -1,12 +1,12 @@ -# 💭 Journalling Helpers Plugin -This plugin gives support for journalling in NotePlan, including making it easier to apply start-of-day Templates, and end-of-day/week Reviews, Summaries or Tidy Up. +# 💭 Journalling & Reviews Plugin +This plugin makes it easier for you to journal and/or review your days/weeks/months/quarters/years into NotePlan. It also makes it quicker to apply templates at the start or end of days/weeks/months/quarters/years. ### Configuration To use this plugin on weekly/monthly/quarterly/yearly notes, you first need to have them turned on in **NotePlan Settings** > Calendar pane: -All the available commands require some **configuration** first. On Mac click the gear button on the 'Journalling' line in the Plugin Preferences panel, and fill in the settings according to which of the following commands you want to use. If you only use iPhone or iPad, you'll need to use the separate **/Journalling: update plugin settings** command instead. +All the available commands require also some **configuration** first. On Mac click on the 'Journalling & Reviews' card in the Plugin Preferences panel, and then the the gear button. ## Quickly applying Templates at the start of each Day/Week The NotePlan website has good [articles on getting started with Templates](https://help.noteplan.co/article/136-templates), and a helpful [Template Gallery](https://noteplan.co/templates), to build from. @@ -19,80 +19,99 @@ These commands make it quicker to apply a Template at the start or end of a day - **/todayStart**: applies your 'Daily Note' Template only to _today's_ calendar note, no matter what note you're editing. - **/dayStart** appends your 'Daily Note' Template to the _currently open daily note_ (or today's note if you're not editing a daily note). Therefore, be careful using it on another calendar note than today using template tag commands like `<%- date... / formattedDate... %>` or `<%- weather() %>` -> because this renders the TODAY content! -### /weekStart command -This acts very similarly to the /dayStart command above. +### /weekStart and /monthStart commands +These act very similarly to the /dayStart command above. -### /dayEnd, /todayEnd, /weekEnd commands +### /dayEnd, /todayEnd, /weekEnd, /monthEnd commands These act in the same way as above, but can be tailored to adding items at the end of a day, perhaps like a Habit or Stats summary from the separate [**Habits & Summaries plugin**](https://noteplan.co/plugins/jgclark.Summaries). This is also a quick way of regularly running one or more commands from the separate [**Tidy Up plugin**](https://noteplan.co/plugins/np.Tidy). ## Helping with periodic Reviews -There's no right or wrong way to do reviews, and you'll no doubt change what you find helpful over time. But the key is to be taking some time to answer questions to help you pause and review what has and hasn't gone well over the last day/week/month/quarter. Some use it as a way of capturing their main **emotions**; others to track **goals**; others to write a simple **gratitude** journal. +There's no right or wrong way to do reviews, and you'll no doubt change what you find helpful over time. But the key is to be taking some time to answer questions to help you pause and review what has and hasn't gone well over the last day/week/month/quarter. Some use it as a way of capturing their main **emotions**; others to track **goals**; others to write a simple **gratitude journal**. ### Using Templates you fill in -One way to do this is to configure an end-of-day/week Template -- for example this [Mental Health Journal](https://noteplan.co/templates/mental-health-journal-template) -- which you then apply to your current note using the /dayEnd or /weekEnd command, and then fill in your responses. +One way to do this is to configure an end-of-day/week Template -- for example this [Mental Health Journal](https://noteplan.co/templates/mental-health-journal-template) -- which you then apply to your current note using the /dayEnd or /weekEnd command, and then add your responses. ### Interactively -Alternatively, the **/dayReview**, **/weekReview**, **/monthReview**, **/quarterReview**, and/or **/yearReview** commands present you with a set of questions interactively. You first need to configure the sets of questions to ask in the plugin settings. In each case a default set of questions is provided to get you started. Each setting is explained: +Alternatively, the **/dayReview**, **/weekReview**, **/monthReview**, **/quarterReview**, and/or **/yearReview** commands present you with a window with your set of questions to fill in. You first need to configure the sets of questions to ask in the plugin settings. In each case a default set of questions is provided to get you started. Each setting is explained: #### Journal Section Heading The name of an existing markdown heading after which the review answers are added. If it doesn't exist, it is added at the end of the note. +??? + "key": "dailyJournalSectionHeading", + "title": "Daily Journal Section Heading", + "description": "The name of a section heading after which Daily Journal answers are added - if it doesn't exist, it is added at the end of the note.", + "type": "string", + "default": "Journal", + "required": true + }, + { + "key": "reviewSectionHeading", + "title": "Review Section Heading", + "description": "The name of a section heading after which weekly/monthly/quarterly/yearly Review answers are added - if it doesn't exist, it is added at the end of the note.", #### Daily / Weekly / Monthly / Quarterly / Year Journal Questions -This string includes both the questions and how to lay out the answers in the note. There are several possible question types: +This string includes both the questions and how to lay out the answers in the note. These are the possible question types: - `` shows the text as a question, and if you answer "Yes" to the question, the text is included. If you answer "No" it isn't included. - `` asks for an integer number - `` asks for a number (which may include fractional part) +- `` asks for a duration in `[H]H:MM` format (for example `1:05` or `12:30`) - `` asks for a string - `` asks for one or more lines; each non-empty line is written to the note as a markdown bullet (`- `) - `` same, but each line is written with a checklist marker (`+ `) - `` same, but each line is written with a task marker (`* `) - ``select one of the configured moods -- `` will include the rest of the text in the line and will be included in the output if you answer "Yes" to the "Yes"/"No" question about it -- `` is not a question: it is replaced in the window and output with the review note title (e.g. `2026-03-25`, `2026-03` or `2026-W13`). -- `

` and `

` (or the legacy equivalent ``) includes the given string as a usual H2 or H3 markdown heading. +- `` will include the rest of the text in the line and will be included in the output if you answer "Yes" to the "Yes"/"No" question about it. + +There are also some placeholders you can use to add things to your output: +- `

` and `

` (or the legacy equivalent ``) includes the text on the rest of the line as a usual H2 or H3 markdown heading. +- `` is replaced in the window and output with the review note title (e.g. `2026-03-25`, `2026-03` or `2025-W52`). +- `` or `` acts similarly but gives the _next_ date (e.g. `2026-03-26`, `2026-04` or `2026-W01`). - You can also use markdown heading lines directly in the settings, for example `## Heading` and `### Subheading`. +- You can include line breaks, or `\n` characters. Other notes: -- You can includes line breaks ('new lines') with `\n` characters. - You can have multiple questions of type ``, `` or `` on a line, with any text around them. The answers will also be placed on the one line, but with the `||` removed, and with a space separating them. -- You can hide the question text by starting it with a dash e.g. `-(thoughts) `. This will just output the entered text. (The identifier is there to work out which question the user currently is on.) ??? + - If a particular question isn't answered (i.e. no input entered), then that question isn't included in the output. -- If a particular question has already been answered in the note (i.e. if a line starts with the same question text), it won't be asked again. +- If a particular question has already been answered in the note (i.e. if a line starts with the same question text), the current answer will be shown in the review window #### Moods A comma-separated list of possible moods to select from. They don't have to have emoji, but I rather like them. ### Example for /dayReview -The following `reviewQuestions` string: +The following question setting: ``` @sleep() @work() @fruitveg() #stretches #closedRings Mood: +Gratitude: +Wins: -Significant Thoughts - - -Focus areas for next week

+

'Big three' tasks for ``` -after answering the questions, would produce something like this in today's note: +produces this review window: + +After answering the questions, would produce something like this in today's note: ```markdown ## Journal @sleep(6.8) @work(7) @fruitveg(4) #stretches Mood: 😇 Blessed - -### Significant Thoughts -- Entered thought 1 -- Another thought - -## Focus areas for next week -+ Area 1 -+ Area 2 +Gratitude: Went to great Nana's 100th birthday party -- result! +Wins: +- First win... +- Another one + +## 'Big three' tasks for 2026-03-29 ++ Main thing ++ Another important thing ``` +## Automation +??? ## Support If you find an issue with this plugin, or would like to suggest new features for it, please raise a [Bug or Feature 'Issue' in GitHub](https://github.com/NotePlan/plugins/issues). diff --git a/jgclark.Journalling/__tests__/journal.test.js b/jgclark.Journalling/__tests__/periodReviews.test.js similarity index 86% rename from jgclark.Journalling/__tests__/journal.test.js rename to jgclark.Journalling/__tests__/periodReviews.test.js index 056b530b6..1549aa8be 100644 --- a/jgclark.Journalling/__tests__/journal.test.js +++ b/jgclark.Journalling/__tests__/periodReviews.test.js @@ -2,7 +2,7 @@ // Last updated: 2026-03-25 for v2.0.0.b3 by @Cursor -import { buildInitialReviewAnswersByFieldName, buildOutputFromReviewWindowAnswers, parseQuestions } from '../src/journal' +import { buildInitialReviewAnswersByFieldName, buildOutputFromReviewWindowAnswers, parseQuestions } from '../src/periodReviews' import { getPeriodAdjectiveFromType, substituteReviewPeriodPlaceholders } from '../src/journalHelpers' import { DataStore } from '@mocks/index' @@ -17,7 +17,7 @@ beforeAll(() => { }) // Jest suite -describe('Journal', () => { +describe('Reviews', () => { describe('parseQuestions', () => { it('should parse questions correctly (test 1)', () => { const config = { @@ -72,6 +72,14 @@ Do: ` expect(questions[0].question).toBe('Wins') }) + it('should parse duration type', () => { + const raw = '@focus()' + const questions = parseQuestions(raw) + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('duration') + expect(questions[0].question).toBe('@focus') + }) + it('should parse markdown headings (## / ###) into h2/h3', () => { const raw = `## Top Heading\n### Sub Heading\nTitle: ` const questions = parseQuestions(raw) @@ -168,7 +176,7 @@ Do: ` expect(getPeriodAdjectiveFromType('year')).toBe('Yearly') }) it('should return Calendar for unknown period type', () => { - expect(getPeriodAdjectiveFromType('unknown')).toBe('Calendar') + expect(getPeriodAdjectiveFromType('unknown')).toBe('(error: unknown period type)') }) }) @@ -180,24 +188,25 @@ Do: ` }) describe('buildInitialReviewAnswersByFieldName', () => { - it('should extract int, boolean, mood, and string answers from review-style lines', () => { + it('should extract int, duration, boolean, mood, and string answers from review-style lines', () => { const config = { - dailyReviewQuestions: `Health: @sleep() @fruitveg() #bible #stretches #closedRings + dailyReviewQuestions: `Health: @sleep() @fruitveg() #bible #stretches #closedRings Work: @work() @1CB() @CRC() Mood: Gratitude: -God was: `, +Not Great: `, } const questions = parseQuestions(config.dailyReviewQuestions) - const lines = [ - 'Health: @sleep(7) @fruitveg(5) #bible #stretches #closedRings', + const textLines = [ + 'Not great: something not great', + '### Journal', + 'Health: @sleep(7:32) @fruitveg(5) #bible #stretches #closedRings', 'Work: @work(8) @1CB(1) @CRC(2)', 'Mood: Calm', - 'Gratitude: Family time', - 'God was: present', + 'Gratitude: Family time' ] - const initial = buildInitialReviewAnswersByFieldName(questions, lines) - expect(initial.q_0).toBe('7') + const initial = buildInitialReviewAnswersByFieldName(questions, textLines) + expect(initial.q_0).toBe('7:32') expect(initial.q_1).toBe('5') expect(initial.q_2).toBe('yes') expect(initial.q_3).toBe('yes') @@ -207,15 +216,15 @@ God was: `, expect(initial.q_7).toBe('2') expect(initial.q_8).toBe('Calm') expect(initial.q_9).toBe('Family time') - expect(initial.q_10).toBe('present') + expect(initial.q_10).toBe('something not great') }) - it('should prefer the last matching line when several exist', () => { + it('should prefer the first matching line when several exist', () => { const config = { dailyReviewQuestions: 'Gratitude: ' } const questions = parseQuestions(config.dailyReviewQuestions) const lines = ['Gratitude: first', 'Gratitude: second'] const initial = buildInitialReviewAnswersByFieldName(questions, lines) - expect(initial.q_0).toBe('second') + expect(initial.q_0).toBe('first') }) it('should extract multiline bullets, checklists, and tasks for pre-fill', () => { @@ -235,6 +244,14 @@ Ship: `, expect(initial.q_1).toBe('eggs\nmilk') expect(initial.q_2).toBe('task one\ntask two') }) + + it('should extract duration answers from review-style lines', () => { + const config = { dailyReviewQuestions: '@focus()' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['@focus(1:45)'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('1:45') + }) }) describe('buildOutputFromReviewWindowAnswers', () => { @@ -293,6 +310,22 @@ Ship: `, expect(out).toBe('Health: @sleep(7) @fruitveg(3)\n') }) + it('should output duration answers in [H]H:MM format', () => { + const raw = '@focus()' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: '1:30' }) + expect(out).toBe('@focus(1:30)\n') + }) + + it('should omit invalid duration answers', () => { + const raw = '@focus()' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: '1:75' }) + expect(out).toBe('') + }) + it('should combine multiline bullet answers with newlines (inline tag replacement keeps prefix)', () => { const raw = 'Wins: ' const parsedQuestions = parseQuestions(raw) diff --git a/jgclark.Journalling/plugin.json b/jgclark.Journalling/plugin.json index f8d947634..7e611c374 100644 --- a/jgclark.Journalling/plugin.json +++ b/jgclark.Journalling/plugin.json @@ -3,7 +3,7 @@ "macOS.minVersion": "10.13.0", "plugin.id": "jgclark.Journalling", "plugin.name": "💭 Journalling & Reviews", - "plugin.description": "Supports you reviewing your days/weeks/months/quarters/years into NotePlan in a 'Journal' section. Also makes it easier to apply start-of-day/week/month/quarter/year templates. Before use it requires some configuration: please see the documentation for details.", + "plugin.description": "This plugin makes it easier for you to journal and/or review your days/weeks/months/quarters/years into NotePlan. It also makes it quicker to apply templates at the start or end of days/weeks/months/quarters/years.\nIt requires some configuration before use: please see the documentation for details.", "plugin.icon": "clipboard-list", "plugin.iconColor": "blue-600", "plugin.author": "Jonathan Clark", @@ -89,7 +89,7 @@ "ds", "startDay" ], - "description": "Apply Daily Note Template", + "description": "Apply Daily Note template to the current daily note", "jsFunction": "dayStart" }, { @@ -99,7 +99,7 @@ "de", "endDay" ], - "description": "Apply Day End Template", + "description": "Apply Day End template to the current daily note", "jsFunction": "dayEnd" }, { @@ -109,7 +109,7 @@ "today", "ts" ], - "description": "Apply Daily Note Template to Today's Calendar Note", + "description": "Apply Daily Note template to Today's note", "jsFunction": "todayStart" }, { @@ -119,7 +119,7 @@ "endToday", "te" ], - "description": "Apply Day End Template to Today's Calendar Note", + "description": "Apply Day End template to Today's note", "jsFunction": "todayEnd" }, { @@ -128,13 +128,13 @@ "weekly", "ws" ], - "description": "Apply Weekly Note Template", + "description": "Apply Week End template to the current weekly note", "jsFunction": "weekStart" }, { "name": "weekEnd", "alias": [], - "description": "Apply Week End Template", + "description": "Apply Week End template to the current weekly note", "jsFunction": "weekStart" }, { @@ -143,9 +143,15 @@ "monthly", "ms" ], - "description": "Apply Monthly Note Template (requires configuring)", + "description": "Apply Monthly Note template to the current monthly note", "jsFunction": "monthStart" }, + { + "name": "monthEnd", + "alias": [], + "description": "Apply Month End template to the current monthly note", + "jsFunction": "monthEnd" + }, { "hidden": true, "name": "onReviewWindowAction", @@ -168,6 +174,15 @@ "name": "Journalling: update plugin settings", "description": "Settings interface (even for iOS)", "jsFunction": "updateSettings" + }, + { + "comment": "TODO: remove soon", + "name": "Test:EditorOpen", + "description": "testEditorOpen", + "jsFunction": "testEditorOpen", + "alias": [ + "teo" + ] } ], "plugin.settings": [ @@ -238,7 +253,7 @@ { "key": "dailyReviewQuestions", "title": "Daily Review/Journal Questions", - "description": "String that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '', '', '', '', '', '', and '' (replaced with the review note title) and '' or '' (the following calendar period in the same format).", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '' ([H]H:MM), '', '', '', '', '', '', and '' (replaced with the review note title) and '' or '' (the following calendar period in the same format).", "type": "string", "default": "@sleep()\n@work()\n@fruitveg()\nMood: \nGratitude: \nGod was: \nAlive: \nNot Great: \nLearn: \nRemember: ", "required": false diff --git a/jgclark.Journalling/requiredFiles/reviews.css b/jgclark.Journalling/requiredFiles/reviews.css index 5a7e28629..a837aa389 100644 --- a/jgclark.Journalling/requiredFiles/reviews.css +++ b/jgclark.Journalling/requiredFiles/reviews.css @@ -5,7 +5,7 @@ body { } .section-wrap { - padding: 0.8rem 1.2rem; + padding: 0.6rem 1rem; border: 1px solid var(--divider-color, #CDCFD0); border-radius: 6px; background: var(--bg-alt-color, #e6e9ef); @@ -27,26 +27,34 @@ body { .summary-title { font-weight: 600; color: var(--h3-color, #5c5f77); + padding: 0rem 0rem; } .summary-content { column-count: 2; column-gap: 1rem; font-size: 0.85rem; + margin-left: 0.75rem; + margin-bottom: 0.35rem; } .summary-item { break-inside: avoid; display: flex; - align-items: flex-start; + align-items: baseline; gap: 0.4rem; - margin-bottom: 0.35rem; + margin-bottom: 0.15rem; +} + +.summary-item-completed-icon { + color: #098308A0; /* TODO: set this to a theme colour */ + line-height: 1.2; + flex-shrink: 0; } -.summary-item-icon { +.summary-item-event-icon { color: #098308A0; /* TODO: set this to a theme colour */ line-height: 1.2; - margin-top: 0.04rem; flex-shrink: 0; } diff --git a/jgclark.Journalling/src/index.js b/jgclark.Journalling/src/index.js index 8796107ef..58616cbfa 100644 --- a/jgclark.Journalling/src/index.js +++ b/jgclark.Journalling/src/index.js @@ -3,7 +3,7 @@ //--------------------------------------------------------------- // Journalling commands // Jonathan Clark -// last update 2026-03-24 for v2.0.0.b2 by @jgclark +// last update 2026-03-28 for v2.0.0.b4 by @jgclark //--------------------------------------------------------------- // allow changes in plugin.json to trigger recompilation @@ -11,7 +11,6 @@ import pluginJson from '../plugin.json' import { clo, compareObjects, JSP, logDebug, logInfo, logError } from "@helpers/dev" import { backupSettings, getSettings, pluginUpdated, saveSettings } from '@helpers/NPConfiguration' import { editSettings } from '@helpers/NPSettings' -import { getEventsForDay } from '@helpers/NPCalendar' const pluginID = 'jgclark.Journalling' const oldPluginID = 'jgclark.DailyJournal' @@ -23,8 +22,7 @@ export { monthlyJournalQuestions, quarterlyJournalQuestions, yearlyJournalQuestions, - writeAnswersToNote // TODO(later): remove -} from './journal' +} from './periodReviews' export { dayStart, @@ -36,6 +34,24 @@ export { monthStart, } from './templatesStartEnd' +// TODO(later): remove +// import { isEditorWindowOpen, isEditorWindowOpenByTitle } from '@helpers/NPWindows' +// export function testEditorOpen(): void { +// try { +// // Test 1 +// // const title = "2026-03-30" +// // const res = isEditorWindowOpenByTitle(title) +// // logInfo('testEditorOpen', `isEditorWindowOpenByTitle(${title}) => ${String(res)}`) + +// // Test 2 +// const title = "20260331.md" // "%%NotePlanCloud%%/1b91b194-4c76-4a48-8d4d-4c499d64a919/20260331.md" +// const res = isEditorWindowOpen(title) +// logInfo('testEditorOpen', `isEditorWindowOpen(${title}) => ${String(res)}`) +// } catch (error) { +// logError('testEditorOpen', error.message) +// } +// } + export function init(): void { try { // Check for the latest version of the plugin, and if a minor update is available, install it and show a message diff --git a/jgclark.Journalling/src/journalHelpers.js b/jgclark.Journalling/src/journalHelpers.js index 9a2e498d5..7a8730de4 100644 --- a/jgclark.Journalling/src/journalHelpers.js +++ b/jgclark.Journalling/src/journalHelpers.js @@ -44,7 +44,7 @@ export type ParsedQuestionType = { /** `` names in review question templates — keep in sync with `parseQuestions` and `REVIEW_SEGMENT_RE`. */ export const REVIEW_QUESTION_TYPE_NAMES_ALT = - 'string|int|number|boolean|mood|subheading|h2|h3|bullets|checklists|tasks' + 'string|int|number|duration|boolean|mood|subheading|h2|h3|bullets|checklists|tasks' //--------------------------------------------------------------- // Settings diff --git a/jgclark.Journalling/src/journal.js b/jgclark.Journalling/src/periodReviews.js similarity index 90% rename from jgclark.Journalling/src/journal.js rename to jgclark.Journalling/src/periodReviews.js index 0ee0d514f..b13bc23c9 100644 --- a/jgclark.Journalling/src/journal.js +++ b/jgclark.Journalling/src/periodReviews.js @@ -33,8 +33,8 @@ import { getEventsForDay } from '@helpers/NPCalendar' import { getFirstDateInPeriod, getLastDateInPeriod } from '@helpers/NPdateTime' import { getNotesChangedInInterval } from '@helpers/NPnote' import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' -import { closeWindowFromCustomId } from '@helpers/NPWindows' -import { findHeadingStartsWith } from '@helpers/paragraph' +import { closeWindowFromCustomId, isEditorWindowOpenByTitle } from '@helpers/NPWindows' +import { findEndOfActivePartOfNote, findHeadingStartsWith } from '@helpers/paragraph' import { getInput, isInt, showMessage } from '@helpers/userInput' //--------------------------------------------------------------- @@ -42,6 +42,7 @@ import { getInput, isInt, showMessage } from '@helpers/userInput' const REVIEW_WINDOW_CUSTOM_ID = 'jgclark.Journalling.period-review' const REVIEW_WINDOW_CALLBACK_COMMAND = 'onReviewWindowAction' +const RE_DURATION_HHMM = /^(\d{1,2}):([0-5]\d)$/ //--------------------------------------------------------------- @@ -51,7 +52,7 @@ const REVIEW_WINDOW_CALLBACK_COMMAND = 'onReviewWindowAction' export async function dailyJournalQuestions(): Promise { try { const thisPeriodStr = strftime(`%Y-%m-%d`) - logDebug(pluginJson, `Starting for day ${thisPeriodStr}`) + logDebug(pluginJson, `Starting for day (currently ${thisPeriodStr})`) await processJournalQuestions(thisPeriodStr, 'day') } catch (error) { @@ -66,7 +67,7 @@ export async function weeklyJournalQuestions(): Promise { try { const currentWeekNum = getWeek(new Date()) const thisPeriodStr = `${strftime(`%Y`)}-W${currentWeekNum}` - logDebug(pluginJson, `Starting for week ${thisPeriodStr}`) + logDebug(pluginJson, `Starting for week (currently ${thisPeriodStr})`) await processJournalQuestions(thisPeriodStr, 'week') } catch (error) { @@ -80,7 +81,7 @@ export async function weeklyJournalQuestions(): Promise { export async function monthlyJournalQuestions(): Promise { try { const thisPeriodStr = strftime(`%Y-%m`) - logDebug(pluginJson, `Starting for month ${thisPeriodStr}`) + logDebug(pluginJson, `Starting for month (currently ${thisPeriodStr})`) await processJournalQuestions(thisPeriodStr, 'month') } catch (error) { @@ -97,7 +98,7 @@ export async function quarterlyJournalQuestions(): Promise { const m = todaysDate.getMonth() // counting from 0 const thisQ = Math.floor(m / 3) + 1 const thisPeriodStr = `${strftime(`%Y`)}Q${String(thisQ)}` - logDebug(pluginJson, `Starting for quarter ${thisPeriodStr}`) + logDebug(pluginJson, `Starting for quarter (currently ${thisPeriodStr})`) await processJournalQuestions(thisPeriodStr, 'quarter') } catch (error) { @@ -111,7 +112,7 @@ export async function quarterlyJournalQuestions(): Promise { export async function yearlyJournalQuestions(): Promise { try { const thisPeriodStr = strftime(`%Y`) - logDebug(pluginJson, `Starting for year ${thisPeriodStr}`) + logDebug(pluginJson, `Starting for year (currently ${thisPeriodStr})`) await processJournalQuestions(thisPeriodStr, 'year') } catch (error) { @@ -127,16 +128,46 @@ export async function yearlyJournalQuestions(): Promise { * Process questions for the given period, and write to the current note. * If we're not in the correct note, offer to open it first. * @author @jgclark - * @param {string} periodString the calendar note title string for the review period + * @param {string} periodStringIn the calendar note title string for the review period * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' */ async function processJournalQuestions(periodStringIn: string = '', periodType: string): Promise { try { const periodAdjective = getPeriodAdjectiveFromType(periodType) - // Get configuration and questions + // Get configuration const config: JournalConfigType = await getJournalSettings() + let reviewNote: ?TNote = null + + // Check that we have a correct period note open ... + const { note: openEditorNote } = Editor + // FIXME: Check for Teamspace stuff here + if (openEditorNote && getPeriodOfNPDateStr(openEditorNote.title ?? '') === periodType) { + // Use the existing open note + reviewNote = openEditorNote + logDebug('processJournalQuestions', `Starting with open note '${String(reviewNote?.title ?? 'unknown')}' of period '${String(getPeriodOfNPDateStr(reviewNote?.title ?? ''))}'`) + } else { + // use the passed periodStringIn to open the correct note + logDebug('processJournalQuestions', `Starting by opening current ${periodAdjective} note '${String(periodStringIn)}'`) + reviewNote = await Editor.openNoteByTitle(periodStringIn) + } + if (!reviewNote) { + // Warn user and stop. + await showMessage(`Cannot open ${periodStringIn} note, so cannot continue. Please open the correct note first.`) + throw new Error(`Cannot open ${periodStringIn} note, so cannot continue.`) + } + + // const reviewNote: ?TNote = ensureCorrectPeriodNoteIsOpen(periodType, periodStringIn) + // if (!reviewNote) { + // await showMessage(`No ${periodAdjective} note found, so cannot continue.`) + // throw new Error(`No ${periodAdjective} note found, so cannot continue.`) + // } + + const titleFromNote = reviewNote.title != null ? String(reviewNote.title).trim() : '' + const periodString = titleFromNote !== '' ? titleFromNote : periodStringIn !== '' ? periodStringIn : '' + logDebug('processJournalQuestions', `- Will use review note '${String(periodString)}' of period '${String(getPeriodOfNPDateStr(periodString))}'`) + + // Get questions and parse them const questionLines = await getQuestionsForPeriod(config, periodType) - // Only continue if we have some questions const numQs = questionLines.length if (!questionLines || numQs === 0 || questionLines[0] === '') { await showMessage(`No questions for ${periodType} found in the plugin settings, so cannot continue.`) @@ -146,21 +177,6 @@ async function processJournalQuestions(periodStringIn: string = '', periodType: // Parse questions (may result in multiple questions per line if '||' is used) const parsedQuestions = parseQuestions(questionLines) - // Either: check that we have a correct period note open; - // Or: open the current period note (if configured). - // Else: warn user and stop. - const { note } = Editor // TODO: use a helper to check all open editors - const currentNotePeriod = (note && note.type === 'Calendar') ? getPeriodOfNPDateStr(note.title ?? '') : '' - logDebug('processJournalQuestions', `Starting with open note:${String(note?.title ?? 'unknown')} is of period '${String(currentNotePeriod)}', and we want '${periodType}' period`) - const reviewNote: ?TNote = ensureCorrectPeriodNoteIsOpen(periodType, periodStringIn) - if (!reviewNote) { - await showMessage(`No ${periodAdjective} note found, so cannot continue.`) - throw new Error(`No ${periodAdjective} note found, so cannot continue.`) - } - const titleFromNote = reviewNote.title != null ? String(reviewNote.title).trim() : '' - const periodString = periodStringIn.trim() !== '' ? periodStringIn.trim() : titleFromNote - logDebug('processJournalQuestions', `- Will use review note:${String(periodString)}`) - await displayQuestionsWindow(parsedQuestions, periodString, periodType, config, questionLines, reviewNote) // Write answers to note @@ -292,7 +308,7 @@ function getSectionHeadingForPeriod(config: JournalConfigType, periodType: strin /** * Parse question lines to extract questions and their types. * Supports multiple questions per line separated by '||'. - * @tests in __tests__/journal.test.js + * @tests in __tests__/periodReviews.test.js * @param {Array | string} questionLines raw question lines from config * @returns {Array<{question: string, type: string, originalLine: string, lineIndex: number}>} parsed questions with types and line index */ @@ -355,7 +371,7 @@ export function parseQuestions(questionLines: Array | string): Array||||||

|

|||/gi, + /:|\(|\)||||||||

|

|||/gi, '', ) .trim() @@ -456,37 +472,19 @@ function splitParsedSegmentAtTypeMarker(segment: string, questionType: string): } /** - * Paragraph text lines to scan for existing review answers: after review heading if present, else whole note. + * Paragraph text lines to scan for existing review answers: across whole of active part of the note. * @param {TNote} note * @param {string} reviewSectionHeading * @returns {Array} */ function getParagraphLineContentsForReviewScan(note: TNote, reviewSectionHeading: string): Array { - const paragraphs = note.paragraphs ?? [] - if (!reviewSectionHeading || reviewSectionHeading.trim() === '') { - return paragraphs.map((p) => p.content) - } - const headingToFindLC = reviewSectionHeading.toLowerCase() - let startIdx = -1 - for (let i = 0; i < paragraphs.length; i++) { - const paragraph = paragraphs[i] - if (paragraph.type !== 'title') { - continue - } - const c = paragraph.content.toLowerCase() - if (c.startsWith(headingToFindLC) || headingToFindLC === c || headingToFindLC.startsWith(c)) { - startIdx = i - break - } - } - if (startIdx < 0) { - return paragraphs.map((p) => p.content) - } - return paragraphs.slice(startIdx + 1).map((p) => p.content) + const endOfActiveLineIndex = findEndOfActivePartOfNote(note) + const paragraphTextLines = note.paragraphs.slice(0, endOfActiveLineIndex).map((p) => p.content) ?? [] + return paragraphTextLines } /** - * Parse one line of note content for a single parsed question's answer (form-ready value). + * Parse one line of note content for a single parsed question's answer (form-ready value). Match for question in case-insensitive way. * @param {ParsedQuestionType} parsedQuestion * @param {string} line * @returns {string} value for the HTML control, or '' if not found on this line @@ -518,6 +516,11 @@ function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: s const m = line.match(re) return m?.[1] != null ? m[1] : '' } + if (t === 'duration') { + const re = new RegExp(`${escapeRegExp(prefix)}(\\d{1,2}:[0-5]\\d)${escapeRegExp(suffix)}`) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } if (t === 'bullets' || t === 'checklists' || t === 'tasks') { const marker = linePrefixForMultilineAnswerType(t) const { prefix, suffix } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) @@ -530,7 +533,7 @@ function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: s } return stripMultilineAnswerPrefixes(trimmed, marker) } - const idx = line.indexOf(prefix) + const idx = line.toLowerCase().indexOf(prefix.toLowerCase()) if (idx < 0) { return '' } @@ -540,7 +543,7 @@ function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: s } return stripMultilineAnswerPrefixes(raw, marker) } - const re = new RegExp(`${escapeRegExp(prefix)}([\\s\\S]*?)${escapeRegExp(suffix)}`) + const re = new RegExp(`${escapeRegExp(prefix)}([\\s\\S]*?)${escapeRegExp(suffix)}`, 'i') const m = line.match(re) if (m?.[1] == null) { return '' @@ -553,13 +556,13 @@ function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: s if (prefix === '') { return '' } - const idx = line.indexOf(prefix) + const idx = line.toLowerCase().indexOf(prefix.toLowerCase()) if (idx < 0) { return '' } return line.slice(idx + prefix.length).trim() } - const re = new RegExp(`${escapeRegExp(prefix)}(.*?)${escapeRegExp(suffix)}`) + const re = new RegExp(`${escapeRegExp(prefix)}(.*?)${escapeRegExp(suffix)}`, 'i') const m = line.match(re) return m?.[1] != null ? m[1].trim() : '' } @@ -567,14 +570,14 @@ function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: s } /** - * Latest matching answer in the note for one question (scans newest-to-oldest by paragraph order). + * Get first matching answer in the note for question * @param {ParsedQuestionType} parsedQuestion - * @param {Array} paragraphLines + * @param {Array} textLines * @returns {string} */ -function extractExistingAnswerForReviewForm(parsedQuestion: ParsedQuestionType, paragraphLines: Array): string { - for (let i = paragraphLines.length - 1; i >= 0; i--) { - const line = paragraphLines[i] +function extractExistingAnswerForReviewForm(parsedQuestion: ParsedQuestionType, textLines: Array): string { + for (let i = 0; i <= textLines.length - 1; i++) { + const line = textLines[i] const v = extractExistingAnswerOnLine(parsedQuestion, line) if (v !== '') { return v @@ -585,21 +588,21 @@ function extractExistingAnswerForReviewForm(parsedQuestion: ParsedQuestionType, /** * Map field names q_0, q_1, … to existing answers in the calendar note for pre-filling the review HTML form. - * @tests in __tests__/journal.test.js + * @tests in __tests__/periodReviews.test.js * @param {Array} parsedQuestions - * @param {Array} paragraphLines lines to scan (e.g. from getParagraphLineContentsForReviewScan) + * @param {Array} textLines text of lines to scan * @returns {{ [string]: string }} */ export function buildInitialReviewAnswersByFieldName( parsedQuestions: Array, - paragraphLines: Array, + textLines: Array, ): { [string]: string } { const out: { [string]: string } = {} - for (let globalIndex = 0; globalIndex < parsedQuestions.length; globalIndex++) { - const pq = parsedQuestions[globalIndex] - const v = extractExistingAnswerForReviewForm(pq, paragraphLines) + for (let i = 0; i < parsedQuestions.length; i++) { + const pq = parsedQuestions[i] + const v = extractExistingAnswerForReviewForm(pq, textLines) if (v !== '') { - out[`q_${globalIndex}`] = v + out[`q_${i}`] = v } } return out @@ -651,6 +654,12 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe } return '' } + case 'duration': { + if (RE_DURATION_HHMM.test(answer)) { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + return '' + } case 'string': { return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) } @@ -694,7 +703,7 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe /** * Build output from answers returned by single-window mode. - * @tests in __tests__/journal.test.js + * @tests in __tests__/periodReviews.test.js * @param {Array} parsedQuestions * @param {Array} rawQuestionLines * @param {string} periodString diff --git a/jgclark.Journalling/src/reviewHTMLViewGenerator.js b/jgclark.Journalling/src/reviewHTMLViewGenerator.js index 8986738a2..be5cc7b1c 100644 --- a/jgclark.Journalling/src/reviewHTMLViewGenerator.js +++ b/jgclark.Journalling/src/reviewHTMLViewGenerator.js @@ -149,6 +149,9 @@ function makeReviewInlineControl( case 'number': { return `` } + case 'duration': { + return `` + } case 'mood': { const moodArray = typeof config.moods === 'string' ? config.moods.split(',').map((m) => m.trim()) : config.moods const moodOptions = moodArray @@ -256,10 +259,18 @@ function makeReviewRawQuestionLineDiv( * @returns {string} HTML string for the summary block */ function makePeriodDaysSummaryDiv(_periodType: string, eventsForPeriod: Array): string { - clo(eventsForPeriod, 'eventsForPeriod') const totalDuration = eventsForPeriod.reduce((total, event) => total + getEventDurationHours(event), 0) - return ` - ${eventsForPeriod.length} events, ${totalDuration.toFixed(1)} hours` + const output = [] + output.push(`
${eventsForPeriod.length} events (${totalDuration.toFixed(1)} hours)
`) + output.push(`
`) + eventsForPeriod.forEach( e => { + output.push(`\t
`) + output.push(`\t\t`) + output.push(`\t\t${e.title}`) + output.push('\t
') + }) + output.push(`
`) + return output.join('\n') } /** @@ -293,21 +304,18 @@ function makePeriodSummaryDiv( const summaryItems = completedTasks.length > 0 ? completedTasks.map((taskLine) => `
- -
${formatTaskAsHTML(taskLine)}
+ + ${formatTaskAsHTML(taskLine)}
`).join('\n') : `
No completed tasks found during the ${periodType}
` const outputHTML = `
-
${completedTasks.length} completed tasks
+
${completedTasks.length} completed tasks
${summaryItems}
-
${makePeriodDaysSummaryDiv(periodType, eventsForPeriod)} -
+ ${makePeriodDaysSummaryDiv(periodType, eventsForPeriod)}
` return outputHTML @@ -351,6 +359,10 @@ function makeReviewQuestionRowDiv( control = `` break } + case 'duration': { + control = `` + break + } case 'mood': { const moodArray = (typeof config.moods === 'string') ? config.moods.split(',').map((m) => m.trim()) : config.moods const moodOptions = moodArray From acc580a82f584b2ef72b63af45469abdb5ba7f6f Mon Sep 17 00:00:00 2001 From: jgclark Date: Sat, 4 Apr 2026 18:44:52 +0100 Subject: [PATCH 08/19] b6 -- nearly there --- helpers/headings.js | 3 + jgclark.Journalling/CHANGELOG.md | 8 + .../__tests__/periodReviews.test.js | 85 ++++- jgclark.Journalling/plugin.json | 85 +++-- jgclark.Journalling/requiredFiles/reviews.css | 76 ++-- jgclark.Journalling/src/journalHelpers.js | 89 +++++ jgclark.Journalling/src/periodReviews.js | 339 ++++++++++++++---- .../src/reviewHTMLViewGenerator.js | 272 +++++++++++--- 8 files changed, 786 insertions(+), 171 deletions(-) diff --git a/helpers/headings.js b/helpers/headings.js index b33482549..466b4a51c 100644 --- a/helpers/headings.js +++ b/helpers/headings.js @@ -9,6 +9,7 @@ import { clo, clof, JSP, logDebug, logError, logInfo, logTimer, logWarn } from ' * Check whether a heading paragraph matches the given text at the specified level, * allowing an optional trailing ellipsis ("…"), which indicates that a heading has been folded. * It tolerates extra whitespace between the base text and the ellipsis. + * TODO: Write tests * @author Cursor, guided by @jgclark * @param {TParagraph} para * @param {string} headingName - base heading text to match (e.g. 'Done') @@ -106,3 +107,5 @@ export function getCurrentHeading(note: CoreNoteFields, para: TParagraph): TPara export function isTitleWithEqualOrLowerHeadingLevel(item: TParagraph, prevLowestLevel: number): boolean { return item.type === 'title' && item.headingLevel <= prevLowestLevel } + +// Note: findHeadingStartsWith(note, headingToFind) couuld/should live here. diff --git a/jgclark.Journalling/CHANGELOG.md b/jgclark.Journalling/CHANGELOG.md index da32f6f7f..9e3e9d237 100644 --- a/jgclark.Journalling/CHANGELOG.md +++ b/jgclark.Journalling/CHANGELOG.md @@ -3,6 +3,14 @@ _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark. Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. +## [2.0.0.b6] - 2026-04-04 +- `` / `` / `` are now substituted in heading and label text taken from parsed questions (e.g. `## Weekly Review for `), not only in the raw template line—so the window matches the period title. +- **Planning vs reviewing:** New settings name planned items per period (daily through yearly, with defaults such as “Big 3 Rocks”, “Top 3 Wins”, etc.). The review window shows a **Summary** block (carry-over plan tasks from this note as open/complete icons, then the usual daily completed-task and event summary). +- A separate **planning** section after the main form writes an H2 and `>> …` tasks at the start of the **next** period’s calendar note, replacing any existing section with that title. Empty planning clears that section on the next note. That H2 uses `{planName} for {next period title}` (e.g. `Big Rocks for 2026-04-04`), distinct from the review-window “Planned:” / “Planning: … for the next …” labels. +- Added fuller translation of markdown to how its displayed in user's current theme in NP, particularly including Priority markers. +- Open and reference the quarterly calendar note using NotePlan’s title format `YYYY-Qn` not `YYYYQn`. +- Reorganised the settings + ## [2.0.0.b5] - 2026-03-31 - Added a new review question type `` that accepts `[H]H:MM` input (for example `1:05` or `12:30`) in the review window and when writing answers to notes. - Ensure Question strings are handled case-insensitively diff --git a/jgclark.Journalling/__tests__/periodReviews.test.js b/jgclark.Journalling/__tests__/periodReviews.test.js index 1549aa8be..d51daa8c1 100644 --- a/jgclark.Journalling/__tests__/periodReviews.test.js +++ b/jgclark.Journalling/__tests__/periodReviews.test.js @@ -2,8 +2,21 @@ // Last updated: 2026-03-25 for v2.0.0.b3 by @Cursor -import { buildInitialReviewAnswersByFieldName, buildOutputFromReviewWindowAnswers, parseQuestions } from '../src/periodReviews' -import { getPeriodAdjectiveFromType, substituteReviewPeriodPlaceholders } from '../src/journalHelpers' +import { + buildInitialReviewAnswersByFieldName, + buildOutputFromReviewWindowAnswers, + extractPlanSectionTaskItems, + normalizePlanningTaskLinesFromForm, + parseQuestions, +} from '../src/periodReviews' +import { buildReviewHTML } from '../src/reviewHTMLViewGenerator' +import { + buildNextPeriodNotePlanSectionHeadingTitle, + buildNextPlanSectionHeadingTitle, + getPeriodAdjectiveFromType, + getPlanItemsNameForPeriodType, + substituteReviewPeriodPlaceholders, +} from '../src/journalHelpers' import { DataStore } from '@mocks/index' beforeAll(() => { @@ -187,6 +200,30 @@ Do: ` }) }) + describe('buildReviewHTML', () => { + it('should substitute in ## heading text (parsed question, not only raw line)', () => { + const raw = `## Weekly Review for +Wins: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + [], + '2026-W13', + 'week', + [], + 'onReviewWindowAction', + 'Top Wins', + {}, + [], + ) + expect(html).toContain('Weekly Review for 2026-W13') + expect(html).not.toContain('<date>') + }) + }) + describe('buildInitialReviewAnswersByFieldName', () => { it('should extract int, duration, boolean, mood, and string answers from review-style lines', () => { const config = { @@ -394,4 +431,48 @@ Ship: `, expect(out).toBe('') }) }) + + describe('planning helpers', () => { + it('buildNextPlanSectionHeadingTitle should format review-window planning block title', () => { + expect(buildNextPlanSectionHeadingTitle('Top 3 Wins', 'week')).toBe('Planning: Top 3 Wins for the next week') + expect(buildNextPlanSectionHeadingTitle('Big 3 Rocks', 'day')).toBe('Planning: Big 3 Rocks for the next day') + expect(buildNextPlanSectionHeadingTitle('Goals', 'quarter')).toBe('Planning: Goals for the next quarter') + }) + + it('buildNextPeriodNotePlanSectionHeadingTitle should use plan name and target period calendar title', () => { + expect(buildNextPeriodNotePlanSectionHeadingTitle('Top 3 Wins', '2026-W14')).toBe('Top 3 Wins for 2026-W14') + expect(buildNextPeriodNotePlanSectionHeadingTitle('Big Rocks', '2026-04-04')).toBe('Big Rocks for 2026-04-04') + expect(buildNextPeriodNotePlanSectionHeadingTitle('Goals', '2026-Q2')).toBe('Goals for 2026-Q2') + }) + + it('getPlanItemsNameForPeriodType should use defaults when config keys missing or blank', () => { + const minimal = {} + expect(getPlanItemsNameForPeriodType(minimal, 'day')).toBe('Big 3 Rocks') + expect(getPlanItemsNameForPeriodType(minimal, 'week')).toBe('Top 3 Wins') + const custom = { weekPlanItemsName: 'Wins' } + expect(getPlanItemsNameForPeriodType(custom, 'week')).toBe('Wins') + }) + + it('normalizePlanningTaskLinesFromForm should strip task markers and >>', () => { + expect(normalizePlanningTaskLinesFromForm('')).toEqual([]) + expect(normalizePlanningTaskLinesFromForm(' a \n\n* >> b')).toEqual(['a', 'b']) + expect(normalizePlanningTaskLinesFromForm('>> solo')).toEqual(['solo']) + }) + + it('extractPlanSectionTaskItems should read open and done tasks under matching H2', () => { + const heading = 'Top 3 Wins for the next week' + const note = { + paragraphs: [ + { type: 'title', headingLevel: 2, content: heading, lineIndex: 0 }, + { type: 'open', content: '* >> First', lineIndex: 1 }, + { type: 'done', content: '* >> Second @done(2026-03-30)', lineIndex: 2 }, + ], + } + const items = extractPlanSectionTaskItems(note, heading) + expect(items.length).toBe(2) + expect(items[0].isDone).toBe(false) + expect(items[1].isDone).toBe(true) + expect(items[0].content).toContain('First') + }) + }) }) diff --git a/jgclark.Journalling/plugin.json b/jgclark.Journalling/plugin.json index 7e611c374..0ebb7e1ec 100644 --- a/jgclark.Journalling/plugin.json +++ b/jgclark.Journalling/plugin.json @@ -9,7 +9,7 @@ "plugin.author": "Jonathan Clark", "plugin.url": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/README.md", "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md", - "plugin.version": "2.0.0.b4", + "plugin.version": "2.0.0.b6", "plugin.releaseStatus": "beta", "plugin.lastUpdateInfo": "v2.0.0: First release of the new Journalling & Reviews plugin, but building on earlier 'Journalling Helpers' plugin (v1).", "plugin.requiredFiles": [ @@ -188,30 +188,7 @@ "plugin.settings": [ { "type": "heading", - "title": "General settings" - }, - { - "key": "dailyJournalSectionHeading", - "title": "Daily Journal Section Heading", - "description": "The name of a section heading after which Daily Journal answers are added - if it doesn't exist, it is added at the end of the note.", - "type": "string", - "default": "Journal", - "required": true - }, - { - "key": "reviewSectionHeading", - "title": "Review Section Heading", - "description": "The name of a section heading after which weekly/monthly/quarterly/yearly Review answers are added - if it doesn't exist, it is added at the end of the note.", - "type": "string", - "default": "Review", - "required": true - }, - { - "type": "separator" - }, - { - "type": "heading", - "title": "Journalling Review settings" + "title": "Reviewing & Journalling" }, { "key": "preferredWindowType", @@ -250,6 +227,22 @@ "default": "🤩 Great,🙂 Good,😇 Blessed,🥱 Tired,😫 Stressed,😤 Frustrated,😔 Low,🥵 Sick", "required": false }, + { + "key": "dailyJournalSectionHeading", + "title": "Daily Journal Section Heading", + "description": "The name of a section heading after which Daily Review/Journal answers are added - if it doesn't exist, it is added at the end of the note.", + "type": "string", + "default": "Journal", + "required": true + }, + { + "key": "reviewSectionHeading", + "title": "Review Section Heading", + "description": "The name of a section heading after which weekly/monthly/quarterly/yearly Review answers are added - if it doesn't exist, it is added at the end of the note.", + "type": "string", + "default": "Review", + "required": true + }, { "key": "dailyReviewQuestions", "title": "Daily Review/Journal Questions", @@ -258,12 +251,28 @@ "default": "@sleep()\n@work()\n@fruitveg()\nMood: \nGratitude: \nGod was: \nAlive: \nNot Great: \nLearn: \nRemember: ", "required": false }, + { + "key": "dayPlanItemsName", + "title": "Daily Planned items heading", + "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned tasks to write to the next day's note (e.g. 'Big Wins' for next day).", + "type": "string", + "default": "Big Wins", + "required": false + }, { "key": "weeklyReviewQuestions", "title": "Weekly Review/Journal Questions", "description": "String that includes both the Journal/Review questions and how to lay out the answers in the weekly note.\nSee 'Daily Review/ Journal Questions' above for details.", "type": "string", - "default": "## Weekly Review for \nWins: \nNew or improved: \nChallenges: \n### 'Big Rocks' for \n", + "default": "## Weekly Review for \nWins: \nNew or improved: \nChallenges: ", + "required": false + }, + { + "key": "weekPlanItemsName", + "title": "Weekly Planned items heading", + "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned tasks to write to the next week's note (e.g. 'Big Rocks' for next week).", + "type": "string", + "default": "Big Rocks", "required": false }, { @@ -274,6 +283,14 @@ "default": "## Monthly Review for \nWhat's working well in processes: \nProcesses that need work: \nPersonal Goals progress: ", "required": false }, + { + "key": "monthPlanItemsName", + "title": "Name for Monthly Planned items", + "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned items to write to the next month's note (e.g. 'Key Outcomes' for next month).", + "type": "string", + "default": "Key Outcomes", + "required": false + }, { "key": "quarterlyReviewQuestions", "title": "Quarterly Review/Journal Questions", @@ -282,6 +299,14 @@ "default": "## Quarterly Review for \nGoals met: \nNew goals identified: \nProjects finished: \nNew projects identified: ", "required": false }, + { + "key": "quarterPlanItemsName", + "title": "Name for Quarterly Planned items", + "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned items to write to the next quarter's note (e.g. 'Goals' for next quarter').", + "type": "string", + "default": "Goals", + "required": false + }, { "key": "yearlyReviewQuestions", "title": "Yearly Review/Journal Questions", @@ -290,6 +315,14 @@ "default": "## Yearly Review for \nMajor events in the year: \nThemes/focus areas for next year: ", "required": false }, + { + "key": "yearPlanItemsName", + "title": "Name for Yearly Planned items", + "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned items to write to the next year's note (e.g. 'Themes' for next year').", + "type": "string", + "default": "Theme", + "required": false + }, { "type": "separator" }, diff --git a/jgclark.Journalling/requiredFiles/reviews.css b/jgclark.Journalling/requiredFiles/reviews.css index a837aa389..7251ae719 100644 --- a/jgclark.Journalling/requiredFiles/reviews.css +++ b/jgclark.Journalling/requiredFiles/reviews.css @@ -1,3 +1,5 @@ +/* last update 2026-04-02 for v2.0.0.b6 by @jgclark */ + body { color: var(--fg-main-color, #4c4f69); background-color: var(--bg-main-color, #eff1f5); @@ -10,11 +12,41 @@ body { border-radius: 6px; background: var(--bg-alt-color, #e6e9ef); color: var(--fg-main-color, #4c4f69); - margin: 1.0rem 0rem; + margin: 0.5rem 0rem; + + /* Override default markdown headings on descendants of .section-wrap */ + .h2 { + font-size: 1.22rem; + padding: 0rem 0.1rem 0.4rem; + background-color: unset; + border: none; + } + .h3 { + font-size: 1.1rem; + padding: 0rem 0.1rem 0.4rem; + background-color: unset; + border: none; + } +} + +.review-title-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin: 0 0 1rem 0; + padding: 0 0.25rem 0 0.75rem; +} + +.review-title-row-actions { + flex-shrink: 0; } .review-title { - margin: 0 0 1rem 1rem; + margin: 0; + flex: 1 1 auto; + min-width: 0; color: var(--h2-color, #5c5f77); } @@ -27,14 +59,14 @@ body { .summary-title { font-weight: 600; color: var(--h3-color, #5c5f77); - padding: 0rem 0rem; + padding: 0rem 0rem 0.4rem 0.3rem; } .summary-content { column-count: 2; column-gap: 1rem; font-size: 0.85rem; - margin-left: 0.75rem; + margin-left: 0.25rem; margin-bottom: 0.35rem; } @@ -46,14 +78,20 @@ body { margin-bottom: 0.15rem; } +.summary-item-incomplete-icon { + color: var(--item-icon-color); + line-height: 1.2; + flex-shrink: 0; +} + .summary-item-completed-icon { - color: #098308A0; /* TODO: set this to a theme colour */ + color: var(--fg-done-color); line-height: 1.2; flex-shrink: 0; } .summary-item-event-icon { - color: #098308A0; /* TODO: set this to a theme colour */ + color: var(--fg-placeholder-color); line-height: 1.2; flex-shrink: 0; } @@ -76,23 +114,28 @@ body { color: var(--fg-placeholder-color, rgba(76, 79, 105, 0.7)); } +.plan-title { + /** placeholder for future use */ +} + .review-form { display: flex; flex-direction: column; - gap: 0.7rem; + /* gap: 0.7rem; */ } /* Used by whole-row questions, which require the label coming before them */ -.review-raw-question-line-block { +.review-question-line-block { display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; - gap: 0.3rem 0.5rem; + gap: 0.1rem 0.5rem; + padding-bottom: 0.6rem; } /* Used by question rows, where the label is inline */ -.review-raw-question-line { +.review-question-line { flex: 1 1 100%; } @@ -110,18 +153,7 @@ body { vertical-align: middle; */ margin: 0rem 0.2rem; } -.review-subheading { - font-size: 1.3rem; - font-weight: 600; - color: var(--h3-color, #5c5f77); - margin-top: 0.9rem; -} -/* Used when settings contain markdown `##` headings or typed `

` markers. */ -.review-subheading.h2, .review-subheading.h3 { - color: var(--h2-color, #5c5f77); - background-color: unset; -} .review-row { display: flex; flex-direction: column; @@ -211,7 +243,7 @@ textarea { font-size: 1.0rem; font-weight: 500; border-radius: 6px; - padding: 0.25rem 0.5rem; + padding: 0.2rem 0.5rem; box-shadow: 1px 1px 1px 0px rgb(from var(--fg-main-color) r g b / 0.2); } .review-button-primary { diff --git a/jgclark.Journalling/src/journalHelpers.js b/jgclark.Journalling/src/journalHelpers.js index 7a8730de4..eb62786e3 100644 --- a/jgclark.Journalling/src/journalHelpers.js +++ b/jgclark.Journalling/src/journalHelpers.js @@ -18,6 +18,11 @@ const pluginID = 'jgclark.Journalling' export type JournalConfigType = { dailyJournalSectionHeading: string, reviewSectionHeading: string, + dayPlanItemsName: string, + weekPlanItemsName: string, + monthPlanItemsName: string, + quarterPlanItemsName: string, + yearPlanItemsName: string, startDailyTemplateTitle: string, endDailyTemplateTitle: string, startWeeklyTemplateTitle: string, @@ -109,4 +114,88 @@ export function getPeriodAdjectiveFromType(periodType: string): string { default: return '(error: unknown period type)' } +} + +/** Default plan-item labels when settings are missing or empty. */ +// TODO: make this look at the plugin.json "default" for the "key" below +const PLAN_ITEMS_NAME_DEFAULTS: { [string]: string } = { + day: 'Big Rocks', + week: 'Top Wins', + month: 'Key Outcomes', + quarter: 'Goals', + year: 'Theme', +} + +/** Settings keys for `getPlanItemsNameForPeriodType`. */ +const PLAN_ITEMS_NAME_CONFIG_KEYS: { [string]: string } = { + day: 'dayPlanItemsName', + week: 'weekPlanItemsName', + month: 'monthPlanItemsName', + quarter: 'quarterPlanItemsName', + year: 'yearPlanItemsName', +} + +/** + * Configured label for planned items for a calendar period (e.g. "Big 3 Rocks"). + * @param {JournalConfigType} config + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} + */ +export function getPlanItemsNameForPeriodType(config: JournalConfigType, periodType: string): string { + const key = PLAN_ITEMS_NAME_CONFIG_KEYS[periodType] + const fallback = PLAN_ITEMS_NAME_DEFAULTS[periodType] ?? 'Plans' + if (key == null) { + return fallback + } + // $FlowIgnore[prop-missing]: dynamic settings keys from plugin.json + const raw = config[key] + const s = typeof raw === 'string' ? raw.trim() : '' + return s !== '' ? s : fallback +} + +/** + * Lowercase English noun for "the next …" in plan section titles. + * @param {string} periodType + * @returns {string} + */ +export function getPeriodNounForType(periodType: string): string { + const nouns: { [string]: string } = { + day: 'day', + week: 'week', + month: 'month', + quarter: 'quarter', + year: 'year', + } + return nouns[periodType] ?? 'period' +} + +/** + * H2 / UI title for the **current** period’s plan in the review summary: `Planned: {planName}`. + * @param {string} planName + * @returns {string} + */ +export function buildThisPlanSectionHeadingTitle(planName: string): string { + return `Planned: ${planName}` +} + +/** + * H2 / UI title for the planning textarea in the review HTML: `Planning: {planName} for the next {noun}`. + * @param {string} planName + * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} + */ +export function buildNextPlanSectionHeadingTitle(planName: string, periodType: string): string { + const noun = getPeriodNounForType(periodType) + return `Planning: ${planName} for the next ${noun}` +} + +/** + * H2 written **on the next calendar note** when saving planned tasks: `{planName} for {periodString}`. + * `periodString` must be that note’s calendar title (e.g. from `getNextNPPeriodString`). + * @param {string} planName + * @param {string} periodString — next period’s NotePlan calendar title + * @returns {string} + */ +export function buildNextPeriodNotePlanSectionHeadingTitle(planName: string, periodString: string): string { + return `${planName} for ${periodString}` } \ No newline at end of file diff --git a/jgclark.Journalling/src/periodReviews.js b/jgclark.Journalling/src/periodReviews.js index b13bc23c9..354467027 100644 --- a/jgclark.Journalling/src/periodReviews.js +++ b/jgclark.Journalling/src/periodReviews.js @@ -2,14 +2,16 @@ //--------------------------------------------------------------- // Journalling commands // Jonathan Clark -// last update 2026-03-28 for v2.0.0.b4 by @jgclark + @Cursor +// last update 2026-04-02 for v2.0.0.b6 by @jgclark + @Cursor //--------------------------------------------------------------- import strftime from 'strftime' import pluginJson from '../plugin.json' import { + buildNextPeriodNotePlanSectionHeadingTitle, getJournalSettings, getPeriodAdjectiveFromType, + getPlanItemsNameForPeriodType, REVIEW_QUESTION_TYPE_NAMES_ALT, substituteReviewPeriodPlaceholders, } from './journalHelpers' @@ -17,6 +19,8 @@ import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' import { stylesheetinksInHeader, faLinksInHeader, buildReviewHTML } from './reviewHTMLViewGenerator' import { RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, + getNextNPPeriodString, + getNPQuarterStr, getWeek, getPeriodOfNPDateStr, isDailyNote, @@ -33,8 +37,9 @@ import { getEventsForDay } from '@helpers/NPCalendar' import { getFirstDateInPeriod, getLastDateInPeriod } from '@helpers/NPdateTime' import { getNotesChangedInInterval } from '@helpers/NPnote' import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' -import { closeWindowFromCustomId, isEditorWindowOpenByTitle } from '@helpers/NPWindows' -import { findEndOfActivePartOfNote, findHeadingStartsWith } from '@helpers/paragraph' +import { closeWindowFromCustomId } from '@helpers/NPWindows' +import { isParaAMatchForHeading } from '@helpers/headings' +import { findEndOfActivePartOfNote, findHeading, findHeadingStartsWith, findStartOfActivePartOfNote } from '@helpers/paragraph' import { getInput, isInt, showMessage } from '@helpers/userInput' //--------------------------------------------------------------- @@ -44,6 +49,214 @@ const REVIEW_WINDOW_CUSTOM_ID = 'jgclark.Journalling.period-review' const REVIEW_WINDOW_CALLBACK_COMMAND = 'onReviewWindowAction' const RE_DURATION_HHMM = /^(\d{1,2}):([0-5]\d)$/ +/** Paragraph types treated as tasks under a plan H2 (carry-over + rewrite). */ +const PLAN_SECTION_PARA_TYPES: Set = new Set([ + 'open', + 'done', + 'scheduled', + 'checklist', + 'checklistDone', + 'checklistScheduled', + 'list' +]) + +/** + * Normalize non-empty lines from the planning textarea for storage (strip task markers / leading `>>`). + * @param {string} planningFormText + * @returns {Array} + */ +export function normalizePlanningTaskLinesFromForm(planningFormText: string): Array { + const raw = typeof planningFormText === 'string' ? planningFormText : String(planningFormText ?? '') + return raw + .split(/\r?\n/) + .map((l) => { + let t = l.trim() + t = t.replace(/^\*\s*/, '') + if (t.startsWith('>>')) { + t = t.slice(2).trim() + } + return t + }) + .filter((t) => t !== '') +} + +/** + * Find first matching H2 in the active part of the note. + * @param {TNote} note + * @param {string} headingTitle + * @returns {TParagraph | null} + */ +function findPlanSectionHeadingPara(note: TNote, headingTitle: string): TParagraph | null { + logDebug('findPlanSectionHeadingPara', `Looking for heading {${headingTitle}} ...`) + const paras = note.paragraphs ?? [] + const last = Math.min(findEndOfActivePartOfNote(note), paras.length - 1) + for (let i = 0; i <= last; i++) { + const p = paras[i] + if (p.type === 'title' && isParaAMatchForHeading(p, headingTitle, 2)) { + logDebug('', `- found line ${String(i)}: {${p.rawContent}} `) + return p + } + } + return null +} + +/** + * Heading plus body paragraphs until the next H1/H2-style break (level <= 2), for removal. + * @param {TNote} note + * @param {TParagraph} headingPara + * @returns {Array} + */ +function getParagraphsForPlanSection(note: TNote, headingPara: TParagraph): Array { + const toRemove: Array = [headingPara] + const paras = note.paragraphs ?? [] + const start = headingPara.lineIndex ?? 0 + for (let i = start + 1; i < paras.length; i++) { + const p = paras[i] + if (p.type === 'title' && (p.headingLevel ?? 99) <= 2) { + break + } + toRemove.push(p) + } + return toRemove +} + +/** + * Remove a plan H2 block (heading + body) if present. + * TODO: Probably can be simplified? + * @param {TNote} note + * @param {string} headingTitle + * @returns {void} + */ +function removePlanSectionFromNoteIfPresent(note: TNote, headingTitle: string): void { + const headingPara = findPlanSectionHeadingPara(note, headingTitle) + if (headingPara == null) { + return + } + const toRemove = getParagraphsForPlanSection(note, headingPara) + const sorted = [...toRemove].sort((a, b) => (b.lineIndex ?? 0) - (a.lineIndex ?? 0)) + for (const p of sorted) { + note.removeParagraph(p) + } +} + +/** + * Insert plan heading and open tasks at the start of the active body. + * Uses `insertParagraph(..., 'open')`, which adds the task `*` marker — pass body text `>> …` only so the note shows `* >> …`, not `* * >> …`. + * @param {TNote} note + * @param {string} headingTitle + * @param {Array} taskTexts normalized plain lines (no `*` / `>>`) + * @returns {void} + */ +function insertPlanSectionAtActiveStart(note: TNote, headingTitle: string, taskTexts: Array): void { + const startIdx = findStartOfActivePartOfNote(note) + if (Number.isNaN(startIdx)) { + logWarn(pluginJson, 'insertPlanSectionAtActiveStart: invalid start index') + return + } + note.insertHeading(headingTitle, startIdx, 2) + for (let i = 0; i < taskTexts.length; i++) { + const line = `>> ${taskTexts[i]}` + note.insertParagraph(line, startIdx + 1 + i, 'open') + } +} + +/** + * Calendar note whose title equals `title` (trimmed), if any. + * @param {string} title + * @returns {TNote | null} + */ +function getCalendarNoteByTitle(title: string): TNote | null { + const want = String(title).trim() + const notes = DataStore.calendarNotes ?? [] + for (const n of notes) { + if (String(n.title ?? '').trim() === want) { + return n + } + } + return null +} + +/** + * Task lines under the configured plan H2 (active part only), for the review summary. Note: The heading match is partial + case insensitive + * @param {TNote} note + * @param {string} planName (e.g. 'Big Rocks') + * @returns {Array<{ content: string, isDone: boolean }>} + */ +export function extractPlanSectionItems(note: TNote, planName: string): Array<{ content: string, isDone: boolean }> { + // const headingPara = findPlanSectionHeadingPara(note, headingTitle) + const headingPara = findHeading(note, planName, true) + if (headingPara == null) { + logDebug('extractPlanSectionItems', `Can't find a heading including '${planName}', so returning empty array`) + return [] + } + const heading = headingPara.content + logDebug('extractPlanSectionItems', `- matched heading '${heading}'`) + const out: Array<{ content: string, isDone: boolean }> = [] + const paras = note.paragraphs ?? [] + const start = headingPara.lineIndex ?? 0 + const end = Math.min(findEndOfActivePartOfNote(note), paras.length) + logDebug('extractPlanSectionItems', `Found heading ${heading}, so processing lines ${String(start+1)}-${String(end)}`) + for (let i = start + 1; i <= end; i++) { + const p = paras[i] + if (p.type === 'title' && (p.headingLevel ?? 99) <= 2) { + // We're now in a different section, so stop processing + break + } + if (!PLAN_SECTION_PARA_TYPES.has(String(p.type))) { + continue + } + const isDone = p.type === 'done' || p.type === 'checklistDone' + out.push({ content: p.content, isDone }) + } + return out +} + +/** + * Write or clear planned tasks on the **next** calendar note: replace existing H2 with same title, insert at active start. + * @param {JournalConfigType} config + * @param {string} periodString + * @param {string} periodType + * @param {string} planningFormText + * @returns {Promise} + */ +export async function writePlanningTasksToNextPeriodNote( + config: JournalConfigType, + periodString: string, + periodType: string, + planningFormText: string, +): Promise { + try { + const nextTitle = getNextNPPeriodString(periodString, periodType) + if (nextTitle === '') { + logWarn(pluginJson, `writePlanningTasksToNextPeriodNote: empty next period for "${periodString}" (${periodType})`) + return + } + const planName = getPlanItemsNameForPeriodType(config, periodType) + const headingTitle = buildNextPeriodNotePlanSectionHeadingTitle(planName, nextTitle) + logDebug('writePlanningTasksToNextPeriodNote', `planName='${planName}' headingTitle='${headingTitle}' / nextTitle='${nextTitle}'`) + let nextNote: ?TNote = getCalendarNoteByTitle(nextTitle) + if (!nextNote) { + logDebug('writePlanningTasksToNextPeriodNote', `Note '${nextTitle}' not found, so opening it`) + await Editor.openNoteByTitle(nextTitle) + nextNote = getCalendarNoteByTitle(nextTitle) ?? Editor.note + } + if (!nextNote) { + logError(pluginJson, `writePlanningTasksToNextPeriodNote: could not open calendar note '${nextTitle}'`) + return + } + + logDebug('writePlanningTasksToNextPeriodNote', `Note '${nextTitle}' opened; will now write (or replace) plan section heading '${headingTitle}'`) + const normalizedLines = normalizePlanningTaskLinesFromForm(planningFormText) + removePlanSectionFromNoteIfPresent(nextNote, headingTitle) + if (normalizedLines.length > 0) { + insertPlanSectionAtActiveStart(nextNote, headingTitle, normalizedLines) + } + DataStore.updateCache(nextNote, true) + } catch (err) { + logError(pluginJson, `writePlanningTasksToNextPeriodNote: ${err.message}`) + } +} + //--------------------------------------------------------------- /** @@ -95,9 +308,7 @@ export async function monthlyJournalQuestions(): Promise { export async function quarterlyJournalQuestions(): Promise { try { const todaysDate = new Date() - const m = todaysDate.getMonth() // counting from 0 - const thisQ = Math.floor(m / 3) + 1 - const thisPeriodStr = `${strftime(`%Y`)}Q${String(thisQ)}` + const thisPeriodStr = getNPQuarterStr(todaysDate) logDebug(pluginJson, `Starting for quarter (currently ${thisPeriodStr})`) await processJournalQuestions(thisPeriodStr, 'quarter') @@ -209,47 +420,49 @@ function normalizeReviewPeriodTitleForNPDateHelpers(periodTitle: string): string } /** + * TEST: Remove * True when the editor’s open note is the calendar note for this review (same period type and same period as `periodStringIn` when it is non-empty). * @param {TNote | void} note * @param {string} periodType * @param {string} periodStringIn * @returns {boolean} */ -function openEditorNoteMatchesReviewCommand(note: ?TNote, periodType: string, periodStringIn: string): boolean { - if (note == null || note.type !== 'Calendar') { - return false - } - const notePeriod = getPeriodOfNPDateStr(note.title ?? '') - if (notePeriod !== periodType) { - return false - } - const want = periodStringIn.trim() - if (want === '') { - return true - } - const a = normalizeReviewPeriodTitleForNPDateHelpers(String(note.title ?? '').trim()) - const b = normalizeReviewPeriodTitleForNPDateHelpers(want) - return a === b -} +// function openEditorNoteMatchesReviewCommand(note: ?TNote, periodType: string, periodStringIn: string): boolean { +// if (note == null || note.type !== 'Calendar') { +// return false +// } +// const notePeriod = getPeriodOfNPDateStr(note.title ?? '') +// if (notePeriod !== periodType) { +// return false +// } +// const want = periodStringIn.trim() +// if (want === '') { +// return true +// } +// const a = normalizeReviewPeriodTitleForNPDateHelpers(String(note.title ?? '').trim()) +// const b = normalizeReviewPeriodTitleForNPDateHelpers(want) +// return a === b +// } /** + * TEST: Removing in favour of smarter helpers. * Reuse the open calendar note when it matches this review; otherwise open the calendar note for the current period. * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' * @param {string} periodStringIn calendar title from the command (must match the open note to reuse it) * @returns {TNote | null} the note, or null if not found */ -function ensureCorrectPeriodNoteIsOpen(periodType: string, periodStringIn: string = ''): TNote | null { - const { note } = Editor - const periodAdjective = getPeriodAdjectiveFromType(periodType) - logDebug('ensureCorrectPeriodNoteIsOpen', `current note=${String(note?.title ?? 'unknown')}`) - if (openEditorNoteMatchesReviewCommand(note, periodType, periodStringIn)) { - logDebug('ensureCorrectPeriodNoteIsOpen', `Reusing open editor calendar note (matches ${periodType} / "${periodStringIn}")`) - return note ?? null - } - logDebug('ensureCorrectPeriodNoteIsOpen', `Opening current ${periodAdjective} note (${periodType}); command period "${periodStringIn}"`) - Editor.openNoteByDate(new Date(), false, 0, 0, false, periodType) - return Editor.note ?? null -} +// function ensureCorrectPeriodNoteIsOpen(periodType: string, periodStringIn: string = ''): TNote | null { +// const { note } = Editor +// const periodAdjective = getPeriodAdjectiveFromType(periodType) +// logDebug('ensureCorrectPeriodNoteIsOpen', `current note=${String(note?.title ?? 'unknown')}`) +// if (openEditorNoteMatchesReviewCommand(note, periodType, periodStringIn)) { +// logDebug('ensureCorrectPeriodNoteIsOpen', `Reusing open editor calendar note (matches ${periodType} / "${periodStringIn}")`) +// return note ?? null +// } +// logDebug('ensureCorrectPeriodNoteIsOpen', `Opening current ${periodAdjective} note (${periodType}); command period "${periodStringIn}"`) +// Editor.openNoteByDate(new Date(), false, 0, 0, false, periodType) +// return Editor.note ?? null +// } /** * Get raw question lines for the given period from config. @@ -756,31 +969,6 @@ export function buildOutputFromReviewWindowAnswers( return output } -/** - * Get the current calendar title string for a review period. - * @param {string} period - * @returns {string} - */ -function getReviewPeriodTitle(period: string): string { - const now = new Date() - switch (period) { - case 'day': - return strftime('%Y-%m-%d', now) - case 'week': - return `${strftime('%Y', now)}-W${getWeek(now)}` - case 'month': - return strftime('%Y-%m', now) - case 'quarter': { - const quarter = Math.floor(now.getMonth() / 3) + 1 - return `${strftime('%Y', now)}Q${quarter}` - } - case 'year': - return strftime('%Y', now) - default: - return Editor.note?.title ?? '' - } -} - // /** // * Stable key for deduping calendar items returned on multiple days (e.g. multi-day events). // * @param {TCalendarItem} ev @@ -914,6 +1102,8 @@ async function displayQuestionsWindow( const sectionHeading = getSectionHeadingForPeriod(config, periodType) const scanLines = getParagraphLineContentsForReviewScan(calendarNote, sectionHeading) const initialAnswers = buildInitialReviewAnswersByFieldName(parsedQuestions, scanLines) + const planName = getPlanItemsNameForPeriodType(config, periodType) + const carryOverPlanItems = extractPlanSectionItems(calendarNote, planName) // Build the HTML body for the review window from this data const htmlBody = buildReviewHTML( @@ -925,7 +1115,9 @@ async function displayQuestionsWindow( periodType, eventsForPeriod, REVIEW_WINDOW_CALLBACK_COMMAND, + planName, initialAnswers, + carryOverPlanItems, ) // Set the options and then open the review window @@ -1003,7 +1195,7 @@ export async function writeAnswersToNote( const resolvedPeriodType = periodType !== '' ? periodType : getPeriodOfNPDateStr(periodString) const sectionHeading = getSectionHeadingForPeriod(config, resolvedPeriodType) // $FlowIgnore[incompatible-call] .note is a superset of CoreNoteFields - logDebug(pluginJson, `Appending answers to heading '${sectionHeading}' in note ${displayTitle(Editor.note)}`) + logDebug(pluginJson, `Appending answers to heading '${sectionHeading}' in note ${displayTitle(outputNote)}`) const matchedHeading = findHeadingStartsWith(outputNote, sectionHeading) outputNote.addParagraphBelowHeadingTitle( answersText, @@ -1028,13 +1220,33 @@ export async function writeAnswersToNote( export async function onReviewWindowAction(actionName: string, payload: string = ''): Promise { logDebug(pluginJson, `onReviewWindowAction action=${actionName}`) // logDebug(pluginJson, `onReviewWindowAction payloadLength=${String(payload?.length ?? 0)} payloadPreview="${String(payload ?? '').slice(0, 100)}"`) - const config: JournalConfigType = await getJournalSettings() if (actionName === 'cancel') { logDebug('Journalling/onReviewWindowAction', `Cancelled by user.`) closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) return } + if (actionName === 'refresh') { + let refreshPayload: any = {} + try { + refreshPayload = payload !== '' ? JSON.parse(payload) : {} + } catch (err) { + logError(pluginJson, `onReviewWindowAction: refresh could not parse payload: ${err.message}`) + return + } + const periodType = String(refreshPayload.periodType ?? '') + const periodString = String(refreshPayload.periodString ?? '') + if (periodType === '' || periodString === '') { + logWarn(pluginJson, 'onReviewWindowAction: refresh missing periodType or periodString') + return + } + logDebug('Journalling/onReviewWindowAction', `Refresh: reopening review for ${periodType} ${periodString}`) + closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) + await processJournalQuestions(periodString, periodType) + return + } + + const config: JournalConfigType = await getJournalSettings() let safePayload: any = {} try { // Allow callback payloads sent via x-callback-url arg1 JSON string. @@ -1065,8 +1277,11 @@ export async function onReviewWindowAction(actionName: string, payload: string = if (output !== '') { await writeAnswersToNote(periodString, periodType, output) } else { - logWarn(pluginJson, 'No answers were collected from the review window') + logWarn(pluginJson, 'No template question answers were collected from the review window') } + const planningRaw = answers.planning_tasks + const planningText = typeof planningRaw === 'string' ? planningRaw : String(planningRaw ?? '') + await writePlanningTasksToNextPeriodNote(config, periodString, periodType, planningText) logDebug('Journalling/onReviewWindowAction', `Finished.`) closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) diff --git a/jgclark.Journalling/src/reviewHTMLViewGenerator.js b/jgclark.Journalling/src/reviewHTMLViewGenerator.js index be5cc7b1c..d2b3a2e9b 100644 --- a/jgclark.Journalling/src/reviewHTMLViewGenerator.js +++ b/jgclark.Journalling/src/reviewHTMLViewGenerator.js @@ -2,24 +2,29 @@ //--------------------------------------------------------------- // HTMLView generation helpers for single-window review mode // Jonathan Clark + Cursor -// last update 2026-03-28 for v2.0.0.b4 by @jgclark + @Cursor +// last update 2026-04-03 for v2.0.0.b6 by @jgclark + @Cursor //--------------------------------------------------------------- import moment from 'moment' import pluginJson from '../plugin.json' import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' import { + buildNextPlanSectionHeadingTitle, + buildThisPlanSectionHeadingTitle, getPeriodAdjectiveFromType, REVIEW_QUESTION_TYPE_NAMES_ALT, substituteReviewPeriodPlaceholders, } from './journalHelpers' import { RE_DONE_DATE_OPT_TIME } from '@helpers/dateTime' import { clo, logDebug, logInfo, logError, logWarn } from '@helpers/dev' +import { getTaskPriority } from '@helpers/paragraph' import { convertBoldAndItalicToHTML, convertHashtagsToHTML, convertHighlightsToHTML, convertMentionsToHTML, + convertPreformattedToHTML, + convertStrikethroughToHTML, convertUnderlinedToHTML, makePluginCommandButton, replaceMarkdownLinkWithHTMLLink, @@ -27,6 +32,15 @@ import { simplifyNPEventLinksForHTML, } from '@helpers/HTMLView.js' import { RE_SYNC_MARKER } from '@helpers/regex' +import { + // changeBareLinksToHTMLLink, + // changeMarkdownLinksToHTMLLink, + stripBackwardsDateRefsFromString, + stripThisWeeksDateRefsFromString, + stripTodaysDateRefsFromString, + truncateHTML, +} from '@helpers/stringTransforms' + //----------------------------------------------------------------------------- // Constants @@ -89,22 +103,36 @@ function stripPresentationDelimiters(input: string): string { /** * Format one done-task line for HTML using the same NotePlan-oriented conversions as getNoteContentAsHTML (minus full-note showdown). - * @param {string} taskLine + * @param {string} taskContent * @returns {string} */ -function formatTaskAsHTML(taskLine: string): string { - let line = taskLine.replace(RE_DONE_MENTION_STRIP_FOR_SUMMARY_G, '').replace(/\s{2,}/g, ' ').trim() +function formatTaskAsHTML(taskContent: string): string { + const taskPriority = getTaskPriority(taskContent) + let line = taskContent.replace(RE_DONE_MENTION_STRIP_FOR_SUMMARY_G, '').replace(/\s{2,}/g, ' ').trim() line = line.replace(RE_SYNC_MARKER, '') - line = line.trimRight() - line = convertBoldAndItalicToHTML(line) line = replaceMarkdownLinkWithHTMLLink(line) line = simplifyNPEventLinksForHTML(line) line = simplifyInlineImagesForHTML(line) line = convertHashtagsToHTML(line) line = convertMentionsToHTML(line) + line = convertPreformattedToHTML(line) + line = convertStrikethroughToHTML(line) line = convertHighlightsToHTML(line) - line = line.replace(/\[\[([^\]]+)\]\]/g, (_match, title) => `~${String(title)}~`) + line = stripTodaysDateRefsFromString(line) + line = stripThisWeeksDateRefsFromString(line) + line = stripBackwardsDateRefsFromString(line) + line = convertBoldAndItalicToHTML(line) line = convertUnderlinedToHTML(line) + line = line.replace(/\[\[([^\]]+)\]\]/g, (_match, title) => `~${String(title)}~`) + line = line.trimRight() + line = truncateHTML(line, 120) + + // If priority > 0, add priorityN styling around the whole string. Where it is "working-on", it uses priority4. + if (taskPriority > 0) { + // remove the priority markers from the start of the line + const lineWithoutPriorityMarkers = line.replace(/^!{1,3}\s*/, '').replace(/^>>\s?/, '') + line = `${lineWithoutPriorityMarkers}` + } return line } @@ -179,14 +207,18 @@ function makeReviewInlineControl( * @param {number} lineIndex * @param {Array} parsedQuestions * @param {JournalConfigType} config + * @param {string} periodString calendar period title for placeholder substitution in labels/headings + * @param {string} periodType * @returns {string} */ -function makeReviewRawQuestionLineDiv( +function makeQuestionLineDiv( rawLine: string, lineIndex: number, parsedQuestions: Array, config: JournalConfigType, initialAnswers: { [string]: string }, + periodString: string, + periodType: string, ): string { const cleanRawLine = stripPresentationDelimiters(rawLine) if (cleanRawLine.trim() === '') { @@ -203,7 +235,7 @@ function makeReviewRawQuestionLineDiv( && lineQuestionsOrdered.every(({ q }) => headingTypes.includes(q.type)) if (isHeadingOnlyLine) { return lineQuestionsOrdered - .map(({ q, globalIndex }) => makeReviewQuestionRowDiv(q, globalIndex, config, '')) + .map(({ q, globalIndex }) => makeReviewQuestionRowDiv(q, globalIndex, config, '', periodString, periodType)) .join('\n') } @@ -229,7 +261,9 @@ function makeReviewRawQuestionLineDiv( const initialVal = initialAnswers[`q_${globalIndex}`] ?? '' if (blockRowTypes.includes(pq.type)) { - parts.push(`
${makeReviewQuestionRowDiv(pq, globalIndex, config, initialVal)}
`) + parts.push( + `
${makeReviewQuestionRowDiv(pq, globalIndex, config, initialVal, periodString, periodType)}
`, + ) } else { const { prefix, suffix } = splitSegmentAtTypeMarker(segmentTrimmed, pq.type) const control = makeReviewInlineControl(pq, globalIndex, config, initialVal) @@ -246,26 +280,26 @@ function makeReviewRawQuestionLineDiv( } if (parts.length === 0) { - return `
${escapeHTML(cleanRawLine)}
` + // return `
${escapeHTML(cleanRawLine)}
` + // TEST: + return `${escapeHTML(cleanRawLine)}` } - return `
${parts.join('')}
` + return `
${parts.join('')}
` } /** * Build a summary of calendar events for the review period: count and total timed duration (all-day excluded from hours). - * @param {string} periodType - * @param {string} periodString * @param {Array} eventsForPeriod * @returns {string} HTML string for the summary block */ -function makePeriodDaysSummaryDiv(_periodType: string, eventsForPeriod: Array): string { +function makePeriodDaysSummaryDiv(eventsForPeriod: Array): string { const totalDuration = eventsForPeriod.reduce((total, event) => total + getEventDurationHours(event), 0) const output = [] output.push(`
${eventsForPeriod.length} events (${totalDuration.toFixed(1)} hours)
`) output.push(`
`) eventsForPeriod.forEach( e => { output.push(`\t
`) - output.push(`\t\t`) + output.push(`\t\t`) output.push(`\t\t${e.title}`) output.push('\t
') }) @@ -287,38 +321,102 @@ function getEventDurationHours(event: TCalendarItem): number { } /** - * Build a summary div for the review period. - * Present completed tasks in a nicely formatted multi-column list in small writing. Each one should start with a circle-check icon. + * First summary block: carried-over plan tasks from this note (open = hollow circle, done = check). + * @param {Array<{ content: string, isDone: boolean }>} carryOverPlanItems + * @returns {string} HTML or empty when none + */ +function makeCarryOverPlanSummaryContentDiv( + planningSectionTitle: string, + carryOverPlanItems: Array<{ content: string, isDone: boolean }>, +): string { + const rows: Array = [] + rows.push(`
${escapeHTML(planningSectionTitle)}
`) + rows.push(`
`) + if (carryOverPlanItems.length > 0) { + carryOverPlanItems.forEach((item) => { + if (item.isDone) { + rows.push(` +
+ + ${formatTaskAsHTML(item.content)} +
`) + } else { + rows.push(` +
+ + ${formatTaskAsHTML(item.content)} +
`) + } + }) + rows.push(`
`) + } else { + rows.push(`No planned items found for this period`) + } + rows.push(`
`) + return rows.join('\n') +} + +/** + * Summary card: optional carry-over plan tasks, then (daily only) completed-task wins + calendar events. * @param {string} periodType * @param {string} periodString + * @param {Array<{ content: string, isDone: boolean }>} carryOverPlanItems * @param {Array} completedTasks * @param {Array} eventsForPeriod - * @returns {string} HTML string for the summary div + * @returns {string} HTML for section-wrap or '' */ -function makePeriodSummaryDiv( +function buildReviewSummarySectionHTML( periodType: string, - _periodString: string, + carryOverPlanItems: Array<{ content: string, isDone: boolean }>, + planningSectionTitle: string, completedTasks: Array, - eventsForPeriod: Array + eventsForPeriod: Array, ): string { - const summaryItems = completedTasks.length > 0 - ? completedTasks.map((taskLine) => ` + const hasCarryOver = carryOverPlanItems.length > 0 + const isDay = periodType === 'day' + if (!hasCarryOver && !isDay) { + return '' + } + const parts: Array = [ + '
', + ] + const carryBlock = makeCarryOverPlanSummaryContentDiv(planningSectionTitle, carryOverPlanItems) + parts.push(carryBlock) + if (isDay) { + const summaryItems = + completedTasks.length > 0 + ? completedTasks + .map( + (taskLine) => `
- + ${formatTaskAsHTML(taskLine)} -
`).join('\n') - : `
No completed tasks found during the ${periodType}
` - - const outputHTML = ` -
-
${completedTasks.length} completed tasks
-
- ${summaryItems} +
`, + ) + .join('\n') + : `
No completed tasks found during the ${periodType}
` + parts.push(`
${completedTasks.length} completed tasks
`) + parts.push(`
\n${summaryItems}\n
`) + parts.push(makePeriodDaysSummaryDiv(eventsForPeriod)) + } + parts.push('
') + return parts.join('\n') +} + +/** + * Planning block (outside the main review form): title + tasks textarea. + * @param {string} planningSectionTitle + * @returns {string} + */ +function makePlanningSectionHTML(planningSectionTitle: string): string { + return `
+
${escapeHTML(planningSectionTitle)}
+
+
+ +
- ${makePeriodDaysSummaryDiv(periodType, eventsForPeriod)}
` - - return outputHTML } /** @@ -327,6 +425,9 @@ function makePeriodSummaryDiv( * @param {ParsedQuestionType} parsedQuestion * @param {number} index * @param {JournalConfigType} config + * @param {string} initialValue + * @param {string} periodString calendar period title for `` / `` in question text + * @param {string} periodType * @returns {string} */ function makeReviewQuestionRowDiv( @@ -334,16 +435,20 @@ function makeReviewQuestionRowDiv( index: number, config: JournalConfigType, initialValue: string = '', + periodString: string, + periodType: string, ): string { const fieldName = `q_${index}` + /** Parsed question text still contains `` etc.; raw display lines are substituted earlier. */ + const questionForDisplay = substituteReviewPeriodPlaceholders(parsedQuestion.question, periodString, periodType) if (parsedQuestion.type === 'subheading' || parsedQuestion.type === 'h2' || parsedQuestion.type === 'h3') { - const cleanHeading = stripPresentationDelimiters(parsedQuestion.question) + const cleanHeading = stripPresentationDelimiters(questionForDisplay) const tag = parsedQuestion.type === 'h2' ? 'h2' : 'h3' // `` defaults to h3 const className = tag - return `
${escapeHTML(cleanHeading)}
` + return `
${escapeHTML(cleanHeading)}
` } - const questionText = stripPresentationDelimiters(parsedQuestion.question).trim() + const questionText = stripPresentationDelimiters(questionForDisplay).trim() const questionLabel = `` let control = '' const useInlineRow = useFlexbox && !blockRowTypes.includes(parsedQuestion.type) @@ -378,7 +483,10 @@ function makeReviewQuestionRowDiv( ` break } - case 'string': + case 'string': { + control = `` + break + } case 'bullets': case 'checklists': case 'tasks': { @@ -406,6 +514,7 @@ function makeReviewQuestionRowDiv( * @param {Array} eventsForPeriod * @param {string} callbackCommandName * @param {{ [string]: string }=} initialAnswers field names q_0 … to pre-fill from the calendar note + * @param {{ carryOverPlanItems?: Array<{ content: string, isDone: boolean }>, planningSectionTitle?: string }=} reviewExtras carry-over plan tasks + planning block title * @returns {string} */ export function buildReviewHTML( @@ -417,43 +526,66 @@ export function buildReviewHTML( periodType: string, eventsForPeriod: Array, callbackCommandName: string, + planName: string, initialAnswers?: { [string]: string }, + carryOverPlanItems?: Array<{ content: string, isDone: boolean }>, ): string { const periodAdjective = getPeriodAdjectiveFromType(periodType) const resolvedInitialAnswers = initialAnswers ?? {} + const resolvedCarryOver = carryOverPlanItems ?? [] + const plannedSectionTitle = buildThisPlanSectionHeadingTitle(planName) + const planningSectionTitle = buildNextPlanSectionHeadingTitle(planName, periodType) const renderQuestionLines = rawQuestionLines.map((l) => substituteReviewPeriodPlaceholders(l, periodString, periodType)) const questionRows = renderQuestionLines - .map((line, lineIndex) => makeReviewRawQuestionLineDiv(line, lineIndex, parsedQuestions, config, resolvedInitialAnswers)) + .map((line, lineIndex) => + makeQuestionLineDiv(line, lineIndex, parsedQuestions, config, resolvedInitialAnswers, periodString, periodType), + ) .filter((row) => row !== '') .join('\n') - const possibleSummarySection = (periodType === 'day') ? makePeriodSummaryDiv(periodType, periodString, summaryCompletedTasks, eventsForPeriod) : '' - + const summarySection = buildReviewSummarySectionHTML( + periodType, + resolvedCarryOver, + plannedSectionTitle, + summaryCompletedTasks, + eventsForPeriod, + ) + const planningSectionHtml = planningSectionTitle !== '' ? makePlanningSectionHTML(planningSectionTitle) : '' + return ` -

${escapeHTML(periodAdjective)} Review for ${escapeHTML(periodString)}

- - ${possibleSummarySection} - -
+
+

${escapeHTML(periodAdjective)} Review for ${escapeHTML(periodString)}

+
+ +
+
+ + ${summarySection} +
+
${questionRows} -
- - - - -
+
+ + ${planningSectionHtml}
+ +
+ + + +
` } From d491b8012a04718d37ac559dfe814ee6e90da9a4 Mon Sep 17 00:00:00 2001 From: jgclark Date: Thu, 9 Apr 2026 14:18:45 +0100 Subject: [PATCH 09/19] Journalling b7 --- helpers/NPWindows.js | 18 +- helpers/__tests__/dateTime.test.js | 21 + helpers/dateTime.js | 63 ++ jgclark.DailyJournal/src/templatesStartEnd.js | 66 +- jgclark.Journalling/CHANGELOG.md | 7 + jgclark.Journalling/README.md | 222 +++-- .../__tests__/periodReviews.test.js | 192 +++- jgclark.Journalling/calendar-settings@2x.png | Bin 73359 -> 23242 bytes jgclark.Journalling/plugin.json | 4 +- jgclark.Journalling/requiredFiles/reviews.css | 17 +- .../review-window-questions@2x.png | Bin 0 -> 83839 bytes jgclark.Journalling/settings-button@2x.png | Bin 0 -> 5570 bytes jgclark.Journalling/src/journalHelpers.js | 199 ++++- jgclark.Journalling/src/periodReviews.js | 841 +++--------------- .../src/reviewHTMLViewGenerator.js | 166 +++- jgclark.Journalling/src/reviewQuestions.js | 466 ++++++++++ jgclark.Journalling/src/templatesStartEnd.js | 70 +- 17 files changed, 1426 insertions(+), 926 deletions(-) create mode 100644 jgclark.Journalling/review-window-questions@2x.png create mode 100644 jgclark.Journalling/settings-button@2x.png create mode 100644 jgclark.Journalling/src/reviewQuestions.js diff --git a/helpers/NPWindows.js b/helpers/NPWindows.js index 78647eda3..b8acbd04b 100644 --- a/helpers/NPWindows.js +++ b/helpers/NPWindows.js @@ -711,17 +711,20 @@ export function getWindowFromId(windowId: string): TEditor | HTMLView | false { * @returns {TEditor | HTMLView | false} the matching window object or false if not found */ export function getWindowFromCustomId(windowCustomId: string): TEditor | HTMLView | false { - // First loop over all Editor windows + // First loop over all Editor windows (match rules aligned with getWindowIdFromCustomId) const allEditorWindows = NotePlan.editors for (const thisWindow of allEditorWindows) { - if (thisWindow.customId === windowCustomId) { + if ( + caseInsensitiveMatch(windowCustomId, thisWindow.customId) || + caseInsensitiveStartsWith(windowCustomId, thisWindow.customId) + ) { return thisWindow } } // And if not found so far, then all HTML windows const allHTMLWindows = NotePlan.htmlWindows for (const thisWindow of allHTMLWindows) { - if (thisWindow.customId === windowCustomId) { + if (caseInsensitiveMatch(windowCustomId, thisWindow.customId)) { return thisWindow } } @@ -734,18 +737,21 @@ export function getWindowFromCustomId(windowCustomId: string): TEditor | HTMLVie * @param {string} windowCustomId */ export function closeWindowFromCustomId(windowCustomId: string): void { - // First loop over all Editor windows + // First loop over all Editor windows (match rules aligned with getWindowIdFromCustomId) let thisWin: TEditor | HTMLView const allEditorWindows = NotePlan.editors for (const thisWindow of allEditorWindows) { - if (thisWindow.customId === windowCustomId) { + if ( + caseInsensitiveMatch(windowCustomId, thisWindow.customId) || + caseInsensitiveStartsWith(windowCustomId, thisWindow.customId) + ) { thisWin = thisWindow } } // And if not found so far, then all HTML windows const allHTMLWindows = NotePlan.htmlWindows for (const thisWindow of allHTMLWindows) { - if (thisWindow.customId === windowCustomId) { + if (caseInsensitiveMatch(windowCustomId, thisWindow.customId)) { thisWin = thisWindow } } diff --git a/helpers/__tests__/dateTime.test.js b/helpers/__tests__/dateTime.test.js index c2533d366..a4d831744 100644 --- a/helpers/__tests__/dateTime.test.js +++ b/helpers/__tests__/dateTime.test.js @@ -1404,4 +1404,25 @@ describe(`${PLUGIN_NAME}`, () => { expect(dt.getNextNPPeriodString('2024', 'year')).toEqual('2025') }) }) + + describe('getPreviousNPPeriodString()' /* function */, () => { + test('2025-W01 - week -> 2024-W52', () => { + expect(dt.getPreviousNPPeriodString('2025-W01', 'week')).toEqual('2024-W52') + }) + test('2026-03-28 - day -> 2026-03-27', () => { + expect(dt.getPreviousNPPeriodString('2026-03-28', 'day')).toEqual('2026-03-27') + }) + test('2027-01 - month -> 2026-12', () => { + expect(dt.getPreviousNPPeriodString('2027-01', 'month')).toEqual('2026-12') + }) + test('2025-Q1 - quarter -> 2024-Q4', () => { + expect(dt.getPreviousNPPeriodString('2025-Q1', 'quarter')).toEqual('2024-Q4') + }) + test('2025Q1 - quarter compact in -> 2024Q4 out', () => { + expect(dt.getPreviousNPPeriodString('2025Q1', 'quarter')).toEqual('2024Q4') + }) + test('2025 - year -> 2024', () => { + expect(dt.getPreviousNPPeriodString('2025', 'year')).toEqual('2024') + }) + }) }) diff --git a/helpers/dateTime.js b/helpers/dateTime.js index 6f0c87d01..551d93314 100644 --- a/helpers/dateTime.js +++ b/helpers/dateTime.js @@ -1311,6 +1311,69 @@ export function getNextNPPeriodString(periodStringIn: string, periodType: string } } +/** + * Calendar period immediately before the given NotePlan period (same string family as note titles). + * Uses `calcOffsetDate` so week rollover matches ISO week handling (e.g. 2025-W01 → 2024-W52). + * @param {string} periodStringIn - e.g. YYYY-MM-DD, YYYY-Www, YYYY-MM, YYYY-Qn / YYYYQn, YYYY + * @param {string} periodType - 'day' | 'week' | 'month' | 'quarter' | 'year' + * @returns {string} previous period title, or '' if parsing fails + */ +export function getPreviousNPPeriodString(periodStringIn: string, periodType: string): string { + try { + const trimmed = periodStringIn.trim() + const wantCompactQuarter = /^(\d{4})Q([1-4])$/i.test(trimmed) + let periodString = trimmed + const compactQ = trimmed.match(/^(\d{4})Q([1-4])$/i) + if (compactQ) { + periodString = `${compactQ[1]}-Q${compactQ[2]}` + } + const intervalByType: { [string]: string } = { + day: '-1d', + week: '-1w', + month: '-1m', + quarter: '-1q', + year: '-1y', + } + const interval = intervalByType[periodType] + if (!interval) { + logError('dateTime / getPreviousNPPeriodString', `Unknown periodType '${periodType}'`) + return '' + } + const newDate = calcOffsetDate(periodString, interval) + if (!newDate) { + return '' + } + let momentDateFormat = '' + if (periodString.match(RE_ISO_DATE)) { + momentDateFormat = 'YYYY-MM-DD' + } else if (periodString.match(RE_YYYYMMDD_DATE)) { + momentDateFormat = MOMENT_FORMAT_NP_DAY + } else if (periodString.match(RE_NP_WEEK_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_WEEK + } else if (periodString.match(RE_NP_MONTH_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_MONTH + } else if (periodString.match(RE_NP_QUARTER_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_QUARTER + } else if (periodString.match(RE_NP_YEAR_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_YEAR + } else { + logError('dateTime / getPreviousNPPeriodString', `Unrecognized period string '${periodString}'`) + return '' + } + let out = moment(newDate).format(momentDateFormat) + if (wantCompactQuarter && momentDateFormat === MOMENT_FORMAT_NP_QUARTER) { + const mq = out.match(/^(\d{4})-Q([1-4])$/i) + if (mq) { + out = `${mq[1]}Q${mq[2]}` + } + } + return out + } catch (e) { + logError('dateTime / getPreviousNPPeriodString', e.message) + return '' + } +} + /** * Split an interval (e.g. '-3m') into number (e.g. -3) and type ('month') parts * If interval arrives with {...} around the terms, remove them first diff --git a/jgclark.DailyJournal/src/templatesStartEnd.js b/jgclark.DailyJournal/src/templatesStartEnd.js index e50d0093c..b3388d1ae 100644 --- a/jgclark.DailyJournal/src/templatesStartEnd.js +++ b/jgclark.DailyJournal/src/templatesStartEnd.js @@ -7,7 +7,7 @@ import { type JournalConfigType, getJournalSettings } from './journalHelpers' import { isDailyNote, isMonthlyNote, isWeeklyNote } from '@helpers/dateTime' -import { logDebug, logError, logInfo } from '@helpers/dev' +import { logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' import { getAttributes } from '@helpers/NPFrontMatter' import { showMessage } from '@helpers/userInput' @@ -74,36 +74,46 @@ async function renderAndInsertTemplate( templateTitle: string, commandName: string, ): Promise { - // Render the template, using recommended decoupled method of invoking a different plugin - const result = await DataStore.invokePluginCommandByName('renderTemplate', 'np.Templating', [templateTitle]) - // TEST: turning off error message for now, as it fires on Templates that only do background work. - // if (result == null || result === '') { - // throw new Error(`No result from running Template '${templateTitle}'. Stopping.`) - // } - - // Work out where to insert it in the note, by reading the template, and checking - // the frontmatter attributes for a 'location' field (append/insert/cursor) - const attrs = getAttributes(templateData, true) - const requestedTemplateLocation = attrs.location ?? 'insert' - let pos = 0 - switch (requestedTemplateLocation) { - case 'insert': { - logDebug(commandName, `- Will insert to start of Editor`) - Editor.insertTextAtCharacterIndex(result, 0) - break + try { + if (!templateData || templateData === '') { + logWarn('renderAndInsertTemplate', `templateData is null or empty. Stopping.`) + return } - case 'append': { - pos = Editor.content?.length ?? 0 // end - logDebug(commandName, `- Will insert to end of Editor (pos ${pos})`) - Editor.insertTextAtCharacterIndex(result, pos) - break + // Render the template, using recommended decoupled method of invoking a different plugin + const resultingTextContent = await DataStore.invokePluginCommandByName('renderTemplate', 'np.Templating', [templateTitle]) + // TEST: turning off error message for now, as it fires on Templates that only do background work. + if (resultingTextContent == null || resultingTextContent === '') { + logDebug('renderAndInsertTemplate', `No resulting text from running Template '${templateTitle}'. Stopping.`) + return } - // Note: unsure if this works. - case 'cursor': { - logDebug(commandName, `- Will insert to Editor at cursor position`) - Editor.insertTextAtCursor(result) - break + + // Work out where to insert it in the note, by reading the template, and checking + // the frontmatter attributes for a 'location' field (append/insert/cursor) + const attrs = getAttributes(templateData, true) + const requestedTemplateLocation = attrs.location ?? 'insert' + let pos = 0 + switch (requestedTemplateLocation) { + case 'insert': { + logDebug(commandName, `- Will insert to start of Editor`) + Editor.insertTextAtCharacterIndex(resultingTextContent, 0) + break + } + case 'append': { + pos = Editor.content?.length ?? 0 // end + logDebug(commandName, `- Will insert to end of Editor (pos ${pos})`) + Editor.insertTextAtCharacterIndex(resultingTextContent, pos) + break + } + // Note: unsure if this works. + case 'cursor': { + logDebug(commandName, `- Will insert to Editor at cursor position`) + Editor.insertTextAtCursor(resultingTextContent) + break + } } + } catch (error) { + logError('renderAndInsertTemplate', error.message) + await showMessage(`Error: ${error.message}`) } } diff --git a/jgclark.Journalling/CHANGELOG.md b/jgclark.Journalling/CHANGELOG.md index 9e3e9d237..b580c2a2a 100644 --- a/jgclark.Journalling/CHANGELOG.md +++ b/jgclark.Journalling/CHANGELOG.md @@ -3,6 +3,13 @@ _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark. Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. +## [2.0.0.b7] - 2026-04-05 +- Review summary: Done tasks whose body starts with `>>` (after the task marker and optional `!` priorities) count as **wins** for the period, same as `#win` / `#bigwin`, without needing a “Wins” section. They appear in the same **completed tasks** list as other done items (wins first), not duplicated. **Weekly/monthly/quarterly/yearly** summaries use the same win rules when listing done tasks for the period (even if there are no carry-over plan items). +- Review window: Fix daily summary layout when carry-over plan items exist. +- Review window: Fix `

…` / `

…` question lines being split by the flex segment matcher, leaving text fragments. +- dev: Refactor review flow — `reviewQuestions.js` (parse / pre-fill / answer output), more helpers in `journalHelpers.js`, slimmer `periodReviews.js`; shared segment regex for HTML + parser; `writeAnswersToNote` is module-private. +- dev: Update Template handling in applyTemplateToNote() to not make any Editor inserts if there's nothing to insert. Aim: avoid race conditions. + ## [2.0.0.b6] - 2026-04-04 - `` / `` / `` are now substituted in heading and label text taken from parsed questions (e.g. `## Weekly Review for `), not only in the raw template line—so the window matches the period title. - **Planning vs reviewing:** New settings name planned items per period (daily through yearly, with defaults such as “Big 3 Rocks”, “Top 3 Wins”, etc.). The review window shows a **Summary** block (carry-over plan tasks from this note as open/complete icons, then the usual daily completed-task and event summary). diff --git a/jgclark.Journalling/README.md b/jgclark.Journalling/README.md index 5169024f9..7840df135 100644 --- a/jgclark.Journalling/README.md +++ b/jgclark.Journalling/README.md @@ -1,120 +1,153 @@ # 💭 Journalling & Reviews Plugin -This plugin makes it easier for you to journal and/or review your days/weeks/months/quarters/years into NotePlan. It also makes it quicker to apply templates at the start or end of days/weeks/months/quarters/years. + +This plugin makes it easier for you to journal and/or review your days, weeks, months, quarters, and years in NotePlan. It also speeds up applying templates at the start or end of each period. + +**Requirements:** NotePlan **3.20** or later (integrated HTML plugin windows). This plugin replaces the older **Journalling Helpers** plugin for current NotePlan versions; on first install, settings will be migrated from that plugin automatically. ### Configuration -To use this plugin on weekly/monthly/quarterly/yearly notes, you first need to have them turned on in **NotePlan Settings** > Calendar pane: + +To use weekly, monthly, quarterly, or yearly notes, turn them on in **NotePlan Settings** → Calendar: -All the available commands require also some **configuration** first. On Mac click on the 'Journalling & Reviews' card in the Plugin Preferences panel, and then the the gear button. - -## Quickly applying Templates at the start of each Day/Week -The NotePlan website has good [articles on getting started with Templates](https://help.noteplan.co/article/136-templates), and a helpful [Template Gallery](https://noteplan.co/templates), to build from. - -For more details of the tag commands you can use in a Template, including a list of events, a quote-of-the-day or summary weather forecast, see the [full Templating Documentation](https://noteplan.co/templates/docs). - -### /dayStart & /todayStart commands -These commands make it quicker to apply a Template at the start or end of a day or week. The names of the Templates to use are set in the Plugin Settings pane, referring to Template names stored in the special NotePlan `Templates` folder. (This command has become less necessary since about NotePlan v3.17, which introduced [auto-inserting of templates into new calendar notes](https://help.noteplan.co/article/229-auto-insert-templates).) - -- **/todayStart**: applies your 'Daily Note' Template only to _today's_ calendar note, no matter what note you're editing. -- **/dayStart** appends your 'Daily Note' Template to the _currently open daily note_ (or today's note if you're not editing a daily note). Therefore, be careful using it on another calendar note than today using template tag commands like `<%- date... / formattedDate... %>` or `<%- weather() %>` -> because this renders the TODAY content! - -### /weekStart and /monthStart commands -These act very similarly to the /dayStart command above. - -### /dayEnd, /todayEnd, /weekEnd, /monthEnd commands -These act in the same way as above, but can be tailored to adding items at the end of a day, perhaps like a Habit or Stats summary from the separate [**Habits & Summaries plugin**](https://noteplan.co/plugins/jgclark.Summaries). - -This is also a quick way of regularly running one or more commands from the separate [**Tidy Up plugin**](https://noteplan.co/plugins/np.Tidy). - -## Helping with periodic Reviews -There's no right or wrong way to do reviews, and you'll no doubt change what you find helpful over time. But the key is to be taking some time to answer questions to help you pause and review what has and hasn't gone well over the last day/week/month/quarter. Some use it as a way of capturing their main **emotions**; others to track **goals**; others to write a simple **gratitude journal**. - -### Using Templates you fill in -One way to do this is to configure an end-of-day/week Template -- for example this [Mental Health Journal](https://noteplan.co/templates/mental-health-journal-template) -- which you then apply to your current note using the /dayEnd or /weekEnd command, and then add your responses. - -### Interactively -Alternatively, the **/dayReview**, **/weekReview**, **/monthReview**, **/quarterReview**, and/or **/yearReview** commands present you with a window with your set of questions to fill in. You first need to configure the sets of questions to ask in the plugin settings. In each case a default set of questions is provided to get you started. Each setting is explained: - -#### Journal Section Heading -The name of an existing markdown heading after which the review answers are added. If it doesn't exist, it is added at the end of the note. -??? - "key": "dailyJournalSectionHeading", - "title": "Daily Journal Section Heading", - "description": "The name of a section heading after which Daily Journal answers are added - if it doesn't exist, it is added at the end of the note.", - "type": "string", - "default": "Journal", - "required": true - }, - { - "key": "reviewSectionHeading", - "title": "Review Section Heading", - "description": "The name of a section heading after which weekly/monthly/quarterly/yearly Review answers are added - if it doesn't exist, it is added at the end of the note.", - -#### Daily / Weekly / Monthly / Quarterly / Year Journal Questions -This string includes both the questions and how to lay out the answers in the note. These are the possible question types: -- `` shows the text as a question, and if you answer "Yes" to the question, the text is included. If you answer "No" it isn't included. -- `` asks for an integer number -- `` asks for a number (which may include fractional part) -- `` asks for a duration in `[H]H:MM` format (for example `1:05` or `12:30`) -- `` asks for a string -- `` asks for one or more lines; each non-empty line is written to the note as a markdown bullet (`- `) -- `` same, but each line is written with a checklist marker (`+ `) -- `` same, but each line is written with a task marker (`* `) -- ``select one of the configured moods -- `` will include the rest of the text in the line and will be included in the output if you answer "Yes" to the "Yes"/"No" question about it. - -There are also some placeholders you can use to add things to your output: -- `

` and `

` (or the legacy equivalent ``) includes the text on the rest of the line as a usual H2 or H3 markdown heading. -- `` is replaced in the window and output with the review note title (e.g. `2026-03-25`, `2026-03` or `2025-W52`). -- `` or `` acts similarly but gives the _next_ date (e.g. `2026-03-26`, `2026-04` or `2026-W01`). -- You can also use markdown heading lines directly in the settings, for example `## Heading` and `### Subheading`. -- You can include line breaks, or `\n` characters. - -Other notes: -- You can have multiple questions of type ``, `` or `` on a line, with any text around them. The answers will also be placed on the one line, but with the `||` removed, and with a space separating them. - -- If a particular question isn't answered (i.e. no input entered), then that question isn't included in the output. -- If a particular question has already been answered in the note (i.e. if a line starts with the same question text), the current answer will be shown in the review window - -#### Moods -A comma-separated list of possible moods to select from. They don't have to have emoji, but I rather like them. - -### Example for /dayReview -The following question setting: + +All commands expect some **configuration** first. On Mac, open the **💭 Journalling & Reviews** card in Plugin Preferences, then use the gear button to edit settings. + +## Quickly applying templates at the start or end of a period + +The NotePlan site has good [articles on Templates](https://help.noteplan.co/article/136-templates) and a [Template Gallery](https://noteplan.co/templates). + +For template tag commands (events, quote-of-the-day, weather, etc.), see the [Templating documentation](https://noteplan.co/templates/docs). + +### `/dayStart` and `/todayStart` + +These apply your configured start-of-day template from the `Templates` folder. (Since about NotePlan 3.17, [auto-insert templates](https://help.noteplan.co/article/229-auto-insert-templates) into new calendar notes reduce the need for manual runs.) + +- **`/todayStart`**: applies your **Start-of-Day** template only to **today's** calendar note, regardless of which note is open. +- **`/dayStart`**: appends that template to the **currently open daily note** (or today’s note if you are not in a daily note). Be careful on **other** dates: tags like `<%- date… %>` or `<%- weather() %>` still resolve to **today**. + +### `/weekStart`, `/monthStart`, and end-of-period commands + +**`/weekStart`** and **`/monthStart`** behave like **`/dayStart`** for weekly and monthly notes. + +**`/dayEnd`**, **`/todayEnd`**, **`/weekEnd`**, **`/monthEnd`** mirror the “start” commands but use your **end-of-period** templates—useful for habit or stats summaries from [**Habits & Summaries**](https://noteplan.co/plugins/jgclark.Summaries) or cleanup via [**Tidy Up**](https://noteplan.co/plugins/np.Tidy). + +--- + +## Periodic reviews (v2) + +There is no single “right” review format. What matters is pausing to answer questions about what went well, what did not, goals, gratitude, mood, and so on. + +### Templates you fill in by hand + +You can use an end-of-day or end-of-week template (for example the [Mental Health Journal](https://noteplan.co/templates/mental-health-journal-template)) via **`/dayEnd`** or **`/weekEnd`**, then type answers in the note. + +### Interactive review window (v2 betas) + +Run **Daily Review**, **Weekly Review**, **Monthly Review**, **Quarterly Review**, or **Yearly Review** from the Command Bar (or pinned sidebar where offered). Daily Review aliases include `dr`, `journal`, and `review`. + +These commands open a **single** themed HTML window that shows **all** questions at once (colours and fonts follow your current NotePlan theme). When you submit, answers are written under the correct section heading in the calendar note. + +**New or changed in v2:** + +- **Window placement:** **Review Window type** — *New Window*, *Main Window*, or *Split View* in the main window. +- **Open the calendar note when reviewing it?** (default on) — skips the old “open this note?” prompt when appropriate. +- **Question strings** no longer use `||` delimiters; use line breaks (and optional `\n`) instead. +- **Prefill:** If matching answers already exist in the note (under your review/journal heading when present, otherwise the whole note), they appear in the form. The latest matching block wins. +- **Summary block** at the top of the window: + - **Daily:** completed tasks from notes touched that day (Dashboard-style), plus **calendar events** (counts and timed duration respect **EventHelpers**-style settings where applicable). + - **Week / month / quarter / year:** done items in that period tagged `#win` or `#bigwin`, shown as multi-column checklist-style rows. Task text is rendered like note HTML export (hashtags, mentions, links, etc.); `@done(…)` stamps are omitted for readability. +- **Calendars to include in review summaries:** optional filter list; leave empty to include all calendars. +- **Correct note for the period:** the open editor is only reused when it is the **same period type and title** as the command (e.g. today for a daily review). Otherwise the plugin opens the intended calendar note. Quarterly notes use NotePlan’s title format **`YYYY-Qn`** (e.g. `2026-Q1`). +- **Planning vs reviewing:** For each period you can set a **planned items** name (defaults such as *Big Wins*, *Big Rocks*, *Key Outcomes*, *Goals*, *Theme*). After the main form, a **planning** area can write an **H2** and `>> …` tasks at the **start** of the **next** period’s calendar note, replacing any existing section with that title. Empty planning clears that section on the next note. The heading uses `{planName} for {next period title}` (e.g. `Big Rocks for 2026-W15`), separate from the on-screen “Planned:” / “Planning: …” labels. + +### Section headings (split in v2) + +- **Daily Journal Section Heading** — where **daily** review/journal answers are appended (default: `Journal`). +- **Review Section Heading** — where **weekly / monthly / quarterly / yearly** answers go (default: `Review`). + +If the heading does not already exist in a note, the content is added at the end of the note. + +### Question types and layout codes + +These define input controls and how lines are written to the note: + +- `` — ticked/unticked; if ticked, the surrounding text is included in the output +- `` — the same as `` above +- `` — whole number +- `` — number, may include a decimal part +- `` — `[H]H:MM` (e.g. `1:05`, `12:30`) +- `` — single-line text +- `` — multi-line; each non-empty line becomes a markdown bullet (`- `) +- `` — same, with checklist markers (`+ `) +- `` — same, with task markers (`* `) +- `` — pick from your configured mood list + +You can include Headings amongst the questions: + +- `

` / `

` (legacy: ``) — output as headings in the note/HTML. +- Literal `##` / `###` lines in settings are also supported. + +Placeholders + +- `` — current review period’s calendar title in the window and in saved output (e.g. `2026-03-28`, `2026-W13`, `2026-Q1`). Substituted in **parsed** heading and label text too (e.g. `## Weekly Review for ` matches the period title in the UI). +- `` or `` — the **following** period in the same format (e.g. weekly `2026-W52` → `2027-W01`). +- line breaks or `\n`. + +Note: Multiple ``, ``, or `` items on one line are supported; answers are merged on one line with spaces (without `||`). + +If a question is left empty, that line is omitted from the output. If a line in the note already starts with the same question text, it is treated as an existing answer, and prefilled. + +### Example (Daily Review) + +Question string: + ``` -@sleep() @work() -@fruitveg() #stretches #closedRings +

Stats for +Health: @sleep() @work() @fruitveg() #stretches #closedRings +Work: @work() + +

Journal Mood: Gratitude: Wins: - -

'Big three' tasks for - +Challenges: ``` -produces this review window: -After answering the questions, would produce something like this in today's note: +The v2 window shows the summary (when applicable), then this layout; submitting might produce: ```markdown -## Journal +## Stats for 2026-04-06 @sleep(6.8) @work(7) @fruitveg(4) #stretches +### Journal Mood: 😇 Blessed Gratitude: Went to great Nana's 100th birthday party -- result! Wins: - First win... - Another one - -## 'Big three' tasks for 2026-03-29 -+ Main thing -+ Another important thing ``` -## Automation -??? + +### Moods + +Comma-separated list of labels (emoji optional). + +--- + +## Screenshots to capture + +Use these when updating plugin or website docs (retina/`@2x` where you already use `calendar-settings@2x.png`): + +1. **Daily Review window** — full window with **Summary** (tasks + events), main questions, and **planning** section; preferably **New Window** and one of **Split View** / **Main Window** if you document all three modes. +2. **Weekly (or monthly) Review window** — Summary showing **#win** / **#bigwin** styling and a heading that uses **``** (e.g. “Weekly Review for 2026-W14”). +3. **Quarterly note** — editor or sidebar showing title **`YYYY-Qn`** and a submitted review block (optional but clarifies Q-format fix). +4. **Next-period planning output** — the **next** day/week/month note showing the **H2** + **`>>`** tasks written by the planning section (paired with a screenshot of the planning fields filled in), if you document that feature visually. + +--- ## Support -If you find an issue with this plugin, or would like to suggest new features for it, please raise a [Bug or Feature 'Issue' in GitHub](https://github.com/NotePlan/plugins/issues). + +Issues and feature ideas: [NotePlan plugins on GitHub](https://github.com/NotePlan/plugins/issues). If you would like to support my late-night work extending NotePlan through writing these plugins, you can through: @@ -123,4 +156,5 @@ If you would like to support my late-night work extending NotePlan through writi Thanks! ## History -Please see the [CHANGELOG](https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md). + +See the [CHANGELOG](https://github.com/NotePlan/plugins/blob/main/jgclark.Journalling/CHANGELOG.md) for v2 beta notes and migration details. diff --git a/jgclark.Journalling/__tests__/periodReviews.test.js b/jgclark.Journalling/__tests__/periodReviews.test.js index d51daa8c1..5cef089ca 100644 --- a/jgclark.Journalling/__tests__/periodReviews.test.js +++ b/jgclark.Journalling/__tests__/periodReviews.test.js @@ -2,21 +2,24 @@ // Last updated: 2026-03-25 for v2.0.0.b3 by @Cursor -import { - buildInitialReviewAnswersByFieldName, - buildOutputFromReviewWindowAnswers, - extractPlanSectionTaskItems, - normalizePlanningTaskLinesFromForm, - parseQuestions, -} from '../src/periodReviews' -import { buildReviewHTML } from '../src/reviewHTMLViewGenerator' import { buildNextPeriodNotePlanSectionHeadingTitle, buildNextPlanSectionHeadingTitle, getPeriodAdjectiveFromType, getPlanItemsNameForPeriodType, + mergeUniqueSummaryDoneTaskLines, + normalizePlanningTaskLinesFromForm, + splitMergedSummaryDoneLinesIntoWinsAndOthers, substituteReviewPeriodPlaceholders, + summaryTaskLineDedupeKey, } from '../src/journalHelpers' +import { extractPlanSectionItems, taskContentIsSummaryWin } from '../src/periodReviews' +import { buildReviewHTML } from '../src/reviewHTMLViewGenerator' +import { + buildInitialReviewAnswersByFieldName, + buildOutputFromReviewWindowAnswers, + parseQuestions, +} from '../src/reviewQuestions' import { DataStore } from '@mocks/index' beforeAll(() => { @@ -211,6 +214,7 @@ Wins: ` parsedQuestions, rawLines, [], + [], '2026-W13', 'week', [], @@ -219,9 +223,147 @@ Wins: ` {}, [], ) - expect(html).toContain('Weekly Review for 2026-W13') + expect(html).toContain('Weekly Review for') + expect(html).toContain('2026-W13') expect(html).not.toContain('<date>') }) + + it('should render a typed `

…` line as one heading (segment regex must not split after the first word)', () => { + const raw = `

Stats for +Mood: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + [], + [], + '2026-04-06', + 'day', + [], + 'onReviewWindowAction', + 'Big Wins', + {}, + [], + ) + expect(html).toContain('Stats for 2026-04-06') + expect(html).not.toContain('review-line-text-fragment"> for 2026-04-06') + }) + + it('should keep daily completed-tasks and events inside #summary section-wrap when carry-over plan items exist', () => { + const raw = `## Stats for +Mood: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const eventsForPeriod = [ + { + title: 'Pray for Jill', + date: new Date('2026-04-07T10:00:00'), + endDate: new Date('2026-04-07T10:18:00'), + isAllDay: false, + }, + ] + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + [], + [], + '2026-04-07', + 'day', + eventsForPeriod, + 'onReviewWindowAction', + 'Wins', + {}, + [{ content: 'Rest day', isDone: false }], + ) + const summaryMarker = 'id="summary">' + const summaryStart = html.indexOf(summaryMarker) + expect(summaryStart).toBeGreaterThan(-1) + const afterSummary = html.slice(summaryStart + summaryMarker.length) + const formIdx = afterSummary.indexOf('
| { + const raw = `Mood: ` + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const html = buildReviewHTML( + { moods: 'Calm,Busy' }, + parsedQuestions, + rawLines, + ['* >> Planned win @done(2026-04-07)'], + ['Plain task @done(2026-04-07)'], + '2026-04-07', + 'day', + [], + 'onReviewWindowAction', + 'Big Wins', + {}, + [], + ) + expect(html.indexOf('summary-content-wins')).toBe(-1) + expect(html).toContain('summary-content-completed-tasks') + expect(html).toContain('1 other completed task') + expect(html).not.toMatch(/\b2 completed tasks\b/) + expect(html.indexOf('Planned win')).toBeLessThan(html.indexOf('other completed')) + expect(html.indexOf('other completed')).toBeLessThan(html.indexOf('Plain task')) + }) + }) + + describe('splitMergedSummaryDoneLinesIntoWinsAndOthers', () => { + it('should split merged list after last leading win line', () => { + const merged = [ + '* >> Win @done(2026-04-08)', + 'Plain @done(2026-04-08)', + ] + const { wins, others } = splitMergedSummaryDoneLinesIntoWinsAndOthers(merged) + expect(wins).toEqual(['* >> Win @done(2026-04-08)']) + expect(others).toEqual(['Plain @done(2026-04-08)']) + }) + }) + + describe('mergeUniqueSummaryDoneTaskLines', () => { + it('should keep win order, drop duplicate keys, and omit lines matching carry-over text', () => { + const wins = ['* >> A @done(2026-04-08)', ' * >> A @done(2026-04-08) '] + const rest = ['B @done(2026-04-08)', 'B @done(2026-04-08)'] + expect(mergeUniqueSummaryDoneTaskLines(wins, rest, [])).toEqual(['* >> A @done(2026-04-08)', 'B @done(2026-04-08)']) + expect( + mergeUniqueSummaryDoneTaskLines(['x @done'], ['y'], [{ content: 'x @done', isDone: false }]), + ).toEqual(['y']) + }) + + it('should treat summaryTaskLineDedupeKey as trim-only', () => { + expect(summaryTaskLineDedupeKey(' hello ')).toBe('hello') + }) + }) + + describe('taskContentIsSummaryWin', () => { + it('should treat >> after optional task marker and priorities as a win', () => { + expect(taskContentIsSummaryWin('* >> Ship it @done(2026-04-08)')).toBe(true) + expect(taskContentIsSummaryWin('>> Ship it @done(2026-04-08)')).toBe(true) + expect(taskContentIsSummaryWin('! >> Ship @done(2026-04-08)')).toBe(true) + expect(taskContentIsSummaryWin('Regular task @done(2026-04-08)')).toBe(false) + }) + + it('should treat #win / #bigwin as wins regardless of >>', () => { + expect(taskContentIsSummaryWin('Launched #win @done(2026-04-08)')).toBe(true) + expect(taskContentIsSummaryWin('Big thing #bigwin @done(2026-04-08)')).toBe(true) + }) }) describe('buildInitialReviewAnswersByFieldName', () => { @@ -289,6 +431,30 @@ Ship: `, const initial = buildInitialReviewAnswersByFieldName(questions, lines) expect(initial.q_0).toBe('1:45') }) + + it('should extract token duration answers even when template segment has extra prefix text', () => { + const config = { dailyReviewQuestions: 'Health: @sleep() @fruitveg()' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['@sleep(7:52)'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('7:52') + }) + + it('should extract token int answers even when template segment has extra prefix text', () => { + const config = { dailyReviewQuestions: 'Health: @sleep() @fruitveg()' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['@fruitveg(5)'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_1).toBe('5') + }) + + it('should extract token number answers even when template segment has extra prefix text', () => { + const config = { dailyReviewQuestions: 'Stats: @weight()' } + const questions = parseQuestions(config.dailyReviewQuestions) + const lines = ['@weight(6.8)'] + const initial = buildInitialReviewAnswersByFieldName(questions, lines) + expect(initial.q_0).toBe('6.8') + }) }) describe('buildOutputFromReviewWindowAnswers', () => { @@ -447,8 +613,8 @@ Ship: `, it('getPlanItemsNameForPeriodType should use defaults when config keys missing or blank', () => { const minimal = {} - expect(getPlanItemsNameForPeriodType(minimal, 'day')).toBe('Big 3 Rocks') - expect(getPlanItemsNameForPeriodType(minimal, 'week')).toBe('Top 3 Wins') + expect(getPlanItemsNameForPeriodType(minimal, 'day')).toBe('Big Rocks') + expect(getPlanItemsNameForPeriodType(minimal, 'week')).toBe('Top Wins') const custom = { weekPlanItemsName: 'Wins' } expect(getPlanItemsNameForPeriodType(custom, 'week')).toBe('Wins') }) @@ -459,7 +625,7 @@ Ship: `, expect(normalizePlanningTaskLinesFromForm('>> solo')).toEqual(['solo']) }) - it('extractPlanSectionTaskItems should read open and done tasks under matching H2', () => { + it('extractPlanSectionItems should read open and done tasks under matching H2', () => { const heading = 'Top 3 Wins for the next week' const note = { paragraphs: [ @@ -468,7 +634,7 @@ Ship: `, { type: 'done', content: '* >> Second @done(2026-03-30)', lineIndex: 2 }, ], } - const items = extractPlanSectionTaskItems(note, heading) + const items = extractPlanSectionItems(note, heading) expect(items.length).toBe(2) expect(items[0].isDone).toBe(false) expect(items[1].isDone).toBe(true) diff --git a/jgclark.Journalling/calendar-settings@2x.png b/jgclark.Journalling/calendar-settings@2x.png index 224686acedaa9eb9b61f7d95891686b9b8e8ef8b..d857a0504126918f3495af2298d7f54b158a843a 100644 GIT binary patch literal 23242 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%0A4rS3x}RnDgr36LueIBr)zayz9bwI?f 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 literal 73359 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 WN$v0NaxaP*fWXt$&t;ucLK6Usx-O^y diff --git a/jgclark.Journalling/plugin.json b/jgclark.Journalling/plugin.json index 0ebb7e1ec..97cb131b5 100644 --- a/jgclark.Journalling/plugin.json +++ b/jgclark.Journalling/plugin.json @@ -9,7 +9,7 @@ "plugin.author": "Jonathan Clark", "plugin.url": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/README.md", "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md", - "plugin.version": "2.0.0.b6", + "plugin.version": "2.0.0.b7", "plugin.releaseStatus": "beta", "plugin.lastUpdateInfo": "v2.0.0: First release of the new Journalling & Reviews plugin, but building on earlier 'Journalling Helpers' plugin (v1).", "plugin.requiredFiles": [ @@ -264,7 +264,7 @@ "title": "Weekly Review/Journal Questions", "description": "String that includes both the Journal/Review questions and how to lay out the answers in the weekly note.\nSee 'Daily Review/ Journal Questions' above for details.", "type": "string", - "default": "## Weekly Review for \nWins: \nNew or improved: \nChallenges: ", + "default": "## Weekly Review for \nWins: \nNew or improved: \nChallenges: ", "required": false }, { diff --git a/jgclark.Journalling/requiredFiles/reviews.css b/jgclark.Journalling/requiredFiles/reviews.css index 7251ae719..e0549eecb 100644 --- a/jgclark.Journalling/requiredFiles/reviews.css +++ b/jgclark.Journalling/requiredFiles/reviews.css @@ -4,6 +4,10 @@ body { color: var(--fg-main-color, #4c4f69); background-color: var(--bg-main-color, #eff1f5); margin: 1.0rem; + + /* Basic common HTML 'resets' */ + scrollbar-gutter: stable; + interpolate-size: allow-keywords; } .section-wrap { @@ -50,6 +54,17 @@ body { color: var(--h2-color, #5c5f77); } +.review-title-period-with-nav { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.review-period-step-button { + padding: 0rem 0.2rem; + font-weight: 600; +} + .summary { display: flex; flex-direction: column; @@ -66,7 +81,7 @@ body { column-count: 2; column-gap: 1rem; font-size: 0.85rem; - margin-left: 0.25rem; + margin-left: 1.0rem; /* 0.25rem; */ margin-bottom: 0.35rem; } diff --git a/jgclark.Journalling/review-window-questions@2x.png b/jgclark.Journalling/review-window-questions@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..552f63cd964b8bdd5f71bff9270c866bb40caca8 GIT binary patch literal 83839 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 joF#JVoOI%cgJXVei)gvX@qaYwv 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, } +/** One parsed segment from review settings; `` names are defined in `reviewQuestions.js` (`REVIEW_QUESTION_TYPE_NAMES_ALT`). */ export type ParsedQuestionType = { question: string, type: string, @@ -47,10 +48,6 @@ export type ParsedQuestionType = { lineIndex: number } -/** `` names in review question templates — keep in sync with `parseQuestions` and `REVIEW_SEGMENT_RE`. */ -export const REVIEW_QUESTION_TYPE_NAMES_ALT = - 'string|int|number|duration|boolean|mood|subheading|h2|h3|bullets|checklists|tasks' - //--------------------------------------------------------------- // Settings @@ -78,6 +75,92 @@ export async function getJournalSettings(): Promise { // want to use Promis } } +/** + * Get raw question lines for the given period from config. + * From v1.16, these may now contain multiple questions per line, separated by '||'. + * @param {JournalConfigType} config the journal configuration + * @param {string} period for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @returns {Promise>} array of question lines, or empty array if unsupported + */ +export async function getQuestionsForPeriod(config: JournalConfigType, period: string): Promise> { + let rawQuestionLines: Array = [] + switch (period) { + case 'day': { + rawQuestionLines = config.dailyReviewQuestions.split('\n') + break + } + case 'week': { + rawQuestionLines = config.weeklyReviewQuestions.split('\n') + break + } + case 'month': { + rawQuestionLines = config.monthlyReviewQuestions.split('\n') + break + } + case 'quarter': { + rawQuestionLines = config.quarterlyReviewQuestions.split('\n') + break + } + case 'year': { + rawQuestionLines = config.yearlyReviewQuestions.split('\n') + break + } + default: { + logError(pluginJson, `${period} review questions aren't yet supported. Stopping.`) + await showMessage(`Sorry, ${period} review questions aren't yet supported.`) + return [] + } + } + logDebug(pluginJson, `rawQuestionLines: ${String(rawQuestionLines)}`) + return rawQuestionLines +} + +/** + * Get the configured section heading to use for a given review period. + * @param {JournalConfigType} config the journal configuration + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @returns {string} + */ +export function getSectionHeadingForPeriod(config: JournalConfigType, periodType: string): string { + if (periodType === 'day') { + return config.dailyJournalSectionHeading + } + return config.reviewSectionHeading +} + +/** + * Map review title strings to forms understood by getFirstDateInPeriod / getLastDateInPeriod (e.g. `2026Q1` -> `2026-Q1`). + * TODO: Revisit why this is necessary. + * @param {string} periodTitle + * @returns {string} + */ +export function normalizeReviewPeriodTitleForNPDateHelpers(periodTitle: string): string { + const compactQuarter = periodTitle.match(/^(\d{4})Q([1-4])$/i) + if (compactQuarter) { + return `${compactQuarter[1]}-Q${compactQuarter[2]}` + } + return periodTitle +} + +/** + * Normalize non-empty lines from the planning textarea for storage (strip task markers / leading `>>`). + * @param {string} planningFormText + * @returns {Array} + */ +export function normalizePlanningTaskLinesFromForm(planningFormText: string): Array { + const raw = typeof planningFormText === 'string' ? planningFormText : String(planningFormText ?? '') + return raw + .split(/\r?\n/) + .map((l) => { + let t = l.trim() + t = t.replace(/^\*\s*/, '') + if (t.startsWith('>>')) { + t = t.slice(2).trim() + } + return t + }) + .filter((t) => t !== '') +} /** * Replace `` with the calendar period title and `` / `` with the following period. @@ -148,7 +231,7 @@ export function getPlanItemsNameForPeriodType(config: JournalConfigType, periodT return fallback } // $FlowIgnore[prop-missing]: dynamic settings keys from plugin.json - const raw = config[key] + const raw = (config: { [string]: mixed })[key] const s = typeof raw === 'string' ? raw.trim() : '' return s !== '' ? s : fallback } @@ -198,4 +281,106 @@ export function buildNextPlanSectionHeadingTitle(planName: string, periodType: s */ export function buildNextPeriodNotePlanSectionHeadingTitle(planName: string, periodString: string): string { return `${planName} for ${periodString}` +} + +/** + * Normalize a task line for comparing duplicates (trim; used when scanning notes and when merging summary lists). + * @param {string} content + * @returns {string} + */ +export function summaryTaskLineDedupeKey(content: string): string { + return content.trim() +} + +/** Global @done stripper for probing task body for `>>` win marker (same pattern as review HTML summary). */ +const RE_STRIP_DONE_FOR_SUMMARY_PROBE: RegExp = new RegExp(RE_DONE_DATE_OPT_TIME.source, 'gi') + +/** + * True if task line includes #win / #bigwin anywhere (word boundary). + * @param {string} content + * @returns {boolean} + */ +function hasWinHashtag(content: string): boolean { + return /(?:^|\s)#(?:bigwin|win)\b/i.test(content) +} + +/** + * True when the task body uses the `>>` win / planning marker after optional list marker and `!` priorities. + * @param {string} content + * @returns {boolean} + */ +function taskLineHasDoubleChevronWinPrefix(content: string): boolean { + let probe = content.replace(RE_STRIP_DONE_FOR_SUMMARY_PROBE, '').trim() + probe = probe.replace(/^\*+\s+/, '').replace(/^[-+]\s+\[[ x]\]\s*/i, '').replace(/^-\s+/, '').trim() + while (!/^>>\s?/.test(probe)) { + const next = probe.replace(/^!{1,3}\s+/, '').trim() + if (next === probe) { + return false + } + probe = next + } + return true +} + +/** + * A done task counts as a "win" for the review summary when tagged #win / #bigwin or prefixed with `>>` on the task body. + * @param {string} content + * @returns {boolean} + */ +export function taskContentIsSummaryWin(content: string): boolean { + return hasWinHashtag(content) || taskLineHasDoubleChevronWinPrefix(content) +} + +/** + * Split the merged summary list into a leading win block and the rest ("other completed"). + * Uses the same win rules as {@link taskContentIsSummaryWin} so runtime HTML matches note scanning. + * @param {Array} mergedLines output of mergeUniqueSummaryDoneTaskLines (wins first, then others) + * @returns {{ wins: Array, others: Array }} + */ +export function splitMergedSummaryDoneLinesIntoWinsAndOthers(mergedLines: Array): {| wins: Array, others: Array |} { + const wins: Array = [] + let i = 0 + for (; i < mergedLines.length; i++) { + const line = mergedLines[i] + if (!taskContentIsSummaryWin(line)) { + break + } + wins.push(line) + } + return { wins, others: mergedLines.slice(i) } +} + +/** + * Merge win-first and other completed lines into one list: each normalized line at most once. + * Lines whose key matches a carry-over plan row are omitted (that row is already shown in the carry-over block). + * @param {Array} winTasks + * @param {Array} completedTasks + * @param {Array<{ content: string }>} carryOverPlanItems + * @returns {Array} + */ +export function mergeUniqueSummaryDoneTaskLines( + winTasks: Array, + completedTasks: Array, + carryOverPlanItems: Array<{ content: string }> = [], +): Array { + const carryKeys = new Set(carryOverPlanItems.map((item) => summaryTaskLineDedupeKey(item.content))) + const seen = new Set() + const out: Array = [] + const tryAdd = (line: string) => { + const k = summaryTaskLineDedupeKey(line) + if (k === '') { + return + } + if (carryKeys.has(k)) { + return + } + if (seen.has(k)) { + return + } + seen.add(k) + out.push(line) + } + winTasks.forEach(tryAdd) + completedTasks.forEach(tryAdd) + return out } \ No newline at end of file diff --git a/jgclark.Journalling/src/periodReviews.js b/jgclark.Journalling/src/periodReviews.js index 354467027..068eef08e 100644 --- a/jgclark.Journalling/src/periodReviews.js +++ b/jgclark.Journalling/src/periodReviews.js @@ -2,7 +2,7 @@ //--------------------------------------------------------------- // Journalling commands // Jonathan Clark -// last update 2026-04-02 for v2.0.0.b6 by @jgclark + @Cursor +// last update 2026-04-05 for v2.0.0.b7 by @jgclark + @Cursor //--------------------------------------------------------------- import strftime from 'strftime' @@ -12,22 +12,28 @@ import { getJournalSettings, getPeriodAdjectiveFromType, getPlanItemsNameForPeriodType, - REVIEW_QUESTION_TYPE_NAMES_ALT, - substituteReviewPeriodPlaceholders, + getQuestionsForPeriod, + getSectionHeadingForPeriod, + normalizePlanningTaskLinesFromForm, + normalizeReviewPeriodTitleForNPDateHelpers, + summaryTaskLineDedupeKey, + taskContentIsSummaryWin, } from './journalHelpers' +export { taskContentIsSummaryWin } import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' +import { + buildInitialReviewAnswersByFieldName, + buildOutputFromReviewWindowAnswers, + parseQuestions, +} from './reviewQuestions' import { stylesheetinksInHeader, faLinksInHeader, buildReviewHTML } from './reviewHTMLViewGenerator' import { RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, getNextNPPeriodString, getNPQuarterStr, + getPreviousNPPeriodString, getWeek, getPeriodOfNPDateStr, - isDailyNote, - isWeeklyNote, - isMonthlyNote, - isQuarterlyNote, - isYearlyNote, } from '@helpers/dateTime' import { clo, logDebug, logError, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' @@ -40,14 +46,13 @@ import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' import { closeWindowFromCustomId } from '@helpers/NPWindows' import { isParaAMatchForHeading } from '@helpers/headings' import { findEndOfActivePartOfNote, findHeading, findHeadingStartsWith, findStartOfActivePartOfNote } from '@helpers/paragraph' -import { getInput, isInt, showMessage } from '@helpers/userInput' +import { getInput, showMessage } from '@helpers/userInput' //--------------------------------------------------------------- // Constants & Types const REVIEW_WINDOW_CUSTOM_ID = 'jgclark.Journalling.period-review' const REVIEW_WINDOW_CALLBACK_COMMAND = 'onReviewWindowAction' -const RE_DURATION_HHMM = /^(\d{1,2}):([0-5]\d)$/ /** Paragraph types treated as tasks under a plan H2 (carry-over + rewrite). */ const PLAN_SECTION_PARA_TYPES: Set = new Set([ @@ -60,26 +65,6 @@ const PLAN_SECTION_PARA_TYPES: Set = new Set([ 'list' ]) -/** - * Normalize non-empty lines from the planning textarea for storage (strip task markers / leading `>>`). - * @param {string} planningFormText - * @returns {Array} - */ -export function normalizePlanningTaskLinesFromForm(planningFormText: string): Array { - const raw = typeof planningFormText === 'string' ? planningFormText : String(planningFormText ?? '') - return raw - .split(/\r?\n/) - .map((l) => { - let t = l.trim() - t = t.replace(/^\*\s*/, '') - if (t.startsWith('>>')) { - t = t.slice(2).trim() - } - return t - }) - .filter((t) => t !== '') -} - /** * Find first matching H2 in the active part of the note. * @param {TNote} note @@ -260,75 +245,57 @@ export async function writePlanningTasksToNextPeriodNote( //--------------------------------------------------------------- /** - * Gather answers to daily journal questions, and inserts at the cursor. + * Shared entry for period review commands: resolve calendar title string, then open the review flow. + * @param {string} periodType + * @param {() => string} getPeriodString + * @returns {Promise} */ -export async function dailyJournalQuestions(): Promise { +async function runJournalQuestionsForCurrentPeriod(periodType: string, getPeriodString: () => string): Promise { try { - const thisPeriodStr = strftime(`%Y-%m-%d`) - logDebug(pluginJson, `Starting for day (currently ${thisPeriodStr})`) - - await processJournalQuestions(thisPeriodStr, 'day') + const thisPeriodStr = getPeriodString() + logDebug(pluginJson, `Starting for ${periodType} (currently ${thisPeriodStr})`) + await processJournalQuestions(thisPeriodStr, periodType) } catch (error) { logError(pluginJson, error.message) } } +/** + * Gather answers to daily journal questions, and inserts at the cursor. + */ +export async function dailyJournalQuestions(): Promise { + await runJournalQuestionsForCurrentPeriod('day', () => strftime('%Y-%m-%d')) +} + /** * Gather answers to weekly journal questions, and inserts at the cursor. */ export async function weeklyJournalQuestions(): Promise { - try { + await runJournalQuestionsForCurrentPeriod('week', () => { const currentWeekNum = getWeek(new Date()) - const thisPeriodStr = `${strftime(`%Y`)}-W${currentWeekNum}` - logDebug(pluginJson, `Starting for week (currently ${thisPeriodStr})`) - - await processJournalQuestions(thisPeriodStr, 'week') - } catch (error) { - logError(pluginJson, error.message) - } + return `${strftime('%Y')}-W${currentWeekNum}` + }) } /** * Gather answers to monthly journal questions, and inserts at the cursor. */ export async function monthlyJournalQuestions(): Promise { - try { - const thisPeriodStr = strftime(`%Y-%m`) - logDebug(pluginJson, `Starting for month (currently ${thisPeriodStr})`) - - await processJournalQuestions(thisPeriodStr, 'month') - } catch (error) { - logError(pluginJson, error.message) - } + await runJournalQuestionsForCurrentPeriod('month', () => strftime('%Y-%m')) } /** * Gather answers to quarterly journal questions, and inserts at the cursor. */ export async function quarterlyJournalQuestions(): Promise { - try { - const todaysDate = new Date() - const thisPeriodStr = getNPQuarterStr(todaysDate) - logDebug(pluginJson, `Starting for quarter (currently ${thisPeriodStr})`) - - await processJournalQuestions(thisPeriodStr, 'quarter') - } catch (error) { - logError(pluginJson, error.message) - } + await runJournalQuestionsForCurrentPeriod('quarter', () => getNPQuarterStr(new Date())) } /** * Gather answers to yearly journal questions, and inserts at the cursor. */ export async function yearlyJournalQuestions(): Promise { - try { - const thisPeriodStr = strftime(`%Y`) - logDebug(pluginJson, `Starting for year (currently ${thisPeriodStr})`) - - await processJournalQuestions(thisPeriodStr, 'year') - } catch (error) { - logError(pluginJson, error.message) - } + await runJournalQuestionsForCurrentPeriod('year', () => strftime('%Y')) } //--------------------------------------------------------- @@ -349,10 +316,14 @@ async function processJournalQuestions(periodStringIn: string = '', periodType: const config: JournalConfigType = await getJournalSettings() let reviewNote: ?TNote = null - // Check that we have a correct period note open ... + // Check that we have the correct period note open (same type *and* same title as requested). const { note: openEditorNote } = Editor - // FIXME: Check for Teamspace stuff here - if (openEditorNote && getPeriodOfNPDateStr(openEditorNote.title ?? '') === periodType) { + const wantTitle = String(periodStringIn).trim() + const openTitle = String(openEditorNote?.title ?? '').trim() + const titlesMatch = + normalizeReviewPeriodTitleForNPDateHelpers(openTitle) === normalizeReviewPeriodTitleForNPDateHelpers(wantTitle) + // TODO: Check for Teamspace stuff here + if (openEditorNote && getPeriodOfNPDateStr(openTitle) === periodType && titlesMatch) { // Use the existing open note reviewNote = openEditorNote logDebug('processJournalQuestions', `Starting with open note '${String(reviewNote?.title ?? 'unknown')}' of period '${String(getPeriodOfNPDateStr(reviewNote?.title ?? ''))}'`) @@ -406,649 +377,42 @@ async function processJournalQuestions(periodStringIn: string = '', periodType: //------------------------------------------------------------- /** - * Map review title strings to forms understood by getFirstDateInPeriod / getLastDateInPeriod (e.g. `2026Q1` -> `2026-Q1`). - * TODO: Revisit why this is necessary. - * @param {string} periodTitle - * @returns {string} - */ -function normalizeReviewPeriodTitleForNPDateHelpers(periodTitle: string): string { - const compactQuarter = periodTitle.match(/^(\d{4})Q([1-4])$/i) - if (compactQuarter) { - return `${compactQuarter[1]}-Q${compactQuarter[2]}` - } - return periodTitle -} - -/** - * TEST: Remove - * True when the editor’s open note is the calendar note for this review (same period type and same period as `periodStringIn` when it is non-empty). - * @param {TNote | void} note - * @param {string} periodType - * @param {string} periodStringIn - * @returns {boolean} - */ -// function openEditorNoteMatchesReviewCommand(note: ?TNote, periodType: string, periodStringIn: string): boolean { -// if (note == null || note.type !== 'Calendar') { -// return false -// } -// const notePeriod = getPeriodOfNPDateStr(note.title ?? '') -// if (notePeriod !== periodType) { -// return false -// } -// const want = periodStringIn.trim() -// if (want === '') { -// return true -// } -// const a = normalizeReviewPeriodTitleForNPDateHelpers(String(note.title ?? '').trim()) -// const b = normalizeReviewPeriodTitleForNPDateHelpers(want) -// return a === b -// } - -/** - * TEST: Removing in favour of smarter helpers. - * Reuse the open calendar note when it matches this review; otherwise open the calendar note for the current period. - * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' - * @param {string} periodStringIn calendar title from the command (must match the open note to reuse it) - * @returns {TNote | null} the note, or null if not found - */ -// function ensureCorrectPeriodNoteIsOpen(periodType: string, periodStringIn: string = ''): TNote | null { -// const { note } = Editor -// const periodAdjective = getPeriodAdjectiveFromType(periodType) -// logDebug('ensureCorrectPeriodNoteIsOpen', `current note=${String(note?.title ?? 'unknown')}`) -// if (openEditorNoteMatchesReviewCommand(note, periodType, periodStringIn)) { -// logDebug('ensureCorrectPeriodNoteIsOpen', `Reusing open editor calendar note (matches ${periodType} / "${periodStringIn}")`) -// return note ?? null -// } -// logDebug('ensureCorrectPeriodNoteIsOpen', `Opening current ${periodAdjective} note (${periodType}); command period "${periodStringIn}"`) -// Editor.openNoteByDate(new Date(), false, 0, 0, false, periodType) -// return Editor.note ?? null -// } - -/** - * Get raw question lines for the given period from config. - * From v1.16, these may now contain multiple questions per line, separated by '||'. - * @param {JournalConfigType} config the journal configuration - * @param {string} period for journal questions: 'day', 'week', 'month', 'quarter', 'year' - * @returns {Promise>} array of question lines, or empty array if unsupported - */ -async function getQuestionsForPeriod(config: JournalConfigType, period: string): Promise> { - let rawQuestionLines: Array = [] - switch (period) { - case 'day': { - rawQuestionLines = config.dailyReviewQuestions.split('\n') - break - } - case 'week': { - rawQuestionLines = config.weeklyReviewQuestions.split('\n') - break - } - case 'month': { - rawQuestionLines = config.monthlyReviewQuestions.split('\n') - break - } - case 'quarter': { - rawQuestionLines = config.quarterlyReviewQuestions.split('\n') - break - } - case 'year': { - rawQuestionLines = config.yearlyReviewQuestions.split('\n') - break - } - default: { - logError(pluginJson, `${period} review questions aren't yet supported. Stopping.`) - await showMessage(`Sorry, ${period} review questions aren't yet supported.`) - return [] - } - } - logDebug(pluginJson, `rawQuestionLines: ${String(rawQuestionLines)}`) - return rawQuestionLines -} - -/** - * Get the configured section heading to use for a given review period. - * @param {JournalConfigType} config the journal configuration - * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' - * @returns {string} - */ -function getSectionHeadingForPeriod(config: JournalConfigType, periodType: string): string { - if (periodType === 'day') { - return config.dailyJournalSectionHeading - } else { - return config.reviewSectionHeading - } -} - -/** - * Parse question lines to extract questions and their types. - * Supports multiple questions per line separated by '||'. - * @tests in __tests__/periodReviews.test.js - * @param {Array | string} questionLines raw question lines from config - * @returns {Array<{question: string, type: string, originalLine: string, lineIndex: number}>} parsed questions with types and line index - */ -export function parseQuestions(questionLines: Array | string): Array { - const parsed = [] - const typeRE = new RegExp(`<\\s*(${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>`, 'i') - const segmentRE = new RegExp(`[^<]*?<\\s*(?:${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>\\)?[^\\s]*`, 'gi') - const linesToProcess = Array.isArray(questionLines) ? questionLines : String(questionLines ?? '').split('\n') - - // Process each line, splitting by '||' to support multiple questions per line - for (let lineIndex = 0; lineIndex < linesToProcess.length; lineIndex++) { - const line = linesToProcess[lineIndex] - // Support explicit typed heading lines, e.g. `

Heading` / `

Heading`. - // These are not questions: they are carried through as heading blocks in the output. - const mTypedH2 = line.match(/^\s*<\s*h2\s*>\s*(.+)$/i) - if (mTypedH2) { - parsed.push({ question: String(mTypedH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) - continue - } - const mTypedH3 = line.match(/^\s*<\s*h3\s*>\s*(.+)$/i) - if (mTypedH3) { - parsed.push({ question: String(mTypedH3[1] ?? '').trim(), type: 'h3', originalLine: line, lineIndex }) - continue - } - // Support markdown heading lines in the settings, e.g. `## Heading` or `### Subheading`. - // These are carried through as HTML heading tags in the output. - const mH2 = line.match(/^##\s+(.+)$/) - if (mH2) { - parsed.push({ question: String(mH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) - continue - } - const mH3 = line.match(/^###\s+(.+)$/) - if (mH3) { - parsed.push({ question: String(mH3[1] ?? '').trim(), type: 'h3', originalLine: line, lineIndex }) - continue - } - // Support `

` / `

` markers as trailing decorators (e.g. `Title

`). - // In this case, treat the line as a plain string question (marker is ignored for typing). - const mTrailingHeadingDecorator = line.match(/^(.*?)\s*<\s*(h2|h3)\s*>\s*$/i) - if (mTrailingHeadingDecorator) { - const base = String(mTrailingHeadingDecorator[1] ?? '').trim() - if (base !== '') { - parsed.push({ question: base, type: 'string', originalLine: `${base}: `, lineIndex }) - continue - } - } - // Split the line by '||' to get individual questions (allowing optional whitespace around ||) - const questionParts = line.split(/\s*\|\|\s*/).map(part => part.trim()).filter(part => part !== '') - - let questionNum = 0 - for (const questionPart of questionParts) { - questionNum++ - const segments = questionPart.match(segmentRE) ?? [] - for (const segmentRaw of segments) { - const segment = segmentRaw.trim() - const reArray = segment.match(typeRE) - const questionType = reArray?.[1] ?? '' - // Prefer #/@ tokens when present (e.g. #bible, @sleep()) - const tokenMatch = segment.match(/([@#][^\s(<]+)/) - const question = tokenMatch?.[1] - ?? segment - .replace( - /:|\(|\)||||||||

|

|||/gi, - '', - ) - .trim() - // logDebug(pluginJson, `- Q#${questionNum}: Line ${lineIndex}, type:${questionType} "${question}"`) - parsed.push({ question, type: questionType, originalLine: segment, lineIndex }) - } - } - } - - return parsed -} - -/** - * Handle an HTML heading question type. - * @param {string} question the question text - * @param {string} headingType 'h2' | 'h3' | 'subheading' (legacy) - * @returns {string} the formatted markdown heading block - */ -function handleHeadingQuestion(question: string, headingType: string): string { - const cleanHeading = question.replace(/<(?:subheading|h2|h3)>\s*$/i, '').trim() - const headingMarker = headingType === 'h2' ? '##' : '###' - return `\n${headingMarker} ${cleanHeading}` -} - -/** - * Handle a legacy `` question type. - * @param {string} question the question text - * @returns {string} the formatted subheading line - */ -function handleSubheadingQuestion(question: string): string { - return handleHeadingQuestion(question, 'subheading') -} - -/** - * Escape a string for use inside a RegExp. - * @param {string} s - * @returns {string} - */ -function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -/** Output prefix per line for multiline journal types (``, ``, ``). */ -const MULTILINE_ANSWER_PREFIX_BY_TYPE: { [string]: string } = { - bullets: '- ', - checklists: '+ ', - tasks: '* ', -} - -/** - * Markdown prefix written before each answer line for a multiline question type. - * @param {string} questionType - * @returns {string} - */ -function linePrefixForMultilineAnswerType(questionType: string): string { - return MULTILINE_ANSWER_PREFIX_BY_TYPE[questionType.toLowerCase()] ?? '' -} - -/** - * Remove leading line markers from saved note text so the review textarea shows plain lines. - * @param {string} rawBlock - * @param {string} linePrefix e.g. '- ' - * @returns {string} - */ -function stripMultilineAnswerPrefixes(rawBlock: string, linePrefix: string): string { - if (linePrefix === '') { - return rawBlock.trim() - } - return rawBlock - .split(/\r?\n/) - .map((l) => { - const trimmed = l.trim() - if (trimmed.startsWith(linePrefix)) { - return trimmed.slice(linePrefix.length).trim() - } - return trimmed - }) - .join('\n') - .trim() -} - -/** - * Split a question segment at the typed marker (same idea as reviewHTMLViewGenerator.splitSegmentAtTypeMarker). - * @param {string} segment - * @param {string} questionType - * @returns {{ prefix: string, suffix: string }} - */ -function splitParsedSegmentAtTypeMarker(segment: string, questionType: string): {| prefix: string, suffix: string |} { - const safeType = questionType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const re = new RegExp(`<\\s*${safeType}\\s*>`, 'i') - const m = segment.match(re) - if (!m || m.index == null) { - return { prefix: segment, suffix: '' } - } - const idx = m.index - const tagLen = m[0].length - return { prefix: segment.slice(0, idx), suffix: segment.slice(idx + tagLen) } -} - -/** - * Paragraph text lines to scan for existing review answers: across whole of active part of the note. + * Paragraph text lines to scan for existing review answers: active part of the note (from start through end of active region). * @param {TNote} note - * @param {string} reviewSectionHeading * @returns {Array} */ -function getParagraphLineContentsForReviewScan(note: TNote, reviewSectionHeading: string): Array { +function getParagraphLineContentsForReviewScan(note: TNote): Array { const endOfActiveLineIndex = findEndOfActivePartOfNote(note) const paragraphTextLines = note.paragraphs.slice(0, endOfActiveLineIndex).map((p) => p.content) ?? [] return paragraphTextLines } /** - * Parse one line of note content for a single parsed question's answer (form-ready value). Match for question in case-insensitive way. - * @param {ParsedQuestionType} parsedQuestion - * @param {string} line - * @returns {string} value for the HTML control, or '' if not found on this line - */ -function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: string): string { - const t = parsedQuestion.type.toLowerCase() - const seg = parsedQuestion.originalLine.trim() - if (t === 'subheading' || t === 'h2' || t === 'h3') { - return '' - } - if (t === 'boolean') { - const token = parsedQuestion.question - if (!token) { - return '' - } - const re = new RegExp(`(?:^|\\s)${escapeRegExp(token)}(?=\\s|$)`) - return re.test(line) ? 'yes' : '' - } - const { prefix, suffix } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) - if (t === 'int') { - const re = new RegExp(`${escapeRegExp(prefix)}(\\d+)${escapeRegExp(suffix)}`) - const m = line.match(re) - return m?.[1] != null ? m[1] : '' - } - if (t === 'number') { - const re = new RegExp( - `${escapeRegExp(prefix)}([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)${escapeRegExp(suffix)}`, - ) - const m = line.match(re) - return m?.[1] != null ? m[1] : '' - } - if (t === 'duration') { - const re = new RegExp(`${escapeRegExp(prefix)}(\\d{1,2}:[0-5]\\d)${escapeRegExp(suffix)}`) - const m = line.match(re) - return m?.[1] != null ? m[1] : '' - } - if (t === 'bullets' || t === 'checklists' || t === 'tasks') { - const marker = linePrefixForMultilineAnswerType(t) - const { prefix, suffix } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) - if (suffix === '') { - // If the template line is just ``/``/``, only treat lines that actually begin with that marker as a match. - if (prefix === '') { - const trimmed = line.trim() - if (!trimmed.startsWith(marker.trim())) { - return '' - } - return stripMultilineAnswerPrefixes(trimmed, marker) - } - const idx = line.toLowerCase().indexOf(prefix.toLowerCase()) - if (idx < 0) { - return '' - } - const raw = line.slice(idx + prefix.length).trim() - if (raw === '') { - return '' - } - return stripMultilineAnswerPrefixes(raw, marker) - } - const re = new RegExp(`${escapeRegExp(prefix)}([\\s\\S]*?)${escapeRegExp(suffix)}`, 'i') - const m = line.match(re) - if (m?.[1] == null) { - return '' - } - return stripMultilineAnswerPrefixes(m[1].trim(), marker) - } - if (t === 'mood' || t === 'string') { - if (suffix === '') { - // If template is just `` (no prefix), it's too ambiguous to pre-fill reliably (it would match every line). - if (prefix === '') { - return '' - } - const idx = line.toLowerCase().indexOf(prefix.toLowerCase()) - if (idx < 0) { - return '' - } - return line.slice(idx + prefix.length).trim() - } - const re = new RegExp(`${escapeRegExp(prefix)}(.*?)${escapeRegExp(suffix)}`, 'i') - const m = line.match(re) - return m?.[1] != null ? m[1].trim() : '' - } - return '' -} - -/** - * Get first matching answer in the note for question - * @param {ParsedQuestionType} parsedQuestion - * @param {Array} textLines - * @returns {string} - */ -function extractExistingAnswerForReviewForm(parsedQuestion: ParsedQuestionType, textLines: Array): string { - for (let i = 0; i <= textLines.length - 1; i++) { - const line = textLines[i] - const v = extractExistingAnswerOnLine(parsedQuestion, line) - if (v !== '') { - return v - } - } - return '' -} - -/** - * Map field names q_0, q_1, … to existing answers in the calendar note for pre-filling the review HTML form. - * @tests in __tests__/periodReviews.test.js - * @param {Array} parsedQuestions - * @param {Array} textLines text of lines to scan - * @returns {{ [string]: string }} - */ -export function buildInitialReviewAnswersByFieldName( - parsedQuestions: Array, - textLines: Array, -): { [string]: string } { - const out: { [string]: string } = {} - for (let i = 0; i < parsedQuestions.length; i++) { - const pq = parsedQuestions[i] - const v = extractExistingAnswerForReviewForm(pq, textLines) - if (v !== '') { - out[`q_${i}`] = v - } - } - return out -} - -/** - * Convert parsed questions into line-indexed groups. - * @param {Array} parsedQuestions - * @returns {{ [number]: Array }} - */ -function groupQuestionsByLine(parsedQuestions: Array): { [number]: Array } { - const questionsByLine: { [number]: Array } = {} - for (let i = 0; i < parsedQuestions.length; i++) { - const q = parsedQuestions[i] - if (!questionsByLine[q.lineIndex]) { - questionsByLine[q.lineIndex] = [] - } - questionsByLine[q.lineIndex].push(q) - } - return questionsByLine -} - -/** - * Convert answer payload from single window into output line for one parsed question. - * @param {ParsedQuestionType} parsedQuestion - * @param {string | boolean} answerRaw - * @returns {string} - */ -function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answerRaw: string | boolean): string { - const t = parsedQuestion.type - if (t === 'boolean') { - const on = answerRaw === true || answerRaw === 'yes' - return on ? parsedQuestion.question : '' - } - const answer = (typeof answerRaw === 'string' ? answerRaw : String(answerRaw ?? '')).trim() - if (answer === '' && t !== 'subheading' && t !== 'h2' && t !== 'h3') { - return '' - } - switch (t) { - case 'int': { - if (isInt(answer)) { - return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) - } - return '' - } - case 'number': { - if (answer != null && Number(answer)) { - return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) - } - return '' - } - case 'duration': { - if (RE_DURATION_HHMM.test(answer)) { - return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) - } - return '' - } - case 'string': { - return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) - } - case 'mood': { - return parsedQuestion.originalLine.replace(//, answer) - } - case 'bullets': - case 'checklists': - case 'tasks': { - const marker = linePrefixForMultilineAnswerType(t) - const lines = answer.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '') - if (lines.length === 0) { - return '' - } - const formatted = lines.map((l) => `${marker}${l}`).join('\n') - const ol = parsedQuestion.originalLine - if (ol.trimStart().startsWith('-')) { - return formatted - } - const { prefix, suffix } = splitParsedSegmentAtTypeMarker(ol, t) - const prefixTrimmed = prefix.trimEnd() - if (prefixTrimmed === '') { - return `${formatted}${suffix}` - } - return `${prefixTrimmed}\n${formatted}${suffix}` - } - case 'subheading': { - return handleSubheadingQuestion(parsedQuestion.question) - } - case 'h2': { - return handleHeadingQuestion(parsedQuestion.question, 'h2') - } - case 'h3': { - return handleHeadingQuestion(parsedQuestion.question, 'h3') - } - default: { - return '' - } - } -} - -/** - * Build output from answers returned by single-window mode. - * @tests in __tests__/periodReviews.test.js - * @param {Array} parsedQuestions - * @param {Array} rawQuestionLines - * @param {string} periodString - * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' - * @param {{ [string]: string | boolean }} answersByIndex - * @returns {string} - */ -export function buildOutputFromReviewWindowAnswers( - parsedQuestions: Array, - rawQuestionLines: Array, - periodString: string, - periodType: string, - answersByIndex: { [string]: string | boolean }, -): string { - let output = '' - const questionsByLine = groupQuestionsByLine(parsedQuestions) - const lineCount = rawQuestionLines.length - const stripPresentationDelimiters = (input: string): string => input.replace(/ \|\| /g, ' ') - - for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { - const lineQuestions = questionsByLine[lineIndex] ?? [] - const lineAnswers: Array = [] - for (let i = 0; i < lineQuestions.length; i++) { - const globalIndex = parsedQuestions.findIndex((q) => q === lineQuestions[i]) - const parsedQuestion = lineQuestions[i] - const answer = answerFromReviewWindowPayload(parsedQuestion, answersByIndex[`q_${globalIndex}`] ?? '') - if (answer !== '') { - lineAnswers.push(answer) - } - } - if (lineAnswers.length > 0) { - const hasMultiline = lineAnswers.some((a) => a.includes('\n')) - let combinedLine = hasMultiline ? lineAnswers.join('\n') : lineAnswers.join(' ') - if (!hasMultiline) { - combinedLine = combinedLine.replace(/\s+/g, ' ') - } - output += `${substituteReviewPeriodPlaceholders(combinedLine, periodString, periodType)}\n` - continue - } - - // If there are no questions/answers on this line, still carry `` / `` through into output. - const rawLine = rawQuestionLines[lineIndex] ?? '' - if (/<\s*date\s*>/i.test(rawLine) || /<\s*(?:datenext|nextdate)\s*>/i.test(rawLine)) { - const substituted = substituteReviewPeriodPlaceholders(stripPresentationDelimiters(rawLine), periodString, periodType).trim() - if (substituted !== '') { - output += `${substituted}\n` - } - } - } - return output -} - -// /** -// * Stable key for deduping calendar items returned on multiple days (e.g. multi-day events). -// * @param {TCalendarItem} ev -// * @returns {string} -// */ -// function calendarItemDedupeKey(ev: TCalendarItem): string { -// if (ev.id != null && String(ev.id) !== '') { -// return String(ev.id) -// } -// const t = ev.date instanceof Date ? ev.date.getTime() : 0 -// return `${ev.title ?? ''}\0${String(t)}\0${ev.calendar ?? ''}` -// } - -// /** -// * Calendar events for [startISO..endISO] using EventHelpers settings + the same per-day fetch as `listDaysEvents`. -// * @param {string} periodType -// * @param {string} periodString -// * @returns {Promise>} -// */ -// async function getListOfEventsForPeriod(_periodType: string, periodString: string): Promise> { -// const npPeriodKey = normalizeReviewPeriodTitleForNPDateHelpers(periodString) -// const startISO = getFirstDateInPeriod(npPeriodKey) -// const endISO = getLastDateInPeriod(npPeriodKey) -// if (startISO === '(error)' || endISO === '(error)') { -// logWarn(pluginJson, `getListOfEventsForPeriod: could not parse period "${periodString}"`) -// return [] -// } -// try { -// logDebug(pluginJson, `getListOfEventsForPeriod: ${_periodType} / ${periodString}`) -// const eventsConfig = await getEventsSettings() -// const calendarSet: Array = Array.isArray(eventsConfig.calendarSet) ? eventsConfig.calendarSet : [] -// let yyyymmdd = startISO.replace(/-/g, '') -// const endYYYYMMDD = endISO.replace(/-/g, '') -// const collected: Array = [] -// const seen = new Set() -// while (yyyymmdd <= endYYYYMMDD) { -// const dayEvents = (await getEventsForDay(yyyymmdd, calendarSet)) ?? [] -// for (const ev of dayEvents) { -// const k = calendarItemDedupeKey(ev) -// if (seen.has(k)) { -// continue -// } -// seen.add(k) -// collected.push(ev) -// } -// if (yyyymmdd === endYYYYMMDD) { -// break -// } -// yyyymmdd = calcOffsetDateStr(yyyymmdd, '+1d') -// } -// return collected -// } catch (err) { -// logError(pluginJson, `getListOfEventsForPeriod: ${err.message}`) -// return [] -// } -// } - -/** - * Collect completed task lines for review summary, according to period rules. - * - day: include all done lines whose @done date matches the day - * - week/month/quarter/year: include done lines in period with #win or #bigwin + * Collect done task lines for the review summary: wins (#win / #bigwin / `>>`) vs other completed tasks. + * The HTML view merges them into one list (wins first; each line once). + * - day: wins and completed are split (completed excludes win lines). + * - week/month/quarter/year: only wins are returned; `completed` is empty. * @param {string} periodType * @param {string} periodString - * @returns {Array} + * @returns {{ wins: Array, completed: Array }} */ -function getSummaryCompletedTasks(periodType: string, periodString: string): Array { +function getSummaryTasksForReview(periodType: string, periodString: string): {| wins: Array, completed: Array |} { const npPeriodKey = normalizeReviewPeriodTitleForNPDateHelpers(periodString) const startISO = getFirstDateInPeriod(npPeriodKey) const endISO = getLastDateInPeriod(npPeriodKey) if (startISO === '(error)' || endISO === '(error)') { - logWarn(pluginJson, `getSummaryCompletedTasks: could not parse period "${periodString}"`) - return [] + logWarn(pluginJson, `getSummaryTasksForReview: could not parse period "${periodString}"`) + return { wins: [], completed: [] } } const periodStartMs = new Date(`${startISO}T12:00:00`).getTime() const lookbackDaysRaw = Math.ceil((Date.now() - periodStartMs) / 86400000) + 1 const lookbackDays = Math.min(Math.max(0, lookbackDaysRaw), 400) const isDailyPeriod = periodType === 'day' - const hasWinTag = (content: string): boolean => /(?:^|\s)#(?:bigwin|win)\b/i.test(content) const notesToScan = getNotesChangedInInterval(lookbackDays, ['Calendar', 'Notes']) - const matchingLines: Array = [] + const wins: Array = [] + const completed: Array = [] + /** Same task line can appear on multiple notes (or with different surrounding whitespace); show each logical line once. */ + const seenKeys = new Set() for (const note of notesToScan) { for (const para of note.paragraphs) { @@ -1064,14 +428,27 @@ function getSummaryCompletedTasks(periodType: string, periodString: string): Arr if (!isInPeriod) { continue } - if (!isDailyPeriod && !hasWinTag(para.content)) { + const dedupeKey = summaryTaskLineDedupeKey(para.content) + if (dedupeKey === '' || seenKeys.has(dedupeKey)) { + continue + } + seenKeys.add(dedupeKey) + const isWin = taskContentIsSummaryWin(para.content) + if (!isDailyPeriod) { + if (isWin) { + wins.push(para.content) + } continue } - matchingLines.push(para.content) + if (isWin) { + wins.push(para.content) + } else { + completed.push(para.content) + } } } - return matchingLines + return { wins, completed } } /** @@ -1095,12 +472,11 @@ async function displayQuestionsWindow( ): Promise { const periodAdjective = getPeriodAdjectiveFromType(periodType) // Get the data sources we need for the review window - const summaryCompletedTasks = periodType === 'day' ? getSummaryCompletedTasks(periodType, periodString) : [] + const { wins: summaryWinTasks, completed: summaryCompletedTasks } = getSummaryTasksForReview(periodType, periodString) const calendarSet: Array = config.calendarSet ?? [] // logDebug(pluginJson, `calendarSet: [${String(calendarSet)}]`) const eventsForPeriod: Array = (periodType === 'day') ? await getEventsForDay(periodString, calendarSet) ?? [] : [] - const sectionHeading = getSectionHeadingForPeriod(config, periodType) - const scanLines = getParagraphLineContentsForReviewScan(calendarNote, sectionHeading) + const scanLines = getParagraphLineContentsForReviewScan(calendarNote) const initialAnswers = buildInitialReviewAnswersByFieldName(parsedQuestions, scanLines) const planName = getPlanItemsNameForPeriodType(config, periodType) const carryOverPlanItems = extractPlanSectionItems(calendarNote, planName) @@ -1110,6 +486,7 @@ async function displayQuestionsWindow( config, parsedQuestions, rawQuestionLines, + summaryWinTasks, summaryCompletedTasks, periodString, periodType, @@ -1157,9 +534,8 @@ async function displayQuestionsWindow( * @param {string} periodString the calendar note title string for the review period * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' * @param {string} answersText the text to insert into the journal - * TODO(later): revert to being private, and take out of index.js & plugin.json */ -export async function writeAnswersToNote( +async function writeAnswersToNote( periodStringIn: string = '', periodTypeIn: string = '', answersTextIn: string = '', @@ -1212,12 +588,34 @@ export async function writeAnswersToNote( // Callback function for HTML single-window review actions //--------------------------------------------------------- +/** + * Normalize review callback payload from the HTML bridge or x-callback-url (string or object per plugin.json). + * @param {mixed} payload + * @returns {any} + */ +function parseReviewWindowPayload(payload: mixed): any { + if (payload == null || payload === '') { + return {} + } + if (typeof payload === 'object' && !Array.isArray(payload)) { + return payload + } + if (typeof payload === 'string') { + const trimmed = payload.trim() + if (trimmed === '') { + return {} + } + return JSON.parse(trimmed) + } + return {} +} + /** * Callback function for HTML single-window review actions. * @param {string} actionName - * @param {ReviewWindowPayloadType} payload + * @param {mixed} payload — JSON string or object (NotePlan may pass either) */ -export async function onReviewWindowAction(actionName: string, payload: string = ''): Promise { +export async function onReviewWindowAction(actionName: string, payload: mixed = ''): Promise { logDebug(pluginJson, `onReviewWindowAction action=${actionName}`) // logDebug(pluginJson, `onReviewWindowAction payloadLength=${String(payload?.length ?? 0)} payloadPreview="${String(payload ?? '').slice(0, 100)}"`) if (actionName === 'cancel') { @@ -1229,7 +627,7 @@ export async function onReviewWindowAction(actionName: string, payload: string = if (actionName === 'refresh') { let refreshPayload: any = {} try { - refreshPayload = payload !== '' ? JSON.parse(payload) : {} + refreshPayload = parseReviewWindowPayload(payload) } catch (err) { logError(pluginJson, `onReviewWindowAction: refresh could not parse payload: ${err.message}`) return @@ -1241,16 +639,41 @@ export async function onReviewWindowAction(actionName: string, payload: string = return } logDebug('Journalling/onReviewWindowAction', `Refresh: reopening review for ${periodType} ${periodString}`) - closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) await processJournalQuestions(periodString, periodType) return } + if (actionName === 'navigatePeriod') { + let navPayload: any = {} + try { + navPayload = parseReviewWindowPayload(payload) + } catch (err) { + logError(pluginJson, `onReviewWindowAction: navigatePeriod could not parse payload: ${err.message}`) + return + } + const periodType = String(navPayload.periodType ?? '') + const periodString = String(navPayload.periodString ?? '') + const direction = String(navPayload.direction ?? '') + if (periodType === '' || periodString === '' || (direction !== 'prev' && direction !== 'next')) { + logWarn(pluginJson, 'onReviewWindowAction: navigatePeriod missing periodType, periodString, or valid direction') + return + } + const targetPeriodString = + direction === 'next' ? getNextNPPeriodString(periodString, periodType) : getPreviousNPPeriodString(periodString, periodType) + if (targetPeriodString === '') { + logWarn(pluginJson, `onReviewWindowAction: navigatePeriod could not compute ${direction} period from "${periodString}" (${periodType})`) + return + } + logDebug('Journalling/onReviewWindowAction', `Navigate ${direction}: reopening review for ${periodType} ${targetPeriodString}`) + await processJournalQuestions(targetPeriodString, periodType) + return + } + const config: JournalConfigType = await getJournalSettings() let safePayload: any = {} try { - // Allow callback payloads sent via x-callback-url arg1 JSON string. - safePayload = JSON.parse(payload) + // Allow callback payloads as JSON string (x-callback / jsBridge) or as an object (native bridge). + safePayload = parseReviewWindowPayload(payload) clo(safePayload, `onReviewWindowAction: parsed payload`) let answers = safePayload.answers ?? {} // Get the period type and string from the hidden fields in the payload @@ -1262,11 +685,11 @@ export async function onReviewWindowAction(actionName: string, payload: string = periodString = periodString || String(safePayload.periodString ?? '') } if ((answers == null || Object.keys(answers).length === 0) && typeof safePayload === 'object' && safePayload != null) { - const extractedAnswers = {} + const extractedAnswers: { [string]: any } = {} const keys = Object.keys(safePayload) for (const key of keys) { if (key.startsWith('q_')) { - extractedAnswers[key] = safePayload[key] + extractedAnswers[key] = (safePayload: any)[key] } } answers = extractedAnswers diff --git a/jgclark.Journalling/src/reviewHTMLViewGenerator.js b/jgclark.Journalling/src/reviewHTMLViewGenerator.js index d2b3a2e9b..992314fbf 100644 --- a/jgclark.Journalling/src/reviewHTMLViewGenerator.js +++ b/jgclark.Journalling/src/reviewHTMLViewGenerator.js @@ -12,9 +12,11 @@ import { buildNextPlanSectionHeadingTitle, buildThisPlanSectionHeadingTitle, getPeriodAdjectiveFromType, - REVIEW_QUESTION_TYPE_NAMES_ALT, + mergeUniqueSummaryDoneTaskLines, + splitMergedSummaryDoneLinesIntoWinsAndOthers, substituteReviewPeriodPlaceholders, } from './journalHelpers' +import { getReviewQuestionSegmentRegExpGi } from './reviewQuestions' import { RE_DONE_DATE_OPT_TIME } from '@helpers/dateTime' import { clo, logDebug, logInfo, logError, logWarn } from '@helpers/dev' import { getTaskPriority } from '@helpers/paragraph' @@ -50,12 +52,6 @@ const useFlexbox = true // Types of questions that use a block layout in the review window. const blockRowTypes = ['string', 'subheading', 'h2', 'h3', 'bullets', 'checklists', 'tasks'] -// Keep in sync with parseQuestions() in journal.js (segment extraction). -const REVIEW_SEGMENT_RE = new RegExp( - `[^<]*?<\\s*(?:${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>\\)?[^\\s]*`, - 'gi', -) - /** Remove @done(…) from summary lines (date with optional time), global. */ const RE_DONE_MENTION_STRIP_FOR_SUMMARY_G = new RegExp(RE_DONE_DATE_OPT_TIME.source, 'gi') @@ -230,10 +226,15 @@ function makeQuestionLineDiv( .filter(({ q }) => q.lineIndex === lineIndex) const headingTypes = ['subheading', 'h2', 'h3'] - const isHeadingOnlyLine = !cleanRawLine.includes('<') - && lineQuestionsOrdered.length > 0 - && lineQuestionsOrdered.every(({ q }) => headingTypes.includes(q.type)) - if (isHeadingOnlyLine) { + const lineHasOnlyHeadingQuestions = + lineQuestionsOrdered.length > 0 && lineQuestionsOrdered.every(({ q }) => headingTypes.includes(q.type)) + // `##` / `###` lines have no angle-bracket markup; `

…` / `

…` lines do — both must use the + // heading path. Otherwise `getReviewQuestionSegmentRegExpGi` matches `

` as a “typed segment” + const isMarkdownStyleHeadingLine = lineHasOnlyHeadingQuestions && !cleanRawLine.includes('<') + const isTypedOpenTagHeadingLine = + lineHasOnlyHeadingQuestions && + (/^\s*<\s*h2\s*>/i.test(cleanRawLine) || /^\s*<\s*h3\s*>/i.test(cleanRawLine)) + if (isMarkdownStyleHeadingLine || isTypedOpenTagHeadingLine) { return lineQuestionsOrdered .map(({ q, globalIndex }) => makeReviewQuestionRowDiv(q, globalIndex, config, '', periodString, periodType)) .join('\n') @@ -242,8 +243,9 @@ function makeQuestionLineDiv( const parts: Array = [] let lastIndex = 0 let segmentOrdinal = 0 - REVIEW_SEGMENT_RE.lastIndex = 0 - let match = REVIEW_SEGMENT_RE.exec(cleanRawLine) + const segmentRe = getReviewQuestionSegmentRegExpGi() + segmentRe.lastIndex = 0 + let match = segmentRe.exec(cleanRawLine) while (match !== null) { if (match.index > lastIndex) { parts.push(`${escapeHTML(cleanRawLine.slice(lastIndex, match.index))}`) @@ -254,7 +256,7 @@ function makeQuestionLineDiv( if (!pair) { parts.push(`${escapeHTML(match[0])}`) lastIndex = match.index + match[0].length - match = REVIEW_SEGMENT_RE.exec(cleanRawLine) + match = segmentRe.exec(cleanRawLine) continue } const { q: pq, globalIndex } = pair @@ -272,7 +274,7 @@ function makeQuestionLineDiv( ) } lastIndex = match.index + match[0].length - match = REVIEW_SEGMENT_RE.exec(cleanRawLine) + match = segmentRe.exec(cleanRawLine) } if (lastIndex < cleanRawLine.length) { @@ -348,20 +350,60 @@ function makeCarryOverPlanSummaryContentDiv(

`) } }) - rows.push(`
`) } else { rows.push(`No planned items found for this period`) } + // Single close for summary-content — avoid an extra when items exist (that closed section-wrap early). rows.push(``) return rows.join('\n') } /** - * Summary card: optional carry-over plan tasks, then (daily only) completed-task wins + calendar events. + * HTML list rows for done tasks in the summary (wins or completed — same markup). + * @param {Array} taskLines + * @returns {string} + */ +function formatSummaryTaskItemsHTML(taskLines: Array): string { + return taskLines + .map( + (taskLine) => ` +
+ + ${formatTaskAsHTML(taskLine)} +
`, + ) + .join('\n') +} + +/** + * Singular or plural "task" / "tasks" for summary counts (0 uses "tasks"). + * @param {number} count + * @returns {string} + */ +function pluralCompletedTaskWord(count: number): string { + return count === 1 ? 'task' : 'tasks' +} + +/** + * Title row for the done-task summary: plain "N completed task(s)", or "N other completed task(s)" after a wins block. + * @param {number} lineCount + * @param {'plain' | 'other'} variant + * @returns {string} + */ +function formatCompletedTasksSummaryHeading(lineCount: number, variant: 'plain' | 'other'): string { + const w = pluralCompletedTaskWord(lineCount) + if (variant === 'other') { + return `${lineCount} other completed ${w}` + } + return `${lineCount} completed ${w}` +} + +/** + * Summary card: optional carry-over plan tasks, then one completed-task list (wins first: #win / #bigwin / `>>`, then other dones; each line once) and (daily only) calendar events. * @param {string} periodType - * @param {string} periodString * @param {Array<{ content: string, isDone: boolean }>} carryOverPlanItems - * @param {Array} completedTasks + * @param {Array} winTasks + * @param {Array} completedTasks non-win completed tasks (daily only) * @param {Array} eventsForPeriod * @returns {string} HTML for section-wrap or '' */ @@ -369,12 +411,18 @@ function buildReviewSummarySectionHTML( periodType: string, carryOverPlanItems: Array<{ content: string, isDone: boolean }>, planningSectionTitle: string, + winTasks: Array, completedTasks: Array, eventsForPeriod: Array, ): string { const hasCarryOver = carryOverPlanItems.length > 0 const isDay = periodType === 'day' - if (!hasCarryOver && !isDay) { + const carryKeysOnly: Array<{ content: string }> = carryOverPlanItems.map((c) => ({ content: c.content })) + /** Full list: unique wins (not in carry) first, then unique non-wins — same order as mergeUniqueSummaryDoneTaskLines. */ + const mergedCompletedLines: Array = mergeUniqueSummaryDoneTaskLines(winTasks, completedTasks, carryKeysOnly) + /** Split by the same win rules as note scanning (not by merge prefix length — avoids runtime mismatch). */ + const { wins: mergedWinsLines, others: mergedOtherLines } = splitMergedSummaryDoneLinesIntoWinsAndOthers(mergedCompletedLines) + if (!hasCarryOver && !isDay && mergedCompletedLines.length === 0) { return '' } const parts: Array = [ @@ -382,22 +430,37 @@ function buildReviewSummarySectionHTML( ] const carryBlock = makeCarryOverPlanSummaryContentDiv(planningSectionTitle, carryOverPlanItems) parts.push(carryBlock) + + const pushDoneTasksSummaryBlocks = () => { + if (mergedCompletedLines.length === 0) { + parts.push(`
${formatCompletedTasksSummaryHeading(0, 'plain')}
`) + parts.push( + `
\n
No completed tasks found during the ${periodType}
\n
`, + ) + return + } + if (mergedWinsLines.length > 0 && mergedOtherLines.length > 0) { + parts.push( + `
\n${formatSummaryTaskItemsHTML(mergedWinsLines)}\n
`, + ) + parts.push(`
${formatCompletedTasksSummaryHeading(mergedOtherLines.length, 'other')}
`) + parts.push( + `
\n${formatSummaryTaskItemsHTML(mergedOtherLines)}\n
`, + ) + return + } + const singleBlockLines = mergedWinsLines.length > 0 ? mergedWinsLines : mergedOtherLines + parts.push(`
${formatCompletedTasksSummaryHeading(singleBlockLines.length, 'plain')}
`) + parts.push( + `
\n${formatSummaryTaskItemsHTML(singleBlockLines)}\n
`, + ) + } + if (isDay) { - const summaryItems = - completedTasks.length > 0 - ? completedTasks - .map( - (taskLine) => ` -
- - ${formatTaskAsHTML(taskLine)} -
`, - ) - .join('\n') - : `
No completed tasks found during the ${periodType}
` - parts.push(`
${completedTasks.length} completed tasks
`) - parts.push(`
\n${summaryItems}\n
`) + pushDoneTasksSummaryBlocks() parts.push(makePeriodDaysSummaryDiv(eventsForPeriod)) + } else if (mergedCompletedLines.length > 0) { + pushDoneTasksSummaryBlocks() } parts.push('') return parts.join('\n') @@ -484,7 +547,7 @@ function makeReviewQuestionRowDiv( break } case 'string': { - control = `` + control = `` break } case 'bullets': @@ -508,7 +571,8 @@ function makeReviewQuestionRowDiv( * @param {JournalConfigType} config * @param {Array} parsedQuestions same order as parseQuestions(rawQuestionLines) (field names q_0 …) * @param {Array} rawQuestionLines lines from getQuestionsForPeriod() - * @param {Array} summaryCompletedTasks + * @param {Array} summaryWinTasks done tasks tagged as wins (#win / #bigwin / `>>`); listed first in the single summary list + * @param {Array} summaryCompletedTasks other done tasks (daily only; excludes win lines) * @param {string} periodString the calendar note title string for the review period * @param {string} periodType * @param {Array} eventsForPeriod @@ -521,6 +585,7 @@ export function buildReviewHTML( config: JournalConfigType, parsedQuestions: Array, rawQuestionLines: Array, + summaryWinTasks: Array, summaryCompletedTasks: Array, periodString: string, periodType: string, @@ -546,6 +611,7 @@ export function buildReviewHTML( periodType, resolvedCarryOver, plannedSectionTitle, + summaryWinTasks, summaryCompletedTasks, eventsForPeriod, ) @@ -553,7 +619,12 @@ export function buildReviewHTML( return `
`) - return rows.join('\n') + return wrapSummaryDetailsBlock('summary-details-carry-over-plan', planningSectionTitle, rows.join('\n'), { + defaultOpen: true, + summaryExtraClasses: 'plan-title h3', + }) } /** @@ -437,26 +474,43 @@ function buildReviewSummarySectionHTML( const pushDoneTasksSummaryBlocks = () => { if (mergedCompletedLines.length === 0) { - parts.push(`
${formatCompletedTasksSummaryHeading(0, 'plain')}
`) parts.push( - `
\n
No completed tasks found during the ${periodType}
\n
`, + wrapSummaryDetailsBlock( + 'summary-details-completed-tasks', + formatCompletedTasksSummaryHeading(0, 'plain'), + `
\n
No completed tasks found during the ${periodType}
\n
`, + { defaultOpen: false }, + ), ) return } if (mergedWinsLines.length > 0 && mergedOtherLines.length > 0) { parts.push( - `
\n${formatSummaryTaskItemsHTML(mergedWinsLines)}\n
`, + wrapSummaryDetailsBlock( + 'summary-details-completed-wins', + formatWinsSummaryHeading(mergedWinsLines.length), + `
\n${formatSummaryTaskItemsHTML(mergedWinsLines)}\n
`, + { defaultOpen: false }, + ), ) - parts.push(`
${formatCompletedTasksSummaryHeading(mergedOtherLines.length, 'other')}
`) parts.push( - `
\n${formatSummaryTaskItemsHTML(mergedOtherLines)}\n
`, + wrapSummaryDetailsBlock( + 'summary-details-completed-other', + formatCompletedTasksSummaryHeading(mergedOtherLines.length, 'other'), + `
\n${formatSummaryTaskItemsHTML(mergedOtherLines)}\n
`, + { defaultOpen: false }, + ), ) return } const singleBlockLines = mergedWinsLines.length > 0 ? mergedWinsLines : mergedOtherLines - parts.push(`
${formatCompletedTasksSummaryHeading(singleBlockLines.length, 'plain')}
`) parts.push( - `
\n${formatSummaryTaskItemsHTML(singleBlockLines)}\n
`, + wrapSummaryDetailsBlock( + 'summary-details-completed-tasks', + formatCompletedTasksSummaryHeading(singleBlockLines.length, 'plain'), + `
\n${formatSummaryTaskItemsHTML(singleBlockLines)}\n
`, + { defaultOpen: false }, + ), ) } From d36b8b9410c3b6b42c8da5dcb2d31b45aedd4a5b Mon Sep 17 00:00:00 2001 From: jgclark Date: Mon, 20 Apr 2026 17:01:51 +0100 Subject: [PATCH 12/19] Polish summary layout. Fix refresh button. --- .../requiredFiles/reviews.css | 8 +++- jgclark.PeriodicReviews/src/periodReviews.js | 3 +- .../src/reviewHTMLViewGenerator.js | 38 +++++++++++++++---- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/jgclark.PeriodicReviews/requiredFiles/reviews.css b/jgclark.PeriodicReviews/requiredFiles/reviews.css index b36b0b1d8..6d74bf4f6 100644 --- a/jgclark.PeriodicReviews/requiredFiles/reviews.css +++ b/jgclark.PeriodicReviews/requiredFiles/reviews.css @@ -129,19 +129,23 @@ body { margin-bottom: 0.15rem; } +.summary-item-icon { + margin-right: 0.3rem; +} + .summary-item-incomplete-icon { color: var(--item-icon-color); line-height: 1.2; flex-shrink: 0; } -.summary-item-completed-icon { +.item-completed-icon { color: var(--fg-done-color); line-height: 1.2; flex-shrink: 0; } -.summary-item-event-icon { +.event-icon { color: var(--fg-placeholder-color); line-height: 1.2; flex-shrink: 0; diff --git a/jgclark.PeriodicReviews/src/periodReviews.js b/jgclark.PeriodicReviews/src/periodReviews.js index 2b9f91edd..278d96c55 100644 --- a/jgclark.PeriodicReviews/src/periodReviews.js +++ b/jgclark.PeriodicReviews/src/periodReviews.js @@ -30,12 +30,13 @@ import { } from './reviewQuestions' import { stylesheetinksInHeader, faLinksInHeader, buildReviewHTML } from './reviewHTMLViewGenerator' import { - RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, + convertISOToYYYYMMDD, getNextNPPeriodString, getNPQuarterStr, getPreviousNPPeriodString, getWeek, getPeriodOfNPDateStr, + RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE, } from '@helpers/dateTime' import { clo, logDebug, logError, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' diff --git a/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js b/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js index aa618d8da..d1d603830 100644 --- a/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js +++ b/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js @@ -340,7 +340,7 @@ function makePeriodDaysSummaryDiv(eventsForPeriod: Array): string output.push(`
`) eventsForPeriod.forEach((e) => { output.push(`\t
`) - output.push(`\t\t`) + output.push(`\t\t`) output.push(`\t\t${e.title}`) output.push('\t
') }) @@ -348,6 +348,20 @@ function makePeriodDaysSummaryDiv(eventsForPeriod: Array): string return wrapSummaryDetailsBlock('summary-details-events', title, output.join('\n'), { defaultOpen: false }) } +/** + * Placeholder for the calendar-events block while the WebView loads events via `Calendar.*`. + * Client script replaces this with markup matching {@link makePeriodDaysSummaryDiv} (or removes it when there are no events). + * @returns {string} + */ +function makeCalendarEventsSummaryMountHTML(): string { + return `
+ Calendar events +
+
Loading calendar…
+
+
` +} + /** * Duration of a calendar item in hours (all-day => 0; uses `date` and `endDate` like EventHelpers). * @param {TCalendarItem} event @@ -378,13 +392,13 @@ function makeCarryOverPlanSummaryContentDiv( if (item.isDone) { rows.push(`
- + ${formatTaskAsHTML(item.content)}
`) } else { rows.push(`
- + ${formatTaskAsHTML(item.content)}
`) } @@ -409,7 +423,7 @@ function formatSummaryTaskItemsHTML(taskLines: Array): string { .map( (taskLine) => `
- + ${formatTaskAsHTML(taskLine)}
`, ) @@ -445,7 +459,7 @@ function formatCompletedTasksSummaryHeading(lineCount: number, variant: 'plain' * @param {Array<{ content: string, isDone: boolean }>} carryOverPlanItems * @param {Array} winTasks * @param {Array} completedTasks non-win completed tasks (daily only) - * @param {Array} eventsForPeriod + * @param {Array} eventsForPeriod server-built events when not using client summary * @returns {string} HTML for section-wrap or '' */ function buildReviewSummarySectionHTML( @@ -454,7 +468,7 @@ function buildReviewSummarySectionHTML( planningSectionTitle: string, winTasks: Array, completedTasks: Array, - eventsForPeriod: Array, + eventsForPeriod: Array ): string { const hasCarryOver = carryOverPlanItems.length > 0 const isDay = periodType === 'day' @@ -637,6 +651,9 @@ function makeReviewQuestionRowDiv( * @param {string} callbackCommandName * @param {{ [string]: string }=} initialAnswers field names q_0 … to pre-fill from the calendar note * @param {{ carryOverPlanItems?: Array<{ content: string, isDone: boolean }>, planningSectionTitle?: string }=} reviewExtras carry-over plan tasks + planning block title + * @param {Array=} calendarSetForClientSummary calendars to include (empty = all), for WebView `Calendar.*` path + * @param {string=} reviewDayYyyymmdd YYYYMMDD from `convertISOToYYYYMMDD(periodString)` when period is daily + * @param {boolean=} overrideExperimentalClientCalendar override module flag: false forces server-rendered events; true forces client mount when daily + review day set * @returns {string} */ export function buildReviewHTML( @@ -652,10 +669,15 @@ export function buildReviewHTML( planName: string, initialAnswers?: { [string]: string }, carryOverPlanItems?: Array<{ content: string, isDone: boolean }>, + calendarSetForClientSummary?: Array, + reviewDayYyyymmdd?: string, + overrideExperimentalClientCalendar?: ?boolean, ): string { const periodAdjective = getPeriodAdjectiveFromType(periodType) const resolvedInitialAnswers = initialAnswers ?? {} const resolvedCarryOver = carryOverPlanItems ?? [] + const calendarSetResolved: Array = calendarSetForClientSummary ?? [] + const reviewDayResolved: string = reviewDayYyyymmdd ?? '' const plannedSectionTitle = buildThisPlanSectionHeadingTitle(planName) const planningSectionTitle = buildNextPlanSectionHeadingTitle(planName, periodType) const renderQuestionLines = rawQuestionLines.map((l) => substituteReviewPeriodPlaceholders(l, periodString, periodType)) @@ -671,7 +693,7 @@ export function buildReviewHTML( plannedSectionTitle, summaryWinTasks, summaryCompletedTasks, - eventsForPeriod, + eventsForPeriod ) const planningSectionHtml = planningSectionTitle !== '' ? makePlanningSectionHTML(planningSectionTitle) : '' @@ -705,7 +727,7 @@ export function buildReviewHTML(
-
-

${escapeHTML(periodAdjective)} Review for ${escapeHTML(periodString)}

+
+ ${escapeHTML(periodAdjective)} Review for + + ${escapeHTML(periodString)} + +
@@ -627,6 +698,8 @@ export function buildReviewHTML( const cancelButton = document.getElementById('review-cancel') const submitButton = document.getElementById('review-submit') const refreshBtn = document.getElementById('review-refresh') + const periodPrevBtn = document.getElementById('review-period-prev') + const periodNextBtn = document.getElementById('review-period-next') /** * Collect answers from the review form and return as a JSON string. @@ -673,6 +746,15 @@ export function buildReviewHTML( }]) } + function navigatePeriod(direction) { + console.log("HTMLView: navigatePeriod(" + direction + ") called") + sendToPlugin('${callbackCommandName}', '${pluginJson['plugin.id']}', ['navigatePeriod', { + periodType: ${JSON.stringify(periodType)}, + periodString: ${JSON.stringify(periodString)}, + direction: direction, + }]) + } + const firstInputControl = reviewForm.querySelector('textarea, input:not([type="hidden"]), select') if (firstInputControl && typeof firstInputControl.focus === 'function') { firstInputControl.focus() @@ -693,6 +775,16 @@ export function buildReviewHTML( refreshReview() }) } + if (periodPrevBtn) { + periodPrevBtn.addEventListener('click', function () { + navigatePeriod('prev') + }) + } + if (periodNextBtn) { + periodNextBtn.addEventListener('click', function () { + navigatePeriod('next') + }) + } ` } diff --git a/jgclark.Journalling/src/reviewQuestions.js b/jgclark.Journalling/src/reviewQuestions.js new file mode 100644 index 000000000..f6c067475 --- /dev/null +++ b/jgclark.Journalling/src/reviewQuestions.js @@ -0,0 +1,466 @@ +// @flow +//--------------------------------------------------------------- +// Review question parsing, pre-fill extraction, and answer → note text. +// Jonathan Clark +// last update 2026-04-05 for v2.0.0.b7 by @jgclark + @Cursor +//--------------------------------------------------------------- + +import type { ParsedQuestionType } from './journalHelpers' +import { substituteReviewPeriodPlaceholders } from './journalHelpers' +import { escapeRegExp } from '@helpers/regex' +import { isInt } from '@helpers/userInput' + +/** `` names in review question templates — single source for parse + HTML segment splitting. */ +export const REVIEW_QUESTION_TYPE_NAMES_ALT = + 'string|int|number|duration|boolean|mood|subheading|h2|h3|bullets|checklists|tasks' + +/** + * TODO: pull these two to be just a constant. + * RegExp matching one typed segment (e.g. `Sleep: hours`) — same pattern as `parseQuestions` uses. + * Callers should use a fresh instance per scan or reset `lastIndex` to avoid `/g` state bugs. + * @returns {RegExp} global, case-insensitive + */ +export function getReviewQuestionSegmentRegExpGi(): RegExp { + return new RegExp(`[^<]*?<\\s*(?:${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>\\)?[^\\s]*`, 'gi') +} + +/** + * RegExp matching the type tag within a segment (first capture = type name). + * @returns {RegExp} + */ +export function getReviewQuestionTypeTagRegExp(): RegExp { + return new RegExp(`<\\s*(${REVIEW_QUESTION_TYPE_NAMES_ALT})\\s*>`, 'i') +} + +const RE_DURATION_HHMM = /^(\d{1,2}):([0-5]\d)$/ + +/** + * Parse question lines to extract questions and their types. + * Supports multiple questions per line separated by '||'. + * @tests in __tests__/periodReviews.test.js + * @param {Array | string} questionLines raw question lines from config + * @returns {Array} + */ +export function parseQuestions(questionLines: Array | string): Array { + const parsed = [] + const typeRE = getReviewQuestionTypeTagRegExp() + const segmentRE = getReviewQuestionSegmentRegExpGi() + const linesToProcess = Array.isArray(questionLines) ? questionLines : String(questionLines ?? '').split('\n') + + for (let lineIndex = 0; lineIndex < linesToProcess.length; lineIndex++) { + const line = linesToProcess[lineIndex] + const mTypedH2 = line.match(/^\s*<\s*h2\s*>\s*(.+)$/i) + if (mTypedH2) { + parsed.push({ question: String(mTypedH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) + continue + } + const mTypedH3 = line.match(/^\s*<\s*h3\s*>\s*(.+)$/i) + if (mTypedH3) { + parsed.push({ question: String(mTypedH3[1] ?? '').trim(), type: 'h3', originalLine: line, lineIndex }) + continue + } + const mH2 = line.match(/^##\s+(.+)$/) + if (mH2) { + parsed.push({ question: String(mH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) + continue + } + const mH3 = line.match(/^###\s+(.+)$/) + if (mH3) { + parsed.push({ question: String(mH3[1] ?? '').trim(), type: 'h3', originalLine: line, lineIndex }) + continue + } + const mTrailingHeadingDecorator = line.match(/^(.*?)\s*<\s*(h2|h3)\s*>\s*$/i) + if (mTrailingHeadingDecorator) { + const base = String(mTrailingHeadingDecorator[1] ?? '').trim() + if (base !== '') { + parsed.push({ question: base, type: 'string', originalLine: `${base}: `, lineIndex }) + continue + } + } + const questionParts = line.split(/\s*\|\|\s*/).map(part => part.trim()).filter(part => part !== '') + + for (const questionPart of questionParts) { + const segments = questionPart.match(segmentRE) ?? [] + for (const segmentRaw of segments) { + const segment = segmentRaw.trim() + const reArray = segment.match(typeRE) + const questionType = reArray?.[1] ?? '' + const tokenMatch = segment.match(/([@#][^\s(<]+)/) + const question = tokenMatch?.[1] + ?? segment + .replace( + /:|\(|\)||||||||

|

|||/gi, + '', + ) + .trim() + parsed.push({ question, type: questionType, originalLine: segment, lineIndex }) + } + } + } + + return parsed +} + +/** + * Handle an HTML heading question type. + * @param {string} question the question text + * @param {string} headingType 'h2' | 'h3' | 'subheading' (legacy; subheading uses same marker as h3) + * @returns {string} the formatted markdown heading block + */ +function handleHeadingQuestion(question: string, headingType: string): string { + const cleanHeading = question.replace(/<(?:subheading|h2|h3)>\s*$/i, '').trim() + const headingMarker = headingType === 'h2' ? '##' : '###' + return `\n${headingMarker} ${cleanHeading}` +} + +/** Output prefix per line for multiline journal types (``, ``, ``). */ +const MULTILINE_ANSWER_PREFIX_BY_TYPE: { [string]: string } = { + bullets: '- ', + checklists: '+ ', + tasks: '* ', +} + +/** + * Markdown prefix written before each answer line for a multiline question type. + * @param {string} questionType + * @returns {string} + */ +function linePrefixForMultilineAnswerType(questionType: string): string { + return MULTILINE_ANSWER_PREFIX_BY_TYPE[questionType.toLowerCase()] ?? '' +} + +/** + * Remove leading line markers from saved note text so the review textarea shows plain lines. + * @param {string} rawBlock + * @param {string} linePrefix e.g. '- ' + * @returns {string} + */ +function stripMultilineAnswerPrefixes(rawBlock: string, linePrefix: string): string { + if (linePrefix === '') { + return rawBlock.trim() + } + return rawBlock + .split(/\r?\n/) + .map((l) => { + const trimmed = l.trim() + if (trimmed.startsWith(linePrefix)) { + return trimmed.slice(linePrefix.length).trim() + } + return trimmed + }) + .join('\n') + .trim() +} + +/** + * Split a question segment at the typed marker (same idea as reviewHTMLViewGenerator.splitSegmentAtTypeMarker). + * @param {string} segment + * @param {string} questionType + * @returns {{ prefix: string, suffix: string }} + */ +function splitParsedSegmentAtTypeMarker(segment: string, questionType: string): {| prefix: string, suffix: string |} { + const safeType = escapeRegExp(questionType) + const re = new RegExp(`<\\s*${safeType}\\s*>`, 'i') + const m = segment.match(re) + if (!m || m.index == null) { + return { prefix: segment, suffix: '' } + } + const idx = m.index + const tagLen = m[0].length + return { prefix: segment.slice(0, idx), suffix: segment.slice(idx + tagLen) } +} + +/** + * Parse one line of note content for a single parsed question's answer (form-ready value). Match for question in case-insensitive way. + * @param {ParsedQuestionType} parsedQuestion + * @param {string} line + * @returns {string} value for the HTML control, or '' if not found on this line + */ +function extractExistingAnswerOnLine(parsedQuestion: ParsedQuestionType, line: string): string { + const t = parsedQuestion.type.toLowerCase() + const seg = parsedQuestion.originalLine.trim() + const token = parsedQuestion.question + if (t === 'subheading' || t === 'h2' || t === 'h3') { + return '' + } + if (t === 'boolean') { + const token = parsedQuestion.question + if (!token) { + return '' + } + const re = new RegExp(`(?:^|\\s)${escapeRegExp(token)}(?=\\s|$)`) + return re.test(line) ? 'yes' : '' + } + const { prefix, suffix } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) + if (t === 'int') { + if (token && token.startsWith('@')) { + const tokenIntRE = new RegExp(`${escapeRegExp(token)}\\s*\\(\\s*(\\d+)\\s*\\)`, 'i') + const tokenIntMatch = line.match(tokenIntRE) + if (tokenIntMatch?.[1] != null) { + return tokenIntMatch[1] + } + } + const re = new RegExp(`${escapeRegExp(prefix)}(\\d+)${escapeRegExp(suffix)}`) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } + if (t === 'number') { + if (token && token.startsWith('@')) { + const tokenNumberRE = new RegExp( + `${escapeRegExp(token)}\\s*\\(\\s*([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)\\s*\\)`, + 'i', + ) + const tokenNumberMatch = line.match(tokenNumberRE) + if (tokenNumberMatch?.[1] != null) { + return tokenNumberMatch[1] + } + } + const re = new RegExp( + `${escapeRegExp(prefix)}([-+]?\\d*\\.?\\d+(?:[eE][-+]?\\d+)?)${escapeRegExp(suffix)}`, + ) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } + if (t === 'duration') { + if (token && token.startsWith('@')) { + const tokenDurationRE = new RegExp(`${escapeRegExp(token)}\\s*\\(\\s*(\\d{1,2}:[0-5]\\d)\\s*\\)`, 'i') + const tokenDurationMatch = line.match(tokenDurationRE) + if (tokenDurationMatch?.[1] != null) { + return tokenDurationMatch[1] + } + } + const re = new RegExp(`${escapeRegExp(prefix)}(\\d{1,2}:[0-5]\\d)${escapeRegExp(suffix)}`) + const m = line.match(re) + return m?.[1] != null ? m[1] : '' + } + if (t === 'bullets' || t === 'checklists' || t === 'tasks') { + const marker = linePrefixForMultilineAnswerType(t) + const { prefix: p2, suffix: s2 } = splitParsedSegmentAtTypeMarker(seg, parsedQuestion.type) + if (s2 === '') { + if (p2 === '') { + const trimmed = line.trim() + if (!trimmed.startsWith(marker.trim())) { + return '' + } + return stripMultilineAnswerPrefixes(trimmed, marker) + } + const idx = line.toLowerCase().indexOf(p2.toLowerCase()) + if (idx < 0) { + return '' + } + const raw = line.slice(idx + p2.length).trim() + if (raw === '') { + return '' + } + return stripMultilineAnswerPrefixes(raw, marker) + } + const re = new RegExp(`${escapeRegExp(p2)}([\\s\\S]*?)${escapeRegExp(s2)}`, 'i') + const m = line.match(re) + if (m?.[1] == null) { + return '' + } + return stripMultilineAnswerPrefixes(m[1].trim(), marker) + } + if (t === 'mood' || t === 'string') { + if (suffix === '') { + if (prefix === '') { + return '' + } + const idx = line.toLowerCase().indexOf(prefix.toLowerCase()) + if (idx < 0) { + return '' + } + return line.slice(idx + prefix.length).trim() + } + const re = new RegExp(`${escapeRegExp(prefix)}(.*?)${escapeRegExp(suffix)}`, 'i') + const m = line.match(re) + return m?.[1] != null ? m[1].trim() : '' + } + return '' +} + +/** + * Get first matching answer in the note for question + * @param {ParsedQuestionType} parsedQuestion + * @param {Array} textLines + * @returns {string} + */ +function extractExistingAnswerForReviewForm(parsedQuestion: ParsedQuestionType, textLines: Array): string { + for (let i = 0; i <= textLines.length - 1; i++) { + const line = textLines[i] + const v = extractExistingAnswerOnLine(parsedQuestion, line) + if (v !== '') { + return v + } + } + return '' +} + +/** + * Map field names q_0, q_1, … to existing answers in the calendar note for pre-filling the review HTML form. + * @tests in __tests__/periodReviews.test.js + * @param {Array} parsedQuestions + * @param {Array} textLines text of lines to scan + * @returns {{ [string]: string }} + */ +export function buildInitialReviewAnswersByFieldName( + parsedQuestions: Array, + textLines: Array, +): { [string]: string } { + const out: { [string]: string } = {} + for (let i = 0; i < parsedQuestions.length; i++) { + const pq = parsedQuestions[i] + const v = extractExistingAnswerForReviewForm(pq, textLines) + if (v !== '') { + out[`q_${i}`] = v + } + } + return out +} + +/** + * Convert parsed questions into line-indexed groups. + * @param {Array} parsedQuestions + * @returns {{ [number]: Array }} + */ +function groupQuestionsByLine(parsedQuestions: Array): { [number]: Array } { + const questionsByLine: { [number]: Array } = {} + for (let i = 0; i < parsedQuestions.length; i++) { + const q = parsedQuestions[i] + if (!questionsByLine[q.lineIndex]) { + questionsByLine[q.lineIndex] = [] + } + questionsByLine[q.lineIndex].push(q) + } + return questionsByLine +} + +/** + * Convert answer payload from single window into output line for one parsed question. + * @param {ParsedQuestionType} parsedQuestion + * @param {string | boolean} answerRaw + * @returns {string} + */ +function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answerRaw: string | boolean): string { + const t = parsedQuestion.type + if (t === 'boolean') { + const on = answerRaw === true || answerRaw === 'yes' + return on ? parsedQuestion.question : '' + } + const answer = (typeof answerRaw === 'string' ? answerRaw : String(answerRaw ?? '')).trim() + if (answer === '' && t !== 'subheading' && t !== 'h2' && t !== 'h3') { + return '' + } + switch (t) { + case 'int': { + if (isInt(answer)) { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + return '' + } + case 'number': { + if (answer != null && Number(answer)) { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + return '' + } + case 'duration': { + if (RE_DURATION_HHMM.test(answer)) { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + return '' + } + case 'string': { + return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + } + case 'mood': { + return parsedQuestion.originalLine.replace(//, answer) + } + case 'bullets': + case 'checklists': + case 'tasks': { + const marker = linePrefixForMultilineAnswerType(t) + const lines = answer.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '') + if (lines.length === 0) { + return '' + } + const formatted = lines.map((l) => `${marker}${l}`).join('\n') + const ol = parsedQuestion.originalLine + if (ol.trimStart().startsWith('-')) { + return formatted + } + const { prefix, suffix } = splitParsedSegmentAtTypeMarker(ol, t) + const prefixTrimmed = prefix.trimEnd() + if (prefixTrimmed === '') { + return `${formatted}${suffix}` + } + return `${prefixTrimmed}\n${formatted}${suffix}` + } + case 'subheading': { + return handleHeadingQuestion(parsedQuestion.question, 'subheading') + } + case 'h2': { + return handleHeadingQuestion(parsedQuestion.question, 'h2') + } + case 'h3': { + return handleHeadingQuestion(parsedQuestion.question, 'h3') + } + default: { + return '' + } + } +} + +/** + * Build output from answers returned by single-window mode. + * @tests in __tests__/periodReviews.test.js + * @param {Array} parsedQuestions + * @param {Array} rawQuestionLines + * @param {string} periodString + * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @param {{ [string]: string | boolean }} answersByIndex + * @returns {string} + */ +export function buildOutputFromReviewWindowAnswers( + parsedQuestions: Array, + rawQuestionLines: Array, + periodString: string, + periodType: string, + answersByIndex: { [string]: string | boolean }, +): string { + let output = '' + const questionsByLine = groupQuestionsByLine(parsedQuestions) + const lineCount = rawQuestionLines.length + const stripPresentationDelimiters = (input: string): string => input.replace(/ \|\| /g, ' ') + + for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { + const lineQuestions = questionsByLine[lineIndex] ?? [] + const lineAnswers: Array = [] + for (let i = 0; i < lineQuestions.length; i++) { + const globalIndex = parsedQuestions.findIndex((q) => q === lineQuestions[i]) + const parsedQuestion = lineQuestions[i] + const answer = answerFromReviewWindowPayload(parsedQuestion, answersByIndex[`q_${globalIndex}`] ?? '') + if (answer !== '') { + lineAnswers.push(answer) + } + } + if (lineAnswers.length > 0) { + const hasMultiline = lineAnswers.some((a) => a.includes('\n')) + let combinedLine = hasMultiline ? lineAnswers.join('\n') : lineAnswers.join(' ') + if (!hasMultiline) { + combinedLine = combinedLine.replace(/\s+/g, ' ') + } + output += `${substituteReviewPeriodPlaceholders(combinedLine, periodString, periodType)}\n` + continue + } + + const rawLine = rawQuestionLines[lineIndex] ?? '' + if (/<\s*date\s*>/i.test(rawLine) || /<\s*(?:datenext|nextdate)\s*>/i.test(rawLine)) { + const substituted = substituteReviewPeriodPlaceholders(stripPresentationDelimiters(rawLine), periodString, periodType).trim() + if (substituted !== '') { + output += `${substituted}\n` + } + } + } + return output +} diff --git a/jgclark.Journalling/src/templatesStartEnd.js b/jgclark.Journalling/src/templatesStartEnd.js index e50d0093c..05cd329d2 100644 --- a/jgclark.Journalling/src/templatesStartEnd.js +++ b/jgclark.Journalling/src/templatesStartEnd.js @@ -2,12 +2,12 @@ //--------------------------------------------------------------- // Add Templates to Periodic notes at start and end of period // Jonathan Clark -// last update 2025-11-01 for v1.15.2 by @jgclark +// last update 2026-04-05 for v0.1.0 by @jgclark //--------------------------------------------------------------- import { type JournalConfigType, getJournalSettings } from './journalHelpers' import { isDailyNote, isMonthlyNote, isWeeklyNote } from '@helpers/dateTime' -import { logDebug, logError, logInfo } from '@helpers/dev' +import { logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' import { getAttributes } from '@helpers/NPFrontMatter' import { showMessage } from '@helpers/userInput' @@ -74,36 +74,48 @@ async function renderAndInsertTemplate( templateTitle: string, commandName: string, ): Promise { - // Render the template, using recommended decoupled method of invoking a different plugin - const result = await DataStore.invokePluginCommandByName('renderTemplate', 'np.Templating', [templateTitle]) - // TEST: turning off error message for now, as it fires on Templates that only do background work. - // if (result == null || result === '') { - // throw new Error(`No result from running Template '${templateTitle}'. Stopping.`) - // } - - // Work out where to insert it in the note, by reading the template, and checking - // the frontmatter attributes for a 'location' field (append/insert/cursor) - const attrs = getAttributes(templateData, true) - const requestedTemplateLocation = attrs.location ?? 'insert' - let pos = 0 - switch (requestedTemplateLocation) { - case 'insert': { - logDebug(commandName, `- Will insert to start of Editor`) - Editor.insertTextAtCharacterIndex(result, 0) - break + try { + if (!templateData || templateData === '') { + logWarn('renderAndInsertTemplate', `templateData is null or empty. Stopping.`) + return } - case 'append': { - pos = Editor.content?.length ?? 0 // end - logDebug(commandName, `- Will insert to end of Editor (pos ${pos})`) - Editor.insertTextAtCharacterIndex(result, pos) - break + // Render the template, using recommended decoupled method of invoking a different plugin + const resultingTextContent = (await DataStore.invokePluginCommandByName('renderTemplate', 'np.Templating', [templateTitle])).trim() + // If no resulting text, or just a newline, then stop early, to try to avoid race conditions for tag-only templates. + if (resultingTextContent == null || resultingTextContent === '' || resultingTextContent === '\n') { + logDebug('renderAndInsertTemplate', `No resulting text from running Template '${templateTitle}'. Stopping.`) + return + } else { + logDebug('renderAndInsertTemplate', `Successfully rendered Template '${templateTitle}' -> ${resultingTextContent.length} characters.`) } - // Note: unsure if this works. - case 'cursor': { - logDebug(commandName, `- Will insert to Editor at cursor position`) - Editor.insertTextAtCursor(result) - break + + // Work out where to insert it in the note, by reading the template, and checking + // the frontmatter attributes for a 'location' field (append/insert/cursor) + const attrs = getAttributes(templateData, true) + const requestedTemplateLocation = attrs.location ?? 'insert' + let pos = 0 + switch (requestedTemplateLocation) { + case 'insert': { + logDebug(commandName, `- Will insert to start of Editor`) + Editor.insertTextAtCharacterIndex(resultingTextContent, 0) + break + } + case 'append': { + pos = Editor.content?.length ?? 0 // end + logDebug(commandName, `- Will insert to end of Editor (pos ${pos})`) + Editor.insertTextAtCharacterIndex(resultingTextContent, pos) + break + } + // Note: unsure if this works. + case 'cursor': { + logDebug(commandName, `- Will insert to Editor at cursor position`) + Editor.insertTextAtCursor(resultingTextContent) + break + } } + } catch (error) { + logError('renderAndInsertTemplate', error.message) + await showMessage(`Error: ${error.message}`) } } From 4b26e20f501a9fa83320c03f363c045217e3ecdb Mon Sep 17 00:00:00 2001 From: jgclark Date: Sun, 12 Apr 2026 17:33:45 +0100 Subject: [PATCH 10/19] b9 change name to "Periodic Reviews" --- jgclark.Journalling/README.md | 160 ------------ .../CHANGELOG.md | 16 +- jgclark.PeriodicReviews/README.md | 165 ++++++++++++ .../__tests__/periodReviews.test.js | 57 +++-- .../calendar-settings@2x.png | Bin .../daily-review-2.0.0.b7@2x.png | Bin 0 -> 181054 bytes .../plugin.json | 103 ++++---- .../requiredFiles/reviews.css | 2 +- .../review-window-questions@2x.png | Bin .../settings-button@2x.png | Bin .../src/index.js | 14 +- .../src/periodReviews.js | 238 ++++++++++-------- .../src/periodicReviewHelpers.js | 20 +- .../src/reviewHTMLViewGenerator.js | 58 +++-- .../src/reviewQuestions.js | 67 +++-- .../src/templatesStartEnd.js | 2 +- 16 files changed, 480 insertions(+), 422 deletions(-) delete mode 100644 jgclark.Journalling/README.md rename {jgclark.Journalling => jgclark.PeriodicReviews}/CHANGELOG.md (88%) create mode 100644 jgclark.PeriodicReviews/README.md rename {jgclark.Journalling => jgclark.PeriodicReviews}/__tests__/periodReviews.test.js (93%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/calendar-settings@2x.png (100%) create mode 100644 jgclark.PeriodicReviews/daily-review-2.0.0.b7@2x.png rename {jgclark.Journalling => jgclark.PeriodicReviews}/plugin.json (85%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/requiredFiles/reviews.css (99%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/review-window-questions@2x.png (100%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/settings-button@2x.png (100%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/src/index.js (96%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/src/periodReviews.js (78%) rename jgclark.Journalling/src/journalHelpers.js => jgclark.PeriodicReviews/src/periodicReviewHelpers.js (94%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/src/reviewHTMLViewGenerator.js (95%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/src/reviewQuestions.js (89%) rename {jgclark.Journalling => jgclark.PeriodicReviews}/src/templatesStartEnd.js (99%) diff --git a/jgclark.Journalling/README.md b/jgclark.Journalling/README.md deleted file mode 100644 index 7840df135..000000000 --- a/jgclark.Journalling/README.md +++ /dev/null @@ -1,160 +0,0 @@ -# 💭 Journalling & Reviews Plugin - -This plugin makes it easier for you to journal and/or review your days, weeks, months, quarters, and years in NotePlan. It also speeds up applying templates at the start or end of each period. - -**Requirements:** NotePlan **3.20** or later (integrated HTML plugin windows). This plugin replaces the older **Journalling Helpers** plugin for current NotePlan versions; on first install, settings will be migrated from that plugin automatically. - -### Configuration - -To use weekly, monthly, quarterly, or yearly notes, turn them on in **NotePlan Settings** → Calendar: - - - - -All commands expect some **configuration** first. On Mac, open the **💭 Journalling & Reviews** card in Plugin Preferences, then use the gear button to edit settings. - -## Quickly applying templates at the start or end of a period - -The NotePlan site has good [articles on Templates](https://help.noteplan.co/article/136-templates) and a [Template Gallery](https://noteplan.co/templates). - -For template tag commands (events, quote-of-the-day, weather, etc.), see the [Templating documentation](https://noteplan.co/templates/docs). - -### `/dayStart` and `/todayStart` - -These apply your configured start-of-day template from the `Templates` folder. (Since about NotePlan 3.17, [auto-insert templates](https://help.noteplan.co/article/229-auto-insert-templates) into new calendar notes reduce the need for manual runs.) - -- **`/todayStart`**: applies your **Start-of-Day** template only to **today's** calendar note, regardless of which note is open. -- **`/dayStart`**: appends that template to the **currently open daily note** (or today’s note if you are not in a daily note). Be careful on **other** dates: tags like `<%- date… %>` or `<%- weather() %>` still resolve to **today**. - -### `/weekStart`, `/monthStart`, and end-of-period commands - -**`/weekStart`** and **`/monthStart`** behave like **`/dayStart`** for weekly and monthly notes. - -**`/dayEnd`**, **`/todayEnd`**, **`/weekEnd`**, **`/monthEnd`** mirror the “start” commands but use your **end-of-period** templates—useful for habit or stats summaries from [**Habits & Summaries**](https://noteplan.co/plugins/jgclark.Summaries) or cleanup via [**Tidy Up**](https://noteplan.co/plugins/np.Tidy). - ---- - -## Periodic reviews (v2) - -There is no single “right” review format. What matters is pausing to answer questions about what went well, what did not, goals, gratitude, mood, and so on. - -### Templates you fill in by hand - -You can use an end-of-day or end-of-week template (for example the [Mental Health Journal](https://noteplan.co/templates/mental-health-journal-template)) via **`/dayEnd`** or **`/weekEnd`**, then type answers in the note. - -### Interactive review window (v2 betas) - -Run **Daily Review**, **Weekly Review**, **Monthly Review**, **Quarterly Review**, or **Yearly Review** from the Command Bar (or pinned sidebar where offered). Daily Review aliases include `dr`, `journal`, and `review`. - -These commands open a **single** themed HTML window that shows **all** questions at once (colours and fonts follow your current NotePlan theme). When you submit, answers are written under the correct section heading in the calendar note. - -**New or changed in v2:** - -- **Window placement:** **Review Window type** — *New Window*, *Main Window*, or *Split View* in the main window. -- **Open the calendar note when reviewing it?** (default on) — skips the old “open this note?” prompt when appropriate. -- **Question strings** no longer use `||` delimiters; use line breaks (and optional `\n`) instead. -- **Prefill:** If matching answers already exist in the note (under your review/journal heading when present, otherwise the whole note), they appear in the form. The latest matching block wins. -- **Summary block** at the top of the window: - - **Daily:** completed tasks from notes touched that day (Dashboard-style), plus **calendar events** (counts and timed duration respect **EventHelpers**-style settings where applicable). - - **Week / month / quarter / year:** done items in that period tagged `#win` or `#bigwin`, shown as multi-column checklist-style rows. Task text is rendered like note HTML export (hashtags, mentions, links, etc.); `@done(…)` stamps are omitted for readability. -- **Calendars to include in review summaries:** optional filter list; leave empty to include all calendars. -- **Correct note for the period:** the open editor is only reused when it is the **same period type and title** as the command (e.g. today for a daily review). Otherwise the plugin opens the intended calendar note. Quarterly notes use NotePlan’s title format **`YYYY-Qn`** (e.g. `2026-Q1`). -- **Planning vs reviewing:** For each period you can set a **planned items** name (defaults such as *Big Wins*, *Big Rocks*, *Key Outcomes*, *Goals*, *Theme*). After the main form, a **planning** area can write an **H2** and `>> …` tasks at the **start** of the **next** period’s calendar note, replacing any existing section with that title. Empty planning clears that section on the next note. The heading uses `{planName} for {next period title}` (e.g. `Big Rocks for 2026-W15`), separate from the on-screen “Planned:” / “Planning: …” labels. - -### Section headings (split in v2) - -- **Daily Journal Section Heading** — where **daily** review/journal answers are appended (default: `Journal`). -- **Review Section Heading** — where **weekly / monthly / quarterly / yearly** answers go (default: `Review`). - -If the heading does not already exist in a note, the content is added at the end of the note. - -### Question types and layout codes - -These define input controls and how lines are written to the note: - -- `` — ticked/unticked; if ticked, the surrounding text is included in the output -- `` — the same as `` above -- `` — whole number -- `` — number, may include a decimal part -- `` — `[H]H:MM` (e.g. `1:05`, `12:30`) -- `` — single-line text -- `` — multi-line; each non-empty line becomes a markdown bullet (`- `) -- `` — same, with checklist markers (`+ `) -- `` — same, with task markers (`* `) -- `` — pick from your configured mood list - -You can include Headings amongst the questions: - -- `

` / `

` (legacy: ``) — output as headings in the note/HTML. -- Literal `##` / `###` lines in settings are also supported. - -Placeholders - -- `` — current review period’s calendar title in the window and in saved output (e.g. `2026-03-28`, `2026-W13`, `2026-Q1`). Substituted in **parsed** heading and label text too (e.g. `## Weekly Review for ` matches the period title in the UI). -- `` or `` — the **following** period in the same format (e.g. weekly `2026-W52` → `2027-W01`). -- line breaks or `\n`. - -Note: Multiple ``, ``, or `` items on one line are supported; answers are merged on one line with spaces (without `||`). - -If a question is left empty, that line is omitted from the output. If a line in the note already starts with the same question text, it is treated as an existing answer, and prefilled. - -### Example (Daily Review) - -Question string: - -``` -

Stats for -Health: @sleep() @work() @fruitveg() #stretches #closedRings -Work: @work() - -

Journal -Mood: -Gratitude: -Wins: -Challenges: -``` - -The v2 window shows the summary (when applicable), then this layout; submitting might produce: - -```markdown -## Stats for 2026-04-06 -@sleep(6.8) @work(7) -@fruitveg(4) #stretches -### Journal -Mood: 😇 Blessed -Gratitude: Went to great Nana's 100th birthday party -- result! -Wins: -- First win... -- Another one -``` - -### Moods - -Comma-separated list of labels (emoji optional). - ---- - -## Screenshots to capture - -Use these when updating plugin or website docs (retina/`@2x` where you already use `calendar-settings@2x.png`): - -1. **Daily Review window** — full window with **Summary** (tasks + events), main questions, and **planning** section; preferably **New Window** and one of **Split View** / **Main Window** if you document all three modes. -2. **Weekly (or monthly) Review window** — Summary showing **#win** / **#bigwin** styling and a heading that uses **``** (e.g. “Weekly Review for 2026-W14”). -3. **Quarterly note** — editor or sidebar showing title **`YYYY-Qn`** and a submitted review block (optional but clarifies Q-format fix). -4. **Next-period planning output** — the **next** day/week/month note showing the **H2** + **`>>`** tasks written by the planning section (paired with a screenshot of the planning fields filled in), if you document that feature visually. - ---- - -## Support - -Issues and feature ideas: [NotePlan plugins on GitHub](https://github.com/NotePlan/plugins/issues). - -If you would like to support my late-night work extending NotePlan through writing these plugins, you can through: - -[Buy Me A Coffee](https://www.buymeacoffee.com/revjgc) - -Thanks! - -## History - -See the [CHANGELOG](https://github.com/NotePlan/plugins/blob/main/jgclark.Journalling/CHANGELOG.md) for v2 beta notes and migration details. diff --git a/jgclark.Journalling/CHANGELOG.md b/jgclark.PeriodicReviews/CHANGELOG.md similarity index 88% rename from jgclark.Journalling/CHANGELOG.md rename to jgclark.PeriodicReviews/CHANGELOG.md index b580c2a2a..a2685bc88 100644 --- a/jgclark.Journalling/CHANGELOG.md +++ b/jgclark.PeriodicReviews/CHANGELOG.md @@ -1,12 +1,22 @@ -# What's changed in 💭 Journalling & Reviews Plugin? +# What's changed in Periodic Reviews Plugin? _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark.DailyJournal/)._ Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. -## [2.0.0.b7] - 2026-04-05 +## [2.0.0.b9] - 2026-04-11 +- settled on name 'Periodic Reviews' not 'Journalling & Reviews' +- Summary / carry-over plan tasks: include **cancelled** `>>` lines (e.g. `* [-] >> …`); they show as **not** done like open items. Fix: plan-section extraction no longer stopped after the first task under a matching H2. + +## [2.0.0.b8] - 2026-04-10 +- Review window: Fix `` (and other `` markers) sometimes appearing before the textarea — label text now strips full angle-bracket tokens, not bare type names. +- Review question templates: `` is accepted as an alias for ``. Simplifying, dropped support for `

` / `

` question lines. +- Further layout improvements to Summary area +- + +## [2.0.0.b7] - 2026-04-09 - Review summary: Done tasks whose body starts with `>>` (after the task marker and optional `!` priorities) count as **wins** for the period, same as `#win` / `#bigwin`, without needing a “Wins” section. They appear in the same **completed tasks** list as other done items (wins first), not duplicated. **Weekly/monthly/quarterly/yearly** summaries use the same win rules when listing done tasks for the period (even if there are no carry-over plan items). - Review window: Fix daily summary layout when carry-over plan items exist. -- Review window: Fix `

…` / `

…` question lines being split by the flex segment matcher, leaving text fragments. +- Review window: Fix heading-only template lines being split by the flex segment matcher, leaving text fragments. - dev: Refactor review flow — `reviewQuestions.js` (parse / pre-fill / answer output), more helpers in `journalHelpers.js`, slimmer `periodReviews.js`; shared segment regex for HTML + parser; `writeAnswersToNote` is module-private. - dev: Update Template handling in applyTemplateToNote() to not make any Editor inserts if there's nothing to insert. Aim: avoid race conditions. diff --git a/jgclark.PeriodicReviews/README.md b/jgclark.PeriodicReviews/README.md new file mode 100644 index 000000000..3019c0a50 --- /dev/null +++ b/jgclark.PeriodicReviews/README.md @@ -0,0 +1,165 @@ +# 💭 Periodic Reviews Plugin + +This plugin makes it easier for you to review your days, weeks, months, quarters, and years in NotePlan. It's designed to help you intentionally focus on whatever are the most important projects/goals/behaviours across all of your different endeavours in life. + +Many truly productive people suggest that regular reviews are the most important tool to help us focus on the most important outcomes in life. + +There is no single “right” way to review personal or work aims or goals. What matters is pausing to answer questions about what went well, what did not, goals, gratitude, mood, and so on. This is where this plugin fits in. + +First you need to configure the questions you want to use for each time period (some of daily, weekly, monthly, quarterly and yearly). Then at the end of the period, run the **/Daily Review** command (alias: 'dr'), or the similar one for the other review periods. + +The plugin then opens a window that shows **all** these questions in a window form (with colours and fonts follow your current NotePlan theme). When you submit the form, your answers are written under the correct section heading in the calendar note. + +Further, it will then ask you to decide your top few tasks/goals/priority work for the next period. There can only be a very few of these. If you give any, they will be written into the next period's note, prefixed with a `>>` marker (customisable), to indicate these most important things to focus on. + +### Example (Daily Review) +Here's an example of the Daily Review Window: + + + +This is generated from the following settings: +- "Daily Review/Journal Questions": + +``` +## Stats for +Health: @sleep() @work() @fruitveg() #stretches #closedRings +Work: @work() + +### Journal +Mood: +Gratitude: +Wins: +Challenges: +``` + +- "Daily Review/Journal Questions": `Wins` + +Submitting the form will insert something like this into **today**'s note: + +```markdown +## Stats for 2026-04-10 +@sleep(6.8) @work(7) +@fruitveg(4) #stretches + +### Journal +Mood: 😇 Blessed +Gratitude: Went to great Nana's 100th birthday party -- result! +Wins: +- First win... +- Another one +``` +And if you enter items in the 'Planning' section, then it will prefix something like this into **tomorrow**'s note: +```markdown +## Wins for 2026-04-10 +* >> First win +* >> Second win +``` + +### Which period is reviewed? +If a daily note is currently open when **/Daily Review** is called, then that day is reviewed, otherwise today's note is used. The same goes for the other calendar periods. + +The window first shows a Summary section, that starts with a reminder of the main few tasks/aims/goals you set for that period, and whether they were completed or not. For Daily notes only, it includes alist of tasks completed that day, plus a list of calendar events. + +### Basic Configuration +To use weekly, monthly, quarterly, or yearly notes, turn them on in **NotePlan Settings** → Calendar: + + + + Open the **💭 Journalling & Reviews** card in Plugin Preferences, then use the gear button to edit settings. + +### Setting the Review Questions +The terms in angle brackets define both the input controls and how lines are written to the note. The available input controls are: + +- `` — ticked/unticked; if ticked, the surrounding text is included in the output +- `` — the same as `` above +- `` or `` — whole number (integer) +- `` — number, which may include a decimal part +- `` — `[H]H:MM` (e.g. `1:05`, `12:30`) +- `` — single-line text +- `` — multi-line; each non-empty line is prefixed with a markdown bullet (`- `) +- `` — same, with checklist markers (`+ `) +- `` — same, with task markers (`* `) +- `` — pick from your configured mood list. + +You can include headings and placeholders: + +- Literal `##` / `###` lines in settings (and legacy ``) — output as headings in the note/HTML. +- `` — current review period’s calendar title in the window and in saved output (e.g. `2026-03-28`, `2026-W13`, `2026-Q1`). Substituted in **parsed** heading and label text too (e.g. `## Weekly Review for ` matches the period title in the UI). +- `` or `` — the **following** period in the same format (e.g. weekly `2026-W52` → `2027-W01`). +- line breaks or `\n`. + +Notes: +- Multiple ``, ``, or `` items on one line are supported +- If matching answers already exist in the note, they will appear **pre-filled** in the form. The latest matching block wins. + +### Other Settings + +- **Window placement:** **Review Window type** — 'New Window' (the default), 'Main Window', or a 'Split View' within the main window. +- **Open the calendar note when reviewing it?** (default: on). +- **Calendars to include in review summaries:** optional filter list; leave empty to include all calendars. +- **Planning vs reviewing:** For each period you can set a **planned items** name (defaults such as *Big Wins*, *Big Rocks*, *Key Outcomes*, *Goals*, *Theme*). After the main form, a **planning** area can write an **H2** and `>> …` tasks at the **start** of the **next** period’s calendar note, replacing any existing section with that title. Empty planning clears that section on the next note. The heading uses `{planName} for {next period title}` (e.g. `Big Rocks for 2026-W15`), separate from the on-screen “Planned:” / “Planning: …” labels. + +### Section headings + +- **Daily Journal Section Heading** — where **daily** review/journal answers are appended (default: `Journal`). +- **Review Section Heading** — where **weekly / monthly / quarterly / yearly** answers go (default: `Review`). + +If the heading does not already exist in a note, the content is added at the end of the note. + + +If a question is left empty, that line is omitted from the output. If a line in the note already starts with the same question text, it is treated as an existing answer, and prefilled. + + +### Moods + +Comma-separated list of labels (emoji optional). + +--- + +## FAQ +Q: What's the minimum version of NotePlan this runs with? +A: v3.20 (for the integrated HTML plugin windows). + +Q: How is this plugin related to your **Journalling Helpers** plugin? +A: This plugin replaces that older plugin for the review questions functionality, but not its start- and end-of-day template helpers. On first install, those settings will be migrated from that plugin automatically, if you'd used that before. + +Q: How is this different from your **Projects & Reviews** plugin? +A: That plugin is designed to be used for assisting track and review Projects or project-like activities. It works on regular notes, and helps you review work on many projects, and review each at its suitable review interval. This plugin is designed to help you intentionally focus on whatever are the most important projects/goals/behaviours across all of your different endeavours in life. + + + +--- + +## Support + +Issues and feature ideas: [NotePlan plugins on GitHub](https://github.com/NotePlan/plugins/issues). + +If you would like to support my late-night work extending NotePlan through writing these plugins, you can through: + +[Buy Me A Coffee](https://www.buymeacoffee.com/revjgc) + +Thanks! + +## History + +See the [CHANGELOG](https://noteplan.co/plugins/jgclark.PeriodicReviews/CHANGELOG.md) for release history for v2. diff --git a/jgclark.Journalling/__tests__/periodReviews.test.js b/jgclark.PeriodicReviews/__tests__/periodReviews.test.js similarity index 93% rename from jgclark.Journalling/__tests__/periodReviews.test.js rename to jgclark.PeriodicReviews/__tests__/periodReviews.test.js index 5cef089ca..c6fef0f2b 100644 --- a/jgclark.Journalling/__tests__/periodReviews.test.js +++ b/jgclark.PeriodicReviews/__tests__/periodReviews.test.js @@ -12,7 +12,7 @@ import { splitMergedSummaryDoneLinesIntoWinsAndOthers, substituteReviewPeriodPlaceholders, summaryTaskLineDedupeKey, -} from '../src/journalHelpers' +} from '../src/periodicReviewHelpers' import { extractPlanSectionItems, taskContentIsSummaryWin } from '../src/periodReviews' import { buildReviewHTML } from '../src/reviewHTMLViewGenerator' import { @@ -111,26 +111,29 @@ Do: ` expect(questions[2].lineIndex).toBe(2) }) - it('should parse h2/h3 types in various positions', () => { - const raw = `

Top Heading\n

Sub Heading\nTitle

` + it('should parse the same as ', () => { + const raw = `Hours: || Count: ` const questions = parseQuestions(raw) - expect(questions.length).toBe(3) - expect(questions[0].type).toBe('h2') - expect(questions[0].question).toBe('Top Heading') - expect(questions[0].lineIndex).toBe(0) - expect(questions[1].type).toBe('h3') - expect(questions[1].question).toBe('Sub Heading') - expect(questions[1].lineIndex).toBe(1) - expect(questions[2].type).toBe('string') - expect(questions[2].question).toBe('Title') - expect(questions[2].lineIndex).toBe(2) + expect(questions.length).toBe(2) + expect(questions[0].type).toBe('int') + expect(questions[0].question).toBe('Hours') + expect(questions[1].type).toBe('int') + expect(questions[1].question).toBe('Count') + }) + + it('should strip full tokens from label text (no stray marker for the review window)', () => { + const questions = parseQuestions('Gratitude: ') + expect(questions.length).toBe(1) + expect(questions[0].type).toBe('string') + expect(questions[0].question).toBe('Gratitude') + expect(questions[0].question).not.toMatch(/[<>]/) }) it('should cope with lines which are just / / / ', () => { const config = { dailyReviewQuestions: ` -

H2 Heading +## H2 Heading ### H3 Heading `, @@ -228,8 +231,8 @@ Wins: ` expect(html).not.toContain('<date>') }) - it('should render a typed `

…` line as one heading (segment regex must not split after the first word)', () => { - const raw = `

Stats for + it('should render a ## heading line as one heading (segment regex must not split after the first word)', () => { + const raw = `## Stats for Mood: ` const parsedQuestions = parseQuestions(raw) const rawLines = raw.split('\n') @@ -513,6 +516,14 @@ Ship: `, expect(out).toBe('Health: @sleep(7) @fruitveg(3)\n') }) + it('should substitute answers into segments like ', () => { + const raw = 'Count: ' + const parsedQuestions = parseQuestions(raw) + const rawLines = raw.split('\n') + const out = buildOutputFromReviewWindowAnswers(parsedQuestions, rawLines, '2026-03-27', 'day', { q_0: '42' }) + expect(out).toBe('Count: 42\n') + }) + it('should output duration answers in [H]H:MM format', () => { const raw = '@focus()' const parsedQuestions = parseQuestions(raw) @@ -640,5 +651,19 @@ Ship: `, expect(items[1].isDone).toBe(true) expect(items[0].content).toContain('First') }) + + it('extractPlanSectionItems should include cancelled >> tasks as not done (summary treats like open)', () => { + const note = { + paragraphs: [ + { type: 'title', headingLevel: 2, content: 'Home', lineIndex: 0 }, + { type: 'cancelled', content: '>> Sort new Santander account', lineIndex: 1 }, + { type: 'done', content: 'Update milk order again @done(2026-04-12 00:05)', lineIndex: 2 }, + ], + } + const items = extractPlanSectionItems(note, '') + expect(items.length).toBe(1) + expect(items[0].isDone).toBe(false) + expect(items[0].content).toContain('Sort new Santander') + }) }) }) diff --git a/jgclark.Journalling/calendar-settings@2x.png b/jgclark.PeriodicReviews/calendar-settings@2x.png similarity index 100% rename from jgclark.Journalling/calendar-settings@2x.png rename to jgclark.PeriodicReviews/calendar-settings@2x.png diff --git a/jgclark.PeriodicReviews/daily-review-2.0.0.b7@2x.png b/jgclark.PeriodicReviews/daily-review-2.0.0.b7@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..233b49a1a3f9e2108f873513cf6c997b57f1a6e9 GIT binary patch literal 181054 zcmeFZWn7%gvNwt|1RE>_cXxMpcXtTxZowTAB*7hm1`X~`(BMvR_rV?RWM!SR_j}IT zzk9#jZ|{7XeyY2kn(BX7S9MiSl(M1}@*CVY5D*Z^GScFz5D+ku5D;)Z066dyD!7Xa z2neWbYjtfmZ3TH=Ge>(yllP9M=8RtUPGC9&1iz4%lZlzFxf`*mxuvy(0O?t4Cn>S@ zdjV1{4h3cfCoyv?YiS=Bb2T4Dbu%AZGoJUPLV|Djy?DV4?9JUwh`sFX99((51W5nl z%M1Sg`!N$K@n2osYz0WQ6_km^99_(bIT@K5nMnoT5c9jdx8PM3m;8G$xFtYp<>uzZ z%f#gA>B;EH#^~r`$;86L!^6bP%EZdb0Pexy>h0iW;>FVE=&5Kvn+||*}{kQBI4%T3) zS^q`S{ud3*l%1W4>rd0p`nPg; z?Mxgj1xURZ-kV#PxZAmr3aXj8n1g?cm7^O!({FX!S^v%BuhxGt;r~l>y#M1(e@6V< zgn)P}+ z9%sQD}SE;p#$FMF&antBooblRmOL^$Q`KD_V{Bd=0 zc{4RV4Npl)d9!tLm71EGxWB&-)0PkZ++)R=L;M^e5fugiAW4W}LIZ(7sDEqZ#>U2a z_;Z*I&4p^|>FN2&$;m00nVCr_DEy4QxVSiJTfqlZY6qZ1fBN)^j-DQWzRIW*K}JSK zO#3SGe9xB1%1rN`K z#|SKfwBKr0tmv&MbLf#Sj=mtDZ|kaP!2HJv`90(ojXS6HsUT}FGK0AX%W-c$>Vc{~gYhI{KhFDa&U&%wQz-v=(g2VOxdFv*H-VB5*GF6`X9

CyxI$ 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
x?Kx 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 literal 0 HcmV?d00001 diff --git a/jgclark.Journalling/plugin.json b/jgclark.PeriodicReviews/plugin.json similarity index 85% rename from jgclark.Journalling/plugin.json rename to jgclark.PeriodicReviews/plugin.json index 97cb131b5..cc049a811 100644 --- a/jgclark.Journalling/plugin.json +++ b/jgclark.PeriodicReviews/plugin.json @@ -1,17 +1,17 @@ { "noteplan.minAppVersion": "3.20.0", "macOS.minVersion": "10.13.0", - "plugin.id": "jgclark.Journalling", - "plugin.name": "💭 Journalling & Reviews", - "plugin.description": "This plugin makes it easier for you to journal and/or review your days/weeks/months/quarters/years into NotePlan. It also makes it quicker to apply templates at the start or end of days/weeks/months/quarters/years.\nIt requires some configuration before use: please see the documentation for details.", - "plugin.icon": "clipboard-list", - "plugin.iconColor": "blue-600", + "plugin.id": "jgclark.PeriodicReviews", + "plugin.name": " Periodic Reviews", + "plugin.description": "This plugin makes it easier for you to review your days/weeks/months/quarters/years into NotePlan. It requires some configuration before use: please see the documentation for details.", + "plugin.icon": "calendar-days", + "plugin.iconColor": "purple-500", "plugin.author": "Jonathan Clark", - "plugin.url": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/README.md", - "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.DailyJournal/CHANGELOG.md", - "plugin.version": "2.0.0.b7", + "plugin.url": "https://noteplan.co/plugins/jgclark.PeriodicReviews/", + "plugin.changelog": "https://noteplan.co/plugins/jgclark.PeriodicReviews/CHANGELOG.md", + "plugin.version": "2.0.0.b8", "plugin.releaseStatus": "beta", - "plugin.lastUpdateInfo": "v2.0.0: First release of the new Journalling & Reviews plugin, but building on earlier 'Journalling Helpers' plugin (v1).", + "plugin.lastUpdateInfo": "v2.0.0: First release of the new Periodic Reviews plugin, building on earlier 'Journalling Helpers' plugin (v1).", "plugin.requiredFiles": [ "reviews.css" ], @@ -33,13 +33,13 @@ "journal", "review" ], - "description": "Ask Review/Journal questions at end-of-day", - "jsFunction": "dailyJournalQuestions", + "description": "Ask Review questions at end-of-day", + "jsFunction": "dailyReviewQuestions", "sidebarView": { - "windowID": "jgclark.Journalling.dayReview", + "windowID": "jgclark.PeriodicReviews.dayReview", "title": "Daily Review", - "icon": "fa-clipboard-list", - "iconColor": "blue-600" + "icon": "fa-calendar-days", + "iconColor": "purple-500" } }, { @@ -49,8 +49,8 @@ "journal", "review" ], - "description": "Ask Review/Journal questions at end-of-week", - "jsFunction": "weeklyJournalQuestions" + "description": "Ask Review questions at end-of-week", + "jsFunction": "weeklyReviewQuestions" }, { "name": "Monthly Review", @@ -59,8 +59,8 @@ "journal", "review" ], - "description": "Ask Review/Journal questions at end-of-month", - "jsFunction": "monthlyJournalQuestions" + "description": "Ask Review questions at end-of-month", + "jsFunction": "monthlyReviewQuestions" }, { "name": "Quarterly Review", @@ -69,8 +69,8 @@ "journal", "review" ], - "description": "Ask Review/Journal questions at end-of-quarter", - "jsFunction": "quarterlyJournalQuestions" + "description": "Ask Review questions at end-of-quarter", + "jsFunction": "quarterlyReviewQuestions" }, { "name": "Yearly Review", @@ -79,8 +79,31 @@ "journal", "review" ], - "description": "Ask Review/Journal questions at end-of-year", - "jsFunction": "yearlyJournalQuestions" + "description": "Ask Review questions at end-of-year", + "jsFunction": "yearlyReviewQuestions" + }, + { + "hidden": true, + "name": "onReviewWindowAction", + "description": "Callback path for review UI", + "jsFunction": "onReviewWindowAction", + "parameters": [ + "actionName ('cancel' or 'submit')", + "payload (object or string)" + ] + } + ], + "plugin.commands_disabled": [ + { + "name": "PeriodicReviews: update plugin settings", + "description": "Settings interface (even for iOS)", + "jsFunction": "updateSettings" + }, + { + "hidden": true, + "name": "PeriodicReviews: onUpdateOrInstall", + "description": "onUpdateOrInstall", + "jsFunction": "onUpdateOrInstall" }, { "name": "dayStart", @@ -151,44 +174,12 @@ "alias": [], "description": "Apply Month End template to the current monthly note", "jsFunction": "monthEnd" - }, - { - "hidden": true, - "name": "onReviewWindowAction", - "description": "Callback path for review UI", - "jsFunction": "onReviewWindowAction", - "parameters": [ - "actionName ('cancel' or 'submit')", - "payload (object or string)" - ] - }, - { - "hidden": true, - "name": "Journalling: onUpdateOrInstall", - "description": "onUpdateOrInstall", - "jsFunction": "onUpdateOrInstall" - } - ], - "plugin.commands_disabled": [ - { - "name": "Journalling: update plugin settings", - "description": "Settings interface (even for iOS)", - "jsFunction": "updateSettings" - }, - { - "comment": "TODO: remove soon", - "name": "Test:EditorOpen", - "description": "testEditorOpen", - "jsFunction": "testEditorOpen", - "alias": [ - "teo" - ] } ], "plugin.settings": [ { "type": "heading", - "title": "Reviewing & Journalling" + "title": "Periodic Review Settings" }, { "key": "preferredWindowType", @@ -246,7 +237,7 @@ { "key": "dailyReviewQuestions", "title": "Daily Review/Journal Questions", - "description": "String that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are '', '', '' ([H]H:MM), '', '', '', '', '', '', and '' (replaced with the review note title) and '' or '' (the following calendar period in the same format).", + "description": "String that includes both the Journal/Review questions and how to lay out the answers in the daily note. (NB: can include line breaks.) The special codes that define the type of question asked are ''/'', '', '' ([H]H:MM), '', '', '', '', '', '', and '' (replaced with the review note title) and '' or '' (the following calendar period in the same format). Headings: use literal '##'/'###' lines or ''.", "type": "string", "default": "@sleep()\n@work()\n@fruitveg()\nMood: \nGratitude: \nGod was: \nAlive: \nNot Great: \nLearn: \nRemember: ", "required": false diff --git a/jgclark.Journalling/requiredFiles/reviews.css b/jgclark.PeriodicReviews/requiredFiles/reviews.css similarity index 99% rename from jgclark.Journalling/requiredFiles/reviews.css rename to jgclark.PeriodicReviews/requiredFiles/reviews.css index e0549eecb..cf7768311 100644 --- a/jgclark.Journalling/requiredFiles/reviews.css +++ b/jgclark.PeriodicReviews/requiredFiles/reviews.css @@ -87,7 +87,7 @@ body { .summary-item { break-inside: avoid; - display: flex; + display: block; align-items: baseline; gap: 0.4rem; margin-bottom: 0.15rem; diff --git a/jgclark.Journalling/review-window-questions@2x.png b/jgclark.PeriodicReviews/review-window-questions@2x.png similarity index 100% rename from jgclark.Journalling/review-window-questions@2x.png rename to jgclark.PeriodicReviews/review-window-questions@2x.png diff --git a/jgclark.Journalling/settings-button@2x.png b/jgclark.PeriodicReviews/settings-button@2x.png similarity index 100% rename from jgclark.Journalling/settings-button@2x.png rename to jgclark.PeriodicReviews/settings-button@2x.png diff --git a/jgclark.Journalling/src/index.js b/jgclark.PeriodicReviews/src/index.js similarity index 96% rename from jgclark.Journalling/src/index.js rename to jgclark.PeriodicReviews/src/index.js index 58616cbfa..3aa7ddf22 100644 --- a/jgclark.Journalling/src/index.js +++ b/jgclark.PeriodicReviews/src/index.js @@ -3,7 +3,7 @@ //--------------------------------------------------------------- // Journalling commands // Jonathan Clark -// last update 2026-03-28 for v2.0.0.b4 by @jgclark +// last update 2026-04-11 for v2.0.0.b9 by @jgclark //--------------------------------------------------------------- // allow changes in plugin.json to trigger recompilation @@ -12,16 +12,16 @@ import { clo, compareObjects, JSP, logDebug, logInfo, logError } from "@helpers/ import { backupSettings, getSettings, pluginUpdated, saveSettings } from '@helpers/NPConfiguration' import { editSettings } from '@helpers/NPSettings' -const pluginID = 'jgclark.Journalling' +const pluginID = 'jgclark.PeriodicReviews' const oldPluginID = 'jgclark.DailyJournal' export { - dailyJournalQuestions, + dailyReviewQuestions, + weeklyReviewQuestions, + monthlyReviewQuestions, + quarterlyReviewQuestions, + yearlyReviewQuestions, onReviewWindowAction, - weeklyJournalQuestions, - monthlyJournalQuestions, - quarterlyJournalQuestions, - yearlyJournalQuestions, } from './periodReviews' export { diff --git a/jgclark.Journalling/src/periodReviews.js b/jgclark.PeriodicReviews/src/periodReviews.js similarity index 78% rename from jgclark.Journalling/src/periodReviews.js rename to jgclark.PeriodicReviews/src/periodReviews.js index 068eef08e..632ad95f5 100644 --- a/jgclark.Journalling/src/periodReviews.js +++ b/jgclark.PeriodicReviews/src/periodReviews.js @@ -2,7 +2,7 @@ //--------------------------------------------------------------- // Journalling commands // Jonathan Clark -// last update 2026-04-05 for v2.0.0.b7 by @jgclark + @Cursor +// last update 2026-04-11 for v2.0.0.b9 by @jgclark + @Cursor //--------------------------------------------------------------- import strftime from 'strftime' @@ -18,9 +18,9 @@ import { normalizeReviewPeriodTitleForNPDateHelpers, summaryTaskLineDedupeKey, taskContentIsSummaryWin, -} from './journalHelpers' +} from './periodicReviewHelpers' export { taskContentIsSummaryWin } -import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' +import type { PeriodicReviewConfigType, ParsedQuestionType } from './periodicReviewHelpers' import { buildInitialReviewAnswersByFieldName, buildOutputFromReviewWindowAnswers, @@ -46,21 +46,24 @@ import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' import { closeWindowFromCustomId } from '@helpers/NPWindows' import { isParaAMatchForHeading } from '@helpers/headings' import { findEndOfActivePartOfNote, findHeading, findHeadingStartsWith, findStartOfActivePartOfNote } from '@helpers/paragraph' +import { getNumericPriorityFromPara } from '@helpers/sorting' import { getInput, showMessage } from '@helpers/userInput' //--------------------------------------------------------------- // Constants & Types -const REVIEW_WINDOW_CUSTOM_ID = 'jgclark.Journalling.period-review' +const REVIEW_WINDOW_CUSTOM_ID = 'jgclark.PeriodicReviews.period-review' const REVIEW_WINDOW_CALLBACK_COMMAND = 'onReviewWindowAction' -/** Paragraph types treated as tasks under a plan H2 (carry-over + rewrite). */ +/** Paragraph types treated as tasks under a plan H2 (carry-over + rewrite). Includes cancelled so >> plan lines stay in the summary as not done. */ const PLAN_SECTION_PARA_TYPES: Set = new Set([ 'open', 'done', 'scheduled', + 'cancelled', 'checklist', 'checklistDone', + 'checklistCancelled', 'checklistScheduled', 'list' ]) @@ -162,34 +165,59 @@ function getCalendarNoteByTitle(title: string): TNote | null { } /** - * Task lines under the configured plan H2 (active part only), for the review summary. Note: The heading match is partial + case insensitive + * Return task lines for the review summary from: + * - the configured 'planName' section (if given) + * - any with priority '>>' (if 'planName' is empty, or can't find the 'planName' section) + * Note: The heading match is partial + case insensitive. * @param {TNote} note - * @param {string} planName (e.g. 'Big Rocks') + * @param {string} planName (e.g. 'Big Rocks', or empty) * @returns {Array<{ content: string, isDone: boolean }>} */ -export function extractPlanSectionItems(note: TNote, planName: string): Array<{ content: string, isDone: boolean }> { - // const headingPara = findPlanSectionHeadingPara(note, headingTitle) - const headingPara = findHeading(note, planName, true) - if (headingPara == null) { - logDebug('extractPlanSectionItems', `Can't find a heading including '${planName}', so returning empty array`) - return [] - } - const heading = headingPara.content - logDebug('extractPlanSectionItems', `- matched heading '${heading}'`) - const out: Array<{ content: string, isDone: boolean }> = [] +export function extractPlanSectionItems( + note: TNote, + planName: string = '' +): Array<{ content: string, isDone: boolean }> { const paras = note.paragraphs ?? [] - const start = headingPara.lineIndex ?? 0 - const end = Math.min(findEndOfActivePartOfNote(note), paras.length) - logDebug('extractPlanSectionItems', `Found heading ${heading}, so processing lines ${String(start+1)}-${String(end)}`) + let start = findStartOfActivePartOfNote(note) + const end = findEndOfActivePartOfNote(note) + const out: Array<{ content: string, isDone: boolean }> = [] + + // Get relevant set of paras to parse + if (planName !== '') { + const headingPara = findHeading(note, planName, true) + if (headingPara != null) { + const heading = headingPara.content + logDebug('extractPlanSectionItems', `- matched heading '${heading}'`) + start = headingPara.lineIndex ?? 0 + logDebug('extractPlanSectionItems', `Found heading ${heading}, so processing lines ${String(start + 1)}-${String(end)}`) + for (let i = start + 1; i <= end; i++) { + const p = paras[i] + if (p.type === 'title' && (p.headingLevel ?? 99) <= 2) { + // We're now in a different section, so stop processing + break + } + if (!PLAN_SECTION_PARA_TYPES.has(String(p.type))) { + continue + } + const isDone = p.type === 'done' || p.type === 'checklistDone' + out.push({ content: p.content, isDone }) + } + return out + } else { + logDebug('extractPlanSectionItems', `Can't find a heading including '${planName}', so will now look for any other >> items`) + } + } + + logDebug('extractPlanSectionItems', `Will look for >> tasks in lines ${String(start+1)}-${String(end)}`) for (let i = start + 1; i <= end; i++) { const p = paras[i] - if (p.type === 'title' && (p.headingLevel ?? 99) <= 2) { - // We're now in a different section, so stop processing - break - } if (!PLAN_SECTION_PARA_TYPES.has(String(p.type))) { continue } + if (getNumericPriorityFromPara(p) !== 4) { + // i.e. if not '>>' priority + continue + } const isDone = p.type === 'done' || p.type === 'checklistDone' out.push({ content: p.content, isDone }) } @@ -198,14 +226,14 @@ export function extractPlanSectionItems(note: TNote, planName: string): Array<{ /** * Write or clear planned tasks on the **next** calendar note: replace existing H2 with same title, insert at active start. - * @param {JournalConfigType} config + * @param {PeriodicReviewConfigType} config * @param {string} periodString * @param {string} periodType * @param {string} planningFormText * @returns {Promise} */ export async function writePlanningTasksToNextPeriodNote( - config: JournalConfigType, + config: PeriodicReviewConfigType, periodString: string, periodType: string, planningFormText: string, @@ -250,11 +278,11 @@ export async function writePlanningTasksToNextPeriodNote( * @param {() => string} getPeriodString * @returns {Promise} */ -async function runJournalQuestionsForCurrentPeriod(periodType: string, getPeriodString: () => string): Promise { +async function runReviewQuestionsForCurrentPeriod(periodType: string, getPeriodString: () => string): Promise { try { const thisPeriodStr = getPeriodString() logDebug(pluginJson, `Starting for ${periodType} (currently ${thisPeriodStr})`) - await processJournalQuestions(thisPeriodStr, periodType) + await processReviewQuestions(thisPeriodStr, periodType) } catch (error) { logError(pluginJson, error.message) } @@ -263,15 +291,15 @@ async function runJournalQuestionsForCurrentPeriod(periodType: string, getPeriod /** * Gather answers to daily journal questions, and inserts at the cursor. */ -export async function dailyJournalQuestions(): Promise { - await runJournalQuestionsForCurrentPeriod('day', () => strftime('%Y-%m-%d')) +export async function dailyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('day', () => strftime('%Y-%m-%d')) } /** * Gather answers to weekly journal questions, and inserts at the cursor. */ -export async function weeklyJournalQuestions(): Promise { - await runJournalQuestionsForCurrentPeriod('week', () => { +export async function weeklyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('week', () => { const currentWeekNum = getWeek(new Date()) return `${strftime('%Y')}-W${currentWeekNum}` }) @@ -280,22 +308,22 @@ export async function weeklyJournalQuestions(): Promise { /** * Gather answers to monthly journal questions, and inserts at the cursor. */ -export async function monthlyJournalQuestions(): Promise { - await runJournalQuestionsForCurrentPeriod('month', () => strftime('%Y-%m')) +export async function monthlyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('month', () => strftime('%Y-%m')) } /** * Gather answers to quarterly journal questions, and inserts at the cursor. */ -export async function quarterlyJournalQuestions(): Promise { - await runJournalQuestionsForCurrentPeriod('quarter', () => getNPQuarterStr(new Date())) +export async function quarterlyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('quarter', () => getNPQuarterStr(new Date())) } /** * Gather answers to yearly journal questions, and inserts at the cursor. */ -export async function yearlyJournalQuestions(): Promise { - await runJournalQuestionsForCurrentPeriod('year', () => strftime('%Y')) +export async function yearlyReviewQuestions(): Promise { + await runReviewQuestionsForCurrentPeriod('year', () => strftime('%Y')) } //--------------------------------------------------------- @@ -309,11 +337,11 @@ export async function yearlyJournalQuestions(): Promise { * @param {string} periodStringIn the calendar note title string for the review period * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' */ -async function processJournalQuestions(periodStringIn: string = '', periodType: string): Promise { +async function processReviewQuestions(periodStringIn: string = '', periodType: string): Promise { try { const periodAdjective = getPeriodAdjectiveFromType(periodType) // Get configuration - const config: JournalConfigType = await getJournalSettings() + const config: PeriodicReviewConfigType = await getJournalSettings() let reviewNote: ?TNote = null // Check that we have the correct period note open (same type *and* same title as requested). @@ -326,10 +354,10 @@ async function processJournalQuestions(periodStringIn: string = '', periodType: if (openEditorNote && getPeriodOfNPDateStr(openTitle) === periodType && titlesMatch) { // Use the existing open note reviewNote = openEditorNote - logDebug('processJournalQuestions', `Starting with open note '${String(reviewNote?.title ?? 'unknown')}' of period '${String(getPeriodOfNPDateStr(reviewNote?.title ?? ''))}'`) + logDebug('processReviewQuestions', `Starting with open note '${String(reviewNote?.title ?? 'unknown')}' of period '${String(getPeriodOfNPDateStr(reviewNote?.title ?? ''))}'`) } else { // use the passed periodStringIn to open the correct note - logDebug('processJournalQuestions', `Starting by opening current ${periodAdjective} note '${String(periodStringIn)}'`) + logDebug('processReviewQuestions', `Starting by opening current ${periodAdjective} note '${String(periodStringIn)}'`) reviewNote = await Editor.openNoteByTitle(periodStringIn) } if (!reviewNote) { @@ -338,15 +366,9 @@ async function processJournalQuestions(periodStringIn: string = '', periodType: throw new Error(`Cannot open ${periodStringIn} note, so cannot continue.`) } - // const reviewNote: ?TNote = ensureCorrectPeriodNoteIsOpen(periodType, periodStringIn) - // if (!reviewNote) { - // await showMessage(`No ${periodAdjective} note found, so cannot continue.`) - // throw new Error(`No ${periodAdjective} note found, so cannot continue.`) - // } - const titleFromNote = reviewNote.title != null ? String(reviewNote.title).trim() : '' const periodString = titleFromNote !== '' ? titleFromNote : periodStringIn !== '' ? periodStringIn : '' - logDebug('processJournalQuestions', `- Will use review note '${String(periodString)}' of period '${String(getPeriodOfNPDateStr(periodString))}'`) + logDebug('processReviewQuestions', `- Will use review note '${String(periodString)}' of period '${String(getPeriodOfNPDateStr(periodString))}'`) // Get questions and parse them const questionLines = await getQuestionsForPeriod(config, periodType) @@ -396,59 +418,63 @@ function getParagraphLineContentsForReviewScan(note: TNote): Array { * @param {string} periodString * @returns {{ wins: Array, completed: Array }} */ -function getSummaryTasksForReview(periodType: string, periodString: string): {| wins: Array, completed: Array |} { - const npPeriodKey = normalizeReviewPeriodTitleForNPDateHelpers(periodString) - const startISO = getFirstDateInPeriod(npPeriodKey) - const endISO = getLastDateInPeriod(npPeriodKey) - if (startISO === '(error)' || endISO === '(error)') { - logWarn(pluginJson, `getSummaryTasksForReview: could not parse period "${periodString}"`) - return { wins: [], completed: [] } - } - const periodStartMs = new Date(`${startISO}T12:00:00`).getTime() - const lookbackDaysRaw = Math.ceil((Date.now() - periodStartMs) / 86400000) + 1 - const lookbackDays = Math.min(Math.max(0, lookbackDaysRaw), 400) - const isDailyPeriod = periodType === 'day' - const notesToScan = getNotesChangedInInterval(lookbackDays, ['Calendar', 'Notes']) - const wins: Array = [] - const completed: Array = [] - /** Same task line can appear on multiple notes (or with different surrounding whitespace); show each logical line once. */ - const seenKeys = new Set() - - for (const note of notesToScan) { - for (const para of note.paragraphs) { - if (para.type !== 'done') { - continue - } - const doneDateMatch = para.content.match(RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE) - const doneDate = doneDateMatch?.[1] ?? '' - if (doneDate === '') { - continue - } - const isInPeriod = doneDate >= startISO && doneDate <= endISO - if (!isInPeriod) { - continue - } - const dedupeKey = summaryTaskLineDedupeKey(para.content) - if (dedupeKey === '' || seenKeys.has(dedupeKey)) { - continue - } - seenKeys.add(dedupeKey) - const isWin = taskContentIsSummaryWin(para.content) - if (!isDailyPeriod) { +function getDoneTasksForSummary(periodType: string, periodString: string): {| wins: Array < string >, completed: Array < string > |} { + try { + const npPeriodKey = normalizeReviewPeriodTitleForNPDateHelpers(periodString) + const startISO = getFirstDateInPeriod(npPeriodKey) + const endISO = getLastDateInPeriod(npPeriodKey) + if (startISO === '(error)' || endISO === '(error)') { + logWarn('getDoneTasksForSummary', `Could not parse period "${periodString}"`) + return { wins: [], completed: [] } + } + const periodStartMs = new Date(`${startISO}T12:00:00`).getTime() + const lookbackDaysRaw = Math.ceil((Date.now() - periodStartMs) / 86400000) + 1 + const lookbackDays = Math.min(Math.max(0, lookbackDaysRaw), 400) + const isDailyPeriod = periodType === 'day' + const notesToScan = getNotesChangedInInterval(lookbackDays, ['Calendar', 'Notes']) + const wins: Array = [] + const completed: Array = [] + /** Same task line can appear on multiple notes (or with different surrounding whitespace); show each logical line once. */ + const seenKeys = new Set < string > () + + for (const note of notesToScan) { + for (const para of note.paragraphs) { + if (para.type !== 'done') { + continue + } + const doneDateMatch = para.content.match(RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE) + const doneDate = doneDateMatch?.[1] ?? '' + if (doneDate === '') { + continue + } + const isInPeriod = doneDate >= startISO && doneDate <= endISO + if (!isInPeriod) { + continue + } + const dedupeKey = summaryTaskLineDedupeKey(para.content) + if (dedupeKey === '' || seenKeys.has(dedupeKey)) { + continue + } + seenKeys.add(dedupeKey) + const isWin = taskContentIsSummaryWin(para.content) + if (!isDailyPeriod) { + if (isWin) { + wins.push(para.content) + } + continue + } if (isWin) { wins.push(para.content) + } else { + completed.push(para.content) } - continue - } - if (isWin) { - wins.push(para.content) - } else { - completed.push(para.content) } } - } - return { wins, completed } + return { wins, completed } + } catch (error) { + logError('getDoneTasksForSummary', error.message) + } } /** @@ -457,7 +483,7 @@ function getSummaryTasksForReview(periodType: string, periodString: string): {| * @param {Array} parsedQuestions * @param {string} periodString * @param {string} periodType - * @param {JournalConfigType} config + * @param {PeriodicReviewConfigType} config * @param {Array} rawQuestionLines lines from getQuestionsForPeriod (same array passed to parseQuestions) * @param {TNote} calendarNote the calendar note to scan for answers * @returns {void} @@ -466,20 +492,20 @@ async function displayQuestionsWindow( parsedQuestions: Array, periodString: string, periodType: string, - config: JournalConfigType, + config: PeriodicReviewConfigType, rawQuestionLines: Array, calendarNote: TNote, ): Promise { const periodAdjective = getPeriodAdjectiveFromType(periodType) // Get the data sources we need for the review window - const { wins: summaryWinTasks, completed: summaryCompletedTasks } = getSummaryTasksForReview(periodType, periodString) + const { wins: summaryWinTasks, completed: summaryCompletedTasks } = getDoneTasksForSummary(periodType, periodString) const calendarSet: Array = config.calendarSet ?? [] // logDebug(pluginJson, `calendarSet: [${String(calendarSet)}]`) const eventsForPeriod: Array = (periodType === 'day') ? await getEventsForDay(periodString, calendarSet) ?? [] : [] const scanLines = getParagraphLineContentsForReviewScan(calendarNote) const initialAnswers = buildInitialReviewAnswersByFieldName(parsedQuestions, scanLines) const planName = getPlanItemsNameForPeriodType(config, periodType) - const carryOverPlanItems = extractPlanSectionItems(calendarNote, planName) + const carryOverPlanItems = extractPlanSectionItems(calendarNote) // TEST: trying without sending planName parameter // Build the HTML body for the review window from this data const htmlBody = buildReviewHTML( @@ -501,9 +527,9 @@ async function displayQuestionsWindow( const preferredWindowType = config.preferredWindowType ?? 'New Window' const windowOptions: HtmlWindowOptions = { customId: REVIEW_WINDOW_CUSTOM_ID, - windowTitle: `${periodAdjective} Review for ${periodString}`, + windowTitle: `${periodAdjective} Review`, headerTags: `${faLinksInHeader}${stylesheetinksInHeader}`, - savedFilename: `../../jgclark.Journalling/period-review-${periodType}.html`, + savedFilename: `../../jgclark.PeriodicReviews/period-review-${periodType}.html`, showInMainWindow: preferredWindowType !== 'New Window', splitView: preferredWindowType === 'Split View', showReloadButton: true, @@ -541,7 +567,7 @@ async function writeAnswersToNote( answersTextIn: string = '', ): Promise { try { - const config: JournalConfigType = await getJournalSettings() + const config: PeriodicReviewConfigType = await getJournalSettings() let periodString = periodStringIn ?? '' if (periodString === '') { periodString = Editor.note?.title ?? '' @@ -639,7 +665,7 @@ export async function onReviewWindowAction(actionName: string, payload: mixed = return } logDebug('Journalling/onReviewWindowAction', `Refresh: reopening review for ${periodType} ${periodString}`) - await processJournalQuestions(periodString, periodType) + await processReviewQuestions(periodString, periodType) return } @@ -665,11 +691,11 @@ export async function onReviewWindowAction(actionName: string, payload: mixed = return } logDebug('Journalling/onReviewWindowAction', `Navigate ${direction}: reopening review for ${periodType} ${targetPeriodString}`) - await processJournalQuestions(targetPeriodString, periodType) + await processReviewQuestions(targetPeriodString, periodType) return } - const config: JournalConfigType = await getJournalSettings() + const config: PeriodicReviewConfigType = await getJournalSettings() let safePayload: any = {} try { // Allow callback payloads as JSON string (x-callback / jsBridge) or as an object (native bridge). diff --git a/jgclark.Journalling/src/journalHelpers.js b/jgclark.PeriodicReviews/src/periodicReviewHelpers.js similarity index 94% rename from jgclark.Journalling/src/journalHelpers.js rename to jgclark.PeriodicReviews/src/periodicReviewHelpers.js index b33c25293..1cab631f4 100644 --- a/jgclark.Journalling/src/journalHelpers.js +++ b/jgclark.PeriodicReviews/src/periodicReviewHelpers.js @@ -2,7 +2,7 @@ //--------------------------------------------------------------- // Helper functions for Journalling plugin for NotePlan // Jonathan Clark -// last update 2026-04-05 for v2.0.0.b7 by @jgclark + @Cursor +// last update 2026-04-11 for v2.0.0.b9 by @jgclark //--------------------------------------------------------------- import pluginJson from '../plugin.json' @@ -13,9 +13,9 @@ import { showMessage } from '@helpers/userInput' //--------------------------------------------------------------- // Constants & Types -const pluginID = 'jgclark.Journalling' +const pluginID = 'jgclark.PeriodicReviews' -export type JournalConfigType = { +export type PeriodicReviewConfigType = { dailyJournalSectionHeading: string, reviewSectionHeading: string, dayPlanItemsName: string, @@ -58,7 +58,7 @@ export type ParsedQuestionType = { export async function getJournalSettings(): Promise { // want to use Promise but too many flow errors result try { // Get settings using Config system - const config: JournalConfigType = await DataStore.loadJSON(`../${pluginID}/settings.json`) + const config: PeriodicReviewConfigType = await DataStore.loadJSON(`../${pluginID}/settings.json`) if (config == null || Object.keys(config).length === 0) { logError(pluginJson, `getJournalSettings() cannot find '${pluginID}' plugin settings. Stopping.`) @@ -82,7 +82,7 @@ export async function getJournalSettings(): Promise { // want to use Promis * @param {string} period for journal questions: 'day', 'week', 'month', 'quarter', 'year' * @returns {Promise>} array of question lines, or empty array if unsupported */ -export async function getQuestionsForPeriod(config: JournalConfigType, period: string): Promise> { +export async function getQuestionsForPeriod(config: PeriodicReviewConfigType, period: string): Promise> { let rawQuestionLines: Array = [] switch (period) { case 'day': { @@ -121,7 +121,7 @@ export async function getQuestionsForPeriod(config: JournalConfigType, period: s * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' * @returns {string} */ -export function getSectionHeadingForPeriod(config: JournalConfigType, periodType: string): string { +export function getSectionHeadingForPeriod(config: PeriodicReviewConfigType, periodType: string): string { if (periodType === 'day') { return config.dailyJournalSectionHeading } @@ -144,6 +144,7 @@ export function normalizeReviewPeriodTitleForNPDateHelpers(periodTitle: string): /** * Normalize non-empty lines from the planning textarea for storage (strip task markers / leading `>>`). + * TODO: Is this needed? Surely no leading '>>' from the textarea? * @param {string} planningFormText * @returns {Array} */ @@ -224,14 +225,15 @@ const PLAN_ITEMS_NAME_CONFIG_KEYS: { [string]: string } = { * @param {string} periodType — 'day' | 'week' | 'month' | 'quarter' | 'year' * @returns {string} */ -export function getPlanItemsNameForPeriodType(config: JournalConfigType, periodType: string): string { +export function getPlanItemsNameForPeriodType(config: PeriodicReviewConfigType, periodType: string): string { const key = PLAN_ITEMS_NAME_CONFIG_KEYS[periodType] const fallback = PLAN_ITEMS_NAME_DEFAULTS[periodType] ?? 'Plans' if (key == null) { return fallback } - // $FlowIgnore[prop-missing]: dynamic settings keys from plugin.json - const raw = (config: { [string]: mixed })[key] + // $FlowFixM + // e[invalid-computed-prop] + const raw = config[key] const s = typeof raw === 'string' ? raw.trim() : '' return s !== '' ? s : fallback } diff --git a/jgclark.Journalling/src/reviewHTMLViewGenerator.js b/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js similarity index 95% rename from jgclark.Journalling/src/reviewHTMLViewGenerator.js rename to jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js index 992314fbf..64ad0e5f1 100644 --- a/jgclark.Journalling/src/reviewHTMLViewGenerator.js +++ b/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js @@ -2,12 +2,12 @@ //--------------------------------------------------------------- // HTMLView generation helpers for single-window review mode // Jonathan Clark + Cursor -// last update 2026-04-03 for v2.0.0.b6 by @jgclark + @Cursor +// last update 2026-04-11 for v2.0.0.b9 by @jgclark + @Cursor //--------------------------------------------------------------- import moment from 'moment' import pluginJson from '../plugin.json' -import type { JournalConfigType, ParsedQuestionType } from './journalHelpers' +import type { PeriodicReviewConfigType, ParsedQuestionType } from './periodicReviewHelpers' import { buildNextPlanSectionHeadingTitle, buildThisPlanSectionHeadingTitle, @@ -15,7 +15,7 @@ import { mergeUniqueSummaryDoneTaskLines, splitMergedSummaryDoneLinesIntoWinsAndOthers, substituteReviewPeriodPlaceholders, -} from './journalHelpers' +} from './periodicReviewHelpers' import { getReviewQuestionSegmentRegExpGi } from './reviewQuestions' import { RE_DONE_DATE_OPT_TIME } from '@helpers/dateTime' import { clo, logDebug, logInfo, logError, logWarn } from '@helpers/dev' @@ -139,8 +139,11 @@ function formatTaskAsHTML(taskContent: string): string { * @returns {{ prefix: string, suffix: string }} */ function splitSegmentAtTypeMarker(segment: string, questionType: string): {| prefix: string, suffix: string |} { - const safeType = questionType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const re = new RegExp(`<\\s*${safeType}\\s*>`, 'i') + const pattern = + questionType.toLowerCase() === 'int' + ? '<\\s*(?:integer|int)\\s*>' + : `<\\s*${questionType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*>` + const re = new RegExp(pattern, 'i') const m = segment.match(re) if (!m || m.index == null) { return { prefix: segment, suffix: '' } @@ -160,7 +163,7 @@ function splitSegmentAtTypeMarker(segment: string, questionType: string): {| pre function makeReviewInlineControl( parsedQuestion: ParsedQuestionType, globalIndex: number, - config: JournalConfigType, + config: PeriodicReviewConfigType, initialValue: string = '', ): string { const fieldName = `q_${globalIndex}` @@ -211,7 +214,7 @@ function makeQuestionLineDiv( rawLine: string, lineIndex: number, parsedQuestions: Array, - config: JournalConfigType, + config: PeriodicReviewConfigType, initialAnswers: { [string]: string }, periodString: string, periodType: string, @@ -228,13 +231,10 @@ function makeQuestionLineDiv( const headingTypes = ['subheading', 'h2', 'h3'] const lineHasOnlyHeadingQuestions = lineQuestionsOrdered.length > 0 && lineQuestionsOrdered.every(({ q }) => headingTypes.includes(q.type)) - // `##` / `###` lines have no angle-bracket markup; `

…` / `

…` lines do — both must use the - // heading path. Otherwise `getReviewQuestionSegmentRegExpGi` matches `

` as a “typed segment” + // `##` / `###` lines have no angle-bracket typed segments; use the heading row path so the flex + // segment matcher does not treat heading text as inline fragments. const isMarkdownStyleHeadingLine = lineHasOnlyHeadingQuestions && !cleanRawLine.includes('<') - const isTypedOpenTagHeadingLine = - lineHasOnlyHeadingQuestions && - (/^\s*<\s*h2\s*>/i.test(cleanRawLine) || /^\s*<\s*h3\s*>/i.test(cleanRawLine)) - if (isMarkdownStyleHeadingLine || isTypedOpenTagHeadingLine) { + if (isMarkdownStyleHeadingLine) { return lineQuestionsOrdered .map(({ q, globalIndex }) => makeReviewQuestionRowDiv(q, globalIndex, config, '', periodString, periodType)) .join('\n') @@ -282,8 +282,6 @@ function makeQuestionLineDiv( } if (parts.length === 0) { - // return `
${escapeHTML(cleanRawLine)}
` - // TEST: return `${escapeHTML(cleanRawLine)}` } return `
${parts.join('')}
` @@ -295,17 +293,23 @@ function makeQuestionLineDiv( * @returns {string} HTML string for the summary block */ function makePeriodDaysSummaryDiv(eventsForPeriod: Array): string { - const totalDuration = eventsForPeriod.reduce((total, event) => total + getEventDurationHours(event), 0) const output = [] - output.push(`
${eventsForPeriod.length} events (${totalDuration.toFixed(1)} hours)
`) - output.push(`
`) - eventsForPeriod.forEach( e => { - output.push(`\t
`) - output.push(`\t\t`) - output.push(`\t\t${e.title}`) - output.push('\t
') - }) - output.push(`
`) + if (eventsForPeriod.length > 0) { + const totalDuration = eventsForPeriod.reduce((total, event) => total + getEventDurationHours(event), 0) + output.push(`
${eventsForPeriod.length} events`) + if (totalDuration > 0) { + output.push(` (${totalDuration.toFixed(1)} hours)`) + } + output.push(`
`) + output.push(`
`) + eventsForPeriod.forEach(e => { + output.push(`\t
`) + output.push(`\t\t`) + output.push(`\t\t${e.title}`) + output.push('\t
') + }) + output.push(`
`) + } return output.join('\n') } @@ -496,7 +500,7 @@ function makePlanningSectionHTML(planningSectionTitle: string): string { function makeReviewQuestionRowDiv( parsedQuestion: ParsedQuestionType, index: number, - config: JournalConfigType, + config: PeriodicReviewConfigType, initialValue: string = '', periodString: string, periodType: string, @@ -582,7 +586,7 @@ function makeReviewQuestionRowDiv( * @returns {string} */ export function buildReviewHTML( - config: JournalConfigType, + config: PeriodicReviewConfigType, parsedQuestions: Array, rawQuestionLines: Array, summaryWinTasks: Array, diff --git a/jgclark.Journalling/src/reviewQuestions.js b/jgclark.PeriodicReviews/src/reviewQuestions.js similarity index 89% rename from jgclark.Journalling/src/reviewQuestions.js rename to jgclark.PeriodicReviews/src/reviewQuestions.js index f6c067475..1f001c5b7 100644 --- a/jgclark.Journalling/src/reviewQuestions.js +++ b/jgclark.PeriodicReviews/src/reviewQuestions.js @@ -2,17 +2,28 @@ //--------------------------------------------------------------- // Review question parsing, pre-fill extraction, and answer → note text. // Jonathan Clark -// last update 2026-04-05 for v2.0.0.b7 by @jgclark + @Cursor +// last update 2026-04-11 for v2.0.0.b9 by @jgclark + @Cursor //--------------------------------------------------------------- -import type { ParsedQuestionType } from './journalHelpers' -import { substituteReviewPeriodPlaceholders } from './journalHelpers' +import type { ParsedQuestionType } from './periodicReviewHelpers' +import { substituteReviewPeriodPlaceholders } from './periodicReviewHelpers' import { escapeRegExp } from '@helpers/regex' import { isInt } from '@helpers/userInput' -/** `` names in review question templates — single source for parse + HTML segment splitting. */ +/** `` names in review question templates — single source for parse + HTML segment splitting. (`integer` before `int` so `` matches as one token.) */ export const REVIEW_QUESTION_TYPE_NAMES_ALT = - 'string|int|number|duration|boolean|mood|subheading|h2|h3|bullets|checklists|tasks' + 'string|integer|int|number|duration|boolean|mood|subheading|bullets|checklists|tasks' + +/** + * Strip `:`, parentheses, and angle-bracket type tokens (``, ``, …) from a segment when deriving the human label. + * Must match full `` tags only — not bare names (e.g. `string` would wrongly match inside words and leave stray `<>`). + */ +const RE_SEGMENT_LABEL_STRIP = new RegExp( + `:|\\(|\\)|<\\s*(?:${REVIEW_QUESTION_TYPE_NAMES_ALT.split('|') + .map((t) => escapeRegExp(t)) + .join('|')})\\s*>`, + 'gi', +) /** * TODO: pull these two to be just a constant. @@ -49,16 +60,6 @@ export function parseQuestions(questionLines: Array | string): Array\s*(.+)$/i) - if (mTypedH2) { - parsed.push({ question: String(mTypedH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) - continue - } - const mTypedH3 = line.match(/^\s*<\s*h3\s*>\s*(.+)$/i) - if (mTypedH3) { - parsed.push({ question: String(mTypedH3[1] ?? '').trim(), type: 'h3', originalLine: line, lineIndex }) - continue - } const mH2 = line.match(/^##\s+(.+)$/) if (mH2) { parsed.push({ question: String(mH2[1] ?? '').trim(), type: 'h2', originalLine: line, lineIndex }) @@ -69,14 +70,6 @@ export function parseQuestions(questionLines: Array | string): Array\s*$/i) - if (mTrailingHeadingDecorator) { - const base = String(mTrailingHeadingDecorator[1] ?? '').trim() - if (base !== '') { - parsed.push({ question: base, type: 'string', originalLine: `${base}: `, lineIndex }) - continue - } - } const questionParts = line.split(/\s*\|\|\s*/).map(part => part.trim()).filter(part => part !== '') for (const questionPart of questionParts) { @@ -84,15 +77,12 @@ export function parseQuestions(questionLines: Array | string): Array' + let questionType = String(reArray?.[1] ?? '').toLowerCase() + if (questionType === 'integer') { + questionType = 'int' + } const tokenMatch = segment.match(/([@#][^\s(<]+)/) - const question = tokenMatch?.[1] - ?? segment - .replace( - /:|\(|\)||||||||

|

|||/gi, - '', - ) - .trim() + const question = tokenMatch?.[1] ?? segment.replace(RE_SEGMENT_LABEL_STRIP, '').trim() parsed.push({ question, type: questionType, originalLine: segment, lineIndex }) } } @@ -104,11 +94,11 @@ export function parseQuestions(questionLines: Array | string): Array\s*$/i, '').trim() + const cleanHeading = question.replace(/<\s*subheading\s*>\s*$/i, '').trim() const headingMarker = headingType === 'h2' ? '##' : '###' return `\n${headingMarker} ${cleanHeading}` } @@ -159,8 +149,11 @@ function stripMultilineAnswerPrefixes(rawBlock: string, linePrefix: string): str * @returns {{ prefix: string, suffix: string }} */ function splitParsedSegmentAtTypeMarker(segment: string, questionType: string): {| prefix: string, suffix: string |} { - const safeType = escapeRegExp(questionType) - const re = new RegExp(`<\\s*${safeType}\\s*>`, 'i') + const pattern = + questionType.toLowerCase() === 'int' + ? '<\\s*(?:integer|int)\\s*>' + : `<\\s*${escapeRegExp(questionType)}\\s*>` + const re = new RegExp(pattern, 'i') const m = segment.match(re) if (!m || m.index == null) { return { prefix: segment, suffix: '' } @@ -354,7 +347,9 @@ function answerFromReviewWindowPayload(parsedQuestion: ParsedQuestionType, answe switch (t) { case 'int': { if (isInt(answer)) { - return parsedQuestion.originalLine.startsWith('-') ? `- ${answer}` : parsedQuestion.originalLine.replace(//, answer) + return parsedQuestion.originalLine.startsWith('-') + ? `- ${answer}` + : parsedQuestion.originalLine.replace(/<\s*(?:integer|int)\s*>/i, answer) } return '' } diff --git a/jgclark.Journalling/src/templatesStartEnd.js b/jgclark.PeriodicReviews/src/templatesStartEnd.js similarity index 99% rename from jgclark.Journalling/src/templatesStartEnd.js rename to jgclark.PeriodicReviews/src/templatesStartEnd.js index 05cd329d2..2d5283790 100644 --- a/jgclark.Journalling/src/templatesStartEnd.js +++ b/jgclark.PeriodicReviews/src/templatesStartEnd.js @@ -5,7 +5,7 @@ // last update 2026-04-05 for v0.1.0 by @jgclark //--------------------------------------------------------------- -import { type JournalConfigType, getJournalSettings } from './journalHelpers' +import { type JournalConfigType, getJournalSettings } from './periodicReviewHelpers' import { isDailyNote, isMonthlyNote, isWeeklyNote } from '@helpers/dateTime' import { logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' From 0a950a2abad8fa2e1eeb5703146e63e2e8e9c753 Mon Sep 17 00:00:00 2001 From: jgclark Date: Wed, 15 Apr 2026 09:27:41 +0100 Subject: [PATCH 11/19] b11 --- jgclark.PeriodicReviews/CHANGELOG.md | 11 ++ .../__tests__/periodReviews.test.js | 60 +++++++-- jgclark.PeriodicReviews/plugin.json | 36 ++++-- .../requiredFiles/reviews.css | 38 +++++- jgclark.PeriodicReviews/src/periodReviews.js | 114 ++++++++++++------ .../src/periodicReviewHelpers.js | 40 +++++- .../src/reviewHTMLViewGenerator.js | 110 ++++++++++++----- 7 files changed, 325 insertions(+), 84 deletions(-) diff --git a/jgclark.PeriodicReviews/CHANGELOG.md b/jgclark.PeriodicReviews/CHANGELOG.md index a2685bc88..323fc4b28 100644 --- a/jgclark.PeriodicReviews/CHANGELOG.md +++ b/jgclark.PeriodicReviews/CHANGELOG.md @@ -3,6 +3,17 @@ _Please also see the [Plugin Documentation](https://noteplan.co/plugins/jgclark. Note: this is a new plugin, forked from my original **Journalling Helpers** one. That will remain available for users who need to run NotePlan 3.19 or earlier -- which doesn't support integrated plugin windows -- but will be retired in due course. +## [2.0.0.b11] - 2026-04-15 +- Make the details in the summary sections +collapsible +- Fix so that review commands no longer switch the editor to the “current” period’s note when you already have another calendar note of that same kind open (for example, yesterday’s daily note stays open instead of jumping to today). + +## [2.0.0.b10] - 2026-04-13 +- Review window **Summary**: completed-task list(s) and the calendar events list are each wrapped in HTML `
` / `` (expanded by default) so you can collapse the lists while keeping the headings visible. +- New optional settings **Planned items prefix** (default `>>`) and **Planned items suffix** (default `#win`) for text written with each planned item into the **next** period’s calendar note. +- Review window submit: do not log “no template question answers” when the user only filled the **planning** textarea (next-period plan lines still count as a substantive submit). +- Review window callback: `onReviewWindowAction` now **returns `{}`** on every path when invoked via `DataStore.invokePluginCommandByName` (required by NotePlan; missing return can stop the handler after the “Executing function” log). Normalize a single-array bridge payload `[actionName, payload]` when needed; bail out cleanly if settings fail to load. + ## [2.0.0.b9] - 2026-04-11 - settled on name 'Periodic Reviews' not 'Journalling & Reviews' - Summary / carry-over plan tasks: include **cancelled** `>>` lines (e.g. `* [-] >> …`); they show as **not** done like open items. Fix: plan-section extraction no longer stopped after the first task under a matching H2. diff --git a/jgclark.PeriodicReviews/__tests__/periodReviews.test.js b/jgclark.PeriodicReviews/__tests__/periodReviews.test.js index c6fef0f2b..44fe85afc 100644 --- a/jgclark.PeriodicReviews/__tests__/periodReviews.test.js +++ b/jgclark.PeriodicReviews/__tests__/periodReviews.test.js @@ -1,10 +1,12 @@ -/* globals describe, expect, test, it, jest, beforeAll, beforeEach, afterEach */ +/* globals describe, expect, it, beforeAll, beforeEach */ -// Last updated: 2026-03-25 for v2.0.0.b3 by @Cursor +// Last updated: 2026-04-13 for v2.0.0.b10 by @jgclark import { buildNextPeriodNotePlanSectionHeadingTitle, buildNextPlanSectionHeadingTitle, + formatPlannedItemLineForNextNote, + getEffectivePlannedItemAffixes, getPeriodAdjectiveFromType, getPlanItemsNameForPeriodType, mergeUniqueSummaryDoneTaskLines, @@ -288,17 +290,13 @@ Mood: ` const formIdx = afterSummary.indexOf('|') + expect(summaryInner).toContain('
') + expect(summaryInner).toContain('
') + expect(summaryInner.indexOf('summary-details-carry-over-plan')).toBeLessThan(summaryInner.indexOf('summary-details-completed-tasks')) + expect(summaryInner.indexOf('summary-details-completed-tasks')).toBeLessThan(summaryInner.indexOf('summary-details-events')) }) it('should list wins first then "other completed task(s)" heading and other dones (daily, each once)', () => { @@ -321,6 +319,11 @@ Mood: ` ) expect(html.indexOf('summary-content-wins')).toBe(-1) expect(html).toContain('summary-content-completed-tasks') + expect(html).toMatch(/\bsummary-details-completed-wins\b/) + expect(html).toMatch(/\bsummary-details-completed-other\b/) + expect(html).toContain('
') + expect(html).toContain('
') + expect(html).toContain('
') expect(html).toContain('1 other completed task') expect(html).not.toMatch(/\b2 completed tasks\b/) expect(html.indexOf('Planned win')).toBeLessThan(html.indexOf('other completed')) @@ -636,6 +639,37 @@ Ship: `, expect(normalizePlanningTaskLinesFromForm('>> solo')).toEqual(['solo']) }) + it('formatPlannedItemLineForNextNote should apply prefix/suffix with sensible spacing', () => { + expect(formatPlannedItemLineForNextNote('foo', '>> ', '#win')).toBe('>> foo #win') + expect(formatPlannedItemLineForNextNote('foo', null, '#win')).toBe('foo #win') + expect(formatPlannedItemLineForNextNote('foo', '>> ', null)).toBe('>> foo') + expect(formatPlannedItemLineForNextNote('foo', null, null)).toBe('foo') + expect(formatPlannedItemLineForNextNote('foo', '', '')).toBe('foo') + expect(formatPlannedItemLineForNextNote('foo', '>>', '#win')).toBe('>> foo #win') + }) + + it('getEffectivePlannedItemAffixes should default missing keys to empty affixes and treat blank strings as disabled', () => { + expect(getEffectivePlannedItemAffixes({})).toEqual({ prefix: '', suffix: '' }) + expect( + getEffectivePlannedItemAffixes({ + plannedItemsPrefix: '', + plannedItemsSuffix: '', + }), + ).toEqual({ + prefix: null, + suffix: null, + }) + expect( + getEffectivePlannedItemAffixes({ + plannedItemsPrefix: '!! ', + plannedItemsSuffix: '', + }), + ).toEqual({ + prefix: '!! ', + suffix: null, + }) + }) + it('extractPlanSectionItems should read open and done tasks under matching H2', () => { const heading = 'Top 3 Wins for the next week' const note = { diff --git a/jgclark.PeriodicReviews/plugin.json b/jgclark.PeriodicReviews/plugin.json index cc049a811..49ce67493 100644 --- a/jgclark.PeriodicReviews/plugin.json +++ b/jgclark.PeriodicReviews/plugin.json @@ -9,7 +9,7 @@ "plugin.author": "Jonathan Clark", "plugin.url": "https://noteplan.co/plugins/jgclark.PeriodicReviews/", "plugin.changelog": "https://noteplan.co/plugins/jgclark.PeriodicReviews/CHANGELOG.md", - "plugin.version": "2.0.0.b8", + "plugin.version": "2.0.0.b11", "plugin.releaseStatus": "beta", "plugin.lastUpdateInfo": "v2.0.0: First release of the new Periodic Reviews plugin, building on earlier 'Journalling Helpers' plugin (v1).", "plugin.requiredFiles": [ @@ -50,7 +50,13 @@ "review" ], "description": "Ask Review questions at end-of-week", - "jsFunction": "weeklyReviewQuestions" + "jsFunction": "weeklyReviewQuestions", + "sidebarView": { + "windowID": "jgclark.PeriodicReviews.weekReview", + "title": "Weekly Review", + "icon": "fa-calendar-week", + "iconColor": "purple-500" + } }, { "name": "Monthly Review", @@ -245,7 +251,7 @@ { "key": "dayPlanItemsName", "title": "Daily Planned items heading", - "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned tasks to write to the next day's note (e.g. 'Big Wins' for next day).", + "description": "Used in the review window and as the H2 title prefix for the main few planned tasks to write to the next day's note (e.g. 'Big Wins' for next day). Optional.", "type": "string", "default": "Big Wins", "required": false @@ -261,7 +267,7 @@ { "key": "weekPlanItemsName", "title": "Weekly Planned items heading", - "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned tasks to write to the next week's note (e.g. 'Big Rocks' for next week).", + "description": "Used in the review window and as the H2 title prefix for the main few planned tasks to write to the next week's note (e.g. 'Big Rocks' for next week). Optional.", "type": "string", "default": "Big Rocks", "required": false @@ -277,7 +283,7 @@ { "key": "monthPlanItemsName", "title": "Name for Monthly Planned items", - "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned items to write to the next month's note (e.g. 'Key Outcomes' for next month).", + "description": "Used in the review window and as the H2 title prefix for the main few planned items to write to the next month's note (e.g. 'Key Outcomes' for next month). Optional.", "type": "string", "default": "Key Outcomes", "required": false @@ -293,7 +299,7 @@ { "key": "quarterPlanItemsName", "title": "Name for Quarterly Planned items", - "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned items to write to the next quarter's note (e.g. 'Goals' for next quarter').", + "description": "Used in the review window and as the H2 title prefix for the main few planned items to write to the next quarter's note (e.g. 'Goals' for next quarter'). Optional.", "type": "string", "default": "Goals", "required": false @@ -309,11 +315,27 @@ { "key": "yearPlanItemsName", "title": "Name for Yearly Planned items", - "description": "Optional. Used in the review window and as the H2 title prefix for the main few planned items to write to the next year's note (e.g. 'Themes' for next year').", + "description": "Used in the review window and as the H2 title prefix for the main few planned items to write to the next year's note (e.g. 'Themes' for next year'). Optional.", "type": "string", "default": "Theme", "required": false }, + { + "key": "plannedItemsPrefix", + "title": "Planned items prefix (for next period note)", + "description": "String added to the start of each planned item when writing them into the next period's calendar note. Default is '>>' but other priority or strings can be used. Optional.", + "type": "string", + "default": ">>", + "required": false + }, + { + "key": "plannedItemsSuffix", + "title": "Planned items suffix (for next period note)", + "description": "String added to the end of each planned item when writing them into the next period's calendar note. Optional.", + "type": "string", + "default": "#win", + "required": false + }, { "type": "separator" }, diff --git a/jgclark.PeriodicReviews/requiredFiles/reviews.css b/jgclark.PeriodicReviews/requiredFiles/reviews.css index cf7768311..b36b0b1d8 100644 --- a/jgclark.PeriodicReviews/requiredFiles/reviews.css +++ b/jgclark.PeriodicReviews/requiredFiles/reviews.css @@ -1,4 +1,4 @@ -/* last update 2026-04-02 for v2.0.0.b6 by @jgclark */ +/* last update 2026-04-15 for v2.0.0.b10 by @jgclark */ body { color: var(--fg-main-color, #4c4f69); @@ -77,6 +77,42 @@ body { padding: 0rem 0rem 0.4rem 0.3rem; } +/* Collapsible summary lists (`
` / `` in review HTML) */ +.summary-details { + margin: 0 0 0.35rem 0; +} + +.summary-details > summary.summary-title { + cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + width: 100%; + box-sizing: border-box; + list-style: none; +} + +/* Native triangle is tiny or missing in some WebViews; use a right-side chevron instead */ +.summary-details > summary.summary-title::-webkit-details-marker { + display: none; +} + +.summary-details > summary.summary-title::after { + content: '\25B8'; + flex-shrink: 0; + font-size: 1.5rem; + line-height: 1; + opacity: 0.75; + color: var(--item-icon-color, #1e66f5); + transition: transform 0.15s ease; +} + +.summary-details[open] > summary.summary-title::after { + transform: rotate(90deg); +} + .summary-content { column-count: 2; column-gap: 1rem; diff --git a/jgclark.PeriodicReviews/src/periodReviews.js b/jgclark.PeriodicReviews/src/periodReviews.js index 632ad95f5..2b9f91edd 100644 --- a/jgclark.PeriodicReviews/src/periodReviews.js +++ b/jgclark.PeriodicReviews/src/periodReviews.js @@ -2,13 +2,15 @@ //--------------------------------------------------------------- // Journalling commands // Jonathan Clark -// last update 2026-04-11 for v2.0.0.b9 by @jgclark + @Cursor +// last update 2026-04-13 for v2.0.0.b10 by @jgclark + @Cursor //--------------------------------------------------------------- import strftime from 'strftime' import pluginJson from '../plugin.json' import { buildNextPeriodNotePlanSectionHeadingTitle, + formatPlannedItemLineForNextNote, + getEffectivePlannedItemAffixes, getJournalSettings, getPeriodAdjectiveFromType, getPlanItemsNameForPeriodType, @@ -129,22 +131,21 @@ function removePlanSectionFromNoteIfPresent(note: TNote, headingTitle: string): /** * Insert plan heading and open tasks at the start of the active body. - * Uses `insertParagraph(..., 'open')`, which adds the task `*` marker — pass body text `>> …` only so the note shows `* >> …`, not `* * >> …`. + * Uses `insertParagraph(..., 'open')`, which adds the task `*` marker — pass full line text (prefix/suffix already applied), not a second task marker. * @param {TNote} note * @param {string} headingTitle - * @param {Array} taskTexts normalized plain lines (no `*` / `>>`) + * @param {Array} taskLines full paragraph content (not rawContent) per planned item (after prefix/suffix formatting) * @returns {void} */ -function insertPlanSectionAtActiveStart(note: TNote, headingTitle: string, taskTexts: Array): void { +function insertPlanSectionAtActiveStart(note: TNote, headingTitle: string, taskLines: Array): void { const startIdx = findStartOfActivePartOfNote(note) if (Number.isNaN(startIdx)) { logWarn(pluginJson, 'insertPlanSectionAtActiveStart: invalid start index') return } note.insertHeading(headingTitle, startIdx, 2) - for (let i = 0; i < taskTexts.length; i++) { - const line = `>> ${taskTexts[i]}` - note.insertParagraph(line, startIdx + 1 + i, 'open') + for (let i = 0; i < taskLines.length; i++) { + note.insertParagraph(taskLines[i], startIdx + 1 + i, 'open') } } @@ -260,9 +261,11 @@ export async function writePlanningTasksToNextPeriodNote( logDebug('writePlanningTasksToNextPeriodNote', `Note '${nextTitle}' opened; will now write (or replace) plan section heading '${headingTitle}'`) const normalizedLines = normalizePlanningTaskLinesFromForm(planningFormText) + const { prefix: plannedPrefix, suffix: plannedSuffix } = getEffectivePlannedItemAffixes(config) + const formattedLines = normalizedLines.map((body) => formatPlannedItemLineForNextNote(body, plannedPrefix, plannedSuffix)) removePlanSectionFromNoteIfPresent(nextNote, headingTitle) - if (normalizedLines.length > 0) { - insertPlanSectionAtActiveStart(nextNote, headingTitle, normalizedLines) + if (formattedLines.length > 0) { + insertPlanSectionAtActiveStart(nextNote, headingTitle, formattedLines) } DataStore.updateCache(nextNote, true) } catch (err) { @@ -282,7 +285,7 @@ async function runReviewQuestionsForCurrentPeriod(periodType: string, getPeriodS try { const thisPeriodStr = getPeriodString() logDebug(pluginJson, `Starting for ${periodType} (currently ${thisPeriodStr})`) - await processReviewQuestions(thisPeriodStr, periodType) + await processReviewQuestions(thisPeriodStr, periodType, { preferOpenNoteOfMatchingPeriodType: true }) } catch (error) { logError(pluginJson, error.message) } @@ -336,25 +339,38 @@ export async function yearlyReviewQuestions(): Promise { * @author @jgclark * @param {string} periodStringIn the calendar note title string for the review period * @param {string} periodType for journal questions: 'day', 'week', 'month', 'quarter', 'year' + * @param {{ preferOpenNoteOfMatchingPeriodType?: boolean }} options when `preferOpenNoteOfMatchingPeriodType` is true (commands that target the “current” period), keep the editor focused if it already has any calendar note of that period type (e.g. do not switch a daily note from yesterday to today). When false or omitted, the open note is only reused if its title matches `periodStringIn` (needed for refresh / prev-next navigation). */ -async function processReviewQuestions(periodStringIn: string = '', periodType: string): Promise { +async function processReviewQuestions( + periodStringIn: string = '', + periodType: string, + options?: {| preferOpenNoteOfMatchingPeriodType?: boolean |}, +): Promise { try { const periodAdjective = getPeriodAdjectiveFromType(periodType) + const preferOpenSameKind = options?.preferOpenNoteOfMatchingPeriodType === true // Get configuration const config: PeriodicReviewConfigType = await getJournalSettings() let reviewNote: ?TNote = null - // Check that we have the correct period note open (same type *and* same title as requested). + // Reuse the editor note when it is a calendar note of the requested period *kind* (day/week/…). + // For refresh / navigatePeriod we require an exact title match so we actually move to the requested period. const { note: openEditorNote } = Editor const wantTitle = String(periodStringIn).trim() const openTitle = String(openEditorNote?.title ?? '').trim() const titlesMatch = normalizeReviewPeriodTitleForNPDateHelpers(openTitle) === normalizeReviewPeriodTitleForNPDateHelpers(wantTitle) + const openPeriodKind = openTitle !== '' ? getPeriodOfNPDateStr(openTitle) : '(error)' + const editorHasMatchingPeriodType = openEditorNote != null && openPeriodKind === periodType + const useOpenNote = editorHasMatchingPeriodType && (preferOpenSameKind || titlesMatch) // TODO: Check for Teamspace stuff here - if (openEditorNote && getPeriodOfNPDateStr(openTitle) === periodType && titlesMatch) { - // Use the existing open note + if (useOpenNote) { reviewNote = openEditorNote - logDebug('processReviewQuestions', `Starting with open note '${String(reviewNote?.title ?? 'unknown')}' of period '${String(getPeriodOfNPDateStr(reviewNote?.title ?? ''))}'`) + logDebug( + 'processReviewQuestions', + `Starting with open note '${String(reviewNote?.title ?? 'unknown')}' of period '${String(getPeriodOfNPDateStr(reviewNote?.title ?? ''))}'` + + (preferOpenSameKind && !titlesMatch ? ` (keeping editor instead of '${String(periodStringIn)}')` : ''), + ) } else { // use the passed periodStringIn to open the correct note logDebug('processReviewQuestions', `Starting by opening current ${periodAdjective} note '${String(periodStringIn)}'`) @@ -636,70 +652,97 @@ function parseReviewWindowPayload(payload: mixed): any { return {} } +/** + * Normalize args from DataStore.invokePluginCommandByName: usually (actionName, payload), but some bridges pass one array [actionName, payload]. + * @param {mixed} actionNameIn + * @param {mixed} payloadIn + * @returns {{ actionName: string, payload: mixed }} + */ +function normalizeReviewWindowInvokeArgs(actionNameIn: mixed, payloadIn: mixed): { actionName: string, payload: mixed } { + if (Array.isArray(actionNameIn)) { + return { + actionName: String(actionNameIn[0] ?? ''), + payload: actionNameIn.length >= 2 ? actionNameIn[1] : payloadIn, + } + } + return { + actionName: typeof actionNameIn === 'string' ? actionNameIn : String(actionNameIn ?? ''), + payload: payloadIn, + } +} + /** * Callback function for HTML single-window review actions. - * @param {string} actionName + * Must return a value when invoked via DataStore.invokePluginCommandByName or NotePlan can fail silently after logging execution. + * @param {mixed} actionNameIn * @param {mixed} payload — JSON string or object (NotePlan may pass either) + * @returns {Promise<{||}>} */ -export async function onReviewWindowAction(actionName: string, payload: mixed = ''): Promise { +export async function onReviewWindowAction(actionNameIn: mixed, payload: mixed = ''): Promise<{||}> { + const { actionName, payload: payloadResolved } = normalizeReviewWindowInvokeArgs(actionNameIn, payload) logDebug(pluginJson, `onReviewWindowAction action=${actionName}`) // logDebug(pluginJson, `onReviewWindowAction payloadLength=${String(payload?.length ?? 0)} payloadPreview="${String(payload ?? '').slice(0, 100)}"`) if (actionName === 'cancel') { logDebug('Journalling/onReviewWindowAction', `Cancelled by user.`) closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) - return + return {} } if (actionName === 'refresh') { let refreshPayload: any = {} try { - refreshPayload = parseReviewWindowPayload(payload) + refreshPayload = parseReviewWindowPayload(payloadResolved) } catch (err) { logError(pluginJson, `onReviewWindowAction: refresh could not parse payload: ${err.message}`) - return + return {} } const periodType = String(refreshPayload.periodType ?? '') const periodString = String(refreshPayload.periodString ?? '') if (periodType === '' || periodString === '') { logWarn(pluginJson, 'onReviewWindowAction: refresh missing periodType or periodString') - return + return {} } logDebug('Journalling/onReviewWindowAction', `Refresh: reopening review for ${periodType} ${periodString}`) await processReviewQuestions(periodString, periodType) - return + return {} } if (actionName === 'navigatePeriod') { let navPayload: any = {} try { - navPayload = parseReviewWindowPayload(payload) + navPayload = parseReviewWindowPayload(payloadResolved) } catch (err) { logError(pluginJson, `onReviewWindowAction: navigatePeriod could not parse payload: ${err.message}`) - return + return {} } const periodType = String(navPayload.periodType ?? '') const periodString = String(navPayload.periodString ?? '') const direction = String(navPayload.direction ?? '') if (periodType === '' || periodString === '' || (direction !== 'prev' && direction !== 'next')) { logWarn(pluginJson, 'onReviewWindowAction: navigatePeriod missing periodType, periodString, or valid direction') - return + return {} } const targetPeriodString = direction === 'next' ? getNextNPPeriodString(periodString, periodType) : getPreviousNPPeriodString(periodString, periodType) if (targetPeriodString === '') { logWarn(pluginJson, `onReviewWindowAction: navigatePeriod could not compute ${direction} period from "${periodString}" (${periodType})`) - return + return {} } logDebug('Journalling/onReviewWindowAction', `Navigate ${direction}: reopening review for ${periodType} ${targetPeriodString}`) await processReviewQuestions(targetPeriodString, periodType) - return + return {} } - const config: PeriodicReviewConfigType = await getJournalSettings() + const configMaybe: ?PeriodicReviewConfigType = await getJournalSettings() + if (configMaybe == null || typeof configMaybe !== 'object') { + logError(pluginJson, 'onReviewWindowAction: no journal settings loaded; cannot save review') + return {} + } + const config: PeriodicReviewConfigType = configMaybe let safePayload: any = {} try { // Allow callback payloads as JSON string (x-callback / jsBridge) or as an object (native bridge). - safePayload = parseReviewWindowPayload(payload) + safePayload = parseReviewWindowPayload(payloadResolved) clo(safePayload, `onReviewWindowAction: parsed payload`) let answers = safePayload.answers ?? {} // Get the period type and string from the hidden fields in the payload @@ -720,21 +763,24 @@ export async function onReviewWindowAction(actionName: string, payload: mixed = } answers = extractedAnswers } + const planningRaw = + answers.planning_tasks ?? (typeof safePayload === 'object' && safePayload != null ? (safePayload: any).planning_tasks : undefined) + const planningText = typeof planningRaw === 'string' ? planningRaw : String(planningRaw ?? '') + const hasPlanningContent = normalizePlanningTaskLinesFromForm(planningText).length > 0 const questionLines = await getQuestionsForPeriod(config, periodType) const parsedQuestions = parseQuestions(questionLines) const output = buildOutputFromReviewWindowAnswers(parsedQuestions, questionLines, periodString, periodType, answers) if (output !== '') { await writeAnswersToNote(periodString, periodType, output) - } else { + } else if (!hasPlanningContent) { logWarn(pluginJson, 'No template question answers were collected from the review window') } - const planningRaw = answers.planning_tasks - const planningText = typeof planningRaw === 'string' ? planningRaw : String(planningRaw ?? '') await writePlanningTasksToNextPeriodNote(config, periodString, periodType, planningText) logDebug('Journalling/onReviewWindowAction', `Finished.`) closeWindowFromCustomId(REVIEW_WINDOW_CUSTOM_ID) - + return {} } catch (err) { - logError(pluginJson, `onReviewWindowAction: couldn't parse payload JSON string: ${err.message}`) + logError(pluginJson, `onReviewWindowAction: ${err.message}`) + return {} } } diff --git a/jgclark.PeriodicReviews/src/periodicReviewHelpers.js b/jgclark.PeriodicReviews/src/periodicReviewHelpers.js index 1cab631f4..22f0cab45 100644 --- a/jgclark.PeriodicReviews/src/periodicReviewHelpers.js +++ b/jgclark.PeriodicReviews/src/periodicReviewHelpers.js @@ -2,7 +2,7 @@ //--------------------------------------------------------------- // Helper functions for Journalling plugin for NotePlan // Jonathan Clark -// last update 2026-04-11 for v2.0.0.b9 by @jgclark +// last update 2026-04-13 for v2.0.0.b10 by @jgclark //--------------------------------------------------------------- import pluginJson from '../plugin.json' @@ -38,6 +38,8 @@ export type PeriodicReviewConfigType = { yearlyReviewQuestions: string, moods: string, calendarSet: Array, + plannedItemsPrefix?: string, + plannedItemsSuffix?: string, } /** One parsed segment from review settings; `` names are defined in `reviewQuestions.js` (`REVIEW_QUESTION_TYPE_NAMES_ALT`). */ @@ -163,6 +165,42 @@ export function normalizePlanningTaskLinesFromForm(planningFormText: string): Ar .filter((t) => t !== '') } +/** + * Effective prefix/suffix for lines written to the next period note (after `normalizePlanningTaskLinesFromForm`). + * Defaults are empty string, if not set. + * @param {PeriodicReviewConfigType} config + * @returns {{ prefix: string, suffix: string }} + */ +export function getEffectivePlannedItemAffixes(config: PeriodicReviewConfigType): { prefix: string, suffix: string } { + const affix = (raw: mixed, defaultValue: string): ?string => { + if (raw === undefined || raw === null || typeof raw !== 'string') return defaultValue + return raw.trim() === '' ? null : raw + } + return { + prefix: affix(config.plannedItemsPrefix, ''), + suffix: affix(config.plannedItemsSuffix, ''), + } +} + +/** + * Build one task line body for the next period note: optional suffix on `body`, then optional prefix. + * @param {string} body normalized plain text (no task marker) + * @param {?string} prefix + * @param {?string} suffix + * @returns {string} + */ +export function formatPlannedItemLineForNextNote(body: string, prefix: ?string, suffix: ?string): string { + const join = (left: string, right: string): string => { + if (left === '') return right + if (right === '') return left + return /\s$/.test(left) || /^\s/.test(right) ? `${left}${right}` : `${left} ${right}` + } + let line = body + if (suffix != null) line = join(line, suffix) + if (prefix != null) line = join(prefix, line) + return line +} + /** * Replace `` with the calendar period title and `` / `` with the following period. * Used for the review HTML window and for text written back to the note. diff --git a/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js b/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js index 64ad0e5f1..aa618d8da 100644 --- a/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js +++ b/jgclark.PeriodicReviews/src/reviewHTMLViewGenerator.js @@ -2,7 +2,7 @@ //--------------------------------------------------------------- // HTMLView generation helpers for single-window review mode // Jonathan Clark + Cursor -// last update 2026-04-11 for v2.0.0.b9 by @jgclark + @Cursor +// last update 2026-04-15 for v2.0.0.b10 by @jgclark + @Cursor //--------------------------------------------------------------- import moment from 'moment' @@ -287,30 +287,65 @@ function makeQuestionLineDiv( return `
${parts.join('')}
` } +/** + * Heading for the wins-only block when wins and other completed tasks are both shown. + * @param {number} count + * @returns {string} + */ +function formatWinsSummaryHeading(count: number): string { + return count === 1 ? '1 win' : `${count} wins` +} + +/** + * Wrap a summary subheading and body in `
` / `` for collapsible lists. + * @param {string} detailsExtraClassNames classes after base `summary-details` (e.g. `summary-details-events`) + * @param {string} summaryLabelPlainText visible heading (HTML-escaped) + * @param {string} bodyInnerHtml markup after `` + * @param {{ defaultOpen?: boolean, summaryExtraClasses?: string }=} opts first summary defaults open; later sections default closed + * @returns {string} + */ +function wrapSummaryDetailsBlock( + detailsExtraClassNames: string, + summaryLabelPlainText: string, + bodyInnerHtml: string, + opts?: {| defaultOpen?: boolean, summaryExtraClasses?: string |}, +): string { + const extra = detailsExtraClassNames.trim() + const classes = extra === '' ? 'summary-details' : `summary-details ${extra}` + const defaultOpen = opts?.defaultOpen ?? true + const summaryExtra = opts?.summaryExtraClasses != null ? opts.summaryExtraClasses.trim() : '' + const summaryClasses = summaryExtra === '' ? 'summary-title' : `summary-title ${summaryExtra}` + const openAttr = defaultOpen ? ' open' : '' + return `
+ ${escapeHTML(summaryLabelPlainText)} +${bodyInnerHtml} +
` +} + /** * Build a summary of calendar events for the review period: count and total timed duration (all-day excluded from hours). * @param {Array} eventsForPeriod * @returns {string} HTML string for the summary block */ function makePeriodDaysSummaryDiv(eventsForPeriod: Array): string { - const output = [] - if (eventsForPeriod.length > 0) { - const totalDuration = eventsForPeriod.reduce((total, event) => total + getEventDurationHours(event), 0) - output.push(`
${eventsForPeriod.length} events`) - if (totalDuration > 0) { - output.push(` (${totalDuration.toFixed(1)} hours)`) - } - output.push(`
`) - output.push(`
`) - eventsForPeriod.forEach(e => { - output.push(`\t
`) - output.push(`\t\t`) - output.push(`\t\t${e.title}`) - output.push('\t
') - }) - output.push(`
`) + if (eventsForPeriod.length === 0) { + return '' } - return output.join('\n') + const totalDuration = eventsForPeriod.reduce((total, event) => total + getEventDurationHours(event), 0) + let title = `${eventsForPeriod.length} events` + if (totalDuration > 0) { + title += ` (${totalDuration.toFixed(1)} hours)` + } + const output = [] + output.push(`
`) + eventsForPeriod.forEach((e) => { + output.push(`\t
`) + output.push(`\t\t`) + output.push(`\t\t${e.title}`) + output.push('\t
') + }) + output.push(`
`) + return wrapSummaryDetailsBlock('summary-details-events', title, output.join('\n'), { defaultOpen: false }) } /** @@ -328,6 +363,7 @@ function getEventDurationHours(event: TCalendarItem): number { /** * First summary block: carried-over plan tasks from this note (open = hollow circle, done = check). + * Wrapped in `
` so the heading matches other summary sections; first section stays expanded by default. * @param {Array<{ content: string, isDone: boolean }>} carryOverPlanItems * @returns {string} HTML or empty when none */ @@ -336,7 +372,6 @@ function makeCarryOverPlanSummaryContentDiv( carryOverPlanItems: Array<{ content: string, isDone: boolean }>, ): string { const rows: Array = [] - rows.push(`
${escapeHTML(planningSectionTitle)}
`) rows.push(`
`) if (carryOverPlanItems.length > 0) { carryOverPlanItems.forEach((item) => { @@ -357,9 +392,11 @@ function makeCarryOverPlanSummaryContentDiv( } else { rows.push(`No planned items found for this period`) } - // Single close for summary-content — avoid an extra
when items exist (that closed section-wrap early). rows.push(`