diff --git a/.cursor/rules/noteplan-programming-general.mdc b/.cursor/rules/noteplan-programming-general.mdc new file mode 100644 index 000000000..2185bf236 --- /dev/null +++ b/.cursor/rules/noteplan-programming-general.mdc @@ -0,0 +1,135 @@ +--- +alwaysApply: true +--- +For NotePlan code, never use require statements. Use import statements at the top of the file -- never inline/dynamically import modules. Rollup will not process them correctly. +You should not ever try to build the code with "npm run build" -- this will not work. The programmer will have to use Rollup to build the code. There is separate tooling for this described in @README.md. +NotePlan has global variables that are available to all plugins such as DataStore, CommandBar, Editor, and NotePlan. You can use these variables to access the NotePlan API. You do not need to import them. + +## Promise Polyfills + +NotePlan's JSContext may not have `Promise.resolve()` or `Promise.all()`. Use the polyfills in `@helpers/promisePolyfill.js`: + +- **Import**: `import { initPromisePolyfills, promiseResolve, promiseAll, waitForCondition, setTimeoutPolyfill } from '@helpers/promisePolyfill'` +- **Initialize early**: Call `initPromisePolyfills()` at the top of your plugin file to add polyfills to the global Promise object +- **Use directly**: `promiseResolve(value)`, `promiseAll(promises)`, `waitForCondition(condition, options)`, `setTimeoutPolyfill(callback, delayMs)` +- **waitForCondition**: Useful for waiting for notes to appear in `DataStore.projectNotes` after creation/modification. Example: + ```javascript + const found = await waitForCondition( + () => getFormTemplateList().find(opt => opt.label === title) != null, + { maxWaitMs: 3000, checkIntervalMs: 100 } + ) + ``` +- **Cache updates**: After creating or modifying notes, call `DataStore.updateCache(note, true)` to ensure the note is available in `DataStore.projectNotes` for searches. + +## React Function Memoization (CRITICAL - Prevents Infinite Loops) + +**ALWAYS wrap functions passed to React Context or child components in `useCallback`**. This is critical to prevent infinite render loops that crash the app. + +### The Problem +Functions created in React components are recreated on every render. If these functions are: +- Passed to `AppContext` (via `AppProvider`) +- Used as dependencies in `useEffect` hooks +- Passed as props to child components + +They will cause infinite loops because: +1. Function reference changes → Context value changes → All consumers re-render +2. Re-render creates new function → Function reference changes → Loop continues + +### The Solution +**Always use `useCallback` for functions passed to context or used as dependencies:** + +```javascript +// ❌ WRONG - Causes infinite loops +const requestFromPlugin = (command: string, dataToSend: any = {}) => { + // ... implementation +} + +// ✅ CORRECT - Stable function reference +const requestFromPlugin = useCallback((command: string, dataToSend: any = {}) => { + // ... implementation +}, [dispatch, windowId]) // Only recreate if these dependencies change +``` + +### Functions That MUST Be Memoized +- `requestFromPlugin` - Request/response pattern function +- `sendActionToPlugin` - Action sender function +- `sendToPlugin` - Direct sender function +- Any function passed to `AppProvider` props +- Any function used in `useEffect` dependency arrays + +### Dependencies +Keep dependencies minimal - usually just: +- `dispatch` (from props, usually stable) +- `windowId` (if used in the function) +- Other stable refs (not state/props that change frequently) + +### AppContext Memoization +`AppContext` should use `useMemo` to memoize the context value: + +```javascript +const contextValue = useMemo(() => ({ + sendActionToPlugin, + sendToPlugin, + requestFromPlugin, + dispatch, + pluginData, + // ... +}), [sendActionToPlugin, sendToPlugin, requestFromPlugin, dispatch, pluginData, ...]) +``` + +**This pattern has caused infinite loops 5+ times. Always check for `useCallback` when creating functions that go into context.** + +### Why This Must Be Checked Every Time + +**IMPORTANT FOR AI ASSISTANTS:** This rule must be checked at the start of EVERY coding session and whenever creating or modifying React components that: +- Create functions passed to `AppProvider` +- Use `useEffect` with function dependencies +- Pass functions as props to child components + +**Why this isn't always caught:** +- The issue only manifests at runtime (infinite loops, app crashes) +- Linters may not catch missing `useCallback` in all cases +- The pattern looks correct syntactically but fails functionally +- Context consumers re-render silently, making the problem hard to trace + +**Before writing ANY React component code, ask:** +1. Are any functions being passed to `AppProvider`? → Must use `useCallback` +2. Are any functions used in `useEffect` dependencies? → Must use `useCallback` +3. Is `AppContext` using `useMemo` for the context value? → Must use `useMemo` + +**This is a CRITICAL pattern that has caused production issues multiple times. Always verify function memoization before considering code complete.** + +## NotePlan Theme Colors + +When using CSS variables for styling, use these NotePlan theme color variables with their default values: + +```css +--bg-main-color: #eff1f5; +--fg-sidebar-color: #242E32; +--bg-sidebar-color: #ECECEC; +--divider-color: #CDCFD0; +--block-id-color: #79A0B5; +--fg-main-color: #4c4f69; +--h1-color: #5c5f77; +--h2-color: #5c5f77; +--h3-color: #5c5f77; +--bg-alt-color: #e6e9ef; +--tint-color: #dc8a78; +--bg-mid-color: #ebedf2; +--bg-apple-input-color: #fbfbfb; +--bg-apple-switch-color: #dadada; +--fg-apple-switch-color: #ffffff; +--bg-apple-button-color: #fcfcfc; +--item-icon-color: #1e66f5; +--fg-done-color: #04a5e5; +--fg-canceled-color: #4F57A0E0; +--hashtag-color: inherit; +--attag-color: inherit; +--code-color: #0091f8; +``` + +**Always use these CSS variables with their default fallback values** when styling React components. For example: +```css +background: var(--tint-color, #dc8a78); +color: var(--fg-main-color, #4c4f69); +``` \ No newline at end of file diff --git a/dwertheimer.Favorites/IMPLEMENTATION_PLAN.md b/dwertheimer.Favorites/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..ed7c41e53 --- /dev/null +++ b/dwertheimer.Favorites/IMPLEMENTATION_PLAN.md @@ -0,0 +1,233 @@ +# Favorites Browser Implementation Plan + +## Overview +This plan outlines the implementation of a React-based Favorites Browser for the dwertheimer.Favorites plugin, using reusable FilterableList and List components with NotePlan sidebar-style display. + +## Components to Create + +### 1. Reusable Components (in helpers/react/) + +#### 1.1 List Component (`helpers/react/List.jsx`) +- **Purpose**: Core list rendering component that can work with different filter mechanisms +- **Props**: + - `items: Array` - Array of items to display + - `displayType: 'noteplan-sidebar' | 'chips'` - Display style + - `renderItem: (item: any, index: number) => React$Node` - Custom render function for each item + - `onItemClick: (item: any, event: MouseEvent) => void` - Click handler + - `selectedIndex: ?number` - Currently selected item index + - `itemActions?: Array<{icon: string, onClick: (item: any, event: MouseEvent) => void, title?: string}>` - Actions to show on right side + - `emptyMessage?: string` - Message when list is empty + - `loading?: boolean` - Loading state +- **Features**: + - Two display styles: + - `noteplan-sidebar`: Hierarchical folder/file style (like NotePlan sidebar) + - `chips`: Card/chip style (like FormBrowser left side) + - Support for action buttons on the right side of each item + - Keyboard navigation support + - Selected/active states + +#### 1.2 FilterableList Component (`helpers/react/FilterableList.jsx`) +- **Purpose**: Wrapper component that adds filtering capability to List +- **Props**: + - All List props + - `filterText: string` - Current filter text + - `onFilterChange: (text: string) => void` - Filter change handler + - `filterPlaceholder?: string` - Placeholder for filter input + - `renderFilter?: () => React$Node` - Custom filter component (optional) +- **Features**: + - Text input for filtering + - Filters items based on filterText + - Can be replaced with custom filter mechanism + +#### 1.3 CSS Files +- `helpers/react/List.css` - Styles for List component +- `helpers/react/FilterableList.css` - Styles for FilterableList component + +### 2. Favorites Plugin React Components + +#### 2.1 FavoritesView Component (`dwertheimer.Favorites/src/components/FavoritesView.jsx`) +- **Purpose**: Main React component for the Favorites Browser +- **Features**: + - Uses FilterableList with noteplan-sidebar display style + - Toggle switch to switch between favorite notes and favorite commands + - Displays list of favorites (notes or commands) + - Handles click events (normal, opt-click, cmd-click) + - Uses AppContext for communication with plugin + +#### 2.2 AppContext (`dwertheimer.Favorites/src/components/AppContext.jsx`) +- **Purpose**: React Context for plugin communication (copy from Forms plugin) +- **Features**: + - `sendActionToPlugin` - Send actions to plugin + - `sendToPlugin` - Send to plugin without saving scroll + - `requestFromPlugin` - Request/response pattern + - `dispatch` - Dispatch messages + - `pluginData` - Data from plugin + - `reactSettings` - Local React settings + - `setReactSettings` - Update React settings + +#### 2.3 CSS Files +- `dwertheimer.Favorites/src/components/FavoritesView.css` - Styles for FavoritesView + +### 3. Plugin Backend Files + +#### 3.1 Window Management (`dwertheimer.Favorites/src/windowManagement.js`) +- **Purpose**: Handle opening React windows +- **Functions**: + - `openFavoritesBrowser()` - Opens the Favorites Browser window + - `createWindowInitData()` - Creates initial data for React window + - `getPluginData()` - Gathers data to pass to React window + +#### 3.2 Request Handlers (`dwertheimer.Favorites/src/requestHandlers.js`) +- **Purpose**: Handle requests from React components +- **Functions**: + - `handleGetFavoriteNotes()` - Returns list of favorite notes + - `handleGetFavoriteCommands()` - Returns list of favorite commands + - `handleOpenNote()` - Opens a note (handles normal, opt-click, cmd-click) + +#### 3.3 Main Handler (`dwertheimer.Favorites/src/index.js`) +- **Purpose**: Plugin entry point +- **Functions**: + - `openFavoritesBrowser()` - Command to open Favorites Browser + - `onFavoritesBrowserAction()` - Handler for actions from React window + +### 4. Rollup Configuration + +#### 4.1 Rollup Entry File (`dwertheimer.Favorites/src/support/rollup.FavoritesView.entry.js`) +- **Purpose**: Entry point for Rollup bundling +- **Content**: Exports FavoritesView as WebView + +#### 4.2 Rollup Script (`dwertheimer.Favorites/src/support/performRollup.node.js`) +- **Purpose**: Rollup build script (similar to Forms plugin) +- **Features**: + - Builds FavoritesView bundle + - Development mode + - Watch mode support + +### 5. Required Files Structure + +``` +dwertheimer.Favorites/ +├── src/ +│ ├── components/ +│ │ ├── FavoritesView.jsx +│ │ ├── FavoritesView.css +│ │ └── AppContext.jsx +│ ├── support/ +│ │ ├── rollup.FavoritesView.entry.js +│ │ └── performRollup.node.js +│ ├── windowManagement.js +│ ├── requestHandlers.js +│ ├── favorites.js (existing) +│ ├── NPFavorites.js (existing) +│ ├── NPFavoritePresets.js (existing) +│ └── index.js (existing - needs updates) +├── requiredFiles/ +│ └── react.c.FavoritesView.bundle.dev.js (generated by rollup) +└── plugin.json (existing - needs new command) + +helpers/react/ +├── List.jsx (new) +├── List.css (new) +├── FilterableList.jsx (new) +└── FilterableList.css (new) +``` + +## Implementation Details + +### 1. List Component Display Styles + +#### NotePlan Sidebar Style +- Hierarchical display with folder/file icons +- Indentation for nested items +- Folder expansion/collapse (if needed) +- Similar to NotePlan's sidebar appearance +- Uses NotePlan theme variables + +#### Chips Style +- Card/chip appearance +- Rounded corners +- Border and background +- Similar to FormBrowser left side +- Hover effects +- Selected state highlighting + +### 2. Favorite Notes Display +- Show note title +- Show folder path (if applicable) +- Show favorite icon (⭐️) +- Use note decoration (icon, color) from `getNoteDecoration()` +- Display in NotePlan sidebar style + +### 3. Favorite Commands Display +- Show command name +- Show command description (if available) +- Display in NotePlan sidebar style +- Icon for command type + +### 4. Click Handling +- **Normal click**: `Editor.openNoteByFilename(filename, false, 0, 0, false)` +- **Option-click (Alt)**: `Editor.openNoteByFilename(filename, false, 0, 0, true)` - split view +- **Cmd-click (Meta)**: `Editor.openNoteByFilename(filename, true, 0, 0, false)` - floating window + +### 5. Toggle Switch +- Located at top of FilterableList +- Switches between "Favorite Notes" and "Favorite Commands" +- Updates list when toggled +- Persists selection in reactSettings + +### 6. Filtering +- Text input at top of FilterableList +- Filters items based on: + - For notes: title, folder path + - For commands: command name, description +- Case-insensitive search +- Updates as user types + +### 7. Request/Response Pattern +- React components use `requestFromPlugin()` to request data +- Plugin handlers respond with data +- Uses correlation IDs for request matching +- Timeout handling (default 10 seconds) + +### 8. Window Opening +- Uses `DataStore.invokePluginCommandByName('showInMainWindow', 'np.Shared', [data, windowOptions])` +- Opens in main window (not floating) +- Uses NotePlan theme CSS +- Includes FontAwesome icons + +## Data Flow + +1. User runs `/favorites-browser` command +2. Plugin calls `openFavoritesBrowser()` +3. `windowManagement.js` creates window data and calls `showInMainWindow` +4. React window loads `FavoritesView` component +5. `FavoritesView` requests favorite notes/commands via `requestFromPlugin` +6. Plugin handlers return data +7. `FavoritesView` displays data in FilterableList +8. User clicks item → React sends action → Plugin opens note + +## Testing Checklist + +- [ ] List component renders correctly in both display styles +- [ ] FilterableList filters items correctly +- [ ] Toggle switch switches between notes and commands +- [ ] Normal click opens note in Editor +- [ ] Option-click opens note in split view +- [ ] Cmd-click opens note in floating window +- [ ] Filtering works for both notes and commands +- [ ] Request/response pattern works correctly +- [ ] Window opens in main window +- [ ] NotePlan theme styling applied correctly +- [ ] Keyboard navigation works +- [ ] Action buttons work (if implemented) + +## Next Steps + +1. Create reusable List and FilterableList components +2. Set up React framework in Favorites plugin +3. Create FavoritesView component +4. Implement window management +5. Implement request handlers +6. Add command to plugin.json +7. Test and refine + diff --git a/dwertheimer.Favorites/plugin.json b/dwertheimer.Favorites/plugin.json index 8308d2f80..e99ab74e0 100644 --- a/dwertheimer.Favorites/plugin.json +++ b/dwertheimer.Favorites/plugin.json @@ -1,7 +1,7 @@ { "COMMENT1": "Note If you are not going to use the `npm run autowatch` command to compile, then delete the macOS.minVersion line below", "macOS.minVersion": "10.13.0", - "noteplan.minAppVersion": "3.0.21", + "noteplan.minAppVersion": "3.20.0", "plugin.id": "dwertheimer.Favorites", "plugin.name": "⭐️ Favorites", "plugin.description": "Get fast access to commonly-used notes. Set any Project Note(s) as a Favorites and have quick access to choose/open the file", @@ -9,6 +9,7 @@ "plugin.version": "1.2.10", "plugin.lastUpdateInfo": "1.2.10: Bug fix for frontmatter", "plugin.dependencies": [], + "plugin.requiredFiles": ["react.c.FavoritesView.bundle.dev.js"], "plugin.script": "script.js", "plugin.url": "Put a link to a web page or your github readme file here, and it will be displayed as the 'website' in the plugin preference pane to give users more info on the plugin before/after install, e.g. https://github.com/dwertheimer/Noteplan-plugins/blob/main/np.plugin-flow-skeleton/readme.md", "plugin.commands": [ @@ -37,6 +38,22 @@ "unfave" ] }, + { + "name": "Sidebar - Open Favorites Browser Sidebar", + "description": "Open a sidebar window to view and open favorite notes and commands", + "jsFunction": "openFavoritesBrowser", + "alias": [ + "favorites-browser", + "fav-browser" + ], + "arguments": [ "IsFloating: If true or 'true', opens as a floating window instead of main window (optional, defaults to false)"] + }, + { + "name": "onFavoritesBrowserAction", + "description": "Handle actions from Favorites Browser React window", + "jsFunction": "onFavoritesBrowserAction", + "hidden": true + }, { "note": "****************************************************", "not1": "********** PRESETS BELOW THIS LINE ***********", diff --git a/dwertheimer.Favorites/src/components/AppContext.jsx b/dwertheimer.Favorites/src/components/AppContext.jsx new file mode 100644 index 000000000..6f785a428 --- /dev/null +++ b/dwertheimer.Favorites/src/components/AppContext.jsx @@ -0,0 +1,84 @@ +// This is a context provider for the app. You should generally not need to edit this file. +// It provides a way to pass functions and data to any component that needs it +// without having to pass from parent to child to grandchild etc. +// including reading and saving reactSettings local to the react window +// +// Any React component that needs access to the AppContext can use the useAppContext hook with these 2 lines +// import { useAppContext } from './AppContext.jsx' +// ... +// const {sendActionToPlugin, sendToPlugin, dispatch, pluginData, reactSettings, updateReactSettings} = useAppContext() // MUST BE inside the React component/function code, cannot be at the top of a file + +// @flow +import React, { createContext, useContext, useMemo, type Node } from 'react' + +/** + * Type definitions for the application context. + */ +export type AppContextType = { + sendActionToPlugin: (command: string, dataToSend: any) => void, // The main one to use to send actions to the plugin, saves scroll position + sendToPlugin: (command: string, dataToSend: any) => void, // Sends to plugin without saving scroll position + requestFromPlugin: (command: string, dataToSend: any, timeout?: number) => Promise, // Request/response pattern - returns a Promise + dispatch: (command: string, dataToSend: any, message?: string) => void, // Used mainly for showing banner at top of page to user + pluginData: Object, // The data that was sent from the plugin in the field "pluginData" + reactSettings: Object, // Dynamic key-value pair for reactSettings local to the react window (e.g. filterPriorityItems) + setReactSettings: (newSettings: Object) => void, // Update the reactSettings + updatePluginData: (newData: Object, messageForLog?: string) => void, // Updates the global pluginData, generally not something you should need to do +} + +// Default context value with initial reactSettings and functions. +const defaultContextValue: AppContextType = { + sendActionToPlugin: () => {}, + sendToPlugin: () => {}, + requestFromPlugin: async () => { + throw new Error('requestFromPlugin not initialized') + }, + dispatch: () => {}, + pluginData: {}, + reactSettings: {}, // Initial empty reactSettings local + setReactSettings: () => {}, // Placeholder function, actual implementation below. + updatePluginData: () => {}, // Placeholder function, actual implementation below. +} + +type Props = { + sendActionToPlugin: (command: string, dataToSend: any) => void, + sendToPlugin: (command: string, dataToSend: any) => void, + requestFromPlugin: (command: string, dataToSend: any, timeout?: number) => Promise, + dispatch: (command: string, dataToSend: any, messageForLog?: string) => void, + pluginData: Object, + children: Node, // React component children + updatePluginData: (newData: Object, messageForLog?: string) => void, + reactSettings: Object, + setReactSettings: (newSettings: Object) => void, +} + +/** + * Create the context with the default value. + */ +const AppContext = createContext(defaultContextValue) + +// Explicitly annotate the return type of AppProvider as a React element +export const AppProvider = ({ children, sendActionToPlugin, sendToPlugin, requestFromPlugin, dispatch, pluginData, updatePluginData, reactSettings, setReactSettings }: Props): Node => { + + // Memoize the context value to prevent unnecessary re-renders of all consumers + // This ensures that functions like requestFromPlugin and dispatch maintain stable references + // Only recreate the context value when the actual props change + const contextValue: AppContextType = useMemo(() => ({ + sendActionToPlugin, + sendToPlugin, + requestFromPlugin, + dispatch, + pluginData, + reactSettings, + setReactSettings, + updatePluginData, + }), [sendActionToPlugin, sendToPlugin, requestFromPlugin, dispatch, pluginData, reactSettings, setReactSettings, updatePluginData]) + + return {children} +} + +/** + * Custom hook to use the AppContext. + * @returns {AppContextType} - The context value. + */ +export const useAppContext = (): AppContextType => useContext(AppContext) + diff --git a/dwertheimer.Favorites/src/components/FavoritesView.css b/dwertheimer.Favorites/src/components/FavoritesView.css new file mode 100644 index 000000000..05dd3b762 --- /dev/null +++ b/dwertheimer.Favorites/src/components/FavoritesView.css @@ -0,0 +1,224 @@ +/* FavoritesView Component Styles */ + +.favorites-view-container { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--np-theme-background, #ffffff); + width: 100%; + height: 100vh; +} + +.favorites-view-window-header { + flex-shrink: 0; + padding: 1rem; + border-bottom: 1px solid var(--np-theme-border, #e0e0e0); + background: var(--np-theme-background-secondary, #f5f5f5); +} + +.favorites-view-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--np-theme-text, #000000); +} + +.favorites-view-header { + flex-shrink: 0; + padding: 1rem; + border-bottom: 1px solid var(--np-theme-border, #e0e0e0); + background: var(--np-theme-background-secondary, #f5f5f5); +} + +.favorites-view-header-controls { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; +} + +.favorites-view-segmented-control { + display: inline-flex; + background: var(--np-theme-background-tertiary, #f0f0f0); + border-radius: 6px; + padding: 2px; + gap: 2px; + border: 1px solid var(--np-theme-border, #e0e0e0); + flex: 1; +} + +.favorites-segment-button { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--np-theme-text-secondary, #666666); + transition: all 0.2s ease; + white-space: nowrap; + user-select: none; + flex: 1; + min-width: 0; +} + +.favorites-segment-button:hover { + background: var(--np-theme-background-hover, rgba(0, 0, 0, 0.05)); + color: var(--np-theme-text, #000000); +} + +.favorites-segment-button-active { + background: var(--np-theme-background, #ffffff); + color: var(--np-theme-text, #000000); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + font-weight: 600; +} + +.favorites-segment-button-active:hover { + background: var(--np-theme-background, #ffffff); +} + +.favorites-segment-icon { + font-size: 0.875rem; + width: 14px; + text-align: center; + font-weight: 600; +} + +.favorites-new-button { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--np-theme-border, #e0e0e0); + background: var(--np-theme-background, #ffffff); + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--np-theme-text, #000000); + transition: all 0.2s ease; + white-space: nowrap; + user-select: none; + flex-shrink: 0; +} + +.favorites-new-button:hover { + background: var(--np-theme-background-hover, rgba(0, 0, 0, 0.05)); + border-color: var(--np-theme-border-hover, #d0d0d0); +} + +.favorites-new-button:active { + transform: scale(0.98); +} + +.favorites-new-icon { + font-size: 0.75rem; + width: 12px; + text-align: center; +} + +.favorites-item-note, +.favorites-item-command { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; /* Allow flex items to shrink below content size */ +} + +.favorites-item-note.favorites-item-newly-added, +.favorites-item-command.favorites-item-newly-added { + background-color: var(--np-theme-tint, #007aff); + background-color: rgba(0, 122, 255, 0.1); /* Light blue highlight */ + animation: highlightFade 2s ease-out; +} + +@keyframes highlightFade { + 0% { + background-color: rgba(0, 122, 255, 0.3); + } + 100% { + background-color: transparent; + } +} + +.favorites-item-icon { + flex-shrink: 0; + width: 16px; + text-align: center; + font-size: 0.9rem; +} + +.favorites-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.favorites-item-title { + font-size: 0.9rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.favorites-item-folder, +.favorites-item-description { + font-size: 0.75rem; + color: var(--np-theme-text-secondary, #666666); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Unfavorite icon styling */ +.favorites-item-note .favorites-unfavorite-icon { + margin-left: auto; + margin-right: 0.25rem; + flex-shrink: 0; +} + +/* Styles for Add Favorite Note Dialog with Markdown Preview */ +.dynamic-dialog.favorites-note-dialog .dynamic-dialog-content { + display: flex; + flex-direction: column; + height: 100%; + max-height: 80vh; +} + +.dynamic-dialog.favorites-note-dialog .ui-item[data-settings-key="note"] { + flex-shrink: 0; + margin-bottom: 1rem; +} + +.dynamic-dialog.favorites-note-dialog .ui-item[data-settings-key="notePreview"] { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.dynamic-dialog.favorites-note-dialog .markdown-preview-container { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.dynamic-dialog.favorites-note-dialog .markdown-preview-content { + flex: 1; + min-height: 0; + max-height: none; + overflow-y: auto; +} + + diff --git a/dwertheimer.Favorites/src/components/FavoritesView.jsx b/dwertheimer.Favorites/src/components/FavoritesView.jsx new file mode 100644 index 000000000..e11b43d32 --- /dev/null +++ b/dwertheimer.Favorites/src/components/FavoritesView.jsx @@ -0,0 +1,903 @@ +// @flow +//-------------------------------------------------------------------------- +// FavoritesView Component +// Browse and open favorite notes and commands +//-------------------------------------------------------------------------- + +import React, { useState, useEffect, useRef, useCallback, useMemo, type Node } from 'react' +import { AppProvider, useAppContext } from './AppContext.jsx' +import { FilterableList } from '@helpers/react/FilterableList' +import { type ListItemAction } from '@helpers/react/List' +import { logDebug, logError } from '@helpers/react/reactDev.js' +import { defaultNoteIconDetails } from '@helpers/NPnote.js' +import DynamicDialog from '@helpers/react/DynamicDialog/DynamicDialog' +import { type TSettingItem } from '@helpers/react/DynamicDialog/DynamicDialog' +import { type NoteOption } from '@helpers/react/DynamicDialog/NoteChooser' +import { waitForCondition } from '@helpers/promisePolyfill' +import { InfoIcon } from '@helpers/react/InfoIcon' +import './FavoritesView.css' + +type FavoriteNote = { + filename: string, + title: string, + type: string, + frontmatterAttributes?: Object, + icon?: string, + color?: string, + folder?: string, +} + +type FavoriteCommand = { + name: string, + description?: string, + jsFunction: string, + data?: string, +} + +type FavoritesViewProps = { + data: any, + dispatch: Function, + reactSettings: any, + setReactSettings: Function, + onSubmitOrCancelCallFunctionNamed: string, +} + +/** + * FavoritesView Component + * @param {FavoritesViewProps} props + * @returns {React$Node} + */ +function FavoritesViewComponent({ + data, + dispatch, + reactSettings, + setReactSettings, + onSubmitOrCancelCallFunctionNamed: _onSubmitOrCancelCallFunctionNamed, +}: FavoritesViewProps): Node { + const { pluginData } = data + + // Map to store pending requests for request/response pattern + const pendingRequestsRef = useRef void, reject: (error: Error) => void, timeoutId: any }>>(new Map()) + + // Store windowId in a ref + const windowIdRef = useRef(pluginData?.windowId || 'favorites-browser-window') + + // Update windowId ref when pluginData changes + useEffect(() => { + windowIdRef.current = pluginData?.windowId || 'favorites-browser-window' + }, [pluginData?.windowId]) + + // State + const [favoriteNotes, setFavoriteNotes] = useState>([]) + const [favoriteCommands, setFavoriteCommands] = useState>([]) + const [loading, setLoading] = useState(true) + const [filterText, setFilterText] = useState('') + const [selectedIndex, setSelectedIndex] = useState(null) + const [showNotes, setShowNotes] = useState(reactSettings?.showNotes !== false) // Default to notes + const [projectNotes, setProjectNotes] = useState>([]) + const [presetCommands, setPresetCommands] = useState>([]) + const [showAddNoteDialog, setShowAddNoteDialog] = useState(false) + const [showAddCommandDialog, setShowAddCommandDialog] = useState(false) + const [addNoteDialogData, setAddNoteDialogData] = useState<{ [key: string]: any }>({}) + const [addCommandDialogData, setAddCommandDialogData] = useState<{ [key: string]: any }>({}) + const [newlyAddedFilename, setNewlyAddedFilename] = useState(null) // Track newly added item for highlighting + const listRef = useRef(null) // Ref for scrolling to items + + // Request function + const requestFromPlugin = useCallback((command: string, dataToSend: any = {}, timeout: number = 10000): Promise => { + if (!command) throw new Error('requestFromPlugin: command must be called with a string') + + const correlationId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + logDebug('FavoritesView', `requestFromPlugin: command="${command}", correlationId="${correlationId}"`) + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + const pending = pendingRequestsRef.current.get(correlationId) + if (pending) { + pendingRequestsRef.current.delete(correlationId) + logDebug('FavoritesView', `requestFromPlugin TIMEOUT: command="${command}", correlationId="${correlationId}"`) + reject(new Error(`Request timeout: ${command}`)) + } + }, timeout) + + pendingRequestsRef.current.set(correlationId, { resolve, reject, timeoutId }) + + const requestData = { + ...dataToSend, + __correlationId: correlationId, + __requestType: 'REQUEST', + __windowId: windowIdRef.current || '', + } + + dispatch('SEND_TO_PLUGIN', [command, requestData], `FavoritesView: requestFromPlugin: ${String(command)}`) + }) + .then((result) => { + logDebug('FavoritesView', `requestFromPlugin RESOLVED: command="${command}", correlationId="${correlationId}"`) + return result + }) + .catch((error) => { + logError('FavoritesView', `requestFromPlugin REJECTED: command="${command}", correlationId="${correlationId}", error="${error.message}"`) + throw error + }) + }, [dispatch]) + + // Listen for RESPONSE messages + useEffect(() => { + const handleResponse = (event: MessageEvent) => { + const { data: eventData } = event + if (eventData && typeof eventData === 'object' && eventData.type === 'RESPONSE' && eventData.payload) { + const payload = eventData.payload + if (payload && typeof payload === 'object') { + const correlationId = (payload: any).correlationId + const success = (payload: any).success + if (correlationId && typeof correlationId === 'string') { + const { data: responseData, error } = (payload: any) + const pending = pendingRequestsRef.current.get(correlationId) + if (pending) { + pendingRequestsRef.current.delete(correlationId) + clearTimeout(pending.timeoutId) + if (success) { + pending.resolve(responseData) + } else { + pending.reject(new Error(error || 'Request failed')) + } + } + } + } + } + } + + window.addEventListener('message', handleResponse) + return () => { + window.removeEventListener('message', handleResponse) + pendingRequestsRef.current.forEach((pending) => { + clearTimeout(pending.timeoutId) + }) + pendingRequestsRef.current.clear() + } + }, []) + + // Load favorite notes + const loadFavoriteNotes = useCallback(async () => { + try { + setLoading(true) + const notes = await requestFromPlugin('getFavoriteNotes') + if (Array.isArray(notes)) { + setFavoriteNotes(notes) + logDebug('FavoritesView', `Loaded ${notes.length} favorite notes`) + } + } catch (error) { + logError('FavoritesView', `Error loading favorite notes: ${error.message}`) + } finally { + setLoading(false) + } + }, [requestFromPlugin]) + + // Effect to scroll to and highlight newly added item + useEffect(() => { + if (newlyAddedFilename && favoriteNotes.length > 0 && listRef.current) { + // Find the index of the newly added item + const newIndex = favoriteNotes.findIndex((note) => note.filename === newlyAddedFilename) + if (newIndex >= 0) { + // Wait a bit for DOM to update, then scroll to the item + setTimeout(() => { + const item = listRef.current?.querySelector(`[data-index="${newIndex}"]`) + if (item instanceof HTMLElement) { + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + // Remove highlight after animation completes (2 seconds) + setTimeout(() => { + setNewlyAddedFilename(null) + }, 2000) + } + }, 100) + } + } + }, [newlyAddedFilename, favoriteNotes]) + + // Load favorite commands + const loadFavoriteCommands = useCallback(async () => { + try { + setLoading(true) + const commands = await requestFromPlugin('getFavoriteCommands') + if (Array.isArray(commands)) { + setFavoriteCommands(commands) + logDebug('FavoritesView', `Loaded ${commands.length} favorite commands`) + } + } catch (error) { + logError('FavoritesView', `Error loading favorite commands: ${error.message}`) + } finally { + setLoading(false) + } + }, [requestFromPlugin]) + + // Load data when view type changes + useEffect(() => { + if (showNotes) { + loadFavoriteNotes() + } else { + loadFavoriteCommands() + } + }, [showNotes, loadFavoriteNotes, loadFavoriteCommands]) + + // Load project notes for NoteChooser + const loadProjectNotes = useCallback(async () => { + try { + const notes = await requestFromPlugin('getProjectNotes') + if (Array.isArray(notes)) { + setProjectNotes(notes) + logDebug('FavoritesView', `Loaded ${notes.length} project notes`) + } + } catch (error) { + logError('FavoritesView', `Error loading project notes: ${error.message}`) + } + }, [requestFromPlugin]) + + // Load preset commands for command dialog + const loadPresetCommands = useCallback(async () => { + try { + const commands = await requestFromPlugin('getPresetCommands') + if (Array.isArray(commands)) { + setPresetCommands(commands) + logDebug('FavoritesView', `Loaded ${commands.length} preset commands`) + } + } catch (error) { + logError('FavoritesView', `Error loading preset commands: ${error.message}`) + } + }, [requestFromPlugin]) + + // Handle adding favorite note dialog + const handleAddNoteDialogSave = useCallback((updatedSettings: { [key: string]: any }) => { + ;(async () => { + try { + if (updatedSettings.note) { + const filename = updatedSettings.note + + // Close dialog immediately + setShowAddNoteDialog(false) + setAddNoteDialogData({}) + + // Add the favorite + // Note: requestFromPlugin resolves with result.data (unwrapped), or rejects on error + // If we get here without throwing, the request succeeded + const response = await requestFromPlugin('addFavoriteNote', { filename }) + logDebug('FavoritesView', `addFavoriteNote response:`, response) + + // Show success toast + dispatch('SHOW_TOAST', { + type: 'SUCCESS', + msg: 'Favorite note added successfully', + timeout: 3000, + }) + + // Reload the favorites list first + await loadFavoriteNotes() + + // Wait for the note to appear in the list by checking the actual list data + // We need to reload and check, since state updates are async + const found = await waitForCondition( + async () => { + // Reload notes to get fresh data, then check + if (showNotes) { + const notes = await requestFromPlugin('getFavoriteNotes') + if (Array.isArray(notes)) { + return notes.some((note) => note.filename === filename) + } + } + return false + }, + { maxWaitMs: 3000, checkIntervalMs: 150 } + ) + + // Reload one more time to ensure UI is in sync + await loadFavoriteNotes() + + // Set the newly added filename for highlighting (useEffect will handle scrolling) + setNewlyAddedFilename(filename) + + if (found) { + logDebug('FavoritesView', 'Successfully added favorite note and found it in list') + } else { + logError('FavoritesView', 'Added favorite note but could not find it in list after waiting') + } + } + } catch (error) { + logError('FavoritesView', `Error adding favorite note: ${error.message}`) + dispatch('SHOW_TOAST', { + type: 'ERROR', + msg: `Error adding favorite: ${error.message}`, + timeout: 3000, + }) + } + })() + }, [requestFromPlugin, loadFavoriteNotes, dispatch, showNotes, favoriteNotes]) + + const handleAddNoteDialogCancel = useCallback(() => { + setShowAddNoteDialog(false) + setAddNoteDialogData({}) + }, []) + + const handleAddFavoriteNote = useCallback(async () => { + // Load notes if not already loaded + if (projectNotes.length === 0) { + await loadProjectNotes() + } + setShowAddNoteDialog(true) + }, [projectNotes, loadProjectNotes]) + + // Handle adding favorite command dialog + const handleAddCommandDialogSave = useCallback((updatedSettings: { [key: string]: any }) => { + ;(async () => { + try { + if (updatedSettings.preset && updatedSettings.commandName && updatedSettings.url) { + const response = await requestFromPlugin('addFavoriteCommand', { + jsFunction: updatedSettings.preset, + name: updatedSettings.commandName, + data: updatedSettings.url, + }) + if (response && response.success) { + await loadFavoriteCommands() + setShowAddCommandDialog(false) + setAddCommandDialogData({}) + logDebug('FavoritesView', 'Successfully added favorite command') + } else { + logError('FavoritesView', `Failed to add favorite command: ${response?.message || 'Unknown error'}`) + } + } + } catch (error) { + logError('FavoritesView', `Error adding favorite command: ${error.message}`) + } + })() + }, [requestFromPlugin, loadFavoriteCommands]) + + const handleAddCommandDialogCancel = useCallback(() => { + setShowAddCommandDialog(false) + setAddCommandDialogData({}) + }, []) + + const handleAddCommandButtonClick = useCallback((key: string, value: string) => { + if (key === 'getCallbackURL') { + ;(async () => { + try { + const urlResponse = await requestFromPlugin('getCallbackURL', {}) + if (urlResponse && urlResponse.success && urlResponse.url) { + // Update the URL field in the dialog + setAddCommandDialogData((prev) => ({ ...prev, url: urlResponse.url })) + logDebug('FavoritesView', `Got URL from Link Creator: ${urlResponse.url}`) + } + } catch (error) { + logError('FavoritesView', `Error getting callback URL: ${error.message}`) + } + })() + return false // Don't close dialog + } + }, [requestFromPlugin]) + + const handleAddFavoriteCommand = useCallback(async () => { + // Load preset commands if not already loaded + if (presetCommands.length === 0) { + await loadPresetCommands() + } + + if (presetCommands.length === 0) { + logError('FavoritesView', 'No preset commands available') + return + } + + setShowAddCommandDialog(true) + }, [presetCommands, loadPresetCommands]) + + // Handle item click + // Note: __windowId is automatically injected by Root.jsx sendToPlugin, so we don't need to add it here + const handleItemClick = useCallback((item: FavoriteNote | FavoriteCommand, event: MouseEvent) => { + const isOptionClick = event.altKey || event.metaKey === false && event.ctrlKey // Alt key (option on Mac) + const isCmdClick = event.metaKey || event.ctrlKey // Cmd key (meta on Mac, ctrl on Windows) + + if (showNotes) { + // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true + const note: FavoriteNote = (item: any) + // Send action to plugin to open note + dispatch('SEND_TO_PLUGIN', [ + 'openNote', + { + filename: note.filename, + newWindow: isCmdClick, // Cmd-click opens in floating window + splitView: isOptionClick, // Option-click opens in split view + }, + ], 'FavoritesView: openNote') + } else { + // $FlowFixMe[incompatible-cast] - item is FavoriteCommand when showNotes is false + const command: FavoriteCommand = (item: any) + // Send action to plugin to run command + dispatch('SEND_TO_PLUGIN', [ + 'runCommand', + { + jsFunction: command.jsFunction, + data: command.data, + }, + ], 'FavoritesView: runCommand') + } + }, [showNotes, dispatch]) + + // Get current items based on view type + const currentItems = useMemo(() => { + return showNotes ? favoriteNotes : favoriteCommands + }, [showNotes, favoriteNotes, favoriteCommands]) + + // Handle removing favorite note + const handleRemoveFavorite = useCallback(async (filename: string) => { + try { + const response = await requestFromPlugin('removeFavoriteNote', { filename }) + if (response && response.success) { + // Show toast notification + dispatch('SHOW_TOAST', { + type: 'SUCCESS', + msg: 'Favorite note removed', + timeout: 2000, + }) + // Reload the favorites list + await loadFavoriteNotes() + } else { + logError('FavoritesView', `Failed to remove favorite note: ${response?.message || 'Unknown error'}`) + dispatch('SHOW_TOAST', { + type: 'ERROR', + msg: `Failed to remove favorite: ${response?.message || 'Unknown error'}`, + timeout: 3000, + }) + } + } catch (error) { + logError('FavoritesView', `Error removing favorite note: ${error.message}`) + dispatch('SHOW_TOAST', { + type: 'ERROR', + msg: `Error removing favorite: ${error.message}`, + timeout: 3000, + }) + } + }, [requestFromPlugin, loadFavoriteNotes, dispatch]) + + // Render note item + const renderNoteItem = useCallback((item: any, index: number): Node => { + // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true + const note: FavoriteNote = item + const folder = note.folder || '' + const folderDisplay = folder && folder !== '/' ? `${folder} / ` : '' + const displayTitle = note.title || note.filename || 'Untitled' + + // Always show an icon - use note icon if provided, otherwise use default + const icon = note.icon || defaultNoteIconDetails.icon + const color = note.color || defaultNoteIconDetails.color + const isNewlyAdded = newlyAddedFilename === note.filename + + return ( +
+ +
+
{displayTitle}
+ {folder && folder !== '/' && ( +
{folderDisplay}
+ )} +
+ { + e.preventDefault() + e.stopPropagation() + handleRemoveFavorite(note.filename) + }} + /> +
+ ) + }, [newlyAddedFilename, handleRemoveFavorite]) + + // Render command item + const renderCommandItem = useCallback((item: any, index: number): Node => { + // $FlowFixMe[incompatible-cast] - item is FavoriteCommand when showNotes is false + const command: FavoriteCommand = item + return ( +
+ +
+
{command.name}
+ {command.description && ( +
{command.description}
+ )} +
+
+ ) + }, []) + + // Filter function for notes + const filterNote = useCallback((item: any, text: string): boolean => { + if (!text) return true + // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true + const note: FavoriteNote = item + const searchText = text.toLowerCase() + const title = (note.title || '').toLowerCase() + const folder = (note.folder || '').toLowerCase() + return title.includes(searchText) || folder.includes(searchText) + }, []) + + // Filter function for commands + const filterCommand = useCallback((item: any, text: string): boolean => { + if (!text) return true + // $FlowFixMe[incompatible-cast] - item is FavoriteCommand when showNotes is false + const command: FavoriteCommand = item + const searchText = text.toLowerCase() + const name = (command.name || '').toLowerCase() + const description = (command.description || '').toLowerCase() + return name.includes(searchText) || description.includes(searchText) + }, []) + + // Get item label for filtering + const getItemLabel = useCallback((item: any): string => { + if (showNotes) { + // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true + const note: FavoriteNote = item + return note.title || note.filename || '' + } else { + // $FlowFixMe[incompatible-cast] - item is FavoriteCommand when showNotes is false + const command: FavoriteCommand = item + return command.name || '' + } + }, [showNotes]) + + // Handle toggle change + const handleToggleChange = useCallback((newShowNotes: boolean) => { + setShowNotes(newShowNotes) + setReactSettings((prev: any) => ({ ...prev, showNotes: newShowNotes })) + setFilterText('') // Clear filter when switching + setSelectedIndex(null) // Reset selection + }, [setReactSettings]) + + // Handle keyboard navigation + // Arrow keys only navigate (change selectedIndex) - they do NOT trigger actions + // Click and Enter trigger actions (run command or open note) + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + // Arrow navigation only - no action triggered + const newIndex = selectedIndex === null || selectedIndex === undefined ? 0 : selectedIndex + 1 + if (newIndex < currentItems.length) { + setSelectedIndex(newIndex) + // Scroll into view + setTimeout(() => { + if (listRef.current) { + const item = listRef.current.querySelector(`[data-index="${newIndex}"]`) + if (item instanceof HTMLElement) { + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + item.focus() + } + } + }, 0) + } + } else if (event.key === 'ArrowUp') { + event.preventDefault() + // Arrow navigation only - no action triggered + if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex > 0) { + const newIndex = selectedIndex - 1 + setSelectedIndex(newIndex) + // Scroll into view + setTimeout(() => { + if (listRef.current) { + const item = listRef.current.querySelector(`[data-index="${newIndex}"]`) + if (item instanceof HTMLElement) { + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + item.focus() + } + } + }, 0) + } + } else if (event.key === 'Enter' && selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0 && selectedIndex < currentItems.length) { + event.preventDefault() + // Enter key triggers the action (run command via x-callback URL or open note) + const item = currentItems[selectedIndex] + if (item) { + handleItemClick(item, (event: any)) + } + } + }, [currentItems, selectedIndex, handleItemClick]) + + // Handle filter input keydown + const handleFilterKeyDown = useCallback((e: any) => { // SyntheticKeyboardEvent + if (e.key === 'ArrowDown' && currentItems.length > 0) { + e.preventDefault() + setSelectedIndex(0) + // Focus the list with setTimeout to ensure DOM is updated + setTimeout(() => { + if (listRef.current) { + const firstItem = listRef.current.querySelector('[data-index="0"]') + if (firstItem instanceof HTMLElement) { + firstItem.focus() + } + } + }, 0) + } else if (e.key === 'Tab' && !e.shiftKey && currentItems.length > 0) { + e.preventDefault() + setSelectedIndex(0) + setTimeout(() => { + if (listRef.current) { + const firstItem = listRef.current.querySelector('[data-index="0"]') + if (firstItem instanceof HTMLElement) { + firstItem.focus() + } + } + }, 0) + } else { + // Pass other keys to handleKeyDown + handleKeyDown(e.nativeEvent) + } + }, [currentItems.length, handleKeyDown]) + + return ( +
+ {/* Header - only show if floating window */} + {pluginData?.showFloating && ( +
+

Favorites

+
+ )} +
+
+
+ + +
+ +
+
+ + + {/* Add Favorite Note Dialog */} + { + loadProjectNotes().catch((error) => { + logError('FavoritesView', `Error reloading notes: ${error.message}`) + }) + }} + /> + + {/* Add Favorite Command Dialog */} + ({ label: cmd.label, value: cmd.value, isDefault: false })), + required: true, + }, + { + type: 'input', + key: 'commandName', + label: 'Command Name', + description: 'What human-readable text do you want to use for the command? (this is the text you will see in the Command Bar when you type slash)', + placeholder: 'Enter command name', + required: true, + }, + { + type: 'input', + key: 'url', + label: 'X-Callback URL or Web URL', + description: 'Enter the X-Callback URL or Web URL to run when this command is selected', + placeholder: 'noteplan://x-callback-url/... or https://...', + required: true, + value: addCommandDialogData.url || '', + }, + { + type: 'button', + key: 'getCallbackURL', + label: 'Use Link Creator', + buttonText: 'Get X-Callback URL from Link Creator', + }, + ]} + onSave={handleAddCommandDialogSave} + onCancel={handleAddCommandDialogCancel} + isModal={true} + handleButtonClick={handleAddCommandButtonClick} + /> +
+ ) +} + +/** + * Root FavoritesView Component with AppProvider + */ +export function FavoritesView({ + data, + dispatch, + reactSettings, + setReactSettings, + onSubmitOrCancelCallFunctionNamed, +}: FavoritesViewProps): Node { + // Map to store pending requests + const pendingRequestsRef = useRef void, reject: (error: Error) => void, timeoutId: any }>>(new Map()) + + const { pluginData } = data + const windowIdRef = useRef(pluginData?.windowId || 'favorites-browser-window') + + useEffect(() => { + windowIdRef.current = pluginData?.windowId || 'favorites-browser-window' + }, [pluginData?.windowId]) + + // Request function for AppContext + const requestFromPlugin = useCallback((command: string, dataToSend: any = {}, timeout: number = 10000): Promise => { + if (!command) throw new Error('requestFromPlugin: command must be called with a string') + + const correlationId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + const pending = pendingRequestsRef.current.get(correlationId) + if (pending) { + pendingRequestsRef.current.delete(correlationId) + reject(new Error(`Request timeout: ${command}`)) + } + }, timeout) + + pendingRequestsRef.current.set(correlationId, { resolve, reject, timeoutId }) + + const requestData = { + ...dataToSend, + __correlationId: correlationId, + __requestType: 'REQUEST', + __windowId: windowIdRef.current || '', + } + + dispatch('SEND_TO_PLUGIN', [command, requestData], `FavoritesView: requestFromPlugin: ${String(command)}`) + }) + }, [dispatch]) + + // Listen for RESPONSE messages + useEffect(() => { + const handleResponse = (event: MessageEvent) => { + const { data: eventData } = event + if (eventData && typeof eventData === 'object' && eventData.type === 'RESPONSE' && eventData.payload) { + const payload = eventData.payload + if (payload && typeof payload === 'object') { + const correlationId = (payload: any).correlationId + const success = (payload: any).success + if (correlationId && typeof correlationId === 'string') { + const { data: responseData, error } = (payload: any) + const pending = pendingRequestsRef.current.get(correlationId) + if (pending) { + pendingRequestsRef.current.delete(correlationId) + clearTimeout(pending.timeoutId) + if (success) { + pending.resolve(responseData) + } else { + pending.reject(new Error(error || 'Request failed')) + } + } + } + } + } + } + + window.addEventListener('message', handleResponse) + return () => { + window.removeEventListener('message', handleResponse) + pendingRequestsRef.current.forEach((pending) => { + clearTimeout(pending.timeoutId) + }) + pendingRequestsRef.current.clear() + } + }, []) + + const sendActionToPlugin = useCallback((command: string, dataToSend: any) => { + dispatch('SEND_TO_PLUGIN', [command, dataToSend], `FavoritesView: sendActionToPlugin: ${String(command)}`) + }, [dispatch]) + + const sendToPlugin = useCallback((command: string, dataToSend: any) => { + dispatch('SEND_TO_PLUGIN', [command, dataToSend], `FavoritesView: sendToPlugin: ${String(command)}`) + }, [dispatch]) + + const updatePluginData = useCallback((newData: any, messageForLog?: string) => { + const newFullData = { ...data, pluginData: newData } + dispatch('UPDATE_DATA', newFullData, messageForLog) + }, [data, dispatch]) + + return ( + + + + ) +} + diff --git a/dwertheimer.Favorites/src/favoritesRouter.js b/dwertheimer.Favorites/src/favoritesRouter.js new file mode 100644 index 000000000..a3d8e29fe --- /dev/null +++ b/dwertheimer.Favorites/src/favoritesRouter.js @@ -0,0 +1,79 @@ +// @flow +//-------------------------------------------------------------------------- +// Favorites Router +// Routes requests from FavoritesView React component to appropriate handlers +//-------------------------------------------------------------------------- + +import { + handleGetFavoriteNotes, + handleGetFavoriteCommands, + handleOpenNote, + handleRunCommand, + handleAddFavoriteNote, + handleRemoveFavoriteNote, + handleGetPresetCommands, + handleAddFavoriteCommand, + handleGetCallbackURL, + handleGetProjectNotes, + handleRenderMarkdown, + handleGetNoteContentAsHTML, +} from './requestHandlers' +import { createRouter, type RequestResponse } from './routerUtils' + +const FAVORITES_BROWSER_WINDOW_ID = 'favorites-browser-window' + +/** + * Route request to appropriate handler based on action type + * @param {string} actionType - The action/command type + * @param {any} data - Request data + * @returns {Promise} + */ +async function routeFavoritesRequest(actionType: string, data: any): Promise { + switch (actionType) { + case 'getFavoriteNotes': + return await handleGetFavoriteNotes(data) + case 'getFavoriteCommands': + return await handleGetFavoriteCommands(data) + case 'openNote': + return await handleOpenNote(data) + case 'runCommand': + return await handleRunCommand(data) + case 'addFavoriteNote': + return await handleAddFavoriteNote(data) + case 'removeFavoriteNote': + return await handleRemoveFavoriteNote(data) + case 'getPresetCommands': + return await handleGetPresetCommands(data) + case 'addFavoriteCommand': + return await handleAddFavoriteCommand(data) + case 'getCallbackURL': + return await handleGetCallbackURL(data) + case 'getProjectNotes': + return await handleGetProjectNotes(data) + case 'renderMarkdown': + return await handleRenderMarkdown(data) + case 'getNoteContentAsHTML': + return await handleGetNoteContentAsHTML(data) + default: + return { + success: false, + message: `Unknown action type: ${actionType}`, + } + } +} + +/** + * Handle actions from FavoritesView React component + * Routes requests to appropriate handlers and sends responses back + * @param {string} actionType - The action/command type + * @param {any} data - Request data with optional __requestType, __correlationId, __windowId + * @returns {Promise} + */ +export const onFavoritesBrowserAction: (actionType: string, data: any) => Promise = createRouter({ + routerName: 'onFavoritesBrowserAction', + defaultWindowId: FAVORITES_BROWSER_WINDOW_ID, + routeRequest: routeFavoritesRequest, + // Also handle non-REQUEST actions (like SEND_TO_PLUGIN) by routing them the same way + handleNonRequestAction: routeFavoritesRequest, +}) + diff --git a/dwertheimer.Favorites/src/index.js b/dwertheimer.Favorites/src/index.js index 2baa4b3ea..6ee0ad9df 100644 --- a/dwertheimer.Favorites/src/index.js +++ b/dwertheimer.Favorites/src/index.js @@ -32,6 +32,9 @@ export { runPreset20, } from './NPFavoritePresets' +export { openFavoritesBrowser } from './windowManagement' +export { onFavoritesBrowserAction } from './favoritesRouter' + /** * NotePlan calls this function after the plugin is installed or updated. * The `updateSettingData` function looks through the new plugin settings in plugin.json and updates diff --git a/dwertheimer.Favorites/src/requestHandlers.js b/dwertheimer.Favorites/src/requestHandlers.js new file mode 100644 index 000000000..de48a9df9 --- /dev/null +++ b/dwertheimer.Favorites/src/requestHandlers.js @@ -0,0 +1,624 @@ +// @flow +//-------------------------------------------------------------------------- +// Request Handlers - Handle requests from React components +//-------------------------------------------------------------------------- + +import pluginJson from '../plugin.json' +import { favoriteNotes, noteIsFavorite, getFavoritedTitle, removeFavoriteFromTitle } from './favorites' +import { getConfig } from './NPFavorites' +import { type RequestResponse } from './routerUtils' +import { getFrontmatterNotes, ensureFrontmatter, getFrontmatterAttributes, updateFrontMatterVars } from '@helpers/NPFrontMatter' +import { getNoteDecoration } from '@helpers/NPnote' +import { getFolderFromFilename, getFolderDisplayName } from '@helpers/folders' +import { getPluginJson } from '@helpers/NPConfiguration' +import { savePluginCommand } from '@helpers/NPPresets' +import { logDebug, logError, JSP } from '@helpers/dev' +import { getNote, setTitle } from '@helpers/note' +import { getNoteContentAsHTML } from '@helpers/HTMLView' + +/** + * Handle request to get favorite notes + * @param {Object} requestData - Request data + * @returns {Promise} + */ +export async function handleGetFavoriteNotes(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleGetFavoriteNotes: ENTRY`) + const config = await getConfig() + + // Get all notes with frontmatter + const notesWithFM = getFrontmatterNotes() // not including template notes + const notesWithStars = DataStore.projectNotes.filter((note) => note.title?.includes(config.favoriteIcon)) + const combinedNotes = [...notesWithFM, ...notesWithStars] + const nonDuplicateNotes = combinedNotes.filter((note, index, self) => self.findIndex((t) => t.filename === note.filename) === index) + const faveNotes = favoriteNotes(nonDuplicateNotes, config) + + // Format notes for React component + const formattedNotes = faveNotes.map((note) => { + const decoration = getNoteDecoration(note) + const folder = getFolderFromFilename(note.filename) || '/' + const folderDisplay = getFolderDisplayName(folder) || folder + + return { + filename: note.filename, + title: note.title || '', + type: note.type || 'Notes', + frontmatterAttributes: note.frontmatterAttributes || {}, + icon: decoration.icon, + color: decoration.color, + folder: folderDisplay, + } + }) + + logDebug(pluginJson, `handleGetFavoriteNotes: Returning ${formattedNotes.length} favorite notes`) + return { + success: true, + data: formattedNotes, + } + } catch (error) { + logError(pluginJson, `handleGetFavoriteNotes: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to get favorite notes', + } + } +} + +/** + * Handle request to get favorite commands (presets) + * @param {Object} requestData - Request data + * @returns {Promise} + */ +export async function handleGetFavoriteCommands(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleGetFavoriteCommands: ENTRY`) + const livePluginJson = await getPluginJson(pluginJson['plugin.id']) + + if (!livePluginJson) { + logError(pluginJson, `handleGetFavoriteCommands: getPluginJson returned null/undefined`) + return { + success: false, + message: 'Failed to load plugin configuration', + } + } + + // plugin.json uses flat keys like 'plugin.commands', not nested objects + const commands = livePluginJson['plugin.commands'] || [] + logDebug(pluginJson, `handleGetFavoriteCommands: Found ${commands.length} total commands`) + + const presetCommands = commands + .filter((cmd) => cmd.isPreset === true && cmd.name && !cmd.name.match(/^Favorites: Set Preset/)) + .map((cmd) => { + // Strip leading dashes and whitespace from names for display + const displayName = (cmd.name || '').replace(/^[-]\s*/, '') + return { + name: displayName, + description: cmd.description || '', + jsFunction: cmd.jsFunction || '', + data: cmd.data || cmd.URL || '', + } + }) + + logDebug(pluginJson, `handleGetFavoriteCommands: Returning ${presetCommands.length} favorite commands`) + return { + success: true, + data: presetCommands, + } + } catch (error) { + logError(pluginJson, `handleGetFavoriteCommands: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to get favorite commands', + } + } +} + +/** + * Handle request to open a note + * @param {Object} requestData - Request data with filename, newWindow, splitView + * @returns {Promise} + */ +export async function handleOpenNote(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleOpenNote: ENTRY - filename="${requestData.filename}", newWindow=${String(requestData.newWindow)}, splitView=${String(requestData.splitView)}`) + + const { filename, newWindow = false, splitView = false } = requestData + + if (!filename) { + return { + success: false, + message: 'Filename is required', + } + } + + // Use Editor.openNoteByFilename with options + // Parameters: filename, newWindow, highlightStart, highlightEnd, splitView, createIfNeeded, content + // Note: Editor.openNoteByFilename returns a Promise + // It may return void even on success, so we don't check the return value + await Editor.openNoteByFilename(filename, newWindow, 0, 0, splitView, false, undefined) + + logDebug(pluginJson, `handleOpenNote: Successfully opened note "${filename}"`) + return { + success: true, + data: { filename }, + } + } catch (error) { + logError(pluginJson, `handleOpenNote: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to open note', + } + } +} + +/** + * Handle request to run a command + * @param {Object} requestData - Request data with jsFunction and data + * @returns {Promise} + */ +export async function handleRunCommand(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleRunCommand: ENTRY - jsFunction="${requestData.jsFunction}"`) + + const { jsFunction, data } = requestData + + if (!jsFunction) { + return { + success: false, + message: 'jsFunction is required', + } + } + + // If data is a URL, open it + if (data && typeof data === 'string' && (data.startsWith('http') || data.startsWith('noteplan://'))) { + NotePlan.openURL(data) + logDebug(pluginJson, `handleRunCommand: Opened URL: ${data}`) + } else { + // Otherwise, try to call the function if it exists + logDebug(pluginJson, `handleRunCommand: Command function not directly callable, URL method used`) + } + + return { + success: true, + data: { jsFunction }, + } + } catch (error) { + logError(pluginJson, `handleRunCommand: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to run command', + } + } +} + +/** + * Handle request to add a favorite note + * @param {Object} requestData - Request data with filename + * @returns {Promise} + */ +export async function handleAddFavoriteNote(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleAddFavoriteNote: ENTRY - filename="${requestData.filename}"`) + + const { filename } = requestData + + if (!filename) { + return { + success: false, + message: 'Filename is required', + } + } + + // Find the note + const note = DataStore.projectNoteByFilename(filename) + if (!note) { + return { + success: false, + message: `Note not found: "${filename}"`, + } + } + + // Check if already a favorite + const config = await getConfig() + if (noteIsFavorite(note, config)) { + return { + success: false, + message: 'This note is already a favorite', + } + } + + // Set it as favorite directly without opening the note + // We'll modify the note's title or frontmatter directly + const { favoriteKey, favoriteIcon, position, favoriteIdentifier } = config + + if (favoriteIdentifier.includes('Star')) { + // Add star to title + const newTitle = getFavoritedTitle(note.title || '', position, favoriteIcon, favoriteIdentifier) + setTitle(note, newTitle) + } + + if (favoriteIdentifier.includes('Frontmatter')) { + // Add frontmatter field + ensureFrontmatter(note) + const fm = getFrontmatterAttributes(note) + if (typeof fm === 'object' && fm !== null) { + fm[favoriteKey] = 'true' + updateFrontMatterVars(note, fm) + } + } + + logDebug(pluginJson, `handleAddFavoriteNote: Successfully added favorite note`) + return { + success: true, + data: { filename }, + } + } catch (error) { + logError(pluginJson, `handleAddFavoriteNote: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to add favorite note', + } + } +} + +/** + * Handle request to remove favorite note + * @param {Object} requestData - Request data with filename + * @returns {Promise} + */ +export async function handleRemoveFavoriteNote(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleRemoveFavoriteNote: ENTRY - filename="${requestData.filename}"`) + + const { filename } = requestData + + if (!filename) { + return { + success: false, + message: 'Filename is required', + } + } + + // Find the note + const note = DataStore.projectNoteByFilename(filename) + if (!note) { + return { + success: false, + message: `Note not found: "${filename}"`, + } + } + + // Check if it's a favorite + const config = await getConfig() + if (!noteIsFavorite(note, config)) { + return { + success: false, + message: 'This note is not a favorite', + } + } + + // Remove favorite status directly without opening the note + const { favoriteKey, favoriteIcon, favoriteIdentifier } = config + + if (favoriteIdentifier.includes('Star')) { + // Remove star from title + const newTitle = removeFavoriteFromTitle(note.title || '', favoriteIcon, favoriteIdentifier) + setTitle(note, newTitle) + } + + if (favoriteIdentifier.includes('Frontmatter')) { + // Remove frontmatter field + const fm = getFrontmatterAttributes(note) + if (typeof fm === 'object' && fm !== null && fm[favoriteKey]) { + const updatedFm = { ...fm } + delete updatedFm[favoriteKey] + updateFrontMatterVars(note, updatedFm) + } + } + + logDebug(pluginJson, `handleRemoveFavoriteNote: Successfully removed favorite note`) + return { + success: true, + data: { filename }, + } + } catch (error) { + logError(pluginJson, `handleRemoveFavoriteNote: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to remove favorite note', + } + } +} + +/** + * Handle request to get preset commands for selection + * @param {Object} requestData - Request data + * @returns {Promise} + */ +export async function handleGetPresetCommands(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleGetPresetCommands: ENTRY`) + + const livePluginJson = await getPluginJson(pluginJson['plugin.id']) + if (!livePluginJson || !livePluginJson['plugin.commands']) { + return { + success: false, + message: 'Plugin configuration is missing plugin.commands', + } + } + + const commands = livePluginJson['plugin.commands'] + const presetCommands = commands.filter((command) => command.isPreset === true) + + // Map to options format for dropdown + const options = presetCommands.map((command) => ({ + label: command.name || command.jsFunction, + value: command.jsFunction, + })) + + logDebug(pluginJson, `handleGetPresetCommands: Returning ${options.length} preset commands`) + return { + success: true, + data: options, + } + } catch (error) { + logError(pluginJson, `handleGetPresetCommands: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to get preset commands', + } + } +} + +/** + * Handle request to add/update a favorite command (preset) + * @param {Object} requestData - Request data with jsFunction, name, and data (URL) + * @returns {Promise} + */ +export async function handleAddFavoriteCommand(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleAddFavoriteCommand: ENTRY - jsFunction="${requestData.jsFunction}", name="${requestData.name}"`) + + const { jsFunction, name, data: url } = requestData + + if (!jsFunction) { + return { + success: false, + message: 'jsFunction is required', + } + } + + if (!name || !name.trim()) { + return { + success: false, + message: 'Command name is required', + } + } + + if (!url || !url.trim()) { + return { + success: false, + message: 'URL/X-Callback is required', + } + } + + // Validate URL + const isValidURL = (url: string) => /^(https?|[a-z0-9\-]+):\/\/[a-z0-9\-]+/i.test(url) + if (!isValidURL(url)) { + return { + success: false, + message: `"${url}" is not a valid URL. Must be an X-Callback URL or full Web URL.`, + } + } + + // Get the command details from plugin.json + const livePluginJson = await getPluginJson(pluginJson['plugin.id']) + if (!livePluginJson || !livePluginJson['plugin.commands']) { + return { + success: false, + message: 'Plugin configuration is missing plugin.commands', + } + } + + const commands = livePluginJson['plugin.commands'] + const command = commands.find((cmd) => cmd.jsFunction === jsFunction && cmd.isPreset === true) + + if (!command) { + return { + success: false, + message: `Preset command not found: ${jsFunction}`, + } + } + + // Apply charsToPrepend if configured + const config = DataStore.settings + let commandName = name.trim() + if (config.charsToPrepend) { + commandName = `${config.charsToPrepend}${commandName}` + } + + // Save the command using savePluginCommand + await savePluginCommand(pluginJson, { ...command, name: commandName, data: url }) + + logDebug(pluginJson, `handleAddFavoriteCommand: Successfully saved preset command`) + return { + success: true, + data: { jsFunction, name: commandName, data: url }, + } + } catch (error) { + logError(pluginJson, `handleAddFavoriteCommand: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to add favorite command', + } + } +} + +/** + * Handle request to get X-Callback URL using Link Creator + * @param {Object} requestData - Request data with commandName and defaultValue + * @returns {Promise} + */ +export async function handleGetCallbackURL(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleGetCallbackURL: ENTRY`) + + const { commandName, defaultValue } = requestData + + // Call the Link Creator plugin + const url = await DataStore.invokePluginCommandByName('Get X-Callback-URL', 'np.CallbackURLs', ['', true]) + + if (url && typeof url === 'string') { + logDebug(pluginJson, `handleGetCallbackURL: Successfully got URL from Link Creator`) + return { + success: true, + data: { url }, + } + } else { + return { + success: false, + message: 'No URL returned from Link Creator', + } + } + } catch (error) { + logError(pluginJson, `handleGetCallbackURL: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to get callback URL', + } + } +} + +/** + * Handle request to get project notes for NoteChooser + * @param {Object} requestData - Request data + * @returns {Promise} + */ +export async function handleGetProjectNotes(requestData: Object): Promise { + try { + logDebug(pluginJson, `handleGetProjectNotes: ENTRY`) + + // Get all project notes (not calendar notes) + const notes = DataStore.projectNotes.map((note) => ({ + title: note.title || '', + filename: note.filename || '', + type: note.type || 'Notes', + frontmatterAttributes: note.frontmatterAttributes || {}, + changedDate: note.changedDate?.getTime() || 0, + })) + + logDebug(pluginJson, `handleGetProjectNotes: Returning ${notes.length} project notes`) + return { + success: true, + data: notes, + } + } catch (error) { + logError(pluginJson, `handleGetProjectNotes: Error: ${JSP(error)}`) + return { + success: false, + message: error.message || 'Failed to get project notes', + } + } +} + +/** + * Render markdown text to HTML + * @param {Object} params - Request parameters + * @param {string} params.markdown - Markdown text to render + * @returns {Promise} + */ +export async function handleRenderMarkdown(params: { markdown: string }): Promise { + const startTime: number = Date.now() + try { + logDebug(pluginJson, `handleRenderMarkdown: ENTRY - markdown length=${params.markdown?.length || 0}`) + + if (!params.markdown) { + return { + success: false, + message: 'Markdown text is required', + data: null, + } + } + + // Use a temporary note object to render the markdown + // getNoteContentAsHTML expects (content: string, note: TNote) + const tempNote: any = { + filename: 'temp.md', + content: params.markdown, + paragraphs: [], + } + + const html = await getNoteContentAsHTML(params.markdown, tempNote) + + const totalElapsed: number = Date.now() - startTime + logDebug(pluginJson, `handleRenderMarkdown: COMPLETE - totalElapsed=${totalElapsed}ms`) + + return { + success: true, + data: html, + } + } catch (error) { + const totalElapsed: number = Date.now() - startTime + logError(pluginJson, `handleRenderMarkdown: ERROR - totalElapsed=${totalElapsed}ms, error="${error.message}"`) + return { + success: false, + message: `Failed to render markdown: ${error.message}`, + data: null, + } + } +} + +/** + * Get note content as HTML + * @param {Object} params - Request parameters + * @param {string} params.noteIdentifier - Filename or title of the note + * @param {boolean} params.isFilename - Whether noteIdentifier is a filename (default: true) + * @param {boolean} params.isTitle - Whether noteIdentifier is a title (default: false) + * @returns {Promise} + */ +export async function handleGetNoteContentAsHTML(params: { noteIdentifier: string, isFilename?: boolean, isTitle?: boolean }): Promise { + const startTime: number = Date.now() + try { + logDebug( + pluginJson, + `handleGetNoteContentAsHTML: ENTRY - noteIdentifier="${params.noteIdentifier}", isFilename=${String(params.isFilename ?? true)}, isTitle=${String(params.isTitle ?? false)}`, + ) + + if (!params.noteIdentifier) { + return { + success: false, + message: 'Note identifier is required', + data: null, + } + } + + // Get the note by filename or title + const note = await getNote(params.noteIdentifier, null, '') + if (!note) { + return { + success: false, + message: `Note not found: ${params.noteIdentifier}`, + data: null, + } + } + + // Get the note content as HTML + const html = await getNoteContentAsHTML(note.content, note) + + const totalElapsed: number = Date.now() - startTime + logDebug(pluginJson, `handleGetNoteContentAsHTML: COMPLETE - totalElapsed=${totalElapsed}ms`) + + return { + success: true, + data: html, + } + } catch (error) { + const totalElapsed: number = Date.now() - startTime + logError(pluginJson, `handleGetNoteContentAsHTML: ERROR - totalElapsed=${totalElapsed}ms, error="${error.message}"`) + return { + success: false, + message: `Failed to get note content as HTML: ${error.message}`, + data: null, + } + } +} diff --git a/dwertheimer.Favorites/src/routerUtils.js b/dwertheimer.Favorites/src/routerUtils.js new file mode 100644 index 000000000..7f7610414 --- /dev/null +++ b/dwertheimer.Favorites/src/routerUtils.js @@ -0,0 +1,166 @@ +// @flow +//-------------------------------------------------------------------------- +// Router Utilities +// Shared scaffolding for handling REQUEST/RESPONSE pattern in routers +//-------------------------------------------------------------------------- + +import pluginJson from '../plugin.json' +import { sendToHTMLWindow } from '../../helpers/HTMLView' +import { logDebug, logError, clo, JSP } from '@helpers/dev' + +// RequestResponse type definition +export type RequestResponse = { + success: boolean, + message?: string, + data?: any, +} + +/** + * Get window ID from request data or use default + * @param {any} data - Request data with optional __windowId + * @param {string} defaultWindowId - Default window ID to use if not provided + * @returns {string} - The window ID to use + */ +function getWindowIdFromRequest(data: any, defaultWindowId: string): string { + return data?.__windowId || defaultWindowId +} + +/** + * Handle REQUEST/RESPONSE pattern - shared scaffolding for all routers + * + * RESPONSE PATTERN: + * - Handler functions return: { success: boolean, data?: any, message?: string } + * - This function sends a RESPONSE message to React with: { correlationId, success, data, error } + * - React's handleResponse extracts payload.data and resolves the promise with just the data + * - So requestFromPlugin() resolves with result.data (the actual data, not the wrapper object) + * + * @param {Object} options - Configuration options + * @param {string} options.actionType - The action/command type + * @param {any} options.data - Request data + * @param {string} options.routerName - Name of the router (for logging) + * @param {string} options.defaultWindowId - Default window ID + * @param {Function} options.routeRequest - Function to route the request to appropriate handler + * @param {Function} options.getWindowId - Optional function to get window ID (for complex lookup) + * @returns {Promise} - Empty object (response is sent via sendToHTMLWindow) + */ +export async function handleRequestResponse({ + actionType, + data, + routerName, + defaultWindowId, + routeRequest, + getWindowId, +}: { + actionType: string, + data: any, + routerName: string, + defaultWindowId: string, + routeRequest: (actionType: string, data: any) => Promise, + getWindowId?: (data: any) => Promise | string, +}): Promise { + try { + logDebug(pluginJson, `${routerName}: Handling REQUEST type="${actionType}" with correlationId="${data.__correlationId}"`) + + // Route request to appropriate handler + const result = await routeRequest(actionType, data) + // Don't log the data if it's an object/array to avoid cluttering logs with [object Object] + const dataPreview = result.data != null ? (typeof result.data === 'object' ? `[object]` : String(result.data)) : 'null' + logDebug(pluginJson, `${routerName}: routeRequest result for "${actionType}": success=${String(result.success)}, data type=${typeof result.data}, data="${dataPreview}"`) + + // Get window ID - use custom function if provided, otherwise use default logic + let windowId: string + if (getWindowId) { + // getWindowId can return Promise or string, await handles both + windowId = await getWindowId(data) + } else { + windowId = getWindowIdFromRequest(data, defaultWindowId) + } + logDebug(pluginJson, `${routerName}: Using windowId="${windowId}" for RESPONSE`) + + // Send response back to React + sendToHTMLWindow(windowId, 'RESPONSE', { + correlationId: data.__correlationId, + success: result.success, + data: result.data, + error: result.message, + }) + return {} + } catch (error) { + logError(pluginJson, `${routerName}: Error handling REQUEST: ${error.message || String(error)}`) + let windowId: string + if (getWindowId) { + // getWindowId can return Promise or string, await handles both + windowId = await getWindowId(data) + } else { + windowId = getWindowIdFromRequest(data, defaultWindowId) + } + sendToHTMLWindow(windowId, 'RESPONSE', { + correlationId: data.__correlationId, + success: false, + data: null, + error: error.message || String(error) || 'Unknown error', + }) + return {} + } +} + +/** + * Create a router function with shared REQUEST/RESPONSE handling + * + * RESPONSE PATTERN: + * - Handlers return { success: boolean, data?: any, message?: string } + * - Router sends RESPONSE message: { correlationId, success, data, error } + * - React resolves promise with payload.data (just the data, not the wrapper) + * + * @param {Object} options - Configuration options + * @param {string} options.routerName - Name of the router (for logging) + * @param {string} options.defaultWindowId - Default window ID + * @param {Function} options.routeRequest - Function to route REQUEST type actions + * @param {Function} options.handleNonRequestAction - Optional function to handle non-REQUEST actions + * @param {Function} options.getWindowId - Optional function to get window ID (for complex lookup) + * @returns {Function} - Router function + */ +export function createRouter({ + routerName, + defaultWindowId, + routeRequest, + handleNonRequestAction, + getWindowId, +}: { + routerName: string, + defaultWindowId: string, + routeRequest: (actionType: string, data: any) => Promise, + handleNonRequestAction?: (actionType: string, data: any) => Promise, + getWindowId?: (data: any) => Promise | string, +}): (actionType: string, data: any) => Promise { + return async function router(actionType: string, data: any = null): Promise { + try { + logDebug(pluginJson, `${routerName} received actionType="${actionType}"`) + clo(data, `${routerName} data=`) + + // Check if this is a request that needs a response + if (data?.__requestType === 'REQUEST' && data?.__correlationId) { + return await handleRequestResponse({ + actionType, + data, + routerName, + defaultWindowId, + routeRequest, + getWindowId, + }) + } + + // For non-REQUEST actions, call the optional handler + if (handleNonRequestAction) { + return await handleNonRequestAction(actionType, data) + } + + // Default: return empty object + return {} + } catch (error) { + logError(pluginJson, `${routerName} error: ${JSP(error)}`) + return {} + } + } +} + diff --git a/dwertheimer.Favorites/src/shared/types.js b/dwertheimer.Favorites/src/shared/types.js new file mode 100644 index 000000000..31da6f3e9 --- /dev/null +++ b/dwertheimer.Favorites/src/shared/types.js @@ -0,0 +1,23 @@ +// @flow +//-------------------------------------------------------------------------- +// Shared Types - Used by both React components and back-end code +// These types have no back-end dependencies and can be safely imported by React +//-------------------------------------------------------------------------- + +/** + * Data structure passed to React windows from the plugin + */ +export type PassedData = { + startTime?: Date /* used for timing/debugging */, + title?: string /* React Window Title */, + width?: number /* React Window Width */, + height?: number /* React Window Height */, + pluginData: any /* Your plugin's data to pass on first launch (or edited later) */, + ENV_MODE?: 'development' | 'production', + debug: boolean /* set based on ENV_MODE above */, + logProfilingMessage: boolean /* whether you want to see profiling messages on React redraws (not super interesting) */, + returnPluginCommand: { id: string, command: string } /* plugin jsFunction that will receive comms back from the React window */, + componentPath: string /* the path to the rolled up webview bundle. should be ../pluginID/react.c.WebView.bundle.* */, + passThroughVars?: any /* any data you want to pass through to the React Window */, +} + diff --git a/dwertheimer.Favorites/src/support/performRollup.node.js b/dwertheimer.Favorites/src/support/performRollup.node.js new file mode 100644 index 000000000..956e568e2 --- /dev/null +++ b/dwertheimer.Favorites/src/support/performRollup.node.js @@ -0,0 +1,48 @@ +#!/usr/bin/node + +/** + * FavoritesView Rollup Script + * + * Builds development and production modes for: + * - FavoritesView bundle + * + * Usage: + * node '/path/to/performRollup.node.js' + * + * Options: + * --react Include the React core bundle + * --graph Create the visualization graph + * --watch Watch for changes + */ + +const rollupReactScript = require('../../../scripts/rollup.generic.js') +const { rollupReactFiles, getCommandLineOptions, getRollupConfig } = rollupReactScript + +//eslint-disable-next-line +;(async function () { + const { watch, graph } = getCommandLineOptions() + + const rollupProms = [] + + // FavoritesView bundle configs + const favoritesViewRollupConfigs = [ + getRollupConfig({ + entryPointPath: 'dwertheimer.Favorites/src/support/rollup.FavoritesView.entry.js', + outputFilePath: 'dwertheimer.Favorites/requiredFiles/react.c.FavoritesView.bundle.REPLACEME.js', + externalModules: ['React', 'react', 'reactDOM', 'dom', 'ReactDOM'], + createBundleGraph: graph, + buildMode: 'development', + bundleName: 'FavoritesViewBundle', + }), + ] + + const favoritesViewConfig = favoritesViewRollupConfigs[0] // use only dev version for now + rollupProms.push(rollupReactFiles(favoritesViewConfig, watch, 'dwertheimer.Favorites FavoritesView Component development version')) + + try { + await Promise.all(rollupProms) + } catch (error) { + console.error('Error during rollup:', error) + } +})() + diff --git a/dwertheimer.Favorites/src/support/rollup.FavoritesView.entry.js b/dwertheimer.Favorites/src/support/rollup.FavoritesView.entry.js new file mode 100644 index 000000000..885ac4d68 --- /dev/null +++ b/dwertheimer.Favorites/src/support/rollup.FavoritesView.entry.js @@ -0,0 +1,6 @@ +// use rollup to create the bundle of these included files +// See directions in the performRollup.node.js file + +// Export FavoritesView as WebView for the Root React Component +export { FavoritesView as WebView } from '../components/FavoritesView.jsx' + diff --git a/dwertheimer.Favorites/src/windowManagement.js b/dwertheimer.Favorites/src/windowManagement.js new file mode 100644 index 000000000..b1648e712 --- /dev/null +++ b/dwertheimer.Favorites/src/windowManagement.js @@ -0,0 +1,156 @@ +// @flow +//-------------------------------------------------------------------------- +// Window Management Functions - Opening and managing React windows +//-------------------------------------------------------------------------- + +import pluginJson from '../plugin.json' +import { type PassedData } from './shared/types.js' +import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' +import { logDebug, logError, timer, JSP } from '@helpers/dev' + +const REACT_WINDOW_TITLE = 'Favorites' +const FAVORITES_BROWSER_WINDOW_ID = 'favorites-browser-window' + +/** + * Generate a unique window ID for a favorites browser window + * @param {string} identifier - Optional identifier to make the window unique + * @returns {string} - The unique window ID + */ +export function getFavoritesBrowserWindowId(identifier?: string): string { + const suffix = identifier && identifier.trim() ? ` ${identifier.trim()}` : '' + return `${FAVORITES_BROWSER_WINDOW_ID}${suffix}` +} + +/** + * Gathers key data for the React Window + * @param {boolean} showFloating - Whether this is a floating window + * @param {string} windowId - The window ID + * @returns {PassedData} the React Data Window object + */ +export function createWindowInitData(showFloating: boolean, windowId: string): PassedData { + const startTime = new Date() + logDebug(pluginJson, `createWindowInitData: ENTRY`) + + const pluginData = getPluginData(showFloating, windowId) + const ENV_MODE = 'development' /* helps during development. set to 'production' when ready to release */ + + const dataToPass: PassedData = { + pluginData: { + ...pluginData, + windowId: windowId, + }, + title: REACT_WINDOW_TITLE, + logProfilingMessage: false, + debug: false, + ENV_MODE, + returnPluginCommand: { id: pluginJson['plugin.id'], command: 'onFavoritesBrowserAction' }, + componentPath: `../dwertheimer.Favorites/react.c.FavoritesView.bundle.dev.js`, + startTime, + } + return dataToPass +} + +/** + * Gather data you want passed to the React Window + * @param {boolean} showFloating - Whether this is a floating window + * @param {string} windowId - The window ID + * @returns {[string]: mixed} - the data that your React Window will start with + */ +export function getPluginData(showFloating: boolean, windowId: string): { [string]: mixed } { + logDebug(pluginJson, `getPluginData: ENTRY`) + + const pluginData = { + platform: NotePlan.environment.platform, + windowId: windowId, + showFloating: showFloating, + } + + return pluginData +} + +/** + * Opens the Favorites Browser React window + * @param {boolean|string} _isFloating - If true or 'true', use openReactWindow instead of showInMainWindow + */ +export async function openFavoritesBrowser(_isFloating: boolean | string = false): Promise { + try { + logDebug(pluginJson, `openFavoritesBrowser: Starting, _isFloating=${String(_isFloating)}`) + const startTime = new Date() + + // Make sure we have np.Shared plugin which has the core react code + await DataStore.installOrUpdatePluginsByID(['np.Shared'], false, false, true) + logDebug(pluginJson, `openFavoritesBrowser: installOrUpdatePluginsByID ['np.Shared'] completed`) + + // Determine if this should be a floating window + const isFloating = _isFloating === true || (typeof _isFloating === 'string' && /true/i.test(_isFloating)) + + // Generate unique window ID based on whether it's floating or main window + const windowId = isFloating ? getFavoritesBrowserWindowId('floating') : getFavoritesBrowserWindowId('main') + + // get initial data to pass to the React Window + const data = createWindowInitData(isFloating, windowId) + + const cssTagsString = ` + + + + + \n` + + const windowOptions = { + savedFilename: `../../${pluginJson['plugin.id']}/favorites_browser_output.html` /* for saving a debug version of the html file */, + headerTags: cssTagsString, + windowTitle: REACT_WINDOW_TITLE, + width: 500, + height: 800, + customId: windowId, // Use unique window ID instead of constant + shouldFocus: true, + generalCSSIn: generateCSSFromTheme(), + specificCSS: ` + /* Favorites browser - left justified, full height, expandable width */ + body, html { + margin: 0; + padding: 0; + height: 100vh; + overflow: hidden; + } + #root, .favorites-view-container { + width: 100%; + height: 100vh; + } + /* Keep header controls fixed size and left-aligned */ + .favorites-view-header { + width: 100%; + } + /* Let list items expand to fill available space */ + .favorites-view-container .filterable-list-container { + flex: 1; + min-width: 0; + } + .favorites-view-container .list-container { + width: 100%; + } + `, + postBodyScript: ` + + `, + // Options for showInMainWindow (main window mode) + splitView: false, + icon: 'star', + iconColor: 'blue-500', + autoTopPadding: true, + } + + // Choose the appropriate command based on whether it's floating or main window + const windowType = isFloating ? 'openReactWindow' : 'showInMainWindow' + logDebug(pluginJson, `openFavoritesBrowser: Using ${windowType} (${isFloating ? 'floating' : 'main'} window)`) + await DataStore.invokePluginCommandByName(windowType, 'np.Shared', [data, windowOptions]) + logDebug(pluginJson, `openFavoritesBrowser: Completed after ${timer(startTime)}`) + } catch (error) { + logError(pluginJson, `openFavoritesBrowser: Error: ${JSP(error)}`) + throw error + } +} diff --git a/dwertheimer.Forms/CHANGELOG.md b/dwertheimer.Forms/CHANGELOG.md index 9f4b1bd0d..afd53710f 100644 --- a/dwertheimer.Forms/CHANGELOG.md +++ b/dwertheimer.Forms/CHANGELOG.md @@ -4,6 +4,23 @@ See Plugin [README](https://github.com/NotePlan/plugins/blob/main/dwertheimer.Forms/README.md) for details on available commands and use case. +## [1.0.6] 2025-12-19 @dwertheimer + +- UI improvements for template tag editor: + - Moved +Field and +Date buttons to the left side of the editor + - Moved "Show RAW template code" toggle switch to the right side + - Double-click any pill (tag or text) to switch to RAW mode + +## [1.0.5] 2025-12-19 @dwertheimer + +- Add `folder-chooser` field type: Select folders from a searchable dropdown with smart path truncation (shows beginning and end of long paths with "..." in the middle) +- Add `note-chooser` field type: Select notes from a searchable dropdown with smart text truncation +- Both chooser types include intelligent truncation that preserves the start and end of long paths/titles for better readability + +## [1.0.4] 2025-12-18 @dwertheimer + +- Add Form Builder + ## [1.0.3] 2025-12-18 @dwertheimer - Add readme with basic instructions @@ -16,14 +33,6 @@ See Plugin [README](https://github.com/NotePlan/plugins/blob/main/dwertheimer.Fo ## [1.0.1] 2025-03-06 @dwertheimer - Workaround for frontmatter UI and CSV strings -### Added -List what has been added. If nothing has been changed, this section can be removed. - -### Changed -List what has changed. If nothing has been changed, this section can be removed. - -### Removed -List what has removed. If nothing has been removed, this section can be removed. ## Changelog diff --git a/dwertheimer.Forms/COMMUNICATION_STRATEGY.md b/dwertheimer.Forms/COMMUNICATION_STRATEGY.md new file mode 100644 index 000000000..a8db67abd --- /dev/null +++ b/dwertheimer.Forms/COMMUNICATION_STRATEGY.md @@ -0,0 +1,169 @@ +# Bidirectional Communication Implementation for Forms Plugin + +## Overview + +This document describes the request/response communication pattern implemented between React components and the NotePlan plugin. This enables React components to make async requests to the plugin and receive responses, similar to a REST API. + +## Implementation + +### Architecture + +The implementation uses a **Promise-based request/response pattern with correlation IDs** to enable async/await syntax in React components. + +### React Side (FormView.jsx) + +**1. Request Function (`requestFromPlugin`)** +- Located in `FormView.jsx` and exposed via `AppContext` +- Generates unique correlation IDs: `req-${Date.now()}-${randomString}` +- Stores Promise resolve/reject callbacks in a `Map` keyed by correlation ID +- Sends requests via `sendToPlugin` with `__requestType: 'REQUEST'` and `__correlationId` +- Returns a Promise that resolves when the response arrives +- Includes timeout handling (default: 10 seconds) + +**2. Response Handler** +- Listens for `RESPONSE` messages via `window.addEventListener('message')` +- Looks up correlation ID in pending requests Map +- Resolves or rejects the Promise based on `success` flag +- Cleans up pending requests on component unmount + +**Code Example:** +```javascript +const { requestFromPlugin } = useAppContext() + +const loadFolders = async () => { + try { + const folders = await requestFromPlugin('getFolders', { excludeTrash: true }) + setFolders(folders) + } catch (error) { + console.error('Failed to load folders:', error) + } +} +``` + +### Plugin Side (requestHandlers.js) + +**1. Request Detection** +- `onFormSubmitFromHTMLView` checks for `data.__requestType === 'REQUEST'` +- Extracts `__correlationId` from request data +- Routes to appropriate handler based on `actionType` + +**2. Handler Functions** +- Each handler (e.g., `getFolders`, `getNotes`, `getTeamspaces`, `createFolder`) returns a `RequestResponse` object: + ```javascript + { + success: boolean, + message?: string, + data?: any + } + ``` + +**3. Response Sending** +- Plugin sends response via `sendToHTMLWindow(windowId, 'RESPONSE', {...})` +- Response includes: + - `correlationId`: Matches the request correlation ID + - `success`: Boolean indicating success/failure + - `data`: Response data (on success) + - `error`: Error message (on failure) + +**Code Example:** +```javascript +export function getFolders(params: { excludeTrash?: boolean } = {}): RequestResponse { + try { + const folders = getFoldersMatching([], false, exclusions, false, true) + return { + success: true, + data: folders + } + } catch (error) { + return { + success: false, + message: `Failed to get folders: ${error.message}`, + data: null + } + } +} +``` + +### Message Flow + +1. **React → Plugin:** + ``` + React: requestFromPlugin('getFolders', { excludeTrash: true }) + → Generates correlationId: "req-1234567890-abc123" + → Sends: { __requestType: 'REQUEST', __correlationId: 'req-...', excludeTrash: true } + → Plugin receives via onFormSubmitFromHTMLView('getFolders', data) + ``` + +2. **Plugin → React:** + ``` + Plugin: sendToHTMLWindow(windowId, 'RESPONSE', { + correlationId: 'req-1234567890-abc123', + success: true, + data: ['/', 'Projects', 'Archive', ...] + }) + → React receives via window message event + → Looks up correlationId in pendingRequests Map + → Resolves Promise with data + ``` + +### Available Request Handlers + +- **`getFolders`**: Returns array of folder paths + - Params: `{ excludeTrash?: boolean }` + - Returns: `Array` + +- **`getNotes`**: Returns array of note options with decoration info + - Params: `{ includeCalendarNotes?: boolean }` + - Returns: `Array` + +- **`getTeamspaces`**: Returns array of teamspace definitions + - Params: `{}` + - Returns: `Array` + +- **`createFolder`**: Creates a new folder + - Params: `{ folderPath: string, parentFolder?: string }` + - Returns: `{ success: boolean, folderPath?: string, error?: string }` + +### Error Handling + +- **Timeouts**: Requests timeout after 10 seconds (configurable) +- **Plugin Errors**: Handled in `handleRequest` catch block, sends error response +- **React Errors**: Caught in try/catch blocks around `requestFromPlugin` calls + +### Performance Optimizations + +- **Request Animation Frame**: Uses `requestAnimationFrame` to yield to browser before resolving promises +- **Diagnostic Logging**: `[DIAG]` logs track request/response timing for performance analysis +- **Cleanup**: Pending requests are cleaned up on component unmount to prevent memory leaks + +### Backward Compatibility + +- Existing fire-and-forget actions continue to work via `sendActionToPlugin` +- Only requests with `__requestType: 'REQUEST'` trigger the response pattern +- All other messages continue to use the existing one-way communication + +### Usage in Components + +**FormBuilder.jsx:** +- Loads folders and notes dynamically when form contains `folder-chooser` or `note-chooser` fields +- Uses `useCallback` and `useEffect` to prevent infinite loops + +**FormView.jsx:** +- Loads folders and notes dynamically when form contains chooser fields +- Passes `requestFromPlugin` to `DynamicDialog` for use by chooser components + +**FolderChooser.jsx:** +- Uses `requestFromPlugin` to load teamspaces for decoration +- Uses `requestFromPlugin` to create new folders + +### Testing + +- **`testRequestHandlers`**: Command to test all request handlers directly (no React) +- **`testFormFieldRender`**: Opens a test form with all field types to verify rendering + +## Future Enhancements + +- Add retry logic for failed requests +- Add request cancellation support +- Add request queuing for rate limiting +- Add middleware for request/response transformation \ No newline at end of file diff --git a/dwertheimer.Forms/README.md b/dwertheimer.Forms/README.md index bd2f136dd..05c089ca5 100644 --- a/dwertheimer.Forms/README.md +++ b/dwertheimer.Forms/README.md @@ -14,9 +14,6 @@ The Forms plugin enables you to create dynamic, interactive forms in NotePlan. Y |--------|----------|-------------|---------| | Screen Cap 2025-12-17 at 23 12 54@2x | Screen Cap 2025-12-17 at 23 16 26@2x | Screen Cap 2025-12-17 at 23 17 09@2x | Screen Cap 2025-12-17 at 23 17 40@2x | - - - ## How It Works 1. You create a **Form Template** that defines the fields to be filled out @@ -27,16 +24,187 @@ The Forms plugin enables you to create dynamic, interactive forms in NotePlan. Y ## Quick Start -1. **Create your templates**: Create both your form template and receiving (processing) template in the `@Templates` directory -2. **Form Template Requirements**: - - Your form template must have the `template-form` type - - Include a `formfields` code block defining your form fields - - Include a `receivingTemplateTitle` in the frontmatter pointing to your processing template -3. **Run the command**: Once your templates are set up, use `/📝 Forms: Open Template Form` (or `/form` or `/dialog`) to launch your form +1. **Open the Form Builder**: Type `/📝 Forms: Form Builder` (or `/builder` or `/buildform`) in the command bar +2. **Create a new form**: Choose "Create New Template" and enter a name for your form +3. **Add fields**: Click "+ Add Field" to add form fields and configure them +4. **Set up processing**: The Form Builder will prompt you to create a processing template +5. **Save and launch**: Click "Save Form" and then use `/📝 Forms: Open Template Form` to launch your form + +## Form Builder + +The **Form Builder** is a visual tool that makes it easy to create and edit forms without writing JSON manually. You should use the Form Builder for all form creation - it's the recommended way to build forms! + +> **Note:** When you create a new form using the Form Builder, a launch link (`[Run Form: Form Name](...)`) is automatically added to the top of your form template body. This link contains the x-callback-url that launches your form. You can copy this link and paste it anywhere you want (daily notes, project notes, etc.) to quickly access your form. + +### Using the Form Builder + +1. **Launch the Form Builder**: + - Command: `/📝 Forms: Form Builder` (or `/builder` or `/buildform`) + - Choose to create a new form template or edit an existing one + +2. **Add and Configure Fields**: + - Click "+ Add Field" in the "Form Fields" section + - Select a field type from the menu (input, dropdown, switch, calendar picker, etc.) + - Click on any field to edit its properties: + - **Label**: The text shown to users + - **Key**: Variable name used in processing templates (auto-generated but editable) + - **Description**: Help text shown below the field + - **Default Value**: Pre-filled value for the field + - **Required**: Mark fields as required (must be filled) + - **Compact Display**: Show label and field side-by-side + - **Dependencies**: Make fields conditional on other fields + +3. **Configure Form Settings** (left sidebar): + - **Receiving Template**: The template that processes form submissions (required) + - **Window Title**: Title shown in the form window + - **Form Title**: Title shown inside the form dialog + - **Window Size**: Width and height of the form window + - **Hide Dependent Items**: Hide dependent fields until parent is enabled + - **Allow Empty Submit**: Allow submitting form with empty required fields + +4. **Preview Your Form** (right side): + - See a live preview of how your form will look + - The preview updates as you make changes + +5. **Reorder Fields**: + - Drag fields up or down using the grip handle (☰) on the left + - Fields appear in the order they're listed + +6. **Delete Fields**: + - Click the trash icon (🗑️) next to any field to remove it + +7. **Save Your Form**: + - Click "Save Form" to save your changes + - The Form Builder automatically creates the JSON codeblock in your template + - If you've made changes, you'll see an "Unsaved changes" warning + +8. **Open Your Form**: + - Once saved, use the "Open Form" button to test your form + - Or use `/📝 Forms: Open Template Form` from the command bar + +### Form Builder Tips + +- **Use descriptive labels**: Clear labels help users understand what each field is for +- **Set defaults**: Pre-fill commonly-used values to save time +- **Add descriptions**: Help text clarifies what each field should contain +- **Group related fields**: Use headings and separators to organize complex forms +- **Test as you go**: Use the "Open Form" button to test your form before finishing + +## Launching Forms + +### From Command Bar + +1. Type `/form` or `/dialog` or `/📝 Forms: Open Template Form` +2. Select your form template from the list +3. Fill out and submit the form + +### From x-callback-url + +When you create a new form using the Form Builder, a launch link is automatically added to the top of your form template. You can copy this link from your template and paste it anywhere you want to quickly launch your form. + +You can also manually create links to launch forms: + +```markdown +[Launch Project Form](noteplan://x-callback-url/runPlugin?pluginID=dwertheimer.Forms&command=Open%20Template%20Form&arg0=jgclark%20Project%20Form) +``` + +Replace `jgclark%20Project%20Form` with your form template title (URL-encoded). + +> **Tip:** Instead of manually creating x-callback-url links, consider using the [np.CallbackURLs](../np.CallbackURLs/README.md) plugin to create callback links with a user-friendly wizard. This plugin helps you build these URLs correctly without having to URL-encode template names manually. Or simply copy the link that's automatically added to your form template! + +## Creating the Processing Template + +The processing template receives the form data and uses it to generate content. It's a standard NotePlan template with the type `forms-processor`. + +### Accessing Form Data + +All form field `key` values become available as variables in your processing template. Use them with `<%- variableName %>` syntax. + +### Example Processing Template + +```yaml +--- +title: Project Form Processing Template +type: forms-processor +newNoteTitle: <%- noteTitle %> +folder: -start: <%- startDateEntry ? date.format("YYYY-MM-DD", startDateEntry) : '' %> -due: <%- dueDateEntry ? date.format("YYYY-MM-DD", dueDateEntry) : '' %> ---- -#project @start(<%- start %>) @due(<%- due %>) @review(<%- interval %>) - -**Aim:** <%- aim %> - -**Context:** <%- context %> +### Complete Example -**Team:** <%- team %> - -Progress: 0@<%- start %>: project started -``` - -### Date Handling - -Date fields from `calendarpicker` return ISO date strings. You can format them using the `date.format()` function: - -```ejs -<%- startDateEntry ? date.format("YYYY-MM-DD", startDateEntry) : '' %> -``` - -### Conditional Rendering - -You can use conditional logic in your processing template: - -```ejs -<% if (isUrgent) { %> -**URGENT:** This task requires immediate attention -<% } %> -``` - -## Complete Example - -### Form Template +#### Form Template ````markdown --- title: jgclark Project Form -type: form-processor +type: template-form receivingTemplateTitle: "Project Form Processing Template" windowTitle: "Project" formTitle: "Create New Project" @@ -486,12 +625,14 @@ height: 750 }, { key: 'startDateEntry', + label: 'Start Date', buttonText: 'Start date', type: 'calendarpicker', visible: false, }, { key: 'dueDateEntry', + label: 'Due Date', buttonText: 'Due date', type: 'calendarpicker', visible: false, @@ -530,12 +671,12 @@ height: 750 ``` ```` -### Processing Template +#### Processing Template ```yaml --- title: Project Form Processing Template -type: form-processor +type: forms-processor newNoteTitle: <%- noteTitle %> folder: to be prompted each time for the folder):', + ' to be prompted each time for the folder, change in frontmatter if you want to use a different folder)\n` + } + if (frontmatterVars.writeNoteTitle) { + basicContent += `\nNote: This template will write to: "${frontmatterVars.writeNoteTitle}"\n` + } + if (frontmatterVars.writeUnderHeading) { + basicContent += `Heading: ${frontmatterVars.writeUnderHeading}\n` + } + if (frontmatterVars.location) { + basicContent += `Location: ${frontmatterVars.location} (append, prepend, replace, write under heading)\n` + } + basicContent += `\n\`\`\`\n` + basicContent += `\`\`\`${varsCodeBlockType}\n` + basicContent += `${varsInForm}\n\`\`\`\n` + + processingNote.appendParagraph(basicContent, 'text') + const emptyParagraph = processingNote.paragraphs.find((p) => p.type === 'empty') + if (emptyParagraph) processingNote.removeParagraph(emptyParagraph) + + // Note: Form template frontmatter is updated by the caller (NPTemplateForm.js), so we don't update it here + // to avoid duplicate receivingTemplateTitle entries + // Only show message and open note if called standalone (not from Form Builder) + // When called from Form Builder, the React UI will handle the update + if (!formTemplateFilename) { + if (formTemplateTitle) { + await showMessage(`Created processing template "${processingTitle}" and linked it to form template "${formTemplateTitle}"`) + } else { + await showMessage(`Created processing template "${processingTitle}"`) + } + // Open the note in the Editor when called standalone + await Editor.openNoteByFilename(filename) + } + + logDebug(pluginJson, `createProcessingTemplate: Successfully created processing template "${processingTitle}"`) + + return { processingTitle, processingFilename: filename } + } catch (error) { + logError(pluginJson, `createProcessingTemplate error: ${JSP(error)}`) + await showMessage(`Error creating processing template: ${error.message}`) + return { processingTitle: undefined, processingFilename: undefined } + } +} diff --git a/dwertheimer.Forms/src/components/AppContext.jsx b/dwertheimer.Forms/src/components/AppContext.jsx index c8020cf86..b3067a209 100644 --- a/dwertheimer.Forms/src/components/AppContext.jsx +++ b/dwertheimer.Forms/src/components/AppContext.jsx @@ -9,7 +9,7 @@ // const {sendActionToPlugin, sendToPlugin, dispatch, pluginData, reactSettings, updateReactSettings} = useAppContext() // MUST BE inside the React component/function code, cannot be at the top of a file // @flow -import React, { createContext, useContext, type Node } from 'react' +import React, { createContext, useContext, useMemo, type Node } from 'react' /** * Type definitions for the application context. @@ -17,6 +17,7 @@ import React, { createContext, useContext, type Node } from 'react' export type AppContextType = { sendActionToPlugin: (command: string, dataToSend: any) => void, // The main one to use to send actions to the plugin, saves scroll position sendToPlugin: (command: string, dataToSend: any) => void, // Sends to plugin without saving scroll position + requestFromPlugin: (command: string, dataToSend: any, timeout?: number) => Promise, // Request/response pattern - returns a Promise dispatch: (command: string, dataToSend: any, message?: string) => void, // Used mainly for showing banner at top of page to user pluginData: Object, // The data that was sent from the plugin in the field "pluginData" reactSettings: Object, // Dynamic key-value pair for reactSettings local to the react window (e.g. filterPriorityItems) @@ -28,6 +29,9 @@ export type AppContextType = { const defaultContextValue: AppContextType = { sendActionToPlugin: () => {}, sendToPlugin: () => {}, + requestFromPlugin: async () => { + throw new Error('requestFromPlugin not initialized') + }, dispatch: () => {}, pluginData: {}, reactSettings: {}, // Initial empty reactSettings local @@ -38,6 +42,7 @@ const defaultContextValue: AppContextType = { type Props = { sendActionToPlugin: (command: string, dataToSend: any) => void, sendToPlugin: (command: string, dataToSend: any) => void, + requestFromPlugin: (command: string, dataToSend: any, timeout?: number) => Promise, dispatch: (command: string, dataToSend: any, messageForLog?: string) => void, pluginData: Object, children: Node, // React component children @@ -52,18 +57,21 @@ type Props = { const AppContext = createContext(defaultContextValue) // Explicitly annotate the return type of AppProvider as a React element -export const AppProvider = ({ children, sendActionToPlugin, sendToPlugin, dispatch, pluginData, updatePluginData, reactSettings, setReactSettings }: Props): Node => { +export const AppProvider = ({ children, sendActionToPlugin, sendToPlugin, requestFromPlugin, dispatch, pluginData, updatePluginData, reactSettings, setReactSettings }: Props): Node => { - // Provide the context value with all functions and state. - const contextValue: AppContextType = { + // Memoize the context value to prevent unnecessary re-renders of all consumers + // This ensures that functions like requestFromPlugin and dispatch maintain stable references + // Only recreate the context value when the actual props change + const contextValue: AppContextType = useMemo(() => ({ sendActionToPlugin, sendToPlugin, + requestFromPlugin, dispatch, pluginData, reactSettings, setReactSettings, updatePluginData, - } + }), [sendActionToPlugin, sendToPlugin, requestFromPlugin, dispatch, pluginData, reactSettings, setReactSettings, updatePluginData]) return {children} } diff --git a/dwertheimer.Forms/src/components/FieldEditor.jsx b/dwertheimer.Forms/src/components/FieldEditor.jsx new file mode 100644 index 000000000..88733381e --- /dev/null +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -0,0 +1,1183 @@ +// @flow +//-------------------------------------------------------------------------- +// FieldEditor Component - Modal editor for editing individual form fields +//-------------------------------------------------------------------------- + +import React, { useState, useEffect, useMemo, useRef, type Node } from 'react' +import { OptionsEditor } from './OptionsEditor.jsx' +import { type TSettingItem } from '@helpers/react/DynamicDialog/DynamicDialog.jsx' + +type FieldEditorProps = { + field: TSettingItem, + allFields: Array, + onSave: (field: TSettingItem) => void, + onCancel: () => void, + requestFromPlugin?: (command: string, dataToSend?: any, timeout?: number) => Promise, // Optional function to call plugin commands +} + +export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlugin }: FieldEditorProps): Node { + const [editedField, setEditedField] = useState({ ...field }) + const [calendars, setCalendars] = useState>([]) + const [calendarsLoaded, setCalendarsLoaded] = useState(false) + const [reminderLists, setReminderLists] = useState>([]) + const [reminderListsLoaded, setReminderListsLoaded] = useState(false) + const calendarsLoadingRef = useRef(false) + const reminderListsLoadingRef = useRef(false) + const requestFromPluginRef = useRef(requestFromPlugin) + + // Track previous field key to detect actual field changes + const prevFieldKeyRef = useRef(field.key) + + // Update ref when requestFromPlugin changes + useEffect(() => { + requestFromPluginRef.current = requestFromPlugin + }, [requestFromPlugin]) + + // Update editedField when field prop changes (e.g., when editing a different field) + useEffect(() => { + const fieldKeyChanged = prevFieldKeyRef.current !== field.key + console.log('[FieldEditor DIAG] field useEffect triggered:', { + prevKey: prevFieldKeyRef.current, + newKey: field.key, + keyChanged: fieldKeyChanged, + type: field.type, + }) + prevFieldKeyRef.current = field.key + + setEditedField({ ...field }) + // Only reset loaded states when field key actually changes (not just object reference) + if (fieldKeyChanged && field.type === 'event-chooser') { + console.log('[FieldEditor DIAG] field useEffect: resetting loaded states') + setCalendarsLoaded(false) + setReminderListsLoaded(false) + calendarsLoadingRef.current = false + reminderListsLoadingRef.current = false + } + }, [field]) + + // Load calendars when editing event-chooser field + useEffect(() => { + const requestFn = requestFromPluginRef.current + console.log('[FieldEditor DIAG] calendars useEffect triggered:', { + type: editedField.type, + calendarsLoaded, + isLoading: calendarsLoadingRef.current, + hasRequestFn: !!requestFn, + }) + + // Only load if we're editing an event-chooser, haven't loaded yet, not currently loading, and have requestFromPlugin + if (editedField.type !== 'event-chooser' || calendarsLoaded || calendarsLoadingRef.current || !requestFn) { + console.log('[FieldEditor DIAG] calendars useEffect: skipping load (conditions not met)') + return + } + + console.log('[FieldEditor DIAG] calendars useEffect: STARTING load') + let isMounted = true + calendarsLoadingRef.current = true + + requestFn('getAvailableCalendars', { writeOnly: false }) + .then((calendarsData) => { + console.log('[FieldEditor DIAG] calendars useEffect: received data, isMounted=', isMounted, 'data type=', Array.isArray(calendarsData) ? 'array' : typeof calendarsData) + if (isMounted && Array.isArray(calendarsData)) { + setCalendars(calendarsData) + setCalendarsLoaded(true) + console.log('[FieldEditor DIAG] calendars useEffect: set calendars and loaded flag') + } + calendarsLoadingRef.current = false + }) + .catch((error) => { + console.error('[FieldEditor DIAG] calendars useEffect: ERROR loading calendars:', error) + if (isMounted) { + setCalendarsLoaded(true) // Set to true to prevent infinite retries + } + calendarsLoadingRef.current = false + }) + + return () => { + console.log('[FieldEditor DIAG] calendars useEffect: cleanup called') + isMounted = false + calendarsLoadingRef.current = false + } + }, [editedField.type, calendarsLoaded]) + + // Load reminder lists when editing event-chooser field and reminders are enabled + useEffect(() => { + const requestFn = requestFromPluginRef.current + const includeReminders = ((editedField: any): { includeReminders?: boolean }).includeReminders + console.log('[FieldEditor DIAG] reminderLists useEffect triggered:', { + type: editedField.type, + reminderListsLoaded, + isLoading: reminderListsLoadingRef.current, + hasRequestFn: !!requestFn, + includeReminders, + }) + + if (editedField.type !== 'event-chooser' || reminderListsLoaded || reminderListsLoadingRef.current || !requestFn || !includeReminders) { + console.log('[FieldEditor DIAG] reminderLists useEffect: skipping load (conditions not met)') + return + } + + console.log('[FieldEditor DIAG] reminderLists useEffect: STARTING load') + let isMounted = true + reminderListsLoadingRef.current = true + + requestFn('getAvailableReminderLists', {}) + .then((listsData) => { + console.log( + '[FieldEditor DIAG] reminderLists useEffect: received data, isMounted=', + isMounted, + 'data type=', + Array.isArray(listsData) ? 'array' : typeof listsData, + 'length=', + Array.isArray(listsData) ? listsData.length : 'N/A', + ) + if (isMounted) { + if (Array.isArray(listsData)) { + setReminderLists(listsData) + setReminderListsLoaded(true) + console.log('[FieldEditor DIAG] reminderLists useEffect: set lists and loaded flag, count=', listsData.length) + if (listsData.length === 0) { + console.log('[FieldEditor DIAG] reminderLists useEffect: WARNING - received empty array, user may not have any reminder lists configured') + } + } else { + console.error('[FieldEditor DIAG] reminderLists useEffect: received non-array data:', typeof listsData, listsData) + setReminderLists([]) + setReminderListsLoaded(true) + } + } + reminderListsLoadingRef.current = false + }) + .catch((error) => { + console.error('[FieldEditor DIAG] reminderLists useEffect: ERROR loading reminder lists:', error) + if (isMounted) { + setReminderLists([]) + setReminderListsLoaded(true) // Set to true to prevent infinite retries + } + reminderListsLoadingRef.current = false + }) + + return () => { + console.log('[FieldEditor DIAG] reminderLists useEffect: cleanup called') + isMounted = false + reminderListsLoadingRef.current = false + } + }, [editedField.type, reminderListsLoaded, ((editedField: any): { includeReminders?: boolean }).includeReminders]) + + // Compute dependency options fresh each render based on current allFields + const dependencyOptions = useMemo(() => { + return allFields + .filter((f) => f.key && f.key !== editedField.key && (f.type === 'switch' || f.type === 'input' || f.type === 'number')) + .map((f) => { + const key = f.key || '' + const label = f.label || key + return { + value: key, + label: `${label} (${key})`, + } + }) + }, [allFields, editedField.key]) + + const updateField = (updates: Partial) => { + setEditedField({ ...editedField, ...updates }) + } + + const handleSave = () => { + onSave(editedField) + } + + const needsKey = editedField.type !== 'separator' && editedField.type !== 'heading' + + // Construct header title with label, key, and type + const headerTitle = needsKey && editedField.key ? `Editing ${editedField.type}: ${editedField.label || ''} (${editedField.key})` : `Editing: ${editedField.type}` + + return ( +
+
e.stopPropagation()}> +
+

{headerTitle}

+ +
+
+ {needsKey && ( +
+ + { + const newKey = e.target.value + updateField({ key: newKey }) + }} + placeholder="e.g., projectName" + /> +
This becomes the variable name in your template (so it's best to give it a descriptive name)
+
+ )} + + {(editedField.type === 'heading' || editedField.type !== 'separator') && ( +
+ + updateField({ label: e.target.value })} placeholder="Field label" /> +
The label is displayed above the field (or to the left of the field if compact display is enabled)
+
+ )} + + {editedField.type !== 'separator' && editedField.type !== 'heading' && editedField.type !== 'calendarpicker' && ( +
+ +
+ )} + + {editedField.type !== 'separator' && ( +
+ +