From 166eeba4c08fbec325d89c9fc5a75c070f49d6a2 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Mon, 18 Aug 2025 12:21:05 -0700 Subject: [PATCH 1/5] wip working code. waiting for @EduardMe to give some APIs --- np.CallbackURLs/CHANGELOG.md | 6 +++++ np.CallbackURLs/plugin.json | 3 ++- np.CallbackURLs/src/NPTemplateRunner.js | 24 +++++++++++++----- np.CallbackURLs/src/NPXCallbackWizard.js | 32 ++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/np.CallbackURLs/CHANGELOG.md b/np.CallbackURLs/CHANGELOG.md index 8de72a41a..bafd5379b 100644 --- a/np.CallbackURLs/CHANGELOG.md +++ b/np.CallbackURLs/CHANGELOG.md @@ -4,6 +4,12 @@ See Plugin [README](https://github.com/NotePlan/plugins/blob/main/np.CallbackURLs/README.md) for details on available commands and use cases. +## [1.9.0] - 2025-08-18 @dwertheimer + +- Fix template runner wizard bug +- Add link to run template to frontmatter of template note +- Add open a named folder view to wizard + ## [1.8.0] - 2025-06-09 @dwertheimer - Added templating specific commands to wizard to reduce confusion diff --git a/np.CallbackURLs/plugin.json b/np.CallbackURLs/plugin.json index d36c11882..0d3e6c8b9 100644 --- a/np.CallbackURLs/plugin.json +++ b/np.CallbackURLs/plugin.json @@ -1,7 +1,8 @@ { "COMMENT": "Details on these fields: https://help.noteplan.co/article/67-create-command-bar-plugins", "macOS.minVersion": "10.13.0", - "noteplan.minAppVersion": "3.4.0", + "noteplan.minAppVersion": "3.18.1", + "noteplan.minAppVersion-NOTE": "Includes folder view picker", "plugin.id": "np.CallbackURLs", "plugin.name": "🔗 Link Creator", "plugin.version": "1.8.0", diff --git a/np.CallbackURLs/src/NPTemplateRunner.js b/np.CallbackURLs/src/NPTemplateRunner.js index 6da45a89d..a28290449 100644 --- a/np.CallbackURLs/src/NPTemplateRunner.js +++ b/np.CallbackURLs/src/NPTemplateRunner.js @@ -4,7 +4,7 @@ import pluginJson from '../plugin.json' import { chooseOption, showMessage, showMessageYesNo, getInputTrimmed, chooseNote } from '../../helpers/userInput' import { log, logError, logDebug, timer, clo, JSP } from '@helpers/dev' import NPTemplating from 'NPTemplating' -import { getAttributes } from '@helpers/NPFrontMatter' +import { getAttributes, updateFrontMatterVars } from '@helpers/NPFrontMatter' import { createRunPluginCallbackUrl } from '@helpers/general' // getNoteTitled, location, writeUnderHeading, replaceNoteContents @@ -117,7 +117,7 @@ async function createNewTemplate(): Promise { async function getSelfRunningTemplate(): Promise { let filename, templateTitle if (Editor?.filename?.includes('@Templates')) { - const useThis = await showMessageYesNo(`Use the currently open template?\n(${Editor?.title || ''})`, ['yes', 'no'], 'Use Open Template') + const useThis = await showMessageYesNo(`Create a run link for the currently open template?\n(title: "${Editor?.title || ''}")`, ['yes', 'no'], 'Use This Template?') if (useThis === 'yes') { filename = Editor.filename templateTitle = Editor.note?.title @@ -131,7 +131,8 @@ async function getSelfRunningTemplate(): Promise { const selectedTemplate = await NPTemplating.chooseTemplate() if (selectedTemplate) { const template = await DataStore.noteByFilename(selectedTemplate, 'Notes') - templateTitle = template?.title || null + templateTitle = template?.title || '' + filename = template?.filename || '' } } } @@ -164,9 +165,20 @@ export async function getXcallbackForTemplate(): Promise { args = args.concat(String(result)) } const url = createRunPluginCallbackUrl(`np.Templating`, `templateRunner`, args) - const note = DataStore.projectNoteByTitle(templateTitle) - if (note?.length) note[0].content = note[0].content?.replace('exampleURL: __XXX__', `exampleURL: ${url}`) - //FIXME: I am here. this works, but the URL gets pasted above metadata + const notes = DataStore.projectNoteByTitle(templateTitle) + const note = notes?.length ? notes[0] : null + if (note) { + note.content = note.content?.replace('exampleURL: __XXX__', `exampleURL: ${url}`) + const addLinkToFM = await showMessageYesNo( + `Add the link to run the template to the frontmatter of the template? (the link will be copied to the clipboard regardless)`, + ['yes', 'no'], + 'Add Link to Run Template', + ) + if (addLinkToFM === 'yes') { + // create a key:value array of the properties of the note.frontmatterAttributes object + updateFrontMatterVars(note, { 'Run This Template': url }) + } + } return url } else { await showMessage(`Template could not be located`) diff --git a/np.CallbackURLs/src/NPXCallbackWizard.js b/np.CallbackURLs/src/NPXCallbackWizard.js index 79c92a8fb..953faa254 100644 --- a/np.CallbackURLs/src/NPXCallbackWizard.js +++ b/np.CallbackURLs/src/NPXCallbackWizard.js @@ -286,6 +286,25 @@ export async function lineLink(): Promise { return '' } +/** + * Create a callback URL for opening a view + * @returns {string} the URL or empty string if user canceled + */ +export async function openView(): Promise { + const name = await getInput('Enter the name of the view', 'OK', 'Saved Folder View', '') + if (name === false || !name) return '' + + const folder = await chooseFolder('Choose a folder (optional but recommended)', false, false, '', true) + if (!folder) return '' + + let params = `?name=${encodeURIComponent(String(name))}` + if (folder) { + params += `&folder=${encodeURIComponent(folder)}` + } + + return `noteplan://x-callback-url/openView${params}` +} + // Plugin command entry point for creating a heading link export async function headingLink() { await xCallbackWizard(`headingLink`) @@ -298,7 +317,7 @@ export async function headingLink() { */ export async function xCallbackWizard(_commandType: ?string = '', passBackResults?: boolean = false): Promise { try { - let url = '', + let url: string | false = '', canceled = false let commandType if (_commandType) { @@ -309,6 +328,7 @@ export async function xCallbackWizard(_commandType: ?string = '', passBackResult { label: 'OPEN a note or folder', value: 'openNote' }, { label: 'NEW NOTE with title and text', value: 'addNote' }, { label: 'ADD text to a note', value: 'addText' }, + { label: 'OPEN a Named Folder View', value: 'openView' }, { label: 'FILTER Notes by Preset', value: 'filter' }, { label: 'SEARCH for text in notes', value: 'search' }, { label: 'Get NOTE INFO (x-success) for use in another app', value: 'noteInfo' }, @@ -360,6 +380,7 @@ export async function xCallbackWizard(_commandType: ?string = '', passBackResult } else { return } + break case 'runTemplate': url = await getXcallbackForTemplate() break @@ -374,13 +395,20 @@ export async function xCallbackWizard(_commandType: ?string = '', passBackResult return } break + case 'openView': + url = await openView() + if (!url) { + showMessage(`No view name or folder selected. Please try again.`, 'OK', 'No View Selected') + return + } + break default: showMessage(`${commandType}: This type is not yet available in this plugin`, 'OK', 'Sorry!') break } if (url === false) canceled = true // user hit cancel on one of the input prompts - if (!canceled && typeof url === 'string') { + if (!canceled && typeof url === 'string' && url) { if (passBackResults) return url if (commandType === 'headingLink') { return url // copied to clipboard already From 8d61a129bed374105893074c47e4d72652c8c4d2 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Mon, 18 Aug 2025 21:02:03 -0700 Subject: [PATCH 2/5] Basic folder views with formatting --- helpers/__tests__/folders.test.js | 92 ++++++++ helpers/folders.js | 258 ++++++++++++++++++++--- helpers/userInput.js | 93 ++++---- np.CallbackURLs/src/NPXCallbackWizard.js | 127 ++++++++++- 4 files changed, 483 insertions(+), 87 deletions(-) diff --git a/helpers/__tests__/folders.test.js b/helpers/__tests__/folders.test.js index 265003ac7..bbf8e6e75 100644 --- a/helpers/__tests__/folders.test.js +++ b/helpers/__tests__/folders.test.js @@ -540,3 +540,95 @@ describe('helpers/folders', () => { }) }) }) + +describe('Folder View Helper Functions', () => { + const mockFolderYaml = { + views: [ + '{"dataLevel":"tasks","folderPath":"@Searches","group_by":"note path","group_sort":"ASC","layout":"cards","name":"Search Cards","sort":{"direction":"ASC","field":"line number"}}', + '{"dataLevel":"tasks","folderPath":"@Searches","group_by":"note path","group_sort":"ASC","isSelected":true,"layout":"list","name":"Search List","sort":{"direction":"ASC","field":"line number"}}', + '{"dataLevel":"notes","fields":["date"],"folderPath":"@Saved Searches","group_by":"note path","group_sort":"ASC","isSelected":true,"layout":"list","name":"View","sort":{"direction":"ASC","field":"line number"}}', + '{"dataLevel":"notes","fields":["dateEdited"],"fixedGroups":{},"folderPath":"CTI","group_by":"folder","group_sort":"ASC","isSelected":true,"layout":"cards","name":"View"}', + '{"dataLevel":"notes","fields":["dateEdited"],"folderPath":"Work","group_by":"folder","group_sort":"ASC","isSelected":true,"layout":"list","name":"Work View"}', + '{"dataLevel":"notes","fields":["dateEdited"],"folderPath":"Daily","group_by":"folder","group_sort":"ASC","isSelected":true,"layout":"list","name":"Daily View"}' + ] + } + + describe('organizeFolderViews', () => { + it('should organize folder views correctly', () => { + const result = f.organizeFolderViews(mockFolderYaml) + + expect(result).toHaveProperty('@Searches') + expect(result).toHaveProperty('Work') + expect(result).toHaveProperty('Daily') + + // Should not include folders with only default "View" names + expect(result).not.toHaveProperty('@Saved Searches') + expect(result).not.toHaveProperty('CTI') + + // Check that named views are properly parsed + expect(result['@Searches']).toHaveLength(2) + expect(result['@Searches'][0].name).toBe('Search Cards') + expect(result['@Searches'][1].name).toBe('Search List') + + expect(result['Work']).toHaveLength(1) + expect(result['Work'][0].name).toBe('Work View') + }) + }) + + describe('getFoldersWithNamedViews', () => { + it('should return list of folders with named views', () => { + const result = f.getFoldersWithNamedViews(mockFolderYaml) + + expect(result).toContain('@Searches') + expect(result).toContain('Work') + expect(result).toContain('Daily') + expect(result).not.toContain('@Saved Searches') + expect(result).not.toContain('CTI') + }) + }) + + describe('getNamedViewsForFolder', () => { + it('should return named views for a specific folder', () => { + const result = f.getNamedViewsForFolder(mockFolderYaml, '@Searches') + + expect(result).toHaveLength(2) + expect(result[0].name).toBe('Search Cards') + expect(result[0].layout).toBe('cards') + expect(result[1].name).toBe('Search List') + expect(result[1].layout).toBe('list') + }) + + it('should return empty array for folder with no named views', () => { + const result = f.getNamedViewsForFolder(mockFolderYaml, 'CTI') + expect(result).toHaveLength(0) + }) + }) + + describe('getNamedView', () => { + it('should return specific named view', () => { + const result = f.getNamedView(mockFolderYaml, '@Searches', 'Search Cards') + + expect(result).toBeTruthy() + expect(result.name).toBe('Search Cards') + expect(result.layout).toBe('cards') + expect(result.dataLevel).toBe('tasks') + }) + + it('should return null for non-existent view', () => { + const result = f.getNamedView(mockFolderYaml, '@Searches', 'Non Existent') + expect(result).toBeNull() + }) + }) + + describe('getNamedViewsByDataLevel', () => { + it('should organize views by data level', () => { + const result = f.getNamedViewsByDataLevel(mockFolderYaml) + + expect(result).toHaveProperty('tasks') + expect(result).toHaveProperty('notes') + + expect(result.tasks).toHaveLength(2) // Search Cards and Search List + expect(result.notes).toHaveLength(2) // Work View and Daily View + }) + }) +}) diff --git a/helpers/folders.js b/helpers/folders.js index 575288904..ae63f28d2 100644 --- a/helpers/folders.js +++ b/helpers/folders.js @@ -4,12 +4,12 @@ // Really should be called 'NPFolders' as it relies on DataStore.folders. //------------------------------------------------------------------------------- +import yaml from 'yaml' import { logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { caseInsensitiveMatch, caseInsensitiveStartsWith, caseInsensitiveSubstringMatch } from '@helpers/search' import { TEAMSPACE_INDICATOR } from '@helpers/regex' import { getAllTeamspaceIDsAndTitles, getTeamspaceTitleFromID } from '@helpers/NPTeamspace' -import { getFilenameWithoutTeamspaceID,getTeamspaceIDFromFilename, isTeamspaceNoteFromFilename } from '@helpers/teamspace' - +import { getFilenameWithoutTeamspaceID, getTeamspaceIDFromFilename, isTeamspaceNoteFromFilename } from '@helpers/teamspace' /** * Return a list of folders (and any sub-folders) that contain one of the strings on the inclusions list (if given). @@ -24,7 +24,7 @@ import { getFilenameWithoutTeamspaceID,getTeamspaceIDFromFilename, isTeamspaceNo * TEST: Teamspace root folders included from b1417 * @author @jgclark * @tests in jest file - * + * * @param {Array} inclusions - if not empty, use these (sub)strings to match folder items * @param {boolean?} excludeSpecialFolders? (default: true) * @param {Array?} exclusions - if these (sub)strings match then exclude this folder. Optional: if none given then will treat as an empty list. @@ -40,7 +40,7 @@ export function getFoldersMatching(inclusions: Array, excludeSpecialFold const teamspaceDefs = getAllTeamspaceIDsAndTitles() teamspaceDefs.forEach((teamspaceDef) => { // Next line avoids auto-formatting errors - // eslint-disable-next-line + // eslint-disable-next-line fullFolderList.splice(1, 0, TEAMSPACE_INDICATOR + '/' + teamspaceDef.id + '/') logDebug('folders / getFoldersMatching', `- adding root for teamspaceDef.id ${teamspaceDef.id}(${teamspaceDef.title}) to work around bug pre v3.18.0`) }) @@ -49,7 +49,8 @@ export function getFoldersMatching(inclusions: Array, excludeSpecialFold logDebug( 'getFoldersMatching', `Starting to filter the ${fullFolderList.length} DataStore.folders with inclusions: [${inclusions.toString()}] and exclusions [${exclusions.toString()}]. ESF? ${String( - excludeSpecialFolders)}`, + excludeSpecialFolders, + )}`, ) // logDebug('folders / getFoldersMatching', `fullFolderList: [${fullFolderList.toString()}]`) @@ -66,7 +67,7 @@ export function getFoldersMatching(inclusions: Array, excludeSpecialFold reducedFolderList.splice(reducedFolderList.indexOf('/'), 1) // To aid partial matching, terminate all folder strings with a trailing /. - let reducedTerminatedWithSlash: Array = reducedFolderList.map((f) => f.endsWith('/') ? f : `${f}/`) + let reducedTerminatedWithSlash: Array = reducedFolderList.map((f) => (f.endsWith('/') ? f : `${f}/`)) // logDebug('folders / getFoldersMatching', `- after termination -> ${reducedTerminatedWithSlash.length} reducedTWS:[${reducedTerminatedWithSlash.toString()}]`) // const rootIncluded = true // inclusions.some((f) => f === '/') @@ -88,10 +89,7 @@ export function getFoldersMatching(inclusions: Array, excludeSpecialFold // filter reducedTerminatedWithSlash to exclude items in the exclusions list (if non-empty). Note: now case insensitive. // Note: technically this fails if the exclusion is any part of '%%NotePlanCloud%%/' but that's unlikely. if (exclusionsWithoutRoot.length > 0) { - reducedTerminatedWithSlash = reducedTerminatedWithSlash - .filter((folder) => !exclusionsWithoutRoot - .some((f) => caseInsensitiveSubstringMatch(f, folder) - )) + reducedTerminatedWithSlash = reducedTerminatedWithSlash.filter((folder) => !exclusionsWithoutRoot.some((f) => caseInsensitiveSubstringMatch(f, folder))) // logDebug('folders / getFoldersMatching',`- after exclusions -> ${reducedTerminatedWithSlash.length} reducedTWS: ${reducedTerminatedWithSlash.toString()}\n`) } @@ -121,7 +119,7 @@ export function getFoldersMatching(inclusions: Array, excludeSpecialFold /** * Return a list of subfolders of a given folder. Includes the given folder itself. * @tests in jest file. - * + * * @author @jgclark * @param {string} folderpath - e.g. "some/folder". Leading or trailing '/' will be removed. * @returns {Array} array of subfolder names @@ -153,7 +151,7 @@ export function getSubFolders(parentFolderPathArg: string): Array { * - always exclude '@Trash' folder (as the API doesn't return it). * @author @jgclark * @tests in jest file - * + * * @param {Array} exclusions - if these (sub)strings match then exclude this folder -- can be empty * @param {boolean?} excludeSpecialFolders? (default: true) * @param {boolean?} forceExcludeRootFolder? (default: false) @@ -170,7 +168,10 @@ export function getFolderListMinusExclusions( // Get all folders as array of strings (other than @Trash). Also remove root as a special case const fullFolderList = DataStore.folders let excludeRoot = forceExcludeRootFolder - logDebug('folders / getFolderListMinusExclusions', `Starting to filter the ${fullFolderList.length} DataStore.folders with exclusions [${exclusions.toString()}] and forceExcludeRootFolder ${String(forceExcludeRootFolder)}`) + logDebug( + 'folders / getFolderListMinusExclusions', + `Starting to filter the ${fullFolderList.length} DataStore.folders with exclusions [${exclusions.toString()}] and forceExcludeRootFolder ${String(forceExcludeRootFolder)}`, + ) // if excludeSpecialFolders, filter fullFolderList to only folders that don't start with the character '@' (special folders) let reducedFolderList = fullFolderList @@ -226,7 +227,7 @@ export function getFolderListMinusExclusions( * Note: for Teamspace notes, this returns the Teamspace indicator + teamspace ID + folder path. See getFolderDisplayName() for a more useful display name. * @author @jgclark * @tests in jest file - * + * * @param {string} fullFilename - full filename to get folder name part from * @returns {string} folder/subfolder name */ @@ -253,10 +254,10 @@ export function getFolderFromFilename(fullFilename: string): string { /** * Get a useful folder name from the folder path, without leading or trailing slash, and with Teamspace name if applicable. - * Note: Not needed before Teamspaces. + * Note: Not needed before Teamspaces. * @author @jgclark * @tests in jest file - * + * * @param {string} folderPath - as returned by DataStore.folders. Note: not full filename. * @param {boolean?} includeTeamspaceEmoji? (default true) include a Teamspace emoji in the display name * @returns {string} folder name for display (including Teamspace name if applicable) @@ -268,7 +269,7 @@ export function getFolderDisplayName(folderPath: string, includeTeamspaceEmoji: throw new Error(`Empty folderPath given. Returning '(error)'.`) } // logDebug('folders/getFolderDisplayName', `folderPath: ${folderPath}`) - + if (isTeamspaceNoteFromFilename(folderPath)) { const teamspaceID = getTeamspaceIDFromFilename(folderPath) // logDebug('folders/getFolderDisplayName', `teamspaceID: ${teamspaceID}`) @@ -294,7 +295,7 @@ export function getFolderDisplayName(folderPath: string, includeTeamspaceEmoji: * Note: does not handle hidden files (starting with a dot, e.g. '.gitignore'). * @author @jgclark * @tests in jest file - * + * * @param {string} fullFilename - full filename to get folder name part from * @param {boolean} removeExtension? (default: false) * @returns {string} folder/subfolder name @@ -319,7 +320,7 @@ export function getJustFilenameFromFullFilename(fullFilename: string, removeExte * Get the lowest-level (subfolder) part of the folder name from the full NP (project) note filename, without leading or trailing slash. * @author @jgclark * @tests available in jest file - * + * * @param {string} fullFilename - full filename to get folder name part from * @returns {string} subfolder name */ @@ -422,11 +423,7 @@ export function notesInFolderSortedByTitle(folder: string, alsoSubFolders: boole * @param {boolean?} ignoreSpecialFolders (default true) ignore folders whose folder path starts with '@' (e.g. @Templates) * @returns {$ReadOnlyArray} array of notes in the folder */ -export function getRegularNotesInFolder( - forFolder: string = '', - ignoreSpecialFolders: boolean = true, - foldersToIgnore: Array = [], -): $ReadOnlyArray { +export function getRegularNotesInFolder(forFolder: string = '', ignoreSpecialFolders: boolean = true, foldersToIgnore: Array = []): $ReadOnlyArray { const notes: $ReadOnlyArray = DataStore.projectNotes let filteredNotes: Array = [] if (forFolder === '') { @@ -545,3 +542,216 @@ export function doesFilenameExistInFolderWithDifferentCase(filepath: string): bo // logDebug(`doesFilenameExistInFolderWithDifferentCase`, `different case version of "${filename}" does NOT exist`) return false } + +/** + * Read and parse the folder view data from NotePlan's data store + * @returns {Object|null} Parsed folder view data or null if not available + */ +export function getFolderViewData(): Object | null { + try { + // Load the folder views data from the standard location + const folderData = DataStore.loadData('../../../Filters/folders.views', true) + if (!folderData) { + logWarn('folders/getFolderViewData', 'No folder views data found at standard location') + return null + } + + // Check if the data is already parsed (object) or needs parsing (string) + if (typeof folderData === 'object' && folderData !== null) { + // Data is already parsed, return it directly + logDebug('folders/getFolderViewData', 'Data already parsed, returning directly') + return folderData + } else if (typeof folderData === 'string') { + // Data is a string, parse it as YAML + logDebug('folders/getFolderViewData', 'Data is string, parsing as YAML') + const parsedData = yaml.parse(folderData) + + if (!parsedData || typeof parsedData !== 'object') { + logWarn('folders/getFolderViewData', 'Failed to parse folder views YAML data') + return null + } + + return parsedData + } else { + logWarn('folders/getFolderViewData', `Unexpected data type: ${typeof folderData}`) + return null + } + } catch (error) { + logError('folders/getFolderViewData', `Error reading folder view data: ${error.message}`) + return null + } +} + +/** + * Parse and organize folder views from the folder YAML data + * @param {Object} folderYaml - The raw folder YAML data from DataStore + * @returns {Object} Object with folder paths as keys and arrays of named views as values + */ +export function organizeFolderViews(folderYaml: Object): Object { + try { + const folderViews = {} + + if (!folderYaml.views || !Array.isArray(folderYaml.views)) { + logWarn('folders/organizeFolderViews', 'No views array found in folder YAML data') + return folderViews + } + + folderYaml.views.forEach((viewItem, index) => { + try { + let view + + // Handle different data formats - the view might be a string or already an object + if (typeof viewItem === 'string') { + // It's a JSON string, parse it + view = JSON.parse(viewItem) + } else if (typeof viewItem === 'object' && viewItem !== null) { + // It's already an object, use it directly + view = viewItem + } else { + logWarn('folders/organizeFolderViews', `Unexpected view item type at index ${index}: ${typeof viewItem}`) + return + } + + const folderPath = view.folderPath + const viewName = view.name + + // Skip default views (name === "View") + if (viewName === 'View') return + + // Initialize folder if it doesn't exist + if (!folderViews[folderPath]) { + folderViews[folderPath] = [] + } + + // Add the named view to the folder + folderViews[folderPath].push({ + name: viewName, + dataLevel: view.dataLevel, + layout: view.layout, + folderPath: view.folderPath, + group_by: view.group_by, + group_sort: view.group_sort, + sort: view.sort, + fields: view.fields, + fixedGroups: view.fixedGroups, + isSelected: view.isSelected, + // Include the original parsed view object for any other properties + original: view, + }) + } catch (error) { + logWarn('folders/organizeFolderViews', `Error parsing view at index ${index}: ${error.message}`) + // Log the actual content for debugging + logDebug('folders/organizeFolderViews', `View item at index ${index}: ${JSON.stringify(viewItem)}`) + } + }) + + logDebug('folders/organizeFolderViews', `Organized ${Object.keys(folderViews).length} folders with named views`) + return folderViews + } catch (error) { + logError('folders/organizeFolderViews', `Error organizing folder views: ${error.message}`) + return {} + } +} + +/** + * Get a list of folders that have named views (excluding default "View" entries) + * @param {Object} folderYaml - The raw folder YAML data from DataStore + * @returns {Array} Array of folder paths that have named views + */ +export function getFoldersWithNamedViews(folderYaml: Object): Array { + try { + const organizedViews = organizeFolderViews(folderYaml) + return Object.keys(organizedViews).sort() + } catch (error) { + logError('folders/getFoldersWithNamedViews', `Error getting folders with named views: ${error.message}`) + return [] + } +} + +/** + * Get all named views for a specific folder + * @param {Object} folderYaml - The raw folder YAML data from DataStore + * @param {string} folderPath - The folder path to get views for + * @returns {Array} Array of named view objects for the specified folder + */ +export function getNamedViewsForFolder(folderYaml: Object, folderPath: string): Array { + try { + const organizedViews = organizeFolderViews(folderYaml) + return organizedViews[folderPath] || [] + } catch (error) { + logError('folders/getNamedViewsForFolder', `Error getting named views for folder '${folderPath}': ${error.message}`) + return [] + } +} + +/** + * Get a specific named view by folder and view name + * @param {Object} folderYaml - The raw folder YAML data from DataStore + * @param {string} folderPath - The folder path + * @param {string} viewName - The name of the view to find + * @returns {Object|null} The named view object or null if not found + */ +export function getNamedView(folderYaml: Object, folderPath: string, viewName: string): Object | null { + try { + const folderViews = getNamedViewsForFolder(folderYaml, folderPath) + return folderViews.find((view) => view.name === viewName) || null + } catch (error) { + logError('folders/getNamedView', `Error getting named view '${viewName}' for folder '${folderPath}': ${error.message}`) + return null + } +} + +/** + * Get all named views across all folders, organized by data level + * @param {Object} folderYaml - The raw folder YAML data from DataStore + * @returns {Object} Object with dataLevel as keys and arrays of views as values + */ +export function getNamedViewsByDataLevel(folderYaml: Object): Object { + try { + const organizedViews = organizeFolderViews(folderYaml) + const viewsByLevel = {} + + Object.values(organizedViews) + .flat() + .forEach((view) => { + const level = view.dataLevel || 'unknown' + if (!viewsByLevel[level]) { + viewsByLevel[level] = [] + } + viewsByLevel[level].push(view) + }) + + return viewsByLevel + } catch (error) { + logError('folders/getNamedViewsByDataLevel', `Error organizing views by data level: ${error.message}`) + return {} + } +} + +/** + * Example function demonstrating how to use the folder view helper functions + * This function shows how to get all named views and display them in a user-friendly way + * @returns {Object} Summary of all folders and their named views + */ +export function getFolderViewsSummary(): Object { + try { + const folderYaml = getFolderViewData() + if (!folderYaml) { + return { error: 'No folder YAML data available' } + } + + const organizedViews = organizeFolderViews(folderYaml) + const summary = { + totalFolders: Object.keys(organizedViews).length, + totalNamedViews: Object.values(organizedViews).reduce((sum, views) => sum + views.length, 0), + folders: organizedViews, + viewsByLevel: getNamedViewsByDataLevel(folderYaml), + } + + logDebug('folders/getFolderViewsSummary', `Found ${summary.totalFolders} folders with ${summary.totalNamedViews} named views`) + return summary + } catch (error) { + logError('folders/getFolderViewsSummary', `Error getting folder views summary: ${error.message}`) + return { error: error.message } + } +} diff --git a/helpers/userInput.js b/helpers/userInput.js index 252a452ad..f62316560 100644 --- a/helpers/userInput.js +++ b/helpers/userInput.js @@ -9,9 +9,7 @@ import json5 from 'json5' import moment from 'moment/min/moment-with-locales' import { getDateStringFromCalendarFilename, RE_DATE, RE_DATE_INTERVAL } from './dateTime' import { clo, logDebug, logError, logInfo, logWarn, JSP } from './dev' -import { - getFoldersMatching, getFolderDisplayName, getFolderFromFilename -} from './folders' +import { getFoldersMatching, getFolderDisplayName, getFolderFromFilename } from './folders' import { getRelativeDates } from './NPdateTime' import { getAllTeamspaceIDsAndTitles, getTeamspaceTitleFromID } from './NPTeamspace' import { calendarNotesSortedByChanged } from './note' @@ -140,16 +138,16 @@ export async function chooseOptionWithModifiers( export async function chooseOptionWithModifiersV2( message: string, options: Array, - additionalCreateNewOption?: TCommandBarOptionObject + additionalCreateNewOption?: TCommandBarOptionObject, ): Promise<{ index: number, keyModifiers: Array, label: string, value: string }> { - logDebug('userInput / chooseOptionWithModifiersV2()', `About to showOptions with ${ options.length } options & prompt: "${message}"`) + logDebug('userInput / chooseOptionWithModifiersV2()', `About to showOptions with ${options.length} options & prompt: "${message}"`) // Add the "Add new item" option at the start, if given const displayOptions = options.slice() if (additionalCreateNewOption) { displayOptions.unshift(additionalCreateNewOption) } - logDebug('userInput / chooseOptionWithModifiersV2()', `displayOptions: ${ displayOptions.length } options`) + logDebug('userInput / chooseOptionWithModifiersV2()', `displayOptions: ${displayOptions.length} options`) // Use newer CommandBar.showOptions() from v3.18 const { index, keyModifiers } = await CommandBar.showOptions(displayOptions, message) @@ -315,14 +313,14 @@ export async function chooseFolder( ): Promise { try { const IS_DESKTOP = NotePlan.environment.platform === 'macOS' - const NEW_FOLDER = `➕ New Folder${ IS_DESKTOP ? ' - or opt-click on a parent folder to create new subfolder' : '' }` + const NEW_FOLDER = `➕ New Folder${IS_DESKTOP ? ' - or opt-click on a parent folder to create new subfolder' : ''}` const teamspaceDefs = getAllTeamspaceIDsAndTitles() - const addNewFolderOption:TCommandBarOptionObject = { - text: NEW_FOLDER, - icon: 'folder-plus', - color: 'orange-500', - shortDescription: 'Add new', - alpha: 0.5, + const addNewFolderOption: TCommandBarOptionObject = { + text: NEW_FOLDER, + icon: 'folder-plus', + color: 'orange-500', + shortDescription: 'Add new', + alpha: 0.5, darkAlpha: 0.5, } logDebug('userInput / createFolder', `creating with folder path, starting at "${startFolder}"`) @@ -333,7 +331,7 @@ export async function chooseFolder( // V2 // Get all folders, excluding the Trash, and only includes folders that match the startFolder (if given) const folderInclusions = startFolder !== '/' ? [startFolder] : [] - const allFolders = getFoldersMatching(folderInclusions, false, []) + const allFolders = getFoldersMatching(folderInclusions, false, []) // Filter and order the list of folders // TODO: can be simplified, as more work is being done in getFoldersMatching() above @@ -374,7 +372,7 @@ export async function chooseFolder( logDebug( `userInput / chooseFolder`, - `chooseFolder folder:${ folder } value:${ value } keyModifiers:${ String(keyModifiers) } keyModifiers.indexOf('opt') = ${ keyModifiers.indexOf('opt') } `, + `chooseFolder folder:${folder} value:${value} keyModifiers:${String(keyModifiers)} keyModifiers.indexOf('opt') = ${keyModifiers.indexOf('opt')} `, ) } else { // no Folders so just choose root @@ -404,13 +402,13 @@ export async function chooseFolder( * @param {Array} teamspaceDefs - teamspace definitions * @param {boolean} includeFolderPath - whether to show full path * @param {string} newFolderText - text for new folder option - * + * * @returns {Array<{ label: string, value: string }> | Array} formatted folder options */ function createFolderOptions( - folders: Array, - teamspaceDefs: Array, - includeFolderPath: boolean, + folders: Array, + teamspaceDefs: Array, + includeFolderPath: boolean, newFolderText: string, ): [Array<{ label: string, value: string }>, Array] { const simpleOptions: Array<{ label: string, value: string }> = [] @@ -454,11 +452,7 @@ function createFolderOptions( * @param {Array} teamspaceDefs - teamspace definitions * @returns {[string, TCommandBarOptionObject]} simple and decorated version of the folder label */ -function createFolderRepresentation( - folder: string, - includeFolderPath: boolean, - teamspaceDefs: Array -): [string, TCommandBarOptionObject] { +export function createFolderRepresentation(folder: string, includeFolderPath: boolean, teamspaceDefs: Array): [string, TCommandBarOptionObject] { // logDebug('userInput / createFolderRepresentation', `- folder: ${folder}`) const INDENT_SPACES = ' ' // to use for indentation of folders that are not the root folder, when includeFolderPath is false const FOLDER_PATH_MAX_LENGTH = 50 // OK on desktop and iOS, at least for @jgclark @@ -471,7 +465,7 @@ function createFolderRepresentation( text: '', shortDescription: '', } - + let simpleIcon = '📁' if (folderParts[0] === '@Archive') { simpleIcon = '🗄️' @@ -488,32 +482,32 @@ function createFolderRepresentation( const teamspaceDetails = parseTeamspaceFilename(folder) // logDebug('userInput / createFolderRepresentation', `teamspaceDef: ${ JSON.stringify(thisTeamspaceDef) } from '${folder}' / filepath:${ teamspaceDetails.filepath } / includeFolderPath:${ String(includeFolderPath) }`) if (teamspaceDetails.filepath === '/') { - simpleOption = `👥 ${ teamspaceTitle }` + simpleOption = `👥 ${teamspaceTitle}` decoratedOption.color = 'green-600' decoratedOption.text = '/' decoratedOption.shortDescription = teamspaceTitle decoratedOption.alpha = 0.6 decoratedOption.darkAlpha = 0.6 } else { - if (includeFolderPath) { - simpleOption = `👥 ${ teamspaceTitle } / ${folderParts.slice(2).join(' / ')}` -decoratedOption.color = 'green-600' -decoratedOption.text = folderParts.slice(2).join(' / ') -decoratedOption.shortDescription = teamspaceTitle -decoratedOption.alpha = 0.6 -decoratedOption.darkAlpha = 0.6 - } else { - simpleOption = `${simpleIcon} ${folderParts.slice(2).join(' / ')}` - decoratedOption.color = 'green-600' - decoratedOption.text = folderParts.slice(2).join(' / ') - decoratedOption.shortDescription = teamspaceTitle - decoratedOption.alpha = 0.6 - decoratedOption.darkAlpha = 0.6 -} + if (includeFolderPath) { + simpleOption = `👥 ${teamspaceTitle} / ${folderParts.slice(2).join(' / ')}` + decoratedOption.color = 'green-600' + decoratedOption.text = folderParts.slice(2).join(' / ') + decoratedOption.shortDescription = teamspaceTitle + decoratedOption.alpha = 0.6 + decoratedOption.darkAlpha = 0.6 + } else { + simpleOption = `${simpleIcon} ${folderParts.slice(2).join(' / ')}` + decoratedOption.color = 'green-600' + decoratedOption.text = folderParts.slice(2).join(' / ') + decoratedOption.shortDescription = teamspaceTitle + decoratedOption.alpha = 0.6 + decoratedOption.darkAlpha = 0.6 + } } // logDebug('userInput / createFolderRepresentation', `→ teamspaceDef: ${ JSON.stringify(decoratedOption) } `) } else if (includeFolderPath) { -// Get the folder path prefix, and truncate it if it's too long + // Get the folder path prefix, and truncate it if it's too long if (folder.length >= FOLDER_PATH_MAX_LENGTH) { const folderPathPrefix = `${folder.slice(0, FOLDER_PATH_MAX_LENGTH - folderParts[folderParts.length - 1].length)} …${folderParts[folderParts.length - 1]} ` simpleOption = `${simpleIcon} ${folderPathPrefix} ` @@ -531,7 +525,7 @@ decoratedOption.darkAlpha = 0.6 simpleOption = `${indentedParts.join('')}${simpleIcon} ${indentedParts[indentedParts.length - 1]}` decoratedOption.text = indentedParts.join('') } -return [simpleOption, decoratedOption] + return [simpleOption, decoratedOption] } /** @@ -583,7 +577,6 @@ async function handleNewFolderCreation(value: string, keyModifiers: Array>, <>. */ -export async function processChosenHeading( - note: TNote, - chosenHeading: string, - headingLevel: number = 2, -): Promise { +export async function processChosenHeading(note: TNote, chosenHeading: string, headingLevel: number = 2): Promise { if (chosenHeading === '') { throw new Error('No heading passed to processChosenHeading(). Stopping.') } diff --git a/np.CallbackURLs/src/NPXCallbackWizard.js b/np.CallbackURLs/src/NPXCallbackWizard.js index 953faa254..b1f57b2cd 100644 --- a/np.CallbackURLs/src/NPXCallbackWizard.js +++ b/np.CallbackURLs/src/NPXCallbackWizard.js @@ -7,14 +7,27 @@ TODO: new search?text=noteplan or search?filter=Upcoming TODO: add back button to return to previous step (@qualitativeeasing) TODO: maybe create choosers based on arguments text */ - -import { log, logError, logDebug, JSP } from '../../helpers/dev' +import yaml from 'yaml' +import { log, logError, logDebug, JSP, clo, timer } from '../../helpers/dev' import { createOpenOrDeleteNoteCallbackUrl, createAddTextCallbackUrl, createCallbackUrl } from '../../helpers/general' import pluginJson from '../plugin.json' import { getXcallbackForTemplate } from './NPTemplateRunner' import { chooseRunPluginXCallbackURL } from '@helpers/NPdev' -import { chooseOption, showMessage, showMessageYesNo, chooseFolder, chooseNote, getInput, getInputTrimmed } from '@helpers/userInput' +import { + chooseOption, + showMessage, + showMessageYesNo, + chooseFolder, + chooseNote, + getInput, + getInputTrimmed, + createFolderRepresentation, + chooseOptionWithModifiersV2, +} from '@helpers/userInput' +import { getFolderViewData, organizeFolderViews, getFoldersWithNamedViews, getNamedViewsForFolder } from '@helpers/folders' +import { getAllTeamspaceIDsAndTitles } from '@helpers/NPTeamspace' import { getSelectedParagraph } from '@helpers/NPParagraph' + // import { getSyncedCopiesAsList } from '@helpers/NPSyncedCopies' // https://help.noteplan.co/article/49-x-callback-url-scheme#addnote @@ -291,18 +304,110 @@ export async function lineLink(): Promise { * @returns {string} the URL or empty string if user canceled */ export async function openView(): Promise { - const name = await getInput('Enter the name of the view', 'OK', 'Saved Folder View', '') - if (name === false || !name) return '' + // Get the folder view data using our new helper function + const folderData = getFolderViewData() + if (!folderData) { + await showMessage('No folder view data available. Please ensure you have folder views configured.') + throw new Error('No folder view data available. Please ensure you have folder views configured.') + } - const folder = await chooseFolder('Choose a folder (optional but recommended)', false, false, '', true) - if (!folder) return '' + clo(folderData, `openView folderData (${typeof folderData})`) - let params = `?name=${encodeURIComponent(String(name))}` - if (folder) { - params += `&folder=${encodeURIComponent(folder)}` + // Get folders that have named views + const foldersWithViews = getFoldersWithNamedViews(folderData) + if (foldersWithViews.length === 0) { + await showMessage('No folders with named views found. Please create some named views first.') + return '' } - return `noteplan://x-callback-url/openView${params}` + // Step 1: Let user choose a folder + const teamspaceDefs = getAllTeamspaceIDsAndTitles() + const folderOptions = foldersWithViews.map((folderPath) => { + // Get the count of named views for this folder + const namedViews = getNamedViewsForFolder(folderData, folderPath) + const viewCount = namedViews.length + + // Create folder representation using the helper function + // Parameters: folder, includeFolderPath, teamspaceDefs + const [simpleOption, dobj] = createFolderRepresentation(folderPath, true, teamspaceDefs) + const decoratedOption: { ...TCommandBarOptionObject, views?: Object } = { ...dobj, views: [] } + // Create label with folder name and view count + const label = `${simpleOption} (${viewCount} view${viewCount !== 1 ? 's' : ''})` + + // Set short description based on view count + decoratedOption.views = namedViews + if (viewCount === 1) { + // For single view, show the view name + decoratedOption.shortDescription = namedViews[0].name + } else { + // For multiple views, show first view + count of others + const firstViewName = namedViews[0].name + const othersCount = viewCount - 1 + decoratedOption.shortDescription = `${firstViewName} + ${othersCount} other${othersCount !== 1 ? 's' : ''}` + } + + return { + label: label, + value: folderPath, + ...decoratedOption, + } + }) + + // const selectedFolder = await chooseOption('Choose a folder with named views:', folderOptions, '') + clo(folderOptions, `folderOptions`) + const selection = await chooseOptionWithModifiersV2('Choose a folder with named views', folderOptions) + if (!selection) return '' + const selectedFolderObj = folderOptions[selection.index] + clo(selection, `openView selection`) + const { value: selectedFolder, views } = selectedFolderObj + + // Step 2: Get named views for the selected folder + // const namedViews = getNamedViewsForFolder(folderData, selectedFolder) + // if (namedViews.length === 0) { + // await showMessage(`No named views found for folder '${selectedFolder}'`) + // return '' + // } + + // Step 3: Let user choose a named view + let selectedViewName = '' + + clo(views, `views`) + + const viewOptions = views + ? views.map((view: Object) => ({ + label: `${view.name}`, + value: view.name, + shortDescription: `(${view.layout})`, + })) + : [] + + viewOptions.unshift({ label: '< Open the folder view default >', value: '_folder_' }) + clo(viewOptions, `viewOptions`) + selectedViewName = viewOptions.length ? await chooseOption(`Choose a named view from '${selectedFolder}'`, viewOptions, '') : '' + + if (!selectedViewName) return '' + + // Build the callback URL + let params = `?` + if (selectedViewName && selectedViewName !== '_folder_') { + params += `name=${encodeURIComponent(selectedViewName)}&` + } + if (selectedFolder && selectedFolder !== '/') { + params += `folder=${encodeURIComponent(selectedFolder)}` + } + + // TODO: I asked @eduardme if he would make it possible to open the folder view default by supplying just the folder name, but he said no. + // In the meantime, we have to do this workaround: + let url = '' + if (selectedViewName === '_folder_') { + url = `noteplan://x-callback-url/openNote?filename=${encodeURIComponent(selectedFolder)}` + } else { + url = `noteplan://x-callback-url/openView${params}` + } + + clo(url, `Generated openView URL`) + + return url } // Plugin command entry point for creating a heading link From 4df26b026f962f6a9d7b01b038de95ba5edb0679 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Mon, 18 Aug 2025 21:10:36 -0700 Subject: [PATCH 3/5] refactored into a new file --- np.CallbackURLs/src/NPOpenFolders.js | 171 +++++++++++++++++++++++ np.CallbackURLs/src/NPXCallbackWizard.js | 133 +----------------- 2 files changed, 176 insertions(+), 128 deletions(-) create mode 100644 np.CallbackURLs/src/NPOpenFolders.js diff --git a/np.CallbackURLs/src/NPOpenFolders.js b/np.CallbackURLs/src/NPOpenFolders.js new file mode 100644 index 000000000..44f301347 --- /dev/null +++ b/np.CallbackURLs/src/NPOpenFolders.js @@ -0,0 +1,171 @@ +// @flow + +import { logDebug, clo } from '../../helpers/dev' +import { showMessage, createFolderRepresentation, chooseOptionWithModifiersV2, chooseOption } from '@helpers/userInput' +import { getFolderViewData, getFoldersWithNamedViews, getNamedViewsForFolder } from '@helpers/folders' +import { getAllTeamspaceIDsAndTitles } from '@helpers/NPTeamspace' + +/** + * Get and validate folder view data + * @returns {Object|null} Parsed folder view data or null if not available + */ +function getValidatedFolderData(): Object | null { + const folderData = getFolderViewData() + if (!folderData) { + showMessage('No folder view data available. Please ensure you have folder views configured.') + return null + } + + clo(folderData, `getValidatedFolderData: folderData (${typeof folderData})`) + return folderData +} + +/** + * Create folder options with view counts and descriptions + * @param {Array} foldersWithViews - List of folders that have named views + * @param {Object} folderData - The folder view data + * @returns {Array} Array of folder option objects + */ +function createFolderOptions(foldersWithViews: Array, folderData: Object): Array { + const teamspaceDefs = getAllTeamspaceIDsAndTitles() + + return foldersWithViews.map((folderPath) => { + // Get the count of named views for this folder + const namedViews = getNamedViewsForFolder(folderData, folderPath) + const viewCount = namedViews.length + + // Create folder representation using the helper function + const [simpleOption, dobj] = createFolderRepresentation(folderPath, true, teamspaceDefs) + const decoratedOption: { ...TCommandBarOptionObject, views?: Object } = { ...dobj, views: [] } + + // Create label with folder name and view count + const label = `${simpleOption} (${viewCount} view${viewCount !== 1 ? 's' : ''})` + + // Set short description based on view count + decoratedOption.views = namedViews + if (viewCount === 1) { + // For single view, show the view name + decoratedOption.shortDescription = namedViews[0].name + } else { + // For multiple views, show first view + count of others + const firstViewName = namedViews[0].name + const othersCount = viewCount - 1 + decoratedOption.shortDescription = `${firstViewName} + ${othersCount} other${othersCount !== 1 ? 's' : ''}` + } + + return { + label: label, + value: folderPath, + ...decoratedOption, + } + }) +} + +/** + * Let user select a folder from the available options + * @param {Array} folderOptions - Array of folder option objects + * @returns {Object|null} Selected folder object or null if cancelled + */ +async function selectFolder(folderOptions: Array): Promise { + clo(folderOptions, `selectFolder: folderOptions`) + + const selection = await chooseOptionWithModifiersV2('Choose a folder with named views', folderOptions) + if (!selection) return null + + const selectedFolderObj = folderOptions[selection.index] + clo(selection, `selectFolder: selection`) + + return selectedFolderObj +} + +/** + * Create view options for the selected folder + * @param {Array} views - Array of named views for the folder + * @returns {Array} Array of view option objects + */ +function createViewOptions(views: Array): Array { + const viewOptions = views + ? views.map((view: Object) => ({ + label: `${view.name}`, + value: view.name, + shortDescription: `(${view.layout})`, + })) + : [] + + // Add option to open folder view default + viewOptions.unshift({ label: '< Open the folder view default >', value: '_folder_', shortDescription: 'Default folder view' }) + + clo(viewOptions, `createViewOptions: viewOptions`) + return viewOptions +} + +/** + * Let user select a view from the available options + * @param {Array} viewOptions - Array of view option objects + * @param {string} selectedFolder - The selected folder path + * @returns {string} Selected view name or empty string if cancelled + */ +async function selectView(viewOptions: Array, selectedFolder: string): Promise { + if (viewOptions.length === 0) return '' + + return await chooseOption(`Choose a named view from '${selectedFolder}'`, viewOptions, '') +} + +/** + * Build the callback URL based on selected folder and view + * @param {string} selectedFolder - The selected folder path + * @param {string} selectedViewName - The selected view name + * @returns {string} The generated callback URL + */ +function buildCallbackUrl(selectedFolder: string, selectedViewName: string): string { + // TODO: I asked @eduardme if he would make it possible to open the folder view default by supplying just the folder name, but he said no. + // In the meantime, we have to do this workaround: + let url = '' + if (selectedViewName === '_folder_') { + url = `noteplan://x-callback-url/openNote?filename=${encodeURIComponent(selectedFolder)}` + } else { + let params = `?` + if (selectedViewName && selectedViewName !== '_folder_') { + params += `name=${encodeURIComponent(selectedViewName)}&` + } + if (selectedFolder && selectedFolder !== '/') { + params += `folder=${encodeURIComponent(selectedFolder)}` + } + url = `noteplan://x-callback-url/openView${params}` + } + + clo(url, `buildCallbackUrl: Generated URL`) + return url +} + +/** + * Main function to open a folder view + * @returns {Promise} The callback URL or empty string if cancelled + */ +export async function openFolderView(): Promise { + // Step 1: Get and validate folder data + const folderData = getValidatedFolderData() + if (!folderData) return '' + + // Step 2: Get folders that have named views + const foldersWithViews = getFoldersWithNamedViews(folderData) + if (foldersWithViews.length === 0) { + await showMessage('No folders with named views found. Please create some named views first.') + return '' + } + + // Step 3: Create folder options and let user choose + const folderOptions = createFolderOptions(foldersWithViews, folderData) + const selectedFolderObj = await selectFolder(folderOptions) + if (!selectedFolderObj) return '' + + const { value: selectedFolder, views } = selectedFolderObj + + // Step 4: Create view options and let user choose + const viewOptions = createViewOptions(views) + const selectedViewName = await selectView(viewOptions, selectedFolder) + if (!selectedViewName) return '' + + // Step 5: Build and return the callback URL + return buildCallbackUrl(selectedFolder, selectedViewName) +} diff --git a/np.CallbackURLs/src/NPXCallbackWizard.js b/np.CallbackURLs/src/NPXCallbackWizard.js index b1f57b2cd..baa074e68 100644 --- a/np.CallbackURLs/src/NPXCallbackWizard.js +++ b/np.CallbackURLs/src/NPXCallbackWizard.js @@ -12,20 +12,9 @@ import { log, logError, logDebug, JSP, clo, timer } from '../../helpers/dev' import { createOpenOrDeleteNoteCallbackUrl, createAddTextCallbackUrl, createCallbackUrl } from '../../helpers/general' import pluginJson from '../plugin.json' import { getXcallbackForTemplate } from './NPTemplateRunner' +import { openFolderView } from './NPOpenFolders' import { chooseRunPluginXCallbackURL } from '@helpers/NPdev' -import { - chooseOption, - showMessage, - showMessageYesNo, - chooseFolder, - chooseNote, - getInput, - getInputTrimmed, - createFolderRepresentation, - chooseOptionWithModifiersV2, -} from '@helpers/userInput' -import { getFolderViewData, organizeFolderViews, getFoldersWithNamedViews, getNamedViewsForFolder } from '@helpers/folders' -import { getAllTeamspaceIDsAndTitles } from '@helpers/NPTeamspace' +import { chooseOption, showMessage, showMessageYesNo, chooseFolder, chooseNote, getInput, getInputTrimmed } from '@helpers/userInput' import { getSelectedParagraph } from '@helpers/NPParagraph' // import { getSyncedCopiesAsList } from '@helpers/NPSyncedCopies' @@ -299,117 +288,6 @@ export async function lineLink(): Promise { return '' } -/** - * Create a callback URL for opening a view - * @returns {string} the URL or empty string if user canceled - */ -export async function openView(): Promise { - // Get the folder view data using our new helper function - const folderData = getFolderViewData() - if (!folderData) { - await showMessage('No folder view data available. Please ensure you have folder views configured.') - throw new Error('No folder view data available. Please ensure you have folder views configured.') - } - - clo(folderData, `openView folderData (${typeof folderData})`) - - // Get folders that have named views - const foldersWithViews = getFoldersWithNamedViews(folderData) - if (foldersWithViews.length === 0) { - await showMessage('No folders with named views found. Please create some named views first.') - return '' - } - - // Step 1: Let user choose a folder - const teamspaceDefs = getAllTeamspaceIDsAndTitles() - const folderOptions = foldersWithViews.map((folderPath) => { - // Get the count of named views for this folder - const namedViews = getNamedViewsForFolder(folderData, folderPath) - const viewCount = namedViews.length - - // Create folder representation using the helper function - // Parameters: folder, includeFolderPath, teamspaceDefs - const [simpleOption, dobj] = createFolderRepresentation(folderPath, true, teamspaceDefs) - const decoratedOption: { ...TCommandBarOptionObject, views?: Object } = { ...dobj, views: [] } - // Create label with folder name and view count - const label = `${simpleOption} (${viewCount} view${viewCount !== 1 ? 's' : ''})` - - // Set short description based on view count - decoratedOption.views = namedViews - if (viewCount === 1) { - // For single view, show the view name - decoratedOption.shortDescription = namedViews[0].name - } else { - // For multiple views, show first view + count of others - const firstViewName = namedViews[0].name - const othersCount = viewCount - 1 - decoratedOption.shortDescription = `${firstViewName} + ${othersCount} other${othersCount !== 1 ? 's' : ''}` - } - - return { - label: label, - value: folderPath, - ...decoratedOption, - } - }) - - // const selectedFolder = await chooseOption('Choose a folder with named views:', folderOptions, '') - clo(folderOptions, `folderOptions`) - const selection = await chooseOptionWithModifiersV2('Choose a folder with named views', folderOptions) - if (!selection) return '' - const selectedFolderObj = folderOptions[selection.index] - clo(selection, `openView selection`) - const { value: selectedFolder, views } = selectedFolderObj - - // Step 2: Get named views for the selected folder - // const namedViews = getNamedViewsForFolder(folderData, selectedFolder) - // if (namedViews.length === 0) { - // await showMessage(`No named views found for folder '${selectedFolder}'`) - // return '' - // } - - // Step 3: Let user choose a named view - let selectedViewName = '' - - clo(views, `views`) - - const viewOptions = views - ? views.map((view: Object) => ({ - label: `${view.name}`, - value: view.name, - shortDescription: `(${view.layout})`, - })) - : [] - - viewOptions.unshift({ label: '< Open the folder view default >', value: '_folder_' }) - clo(viewOptions, `viewOptions`) - selectedViewName = viewOptions.length ? await chooseOption(`Choose a named view from '${selectedFolder}'`, viewOptions, '') : '' - - if (!selectedViewName) return '' - - // Build the callback URL - let params = `?` - if (selectedViewName && selectedViewName !== '_folder_') { - params += `name=${encodeURIComponent(selectedViewName)}&` - } - if (selectedFolder && selectedFolder !== '/') { - params += `folder=${encodeURIComponent(selectedFolder)}` - } - - // TODO: I asked @eduardme if he would make it possible to open the folder view default by supplying just the folder name, but he said no. - // In the meantime, we have to do this workaround: - let url = '' - if (selectedViewName === '_folder_') { - url = `noteplan://x-callback-url/openNote?filename=${encodeURIComponent(selectedFolder)}` - } else { - url = `noteplan://x-callback-url/openView${params}` - } - - clo(url, `Generated openView URL`) - - return url -} - // Plugin command entry point for creating a heading link export async function headingLink() { await xCallbackWizard(`headingLink`) @@ -433,7 +311,7 @@ export async function xCallbackWizard(_commandType: ?string = '', passBackResult { label: 'OPEN a note or folder', value: 'openNote' }, { label: 'NEW NOTE with title and text', value: 'addNote' }, { label: 'ADD text to a note', value: 'addText' }, - { label: 'OPEN a Named Folder View', value: 'openView' }, + { label: 'OPEN FOLDER View', value: 'openFolderView' }, { label: 'FILTER Notes by Preset', value: 'filter' }, { label: 'SEARCH for text in notes', value: 'search' }, { label: 'Get NOTE INFO (x-success) for use in another app', value: 'noteInfo' }, @@ -500,11 +378,10 @@ export async function xCallbackWizard(_commandType: ?string = '', passBackResult return } break - case 'openView': - url = await openView() + case 'openFolderView': + url = await openFolderView() if (!url) { showMessage(`No view name or folder selected. Please try again.`, 'OK', 'No View Selected') - return } break default: From 87ccab5be6774a652e321389cc46c40fc5341c48 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Mon, 18 Aug 2025 21:42:54 -0700 Subject: [PATCH 4/5] Allow all folders in chooser --- np.CallbackURLs/src/NPOpenFolders.js | 112 ++++++++++++++++----------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/np.CallbackURLs/src/NPOpenFolders.js b/np.CallbackURLs/src/NPOpenFolders.js index 44f301347..1a12bb1d5 100644 --- a/np.CallbackURLs/src/NPOpenFolders.js +++ b/np.CallbackURLs/src/NPOpenFolders.js @@ -22,41 +22,53 @@ function getValidatedFolderData(): Object | null { /** * Create folder options with view counts and descriptions - * @param {Array} foldersWithViews - List of folders that have named views + * @param {Array} allFolders - List of all folders * @param {Object} folderData - The folder view data * @returns {Array} Array of folder option objects */ -function createFolderOptions(foldersWithViews: Array, folderData: Object): Array { +function createFolderOptions(allFolders: $ReadOnlyArray, folderData: Object): Array { const teamspaceDefs = getAllTeamspaceIDsAndTitles() - return foldersWithViews.map((folderPath) => { - // Get the count of named views for this folder + return allFolders.map((folderPath) => { + // Check if this folder has named views const namedViews = getNamedViewsForFolder(folderData, folderPath) - const viewCount = namedViews.length - - // Create folder representation using the helper function - const [simpleOption, dobj] = createFolderRepresentation(folderPath, true, teamspaceDefs) - const decoratedOption: { ...TCommandBarOptionObject, views?: Object } = { ...dobj, views: [] } - - // Create label with folder name and view count - const label = `${simpleOption} (${viewCount} view${viewCount !== 1 ? 's' : ''})` - - // Set short description based on view count - decoratedOption.views = namedViews - if (viewCount === 1) { - // For single view, show the view name - decoratedOption.shortDescription = namedViews[0].name + const hasNamedViews = namedViews && namedViews.length > 0 + + if (hasNamedViews) { + // For folders with named views, create decorated options + const viewCount = namedViews.length + const [simpleOption, dobj] = createFolderRepresentation(folderPath, true, teamspaceDefs) + const decoratedOption: { ...TCommandBarOptionObject, views?: Object } = { ...dobj, views: [] } + + // Create label with folder name and view count + const label = `${simpleOption} (${viewCount} view${viewCount !== 1 ? 's' : ''})` + + // Set short description based on view count + decoratedOption.views = namedViews + if (viewCount === 1) { + // For single view, show the view name + decoratedOption.shortDescription = `View: ${namedViews[0].name}` + } else { + // For multiple views, show first view + count of others + const firstViewName = namedViews[0].name + const othersCount = viewCount - 1 + decoratedOption.shortDescription = `Views: ${firstViewName} + ${othersCount} other${othersCount !== 1 ? 's' : ''}` + } + + return { + label: label, + value: folderPath, + ...decoratedOption, + } } else { - // For multiple views, show first view + count of others - const firstViewName = namedViews[0].name - const othersCount = viewCount - 1 - decoratedOption.shortDescription = `${firstViewName} + ${othersCount} other${othersCount !== 1 ? 's' : ''}` - } - - return { - label: label, - value: folderPath, - ...decoratedOption, + // For folders without named views, create standard folder options + const [simpleOption, dobj] = createFolderRepresentation(folderPath, true, teamspaceDefs) + return { + label: simpleOption, + value: folderPath, + ...dobj, + views: [], // Empty views array for consistency + } } }) } @@ -69,7 +81,7 @@ function createFolderOptions(foldersWithViews: Array, folderData: Object async function selectFolder(folderOptions: Array): Promise { clo(folderOptions, `selectFolder: folderOptions`) - const selection = await chooseOptionWithModifiersV2('Choose a folder with named views', folderOptions) + const selection = await chooseOptionWithModifiersV2('Choose a folder', folderOptions) if (!selection) return null const selectedFolderObj = folderOptions[selection.index] @@ -83,16 +95,19 @@ async function selectFolder(folderOptions: Array): Promise} views - Array of named views for the folder * @returns {Array} Array of view option objects */ -function createViewOptions(views: Array): Array { - const viewOptions = views - ? views.map((view: Object) => ({ - label: `${view.name}`, - value: view.name, - shortDescription: `(${view.layout})`, - })) - : [] - - // Add option to open folder view default +function createViewOptions(views: Array): Array { + let viewOptions: Array = [] + + // If there are named views, add them as options + if (views && views.length > 0) { + viewOptions = views.map((view: Object) => ({ + label: `${view.name}`, + value: view.name, + shortDescription: `(${view.layout})`, + })) + } + + // Always add option to open folder view default viewOptions.unshift({ label: '< Open the folder view default >', value: '_folder_', shortDescription: 'Default folder view' }) clo(viewOptions, `createViewOptions: viewOptions`) @@ -108,7 +123,12 @@ function createViewOptions(views: Array): Array { async function selectView(viewOptions: Array, selectedFolder: string): Promise { if (viewOptions.length === 0) return '' - return await chooseOption(`Choose a named view from '${selectedFolder}'`, viewOptions, '') + // If there's only the default option (no named views), just return it + if (viewOptions.length === 1) { + return viewOptions[0].value + } + + return await chooseOption(`Choose a view for '${selectedFolder}'`, viewOptions, '') } /** @@ -147,15 +167,15 @@ export async function openFolderView(): Promise { const folderData = getValidatedFolderData() if (!folderData) return '' - // Step 2: Get folders that have named views - const foldersWithViews = getFoldersWithNamedViews(folderData) - if (foldersWithViews.length === 0) { - await showMessage('No folders with named views found. Please create some named views first.') + // Step 2: Get ALL folders from DataStore + const allFolders = DataStore.folders + if (!allFolders || allFolders.length === 0) { + await showMessage('No folders found. Please ensure you have folders in your NotePlan setup.') return '' } - // Step 3: Create folder options and let user choose - const folderOptions = createFolderOptions(foldersWithViews, folderData) + // Step 3: Create folder options for all folders and let user choose + const folderOptions = createFolderOptions(allFolders, folderData) const selectedFolderObj = await selectFolder(folderOptions) if (!selectedFolderObj) return '' From a45907d8a306998fc71740695e1c9d27ef39ae01 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Mon, 18 Aug 2025 21:46:05 -0700 Subject: [PATCH 5/5] np.CallbackURLs (v1.9.0) --- np.CallbackURLs/plugin.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/np.CallbackURLs/plugin.json b/np.CallbackURLs/plugin.json index 0d3e6c8b9..a262adc44 100644 --- a/np.CallbackURLs/plugin.json +++ b/np.CallbackURLs/plugin.json @@ -5,8 +5,8 @@ "noteplan.minAppVersion-NOTE": "Includes folder view picker", "plugin.id": "np.CallbackURLs", "plugin.name": "🔗 Link Creator", - "plugin.version": "1.8.0", - "plugin.lastUpdateInfo": "1.8.0: Added templating specific commands to wizard to reduce confusion", + "plugin.version": "1.9.0", + "plugin.lastUpdateInfo": "1.9.0: Added folder view picker to wizard", "plugin.description": "Interactively helps you form links/x-callback-urls (and also Template Tags with runPlugin commands) to perform actions from within NotePlan or between other applications and NotePlan.", "plugin.author": "dwertheimer", "plugin.dependencies": [],