├── .gitignore ├── icons ├── 16.png ├── 32.png ├── 64.png ├── 128.png └── icon.svg ├── .npmignore ├── api ├── index.ts ├── Global.d.ts ├── JoplinFilters.d.ts ├── JoplinViewsToolbarButtons.d.ts ├── JoplinViewsMenuItems.d.ts ├── JoplinViewsMenus.d.ts ├── JoplinWindow.d.ts ├── JoplinInterop.d.ts ├── JoplinPlugins.d.ts ├── JoplinClipboard.d.ts ├── JoplinViews.d.ts ├── JoplinViewsNoteList.d.ts ├── JoplinContentScripts.d.ts ├── Joplin.d.ts ├── JoplinViewsDialogs.d.ts ├── JoplinViewsPanels.d.ts ├── JoplinSettings.d.ts ├── JoplinImaging.d.ts ├── JoplinData.d.ts ├── JoplinWorkspace.d.ts ├── JoplinCommands.d.ts ├── JoplinViewsEditor.d.ts └── noteListType.d.ts ├── src ├── gui │ ├── lib │ │ ├── utils.tsx │ │ ├── collectUnique.ts │ │ ├── selectUtils.ts │ │ └── filters.ts │ ├── components │ │ └── ui │ │ │ ├── skeleton.tsx │ │ │ ├── label.tsx │ │ │ ├── separator.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── input.tsx │ │ │ ├── popover.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── sheet.tsx │ │ │ └── dropdown-menu.tsx │ ├── hooks │ │ ├── use-mobile.ts │ │ ├── usePluginData.ts │ │ └── useFilters.ts │ ├── RefreshButton.tsx │ ├── DateFilter.tsx │ ├── FilterNameDialog.tsx │ ├── SaveFilter.tsx │ ├── SelectFilter.tsx │ ├── BooleanFilter.tsx │ ├── App.tsx │ ├── Sidebar.tsx │ ├── SavedFilters.tsx │ ├── CheckFilter.tsx │ ├── style │ │ └── input.css │ └── TodoCard.tsx ├── panel.tsx ├── todoRender │ ├── index.ts │ ├── todoRender.css │ └── conferenceStyleRender.ts ├── manifest.json ├── summary.ts ├── summaryFormatters │ ├── table.ts │ ├── plain.ts │ └── diary.ts ├── summary_note.ts ├── editor.ts ├── types.ts ├── builder.ts ├── mark_todo.ts ├── settings_tables.ts └── index.ts ├── tsconfig.json ├── .eslintrc.js ├── jest.config.js ├── plugin.config.json ├── eslint.config.mjs ├── components.json ├── LICENSE ├── package.json ├── GENERATOR_DOC.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | publish/*.json 4 | publish/ 5 | .idea 6 | .*~ 7 | -------------------------------------------------------------------------------- /icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CalebJohn/joplin-inline-todo/HEAD/icons/16.png -------------------------------------------------------------------------------- /icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CalebJohn/joplin-inline-todo/HEAD/icons/32.png -------------------------------------------------------------------------------- /icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CalebJohn/joplin-inline-todo/HEAD/icons/64.png -------------------------------------------------------------------------------- /icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CalebJohn/joplin-inline-todo/HEAD/icons/128.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.md 2 | !README.md 3 | /*.jpl 4 | /api 5 | /src 6 | /dist 7 | tsconfig.json 8 | webpack.config.js 9 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import type Joplin from './Joplin'; 2 | 3 | declare const joplin: Joplin; 4 | 5 | export default joplin; 6 | -------------------------------------------------------------------------------- /src/gui/lib/utils.tsx: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "module": "commonjs", 5 | "target": "es2015", 6 | "jsx": "react-jsx", 7 | "allowJs": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | ecmaFeatures: { jsx: true } 4 | }, 5 | plugins: ["react", "react-hooks"], 6 | extends: ["eslint:recommended", "plugin:react/recommended"], 7 | rules: { "react/prop-types": "off" } 8 | }; 9 | 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 5 | testMatch: ['**/*.test.ts', '**/*.test.tsx'], 6 | transform: { 7 | '^.+\\.tsx?$': 'ts-jest', 8 | }, 9 | moduleNameMapper: { 10 | '^@/(.*)$': '/$1', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /plugin.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extraScripts": [ 3 | "types.ts", 4 | "builder.ts", 5 | "settings_tables.ts", 6 | "summaryFormatters/table.ts", 7 | "summaryFormatters/plain.ts", 8 | "summary.ts", 9 | 10 | "todoRender/index.ts", 11 | "todoRender/conferenceStyleRender.ts", 12 | 13 | "panel.tsx" 14 | ] 15 | } -------------------------------------------------------------------------------- /api/Global.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import Joplin from './Joplin'; 3 | /** 4 | * @ignore 5 | */ 6 | /** 7 | * @ignore 8 | */ 9 | export default class Global { 10 | private joplin_; 11 | constructor(implementation: any, plugin: Plugin, store: any); 12 | get joplin(): Joplin; 13 | get process(): any; 14 | } 15 | -------------------------------------------------------------------------------- /api/JoplinFilters.d.ts: -------------------------------------------------------------------------------- 1 | import { FilterHandler } from '../../../eventManager'; 2 | /** 3 | * @ignore 4 | * 5 | * Not sure if it's the best way to hook into the app 6 | * so for now disable filters. 7 | */ 8 | export default class JoplinFilters { 9 | on(name: string, callback: FilterHandler): Promise; 10 | off(name: string, callback: FilterHandler): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/gui/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cn } from "@/src/gui/lib/utils" 3 | 4 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 5 | return ( 6 |
11 | ) 12 | } 13 | 14 | export { Skeleton } 15 | -------------------------------------------------------------------------------- /src/panel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./gui/App"; 4 | import Logger, { TargetType } from '@joplin/utils/Logger'; 5 | 6 | const logger = new Logger(); 7 | logger.addTarget(TargetType.Console); 8 | Logger.initializeGlobalLogger(logger); 9 | 10 | const rootElement = document.getElementById('root'); 11 | const root = createRoot(rootElement); 12 | root.render(); 13 | -------------------------------------------------------------------------------- /src/todoRender/index.ts: -------------------------------------------------------------------------------- 1 | import {conferenceStyleRender} from "./conferenceStyleRender"; 2 | 3 | export default function (context) { 4 | return { 5 | plugin: function (markdownIt, _options) { 6 | const pluginId = context.pluginId; 7 | 8 | conferenceStyleRender(markdownIt, _options); 9 | }, 10 | assets: function() { 11 | return [ 12 | { name: 'todoRender.css' } 13 | ]; 14 | }, 15 | } 16 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginReact from "eslint-plugin-react"; 5 | import { defineConfig } from "eslint/config"; 6 | 7 | export default defineConfig([ 8 | { files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } }, 9 | tseslint.configs.recommended, 10 | pluginReact.configs.flat.recommended, 11 | ]); 12 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/gui/style/input.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/src/gui/components", 15 | "utils": "@/src/gui/lib/utils", 16 | "ui": "@/src/gui/components/ui", 17 | "lib": "@/src/gui/lib", 18 | "hooks": "@/src/gui/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /src/gui/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /api/JoplinViewsToolbarButtons.d.ts: -------------------------------------------------------------------------------- 1 | import { ToolbarButtonLocation } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * Allows creating and managing toolbar buttons. 5 | * 6 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) 7 | */ 8 | export default class JoplinViewsToolbarButtons { 9 | private store; 10 | private plugin; 11 | constructor(plugin: Plugin, store: any); 12 | /** 13 | * Creates a new toolbar button and associate it with the given command. 14 | */ 15 | create(id: string, commandName: string, location: ToolbarButtonLocation): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/gui/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | 4 | import { cn } from "@/src/gui/lib/utils" 5 | 6 | function Label({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ) 20 | } 21 | 22 | export { Label } 23 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 1, 3 | "id": "plugin.calebjohn.todo", 4 | "app_min_version": "3.4.2", 5 | "version": "1.9.0", 6 | "name": "Inline TODO", 7 | "description": "Write TODOs *everywhere* and view them in one place.", 8 | "author": "Caleb John", 9 | "homepage_url": "https://github.com/CalebJohn/joplin-inline-todo#readme", 10 | "repository_url": "https://github.com/CalebJohn/joplin-inline-todo", 11 | "keywords": ["todo", "productivity"], 12 | "categories": ["editor", "productivity", "personal knowledge management"], 13 | "platforms": ["desktop", "mobile"], 14 | "icons": { 15 | "16": "icons/16.png", 16 | "32": "icons/32.png", 17 | "48": "icons/48.png", 18 | "128": "icons/128.png" 19 | } 20 | } -------------------------------------------------------------------------------- /src/gui/RefreshButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useState } from 'react'; 3 | import { Button } from "@/src/gui/components/ui/button" 4 | import { RefreshCw } from "lucide-react"; 5 | 6 | interface Props { 7 | refreshSummary: () => void; 8 | } 9 | 10 | 11 | export function RefreshButton({ refreshSummary }: Props) { 12 | const [rotation, setRotation] = useState(0); 13 | 14 | const handleClick = () => { 15 | setRotation(prev => prev + 180); 16 | refreshSummary(); 17 | }; 18 | 19 | return ( 20 | 29 | ); 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/gui/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/src/gui/lib/utils" 5 | 6 | function Separator({ 7 | className, 8 | orientation = "horizontal", 9 | decorative = true, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 23 | ) 24 | } 25 | 26 | export { Separator } 27 | -------------------------------------------------------------------------------- /api/JoplinViewsMenuItems.d.ts: -------------------------------------------------------------------------------- 1 | import { CreateMenuItemOptions, MenuItemLocation } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * Allows creating and managing menu items. 5 | * 6 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) 7 | * 8 | * desktop 9 | */ 10 | export default class JoplinViewsMenuItems { 11 | private store; 12 | private plugin; 13 | constructor(plugin: Plugin, store: any); 14 | /** 15 | * Creates a new menu item and associate it with the given command. You can specify under which menu the item should appear using the `location` parameter. 16 | */ 17 | create(id: string, commandName: string, location?: MenuItemLocation, options?: CreateMenuItemOptions): Promise; 18 | } 19 | -------------------------------------------------------------------------------- /api/JoplinViewsMenus.d.ts: -------------------------------------------------------------------------------- 1 | import { MenuItem, MenuItemLocation } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * Allows creating menus. 5 | * 6 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/menu) 7 | * 8 | * desktop 9 | */ 10 | export default class JoplinViewsMenus { 11 | private store; 12 | private plugin; 13 | constructor(plugin: Plugin, store: any); 14 | private registerCommandAccelerators; 15 | /** 16 | * Creates a new menu from the provided menu items and place it at the given location. As of now, it is only possible to place the 17 | * menu as a sub-menu of the application build-in menus. 18 | */ 19 | create(id: string, label: string, menuItems: MenuItem[], location?: MenuItemLocation): Promise; 20 | } 21 | -------------------------------------------------------------------------------- /src/gui/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 2 | 3 | function Collapsible({ 4 | ...props 5 | }: React.ComponentProps) { 6 | return 7 | } 8 | 9 | function CollapsibleTrigger({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 17 | ) 18 | } 19 | 20 | function CollapsibleContent({ 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 28 | ) 29 | } 30 | 31 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 32 | -------------------------------------------------------------------------------- /src/gui/lib/collectUnique.ts: -------------------------------------------------------------------------------- 1 | import { Todo, UniqueFields } from "../../types"; 2 | 3 | export default function collectUnique(summary: Todo[]): UniqueFields { 4 | const note = new Set(); 5 | const note_title = new Set(); 6 | const parent_id = new Set(); 7 | const parent_title = new Set(); 8 | const category = new Set(); 9 | const tags = new Set(); 10 | 11 | for (const item of summary) { 12 | note.add(item.note); 13 | note_title.add(item.note_title); 14 | parent_id.add(item.parent_id); 15 | parent_title.add(item.parent_title); 16 | if (item.category) { 17 | category.add(item.category); 18 | } 19 | if (item.tags) { 20 | item.tags.forEach(tag => tags.add(tag)); 21 | } 22 | } 23 | 24 | return { 25 | note: [...note].sort(), 26 | note_title: [...note_title].sort(), 27 | parent_id: [...parent_id].sort(), 28 | parent_title: [...parent_title].sort(), 29 | category: [...category].sort(), 30 | tags: [...tags].sort(), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/gui/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/src/gui/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Caleb John 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/todoRender/todoRender.css: -------------------------------------------------------------------------------- 1 | span.inline-todo { 2 | font-size: 90%; 3 | padding: 2px 3px; 4 | text-align: center; 5 | } 6 | 7 | 8 | span.inline-todo-category { 9 | background-color: dodgerblue; 10 | color: white; 11 | border-radius: 4px; 12 | } 13 | 14 | span.inline-todo-date { 15 | background-color: #52C41B; 16 | color: white; 17 | border-radius: 4px; 18 | } 19 | span.inline-todo-date.due { 20 | background-color: orange; 21 | } 22 | span.inline-todo-date.overdue { 23 | background-color: orangered; 24 | } 25 | 26 | span.inline-todo-date::before { 27 | content: '🕰 '; 28 | } 29 | 30 | .tag { 31 | position: relative; 32 | top: 0; 33 | z-index: 1; 34 | text-align: center; 35 | color: white; 36 | } 37 | 38 | .tag::before, 39 | .tag::after { 40 | content: ''; 41 | position: absolute; 42 | top: 0; 43 | transform: skew(-12deg); 44 | } 45 | 46 | .tag::after { 47 | right: 0; 48 | bottom: 0; 49 | z-index: -1; 50 | border-radius: 5px; 51 | } 52 | 53 | .tag-left::after { 54 | left: 0; 55 | } 56 | 57 | .tag-left::before { 58 | left: -10px; 59 | } 60 | 61 | .tag-right::after { 62 | background-color: #52C41B; 63 | left: 0; 64 | } 65 | 66 | -------------------------------------------------------------------------------- /api/JoplinWindow.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | export default class JoplinWindow { 3 | private store_; 4 | constructor(_plugin: Plugin, store: any); 5 | /** 6 | * Loads a chrome CSS file. It will apply to the window UI elements, except 7 | * for the note viewer. It is the same as the "Custom stylesheet for 8 | * Joplin-wide app styles" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) 9 | * for an example. 10 | * 11 | * desktop 12 | */ 13 | loadChromeCssFile(filePath: string): Promise; 14 | /** 15 | * Loads a note CSS file. It will apply to the note viewer, as well as any 16 | * exported or printed note. It is the same as the "Custom stylesheet for 17 | * rendered Markdown" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) 18 | * for an example. 19 | * 20 | * desktop 21 | */ 22 | loadNoteCssFile(filePath: string): Promise; 23 | } 24 | -------------------------------------------------------------------------------- /api/JoplinInterop.d.ts: -------------------------------------------------------------------------------- 1 | import { ExportModule, ImportModule } from './types'; 2 | /** 3 | * Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format. 4 | * 5 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export) 6 | * 7 | * To implement an import or export module, you would simply define an object with various event handlers that are called 8 | * by the application during the import/export process. 9 | * 10 | * See the documentation of the [[ExportModule]] and [[ImportModule]] for more information. 11 | * 12 | * You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/help/api/references/rest_api 13 | * 14 | * desktop: While it is possible to register import and export 15 | * modules on mobile, there is no GUI to activate them. 16 | */ 17 | export default class JoplinInterop { 18 | registerExportModule(module: ExportModule): Promise; 19 | registerImportModule(module: ImportModule): Promise; 20 | } 21 | -------------------------------------------------------------------------------- /src/gui/DateFilter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { SelectFilterComponent } from "./SelectFilter"; 3 | import { DateFilter } from "../types"; 4 | import { groupsToOptions } from "./lib/selectUtils"; 5 | 6 | 7 | interface Props { 8 | label: string; 9 | title?: string; 10 | field: string; 11 | filter: DateFilter; 12 | dispatch: (o) => void; 13 | } 14 | 15 | 16 | export function DateFilterComponent(props: Props) { 17 | const groups = { 18 | "": [ 19 | (props.field === 'date' ? "All" : "None"), 20 | "Overdue", 21 | "Today", 22 | "Tomorrow", 23 | "End of Week", 24 | "End of Month", 25 | "End of Year", 26 | ], 27 | "Weeks": [ 28 | "1 week", 29 | "2 weeks", 30 | "3 weeks", 31 | "4 weeks", 32 | "5 weeks", 33 | "6 weeks", 34 | ], 35 | "Months": [ 36 | "1 month", 37 | "2 months", 38 | "3 months", 39 | "4 months", 40 | "5 months", 41 | "6 months", 42 | "7 months", 43 | "8 months", 44 | "9 months", 45 | "10 months", 46 | "11 months", 47 | "12 months", 48 | ], 49 | }; 50 | return ( 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/gui/FilterNameDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState } from "react"; 3 | import { Button } from "@/src/gui/components/ui/button"; 4 | import { Input } from "@/src/gui/components/ui/input" 5 | import { Label } from "@/src/gui/components/ui/label"; 6 | 7 | interface Props { 8 | defaultName: string; 9 | saveAndClose: (s: string) => void; 10 | } 11 | 12 | 13 | export function FilterNameDialogComponent({ defaultName, saveAndClose }: Props) { 14 | const [filterName, setFilterName] = useState(defaultName); 15 | 16 | const saveName = () => { 17 | saveAndClose(filterName); 18 | }; 19 | 20 | const handleKeyPress = (e) => { 21 | if (e.key === 'Enter') { 22 | saveName(); 23 | } 24 | }; 25 | 26 | return ( 27 |
28 | 29 | setFilterName(e.target.value)} 34 | onKeyPress={handleKeyPress} 35 | autoFocus 36 | /> 37 | 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/gui/SaveFilter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState } from "react"; 3 | import { FilterNameDialogComponent } from './FilterNameDialog'; 4 | import { Button } from "@/src/gui/components/ui/button"; 5 | import { 6 | Popover, 7 | PopoverContent, 8 | PopoverTrigger, 9 | } from "@/src/gui/components/ui/popover" 10 | import { Filter } from "lucide-react"; 11 | import Logger from "@joplin/utils/Logger"; 12 | 13 | const logger = Logger.create('inline-todo: SaveFilter.tsx'); 14 | 15 | interface Props { 16 | dispatch: (o) => void; 17 | } 18 | 19 | 20 | export function SaveFilterComponent({ dispatch }: Props) { 21 | const [open, setOpen] = useState(false); 22 | 23 | const saveActiveFilter = (filterName: string) => { 24 | dispatch({ 25 | type: "saveActive", 26 | name: filterName, 27 | }); 28 | setOpen(false); 29 | }; 30 | 31 | return ( 32 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/gui/lib/selectUtils.ts: -------------------------------------------------------------------------------- 1 | function arrayToOptions(arr: string[]) { 2 | return arr.map((i) => ({ 3 | label: i, 4 | value: i, 5 | })); 6 | } 7 | 8 | export function groupsToOptions(groups) { 9 | return Object.entries(groups).map(([g, items]) => ({ 10 | label: g, 11 | options: arrayToOptions(items as string[]), 12 | })); 13 | } 14 | 15 | export function selectTheme(tagTheme: any) { 16 | return { ...tagTheme, 17 | colors: { ...tagTheme.colors, 18 | primary: 'var(--joplin-background-color3)', 19 | primary25: 'var(--joplin-background-color3)', 20 | neutral0: 'var(--joplin-background-color)', 21 | neutral5: 'var(--joplin-background-color)', 22 | neutral10: 'var(--joplin-divider-color)', 23 | neutral20: 'var(--joplin-divider-color)', 24 | neutral30: 'var(--joplin-divider-color)', 25 | neutral40: 'var(--joplin-color3)', 26 | neutral50: 'var(--joplin-color3)', 27 | neutral60: 'var(--joplin-color3)', 28 | neutral70: 'var(--joplin-color3)', 29 | neutral80: 'var(--joplin-color3)', 30 | neutral90: 'var(--joplin-color3)', 31 | danger: 'var(--joplin-background-color)', 32 | dangerLight: 'var(--joplin-color-error2)', 33 | }, 34 | }; 35 | }; 36 | 37 | export const selectStyles = { 38 | option: (provided, state) => ({ 39 | ...provided, 40 | color: state.isFocused ? 'var(--joplin-color3)' : 'var(--joplin-color)', 41 | }), 42 | }; 43 | -------------------------------------------------------------------------------- /api/JoplinPlugins.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ContentScriptType, Script } from './types'; 3 | /** 4 | * This class provides access to plugin-related features. 5 | */ 6 | export default class JoplinPlugins { 7 | private plugin; 8 | constructor(plugin: Plugin); 9 | /** 10 | * Registers a new plugin. This is the entry point when creating a plugin. You should pass a simple object with an `onStart` method to it. 11 | * That `onStart` method will be executed as soon as the plugin is loaded. 12 | * 13 | * ```typescript 14 | * joplin.plugins.register({ 15 | * onStart: async function() { 16 | * // Run your plugin code here 17 | * } 18 | * }); 19 | * ``` 20 | */ 21 | register(script: Script): Promise; 22 | /** 23 | * @deprecated Use joplin.contentScripts.register() 24 | */ 25 | registerContentScript(type: ContentScriptType, id: string, scriptPath: string): Promise; 26 | /** 27 | * Gets the plugin own data directory path. Use this to store any 28 | * plugin-related data. Unlike [[installationDir]], any data stored here 29 | * will be persisted. 30 | */ 31 | dataDir(): Promise; 32 | /** 33 | * Gets the plugin installation directory. This can be used to access any 34 | * asset that was packaged with the plugin. This directory should be 35 | * considered read-only because any data you store here might be deleted or 36 | * re-created at any time. To store new persistent data, use [[dataDir]]. 37 | */ 38 | installationDir(): Promise; 39 | /** 40 | * @deprecated Use joplin.require() 41 | */ 42 | require(_path: string): any; 43 | } 44 | -------------------------------------------------------------------------------- /api/JoplinClipboard.d.ts: -------------------------------------------------------------------------------- 1 | import { ClipboardContent } from './types'; 2 | export default class JoplinClipboard { 3 | private electronClipboard_; 4 | private electronNativeImage_; 5 | constructor(electronClipboard: any, electronNativeImage: any); 6 | readText(): Promise; 7 | writeText(text: string): Promise; 8 | /** desktop */ 9 | readHtml(): Promise; 10 | /** desktop */ 11 | writeHtml(html: string): Promise; 12 | /** 13 | * Returns the image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. 14 | * 15 | * desktop 16 | */ 17 | readImage(): Promise; 18 | /** 19 | * Takes an image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. 20 | * 21 | * desktop 22 | */ 23 | writeImage(dataUrl: string): Promise; 24 | /** 25 | * Returns the list available formats (mime types). 26 | * 27 | * For example [ 'text/plain', 'text/html' ] 28 | */ 29 | availableFormats(): Promise; 30 | /** 31 | * Writes multiple formats to the clipboard simultaneously. 32 | * This allows setting both text/plain and text/html at the same time. 33 | * 34 | * desktop 35 | * 36 | * @example 37 | * ```typescript 38 | * await joplin.clipboard.write({ 39 | * text: 'Plain text version', 40 | * html: 'HTML version' 41 | * }); 42 | * ``` 43 | */ 44 | write(content: ClipboardContent): Promise; 45 | } 46 | -------------------------------------------------------------------------------- /src/gui/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as PopoverPrimitive from "@radix-ui/react-popover" 3 | 4 | import { cn } from "@/src/gui/lib/utils" 5 | 6 | function Popover({ 7 | ...props 8 | }: React.ComponentProps) { 9 | return 10 | } 11 | 12 | function PopoverTrigger({ 13 | ...props 14 | }: React.ComponentProps) { 15 | return 16 | } 17 | 18 | function PopoverContent({ 19 | className, 20 | align = "center", 21 | sideOffset = 4, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 36 | 37 | ) 38 | } 39 | 40 | function PopoverAnchor({ 41 | ...props 42 | }: React.ComponentProps) { 43 | return 44 | } 45 | 46 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 47 | -------------------------------------------------------------------------------- /api/JoplinViews.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import JoplinViewsDialogs from './JoplinViewsDialogs'; 3 | import JoplinViewsMenuItems from './JoplinViewsMenuItems'; 4 | import JoplinViewsMenus from './JoplinViewsMenus'; 5 | import JoplinViewsToolbarButtons from './JoplinViewsToolbarButtons'; 6 | import JoplinViewsPanels from './JoplinViewsPanels'; 7 | import JoplinViewsNoteList from './JoplinViewsNoteList'; 8 | import JoplinViewsEditors from './JoplinViewsEditor'; 9 | /** 10 | * This namespace provides access to view-related services. 11 | * 12 | * ## Creating a view 13 | * 14 | * All view services provide a `create()` method which you would use to create the view object, 15 | * whether it's a dialog, a toolbar button or a menu item. In some cases, the `create()` method will 16 | * return a [[ViewHandle]], which you would use to act on the view, for example to set certain 17 | * properties or call some methods. 18 | * 19 | * ## The `webviewApi` object 20 | * 21 | * Within a view, you can use the global object `webviewApi` for various utility functions, such as 22 | * sending messages or displaying context menu. Refer to [[WebviewApi]] for the full documentation. 23 | */ 24 | export default class JoplinViews { 25 | private store; 26 | private plugin; 27 | private panels_; 28 | private menuItems_; 29 | private menus_; 30 | private toolbarButtons_; 31 | private dialogs_; 32 | private editors_; 33 | private noteList_; 34 | private implementation_; 35 | constructor(implementation: any, plugin: Plugin, store: any); 36 | get dialogs(): JoplinViewsDialogs; 37 | get panels(): JoplinViewsPanels; 38 | get editors(): JoplinViewsEditors; 39 | get menuItems(): JoplinViewsMenuItems; 40 | get menus(): JoplinViewsMenus; 41 | get toolbarButtons(): JoplinViewsToolbarButtons; 42 | get noteList(): JoplinViewsNoteList; 43 | } 44 | -------------------------------------------------------------------------------- /src/gui/SelectFilter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Select, { GroupBase } from 'react-select'; 3 | import { Label } from "@/src/gui/components/ui/label" 4 | import { selectStyles, selectTheme } from "./lib/selectUtils"; 5 | import { 6 | Collapsible, 7 | CollapsibleContent, 8 | CollapsibleTrigger, 9 | } from "@/src/gui/components/ui/collapsible"; 10 | import { SidebarMenuButton, SidebarMenuItem } from "@/src/gui/components/ui/sidebar"; 11 | import { ChevronRight } from "lucide-react"; 12 | 13 | 14 | interface Props { 15 | label: string; 16 | title?: string; 17 | field: string; 18 | filter: string; 19 | groups: GroupBase[]; 20 | defaultClosed: boolean; 21 | dispatch: (o) => void; 22 | } 23 | 24 | 25 | export function SelectFilterComponent({ label, title, field, filter, dispatch, groups, defaultClosed }: Props) { 26 | const selectValue = { 27 | value: filter, 28 | label: filter 29 | }; 30 | 31 | const handleSelectionChange = (s) => { 32 | dispatch({ 33 | type: "updateActiveField", 34 | field: field, 35 | value: s.value, 36 | }); 37 | } 38 | 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | 54 | 57 |
58 |
59 | 60 | 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /api/JoplinViewsNoteList.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import Plugin from '../Plugin'; 3 | import { ListRenderer } from './noteListType'; 4 | /** 5 | * This API allows you to customise how each note in the note list is rendered. 6 | * The renderer you implement follows a unidirectional data flow. 7 | * 8 | * The app provides the required dependencies whenever a note is updated - you 9 | * process these dependencies, and return some props, which are then passed to 10 | * your template and rendered. See [[ListRenderer]] for a detailed description 11 | * of each property of the renderer. 12 | * 13 | * ## Reference 14 | * 15 | * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/note_list_renderer) 16 | * 17 | * * [Default simple renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts) 18 | * 19 | * * [Default detailed renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultMultiColumnsRenderer.ts) 20 | * 21 | * ## Screenshots: 22 | * 23 | * ### Top to bottom with title, date and body 24 | * 25 | * 26 | * 27 | * ### Left to right with thumbnails 28 | * 29 | * 30 | * 31 | * ### Top to bottom with editable title 32 | * 33 | * 34 | * 35 | * desktop 36 | */ 37 | export default class JoplinViewsNoteList { 38 | private plugin_; 39 | private store_; 40 | constructor(plugin: Plugin, store: Store); 41 | registerRenderer(renderer: ListRenderer): Promise; 42 | } 43 | -------------------------------------------------------------------------------- /src/gui/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/src/gui/lib/utils" 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 0, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 60 | -------------------------------------------------------------------------------- /src/gui/hooks/usePluginData.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useEffect, useState } from "react"; 3 | import { Settings, Summary, Todo, WebviewApi } from "../../types"; 4 | import Logger from "@joplin/utils/Logger"; 5 | 6 | const logger = Logger.create('inline-todo: usePluginData'); 7 | 8 | interface Props { 9 | webviewApi: WebviewApi; 10 | } 11 | 12 | // necessary so that react can keep track of todos 13 | function addStableKey(todo: Todo): Todo { 14 | const key = todo.msg + todo.category + todo.tags.join() + todo.note + todo.parent_id + todo.date; 15 | return { ...todo, key: key }; 16 | } 17 | 18 | // This handles the loading and updating for all data that is read-only from the plugin 19 | // point of view (contrast to the useFilters hook) 20 | export default (props: Props) => { 21 | const [summary, setSummary] = useState([]); 22 | const [settings, setSettings] = useState(null); 23 | 24 | const refreshSummary = () => { 25 | const fn = async() => { 26 | const newSummary: Summary = await props.webviewApi.postMessage({ type: 'getSummary' }); 27 | // Flatten Summary map to a list of todos 28 | const flatSummary = Object.values(newSummary.map).flat(); 29 | setSummary(flatSummary.map(addStableKey)); 30 | } 31 | void fn(); 32 | } 33 | 34 | useEffect(() => { 35 | const fn = async() => { 36 | refreshSummary(); 37 | 38 | // Settings are passed as a JSON string in order to support more complex data types 39 | const newSettings: string = await props.webviewApi.postMessage({ type: 'getSettings' }); 40 | setSettings(JSON.parse(newSettings)); 41 | } 42 | 43 | void fn(); 44 | }, []); 45 | 46 | useEffect(() => { 47 | props.webviewApi.onMessage(async (event) => { 48 | const message = event.message; 49 | 50 | if (message.type === 'updateSummary') { 51 | const newSummary = message.value as Summary; 52 | const flatSummary = Object.values(newSummary.map).flat(); 53 | setSummary(flatSummary.map(addStableKey)); 54 | } else { 55 | logger.warn('Unknown message:' + JSON.stringify(message)); 56 | } 57 | }); 58 | }, []); 59 | 60 | return { summary, settings, refreshSummary }; 61 | } 62 | -------------------------------------------------------------------------------- /src/summary.ts: -------------------------------------------------------------------------------- 1 | import joplin from 'api'; 2 | import { Settings, Summary } from './types'; 3 | import { summaries } from './settings_tables'; 4 | import { insertNewSummary, filterSummaryCategories } from './summary_note'; 5 | // import { icalBlock } from './ical'; 6 | 7 | export async function update_summary(summary: Summary, settings: Settings, summary_id: string, old_body: string) { 8 | const bodyFunc = summaries[settings.summary_type].func; 9 | 10 | // Use the summary special comment to filter the todos for this summary note 11 | const filtered_map = filterSummaryCategories(old_body, summary); 12 | 13 | const summaryBody = await bodyFunc(filtered_map, settings); 14 | 15 | // if (settings.add_ical_block) { 16 | // summaryBody += icalBlock(filtered_map, settings); 17 | // } 18 | 19 | await setSummaryBody(summaryBody, summary_id, old_body, settings); 20 | } 21 | 22 | async function setSummaryBody(summaryBody: string, summary_id: string, old_body: string, settings: Settings) { 23 | const body = insertNewSummary(old_body, summaryBody); 24 | 25 | // Only update the note if it actually changed... 26 | if (old_body === body) { return; } 27 | 28 | // if (settings.add_ical_block) { 29 | // // UIDs in the ical block change with each generation, so need to compare without them 30 | // // TODO: When I make the UIDs stable, this can be removed 31 | // if (old_body.replace(/```ical[\s\S]*```/, '') === body.replace(/```ical[\s\S]*```/, '')) { return; } 32 | // } 33 | 34 | // https://github.com/laurent22/joplin/issues/5955 35 | const currentNote = await joplin.workspace.selectedNote(); 36 | if (currentNote.id == summary_id) { 37 | try { 38 | await joplin.commands.execute('editor.setText', body); 39 | } catch (error) { 40 | console.warn("Could not update summary note with editor.setText: " + summary_id); 41 | console.error(error); 42 | } 43 | } 44 | 45 | await joplin.data.put(['notes', summary_id], null, { body: body }) 46 | .catch((error) => { 47 | console.error(error); 48 | console.warn("Could not update summary note with api: " + summary_id); 49 | }); 50 | 51 | if (settings.force_sync) { 52 | await joplin.commands.execute('synchronize'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/gui/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import calcFiltered from "./lib/filters"; 3 | import collectUnique from "./lib/collectUnique"; 4 | import useFilters from './hooks/useFilters'; 5 | import usePluginData from './hooks/usePluginData'; 6 | import { useIsMobile } from './hooks/use-mobile'; 7 | import { WebviewApi } from "../types"; 8 | import { TodoCard } from "./TodoCard" 9 | import { FilterSidebar } from "./Sidebar" 10 | import { RefreshButton } from "./RefreshButton"; 11 | import { Separator } from "@/src/gui/components/ui/separator" 12 | import { SidebarProvider, SidebarTrigger } from "@/src/gui/components/ui/sidebar" 13 | import Logger from "@joplin/utils/Logger"; 14 | 15 | const logger = Logger.create('inline-todo: App'); 16 | 17 | declare var webviewApi: WebviewApi; 18 | 19 | 20 | export default function App() { 21 | const {summary, settings, refreshSummary} = usePluginData({ webviewApi }); 22 | 23 | const [filters, dispatch] = useFilters({ webviewApi, summary }); 24 | 25 | const isMobile = useIsMobile(); 26 | 27 | const filtered = calcFiltered(summary, filters); 28 | const uniqueFields = collectUnique(summary); 29 | 30 | const sidebarProps = { 31 | filters, 32 | dispatch, 33 | filtered, 34 | uniqueFields, 35 | }; 36 | 37 | // The parent container has a 10px border, so we need to subtract 20px from the width 38 | // to make the plugin fit perfectly 39 | return ( 40 | 41 | 42 |
43 |
44 | 45 | 49 |
50 | 51 |
52 |
53 |
54 | {!!filtered && 55 | filtered.active.todos.map((todo) => { 56 | return ( 57 | 58 | ); 59 | }) 60 | } 61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/gui/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/src/gui/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 15 | outline: 16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: 20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | "icon-sm": "size-8", 29 | "icon-lg": "size-10", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ) 38 | 39 | function Button({ 40 | className, 41 | variant, 42 | size, 43 | asChild = false, 44 | ...props 45 | }: React.ComponentProps<"button"> & 46 | VariantProps & { 47 | asChild?: boolean 48 | }) { 49 | const Comp = asChild ? Slot : "button" 50 | 51 | return ( 52 | 57 | ) 58 | } 59 | 60 | export { Button, buttonVariants } 61 | -------------------------------------------------------------------------------- /api/JoplinContentScripts.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ContentScriptType } from './types'; 3 | export default class JoplinContentScripts { 4 | private plugin; 5 | constructor(plugin: Plugin); 6 | /** 7 | * Registers a new content script. Unlike regular plugin code, which runs in 8 | * a separate process, content scripts run within the main process code and 9 | * thus allow improved performances and more customisations in specific 10 | * cases. It can be used for example to load a Markdown or editor plugin. 11 | * 12 | * Note that registering a content script in itself will do nothing - it 13 | * will only be loaded in specific cases by the relevant app modules (eg. 14 | * the Markdown renderer or the code editor). So it is not a way to inject 15 | * and run arbitrary code in the app, which for safety and performance 16 | * reasons is not supported. 17 | * 18 | * The plugin generator provides a way to build any content script you might 19 | * want to package as well as its dependencies. See the [Plugin Generator 20 | * doc](https://github.com/laurent22/joplin/blob/dev/packages/generator-joplin/README.md) 21 | * for more information. 22 | * 23 | * * [View the renderer demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) 24 | * * [View the editor plugin tutorial](https://joplinapp.org/help/api/tutorials/cm6_plugin) 25 | * * [View the legacy editor demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) 26 | * 27 | * See also the [postMessage demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) 28 | * 29 | * @param type Defines how the script will be used. See the type definition for more information about each supported type. 30 | * @param id A unique ID for the content script. 31 | * @param scriptPath Must be a path relative to the plugin main script. For example, if your file content_script.js is next to your index.ts file, you would set `scriptPath` to `"./content_script.js`. 32 | */ 33 | register(type: ContentScriptType, id: string, scriptPath: string): Promise; 34 | /** 35 | * Listens to a messages sent from the content script using postMessage(). 36 | * See {@link ContentScriptType} for more information as well as the 37 | * [postMessage 38 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) 39 | */ 40 | onMessage(contentScriptId: string, callback: any): Promise; 41 | } 42 | -------------------------------------------------------------------------------- /src/summaryFormatters/table.ts: -------------------------------------------------------------------------------- 1 | import { Settings, Todo, SummaryMap } from '../types'; 2 | 3 | export function formatTodo(todo: Todo, settings: Settings): string { 4 | let todoString = `\n| ${todo.msg} | ${todo.category} | ${todo.date} | ${todo.tags.join(' ')} | ${todo.parent_title} | [${todo.note_title}](:/${todo.note}) |`; 5 | if (settings.show_complete_todo) { 6 | todoString += ` ${todo.completed ? 'Y' : ''} |`; 7 | } 8 | return todoString; 9 | } 10 | 11 | // Create a string by concating some Note fields, this will determine sort order 12 | function sortString(todo: Todo, settings: Settings): string { 13 | if (settings.sort_by === 'date') { 14 | const sortableDate = formatDateForSorting(todo.date) || '9999-12-31'; 15 | return sortableDate + todo.category + todo.parent_title + todo.note_title + todo.msg + todo.note; 16 | } 17 | return todo.category + todo.parent_title + todo.note_title + todo.msg + todo.note; 18 | } 19 | 20 | // Convert date to sortable format (YYYY-MM-DD), handling various input formats 21 | function formatDateForSorting(dateStr: string): string | null { 22 | if (!dateStr || dateStr.trim() === '') { 23 | return null; 24 | } 25 | 26 | try { 27 | const date = new Date(dateStr); 28 | if (isNaN(date.getTime())) { 29 | return null; 30 | } 31 | 32 | // Format as YYYY-MM-DD for proper string sorting 33 | return date.getFullYear() + '-' + 34 | String(date.getMonth() + 1).padStart(2, '0') + '-' + 35 | String(date.getDate()).padStart(2, '0'); 36 | } catch { 37 | return null; 38 | } 39 | } 40 | 41 | export async function tableBody(summary_map: SummaryMap, settings: Settings) { 42 | let completed: Todo[] = []; 43 | let summaryBody = '| Task | category | Due | Tags | Notebook | Note |'; 44 | if (settings.show_complete_todo) { 45 | summaryBody += ' Completed |'; 46 | } 47 | summaryBody += '\n| ---- | -------- | --- | ---- | -------- | ---- |'; 48 | if (settings.show_complete_todo) { 49 | summaryBody += ' --------- |'; 50 | } 51 | 52 | let todos = [] 53 | for (const [id, tds] of Object.entries(summary_map)) { 54 | todos = todos.concat(tds) 55 | } 56 | 57 | todos = todos.sort((a, b) => sortString(a, settings).localeCompare(sortString(b, settings), undefined, { sensitivity: 'accent', numeric: true })); 58 | for (let todo of todos) { 59 | if (todo.completed) { 60 | completed.push(todo) 61 | } 62 | else { 63 | summaryBody += formatTodo(todo, settings); 64 | } 65 | } 66 | 67 | if (completed.length > 0 && settings.show_complete_todo) { 68 | for (let todo of completed) { 69 | summaryBody += formatTodo(todo, settings); 70 | } 71 | } 72 | 73 | if (!summaryBody) { 74 | summaryBody = '# All done!\n\n'; 75 | } 76 | 77 | return summaryBody; 78 | } 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joplin-plugin-inline-todo", 3 | "version": "1.9.0", 4 | "scripts": { 5 | "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", 6 | "prepare": "npm run dist", 7 | "updateVersion": "webpack --env joplin-plugin-config=updateVersion", 8 | "update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force", 9 | "updatetags": "LOGS=$(git log $(git describe --tags --abbrev=0 HEAD~1)..HEAD~1 --oneline) && git tag -fam \"v$npm_package_version\n\n$LOGS\" v$npm_package_version && git tag -fa v$npm_package_version", 10 | "test": "jest", 11 | "postversion": "npm run updatetags && git push origin master --tags", 12 | "version": "sed -i '/\\\"version\\\": \\\"/s/[^\\\"]*\\\",/'\"$npm_package_version\\\",/\" src/manifest.json && git add src/manifest.json" 13 | }, 14 | "license": "MIT", 15 | "homepage": "https://github.com/CalebJohn/joplin-inline-todo#readme", 16 | "keywords": [ 17 | "joplin-plugin", 18 | "todo" 19 | ], 20 | "devDependencies": { 21 | "@eslint/js": "^9.38.0", 22 | "@tailwindcss/cli": "^4.1.14", 23 | "@types/jest": "^30.0.0", 24 | "@types/node": "^18.7.13", 25 | "chalk": "^4.1.0", 26 | "class-variance-authority": "^0.7.1", 27 | "clsx": "^2.1.1", 28 | "copy-webpack-plugin": "^11.0.0", 29 | "esbuild-loader": "^4.4.0", 30 | "eslint": "^9.38.0", 31 | "eslint-plugin-react": "^7.37.5", 32 | "eslint-plugin-react-hooks": "^7.0.1", 33 | "fs-extra": "^10.1.0", 34 | "glob": "^8.0.3", 35 | "globals": "^16.4.0", 36 | "jest": "^30.2.0", 37 | "lucide-react": "^0.544.0", 38 | "on-build-webpack": "^0.1.0", 39 | "react": "^19.1.1", 40 | "react-dom": "^19.1.1", 41 | "tailwind-merge": "^3.3.1", 42 | "tailwindcss": "^4.1.14", 43 | "tar": "^6.1.11", 44 | "ts-jest": "^29.4.6", 45 | "ts-loader": "^9.3.1", 46 | "tw-animate-css": "^1.4.0", 47 | "typescript": "^4.8.2", 48 | "typescript-eslint": "^8.46.2", 49 | "webpack": "^5.74.0", 50 | "webpack-cli": "^4.10.0", 51 | "yargs": "^16.2.0" 52 | }, 53 | "files": [ 54 | "publish" 55 | ], 56 | "dependencies": { 57 | "@joplin/utils": "^3.4.1", 58 | "@radix-ui/react-collapsible": "^1.1.12", 59 | "@radix-ui/react-dialog": "^1.1.15", 60 | "@radix-ui/react-dropdown-menu": "^2.1.16", 61 | "@radix-ui/react-label": "^2.1.8", 62 | "@radix-ui/react-popover": "^1.1.15", 63 | "@radix-ui/react-separator": "^1.1.7", 64 | "@radix-ui/react-slot": "^1.2.4", 65 | "@radix-ui/react-tooltip": "^1.2.8", 66 | "luxon": "^3.7.2", 67 | "react-select": "^5.10.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/summary_note.ts: -------------------------------------------------------------------------------- 1 | import joplin from 'api'; 2 | import { Note, Summary, SummaryMap } from './types'; 3 | 4 | const summary_regex = //gm; 5 | 6 | function summaryString(notebooks: string): string { 7 | const summary_string = `\n`; 8 | 9 | return summary_string; 10 | } 11 | 12 | export async function createSummaryNote() { 13 | const par = await joplin.workspace.selectedFolder() 14 | await joplin.data.post(['notes'], null, {title: 'Todo Summary', parent_id: par.id, body: summaryString(' ')}) 15 | .catch((error) => { 16 | console.error(error); 17 | console.warn("Could not create summary note with api in notebook: " + par.id); 18 | }); 19 | } 20 | 21 | export function insertNewSummary(old_body: string, summaryBody: string): string { 22 | // Preserve the content after the hr 23 | let spl = old_body.split(summary_regex); 24 | spl[0] = summaryBody; 25 | // preserve the custom filters 26 | spl[1] = summaryString(spl[1]); 27 | return spl.join(''); 28 | } 29 | 30 | export function filterSummaryCategories(body: string, summary: Summary): SummaryMap { 31 | summary_regex.lastIndex = 0; 32 | const match = summary_regex.exec(body); 33 | if (match === null) { 34 | console.error("filterSummaryCategories called on a note with no summary comment"); 35 | console.error(body); 36 | // This would be a bug if it happens but ¯\_(ツ)_/¯ 37 | return summary.map; 38 | } 39 | const notebook_string = match[1].trim(); 40 | 41 | // No filtering necessary 42 | if (!notebook_string) { return summary.map; } 43 | 44 | 45 | // notebooks is a string with notebook names seperated by whitespace. 46 | // If the name itself contains a whitespace, the notebook name will have quotes 47 | // So we'll have to hand off the parsing to an actual parser 48 | const notebooks = parseNotebookNames(notebook_string); 49 | 50 | let new_summary: SummaryMap = {}; 51 | for (const [id, todos] of Object.entries(summary.map)) { 52 | const entry = todos.filter(todo => notebooks.includes(todo.parent_title)); 53 | if (entry.length > 0) { 54 | new_summary[id] = entry; 55 | } 56 | } 57 | 58 | return new_summary; 59 | } 60 | 61 | function parseNotebookNames(nbs: string): string[] { 62 | let parsed = []; 63 | let acc = ''; 64 | let in_quote = false; 65 | for (let i = 0; i < nbs.length; i++) { 66 | const ct = nbs[i]; 67 | 68 | if (ct === ' ' && !in_quote) { 69 | parsed.push(acc); 70 | acc = ''; 71 | } 72 | else if (ct === '"') { 73 | in_quote = !in_quote; 74 | } 75 | else { 76 | acc += ct; 77 | } 78 | } 79 | 80 | parsed.push(acc); 81 | 82 | return parsed; 83 | } 84 | 85 | export function isSummary(currentNote: Note): boolean { 86 | return !!currentNote?.body.match(summary_regex); 87 | } 88 | -------------------------------------------------------------------------------- /src/summaryFormatters/plain.ts: -------------------------------------------------------------------------------- 1 | import { Settings, Todo, SummaryMap } from '../types'; 2 | 3 | export function formatTodo(todo: Todo, _settings: Settings): string { 4 | const tags = todo.tags.map((s: string) => '+' + s).join(' '); 5 | if (todo.date) { 6 | return `- [${todo.note_title}](:/${todo.note}): ${todo.date} ${todo.msg} @${todo.category} ${tags}\n`; 7 | } else { 8 | return `- [${todo.note_title}](:/${todo.note}): ${todo.msg} ${tags}\n`; 9 | } 10 | } 11 | 12 | // Create a string by concating some Note fields, this will determine sort order 13 | function sortString(todo: Todo): string { 14 | return todo.note_title + todo.msg + todo.note; 15 | } 16 | 17 | export async function plainBody(summary_map: SummaryMap, settings: Settings) { 18 | let summaryBody = ''; 19 | let summary: Record> = {}; 20 | let due: Todo[] = []; 21 | let completed: Todo[] = []; 22 | 23 | for (const [id, todos] of Object.entries(summary_map)) { 24 | for (let todo of todos) { 25 | if (todo.completed) { 26 | completed.push(todo) 27 | } 28 | else { 29 | if (todo.date) { 30 | due.push(todo); 31 | } else { 32 | const category = todo.category.toUpperCase(); 33 | if (!(category in summary)) { 34 | summary[category] = {}; 35 | } 36 | if (!(todo.parent_title in summary[category])) { 37 | summary[category][todo.parent_title] = []; 38 | } 39 | summary[category][todo.parent_title].push(todo); 40 | } 41 | } 42 | } 43 | } 44 | 45 | if (due.length > 0) { 46 | summaryBody += `# DUE\n`; 47 | 48 | due.sort((a, b) => { return Date.parse(a.date) - Date.parse(b.date); }); 49 | summaryBody += due.map((d) => formatTodo(d, settings)).join('\n'); 50 | summaryBody += '\n'; 51 | 52 | delete summary["DUE"]; 53 | } 54 | 55 | const entries = Object.entries(summary).sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: 'accent', numeric: true })); 56 | // The rest of the "categories" 57 | for (const [category, folders] of entries) { 58 | if (category) { 59 | summaryBody += `# ${category}\n`; 60 | } 61 | const fentries = Object.entries(folders).sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: 'accent', numeric: true })); 62 | for (const [folder, tds] of fentries) { 63 | summaryBody += `## ${folder}\n`; 64 | const todos = tds.sort((a, b) => sortString(a).localeCompare(sortString(b), undefined, { sensitivity: 'accent', numeric: true })); 65 | for (let todo of todos) { 66 | summaryBody += formatTodo(todo, settings) + '\n'; 67 | } 68 | } 69 | } 70 | if (completed.length > 0 && settings.show_complete_todo) { 71 | summaryBody += `# COMPLETED\n`; 72 | 73 | completed.sort((a, b) => { return Date.parse(a.date) - Date.parse(b.date); }); 74 | summaryBody += completed.map((d) => formatTodo(d, settings)).join('\n'); 75 | 76 | summaryBody += '\n'; 77 | 78 | delete summary["COMPLETED"]; 79 | } 80 | 81 | if (!summaryBody) { 82 | summaryBody = '# All done!\n\n'; 83 | } 84 | 85 | return summaryBody; 86 | } 87 | -------------------------------------------------------------------------------- /api/Joplin.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import JoplinData from './JoplinData'; 3 | import JoplinPlugins from './JoplinPlugins'; 4 | import JoplinWorkspace from './JoplinWorkspace'; 5 | import JoplinFilters from './JoplinFilters'; 6 | import JoplinCommands from './JoplinCommands'; 7 | import JoplinViews from './JoplinViews'; 8 | import JoplinInterop from './JoplinInterop'; 9 | import JoplinSettings from './JoplinSettings'; 10 | import JoplinContentScripts from './JoplinContentScripts'; 11 | import JoplinClipboard from './JoplinClipboard'; 12 | import JoplinWindow from './JoplinWindow'; 13 | import BasePlatformImplementation from '../BasePlatformImplementation'; 14 | import JoplinImaging from './JoplinImaging'; 15 | /** 16 | * This is the main entry point to the Joplin API. You can access various services using the provided accessors. 17 | * 18 | * The API is now relatively stable and in general maintaining backward compatibility is a top priority, so you shouldn't except much breakages. 19 | * 20 | * If a breaking change ever becomes needed, best effort will be done to: 21 | * 22 | * - Deprecate features instead of removing them, so as to give you time to fix the issue; 23 | * - Document breaking changes in the changelog; 24 | * 25 | * So if you are developing a plugin, please keep an eye on the changelog as everything will be in there with information about how to update your code. 26 | */ 27 | export default class Joplin { 28 | private data_; 29 | private plugins_; 30 | private imaging_; 31 | private workspace_; 32 | private filters_; 33 | private commands_; 34 | private views_; 35 | private interop_; 36 | private settings_; 37 | private contentScripts_; 38 | private clipboard_; 39 | private window_; 40 | private implementation_; 41 | constructor(implementation: BasePlatformImplementation, plugin: Plugin, store: any); 42 | get data(): JoplinData; 43 | get clipboard(): JoplinClipboard; 44 | get imaging(): JoplinImaging; 45 | get window(): JoplinWindow; 46 | get plugins(): JoplinPlugins; 47 | get workspace(): JoplinWorkspace; 48 | get contentScripts(): JoplinContentScripts; 49 | /** 50 | * @ignore 51 | * 52 | * Not sure if it's the best way to hook into the app 53 | * so for now disable filters. 54 | */ 55 | get filters(): JoplinFilters; 56 | get commands(): JoplinCommands; 57 | get views(): JoplinViews; 58 | get interop(): JoplinInterop; 59 | get settings(): JoplinSettings; 60 | /** 61 | * It is not possible to bundle native packages with a plugin, because they 62 | * need to work cross-platforms. Instead access to certain useful native 63 | * packages is provided using this function. 64 | * 65 | * Currently these packages are available: 66 | * 67 | * - [sqlite3](https://www.npmjs.com/package/sqlite3) 68 | * - [fs-extra](https://www.npmjs.com/package/fs-extra) 69 | * 70 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/nativeModule) 71 | * 72 | * desktop 73 | */ 74 | require(_path: string): any; 75 | versionInfo(): Promise; 76 | /** 77 | * Tells whether the current theme is a dark one or not. 78 | */ 79 | shouldUseDarkColors(): Promise; 80 | } 81 | -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- 1 | import joplin from 'api'; 2 | import { ModelType } from 'api/types'; 3 | import { SummaryBuilder } from './builder'; 4 | import { isSummary } from './summary_note'; 5 | import { Filters, IpcMessage, Todo } from './types'; 6 | import { mark_done_scrollto } from './mark_todo'; 7 | import Logger from "@joplin/utils/Logger"; 8 | 9 | const logger = Logger.create('inline-todo: registerEditor'); 10 | 11 | export async function registerEditor(builder: SummaryBuilder) { 12 | const versionInfo = await joplin.versionInfo(); 13 | const editors = joplin.views.editors; 14 | 15 | editors.register("todo-editor", { 16 | async onSetup(view) { 17 | await editors.setHtml(view, `
`); 18 | await editors.addScript(view, './panel.js'); 19 | await editors.addScript(view, './gui/style/output.css'); 20 | 21 | // await editors.onUpdate(view, async () => { 22 | // logger.info('onUpdate'); 23 | // }); 24 | 25 | editors.onMessage(view, async (message:IpcMessage) => { 26 | // These messages are internal messages sent within the app webview and can be ignored 27 | if ((message as any).kind === 'ReturnValueResponse') return; 28 | 29 | logger.info('PostMessagePlugin (Webview): Got message from webview:', message); 30 | 31 | if (message.type === 'getSettings') { 32 | return JSON.stringify(builder.settings); 33 | } 34 | else if (message.type === 'getSummary') { 35 | await builder.search_in_all(); 36 | return builder.summary; 37 | } 38 | else if (message.type === 'markDone') { 39 | const todo = message.value as Todo; 40 | await mark_done_scrollto(todo); 41 | 42 | await builder.search_in_all(); 43 | editors.postMessage(view, { type: 'updateSummary', value: builder.summary }); 44 | 45 | return; 46 | } 47 | else if (message.type === 'jumpTo') { 48 | const todo = message.value as Todo; 49 | 50 | if (!todo || !todo.scrollTo) { return; } 51 | 52 | await joplin.commands.execute('openNote', todo.note); 53 | await new Promise(resolve => setTimeout(resolve, 500)); 54 | await joplin.commands.execute('editor.scrollToText', todo.scrollTo); 55 | return; 56 | } 57 | else if (message.type === 'setFilters') { 58 | const currentNote = await joplin.workspace.selectedNote(); 59 | if (!currentNote) return false; 60 | 61 | if (isSummary(currentNote)) { 62 | const filters = message.value as Filters; 63 | await joplin.data.userDataSet(ModelType.Note, currentNote.id, 'filters', filters); 64 | } 65 | return; 66 | } 67 | else if (message.type === 'getFilters') { 68 | const currentNote = await joplin.workspace.selectedNote(); 69 | if (!currentNote) return false; 70 | 71 | if (isSummary(currentNote)) { 72 | return await joplin.data.userDataGet(ModelType.Note, currentNote.id, 'filters'); 73 | } 74 | return; 75 | } 76 | 77 | logger.warn('Unknown message: ' + JSON.stringify(message)); 78 | }); 79 | 80 | }, 81 | async onActivationCheck(event) { 82 | if (!event.noteId) return false; 83 | if (!builder.settings.custom_editor) return false; 84 | 85 | const note = await joplin.data.get([ 'notes', event.noteId ], { fields: ['body'] }); 86 | 87 | logger.info('onActivationCheck: Handling note: ' + event.noteId); 88 | return isSummary(note); 89 | } 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /api/JoplinViewsDialogs.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ButtonSpec, ViewHandle, DialogResult, Toast } from './types'; 3 | /** 4 | * Allows creating and managing dialogs. A dialog is modal window that 5 | * contains a webview and a row of buttons. You can update the 6 | * webview using the `setHtml` method. Dialogs are hidden by default and 7 | * you need to call `open()` to open them. Once the user clicks on a 8 | * button, the `open` call will return an object indicating what button was 9 | * clicked on. 10 | * 11 | * ## Retrieving form values 12 | * 13 | * If your HTML content included one or more forms, a `formData` object 14 | * will also be included with the key/value for each form. 15 | * 16 | * ## Special button IDs 17 | * 18 | * The following buttons IDs have a special meaning: 19 | * 20 | * - `ok`, `yes`, `submit`, `confirm`: They are considered "submit" buttons 21 | * - `cancel`, `no`, `reject`: They are considered "dismiss" buttons 22 | * 23 | * This information is used by the application to determine what action 24 | * should be done when the user presses "Enter" or "Escape" within the 25 | * dialog. If they press "Enter", the first "submit" button will be 26 | * automatically clicked. If they press "Escape" the first "dismiss" button 27 | * will be automatically clicked. 28 | * 29 | * [View the demo 30 | * plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/dialog) 31 | */ 32 | export default class JoplinViewsDialogs { 33 | private store; 34 | private plugin; 35 | private implementation_; 36 | constructor(implementation: any, plugin: Plugin, store: any); 37 | private controller; 38 | /** 39 | * Creates a new dialog 40 | */ 41 | create(id: string): Promise; 42 | /** 43 | * Displays a message box with OK/Cancel buttons. Returns the button index that was clicked - "0" for OK and "1" for "Cancel" 44 | */ 45 | showMessageBox(message: string): Promise; 46 | /** 47 | * Displays a Toast notification in the corner of the application screen. 48 | */ 49 | showToast(toast: Toast): Promise; 50 | /** 51 | * Displays a dialog to select a file or a directory. Same options and 52 | * output as 53 | * https://www.electronjs.org/docs/latest/api/dialog#dialogshowopendialogbrowserwindow-options 54 | * 55 | * desktop 56 | */ 57 | showOpenDialog(options: any): Promise; 58 | /** 59 | * Sets the dialog HTML content 60 | */ 61 | setHtml(handle: ViewHandle, html: string): Promise; 62 | /** 63 | * Adds and loads a new JS or CSS files into the dialog. 64 | */ 65 | addScript(handle: ViewHandle, scriptPath: string): Promise; 66 | /** 67 | * Sets the dialog buttons. 68 | */ 69 | setButtons(handle: ViewHandle, buttons: ButtonSpec[]): Promise; 70 | /** 71 | * Opens the dialog. 72 | * 73 | * On desktop, this closes any copies of the dialog open in different windows. 74 | */ 75 | open(handle: ViewHandle): Promise; 76 | /** 77 | * Toggle on whether to fit the dialog size to the content or not. 78 | * When set to false, the dialog is set to 90vw and 80vh 79 | * @default true 80 | */ 81 | setFitToContent(handle: ViewHandle, status: boolean): Promise; 82 | } 83 | -------------------------------------------------------------------------------- /api/JoplinViewsPanels.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ViewHandle } from './types'; 3 | /** 4 | * Allows creating and managing view panels. View panels allow displaying any HTML 5 | * content (within a webview) and updating it in real-time. For example it 6 | * could be used to display a table of content for the active note, or 7 | * display various metadata or graph. 8 | * 9 | * On desktop, view panels currently are displayed at the right of the sidebar, though can 10 | * be moved with "View" > "Change application layout". 11 | * 12 | * On mobile, view panels are shown in a tabbed dialog that can be opened using a 13 | * toolbar button. 14 | * 15 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/toc) 16 | */ 17 | export default class JoplinViewsPanels { 18 | private store; 19 | private plugin; 20 | constructor(plugin: Plugin, store: any); 21 | private controller; 22 | /** 23 | * Creates a new panel 24 | */ 25 | create(id: string): Promise; 26 | /** 27 | * Sets the panel webview HTML 28 | */ 29 | setHtml(handle: ViewHandle, html: string): Promise; 30 | /** 31 | * Adds and loads a new JS or CSS files into the panel. 32 | */ 33 | addScript(handle: ViewHandle, scriptPath: string): Promise; 34 | /** 35 | * Called when a message is sent from the webview (using postMessage). 36 | * 37 | * To post a message from the webview to the plugin use: 38 | * 39 | * ```javascript 40 | * const response = await webviewApi.postMessage(message); 41 | * ``` 42 | * 43 | * - `message` can be any JavaScript object, string or number 44 | * - `response` is whatever was returned by the `onMessage` handler 45 | * 46 | * Using this mechanism, you can have two-way communication between the 47 | * plugin and webview. 48 | * 49 | * See the [postMessage 50 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) for more details. 51 | * 52 | */ 53 | onMessage(handle: ViewHandle, callback: Function): Promise; 54 | /** 55 | * Sends a message to the webview. 56 | * 57 | * The webview must have registered a message handler prior, otherwise the message is ignored. Use; 58 | * 59 | * ```javascript 60 | * webviewApi.onMessage((message) => { ... }); 61 | * ``` 62 | * 63 | * - `message` can be any JavaScript object, string or number 64 | * 65 | * The view API may have only one onMessage handler defined. 66 | * This method is fire and forget so no response is returned. 67 | * 68 | * It is particularly useful when the webview needs to react to events emitted by the plugin or the joplin api. 69 | */ 70 | postMessage(handle: ViewHandle, message: any): void; 71 | /** 72 | * Shows the panel 73 | */ 74 | show(handle: ViewHandle, show?: boolean): Promise; 75 | /** 76 | * Hides the panel 77 | */ 78 | hide(handle: ViewHandle): Promise; 79 | /** 80 | * Tells whether the panel is visible or not 81 | */ 82 | visible(handle: ViewHandle): Promise; 83 | /** 84 | * Assuming that the current panel is an editor plugin view, returns 85 | * whether the editor plugin view supports editing the current note. 86 | */ 87 | isActive(handle: ViewHandle): Promise; 88 | } 89 | -------------------------------------------------------------------------------- /src/summaryFormatters/diary.ts: -------------------------------------------------------------------------------- 1 | // Maintainer: [psfinal9](https://github.com/psfinal9/) 2 | 3 | import { Settings, Todo, SummaryMap } from '../types'; 4 | 5 | export function formatTodo(todo: Todo, _settings: Settings): string { 6 | const tags = todo.tags.map((s: string) => '+' + s).join(' '); 7 | 8 | const regex = /\[.*\]/gi; 9 | if (todo.date) { 10 | // show note name if todo name contain link to avoid ugly nested link 11 | if ( regex.test(todo.msg) ) { 12 | return `- [${todo.note_title}](:/${todo.note}): ${todo.date} ${todo.msg} ${tags}\n`; 13 | } 14 | else { 15 | return `- [${todo.msg}](:/${todo.note}): ${todo.date} ${tags}\n`; 16 | } 17 | } 18 | else { 19 | if ( regex.test(todo.msg) ) { 20 | return `- [${todo.note_title}](:/${todo.note}): ${todo.msg} ${tags}\n`; 21 | } 22 | else { 23 | return `- [${todo.msg}](:/${todo.note}) ${tags}\n`; 24 | } 25 | } 26 | } 27 | 28 | // Create a string by concating some Note fields, this will determine sort order 29 | function sortString(todo: Todo): string { 30 | return todo.note_title + todo.msg + todo.note; 31 | } 32 | 33 | export async function diaryBody(summary_map: SummaryMap, settings: Settings) { 34 | let summaryBody = ''; 35 | let summary: Record> = {}; 36 | let due: Todo[] = []; 37 | let completed: Todo[] = []; 38 | 39 | 40 | for (const [id, todos] of Object.entries(summary_map)) { 41 | for (let todo of todos) { 42 | if (todo.completed) { 43 | completed.push(todo) 44 | } 45 | else { 46 | if (todo.date) { 47 | due.push(todo); 48 | } else { 49 | const category = todo.category.toUpperCase(); 50 | if (!(category in summary)) { 51 | summary[category] = {}; 52 | } 53 | if (!(todo.parent_title in summary[category])) { 54 | summary[category][todo.parent_title] = []; 55 | } 56 | summary[category][todo.parent_title].push(todo); 57 | } 58 | } 59 | } 60 | } 61 | if (due.length > 0) { 62 | summaryBody += `# DUE\n`; 63 | 64 | due.sort((a, b) => { return Date.parse(a.date) - Date.parse(b.date); }); 65 | summaryBody += due.map(td => formatTodo(td, settings)).join('\n'); 66 | summaryBody += '\n'; 67 | 68 | delete summary["DUE"]; 69 | } 70 | 71 | const entries = Object.entries(summary).sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: 'accent', numeric: true })); 72 | // The rest of the "categories" 73 | for (const [category, folders] of entries) { 74 | if (category) { 75 | summaryBody += `# ${category}\n`; 76 | } 77 | const fentries = Object.entries(folders).sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: 'accent', numeric: true })); 78 | for (const [folder, tds] of fentries) { 79 | if (false) { 80 | summaryBody += `## ${folder}\n`; 81 | } 82 | const todos = tds.sort((a, b) => sortString(a).localeCompare(sortString(b), undefined, { sensitivity: 'accent', numeric: true })); 83 | for (let todo of todos) { 84 | summaryBody += formatTodo(todo, settings) + '\n'; 85 | } 86 | } 87 | } 88 | 89 | if (completed.length > 0 && settings.show_complete_todo) { 90 | summaryBody += `# COMPLETED\n`; 91 | 92 | completed.sort((a, b) => { return Date.parse(a.date) - Date.parse(b.date); }); 93 | summaryBody += completed.map(td => formatTodo(td, settings)).join('\n'); 94 | 95 | summaryBody += '\n'; 96 | 97 | delete summary["COMPLETED"]; 98 | } 99 | 100 | if (!summaryBody) { 101 | summaryBody = '# All done!\n\n'; 102 | } 103 | 104 | return summaryBody; 105 | } 106 | -------------------------------------------------------------------------------- /api/JoplinSettings.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { SettingItem, SettingSection } from './types'; 3 | export interface ChangeEvent { 4 | /** 5 | * Setting keys that have been changed 6 | */ 7 | keys: string[]; 8 | } 9 | export type ChangeHandler = (event: ChangeEvent) => void; 10 | /** 11 | * This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user. 12 | * 13 | * Settings are essentially key/value pairs. 14 | * 15 | * Note: Currently this API does **not** provide access to Joplin's built-in settings. This is by design as plugins that modify user settings could give unexpected results 16 | * 17 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/settings) 18 | */ 19 | export default class JoplinSettings { 20 | private plugin_; 21 | constructor(plugin: Plugin); 22 | /** 23 | * Registers new settings. 24 | * Note that registering a setting item is dynamic and will be gone next time Joplin starts. 25 | * What it means is that you need to register the setting every time the plugin starts (for example in the onStart event). 26 | * The setting value however will be preserved from one launch to the next so there is no risk that it will be lost even if for some 27 | * reason the plugin fails to start at some point. 28 | */ 29 | registerSettings(settings: Record): Promise; 30 | /** 31 | * @deprecated Use joplin.settings.registerSettings() 32 | * 33 | * Registers a new setting. 34 | */ 35 | registerSetting(key: string, settingItem: SettingItem): Promise; 36 | /** 37 | * Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts. 38 | */ 39 | registerSection(name: string, section: SettingSection): Promise; 40 | /** 41 | * Gets setting values (only applies to setting you registered from your plugin) 42 | */ 43 | values(keys: string[] | string): Promise>; 44 | /** 45 | * Gets a setting value (only applies to setting you registered from your plugin). 46 | * 47 | * Note: If you want to retrieve all your plugin settings, for example when the plugin starts, 48 | * it is recommended to use the `values()` function instead - it will be much faster than 49 | * calling `value()` multiple times. 50 | */ 51 | value(key: string): Promise; 52 | /** 53 | * Sets a setting value (only applies to setting you registered from your plugin) 54 | */ 55 | setValue(key: string, value: any): Promise; 56 | /** 57 | * Gets global setting values, including app-specific settings and those set by other plugins. 58 | * 59 | * The list of available settings is not documented yet, but can be found by looking at the source code: 60 | * 61 | * https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts 62 | */ 63 | globalValues(keys: string[]): Promise; 64 | /** 65 | * @deprecated Use joplin.settings.globalValues() 66 | */ 67 | globalValue(key: string): Promise; 68 | /** 69 | * Called when one or multiple settings of your plugin have been changed. 70 | * - For performance reasons, this event is triggered with a delay. 71 | * - You will only get events for your own plugin settings. 72 | */ 73 | onChange(handler: ChangeHandler): Promise; 74 | } 75 | -------------------------------------------------------------------------------- /icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 38 | 40 | 44 | 55 | 63 | 67 | 72 | 80 | 88 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /src/gui/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FilterX } from "lucide-react"; 3 | import { CheckFilterComponent } from "./CheckFilter" 4 | import { DateFilterComponent } from "./DateFilter" 5 | import { SaveFilterComponent } from "./SaveFilter" 6 | import { SavedFiltersComponent } from "./SavedFilters" 7 | import { SelectFilterComponent } from "./SelectFilter"; 8 | import { groupsToOptions } from "./lib/selectUtils"; 9 | import { Button } from "@/src/gui/components/ui/button"; 10 | import { 11 | Sidebar, 12 | SidebarContent, 13 | SidebarFooter, 14 | SidebarGroup, 15 | SidebarGroupContent, 16 | SidebarGroupLabel, 17 | SidebarMenu, 18 | SidebarMenuItem, 19 | } from "@/src/gui/components/ui/sidebar"; 20 | import { Filtered, Filters, UniqueFields } from "../types"; 21 | import Logger from "@joplin/utils/Logger"; 22 | 23 | const logger = Logger.create('inline-todo: Sidebar.tsx'); 24 | 25 | interface Props { 26 | dispatch: (o) => void; 27 | filtered: Filtered; 28 | filters: Filters; 29 | uniqueFields: UniqueFields; 30 | } 31 | 32 | export function FilterSidebar({ dispatch, filtered, filters, uniqueFields }: Props) { 33 | const clearActiveFilter = () => { 34 | dispatch({ type: 'clearActive' }); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 41 | Saved 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Filters 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 75 | 76 | 77 | 78 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /src/gui/SavedFilters.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState } from "react"; 3 | import { Filtered, Filters } from "../types"; 4 | import { FilterNameDialogComponent } from './FilterNameDialog'; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogDescription, 9 | DialogHeader, 10 | DialogTitle, 11 | } from "@/src/gui/components/ui/dialog" 12 | import { 13 | DropdownMenu, 14 | DropdownMenuContent, 15 | DropdownMenuItem, 16 | DropdownMenuTrigger, 17 | } from "@/src/gui/components/ui/dropdown-menu" 18 | import { 19 | SidebarMenuAction, 20 | SidebarMenuBadge, 21 | SidebarMenuButton, 22 | SidebarMenuItem, 23 | } from "@/src/gui/components/ui/sidebar"; 24 | import { MoreHorizontal } from "lucide-react"; 25 | import Logger from "@joplin/utils/Logger"; 26 | 27 | const logger = Logger.create('inline-todo: SavedFilters.tsx'); 28 | 29 | interface Props { 30 | dispatch: (o) => void; 31 | filtered: Filtered; 32 | filters: Filters; 33 | } 34 | 35 | 36 | export function SavedFiltersComponent({ dispatch, filtered, filters }: Props) { 37 | const [showSaveDialog, setShowSaveDialog] = useState(false); 38 | 39 | const switchToFilter = (item) => { 40 | const savedFilter = filters.saved.find(sf => sf.filterName === item.filterName); 41 | 42 | if (!!savedFilter) { 43 | dispatch({ type: 'switchToSaved', filter: savedFilter }) 44 | } else { 45 | logger.error("Cannot find a saved filter with name:", item.name, "this is a bug"); 46 | } 47 | }; 48 | 49 | const changeFilterName = (item, newName) => { 50 | dispatch({ 51 | type: "renameSaved", 52 | oldName: item.filterName, 53 | newName: newName, 54 | }); 55 | setShowSaveDialog(false); 56 | } 57 | 58 | const deleteFilter = (e, item) => { 59 | // Give time for the dropdown to close before deleting the item 60 | e.preventDefault(); 61 | setTimeout(() => { 62 | dispatch({ 63 | type: "deleteSaved", 64 | name: item.filterName, 65 | }); 66 | }, 10); 67 | } 68 | 69 | 70 | return ( 71 |
72 | {filtered.saved.map((sf) => ( 73 | 74 | 75 | switchToFilter(sf)}> 76 | {sf.filterName} 77 | 78 | 79 | {sf.openCount} 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | setShowSaveDialog(true)}> 88 | Rename Filter 89 | 90 | deleteFilter(e, sf)}> 91 | Delete Filter 92 | 93 | 94 | 95 | 96 | 97 | 98 | Rename Filter 99 | 100 | Please Enter a new name. Press Escape to cancel. 101 | 102 | 103 | changeFilterName(sf, newName)} /> 104 | 105 | 106 | 107 | ))} 108 |
109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /api/JoplinImaging.d.ts: -------------------------------------------------------------------------------- 1 | import { Rectangle } from './types'; 2 | export interface CreateFromBufferOptions { 3 | width?: number; 4 | height?: number; 5 | scaleFactor?: number; 6 | } 7 | export interface CreateFromPdfOptions { 8 | /** 9 | * The first page to export. Defaults to `1`, the first page in 10 | * the document. 11 | */ 12 | minPage?: number; 13 | /** 14 | * The number of the last page to convert. Defaults to the last page 15 | * if not given. 16 | * 17 | * If `maxPage` is greater than the number of pages in the PDF, all pages 18 | * in the PDF will be converted to images. 19 | */ 20 | maxPage?: number; 21 | scaleFactor?: number; 22 | } 23 | export interface PdfInfo { 24 | pageCount: number; 25 | } 26 | export interface Implementation { 27 | createFromPath: (path: string) => Promise; 28 | createFromPdf: (path: string, options: CreateFromPdfOptions) => Promise; 29 | getPdfInfo: (path: string) => Promise; 30 | } 31 | export interface ResizeOptions { 32 | width?: number; 33 | height?: number; 34 | quality?: 'good' | 'better' | 'best'; 35 | } 36 | export type Handle = string; 37 | /** 38 | * Provides imaging functions to resize or process images. You create an image 39 | * using one of the `createFrom` functions, then use the other functions to 40 | * process the image. 41 | * 42 | * Images are associated with a handle which is what will be available to the 43 | * plugin. Once you are done with an image, free it using the `free()` function. 44 | * 45 | * [View the 46 | * example](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/imaging/src/index.ts) 47 | * 48 | * desktop 49 | */ 50 | export default class JoplinImaging { 51 | private implementation_; 52 | private images_; 53 | constructor(implementation: Implementation); 54 | private createImageHandle; 55 | private imageByHandle; 56 | private cacheImage; 57 | /** 58 | * Creates an image from the provided path. Note that images and PDFs are supported. If you 59 | * provide a URL instead of a local path, the file will be downloaded first then converted to an 60 | * image. 61 | */ 62 | createFromPath(filePath: string): Promise; 63 | createFromResource(resourceId: string): Promise; 64 | createFromPdfPath(path: string, options?: CreateFromPdfOptions): Promise; 65 | createFromPdfResource(resourceId: string, options?: CreateFromPdfOptions): Promise; 66 | getPdfInfoFromPath(path: string): Promise; 67 | getPdfInfoFromResource(resourceId: string): Promise; 68 | getSize(handle: Handle): Promise; 69 | resize(handle: Handle, options?: ResizeOptions): Promise; 70 | crop(handle: Handle, rectangle: Rectangle): Promise; 71 | toPngFile(handle: Handle, filePath: string): Promise; 72 | /** 73 | * Quality is between 0 and 100 74 | */ 75 | toJpgFile(handle: Handle, filePath: string, quality?: number): Promise; 76 | private tempFilePath; 77 | /** 78 | * Creates a new Joplin resource from the image data. The image will be 79 | * first converted to a JPEG. 80 | */ 81 | toJpgResource(handle: Handle, resourceProps: any, quality?: number): Promise; 82 | /** 83 | * Creates a new Joplin resource from the image data. The image will be 84 | * first converted to a PNG. 85 | */ 86 | toPngResource(handle: Handle, resourceProps: any): Promise; 87 | /** 88 | * Image data is not automatically deleted by Joplin so make sure you call 89 | * this method on the handle once you are done. 90 | */ 91 | free(handles: Handle[] | Handle): Promise; 92 | } 93 | -------------------------------------------------------------------------------- /src/gui/CheckFilter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Label } from "@/src/gui/components/ui/label"; 3 | import Select from "react-select"; 4 | import { selectTheme } from "./lib/selectUtils"; 5 | import { 6 | Collapsible, 7 | CollapsibleContent, 8 | CollapsibleTrigger, 9 | } from "@/src/gui/components/ui/collapsible"; 10 | import { SidebarMenuButton, SidebarMenuItem } from "@/src/gui/components/ui/sidebar"; 11 | import { ChevronRight } from "lucide-react"; 12 | import Logger from "@joplin/utils/Logger"; 13 | 14 | const logger = Logger.create('inline-todo: CheckFilter'); 15 | 16 | interface Props { 17 | label: string; 18 | field: string; 19 | filter: string[]; 20 | items: string[]; 21 | defaultClosed?: boolean; 22 | dispatch: (o) => void; 23 | } 24 | 25 | 26 | export function CheckFilterComponent({ label, field, filter, items, defaultClosed, dispatch }: Props) { 27 | const options = items.map(item => ({ 28 | value: item, 29 | label: item 30 | })); 31 | 32 | // Convert filter array to react-select format 33 | const selectValues = filter.map(item => ({ 34 | value: item, 35 | label: item 36 | })); 37 | 38 | const handleCheck = (s: string) => { 39 | const newFilter = filter.includes(s) 40 | ? filter.filter(i => i !== s) 41 | : [...filter, s]; 42 | 43 | dispatch({ 44 | type: "updateActiveField", 45 | field: field, 46 | value: newFilter, 47 | }); 48 | } 49 | 50 | const handleSelectChange = (selected) => { 51 | const newFilter = selected ? selected.map(option => option.value) : []; 52 | 53 | dispatch({ 54 | type: "updateActiveField", 55 | field: field, 56 | value: newFilter, 57 | }); 58 | } 59 | 60 | const checkItem = async (event) => { 61 | event.stopPropagation(); 62 | }; 63 | 64 | return items.length > 0 && ( 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
75 |
76 | {items.length <=3 && ((items).map((item) => ( 77 |
78 | handleCheck(item)} 82 | onClick={checkItem} /> 83 | 86 |
87 | )))} 88 |
89 | {items.length > 3 && selectValues.map(option => ( 90 |
91 |
92 | {option.label} 93 |
94 | 102 |
103 | ))} 104 |
105 | {items.length > 3 && ( 106 | 173 | 174 | 175 |
176 |

177 | {todo.msg} 178 |

179 | 180 |
181 | {todo.date && ( 182 |
183 | {formatDate(todo.date)} 184 |
185 | )} 186 | 187 | 188 | 189 | {todo.parent_title} > 190 | 191 | {todo.note_title} 192 | 193 | {todo.category && 194 | 195 | @{todo.category} 196 | 197 | } 198 | 199 | { 200 | todo.tags.map((tag) => { 201 | return ( 202 | +{tag} 203 | ); 204 | }) 205 | } 206 | 207 |
208 |
209 |
210 |
211 | ); 212 | } 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inline TODOs 2 | A plugin that allows you to manage your TODOs anywhere in your notes and view a summary in one place. 3 | 4 | This plugin was initially written before the plugin system and interacted with Joplin through the API. I've been using it like that for the last few years. I finally took the time to translate it for the official Plugin system. Its implementation is pretty specific to my workflow and I don't plan to update it much, as it works for me. 5 | 6 | The basic function of this plugin is to have a single note where you can view all your inline TODOs. This single note is identified by containing the following special comment ``. This comment can be inserted by pressing `Tools -> Create TODO summary note`. Be careful not to place this in an existing note as the plugin will overwrite everything. 7 | 8 | # Installation 9 | - Go to `Tools -> Options -> Plugins`(macOS: Joplin -> Preferences -> Plugins) 10 | - Search for "Inline TODO" in the search box 11 | - Click Install and restart Joplin 12 | - Create a Todo Summary in your folder of choice (Tools -> Create TODO Summary Note) 13 | 14 | #### Or 15 | - Download the [plugin jpl](https://github.com/joplin/plugins/raw/master/plugins/plugin.calebjohn.todo/plugin.jpl) 16 | - Go to `Tools -> Options -> Plugins` 17 | - Click on the gear icon and select "Install from file" 18 | - Select the downloaded jpl file 19 | - Restart Joplin 20 | - Create a Todo Summary in your folder of choice (Tools -> Create TODO Summary Note) 21 | 22 | 23 | # Configuration 24 | 25 | - Tools -> Options -> Inline TODO (Windows/Linux) 26 | - Joplin -> Preferences (macOS)) 27 | 28 | ## TODO Types 29 | ### Metalist Style 30 | Inspired by [this post](https://discourse.joplinapp.org/t/create-a-task-report-plugin-for-a-joplin-note-taking-app/21177) on the Joplin forum. This is the preferred style because it uses the markdown checkbox format (plus some special syntax), making it trivial to check the box and hide the TODO from the summary. 31 | 32 | The basic form is a checkbox, followed any (or all) of: @category (this is a primary filtering field, so there can only be one), //date, +tags, and finally the TODO content. Having at least on of these special fields is required for the todo to be picked up by the plugin, without them it is just a plain checkbox. 33 | 34 | @category does not need to be a person, it can also be viewed as a category. It will sometimes affect the rendering of the content by grouping categories. 35 | ``` 36 | I take a lot of notes about various things. It can be helpful to 37 | keep my TODOs together with the content they pertain to. 38 | 39 | - [ ] @TODO Think about how to make a plugin to solve this +joplin 40 | 41 | This way the TODO benefits from context. 42 | 43 | - [ ] @TODO +joplin //2022-04-04 Release the TODO plugin! 44 | 45 | I'd still like a way to view all these! See below. 46 | ``` 47 | 48 | 49 | ### Link Style 50 | This is a simple TODO style that I've been using for the last few years. It intentionally uses the markdown link syntax which gives it highlighting in the editor and the viewer. 51 | This format can accept a [wider variety](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse#date_time_string_format) of date formats. 52 | 53 | The basic form is a link, where the name is "TODO" and the date replaces the URL section. The TODO content just follows after. 54 | 55 | ``` 56 | I take a lot of notes about various things. It can be helpful to 57 | keep my TODOs together with the content they pertain to. 58 | 59 | [TODO]() Think about how to make a plugin to solve this 60 | 61 | This way the TODO benefits from context. 62 | 63 | [TODO](2022-04-04) Release the TODO plugin! 64 | 65 | I'd still like a way to view all these! See below. 66 | ``` 67 | 68 | ### List Style 69 | This style just uses markdown checklist items. What sets this apart from the Metalist style is the lack of support for categories and dates in this style. This style was created for users that are already happy using plain checklists for their tasks, but want an additional place to collect them. For most users, I recommend the Metalist style instead. 70 | 71 | ``` 72 | I take a lot of notes about various things. It can be helpful to 73 | keep my TODOs together with the content they pertain to. 74 | 75 | - [ ] Think about how to make a plugin to solve this 76 | 77 | This way the TODO benefits from context. 78 | 79 | - [ ] Release the TODO plugin! 80 | 81 | I'd still like a way to view all these! See below. 82 | ``` 83 | 84 | 85 | ## Summary Types 86 | There are two supported summary styles. 87 | 88 | ### Plain 89 | This is the basic style that I created for myself, and have been using for the last few years. 90 | 91 | It starts by showing all the TODOs that have dates under the DUE section (sorted by date). After that, all the other TODOs are shown in no specific order under their respective category and parent notebook. 92 | 93 | This style is meant for personal use, the table method (below) is recommended for more complex use. 94 | 95 | ``` 96 | # DUE 97 | - [Note a](:/e710b7af31fc47c89ca5fc4d3c0ecb3a): 2022-01-13 Have some me time 98 | 99 | - [Note b](:/beef7ed6d91649149751cea8d14af02d): 2022-03-12 Meat delivery +burgers 100 | 101 | # Bob 102 | ## Folder 2 103 | - [Note c](:/ef3aac56ffa246baa6a96cc94dd8f25e): Call Teddy +repairs 104 | 105 | # Linda 106 | ## Folder 1 107 | - [Note b](:/beef7ed6d91649149751cea8d14af02d): I'll get to this eventually 108 | ``` 109 | 110 | ### Table 111 | This is particularly powerful when combined with hieuthi's [table sorting plugin](https://discourse.joplinapp.org/t/plugin-markdown-table-sortable/21846). (warning: if you use the "apply sorting" feature, the sort will be overwritten when a new summary is written, don't rely on it!). 112 | 113 | ``` 114 | | Task | Category | Due | Tags | Notebook | Note | 115 | | ---- | -------- | --- | ---- | -------- | ---- | 116 | | Have some me time | Linda | 2022-01-13 | | Folder 3 | [Note a](:/e710b7af31fc47c89ca5fc4d3c0ecb3a) 117 | | Call Teddy | Bob | | repairs | Folder 2 | [Note c](:/ef3aac56ffa246baa6a96cc94dd8f25e) 118 | | I'll get to this eventually | Linda | | | Folder 1 | [Note b](:/beef7ed6d91649149751cea8d14af02d) 119 | | Meat delivery | Bob | 2022-03-12 | burgers | Folder 1 | [Note b](:/beef7ed6d91649149751cea8d14af02d) 120 | ``` 121 | 122 | ## Filtering 123 | Todos can be filtered such that the plugin will only display Todos from specific notebooks. This can be done by adding notebook names inside the special comment. Notebooks that have a space in their name must be quoted. For example, to limit a search to only "Work" and "Special Project" notebooks, replace the default special comment with. 124 | 125 | ``` 126 | 127 | ``` 128 | 129 | 130 | # Roadmap 131 | I consider this plugin to be finished (it meets my needs). But below are some ideas that I will implement in the future if I have some time. 132 | ### Ideas 133 | - [ ] Add in support for spaces in the category field of the metalist style. This will allow for categories like @"Caleb John" 134 | - [ ] Add in the fuzzy date handling (e.g. mid april) 135 | - [ ] Add a renderer component that adds html ids (so we can scroll to TODOs) 136 | - [ ] Add support for the [Metis plugin](https://github.com/hieuthi/joplin-plugin-metis) (todo.txt) 137 | - [ ] [xit format](https://xit.jotaen.net/) 138 | 139 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import joplin from 'api'; 2 | import Logger, { TargetType } from '@joplin/utils/Logger'; 3 | import {ContentScriptType, MenuItem, MenuItemLocation, SettingItemType, SettingStorage, ToolbarButtonLocation} from 'api/types'; 4 | import { SummaryBuilder } from './builder'; 5 | import { Settings } from './types'; 6 | import { update_summary } from './summary'; 7 | import { mark_current_line_as_done } from './mark_todo'; 8 | import { regexes, regexTitles, summaryTitles } from './settings_tables'; 9 | import { createSummaryNote, isSummary } from './summary_note'; 10 | import { registerEditor } from './editor'; 11 | 12 | const globalLogger = new Logger(); 13 | globalLogger.addTarget(TargetType.Console); 14 | Logger.initializeGlobalLogger(globalLogger); 15 | 16 | const logger = Logger.create('inline-todo: Index'); 17 | 18 | 19 | async function getSettings(): Promise { 20 | return { 21 | scan_period_s: await joplin.settings.value('scanPeriod'), 22 | scan_period_c: await joplin.settings.value('scanPeriodRequestCount'), 23 | todo_type: regexes[await joplin.settings.value('regexType')], 24 | summary_type: await joplin.settings.value('summaryType'), 25 | sort_by: await joplin.settings.value('sortBy'), 26 | force_sync: await joplin.settings.value('forceSync'), 27 | show_complete_todo: await joplin.settings.value('showCompletetodoitems'), 28 | auto_refresh_summary: await joplin.settings.value('autoRefreshSummary'), 29 | custom_editor: await joplin.settings.value('enableCustomEditor'), 30 | }; 31 | } 32 | 33 | joplin.plugins.register({ 34 | onStart: async function() { 35 | await joplin.settings.registerSection('settings.calebjohn.todo', { 36 | label: 'Inline TODO', 37 | iconName: 'fa fa-check' 38 | }); 39 | await joplin.settings.registerSettings({ 40 | 'regexType': { 41 | value: 'list', 42 | type: SettingItemType.String, 43 | isEnum: true, 44 | options: regexTitles, 45 | section: 'settings.calebjohn.todo', 46 | public: true, 47 | label: 'Choose the inline TODO style (default is recommended)', 48 | }, 49 | 'summaryType': { 50 | value: 'plain', 51 | type: SettingItemType.String, 52 | isEnum: true, 53 | options: summaryTitles, 54 | section: 'settings.calebjohn.todo', 55 | public: true, 56 | label: 'Choose a Summary Note Format. Check the project page for examples', 57 | }, 58 | 'sortBy': { 59 | value: 'category', 60 | type: SettingItemType.String, 61 | isEnum: true, 62 | options: { 63 | 'category': 'Category (Default)', 64 | 'date': 'Due Date' 65 | }, 66 | section: 'settings.calebjohn.todo', 67 | public: true, 68 | label: 'Sort table display TODOs by', 69 | }, 70 | 'scanPeriod': { 71 | value: 11, 72 | type: SettingItemType.Int, 73 | section: 'settings.calebjohn.todo', 74 | public: true, 75 | advanced: true, 76 | minimum: 0, 77 | maximum: 99, 78 | step: 1, 79 | label: 'Scan Period (how many seconds to wait between bursts of scanning)', 80 | }, 81 | 'scanPeriodRequestCount': { 82 | value: 960, 83 | type: SettingItemType.Int, 84 | section: 'settings.calebjohn.todo', 85 | public: true, 86 | advanced: true, 87 | minimum: 1, 88 | maximum: 200, 89 | step: 1, 90 | label: 'Scan Period Allowed Requests (how many requests to make before taking a rest)', 91 | }, 92 | 'styleConfluenceTodos': { 93 | value: true, 94 | type: SettingItemType.Bool, 95 | section: 'settings.calebjohn.todo', 96 | public: true, 97 | advanced: true, 98 | label: 'Apply styling to metalist style todos in the markdown renderer (Restart Required)', 99 | }, 100 | 'forceSync': { 101 | value: true, 102 | type: SettingItemType.Bool, 103 | section: 'settings.calebjohn.todo', 104 | public: true, 105 | advanced: true, 106 | label: 'Force sync after summary note update (Important: do not un-check this)', 107 | }, 108 | 'showCompletetodoitems': { 109 | value: false, 110 | type: SettingItemType.Bool, 111 | section: 'settings.calebjohn.todo', 112 | public: true, 113 | advanced: true, 114 | label: 'Include complete TODO items in TODO summary (it might take long time/long list)', 115 | }, 116 | 'autoRefreshSummary': { 117 | value: true, 118 | type: SettingItemType.Bool, 119 | section: 'settings.calebjohn.todo', 120 | public: true, 121 | advanced: true, 122 | label: 'Refresh Summary note when opening the note.', 123 | }, 124 | 'enableCustomEditor': { 125 | value: false, 126 | type: SettingItemType.Bool, 127 | section: 'settings.calebjohn.todo', 128 | public: true, 129 | label: 'Enable custom editor for summary notes', 130 | }, 131 | }); 132 | 133 | // TODO: remove this and change default to false 134 | // This line ensures that the setting has been set, allowing us to 135 | // change the default for new users only 136 | if (!(await joplin.settings.value('enableCustomEditor'))) { 137 | await joplin.settings.setValue('enableCustomEditor', false); 138 | } 139 | 140 | const builder = new SummaryBuilder(await getSettings()); 141 | await registerEditor(builder); 142 | 143 | await joplin.commands.register({ 144 | name: "inlineTodo.createSummaryNote", 145 | label: "Create TODO summary note", 146 | execute: async () => { 147 | await createSummaryNote(); 148 | }, 149 | }); 150 | 151 | await joplin.views.menuItems.create( 152 | "createSummaryNoteMenuTools", 153 | "inlineTodo.createSummaryNote", 154 | MenuItemLocation.Tools 155 | ); 156 | 157 | await joplin.commands.register({ 158 | name: "inlineTodo.markDone", 159 | label: "Toggle TODO", 160 | execute: async () => { 161 | const currentNote = await joplin.workspace.selectedNote(); 162 | if (!isSummary(currentNote)) { return; } 163 | mark_current_line_as_done(builder, currentNote); 164 | }, 165 | }); 166 | 167 | joplin.workspace.filterEditorContextMenu(async (object: any) => { 168 | const currentNote = await joplin.workspace.selectedNote(); 169 | if (!isSummary(currentNote)) { return object; } 170 | 171 | const newItems: MenuItem[] = [ 172 | { 173 | type: 'separator', 174 | }, 175 | { 176 | label: 'Toggle TODO', 177 | accelerator: 'Ctrl+Alt+D', 178 | commandName: 'inlineTodo.markDone', 179 | commandArgs: [], 180 | }, 181 | ]; 182 | 183 | object.items = object.items.concat(newItems); 184 | 185 | return object; 186 | }); 187 | 188 | await joplin.views.menuItems.create( 189 | "markDoneMenuTools", 190 | "inlineTodo.markDone", 191 | MenuItemLocation.Note, 192 | { accelerator: 'Ctrl+Alt+D' } 193 | ); 194 | 195 | await joplin.commands.register({ 196 | name: "inlineTodo.refreshSummary", 197 | label: "Refresh Summary Note", 198 | iconName: "fas fa-sync-alt", 199 | execute: async () => { 200 | await builder.search_in_all(); 201 | let query = '/" 140 | * 141 | * `, 142 | * 143 | * ``` 144 | * 145 | * See 146 | * `[https://github.com/laurent22/joplin/blob/dev/packages/lib/services/noteList/renderViewProps.ts](renderViewProps.ts)` 147 | * for the list of properties that have a default rendering. 148 | */ 149 | itemTemplate: string; 150 | /** 151 | * This property applies only when `multiColumns` is `true`. It is used to render something 152 | * different for each note property. 153 | * 154 | * This is a map of actual dependencies to templates - you only need to return something if the 155 | * default, as specified in `template`, is not enough. 156 | * 157 | * Again you need to return a Mustache template and it will be combined with the `template` 158 | * property to create the final template. For example if you return a property named 159 | * `formattedDate` from `onRenderNote`, you can insert it in the template using 160 | * `{{formattedDate}}`. This string will replace `{{value}}` in the `template` property. 161 | * 162 | * So if the template property is set to `{{value}}`, the final template will be 163 | * `{{formattedDate}}`. 164 | * 165 | * The property would be set as so: 166 | * 167 | * ```javascript 168 | * itemValueTemplates: { 169 | * 'note.user_updated_time': '{{formattedDate}}', 170 | * } 171 | * ``` 172 | */ 173 | itemValueTemplates?: ListRendererItemValueTemplates; 174 | /** 175 | * This user-facing text is used for example in the View menu, so that your 176 | * renderer can be selected. 177 | */ 178 | label: () => Promise; 179 | /** 180 | * This is where most of the real-time processing will happen. When a note 181 | * is rendered for the first time and every time it changes, this handler 182 | * receives the properties specified in the `dependencies` property. You can 183 | * then process them, load any additional data you need, and once done you 184 | * need to return the properties that are needed in the `itemTemplate` HTML. 185 | * Again, to use the formatted date example, you could have such a renderer: 186 | * 187 | * ```typescript 188 | * dependencies: [ 189 | * 'note.title', 190 | * 'note.created_time', 191 | * ], 192 | * 193 | * itemTemplate: // html 194 | * ` 195 | *
196 | * Title: {{note.title}}
197 | * Date: {{formattedDate}} 198 | *
199 | * `, 200 | * 201 | * onRenderNote: async (props: any) => { 202 | * const formattedDate = dayjs(props.note.created_time).format(); 203 | * return { 204 | * // Also return the props, so that note.title is available from the 205 | * // template 206 | * ...props, 207 | * formattedDate, 208 | * } 209 | * }, 210 | * ``` 211 | */ 212 | onRenderNote: OnRenderNoteHandler; 213 | /** 214 | * This handler allows adding some interactivity to the note renderer - whenever an input element 215 | * within the item is changed (for example, when a checkbox is clicked, or a text input is 216 | * changed), this `onChange` handler is going to be called. 217 | * 218 | * You can inspect `event.elementId` to know which element had some changes, and `event.value` 219 | * to know the new value. `event.noteId` also tells you what note is affected, so that you can 220 | * potentially apply changes to it. 221 | * 222 | * You specify the element ID, by setting a `data-id` attribute on the input. 223 | * 224 | * For example, if you have such a template: 225 | * 226 | * ```html 227 | *
228 | * 229 | *
230 | * ``` 231 | * 232 | * The event handler will receive an event with `elementId` set to `noteTitleInput`. 233 | * 234 | * ## Default event handlers 235 | * 236 | * Currently one click event is automatically handled: 237 | * 238 | * If there is a checkbox with a `data-id="todo-checkbox"` attribute is present, it is going to 239 | * automatically toggle the note to-do "completed" status. 240 | * 241 | * For example this is what is used in the default list renderer: 242 | * 243 | * `` 244 | */ 245 | onChange?: OnChangeHandler; 246 | } 247 | export interface NoteListColumn { 248 | name: ColumnName; 249 | width: number; 250 | } 251 | export type NoteListColumns = NoteListColumn[]; 252 | export declare const defaultWidth = 100; 253 | export declare const defaultListColumns: () => NoteListColumns; 254 | export {}; 255 | --------------------------------------------------------------------------------