diff --git a/dwertheimer.TaskSorting/CHANGELOG.md b/dwertheimer.TaskSorting/CHANGELOG.md index 1cfd39e30..258618d2c 100644 --- a/dwertheimer.TaskSorting/CHANGELOG.md +++ b/dwertheimer.TaskSorting/CHANGELOG.md @@ -4,13 +4,18 @@ See Plugin [README](https://github.com/NotePlan/plugins/blob/main/dwertheimer.TaskSorting/README.md) for details on available commands and use case. +## [1.2.1] - 2024-10-13 (@jgclark) _unreleased_ +- added basic sortTasksViaExternalCall() as an exported command +- added ability for "Sort tasks under heading" to be called externally with a passed heading parameter +- removed code for openTasksToTop() which didn't work and wasn't exposed as a command +- code tidy up, and fix the flow errors I was confident about + ## [1.2.0] - 2025-01-25 (@dwertheimer) - Added sortTasksUnderHeading command to sort tasks under a heading. - Added /cnt command ## [1.1.0] - 2024-05-26 (@aaronpoweruser) - - Added /cnt command to copy **all** noteTags to **all** tasks in a note. - Added an onSave trigger command for cnt. diff --git a/dwertheimer.TaskSorting/plugin.json b/dwertheimer.TaskSorting/plugin.json index 06779595f..937525bff 100644 --- a/dwertheimer.TaskSorting/plugin.json +++ b/dwertheimer.TaskSorting/plugin.json @@ -3,14 +3,10 @@ "noteplan.minAppVersion": "3.4.0", "plugin.id": "dwertheimer.TaskSorting", "plugin.name": "🥷 Task Sorting & Tools", - "plugin.version": "1.2.0", - "plugin.lastUpdateInfo": "1.2.0: Added heading and sortOrder as params that can be passed. Added \\cnt copy noteTags command.\nInitial release of commands moved from the Task Automations plugin to the TaskSorter plugin.", + "plugin.version": "1.2.1", + "plugin.lastUpdateInfo": "1.2.1: Added 'sortTasksViaExternalCall' hidden command for external calls or other plugins.\n1.2.0: Added heading and sortOrder as params that can be passed. Added \\cnt copy noteTags command.\nInitial release of commands moved from the Task Automations plugin to the TaskSorter plugin.", "plugin.description": "Commands for sorting tasks in a note", "plugin.author": "dwertheimer", - "plugin.requiredFiles-EDIT_ME": [ - "html-plugin-comms.js" - ], - "plugin.requiredFiles-NOTE": "If you want to use HTML windows, remove the '-EDIT_ME' ABOVE", "plugin.dependencies": [], "plugin.script": "script.js", "plugin.url": "https://github.com/NotePlan/plugins/blob/main/dwertheimer.TaskSorting/README.md", @@ -34,8 +30,8 @@ ] }, { - "name": "Sort tasks under heading (choose)", - "description": "sth", + "name": "Sort tasks under heading", + "description": "Sort tasks under heading", "jsFunction": "sortTasksUnderHeading", "alias": [ "tsh" @@ -47,7 +43,7 @@ }, { "name": "Tasks Sort by User Default (in settings)", - "description": "tsd", + "description": "Tasks Sort by User Default (in settings)", "jsFunction": "sortTasksDefault", "alias": [ "tsd" @@ -55,7 +51,7 @@ }, { "name": "Tasks Sort by calendar due date", - "description": "tsc", + "description": "Tasks Sort by calendar due date", "jsFunction": "sortTasksByDue", "alias": [ "tsc" @@ -63,7 +59,7 @@ }, { "name": "Tasks Sort by @Mention/person", - "description": "tsm", + "description": "Tasks Sort by @Mention/person", "jsFunction": "sortTasksByPerson", "alias": [ "tsm" @@ -71,7 +67,7 @@ }, { "name": "Tasks Sort by #Tag", - "description": "tst", + "description": "Tasks Sort by #Tag", "jsFunction": "sortTasksByTag", "alias": [ "tst" @@ -79,7 +75,7 @@ }, { "name": "Tasks Sort by #Tag + @Mention", - "description": "tstm", + "description": "Tasks Sort by #Tag + @Mention", "jsFunction": "sortTasksTagMention", "alias": [ "tstm" @@ -87,7 +83,7 @@ }, { "name": "Tasks to Top - Bring all tasks in note to top", - "description": "tt", + "description": "Tasks to Top - Bring all tasks in note to top", "jsFunction": "tasksToTop", "alias": [ "tt" @@ -95,7 +91,7 @@ }, { "name": "Mark All Tasks on Page (open or complete)", - "description": "mat", + "description": "Mark All Tasks on Page (open or complete)", "jsFunction": "markTasks", "alias": [ "mat" @@ -104,33 +100,57 @@ { "name": "cta - Copy tags from previous line", "description": "Copy #tags and @mentions from previous line", - "jsFunction": "copyTagsFromLineAbove" + "jsFunction": "copyTagsFromLineAbove", + "alias": [ + "cta" + ] }, { "name": "cth - Copy tags from heading above", "description": "Copy #tags/@mentions from heading to all lines between", - "jsFunction": "copyTagsFromHeadingAbove" + "jsFunction": "copyTagsFromHeadingAbove", + "alias": [ + "cth" + ] }, { "name": "ctm - Copy line for each mention", "description": "Copy line for each @mention, listing it first", - "jsFunction": "copyLineForEachMention" + "jsFunction": "copyLineForEachMention", + "alias": [ + "ctm" + ] }, { "name": "ctt - Copy line for each hashtag", "description": "Copy line for each #hashtag, listing it first", - "jsFunction": "copyLineForEachHashtag" + "jsFunction": "copyLineForEachHashtag", + "alias": [ + "ctt" + ] }, { "name": "cnt - Copy tags from noteTags", "description": "Copies all noteTags to all tasks in note", - "jsFunction": "addNoteTagsToAllTask" + "jsFunction": "addNoteTagsToAllTask", + "alias": [ + "cnt" + ] }, { - "name": "Add a onSave trigger to copy noteTags to all tasks", - "description": "Copies all noteTags to all tasks in note", + "name": "Add an onSave trigger to copy noteTags to all tasks", + "description": "Add an onSave trigger to copy noteTags to all tasks", "jsFunction": "addNoteTagsTriggerToFm" }, + { + "name": "sortTasksViaExternalCall", + "description": "entry point to sortTasks from x-callback etc.", + "jsFunction": "sortTasksViaExternalCall", + "arguments": [ + "JSON5 paramater string (withUserInput (boolean), sortFields (Array), withHeadings (boolean), subHeadingCategory (boolean)" + ], + "hidden": true + }, { "name": "triggerCopyNoteTags", "description": "onEditorWillSave", @@ -138,33 +158,35 @@ "hidden": true }, { - "NOTE": "DO NOT EDIT THIS COMMAND/TRIGGER", "name": "Task Sorting: Version", + "NOTE": "DO NOT EDIT THIS COMMAND/TRIGGER", "description": "Update + Check Version", "jsFunction": "versionCheck" }, { - "description": "DO NOT EDIT THIS COMMAND/TRIGGER", "name": "onOpen", + "NOTE": "DO NOT EDIT THIS COMMAND/TRIGGER", + "description": "entry point for onOpen trigger", "jsFunction": "onOpen", "hidden": true }, { - "description": "DO NOT EDIT THIS COMMAND/TRIGGER", "name": "onEditorWillSave", + "NOTE": "DO NOT EDIT THIS COMMAND/TRIGGER", + "description": "entry point for onEditorWillSave trigger", "jsFunction": "onEditorWillSave", "hidden": true }, { - "NOTE": "DO NOT EDIT THIS COMMAND/TRIGGER", "name": "onMessageFromHTMLView", + "NOTE": "DO NOT EDIT THIS COMMAND/TRIGGER", "description": "dwertheimer.TaskSorting: Callback function to receive messages from HTML view", "jsFunction": "onMessageFromHTMLView", "hidden": true }, { - "NOTE": "DO NOT EDIT THIS COMMAND/TRIGGER", "name": "Task Sorting: Update Plugin Settings", + "NOTE": "DO NOT EDIT THIS COMMAND/TRIGGER", "description": "Preferences", "jsFunction": "editSettings" } diff --git a/dwertheimer.TaskSorting/src/index.js b/dwertheimer.TaskSorting/src/index.js index 35b0bfb81..7606a0806 100644 --- a/dwertheimer.TaskSorting/src/index.js +++ b/dwertheimer.TaskSorting/src/index.js @@ -1,18 +1,11 @@ // @flow +// Last updated: 2024-10-13 for v1.1.1 by @jgclark +import pluginJson from '../plugin.json' +import { clo, logDebug, logError } from '@helpers/dev' -/** - * Imports - */ -// import pluginJson from '../plugin.json' -// import { clo } from '@helpers/dev' - -// FETCH mocking for offline testing -// If you want to use external server calls in your plugin, it can be useful to mock the server responses -// while you are developing the plugin. This allows you to test the plugin without having to -// have a server running or having to have a network connection (or wait/pay for the server calls) -// Comment the following import line out if you want to use live fetch/server endpoints (normal operation) -// Uncomment it for using server mocks (fake/canned responses) you define in support/fetchOverrides.js -// import './support/fetchOverrides' +export { editSettings } from '@helpers/NPSettings' +export { onUpdateOrInstall, init, onSettingsUpdated, triggerCopyNoteTags, versionCheck } from './NPTriggers-Hooks' +export { onOpen, onEditorWillSave } from './NPTriggers-Hooks' /** * Command Exports @@ -23,19 +16,11 @@ export { sortTasksByTag, sortTasksByDue, tasksToTop, - openTasksToTop, - sortTasksViaTemplate, + // openTasksToTop, + sortTasksViaExternalCall, sortTasksTagMention, sortTasksDefault, sortTasksUnderHeading, } from './sortTasks' export { addNoteTagsToAllTask, addNoteTagsTriggerToFm, copyTagsFromLineAbove, copyTagsFromHeadingAbove, copyLineForEachMention, copyLineForEachHashtag } from './tagTasks' export { default as markTasks } from './markTasks' - -/** - * Other imports/exports - you will normally not need to edit these - */ -// eslint-disable-next-line import/order -export { editSettings } from '@helpers/NPSettings' -export { onUpdateOrInstall, init, onSettingsUpdated, triggerCopyNoteTags, versionCheck } from './NPTriggers-Hooks' -export { onOpen, onEditorWillSave } from './NPTriggers-Hooks' diff --git a/dwertheimer.TaskSorting/src/sortTasks.js b/dwertheimer.TaskSorting/src/sortTasks.js index 9d49dc38e..b841c38cb 100644 --- a/dwertheimer.TaskSorting/src/sortTasks.js +++ b/dwertheimer.TaskSorting/src/sortTasks.js @@ -1,11 +1,12 @@ // @flow +// Last updated 2025-07-13 for v1.2.1 by @jgclark import pluginJson from '../plugin.json' import { chooseOption, chooseHeading, showMessage } from '@helpers/userInput' import { getTagParamsFromString } from '@helpers/general' import { removeHeadingFromNote, getBlockUnderHeading } from '@helpers/NPParagraph' import { sortListBy, getTasksByType, TASK_TYPES, type ParagraphsGroupedByType } from '@helpers/sorting' -import { logDebug, logWarn, logError, clo, JSP } from '@helpers/dev' +import { logDebug, logWarn, logError, clo, JSP, logInfo } from '@helpers/dev' import { findStartOfActivePartOfNote, findEndOfActivePartOfNote } from '@helpers/paragraph' const TOP_LEVEL_HEADINGS = { @@ -76,51 +77,29 @@ const SORT_ORDERS = [ }, ] -/** - * @param {string} heading The text that goes above the tasks. Should have a \n at the end. - * @param {string} separator The line that goes beneath the tasks. Should have a \n at the end. - */ -export function openTasksToTop(heading: string = '## Tasks:\n', separator: string = '---\n') { - if (Editor.note == null) { - return // if no note, stop. Should resolve 2 flow errors below, but doesn't :-( - } - logDebug(`openTasksToTop(): Bringing open tasks to top`) - //FIXME: need to make this work now that nmn.sweep is gone - // MAYBE ADD A QUESTION IN THE FLOW FOR WHICH TASKS TO MOVE - - const sweptTasks = { msg: '', status: '', taskArray: [], tasks: 0 } - // if (Editor.type === 'Calendar') { - // if (Editor.note) sweptTasks = await sweepNote(Editor.note, false, true, false, false, true, false, 'move') - // } else { - // if (Editor.note) sweptTasks = await sweepNote(Editor.note, false, true, false, true, true, false, 'move') - // } - if (sweptTasks) logDebug(`openTasksToTop(): ${sweptTasks?.taskArray?.length || 0} open tasks:`) - // logDebug(JSON.stringify(sweptTasks)) - if (sweptTasks.taskArray?.length) { - if (sweptTasks.taskArray[0].content === Editor.title) { - sweptTasks.taskArray.shift() - } - Editor.prependParagraph(heading.concat((sweptTasks.taskArray ?? []).map((m) => m.rawContent).join('\n')).concat(`\n${separator}`), 'text') - } -} +const DEFAULT_SORT_INDEX = 0 + +// -------------------------------------------------------------- -//FIXME: need to finish this... /** - * This template/macro is going to headlessly sort all tasks in the note based on certain criteria. + * This is going to headlessly sort all tasks in the Editor based on certain criteria passed in. + * i.e. entry point for Templates/x-callback/invokePluginCommand. * e.g. {{sortTasks({withUserInput: false, withHeadings: true, subHeadingCategory: true, sortOrder: ['-priority', 'content'], })}} + * TODO: Extend to deal with notes other than Editor. + * @param {string} paramStr */ -export async function sortTasksViaTemplate(paramStr: string = ''): Promise { - logDebug(`tasksortTasksViaTemplateToTop(): calling sortTasks`) +export async function sortTasksViaExternalCall(paramStr: string = ''): Promise { + logDebug('sortTasksViaExternalCall', `Starting with paramStr '${paramStr}', and will call sortTasks()`) const withUserInput: boolean = await getTagParamsFromString(paramStr, 'withUserInput', true) const sortFields: string[] = await getTagParamsFromString(paramStr, 'sortFields', SORT_ORDERS[DEFAULT_SORT_INDEX].sortFields) const withHeadings: boolean = await getTagParamsFromString(paramStr, 'withHeadings', false) const subHeadingCategory: boolean = await getTagParamsFromString(paramStr, 'subHeadingCategory', false) await sortTasks(withUserInput, sortFields, withHeadings, subHeadingCategory) + logDebug('sortTasksViaExternalCall', `finished`) } /** - * @description Bring tasks (tasks only, no surrounding text) to top of note - * @returns {Promise} + * Bring tasks (tasks only, no surrounding text) to top of note */ export async function tasksToTop() { try { @@ -160,7 +139,15 @@ export async function sortTasksByTag() { export async function sortTasksDefault() { try { - const { defaultSort1, defaultSort2, defaultSort3, includeHeading, includeSubHeading } = DataStore.settings + logDebug('sortTasksDefault', `startng sortTasksDefault()`) + let settings = await DataStore.loadJSON("../dwertheimer.TaskSorting/settings.json") + clo(settings) + // const { defaultSort1, defaultSort2, defaultSort3, includeHeading, includeSubHeading } = settings + const defaultSort1 = settings.defaultSort1 + const defaultSort2 = settings.defaultSort2 + const defaultSort3 = settings.defaultSort3 + const includeHeading = settings.includeHeading + const includeSubHeading = settings.includeSubHeading logDebug( `sortTasksDefault(): defaultSort1=${defaultSort1}, defaultSort2=${defaultSort2}, defaultSort3=${defaultSort3}, includeHeading=${includeHeading}, includeSubHeading=${includeSubHeading}\nCalling sortTasks now`, ) @@ -179,96 +166,95 @@ export async function sortTasksTagMention() { } } -const DEFAULT_SORT_INDEX = 0 - /** - * + * Insert multiple tasks to the Editor, in a more efficient manner (as opposed to inserting them one by one). * @param {TNote} note * @param {array} todos // @jgclark comment: needs type not just array. Perhaps Array ? - * @param {string} heading - * @param {string} separator - * @param {string} subHeadingCategory - * @return {number} next line number + * @param {string?} heading to remove from note TODO: is this really a thing? + * @param {string?} separator + * @param {string?} subHeadingCategory + * @param {string?} theTitle of the note? + * @param {boolean?} insertAtSectionStart? (default: true) Insert at start of active part of note, or else at end of active part. + * @returns {number} next line number */ -function insertTodos(note: CoreNoteFields, todos, heading: string = '', separator: string = '', subHeadingCategory: string = '', theTitle: string = '') { - const title = theTitle === ROOT ? '' : theTitle // root level tasks in Calendar note have no heading - const { tasksToTop } = DataStore.settings - // THE API IS SUPER SLOW TO INSERT TASKS ONE BY ONE - // SO INSTEAD, JUST PASTE THEM ALL IN ONE BIG STRING - logDebug(`\tInsertTodos: subHeadingCategory=${String(subHeadingCategory)} typeof=${typeof subHeadingCategory} ${todos.length} todos`) - let todossubHeadingCategory = [] - const headingStr = heading ? `${heading}\n` : '' - if (heading) { - logDebug(`\tInsertTodos: heading=${heading}`) - removeHeadingFromNote(note, heading, true) - } - - if (subHeadingCategory) { - const leadingDigit = { - hashtags: '#', - mentions: '@', - priority: '', - content: '', - due: '', +export function insertTodos(note: CoreNoteFields, todos, heading: string = '', separator: string = '', subHeadingCategory: string = '', theTitle: string = '', insertAtSectionStart: boolean = true): number { + try { + const title = theTitle === ROOT ? '' : theTitle // root level tasks in Calendar note have no heading + // const { tasksToTop } = DataStore.settings // now coming from calling function, to avoid potentially wrong plugin settings + // THE API IS SUPER SLOW TO INSERT TASKS ONE BY ONE + // SO INSTEAD, JUST PASTE THEM ALL IN ONE BIG STRING + logDebug(`\tInsertTodos: subHeadingCategory=${String(subHeadingCategory)} typeof=${typeof subHeadingCategory} ${todos.length} todos`) + let todossubHeadingCategory = [] + const headingStr = heading ? `${heading}\n` : '' + if (heading) { + logDebug('InsertTodos', `\theading=${heading}`) + removeHeadingFromNote(note, heading, true) } - let lastSubcat = '' - for (const lineIndex in todos) { - const shcZero = todos[lineIndex][subHeadingCategory][0] ?? `` - // logDebug(`InsertTodos: shcZero=${shcZero} typeof=${typeof shcZero} todos[lineIndex][subHeadingCategory]=${todos[lineIndex][subHeadingCategory]}`) - const subCat = - /* $FlowIgnore - complaining about -priority being missing. */ - (leadingDigit[subHeadingCategory] ? leadingDigit[subHeadingCategory] : '') + shcZero || todos[lineIndex][subHeadingCategory] || '' - // logDebug( - // `lastSubcat[${subHeadingCategory}]=${subCat} check: ${JSON.stringify( - // todos[lineIndex], - // )}`, - // ) - if (lastSubcat !== subCat) { - lastSubcat = subCat + + if (subHeadingCategory) { + const leadingDigit = { + hashtags: '#', + mentions: '@', + priority: '', + content: '', + due: '', + } + let lastSubcat = '' + for (const lineIndex in todos) { + const shcZero = todos[lineIndex][subHeadingCategory][0] ?? `` + // logDebug(`InsertTodos: shcZero=${shcZero} typeof=${typeof shcZero} todos[lineIndex][subHeadingCategory]=${todos[lineIndex][subHeadingCategory]}`) + const subCat = + (leadingDigit[subHeadingCategory] ? leadingDigit[subHeadingCategory] : '') + shcZero || todos[lineIndex][subHeadingCategory] || '' + if (lastSubcat !== subCat) { + lastSubcat = subCat // logDebug(pluginJson, `insertTodos subCat:"${subCat}" typeof=${typeof subCat} length=${subCat.length}`) - const headingStr = `#### ${subCat}:` - todossubHeadingCategory.push({ raw: `\n${headingStr}` }) - // delete the former version of this subheading - removeHeadingFromNote(note, subCat) + const headingStr = `#### ${subCat}:` + todossubHeadingCategory.push({ raw: `\n${headingStr}` }) + // delete the former version of this subheading + removeHeadingFromNote(note, subCat) + } + todossubHeadingCategory.push(todos[lineIndex]) } - todossubHeadingCategory.push(todos[lineIndex]) + } else { + todossubHeadingCategory = todos } - } else { - todossubHeadingCategory = todos - } - const contentStr = todossubHeadingCategory - .map((t) => { - let str = t.raw - if (t.children && t.children.length) { - //TODO: sort 2nd level also indented tasks - str += `\n${t.children.map((c) => c.raw).join('\n')}` - } - return str - }) - .join(`\n`) - // logDebug(`Inserting tasks into Editor:\n${contentStr}`) - // logDebug(`inserting tasks: \n${JSON.stringify(todossubHeadingCategory)}`) - const content = `${headingStr}${contentStr}${separator ? `\n${separator}` : ''}` - if (title !== '') { + const contentStr = todossubHeadingCategory + .map((t) => { + let str = t.raw + if (t.children && t.children.length) { + //TODO: sort 2nd level also indented tasks + str += `\n${t.children.map((c) => c.raw).join('\n')}` + } + return str + }) + .join(`\n`) + // logDebug(`Inserting tasks into Editor:\n${contentStr}`) + // logDebug(`inserting tasks: \n${JSON.stringify(todossubHeadingCategory)}`) + const content = `${headingStr}${contentStr}${separator ? `\n${separator}` : ''}` + if (title !== '') { // const headingIndex = findHeading(note, title)?.lineIndex || 0 - logDebug(`\tinsertTodos`, `tasksToTop=${tasksToTop} title="${title}"`) - if (tasksToTop) { - note.addParagraphBelowHeadingTitle(content, 'text', title, false, true) + logDebug(`\tinsertTodos: insertAtSectionStart=${String(insertAtSectionStart)} title="${title}"`) + if (insertAtSectionStart) { + note.addParagraphBelowHeadingTitle(content, 'text', title, false, true) + } else { + const paras = getBlockUnderHeading(note, title) + const lastPara = paras[paras.length - 1] + const insertFunc = lastPara.type === 'separator' ? `insertTodoBeforeParagraph` : `insertParagraphAfterParagraph` + logDebug(`\tinsertTodos note.${insertFunc} "${lastPara.content}"`) + // $FlowIgnore - calling function by name is not very Flow friendly (but it works!) + note[insertFunc](content, lastPara) + } } else { - const paras = getBlockUnderHeading(note, title) - const lastPara = paras[paras.length - 1] - const insertFunc = lastPara.type === 'separator' ? `insertTodoBeforeParagraph` : `insertParagraphAfterParagraph` - logDebug(`\tinsertTodos note.${insertFunc} "${lastPara.content}"`) - // $FlowIgnore - calling function by name is not very Flow friendly (but it works!) - note[insertFunc](content, lastPara) + const insertionIndex = insertAtSectionStart ? findStartOfActivePartOfNote(note) : findEndOfActivePartOfNote(note) + 1 + note.insertParagraph(content, insertionIndex, 'text') } - } else { - const insertionIndex = tasksToTop ? findStartOfActivePartOfNote(note) : findEndOfActivePartOfNote(note) + 1 - note.insertParagraph(content, insertionIndex, 'text') + logDebug(`\tinsertTodos finished`) + } + catch (error) { + logError('InsertTodos', JSP(error)) } - // logDebug(`\tinsertTodos finished`) } /** @@ -316,44 +302,6 @@ async function getUserSort(sortChoices: Array = SORT_ORDERS) { // logDebug(`\tgetUserSort returning ${JSON.stringify(sortChoices[choice.index].sortFields)}`) return sortChoices[choice.index].sortFields } - -// function findRawParagraph(note: TNote, content) { -// if (content) { -// const found = note.paragraphs.filter((p) => p.rawContent === content) -// if (found && found.length > 1) { -// logDebug(`** Found ${found.length} identical occurrences for "${content}". Deleting the first.`) -// } -// return found[0] || null -// } else { -// return null -// } -// } -// async function saveBackup(taskList: Array) { -// const backupPath = `@Trash` -// const backupTitle = `_Task-sort-backup` -// const backupFilename = `${backupPath}/${backupTitle}.${DataStore.defaultFileExtension}` -// logDebug(`\tBackup filename: ${backupFilename}`) -// let notes = await DataStore.projectNoteByTitle(backupTitle, false, true) -// logDebug(`\tGot note back: ${notes ? JSON.stringify(notes) : ''}`) -// if (!notes || !notes.length) { -// logDebug(`\tsaveBackup: no note named ${backupFilename}`) -// const filename = await DataStore.newNote(`_Task-sort-backup`, `@Trash`) -// // TODO: There's a bug in API where filename is not correct and the file is not in cache unless you open a command bar -// // remove all this: -// await CommandBar.showOptions(['OK'], `\tBacking up todos in @Trash/${backupTitle}`) -// // -// logDebug(`\tCreated ${filename ? filename : ''} for backups`) -// notes = await DataStore.projectNoteByTitle(backupTitle, false, true) -// // note = await DataStore.projectNoteByFilename(backupFilename) -// logDebug(`\tbackup file contents:\n${notes ? JSON.stringify(notes) : ''}`) -// } -// if (notes && notes[0]) { -// notes[0].insertParagraph(`---`, 2, 'text') -// logDebug(`\tBACKUP Saved to ${backupTitle}`) -// await insertTodos(notes[0], taskList) -// } -// } - /** * Delete Tasks from the note * @param {TNote} note @@ -380,14 +328,6 @@ function deleteExistingTasks(note: CoreNoteFields, tasks: ParagraphsGroupedByTyp // return findRawParagraph(note, t.raw || null) tasksToDelete.push(t.paragraph) }) - //$FlowIgnore - // logDebug(`deletesForThisType.length=${deletesForThisType.length} \n${JSON.stringify(deletesForThisType)}`) - // deletesForThisType.map(t=>logDebug(`Before: lineIndex:${t.lineIndex} content:${t.content}`)) - // logDebug(`Editor content before remove: ${Editor.content || ''}`) - // $FlowFixMe - // if (deletesForThisType && deletesForThisType.length) tasksToDelete.push(deletesForThisType) - // Editor.paragraphs.map(t=>logDebug(`After: lineIndex:${t.lineIndex} content:${t.content}`)) - // logDebug(`Editor content after remove: ${Editor.content || ''}`) } if (tasksToDelete.length) { @@ -443,14 +383,14 @@ export async function writeOutTasks( subHeadingCategory: any | null | string = null, title: string = '', ): Promise { - const { outputOrder, tasksToTop } = DataStore.settings + const { outputOrder, insertAtSectionStart } = DataStore.settings let taskTypes = (outputOrder ?? 'open, scheduled, done, cancelled').split(',').map((t) => t.trim()) taskTypes = addChecklistTypes(taskTypes) logDebug(pluginJson, `writeOutTasks taskTypes: ${taskTypes.toString()}`) const headings = TOP_LEVEL_HEADINGS // need to write in reverse order if we are going to keep adding a top insertionIndex - const writeSequence = tasksToTop ? taskTypes.slice().reverse() : taskTypes - logDebug(`writeOutTasks: writing task types in ${tasksToTop ? 'reverse for lineIndex security' : 'order'} : ${writeSequence.toString()}`) + const writeSequence = insertAtSectionStart ? taskTypes.slice().reverse() : taskTypes + logDebug(`writeOutTasks: writing task types in ${insertAtSectionStart ? 'reverse for lineIndex security' : 'order'} : ${writeSequence.toString()}`) for (let i = 0; i < writeSequence.length; i++) { const ty = writeSequence[i] if (tasks[ty]?.length) { @@ -462,8 +402,9 @@ export async function writeOutTasks( tasks[ty], withHeadings ? `### ${headings[ty]}:` : '', drawSeparators ? `${i === tasks[ty].length - 1 ? '---' : ''}` : '', - subHeadingCategory, - title, + subHeadingCategory || '', + title, + insertAtSectionStart ) : null } catch (e) { @@ -473,7 +414,7 @@ export async function writeOutTasks( } } -async function wantHeadings() { +async function wantHeadings(): boolean { return await chooseOption( `Include Task Type headings in the output?`, [ @@ -484,7 +425,7 @@ async function wantHeadings() { ) } -async function wantSubHeadings() { +async function wantSubHeadings(): boolean { return await chooseOption( `Include sort field subheadings in the output?`, [ @@ -495,7 +436,7 @@ async function wantSubHeadings() { ) } -async function sortInsideHeadings() { +async function sortInsideHeadings(): boolean { return await chooseOption( `Sort each heading's tasks individually?`, [ @@ -506,7 +447,7 @@ async function sortInsideHeadings() { ) } -export function removeEmptyHeadings(note: CoreNoteFields) { +export function removeEmptyHeadings(note: CoreNoteFields): void { const paras = note.paragraphs const updates = [] const topLevelHeadings = Object.keys(TOP_LEVEL_HEADINGS).map((key) => TOP_LEVEL_HEADINGS[key]) @@ -537,18 +478,23 @@ export function removeEmptyHeadings(note: CoreNoteFields) { if (updates.length) { // updates.map((u) => logDebug(pluginJson, `removeEmptyHeadings deleting spinster lineIndex[${u.lineIndex}] ${u.rawContent}`)) // note.updateParagraphs(updates) + logInfo(pluginJson, `removeEmptyHeadings: deleting ${updates.length} empty heading paras`) note.removeParagraphs(updates) + } else { + logDebug(pluginJson, `removeEmptyHeadings: no empty heading paras to delete.`) } } /** - * If {stopAtDoneHeading} setting is set, then find just the paragraphs up to the first done/cancelled heading - * @param {*} note - input note + * If {stopAtDoneHeading} setting is set, then find just the paragraphs up to the first done/cancelled heading. + * WARNING: this didn't work for @jgclark in Oct 2024, returning all paras, not just those in active part. + * @param {CoreNoteFields} note - input note * @returns {$ReadOnlyArray} - array of paragraphs */ export function getActiveParagraphs(note: CoreNoteFields): $ReadOnlyArray { const { stopAtDoneHeading } = DataStore.settings - return (stopAtDoneHeading ? note.paragraphs.filter((p) => p.lineIndex <= findEndOfActivePartOfNote(note)) : note?.paragraphs) || [] + const endOfActive = findEndOfActivePartOfNote(note) + return (stopAtDoneHeading ? note.paragraphs.filter((p) => p.lineIndex <= endOfActive) : note?.paragraphs) || [] } /** @@ -601,8 +547,11 @@ export async function sortTasks( withHeadings: boolean | null = null, subHeadingCategory: boolean | null = null, ) { - const { eliminateSpinsters, sortInHeadings, includeSubHeading } = DataStore.settings - + // const { eliminateSpinsters, sortInHeadings, includeSubHeading } = DataStore.settings + let settings = await DataStore.loadJSON("../dwertheimer.TaskSorting/settings.json") + const eliminateSpinsters = settings.includeSubHeading + const sortInHeadings = settings.sortInHeadings + const includeSubHeading = settings.includeSubHeading const byHeading = withUserInput ? await sortInsideHeadings() : sortInHeadings logDebug( @@ -637,7 +586,7 @@ export async function sortTasks( logDebug(pluginJson, `sortTasks have sortGroups object. key count=${Object.keys(sortGroups).length}. About to start the display loop`) for (const key in sortGroups) { - logDebug(`sortTasks: heading Group title="${key}" (${sortGroups[key].length} paragraphs)`) + logDebug(`sortTasks: sortGroup heading/key="${key}" (${sortGroups[key].length} paragraphs)`) if (sortGroups[key].length) { const sortedTasks = sortParagraphsByType(sortGroups[key], sortOrder) if (Editor.note) deleteExistingTasks(Editor.note, sortedTasks) // need to do this before adding new lines to preserve line numbers @@ -668,17 +617,24 @@ export async function sortTasks( * sortTasksUnderHeading * Plugin entrypoint for "/sth". * Can also be called from templates or other plugins. - * @param {string} _heading - the heading to sort (probably comes in from xcallback) - * @param {string} _sortOrder - the sort order (probably comes in from xcallback) + * @param {string} _heading (optional) heading to sort - from xcallback or other plugin call + * @param {string} _sortOrder (optional) sort order - from xcallback or other plugin call */ -export async function sortTasksUnderHeading(_heading: string, _sortOrder: string | Array): Promise { +export async function sortTasksUnderHeading(_heading: string='', _sortOrder: string | Array =''): Promise { try { - if (!Editor.note) { - logError(pluginJson, `sortTasksUnderHeading: There is no Editor.note. Bailing`) - await showMessage('No note is open') - return + if (!Editor || !Editor.note) { + logError('sortTasksUnderHeading', `sortTasksUnderHeading: There is no open Editor.note. Stopping.`) + await showMessage('No note is open, so stopping.') } - const heading = _heading || (await chooseHeading(Editor?.note, false, false, false)) + + // Get heading from param or ask user + const heading = (_heading !== '') ? _heading : await chooseHeading(Editor?.note, false, false, false) + if (!heading) { + logInfo('sortTasksUnderHeading', `No heading given, so stopping.`) + await showMessage(`No heading given, so stopping.`) + } + + // Sort tasks let sortOrder: Array = [] if (typeof _sortOrder === 'object') { // if sortOrder is an array, then it's already in the correct format @@ -688,30 +644,27 @@ export async function sortTasksUnderHeading(_heading: string, _sortOrder: string sortOrder = _sortOrder ? JSON.parse(_sortOrder) : await getUserSort() } logDebug(pluginJson, `sortTasksUnderHeading: starting for heading="${heading}" sortOrder="${String(sortOrder)}"`) - + logDebug('sortTasksUnderHeading', `Will sort tasks under heading '${heading}'`) if (heading && Editor.note) { const block = getBlockUnderHeading(Editor.note, heading) - clo(block, `sortTasksUnderHeading block`) if (block?.length) { - // clo(sortOrder, `sortTasksUnderHeading sortOrder`) if (sortOrder) { const sortedTasks = sortParagraphsByType(block, sortOrder) - clo(sortedTasks, `sortTasksUnderHeading sortedTasks`) - // const printHeadings = (await wantHeadings()) || false - // const printSubHeadings = (await wantSubHeadings()) || false - // const sortField1 = sortOrder[0][0] === '-' ? sortOrder[0].substring(1) : sortOrder[0] - if (Editor.note) deleteExistingTasks(Editor.note, sortedTasks) // need to do this before adding new lines to preserve line numbers - // if (Editor.note) await writeOutTasks(Editor.note, sortedTasks, false, printHeadings, printSubHeadings ? sortField1 : '', heading) - if (Editor.note) await writeOutTasks(Editor.note, sortedTasks, false, false, '', heading) + // clo(sortedTasks, `sortTasksUnderHeading sortedTasks`) + // $FlowIgnore(incompatible-call) + deleteExistingTasks(Editor.note, sortedTasks) // need to do this before adding new lines to preserve line numbers + // $FlowIgnore(incompatible-call) + await writeOutTasks(Editor.note, sortedTasks, false, false, '', heading) } } else { + logInfo('sortTasksUnderHeading', `No tasks found under heading "${heading}"`) await showMessage(`No tasks found under heading "${heading}"`) } } else { - logError(pluginJson, `sortTasksUnderHeading: There is no Editor.note. Bailing`) - await showMessage('No note is open') + logError(pluginJson, `sortTasksUnderHeading: There is no Editor.note. Stopping.`) + await showMessage('No note is open, so stopping.') } } catch (error) { - logError(pluginJson, JSON.stringify(error)) + logError(pluginJson, JSP(error)) } } diff --git a/helpers/NPMoveItems.js b/helpers/NPMoveItems.js index cc0e638e9..644357f00 100644 --- a/helpers/NPMoveItems.js +++ b/helpers/NPMoveItems.js @@ -3,7 +3,6 @@ // Helpers for moving paragraphs around. // ----------------------------------------------------------------- -import { addParasAsText } from '../jgclark.Filer/src/filerHelpers.js' import { findScheduledDates, getAPIDateStrFromDisplayDateStr } from '@helpers/dateTime' import { clo, JSP, logDebug, logError, logInfo, logWarn, timer } from '@helpers/dev' import { displayTitle } from '@helpers/general' @@ -12,7 +11,7 @@ import { getNoteByFilename } from '@helpers/note' import { coreAddChecklistToNoteHeading, coreAddTaskToNoteHeading } from '@helpers/NPAddItems' import { getParaAndAllChildren } from '@helpers/parentsAndChildren' import { findEndOfActivePartOfNote, findHeading, findHeadingStartsWith, findStartOfActivePartOfNote, parasToText, smartAppendPara, smartCreateSectionsAndPara, smartPrependPara } from '@helpers/paragraph' -import { findParaFromStringAndFilename, insertParagraph, noteHasContent } from '@helpers/NPParagraph' +import { addParasAsText, findParaFromStringAndFilename, insertParagraph, noteHasContent } from '@helpers/NPParagraph' import { removeDateTagsAndToday } from '@helpers/stringTransforms' import { chooseHeading, chooseNote, displayTitleWithRelDate, showMessage, showMessageYesNo } from '@helpers/userInput' @@ -260,14 +259,13 @@ export function moveParagraphToNote(para: TParagraph, destinationNote: TNote): b * Move a given paragraph (and any following indented paragraphs) to a different note. * Note: simplified version of 'moveParas()' in NPParagraph. * NB: the Setting 'includeFromStartOfSection' decides whether these directly following paragaphs have to be indented (false) or can take all following lines at same level until next empty line as well. - * Note: originally in helpers/blocks.js, not used anywhere yet. * @param {TParagraph} para * @param {string} destFilename * @param {NoteType} destNoteType * @param {string} destHeading to move under * @author @jgclark */ -export function moveGivenParaAndBlock(para: TParagraph, destFilename: string, destNoteType: NoteType, destHeading: string): void { +export function moveGivenParaAndIndentedChildren(para: TParagraph, destFilename: string, destNoteType: NoteType, destHeading: string): void { try { if (!destFilename) { throw new Error('Invalid destination filename given.') @@ -283,7 +281,7 @@ export function moveGivenParaAndBlock(para: TParagraph, destFilename: string, de // get children paras (as well as the original) const parasInBlock = getParaAndAllChildren(para) - logDebug('blocks/moveGivenParaAndBlock', `moveParas: move block of ${parasInBlock.length} paras`) + logDebug('blocks/moveGivenParaAndIndentedChildren', `moveParas: move block of ${parasInBlock.length} paras`) // Note: There's still no API function to add multiple // paragraphs in one go, but we can insert a raw text string. @@ -294,14 +292,14 @@ export function moveGivenParaAndBlock(para: TParagraph, destFilename: string, de if (!destNote) { throw new Error(`Destination note can't be found from filename '${destFilename}'`) } - logDebug('blocks/moveGivenParaAndBlock', `- Moving to note '${displayTitle(destNote)}' under heading: '${destHeading}'`) + logDebug('blocks/moveGivenParaAndIndentedChildren', `- Moving to note '${displayTitle(destNote)}' under heading: '${destHeading}'`) addParasAsText(destNote, selectedParasAsText, destHeading, 'start', true) // delete from existing location - logDebug('blocks/moveGivenParaAndBlock', `- Removing ${parasInBlock.length} paras from original note`) + logDebug('blocks/moveGivenParaAndIndentedChildren', `- Removing ${parasInBlock.length} paras from original note`) originNote.removeParagraphs(parasInBlock) } catch (error) { - logError('blocks/moveGivenParaAndBlock', `moveParas(): ${error.message}`) + logError('blocks/moveGivenParaAndIndentedChildren', `moveParas(): ${error.message}`) } } diff --git a/helpers/NPParagraph.js b/helpers/NPParagraph.js index 2ae9e496d..ba36df228 100644 --- a/helpers/NPParagraph.js +++ b/helpers/NPParagraph.js @@ -25,7 +25,7 @@ import { import { displayTitle } from '@helpers/general' import { getFirstDateInPeriod, getNPWeekData, getMonthData, getQuarterData, getYearData, nowDoneDateTimeString, toLocaleDateTimeString } from '@helpers/NPdateTime' import { clo, JSP, logDebug, logError, logInfo, logWarn, timer } from '@helpers/dev' -import { filterOutParasInExcludeFolders, getNoteType } from '@helpers/note' +import { getNoteType } from '@helpers/note' import { findStartOfActivePartOfNote, isTermInMarkdownPath, isTermInURL } from '@helpers/paragraph' import { RE_FIRST_SCHEDULED_DATE_CAPTURE } from '@helpers/regex' import { caseInsensitiveMatch, caseInsensitiveSubstringMatch, caseInsensitiveStartsWith, getLineMainContentPos } from '@helpers/search' @@ -1804,3 +1804,50 @@ export function removeAllDueDates(filename: string): boolean { return false } } + +/** + * Function to write text either to top of note, bottom of note, or after a heading + * Note: When written, there was no API function to deal with multiple selectedParagraphs, but we can insert a raw text string. + * Note: now can't simply use note.addParagraphBelowHeadingTitle() as we have more options than it supports. + * @author @jgclark + * + * @param {TNote} destinationNote + * @param {string} selectedParasAsText + * @param {string} headingToFind if empty, means 'end of note'. Can also be the special string '(top of note)' + * @param {string} whereToAddInSection to add after a heading: 'start' or 'end' + * @param {boolean} allowNotePreambleBeforeHeading? + */ +export function addParasAsText( + destinationNote: TNote, + selectedParasAsText: string, + headingToFind: string, + whereToAddInSection: string, + allowNotePreambleBeforeHeading: boolean +): void { + const destinationNoteParas = destinationNote.paragraphs + let insertionIndex: number + if (headingToFind === destinationNote.title || headingToFind === '<>') { + // i.e. the first line in project or calendar note + insertionIndex = findStartOfActivePartOfNote(destinationNote, allowNotePreambleBeforeHeading) + logDebug('Filer/addParasAsText', `-> top of note, line ${insertionIndex}`) + destinationNote.insertParagraph(selectedParasAsText, insertionIndex, 'text') + + } else if (headingToFind === '<>' || headingToFind === '') { + // blank return from chooseHeading has special meaning of 'end of note' + insertionIndex = destinationNoteParas.length + 1 || 0 + logDebug('Filer/addParasAsText', `-> bottom of note, line ${insertionIndex}`) + destinationNote.insertParagraph(selectedParasAsText, insertionIndex, 'text') + + } else if (whereToAddInSection === 'start') { + logDebug('Filer/addParasAsText', `-> Inserting at start of section '${headingToFind}'`) + destinationNote.addParagraphBelowHeadingTitle(selectedParasAsText, 'text', headingToFind, false, false) + + } else if (whereToAddInSection === 'end') { + logDebug('Filer/addParasAsText', `-> Inserting at end of section '${headingToFind}'`) + destinationNote.addParagraphBelowHeadingTitle(selectedParasAsText, 'text', headingToFind, true, false) + + } else { + // Shouldn't get here + logError('Filer/addParasAsText', `Can't find heading '${headingToFind}'. Stopping.`) + } +} diff --git a/helpers/__tests__/dateTime.test.js b/helpers/__tests__/dateTime.test.js index 14b0d86a0..597810fb6 100644 --- a/helpers/__tests__/dateTime.test.js +++ b/helpers/__tests__/dateTime.test.js @@ -14,7 +14,7 @@ beforeAll(() => { global.DataStore = DataStore global.Editor = Editor global.NotePlan = new NotePlan() - DataStore.settings['_logLevel'] = 'none' // change this to DEBUG to get more logging, or 'none' for quiet + DataStore.settings['_logLevel'] = 'DEBUG' // change this to DEBUG to get more logging, or 'none' for quiet }) const PLUGIN_NAME = `📙 ${colors.yellow('helpers/dateTime')}` @@ -556,6 +556,25 @@ describe(`${PLUGIN_NAME}`, () => { }) }) + describe('convertQuarterDateToHalfYearDate', () => { + test("convertQuarterDateToHalfYearDate('2023-Q3')", () => { + const answer = dt.convertQuarterDateToHalfYearDate('2023-Q3') + expect(answer).toBe('2023H2') + }) + test("convertQuarterDateToHalfYearDate('2023-Q4')", () => { + const answer = dt.convertQuarterDateToHalfYearDate('2023-Q4') + expect(answer).toBe('2023H2') + }) + test("convertQuarterDateToHalfYearDate('2024-Q1')", () => { + const answer = dt.convertQuarterDateToHalfYearDate('2024-Q1') + expect(answer).toBe('2024H1') + }) + test("convertQuarterDateToHalfYearDate('2024-Q2')", () => { + const answer = dt.convertQuarterDateToHalfYearDate('2024-Q2') + expect(answer).toBe('2024H1') + }) + }) + describe('calcOffsetDateStr', () => { describe('should pass', () => { test('20220101 +1d', () => { @@ -675,6 +694,19 @@ describe(`${PLUGIN_NAME}`, () => { test('2022-Q2 -2q', () => { expect(dt.calcOffsetDateStr('2022-Q2', '-2q')).toEqual('2021-Q4') }) + test('2022-Q2 +1h', () => { + expect(dt.calcOffsetDateStr('2022-Q2', '1h')).toEqual('2022-Q4') + }) + test('2022-Q2 +2h', () => { + expect(dt.calcOffsetDateStr('2022-Q2', '2h')).toEqual('2023-Q2') + }) + // TODO: Half-year support + test.skip('2022H2 +1h', () => { + expect(dt.calcOffsetDateStr('2022H2', '1h')).toEqual('2023H1') + }) + test.skip('2022H2 +2h', () => { + expect(dt.calcOffsetDateStr('2022H2', '2h')).toEqual('2023H2') + }) test('2022 +2y', () => { expect(dt.calcOffsetDateStr('2022', '2y')).toEqual('2024') }) @@ -721,6 +753,13 @@ describe(`${PLUGIN_NAME}`, () => { test('2023 +3q -> 2023-Q4', () => { expect(dt.calcOffsetDateStr('2023', '3q', 'offset')).toEqual('2023-Q4') }) + // TODO: Half-year support + test.skip('2023-Q3 +1h -> 2024H1', () => { + expect(dt.calcOffsetDateStr('2023-Q3', '1h', 'offset')).toEqual('2024H1') + }) + test.skip('2024-Q1 +1h -> 2024H2', () => { + expect(dt.calcOffsetDateStr('2023-Q3', '1h', 'offset')).toEqual('2024H2') + }) }) describe('adapting output to shorter durations than base', () => { test('20230101 +1d -> 20230102', () => { @@ -1306,6 +1345,13 @@ describe(`${PLUGIN_NAME}`, () => { type: 'quarter', }) }) + test('1h', () => { + const result = dt.splitIntervalToParts('1h') + expect(result).toEqual({ + number: 1, + type: 'half-year', + }) + }) test('2y', () => { const result = dt.splitIntervalToParts('2y') expect(result).toEqual({ diff --git a/helpers/__tests__/sorting.test.js b/helpers/__tests__/sorting.test.js index 7dacc3104..e41267794 100644 --- a/helpers/__tests__/sorting.test.js +++ b/helpers/__tests__/sorting.test.js @@ -1,12 +1,9 @@ /* global jest, describe, test, expect, beforeAll, afterAll, beforeEach, afterEach */ import { _ } from 'lodash' -import * as s from '../sorting' import { CustomConsole, LogType, LogMessage } from '@jest/console' // see note below +import * as s from '../sorting' import { Calendar, Clipboard, CommandBar, DataStore, Editor, NotePlan, simpleFormatter, Paragraph /* Note, mockWasCalledWithString, Paragraph */ } from '@mocks/index' -const PLUGIN_NAME = `helpers` -const FILENAME = `NPNote` - beforeAll(() => { global.Calendar = Calendar global.Clipboard = Clipboard @@ -450,12 +447,12 @@ describe('sorting.js', () => { }) }) /* - * getSortableTask() + * getSortableParaSubset() */ - describe('getSortableTask()' /* function */, () => { + describe('getSortableParaSubset()' /* function */, () => { test('should create basic task object', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content', filename: 'testFile.md', lineIndex: 15 }) - const result = s.getSortableTask(paragraph) + const result = s.getSortableParaSubset(paragraph) const expected = { calculatedType: 'open', children: [], @@ -475,29 +472,35 @@ describe('sorting.js', () => { }) test('should have hashtags', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content #foo', filename: 'testFile.md' }) - const result = s.getSortableTask(paragraph) + const result = s.getSortableParaSubset(paragraph) expect(result).toHaveProperty('hashtags', ['foo']) }) test('should have mentions', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content @foo', filename: 'testFile.md' }) - const result = s.getSortableTask(paragraph) + const result = s.getSortableParaSubset(paragraph) expect(result).toHaveProperty('mentions', ['foo']) }) test('should not have exclamation mark priority', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content !!!', filename: 'testFile.md' }) - const result = s.getSortableTask(paragraph) + const result = s.getSortableParaSubset(paragraph) expect(result).toHaveProperty('priority', -1) expect(result).toHaveProperty('exclamations', []) }) + test('should have exclamation mark priority', () => { + const paragraph = new Paragraph({ type: 'open', content: '!!! test content', filename: 'testFile.md' }) + const result = s.getSortableParaSubset(paragraph) + expect(result).toHaveProperty('priority', 3) + expect(result).toHaveProperty('exclamations', ['!!!']) + }) test('should have parens priority', () => { const paragraph = new Paragraph({ type: 'open', content: '(B) test content', filename: 'testFile.md' }) - const result = s.getSortableTask(paragraph) + const result = s.getSortableParaSubset(paragraph) expect(result).toHaveProperty('priority', 2) expect(result).toHaveProperty('parensPriority', ['B']) }) test('should have calculatedType', () => { const paragraph = new Paragraph({ type: 'checklist', content: 'test content >2020-01-01', filename: 'testFile.md' }) - const result = s.getSortableTask(paragraph) + const result = s.getSortableParaSubset(paragraph) expect(result).toHaveProperty('calculatedType', 'checklistScheduled') }) }) @@ -505,61 +508,61 @@ describe('sorting.js', () => { describe('getNumericPriority()', () => { test('should return -1 for empty paragraph', () => { const paragraph = new Paragraph({ type: 'open', content: '', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(-1) }) test('should return -1 from exclamation marks in words', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content !!!', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(-1) }) test('should return -1 from exclamation marks in words', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content !!!', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(-1) }) test('should return priority 3 from exclamation marks (even with 6 in line)', () => { const paragraph = new Paragraph({ type: 'open', content: '!!! test content !!!', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(3) }) test('should return no priority from exclamation marks at end', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content !!!', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(-1) }) test('should return priority from parentheses', () => { const paragraph = new Paragraph({ type: 'open', content: '(B) test content', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(2) }) test('should return priority 4 from starting >>', () => { const paragraph = new Paragraph({ type: 'open', content: '>> test content', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(4) }) test('should return priority 4 from included (W)', () => { const paragraph = new Paragraph({ type: 'open', content: '(W) test content', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(4) }) test('should return no priority from ending >>', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content >>', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(-1) }) test('should return -1 for unknown priority', () => { const paragraph = new Paragraph({ type: 'open', content: 'test content ??', filename: 'testFile.md' }) - const result = s.getNumericPriority(s.getSortableTask(paragraph)) + const result = s.getNumericPriority(s.getSortableParaSubset(paragraph)) expect(result).toEqual(-1) }) }) diff --git a/helpers/blocks.js b/helpers/blocks.js index b7bb8f177..ce1d6a008 100644 --- a/helpers/blocks.js +++ b/helpers/blocks.js @@ -11,7 +11,8 @@ import { clo, JSP, logDebug, logError, logInfo, logWarn, timer } from '@helpers/ * Blocks are broken based on the following block types: 'empty', 'separator', * or a 'title'.headingLevel <= the last title level in the block * Separators and empty lines are included as their own blocks - * + * + * @author @dwertheimer * @param {Array} array - The array of objects to break into blocks. * @return {Array>} An array of blocks, where each block is an array of objects. */ @@ -60,6 +61,7 @@ export function isBreakBlock(item: TParagraph, breakBlockTypes: Array = /** * Checks if a title's heading level is lower than the specified level. * + * @author @jgclark * @param {TParagraph} item - The title object to check. * @param {number} level - The lowest heading level in the block. * @return {boolean} True if the title's heading level is lower than the specified level, false otherwise. @@ -67,3 +69,64 @@ export function isBreakBlock(item: TParagraph, breakBlockTypes: Array = export function isTitleWithEqualOrLowerHeadingLevel(item: TParagraph, prevLowestLevel: number): boolean { return item.type === 'title' && item.headingLevel <= prevLowestLevel } + +/** + * Return whether this paragraph is a 'child' of a given 'parent' para. + * The NP documentation requires a child to be an indented task/checklist of an earlier task/checklist. + * (JGC doesn't know enough to make jest tests for this. But is confident this works from lots of logging.) + * @author @jgclark + * @param {TParagraph} para - the 'parent' paragraph + * @returns {Array} - array of child paragraphs + */ +export function isAChildPara(thisPara: TParagraph): boolean { + try { + const thisLineIndex = thisPara.lineIndex + const allParas = thisPara.note?.paragraphs ?? [] + // First get all paras up to this one which are parents + const allParentsUpToHere = allParas + .filter((p) => p.children().length > 0) + .filter((p) => p.lineIndex < thisLineIndex) + for (const parent of allParentsUpToHere) { + const theseChildren = parent.children() + for (const child of theseChildren) { + if (child.lineIndex === thisLineIndex) { + // logInfo('blocks/isAChildPara', `✅: ${thisPara.rawContent}`) + return true // note: now allowed in forEach but OK in for + } + } + } + // logInfo('blocks/isAChildPara', `❌: ${thisPara.rawContent}`) + return false + } catch (error) { + logError('blocks/isAChildPara', `isAChildPara(): ${error.message}`) + return false + } +} + +/** + * Get the child (indented) paragraphs of a given 'parent' paragraph (including [great]grandchildren). + * (JGC doesn't know enough to make jest tests for this.) + * @author @jgclark + * @param {TParagraph} para - the 'parent' paragraph + * @returns {Array} - array of child paragraphs + */ +export function getParaAndAllChildren(parentPara: TParagraph): Array { + const allChildren = parentPara.children() + // but if there are multiple levels of children, then there will be duplicates in this array, which we want to remove + const allChildrenNoDupes = allChildren.filter((p, index) => allChildren.findIndex((p2) => p2.lineIndex === p.lineIndex) === index) + + if (!allChildrenNoDupes.length) { + logDebug('blocks/getParaAndAllChildren', `No child paragraphs found`) + return [parentPara] + } + + const resultingParas = allChildrenNoDupes.slice() + resultingParas.unshift(parentPara) + // Show what we have ... + logDebug('blocks/getParaAndAllChildren', `Returns ${resultingParas.length} paras:`) + resultingParas.forEach((item, index, _array) => { + console.log(`- ${index}: "${item.content}" with ${item.indents} indents`) + }) + + return resultingParas +} diff --git a/helpers/dateTime.js b/helpers/dateTime.js index 89b6283ac..54b51aa66 100644 --- a/helpers/dateTime.js +++ b/helpers/dateTime.js @@ -1,4 +1,5 @@ // @flow +/* eslint-disable prefer-template */ //------------------------------------------------------------------------------- // Date functions, that don't rely on NotePlan functions/types // @jgclark except where shown @@ -29,6 +30,7 @@ export const RE_DATE = '\\d{4}-[01]\\d-[0123]\\d' // find ISO dates of form YYYY export const RE_YYYYMMDD_DATE = '\\d{4}[01]\\d[0123]\\d' // version of above that finds dates of form YYYYMMDD export const RE_DATE_CAPTURE = `(\\d{4}[01]\\d{1}\\d{2})` // capture date of form YYYYMMDD export const RE_ISO_DATE = RE_DATE // now earlier RE_DATE made the same as this stricter one +export const RE_ISO_DATE_ALL = new RegExp(RE_ISO_DATE, "g") export const RE_PLUS_DATE_G: RegExp = />(\d{4}-\d{2}-\d{2})(\+)*/g export const RE_PLUS_DATE: RegExp = />(\d{4}-\d{2}-\d{2})(\+)*/ export const RE_SCHEDULED_ISO_DATE = '>\\d{4}-[01]\\d-[0123]\\d' // find scheduled dates of form >YYYY-MM-DD @@ -42,6 +44,7 @@ export const RE_SCHEDULED_DAILY_NOTE_LINK: RegExp = />\d{4}-[01]\d-[0123]\d/ // // Week regex strings export const RE_NP_WEEK_SPEC = '\\d{4}\\-W[0-5]\\d' // find dates of form YYYY-Wnn +export const RE_NP_WEEK_ALL = new RegExp(RE_NP_WEEK_SPEC, "g") export const WEEK_NOTE_LINK = `[\<\>]${RE_NP_WEEK_SPEC}` export const SCHEDULED_WEEK_NOTE_LINK = '\\s+>\\d{4}\\-W[0-5]\\d' export const RE_SCHEDULED_WEEK_NOTE_LINK: RegExp = />\d{4}\-W[0-5]\d/ // Note: finds '>RE_NP_WEEK_SPEC' @@ -50,8 +53,9 @@ export const RE_BARE_WEEKLY_DATE = `[^\d(<\/-]${RE_NP_WEEK_SPEC}` // a YYYY-Www export const RE_BARE_WEEKLY_DATE_CAPTURE = `[^\d(<\/-](${RE_NP_WEEK_SPEC})` // capturing date in above // Months -// export const RE_NP_MONTH_SPEC = '(?]${RE_NP_MONTH_SPEC}` export const SCHEDULED_MONTH_NOTE_LINK = `>${RE_NP_MONTH_SPEC}` export const RE_SCHEDULED_MONTH_NOTE_LINK: RegExp = new RegExp(`>${RE_NP_MONTH_SPEC}`) @@ -59,20 +63,25 @@ export const RE_MONTHLY_NOTE_FILENAME = `(^|\\/)${RE_NP_MONTH_SPEC}${RE_FILE_EXT // Quarters export const RE_NP_QUARTER_SPEC = '\\d{4}\\-Q[1-4](?!\\d)' // find dates of form YYYY-Qn not followed by digit +export const RE_NP_QUARTER_ALL = new RegExp(RE_NP_QUARTER_SPEC, "g") export const QUARTER_NOTE_LINK = `[\<\>]${RE_NP_QUARTER_SPEC}` export const SCHEDULED_QUARTERLY_NOTE_LINK = `>${RE_NP_QUARTER_SPEC}` export const RE_SCHEDULED_QUARTERLY_NOTE_LINK: RegExp = new RegExp(`>${RE_NP_QUARTER_SPEC}`) export const RE_QUARTERLY_NOTE_FILENAME = `(^|\\/)${RE_NP_QUARTER_SPEC}${RE_FILE_EXTENSIONS_GROUP}` +// Half-Years -- Note: not supported by NotePlan, but used by @jgclark in some places +export const RE_NP_HALFYEAR_SPEC = '\\d{4}\\H[1-2](?!\\d)' // find dates of form YYYYHn not followed by digit + // Years export const RE_NP_YEAR_SPEC = '\\d{4}(?![\\d-])' // find years of form YYYY without leading or trailing - or digit [fails if I add negative start or negative lookbehinds] export const RE_BARE_YEAR_SPEC = '^\\d{4}$' // find years of form YYYY without anything leading or trailing +export const RE_NP_YEAR_ALL = new RegExp(RE_NP_YEAR_SPEC, "g") export const YEAR_NOTE_LINK = `[\<\>]${RE_NP_YEAR_SPEC}` export const SCHEDULED_YEARLY_NOTE_LINK = `>${RE_NP_YEAR_SPEC}` export const RE_SCHEDULED_YEARLY_NOTE_LINK: RegExp = new RegExp(`>${RE_NP_YEAR_SPEC}`) export const RE_YEARLY_NOTE_FILENAME = `(^|\\/)${RE_NP_YEAR_SPEC}${RE_FILE_EXTENSIONS_GROUP}` -// Tests for all calendar period types +// Tests for all NP-supported scheduled date types export const RE_ANY_DUE_DATE_TYPE: RegExp = new RegExp(`\\s+>(${RE_DATE}|${RE_NP_WEEK_SPEC}|${RE_NP_MONTH_SPEC}|${RE_NP_QUARTER_SPEC}|${RE_NP_YEAR_SPEC})`) export const RE_IS_SCHEDULED: RegExp = new RegExp(`>(${RE_DATE}|${RE_NP_WEEK_SPEC}|${RE_NP_MONTH_SPEC}|${RE_NP_QUARTER_SPEC}|${RE_NP_YEAR_SPEC}|today)`) export const RE_SCHEDULED_DATES_G: RegExp = new RegExp(`>(${RE_DATE}|${RE_NP_WEEK_SPEC}|${RE_NP_MONTH_SPEC}|${RE_NP_QUARTER_SPEC}|${RE_NP_YEAR_SPEC}|today)`, 'g') @@ -85,7 +94,7 @@ export const RE_DONE_DATE_OR_DATE_TIME_DATE_CAPTURE: RegExp = new RegExp(`@done\ export const RE_DONE_DATE_OPT_TIME: RegExp = new RegExp(`@done\\(${RE_ISO_DATE}( ${RE_TIME})?\\)`) // Intervals -export const RE_DATE_INTERVAL = `[+\\-]?\\d+[BbDdWwMmQqYy]` +export const RE_DATE_INTERVAL = `[+\\-]?\\d+[BbDdWwMmQqHhYy]` // Note: includes @jgclark extension export const RE_OFFSET_DATE = `{\\^?${RE_DATE_INTERVAL}}` export const RE_OFFSET_DATE_CAPTURE = `{(\\^?${RE_DATE_INTERVAL})}` @@ -1001,6 +1010,78 @@ export function calcWeekOffset(startWeek: number, startYear: number, offset: num return { week, year } } +/** + * Method: take the Quarter date and divide by 2. + * So: H11->Q1, H2->Q3. + * Note: Written as moment library doesn't support half-years + * @param {string} inDateStr + * @returns {string} + */ +export function convertHalfYearDateToQuarterDate(inDateStr: string): string { + let outDateStr = '' + if (inDateStr.endsWith('H1')) { + outDateStr = inDateStr.slice(0, inDateStr.length - 3) + '-Q1' + } else if (inDateStr.endsWith('H2')) { + outDateStr = inDateStr.slice(0, inDateStr.length - 3) + '-Q3' + } else { + logError('dateTime / convertHalfYearDateToQuarterDate', `'${inDateStr}' is not a valid HalfYear date`) + return '(error)' + } + logDebug('dateTime / convertHalfYearDateToQuarterDate', `converted ${inDateStr} -> ${outDateStr}`) + return outDateStr +} + +/** + * Method: take the Quarter date and divide by 2. + * So: Q1->H1, Q2->H1, Q3->H2, Q4->H2. + * Note: Written as moment library doesn't support half-years + * @param {string} inDateStr + * @returns {string} + */ +export function convertQuarterDateToHalfYearDate(inDateStr: string): string { + let outDateStr = '' + if (inDateStr.endsWith('-Q1') || inDateStr.endsWith('-Q2')) { + outDateStr = inDateStr.slice(0, inDateStr.length - 3) + 'H1' + } else if (inDateStr.endsWith('-Q3') || inDateStr.endsWith('-Q4')) { + outDateStr = inDateStr.slice(0, inDateStr.length - 3) + 'H2' + } else { + logError('dateTime / convertQuarterDateToHalfYearDate', `'${inDateStr}' is not a valid Quarter date`) + return '(error)' + } + logDebug('dateTime / convertQuarterDateToHalfYearDate', `converted ${inDateStr} -> ${outDateStr}`) + return outDateStr +} + +/** + * Get start/end ISO dates for a given Half-Year date string (YYYY-H[1|2]) + * Note: Written as moment library doesn't support half-years. + * @tests done manually by JGC + * @param {string} inDateStr + * @returns {[string, string]} + */ +export function getHalfYearRangeDate(inDateStr: string): [string, string] { + logDebug('dateTime / getHalfYearRangeDate', `Starting for ${inDateStr}`) + let startDateStr = '' + let endDateStr = '' + const year = Number(inDateStr.slice(0, 4)) + if (isNaN(year)) { + logError('dateTime / getHalfYearRangeDate', `Invalid year in ${inDateStr}`) + return ['(error)', '(error)'] + } + if (inDateStr.endsWith('H1')) { + startDateStr = `${year}-01-01` + endDateStr = `${year}-06-30` + } else if (inDateStr.endsWith('H2')) { + startDateStr = `${year}-07-01` + endDateStr = `${year}-12-31` + } else { + logError('dateTime / getHalfYearRangeDate', `'${inDateStr}' is not a valid Halfyear date`) + return ['(error)', '(error)'] + } + logDebug('dateTime / getHalfYearRangeDate', `-> [${startDateStr}, ${endDateStr}]`) + return [startDateStr, endDateStr] +} + /** * Get moment format unit [bdwMQy] equivalent to my offset unit [bdwmqy] * @param {string} unit @@ -1015,6 +1096,9 @@ function convertOffsetUnitToMomentUnit(unit: string): string { case 'q': unitForMoment = 'Q' break + case 'h': + unitForMoment = 'y' // Note: this isn't fully accurate, and needs to be corrected for later. + break default: unitForMoment = unit break @@ -1093,10 +1177,10 @@ export function getPeriodOfNPDateStr(dateStr: string): string { /** * Calculate an offset date of a NP Daily/Weekly/Monthly/Quarterly/Yearly date string, and return as a JS Date. - * v5 method, using 'moment' library to avoid using NP calls, now extended to allow for strings as well. Docs: https://momentjs.com/docs/#/get-set/ + * v5 method, using 'moment' library to avoid using NP calls, now extended to allow for strings as well. Docs: https://momentjs.com/docs/#/get-set/ * @author @jgclark * - * @param {string} baseDateStrIn is type ISO Date (i.e. YYYY-MM-DD), NP's filename format YYYYMMDD, or NP Weekly/Monthly/Quarterly/Yearly date strings + * @param {string} baseDateStrIn is type ISO Date (i.e. YYYY-MM-DD), NP's filename format YYYYMMDD, or NP Weekly/Monthly/Quarterly/Yearly date strings, or (for @jgc) Half-Years (i.e. YYYYHn). * @param {interval} string of form +nn[bdwmq] or -nn[bdwmq], where 'b' is weekday (i.e. Monday - Friday in English) * @returns {Date} new date * @test - available in jest file @@ -1107,13 +1191,22 @@ export function calcOffsetDate(baseDateStrIn: string, interval: string): Date | logError('dateTime / cOD', `Invalid date interval '${interval}'`) return null } - const unit = interval.charAt(interval.length - 1) // get last character - const num = Number(interval.substr(0, interval.length - 1)) // return all but last character + let unit = interval.charAt(interval.length - 1) // get last character + let num = Number(interval.substr(0, interval.length - 1)) // return all but last character + // Note: splitIntervalToParts(interval) has a different output! + + // Note: Annoyingly, Moment doesn't cope with half-years, so we need to convert to quarters and back again TODO: finish this here and in calcOffsetDateStr() + if (unit === 'h') { + unit = 'q' + num = Math.floor(num * 2) + logDebug('dateTime / cOD', `special case: converted half-year interval ${interval} -> ${num} / ${unit}`) + } - // short codes in moment library aren't quite the same as mine + // Note: the short codes in moment library aren't quite the same as mine const unitForMoment = convertOffsetUnitToMomentUnit(unit) let momentDateFormat = '' + let baseDateStr = baseDateStrIn if (baseDateStrIn.match(RE_ISO_DATE)) { momentDateFormat = 'YYYY-MM-DD' } else if (baseDateStrIn.match(RE_YYYYMMDD_DATE)) { @@ -1125,6 +1218,12 @@ export function calcOffsetDate(baseDateStrIn: string, interval: string): Date | momentDateFormat = MOMENT_FORMAT_NP_MONTH } else if (baseDateStrIn.match(RE_NP_QUARTER_SPEC)) { momentDateFormat = MOMENT_FORMAT_NP_QUARTER + } else if (baseDateStrIn.match(RE_NP_HALFYEAR_SPEC)) { + momentDateFormat = MOMENT_FORMAT_NP_QUARTER + const h = parseInt(baseDateStrIn.charAt(baseDateStrIn.length - 1)) + const q = Math.floor(h * 2) + baseDateStr = baseDateStrIn.slice(0, 4) + '-Q' + String(q) + logDebug('dateTime / cOD', `special case: baseDatrStrIn ${baseDateStrIn} converted -> ${baseDateStr}`) } else if (baseDateStrIn.match(RE_NP_YEAR_SPEC)) { // NB: test has to go at end as it will match all longer formats momentDateFormat = MOMENT_FORMAT_NP_YEAR @@ -1133,10 +1232,10 @@ export function calcOffsetDate(baseDateStrIn: string, interval: string): Date | } // calc offset (Note: library functions cope with negative nums, so just always use 'add' function) - const baseDateMoment = moment(baseDateStrIn, momentDateFormat) + const baseDateMoment = moment(baseDateStr, momentDateFormat) const newDate = unit !== 'b' ? baseDateMoment.add(num, unitForMoment) : momentBusiness(baseDateMoment).businessAdd(num).toDate() - // logDebug('dateTime / cOD', `for '${baseDateStrIn}' interval ${num} / ${unitForMoment} -> ${String(newDate)}`) + // logDebug('dateTime / cOD', `for '${baseDateStr}' interval ${num} / ${unitForMoment} -> ${String(newDate)}`) return newDate } catch (e) { logError('dateTime / cOD', `${e.message} for '${baseDateStrIn}' interval '${interval}'`) @@ -1156,7 +1255,13 @@ export function splitIntervalToParts(intervalStr: string): { number: number, typ const intervalNumber = Number(interval.slice(0, interval.length - 1)) const intervalChar = interval.charAt(interval.length - 1) const intervalType = - intervalChar === 'd' ? 'day' : intervalChar === 'w' ? 'week' : intervalChar === 'm' ? 'month' : intervalChar === 'q' ? 'quarter' : intervalChar === 'y' ? 'year' : 'error' + intervalChar === 'd' ? 'day' + : intervalChar === 'w' ? 'week' + : intervalChar === 'm' ? 'month' + : intervalChar === 'q' ? 'quarter' + : intervalChar === 'h' ? 'half-year' + : intervalChar === 'y' ? 'year' + : 'error' const intervalParts = { number: intervalNumber, type: intervalType } return intervalParts } @@ -1166,12 +1271,13 @@ export function splitIntervalToParts(intervalStr: string): { number: number, typ * v5 method, using 'moment' library to avoid using NP calls, now extended to allow for Weekly, Monthly etc. strings as well. * WARNING: don't use when you want the output to be in week format, as the moment library doesn't understand different start-of-weeks. Use NPdateTime::getNPWeekData() instead. * Moment docs: https://momentjs.com/docs/#/get-set/ - * - 'baseDateIn' the base date as a string in any of the formats that NP supports: YYYY-MM-DD, YYYYMMDD (filename format), YYYY-Wnn, YYYY-MM, YYYY-Qn, YYYY. - * - 'offsetInterval' of form +nn[bdwmq] or -nn[bdwmq], where 'b' is weekday (i.e. Monday - Friday in Europe and Americas) + * - 'baseDateIn' the base date as a string in any of the formats that NP supports: YYYY-MM-DD, YYYYMMDD (filename format), YYYY-Wnn, YYYY-MM, YYYY-Qn, YYYY (plus 'half-year' YYYY-Hn) + * - 'offsetInterval' of form +nn[bdwmqhy] or -nn[bdwmqhy], where 'b' is weekday (i.e. Monday - Friday in Europe and Americas) * - 'adaptOutputInterval' (optional). Options: 'shorter', 'longer', 'offset', 'base', 'day', 'week', 'month', 'quarter', 'year' + * TODO: finish adding half-year support * @author @jgclark - * @param {string} baseDateIn the base date as a string in any of the formats that NP supports: YYYY-MM-DD, YYYYMMDD (filename format), YYYY-Wnn, YYYY-MM, YYYY-Qn, YYYY. - * @param {string} offsetInterval of form +nn[bdwmq] or -nn[bdwmq], where 'b' is weekday (i.e. Monday - Friday in Europe and Americas) + * @param {string} baseDateIn the base date as a string in any of the formats that NP supports: YYYY-MM-DD, YYYYMMDD (filename format), YYYY-Wnn, YYYY-MM, YYYY-Qn, YYYY, plus YYYYHn for @jgc + * @param {string} offsetInterval of form +nn[bdwmqhy] or -nn[bdwmqhy], where 'b' is weekday (i.e. Monday - Friday in Europe and Americas), and 'h' is @jgc addition for half-year * @param {string?} adaptOutputInterval. Options: 'shorter', 'longer', 'offset', 'base', 'day', 'week', 'month', 'quarter', 'year' * - 'shorter': keep the shorter of the two calendar types. E.g. a daily date + 1w -> daily date. Or '2023-07' + '2w' -> '2023-W28'. * - 'longer': use the longer of the two calendar types. E.g. a daily date + 1w -> weekly date. @@ -1179,7 +1285,7 @@ export function splitIntervalToParts(intervalStr: string): { number: number, typ * - 'base': (default) keep the type of the base date. * - 'day', 'week', 'month', 'quarter', 'year': lock to that calendar type. * @returns {string} new date in the requested format - * @tests - available in jest file (though not for the most recent adaptOutputInterval options) + * @tests - available in jest file (though not for the most recent adaptOutputInterval options). */ export function calcOffsetDateStr(baseDateIn: string, offsetInterval: string, adaptOutputInterval: string = 'base'): string { try { @@ -1190,20 +1296,15 @@ export function calcOffsetDateStr(baseDateIn: string, offsetInterval: string, ad throw new Error('Empty offsetInterval string') } const offsetUnit = offsetInterval.charAt(offsetInterval.length - 1) // get last character - // logDebug('dateTime / cODS', `Starting with ${adaptOutputInterval} adapt for ${baseDateIn} + ${offsetInterval}`) + logDebug('dateTime / cODS', `Starting with adapt type '${adaptOutputInterval}' for ${baseDateIn} + ${offsetInterval}`) - // calc offset date - // (Note: library functions cope with negative nums, so just always use 'add' function) - const offsetDate = calcOffsetDate(baseDateIn, offsetInterval) - if (!offsetDate) { - throw new Error('Invalid return from calcOffsetDate()') - } - // Now decide how to format the new date. + // Decide how to format the new date. // Start with using baseDateIn's format - const calendarTypeOrder = 'dbwmqy' + const calendarTypeOrder = 'dbwmqhy' let newDateStr = '' let baseDateMomentFormat = '' let baseDateUnit = '' + let baseDate = baseDateIn if (baseDateIn.match(RE_ISO_DATE)) { baseDateMomentFormat = MOMENT_FORMAT_NP_ISO baseDateUnit = 'd' @@ -1220,6 +1321,10 @@ export function calcOffsetDateStr(baseDateIn: string, offsetInterval: string, ad } else if (baseDateIn.match(RE_NP_QUARTER_SPEC)) { baseDateMomentFormat = MOMENT_FORMAT_NP_QUARTER baseDateUnit = 'q' + } else if (baseDateIn.match(RE_NP_HALFYEAR_SPEC)) { + baseDateMomentFormat = MOMENT_FORMAT_NP_QUARTER // Note: this is a workaround as Moment won't support half-years + baseDate = convertHalfYearDateToQuarterDate(baseDateIn) + baseDateUnit = 'h' } else if (baseDateIn.match(RE_NP_YEAR_SPEC)) { // NB: test has to go at end as it will match all longer formats baseDateMomentFormat = MOMENT_FORMAT_NP_YEAR @@ -1227,29 +1332,48 @@ export function calcOffsetDateStr(baseDateIn: string, offsetInterval: string, ad } else { throw new Error('Invalid date string') } + + // calc offset date + // (Note: library functions cope with negative nums, so just always use 'add' function) + const offsetDate = calcOffsetDate(baseDate, offsetInterval) + if (!offsetDate) { + throw new Error('Invalid return from calcOffsetDate()') + } + const newDateStrFromBaseDateType = moment(offsetDate).format(baseDateMomentFormat) newDateStr = newDateStrFromBaseDateType + // // Deal with special half-year case: take the Quarter number and divide by 2 + // if (baseDateUnit === 'h') { + // newDateStr = convertQuarterDateToHalfYearDate(newDateStr) + // } + // Also calculate offset's output format - const offsetMomentFormat = offsetUnit === 'd' && baseDateIn.match(RE_YYYYMMDD_DATE) ? MOMENT_FORMAT_NP_DAY : getNPDateFormatForDisplayFromOffsetUnit(offsetUnit) + const offsetMomentFormat = (offsetUnit === 'd') && baseDate.match(RE_YYYYMMDD_DATE) + ? MOMENT_FORMAT_NP_DAY + : getNPDateFormatForDisplayFromOffsetUnit(offsetUnit) const newDateStrFromOffsetDateType = moment(offsetDate).format(offsetMomentFormat) if (offsetUnit === 'w') { logInfo( 'dateTime / cODS', - `- This output will only be accurate if your week start is a Monday. Please raise an issue if this is not the case. More details in DEBUG-level log.`, + `- This output will only be accurate if your week start is a Monday. More details in DEBUG-level log.`, ) logDebug( 'dateTime / cODS', - ` Details: ${adaptOutputInterval} adapt for ${baseDateIn} / ${baseDateUnit} / ${baseDateMomentFormat} / ${offsetMomentFormat} / ${offsetInterval} / ${newDateStrFromOffsetDateType}`, + ` Details: ${adaptOutputInterval} adapt for ${baseDate} / ${baseDateUnit} / ${baseDateMomentFormat} / ${offsetMomentFormat} / ${offsetInterval} / ${newDateStrFromOffsetDateType}`, ) } - // If we want to adapt smaller + // adapt the output format if required switch (adaptOutputInterval) { case 'offset': { newDateStr = newDateStrFromOffsetDateType logDebug('dateTime / cODS', `- 'offset' output: -> ${newDateStrFromOffsetDateType}`) + // Note: As Moment doesn't cope with half-years, we need to convert back from quarters FIXME: full date-string, not NP-style here + if (offsetUnit === 'h') { + newDateStr = convertQuarterDateToHalfYearDate(newDateStr) + } break } case 'shorter': { @@ -1292,6 +1416,13 @@ export function calcOffsetDateStr(baseDateIn: string, offsetInterval: string, ad logDebug('dateTime / cODS', `- 'quarter' output: changed format to ${offsetMomentFormat}`) break } + case 'halfyear': { + // Note the workaround of keeping as quarters + const offsetMomentFormat = getNPDateFormatForDisplayFromOffsetUnit('q') + newDateStr = moment(offsetDate).format(offsetMomentFormat) + logDebug('dateTime / cODS', `- 'halfyear' output: temporarily changed format to ${offsetMomentFormat}`) + break + } case 'year': { const offsetMomentFormat = getNPDateFormatForDisplayFromOffsetUnit('y') newDateStr = moment(offsetDate).format(offsetMomentFormat) @@ -1304,6 +1435,13 @@ export function calcOffsetDateStr(baseDateIn: string, offsetInterval: string, ad break } } + + // Finally, deal with special half-year case: take the Quarter number and divide by 2 + // FIXME: not always being applied + if (baseDateUnit === 'h') { + newDateStr = convertQuarterDateToHalfYearDate(newDateStr) + } + // logDebug('dateTime / cODS', `for '${baseDateIn}' date, offsetInterval ${offsetInterval} using type ${adaptOutputInterval} -> '${newDateStr}'`) return newDateStr } catch (e) { @@ -1318,11 +1456,11 @@ export function calcOffsetDateStr(baseDateIn: string, offsetInterval: string, ad * (Uses 'moment' library to avoid using NP calls. Docs: https://momentjs.com/docs/#/get-set/) * @author @jgclark * @param {string} offsetInterval of form +nn[bdwmq] or -nn[bdwmq], where 'b' is weekday (i.e. Monday - Friday in English) - * @param {string?} baseDateISO is type ISO Date (i.e. YYYY-MM-DD) - NB: different from JavaScript's Date type. If not given then today's date is used. + * @param {string?} baseISODateStr is type ISO Date (i.e. YYYY-MM-DD) - NB: not JavaScript's Date type. If not given then today's date is used. * @returns {string} new date in the same format that was supplied * @test - available in jest file */ -export function calcOffsetDateStrUsingCalendarType(offsetInterval: string, baseDateISOIn: string = ''): string { +export function calcOffsetDateStrUsingCalendarType(offsetInterval: string, baseISODateStr: string = ''): string { try { // Check offsetInterval is valid if (offsetInterval === '') { @@ -1333,12 +1471,12 @@ export function calcOffsetDateStrUsingCalendarType(offsetInterval: string, baseD } const unit = offsetInterval.charAt(offsetInterval.length - 1) // get last character - // Check baseDateISOIn is valid - if (baseDateISOIn !== '' && !baseDateISOIn.match(RE_ISO_DATE)) { - throw new Error(`Invalid ISO input date '${baseDateISOIn}'`) + // Check baseISODateStr is valid + if (baseISODateStr !== '' && !baseISODateStr.match(RE_ISO_DATE)) { + throw new Error(`Invalid ISO input date '${baseISODateStr}'`) } - // If no baseDateISOIn, use today's date - const baseDateISO = baseDateISOIn !== '' ? baseDateISOIn : new moment().startOf('day').format('YYYY-MM-DD') + // If no baseISODateStr, use today's date + const baseDateISO = baseISODateStr !== '' ? baseISODateStr : new moment().startOf('day').format('YYYY-MM-DD') // calc offset (Note: library functions cope with negative nums, so just always use 'add' function) const offsetDate = calcOffsetDate(baseDateISO, offsetInterval) @@ -1355,7 +1493,7 @@ export function calcOffsetDateStrUsingCalendarType(offsetInterval: string, baseD // logDebug('dateTime / cODSUCT', `for '${offsetInterval}' (unit=${unit}) from ${baseDateISO}' -> ${newDateStr} using type ${momentDateFormat}`) return newDateStr } catch (e) { - logError('dateTime / cODSUCT', `${e.message} for '${baseDateISOIn}' offsetInterval '${offsetInterval}'`) + logError('dateTime / cODSUCT', `${e.message} for '${baseISODateStr}' offsetInterval '${offsetInterval}'`) return '(error)' } } diff --git a/helpers/paragraph.js b/helpers/paragraph.js index dda6bfbb2..ee989bc0a 100644 --- a/helpers/paragraph.js +++ b/helpers/paragraph.js @@ -203,8 +203,50 @@ export function printParagraph(p: TParagraph) { } /** - * Appends text to a chosen note, but more smartly than usual. - * I.e. adds before any ## Done or ## Completed archive section. + * Return the heading level (1-5) of a given heading, by counting the number of contiguous leading # markers. + * @param {string} heading to check + * @returns + */ +export function getHeadingLevel(heading: string): number { + if (/^#+\s+/.test(heading)) { + const match = heading.match(/^#+/) + return match ? match[0].length : 0 + } else { + return 0 + } +} + +/** + * Strip off any leading # markers + * @param {string} inString + * @returns {string} + */ +export function getHeadingTextFromMarkdownHeadingText(inString: string): string { + return inString.replaceAll('#', '').trimLeft() +} + +/** + * Adds a heading (not title!) to a note based on the number of leading '#' markers -- or if none default to 2. + * @param {*} note + * @param {*} heading + * @param {*} insertionIndex + */ +export function smartInsertHeading(note: TNote, markdownHeading: string, insertionIndex: number): void { + let headingLevel = 2 + let headingStr = markdownHeading + const markdownHeadingLevel = getHeadingLevel(markdownHeading) + // logDebug('smartInsertHeading', `markdownHeadingLevel = ${String(markdownHeadingLevel)}`) + if (markdownHeadingLevel > 0) { + headingStr = getHeadingTextFromMarkdownHeadingText(markdownHeading) + headingLevel = markdownHeadingLevel + } + logDebug('smartInsertHeading', `inserting headingLevel ${String(headingLevel)} for '${headingStr}' at line ${String(insertionIndex)}`) + note.insertHeading(headingStr, insertionIndex, headingLevel) +} + +/** + * Appends text to a chosen note, but more smartly than usual: before any ## Done or ## Completed archive section. + * If the ParagraphType is 'title' (which includes section headings), then add with the appropriate heading level based on the number of leading '#' markers -- or if none default to 2. * @author @jgclark * * @param {TNote} note - the note to append to @@ -213,13 +255,17 @@ export function printParagraph(p: TParagraph) { */ export function smartAppendPara(note: TNote, paraText: string, paragraphType: ParagraphType): void { // Insert the text at the smarter point (+ 1 as the API call inserts before the line in question) - note.insertParagraph(paraText, findEndOfActivePartOfNote(note) + 1, paragraphType) + if (paragraphType === 'title') { + smartInsertHeading(note, paraText, findEndOfActivePartOfNote(note) + 1) + } else { + note.insertParagraph(paraText, findEndOfActivePartOfNote(note) + 1, paragraphType) + } + DataStore.updateCache(note) // don't know if this helps } /** - * Prepends text to a chosen note, but more smartly than usual. - * I.e. if the note starts with YAML frontmatter - * or a metadata line (= starts with a hashtag), then add after that. + * Prepends text to a chosen note, but more smartly than usual: after any YAML frontmatter or a metadata line (= starts with a hashtag). + * If the ParagraphType is 'title' (which includes section headings), then add with the appropriate heading level based on the number of leading '#' markers -- or if none default to 2. * Note: see smartPrependParas that works on multiple lines * @author @jgclark * @@ -229,7 +275,12 @@ export function smartAppendPara(note: TNote, paraText: string, paragraphType: Pa */ export function smartPrependPara(note: TNote, paraText: string, paragraphType: ParagraphType): void { // Insert the text at the smarter point - note.insertParagraph(paraText, findStartOfActivePartOfNote(note), paragraphType) + if (paragraphType === 'title') { + smartInsertHeading(note, paraText, findStartOfActivePartOfNote(note)) + } else { + note.insertParagraph(paraText, findStartOfActivePartOfNote(note), paragraphType) + } + DataStore.updateCache(note) // don't know if this helps } /** @@ -290,10 +341,8 @@ export function smartCreateSectionsAndPara(destNote: TNote, paraText: string, pa /** * TEST: - * Prepends multiple lines of text to a chosen note, as separate paragraphs, but more smartly than usual. - * I.e. if the note starts with YAML frontmatter - * or a metadata line (= starts with a hashtag), then add after that. - * Note: does work on a single line too + * Prepends multiple lines of text to a chosen note, as separate paragraphs, but more smartly than usual: adds after any YAML frontmatter or metadata line (= starts with a hashtag). + * Note: does work on a single line too. * @author @jgclark * * @param {TNote} note - the note to prepend to diff --git a/helpers/sorting.js b/helpers/sorting.js index 3a49e2de0..9595af168 100644 --- a/helpers/sorting.js +++ b/helpers/sorting.js @@ -163,7 +163,7 @@ export function getElementsFromTask(content: string, reSearch: RegExp): Array 4) * @author @dwertheimer extended by @jgclark * @param {SortableParagraphSubset} item @@ -189,7 +189,7 @@ export function getNumericPriority(item: SortableParagraphSubset): number { * @returns {number} priority from 3, 2, 1, -1 (default) */ export function getNumericPriorityFromPara(para: TParagraph): number { - const item: SortableParagraphSubset = getSortableTask(para) + const item: SortableParagraphSubset = getSortableParaSubset(para) return getNumericPriority(item) } @@ -223,7 +223,7 @@ export function calculateParagraphType(para: TParagraph): string { * @returns {SortableParagraphSubset} - a sortable object * @author @dwertheimer */ -export function getSortableTask(para: TParagraph): SortableParagraphSubset { +export function getSortableParaSubset(para: TParagraph): SortableParagraphSubset { const content = para.content const hashtags = getElementsFromTask(content, RE_HASHTAGS) const mentions = getElementsFromTask(content, RE_MENTIONS) @@ -260,22 +260,26 @@ export function getSortableTask(para: TParagraph): SortableParagraphSubset { */ export function getTasksByType(paragraphs: $ReadOnlyArray, ignoreIndents: boolean = false, useCalculatedScheduled: boolean = false): GroupedTasks { const tasks = TASK_TYPES.reduce((acc, t) => ({ ...acc, ...{ [t]: [] } }), {}) - let lastParent = { indents: 999, children: [] } + // $FlowFixMe[prop-missing] only dealing with sub-type here + let lastParent: SortableParagraphSubset = { indents: 999, children: [] } // clo(paragraphs, 'getTasksByType') for (let index = 0; index < paragraphs.length; index++) { const para = paragraphs[index] // logDebug('getTasksByType', `${para.lineIndex}: ${para.type}`) - if (isTask || (!ignoreIndents && para.indents > lastParent.indents)) { + // FlowFixMe[invalid-compare] reason for suppression + if (isTask || (!ignoreIndents && (para.indents > lastParent.indents))) { // const content = para.content // Not used // console.log(`found: ${index}: ${para.type}: ${para.content}`) try { - const task: SortableParagraphSubset = getSortableTask(para) + const task: SortableParagraphSubset = getSortableParaSubset(para) if (!ignoreIndents && para.indents > lastParent.indents) { lastParent.children.push(task) } else { const ct = useCalculatedScheduled ? task.calculatedType : task.type // will always be the same as para.type except in case of scheduled + // $FlowIgnore[invalid-computed-prop] if (ct && tasks[ct]) { const len = tasks[ct].push(task) + // $FlowIgnore[invalid-computed-prop] lastParent = tasks[ct][len - 1] } } diff --git a/helpers/userInput.js b/helpers/userInput.js index 87dbf472e..e97633e7e 100644 --- a/helpers/userInput.js +++ b/helpers/userInput.js @@ -247,7 +247,7 @@ export async function chooseFolder(msg: string, includeArchive?: boolean = false folderOptionList.push({ label: '📁 /', value: '/' }) } } - // const re = await CommandBar.showOptions(folders, msg) + // TODO(dwertheimer): fix what happens in this function, which Cursor says isn't specified correctly. ;({ value, keyModifiers } = await chooseOptionWithModifiers(msg, folderOptionList)) if (keyModifiers?.length && keyModifiers.indexOf('opt') > -1) { folder = NEW_FOLDER @@ -539,7 +539,7 @@ const relativeDates = getRelativeDates() export async function createNewNote(_title?: string = '', _content?: string = '', _folder?: string = ''): Promise { const title = _title || (await getInput('Title of new note', 'OK', 'New Note', '')) const content = _content - if (title) { + if (title !== '' && typeof title === 'string') { const folder = _folder || (await chooseFolder('Select folder to add note in:', false, true)) const noteContent = `# ${title}\n${content}` const filename = await DataStore.newNoteWithContent(noteContent, folder) @@ -613,7 +613,7 @@ export async function chooseNote( isInIgnoredFolder = isInIgnoredFolder || !/(\.md|\.txt)$/i.test(note.filename) //do not include non-markdown files return !isInIgnoredFolder }) - const sortedNoteListFiltered = noteListFiltered.sort((first, second) => second.changedDate - first.changedDate) // most recent first + const sortedNoteListFiltered = noteListFiltered.sort((first, second) => second.changedDate.getTime() - first.changedDate.getTime()) // most recent first const opts = sortedNoteListFiltered.map((note) => { return displayTitleWithRelDate(note) }) diff --git a/jgclark.EventHelpers/CHANGELOG.md b/jgclark.EventHelpers/CHANGELOG.md index 97ddd29a9..c2146492e 100644 --- a/jgclark.EventHelpers/CHANGELOG.md +++ b/jgclark.EventHelpers/CHANGELOG.md @@ -2,6 +2,14 @@ See [website README for more details](https://github.com/NotePlan/plugins/tree/main/jgclark.EventHelpers), and how to configure. +## [0.22.2] - ??? unreleased @jgclark +### Dev notes +- code refactoring to allow shiftDates() to be used by other commands +- split offsets.js into 2 files + +## [0.22.1] - 2025-01-20 @jgclark +- extend /shift dates to cover weekly, monthly, quarterly and yearly dates as well as daily ones + ## [0.22.0] - 2024-09-06 @jgclark - can now use `events()` and `matchingEvents()` calls from Templates running on Weekly notes and other non-daily Calendar notes (for @gdrn). - refactored documentation. diff --git a/jgclark.EventHelpers/README.md b/jgclark.EventHelpers/README.md index ef67ee69c..90eb14f65 100644 --- a/jgclark.EventHelpers/README.md +++ b/jgclark.EventHelpers/README.md @@ -5,7 +5,7 @@ This plugin provides commands to help you do useful things with Events and Calen - **insert matching events**: insert a list of this day's calendar events that match certain patterns into the current note - **time blocks to calendar**: takes [NotePlan-defined time blocks](https://help.noteplan.co/article/52-part-2-tasks-events-and-reminders#timeblocking) and converts to them to full Calendar events in your current default calendar, as set by iCal. (See also [Display of Time Blocks](#display-of-time-blocks) below.) - **process date offsets**: finds date offset patterns and turns them into due dates, based on date at start of section. (See [Date Offsets](#process-date-offsets) below for full details.) -- **shift dates**: takes dates _in the selected lines_ and shifts them forwards or backwards by a given date interval. It works on both `>YYYY-MM-DD` and `>YYYY-Wnn` style dates. (User George Crump (@george65) has created a [video showing how this command works](https://storone.zoom.us/rec/play/tzI6AreYeKvoyHRw11HX93IGVf2OI-U7WgKXYn2rmGJvbFHXZp8PSr6ajmOrtWymOU5jFIItScSJnL9U.tboBQEXjdw1uRTqu).) +- **shift dates**: takes dates _in the selected lines_ and shifts them forwards or backwards by a given date interval. It works on all YYYY-MM-DD (day), YYYY-Wnn (week) and YYYY-mm (month) style dates, in any place in the lines. (User George Crump (@george65) has created a [video showing how this command works](https://storone.zoom.us/rec/play/tzI6AreYeKvoyHRw11HX93IGVf2OI-U7WgKXYn2rmGJvbFHXZp8PSr6ajmOrtWymOU5jFIItScSJnL9U.tboBQEXjdw1uRTqu).) Most of these commands require configuration, described in the sections below. On macOS, click the gear button on the 'Event Helpers' line in the Plugin Preferences panel to access the settings. Or on on iOS/iPadOS devices, use the **Events: update plugin settings** command instead. diff --git a/jgclark.EventHelpers/plugin.json b/jgclark.EventHelpers/plugin.json index fcaff10c0..03bad233e 100644 --- a/jgclark.EventHelpers/plugin.json +++ b/jgclark.EventHelpers/plugin.json @@ -8,8 +8,8 @@ "plugin.author": "jgclark", "plugin.url": "https://github.com/NotePlan/plugins/tree/main/jgclark.EventHelpers", "plugin.changelog": "https://github.com/NotePlan/plugins/tree/main/jgclark.EventHelpers/CHANGELOG.md", - "plugin.version": "0.22.1", - "plugin.lastUpdateInfo": "0.22.1: improve setting defaults and documentation.\n0.22.0: can now use events() template calls in Weekly notes.\n0.21.3: bug fix adding time blocks to calendar.\n0.21.2: /shiftDates now covers more cases.\n0.21.1: add 'Yes to all' to option to create time blocks. Extend '/shift dates' to work on week dates.\n0.21.0: improvements to '/shift dates' and '/process date offsets'.", + "plugin.version": "0.22.2", + "plugin.lastUpdateInfo": "0.22.2: ???\n0.22.1: extend /shift dates to cover weekly, monthly, quarterly and yearly dates as well as daily ones. Improve setting defaults and documentation.\n0.22.0: can now use events() template calls in Weekly notes.\n0.21.3: bug fix adding time blocks to calendar.\n0.21.2: /shiftDates now covers more cases.\n0.21.1: add 'Yes to all' to option to create time blocks. Extend '/shift dates' to work on week dates.\n0.21.0: improvements to '/shift dates' and '/process date offsets'.", "plugin.dependencies": [], "plugin.script": "script.js", "plugin.isRemote": "false", @@ -40,7 +40,8 @@ { "name": "shift dates", "alias": [ - "offset" + "offset", + "shift" ], "description": "takes dates in the selection and shifts them forwards or backwards by a given date interval", "jsFunction": "shiftDates" diff --git a/jgclark.EventHelpers/src/index.js b/jgclark.EventHelpers/src/index.js index 386d0c890..3190114ce 100644 --- a/jgclark.EventHelpers/src/index.js +++ b/jgclark.EventHelpers/src/index.js @@ -3,7 +3,7 @@ //----------------------------------------------------------------------------- // Event Helpers // Jonathan Clark -// last updated 29.9.2023, for v0.20.4 +// last updated 2025-01-20, for v0.22.2 //----------------------------------------------------------------------------- // allow changes in plugin.json to trigger recompilation @@ -14,8 +14,14 @@ import { editSettings } from '@helpers/NPSettings' import { showMessage } from '@helpers/userInput' export { timeBlocksToCalendar } from './timeblocks' -export { listDaysEvents, insertDaysEvents, listMatchingDaysEvents, insertMatchingDaysEvents } from './eventsToNotes' -export { processDateOffsets, shiftDates } from './offsets' +export { + listDaysEvents, + insertDaysEvents, + listMatchingDaysEvents, + insertMatchingDaysEvents +} from './eventsToNotes' +export { processDateOffsets } from './offsetDates' +export { shiftDates } from './shiftDates' export function init(): void { // In the background, see if there is an update to the plugin to install, and if so let user know diff --git a/jgclark.EventHelpers/src/offsets.js b/jgclark.EventHelpers/src/offsetDates.js similarity index 52% rename from jgclark.EventHelpers/src/offsets.js rename to jgclark.EventHelpers/src/offsetDates.js index e6773a97e..9f80806ec 100644 --- a/jgclark.EventHelpers/src/offsets.js +++ b/jgclark.EventHelpers/src/offsetDates.js @@ -1,194 +1,29 @@ // @flow // ---------------------------------------------------------------------------- -// Command to Process Date Offsets and Shifts +// Command to Process Date Offsets // @jgclark -// Last updated 13.2.2024 for v0.21.2+, by @jgclark +// Last updated 2025-01-20 for v0.22.2, by @jgclark // ---------------------------------------------------------------------------- -// TODO: -// * [Allow other date styles in /process date offsets](https://github.com/NotePlan/plugins/issues/221) from Feb 2021 -- but much harder than it looks. -// * Also allow other date styles in /shift? -- as above - import pluginJson from '../plugin.json' -import { getEventsSettings } from './eventsHelpers' import { timeBlocksToCalendar } from './timeblocks' import { calcOffsetDateStr, RE_BARE_DATE_CAPTURE, RE_BARE_DATE, - // RE_BARE_WEEKLY_DATE, - // RE_BARE_WEEKLY_DATE_CAPTURE, RE_DATE_INTERVAL, RE_DONE_DATE_OPT_TIME, - RE_ISO_DATE, - RE_NP_WEEK_SPEC, RE_OFFSET_DATE, RE_OFFSET_DATE_CAPTURE, - splitIntervalToParts, - // toISODateString, - // toLocaleDateString, } from '@helpers/dateTime' -// import { getNPWeekData } from '@helpers/NPdateTime' import { clo, log, logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' import { findEndOfActivePartOfNote } from '@helpers/paragraph' import { stripBlockIDsFromString } from '@helpers/stringTransforms' import { isTimeBlockPara } from '@helpers/timeblocks' -import { askDateInterval, datePicker, showMessage, showMessageYesNo } from '@helpers/userInput' +import { datePicker, showMessage, showMessageYesNo } from '@helpers/userInput' // ---------------------------------------------------------------------------- -/** - * Shift Dates - * Go through currently selected lines in the open note and shift YYYY-MM-DD and YYYY-Wnn dates by an interval given by the user. - * Optionally removes @done(...) dates if wanted, but doesn't touch other others than don't have whitespace or newline before them. - * Optionally will un-complete completed tasks/checklists. - * Note: Only deals with ISO and Weekly dates so far. - * @author @jgclark - */ -export async function shiftDates(): Promise { - try { - const config = await getEventsSettings() - const RE_ISO_DATE_ALL = new RegExp(RE_ISO_DATE, "g") - const RE_NP_WEEK_ALL = new RegExp(RE_NP_WEEK_SPEC, "g") - - // Get working selection as an array of paragraphs - const { paragraphs, selection, note } = Editor - let pArr: $ReadOnlyArray = [] - const startingCursorPos = selection?.start ?? 0 - if (Editor == null || paragraphs == null || note == null) { - logError(pluginJson, `No note or content found to process. Stopping.`) - await showMessage('No note or content found to process.', 'OK', 'Shift Dates') - return - } - const selectionLength = selection?.length ?? 0 - if (selectionLength > 0) { - // Use just the selected paragraphs - pArr = Editor.selectedParagraphs - } else { - // Use the whole note - pArr = paragraphs.slice(0, findEndOfActivePartOfNote(note)) - } - logDebug(pluginJson, `shiftDates starting for ${pArr.length} lines`) - if (pArr.length === 0) { - logError(pluginJson, `Empty selection found. Stopping.`) - await showMessage('Please select some lines to process.', 'OK', 'Shift Dates') - return - } - - // Get interval to use - const interval = await askDateInterval("{question:'What interval would you like me to shift these dates by?'}") - if (interval === '') { - logError(pluginJson, `No valid interval supplied. Stopping.`) - await showMessage(`Sorry, that was not a valid date interval.`) - return - } - const intervalParts = splitIntervalToParts(interval) - - // Main loop - let updatedCount = 0 - pArr.forEach((p) => { - const origContent = p.content - let dates: Array = [] - let originalDateStr = '' - - // Work on lines with dates - if (origContent.match(RE_ISO_DATE) || origContent.match(RE_NP_WEEK_SPEC)) { - // As we're about to update the string, first 'unhook' it from any sync'd copies - let updatedContent = stripBlockIDsFromString(origContent) - - // If wanted, remove @done(...) part - const doneDatePart = (updatedContent.match(RE_DONE_DATE_OPT_TIME)) ?? [''] - // logDebug(pluginJson, `>> ${String(doneDatePart)}`) - if (config.removeDoneDates && doneDatePart[0] !== '') { - updatedContent = updatedContent.replace(doneDatePart[0], '') - } - - // If wanted, remove any processedTagName - if (config.removeProcessedTagName && updatedContent.includes(config.processedTagName)) { - updatedContent = updatedContent.replace(config.processedTagName, '') - } - - // If wanted, set any complete or cancelled tasks/checklists to not complete - if (config.uncompleteTasks) { - if (p.type === 'done') { - // logDebug(pluginJson, `>> changed done -> open`) - p.type = 'open' - } else if (p.type === 'cancelled') { - // logDebug(pluginJson, `>> changed cancelled -> open`) - p.type = 'open' - } else if (p.type === 'scheduled') { - // logDebug(pluginJson, `>> changed scheduled -> open`) - p.type = 'open' - } else if (p.type === 'checklistDone') { - // logDebug(pluginJson, `>> changed checklistDone -> checklist`) - p.type = 'checklist' - } else if (p.type === 'checklistScheduled') { - // logDebug(pluginJson, `>> changed checklistScheduled -> checklist`) - p.type = 'checklist' - } else if (p.type === 'checklistCancelled') { - // logDebug(pluginJson, `>> changed checklistCancelled -> checklist`) - p.type = 'checklist' - } - } - - // logDebug(pluginJson, `${origContent}`) - // For any YYYY-MM-DD dates in the line (can make sense in metadata lines to have multiples) - let shiftedDateStr = '' - if (updatedContent.match(RE_ISO_DATE)) { - // Process all YYYY-MM-DD dates in the line - dates = updatedContent.match(RE_ISO_DATE_ALL) ?? [] - for (let thisDate of dates) { - originalDateStr = thisDate - shiftedDateStr = calcOffsetDateStr(originalDateStr, interval) - // Replace date part with the new shiftedDateStr - updatedContent = updatedContent.replace(originalDateStr, shiftedDateStr) - logDebug(pluginJson, `- ${originalDateStr}: match found -> ${shiftedDateStr} from interval ${interval}`) - updatedCount += 1 - } - logDebug(pluginJson, `-> ${updatedContent}`) - } - // For any YYYY-Wnn dates in the line (might in future make sense in metadata lines to have multiples) - if (updatedContent.match(RE_NP_WEEK_SPEC)) { - // Process all YYYY-Www dates in the line - dates = updatedContent.match(RE_NP_WEEK_ALL) ?? [] - for (let thisDate of dates) { - originalDateStr = thisDate - // v1: but doesn't handle different start-of-week settings - // shiftedDateStr = calcOffsetDateStr(originalDateStr, interval) - - // v2: using NPdateTime::getNPWeekData instead - // const thisWeekInfo = getNPWeekData(originalDateStr, intervalParts.number, intervalParts.type) - // Replace date part with the new shiftedDateStr - updatedContent = updatedContent.replace(originalDateStr, shiftedDateStr) - logDebug(pluginJson, `- ${originalDateStr}: match found -> ${shiftedDateStr} from interval ${interval}`) - updatedCount += 1 - } - logDebug(pluginJson, `-> ${updatedContent}`) - } - // else { - // TODO: This would be the place to assess another date format, but it's much harder than it looks. - // Method probably to define new settings "regex" and "format". - // Just using moment doesn't work fully unless you take out all other numbers in the rest of the line first. - // NP.parseDate() uses chrono library, and probably useful, but needs testing to see how it actually works with ambiguous dates (documentation doesn't say) - // } - - // Update the paragraph content - p.content = updatedContent.trimEnd() - // logDebug(pluginJson, `-> '${p.content}'`) - } - }) - // Write all paragraphs to the note - note.updateParagraphs(pArr) - // undo selection for safety, and because the end won't now be correct - Editor.highlightByIndex(startingCursorPos, 0) - - // Notify user - logDebug(pluginJson, `Shifted ${updatedCount} dates in ${pArr.length} lines`) - await showMessage(`Shifted ${updatedCount} dates in ${pArr.length} lines`, 'OK', 'Shift Dates') - } catch (err) { - logError(pluginJson, err.message) - } -} /** * Go through current Editor note and identify date offsets and turn into due dates. @@ -201,7 +36,8 @@ export async function shiftDates(): Promise { * offset date after the 'pivot date'. * Offsets apply within a contiguous section; a section is considered ended when * a line has a lower indent or heading level, or is a blank line or separator line. - * + * TODO: [Allow other date styles in /process date offsets](https://github.com/NotePlan/plugins/issues/221) from Feb 2021 -- but much harder than it looks. + * @author @jgclark */ export async function processDateOffsets(): Promise { @@ -221,7 +57,7 @@ export async function processDateOffsets(): Promise { } const noteTitle = displayTitle(note) logDebug(pluginJson, `processDateOffsets() for note '${noteTitle}'`) - const config = await getEventsSettings() + // const config = await getEventsSettings() let currentTargetDate = '' let currentTargetDateLine = 0 // the line number where we found the currentTargetDate. Zero means not set. @@ -276,7 +112,7 @@ export async function processDateOffsets(): Promise { // (check it's not got various characters before it, to defeat common usage in middle of things like URLs) // TODO: make a different type of CTD for in-line vs in-heading dates - // TODO: Somewhere around would be the place to assess another date format, but it's much harder than it looks. (See more detail in shiftDates() above.) + // TODO: Somewhere around would be the place to assess another date format, but it's much harder than it looks. (Can the more recent support in shiftDates() above now help?) if (content.match(RE_BARE_DATE) && !content.match(RE_DONE_DATE_OPT_TIME)) { const dateISOStrings = content.match(RE_BARE_DATE_CAPTURE) ?? [''] diff --git a/jgclark.EventHelpers/src/shiftDates.js b/jgclark.EventHelpers/src/shiftDates.js new file mode 100644 index 000000000..32d99afd6 --- /dev/null +++ b/jgclark.EventHelpers/src/shiftDates.js @@ -0,0 +1,248 @@ +// @flow +// ---------------------------------------------------------------------------- +// Command to Shift Dates +// @jgclark +// Last updated 2025-01-20 for v0.22.2, by @jgclark +// ---------------------------------------------------------------------------- + +import pluginJson from '../plugin.json' +import { getEventsSettings } from './eventsHelpers' +import { + calcOffsetDateStr, + RE_DONE_DATE_OPT_TIME, + RE_ISO_DATE, + RE_ISO_DATE_ALL, + RE_NP_MONTH_SPEC, + RE_NP_MONTH_ALL, + RE_NP_QUARTER_SPEC, + RE_NP_QUARTER_ALL, + RE_NP_WEEK_SPEC, + RE_NP_WEEK_ALL, + RE_NP_YEAR_SPEC, + RE_NP_YEAR_ALL, + splitIntervalToParts, +} from '@helpers/dateTime' +import { getNPWeekData } from '@helpers/NPdateTime' +import { clo, log, logDebug, logError, logInfo, logWarn } from '@helpers/dev' +import { findEndOfActivePartOfNote } from '@helpers/paragraph' +import { stripBlockIDsFromString } from '@helpers/stringTransforms' +import { askDateInterval, showMessage } from '@helpers/userInput' + +// ---------------------------------------------------------------------------- + +/** + * Shift Dates -- user command entry point. + * Gets data ready for shiftDatesCore() function. + * @author @jgclark + */ +export async function shiftDates(): Promise { + try { + // Get working selection as an array of paragraphs + const { paragraphs, selection, note } = Editor + let pArr: $ReadOnlyArray = [] + const startingCursorPos = selection?.start ?? 0 + if (Editor == null || paragraphs == null || note == null) { + logError(pluginJson, `No note or content found to process. Stopping.`) + await showMessage('No note or content found to process.', 'OK', 'Shift Dates') + return + } + const selectionLength = selection?.length ?? 0 + if (selectionLength > 0) { + // Use just the selected paragraphs + pArr = Editor.selectedParagraphs.slice() // to tell Flow it's no longer a $ReadOnlyArray + } else { + // Use the whole note + pArr = paragraphs.slice(0, findEndOfActivePartOfNote(note) + 1) + } + logDebug(pluginJson, `shiftDates starting for ${pArr.length} lines`) + if (pArr.length === 0) { + await showMessage('Please select some lines to process.', 'OK', 'Shift Dates') + throw new Error(`Empty selection found. Stopping.`) + } + + // Get interval to use + const interval = await askDateInterval("{question:'What interval would you like me to shift these dates by?'}") + if (interval === '') { + await showMessage(`Sorry, that was not a valid date interval.`) + throw new Error(`No valid interval supplied. Stopping.`) + } + + // Do main work + const updatedCount = await shiftDatesCore(note, pArr, interval) + + // undo selection for safety, and because the end won't now be correct + Editor.highlightByIndex(startingCursorPos, 0) + + // Notify user + logDebug(pluginJson, `Shifted ${updatedCount} dates in ${pArr.length} lines`) + await showMessage(`Shifted ${updatedCount} dates in ${pArr.length} lines`, 'OK', 'Shift Dates') + } catch (err) { + logError(pluginJson, `shiftDates: ${err.message}`) + } +} + +/** + * Shift Dates -- core function called by other entry point(s). + * Go through currently selected lines in the open note and shift YYYY-MM-DD, YYYY-Www and YYYY-MM dates by an interval given by the user. + * Optionally removes @done(...) dates if wanted, but doesn't touch other others than don't have whitespace or newline before them. + * Optionally will un-complete completed tasks/checklists. + * @author @jgclark + * @param {TNote} noteToProcess + * @param {Array} parasToProcess + * @param {string} interval - the interval to shift the dates by + */ +export async function shiftDatesCore(note: TNote, parasToProcess: Array, interval: string): Promise { + try { + const config = await getEventsSettings() + const intervalParts = splitIntervalToParts(interval) + + // Iterate over all paras + let updatedCount = 0 + parasToProcess.forEach((p) => { + const origContent = p.content + let dates: Array = [] + let originalDateStr = '' + + // Work on lines with dates + if ( + origContent.match(RE_ISO_DATE) || origContent.match(RE_NP_WEEK_SPEC) || origContent.match(RE_NP_MONTH_SPEC) + || origContent.match(RE_NP_QUARTER_SPEC) || origContent.match(RE_NP_YEAR_SPEC)) { + logDebug('shiftDatesCore', `#${String(p.lineIndex)}: ${origContent}`) + // As we're about to update the string, first 'unhook' it from any sync'd copies + let updatedContent = stripBlockIDsFromString(origContent) + + // If wanted, remove @done(...) part + const doneDatePart = (updatedContent.match(RE_DONE_DATE_OPT_TIME)) ?? [''] + // logDebug('shiftDatesCore', `>> ${String(doneDatePart)}`) + if (config.removeDoneDates && doneDatePart[0] !== '') { + updatedContent = updatedContent.replace(doneDatePart[0], '') + } + + // If wanted, remove any processedTagName + if (config.removeProcessedTagName && updatedContent.includes(config.processedTagName)) { + updatedContent = updatedContent.replace(config.processedTagName, '') + } + + // If wanted, set any complete or cancelled tasks/checklists to not complete + if (config.uncompleteTasks) { + if (p.type === 'done') { + // logDebug('shiftDatesCore', `>> changed done -> open`) + p.type = 'open' + } else if (p.type === 'cancelled') { + // logDebug('shiftDatesCore', `>> changed cancelled -> open`) + p.type = 'open' + } else if (p.type === 'scheduled') { + // logDebug('shiftDatesCore', `>> changed scheduled -> open`) + p.type = 'open' + } else if (p.type === 'checklistDone') { + // logDebug('shiftDatesCore', `>> changed checklistDone -> checklist`) + p.type = 'checklist' + } else if (p.type === 'checklistScheduled') { + // logDebug('shiftDatesCore', `>> changed checklistScheduled -> checklist`) + p.type = 'checklist' + } else if (p.type === 'checklistCancelled') { + // logDebug('shiftDatesCore', `>> changed checklistCancelled -> checklist`) + p.type = 'checklist' + } + } + + logDebug('shiftDatesCore', ` -> ${updatedContent}`) + + // For any YYYY-MM-DD dates in the line (can make sense in metadata lines to have multiples) + let shiftedDateStr = '' + if (updatedContent.match(RE_ISO_DATE)) { + // Process all YYYY-MM-DD dates in the line + dates = updatedContent.match(RE_ISO_DATE_ALL) ?? [] + for (const thisDate of dates) { + originalDateStr = thisDate + shiftedDateStr = calcOffsetDateStr(originalDateStr, interval) + // Replace date part with the new shiftedDateStr + updatedContent = updatedContent.replace(originalDateStr, shiftedDateStr) + logDebug('shiftDatesCore', `- ${originalDateStr}: DAY match found -> ${shiftedDateStr} from interval ${interval}`) + updatedCount += 1 + } + // logDebug('shiftDatesCore', `-> ${updatedContent}`) + } + // For any YYYY-Wnn dates in the line (might in future make sense in metadata lines to have multiples) + if (updatedContent.match(RE_NP_WEEK_SPEC)) { + // Process all YYYY-Www dates in the line + dates = updatedContent.match(RE_NP_WEEK_ALL) ?? [] + for (const thisDate of dates) { + originalDateStr = thisDate + // v1: but doesn't handle different start-of-week settings + // shiftedDateStr = calcOffsetDateStr(originalDateStr, interval) + // v2: using NPdateTime::getNPWeekData instead + const thisWeekInfo = getNPWeekData(originalDateStr, intervalParts.number, intervalParts.type) + if (thisWeekInfo) { + shiftedDateStr = thisWeekInfo?.weekString + // Replace date part with the new shiftedDateStr + updatedContent = updatedContent.replace(originalDateStr, shiftedDateStr) + logDebug('shiftDatesCore', `- ${originalDateStr}: WEEK match found -> ${shiftedDateStr} from interval ${interval}`) + updatedCount += 1 + } else { + logWarn('shiftDatesCore', `No week data found for ${originalDateStr} - will skip this line`) + } + } + // logDebug('shiftDatesCore', `-> ${updatedContent}`) + } + // For any YYYY-MM dates in the line (might in future make sense in metadata lines to have multiples) + // (Note: the regex ensures we don't match on YYYY-MM-DD dates as well) + if (updatedContent.match(RE_NP_MONTH_SPEC)) { + dates = updatedContent.match(RE_NP_MONTH_ALL) ?? [] + // Process all YYYY-MM dates in the line + for (const thisDate of dates) { + originalDateStr = thisDate + shiftedDateStr = calcOffsetDateStr(originalDateStr, interval) + // Replace date part with the new shiftedDateStr + updatedContent = updatedContent.replace(originalDateStr, shiftedDateStr) + logDebug('shiftDatesCore', `- ${originalDateStr}: MONTH match found -> ${shiftedDateStr} from interval ${interval}`) + updatedCount += 1 + } + // logDebug('shiftDatesCore', `-> ${updatedContent}`) + } + + // For any YYYY-Qq dates in the line (might in future make sense in metadata lines to have multiples) + if (updatedContent.match(RE_NP_QUARTER_SPEC)) { + dates = updatedContent.match(RE_NP_QUARTER_ALL) ?? [] + // Process all YYYY-Qq dates in the line + for (const thisDate of dates) { + originalDateStr = thisDate + shiftedDateStr = calcOffsetDateStr(originalDateStr, interval) + // Replace date part with the new shiftedDateStr + updatedContent = updatedContent.replace(originalDateStr, shiftedDateStr) + logDebug('shiftDatesCore', `- ${originalDateStr}: QUARTER match found -> ${shiftedDateStr} from interval ${interval}`) + updatedCount += 1 + } + // logDebug('shiftDatesCore', `-> ${updatedContent}`) + } + + // For any YYYY dates in the line (might in future make sense in metadata lines to have multiples) + // (Note: the regex ensures we don't match on YYYY-MM or YYYY-MM-DD dates as well) + if (updatedContent.match(RE_NP_YEAR_SPEC)) { + dates = updatedContent.match(RE_NP_YEAR_ALL) ?? [] + // Process all YYYY dates in the line + for (const thisDate of dates) { + originalDateStr = thisDate + shiftedDateStr = calcOffsetDateStr(originalDateStr, interval) + // Replace date part with the new shiftedDateStr + updatedContent = updatedContent.replace(originalDateStr, shiftedDateStr) + logDebug('shiftDatesCore', `- ${originalDateStr}: YEAR match found -> ${shiftedDateStr} from interval ${interval}`) + updatedCount += 1 + } + // logDebug('shiftDatesCore', `-> ${updatedContent}`) + } + + // Update the paragraph content + p.content = updatedContent.trimEnd() + // logDebug('shiftDatesCore', `-> '${p.content}'`) + } + }) + // Write all paragraphs to the note + note.updateParagraphs(parasToProcess) + + return updatedCount + } catch (err) { + logError(pluginJson, err.message) + return 0 + } +} diff --git a/jgclark.Filer/CHANGELOG.md b/jgclark.Filer/CHANGELOG.md index 96dae82da..5fce63863 100644 --- a/jgclark.Filer/CHANGELOG.md +++ b/jgclark.Filer/CHANGELOG.md @@ -1,6 +1,30 @@ # What's changed in 📦 Filer plugin? Please see the [Readme for this plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.Filer) for more details, including the available settings. + + +## [1.3.0] - 2025-07-??? +### New +- New command **/move paragraph and children**, which moves a paragraph to a user-selected note, and any indented lines following it. + +## [1.2.1] - 2025-07-13 (unreleased) +### Changed +- The various 'move...' commands that ask for a note now offers the ability to create a new note at this point (for @oldielajolla) +- Improved clarity of settings dialog +### Fixed +- Some regressions in 1.2.0 + +## [1.2.0] - 2025-01-07 +### New +- the **/add sync'd copy to note** command will now work on multiple lines -- and there's a new setting that allows you to set a default heading to sync all lines under. (for @chrismetcalf, closes #610) +- New **/quick move ...** commands for monthly and quarterly notes, not just daily and weekly notes. +### Fixed +- fix regression in "/add sync'd copy to note" command. + ## [1.1.6] - 2024-12-31 - the **new note from clipboard** and **new note from selection** commands have moved to the NoteHelpers plugin. - workaround for **move ...** commands not working properly, which stem from trailing whitespace on Headings in the destination notes. (Thanks to @trmax + @magicnemo for help diagnosing the problem and suggesting a workaround.) diff --git a/jgclark.Filer/README.md b/jgclark.Filer/README.md index 2a583078d..9ef0e0bd8 100644 --- a/jgclark.Filer/README.md +++ b/jgclark.Filer/README.md @@ -3,8 +3,11 @@ This plugin provides extra commands to help move or copy things around in NotePl It has some settings, which you review and change by clicking on the ⚙️ gear button on the 'Filer' line in the Plugin Preferences panel (on macOS) or by running the '/Filer: update plugin settings' command (on iOS). +## /move paragraph and children +This command (aliased to **/mpc**) quickly moves a paragraph, and any indented lines following it, to a different note in NotePlan, without having to lose your flow by switching to the other note. It works on any sort of lines, not just tasks. + ## /move paragraph or selection -The **/move paragraph** command (aliased to **/mp** and **/fp**) quickly **files** (moves) lines to different notes in NotePlan, without having to lose your flow by switching to the other note. It works on any sort of lines, not just tasks. +The **/move paragraph or selection** command (aliased to **/mp** and **/file**) quickly **files** (moves) lines to a different note in NotePlan, without having to lose your flow by switching to the other note. It works on any sort of lines, not just tasks. It pops up the command bar to choose the note you want to move it to, followed by the heading within that note to move it after. Where possible it will visually highlight the lines it will be moving (on NotePlan v3.6.2+). You can press Escape (on Mac) at any time to cancel. The move happens in the background, leaving you in the current note. @@ -15,31 +18,46 @@ NB: due to limitations in the API it's not yet possible to move items to a Calen ## /move paragraph block This extends the first command, by also moving commands in the current paragraph 'block'. If the 'Include lines from start of Section in the Block?' setting is true, it takes the most recent heading and its following section, up to the next heading of the same level or higher, or the next horizontal line, or the start of the `## Done` or `## Cancelled` section. This means you don't have to move the cursor to the start of the section before you run it. -From v0.7.0, you can turn on 'Use a tighter definition of when a Block finishes?' in the settings, which will stop the section at the next blank line, as well as next heading of the same level or higher, or the next horizontal line, or the start of the `## Done` or `## Cancelled` section. +You can turn on 'Use a tighter definition of when a Block finishes?' in the settings, which will stop the section at the next blank line, as well as next heading of the same level or higher, or the next horizontal line, or the start of the `## Done` or `## Cancelled` section. ## /quick move to <...> note -These 4 commands each moves lines to the current weekly note, using the same selection strategy as /mp (see above). The move happens in the background, leaving you in the flow in your current note. (Available with weekly notes from NotePlan v3.6.) +These 4 commands each moves lines to the current weekly note, using the same selection strategy as /mp (see above). The move happens in the background, leaving you in the flow in your current note. - **/quick move to Today's note** (alias **/qmtd**) -- Note: this is different from the existing 'Move Task To Today ⌘0' shortcut, which actually _schedules_ not moves. - **/quick move to Tomorrow's note** (alias **/qmtm**) -- Note: this is different from the existing 'Move Task To Tomorrow ⌘1' shortcut, which actually _schedules_ not moves. - **/quick move to Weekly note** (alias **/qmw**) - **/quick move to Next Weekly note** (alias **/qmnw**) +- **/quick move to Monthly note** (alias **/qmm**) +- **/quick move to Next Monthly note** (alias **/qmnm**) +- **/quick move to Quarterly note** (alias **/qmq**) +- **/quick move to Next Quarterly note** (alias **/qmnq**) They could be mapped to shortcut keys to make using them even faster. +## /new note from clipboard +This command (alias **/nnc**) takes the current text in the clipboard to form the basis of a new note. The command asks for the note title and folder location. + +## /new note from selection +This command (alias **/nns**) takes the current selected text to form the basis of a new note. The command asks for the note title and folder location. + ## /add sync'd copy to note -This command (alias **/asc**) adds a sync'd copy of the current line to a section in another note. Here's a demo with two notes side by side, only to make it clearer: +This command (alias **/asc**) adds a sync'd copy of the current line(s) to a section in destination note(s) that you choose. Here's a demo with two notes side by side, to make it clearer: ![add sync demo](add-link-line-demo-T2.gif) -NB: This feature only works on single lines, not whole blocks, at the moment. +The setting "Default section Heading to sync lines to" allows you to set the Heading in the destination note to sync items under. This is particularly useful if you select multiple lines to sync. Note: it needs to start with the appropriate number of `#` Markdown heading markers. + +There's also a setting "Where to add a new heading in the note": if the default heading doesn't yet exist in the note, this controls whether it will first be added at the start or end of the note. + +## /archive note keeping folder structure +Move the current note to NotePlan's Archive, but keep the same folder structure for it inside the special @Archive folder. ## various /... note link ... commands -There are 4 related commands that move or copy lines in calendar notes that include a `[[note link]]` to regular notes with that title: -- **/move note links** -- **/move note links (recently changed)** -- **/copy note links** -- **/copy note links (recently changed)** +There are 4 related commands that move or copy lines in calendar notes that include a `[[note link]]` to the regular note with that title: +- **/copy note links** (alias **/cnl**) +- **/copy note links (recently changed)** (alias **/cnlrc**) +- **/move note links** (alias **/mnl**) +- **/move note links (recently changed)** (alias **/mnlrc**) For example, if you collect tasks and notes on 3 different main areas in your daily note, you might want to copy or move those to different 'progress log' notes at the end of each day: @@ -59,11 +77,11 @@ There are a number of settings to make it useful for a variety of ways of organi - File the wider block the note link is in? If set, this command will include the rest of the following block this line is in: any indented lines, or (if this line is a heading) all lines following until a blank line, or heading of the same level or higher. Default is not to use blocks, which only files this line. - Where to add in the note: If the [[note link]] doesn't include a heading, then this controls whether filed lines get inserted at the start or end of the note. - Allow preamble before first heading? If set, some 'preamble' lines are allowed directly after the title. When filing/moving/inserting items with these commands, this preamble will be left in place, up to and including the first blank line, heading or separator. Otherwise the first heading will be directly after the note's title line (or frontmatter if used). -- Tag that indicates a [[note link]] should be ignored: If this tag (e.g. "#ignore") is included in a line with a [[note link]] then it (and where relevant the rest of its block) will not be moved or copied. +- Tag that indicates a [[note link]] should be ignored: if this tag (e.g. "#ignore") is included in a line with a [[note link]] then it (and where relevant the rest of its block) will not be moved or copied. In the demo above, the daily note includes the date ("Tues 21/3") as part of the (sub)heading. As this is copied into the project log, it serves as an automatic index in that note. To add today's date in whatever style you wish is relatively simple using the [date commands in the Templating plugin](https://nptemplating-docs.netlify.app/docs/templating-examples/date-time). -The **/... (recently changed)** versions of these commands operate on recently-changed calendar notes, not just the currently open one. To contol this there's an additional setting: +The **/... (recently changed)** versions of these commands operate on recently-changed calendar notes, not just the currently open one. To control this there's an additional setting: - How many days to include in 'recent' changes to calendar notes? This sets how many days' worth of changes to calendar notes to include? To include all days, set to 0. For example, this can be used to copy at the end of each day from the daily note a section with any completed tasks, and any notes that it contains, to a 'progress log' note. This can be run on demand, or could be automated through the following method ... @@ -87,6 +105,11 @@ You can also run from an x-callback call. At simplest this is: noteplan://x-callback-url/runPlugin?pluginID=jgclark.Filer&command=move%20note%20links%20%28recently%20changed%29&arg0= ``` + + ## /filer:update plugin settings This command allows settings to be changed on iOS/iPadOS. diff --git a/jgclark.Filer/plugin.json b/jgclark.Filer/plugin.json index d6fbb76a5..6ed1952ff 100644 --- a/jgclark.Filer/plugin.json +++ b/jgclark.Filer/plugin.json @@ -8,8 +8,9 @@ "plugin.author": "jgclark", "plugin.url": "https://github.com/NotePlan/plugins/tree/main/jgclark.Filer", "plugin.changelog": "https://github.com/NotePlan/plugins/blob/main/jgclark.Filer/CHANGELOG.md", - "plugin.version": "1.1.6", - "plugin.lastUpdateInfo": "1.1.6: move /new note commands to NoteHelpers plugin. Updated folder chooser. Fix to /move commands.\n1.1.5: try to fix /move paras occasionally not deleting from original\n1.1.4: try to fix a race condition in /add sync'd copy to note. Fix to /move note link.\n1.1.3: bug fix.\n1.1.2: updating to newer libraries.\n1.1.1: new create folder option.\n1.1.0: new /archive command. 1.1.0: new '/... note links...' commands.", + "plugin.version": "1.3.0", + "plugin.lastUpdateInfo_NEXT": "1.4.0: ??? New '/smart duplicate note' and '/smart file to completed sections' commands.", + "plugin.lastUpdateInfo": "1.3.0: New '/move paragraph and children' command.\n1.2.1: More useful choose note dialog in 'move...' commands. Fix regressions.\n1.2.0: Allow '/add sync'd copy to note' command to work on multiple lines.\n1.1.6: move /new note commands to NoteHelpers plugin. Updated folder chooser. Fix to /move commands.\n1.1.5: try to fix /move paras occasionally not deleting from original\n1.1.4: try to fix a race condition in /add sync'd copy to note. Fix to /move note link.\n1.1.3: bug fix.\n1.1.2: updating to newer libraries.\n1.1.1: new create folder option.\n1.1.0: new /archive... command. New '/... note links...' commands.", "plugin.dependencies": [], "plugin.script": "script.js", "plugin.isRemote": "false", @@ -21,7 +22,7 @@ "sync", "lines" ], - "description": "Add a sync'd copy of the current line to a section in another note", + "description": "Add a sync'd copy of the current line(s) to a section in other note(s)", "jsFunction": "addIDAndAddToOtherNote" }, { @@ -83,6 +84,15 @@ "JSON-formatted parameter list" ] }, + { + "name": "move paragraph and children", + "alias": [ + "mpc", + "file" + ], + "description": "moves this paragraph and all its children (directly indented) to a different note", + "jsFunction": "moveParaAndChildren" + }, { "name": "move paragraph or selection", "alias": [ @@ -139,10 +149,67 @@ "description": "quick move a block of paragraphs to Next Week's note", "jsFunction": "moveParasToNextWeekly" }, + { + "name": "quick move to Monthly note", + "alias": [ + "qmtm", + "month" + ], + "description": "quick move a block of paragraphs to the current Monthly note", + "jsFunction": "moveParasToThisMonthly" + }, + { + "name": "quick move to Next Monthly note", + "alias": [ + "qmnm", + "month" + ], + "description": "quick move a block of paragraphs to Next Week's note", + "jsFunction": "moveParasToNextMonthly" + }, + { + "name": "quick move to Quarterly note", + "alias": [ + "qmtq", + "quarter" + ], + "description": "quick move a block of paragraphs to the current Quarterly note", + "jsFunction": "moveParasToThisQuarterly" + }, + { + "name": "quick move to Next Quarterly note", + "alias": [ + "qmnq", + "quarter" + ], + "description": "quick move a block of paragraphs to Next Quarter's note", + "jsFunction": "moveParasToNextQuarterly" + }, { "name": "Filer: update plugin settings", "description": "Settings interface (even for iOS)", "jsFunction": "updateSettings" + }, + { + "hidden": true, + "name": "smart duplicate note", + "alias": [ + "sdn" + ], + "description": "from the current regular note create a new one that uses the same structure of headings, and moves over any open tasks/checklists, and sorts the new note.", + "jsFunction": "smartDuplicateRegularNote" + }, + { + "hidden": true, + "name": "smart file to completed sections", + "alias": [ + "sftcs" + ], + "description": "???from the current regular note create a new one that uses the same structure of headings, and moves over any open tasks/checklists, and sorts the new note.", + "jsFunction": "smartFileToCompletedSections", + "arguments": [ + "filename of note to work on" + ] } ], "plugin.settings": [ @@ -150,6 +217,10 @@ "type": "heading", "title": "Filer plugin settings" }, + { + "type": "heading", + "title": "Block selection settings" + }, { "key": "includeFromStartOfSection", "title": "Include lines from start of Section in the Block?", @@ -166,10 +237,25 @@ "default": false, "required": true }, + { + "key": "allowNotePreambleBeforeHeading", + "title": "Allow preamble before first heading?", + "description": "If set, some 'preamble' lines are allowed directly after the title. When filing/moving/inserting items with these commands, this preamble will be left in place, up to and including the first blank line, heading or separator. Otherwise the first heading will be directly after the note's title line (or frontmatter if used).", + "type": "bool", + "default": true, + "required": true + }, + { + "type": "separator" + }, + { + "type": "heading", + "title": "Move... and copy... commands settings" + }, { "key": "whereToAddInSection", "title": "Where to add in section", - "description": "Controls whether moved lines get inserted at the start or end of the chosen section.", + "description": "Controls whether moved/copied lines get inserted: at the start or end of the chosen section.", "type": "string", "choices": [ "start", @@ -178,18 +264,10 @@ "default": "start", "required": true }, - { - "key": "allowNotePreambleBeforeHeading", - "title": "Allow preamble before first heading?", - "description": "If set, some 'preamble' lines are allowed directly after the title. When filing/moving/inserting items with these commands, this preamble will be left in place, up to and including the first blank line, heading or separator. Otherwise the first heading will be directly after the note's title line (or frontmatter if used).", - "type": "bool", - "default": true, - "required": true - }, { "key": "addDateBacklink", "title": "Add date reference?", - "description": "If true, adds date reference on the moved paragraph(s) when moved from a daily note.", + "description": "If true, adds date reference on moved paragraph(s) when moved from a daily note.", "type": "bool", "default": false, "required": true @@ -210,6 +288,33 @@ { "type": "separator" }, + { + "type": "heading", + "title": "\"/add sync'd copy to note\" command settings" + }, + { + "key": "defaultHeadingToSyncTo", + "title": "Default section Heading to sync lines to", + "description": "(optional) If set, this avoids the question of which heading you want to '/add sync'd copy' to. (Note: needs to include the relevant number of '#' heading markers.)", + "type": "string", + "default": "## Actions", + "required": false + }, + { + "key": "whereToAddNewHeadingInNote", + "title": "Where to add a new heading in the note", + "description": "If the default heading doesn't yet exist in the note, this controls whether it will first be added at the start or end of the note.", + "type": "string", + "choices": [ + "start", + "end" + ], + "default": "start", + "required": true + }, + { + "type": "separator" + }, { "type": "heading", "title": "\"/note link\" commands settings" @@ -243,7 +348,7 @@ { "key": "useBlocks", "title": "File the rest of a block the note link is in?", - "description": "If set, this command will include the rest of the following block this line is in: any indented lines, or (if this line is a heading) all lines following until a blank line, or heading of the same level or higher. Default is not to use blocks, which only files this line.", + "description": "If set, this command will include the block following this line: any indented lines, or (if this line is a heading) all lines following until a blank line, or heading of the same level or higher. Default is not to use blocks, which only files this line.", "type": "bool", "default": false, "required": true @@ -253,13 +358,13 @@ "title": "Tag that indicates a [[note link]] should be ignored", "description": "If this tag (e.g. '#ignore') is included in a line with a [[note link]] then it (and where relevant the rest of its block) will not be moved or copied.", "type": "string", - "default": "", + "default": "#ignore", "required": false }, { "key": "recentDays", "title": "How many days to include in 'recent' changes to calendar notes?", - "description": "This sets how many days' worth of changes to calendar notes to include? To include all days, set to 0.", + "description": "When looking for note links to file, this sets how many days' worth of changes to calendar notes to include? To include all days, set to 0.", "type": "number", "default": 7, "required": true diff --git a/jgclark.Filer/src/IDs.js b/jgclark.Filer/src/IDs.js index 2ece32b52..798f5f225 100644 --- a/jgclark.Filer/src/IDs.js +++ b/jgclark.Filer/src/IDs.js @@ -2,18 +2,17 @@ // ---------------------------------------------------------------------------- // Plugin to help link lines between notes with Line IDs // Jonathan Clark -// last updated 2025-04-03 for v0.7.0+ +// last updated 2025-01-07 for v1.2.0 // ---------------------------------------------------------------------------- import pluginJson from "../plugin.json" -import { addParasAsText, getFilerSettings } from './filerHelpers' +import { getFilerSettings } from './filerHelpers' import { logDebug, logError, logWarn } from '@helpers/dev' -import { saveEditorIfNecessary } from '@helpers/editor' +// import { saveEditorIfNecessary } from '@helpers/editor' import { displayTitle } from '@helpers/general' -import { allNotesSortedByChanged } from '@helpers/note' -// import { getSelectedParaIndex } from '@helpers/NPParagraph' -import { parasToText } from '@helpers/paragraph' -import { chooseHeading } from '@helpers/userInput' +import { addParasAsText } from '@helpers/NPParagraph' +import { findHeading, getHeadingTextFromMarkdownHeadingText, parasToText, smartAppendPara, smartPrependPara } from '@helpers/paragraph' +import { chooseNote, chooseHeading } from '@helpers/userInput' //----------------------------------------------------------------------------- @@ -26,56 +25,95 @@ export async function addIDAndAddToOtherNote(): Promise { const { note, content, selectedParagraphs } = Editor if (content == null || selectedParagraphs == null || note == null) { // No note open, or no selectedParagraph selection (perhaps empty note), so don't do anything. - logWarn(pluginJson, 'No note open, so stopping.') + logWarn(pluginJson, 'addIDAndAddToOtherNote(): No note open, so stopping.') return } // Get config settings const config = await getFilerSettings() + const defaultMarkdownHeading = config.defaultHeadingToSyncTo - // Get current paragraph - const firstSelParaIndex = selectedParagraphs[0].lineIndex //getSelectedParaIndex() - let para = note.paragraphs[firstSelParaIndex] + // Get current selected paragraph(s) + logDebug(pluginJson, `addIDAndAddToOtherNote() starting with ${String(selectedParagraphs.length)} selected lines, and syncToDifferentDestination ${String(config.syncToDifferentDestination)}.`) - // Add Line ID for the first paragraph (known as 'blockID' by API) - note.addBlockID(para) // in this case, note is Editor.note, which is not saved in realtime. This has been causing race conditions at times. - note.updateParagraph(para) - if (NotePlan.environment.buildVersion >= 1053) { - await saveEditorIfNecessary() // attempt to save this before reading it again (if running NP 3.9.3+) - } - para = note.paragraphs[firstSelParaIndex] // refresh para - const newBlockID = para.blockId - if (newBlockID) { - logDebug(pluginJson, `- blockId added: '${newBlockID}'`) - } else { - logError(pluginJson, `- no blockId created. Stopping.`) - return - } + // Iterate over each selected paragraph + let destNote: ?TNote + let destHeading: string = '' + for (const thisPara of selectedParagraphs) { + // Add blockId for thisPara (confusingly, also known as 'blockID' by API) + // But first check if it already has one, and if so, use that. + if (thisPara.blockId !== '') { + note.addBlockID(thisPara) + note.updateParagraph(thisPara) + } + // Get (new or existing) blockId + const newBlockID = thisPara.blockId + if (!newBlockID) { + throw new Error(`No blockId created for line {${thisPara.content}} for unknown reason. Stopping.`) + } + logDebug('addIDAndAddToOtherNote', `-> blockId for line #${String(thisPara.lineIndex)}: '${newBlockID}'`) - // turn into text, for reasons given in moveParas() - const selectedParasAsText = parasToText([para]) // i.e. turn single para into a single-iterm array + // Decide where to copy the line to (the first time around, or if we want a different destination for each para) + // V1 + // const allNotes = allNotesSortedByChanged() + // const res = await CommandBar.showOptions( + // allNotes.map((n) => n.title ?? 'untitled'), + // `Select note to copy the line to`, + // ) + // const destNote = allNotes[res.index] + // Note: showOptions returns the first item if something else is typed. And I can't see a way to distinguish between the two. + // V2 + // Attempt to highlight them to help user check all is well + const firstStartIndex = thisPara.contentRange?.start ?? NaN + const lastEndIndex = thisPara.contentRange?.end ?? NaN + if (firstStartIndex && lastEndIndex) { + const parasCharIndexRange: TRange = Range.create(firstStartIndex, lastEndIndex) + Editor.highlightByRange(parasCharIndexRange) + } + const truncatedContent = `${thisPara.content.slice(0, 30)}…` + destNote = await chooseNote(true, true, [], `Note to sync '${truncatedContent}' to`, false, true) + if (!destNote) { + logDebug('addIDAndAddToOtherNote', `User cancelled operation. Stopping.`) + return + } - // Decide where to copy the line to - const allNotes = allNotesSortedByChanged() - const res = await CommandBar.showOptions( - allNotes.map((n) => n.title ?? 'untitled'), - `Select note to copy the line to`, - ) - const destNote = allNotes[res.index] - // Note: showOptions returns the first item if something else is typed. And I can't see a way to distinguish between the two. + // Ask to which heading to add the selectedParas (if we don't have a default) + if (defaultMarkdownHeading === '') { + destHeading = await chooseHeading(destNote, true, true, false) + logDebug('addIDAndAddToOtherNote', `- Will add to note '${displayTitle(destNote)}' under heading: '${destHeading}'`) + if (destHeading === '') { + logDebug('addIDAndAddToOtherNote', `User cancelled operation. Stopping.`) + return + } + } else { + // Now have to check whether it exists, and if not create it + const defaultHeading = getHeadingTextFromMarkdownHeadingText(defaultMarkdownHeading) + if (!findHeading(destNote, defaultHeading, false)) { + logDebug('addIDAndAddToOtherNote', `Heading ${defaultHeading} doesn't exist in destNote so will need to add first`) + const markdownHeading = defaultMarkdownHeading + if (config.whereToAddNewHeadingInNote === 'start') { + smartPrependPara(destNote, markdownHeading, 'title') + } else { + smartAppendPara(destNote, markdownHeading, 'title') + } + logDebug('addIDAndAddToOtherNote', `Added ${markdownHeading} at ${config.whereToAddNewHeadingInNote} of note`) + DataStore.updateCache(destNote, false) + } + destHeading = defaultHeading + } - // Ask to which heading to add the selectedParas - const headingToFind = await chooseHeading(destNote, true, true, false) - logDebug(pluginJson, `- Will add to note '${displayTitle(destNote)}' under heading: '${headingToFind}'`) + // Turn para into text, for reasons given in moveParas() + const selectedParaAsText = parasToText([thisPara]) // i.e. turn single para into a single-iterm array - // Add text to the new location in destination note - addParasAsText(destNote, selectedParasAsText, headingToFind, config.whereToAddInSection, true) + // Add text to the new location in destination note + addParasAsText(destNote, selectedParaAsText, destHeading, config.whereToAddInSection, true) - // NB: handily, the blockId goes with it as part of the para.content - // logDebug(pluginJson, `- Inserting 1 para at index ${insertionIndex} into ${displayTitle(destNote)}`) - // await destNote.insertParagraph(para.content, insertionIndex, paraType) + // NB: handily, the blockId goes with it as part of the para.content + // logDebug('addIDAndAddToOtherNote', `- Inserting 1 para at index ${insertionIndex} into ${displayTitle(destNote)}`) + // await destNote.insertParagraph(para.content, insertionIndex, paraType) + } } catch (error) { - logError(pluginJson, error.message) + logError('addIDAndAddToOtherNote', error.message) } } diff --git a/jgclark.Filer/src/filerHelpers.js b/jgclark.Filer/src/filerHelpers.js index 3d9ba6310..599983e65 100644 --- a/jgclark.Filer/src/filerHelpers.js +++ b/jgclark.Filer/src/filerHelpers.js @@ -2,13 +2,11 @@ // ---------------------------------------------------------------------------- // Helper functions for Filer plugin. // Jonathan Clark -// last updated 9.6.2024, for v1.1.0+ +// last updated 2025-07-13, for v1.2.1 // ---------------------------------------------------------------------------- import pluginJson from "../plugin.json" -import { clo, JSP, logDebug, logError } from '@helpers/dev' -// import { getSetting } from '@helpers/NPConfiguration' -import { findStartOfActivePartOfNote } from '@helpers/paragraph' +import { clo, logDebug, logError, logWarn } from '@helpers/dev' import { showMessage } from '@helpers/userInput' //----------------------------------------------------------------------------- @@ -22,13 +20,14 @@ export type FilerConfig = { allowNotePreambleBeforeHeading: boolean, useTightBlockDefinition: boolean, whereToAddInSection: string, // 'start' (default) or 'end' - // justCompletedItems: boolean, // migrating to the next item typesToFile: string, // now a choice: all but incomplete tasks useBlocks: boolean, whereToAddInNote: string, // 'start' (default) or 'end' ignoreNoteLinkFilerTag: string, - copyOrMove: string, // 'copy' or 'move'. Note: not set in plugin settings, but in object to send from wrappers to main functions + copyOrMove: string, // 'copy' or 'move'. Note: not set in this plugin settings, but in object to send from wrappers to main functions, e.g. from x-callbacks recentDays: number, + defaultHeadingToSyncTo: string, + whereToAddNewHeadingInNote: string, // 'start' (default) or 'end' _logLevel: string, } @@ -39,7 +38,7 @@ export async function getFilerSettings(): Promise { // let useTightBlockDefinition = await getSetting('np.Shared', 'useTightBlockDefinition') // logDebug('getFilerSettings', `- useTightBlockDefinition: np.Globals: ${String(useTightBlockDefinition)}`) - // Get settings using Config system + // Get settings const config: FilerConfig = await DataStore.loadJSON(`../${pluginID}/settings.json`) if (config == null || Object.keys(config).length === 0) { @@ -55,50 +54,3 @@ export async function getFilerSettings(): Promise { await showMessage(`Error: ${err.message}`) } } - -/** - * Function to write text either to top of note, bottom of note, or after a heading - * Note: When written, there was no API function to deal with multiple selectedParagraphs, but we can insert a raw text string. - * Note: now can't simply use note.addParagraphBelowHeadingTitle() as we have more options than it supports. - * @author @jgclark - * - * @param {TNote} destinationNote - * @param {string} selectedParasAsText - * @param {string} headingToFind if empty, means 'end of note'. Can also be the special string '(top of note)' - * @param {string} whereToAddInSection to add after a heading: 'start' or 'end' - * @param {boolean} allowNotePreambleBeforeHeading? - */ -export function addParasAsText( - destinationNote: TNote, - selectedParasAsText: string, - headingToFind: string, - whereToAddInSection: string, - allowNotePreambleBeforeHeading: boolean -): void { - const destinationNoteParas = destinationNote.paragraphs - let insertionIndex: number - if (headingToFind === destinationNote.title || headingToFind === '<>') { - // i.e. the first line in project or calendar note - insertionIndex = findStartOfActivePartOfNote(destinationNote, allowNotePreambleBeforeHeading) - logDebug(pluginJson, `-> top of note, line ${insertionIndex}`) - destinationNote.insertParagraph(selectedParasAsText, insertionIndex, 'text') - - } else if (headingToFind === '<>' || headingToFind === '') { - // blank return from chooseHeading has special meaning of 'end of note' - insertionIndex = destinationNoteParas.length + 1 || 0 - logDebug(pluginJson, `-> bottom of note, line ${insertionIndex}`) - destinationNote.insertParagraph(selectedParasAsText, insertionIndex, 'text') - - } else if (whereToAddInSection === 'start') { - logDebug(pluginJson, `-> Inserting at start of section '${headingToFind}'`) - destinationNote.addParagraphBelowHeadingTitle(selectedParasAsText, 'text', headingToFind, false, false) - - } else if (whereToAddInSection === 'end') { - logDebug(pluginJson, `-> Inserting at end of section '${headingToFind}'`) - destinationNote.addParagraphBelowHeadingTitle(selectedParasAsText, 'text', headingToFind, true, false) - - } else { - // Shouldn't get here - logError(pluginJson, `Can't find heading '${headingToFind}'. Stopping.`) - } -} diff --git a/jgclark.Filer/src/index.js b/jgclark.Filer/src/index.js index 0e5751d15..96d9b0e8f 100644 --- a/jgclark.Filer/src/index.js +++ b/jgclark.Filer/src/index.js @@ -3,7 +3,7 @@ // ----------------------------------------------------------------------------- // Plugin to help move selected pargraphs to other notes // Jonathan Clark -// Last updated 2024-12-31, for v1.2.0 +// Last updated 2025-07-13, for v1.3.0 // ----------------------------------------------------------------------------- // allow changes in plugin.json to trigger recompilation @@ -14,9 +14,14 @@ import { showMessage } from '@helpers/userInput' export { moveParas, + moveParaAndChildren, moveParaBlock, - moveParasToCalendarDate, + // moveParasToCalendarDayDate, moveParasToCalendarWeekly, + moveParasToNextMonthly, + moveParasToThisMonthly, + moveParasToNextQuarterly, + moveParasToThisQuarterly, moveParasToNextWeekly, moveParasToThisWeekly, moveParasToToday, @@ -30,7 +35,8 @@ export { } from './noteLinks' export { addIDAndAddToOtherNote } from './IDs' export { archiveNoteUsingFolder } from './archive' -// export { newNoteFromClipboard, newNoteFromSelection } from './newNote' Note: moved to NoteHelpers plugin. +export { smartDuplicateRegularNote } from './smartDuplicate' +export { smartFileToCompletedSections } from './smartFile' const pluginID = "jgclark.Filer" diff --git a/jgclark.Filer/src/moveItems.js b/jgclark.Filer/src/moveItems.js index 91dd9fbd9..3976d37e3 100644 --- a/jgclark.Filer/src/moveItems.js +++ b/jgclark.Filer/src/moveItems.js @@ -3,22 +3,19 @@ // ---------------------------------------------------------------------------- // Plugin to help move selected Paragraphs to other notes // Jonathan Clark -// last updated 2024-12-31, for v1.1.6 +// last updated 2025-07-13, for v1.3.0 // ---------------------------------------------------------------------------- import pluginJson from "../plugin.json" -import { addParasAsText, getFilerSettings } from './filerHelpers' +import { getFilerSettings } from './filerHelpers' import { hyphenatedDate, toLocaleDateTimeString } from '@helpers/dateTime' import { toNPLocaleDateString } from '@helpers/NPdateTime' -import { clo, logDebug, logError, logWarn } from '@helpers/dev' +import { clo, logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { displayTitle } from '@helpers/general' -import { allNotesSortedByChanged } from '@helpers/note' +import { moveGivenParaAndIndentedChildren } from '@helpers/NPMoveItems' +import { addParasAsText, getParagraphBlock, selectedLinesIndex } from '@helpers/NPParagraph' import { findHeading, parasToText } from '@helpers/paragraph' -import { - getParagraphBlock, - selectedLinesIndex, -} from '@helpers/NPParagraph' -import { chooseHeading, showMessage } from '@helpers/userInput' +import { chooseHeading, chooseNote, showMessage } from '@helpers/userInput' //----------------------------------------------------------------------------- @@ -33,6 +30,62 @@ export async function moveParaBlock(): Promise { await moveParas(true) } +/** + * Move text to a different note, forcing treating this as a block, but only including all children of the current paragraph. + * @author @jgclark + */ +export async function moveParaAndChildren(): Promise { + try { + const { note, content, selectedParagraphs } = Editor + if (content == null || selectedParagraphs == null || note == null) { + // No note open, or no selectedParagraph selection (perhaps empty note), so don't do anything. + logWarn(pluginJson, 'moveParas: No note open, so stopping.') + return + } + logDebug(pluginJson, 'moveParaAndChildren(): Starting') + + // just move the current paragraph + const parasInBlock = selectedParagraphs.slice(0, 1) // just first para + logDebug('moveParaAndChildren', `move current para and children`) + + // Attempt to highlight the current paragraph to help user check all is well + const firstStartIndex = parasInBlock[0].contentRange?.start ?? NaN + const lastEndIndex = parasInBlock[parasInBlock.length - 1].contentRange?.end ?? null + if (firstStartIndex && lastEndIndex) { + const parasCharIndexRange: TRange = Range.create(firstStartIndex, lastEndIndex) + // logDebug('moveParas', `- will try to highlight automatic block selection range ${rangeToString(parasCharIndexRange)}`) + Editor.highlightByRange(parasCharIndexRange) + } + + // Decide where to move to + // Ask for the note we want to add the selectedParas + const destNote = await chooseNote(true, true, [], `Select note to move this line and its children to`, false, true) + if (destNote == null) { + logInfo('moveParas', 'No note selected, so stopping.') + return + } + // Ask to which heading to add the selectedParas + let headingToFind = await chooseHeading(destNote, true, true, false) + logDebug('moveParas', `- Moving to note '${displayTitle(destNote)}' under heading: '${headingToFind}'`) + if (/\s$/.test(headingToFind)) { + logWarn('moveParas', `Heading to move to ('${headingToFind}') has trailing whitespace. Will pre-emptively remove them to try to avoid problems.`) + const headingPara = findHeading(destNote, headingToFind) + if (headingPara) { + headingPara.content = headingPara.content.trim() + destNote.updateParagraph(headingPara) + logDebug('moveParas', `- now headingPara in destNote is '${headingPara.content}'`) + headingToFind = headingPara.content + } + } + + await moveGivenParaAndIndentedChildren(Editor.selectedParagraphs[0], destNote.filename, destNote.type, headingToFind) + } + catch (error) { + logError('moveParaAndChildren', error.message) + await showMessage(error.message, 'OK', 'Filer: Error moving lines') + } +} + /** * Move text to a different note. * NB: Can't select dates without an existing Calendar note. @@ -65,7 +118,7 @@ export async function moveParas(withBlockContext: boolean = false): Promise if (lastSelLineIndex !== firstSelLineIndex) { // use only the selected paras - logDebug(pluginJson, `moveParas: user has selected lineIndexes ${firstSelLineIndex}-${lastSelLineIndex}`) + logDebug('moveParas', `moveParas: user has selected lineIndexes ${firstSelLineIndex}-${lastSelLineIndex}`) parasInBlock = selectedParagraphs.slice() // copy to avoid $ReadOnlyArray problem } else { // there is no user selection @@ -86,11 +139,11 @@ export async function moveParas(withBlockContext: boolean = false): Promise n.title ?? 'untitled'), - `Select note to move ${(parasInBlock.length > 1) ? parasInBlock.length + ' lines' : 'current line'} to`, - ) - const destNote = allNotes[res.index] - // Note: showOptions returns the first item if something else is typed. And I can't see a way to distinguish between the two. - + const destNote = await chooseNote(true, true, [], `Select note to move ${(parasInBlock.length > 1) ? parasInBlock.length + ' lines' : 'current line'} to`, false, true) + if (destNote == null) { + logInfo('moveParas', 'No note selected, so stopping.') + return + } // Ask to which heading to add the selectedParas let headingToFind = await chooseHeading(destNote, true, true, false) - logDebug(pluginJson, `- Moving to note '${displayTitle(destNote)}' under heading: '${headingToFind}'`) + logDebug('moveParas', `- Moving to note '${displayTitle(destNote)}' under heading: '${headingToFind}'`) if (/\s$/.test(headingToFind)) { - logWarn(pluginJson, `Heading to move to ('${headingToFind}') has trailing whitespace. Will pre-emptively remove them to try to avoid problems.`) + logWarn('moveParas', `Heading to move to ('${headingToFind}') has trailing whitespace. Will pre-emptively remove them to try to avoid problems.`) const headingPara = findHeading(destNote, headingToFind) if (headingPara) { headingPara.content = headingPara.content.trim() destNote.updateParagraph(headingPara) - logDebug(pluginJson, `- now headingPara in destNote is '${headingPara.content}'`) + logDebug('moveParas', `- now headingPara in destNote is '${headingPara.content}'`) headingToFind = headingPara.content } } @@ -150,17 +199,17 @@ export async function moveParas(withBlockContext: boolean = false): Promise { + await moveParasToCalendarDayDate(new Date()) // today +} + +/** + * Move text to tomorrow's Daily note. + * Uses the same selection strategy as moveParas() above + * @author @jgclark + */ +export async function moveParasToTomorrow(): Promise { + await moveParasToCalendarDayDate(Calendar.addUnitToDate(new Date(), 'day', 1))// tomorrow +} + +/** + * Move text to a specified Daily note. + * (Not called directly by users.) + * Uses the same selection strategy as moveParas() above + * @author @jgclark + * @param { Date } date of daily note to move to + * @param { boolean ?} withBlockContext ? + */ +export async function moveParasToCalendarDayDate(destDate: Date, withBlockContext: boolean = false): Promise { + try { + const { content, selectedParagraphs, note } = Editor + + // Pre-flight checks + if (content == null || selectedParagraphs == null || note == null) { + // No note open, or no selectedParagraph selection (perhaps empty note), so don't do anything. + throw new Error('No note open, so stopping.') + } + logDebug(pluginJson, 'moveParasToCalendarDayDate(): Starting') + + // Get config settings + const config = await getFilerSettings() + const origNote = note + const paragraphs = origNote.paragraphs + const origNumParas = origNote.paragraphs.length + + // Find the Daily note to move to + const destNote = DataStore.calendarNoteByDate(destDate, 'day') + if (destNote == null) { + await showMessage(`Sorry: I can't find the Daily note for ${toNPLocaleDateString(destDate)}.`) + throw new Error(`Failed to open the Daily note for ${toNPLocaleDateString(destDate)}. Stopping.`) + } + + // Get current selection, and its range + const selection = Editor.selection + if (selection == null) { + throw new Error('No selection found, so stopping.') + } + // Get paragraph indexes for the start and end of the selection (can be the same) + const [firstSelParaIndex, _lastSelParaIndex] = selectedLinesIndex(selection, paragraphs) + + // Get paragraphs for the selection or block + let firstStartIndex = 0 + let parasInBlock: Array + if (withBlockContext) { + // user has requested working on the surrounding block + parasInBlock = getParagraphBlock(origNote, firstSelParaIndex, config.includeFromStartOfSection, config.useTightBlockDefinition) + logDebug(pluginJson, `moveParasToCalendarDayDate: move block of ${parasInBlock.length} paras`) + } else { + // user just wants to move the current line + parasInBlock = selectedParagraphs.slice(0, 1) // just first para + logDebug(pluginJson, `moveParasToCalendarDayDate: move current para only`) + } + + // Now attempt to highlight them to help user check all is well + firstStartIndex = parasInBlock[0].contentRange?.start ?? NaN + const lastEndIndex = parasInBlock[parasInBlock.length - 1].contentRange?.end ?? null + if (firstStartIndex && lastEndIndex) { + const parasCharIndexRange: TRange = Range.create(firstStartIndex, lastEndIndex) + // logDebug('moveParasToCalendarDayDate', `- will try to highlight automatic block selection range ${rangeToString(parasCharIndexRange)}`) + Editor.highlightByRange(parasCharIndexRange) + } + + // At the time of writing, there was no API function to work on multiple selectedParagraphs, + // or one to insert an indented selectedParagraph, so we need to convert the selectedParagraphs + // to a raw text version which we can include + const selectedParasAsText = parasToText(parasInBlock) + const selectedNumLines = parasInBlock.length + + // Append text to the new location in destination note + const beforeNumParasInDestNote = destNote.paragraphs.length + addParasAsText(destNote, selectedParasAsText, '', config.whereToAddInSection, config.allowNotePreambleBeforeHeading) + // Now check that the paras have been added -- it was sometimes failing probably with whitespace issues. + const afterNumParasInDestNote = destNote.paragraphs.length + logDebug(pluginJson, `Added ${selectedNumLines} lines to ${destNote.title ?? 'error'}: before ${beforeNumParasInDestNote} paras / after ${afterNumParasInDestNote} paras`) + if (beforeNumParasInDestNote === afterNumParasInDestNote) { + throw new Error(`Failed to add ${selectedNumLines} lines to ${displayTitle(destNote)}, so will stop before removing the lines from ${displayTitle(note)}.\nThis is normally caused by spaces on the start/end of the heading.`) + } + + // delete from existing location + logDebug('moveParasToCalendarDayDate', `- Removing ${parasInBlock.length} paras from original origNote (which had ${String(origNumParas)} paras)`) + origNote.removeParagraphs(parasInBlock) + // double-check that the paras have been removed + if (note.paragraphs.length !== (origNumParas - parasInBlock.length)) { + logWarn(pluginJson, `- WARNING: Delete has removed ${Number(origNumParas - note.paragraphs.length)} paragraphs`) + } + + // unhighlight the previous selection, for safety's sake + const emptyRange: TRange = Range.create(firstStartIndex ?? 0, firstStartIndex ?? 0) + Editor.highlightByRange(emptyRange) + } + catch (error) { + logError('moveParasToCalendarDayDate', error.message) + const res = await showMessage(error.message, 'OK', 'Filer: Error moving lines to calendar date') + } +} + +// ----------------------------------------------------------------- + /** * Move text to the current Weekly note. * Uses the same selection strategy as moveParas() above @@ -240,11 +407,11 @@ export async function moveParasToCalendarWeekly(destDate: Date, withBlockContext if (withBlockContext) { // user has requested working on the surrounding block parasInBlock = getParagraphBlock(origNote, firstSelParaIndex, config.includeFromStartOfSection, config.useTightBlockDefinition) - logDebug(pluginJson, `moveParas: move block of ${parasInBlock.length} paras`) + logDebug(pluginJson, `moveParasToCalendarWeekly: move block of ${parasInBlock.length} paras`) } else { // user just wants to move the current line parasInBlock = selectedParagraphs.slice(0, 1) // just first para - logDebug(pluginJson, `moveParas: move current para only`) + logDebug(pluginJson, `moveParasToCalendarWeekly: move current para only`) } // Now attempt to highlight them to help user check all is well @@ -252,11 +419,11 @@ export async function moveParasToCalendarWeekly(destDate: Date, withBlockContext const lastEndIndex = parasInBlock[parasInBlock.length - 1].contentRange?.end ?? null if (firstStartIndex && lastEndIndex) { const parasCharIndexRange: TRange = Range.create(firstStartIndex, lastEndIndex) - // logDebug(pluginJson, `- will try to highlight automatic block selection range ${rangeToString(parasCharIndexRange)}`) + // logDebug('moveParasToCalendarWeekly', `- will try to highlight automatic block selection range ${rangeToString(parasCharIndexRange)}`) Editor.highlightByRange(parasCharIndexRange) } - // At the time of writing, there's no API function to work on multiple selectedParagraphs, + // At the time of writing, there was no API function to work on multiple selectedParagraphs, // or one to insert an indented selectedParagraph, so we need to convert the selectedParagraphs // to a raw text version which we can include const selectedParasAsText = parasToText(parasInBlock) @@ -267,13 +434,13 @@ export async function moveParasToCalendarWeekly(destDate: Date, withBlockContext addParasAsText(destNote, selectedParasAsText, '', config.whereToAddInSection, config.allowNotePreambleBeforeHeading) // Now check that the paras have been added -- it was sometimes failing probably with whitespace issues. const afterNumParasInDestNote = destNote.paragraphs.length - logDebug(pluginJson, `Added ${selectedNumLines} lines to ${destNote.title}: before ${beforeNumParasInDestNote} paras / after ${afterNumParasInDestNote} paras`) + logDebug(pluginJson, `Added ${selectedNumLines} lines to ${destNote.title ?? 'error'}: before ${beforeNumParasInDestNote} paras / after ${afterNumParasInDestNote} paras`) if (beforeNumParasInDestNote === afterNumParasInDestNote) { throw new Error(`Failed to add ${selectedNumLines} lines to ${displayTitle(destNote)}, so will stop before removing the lines from ${displayTitle(note)}.\nThis is normally caused by spaces on the start/end of the heading.`) } // delete from existing location - logDebug(pluginJson, `- Removing ${parasInBlock.length} paras from original note (which had ${String(origNumParas)} paras)`) + logDebug('moveParasToCalendarWeekly', `- Removing ${parasInBlock.length} paras from original note (which had ${String(origNumParas)} paras)`) origNote.removeParagraphs(parasInBlock) // double-check that the paras have been removed if (note.paragraphs.length !== (origNumParas - parasInBlock.length)) { @@ -286,49 +453,48 @@ export async function moveParasToCalendarWeekly(destDate: Date, withBlockContext } catch (error) { logError('moveParasToCalendarWeekly', error.message) - const res = await showMessage(error.message, 'OK', 'Filer: Error moving lines to calendar date') + // const res = await showMessage(error.message, 'OK', 'Filer: Error moving lines to calendar date') } } // ----------------------------------------------------------------- /** - * Move text to today's Daily note. + * Move text to the current Monthly note. * Uses the same selection strategy as moveParas() above * @author @jgclark */ -export async function moveParasToToday(): Promise { - await moveParasToCalendarDate(new Date()) // today +export async function moveParasToThisMonthly(): Promise { + await moveParasToCalendarMonthly(new Date()) } /** - * Move text to tomorrow's Daily note. + * Move text to the current Monthly note. * Uses the same selection strategy as moveParas() above * @author @jgclark */ -export async function moveParasToTomorrow(): Promise { - await moveParasToCalendarDate(Calendar.addUnitToDate(new Date(), 'day', 1))// tomorrow +export async function moveParasToNextMonthly(): Promise { + await moveParasToCalendarMonthly(Calendar.addUnitToDate(new Date(), 'day', 7)) // + 1 week } /** - * Move text to a specified Daily note. + * Move text to the current Monthly note. * (Not called directly by users.) * Uses the same selection strategy as moveParas() above * @author @jgclark - * @param {Date} date of daily note to move to + * @param {Date} date of monthly note to move to * @param {boolean?} withBlockContext? */ -export async function moveParasToCalendarDate(destDate: Date, withBlockContext: boolean = false): Promise { +export async function moveParasToCalendarMonthly(destDate: Date, withBlockContext: boolean = false): Promise { try { const { content, selectedParagraphs, note } = Editor // Pre-flight checks if (content == null || selectedParagraphs == null || note == null) { - // No note open, or no selectedParagraph selection (perhaps empty note), so don't do anything. - logError(pluginJson, 'No note open, so stopping.') + // No note open, or no selectedParagraph selection (empty note?), so don't do anything. + logWarn(pluginJson, 'moveParasToCalendarMonthly(): No note open, so stopping.') return } - logDebug(pluginJson, 'moveParasToCalendarDate(): Starting') // Get config settings const config = await getFilerSettings() @@ -336,48 +502,46 @@ export async function moveParasToCalendarDate(destDate: Date, withBlockContext: const paragraphs = origNote.paragraphs const origNumParas = origNote.paragraphs.length - // Find the Daily note to move to - const destNote = DataStore.calendarNoteByDate(destDate, 'day') + // Find the Monthly note to move to + const destNote = DataStore.calendarNoteByDate(destDate, 'month') if (destNote == null) { - await showMessage(`Sorry: I can't find the Daily note for ${toNPLocaleDateString(destDate)}.`) - logError(pluginJson, `Failed to open the Daily note for ${toNPLocaleDateString(destDate)}. Stopping.`) + await showMessage(`Sorry: I can't find the Monthly note for ${toNPLocaleDateString(destDate)}.`) + logError('moveParasToCalendarMonthly', `Failed to open the Monthly note for ${toNPLocaleDateString(destDate)}. Stopping.`) return } // Get current selection, and its range const selection = Editor.selection if (selection == null) { - logError(pluginJson, 'No selection found, so stopping.') + logError('moveParasToCalendarMonthly', 'No selection found, so stopping.') return } - // Get paragraph indexes for the start and end of the selection (can be the same) - const [firstSelParaIndex, _lastSelParaIndex] = selectedLinesIndex(selection, paragraphs) - // Get paragraphs for the selection or block + // Get paragraph indexes for the start and end of the selection (can be the same) let firstStartIndex = 0 let parasInBlock: Array + const [firstSelParaIndex, _lastSelParaIndex] = selectedLinesIndex(selection, paragraphs) + // Get paragraphs for the selection or block if (withBlockContext) { // user has requested working on the surrounding block parasInBlock = getParagraphBlock(origNote, firstSelParaIndex, config.includeFromStartOfSection, config.useTightBlockDefinition) - logDebug(pluginJson, `moveParas: move block of ${parasInBlock.length} paras`) + logDebug('moveParasToCalendarMonthly', `moveParasToCalendarMonthly: move block of ${parasInBlock.length} paras`) } else { // user just wants to move the current line parasInBlock = selectedParagraphs.slice(0, 1) // just first para - logDebug(pluginJson, `moveParas: move current para only`) + logDebug('moveParasToCalendarMonthly', `moveParasToCalendarMonthly: move current para only`) } - // Now attempt to highlight them to help user check all is well (but only works from v3.6.2, build 844) - if (NotePlan.environment.buildVersion > 844) { - firstStartIndex = parasInBlock[0].contentRange?.start ?? NaN - const lastEndIndex = parasInBlock[parasInBlock.length - 1].contentRange?.end ?? null - if (firstStartIndex && lastEndIndex) { - const parasCharIndexRange: TRange = Range.create(firstStartIndex, lastEndIndex) - // logDebug(pluginJson, `- will try to highlight automatic block selection range ${rangeToString(parasCharIndexRange)}`) - Editor.highlightByRange(parasCharIndexRange) - } + // Attempt to highlight them to help user check all is well + firstStartIndex = parasInBlock[0].contentRange?.start ?? NaN + const lastEndIndex = parasInBlock[parasInBlock.length - 1].contentRange?.end ?? null + if (firstStartIndex && lastEndIndex) { + const parasCharIndexRange: TRange = Range.create(firstStartIndex, lastEndIndex) + // logDebug('moveParasToCalendarMonthly', `- will try to highlight automatic block selection range ${rangeToString(parasCharIndexRange)}`) + Editor.highlightByRange(parasCharIndexRange) } - // At the time of writing, there's no API function to work on multiple selectedParagraphs, + // At the time of writing, there was no API function to work on multiple selectedParagraphs, // or one to insert an indented selectedParagraph, so we need to convert the selectedParagraphs // to a raw text version which we can include const selectedParasAsText = parasToText(parasInBlock) @@ -388,17 +552,17 @@ export async function moveParasToCalendarDate(destDate: Date, withBlockContext: addParasAsText(destNote, selectedParasAsText, '', config.whereToAddInSection, config.allowNotePreambleBeforeHeading) // Now check that the paras have been added -- it was sometimes failing probably with whitespace issues. const afterNumParasInDestNote = destNote.paragraphs.length - logDebug(pluginJson, `Added ${selectedNumLines} lines to ${destNote.title}: before ${beforeNumParasInDestNote} paras / after ${afterNumParasInDestNote} paras`) + logDebug('moveParasToCalendarMonthly', `Added ${selectedNumLines} lines to ${destNote.title ?? 'error'}: before ${beforeNumParasInDestNote} paras / after ${afterNumParasInDestNote} paras`) if (beforeNumParasInDestNote === afterNumParasInDestNote) { throw new Error(`Failed to add ${selectedNumLines} lines to ${displayTitle(destNote)}, so will stop before removing the lines from ${displayTitle(note)}.\nThis is normally caused by spaces on the start/end of the heading.`) } // delete from existing location - logDebug(pluginJson, `- Removing ${parasInBlock.length} paras from original origNote (which had ${String(origNumParas)} paras)`) + logDebug('moveParasToCalendarMonthly', `- Removing ${parasInBlock.length} paras from original note (which had ${String(origNumParas)} paras)`) origNote.removeParagraphs(parasInBlock) // double-check that the paras have been removed if (note.paragraphs.length !== (origNumParas - parasInBlock.length)) { - logWarn(pluginJson, `- WARNING: Delete has removed ${Number(origNumParas - note.paragraphs.length)} paragraphs`) + logWarn('moveParasToCalendarMonthly', `- WARNING: Delete has removed ${Number(origNumParas - note.paragraphs.length)} paragraphs`) } // unhighlight the previous selection, for safety's sake @@ -406,7 +570,117 @@ export async function moveParasToCalendarDate(destDate: Date, withBlockContext: Editor.highlightByRange(emptyRange) } catch (error) { - logError('moveParasToCalendarDate', error.message) + logError('moveParasToCalendarMonthly', error.message) const res = await showMessage(error.message, 'OK', 'Filer: Error moving lines to calendar date') } } + +// ----------------------------------------------------------------- + +/** + * Move text to the current Quarterly note. + * Uses the same selection strategy as moveParas() above + * @author @jgclark + */ +export async function moveParasToThisQuarterly(): Promise { + await moveParasToCalendarQuarterly(new Date()) +} + +/** + * Move text to the current Quarterly note. + * Uses the same selection strategy as moveParas() above + * @author @jgclark + */ +export async function moveParasToNextQuarterly(): Promise { + await moveParasToCalendarQuarterly(Calendar.addUnitToDate(new Date(), 'day', 7)) // + 1 week +} + +/** + * Move text to the current Quarterly note. + * (Not called directly by users.) + * Uses the same selection strategy as moveParas() above + * @author @jgclark + * @param {Date} date of Quarterly note to move to + * @param {boolean?} withBlockContext? + */ +export async function moveParasToCalendarQuarterly(destDate: Date, withBlockContext: boolean = false): Promise { + try { + const { content, selectedParagraphs, note } = Editor + + // Pre-flight checks + if (content == null || selectedParagraphs == null || note == null) { + // No note open, or no selectedParagraph selection (empty note?), so don't do anything. + logWarn(pluginJson, 'moveParasToCalendarQuarterly(): No note open, so stopping.') + return + } + + // Get config settings + const config = await getFilerSettings() + const origNote = note + const paragraphs = origNote.paragraphs + const origNumParas = origNote.paragraphs.length + + // Find the Quarterly note to move to + const destNote = DataStore.calendarNoteByDate(destDate, 'quarter') + if (destNote == null) { + await showMessage(`Sorry: I can't find the Quarterly note for ${toNPLocaleDateString(destDate)}.`) + logError('moveParasToCalendarQuarterly', `Failed to open the Quarterly note for ${toNPLocaleDateString(destDate)}. Stopping.`) + return + } + + // Get current selection, and its range + const selection = Editor.selection + if (selection == null) { + logError('moveParasToCalendarQuarterly', 'No selection found, so stopping.') + return + } + + // Get paragraph indexes for the start and end of the selection (can be the same) + let firstStartIndex = 0 + let parasInBlock: Array + const [firstSelParaIndex, _lastSelParaIndex] = selectedLinesIndex(selection, paragraphs) + // Get paragraphs for the selection or block + if (withBlockContext) { + // user has requested working on the surrounding block + parasInBlock = getParagraphBlock(origNote, firstSelParaIndex, config.includeFromStartOfSection, config.useTightBlockDefinition) + logDebug('moveParasToCalendarQuarterly', `moveParasToCalendarQuarterly: move block of ${parasInBlock.length} paras`) + } else { + // user just wants to move the current line + parasInBlock = selectedParagraphs.slice(0, 1) // just first para + logDebug('moveParasToCalendarQuarterly', `moveParasToCalendarQuarterly: move current para only`) + } + + // Attempt to highlight them to help user check all is well + firstStartIndex = parasInBlock[0].contentRange?.start ?? NaN + const lastEndIndex = parasInBlock[parasInBlock.length - 1].contentRange?.end ?? null + if (firstStartIndex && lastEndIndex) { + const parasCharIndexRange: TRange = Range.create(firstStartIndex, lastEndIndex) + // logDebug('moveParasToCalendarQuarterly', `- will try to highlight automatic block selection range ${rangeToString(parasCharIndexRange)}`) + Editor.highlightByRange(parasCharIndexRange) + } + + // At the time of writing, there was no API function to work on multiple selectedParagraphs, + // or one to insert an indented selectedParagraph, so we need to convert the selectedParagraphs + // to a raw text version which we can include + const selectedParasAsText = parasToText(parasInBlock) + + // Append text to the new location in destination note + addParasAsText(destNote, selectedParasAsText, '', config.whereToAddInSection, config.allowNotePreambleBeforeHeading) + + // delete from existing location + logDebug('moveParasToCalendarMonthly', `- Removing ${parasInBlock.length} paras from original note (which had ${String(origNumParas)} paras)`) + origNote.removeParagraphs(parasInBlock) + // double-check that the paras have been removed + if (note.paragraphs.length !== (origNumParas - parasInBlock.length)) { + logWarn('moveParasToCalendarQuarterly', `- WARNING: Delete has removed ${Number(origNumParas - note.paragraphs.length)} paragraphs`) + } + + // unhighlight the previous selection, for safety's sake + const emptyRange: TRange = Range.create(firstStartIndex ?? 0, firstStartIndex ?? 0) + Editor.highlightByRange(emptyRange) + } + catch (error) { + logError('moveParasToCalendarQuarterly', error.message) + // const res = await showMessage(error.message, 'OK', 'Filer: Error moving lines to calendar date') + } +} \ No newline at end of file diff --git a/jgclark.Filer/src/noteLinks.js b/jgclark.Filer/src/noteLinks.js index a4b01471f..ee5d48b4c 100644 --- a/jgclark.Filer/src/noteLinks.js +++ b/jgclark.Filer/src/noteLinks.js @@ -3,18 +3,18 @@ // ---------------------------------------------------------------------------- // Functions to file [[note links]] from calendar notes to project notes. // Jonathan Clark -// last updated 29.6.2024, for v1.1.0+ +// last updated 2025-07-13, for v1.2.1 // ---------------------------------------------------------------------------- import pluginJson from "../plugin.json" -import { addParasAsText, getFilerSettings, type FilerConfig } from './filerHelpers' +import { getFilerSettings, type FilerConfig } from './filerHelpers' import { clo, logDebug, logError, logInfo, logWarn, overrideSettingsWithEncodedTypedArgs, } from '@helpers/dev' import { displayTitle } from '@helpers/general' import { getAllNotesOfType, getNotesChangedInInterval } from '@helpers/NPnote' -import { getParagraphBlock } from '@helpers/NPParagraph' +import { addParasAsText, getParagraphBlock } from '@helpers/NPParagraph' import { NP_RE_note_title_link, RE_NOTE_TITLE_CAPTURE } from '@helpers/regex' import { showMessage } from '@helpers/userInput' diff --git a/jgclark.Filer/src/smartDuplicate.js b/jgclark.Filer/src/smartDuplicate.js new file mode 100644 index 000000000..639455a1e --- /dev/null +++ b/jgclark.Filer/src/smartDuplicate.js @@ -0,0 +1,304 @@ +// @flow +/* eslint-disable prefer-template */ +//----------------------------------------------------------------------------- +// Smart duplicate note from an existing one. +// Last updated 2024-10-14 for v1.2.0 by @jgclark +//----------------------------------------------------------------------------- + +import moment from 'moment/min/moment-with-locales' +import pluginJson from '../plugin.json' +import { Project } from '../../jgclark.Reviews/src/projectClass' +import { updateMetadataInEditor } from '../../jgclark.Reviews/src/reviewHelpers' +import { removeEmptyHeadings, sortTasksDefault } from '../../dwertheimer.TaskSorting/src/sortTasks' +import { archiveNoteUsingFolder } from './archive' +import { + calcOffsetDateStr, + getHalfYearRangeDate, + getTodaysDateHyphenated, + MOMENT_FORMAT_NP_QUARTER, MOMENT_FORMAT_NP_YEAR, + RE_NP_QUARTER_SPEC, RE_NP_HALFYEAR_SPEC, RE_NP_YEAR_SPEC +} from '@helpers/dateTime' +import { clo, JSP, logDebug, logError, logInfo, logWarn } from '@helpers/dev' +import { getFolderFromFilename } from '@helpers/folders' +import { displayTitle } from '@helpers/general' +import { getAttributes, noteHasFrontMatter, removeFrontMatterField } from '@helpers/NPFrontMatter' +import { openNoteInNewSplitIfNeeded } from '@helpers/NPWindows' +import { findEndOfActivePartOfNote } from '@helpers/paragraph' +import { chooseOption, getInput, showMessageYesNo } from '@helpers/userInput' +import { isClosed } from '@helpers/utils' + + +//----------------------------------------------------------------------------- + +/** + * Rename title of regular note, by changing line 0 (or 1 if frontmatter) to be the new title + * @param {TNote} note + * @param {string} newTitle + */ +export function renameNoteTitle(note: TNote, newTitle: string): void { + const origTitle = note.title ?? '(error)' + let titlePara: TParagraph + if (noteHasFrontMatter(note)) { + titlePara = note.paragraphs[1] + titlePara.content = `title: ${newTitle}` + } else { + titlePara = note.paragraphs[0] + titlePara.content = newTitle + } + note.updateParagraph(titlePara) + logDebug('renameNoteTitle', `CHECK: note title is now ${displayTitle(note)} (was: ${origTitle})`) +} + +/** + * Smartly Duplicate the currently open regular note in the Editor. + * Don't carry forward: + * - any items in '## Done' or '## Completed' sections. + * - any completed tasks/checklists (unless they have open ) + * - any quotes/bullets/ indented under completed items + * - any section headings that then have no content + * Then tidy up: + * - remove duplicate blank lines or separators + * - remove blank lines after headings + * Then sort what remains, using Filer's "Tasks sort by user defaults" command, using its settings. + * Offer to remove any triggers from the original note. + * Offer to archive the original note. + * TODO: More clearly need to distinguish between copying all closed items over and making open again, or just keeping structure and non-closed items. + * @author @jgclark + */ +export async function smartDuplicateRegularNote(): Promise { + try { + const sourceNote = Editor.note ?? null + if (!sourceNote) { + // No note open, so don't do anything. + logWarn(pluginJson, `archiveNoteUsingFolder(): No note passed or open in the Editor, so stopping.`) + return + } + if (sourceNote.type !== 'Notes') { + // Doesn't make sense to run on Calendar notes + logWarn(pluginJson, `archiveNoteUsingFolder(): It doesn't make sense to run this on Calendar noteHasFrontMatter, so stopping.`) + return + } + logDebug(pluginJson, `smartDuplicateRegularNote() starting from note '${displayTitle(sourceNote)}'`) + + // Get period for existing source note, and work out title for new note + // First work out if source note has a period in the title + const sourceTitle = sourceNote.title ?? '' + let datedSourceTitle = sourceTitle + if (!(new RegExp(RE_NP_QUARTER_SPEC)).test(sourceTitle) && !(new RegExp(RE_NP_HALFYEAR_SPEC)).test(sourceTitle) && !(new RegExp(RE_NP_YEAR_SPEC)).test(sourceTitle)) { + // No period found, so ask user + const chosenSourcePeriod = await getTimePeriodFromUser() + if (!chosenSourcePeriod || chosenSourcePeriod === '') { + logWarn('smartDuplicateRegularNote', 'No time period given, so treating as if user cancelled the operation.') + return + } + logDebug('smartDuplicateRegularNote', `user's chosen period: ${chosenSourcePeriod}`) + datedSourceTitle = `${sourceTitle} (${chosenSourcePeriod})` + } + + // Now process original title, or original title + period, to work out next period and title. + // Remove any "Qn" or "Hn" or "YYYY" (which needs to come last) from the title, with or without brackets + let notePeriod = '' + let sourcePeriodStr = '' + let nextPeriodStr = '' + let nextPeriodStartDateStr = '' // for ISO date + let nextPeriodEndDateStr = '' // for ISO date + let nextTitle = '' + let undatedSourceTitle = sourceTitle + // logDebug('smartDuplicateRegularNote', `undatedSourceTitle: '${undatedSourceTitle}'`) + if ((new RegExp(RE_NP_QUARTER_SPEC)).test(datedSourceTitle)) { + logDebug('smartDuplicateRegularNote', `found note with quarter period`) + notePeriod = 'quarter' + sourcePeriodStr = datedSourceTitle.match(/\D(\d{4}[Q][1-4])(\D|$)/)[1] + nextPeriodStr = calcOffsetDateStr(sourcePeriodStr, "+1q") + nextPeriodStartDateStr = moment(nextPeriodStr, MOMENT_FORMAT_NP_QUARTER).startOf('quarter').format('YYYY-MM-DD') + nextPeriodEndDateStr = moment(nextPeriodStr, MOMENT_FORMAT_NP_QUARTER).endOf('quarter').format('YYYY-MM-DD') + nextTitle = undatedSourceTitle.replace(/\d{4}Q[1-4]/, nextPeriodStr) + undatedSourceTitle = undatedSourceTitle.replace(/\d{4}H[1-2]/, '') + } else if ((new RegExp(RE_NP_HALFYEAR_SPEC)).test(datedSourceTitle)) { + logDebug('smartDuplicateRegularNote', `found note with half-year period`) + notePeriod = 'half-year' + sourcePeriodStr = datedSourceTitle.match(/\D(\d{4}H[1-2])(\D|$)/)[1] + nextPeriodStr = calcOffsetDateStr(sourcePeriodStr, "+1h") + logDebug('smartDuplicate', `${sourcePeriodStr} +1h -> ${nextPeriodStr}`) + ;[nextPeriodStartDateStr, nextPeriodEndDateStr] = getHalfYearRangeDate(nextPeriodStr) + nextTitle = undatedSourceTitle.replace(/\d{4}H[1-2]/, nextPeriodStr) + undatedSourceTitle = undatedSourceTitle.replace(/\d{4}H[1-2]/, '') + } else if ((new RegExp(RE_NP_YEAR_SPEC)).test(datedSourceTitle)) { + logDebug('smartDuplicateRegularNote', `found note with year period`) + notePeriod = 'year' + sourcePeriodStr = datedSourceTitle.match(/\D(\d{4})(\D|$)/)[1] + nextPeriodStr = calcOffsetDateStr(sourcePeriodStr, "+1y") + nextPeriodStartDateStr = moment(nextPeriodStr, MOMENT_FORMAT_NP_YEAR).startOf('year').format('YYYY-MM-DD') + nextPeriodEndDateStr = moment(nextPeriodStr, MOMENT_FORMAT_NP_QUARTER).endOf('year').format('YYYY-MM-DD') + nextTitle = undatedSourceTitle.replace(/\d{4}/, nextPeriodStr) + undatedSourceTitle = undatedSourceTitle.replace(/\d{4}/, '') + } + else { + // Shouldn't get here + logError('smartDuplicateRegularNote', `Couldn't work out source period. Stopping.`) + } + + logDebug('smartDuplicateRegularNote', `user's chosen period: ${notePeriod}, nextPeriodStr: ${nextPeriodStr}, nextTitle: '${nextTitle}'`) + undatedSourceTitle = undatedSourceTitle.replace("()", '').replace("[]", '').trim() + if (notePeriod !== '') logDebug(`smartDuplicateRegularNote`, `found note with period: ${notePeriod}, nextTitle: '${nextTitle}'`) + + // Rename existing file to include time period (if it didn't already) + if (datedSourceTitle !== sourceTitle) { + logDebug('smartDuplicateRegularNote', `updating source title to '${datedSourceTitle}'`) + renameNoteTitle(sourceNote, datedSourceTitle) + } + + // Offer the first line to use, shorn of any leading # marks + const newTitleToOffer = nextTitle + // try to work out new title + const newTitle = await getInput(`Title of new ${notePeriod !== '' ? notePeriod + 'ly ' : ''}note`, 'OK', 'Smart Duplicate Note', newTitleToOffer) + if (typeof newTitle === 'boolean') { + logWarn('smartDuplicateRegularNote', 'The user cancelled the operation.') + return + } + logDebug('smartDuplicateRegularNote', `new title will be ${newTitle}`) + + // Work out the contents of the active part of the note + // const activeParas = getActiveParagraphs(sourceNote) // TODO: doesn't work properly + const activeParas = sourceNote.paragraphs.slice(0, findEndOfActivePartOfNote(sourceNote)) + logDebug('smartDuplicateRegularNote', `- has ${activeParas.length} paras in the active part`) + // Keep all lines that aren't open tasks/checklists + // FIXME: but also keep sync'd lines + const parasToKeep = activeParas.filter(para => !isClosed(para)) + logDebug('smartDuplicateRegularNote', `- has ${parasToKeep.length} paras to keep (!isClosed)`) + + // TEST: need to update title line first: change line 0 (or 1 if frontmatter) to be the new title + renameNoteTitle(sourceNote, newTitle) + let destTitlePara: TParagraph + if (noteHasFrontMatter(sourceNote)) { + destTitlePara = parasToKeep[1] + destTitlePara.content = `title: ${newTitle}` + } else { + destTitlePara = parasToKeep[0] + destTitlePara.content = newTitle + } + logDebug('smartDuplicateRegularNote', `- CHECK: destTitlePara => ${destTitlePara.content}`) + + // Do some further clean up of the paragraphs + // let lastContent = '' + let lastType = 'title' + for (let i = 1; i < parasToKeep.length; i++) { + // lastContent = parasToKeep[i - 1].content + const thisRawContent = parasToKeep[i].rawContent + logDebug('smartDuplicateRegularNote', `#${i}: {${thisRawContent}}`) + lastType = parasToKeep[i - 1].type + // Remove consecutive separators or empty lines + if ((parasToKeep[i].type === 'empty' && lastType === 'empty') || (parasToKeep[i].type === 'separator') && (lastType === 'separator')) { + logDebug('smartDuplicateRegularNote', `- removing consecutive empty/separator line`) + parasToKeep.splice(i, 1) + continue + } + // Remove any blank lines after headings (if wanted) + if (lastType === 'title' && parasToKeep[i].type === 'empty') { + logDebug('smartDuplicateRegularNote', `- removing blank line after heading`) + parasToKeep.splice(i, 1) + continue + } + } + + // Save contents to new note in the same folder + // const currentFolder = await chooseFolder('Select folder to add note in:', false, true) // don't include @Archive as an option, but do allow creation of a new folder + const currentFolder = getFolderFromFilename(sourceNote.filename) + const content = parasToKeep.map((p) => p.rawContent).join('\n') + const newFilename = (await DataStore.newNoteWithContent(content, currentFolder)) ?? '' + logInfo('smartDuplicateRegularNote', `-> NEW note with filename: ${newFilename}`) + + // Open the new note + const newNote: ?TNote = await Editor.openNoteByFilename(newFilename) + if (!newNote) { + throw new Error(`Error trying to open new note with filename ${newFilename}`) + } + logDebug('smartDuplicateRegularNote', ` CHECK: wanted title = ${newTitle}; new title = ${displayTitle(newNote)}`) + + // TEST: Remove empty headings + removeEmptyHeadings(newNote) + + // Update project-related metadata: + const metadataArr: Array = [] + // - @reviewed() -> start of this period + const todayMom = moment() + metadataArr.push(`@reviewed(${todayMom.format('YYYY-MM-DD')})`) + // - @start() -> start of this period + metadataArr.push(`@start(${nextPeriodStartDateStr})`) + // - @due() -> end of this period + metadataArr.push(`@due(${nextPeriodEndDateStr})`) + const res = updateMetadataInEditor(metadataArr) + // TODO: delete any @completed() date + + // Sort remaining tasks according to user's defaults. + // Note: this uses @dwertheimer's sortTasksDefault() = /std, which only works on the current Editor, so has to come here. + // TODO: see if we can get it to work on passed paragraphs instead, to avoid the step above. + await sortTasksDefault() + + // Does the source note have a trigger field? + if (noteHasFrontMatter(sourceNote)) { + const allFMFields = getAttributes(sourceNote.content) + clo(allFMFields, 'allFMFields') + if (allFMFields['triggers']) { + // If so, offer to remove them + if (await showMessageYesNo('Remove triggers from source note?', ['Yes', 'No'], `Smart Duplicate Note`) === 'Yes') { + const result = removeFrontMatterField(sourceNote, 'triggers', '', true) + if (result) { + logDebug('smartDuplicateRegularNote', `removed frontmatter trigger field from '${displayTitle(sourceNote)}'`) + } else { + logWarn('smartDuplicateRegularNote', `failed to remove frontmatter trigger field from '${displayTitle(sourceNote)}' for some reason`) + } + } + } + } + + // Offer to archive the source note + // TODO(later): Allow a different archive root folder, as in Reviews. + if (await showMessageYesNo('Archive the source note?', ['Yes', 'No'], `Smart Duplicate Note`) === 'Yes') { + const res = archiveNoteUsingFolder(sourceNote) + logDebug('smartDuplicateRegularNote', `result of archiving '${displayTitle(sourceNote)}': ${String(res)}`) + } + + // Offer to open the source note in a new split + if (await showMessageYesNo('Open the source note in a new split?', ['Yes', 'No'], `Smart Duplicate Note`) === 'Yes') { + const res = openNoteInNewSplitIfNeeded(sourceNote.filename) + logDebug('smartDuplicateRegularNote', `result of opening source note in new split: ${String(res)}`) + } + + } catch (err) { + logError('smartDuplicateRegularNote', err.message) + } +} + +async function getTimePeriodFromUser(): Promise { + // make a list of possible titles for old and new notes + const relativeDates = [] + const todayMom = moment() + const todayDateStr = getTodaysDateHyphenated() + let thisDateStr = '' + thisDateStr = moment(todayMom).startOf('year').format(MOMENT_FORMAT_NP_YEAR) + relativeDates.push({ label: `this year (${thisDateStr})`, value: thisDateStr }) + thisDateStr = moment(todayMom).subtract(1, 'year').startOf('year').format(MOMENT_FORMAT_NP_YEAR) + relativeDates.push({ label: `last year (${thisDateStr})`, value: thisDateStr }) + thisDateStr = moment(todayMom).add(1, 'year').startOf('year').format(MOMENT_FORMAT_NP_YEAR) + relativeDates.push({ label: `next year (${thisDateStr})`, value: thisDateStr }) + + thisDateStr = calcOffsetDateStr(todayDateStr, '0h', 'offset') + relativeDates.push({ label: `this half-year (${thisDateStr})`, value: thisDateStr }) + thisDateStr = calcOffsetDateStr(todayDateStr, '-1h', 'offset') + relativeDates.push({ label: `last half-year (${thisDateStr})`, value: thisDateStr }) + thisDateStr = calcOffsetDateStr(todayDateStr, '+1h', 'offset') + relativeDates.push({ label: `next half-year (${thisDateStr})`, value: thisDateStr }) + + thisDateStr = moment(todayMom).startOf('quarter').format(MOMENT_FORMAT_NP_QUARTER) + relativeDates.push({ label: `this quarter (${thisDateStr})`, value: thisDateStr }) + thisDateStr = moment(todayMom).subtract(1, 'quarter').startOf('quarter').format(MOMENT_FORMAT_NP_QUARTER) + relativeDates.push({ label: `last quarter (${thisDateStr})`, value: thisDateStr }) + thisDateStr = moment(todayMom).add(1, 'quarter').startOf('quarter').format(MOMENT_FORMAT_NP_QUARTER) + relativeDates.push({ label: `next quarter (${thisDateStr})`, value: thisDateStr }) + clo(relativeDates, 'relativeDates') + + const periodOptions = relativeDates + const chosenSourcePeriod = await chooseOption(`What was the time period that this existing note covered?`, periodOptions, ``) + return chosenSourcePeriod +} \ No newline at end of file diff --git a/jgclark.Filer/src/smartFile.js b/jgclark.Filer/src/smartFile.js new file mode 100644 index 000000000..44db9def7 --- /dev/null +++ b/jgclark.Filer/src/smartFile.js @@ -0,0 +1,175 @@ +// @flow +//----------------------------------------------------------------------------- +// Smart tidy up a note, filing completed items to Done/Cancelled. +// Last updated 2024-10-14 for v1.2.0 by @jgclark +//----------------------------------------------------------------------------- + +import pluginJson from '../plugin.json' +import { insertTodos, removeEmptyHeadings, sortTasksDefault } from '../../dwertheimer.TaskSorting/src/sortTasks' +import { clo, logDebug, logError, logWarn } from '@helpers/dev' +import { displayTitle } from '@helpers/general' +import { findEndOfActivePartOfNote, findStartOfActivePartOfNote } from '@helpers/paragraph' +import { showMessage, showMessageYesNo } from '@helpers/userInput' +import { isClosed, isOpen } from '@helpers/utils' +import { + getParagraphBlock, + // selectedLinesIndex, +} from '@helpers/NPParagraph' + +type ParagraphForFiling = { + lineIndex: number, + content: string, + type: string, + headingUnder: string, + indents: number, + numChildren: number, + numOpenChildren: number, + toMove: boolean, +} + +//----------------------------------------------------------------------------- + +function makeFilablePara(paragraph: TParagraph): ParagraphForFiling { + let fp = {} + if (!paragraph || !paragraph.note) { + throw new Error(`makeFilablePara(): No valid paragraph passed`) + } + + if (paragraph.type === 'title') { + const sectionBlock = getParagraphBlock(paragraph.note, paragraph.lineIndex, true, true) + fp = { + headingUnder: paragraph.heading || '', // TEST: should only be '' near the top of a calendar note + content: paragraph.content, + lineIndex: paragraph.lineIndex, + type: paragraph.type, + level: paragraph.headingLevel, // = heading level + numChildren: sectionBlock.length, + numOpenChildren: sectionBlock.filter(isOpen).length, + toMove: false, // by default + } + } else { + fp = { + headingUnder: paragraph.heading || '', // TEST: should only be '' near the top of a calendar note + content: paragraph.content, + lineIndex: paragraph.lineIndex, + type: paragraph.type, + level: paragraph.indents, // = indent level + numChildren: paragraph.children().length, + numOpenChildren: paragraph.children().filter(isOpen).length, + toMove: false, // by default + } + } + return fp +} + + +//----------------------------------------------------------------------------- + +/** + * Smartly file completed (done/cancelled) items in a note to its Done/Cancelled sections. + * Only move if an item is completed, and all its subitems too (if any). + * + * Then tidy up: + * - remove duplicate blank lines or separators + * - ? remove blank lines after headings + * Then sort what remains, using Task Sorting & Tool's "Tasks sort by user defaults" command, using its settings. + * @author @jgclark + */ +// eslint-disable-next-line require-await +export async function smartFileToCompletedSections(filenameIn: string = ''): Promise { + try { + const note = filenameIn ? DataStore.noteByFilename(filenameIn, 'Notes') : Editor?.note ? Editor.note : null + if (!note) { + // No note open or passed, so don't do anything. + logWarn(pluginJson, 'archiveNoteUsingFolder(): No note passed or open in the Editor, so stopping.') + return + } + logDebug(pluginJson, `smartFileToCompletedSections() starting from note '${displayTitle(note)}'`) + + // Work out the contents of the active part of the note (after the preamble, which we leave alone) + const activeStart = findStartOfActivePartOfNote(note) + const activeEnd = findEndOfActivePartOfNote(note) + logDebug('paragraph/smartFileToCompletedSections', `activeStart = ${activeStart} / activeEnd = ${activeEnd} / total lines = ${note.paragraphs.length}`) + + // Make reduced-but-filable paras to process + const activeParas = note.paragraphs.slice(activeStart, activeEnd).map(ap => makeFilablePara(ap)) + + // Now go through each section/heading + const activeHeadingParas = activeParas.filter(para => para.type === 'title') + // logDebug('smartFileToCompletedSections', `found ${activeHeadingParas.length} headings: [${activeHeadingParas.map(p => p.content).join(', ')}]`) // OK + + // let previousHeading = undefined + for (const thisHeading of activeHeadingParas) { + let parasToMove = [] + let headingIndex = thisHeading.lineIndex + let headingLevel = thisHeading.level + let lineIndex = headingIndex + 1 + logDebug('smartFileToCompletedSections', `processing heading ${thisHeading.content} (level ${headingLevel}) at line ${headingIndex}:`) + while (lineIndex < activeParas.length && activeParas[lineIndex].type !== 'title') { + lineIndex++ // skip heading lines until () + const thisPara = activeParas[lineIndex] + if (lineIndex > 42) clo(thisPara) + if (thisPara.numChildren === 0 || (thisPara.numChildren > 0 && thisPara.numOpenChildren === 0)) { + thisPara.toMove = true + parasToMove.push(thisPara) + logDebug('smartFileToCompletedSections', `found line ${thisPara.lineIndex}:<${thisPara.content}> to move to Done`) + } + } + logDebug('smartFileToCompletedSections', `- after processing heading ${thisHeading.content}, lineIndex=${lineIndex} and found ${parasToMove.length} paras to move to Done/Cancelled`) + if (parasToMove.length > 0) { + clo(parasToMove) + // Insert block of these paras under ## Done section + // FIXME: "undefined is not an object (evaluating 'todos[lineIndex][subHeadingCategory][0]')", + // I DON'T THINK MY DATA STRUCTURE IS CORRECT for insertTodos. + // insertTodos(note, parasToMove, '', '', thisHeading.content, 'Done', false) + // TODO: Try DataStore....HeadingSection instead + } + } + + // Check: #moved against length + // TODO: Now remove the moved lines + + // Do some further clean up of the paragraphs + // let lastContent = '' + // let lastType = 'title' + // for (let i = 1; i < parasToKeep.length; i++) { + // // lastContent = parasToKeep[i - 1].content + // const thisRawContent = parasToKeep[i].rawContent + // logDebug('smartFileToCompletedSections', `#${i}: {${thisRawContent}}`) + // lastType = parasToKeep[i - 1].type + // // Remove consecutive separators or empty lines + // if ((parasToKeep[i].type === 'empty' && lastType === 'empty') || (parasToKeep[i].type === 'separator') && (lastType === 'separator')) { + // logDebug('smartFileToCompletedSections', `- removing consecutive empty/separator line`) + // parasToKeep.splice(i, 1) + // continue + // } + // // Remove any blank lines after headings (if wanted) + // if (lastType === 'title' && parasToKeep[i].type === 'empty') { + // logDebug('smartFileToCompletedSections', `- removing blank line after heading`) + // parasToKeep.splice(i, 1) + // continue + // } + // } + + // TODO: Save contents to the note + + // TEST: Remove empty headings + // removeEmptyHeadings(note) + + // If wanted, file completed tasks with a [[note link]] to that note, using the Tidy command/code. + // TODO: Decide if there are relevant items to file, and if so, offer to file those. + // if (await showMessageYesNo('Shall I sort the note using your default options (from TaskSorting plugin)?', ['Yes', 'No'], `Smart File Paragraphs in Note`) === 'Yes') { + // TODO: something from Tidy + // } + + // If wanted, sort remaining tasks according to user's defaults. + // Note: this uses @dwertheimer's sortTasksDefault() = /std, which only works on the current Editor, so has to come here. + // TODO: see if we can get it to work on passed paragraphs instead, to avoid the step above. + // if (await showMessageYesNo('Shall I sort the note using your default options (from TaskSorting plugin)?', ['Yes', 'No'], `Smart File Paragraphs in Note`) === 'Yes') { + // await sortTasksDefault() + // } + + } catch (err) { + logError('smartFileToCompletedSections', err) + } +} diff --git a/jgclark.NoteHelpers/CHANGELOG.md b/jgclark.NoteHelpers/CHANGELOG.md index 9964255df..40f608e85 100644 --- a/jgclark.NoteHelpers/CHANGELOG.md +++ b/jgclark.NoteHelpers/CHANGELOG.md @@ -2,12 +2,12 @@ For more details see the [plugin's README](https://github.com/NotePlan/plugins/tree/main/jgclark.NoteHelpers/). -## [1.2.0] - 2025-06-13??? +## [1.2.0] - 2025-07-??? - various improvements/fixes to the **inconsistent file name** commands. Resolves issues #640, #642, #643 raised by @tastapod. +- made the title generation smarter in **new note from selection** and **new note from clipboard** commands ## [1.1.1] - 2025-04-22 - the **log Editor Note** commands now handle Teamspace notes correctly. diff --git a/jgclark.NoteHelpers/src/newNote.js b/jgclark.NoteHelpers/src/newNote.js index 3e024b3df..b0f9decab 100644 --- a/jgclark.NoteHelpers/src/newNote.js +++ b/jgclark.NoteHelpers/src/newNote.js @@ -4,13 +4,18 @@ // Create new note from currently selected text // and (optionally) leave backlink to it where selection was // Note: this was originally in Filer plugin -// Last updated 2025-04-08 for 1.1.0+, @jgclark (originally @dwertheimer) +// Last updated 2025-07-14 for 1.2.0, @jgclark (originally @dwertheimer) //----------------------------------------------------------------------------- import pluginJson from '../plugin.json' +import { getActiveParagraphs, sortTasksDefault } from '../../dwertheimer.TaskSorting/src/sortTasks' +import { archiveNoteUsingFolder } from '../../jgclark.Filer/src/archive' import { addFrontmatterToNote, getSettings } from './noteHelpers' import { logDebug, logError, logWarn } from '@helpers/dev' +import { getFolderFromFilename } from '@helpers/folders' import { displayTitle } from '@helpers/general' +import { openNoteInNewSplitIfNeeded } from '@helpers/NPWindows' +import { isOpen } from '@helpers/utils' import { getUniqueNoteTitle, getNote } from '@helpers/note' import { chooseFolder, getInput, getInputTrimmed, showMessage, showMessageYesNo } from '@helpers/userInput' @@ -21,7 +26,7 @@ import { chooseFolder, getInput, getInputTrimmed, showMessage, showMessageYesNo export async function newNote(): Promise { try { // Get title for this note - const title = await getInputTrimmed('Title of new note', 'OK', 'New Note from Clipboard', '') + const title = await getInputTrimmed('Title of new note', 'OK', 'New Note', '') if (typeof title === 'string') { const currentFolder = await chooseFolder('Select folder to add note in:', false, true) // don't include @Archive as an option, but do allow creation of a new folder const content = `# ${title}\n` @@ -51,31 +56,43 @@ export async function newNote(): Promise { logWarn('newNote', 'The user cancelled the operation.') } } catch (err) { - logError(pluginJson, `newNote: ${err}`) + logError(pluginJson, `newNote: ${err.message}`) } } /** - * Create new note from the clipboard contents. + * Create a new note from the clipboard contents. * @author @jgclark */ export async function newNoteFromClipboard(): Promise { - const { string } = Clipboard + try { + const { string } = Clipboard + + if (string == null || string.length === 0) { + logWarn(pluginJson, 'newNoteFromClipboard: The clipboard was empty, so nothing to do.') + await showMessage("The clipboard was empty, so there's nothing to do.", "OK", `New Note from Clipboard`) + return + } - if (string != null && string.length > 0) { logDebug(pluginJson, `newNoteFromClipboard() starting: have ${string.length} characters in clipboard`) // Get title for this note // Offer the first line to use, shorn of any leading # marks, from either frontmatter's 'title:' or the first line of the text - // FIXME: - const firstLineMatches: Array = string.match(/^(?:---\n(?:title:\s*)?|^title:\s+(.*)\n|^[#\s]*)?(.*)\n/) ?? [] - const titleToOffer = firstLineMatches[1] ?? '' + const lines = string.split('\n') + const existingFMTitle = (string.match(/title:\s+(.*)/))?.[1] ?? '' + const strippedFirstLine = lines[0].replace(/^#+\s/, '') + const titleToOffer = existingFMTitle || strippedFirstLine || '' let title = await getInput('Title of new note', 'OK', 'New Note from Clipboard', titleToOffer) if (typeof title === 'string') { const uniqueTitle = getUniqueNoteTitle(title) if (title !== uniqueTitle) { - await showMessage(` Title exists. Using "${uniqueTitle}" instead`, `OK`, `New Note from Clipboard`) - title = uniqueTitle + const res = await showMessageYesNo(`That title already exists. Use "${uniqueTitle}" instead?`, ['Yes', 'Cancel'], `New Note from Clipboard`, false) + if (res === 'Yes') { + title = uniqueTitle + } else { + logWarn(pluginJson, `newNoteFromClipboard: User cancelled the operation.`) + return + } } const currentFolder = await chooseFolder('Select folder to add note in:', false, true) // don't include @Archive as an option, but do allow creation of a new folder const content = `# ${title}\n${string}` @@ -94,9 +111,8 @@ export async function newNoteFromClipboard(): Promise { } else { logWarn(pluginJson, 'The user cancelled the operation.') } - } else { - logWarn(pluginJson, 'The clipboard was empty, so nothing to do.') - showMessage("The clipboard was empty, so there's nothing to do.", "OK, I'll try again", `New Note from Clipboard`) + } catch (err) { + logError(pluginJson, `newNoteFromClipboard: ${err.message}`) } } @@ -105,79 +121,172 @@ export async function newNoteFromClipboard(): Promise { * @author @dwertheimer + @jgclark */ export async function newNoteFromSelection(): Promise { - const { selectedLinesText, selectedText, selectedParagraphs, note } = Editor - - if (note != null && selectedLinesText.length && selectedText !== '') { - logDebug(pluginJson, `newNoteFromSelection() starting with ${selectedParagraphs.length} selected:`) - const selectedLinesTextToMutate = selectedLinesText.slice() // copy that we can change - - // Get title for the new note - // First get frontmatter's 'title:' (if present) or the first line of the text (shorn of any leading # or spaces) - const isTextContent = ['title', 'text', 'separator'].indexOf(selectedParagraphs[0].type) >= 0 - const firstLineMatches: Array = selectedText?.match(/^(?:---\n(?:title:\s*)?|^title:\s+(.*)\n|^[#\s]*)?(.*)\n/) ?? [] - const titleToOffer = isTextContent && firstLineMatches[1] ? firstLineMatches[1] : '' - // const strippedFirstLine = selectedParagraphs[0].content - let title = await getInput('Title of new note', 'OK', 'New Note from Selection', titleToOffer) - if (typeof title === 'string') { + try { + const { selectedLinesText, selectedText, selectedParagraphs, note } = Editor + + if (note != null && selectedLinesText.length && selectedText !== '') { + logDebug(pluginJson, `newNoteFromSelection() starting with ${selectedParagraphs.length} selected:`) + const selectedLinesTextToMutate = selectedLinesText.slice() // copy that we can change + + // Get title for the new note + const strippedFirstLine = selectedParagraphs[0].content.replace(/^#+\s/, '') + let title = await getInput('Title of new note', 'OK', 'New Note from Selection', strippedFirstLine) + if (typeof title === 'string') { // If user selected the first line, then remove it from the body content - if (title === titleToOffer && selectedParagraphs[0].type === 'title') { - selectedLinesTextToMutate.shift() - } - const movedText = selectedLinesTextToMutate.join('\n') - const uniqueTitle = getUniqueNoteTitle(title) - if (title !== uniqueTitle) { - await showMessage(`Title exists. Using "${uniqueTitle}" instead`, `OK`, `New Note from Selection`) - title = uniqueTitle - } - const currentFolder = await chooseFolder('Select folder to add note in:', false, true) // don't include @Archive as an option, but do allow creation of a new folder + if (title === strippedFirstLine && selectedParagraphs[0].type === 'title') { + selectedLinesTextToMutate.shift() + } + const movedText = selectedLinesTextToMutate.join('\n') + const uniqueTitle = getUniqueNoteTitle(title) + if (title !== uniqueTitle) { + await showMessage(`Title exists. Using "${uniqueTitle}" instead`, `OK`, `New Note from Selection`) + title = uniqueTitle + } + const currentFolder = await chooseFolder('Select folder to add note in:', false, true) // don't include @Archive as an option, but do allow creation of a new folder - if (title) { - // Create new note in the specific folder - const origFile = displayTitle(note) // Calendar notes have no title, so need to make one - logDebug(pluginJson, `- origFile: ${origFile}`) - const filename = (await DataStore.newNote(title, currentFolder)) ?? '' - logDebug(pluginJson, `- newNote() -> filename: ${filename}`) - - // This question needs to be here after newNote and before getNote to force a cache refresh after newNote. - // Note: This API bug has probably now been fixed. - const res = await CommandBar.showOptions(['Yes', 'No'], 'Insert link to new file where selection was?') - - const newNote = await getNote(filename, true) - - if (newNote) { - logDebug(pluginJson, `- newNote's title: ${String(newNote.title)}`) - logDebug(pluginJson, `- newNote's content: ${String(newNote.content)} ...`) - const insertBackLink = res.index === 0 - // $FlowFixMe[method-unbinding] - Flow thinks the function is being removed from the object, but it's not - if (Editor.replaceSelectionWithText) { - // for compatibility, make sure the function exists - if (insertBackLink) { - Editor.replaceSelectionWithText(`[[${title}]]`) - } else { - Editor.replaceSelectionWithText(``) + if (title) { + // Create new note in the specific folder + const origFile = displayTitle(note) // Calendar notes have no title, so need to make one + logDebug(pluginJson, `- origFile: ${origFile}`) + const filename = (await DataStore.newNote(title, currentFolder)) ?? '' + logDebug(pluginJson, `- newNote() -> filename: ${filename}`) + + // This question needs to be here after newNote and before getNote to force a cache refresh after newNote. + // Note: This API bug has probably now been fixed. + const res = await CommandBar.showOptions(['Yes', 'No'], 'Insert link to new file where selection was?') + + const newNote = await getNote(filename, true) + + if (newNote) { + logDebug(pluginJson, `- newNote's title: ${String(newNote.title)}`) + logDebug(pluginJson, `- newNote's content: ${String(newNote.content)} ...`) + const insertBackLink = res.index === 0 + // $FlowFixMe[method-unbinding] - Flow thinks the function is being removed from the object, but it's not + if (Editor.replaceSelectionWithText) { + // for compatibility, make sure the function exists + if (insertBackLink) { + Editor.replaceSelectionWithText(`[[${title}]]`) + } else { + Editor.replaceSelectionWithText(``) + } } - } - newNote.appendParagraph(movedText, 'empty') - if (insertBackLink) { - newNote.appendParagraph(`^ Moved from [[${origFile}]]:`, 'text') - } - const res2 = await showMessageYesNo('New Note created. Open it now?', ['Yes', 'No'], `New Note from Selection`) - if (res2 === 'Yes') { - await Editor.openNoteByFilename(filename) + newNote.appendParagraph(movedText, 'empty') + if (insertBackLink) { + newNote.appendParagraph(`^ Moved from [[${origFile}]]:`, 'text') + } + const res2 = await showMessageYesNo('New Note created. Open it now?', ['Yes', 'No'], `New Note from Selection`) + if (res2 === 'Yes') { + await Editor.openNoteByFilename(filename) + } + } else { + logWarn(pluginJson, `Couldn't open new note: ${filename}`) + showMessage(`Could not open new note ${filename}`, `OK`, `New Note from Selection`) } } else { - logWarn(pluginJson, `Couldn't open new note: ${filename}`) - showMessage(`Could not open new note ${filename}`, `OK`, `New Note from Selection`) + logError(pluginJson, 'Undefined or empty title') } } else { - logError(pluginJson, 'Undefined or empty title') + logWarn(pluginJson, 'The user cancelled the operation.') } } else { - logWarn(pluginJson, 'The user cancelled the operation.') + logDebug(pluginJson, '- No text was selected, so nothing to do.') + showMessage('No text was selected, so nothing to do.', "OK, I'll try again", `New Note from Selection`) } - } else { - logDebug(pluginJson, '- No text was selected, so nothing to do.') - showMessage('No text was selected, so nothing to do.', "OK, I'll try again", `New Note from Selection`) + } catch (err) { + logError(pluginJson, `newNoteFromSelection: ${err.message}`) } } + +/** + * Create a new note from the current note, with a title that includes the current date. + * TODO: be smarter about guessing the period + * @author @jgclark + */ +export async function smartDuplicateRegularNote(): Promise { + try { + // TODO: be smarter about guessing the period + let notePeriod = 'yearly' + const sourceNote = Editor.note ?? null + + if (!sourceNote) { + // No note open, so don't do anything. + logWarn(pluginJson, 'archiveNoteUsingFolder(): No note passed or open in the Editor, so stopping.') + return + } + logDebug(pluginJson, `smartDuplicateRegularNote() starting from note '${displayTitle(Editor.note)}'`) + + // Get title for this note + // Offer the first line to use, shorn of any leading # marks + const sourceNoteTitle = sourceNote.title ?? 'error' + let titleToOffer = sourceNoteTitle + // Remove any "YYYY" or "Qn" or "Hn" from the title, with or without brackets + titleToOffer = titleToOffer.replace(/\(\d{4}\)/g, '').replace(/\s+\d{4}/g, '').replace(/\([QH]\d\)/g, '').replace(/\s+[QH]\d/g, '') + // try to work out new title + let title = await getInput(`Title of new ${notePeriod} period note`, 'OK', 'New Periodic Note', titleToOffer) + if (typeof title === 'boolean' && title === false) { + logWarn('smartDuplicateRegularNote', 'The user cancelled the operation.') + return + } + const uniqueTitle = getUniqueNoteTitle(sourceNoteTitle) + if (title !== uniqueTitle) { + await showMessage(`Title exists. Using "${uniqueTitle}" instead`, `OK`, `New Periodic Note`) + title = uniqueTitle + } + + // Work out the contents of the active part of the note + const activeParas = getActiveParagraphs(sourceNote) + // Keep all lines that aren't open tasks/checklists + let parasToKeep = activeParas.filter(para => !isOpen(para)) + + // Do some further clean up of the paragraphs + let lastContent = '' + let lastType = 'title' + for (let i = 1; i < parasToKeep.length; i++) { + lastContent = parasToKeep[i - 1].content + logDebug('smartDuplicateRegularNote', `#${i}: {${lastContent}}`) + lastType = parasToKeep[i - 1].type + // Remove consecutive separators or empty lines + if ((parasToKeep[i].type === 'empty' && lastType === 'empty') || (parasToKeep[i].type === 'separator') && (lastType === 'separator')) { + logDebug('smartDuplicateRegularNote', `- removing consecutive empty/separator line`) + parasToKeep.splice(i, 1) + continue + } + // Remove any blank lines after headings (if wanted) + if (lastType === 'title' && parasToKeep[i].type === 'empty') { + logDebug('smartDuplicateRegularNote', `- removing blank line after heading`) + parasToKeep.splice(i, 1) + continue + } + } + + // Save contents to new note in the same folder + // const currentFolder = await chooseFolder('Select folder to add note in:', false, true) // don't include @Archive as an option, but do allow creation of a new folder + const currentFolder = getFolderFromFilename(sourceNote.filename) + const content = parasToKeep.join('\n') + const newFilename = (await DataStore.newNoteWithContent(content, currentFolder)) ?? '' + logDebug('smartDuplicateRegularNote', ` -> filename: ${newFilename}`) + + // Open the new note + const res: ?TNote = await Editor.openNoteByFilename(newFilename) + + // Sort remaining tasks according to user's defaults. + // Note: this uses @dwertheimer's sortTasksDefault() = /std, which only works on the current Editor, so has to come here. + // TODO: see if we can get it to work on passed paragraphs instead, to avoid the step above. + await sortTasksDefault() + + // Offer to archive the source note + // TODO: Allow a different archive root folder, as in Reviews. + if (await showMessageYesNo('Archive the source note?', ['Yes', 'No'], `New Periodic Note`) === 'Yes') { + const res = archiveNoteUsingFolder(sourceNote) + } + + // Offer to open the source note in a new split + if (await showMessageYesNo('Open the source note in a new split?', ['Yes', 'No'], `New Periodic Note`) === 'Yes') { + const res = openNoteInNewSplitIfNeeded(sourceNote.filename) + } + + } catch (err) { + logError(pluginJson, `smartDuplicateRegularNote: ${err.message}`) + } +} \ No newline at end of file diff --git a/jgclark.Reviews/src/projectClass.js b/jgclark.Reviews/src/projectClass.js index 6cebc620a..eccde175f 100644 --- a/jgclark.Reviews/src/projectClass.js +++ b/jgclark.Reviews/src/projectClass.js @@ -34,8 +34,8 @@ import { redToGreenInterpolation, } from '@helpers/HTMLView' import { removeAllDueDates } from '@helpers/NPParagraph' -import { findStartOfActivePartOfNote, simplifyRawContent } from '@helpers/paragraph' -import { getLineMainContentPos } from '@helpers/search' +import { findStartOfActivePartOfNote } from '@helpers/paragraph' +import { getLineMainContentPos, simplifyRawContent } from '@helpers/search' import { encodeRFC3986URIComponent } from '@helpers/stringTransforms' import { getInputTrimmed,