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 | *
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 |
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 |
--------------------------------------------------------------------------------