├── .github └── workflows │ ├── auto-assign-issue.yml │ └── buildAndTest.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── GENERATOR_DOC.md ├── LICENSE ├── README.md ├── api ├── Global.d.ts ├── Joplin.d.ts ├── JoplinClipboard.d.ts ├── JoplinCommands.d.ts ├── JoplinContentScripts.d.ts ├── JoplinData.d.ts ├── JoplinFilters.d.ts ├── JoplinImaging.d.ts ├── JoplinInterop.d.ts ├── JoplinPlugins.d.ts ├── JoplinSettings.d.ts ├── JoplinViews.d.ts ├── JoplinViewsDialogs.d.ts ├── JoplinViewsMenuItems.d.ts ├── JoplinViewsMenus.d.ts ├── JoplinViewsNoteList.d.ts ├── JoplinViewsPanels.d.ts ├── JoplinViewsToolbarButtons.d.ts ├── JoplinWindow.d.ts ├── JoplinWorkspace.d.ts ├── index.ts ├── noteListType.d.ts ├── noteListType.ts └── types.ts ├── img ├── icon.svg ├── icon_256.png ├── icon_32.png ├── main_tagging.gif ├── showcase1.png ├── showcase2.png ├── tagging_dialog.png └── tagging_dialog_search.png ├── package-lock.json ├── package.json ├── plugin.config.json ├── src ├── html.ts ├── index.ts ├── manifest.json ├── tagging.ts ├── type.ts ├── webview.css └── webview.ts ├── tsconfig.json └── webpack.config.js /.github/workflows/auto-assign-issue.yml: -------------------------------------------------------------------------------- 1 | name: Issue assignment 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | auto-assign: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: "Auto-assign issue" 12 | uses: pozil/auto-assign-issue@v1.4.0 13 | with: 14 | assignees: JackGruber 15 | -------------------------------------------------------------------------------- /.github/workflows/buildAndTest.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | on: [push, pull_request] 3 | jobs: 4 | buildAndTest: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v2 9 | with: 10 | node-version: "16" 11 | - name: Install dependencies 12 | run: npm install 13 | - name: Build 14 | run: npm run dist 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | publish/ 4 | .env 5 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.md 2 | !README.md 3 | /*.jpl 4 | /api 5 | /src 6 | /dist 7 | tsconfig.json 8 | webpack.config.js 9 | /__test__ 10 | .env 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | api/ 2 | dist/ 3 | publish/ 4 | webpack.config.js 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "endOfLine": "auto", 7 | "overrides": [ 8 | { 9 | "files": ["tsconfig.json"], 10 | "options": { 11 | "tabWidth": 4, 12 | "useTabs": true 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## not released 4 | 5 | ## v1.0.3 (2024-01-12) 6 | 7 | - Fix: Missing file in jpl after update to generator-joplin@2.13.3 8 | 9 | ## v1.0.2 (2024-01-11) 10 | 11 | - Add: Screenshots / icon for [https://joplinapp.org/plugins/](https://joplinapp.org/plugins/) 12 | 13 | ## v1.0.1 (2021-05-21) 14 | 15 | - Fix: Dialog colors on theme change 16 | 17 | ## v1.0.0 (2021-05-21) 18 | 19 | - Rename Plugin to `Tagging` 20 | - Rename `Tagging list` command to `Tagging dialog` 21 | - Add: Tag search on tagging dialog 22 | - Add: Tag creation on tagging dialog 23 | 24 | ## v0.3.3 (2021-01-20) 25 | 26 | - Add processing message 27 | 28 | ## v0.3.2 (2021-01-08) 29 | 30 | - Fix: Missing js File in JPL 31 | 32 | ## v0.3.1 (2021-01-06) 33 | 34 | - Change of the Plugin ID for Joplin 35 | 36 | ## v0.3.0 (2021-01-05) 37 | 38 | ❗ Requires at least Joplin v1.6.2 ❗ 39 | 40 | - Add `Tagging list` command 41 | 42 | ## v0.2.0 (2020-12-21) 43 | 44 | - Add context menue 45 | 46 | ## v0.1.0 (2020-12-17) 47 | 48 | - First version 49 | -------------------------------------------------------------------------------- /GENERATOR_DOC.md: -------------------------------------------------------------------------------- 1 | # Plugin development 2 | 3 | This documentation describes how to create a plugin, and how to work with the plugin builder framework and API. 4 | 5 | ## Installation 6 | 7 | First, install [Yeoman](http://yeoman.io) and generator-joplin using [npm](https://www.npmjs.com/) (we assume you have pre-installed [node.js](https://nodejs.org/)). 8 | 9 | ```bash 10 | npm install -g yo@4.3.1 11 | npm install -g generator-joplin 12 | ``` 13 | 14 | Then generate your new project: 15 | 16 | ```bash 17 | yo --node-package-manager npm joplin 18 | ``` 19 | 20 | ## Structure 21 | 22 | The main two files you will want to look at are: 23 | 24 | - `/src/index.ts`, which contains the entry point for the plugin source code. 25 | - `/src/manifest.json`, which is the plugin manifest. It contains information such as the plugin a name, version, etc. 26 | 27 | The file `/plugin.config.json` could also be useful if you intend to use [external scripts](#external-script-files), such as content scripts or webview scripts. 28 | 29 | ## Building the plugin 30 | 31 | The plugin is built using Webpack, which creates the compiled code in `/dist`. A JPL archive will also be created at the root, which can use to distribute the plugin. 32 | 33 | To build the plugin, simply run `npm run dist`. 34 | 35 | The project is setup to use TypeScript, although you can change the configuration to use plain JavaScript. 36 | 37 | ## Updating the manifest version number 38 | 39 | You can run `npm run updateVersion` to bump the patch part of the version number, so for example 1.0.3 will become 1.0.4. This script will update both the package.json and manifest.json version numbers so as to keep them in sync. 40 | 41 | ## Publishing the plugin 42 | 43 | To publish the plugin, add it to npmjs.com by running `npm publish`. Later on, a script will pick up your plugin and add it automatically to the Joplin plugin repository as long as the package satisfies these conditions: 44 | 45 | - In `package.json`, the name starts with "joplin-plugin-". For example, "joplin-plugin-toc". 46 | - In `package.json`, the keywords include "joplin-plugin". 47 | - In the `publish/` directory, there should be a .jpl and .json file (which are built by `npm run dist`) 48 | 49 | In general all this is done automatically by the plugin generator, which will set the name and keywords of package.json, and will put the right files in the "publish" directory. But if something doesn't work and your plugin doesn't appear in the repository, double-check the above conditions. 50 | 51 | ## Updating the plugin framework 52 | 53 | To update the plugin framework, run `npm run update`. 54 | 55 | In general this command tries to do the right thing - in particular it's going to merge the changes in package.json and .gitignore instead of overwriting. It will also leave "/src" as well as README.md untouched. 56 | 57 | The file that may cause problem is "webpack.config.js" because it's going to be overwritten. For that reason, if you want to change it, consider creating a separate JavaScript file and include it in webpack.config.js. That way, when you update, you only have to restore the line that include your file. 58 | 59 | ## External script files 60 | 61 | By default, the compiler (webpack) is going to compile `src/index.ts` only (as well as any file it imports), and any other file will simply be copied to the plugin package. In some cases this is sufficient, however if you have [content scripts](https://joplinapp.org/api/references/plugin_api/classes/joplincontentscripts.html) or [webview scripts](https://joplinapp.org/api/references/plugin_api/classes/joplinviewspanels.html#addscript) you might want to compile them too, in particular in these two cases: 62 | 63 | - The script is a TypeScript file - in which case it has to be compiled to JavaScript. 64 | 65 | - The script requires modules you've added to package.json. In that case, the script, whether JS or TS, must be compiled so that the dependencies are bundled with the JPL file. 66 | 67 | To get such an external script file to compile, you need to add it to the `extraScripts` array in `plugin.config.json`. The path you add should be relative to /src. For example, if you have a file in "/src/webviews/index.ts", the path should be set to "webviews/index.ts". Once compiled, the file will always be named with a .js extension. So you will get "webviews/index.js" in the plugin package, and that's the path you should use to reference the file. 68 | 69 | ## More information 70 | 71 | - [Joplin Plugin API](https://joplinapp.org/api/references/plugin_api/classes/joplin.html) 72 | - [Joplin Data API](https://joplinapp.org/help/api/references/rest_api) 73 | - [Joplin Plugin Manifest](https://joplinapp.org/api/references/plugin_manifest/) 74 | - Ask for help on the [forum](https://discourse.joplinapp.org/) or our [Discord channel](https://discord.gg/VSj7AFHvpq) 75 | 76 | ## License 77 | 78 | MIT © Laurent Cozic 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gruber Alexander 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Joplin Plugin: Tagging 2 | 3 | Plugin to extend the Joplin tagging menu with a copy all tags and a tagging dialog with more control. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | - [Installation](#installation) 12 | - [Automatic](#automatic) 13 | - [Manual](#manual) 14 | - [Manual via GUI](#manual-via-gui) 15 | - [Manual via file system](#manual-via-file-system) 16 | - [Commands](#commands) 17 | - [Copy all tags](#copy-all-tags) 18 | - [Tagging dialog](#tagging-dialog) 19 | - [Keyboard Shortcuts](#keyboard-shortcuts) 20 | - [Changelog](#changelog) 21 | 22 | 23 | 24 | 25 | 26 | ## Installation 27 | 28 | ### Automatic 29 | 30 | - Go to `Tools > Options > Plugins` 31 | - Search for `Tagging` 32 | - Click Install plugin 33 | - Restart Joplin to enable the plugin 34 | 35 | ### Manual 36 | 37 | #### Manual via GUI 38 | 39 | - Download the latest released JPL package (`io.github.jackgruber.copytags.jpl`) from [here](https://github.com/JackGruber/joplin-plugin-copytags/releases/latest) 40 | - Go to `Tools > Options > Plugins` in Joplin 41 | - Click on the gear wheel and select `Install from file` 42 | - Select the downloaded JPL file 43 | - Restart Joplin 44 | 45 | #### Manual via file system 46 | 47 | - Download the latest released JPL package (`io.github.jackgruber.copytags.jpl`) from [here](https://github.com/JackGruber/joplin-plugin-copytags/releases/latest) 48 | - Close Joplin 49 | - Got to your Joplin profile folder and place the JPL file in the `plugins` folder 50 | - Start Joplin 51 | 52 | ## Commands 53 | 54 | - `Copy all tags` 55 | - `Show Tagging dialog` 56 | 57 | ### Copy all tags 58 | 59 | Copies all tags of the first marked note to all other marked notes. 60 | 61 | - Select multiple notes (The first marked note must be the one from which the tags are to be copied) 62 | - Click on `Tools > Copy all tags` or use the command `Copy all tags` from the context menu 63 | 64 | ### Tagging dialog 65 | 66 | Select on or more notes, click on `Tools > Tagging dialog` or use the command `Tagging dialog` from the context menu. 67 | 68 | - Add tags to notes 69 | - Remove tags from notes 70 | - Create new tags 71 | 72 | 73 | 74 | 75 | ## Keyboard Shortcuts 76 | 77 | Under `Options > Keyboard Shortcuts` you can assign a keyboard shortcut for the following commands: 78 | 79 | - `Copy all tags` 80 | - `Tagging dialog` 81 | 82 | ## Changelog 83 | 84 | See [CHANGELOG.md](CHANGELOG.md) 85 | -------------------------------------------------------------------------------- /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/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 | require(_path: string): any; 73 | versionInfo(): Promise; 74 | } 75 | -------------------------------------------------------------------------------- /api/JoplinClipboard.d.ts: -------------------------------------------------------------------------------- 1 | export default class JoplinClipboard { 2 | private electronClipboard_; 3 | private electronNativeImage_; 4 | constructor(electronClipboard: any, electronNativeImage: any); 5 | readText(): Promise; 6 | writeText(text: string): Promise; 7 | readHtml(): Promise; 8 | writeHtml(html: string): Promise; 9 | /** 10 | * Returns the image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. 11 | */ 12 | readImage(): Promise; 13 | /** 14 | * Takes an image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. 15 | */ 16 | writeImage(dataUrl: string): Promise; 17 | /** 18 | * Returns the list available formats (mime types). 19 | * 20 | * For example [ 'text/plain', 'text/html' ] 21 | */ 22 | availableFormats(): Promise; 23 | } 24 | -------------------------------------------------------------------------------- /api/JoplinCommands.d.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './types'; 2 | /** 3 | * This class allows executing or registering new Joplin commands. Commands 4 | * can be executed or associated with 5 | * {@link JoplinViewsToolbarButtons | toolbar buttons} or 6 | * {@link JoplinViewsMenuItems | menu items}. 7 | * 8 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) 9 | * 10 | * ## Executing Joplin's internal commands 11 | * 12 | * It is also possible to execute internal Joplin's commands which, as of 13 | * now, are not well documented. You can find the list directly on GitHub 14 | * though at the following locations: 15 | * 16 | * * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands) 17 | * * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands) 18 | * * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts) 19 | * 20 | * To view what arguments are supported, you can open any of these files 21 | * and look at the `execute()` command. 22 | * 23 | * ## Executing editor commands 24 | * 25 | * There might be a situation where you want to invoke editor commands 26 | * without using a {@link JoplinContentScripts | contentScript}. For this 27 | * reason Joplin provides the built in `editor.execCommand` command. 28 | * 29 | * `editor.execCommand` should work with any core command in both the 30 | * [CodeMirror](https://codemirror.net/doc/manual.html#execCommand) and 31 | * [TinyMCE](https://www.tiny.cloud/docs/api/tinymce/tinymce.editorcommands/#execcommand) editors, 32 | * as well as most functions calls directly on a CodeMirror editor object (extensions). 33 | * 34 | * * [CodeMirror commands](https://codemirror.net/doc/manual.html#commands) 35 | * * [TinyMCE core editor commands](https://www.tiny.cloud/docs/advanced/editor-command-identifiers/#coreeditorcommands) 36 | * 37 | * `editor.execCommand` supports adding arguments for the commands. 38 | * 39 | * ```typescript 40 | * await joplin.commands.execute('editor.execCommand', { 41 | * name: 'madeUpCommand', // CodeMirror and TinyMCE 42 | * args: [], // CodeMirror and TinyMCE 43 | * ui: false, // TinyMCE only 44 | * value: '', // TinyMCE only 45 | * }); 46 | * ``` 47 | * 48 | * [View the example using the CodeMirror editor](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/codemirror_content_script/src/index.ts) 49 | * 50 | */ 51 | export default class JoplinCommands { 52 | /** 53 | * desktop Executes the given 54 | * command. 55 | * 56 | * The command can take any number of arguments, and the supported 57 | * arguments will vary based on the command. For custom commands, this 58 | * is the `args` passed to the `execute()` function. For built-in 59 | * commands, you can find the supported arguments by checking the links 60 | * above. 61 | * 62 | * ```typescript 63 | * // Create a new note in the current notebook: 64 | * await joplin.commands.execute('newNote'); 65 | * 66 | * // Create a new sub-notebook under the provided notebook 67 | * // Note: internally, notebooks are called "folders". 68 | * await joplin.commands.execute('newFolder', "SOME_FOLDER_ID"); 69 | * ``` 70 | */ 71 | execute(commandName: string, ...args: any[]): Promise; 72 | /** 73 | * desktop Registers a new command. 74 | * 75 | * ```typescript 76 | * // Register a new commmand called "testCommand1" 77 | * 78 | * await joplin.commands.register({ 79 | * name: 'testCommand1', 80 | * label: 'My Test Command 1', 81 | * iconName: 'fas fa-music', 82 | * execute: () => { 83 | * alert('Testing plugin command 1'); 84 | * }, 85 | * }); 86 | * ``` 87 | */ 88 | register(command: Command): Promise; 89 | } 90 | -------------------------------------------------------------------------------- /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 demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) 25 | * 26 | * See also the [postMessage demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) 27 | * 28 | * @param type Defines how the script will be used. See the type definition for more information about each supported type. 29 | * @param id A unique ID for the content script. 30 | * @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`. 31 | */ 32 | register(type: ContentScriptType, id: string, scriptPath: string): Promise; 33 | /** 34 | * Listens to a messages sent from the content script using postMessage(). 35 | * See {@link ContentScriptType} for more information as well as the 36 | * [postMessage 37 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) 38 | */ 39 | onMessage(contentScriptId: string, callback: any): Promise; 40 | } 41 | -------------------------------------------------------------------------------- /api/JoplinData.d.ts: -------------------------------------------------------------------------------- 1 | import { ModelType } from '../../../BaseModel'; 2 | import Plugin from '../Plugin'; 3 | import { Path } from './types'; 4 | /** 5 | * This module provides access to the Joplin data API: https://joplinapp.org/help/api/references/rest_api 6 | * This is the main way to retrieve data, such as notes, notebooks, tags, etc. 7 | * or to update them or delete them. 8 | * 9 | * This is also what you would use to search notes, via the `search` endpoint. 10 | * 11 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/simple) 12 | * 13 | * In general you would use the methods in this class as if you were using a REST API. There are four methods that map to GET, POST, PUT and DELETE calls. 14 | * And each method takes these parameters: 15 | * 16 | * * `path`: This is an array that represents the path to the resource in the form `["resouceName", "resourceId", "resourceLink"]` (eg. ["tags", ":id", "notes"]). The "resources" segment is the name of the resources you want to access (eg. "notes", "folders", etc.). If not followed by anything, it will refer to all the resources in that collection. The optional "resourceId" points to a particular resources within the collection. Finally, an optional "link" can be present, which links the resource to a collection of resources. This can be used in the API for example to retrieve all the notes associated with a tag. 17 | * * `query`: (Optional) The query parameters. In a URL, this is the part after the question mark "?". In this case, it should be an object with key/value pairs. 18 | * * `data`: (Optional) Applies to PUT and POST calls only. The request body contains the data you want to create or modify, for example the content of a note or folder. 19 | * * `files`: (Optional) Used to create new resources and associate them with files. 20 | * 21 | * Please refer to the [Joplin API documentation](https://joplinapp.org/help/api/references/rest_api) for complete details about each call. As the plugin runs within the Joplin application **you do not need an authorisation token** to use this API. 22 | * 23 | * For example: 24 | * 25 | * ```typescript 26 | * // Get a note ID, title and body 27 | * const noteId = 'some_note_id'; 28 | * const note = await joplin.data.get(['notes', noteId], { fields: ['id', 'title', 'body'] }); 29 | * 30 | * // Get all folders 31 | * const folders = await joplin.data.get(['folders']); 32 | * 33 | * // Set the note body 34 | * await joplin.data.put(['notes', noteId], null, { body: "New note body" }); 35 | * 36 | * // Create a new note under one of the folders 37 | * await joplin.data.post(['notes'], null, { body: "my new note", title: "some title", parent_id: folders[0].id }); 38 | * ``` 39 | */ 40 | export default class JoplinData { 41 | private api_; 42 | private pathSegmentRegex_; 43 | private plugin; 44 | constructor(plugin: Plugin); 45 | private serializeApiBody; 46 | private pathToString; 47 | get(path: Path, query?: any): Promise; 48 | post(path: Path, query?: any, body?: any, files?: any[]): Promise; 49 | put(path: Path, query?: any, body?: any, files?: any[]): Promise; 50 | delete(path: Path, query?: any): Promise; 51 | itemType(itemId: string): Promise; 52 | resourcePath(resourceId: string): Promise; 53 | /** 54 | * Gets an item user data. User data are key/value pairs. The `key` can be any 55 | * arbitrary string, while the `value` can be of any type supported by 56 | * [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description) 57 | * 58 | * User data is synchronised across devices, and each value wil be merged based on their timestamp: 59 | * 60 | * - If value is modified by client 1, then modified by client 2, it will take the value from client 2 61 | * - If value is modified by client 1, then deleted by client 2, the value will be deleted after merge 62 | * - If value is deleted by client 1, then updated by client 2, the value will be restored and set to the value from client 2 after merge 63 | */ 64 | userDataGet(itemType: ModelType, itemId: string, key: string): Promise; 65 | /** 66 | * Sets a note user data. See {@link JoplinData.userDataGet} for more details. 67 | */ 68 | userDataSet(itemType: ModelType, itemId: string, key: string, value: T): Promise; 69 | /** 70 | * Deletes a note user data. See {@link JoplinData.userDataGet} for more details. 71 | */ 72 | userDataDelete(itemType: ModelType, itemId: string, key: string): Promise; 73 | } 74 | -------------------------------------------------------------------------------- /api/JoplinFilters.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @ignore 3 | * 4 | * Not sure if it's the best way to hook into the app 5 | * so for now disable filters. 6 | */ 7 | export default class JoplinFilters { 8 | on(name: string, callback: Function): Promise; 9 | off(name: string, callback: Function): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /api/JoplinImaging.d.ts: -------------------------------------------------------------------------------- 1 | import { Rectangle } from './types'; 2 | export interface Implementation { 3 | nativeImage: any; 4 | } 5 | export interface CreateFromBufferOptions { 6 | width?: number; 7 | height?: number; 8 | scaleFactor?: number; 9 | } 10 | export interface ResizeOptions { 11 | width?: number; 12 | height?: number; 13 | quality?: 'good' | 'better' | 'best'; 14 | } 15 | export type Handle = string; 16 | /** 17 | * Provides imaging functions to resize or process images. You create an image 18 | * using one of the `createFrom` functions, then use the other functions to 19 | * process the image. 20 | * 21 | * Images are associated with a handle which is what will be available to the 22 | * plugin. Once you are done with an image, free it using the `free()` function. 23 | * 24 | * [View the 25 | * example](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/imaging/src/index.ts) 26 | * 27 | */ 28 | export default class JoplinImaging { 29 | private implementation_; 30 | private images_; 31 | constructor(implementation: Implementation); 32 | private createImageHandle; 33 | private imageByHandle; 34 | private cacheImage; 35 | createFromPath(filePath: string): Promise; 36 | createFromResource(resourceId: string): Promise; 37 | getSize(handle: Handle): Promise; 38 | resize(handle: Handle, options?: ResizeOptions): Promise; 39 | crop(handle: Handle, rectange: Rectangle): Promise; 40 | toPngFile(handle: Handle, filePath: string): Promise; 41 | /** 42 | * Quality is between 0 and 100 43 | */ 44 | toJpgFile(handle: Handle, filePath: string, quality?: number): Promise; 45 | private tempFilePath; 46 | /** 47 | * Creates a new Joplin resource from the image data. The image will be 48 | * first converted to a JPEG. 49 | */ 50 | toJpgResource(handle: Handle, resourceProps: any, quality?: number): Promise; 51 | /** 52 | * Creates a new Joplin resource from the image data. The image will be 53 | * first converted to a PNG. 54 | */ 55 | toPngResource(handle: Handle, resourceProps: any): Promise; 56 | /** 57 | * Image data is not automatically deleted by Joplin so make sure you call 58 | * this method on the handle once you are done. 59 | */ 60 | free(handle: Handle): Promise; 61 | } 62 | -------------------------------------------------------------------------------- /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 | export default class JoplinInterop { 15 | registerExportModule(module: ExportModule): Promise; 16 | registerImportModule(module: ImportModule): Promise; 17 | } 18 | -------------------------------------------------------------------------------- /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/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 | export declare const namespacedKey: (pluginId: string, key: string) => string; 11 | /** 12 | * 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. 13 | * 14 | * Settings are essentially key/value pairs. 15 | * 16 | * 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 17 | * 18 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/settings) 19 | */ 20 | export default class JoplinSettings { 21 | private plugin_; 22 | constructor(plugin: Plugin); 23 | /** 24 | * Registers new settings. 25 | * Note that registering a setting item is dynamic and will be gone next time Joplin starts. 26 | * What it means is that you need to register the setting every time the plugin starts (for example in the onStart event). 27 | * 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 28 | * reason the plugin fails to start at some point. 29 | */ 30 | registerSettings(settings: Record): Promise; 31 | /** 32 | * @deprecated Use joplin.settings.registerSettings() 33 | * 34 | * Registers a new setting. 35 | */ 36 | registerSetting(key: string, settingItem: SettingItem): Promise; 37 | /** 38 | * Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts. 39 | */ 40 | registerSection(name: string, section: SettingSection): Promise; 41 | /** 42 | * Gets a setting value (only applies to setting you registered from your plugin) 43 | */ 44 | value(key: string): Promise; 45 | /** 46 | * Sets a setting value (only applies to setting you registered from your plugin) 47 | */ 48 | setValue(key: string, value: any): Promise; 49 | /** 50 | * Gets a global setting value, including app-specific settings and those set by other plugins. 51 | * 52 | * The list of available settings is not documented yet, but can be found by looking at the source code: 53 | * 54 | * https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Setting.ts#L142 55 | */ 56 | globalValue(key: string): Promise; 57 | /** 58 | * Called when one or multiple settings of your plugin have been changed. 59 | * - For performance reasons, this event is triggered with a delay. 60 | * - You will only get events for your own plugin settings. 61 | */ 62 | onChange(handler: ChangeHandler): Promise; 63 | } 64 | -------------------------------------------------------------------------------- /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 | /** 9 | * This namespace provides access to view-related services. 10 | * 11 | * All view services provide a `create()` method which you would use to create the view object, whether it's a dialog, a toolbar button or a menu item. 12 | * In some cases, the `create()` method will return a [[ViewHandle]], which you would use to act on the view, for example to set certain properties or call some methods. 13 | */ 14 | export default class JoplinViews { 15 | private store; 16 | private plugin; 17 | private panels_; 18 | private menuItems_; 19 | private menus_; 20 | private toolbarButtons_; 21 | private dialogs_; 22 | private noteList_; 23 | private implementation_; 24 | constructor(implementation: any, plugin: Plugin, store: any); 25 | get dialogs(): JoplinViewsDialogs; 26 | get panels(): JoplinViewsPanels; 27 | get menuItems(): JoplinViewsMenuItems; 28 | get menus(): JoplinViewsMenus; 29 | get toolbarButtons(): JoplinViewsToolbarButtons; 30 | get noteList(): JoplinViewsNoteList; 31 | } 32 | -------------------------------------------------------------------------------- /api/JoplinViewsDialogs.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ButtonSpec, ViewHandle, DialogResult } 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 dialog to select a file or a directory. Same options and 48 | * output as 49 | * https://www.electronjs.org/docs/latest/api/dialog#dialogshowopendialogbrowserwindow-options 50 | */ 51 | showOpenDialog(options: any): Promise; 52 | /** 53 | * Sets the dialog HTML content 54 | */ 55 | setHtml(handle: ViewHandle, html: string): Promise; 56 | /** 57 | * Adds and loads a new JS or CSS files into the dialog. 58 | */ 59 | addScript(handle: ViewHandle, scriptPath: string): Promise; 60 | /** 61 | * Sets the dialog buttons. 62 | */ 63 | setButtons(handle: ViewHandle, buttons: ButtonSpec[]): Promise; 64 | /** 65 | * Opens the dialog 66 | */ 67 | open(handle: ViewHandle): Promise; 68 | /** 69 | * Toggle on whether to fit the dialog size to the content or not. 70 | * When set to false, the dialog is set to 90vw and 80vh 71 | * @default true 72 | */ 73 | setFitToContent(handle: ViewHandle, status: boolean): Promise; 74 | } 75 | -------------------------------------------------------------------------------- /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 | export default class JoplinViewsMenuItems { 9 | private store; 10 | private plugin; 11 | constructor(plugin: Plugin, store: any); 12 | /** 13 | * 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. 14 | */ 15 | create(id: string, commandName: string, location?: MenuItemLocation, options?: CreateMenuItemOptions): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /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 | export default class JoplinViewsMenus { 9 | private store; 10 | private plugin; 11 | constructor(plugin: Plugin, store: any); 12 | private registerCommandAccelerators; 13 | /** 14 | * 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 15 | * menu as a sub-menu of the application build-in menus. 16 | */ 17 | create(id: string, label: string, menuItems: MenuItem[], location?: MenuItemLocation): Promise; 18 | } 19 | -------------------------------------------------------------------------------- /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 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/note_list_renderer) 14 | * 15 | * The default list renderer is implemented using the same API, so it worth checking it too: 16 | * 17 | * [Default list renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts) 18 | */ 19 | export default class JoplinViewsNoteList { 20 | private plugin_; 21 | private store_; 22 | constructor(plugin: Plugin, store: Store); 23 | registerRenderer(renderer: ListRenderer): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /api/JoplinViewsPanels.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ViewHandle } from './types'; 3 | /** 4 | * Allows creating and managing view panels. View panels currently are 5 | * displayed at the right of the sidebar and allows displaying any HTML 6 | * content (within a webview) and update it in real-time. For example it 7 | * could be used to display a table of content for the active note, or 8 | * display various metadata or graph. 9 | * 10 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/toc) 11 | */ 12 | export default class JoplinViewsPanels { 13 | private store; 14 | private plugin; 15 | constructor(plugin: Plugin, store: any); 16 | private controller; 17 | /** 18 | * Creates a new panel 19 | */ 20 | create(id: string): Promise; 21 | /** 22 | * Sets the panel webview HTML 23 | */ 24 | setHtml(handle: ViewHandle, html: string): Promise; 25 | /** 26 | * Adds and loads a new JS or CSS files into the panel. 27 | */ 28 | addScript(handle: ViewHandle, scriptPath: string): Promise; 29 | /** 30 | * Called when a message is sent from the webview (using postMessage). 31 | * 32 | * To post a message from the webview to the plugin use: 33 | * 34 | * ```javascript 35 | * const response = await webviewApi.postMessage(message); 36 | * ``` 37 | * 38 | * - `message` can be any JavaScript object, string or number 39 | * - `response` is whatever was returned by the `onMessage` handler 40 | * 41 | * Using this mechanism, you can have two-way communication between the 42 | * plugin and webview. 43 | * 44 | * See the [postMessage 45 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) for more details. 46 | * 47 | */ 48 | onMessage(handle: ViewHandle, callback: Function): Promise; 49 | /** 50 | * Sends a message to the webview. 51 | * 52 | * The webview must have registered a message handler prior, otherwise the message is ignored. Use; 53 | * 54 | * ```javascript 55 | * webviewApi.onMessage((message) => { ... }); 56 | * ``` 57 | * 58 | * - `message` can be any JavaScript object, string or number 59 | * 60 | * The view API may have only one onMessage handler defined. 61 | * This method is fire and forget so no response is returned. 62 | * 63 | * It is particularly useful when the webview needs to react to events emitted by the plugin or the joplin api. 64 | */ 65 | postMessage(handle: ViewHandle, message: any): void; 66 | /** 67 | * Shows the panel 68 | */ 69 | show(handle: ViewHandle, show?: boolean): Promise; 70 | /** 71 | * Hides the panel 72 | */ 73 | hide(handle: ViewHandle): Promise; 74 | /** 75 | * Tells whether the panel is visible or not 76 | */ 77 | visible(handle: ViewHandle): Promise; 78 | } 79 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api/JoplinWindow.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | export interface Implementation { 3 | injectCustomStyles(elementId: string, cssFilePath: string): Promise; 4 | } 5 | export default class JoplinWindow { 6 | private plugin_; 7 | private store_; 8 | private implementation_; 9 | constructor(implementation: Implementation, plugin: Plugin, store: any); 10 | /** 11 | * Loads a chrome CSS file. It will apply to the window UI elements, except 12 | * for the note viewer. It is the same as the "Custom stylesheet for 13 | * 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) 14 | * for an example. 15 | */ 16 | loadChromeCssFile(filePath: string): Promise; 17 | /** 18 | * Loads a note CSS file. It will apply to the note viewer, as well as any 19 | * exported or printed note. It is the same as the "Custom stylesheet for 20 | * rendered Markdown" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) 21 | * for an example. 22 | */ 23 | loadNoteCssFile(filePath: string): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /api/JoplinWorkspace.d.ts: -------------------------------------------------------------------------------- 1 | import { FolderEntity } from '../../database/types'; 2 | import { Disposable, MenuItem } from './types'; 3 | export interface EditContextMenuFilterObject { 4 | items: MenuItem[]; 5 | } 6 | type FilterHandler = (object: T) => Promise; 7 | declare enum ItemChangeEventType { 8 | Create = 1, 9 | Update = 2, 10 | Delete = 3 11 | } 12 | interface ItemChangeEvent { 13 | id: string; 14 | event: ItemChangeEventType; 15 | } 16 | interface SyncStartEvent { 17 | withErrors: boolean; 18 | } 19 | interface ResourceChangeEvent { 20 | id: string; 21 | } 22 | type ItemChangeHandler = (event: ItemChangeEvent) => void; 23 | type SyncStartHandler = (event: SyncStartEvent) => void; 24 | type ResourceChangeHandler = (event: ResourceChangeEvent) => void; 25 | /** 26 | * The workspace service provides access to all the parts of Joplin that 27 | * are being worked on - i.e. the currently selected notes or notebooks as 28 | * well as various related events, such as when a new note is selected, or 29 | * when the note content changes. 30 | * 31 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins) 32 | */ 33 | export default class JoplinWorkspace { 34 | private store; 35 | constructor(store: any); 36 | /** 37 | * Called when a new note or notes are selected. 38 | */ 39 | onNoteSelectionChange(callback: Function): Promise; 40 | /** 41 | * Called when the content of a note changes. 42 | * @deprecated Use `onNoteChange()` instead, which is reliably triggered whenever the note content, or any note property changes. 43 | */ 44 | onNoteContentChange(callback: Function): Promise; 45 | /** 46 | * Called when the content of the current note changes. 47 | */ 48 | onNoteChange(handler: ItemChangeHandler): Promise; 49 | /** 50 | * Called when a resource is changed. Currently this handled will not be 51 | * called when a resource is added or deleted. 52 | */ 53 | onResourceChange(handler: ResourceChangeHandler): Promise; 54 | /** 55 | * Called when an alarm associated with a to-do is triggered. 56 | */ 57 | onNoteAlarmTrigger(handler: Function): Promise; 58 | /** 59 | * Called when the synchronisation process is starting. 60 | */ 61 | onSyncStart(handler: SyncStartHandler): Promise; 62 | /** 63 | * Called when the synchronisation process has finished. 64 | */ 65 | onSyncComplete(callback: Function): Promise; 66 | /** 67 | * Called just before the editor context menu is about to open. Allows 68 | * adding items to it. 69 | */ 70 | filterEditorContextMenu(handler: FilterHandler): void; 71 | /** 72 | * Gets the currently selected note 73 | */ 74 | selectedNote(): Promise; 75 | /** 76 | * Gets the currently selected folder. In some cases, for example during 77 | * search or when viewing a tag, no folder is actually selected in the user 78 | * interface. In that case, that function would return the last selected 79 | * folder. 80 | */ 81 | selectedFolder(): Promise; 82 | /** 83 | * Gets the IDs of the selected notes (can be zero, one, or many). Use the data API to retrieve information about these notes. 84 | */ 85 | selectedNoteIds(): Promise; 86 | } 87 | export {}; 88 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import type Joplin from './Joplin'; 2 | 3 | declare const joplin: Joplin; 4 | 5 | export default joplin; 6 | -------------------------------------------------------------------------------- /api/noteListType.d.ts: -------------------------------------------------------------------------------- 1 | import { Size } from './types'; 2 | type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; 3 | export declare enum ItemFlow { 4 | TopToBottom = "topToBottom", 5 | LeftToRight = "leftToRight" 6 | } 7 | export type RenderNoteView = Record; 8 | export interface OnChangeEvent { 9 | elementId: string; 10 | value: any; 11 | noteId: string; 12 | } 13 | export type OnRenderNoteHandler = (props: any) => Promise; 14 | export type OnChangeHandler = (event: OnChangeEvent) => Promise; 15 | /** 16 | * Most of these are the built-in note properties, such as `note.title`, 17 | * `note.todo_completed`, etc. 18 | * 19 | * Additionally, the `item.*` properties are specific to the rendered item. The 20 | * most important being `item.selected`, which you can use to display the 21 | * selected note in a different way. 22 | * 23 | * Finally some special properties are provided to make it easier to render 24 | * notes. In particular, if possible prefer `note.titleHtml` to `note.title` 25 | * since some important processing has already been done on the string, such as 26 | * handling the search highlighter and escaping. Since it's HTML and already 27 | * escaped you would insert it using `{{{titleHtml}}}` (triple-mustache syntax, 28 | * which disables escaping). 29 | * 30 | * `notes.tag` gives you the list of tags associated with the note. 31 | * 32 | * `note.isWatched` tells you if the note is currently opened in an external 33 | * editor. In which case you would generally display some indicator. 34 | */ 35 | export type ListRendererDepependency = ListRendererDatabaseDependency | 'item.size.width' | 'item.size.height' | 'item.selected' | 'note.titleHtml' | 'note.isWatched' | 'note.tags'; 36 | export interface ListRenderer { 37 | /** 38 | * It must be unique to your plugin. 39 | */ 40 | id: string; 41 | /** 42 | * Can be top to bottom or left to right. Left to right gives you more 43 | * option to set the size of the items since you set both its width and 44 | * height. 45 | */ 46 | flow: ItemFlow; 47 | /** 48 | * The size of each item must be specified in advance for performance 49 | * reasons, and cannot be changed afterwards. If the item flow is top to 50 | * bottom, you only need to specificy the item height (the width will be 51 | * ignored). 52 | */ 53 | itemSize: Size; 54 | /** 55 | * The CSS is relative to the list item container. What will appear in the 56 | * page is essentially `.note-list-item { YOUR_CSS; }`. It means you can use 57 | * child combinator with guarantee it will only apply to your own items. In 58 | * this example, the styling will apply to `.note-list-item > .content`: 59 | * 60 | * ```css 61 | * > .content { 62 | * padding: 10px; 63 | * } 64 | * ``` 65 | * 66 | * In order to get syntax highlighting working here, it's recommended 67 | * installing an editor extension such as [es6-string-html VSCode 68 | * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) 69 | */ 70 | itemCss?: string; 71 | /** 72 | * List the dependencies that your plugin needs to render the note list 73 | * items. Only these will be passed to your `onRenderNote` handler. Ensure 74 | * that you do not add more than what you need since there is a performance 75 | * penalty for each property. 76 | */ 77 | dependencies: ListRendererDepependency[]; 78 | /** 79 | * This is the HTML template that will be used to render the note list item. 80 | * This is a [Mustache template](https://github.com/janl/mustache.js) and it 81 | * will receive the variable you return from `onRenderNote` as tags. For 82 | * example, if you return a property named `formattedDate` from 83 | * `onRenderNote`, you can insert it in the template using `Created date: 84 | * {{formattedDate}}`. 85 | * 86 | * In order to get syntax highlighting working here, it's recommended 87 | * installing an editor extension such as [es6-string-html VSCode 88 | * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) 89 | */ 90 | itemTemplate: string; 91 | /** 92 | * This user-facing text is used for example in the View menu, so that your 93 | * renderer can be selected. 94 | */ 95 | label: () => Promise; 96 | /** 97 | * This is where most of the real-time processing will happen. When a note 98 | * is rendered for the first time and every time it changes, this handler 99 | * receives the properties specified in the `dependencies` property. You can 100 | * then process them, load any additional data you need, and once done you 101 | * need to return the properties that are needed in the `itemTemplate` HTML. 102 | * Again, to use the formatted date example, you could have such a renderer: 103 | * 104 | * ```typescript 105 | * dependencies: [ 106 | * 'note.title', 107 | * 'note.created_time', 108 | * ], 109 | * 110 | * itemTemplate: // html 111 | * ` 112 | *
113 | * Title: {{note.title}}
114 | * Date: {{formattedDate}} 115 | *
116 | * `, 117 | * 118 | * onRenderNote: async (props: any) => { 119 | * const formattedDate = dayjs(props.note.created_time).format(); 120 | * return { 121 | * // Also return the props, so that note.title is available from the 122 | * // template 123 | * ...props, 124 | * formattedDate, 125 | * } 126 | * }, 127 | * ``` 128 | */ 129 | onRenderNote: OnRenderNoteHandler; 130 | /** 131 | * This handler allows adding some interacivity to the note renderer - 132 | * whenever an input element within the item is changed (for example, when a 133 | * checkbox is clicked, or a text input is changed), this `onChange` handler 134 | * is going to be called. 135 | * 136 | * You can inspect `event.elementId` to know which element had some changes, 137 | * and `event.value` to know the new value. `event.noteId` also tells you 138 | * what note is affected, so that you can potentially apply changes to it. 139 | * 140 | * You specify the element ID, by setting a `data-id` attribute on the 141 | * input. 142 | * 143 | * For example, if you have such a template: 144 | * 145 | * ```html 146 | *
147 | * 148 | *
149 | * ``` 150 | * 151 | * The event handler will receive an event with `elementId` set to 152 | * `noteTitleInput`. 153 | */ 154 | onChange?: OnChangeHandler; 155 | } 156 | export {}; 157 | -------------------------------------------------------------------------------- /api/noteListType.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable multiline-comment-style */ 2 | 3 | import { Size } from './types'; 4 | 5 | // AUTO-GENERATED by generate-database-type 6 | type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_'; 7 | // AUTO-GENERATED by generate-database-type 8 | 9 | export enum ItemFlow { 10 | TopToBottom = 'topToBottom', 11 | LeftToRight = 'leftToRight', 12 | } 13 | 14 | export type RenderNoteView = Record; 15 | 16 | export interface OnChangeEvent { 17 | elementId: string; 18 | value: any; 19 | noteId: string; 20 | } 21 | 22 | export type OnRenderNoteHandler = (props: any)=> Promise; 23 | export type OnChangeHandler = (event: OnChangeEvent)=> Promise; 24 | 25 | /** 26 | * Most of these are the built-in note properties, such as `note.title`, 27 | * `note.todo_completed`, etc. 28 | * 29 | * Additionally, the `item.*` properties are specific to the rendered item. The 30 | * most important being `item.selected`, which you can use to display the 31 | * selected note in a different way. 32 | * 33 | * Finally some special properties are provided to make it easier to render 34 | * notes. In particular, if possible prefer `note.titleHtml` to `note.title` 35 | * since some important processing has already been done on the string, such as 36 | * handling the search highlighter and escaping. Since it's HTML and already 37 | * escaped you would insert it using `{{{titleHtml}}}` (triple-mustache syntax, 38 | * which disables escaping). 39 | * 40 | * `notes.tag` gives you the list of tags associated with the note. 41 | * 42 | * `note.isWatched` tells you if the note is currently opened in an external 43 | * editor. In which case you would generally display some indicator. 44 | */ 45 | export type ListRendererDepependency = 46 | ListRendererDatabaseDependency | 47 | 'item.size.width' | 48 | 'item.size.height' | 49 | 'item.selected' | 50 | 'note.titleHtml' | 51 | 'note.isWatched' | 52 | 'note.tags'; 53 | 54 | export interface ListRenderer { 55 | /** 56 | * It must be unique to your plugin. 57 | */ 58 | id: string; 59 | 60 | /** 61 | * Can be top to bottom or left to right. Left to right gives you more 62 | * option to set the size of the items since you set both its width and 63 | * height. 64 | */ 65 | flow: ItemFlow; 66 | 67 | /** 68 | * The size of each item must be specified in advance for performance 69 | * reasons, and cannot be changed afterwards. If the item flow is top to 70 | * bottom, you only need to specificy the item height (the width will be 71 | * ignored). 72 | */ 73 | itemSize: Size; 74 | 75 | /** 76 | * The CSS is relative to the list item container. What will appear in the 77 | * page is essentially `.note-list-item { YOUR_CSS; }`. It means you can use 78 | * child combinator with guarantee it will only apply to your own items. In 79 | * this example, the styling will apply to `.note-list-item > .content`: 80 | * 81 | * ```css 82 | * > .content { 83 | * padding: 10px; 84 | * } 85 | * ``` 86 | * 87 | * In order to get syntax highlighting working here, it's recommended 88 | * installing an editor extension such as [es6-string-html VSCode 89 | * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) 90 | */ 91 | itemCss?: string; 92 | 93 | /** 94 | * List the dependencies that your plugin needs to render the note list 95 | * items. Only these will be passed to your `onRenderNote` handler. Ensure 96 | * that you do not add more than what you need since there is a performance 97 | * penalty for each property. 98 | */ 99 | dependencies: ListRendererDepependency[]; 100 | 101 | /** 102 | * This is the HTML template that will be used to render the note list item. 103 | * This is a [Mustache template](https://github.com/janl/mustache.js) and it 104 | * will receive the variable you return from `onRenderNote` as tags. For 105 | * example, if you return a property named `formattedDate` from 106 | * `onRenderNote`, you can insert it in the template using `Created date: 107 | * {{formattedDate}}`. 108 | * 109 | * In order to get syntax highlighting working here, it's recommended 110 | * installing an editor extension such as [es6-string-html VSCode 111 | * extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html) 112 | */ 113 | itemTemplate: string; 114 | 115 | /** 116 | * This user-facing text is used for example in the View menu, so that your 117 | * renderer can be selected. 118 | */ 119 | label: ()=> Promise; 120 | 121 | /** 122 | * This is where most of the real-time processing will happen. When a note 123 | * is rendered for the first time and every time it changes, this handler 124 | * receives the properties specified in the `dependencies` property. You can 125 | * then process them, load any additional data you need, and once done you 126 | * need to return the properties that are needed in the `itemTemplate` HTML. 127 | * Again, to use the formatted date example, you could have such a renderer: 128 | * 129 | * ```typescript 130 | * dependencies: [ 131 | * 'note.title', 132 | * 'note.created_time', 133 | * ], 134 | * 135 | * itemTemplate: // html 136 | * ` 137 | *
138 | * Title: {{note.title}}
139 | * Date: {{formattedDate}} 140 | *
141 | * `, 142 | * 143 | * onRenderNote: async (props: any) => { 144 | * const formattedDate = dayjs(props.note.created_time).format(); 145 | * return { 146 | * // Also return the props, so that note.title is available from the 147 | * // template 148 | * ...props, 149 | * formattedDate, 150 | * } 151 | * }, 152 | * ``` 153 | */ 154 | onRenderNote: OnRenderNoteHandler; 155 | 156 | /** 157 | * This handler allows adding some interacivity to the note renderer - 158 | * whenever an input element within the item is changed (for example, when a 159 | * checkbox is clicked, or a text input is changed), this `onChange` handler 160 | * is going to be called. 161 | * 162 | * You can inspect `event.elementId` to know which element had some changes, 163 | * and `event.value` to know the new value. `event.noteId` also tells you 164 | * what note is affected, so that you can potentially apply changes to it. 165 | * 166 | * You specify the element ID, by setting a `data-id` attribute on the 167 | * input. 168 | * 169 | * For example, if you have such a template: 170 | * 171 | * ```html 172 | *
173 | * 174 | *
175 | * ``` 176 | * 177 | * The event handler will receive an event with `elementId` set to 178 | * `noteTitleInput`. 179 | */ 180 | onChange?: OnChangeHandler; 181 | } 182 | -------------------------------------------------------------------------------- /api/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable multiline-comment-style */ 2 | 3 | // ================================================================= 4 | // Command API types 5 | // ================================================================= 6 | 7 | export interface Command { 8 | /** 9 | * Name of command - must be globally unique 10 | */ 11 | name: string; 12 | 13 | /** 14 | * Label to be displayed on menu items or keyboard shortcut editor for example. 15 | * If it is missing, it's assumed it's a private command, to be called programmatically only. 16 | * In that case the command will not appear in the shortcut editor or command panel, and logically 17 | * should not be used as a menu item. 18 | */ 19 | label?: string; 20 | 21 | /** 22 | * Icon to be used on toolbar buttons for example 23 | */ 24 | iconName?: string; 25 | 26 | /** 27 | * Code to be ran when the command is executed. It may return a result. 28 | */ 29 | execute(...args: any[]): Promise; 30 | 31 | /** 32 | * Defines whether the command should be enabled or disabled, which in turns 33 | * affects the enabled state of any associated button or menu item. 34 | * 35 | * The condition should be expressed as a "when-clause" (as in Visual Studio 36 | * Code). It's a simple boolean expression that evaluates to `true` or 37 | * `false`. It supports the following operators: 38 | * 39 | * Operator | Symbol | Example 40 | * -- | -- | -- 41 | * Equality | == | "editorType == markdown" 42 | * Inequality | != | "currentScreen != config" 43 | * Or | \|\| | "noteIsTodo \|\| noteTodoCompleted" 44 | * And | && | "oneNoteSelected && !inConflictFolder" 45 | * 46 | * Joplin, unlike VSCode, also supports parenthesis, which allows creating 47 | * more complex expressions such as `cond1 || (cond2 && cond3)`. Only one 48 | * level of parenthesis is possible (nested ones aren't supported). 49 | * 50 | * Currently the supported context variables aren't documented, but you can 51 | * find the list below: 52 | * 53 | * - [Global When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts) 54 | * - [Desktop app When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts) 55 | * 56 | * Note: Commands are enabled by default unless you use this property. 57 | */ 58 | enabledCondition?: string; 59 | } 60 | 61 | // ================================================================= 62 | // Interop API types 63 | // ================================================================= 64 | 65 | export enum FileSystemItem { 66 | File = 'file', 67 | Directory = 'directory', 68 | } 69 | 70 | export enum ImportModuleOutputFormat { 71 | Markdown = 'md', 72 | Html = 'html', 73 | } 74 | 75 | /** 76 | * Used to implement a module to export data from Joplin. [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export) for an example. 77 | * 78 | * In general, all the event handlers you'll need to implement take a `context` object as a first argument. This object will contain the export or import path as well as various optional properties, such as which notes or notebooks need to be exported. 79 | * 80 | * To get a better sense of what it will contain it can be useful to print it using `console.info(context)`. 81 | */ 82 | export interface ExportModule { 83 | /** 84 | * The format to be exported, eg "enex", "jex", "json", etc. 85 | */ 86 | format: string; 87 | 88 | /** 89 | * The description that will appear in the UI, for example in the menu item. 90 | */ 91 | description: string; 92 | 93 | /** 94 | * Whether the module will export a single file or multiple files in a directory. It affects the open dialog that will be presented to the user when using your exporter. 95 | */ 96 | target: FileSystemItem; 97 | 98 | /** 99 | * Only applies to single file exporters or importers 100 | * It tells whether the format can package multiple notes into one file. 101 | * For example JEX or ENEX can, but HTML cannot. 102 | */ 103 | isNoteArchive: boolean; 104 | 105 | /** 106 | * The extensions of the files exported by your module. For example, it is `["htm", "html"]` for the HTML module, and just `["jex"]` for the JEX module. 107 | */ 108 | fileExtensions?: string[]; 109 | 110 | /** 111 | * Called when the export process starts. 112 | */ 113 | onInit(context: ExportContext): Promise; 114 | 115 | /** 116 | * Called when an item needs to be processed. An "item" can be any Joplin object, such as a note, a folder, a notebook, etc. 117 | */ 118 | onProcessItem(context: ExportContext, itemType: number, item: any): Promise; 119 | 120 | /** 121 | * Called when a resource file needs to be exported. 122 | */ 123 | onProcessResource(context: ExportContext, resource: any, filePath: string): Promise; 124 | 125 | /** 126 | * Called when the export process is done. 127 | */ 128 | onClose(context: ExportContext): Promise; 129 | } 130 | 131 | export interface ImportModule { 132 | /** 133 | * The format to be exported, eg "enex", "jex", "json", etc. 134 | */ 135 | format: string; 136 | 137 | /** 138 | * The description that will appear in the UI, for example in the menu item. 139 | */ 140 | description: string; 141 | 142 | /** 143 | * Only applies to single file exporters or importers 144 | * It tells whether the format can package multiple notes into one file. 145 | * For example JEX or ENEX can, but HTML cannot. 146 | */ 147 | isNoteArchive: boolean; 148 | 149 | /** 150 | * The type of sources that are supported by the module. Tells whether the module can import files or directories or both. 151 | */ 152 | sources: FileSystemItem[]; 153 | 154 | /** 155 | * Tells the file extensions of the exported files. 156 | */ 157 | fileExtensions?: string[]; 158 | 159 | /** 160 | * Tells the type of notes that will be generated, either HTML or Markdown (default). 161 | */ 162 | outputFormat?: ImportModuleOutputFormat; 163 | 164 | /** 165 | * Called when the import process starts. There is only one event handler within which you should import the complete data. 166 | */ 167 | onExec(context: ImportContext): Promise; 168 | } 169 | 170 | export interface ExportOptions { 171 | format?: string; 172 | path?: string; 173 | sourceFolderIds?: string[]; 174 | sourceNoteIds?: string[]; 175 | // modulePath?: string; 176 | target?: FileSystemItem; 177 | } 178 | 179 | export interface ExportContext { 180 | destPath: string; 181 | options: ExportOptions; 182 | 183 | /** 184 | * You can attach your own custom data using this propery - it will then be passed to each event handler, allowing you to keep state from one event to the next. 185 | */ 186 | userData?: any; 187 | } 188 | 189 | export interface ImportContext { 190 | sourcePath: string; 191 | options: any; 192 | warnings: string[]; 193 | } 194 | 195 | // ================================================================= 196 | // Misc types 197 | // ================================================================= 198 | 199 | export interface Script { 200 | onStart?(event: any): Promise; 201 | } 202 | 203 | export interface Disposable { 204 | // dispose():void; 205 | } 206 | 207 | export enum ModelType { 208 | Note = 1, 209 | Folder = 2, 210 | Setting = 3, 211 | Resource = 4, 212 | Tag = 5, 213 | NoteTag = 6, 214 | Search = 7, 215 | Alarm = 8, 216 | MasterKey = 9, 217 | ItemChange = 10, 218 | NoteResource = 11, 219 | ResourceLocalState = 12, 220 | Revision = 13, 221 | Migration = 14, 222 | SmartFilter = 15, 223 | Command = 16, 224 | } 225 | 226 | export interface VersionInfo { 227 | version: string; 228 | profileVersion: number; 229 | syncVersion: number; 230 | } 231 | 232 | // ================================================================= 233 | // Menu types 234 | // ================================================================= 235 | 236 | export interface CreateMenuItemOptions { 237 | accelerator: string; 238 | } 239 | 240 | export enum MenuItemLocation { 241 | File = 'file', 242 | Edit = 'edit', 243 | View = 'view', 244 | Note = 'note', 245 | Tools = 'tools', 246 | Help = 'help', 247 | 248 | /** 249 | * @deprecated Do not use - same as NoteListContextMenu 250 | */ 251 | Context = 'context', 252 | 253 | // If adding an item here, don't forget to update isContextMenuItemLocation() 254 | 255 | /** 256 | * When a command is called from the note list context menu, the 257 | * command will receive the following arguments: 258 | * 259 | * - `noteIds:string[]`: IDs of the notes that were right-clicked on. 260 | */ 261 | NoteListContextMenu = 'noteListContextMenu', 262 | 263 | EditorContextMenu = 'editorContextMenu', 264 | 265 | /** 266 | * When a command is called from a folder context menu, the 267 | * command will receive the following arguments: 268 | * 269 | * - `folderId:string`: ID of the folder that was right-clicked on 270 | */ 271 | FolderContextMenu = 'folderContextMenu', 272 | 273 | /** 274 | * When a command is called from a tag context menu, the 275 | * command will receive the following arguments: 276 | * 277 | * - `tagId:string`: ID of the tag that was right-clicked on 278 | */ 279 | TagContextMenu = 'tagContextMenu', 280 | } 281 | 282 | export function isContextMenuItemLocation(location: MenuItemLocation): boolean { 283 | return [ 284 | MenuItemLocation.Context, 285 | MenuItemLocation.NoteListContextMenu, 286 | MenuItemLocation.EditorContextMenu, 287 | MenuItemLocation.FolderContextMenu, 288 | MenuItemLocation.TagContextMenu, 289 | ].includes(location); 290 | } 291 | 292 | export interface MenuItem { 293 | /** 294 | * Command that should be associated with the menu item. All menu item should 295 | * have a command associated with them unless they are a sub-menu. 296 | */ 297 | commandName?: string; 298 | 299 | /** 300 | * Arguments that should be passed to the command. They will be as rest 301 | * parameters. 302 | */ 303 | commandArgs?: any[]; 304 | 305 | /** 306 | * Set to "separator" to create a divider line 307 | */ 308 | type?: ('normal' | 'separator' | 'submenu' | 'checkbox' | 'radio'); 309 | 310 | /** 311 | * Accelerator associated with the menu item 312 | */ 313 | accelerator?: string; 314 | 315 | /** 316 | * Menu items that should appear below this menu item. Allows creating a menu tree. 317 | */ 318 | submenu?: MenuItem[]; 319 | 320 | /** 321 | * Menu item label. If not specified, the command label will be used instead. 322 | */ 323 | label?: string; 324 | } 325 | 326 | // ================================================================= 327 | // View API types 328 | // ================================================================= 329 | 330 | export interface ButtonSpec { 331 | id: ButtonId; 332 | title?: string; 333 | onClick?(): void; 334 | } 335 | 336 | export type ButtonId = string; 337 | 338 | export enum ToolbarButtonLocation { 339 | /** 340 | * This toolbar in the top right corner of the application. It applies to the note as a whole, including its metadata. 341 | */ 342 | NoteToolbar = 'noteToolbar', 343 | 344 | /** 345 | * This toolbar is right above the text editor. It applies to the note body only. 346 | */ 347 | EditorToolbar = 'editorToolbar', 348 | } 349 | 350 | export type ViewHandle = string; 351 | 352 | export interface EditorCommand { 353 | name: string; 354 | value?: any; 355 | } 356 | 357 | export interface DialogResult { 358 | id: ButtonId; 359 | formData?: any; 360 | } 361 | 362 | export interface Size { 363 | width?: number; 364 | height?: number; 365 | } 366 | 367 | export interface Rectangle { 368 | x?: number; 369 | y?: number; 370 | width?: number; 371 | height?: number; 372 | } 373 | 374 | // ================================================================= 375 | // Settings types 376 | // ================================================================= 377 | 378 | export enum SettingItemType { 379 | Int = 1, 380 | String = 2, 381 | Bool = 3, 382 | Array = 4, 383 | Object = 5, 384 | Button = 6, 385 | } 386 | 387 | export enum SettingItemSubType { 388 | FilePathAndArgs = 'file_path_and_args', 389 | FilePath = 'file_path', // Not supported on mobile! 390 | DirectoryPath = 'directory_path', // Not supported on mobile! 391 | } 392 | 393 | export enum AppType { 394 | Desktop = 'desktop', 395 | Mobile = 'mobile', 396 | Cli = 'cli', 397 | } 398 | 399 | export enum SettingStorage { 400 | Database = 1, 401 | File = 2, 402 | } 403 | 404 | // Redefine a simplified interface to mask internal details 405 | // and to remove function calls as they would have to be async. 406 | export interface SettingItem { 407 | value: any; 408 | type: SettingItemType; 409 | 410 | /** 411 | * Currently only used to display a file or directory selector. Always set 412 | * `type` to `SettingItemType.String` when using this property. 413 | */ 414 | subType?: SettingItemSubType; 415 | 416 | label: string; 417 | description?: string; 418 | 419 | /** 420 | * A public setting will appear in the Configuration screen and will be 421 | * modifiable by the user. A private setting however will not appear there, 422 | * and can only be changed programmatically. You may use this to store some 423 | * values that you do not want to directly expose. 424 | */ 425 | public: boolean; 426 | 427 | /** 428 | * You would usually set this to a section you would have created 429 | * specifically for the plugin. 430 | */ 431 | section?: string; 432 | 433 | /** 434 | * To create a setting with multiple options, set this property to `true`. 435 | * That setting will render as a dropdown list in the configuration screen. 436 | */ 437 | isEnum?: boolean; 438 | 439 | /** 440 | * This property is required when `isEnum` is `true`. In which case, it 441 | * should contain a map of value => label. 442 | */ 443 | options?: Record; 444 | 445 | /** 446 | * Reserved property. Not used at the moment. 447 | */ 448 | appTypes?: AppType[]; 449 | 450 | /** 451 | * Set this to `true` to store secure data, such as passwords. Any such 452 | * setting will be stored in the system keychain if one is available. 453 | */ 454 | secure?: boolean; 455 | 456 | /** 457 | * An advanced setting will be moved under the "Advanced" button in the 458 | * config screen. 459 | */ 460 | advanced?: boolean; 461 | 462 | /** 463 | * Set the min, max and step values if you want to restrict an int setting 464 | * to a particular range. 465 | */ 466 | minimum?: number; 467 | maximum?: number; 468 | step?: number; 469 | 470 | /** 471 | * Either store the setting in the database or in settings.json. Defaults to database. 472 | */ 473 | storage?: SettingStorage; 474 | } 475 | 476 | export interface SettingSection { 477 | label: string; 478 | iconName?: string; 479 | description?: string; 480 | name?: string; 481 | } 482 | 483 | // ================================================================= 484 | // Data API types 485 | // ================================================================= 486 | 487 | /** 488 | * An array of at least one element and at most three elements. 489 | * 490 | * - **[0]**: Resource name (eg. "notes", "folders", "tags", etc.) 491 | * - **[1]**: (Optional) Resource ID. 492 | * - **[2]**: (Optional) Resource link. 493 | */ 494 | export type Path = string[]; 495 | 496 | // ================================================================= 497 | // Content Script types 498 | // ================================================================= 499 | 500 | export type PostMessageHandler = (message: any)=> Promise; 501 | 502 | /** 503 | * When a content script is initialised, it receives a `context` object. 504 | */ 505 | export interface ContentScriptContext { 506 | /** 507 | * The plugin ID that registered this content script 508 | */ 509 | pluginId: string; 510 | 511 | /** 512 | * The content script ID, which may be necessary to post messages 513 | */ 514 | contentScriptId: string; 515 | 516 | /** 517 | * Can be used by CodeMirror content scripts to post a message to the plugin 518 | */ 519 | postMessage: PostMessageHandler; 520 | } 521 | 522 | export interface ContentScriptModuleLoadedEvent { 523 | userData?: any; 524 | } 525 | 526 | export interface ContentScriptModule { 527 | onLoaded?: (event: ContentScriptModuleLoadedEvent)=> void; 528 | plugin: ()=> any; 529 | assets?: ()=> void; 530 | } 531 | 532 | export interface MarkdownItContentScriptModule extends Omit { 533 | plugin: (markdownIt: any, options: any)=> any; 534 | } 535 | 536 | export enum ContentScriptType { 537 | /** 538 | * Registers a new Markdown-It plugin, which should follow the template 539 | * below. 540 | * 541 | * ```javascript 542 | * module.exports = { 543 | * default: function(context) { 544 | * return { 545 | * plugin: function(markdownIt, pluginOptions) { 546 | * // ... 547 | * }, 548 | * assets: { 549 | * // ... 550 | * }, 551 | * } 552 | * } 553 | * } 554 | * ``` 555 | * 556 | * See [the 557 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) 558 | * for a simple Markdown-it plugin example. 559 | * 560 | * ## Exported members 561 | * 562 | * - The `context` argument is currently unused but could be used later on 563 | * to provide access to your own plugin so that the content script and 564 | * plugin can communicate. 565 | * 566 | * - The **required** `plugin` key is the actual Markdown-It plugin - check 567 | * the [official doc](https://github.com/markdown-it/markdown-it) for more 568 | * information. 569 | * 570 | * - Using the **optional** `assets` key you may specify assets such as JS 571 | * or CSS that should be loaded in the rendered HTML document. Check for 572 | * example the Joplin [Mermaid 573 | * plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) 574 | * to see how the data should be structured. 575 | * 576 | * ## Getting the settings from the renderer 577 | * 578 | * You can access your plugin settings from the renderer by calling 579 | * `pluginOptions.settingValue("your-setting-key')`. 580 | * 581 | * ## Posting messages from the content script to your plugin 582 | * 583 | * The application provides the following function to allow executing 584 | * commands from the rendered HTML code: 585 | * 586 | * ```javascript 587 | * const response = await webviewApi.postMessage(contentScriptId, message); 588 | * ``` 589 | * 590 | * - `contentScriptId` is the ID you've defined when you registered the 591 | * content script. You can retrieve it from the 592 | * {@link ContentScriptContext | context}. 593 | * - `message` can be any basic JavaScript type (number, string, plain 594 | * object), but it cannot be a function or class instance. 595 | * 596 | * When you post a message, the plugin can send back a `response` thus 597 | * allowing two-way communication: 598 | * 599 | * ```javascript 600 | * await joplin.contentScripts.onMessage(contentScriptId, (message) => { 601 | * // Process message 602 | * return response; // Can be any object, string or number 603 | * }); 604 | * ``` 605 | * 606 | * See {@link JoplinContentScripts.onMessage} for more details, as well as 607 | * the [postMessage 608 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages). 609 | * 610 | * ## Registering an existing Markdown-it plugin 611 | * 612 | * To include a regular Markdown-It plugin, that doesn't make use of any 613 | * Joplin-specific features, you would simply create a file such as this: 614 | * 615 | * ```javascript 616 | * module.exports = { 617 | * default: function(context) { 618 | * return { 619 | * plugin: require('markdown-it-toc-done-right'); 620 | * } 621 | * } 622 | * } 623 | * ``` 624 | */ 625 | MarkdownItPlugin = 'markdownItPlugin', 626 | 627 | /** 628 | * Registers a new CodeMirror plugin, which should follow the template 629 | * below. 630 | * 631 | * ```javascript 632 | * module.exports = { 633 | * default: function(context) { 634 | * return { 635 | * plugin: function(CodeMirror) { 636 | * // ... 637 | * }, 638 | * codeMirrorResources: [], 639 | * codeMirrorOptions: { 640 | * // ... 641 | * }, 642 | * assets: { 643 | * // ... 644 | * }, 645 | * } 646 | * } 647 | * } 648 | * ``` 649 | * 650 | * - The `context` argument is currently unused but could be used later on 651 | * to provide access to your own plugin so that the content script and 652 | * plugin can communicate. 653 | * 654 | * - The `plugin` key is your CodeMirror plugin. This is where you can 655 | * register new commands with CodeMirror or interact with the CodeMirror 656 | * instance as needed. 657 | * 658 | * - The `codeMirrorResources` key is an array of CodeMirror resources that 659 | * will be loaded and attached to the CodeMirror module. These are made up 660 | * of addons, keymaps, and modes. For example, for a plugin that want's to 661 | * enable clojure highlighting in code blocks. `codeMirrorResources` would 662 | * be set to `['mode/clojure/clojure']`. 663 | * 664 | * - The `codeMirrorOptions` key contains all the 665 | * [CodeMirror](https://codemirror.net/doc/manual.html#config) options 666 | * that will be set or changed by this plugin. New options can alse be 667 | * declared via 668 | * [`CodeMirror.defineOption`](https://codemirror.net/doc/manual.html#defineOption), 669 | * and then have their value set here. For example, a plugin that enables 670 | * line numbers would set `codeMirrorOptions` to `{'lineNumbers': true}`. 671 | * 672 | * - Using the **optional** `assets` key you may specify **only** CSS assets 673 | * that should be loaded in the rendered HTML document. Check for example 674 | * the Joplin [Mermaid 675 | * plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) 676 | * to see how the data should be structured. 677 | * 678 | * One of the `plugin`, `codeMirrorResources`, or `codeMirrorOptions` keys 679 | * must be provided for the plugin to be valid. Having multiple or all 680 | * provided is also okay. 681 | * 682 | * See also the [demo 683 | * plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) 684 | * for an example of all these keys being used in one plugin. 685 | * 686 | * ## Posting messages from the content script to your plugin 687 | * 688 | * In order to post messages to the plugin, you can use the postMessage 689 | * function passed to the {@link ContentScriptContext | context}. 690 | * 691 | * ```javascript 692 | * const response = await context.postMessage('messageFromCodeMirrorContentScript'); 693 | * ``` 694 | * 695 | * When you post a message, the plugin can send back a `response` thus 696 | * allowing two-way communication: 697 | * 698 | * ```javascript 699 | * await joplin.contentScripts.onMessage(contentScriptId, (message) => { 700 | * // Process message 701 | * return response; // Can be any object, string or number 702 | * }); 703 | * ``` 704 | * 705 | * See {@link JoplinContentScripts.onMessage} for more details, as well as 706 | * the [postMessage 707 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages). 708 | * 709 | */ 710 | CodeMirrorPlugin = 'codeMirrorPlugin', 711 | } 712 | -------------------------------------------------------------------------------- /img/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /img/icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackGruber/joplin-plugin-tagging/61db7c5e1710871787879e91bee0004f3081f7fc/img/icon_256.png -------------------------------------------------------------------------------- /img/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackGruber/joplin-plugin-tagging/61db7c5e1710871787879e91bee0004f3081f7fc/img/icon_32.png -------------------------------------------------------------------------------- /img/main_tagging.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackGruber/joplin-plugin-tagging/61db7c5e1710871787879e91bee0004f3081f7fc/img/main_tagging.gif -------------------------------------------------------------------------------- /img/showcase1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackGruber/joplin-plugin-tagging/61db7c5e1710871787879e91bee0004f3081f7fc/img/showcase1.png -------------------------------------------------------------------------------- /img/showcase2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackGruber/joplin-plugin-tagging/61db7c5e1710871787879e91bee0004f3081f7fc/img/showcase2.png -------------------------------------------------------------------------------- /img/tagging_dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackGruber/joplin-plugin-tagging/61db7c5e1710871787879e91bee0004f3081f7fc/img/tagging_dialog.png -------------------------------------------------------------------------------- /img/tagging_dialog_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackGruber/joplin-plugin-tagging/61db7c5e1710871787879e91bee0004f3081f7fc/img/tagging_dialog_search.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joplin-plugin-copytags", 3 | "version": "1.0.3", 4 | "description": "Plugin to extend the Joplin tagging menu with a coppy all tags and tagging list with more control.", 5 | "scripts": { 6 | "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=webview && webpack --env joplin-plugin-config=createArchive", 7 | "prepare": "npm run dist", 8 | "update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force", 9 | "release": "node ./node_modules/joplinplugindevtools/dist/createRelease.js", 10 | "preRelease": "node ./node_modules/joplinplugindevtools/dist/createRelease.js --prerelease", 11 | "gitRelease": "node ./node_modules/joplinplugindevtools/dist/createRelease.js --upload", 12 | "gitPreRelease": "node ./node_modules/joplinplugindevtools/dist/createRelease.js --upload --prerelease", 13 | "updateVersion": "webpack --env joplin-plugin-config=updateVersion" 14 | }, 15 | "keywords": [ 16 | "joplin-plugin" 17 | ], 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@types/node": "^18.7.13", 21 | "chalk": "^4.1.0", 22 | "copy-webpack-plugin": "^11.0.0", 23 | "fs-extra": "^10.1.0", 24 | "glob": "^8.0.3", 25 | "husky": "^6.0.0", 26 | "joplinplugindevtools": "^1.0.15", 27 | "lint-staged": "^11.0.0", 28 | "on-build-webpack": "^0.1.0", 29 | "prettier": "2.3.0", 30 | "tar": "^6.1.11", 31 | "ts-loader": "^9.3.1", 32 | "typescript": "^4.8.2", 33 | "webpack": "^5.74.0", 34 | "webpack-cli": "^4.10.0", 35 | "yargs": "^16.2.0", 36 | "@joplin/lib": "~2.9" 37 | }, 38 | "dependencies": { 39 | "string-natural-compare": "^3.0.1" 40 | }, 41 | "lint-staged": { 42 | "**/*": "prettier --write --ignore-unknown" 43 | }, 44 | "files": [ 45 | "publish" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /plugin.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extraScripts": [] 3 | } 4 | -------------------------------------------------------------------------------- /src/html.ts: -------------------------------------------------------------------------------- 1 | export function htmlToElem(html) { 2 | let temp = document.createElement("template"); 3 | temp.innerHTML = html; 4 | return temp.content.firstChild; 5 | } 6 | 7 | export function createTagHTML( 8 | tagId: string, 9 | status: number, 10 | title: string 11 | ): string { 12 | const domTagDiv = document.createElement("div"); 13 | const domCheckbox = document.createElement("input"); 14 | const domMark = document.createElement("span"); 15 | const domInput = document.createElement("input"); 16 | const domLabel = document.createElement("label"); 17 | 18 | domTagDiv.setAttribute("class", "tag"); 19 | domLabel.innerHTML = title; 20 | 21 | domCheckbox.setAttribute("type", "checkbox"); 22 | domCheckbox.setAttribute("tagId", tagId); 23 | domCheckbox.setAttribute("value", status.toString()); 24 | domCheckbox.classList.add("tagcheckbox"); 25 | if (status == 1) { 26 | domCheckbox.defaultChecked = true; 27 | } else if (status == 2) { 28 | domCheckbox.classList.add("indeterminate"); 29 | } 30 | 31 | domMark.setAttribute("class", "checkmark"); 32 | 33 | domInput.setAttribute("type", "hidden"); 34 | domInput.setAttribute("name", tagId); 35 | domInput.setAttribute("value", status.toString()); 36 | 37 | domTagDiv.appendChild(domCheckbox); 38 | domTagDiv.appendChild(domMark); 39 | domTagDiv.appendChild(domLabel); 40 | domTagDiv.appendChild(domInput); 41 | 42 | return domTagDiv.outerHTML; 43 | } 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import joplin from "api"; 2 | import { MenuItemLocation } from "api/types"; 3 | import { tagging } from "./tagging"; 4 | 5 | joplin.plugins.register({ 6 | onStart: async function () { 7 | console.info("Tagging plugin started"); 8 | 9 | await joplin.commands.register({ 10 | name: "CopyAllTags", 11 | label: "Copy all tags", 12 | enabledCondition: "noteListHasNotes", 13 | execute: async () => tagging.copyAllTags(), 14 | }); 15 | 16 | await joplin.views.menuItems.create( 17 | "myMenuItemToolsCopyAllTags", 18 | "CopyAllTags", 19 | MenuItemLocation.Tools 20 | ); 21 | await joplin.views.menuItems.create( 22 | "contextMenuItemCopyAllTags", 23 | "CopyAllTags", 24 | MenuItemLocation.NoteListContextMenu 25 | ); 26 | 27 | await tagging.createDialog(); 28 | 29 | await joplin.commands.register({ 30 | name: "TaggingDialog", 31 | label: "Tagging dialog", 32 | enabledCondition: "noteListHasNotes", 33 | execute: async () => { 34 | const noteIds = await joplin.workspace.selectedNoteIds(); 35 | if (noteIds.length > 0) { 36 | const taggingInfo = await tagging.getTaggingInfo(noteIds); 37 | 38 | const result = await tagging.showTaggingDialog(taggingInfo); 39 | 40 | if (result["id"] == "ok") { 41 | await tagging.processTags( 42 | noteIds, 43 | result["formData"]["tags"], 44 | taggingInfo 45 | ); 46 | } 47 | } 48 | }, 49 | }); 50 | 51 | await joplin.views.menuItems.create( 52 | "MenuItemToolsTaggingDialog", 53 | "TaggingDialog", 54 | MenuItemLocation.Tools 55 | ); 56 | await joplin.views.menuItems.create( 57 | "contextMenuItemTaggingDialog", 58 | "TaggingDialog", 59 | MenuItemLocation.NoteListContextMenu 60 | ); 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 1, 3 | "id": "io.github.jackgruber.copytags", 4 | "app_min_version": "1.6.2", 5 | "version": "1.0.3", 6 | "name": "Tagging", 7 | "description": "Plugin to extend the Joplin tagging menu with a copy all tags and a tagging dialog with more control. (Formerly Copy Tags).", 8 | "author": "JackGruber", 9 | "homepage_url": "https://github.com/JackGruber/joplin-plugin-tagging/blob/master/README.md", 10 | "repository_url": "https://github.com/JackGruber/joplin-plugin-tagging", 11 | "keywords": ["duplicate", "copy", "tags", "tagging", "tag"], 12 | "categories": ["productivity", "tags"], 13 | "screenshots": [ 14 | { 15 | "src": "img/main_tagging.gif", 16 | "label": "Screenshot: Showing the tagging function" 17 | }, 18 | { 19 | "src": "img/showcase1.png", 20 | "label": "Screenshot: Search tags" 21 | }, 22 | { 23 | "src": "img/showcase2.png", 24 | "label": "Screenshot: Multiple notes tagged" 25 | } 26 | ], 27 | "icons": { 28 | "256": "img/icon_256.png" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/tagging.ts: -------------------------------------------------------------------------------- 1 | import joplin from "api"; 2 | import { ResultMessage, TagResult, Tag, SearchMessage } from "./type"; 3 | import * as naturalCompare from "string-natural-compare"; 4 | import { createTagHTML } from "./html"; 5 | 6 | export let tagDialog: string; 7 | 8 | export namespace tagging { 9 | export async function showTaggingDialog(taggingInfo) { 10 | const tagList = []; 11 | const tagListmax = 40; 12 | for (const key in taggingInfo) { 13 | tagList.push( 14 | await createTagHTML( 15 | key, 16 | taggingInfo[key]["status"], 17 | taggingInfo[key]["title"] 18 | ) 19 | ); 20 | if (tagList.length == tagListmax) { 21 | break; 22 | } 23 | } 24 | 25 | const dialogDiv = document.createElement("div"); 26 | dialogDiv.setAttribute("id", "copytags"); 27 | const autocompleteDiv = document.createElement("div"); 28 | autocompleteDiv.setAttribute("id", "autocomplete"); 29 | autocompleteDiv.setAttribute("class", "autocomplete"); 30 | 31 | const searchBox = document.createElement("textarea"); 32 | searchBox.setAttribute("id", "query-input"); 33 | searchBox.setAttribute("rows", "1"); 34 | searchBox.setAttribute("name", "addTag"); 35 | searchBox.setAttribute("placeholder", "Tag search"); 36 | autocompleteDiv.appendChild(searchBox); 37 | 38 | const form = document.createElement("form"); 39 | form.setAttribute("name", "tags"); 40 | 41 | const assignedTags = document.createElement("div"); 42 | assignedTags.setAttribute("id", "assignedTags"); 43 | assignedTags.innerHTML = tagList.join("\n"); 44 | form.appendChild(assignedTags); 45 | 46 | dialogDiv.appendChild(autocompleteDiv); 47 | dialogDiv.appendChild(form); 48 | 49 | if (tagList.length == tagListmax) { 50 | const warning = document.createElement("div"); 51 | warning.setAttribute("id", "tagwarning"); 52 | warning.innerHTML = "Too many tags!"; 53 | dialogDiv.appendChild(warning); 54 | } 55 | 56 | await joplin.views.dialogs.setHtml(tagDialog, dialogDiv.outerHTML); 57 | joplin.views.panels.onMessage(tagDialog, async (msg) => 58 | tagging.processDialogMsg(msg) 59 | ); 60 | return await joplin.views.dialogs.open(tagDialog); 61 | } 62 | 63 | export async function getTaggingInfo(noteIds: string[]): Promise { 64 | let taggingInfo = {}; 65 | for (const noteId of noteIds) { 66 | var pageNum = 1; 67 | do { 68 | var tags = await joplin.data.get(["notes", noteId, "tags"], { 69 | fields: "id, title", 70 | limit: 20, 71 | page: pageNum++, 72 | }); 73 | for (const tag of tags.items) { 74 | if (typeof taggingInfo[tag.id] === "undefined") { 75 | taggingInfo[tag.id] = {}; 76 | taggingInfo[tag.id]["count"] = 1; 77 | taggingInfo[tag.id]["title"] = tag.title; 78 | } else { 79 | taggingInfo[tag.id]["count"]++; 80 | } 81 | } 82 | } while (tags.has_more); 83 | } 84 | 85 | for (const key in taggingInfo) { 86 | if (taggingInfo[key]["count"] == noteIds.length) 87 | taggingInfo[key]["status"] = 1; 88 | else taggingInfo[key]["status"] = 2; 89 | } 90 | 91 | return taggingInfo; 92 | } 93 | 94 | export async function processTags(noteIds: string[], tags, taggingInfo) { 95 | for (var key in tags) { 96 | // new tag 97 | if (key.substring(0, 4) === "new_") { 98 | if (tags[key] == 1) { 99 | const title = key.substring(4); 100 | const newTag = await joplin.data.post(["tags"], null, { 101 | title: title, 102 | }); 103 | for (var i = 0; i < noteIds.length; i++) { 104 | await joplin.data.post(["tags", newTag.id, "notes"], null, { 105 | id: noteIds[i], 106 | }); 107 | } 108 | } 109 | } else if ( 110 | taggingInfo[key] === undefined || 111 | tags[key] != taggingInfo[key]["status"] 112 | ) { 113 | if (tags[key] == 0) { 114 | // Remove Tag 115 | for (var i = 0; i < noteIds.length; i++) { 116 | await joplin.data.delete(["tags", key, "notes", noteIds[i]]); 117 | } 118 | } else if (tags[key] == 1) { 119 | // Add Tag 120 | for (var i = 0; i < noteIds.length; i++) { 121 | await joplin.data.post(["tags", key, "notes"], null, { 122 | id: noteIds[i], 123 | }); 124 | } 125 | } 126 | } 127 | } 128 | } 129 | 130 | export async function processDialogMsg( 131 | msg: SearchMessage 132 | ): Promise { 133 | let result = null; 134 | if (msg.type === "tagSearch") { 135 | const tags = await getTags(msg.query, msg.exclude); 136 | result = { 137 | type: "tagResult", 138 | result: tags, 139 | } as TagResult; 140 | } 141 | return result; 142 | } 143 | 144 | export async function searchTag(query: string, limit: number): Promise { 145 | const result = await joplin.data.get(["search"], { 146 | query: query, 147 | type: "tag", 148 | fields: "id,title", 149 | limit: limit, 150 | sort: "title ASC", 151 | }); 152 | 153 | return result; 154 | } 155 | 156 | export async function getTags( 157 | query: string, 158 | exclude: string[] 159 | ): Promise { 160 | const maxTags = 10; 161 | let tagResult = []; 162 | query = query.trim(); 163 | 164 | // Best match 165 | let result = await searchTag(query, maxTags + exclude.length); 166 | for (const tag of result.items) { 167 | if (exclude.indexOf(tag.id) === -1) { 168 | tagResult.push({ id: tag.id, title: tag.title }); 169 | } 170 | 171 | if (tagResult.length == maxTags) break; 172 | } 173 | 174 | // match from start 175 | result = await searchTag(query + "*", maxTags + exclude.length); 176 | for (const tag of result.items) { 177 | if ( 178 | tagResult.map((t) => t.title).indexOf(tag.title) === -1 && 179 | exclude.indexOf(tag.id) === -1 180 | ) { 181 | tagResult.push({ id: tag.id, title: tag.title }); 182 | } 183 | 184 | if (tagResult.length == maxTags) break; 185 | } 186 | 187 | // 188 | if (tagResult.length < maxTags) { 189 | result = await searchTag("*" + query + "*", maxTags + exclude.length); 190 | for (const tag of result.items) { 191 | if ( 192 | tagResult.map((t) => t.title).indexOf(tag.title) === -1 && 193 | exclude.indexOf(tag.id) === -1 194 | ) { 195 | tagResult.push({ id: tag.id, title: tag.title }); 196 | } 197 | 198 | if (tagResult.length >= maxTags) { 199 | break; 200 | } 201 | } 202 | } 203 | 204 | tagResult.sort((a, b) => { 205 | return naturalCompare(a.title, b.title, { caseInsensitive: true }); 206 | }); 207 | 208 | return tagResult; 209 | } 210 | 211 | export async function copyAllTags() { 212 | var noteIds = await joplin.workspace.selectedNoteIds(); 213 | if (noteIds.length > 1) { 214 | const note = await joplin.data.get(["notes", noteIds[0]], { 215 | fields: ["id", "title"], 216 | }); 217 | if ( 218 | (await joplin.views.dialogs.showMessageBox( 219 | `Copy all tags from ${note["title"]}?` 220 | )) == 0 221 | ) { 222 | var pageNum = 1; 223 | do { 224 | var tags = await joplin.data.get(["notes", noteIds[0], "tags"], { 225 | fields: "id", 226 | limit: 10, 227 | page: pageNum++, 228 | }); 229 | for (var a = 0; a < tags.items.length; a++) { 230 | for (var i = 1; i < noteIds.length; i++) { 231 | await joplin.data.post( 232 | ["tags", tags.items[a].id, "notes"], 233 | null, 234 | { id: noteIds[i] } 235 | ); 236 | } 237 | } 238 | } while (tags.has_more); 239 | } 240 | } 241 | } 242 | 243 | export async function createDialog() { 244 | tagDialog = await joplin.views.dialogs.create("TagDialog"); 245 | await joplin.views.dialogs.addScript(tagDialog, "webview.js"); 246 | await joplin.views.dialogs.addScript(tagDialog, "webview.css"); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 1 | type TagSearch = { 2 | type: "tagSearch"; 3 | query: string; 4 | exclude: string[]; 5 | }; 6 | 7 | type TagResult = { 8 | type: "tagResult"; 9 | result: Tag[]; 10 | }; 11 | 12 | type Tag = { 13 | id: string; 14 | title: string; 15 | }; 16 | 17 | type ResultMessage = TagResult; 18 | type SearchMessage = TagSearch; 19 | 20 | export { ResultMessage, SearchMessage, TagSearch, TagResult, Tag }; 21 | -------------------------------------------------------------------------------- /src/webview.css: -------------------------------------------------------------------------------- 1 | #joplin-plugin-content { 2 | width: fit-content; 3 | background-color: var(--joplin-background-color); 4 | color: var(--joplin-color); 5 | } 6 | 7 | #copytags { 8 | width: fit-content; 9 | display: block; 10 | flex-direction: column; 11 | min-width: 300px; 12 | min-height: 430px; 13 | overflow-wrap: break-word; 14 | font-size: var(--joplin-font-size); 15 | font-family: var(--joplin-font-family); 16 | } 17 | 18 | .autocomplete { 19 | /*the container must be positioned relative:*/ 20 | position: relative; 21 | display: inline-block; 22 | width: 275px; 23 | } 24 | .autocomplete textarea { 25 | width: 275px; 26 | border: 1px solid var(--joplin-code-border-color); 27 | background-color: var(--joplin-background-color); 28 | padding: 10px; 29 | font-size: var(--joplin-font-size); 30 | resize: none; 31 | color: var(--joplin-color); 32 | border-radius: 3px; 33 | } 34 | .autocomplete textarea:focus { 35 | outline: none; 36 | } 37 | .autocomplete textarea[type="text"] { 38 | background-color: var(--joplin-background-color); 39 | width: 100%; 40 | } 41 | .autocomplete-items { 42 | position: absolute; 43 | border: 1px solid var(--joplin-code-border-color); 44 | border-bottom: none; 45 | border-top: none; 46 | z-index: 99; 47 | /*position the autocomplete items to be the same width as the container:*/ 48 | top: 100%; 49 | left: 0; 50 | right: 0; 51 | } 52 | .autocomplete-items div { 53 | padding: 10px; 54 | cursor: pointer; 55 | background-color: var(--joplin-background-color); 56 | border-bottom: 1px solid var(--joplin-code-border-color); 57 | } 58 | .autocomplete-items div:hover { 59 | /*when hovering an item:*/ 60 | background-color: var(--joplin-background-color-hover3); 61 | } 62 | 63 | .autocomplete-active { 64 | /*when navigating through the items using the arrow keys:*/ 65 | background-color: var(--joplin-selected-color) !important; 66 | color: var(--joplin-color-hover); 67 | } 68 | 69 | #tagwarning { 70 | color: var(--joplin-color-warn); 71 | font-weight: bold; 72 | text-align: center; 73 | margin: 10px; 74 | } 75 | -------------------------------------------------------------------------------- /src/webview.ts: -------------------------------------------------------------------------------- 1 | import { TagSearch, ResultMessage } from "./type"; 2 | import { createTagHTML, htmlToElem } from "./html"; 3 | 4 | declare const webviewApi: any; 5 | 6 | class CopytagsDialog { 7 | resultMessage: ResultMessage; 8 | autocompleteCurrentFocus: number = -1; 9 | searchText: string; 10 | allTagsIds: string[]; 11 | 12 | debounce(func: Function, timeout = 300) { 13 | let timer: any; 14 | return (...args: any[]) => { 15 | clearTimeout(timer); 16 | timer = setTimeout(() => { 17 | func.apply(this, args); 18 | }, timeout); 19 | }; 20 | } 21 | 22 | constructor() { 23 | this.setCheckboxIndeterminate(); 24 | this.setOnClickEventTagAllCheckBox(); 25 | this.setSearchBoxEvent(); 26 | this.storeAllTags(); 27 | this.setFocus(); 28 | // Remove autocomplete items on document click 29 | document.addEventListener("click", (event) => { 30 | this.removeAutocompleteItems(); 31 | }); 32 | } 33 | 34 | storeAllTags() { 35 | this.allTagsIds = []; 36 | const assignedTagsDiv = document.getElementById("assignedTags"); 37 | const inputs = assignedTagsDiv.getElementsByTagName("input"); 38 | for (const input of inputs) { 39 | if (input.getAttribute("type") == "checkbox") { 40 | this.allTagsIds.push(input.getAttribute("tagId")); 41 | } 42 | } 43 | } 44 | 45 | setSearchBoxEvent() { 46 | const queryInput = document.getElementById( 47 | "query-input" 48 | ) as HTMLInputElement; 49 | 50 | document.addEventListener( 51 | "input", 52 | this.debounce(function (event) { 53 | if (queryInput.value.trim() === "") { 54 | this.clearSearchField(); 55 | } else { 56 | this.searchTag(queryInput.value); 57 | } 58 | }, 250) 59 | ); 60 | 61 | queryInput.addEventListener("keydown", (event) => { 62 | this.navigateAutocompleteList(event); 63 | }); 64 | } 65 | 66 | navigateAutocompleteList(event: KeyboardEvent) { 67 | let autocompleteListe = document.getElementById("autocomplete-list"); 68 | if (!autocompleteListe) return; 69 | let autocompleteItems = autocompleteListe.getElementsByTagName("div"); 70 | switch (event.key) { 71 | case "Up": 72 | case "Down": 73 | case "ArrowUp": 74 | case "ArrowDown": 75 | this.autocompleteCurrentFocus = 76 | event.key === "ArrowUp" || event.key === "Up" 77 | ? this.autocompleteCurrentFocus - 1 78 | : this.autocompleteCurrentFocus + 1; 79 | this.markActive(autocompleteItems); 80 | break; 81 | case "Enter": 82 | event.preventDefault(); 83 | if (this.autocompleteCurrentFocus === -1) { 84 | autocompleteItems[0].click(); 85 | } else { 86 | autocompleteItems[this.autocompleteCurrentFocus].click(); 87 | } 88 | break; 89 | } 90 | } 91 | 92 | removeActive(x) { 93 | for (var i = 0; i < x.length; i++) { 94 | x[i].classList.remove("autocomplete-active"); 95 | } 96 | } 97 | 98 | markActive(x) { 99 | if (!x) return false; 100 | this.removeActive(x); 101 | if (this.autocompleteCurrentFocus >= x.length) 102 | this.autocompleteCurrentFocus = 0; 103 | if (this.autocompleteCurrentFocus < 0) 104 | this.autocompleteCurrentFocus = x.length - 1; 105 | x[this.autocompleteCurrentFocus].classList.add("autocomplete-active"); 106 | } 107 | 108 | setCheckboxIndeterminate() { 109 | const indeterminates = document.getElementsByClassName("indeterminate"); 110 | for (let i = 0; i < indeterminates.length; i++) { 111 | indeterminates[i]["indeterminate"] = true; 112 | } 113 | } 114 | 115 | toggleTagCheckbox(event) { 116 | const element = event.target; 117 | const parent = element.parentNode; 118 | const checkBox = parent.getElementsByClassName("tagcheckbox")[0]; 119 | const tagId = checkBox.getAttribute("tagId"); 120 | const tagElement = document.getElementsByName(tagId)[0]; 121 | 122 | // indeterminate checkbox 123 | if (checkBox.className.indexOf("indeterminate") !== -1) { 124 | if (checkBox.value == 1) { 125 | checkBox.indeterminate = true; 126 | checkBox.checked = false; 127 | checkBox.value = 2; 128 | tagElement.setAttribute("value", "2"); 129 | } else if (checkBox.value == 2) { 130 | checkBox.indeterminate = false; 131 | checkBox.checked = false; 132 | checkBox.value = 0; 133 | tagElement.setAttribute("value", "0"); 134 | } else { 135 | checkBox.indeterminate = false; 136 | checkBox.checked = true; 137 | checkBox.value = 1; 138 | tagElement.setAttribute("value", "1"); 139 | } 140 | } else { 141 | if (checkBox.value == 1) { 142 | checkBox.checked = false; 143 | checkBox.value = 0; 144 | tagElement.setAttribute("value", "0"); 145 | } else { 146 | checkBox.checked = true; 147 | checkBox.value = 1; 148 | tagElement.setAttribute("value", "1"); 149 | } 150 | } 151 | return false; 152 | } 153 | 154 | setOnClickEventTagAllCheckBox() { 155 | const tagClass = document.getElementsByClassName("tag"); 156 | for (let i = 0; i < tagClass.length; i++) { 157 | this.setOnClickEvenForCheckbox( 158 | tagClass[i].getElementsByTagName("input")[0] 159 | ); 160 | this.setOnClickEvenForCheckbox( 161 | tagClass[i].getElementsByTagName("label")[0] 162 | ); 163 | } 164 | } 165 | 166 | setOnClickEvenForCheckbox(checkBox: Element) { 167 | checkBox.addEventListener("click", (event) => { 168 | this.toggleTagCheckbox(event); 169 | }); 170 | } 171 | 172 | async searchTag(query: string) { 173 | this.searchText = query; 174 | this.resultMessage = await webviewApi.postMessage({ 175 | type: "tagSearch", 176 | query: this.searchText, 177 | exclude: this.allTagsIds, 178 | } as TagSearch); 179 | 180 | this.showTagSearch(); 181 | } 182 | 183 | showTagSearch() { 184 | const searchResults = document.getElementById("autocomplete"); 185 | let createTag = true; 186 | this.removeAutocompleteItems(); 187 | this.autocompleteCurrentFocus = -1; 188 | if (this.resultMessage) { 189 | const autocompleteItems = document.createElement("div"); 190 | autocompleteItems.setAttribute("class", "autocomplete-items"); 191 | autocompleteItems.setAttribute("id", "autocomplete-list"); 192 | searchResults.appendChild(autocompleteItems); 193 | for (const tag of this.resultMessage.result) { 194 | const item = document.createElement("div"); 195 | const searchEscaped = this.searchText 196 | .trim() 197 | .replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); 198 | const regex = new RegExp("(" + searchEscaped + ")", "i"); 199 | const htmlTitle = tag.title.replace(regex, "$1"); 200 | 201 | item.setAttribute("tagId", tag.id); 202 | item.setAttribute("tagTitle", tag.title); 203 | item.innerHTML = htmlTitle; 204 | item.addEventListener("click", (event) => { 205 | this.selectTag(event); 206 | }); 207 | autocompleteItems.appendChild(item); 208 | if (tag.title.toLowerCase() === this.searchText.trim().toLowerCase()) 209 | createTag = false; 210 | } 211 | 212 | const title = this.searchText.trim(); 213 | if ( 214 | createTag === true && 215 | this.allTagsIds.indexOf("new_" + title) === -1 216 | ) { 217 | const createTag = document.createElement("div"); 218 | createTag.setAttribute("tagId", "new"); 219 | createTag.setAttribute("tagTitle", title); 220 | createTag.innerHTML = "Create tag: " + title; 221 | createTag.addEventListener("click", (event) => { 222 | this.selectTag(event); 223 | }); 224 | autocompleteItems.insertBefore(createTag, autocompleteItems.firstChild); 225 | } 226 | } 227 | } 228 | 229 | selectTag(event) { 230 | const element = event.target; 231 | const tagId = element.getAttribute("tagId"); 232 | const tagTitle = element.getAttribute("tagTitle"); 233 | this.clearSearchField(); 234 | 235 | this.addTag(tagId, tagTitle); 236 | } 237 | 238 | clearSearchField() { 239 | this.removeAutocompleteItems(); 240 | const searchResults = ( 241 | document.getElementById("query-input") 242 | ); 243 | searchResults.value = ""; 244 | this.searchText = ""; 245 | } 246 | 247 | removeAutocompleteItems() { 248 | const items = document.getElementsByClassName("autocomplete-items"); 249 | this.autocompleteCurrentFocus = -1; 250 | for (const item of items) { 251 | item.parentNode.removeChild(item); 252 | } 253 | } 254 | 255 | setFocus() { 256 | document.getElementById("query-input").focus(); 257 | } 258 | 259 | addTag(tagId: string, tagTitle: string) { 260 | const assignedTags = document.getElementById("assignedTags"); 261 | const label = document.createElement("label"); 262 | label.innerHTML = tagTitle; 263 | 264 | if (tagId == "new") { 265 | tagId = "new_" + tagTitle; 266 | } 267 | this.allTagsIds.push(tagId); 268 | 269 | const tag = htmlToElem(createTagHTML(tagId, 1, tagTitle)); 270 | assignedTags.appendChild(tag); 271 | 272 | const tagElement = assignedTags.getElementsByClassName("tag"); 273 | this.setOnClickEvenForCheckbox( 274 | tagElement[tagElement.length - 1].getElementsByTagName("input")[0] 275 | ); 276 | this.setOnClickEvenForCheckbox( 277 | tagElement[tagElement.length - 1].getElementsByTagName("label")[0] 278 | ); 279 | } 280 | } 281 | 282 | new CopytagsDialog(); 283 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "module": "commonjs", 5 | "target": "es2015", 6 | "jsx": "react", 7 | "allowJs": true, 8 | "baseUrl": "." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file is used to build the plugin file (.jpl) and plugin info (.json). It 3 | // is recommended not to edit this file as it would be overwritten when updating 4 | // the plugin framework. If you do make some changes, consider using an external 5 | // JS file and requiring it here to minimize the changes. That way when you 6 | // update, you can easily restore the functionality you've added. 7 | // ----------------------------------------------------------------------------- 8 | 9 | /* eslint-disable no-console */ 10 | 11 | const path = require('path'); 12 | const crypto = require('crypto'); 13 | const fs = require('fs-extra'); 14 | const chalk = require('chalk'); 15 | const CopyPlugin = require('copy-webpack-plugin'); 16 | const tar = require('tar'); 17 | const glob = require('glob'); 18 | const execSync = require('child_process').execSync; 19 | const allPossibleCategories = require('@joplin/lib/pluginCategories.json'); 20 | 21 | const rootDir = path.resolve(__dirname); 22 | const userConfigFilename = './plugin.config.json'; 23 | const userConfigPath = path.resolve(rootDir, userConfigFilename); 24 | const distDir = path.resolve(rootDir, 'dist'); 25 | const srcDir = path.resolve(rootDir, 'src'); 26 | const publishDir = path.resolve(rootDir, 'publish'); 27 | 28 | const userConfig = { extraScripts: [], ...(fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {}) }; 29 | 30 | const manifestPath = `${srcDir}/manifest.json`; 31 | const packageJsonPath = `${rootDir}/package.json`; 32 | const allPossibleScreenshotsType = ['jpg', 'jpeg', 'png', 'gif', 'webp']; 33 | const manifest = readManifest(manifestPath); 34 | const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`); 35 | const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`); 36 | 37 | const { builtinModules } = require('node:module'); 38 | 39 | // Webpack5 doesn't polyfill by default and displays a warning when attempting to require() built-in 40 | // node modules. Set these to false to prevent Webpack from warning about not polyfilling these modules. 41 | // We don't need to polyfill because the plugins run in Electron's Node environment. 42 | const moduleFallback = {}; 43 | for (const moduleName of builtinModules) { 44 | moduleFallback[moduleName] = false; 45 | } 46 | 47 | const getPackageJson = () => { 48 | return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); 49 | }; 50 | 51 | function validatePackageJson() { 52 | const content = getPackageJson(); 53 | if (!content.name || content.name.indexOf('joplin-plugin-') !== 0) { 54 | console.warn(chalk.yellow(`WARNING: To publish the plugin, the package name should start with "joplin-plugin-" (found "${content.name}") in ${packageJsonPath}`)); 55 | } 56 | 57 | if (!content.keywords || content.keywords.indexOf('joplin-plugin') < 0) { 58 | console.warn(chalk.yellow(`WARNING: To publish the plugin, the package keywords should include "joplin-plugin" (found "${JSON.stringify(content.keywords)}") in ${packageJsonPath}`)); 59 | } 60 | 61 | if (content.scripts && content.scripts.postinstall) { 62 | console.warn(chalk.yellow(`WARNING: package.json contains a "postinstall" script. It is recommended to use a "prepare" script instead so that it is executed before publish. In ${packageJsonPath}`)); 63 | } 64 | } 65 | 66 | function fileSha256(filePath) { 67 | const content = fs.readFileSync(filePath); 68 | return crypto.createHash('sha256').update(content).digest('hex'); 69 | } 70 | 71 | function currentGitInfo() { 72 | try { 73 | let branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' }).toString().trim(); 74 | const commit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim(); 75 | if (branch === 'HEAD') branch = 'master'; 76 | return `${branch}:${commit}`; 77 | } catch (error) { 78 | const messages = error.message ? error.message.split('\n') : ['']; 79 | console.info(chalk.cyan('Could not get git commit (not a git repo?):', messages[0].trim())); 80 | console.info(chalk.cyan('Git information will not be stored in plugin info file')); 81 | return ''; 82 | } 83 | } 84 | 85 | function validateCategories(categories) { 86 | if (!categories) return null; 87 | if ((categories.length !== new Set(categories).size)) throw new Error('Repeated categories are not allowed'); 88 | // eslint-disable-next-line github/array-foreach -- Old code before rule was applied 89 | categories.forEach(category => { 90 | if (!allPossibleCategories.map(category => { return category.name; }).includes(category)) throw new Error(`${category} is not a valid category. Please make sure that the category name is lowercase. Valid categories are: \n${allPossibleCategories.map(category => { return category.name; })}\n`); 91 | }); 92 | } 93 | 94 | function validateScreenshots(screenshots) { 95 | if (!screenshots) return null; 96 | for (const screenshot of screenshots) { 97 | if (!screenshot.src) throw new Error('You must specify a src for each screenshot'); 98 | 99 | // Avoid attempting to download and verify URL screenshots. 100 | if (screenshot.src.startsWith('https://') || screenshot.src.startsWith('http://')) { 101 | continue; 102 | } 103 | 104 | const screenshotType = screenshot.src.split('.').pop(); 105 | if (!allPossibleScreenshotsType.includes(screenshotType)) throw new Error(`${screenshotType} is not a valid screenshot type. Valid types are: \n${allPossibleScreenshotsType}\n`); 106 | 107 | const screenshotPath = path.resolve(rootDir, screenshot.src); 108 | 109 | // Max file size is 1MB 110 | const fileMaxSize = 1024; 111 | const fileSize = fs.statSync(screenshotPath).size / 1024; 112 | if (fileSize > fileMaxSize) throw new Error(`Max screenshot file size is ${fileMaxSize}KB. ${screenshotPath} is ${fileSize}KB`); 113 | } 114 | } 115 | 116 | function readManifest(manifestPath) { 117 | const content = fs.readFileSync(manifestPath, 'utf8'); 118 | const output = JSON.parse(content); 119 | if (!output.id) throw new Error(`Manifest plugin ID is not set in ${manifestPath}`); 120 | validateCategories(output.categories); 121 | validateScreenshots(output.screenshots); 122 | return output; 123 | } 124 | 125 | function createPluginArchive(sourceDir, destPath) { 126 | const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true, windowsPathsNoEscape: true }) 127 | .map(f => f.substr(sourceDir.length + 1)); 128 | 129 | if (!distFiles.length) throw new Error('Plugin archive was not created because the "dist" directory is empty'); 130 | fs.removeSync(destPath); 131 | 132 | tar.create( 133 | { 134 | strict: true, 135 | portable: true, 136 | file: destPath, 137 | cwd: sourceDir, 138 | sync: true, 139 | }, 140 | distFiles, 141 | ); 142 | 143 | console.info(chalk.cyan(`Plugin archive has been created in ${destPath}`)); 144 | } 145 | 146 | const writeManifest = (manifestPath, content) => { 147 | fs.writeFileSync(manifestPath, JSON.stringify(content, null, '\t'), 'utf8'); 148 | }; 149 | 150 | function createPluginInfo(manifestPath, destPath, jplFilePath) { 151 | const contentText = fs.readFileSync(manifestPath, 'utf8'); 152 | const content = JSON.parse(contentText); 153 | content._publish_hash = `sha256:${fileSha256(jplFilePath)}`; 154 | content._publish_commit = currentGitInfo(); 155 | writeManifest(destPath, content); 156 | } 157 | 158 | function onBuildCompleted() { 159 | try { 160 | fs.removeSync(path.resolve(publishDir, 'index.js')); 161 | createPluginArchive(distDir, pluginArchiveFilePath); 162 | createPluginInfo(manifestPath, pluginInfoFilePath, pluginArchiveFilePath); 163 | validatePackageJson(); 164 | } catch (error) { 165 | console.error(chalk.red(error.message)); 166 | } 167 | } 168 | 169 | const baseConfig = { 170 | mode: 'production', 171 | target: 'node', 172 | stats: 'errors-only', 173 | module: { 174 | rules: [ 175 | { 176 | test: /\.tsx?$/, 177 | use: 'ts-loader', 178 | exclude: /node_modules/, 179 | }, 180 | ], 181 | }, 182 | }; 183 | 184 | const pluginConfig = { ...baseConfig, entry: './src/index.ts', 185 | resolve: { 186 | alias: { 187 | api: path.resolve(__dirname, 'api'), 188 | }, 189 | fallback: moduleFallback, 190 | // JSON files can also be required from scripts so we include this. 191 | // https://github.com/joplin/plugin-bibtex/pull/2 192 | extensions: ['.js', '.tsx', '.ts', '.json'], 193 | }, 194 | output: { 195 | filename: 'index.js', 196 | path: distDir, 197 | }, 198 | plugins: [ 199 | new CopyPlugin({ 200 | patterns: [ 201 | { 202 | from: '**/*', 203 | context: path.resolve(__dirname, 'src'), 204 | to: path.resolve(__dirname, 'dist'), 205 | globOptions: { 206 | ignore: [ 207 | // All TypeScript files are compiled to JS and 208 | // already copied into /dist so we don't copy them. 209 | '**/*.ts', 210 | '**/*.tsx', 211 | ], 212 | }, 213 | }, 214 | ], 215 | }), 216 | ] }; 217 | 218 | const extraScriptConfig = { ...baseConfig, resolve: { 219 | alias: { 220 | api: path.resolve(__dirname, 'api'), 221 | }, 222 | fallback: moduleFallback, 223 | extensions: ['.js', '.tsx', '.ts', '.json'], 224 | } }; 225 | 226 | const createArchiveConfig = { 227 | stats: 'errors-only', 228 | entry: './dist/index.js', 229 | resolve: { 230 | fallback: moduleFallback, 231 | }, 232 | output: { 233 | filename: 'index.js', 234 | path: publishDir, 235 | }, 236 | plugins: [{ 237 | apply(compiler) { 238 | compiler.hooks.done.tap('archiveOnBuildListener', onBuildCompleted); 239 | }, 240 | }], 241 | }; 242 | 243 | function resolveExtraScriptPath(name) { 244 | const relativePath = `./src/${name}`; 245 | 246 | const fullPath = path.resolve(`${rootDir}/${relativePath}`); 247 | if (!fs.pathExistsSync(fullPath)) throw new Error(`Could not find extra script: "${name}" at "${fullPath}"`); 248 | 249 | const s = name.split('.'); 250 | s.pop(); 251 | const nameNoExt = s.join('.'); 252 | 253 | return { 254 | entry: relativePath, 255 | output: { 256 | filename: `${nameNoExt}.js`, 257 | path: distDir, 258 | library: 'default', 259 | libraryTarget: 'commonjs', 260 | libraryExport: 'default', 261 | }, 262 | }; 263 | } 264 | 265 | function buildExtraScriptConfigs(userConfig) { 266 | if (!userConfig.extraScripts.length) return []; 267 | 268 | const output = []; 269 | 270 | for (const scriptName of userConfig.extraScripts) { 271 | const scriptPaths = resolveExtraScriptPath(scriptName); 272 | output.push({ ...extraScriptConfig, entry: scriptPaths.entry, 273 | output: scriptPaths.output }); 274 | } 275 | 276 | return output; 277 | } 278 | 279 | const webviewConfig = Object.assign({}, baseConfig, { 280 | entry: './src/webview.ts', 281 | target: 'web', 282 | output: { 283 | filename: 'webview.js', 284 | path: distDir, 285 | }, 286 | resolve: { 287 | extensions: ['.tsx', '.ts', '.js'], 288 | }, 289 | plugins: [ 290 | new CopyPlugin({ 291 | patterns: [ 292 | { 293 | from: '**/*', 294 | context: path.resolve(__dirname, 'src/'), 295 | to: path.resolve(__dirname, 'dist'), 296 | globOptions: { 297 | ignore: [ 298 | // All TypeScript files are compiled to JS and 299 | // already copied into /dist so we don't copy them. 300 | '**/*.ts', 301 | '**/*.tsx', 302 | ], 303 | }, 304 | }, 305 | ], 306 | }), 307 | ] 308 | }); 309 | 310 | const increaseVersion = version => { 311 | try { 312 | const s = version.split('.'); 313 | const d = Number(s[s.length - 1]) + 1; 314 | s[s.length - 1] = `${d}`; 315 | return s.join('.'); 316 | } catch (error) { 317 | error.message = `Could not parse version number: ${version}: ${error.message}`; 318 | throw error; 319 | } 320 | }; 321 | 322 | const updateVersion = () => { 323 | const packageJson = getPackageJson(); 324 | packageJson.version = increaseVersion(packageJson.version); 325 | fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); 326 | 327 | const manifest = readManifest(manifestPath); 328 | manifest.version = increaseVersion(manifest.version); 329 | writeManifest(manifestPath, manifest); 330 | 331 | if (packageJson.version !== manifest.version) { 332 | console.warn(chalk.yellow(`Version numbers have been updated but they do not match: package.json (${packageJson.version}), manifest.json (${manifest.version}). Set them to the required values to get them in sync.`)); 333 | } 334 | }; 335 | 336 | function main(environ) { 337 | const configName = environ['joplin-plugin-config']; 338 | if (!configName) throw new Error('A config file must be specified via the --joplin-plugin-config flag'); 339 | 340 | // Webpack configurations run in parallel, while we need them to run in 341 | // sequence, and to do that it seems the only way is to run webpack multiple 342 | // times, with different config each time. 343 | 344 | const configs = { 345 | // Builds the main src/index.ts and copy the extra content from /src to 346 | // /dist including scripts, CSS and any other asset. 347 | buildMain: [pluginConfig], 348 | 349 | // Builds the extra scripts as defined in plugin.config.json. When doing 350 | // so, some JavaScript files that were copied in the previous might be 351 | // overwritten here by the compiled version. This is by design. The 352 | // result is that JS files that don't need compilation, are simply 353 | // copied to /dist, while those that do need it are correctly compiled. 354 | buildExtraScripts: buildExtraScriptConfigs(userConfig), 355 | 356 | // Ths config is for creating the .jpl, which is done via the plugin, so 357 | // it doesn't actually need an entry and output, however webpack won't 358 | // run without this. So we give it an entry that we know is going to 359 | // exist and output in the publish dir. Then the plugin will delete this 360 | // temporary file before packaging the plugin. 361 | createArchive: [createArchiveConfig], 362 | 363 | // Build scripts for web 364 | webview: [webviewConfig], 365 | }; 366 | 367 | // If we are running the first config step, we clean up and create the build 368 | // directories. 369 | if (configName === 'buildMain') { 370 | fs.removeSync(distDir); 371 | fs.removeSync(publishDir); 372 | fs.mkdirpSync(publishDir); 373 | } 374 | 375 | if (configName === 'updateVersion') { 376 | updateVersion(); 377 | return []; 378 | } 379 | 380 | return configs[configName]; 381 | } 382 | 383 | 384 | module.exports = (env) => { 385 | let exportedConfigs = []; 386 | 387 | try { 388 | exportedConfigs = main(env); 389 | } catch (error) { 390 | console.error(error.message); 391 | process.exit(1); 392 | } 393 | 394 | if (!exportedConfigs.length) { 395 | // Nothing to do - for example where there are no external scripts to 396 | // compile. 397 | process.exit(0); 398 | } 399 | 400 | return exportedConfigs; 401 | }; 402 | --------------------------------------------------------------------------------