├── src ├── tools │ ├── outline │ │ ├── style.css │ │ ├── types.ts │ │ └── worker.js │ ├── backlinks │ │ ├── style.css │ │ ├── worker.js │ │ ├── view.tsx │ │ └── index.ts │ ├── dailynote │ │ ├── style.css │ │ └── worker.js │ ├── randomnote │ │ ├── style.css │ │ ├── worker.js │ │ └── randomnotetool.ts │ ├── recentnotes │ │ ├── style.css │ │ ├── worker.js │ │ ├── view.tsx │ │ └── index.ts │ ├── shortcuts │ │ ├── style.css │ │ ├── types.ts │ │ ├── worker.js │ │ ├── overlay.tsx │ │ └── index.ts │ ├── scratchpad │ │ ├── style.css │ │ ├── worker.js │ │ └── index.ts │ └── tool.ts ├── types │ ├── drag.ts │ ├── outline.ts │ ├── toolinfo.ts │ ├── themetype.ts │ └── link.ts ├── services │ ├── linkgraph │ │ ├── linkgraphnode.ts │ │ ├── linkgraphupdatequeue.ts │ │ └── linkgraphservice.ts │ ├── toolbar │ │ ├── toolbarworker.js │ │ ├── toolbar.css │ │ ├── toolbarservice.ts │ │ └── toolbarview.tsx │ ├── renderer │ │ └── rendererservice.ts │ ├── datetime │ │ └── datetimeservice.ts │ ├── notedialog │ │ ├── notedialogworker.js │ │ ├── notedialog.css │ │ ├── notedialogview.tsx │ │ └── notedialogservice.ts │ └── servicepool.ts ├── repo │ ├── platformrepo.ts │ ├── timerrepo.ts │ └── joplinrepo.ts ├── utils │ └── debouncer.ts ├── manifest.json ├── views │ ├── fixedheightcontainer.tsx │ ├── inlineiconbutton.tsx │ ├── linklist.tsx │ ├── notelink.tsx │ ├── expandbutton.tsx │ ├── primarybutton.tsx │ ├── overlay.tsx │ ├── smalliconbutton.tsx │ ├── linkitem.tsx │ └── resizablecontainer.tsx ├── sandbox │ ├── contentScriptCM5.js │ ├── codemirror5manager.js │ ├── app.tsx │ ├── contentScriptCM6.ts │ ├── dddot.js │ └── section.tsx ├── index.ts ├── i18n │ └── index.tsx ├── models │ └── linklistmodel.ts ├── locales │ ├── zh_CN.json │ ├── zh_TW.json │ ├── en.json │ └── fr_FR.json ├── theme │ └── codemirror │ │ └── blackboard.css ├── styles │ └── css-tooltip.css └── panel.css ├── config.css ├── docs ├── sort-text.png ├── screenshot1.png └── toggle-visibility.png ├── tailwind.config.js ├── .npmignore ├── api ├── index.ts ├── Global.d.ts ├── JoplinFilters.d.ts ├── JoplinViewsToolbarButtons.d.ts ├── JoplinViewsMenuItems.d.ts ├── JoplinViewsMenus.d.ts ├── JoplinWindow.d.ts ├── JoplinInterop.d.ts ├── JoplinClipboard.d.ts ├── JoplinViews.d.ts ├── JoplinPlugins.d.ts ├── JoplinViewsNoteList.d.ts ├── JoplinContentScripts.d.ts ├── JoplinViewsPanels.d.ts ├── Joplin.d.ts ├── JoplinViewsDialogs.d.ts ├── JoplinSettings.d.ts ├── JoplinImaging.d.ts ├── JoplinWorkspace.d.ts ├── JoplinCommands.d.ts ├── JoplinViewsEditor.d.ts └── JoplinData.d.ts ├── .eslintignore ├── plugin.config.json ├── .gitignore ├── Makefile ├── tsconfig.json ├── bower.json ├── jest.config.js ├── tests ├── datetimeservice.test.ts ├── rendererservice.test.ts ├── themetype.test.ts ├── recentnotes.test.ts ├── joplinservice.test.ts ├── panel.test.ts ├── tool.test.ts ├── linkgraphupdatequeue.test.ts ├── dailynotetool.test.ts ├── shortcuts.test.ts ├── linklistmodel.test.ts ├── randomnote.test.ts └── markdownparserservice.test.ts ├── .github └── workflows │ └── build.yml ├── .eslintrc ├── package.json ├── GENERATOR_DOC.md └── README.md /src/tools/outline/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tools/backlinks/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tools/dailynote/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tools/randomnote/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config.css: -------------------------------------------------------------------------------- 1 | @tailwind components; 2 | @tailwind utilities; -------------------------------------------------------------------------------- /src/tools/recentnotes/style.css: -------------------------------------------------------------------------------- 1 | #dddot-recent-notes-content { 2 | flex-grow: 1; 3 | } -------------------------------------------------------------------------------- /docs/sort-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlau/joplin-plugin-dddot/HEAD/docs/sort-text.png -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{html,js,ts,tsx}"], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlau/joplin-plugin-dddot/HEAD/docs/screenshot1.png -------------------------------------------------------------------------------- /src/tools/shortcuts/style.css: -------------------------------------------------------------------------------- 1 | 2 | #dddot-shortcuts-content { 3 | min-height: 50px; 4 | } 5 | 6 | -------------------------------------------------------------------------------- /src/tools/dailynote/worker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function dailynoteWorker() { 3 | } 4 | -------------------------------------------------------------------------------- /src/tools/randomnote/worker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function randomnoteWorker() { 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.md 2 | !README.md 3 | /*.jpl 4 | /api 5 | /src 6 | /dist 7 | tsconfig.json 8 | webpack.config.js 9 | -------------------------------------------------------------------------------- /docs/toggle-visibility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benlau/joplin-plugin-dddot/HEAD/docs/toggle-visibility.png -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import type Joplin from './Joplin'; 2 | 3 | declare const joplin: Joplin; 4 | 5 | export default joplin; 6 | -------------------------------------------------------------------------------- /src/tools/outline/types.ts: -------------------------------------------------------------------------------- 1 | export enum OutlineToolResizeMode { 2 | Manual = "manual", 3 | Auto = "auto", 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | jest.config.js 4 | babel.config.js 5 | webpack.config.js 6 | src/libs 7 | bower_components 8 | api -------------------------------------------------------------------------------- /plugin.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extraScripts": [ 3 | "sandbox/app.tsx", 4 | "sandbox/contentScriptCM6.ts", 5 | "sandbox/contentScriptCM5.js" 6 | ] 7 | } -------------------------------------------------------------------------------- /src/types/drag.ts: -------------------------------------------------------------------------------- 1 | export enum DragItemType { 2 | Section = "dddot.section", 3 | Link = "dddot.link", 4 | Shortcut = "dddot.shortcut" 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | publish/ 4 | src/libs 5 | bower_components/ 6 | .DS_Store 7 | 8 | *.swp 9 | 10 | src/styles/tailwind.css 11 | -------------------------------------------------------------------------------- /src/services/linkgraph/linkgraphnode.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "../../types/link"; 2 | 3 | export default class LinkGraphNode { 4 | id: string; 5 | 6 | backlinks: Link[]; 7 | } 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LIBS_PATH=src/libs 2 | 3 | .PHONY: copy-libs 4 | copy-libs: 5 | mkdir -p ${LIBS_PATH} 6 | cp bower_components/codemirror/lib/* ${LIBS_PATH} 7 | 8 | .PHONY: install 9 | install: 10 | npm install 11 | bower install 12 | make copy-libs 13 | 14 | -------------------------------------------------------------------------------- /src/repo/platformrepo.ts: -------------------------------------------------------------------------------- 1 | import { platform } from "os"; 2 | 3 | export default class PlatformRepo { 4 | isLinux() { 5 | return platform() === "linux"; 6 | } 7 | 8 | isMac() { 9 | return platform() === "darwin"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/debouncer.ts: -------------------------------------------------------------------------------- 1 | export function debouncer(func: Function, wait: number) { 2 | let timeout: NodeJS.Timeout; 3 | return (...args: any[]) => { 4 | clearTimeout(timeout); 5 | timeout = setTimeout(() => func.apply(this, args), wait); 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/outline.ts: -------------------------------------------------------------------------------- 1 | export enum OutlineType { 2 | Heading, 3 | Link 4 | } 5 | 6 | export type OutlineItem = { 7 | title: string; 8 | level: number; 9 | slug: string; 10 | lineno: number; 11 | type: OutlineType; 12 | link?: string; 13 | children: OutlineItem[]; 14 | }; 15 | -------------------------------------------------------------------------------- /api/Global.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import Joplin from './Joplin'; 3 | /** 4 | * @ignore 5 | */ 6 | /** 7 | * @ignore 8 | */ 9 | export default class Global { 10 | private joplin_; 11 | constructor(implementation: any, plugin: Plugin, store: any); 12 | get joplin(): Joplin; 13 | get process(): any; 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "module": "commonjs", 5 | "target": "es2019", 6 | "jsx": "react", 7 | "allowJs": true, 8 | "baseUrl": ".", 9 | "experimentalDecorators": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true 12 | }, 13 | "exclude": [ 14 | "./tests" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/services/toolbar/toolbarworker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function toolbarWorker() { 3 | 4 | const refresh = (toolbarItems) => { 5 | App.setSectionViewProp("toolbar", "toolbarItems", toolbarItems); 6 | }; 7 | 8 | const toolbarItems = await DDDot.postMessage({ type: "toolbar.service.onReady" }); 9 | refresh(toolbarItems); 10 | } 11 | -------------------------------------------------------------------------------- /api/JoplinFilters.d.ts: -------------------------------------------------------------------------------- 1 | import { FilterHandler } from '../../../eventManager'; 2 | /** 3 | * @ignore 4 | * 5 | * Not sure if it's the best way to hook into the app 6 | * so for now disable filters. 7 | */ 8 | export default class JoplinFilters { 9 | on(name: string, callback: FilterHandler): Promise; 10 | off(name: string, callback: FilterHandler): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/types/toolinfo.ts: -------------------------------------------------------------------------------- 1 | export type ToolButton = { 2 | tooltip: string; 3 | icon: string; 4 | message: any; 5 | } 6 | 7 | export type ToolInfo = { 8 | key: string; 9 | title: string; 10 | containerId: string; 11 | contentId: string; 12 | viewComponentName?: string; 13 | enabled: boolean; 14 | hasView: boolean; 15 | extraButtons: ToolButton[]; 16 | } 17 | -------------------------------------------------------------------------------- /src/tools/scratchpad/style.css: -------------------------------------------------------------------------------- 1 | 2 | #dddot-scratchpad-tool-content .CodeMirror { 3 | border: 1px solid #000; 4 | border-radius: 2px; 5 | margin-left: 4px; 6 | margin-right: 4px; 7 | } 8 | 9 | #dddot-scratchpad-tool-content .CodeMirror-wrap pre { 10 | word-break: break-word; 11 | } 12 | 13 | .dddot-scratchpad-handle { 14 | width: 100%; 15 | height: 20px; 16 | line-height: 20px; 17 | text-align: center; 18 | cursor: ns-resize; 19 | } -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 1, 3 | "id": "joplin-plugin-dddot", 4 | "app_min_version": "2.6", 5 | "version": "0.4.4", 6 | "name": "Joplin DDDot", 7 | "description": "Recent notes, shortcuts, scratchpad, and .... in a single sidebar.", 8 | "author": "Ben Lau", 9 | "homepage_url": "https://github.com/benlau/joplin-plugin-dddot", 10 | "repository_url": "https://github.com/benlau/joplin-plugin-dddot", 11 | "keywords": [] 12 | } -------------------------------------------------------------------------------- /src/tools/scratchpad/worker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function scratchpadWorker() { 3 | const refresh = async (content, height) => { 4 | App.setSectionViewProp("scratchpad", "content", content); 5 | App.setSectionViewProp("scratchpad", "height", height); 6 | }; 7 | 8 | const { content, height } = await DDDot.postMessage({ 9 | type: "scratchpad.onReady", 10 | }); 11 | refresh(content, height); 12 | } 13 | -------------------------------------------------------------------------------- /src/tools/backlinks/worker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function backlinksWorker() { 3 | const refresh = (links) => { 4 | App.setSectionViewProp("backlinks", "links", links); 5 | }; 6 | 7 | DDDot.onMessage("backlinks.refresh", (message) => { 8 | refresh(message.links); 9 | }); 10 | 11 | const response = await DDDot.postMessage({ 12 | type: "backlinks.onReady", 13 | }); 14 | refresh(response); 15 | } 16 | -------------------------------------------------------------------------------- /src/tools/recentnotes/worker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function recentnotesWorker() { 3 | const refresh = (links) => { 4 | App.setSectionViewProp("recentnotes", "links", links); 5 | }; 6 | 7 | DDDot.onMessage("recentnotes.refresh", (message) => { 8 | refresh(message.links); 9 | }); 10 | 11 | const response = await DDDot.postMessage({ 12 | type: "recentnotes.onReady", 13 | }); 14 | refresh(response); 15 | } 16 | -------------------------------------------------------------------------------- /src/views/fixedheightcontainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | children?: React.ReactNode; 5 | height: number; 6 | } 7 | 8 | export function FixedHeightContainer(props: Props) { 9 | const { 10 | children, 11 | height, 12 | } = props; 13 | 14 | return ( 15 |
18 | {children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joplin-plugin-joplin-dddot", 3 | "description": "", 4 | "main": "", 5 | "authors": [ 6 | "Ben Lau " 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "joplin-plugin" 11 | ], 12 | "homepage": "", 13 | "private": true, 14 | "ignore": [ 15 | "**/.*", 16 | "node_modules", 17 | "bower_components", 18 | "test", 19 | "tests" 20 | ], 21 | "dependencies": { 22 | "codemirror": "^5.65.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | // moduleDirectories: ["node_modules", "."], 6 | modulePathIgnorePatterns: ["bower_components"], 7 | transform: { 8 | '^.+\.(ts|tsx|js)$': 'ts-jest', 9 | }, 10 | "moduleNameMapper": { 11 | "^api$": "/api", 12 | "^api/types$": "/api/types" 13 | }, 14 | "globals": { 15 | "joplin": true 16 | } 17 | }; -------------------------------------------------------------------------------- /src/tools/shortcuts/types.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "../../types/link"; 2 | 3 | export type ShortcutsStorage = { 4 | version: 1, 5 | shortcuts: Link[], 6 | } 7 | 8 | export class ShortcutsStorageValidator { 9 | validateLink(link: Link) { 10 | return link.id !== undefined 11 | && link.title !== undefined 12 | && link.type !== undefined; 13 | } 14 | 15 | validate(storage: any) { 16 | try { 17 | return storage.version === 1 && storage.shortcuts.every(this.validateLink); 18 | } catch (e) { 19 | return false; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/services/renderer/rendererservice.ts: -------------------------------------------------------------------------------- 1 | export default class RendererService { 2 | renderInlineMarkdownLink(id: string, title: string) { 3 | let escapedTitle = this.escapeInline(title); 4 | escapedTitle = this.replaceAll(escapedTitle, "[", "\\["); 5 | escapedTitle = this.replaceAll(escapedTitle, "]", "\\]"); 6 | return `[${escapedTitle}](:/${id})`; 7 | } 8 | 9 | replaceAll(text: string, from: string, to: string) { 10 | return text.split(from).join(to); 11 | } 12 | 13 | escapeInline(text: string) { 14 | return this.replaceAll(text, "\"", """); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/sandbox/contentScriptCM5.js: -------------------------------------------------------------------------------- 1 | function plugin(context) { 2 | if (context.cm6) { 3 | return; 4 | } 5 | 6 | context.defineExtension("dddot.contentScript.scrollToLine", function contentScript(lineno) { 7 | // Ref&Credit: joplin-outline project 8 | // https://github.com/cqroot/joplin-outline 9 | this.scrollTo(null, this.charCoords({ line: lineno, ch: 0 }, "local").top); 10 | this.scrollTo(null, this.charCoords({ line: lineno, ch: 0 }, "local").top); 11 | }); 12 | } 13 | 14 | module.exports = { 15 | default() { 16 | return { 17 | plugin, 18 | }; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/tools/shortcuts/worker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function shortcutsWorker() { 3 | const refresh = (links) => { 4 | App.setSectionViewProp("shortcuts", "links", links); 5 | App.setSectionViewProp("shortcutsOverlay", "links", links); 6 | }; 7 | 8 | DDDot.onMessage("shortcuts.showOverlay", () => { 9 | App.setOverlayVisible("shortcutsOverlay", true); 10 | }); 11 | 12 | DDDot.onMessage("shortcuts.refresh", (message) => { 13 | refresh(message.links); 14 | }); 15 | 16 | const response = await DDDot.postMessage({ type: "shortcuts.onReady" }); 17 | refresh(response); 18 | } 19 | -------------------------------------------------------------------------------- /src/sandbox/codemirror5manager.js: -------------------------------------------------------------------------------- 1 | class CodeMirror5Manager { 2 | static instance = null; 3 | 4 | constructor(theme) { 5 | this.theme = theme; 6 | } 7 | 8 | init(theme) { 9 | this.theme = theme; 10 | if (CodeMirror5Manager.instance === null) { 11 | CodeMirror5Manager.instance = this; 12 | } 13 | } 14 | 15 | get themeName() { 16 | return this.theme.isDarkTheme ? "blackboard" : "default"; 17 | } 18 | 19 | create(textArea, options) { 20 | return CodeMirror.fromTextArea(textArea, options); 21 | } 22 | } 23 | 24 | window.CodeMirror5Manager = CodeMirror5Manager; 25 | -------------------------------------------------------------------------------- /src/services/datetime/datetimeservice.ts: -------------------------------------------------------------------------------- 1 | export default class DateTimeService { 2 | normalizeDate(date: Date, startHour: number): Date { 3 | if (date.getHours() < startHour) { 4 | const newDate = new Date(date.getTime()); 5 | newDate.setDate(date.getDate() - 1); 6 | newDate.setHours(0); 7 | newDate.setMinutes(0); 8 | newDate.setMilliseconds(0); 9 | return newDate; 10 | } 11 | return date; 12 | } 13 | 14 | getNormalizedToday(startHour: number) { 15 | const today = new Date(); 16 | return this.normalizeDate(today, startHour); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/datetimeservice.test.ts: -------------------------------------------------------------------------------- 1 | import DateTimeService from "../src/services/datetime/datetimeservice"; 2 | 3 | test("normalizeDate", async () => { 4 | const dateTimeService = new DateTimeService(); 5 | 6 | const today = new Date(2022, 8, 2); 7 | 8 | const yesterday = new Date(today.getTime()); 9 | yesterday.setDate(today.getDate() - 1); 10 | yesterday.setHours(0); 11 | yesterday.setMinutes(0); 12 | yesterday.setMilliseconds(0); 13 | 14 | expect( 15 | dateTimeService.normalizeDate(today, 0), 16 | ).toStrictEqual(today); 17 | 18 | expect( 19 | dateTimeService.normalizeDate(today, 8), 20 | ).toStrictEqual(yesterday); 21 | }); 22 | -------------------------------------------------------------------------------- /src/repo/timerrepo.ts: -------------------------------------------------------------------------------- 1 | export default class TimerRepo { 2 | static DEFAULT_TIMEOUT = 3000; 3 | 4 | static DEFAULT_INTERVAL = 100; 5 | 6 | async sleep(ms: number) { 7 | await new Promise((resolve) => { 8 | setTimeout(resolve, ms); 9 | }); 10 | } 11 | 12 | async tryWaitUntilTimeout( 13 | condition: () => Promise, 14 | timeout: number = TimerRepo.DEFAULT_TIMEOUT, 15 | ) { 16 | const start = Date.now(); 17 | while (!await condition()) { 18 | if (Date.now() - start > timeout) { 19 | throw new Error("Timeout"); 20 | } 21 | await this.sleep(TimerRepo.DEFAULT_INTERVAL); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/rendererservice.test.ts: -------------------------------------------------------------------------------- 1 | import RendererService from "../src/services/renderer/rendererservice"; 2 | 3 | jest.mock("../src/repo/joplinrepo"); 4 | jest.mock("../src/services/joplin/joplinservice"); 5 | 6 | test("escapeInline", () => { 7 | const service = new RendererService(); 8 | 9 | expect(service.escapeInline("\"\"")).toBe(""""); 10 | }); 11 | 12 | test("renderInlineMarkdownLink", () => { 13 | const service = new RendererService(); 14 | 15 | expect(service.renderInlineMarkdownLink("0", "title")).toBe("[title](:/0)"); 16 | 17 | expect(service.renderInlineMarkdownLink("0", "[title]")).toBe("[\\[title\\]](:/0)"); 18 | 19 | expect(service.renderInlineMarkdownLink("0", "\"title\"")).toBe("["title"](:/0)"); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/themetype.test.ts: -------------------------------------------------------------------------------- 1 | import ThemeType from "../src/types/themetype"; 2 | 3 | test("isDarkTheme", () => { 4 | expect(ThemeType.isDarkTheme(ThemeType.THEME_LIGHT)).toBe(false); 5 | expect(ThemeType.isDarkTheme(ThemeType.THEME_UNKNOWN)).toBe(false); 6 | 7 | expect(ThemeType.isDarkTheme(ThemeType.THEME_DARK)).toBe(true); 8 | expect(ThemeType.isDarkTheme(ThemeType.THEME_OLED_DARK)).toBe(true); 9 | }); 10 | 11 | test("isLightTheme", () => { 12 | expect(ThemeType.isLightTheme(ThemeType.THEME_LIGHT)).toBe(true); 13 | expect(ThemeType.isLightTheme(ThemeType.THEME_UNKNOWN)).toBe(true); 14 | 15 | expect(ThemeType.isLightTheme(ThemeType.THEME_DARK)).toBe(false); 16 | expect(ThemeType.isLightTheme(ThemeType.THEME_OLED_DARK)).toBe(false); 17 | }); 18 | -------------------------------------------------------------------------------- /src/views/inlineiconbutton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cntl from "cntl"; 3 | 4 | export function InlineIconButton(props: { 5 | icon: string; 6 | onClick: () => void; 7 | }) { 8 | const onClick = React.useCallback((e: MouseEvent) => { 9 | e.stopPropagation(); 10 | e.preventDefault(); 11 | props.onClick(); 12 | }, [props]); 13 | 14 | return ( 15 |
23 |
24 | 25 |
26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/views/linklist.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LinkItem } from "./linkitem"; 3 | import { Link } from "../types/link"; 4 | 5 | type Props = { 6 | links: Link[]; 7 | onClick: (link: Link) => void; 8 | onContextMenu: (link: Link) => void; 9 | } 10 | 11 | export function LinkList(props: Props) { 12 | return ( 13 |
14 | { 15 | props.links.map((link) => ( 16 | 17 | 20 | 21 | )) 22 | } 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/tools/backlinks/view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LinkList } from "../../views/linklist"; 3 | import { Link } from "../../types/link"; 4 | 5 | type Props = { 6 | links?: Link[]; 7 | } 8 | 9 | export function BacklinksView(props: Props) { 10 | const onClick = React.useCallback((link: Link) => { 11 | DDDot.postMessage({ 12 | type: "dddot.openNote", 13 | noteId: link.id, 14 | }); 15 | }, []); 16 | 17 | const onContextMenu = React.useCallback((link: Link) => { 18 | DDDot.postMessage({ 19 | type: "backlinks.tool.openNoteDetailDialog", 20 | noteId: link.id, 21 | }); 22 | }, []); 23 | 24 | return ( 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/services/toolbar/toolbar.css: -------------------------------------------------------------------------------- 1 | .dddot-toolbar-item { 2 | margin-left: 4px; 3 | margin-right: 4px; 4 | margin-top: 4px; 5 | margin-bottom: 4px; 6 | } 7 | 8 | .dddot-toolbar-item-icon { 9 | font-size: 16px; 10 | color: var(--joplin-color); 11 | } 12 | 13 | .dddot-toolbar-content { 14 | background-color: var(--joplin-background-color); 15 | height: 26px; 16 | display: flex; 17 | justify-content: left; 18 | align-items: center; 19 | padding-left: 0px; 20 | padding-right: 4px; 21 | margin-top: 4px; 22 | position: relative; 23 | } 24 | 25 | .dddot-toolbar-content:before { 26 | content: " "; 27 | position: absolute; 28 | left: 4px; 29 | bottom: 0px; 30 | right: 4px; 31 | height: 1px; 32 | background-color: var(--joplin-divider-color); 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/tools/recentnotes/view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LinkList } from "../../views/linklist"; 3 | import { Link } from "../../types/link"; 4 | 5 | type Props = { 6 | links?: Link[]; 7 | } 8 | 9 | export function RecentNotesView(props: Props) { 10 | const onClick = React.useCallback((link: Link) => { 11 | DDDot.postMessage({ 12 | type: "dddot.openNote", 13 | noteId: link.id, 14 | }); 15 | }, []); 16 | 17 | const onContextMenu = React.useCallback((link: Link) => { 18 | DDDot.postMessage({ 19 | type: "recentnotes.tool.openNoteDetailDialog", 20 | noteId: link.id, 21 | }); 22 | }, []); 23 | 24 | return ( 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /api/JoplinViewsMenuItems.d.ts: -------------------------------------------------------------------------------- 1 | import { CreateMenuItemOptions, MenuItemLocation } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * Allows creating and managing menu items. 5 | * 6 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) 7 | * 8 | * desktop 9 | */ 10 | export default class JoplinViewsMenuItems { 11 | private store; 12 | private plugin; 13 | constructor(plugin: Plugin, store: any); 14 | /** 15 | * Creates a new menu item and associate it with the given command. You can specify under which menu the item should appear using the `location` parameter. 16 | */ 17 | create(id: string, commandName: string, location?: MenuItemLocation, options?: CreateMenuItemOptions): Promise; 18 | } 19 | -------------------------------------------------------------------------------- /src/types/themetype.ts: -------------------------------------------------------------------------------- 1 | enum ThemeType { 2 | THEME_UNKNOWN = -1, 3 | THEME_LIGHT= 1, 4 | THEME_DARK= 2, 5 | THEME_OLED_DARK =22, 6 | THEME_SOLARIZED_LIGHT=3, 7 | THEME_SOLARIZED_DARK= 4, 8 | THEME_DRACULA=5, 9 | THEME_NORD= 6, 10 | THEME_ARITIM_DARK= 7 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-namespace 14 | namespace ThemeType { 15 | export function isDarkTheme(themeType: ThemeType) { 16 | return [ 17 | ThemeType.THEME_DARK, 18 | ThemeType.THEME_OLED_DARK, 19 | ThemeType.THEME_SOLARIZED_DARK, 20 | ThemeType.THEME_ARITIM_DARK, 21 | ].indexOf(themeType) >= 0; 22 | } 23 | 24 | export function isLightTheme(themeType: ThemeType) { 25 | return !ThemeType.isDarkTheme(themeType); 26 | } 27 | } 28 | 29 | export default ThemeType; 30 | -------------------------------------------------------------------------------- /api/JoplinViewsMenus.d.ts: -------------------------------------------------------------------------------- 1 | import { MenuItem, MenuItemLocation } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * Allows creating menus. 5 | * 6 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/menu) 7 | * 8 | * desktop 9 | */ 10 | export default class JoplinViewsMenus { 11 | private store; 12 | private plugin; 13 | constructor(plugin: Plugin, store: any); 14 | private registerCommandAccelerators; 15 | /** 16 | * Creates a new menu from the provided menu items and place it at the given location. As of now, it is only possible to place the 17 | * menu as a sub-menu of the application build-in menus. 18 | */ 19 | create(id: string, label: string, menuItems: MenuItem[], location?: MenuItemLocation): Promise; 20 | } 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import joplin from "api"; 2 | import { ToolbarButtonLocation } from "api/types"; 3 | import Panel from "./panel"; 4 | import { initializeI18N } from "./i18n"; 5 | 6 | joplin.plugins.register({ 7 | async onStart() { 8 | const locale = await joplin.settings.globalValue("locale"); 9 | 10 | initializeI18N(locale); 11 | 12 | const panel = new Panel(); 13 | 14 | const command = "dddot.cmd.toggleDDDot"; 15 | 16 | await joplin.commands.register({ 17 | name: command, 18 | label: "Toggle DDDot Visibility", 19 | iconName: "fas fa-braille", 20 | execute: async () => { 21 | await panel.toggleVisibility(); 22 | }, 23 | }); 24 | 25 | await panel.start(); 26 | 27 | await joplin.views.toolbarButtons.create("joplinDDDotToggleVisibilityButton", command, ToolbarButtonLocation.NoteToolbar); 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/i18n/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: "off" */ 2 | import resourcesToBackend from "i18next-resources-to-backend"; 3 | import * as i18next from "i18next"; 4 | import en from "../locales/en.json"; 5 | import zh_TW from "../locales/zh_TW.json"; 6 | import fr_FR from "../locales/fr_FR.json"; 7 | import zh_CN from "../locales/zh_CN.json"; 8 | 9 | const locales = { 10 | en, 11 | zh_TW, 12 | fr_FR, 13 | zh_CN, 14 | }; 15 | 16 | export function initializeI18N(locale: string) { 17 | i18next 18 | .use(resourcesToBackend((language, namespace, callback) => { 19 | if (locales[language] === undefined) { 20 | callback(new Error(`Language ${language} not supported`), null); 21 | } else { 22 | callback(null, locales[language]); 23 | } 24 | })) 25 | .init({ 26 | lng: locale, 27 | debug: true, 28 | fallbackLng: "en", 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/views/notelink.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | title: string; 5 | noteId: string; 6 | } 7 | 8 | export function NoteLink(props: Props) { 9 | const { 10 | title, noteId, 11 | } = props; 12 | 13 | const escapedTitle = title.split("\"").join("""); 14 | 15 | const onClick = React.useCallback((e: React.MouseEvent) => { 16 | e.preventDefault(); 17 | DDDot.postMessage({ 18 | type: "dddot.openNote", 19 | noteId, 20 | }); 21 | }, [noteId]); 22 | 23 | return ( 24 |
28 |
29 |

30 | {title} 31 |

32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/views/expandbutton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { SmallIconButton } from "../views/smalliconbutton"; 3 | 4 | type Props = { 5 | isExpanded: boolean; 6 | onClick: () => void; 7 | } 8 | 9 | export function ExpandButton(props: Props) { 10 | const styles = props.isExpanded ? { 11 | transformOrigin: "center center", 12 | transform: "translate(2px,2px) rotate(90deg)", 13 | } : { 14 | transformOrigin: "center center", 15 | transform: "translate(0px,4px) rotate(180deg)", 16 | }; 17 | 18 | return ( 19 | 23 |
26 |

27 | 28 |

29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/services/notedialog/notedialogworker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function noteDialogWorker() { 3 | 4 | const closeDialog = () => { 5 | App.setOverlayVisible("notedialog", false); 6 | }; 7 | 8 | const openDialog = () => { 9 | App.setOverlayVisible("notedialog", true); 10 | }; 11 | 12 | DDDot.onMessage("notedialog.worker.open", (message) => { 13 | const { 14 | noteId, 15 | title, 16 | content, 17 | } = message; 18 | App.setSectionViewProp("notedialog", "noteId", noteId); 19 | App.setSectionViewProp("notedialog", "title", title); 20 | App.setSectionViewProp("notedialog", "content", content); 21 | closeDialog(); 22 | openDialog(); 23 | }); 24 | 25 | DDDot.onMessage("notedialog.worker.refresh", (message) => { 26 | const { 27 | content, 28 | } = message; 29 | App.setSectionViewProp("notedialog", "content", content); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/services/toolbar/toolbarservice.ts: -------------------------------------------------------------------------------- 1 | import JoplinService from "../joplin/joplinservice"; 2 | 3 | interface ToolbarItem { 4 | name: string; 5 | icon: string; 6 | tooltip: string; 7 | onClick: object; 8 | onContextMenu?: object; 9 | } 10 | 11 | export default class ToolbarService { 12 | joplinService: JoplinService; 13 | 14 | toolbarItems: ToolbarItem[] = []; 15 | 16 | constructor(joplinService: JoplinService) { 17 | this.joplinService = joplinService; 18 | } 19 | 20 | async onLoaded() { 21 | this.toolbarItems = []; 22 | } 23 | 24 | addToolbarItem(item: ToolbarItem) { 25 | this.toolbarItems.push(item); 26 | } 27 | 28 | async onMessage(message: any) { 29 | const { 30 | type, 31 | } = message; 32 | 33 | switch (type) { 34 | case "toolbar.service.onReady": 35 | return this.toolbarItems; 36 | default: 37 | break; 38 | } 39 | return undefined; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/views/primarybutton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cn from "classnames"; 3 | import { t } from "i18next"; 4 | 5 | type Props = { 6 | tooltip?: string; 7 | className?: string; 8 | children?: React.ReactNode; 9 | onClick?: () => void; 10 | } 11 | 12 | export function PrimaryButton(props: Props) { 13 | const tooltip = props.tooltip != null ? t(props.tooltip) : null; 14 | const className = cn(props.className, { 15 | "tooltip-multiline": props.tooltip != null, 16 | }, [ 17 | "bg-transparent", 18 | "hover:bg-[#ffffff7f]", 19 | "active:bg-[#ffffff5f]", 20 | "bg-transparent", 21 | "py-[4px]", 22 | "rounded-[4px]", 23 | "border-[1px] border-solid border-[--joplin-color]", 24 | "my-[4px]", 25 | "text-[--joplin-color]", 26 | ]); 27 | 28 | return ( 29 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | pull_request: 9 | branches: "*" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | - name: Install node 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "18.x" 21 | - name: Install dependencies 22 | run: | 23 | npm install -g codecov 24 | - name: Test the extension 25 | run: | 26 | npm install 27 | npx bower install 28 | make copy-libs 29 | npm run test 30 | npm run lint 31 | - name: Build the extension 32 | run: | 33 | npm run dist 34 | - name: Archive production artifacts 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: jpl 38 | path: | 39 | package.json 40 | publish 41 | README.md 42 | -------------------------------------------------------------------------------- /src/sandbox/app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { MainPanel } from "./mainpanel"; 4 | import { initializeI18N } from "../i18n"; 5 | 6 | class App { 7 | static toolsOrder = [] as string[]; 8 | 9 | static render(id: string, tools: any[]) { 10 | const domNode = document.getElementById(id); 11 | const root = createRoot(domNode); 12 | 13 | root.render(); 14 | } 15 | 16 | static setSectionViewProp(toolId: string, key: string, value: any) { 17 | MainPanel.setSectionViewProp(toolId, key, value); 18 | } 19 | 20 | static setOverlayVisible(view: string, visible: boolean) { 21 | MainPanel.setOverlayVisible(view, visible); 22 | } 23 | 24 | static setToolsOrder(order: string[]) { 25 | App.toolsOrder = order; 26 | } 27 | 28 | static setupLocale(locale: string) { 29 | initializeI18N(locale); 30 | } 31 | } 32 | 33 | (window as any).App = App; 34 | -------------------------------------------------------------------------------- /tests/recentnotes.test.ts: -------------------------------------------------------------------------------- 1 | import RecentNotes from "../src/tools/recentnotes"; 2 | import JoplinRepo from "../src/repo/joplinrepo"; 3 | import ServicePool from "../src/services/servicepool"; 4 | import { LinkMonad } from "../src/types/link"; 5 | import LinkListModel from "../src/models/linklistmodel"; 6 | 7 | jest.mock("../src/repo/joplinrepo"); 8 | 9 | test("truncate - it should remove items more than maxnotes value", async () => { 10 | const joplinRepo: any = new JoplinRepo(); 11 | const servicePool = new ServicePool(joplinRepo); 12 | const tool = new RecentNotes(servicePool); 13 | 14 | const ids = ["1", "2", "3"]; 15 | const model = new LinkListModel(); 16 | ids.forEach((id) => { 17 | const link = LinkMonad.createNoteLink(id, "title"); 18 | model.push(link); 19 | }); 20 | const max = 1; 21 | 22 | tool.linkListModel = model; 23 | 24 | joplinRepo.settingsLoad.mockReturnValue(max); 25 | 26 | await tool.truncate(); 27 | 28 | expect(tool.linkListModel.links.length).toBe(max); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/joplinservice.test.ts: -------------------------------------------------------------------------------- 1 | import JoplinRepo from "../src/repo/joplinrepo"; 2 | import JoplinService from "../src/services/joplin/joplinservice"; 3 | 4 | jest.mock("../src/repo/joplinrepo"); 5 | 6 | test("appendTextToNote should append text via dataPut", async () => { 7 | const joplinRepo = (new JoplinRepo()) as any; 8 | const joplinService = new JoplinService(joplinRepo); 9 | const noteId = "noteId"; 10 | const text = "text"; 11 | joplinRepo.workspaceSelectedNote.mockReturnValueOnce({ id: "another-id" }); 12 | joplinRepo.dataGet.mockReturnValueOnce({ body: "header" }); 13 | 14 | await joplinService.appendTextToNote(noteId, text); 15 | 16 | expect(joplinRepo.dataPut.mock.calls[0][2].body).toBe("headertext"); 17 | }); 18 | 19 | test("urlToId", async () => { 20 | const joplinRepo = (new JoplinRepo()) as any; 21 | const joplinService = new JoplinService(joplinRepo); 22 | 23 | expect( 24 | await joplinService.urlToId("https://www.google.com/"), 25 | ).toBe("d0e196a0c25d35dd0a84593cbae0f383"); 26 | }); 27 | -------------------------------------------------------------------------------- /api/JoplinWindow.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | export default class JoplinWindow { 3 | private store_; 4 | constructor(_plugin: Plugin, store: any); 5 | /** 6 | * Loads a chrome CSS file. It will apply to the window UI elements, except 7 | * for the note viewer. It is the same as the "Custom stylesheet for 8 | * Joplin-wide app styles" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) 9 | * for an example. 10 | * 11 | * desktop 12 | */ 13 | loadChromeCssFile(filePath: string): Promise; 14 | /** 15 | * Loads a note CSS file. It will apply to the note viewer, as well as any 16 | * exported or printed note. It is the same as the "Custom stylesheet for 17 | * rendered Markdown" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) 18 | * for an example. 19 | * 20 | * desktop 21 | */ 22 | loadNoteCssFile(filePath: string): Promise; 23 | } 24 | -------------------------------------------------------------------------------- /api/JoplinInterop.d.ts: -------------------------------------------------------------------------------- 1 | import { ExportModule, ImportModule } from './types'; 2 | /** 3 | * Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format. 4 | * 5 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export) 6 | * 7 | * To implement an import or export module, you would simply define an object with various event handlers that are called 8 | * by the application during the import/export process. 9 | * 10 | * See the documentation of the [[ExportModule]] and [[ImportModule]] for more information. 11 | * 12 | * You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/help/api/references/rest_api 13 | * 14 | * desktop: While it is possible to register import and export 15 | * modules on mobile, there is no GUI to activate them. 16 | */ 17 | export default class JoplinInterop { 18 | registerExportModule(module: ExportModule): Promise; 19 | registerImportModule(module: ImportModule): Promise; 20 | } 21 | -------------------------------------------------------------------------------- /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 | /** desktop */ 8 | readHtml(): Promise; 9 | /** desktop */ 10 | writeHtml(html: string): Promise; 11 | /** 12 | * Returns the image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. 13 | * 14 | * desktop 15 | */ 16 | readImage(): Promise; 17 | /** 18 | * Takes an image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. 19 | * 20 | * desktop 21 | */ 22 | writeImage(dataUrl: string): Promise; 23 | /** 24 | * Returns the list available formats (mime types). 25 | * 26 | * For example [ 'text/plain', 'text/html' ] 27 | */ 28 | availableFormats(): Promise; 29 | } 30 | -------------------------------------------------------------------------------- /src/sandbox/contentScriptCM6.ts: -------------------------------------------------------------------------------- 1 | import type { ContentScriptContext, MarkdownEditorContentScriptModule } from "api/types"; 2 | 3 | // modified from: https://github.com/personalizedrefrigerator/bug-report/tree/example/plugin-scroll-to-line 4 | // ref: https://github.com/cqroot/joplin-outline 5 | 6 | export default (_context: ContentScriptContext): MarkdownEditorContentScriptModule => ({ 7 | plugin: (editorControl) => { 8 | if (editorControl.cm6) { 9 | editorControl.registerCommand("dddot.contentScript.scrollToLine", (lineNo: number) => { 10 | const { editor } = editorControl; 11 | 12 | let lineNumber = lineNo; 13 | if (lineNumber <= 0) { 14 | lineNumber = 1; 15 | } 16 | if (lineNumber >= editor.state.doc.lines) { 17 | lineNumber = editor.state.doc.lines; 18 | } 19 | 20 | const lineInfo = editor.state.doc.line(lineNumber); 21 | editor.dispatch(editor.state.update({ 22 | selection: { anchor: lineInfo.from }, 23 | scrollIntoView: true, 24 | })); 25 | }); 26 | } 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/views/overlay.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PrimaryButton } from "./primarybutton"; 3 | 4 | type Props = { 5 | header?: React.ReactNode; 6 | onClose: () => void; 7 | children?: React.ReactNode; 8 | } 9 | 10 | export function Overlay(props: Props) { 11 | const onClick = React.useCallback((e: MouseEvent) => { 12 | e.stopPropagation(); 13 | e.preventDefault(); 14 | }, []); 15 | 16 | return ( 17 |
19 |
20 |
21 | {props.header} 22 |
23 |
24 | 25 | 26 | 27 |
28 |
29 |
30 | {props.children} 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/types/link.ts: -------------------------------------------------------------------------------- 1 | export enum LinkType { 2 | NoteLink = "NoteLink", 3 | FolderLink = "FolderLink" 4 | } 5 | 6 | export type Link = { 7 | id: string; 8 | 9 | title: string; 10 | 11 | type: LinkType; 12 | 13 | isTodo: boolean | undefined; 14 | 15 | isTodoCompleted: boolean | undefined; 16 | } 17 | 18 | export class LinkMonad { 19 | static createNoteLink( 20 | id: string, 21 | title: string, 22 | ): Link { 23 | return { 24 | id, 25 | title, 26 | isTodo: false, 27 | isTodoCompleted: false, 28 | type: LinkType.NoteLink, 29 | }; 30 | } 31 | 32 | static createFolderLink(id: string, title: string): Link { 33 | return { 34 | id, 35 | title, 36 | isTodo: false, 37 | isTodoCompleted: false, 38 | type: LinkType.FolderLink, 39 | }; 40 | } 41 | 42 | static createNoteLinkFromRawData(data): Link { 43 | return { 44 | id: data.id ?? "", 45 | title: data.title ?? "", 46 | isTodo: data.is_todo === 1, 47 | isTodoCompleted: data.todo_completed > 0, 48 | type: LinkType.NoteLink, 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/tools/outline/worker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | async function outlineWorker() { 3 | let heightRefreshCounter = 0; 4 | 5 | const refresh = (id, title, outlines) => { 6 | App.setSectionViewProp("outline", "id", id); 7 | App.setSectionViewProp("outline", "title", title); 8 | App.setSectionViewProp("outline", "outlines", outlines); 9 | }; 10 | 11 | DDDot.onMessage("outline.refresh", (message) => { 12 | refresh(message.id, message.title, message.outlines); 13 | }); 14 | 15 | DDDot.onMessage("outline.autoResize", (message) => { 16 | const { 17 | newHeight, 18 | } = message; 19 | App.setSectionViewProp("outline", "height", newHeight); 20 | heightRefreshCounter += 1; 21 | App.setSectionViewProp("outline", "heightRefreshCounter", heightRefreshCounter); 22 | }); 23 | 24 | const response = await DDDot.postMessage({ 25 | type: "outline.onReady", 26 | }); 27 | const { 28 | id, 29 | title, 30 | outlines, 31 | height, 32 | resizeMode, 33 | } = response; 34 | refresh(id, title, outlines); 35 | App.setSectionViewProp("outline", "height", height); 36 | App.setSectionViewProp("outline", "resizeMode", resizeMode); 37 | } 38 | -------------------------------------------------------------------------------- /src/views/smalliconbutton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cntl from "cntl"; 3 | 4 | type Props = { 5 | icon: string 6 | children?: React.ReactNode; 7 | tooltip?: string; 8 | onClick?: () => void; 9 | } 10 | 11 | export function SmallIconButton(props: Props) { 12 | const onClick = React.useCallback((e: MouseEvent) => { 13 | e.stopPropagation(); 14 | e.preventDefault(); 15 | if (props.onClick) { 16 | props.onClick(); 17 | } 18 | }, [props]); 19 | 20 | return ( 21 |
28 |
33 | { 34 | props.children ? ( 35 | props.children 36 | ) : ( 37 |
38 |

39 |
40 | ) 41 | } 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /api/JoplinViews.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import JoplinViewsDialogs from './JoplinViewsDialogs'; 3 | import JoplinViewsMenuItems from './JoplinViewsMenuItems'; 4 | import JoplinViewsMenus from './JoplinViewsMenus'; 5 | import JoplinViewsToolbarButtons from './JoplinViewsToolbarButtons'; 6 | import JoplinViewsPanels from './JoplinViewsPanels'; 7 | import JoplinViewsNoteList from './JoplinViewsNoteList'; 8 | import JoplinViewsEditors from './JoplinViewsEditor'; 9 | /** 10 | * This namespace provides access to view-related services. 11 | * 12 | * 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. 13 | * 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. 14 | */ 15 | export default class JoplinViews { 16 | private store; 17 | private plugin; 18 | private panels_; 19 | private menuItems_; 20 | private menus_; 21 | private toolbarButtons_; 22 | private dialogs_; 23 | private editors_; 24 | private noteList_; 25 | private implementation_; 26 | constructor(implementation: any, plugin: Plugin, store: any); 27 | get dialogs(): JoplinViewsDialogs; 28 | get panels(): JoplinViewsPanels; 29 | get editors(): JoplinViewsEditors; 30 | get menuItems(): JoplinViewsMenuItems; 31 | get menus(): JoplinViewsMenus; 32 | get toolbarButtons(): JoplinViewsToolbarButtons; 33 | get noteList(): JoplinViewsNoteList; 34 | } 35 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true, 6 | "mocha": true, 7 | "jest": true, 8 | }, 9 | "globals": { 10 | "module": true, 11 | "webviewApi": true, 12 | "messenger": true, 13 | "CodeMirror": true, 14 | "DDDot": true, 15 | "CodeMirror5Manager": true, 16 | "App": true 17 | }, 18 | "parser": "@typescript-eslint/parser", 19 | "plugins": [ 20 | "react-hooks", 21 | "@typescript-eslint" 22 | ], 23 | "extends": [ 24 | "airbnb-base", 25 | "eslint:recommended", 26 | "plugin:@typescript-eslint/eslint-recommended", 27 | "plugin:@typescript-eslint/recommended" 28 | ], 29 | "rules": { 30 | "indent": ["error", 4], 31 | "@typescript-eslint/no-explicit-any": "off", 32 | "@typescript-eslint/no-empty-interface": "off", 33 | "@typescript-eslint/ban-types": "off", 34 | "@typescript-eslint/no-empty-function": "off", 35 | "import/extensions": "off", 36 | "no-await-in-loop": "off", 37 | "class-methods-use-this": "off", 38 | "import/no-unresolved": "off", 39 | "quotes": [2, "double"], 40 | "no-shadow": "off", 41 | "@typescript-eslint/no-shadow": ["error"], 42 | "no-console": ["error", { "allow": ["warn", "error"] }], 43 | "@typescript-eslint/no-unused-vars": [ 44 | "warn", 45 | { 46 | "argsIgnorePattern": "^_", 47 | "varsIgnorePattern": '^_', 48 | "caughtErrorsIgnorePattern": "^_", 49 | } 50 | ], 51 | "import/prefer-default-export": "off", 52 | "no-empty-function": "off", 53 | "no-alert": "off", 54 | "react-hooks/exhaustive-deps": "error", 55 | "no-lonely-if": "off" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/models/linklistmodel.ts: -------------------------------------------------------------------------------- 1 | import { Link } from "../types/link"; 2 | 3 | export default class LinkListModel { 4 | links: Link[]; 5 | 6 | constructor() { 7 | this.links = []; 8 | } 9 | 10 | unshift(link: Link) { 11 | this.remove(link.id); 12 | this.links.unshift(link); 13 | } 14 | 15 | push(link: Link) { 16 | this.remove(link.id); 17 | this.links.push(link); 18 | } 19 | 20 | remove(id: String) { 21 | this.links = this.links.filter((link) => link.id !== id); 22 | } 23 | 24 | dehydrate() { 25 | return this.links.map((link) => ({ ...link })); 26 | } 27 | 28 | rehydrate(items) { 29 | this.links = items.filter((item) => item != null && item.type != null); 30 | } 31 | 32 | reorder(orderedIds: string[]) { 33 | const ids = this.links.map((link) => link.id); 34 | const weight = (id) => { 35 | const index = orderedIds.indexOf(id); 36 | return index >= 0 ? index : ids.indexOf(id) + orderedIds.length; 37 | }; 38 | this.links = [...this.links].sort((a, b) => weight(a.id) - weight(b.id)); 39 | } 40 | 41 | update(id: string, options) { 42 | const ids = this.links.map((link) => link.id); 43 | const index = ids.indexOf(id); 44 | if (index >= 0) { 45 | const link = this.links[index]; 46 | const newLink = { 47 | ...link, 48 | ...options, 49 | }; 50 | if (JSON.stringify(link) !== JSON.stringify(newLink)) { 51 | this.links[index] = newLink; 52 | return true; 53 | } 54 | } 55 | return false; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/services/toolbar/toolbarview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type ToolbarItem = { 4 | name: string; 5 | icon: string; 6 | tooltip: string; 7 | onClick: any; 8 | onContextMenu?: any; 9 | } 10 | 11 | export function IconButton(props: { 12 | name: string; 13 | icon: string; 14 | tooltip: string; 15 | onClick?: object; 16 | onContextMenu?: object; 17 | }) { 18 | const { 19 | icon, 20 | tooltip, 21 | } = props; 22 | 23 | const onClick = React.useCallback(() => { 24 | DDDot.postMessage(props.onClick); 25 | }, [props.onClick]); 26 | 27 | const onContextMenu = React.useCallback(() => { 28 | DDDot.postMessage(props.onContextMenu); 29 | }, [props.onContextMenu]); 30 | 31 | return ( 32 |
33 |
36 | 37 |
38 |
39 | ); 40 | } 41 | 42 | type Props = { 43 | toolbarItems?: ToolbarItem[]; 44 | } 45 | 46 | export function ToolbarView(props: Props) { 47 | const { 48 | toolbarItems, 49 | } = props; 50 | return ( 51 |
52 | { 53 | toolbarItems?.map((item) => ( 54 | 60 | )) 61 | } 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/locales/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "backlinks.title": "反向链接", 3 | "backlinks.enable": "启用反向链接", 4 | "shortcuts.title": "快捷方式", 5 | "shortcuts.enable": "启用快捷方式", 6 | "shortcuts.drag_note_here": "拖动笔记到此处", 7 | "shortcuts.import_export_tooltip": "导入/导出", 8 | "shortcuts.import": "导入", 9 | "shortcuts.export": "导出", 10 | "shortcuts.imported": "已导入", 11 | "shortcuts.import_error": "快捷方式文件无效。请检查文件内容后重试。", 12 | "scratchpad.title": "便签", 13 | "scratchpad.enable": "启用便签", 14 | "recentnotes.title": "近期笔记", 15 | "recentnotes.enable": "启用近期笔记", 16 | "scratchpad.settings.height": "便签高度(px)", 17 | "notedialog.swap": "交换", 18 | "notedialog.swap_tooltip": "交换编辑器笔记和快速查看笔记", 19 | "notedialog.note_editor": "编辑器", 20 | "notedialog.quick_view": "快速查看", 21 | "notedialog.open_note_dddot": "在DDDot快速查看中打开笔记", 22 | "notedialog.cut_append_selected_text": "剪切并追加选定文本", 23 | "notedialog.cut_append_selected_text_tooltip": "从编辑器中剪切选定的文本,然后追加到快速查看中", 24 | "notedialog.append_note_link": "追加笔记链接", 25 | "notedialog.append_note_link_tooltip": "将笔记链接从编辑器中追加到快速查看中", 26 | "dailynote.title": "日记", 27 | "dailynote.enable": "启用日记", 28 | "dailynote.open_daily_note": "打开日记", 29 | "dailynote.default_notebook_not_found": "找不到默认笔记本:{notebook}", 30 | "randomnote.title": "随手记", 31 | "randomnote.enable": "启用随手记", 32 | "randomnote.open_random_note": "打开随手记", 33 | "outline.title": "大纲", 34 | "outline.enable": "启用大纲", 35 | "outline.link_copied": "链接已复制!", 36 | "outline.settings.height": "大纲高度(px)", 37 | "outline.resize_to_fit_tooltip": "调整高度适应内容", 38 | "outline.settings.resize_mode": "大纲大小调整模式", 39 | "outline.settings.manual": "拖动模式", 40 | "outline.settings.auto": "自动模式", 41 | "outline.settings.schemas": "包含带有模式的URL(以“,”分隔,例如http、https、file)" 42 | } 43 | -------------------------------------------------------------------------------- /src/locales/zh_TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "backlinks.title": "反向鏈結", 3 | "backlinks.enable": "啟用反向鏈結", 4 | "shortcuts.title": "捷徑", 5 | "shortcuts.enable": "啟用捷徑", 6 | "shortcuts.import": "匯入", 7 | "shortcuts.export": "匯出", 8 | "shortcuts.drag_note_here": "將筆記拉到這裡", 9 | "shortcuts.import_export_tooltip": "匯入/匯出", 10 | "shortcuts.imported": "已匯入", 11 | "shortcuts.import_error": "無效的捷徑檔案。請檢查檔案內容並重試。", 12 | "scratchpad.title": "拍紙簿", 13 | "scratchpad.enable": "啟用拍紙簿", 14 | "scratchpad.settings.height": "拍紙簿高度(px)", 15 | "recentnotes.title": "最近瀏覽", 16 | "recentnotes.enable": "啟用最近瀏覽", 17 | "notedialog.swap": "交換", 18 | "notedialog.swap_tooltip": "交換編輯中的筆記與側欄的筆記", 19 | "notedialog.note_editor": "編輯器", 20 | "notedialog.quick_view": "預覽", 21 | "notedialog.open_note_dddot": "在 DDDot 快速預覽中開啟筆記", 22 | "notedialog.cut_append_selected_text": "剪下並附加選取的文字", 23 | "notedialog.cut_append_selected_text_tooltip": "從編輯器剪下選取的文字並附加到快速預覽", 24 | "notedialog.append_note_link": "附加筆記連結", 25 | "notedialog.append_note_link_tooltip": "從編輯器附加筆記連結到快速預覽", 26 | "dailynote.title": "日記", 27 | "dailynote.enable": "啟用日記", 28 | "dailynote.open_daily_note": "開啟日記", 29 | "dailynote.default_notebook_not_found": "找不到預設筆記本:{notebook}", 30 | "randomnote.title": "隨機筆記", 31 | "randomnote.enable": "啟用隨機筆記", 32 | "randomnote.open_random_note": "開啟隨機筆記", 33 | "outline.title": "大綱", 34 | "outline.enable": "啟用大綱", 35 | "outline.link_copied": "連結已複製!", 36 | "outline.settings.height": "大綱高度(px)", 37 | "outline.resize_to_fit_tooltip": "調整高度以符合內容", 38 | "outline.settings.resize_mode": "大綱調整模式", 39 | "outline.settings.manual": "手動模式", 40 | "outline.settings.auto": "自動模式", 41 | "outline.settings.schemas": "包含 URL 的協議 (用 ',' 分隔,例如 http,https,file)" 42 | } 43 | -------------------------------------------------------------------------------- /tests/panel.test.ts: -------------------------------------------------------------------------------- 1 | import Panel from "../src/panel"; 2 | import JoplinRepo from "../src/repo/joplinrepo"; 3 | import JoplinService from "../src/services/joplin/joplinservice"; 4 | import ServicePool from "../src/services/servicepool"; 5 | import Tool from "../src/tools/tool"; 6 | 7 | jest.mock("../src/repo/joplinrepo"); 8 | jest.mock("../src/services/joplin/joplinservice"); 9 | jest.mock("../src/tools/tool"); 10 | 11 | test("Panel.onLoaded should post dddot.start", async () => { 12 | const panel = new Panel(); 13 | const joplinRepo: any = new JoplinRepo(); 14 | const servicePool = new ServicePool(joplinRepo); 15 | panel.joplinRepo = joplinRepo; 16 | panel.tools = []; 17 | const joplinService = new JoplinService(joplinRepo); 18 | servicePool.joplinService = joplinService; 19 | 20 | panel.servicePool = servicePool; 21 | await panel.onLoaded(); 22 | 23 | const message = joplinRepo.panelPostMessage.mock.calls.pop()[0]; 24 | expect(message.type).toBe("dddot.start"); 25 | expect(message.tools).toStrictEqual([]); 26 | }); 27 | 28 | test("Panel.onLoaded should post dddot.start and ignore tool.hasView is false", async () => { 29 | const panel = new Panel(); 30 | const joplinRepo: any = new JoplinRepo(); 31 | const servicePool = new ServicePool(joplinRepo); 32 | const joplinService = new JoplinService(joplinRepo); 33 | servicePool.joplinService = joplinService; 34 | 35 | const tool: any = new Tool(servicePool); 36 | tool.hasView = false; 37 | 38 | panel.joplinRepo = joplinRepo; 39 | panel.tools = [tool]; 40 | panel.servicePool = servicePool; 41 | await panel.onLoaded(); 42 | 43 | const message = joplinRepo.panelPostMessage.mock.calls.pop()[0]; 44 | expect(message.type).toBe("dddot.start"); 45 | expect(message.tools).toStrictEqual([]); 46 | }); 47 | -------------------------------------------------------------------------------- /api/JoplinViewsNoteList.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import Plugin from '../Plugin'; 3 | import { ListRenderer } from './noteListType'; 4 | /** 5 | * This API allows you to customise how each note in the note list is rendered. 6 | * The renderer you implement follows a unidirectional data flow. 7 | * 8 | * The app provides the required dependencies whenever a note is updated - you 9 | * process these dependencies, and return some props, which are then passed to 10 | * your template and rendered. See [[ListRenderer]] for a detailed description 11 | * of each property of the renderer. 12 | * 13 | * ## Reference 14 | * 15 | * * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/note_list_renderer) 16 | * 17 | * * [Default simple renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts) 18 | * 19 | * * [Default detailed renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultMultiColumnsRenderer.ts) 20 | * 21 | * ## Screenshots: 22 | * 23 | * ### Top to bottom with title, date and body 24 | * 25 | * 26 | * 27 | * ### Left to right with thumbnails 28 | * 29 | * 30 | * 31 | * ### Top to bottom with editable title 32 | * 33 | * 34 | * 35 | * desktop 36 | */ 37 | export default class JoplinViewsNoteList { 38 | private plugin_; 39 | private store_; 40 | constructor(plugin: Plugin, store: Store); 41 | registerRenderer(renderer: ListRenderer): Promise; 42 | } 43 | -------------------------------------------------------------------------------- /src/views/linkitem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDrag } from "react-dnd"; 3 | import { DragItemType } from "../types/drag"; 4 | import { Link } from "../types/link"; 5 | 6 | export function LinkItem(props: { 7 | link: Link 8 | onClick: (link: Link) => void; 9 | onContextMenu: (link: Link) => void; 10 | }) { 11 | const { 12 | link, 13 | } = props; 14 | 15 | const onClick = React.useCallback((e) => { 16 | e.preventDefault(); 17 | e.stopPropagation(); 18 | props.onClick(link); 19 | }, [props, link]); 20 | 21 | const onContextMenu = React.useCallback((e) => { 22 | e.preventDefault(); 23 | e.stopPropagation(); 24 | props.onContextMenu(link); 25 | }, [props, link]); 26 | 27 | const onDragStart = React.useCallback((e) => { 28 | e.dataTransfer.clearData(); 29 | e.dataTransfer.setData(DDDot.X_JOP_NOTE_IDS, `["${props.link.id}"]`); 30 | }, [props]); 31 | 32 | const [_collected, drag] = useDrag({ 33 | type: DragItemType.Link, 34 | }); 35 | 36 | const checkboxClasses = link.isTodoCompleted ? "far fa-check-square" : "far fa-square"; 37 | 38 | return ( 39 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/theme/codemirror/blackboard.css: -------------------------------------------------------------------------------- 1 | /* Port of TextMate's Blackboard theme */ 2 | 3 | .cm-s-blackboard.CodeMirror { background: #0C1021; color: #F8F8F8; } 4 | .cm-s-blackboard div.CodeMirror-selected { background: #253B76; } 5 | .cm-s-blackboard .CodeMirror-line::selection, .cm-s-blackboard .CodeMirror-line > span::selection, .cm-s-blackboard .CodeMirror-line > span > span::selection { background: rgba(37, 59, 118, .99); } 6 | .cm-s-blackboard .CodeMirror-line::-moz-selection, .cm-s-blackboard .CodeMirror-line > span::-moz-selection, .cm-s-blackboard .CodeMirror-line > span > span::-moz-selection { background: rgba(37, 59, 118, .99); } 7 | .cm-s-blackboard .CodeMirror-gutters { background: #0C1021; border-right: 0; } 8 | .cm-s-blackboard .CodeMirror-guttermarker { color: #FBDE2D; } 9 | .cm-s-blackboard .CodeMirror-guttermarker-subtle { color: #888; } 10 | .cm-s-blackboard .CodeMirror-linenumber { color: #888; } 11 | .cm-s-blackboard .CodeMirror-cursor { border-left: 1px solid #A7A7A7; } 12 | 13 | .cm-s-blackboard .cm-keyword { color: #FBDE2D; } 14 | .cm-s-blackboard .cm-atom { color: #D8FA3C; } 15 | .cm-s-blackboard .cm-number { color: #D8FA3C; } 16 | .cm-s-blackboard .cm-def { color: #8DA6CE; } 17 | .cm-s-blackboard .cm-variable { color: #FF6400; } 18 | .cm-s-blackboard .cm-operator { color: #FBDE2D; } 19 | .cm-s-blackboard .cm-comment { color: #AEAEAE; } 20 | .cm-s-blackboard .cm-string { color: #61CE3C; } 21 | .cm-s-blackboard .cm-string-2 { color: #61CE3C; } 22 | .cm-s-blackboard .cm-meta { color: #D8FA3C; } 23 | .cm-s-blackboard .cm-builtin { color: #8DA6CE; } 24 | .cm-s-blackboard .cm-tag { color: #8DA6CE; } 25 | .cm-s-blackboard .cm-attribute { color: #8DA6CE; } 26 | .cm-s-blackboard .cm-header { color: #FF6400; } 27 | .cm-s-blackboard .cm-hr { color: #AEAEAE; } 28 | .cm-s-blackboard .cm-link { color: #8DA6CE; } 29 | .cm-s-blackboard .cm-error { background: #9D1E15; color: #F8F8F8; } 30 | 31 | .cm-s-blackboard .CodeMirror-activeline-background { background: #3C3636; } 32 | .cm-s-blackboard .CodeMirror-matchingbracket { outline:1px solid grey;color:white !important; } 33 | -------------------------------------------------------------------------------- /tests/tool.test.ts: -------------------------------------------------------------------------------- 1 | import ScratchPad from "../src/tools/scratchpad"; 2 | import RecentNotes from "../src/tools/recentnotes"; 3 | import Shortcuts from "../src/tools/shortcuts"; 4 | import JoplinRepo from "../src/repo/joplinrepo"; 5 | import ServicePool from "../src/services/servicepool"; 6 | import Tool, { blockDisabled } from "../src/tools/tool"; 7 | 8 | jest.mock("../src/repo/joplinrepo"); 9 | 10 | test("containerId", () => { 11 | const joplinRepo = new JoplinRepo(); 12 | const servicePool = new ServicePool(joplinRepo); 13 | 14 | const scratchpad = new ScratchPad(servicePool); 15 | expect(scratchpad.containerId).toBe("dddot-scratchpad-tool-container"); 16 | expect(scratchpad.contentId).toBe("dddot-scratchpad-tool-content"); 17 | 18 | const recentNotes = new RecentNotes(servicePool); 19 | expect(recentNotes.containerId).toBe("dddot-recentnotes-tool-container"); 20 | expect(recentNotes.contentId).toBe("dddot-recentnotes-tool-content"); 21 | 22 | const shortcuts = new Shortcuts(servicePool); 23 | expect(shortcuts.containerId).toBe("dddot-shortcuts-tool-container"); 24 | expect(shortcuts.contentId).toBe("dddot-shortcuts-tool-content"); 25 | }); 26 | 27 | test("@blockDisabled", () => { 28 | class TestClass { 29 | isEnabled: Boolean = true; 30 | 31 | arg1 = ""; 32 | 33 | @blockDisabled 34 | test(arg1) { 35 | this.arg1 = arg1; 36 | return true; 37 | } 38 | } 39 | 40 | const object1 = new TestClass(); 41 | expect(object1.test("a")).toBe(true); 42 | expect(object1.arg1).toBe("a"); 43 | 44 | const object2 = new TestClass(); 45 | object2.isEnabled = false; 46 | expect(object2.test("a")).toBe(undefined); 47 | }); 48 | 49 | test("updateEnabledFromSetting", async () => { 50 | const joplinRepo: any = new JoplinRepo(); 51 | const servicePool = new ServicePool(joplinRepo); 52 | 53 | const tool = new Tool(servicePool); 54 | expect(tool.isEnabled).toBe(true); 55 | 56 | joplinRepo.settingsLoad.mockReturnValue(false); 57 | 58 | expect(await tool.updateEnabledFromSetting()).toBe(false); 59 | expect(tool.isEnabled).toBe(false); 60 | }); 61 | -------------------------------------------------------------------------------- /src/styles/css-tooltip.css: -------------------------------------------------------------------------------- 1 | /* 2 | Source: https://github.com/alterebro/css-tooltip 3 | */ 4 | [data-tooltip] { 5 | position: relative; 6 | display: inline-block; 7 | } 8 | 9 | [data-tooltip]:before, [data-tooltip]:after { 10 | position: absolute; 11 | left: 50%; 12 | transform: translate(-50%, -12px); 13 | z-index: 1000; 14 | pointer-events: none; 15 | -webkit-user-select: none; 16 | -moz-user-select: none; 17 | -ms-user-select: none; 18 | user-select: none; 19 | opacity: 0; 20 | transition: opacity .35s ease .25s; 21 | } 22 | 23 | [data-tooltip]:before { 24 | content: attr(data-tooltip); 25 | text-align: center; 26 | background: #333; 27 | color: #eee; 28 | padding: 8px 12px; 29 | white-space: nowrap; 30 | bottom: 100%; 31 | border-radius: 3px; 32 | box-shadow: 0 5px 15px -5px rgba(0, 0, 0, 0.65); 33 | } 34 | 35 | [data-tooltip]:after { 36 | content: ''; 37 | background: transparent; 38 | border: 8px solid transparent; 39 | border-top-color: #333; 40 | } 41 | 42 | [data-tooltip]:hover:before, [data-tooltip]:hover:after, [data-tooltip]:focus:before, [data-tooltip]:focus:after, [data-tooltip]:active:before, [data-tooltip]:active:after { 43 | opacity: 1; 44 | } 45 | 46 | [data-tooltip].tooltip-multiline:before { 47 | width: 100vw; 48 | max-width: 120px; 49 | white-space: normal; 50 | } 51 | 52 | [data-tooltip][class*="tooltip-bottom"]:before, [data-tooltip][class*="tooltip-bottom"]:after { 53 | transform: translate(-50%, 12px); 54 | } 55 | 56 | [data-tooltip][class*="tooltip-bottom"]:before { 57 | bottom: auto; 58 | top: 100%; 59 | } 60 | 61 | [data-tooltip][class*="tooltip-bottom"]:after { 62 | bottom: 0; 63 | border: 8px solid transparent; 64 | border-bottom-color: #333; 65 | } 66 | 67 | [data-tooltip].tooltip-bottom-left:before { 68 | transform: translate(-24px, 12px); 69 | } 70 | 71 | [data-tooltip].tooltip-bottom-right:before { 72 | left: auto; 73 | right: 50%; 74 | transform: translate(24px, 12px); 75 | } 76 | 77 | [data-tooltip].tooltip-top-left:before { 78 | transform: translate(-24px, -12px); 79 | } 80 | 81 | [data-tooltip].tooltip-top-right:before { 82 | left: auto; 83 | right: 50%; 84 | transform: translate(24px, -12px); 85 | } 86 | -------------------------------------------------------------------------------- /src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "backlinks.title": "Backlink", 3 | "backlinks.enable": "Enable Backlink", 4 | "shortcuts.title": "Shortcuts", 5 | "shortcuts.enable": "Enable Shortcuts", 6 | "shortcuts.drag_note_here": "Drag a note here", 7 | "shortcuts.import_export_tooltip": "Import/Export", 8 | "shortcuts.import": "Import", 9 | "shortcuts.export": "Export", 10 | "shortcuts.imported": "Imported", 11 | "shortcuts.import_error": "Invalid shortcuts file. Please check the file content and try again.", 12 | "scratchpad.title": "Scratchpad", 13 | "scratchpad.enable": "Enable Scratchpad", 14 | "recentnotes.title": "Recent Notes", 15 | "recentnotes.enable": "Enable Recent Notes", 16 | "scratchpad.settings.height": "Scratchpad Height(px)", 17 | "notedialog.swap": "Swap", 18 | "notedialog.swap_tooltip": "Swap editor note and Quick View note", 19 | "notedialog.note_editor": "Editor", 20 | "notedialog.quick_view": "Quick View", 21 | "notedialog.open_note_dddot": "Open Note in DDDot Quick View", 22 | "notedialog.cut_append_selected_text": "Cut and append selected Text", 23 | "notedialog.cut_append_selected_text_tooltip": "Cut selected text from editor and append to Quick View", 24 | "notedialog.append_note_link": "Append note link", 25 | "notedialog.append_note_link_tooltip": "Append note link from editor to Quick View", 26 | "dailynote.title": "Daily Note", 27 | "dailynote.enable": "Enable Daily Note", 28 | "dailynote.open_daily_note": "Open Daily Note", 29 | "dailynote.default_notebook_not_found": "The default notebook '{{notebook}}' not found", 30 | "randomnote.title": "Random Note", 31 | "randomnote.enable": "Enable Random Note", 32 | "randomnote.open_random_note": "Open Random Note", 33 | "outline.title": "Outline", 34 | "outline.enable": "Enable Outline", 35 | "outline.link_copied": "Link copied!", 36 | "outline.settings.height": "Outline Height(px)", 37 | "outline.resize_to_fit_tooltip": "Resize Height to Fit Content", 38 | "outline.settings.resize_mode": "Outline Resize Mode", 39 | "outline.settings.manual": "Manual mode", 40 | "outline.settings.auto": "Auto mode", 41 | "outline.settings.schemas": "Include URL with schemas (',' separated, e.g. http,https,file)" 42 | } 43 | -------------------------------------------------------------------------------- /src/tools/tool.ts: -------------------------------------------------------------------------------- 1 | import { MenuItem } from "api/types"; 2 | import JoplinRepo from "../repo/joplinrepo"; 3 | import JoplinService from "../services/joplin/joplinservice"; 4 | import ServicePool from "../services/servicepool"; 5 | import { ToolButton } from "../types/toolinfo"; 6 | 7 | export function blockDisabled(_target, _name, descriptor) { 8 | const original = descriptor.value; 9 | // eslint-disable-next-line 10 | descriptor.value = function(...args) { 11 | return this.isEnabled ? original.apply(this, args) : undefined; 12 | }; 13 | } 14 | 15 | export default class Tool { 16 | joplinRepo: JoplinRepo; 17 | 18 | joplinService: JoplinService; 19 | 20 | servicePool: ServicePool; 21 | 22 | isEnabled: Boolean = true; 23 | 24 | constructor(servicePool: ServicePool) { 25 | const { 26 | joplinService, 27 | } = servicePool; 28 | this.joplinRepo = joplinService.joplinRepo; 29 | this.joplinService = joplinService; 30 | this.servicePool = servicePool; 31 | } 32 | 33 | get containerId() { 34 | return `dddot-${this.key}-tool-container`; 35 | } 36 | 37 | get contentId() { 38 | return `dddot-${this.key}-tool-content`; 39 | } 40 | 41 | get key() { 42 | return ""; 43 | } 44 | 45 | get title() { 46 | return ""; 47 | } 48 | 49 | get workerFunctionName() { 50 | return `${this.key}Worker`; 51 | } 52 | 53 | get isDefaultEnabled() { 54 | return true; 55 | } 56 | 57 | get hasView() { 58 | return true; 59 | } 60 | 61 | get hasWorkerFunction() { 62 | return true; 63 | } 64 | 65 | async updateEnabledFromSetting() { 66 | const res = await this.joplinRepo.settingsLoad(this.genSettingKey("enabled"), true); 67 | this.isEnabled = res as Boolean; 68 | return res; 69 | } 70 | 71 | async queryExtraButtons(): Promise { 72 | return []; 73 | } 74 | 75 | genSettingKey(key: string) { 76 | return `dddot.settings.${this.key}.${key}`; 77 | } 78 | 79 | settings(_: string) { 80 | return {}; 81 | } 82 | 83 | async start() { 84 | } 85 | 86 | async registerCommands(): Promise { 87 | return []; 88 | } 89 | 90 | async onLoaded() { 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/tools/backlinks/index.ts: -------------------------------------------------------------------------------- 1 | import LinkGraphNode from "src/services/linkgraph/linkgraphnode"; 2 | import { t } from "i18next"; 3 | import Tool, { blockDisabled } from "../tool"; 4 | 5 | export default class BackLinks extends Tool { 6 | selectedNoteId = ""; 7 | 8 | get title() { 9 | return t("backlinks.title"); 10 | } 11 | 12 | get key() { 13 | return "backlinks"; 14 | } 15 | 16 | async start() { 17 | this.servicePool.linkGraphService.onNodeUpdated((node) => { 18 | if (node.id === this.selectedNoteId) { 19 | this.refresh(node); 20 | } 21 | }); 22 | 23 | await this.joplinRepo.workspaceOnNoteSelectionChange(() => { 24 | this.onNoteSelectionChanged(); 25 | }); 26 | } 27 | 28 | async onMessage(message: any) { 29 | switch (message.type) { 30 | case "backlinks.onReady": 31 | this.query(); 32 | break; 33 | case "backlinks.tool.openNoteDetailDialog": 34 | this.openNoteDetailDialog(message.noteId); 35 | break; 36 | default: 37 | break; 38 | } 39 | } 40 | 41 | async query() { 42 | if (this.selectedNoteId === "") { 43 | return; 44 | } 45 | const node = this.servicePool.linkGraphService.queryBacklinks(this.selectedNoteId); 46 | if (node !== undefined) { 47 | await this.refresh(node); 48 | } 49 | } 50 | 51 | @blockDisabled 52 | async onNoteSelectionChanged() { 53 | const activeNote = await this.joplinRepo.workspaceSelectedNote(); 54 | this.selectedNoteId = activeNote.id; 55 | await this.query(); 56 | } 57 | 58 | @blockDisabled 59 | async refresh(node: LinkGraphNode) { 60 | const links = node.backlinks.map((link) => ({ 61 | id: link.id, 62 | title: link.title, 63 | type: link.type, 64 | isTodo: link.isTodo, 65 | isTodoCompleted: link.isTodoCompleted, 66 | })); 67 | 68 | const message = { 69 | type: "backlinks.refresh", 70 | links, 71 | }; 72 | 73 | this.joplinRepo.panelPostMessage(message); 74 | } 75 | 76 | async openNoteDetailDialog(noteId: string) { 77 | const { 78 | noteDialogService, 79 | } = this.servicePool; 80 | 81 | await noteDialogService.open(noteId); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/services/notedialog/notedialog.css: -------------------------------------------------------------------------------- 1 | 2 | .dddot-notedialog-close-button-holder { 3 | vertical-align: middle; 4 | } 5 | 6 | .dddot-notedialog-close-button { 7 | min-width: 40px; 8 | border: 1px solid var(--joplin-color); 9 | border-radius: 4px 4px; 10 | min-height: 18px; 11 | margin-right: 4px; 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: center; 15 | align-items: center; 16 | } 17 | 18 | .dddot-notedialog-close-button h3 { 19 | margin-top: 0px; 20 | margin-bottom: 0px; 21 | vertical-align: bottom; 22 | line-height: 12px; 23 | } 24 | 25 | .dddot-notedialog-container { 26 | display: flex; 27 | flex-direction: column; 28 | } 29 | 30 | .dddot-note-dialog-title-text a { 31 | color: var(--joplin-color); 32 | } 33 | 34 | .dddot-notedialog-content { 35 | flex-grow: 1; 36 | display: flex; 37 | flex-direction: column; 38 | } 39 | 40 | .dddot-notedialog-editor { 41 | flex-grow: 1; 42 | position: relative; 43 | opacity: 0.7; 44 | } 45 | 46 | .dddot-notedialog-editor .cm-editor { border: none; } 47 | .dddot-notedialog-editor .cm-editor.cm-focused { border: none } 48 | 49 | .dddot-notedialog-editor-content { 50 | position: absolute; 51 | top: 0px; 52 | bottom: 0px; 53 | left: 0px; 54 | right: 4px; 55 | border: 1px solid var(--joplin-color); 56 | } 57 | 58 | .dddot-notedialog-tool-panel { 59 | text-align: right; 60 | margin-top: 8px; 61 | margin-right: 8px; 62 | } 63 | 64 | .dddot-note-dialog-command-panel { 65 | margin-bottom: 8px; 66 | margin-left: 4px; 67 | position: relative; 68 | } 69 | 70 | .dddot-note-dialog-command-panel:before { 71 | content: " "; 72 | position: absolute; 73 | left: 4px; 74 | top: 4px; 75 | right: 4px; 76 | height: 1px; 77 | background-color: var(--joplin-divider-color); 78 | } 79 | 80 | .dddot-note-dialog-command-panel-content { 81 | display: flex; 82 | flex-direction: column; 83 | justify-content: flex-start; 84 | } 85 | 86 | .dddot-notedialog-content button { 87 | background-color: transparent; 88 | padding-top: 4px; 89 | padding-bottom: 4px; 90 | border-radius: 4px; 91 | border: 1px solid var(--joplin-color); 92 | text-align: center; 93 | text-decoration: none; 94 | margin-top: 4px; 95 | margin-bottom: 4px; 96 | color: var(--joplin-color); 97 | } 98 | 99 | .dddot-note-dialog-command-panel-content h3 { 100 | margin-block-start: 8px; 101 | margin-block-end: 8px; 102 | } -------------------------------------------------------------------------------- /src/locales/fr_FR.json: -------------------------------------------------------------------------------- 1 | { 2 | "backlinks.title": "Rétroliens", 3 | "backlinks.enable": "Activer les rétroliens", 4 | "shortcuts.title": "Raccourcis", 5 | "shortcuts.enable": "Activer les raccourcis", 6 | "shortcuts.drag_note_here": "Glissez une note ici", 7 | "shortcuts.import_export_tooltip": "Importer/Exporter", 8 | "shortcuts.import": "Importer", 9 | "shortcuts.export": "Exporter", 10 | "shortcuts.imported": "Importé", 11 | "shortcuts.import_error": "Fichier de raccourcis invalide. Veuillez vérifier le contenu du fichier et réessayer.", 12 | "scratchpad.title": "Bloc-notes", 13 | "scratchpad.enable": "Activer le bloc-notes", 14 | "recentnotes.title": "Notes Récentes", 15 | "recentnotes.enable": "Activer les notes récentes", 16 | "scratchpad.settings.height": "Hauteur du bloc-notes (px)", 17 | "notedialog.swap": "Échanger", 18 | "notedialog.swap_tooltip": "Échanger la note de l'éditeur et la note de l'Aperçu rapide", 19 | "notedialog.note_editor": "Éditeur", 20 | "notedialog.quick_view": "Aperçu rapide", 21 | "notedialog.open_note_dddot": "Ouvrir la note dans l'Aperçu rapide DDDot", 22 | "notedialog.cut_append_selected_text": "Couper et ajouter le texte sélectionné", 23 | "notedialog.cut_append_selected_text_tooltip": "Couper le texte sélectionné de l'éditeur et l'ajouter à l'Aperçu rapide", 24 | "notedialog.append_note_link": "Ajouter le lien de la note", 25 | "notedialog.append_note_link_tooltip": "Ajouter le lien de la note de l'éditeur à l'Aperçu rapide", 26 | "dailynote.title": "Note quotidienne", 27 | "dailynote.enable": "Activer la note quotidienne", 28 | "dailynote.open_daily_note": "Ouvrir la note quotidienne", 29 | "dailynote.default_notebook_not_found": "Le carnet par défaut '{{notebook}}' introuvable", 30 | "randomnote.title": "Note aléatoire", 31 | "randomnote.enable": "Activer la note aléatoire", 32 | "randomnote.open_random_note": "Ouvrir une note aléatoire", 33 | "outline.title": "Plan", 34 | "outline.enable": "Activer le plan", 35 | "outline.link_copied": "Lien copié !", 36 | "outline.settings.height": "Hauteur du plan (px)", 37 | "outline.resize_to_fit_tooltip": "Redimensionner la hauteur pour s'adapter au contenu", 38 | "outline.settings.resize_mode": "Mode de redimensionnement du plan", 39 | "outline.settings.manual": "Mode manuel", 40 | "outline.settings.auto": "Mode automatique", 41 | "outline.settings.schemas": "Inclure les URL avec les schémas (séparés par ',', ex: http,https,file)" 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joplin-plugin-joplin-dddot", 3 | "version": "0.4.4", 4 | "scripts": { 5 | "predist": "tailwindcss -i config.css -o src/styles/tailwind.css", 6 | "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", 7 | "update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force", 8 | "lint": "eslint . --ext .ts --ext .js --ext .tsx", 9 | "lint:fix": "eslint . --ext .ts --ext .js --ext .tsx --fix", 10 | "test": "jest --silent false --verbose false", 11 | "test:watch": "jest --watch --silent false --verbose false", 12 | "updateVersion": "webpack --env joplin-plugin-config=updateVersion", 13 | "prepare": "npm run dist" 14 | }, 15 | "license": "MIT", 16 | "keywords": [ 17 | "joplin-plugin" 18 | ], 19 | "devDependencies": { 20 | "@codemirror/view": "6.26.0", 21 | "@babel/core": "^7.17.5", 22 | "@babel/preset-env": "^7.16.11", 23 | "@babel/preset-typescript": "^7.16.7", 24 | "@joplin/lib": "^2.6.3", 25 | "@types/jest": "^27.4.1", 26 | "@types/node": "^18.7.13", 27 | "@typescript-eslint/eslint-plugin": "^7.0.1", 28 | "@typescript-eslint/parser": "^7.0.1", 29 | "babel-jest": "^27.5.1", 30 | "chalk": "^4.1.0", 31 | "copy-webpack-plugin": "^11.0.0", 32 | "eslint": "^8.9.0", 33 | "eslint-plugin-import": "^2.25.4", 34 | "eslint-plugin-react-hooks": "^4.6.0", 35 | "fs-extra": "^10.1.0", 36 | "glob": "^8.0.3", 37 | "jest": "^27.5.1", 38 | "jest-config": "^27.5.1", 39 | "on-build-webpack": "^0.1.0", 40 | "tar": "^6.1.11", 41 | "ts-jest": "^27.1.3", 42 | "ts-loader": "^9.3.1", 43 | "typescript": "^4.8.2", 44 | "wait-for-expect": "^3.0.2", 45 | "webpack": "^5.74.0", 46 | "webpack-cli": "^4.10.0", 47 | "yargs": "^16.2.0" 48 | }, 49 | "dependencies": { 50 | "classnames": "^2.5.1", 51 | "cntl": "^1.0.0", 52 | "crypto-js": "^4.2.0", 53 | "eslint-config-airbnb-base": "^15.0.0", 54 | "fecha": "^4.2.3", 55 | "i18next": "^21.6.16", 56 | "i18next-resources-to-backend": "^1.2.0", 57 | "markdown-it": "^14.0.0", 58 | "react": "^18.2.0", 59 | "react-dnd": "^16.0.1", 60 | "react-dnd-html5-backend": "^16.0.1", 61 | "react-dom": "^18.2.0", 62 | "tailwindcss": "^3.4.1", 63 | "uslug": "git+https://github.com/laurent22/uslug.git#emoji-support" 64 | }, 65 | "files": [ 66 | "publish" 67 | ], 68 | "overrides": { 69 | "immer": "^10.0.3" 70 | } 71 | } -------------------------------------------------------------------------------- /tests/linkgraphupdatequeue.test.ts: -------------------------------------------------------------------------------- 1 | import waitForExpect from "wait-for-expect"; 2 | import JoplinRepo from "../src/repo/joplinrepo"; 3 | import LinkGraphService from "../src/services/linkgraph/linkgraphservice"; 4 | import LinkGraphUpdateQueue from "../src/services/linkgraph/linkgraphupdatequeue"; 5 | import JoplinService from "../src/services/joplin/joplinservice"; 6 | 7 | jest.mock("../src/repo/joplinrepo"); 8 | jest.mock("../src/services/joplin/joplinservice"); 9 | 10 | test("update", async () => { 11 | const joplinService = new JoplinService(new JoplinRepo()); 12 | const service = new LinkGraphService(joplinService); 13 | const queue = new LinkGraphUpdateQueue( 14 | joplinService, 15 | service.graph, 16 | 0, 17 | ); 18 | const node = await queue.update("1"); 19 | 20 | expect(node.id).toBe("1"); 21 | }); 22 | 23 | test("enqueue", async () => { 24 | const joplinService = new JoplinService(new JoplinRepo()); 25 | const service = new LinkGraphService(joplinService); 26 | const queue = new LinkGraphUpdateQueue( 27 | joplinService, 28 | service.graph, 29 | 0, 30 | ); 31 | const node = await queue.enqueue("1"); 32 | 33 | expect(node.id).toBe("1"); 34 | }); 35 | 36 | test("enqueue should first come last serve", async () => { 37 | const joplinService = (new JoplinService(new JoplinRepo())) as any; 38 | const service = new LinkGraphService(joplinService); 39 | const queue = new LinkGraphUpdateQueue( 40 | joplinService, 41 | service.graph, 42 | 100, 43 | ); 44 | queue.enqueue("0"); 45 | queue.enqueue("1"); 46 | queue.enqueue("2"); 47 | 48 | await waitForExpect(() => { 49 | expect(joplinService.searchBacklinks.mock.calls.length).toBe(3); 50 | }); 51 | 52 | const args = joplinService.searchBacklinks.mock.calls.map((item) => item[0]); 53 | 54 | expect( 55 | JSON.stringify(args), 56 | ).toStrictEqual( 57 | JSON.stringify(["2", "1", "0"]), 58 | ); 59 | }); 60 | 61 | test("enqueue should perform deduplication", async () => { 62 | const joplinService = (new JoplinService(new JoplinRepo())) as any; 63 | const service = new LinkGraphService(joplinService); 64 | const queue = new LinkGraphUpdateQueue( 65 | joplinService, 66 | service.graph, 67 | 100, 68 | ); 69 | const p1 = queue.enqueue("0"); 70 | const p2 = queue.enqueue("0"); 71 | expect(queue.queue.length).toBe(1); 72 | expect(queue.queue[0].callbacks.length).toBe(2); 73 | await p1; 74 | await p2; 75 | }); 76 | -------------------------------------------------------------------------------- /api/JoplinContentScripts.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ContentScriptType } from './types'; 3 | export default class JoplinContentScripts { 4 | private plugin; 5 | constructor(plugin: Plugin); 6 | /** 7 | * Registers a new content script. Unlike regular plugin code, which runs in 8 | * a separate process, content scripts run within the main process code and 9 | * thus allow improved performances and more customisations in specific 10 | * cases. It can be used for example to load a Markdown or editor plugin. 11 | * 12 | * Note that registering a content script in itself will do nothing - it 13 | * will only be loaded in specific cases by the relevant app modules (eg. 14 | * the Markdown renderer or the code editor). So it is not a way to inject 15 | * and run arbitrary code in the app, which for safety and performance 16 | * reasons is not supported. 17 | * 18 | * The plugin generator provides a way to build any content script you might 19 | * want to package as well as its dependencies. See the [Plugin Generator 20 | * doc](https://github.com/laurent22/joplin/blob/dev/packages/generator-joplin/README.md) 21 | * for more information. 22 | * 23 | * * [View the renderer demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) 24 | * * [View the editor plugin tutorial](https://joplinapp.org/help/api/tutorials/cm6_plugin) 25 | * * [View the legacy editor demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script) 26 | * 27 | * See also the [postMessage demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) 28 | * 29 | * @param type Defines how the script will be used. See the type definition for more information about each supported type. 30 | * @param id A unique ID for the content script. 31 | * @param scriptPath Must be a path relative to the plugin main script. For example, if your file content_script.js is next to your index.ts file, you would set `scriptPath` to `"./content_script.js`. 32 | */ 33 | register(type: ContentScriptType, id: string, scriptPath: string): Promise; 34 | /** 35 | * Listens to a messages sent from the content script using postMessage(). 36 | * See {@link ContentScriptType} for more information as well as the 37 | * [postMessage 38 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) 39 | */ 40 | onMessage(contentScriptId: string, callback: any): Promise; 41 | } 42 | -------------------------------------------------------------------------------- /tests/dailynotetool.test.ts: -------------------------------------------------------------------------------- 1 | import JoplinRepo from "../src/repo/joplinrepo"; 2 | import ServicePool from "../src/services/servicepool"; 3 | import DailyNoteTool from "../src/tools/dailynote/dailynotetool"; 4 | 5 | jest.mock("../src/repo/joplinrepo"); 6 | jest.mock("../src/services/joplin/joplinservice"); 7 | jest.mock("../src/services/linkgraph/linkgraphupdatequeue"); 8 | jest.mock("../src/services/datetime/datetimeservice"); 9 | 10 | test("getOrCreateDailyNote should create note", async () => { 11 | const joplinRepo = new JoplinRepo() as any; 12 | const servicePool = new ServicePool(joplinRepo); 13 | const dailyNoteTool = new DailyNoteTool(servicePool); 14 | const joplinService = servicePool.joplinService as any; 15 | const dateTimeService = servicePool.dateTimeService as any; 16 | 17 | dateTimeService.getNormalizedToday.mockReturnValueOnce(new Date(2020, 0, 1)); 18 | joplinService.searchNoteByTitle.mockReturnValueOnce([]); 19 | joplinService.urlToId.mockReturnValueOnce("hashId"); 20 | joplinService.queryNotebookId.mockReturnValueOnce("notebookId"); 21 | joplinRepo.settingsLoadGlobal.mockReturnValueOnce("YYYY-MM-DD"); 22 | joplinRepo.settingsLoad.mockReturnValue("default"); 23 | 24 | await dailyNoteTool.getOrCreateDailyNote(); 25 | 26 | expect(joplinService.urlToId).toHaveBeenLastCalledWith("calendar://default/day/2020/01/01"); 27 | expect(joplinService.createNoteWithIdIfNotExists.mock.calls.length).toBe(1); 28 | expect(joplinService.createNoteWithIdIfNotExists.mock.calls[0][0]).toBe( 29 | "hashId", 30 | ); 31 | expect(joplinService.searchNoteByTitle.mock.calls[0][0]).toBe( 32 | "2020-01-01", 33 | ); 34 | }); 35 | 36 | test("getOrCreateDailyNote should show toast if default notebook is not found", async () => { 37 | const joplinRepo = new JoplinRepo() as any; 38 | const servicePool = new ServicePool(joplinRepo); 39 | const dailyNoteTool = new DailyNoteTool(servicePool); 40 | const joplinService = servicePool.joplinService as any; 41 | const dateTimeService = servicePool.dateTimeService as any; 42 | 43 | dateTimeService.getNormalizedToday.mockReturnValueOnce(new Date(2020, 0, 1)); 44 | joplinService.searchNoteByTitle.mockReturnValueOnce([]); 45 | joplinService.urlToId.mockReturnValueOnce("hashId"); 46 | joplinService.queryNotebookId.mockReturnValueOnce(null); // Default notebook not found 47 | joplinRepo.settingsLoadGlobal.mockReturnValueOnce("YYYY-MM-DD"); 48 | joplinRepo.settingsLoad.mockReturnValue("default"); 49 | 50 | await dailyNoteTool.getOrCreateDailyNote(); 51 | 52 | expect(joplinService.queryNotebookId).toHaveBeenCalledWith("default"); 53 | expect(joplinRepo.toast).toHaveBeenCalledTimes(1); 54 | expect(joplinService.createNoteWithIdIfNotExists).not.toHaveBeenCalled(); 55 | }); 56 | -------------------------------------------------------------------------------- /src/services/linkgraph/linkgraphupdatequeue.ts: -------------------------------------------------------------------------------- 1 | import TimerRepo from "../../repo/timerrepo"; 2 | import JoplinService from "../joplin/joplinservice"; 3 | import LinkGraphNode from "./linkgraphnode"; 4 | 5 | interface LinkGraphUpdateQueueItem { 6 | id: string; 7 | 8 | callbacks: ((value: LinkGraphNode | PromiseLike) => void)[]; 9 | } 10 | 11 | export default class LinkGraphUpdateQueue { 12 | graph: Map; 13 | 14 | interval = 100; 15 | 16 | queue: LinkGraphUpdateQueueItem[]; 17 | 18 | joplinService: JoplinService; 19 | 20 | isRunning: Boolean = false; 21 | 22 | timeRepo: TimerRepo; 23 | 24 | constructor( 25 | joplinService: JoplinService, 26 | graph: Map, 27 | interval: number, 28 | timeRepo: TimerRepo = new TimerRepo(), 29 | ) { 30 | this.joplinService = joplinService; 31 | this.graph = graph; 32 | this.interval = interval; 33 | this.queue = []; 34 | this.timeRepo = timeRepo; 35 | } 36 | 37 | // Update a node immediately 38 | async update(id: string): Promise { 39 | const links = await this.joplinService.searchBacklinks(id); 40 | if (!this.graph.has(id)) { 41 | const node = new LinkGraphNode(); 42 | node.id = id; 43 | this.graph.set(id, node); 44 | } 45 | 46 | const currentNode = this.graph.get(id); 47 | currentNode.backlinks = links; 48 | return currentNode; 49 | } 50 | 51 | async enqueue(id: string): Promise { 52 | return new Promise((resolve) => { 53 | const index = this.queue.findIndex((item) => item.id === id); 54 | if (index >= 0) { 55 | const item = this.queue[index]; 56 | this.queue.splice(index, 1); 57 | item.callbacks.push(resolve); 58 | this.queue.push(item); 59 | } else { 60 | const item : LinkGraphUpdateQueueItem = { 61 | id, 62 | callbacks: [resolve], 63 | }; 64 | 65 | this.queue.push(item); 66 | } 67 | 68 | this.run(); 69 | }); 70 | } 71 | 72 | private async run() { 73 | if (this.isRunning) { 74 | return; 75 | } 76 | this.isRunning = true; 77 | 78 | for (;;) { 79 | await this.timeRepo.sleep(this.interval); 80 | 81 | if (this.queue.length === 0) { 82 | break; 83 | } 84 | 85 | const next = this.queue.pop(); 86 | const result = await this.update(next.id); 87 | next.callbacks.forEach((callback) => callback(result)); 88 | } 89 | 90 | this.isRunning = false; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/views/resizablecontainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | minHeight: number; 5 | setHeight: (height: number) => void; 6 | getHeight: () => number; 7 | children?: React.ReactNode; 8 | } 9 | 10 | type State = { 11 | isMouseDown: boolean; 12 | startY: number; 13 | height: number; 14 | dragStartHeight: number; 15 | } 16 | 17 | export function ResizableContainer(props: Props) { 18 | const { 19 | minHeight, 20 | children, 21 | } = props; 22 | 23 | const handleRef = React.useRef(null); 24 | 25 | const state = React.useRef({ 26 | isMouseDown: false, 27 | startY: -1, 28 | dragStartHeight: 0, 29 | }); 30 | 31 | const setHeightRef = React.useRef(props.setHeight); 32 | setHeightRef.current = props.setHeight; 33 | 34 | const getHeightRef = React.useRef(props.getHeight); 35 | getHeightRef.current = props.getHeight; 36 | 37 | React.useEffect(() => { 38 | const handle = handleRef.current; 39 | if (!handle) { 40 | return; 41 | } 42 | 43 | const updateHeight = (newHeight: number) => { 44 | state.current.height = newHeight; 45 | setHeightRef.current(newHeight); 46 | }; 47 | 48 | const onMouseDown = (e: MouseEvent) => { 49 | e.preventDefault(); 50 | e.stopPropagation(); 51 | state.current.isMouseDown = true; 52 | state.current.startY = e.clientY; 53 | state.current.dragStartHeight = getHeightRef.current(); 54 | }; 55 | 56 | const onMouseMove = (e: MouseEvent) => { 57 | if (state.current.isMouseDown) { 58 | e.preventDefault(); 59 | e.stopPropagation(); 60 | const newHeight = state.current.dragStartHeight + e.clientY - state.current.startY; 61 | if (newHeight >= minHeight) { 62 | updateHeight(newHeight); 63 | } 64 | } 65 | }; 66 | 67 | const onMouseUp = (e: MouseEvent) => { 68 | if (state.current.isMouseDown) { 69 | state.current.isMouseDown = false; 70 | e.preventDefault(); 71 | e.stopPropagation(); 72 | } 73 | }; 74 | 75 | handle.addEventListener("mousedown", onMouseDown); 76 | document.addEventListener("mouseup", onMouseUp); 77 | document.addEventListener("mousemove", onMouseMove); 78 | 79 | // eslint-disable-next-line consistent-return 80 | return () => { 81 | handle.removeEventListener("mousedown", onMouseDown); 82 | document.removeEventListener("mouseup", onMouseUp); 83 | document.removeEventListener("mousemove", onMouseMove); 84 | }; 85 | // eslint-disable-next-line react-hooks/exhaustive-deps 86 | }, []); 87 | 88 | return ( 89 |
90 | {children} 91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/services/servicepool.ts: -------------------------------------------------------------------------------- 1 | import JoplinRepo from "src/repo/joplinrepo"; 2 | import { MenuItem } from "api/types"; 3 | import JoplinService from "./joplin/joplinservice"; 4 | import LinkGraphService from "./linkgraph/linkgraphservice"; 5 | import RendererService from "./renderer/rendererservice"; 6 | import NoteDialogService from "./notedialog/notedialogservice"; 7 | import ToolbarService from "./toolbar/toolbarservice"; 8 | import DateTimeService from "./datetime/datetimeservice"; 9 | import PlatformRepo from "../repo/platformrepo"; 10 | import { MarkdownParserService } from "./markdownparser/markdownparserservice"; 11 | 12 | export default class ServicePool { 13 | joplinService: JoplinService; 14 | 15 | linkGraphService: LinkGraphService; 16 | 17 | rendererService: RendererService; 18 | 19 | joplinRepo: JoplinRepo; 20 | 21 | noteDialogService: NoteDialogService; 22 | 23 | toolbarService: ToolbarService; 24 | 25 | dateTimeService: DateTimeService; 26 | 27 | platformRepo: PlatformRepo; 28 | 29 | markdownParserService: MarkdownParserService; 30 | 31 | receivers: { [key: string]: any } = {}; 32 | 33 | constructor(joplinRepo: JoplinRepo) { 34 | this.joplinRepo = joplinRepo; 35 | this.platformRepo = new PlatformRepo(); 36 | this.joplinService = new JoplinService(this.joplinRepo, this.platformRepo); 37 | this.linkGraphService = new LinkGraphService(this.joplinService); 38 | this.rendererService = new RendererService(); 39 | this.noteDialogService = new NoteDialogService(this.joplinService, this.rendererService); 40 | this.toolbarService = new ToolbarService(this.joplinService); 41 | this.dateTimeService = new DateTimeService(); 42 | this.markdownParserService = new MarkdownParserService(); 43 | 44 | this.receivers = { 45 | notedialog: this.noteDialogService, 46 | toolbar: this.toolbarService, 47 | }; 48 | } 49 | 50 | get assetFiles() { 51 | return [ 52 | "./services/notedialog/notedialogworker.js", 53 | "./services/notedialog/notedialog.css", 54 | "./services/toolbar/toolbarworker.js", 55 | "./services/toolbar/toolbar.css", 56 | ]; 57 | } 58 | 59 | get serviceWorkerFunctions() { 60 | return [ 61 | "noteDialogWorker", 62 | "toolbarWorker", 63 | ]; 64 | } 65 | 66 | async onLoaded() { 67 | const services = [ 68 | this.toolbarService, 69 | ]; 70 | await Promise.all(services.map((service) => service.onLoaded())); 71 | } 72 | 73 | async registerCommands(): Promise { 74 | let res = []; 75 | res = res.concat(await this.noteDialogService.registerCommands()); 76 | return res; 77 | } 78 | 79 | hasReceiver(target: string) { 80 | return target in this.receivers; 81 | } 82 | 83 | async onMessage(message: any) { 84 | const token = message.type.split("."); 85 | const target = token[0]; 86 | return this.receivers[target].onMessage(message); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /api/JoplinViewsPanels.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ViewHandle } from './types'; 3 | /** 4 | * Allows creating and managing view panels. View panels allow displaying any HTML 5 | * content (within a webview) and updating it in real-time. For example it 6 | * could be used to display a table of content for the active note, or 7 | * display various metadata or graph. 8 | * 9 | * On desktop, view panels currently are displayed at the right of the sidebar, though can 10 | * be moved with "View" > "Change application layout". 11 | * 12 | * On mobile, view panels are shown in a tabbed dialog that can be opened using a 13 | * toolbar button. 14 | * 15 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/toc) 16 | */ 17 | export default class JoplinViewsPanels { 18 | private store; 19 | private plugin; 20 | constructor(plugin: Plugin, store: any); 21 | private controller; 22 | /** 23 | * Creates a new panel 24 | */ 25 | create(id: string): Promise; 26 | /** 27 | * Sets the panel webview HTML 28 | */ 29 | setHtml(handle: ViewHandle, html: string): Promise; 30 | /** 31 | * Adds and loads a new JS or CSS files into the panel. 32 | */ 33 | addScript(handle: ViewHandle, scriptPath: string): Promise; 34 | /** 35 | * Called when a message is sent from the webview (using postMessage). 36 | * 37 | * To post a message from the webview to the plugin use: 38 | * 39 | * ```javascript 40 | * const response = await webviewApi.postMessage(message); 41 | * ``` 42 | * 43 | * - `message` can be any JavaScript object, string or number 44 | * - `response` is whatever was returned by the `onMessage` handler 45 | * 46 | * Using this mechanism, you can have two-way communication between the 47 | * plugin and webview. 48 | * 49 | * See the [postMessage 50 | * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) for more details. 51 | * 52 | */ 53 | onMessage(handle: ViewHandle, callback: Function): Promise; 54 | /** 55 | * Sends a message to the webview. 56 | * 57 | * The webview must have registered a message handler prior, otherwise the message is ignored. Use; 58 | * 59 | * ```javascript 60 | * webviewApi.onMessage((message) => { ... }); 61 | * ``` 62 | * 63 | * - `message` can be any JavaScript object, string or number 64 | * 65 | * The view API may have only one onMessage handler defined. 66 | * This method is fire and forget so no response is returned. 67 | * 68 | * It is particularly useful when the webview needs to react to events emitted by the plugin or the joplin api. 69 | */ 70 | postMessage(handle: ViewHandle, message: any): void; 71 | /** 72 | * Shows the panel 73 | */ 74 | show(handle: ViewHandle, show?: boolean): Promise; 75 | /** 76 | * Hides the panel 77 | */ 78 | hide(handle: ViewHandle): Promise; 79 | /** 80 | * Tells whether the panel is visible or not 81 | */ 82 | visible(handle: ViewHandle): Promise; 83 | isActive(handle: ViewHandle): Promise; 84 | } 85 | -------------------------------------------------------------------------------- /src/tools/shortcuts/overlay.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { t } from "i18next"; 3 | import { Overlay } from "../../views/overlay"; 4 | import { PrimaryButton } from "../../views/primarybutton"; 5 | 6 | import { Link } from "../../types/link"; 7 | import { ShortcutsStorage, ShortcutsStorageValidator } from "./types"; 8 | 9 | export function ShortcutsOverlay(props: { 10 | links?: Link[]; 11 | onClose: () => void; 12 | }) { 13 | const { 14 | links, 15 | } = props; 16 | 17 | const exportShortcuts = React.useCallback(() => { 18 | const storage = { 19 | version: 1, 20 | shortcuts: links ?? [], 21 | } as ShortcutsStorage; 22 | 23 | const data = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(storage, null, 4))}`; 24 | const elem = document.createElement("a"); 25 | elem.setAttribute("href", data); 26 | elem.setAttribute("download", "shortcuts.json"); 27 | elem.click(); 28 | elem.remove(); 29 | }, [links]); 30 | 31 | const importShortcuts = React.useCallback(() => { 32 | const elem = document.createElement("input") as HTMLInputElement; 33 | elem.type = "file"; 34 | elem.style.display = "none"; 35 | document.body.appendChild(elem); 36 | elem.accept = "application/json"; 37 | elem.onchange = (e: Event) => { 38 | if (e.target instanceof HTMLInputElement) { 39 | const { files } = e.target; 40 | const file = files[0]; 41 | 42 | const reader = new FileReader(); 43 | reader.addEventListener( 44 | "load", 45 | () => { 46 | try { 47 | const content = JSON.parse(reader.result as string); 48 | const validator = new ShortcutsStorageValidator(); 49 | const res = validator.validate(content); 50 | if (!res) { 51 | throw new Error(); 52 | } 53 | DDDot.postMessage({ 54 | type: "shortcuts.importShortcuts", 55 | shortcuts: content.shortcuts, 56 | }); 57 | alert(t("shortcuts.imported")); 58 | props.onClose(); 59 | } catch { 60 | alert(t("shortcuts.import_error")); 61 | } 62 | }, 63 | ); 64 | reader.readAsText(file); 65 | } 66 | }; 67 | elem.click(); 68 | }, [props]); 69 | 70 | return ( 71 | {t("shortcuts.title")})} 73 | onClose={props.onClose} 74 | > 75 |
76 | 77 | {t("shortcuts.import")} 78 | 79 | 80 | {t("shortcuts.export")} 81 | 82 |
83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /api/Joplin.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import JoplinData from './JoplinData'; 3 | import JoplinPlugins from './JoplinPlugins'; 4 | import JoplinWorkspace from './JoplinWorkspace'; 5 | import JoplinFilters from './JoplinFilters'; 6 | import JoplinCommands from './JoplinCommands'; 7 | import JoplinViews from './JoplinViews'; 8 | import JoplinInterop from './JoplinInterop'; 9 | import JoplinSettings from './JoplinSettings'; 10 | import JoplinContentScripts from './JoplinContentScripts'; 11 | import JoplinClipboard from './JoplinClipboard'; 12 | import JoplinWindow from './JoplinWindow'; 13 | import BasePlatformImplementation from '../BasePlatformImplementation'; 14 | import JoplinImaging from './JoplinImaging'; 15 | /** 16 | * This is the main entry point to the Joplin API. You can access various services using the provided accessors. 17 | * 18 | * The API is now relatively stable and in general maintaining backward compatibility is a top priority, so you shouldn't except much breakages. 19 | * 20 | * If a breaking change ever becomes needed, best effort will be done to: 21 | * 22 | * - Deprecate features instead of removing them, so as to give you time to fix the issue; 23 | * - Document breaking changes in the changelog; 24 | * 25 | * So if you are developing a plugin, please keep an eye on the changelog as everything will be in there with information about how to update your code. 26 | */ 27 | export default class Joplin { 28 | private data_; 29 | private plugins_; 30 | private imaging_; 31 | private workspace_; 32 | private filters_; 33 | private commands_; 34 | private views_; 35 | private interop_; 36 | private settings_; 37 | private contentScripts_; 38 | private clipboard_; 39 | private window_; 40 | private implementation_; 41 | constructor(implementation: BasePlatformImplementation, plugin: Plugin, store: any); 42 | get data(): JoplinData; 43 | get clipboard(): JoplinClipboard; 44 | get imaging(): JoplinImaging; 45 | get window(): JoplinWindow; 46 | get plugins(): JoplinPlugins; 47 | get workspace(): JoplinWorkspace; 48 | get contentScripts(): JoplinContentScripts; 49 | /** 50 | * @ignore 51 | * 52 | * Not sure if it's the best way to hook into the app 53 | * so for now disable filters. 54 | */ 55 | get filters(): JoplinFilters; 56 | get commands(): JoplinCommands; 57 | get views(): JoplinViews; 58 | get interop(): JoplinInterop; 59 | get settings(): JoplinSettings; 60 | /** 61 | * It is not possible to bundle native packages with a plugin, because they 62 | * need to work cross-platforms. Instead access to certain useful native 63 | * packages is provided using this function. 64 | * 65 | * Currently these packages are available: 66 | * 67 | * - [sqlite3](https://www.npmjs.com/package/sqlite3) 68 | * - [fs-extra](https://www.npmjs.com/package/fs-extra) 69 | * 70 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/nativeModule) 71 | * 72 | * desktop 73 | */ 74 | require(_path: string): any; 75 | versionInfo(): Promise; 76 | /** 77 | * Tells whether the current theme is a dark one or not. 78 | */ 79 | shouldUseDarkColors(): Promise; 80 | } 81 | -------------------------------------------------------------------------------- /api/JoplinViewsDialogs.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ButtonSpec, ViewHandle, DialogResult, Toast } from './types'; 3 | /** 4 | * Allows creating and managing dialogs. A dialog is modal window that 5 | * contains a webview and a row of buttons. You can update the 6 | * webview using the `setHtml` method. Dialogs are hidden by default and 7 | * you need to call `open()` to open them. Once the user clicks on a 8 | * button, the `open` call will return an object indicating what button was 9 | * clicked on. 10 | * 11 | * ## Retrieving form values 12 | * 13 | * If your HTML content included one or more forms, a `formData` object 14 | * will also be included with the key/value for each form. 15 | * 16 | * ## Special button IDs 17 | * 18 | * The following buttons IDs have a special meaning: 19 | * 20 | * - `ok`, `yes`, `submit`, `confirm`: They are considered "submit" buttons 21 | * - `cancel`, `no`, `reject`: They are considered "dismiss" buttons 22 | * 23 | * This information is used by the application to determine what action 24 | * should be done when the user presses "Enter" or "Escape" within the 25 | * dialog. If they press "Enter", the first "submit" button will be 26 | * automatically clicked. If they press "Escape" the first "dismiss" button 27 | * will be automatically clicked. 28 | * 29 | * [View the demo 30 | * plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/dialog) 31 | */ 32 | export default class JoplinViewsDialogs { 33 | private store; 34 | private plugin; 35 | private implementation_; 36 | constructor(implementation: any, plugin: Plugin, store: any); 37 | private controller; 38 | /** 39 | * Creates a new dialog 40 | */ 41 | create(id: string): Promise; 42 | /** 43 | * Displays a message box with OK/Cancel buttons. Returns the button index that was clicked - "0" for OK and "1" for "Cancel" 44 | */ 45 | showMessageBox(message: string): Promise; 46 | /** 47 | * Displays a Toast notification in the corner of the application screen. 48 | */ 49 | showToast(toast: Toast): Promise; 50 | /** 51 | * Displays a dialog to select a file or a directory. Same options and 52 | * output as 53 | * https://www.electronjs.org/docs/latest/api/dialog#dialogshowopendialogbrowserwindow-options 54 | * 55 | * desktop 56 | */ 57 | showOpenDialog(options: any): Promise; 58 | /** 59 | * Sets the dialog HTML content 60 | */ 61 | setHtml(handle: ViewHandle, html: string): Promise; 62 | /** 63 | * Adds and loads a new JS or CSS files into the dialog. 64 | */ 65 | addScript(handle: ViewHandle, scriptPath: string): Promise; 66 | /** 67 | * Sets the dialog buttons. 68 | */ 69 | setButtons(handle: ViewHandle, buttons: ButtonSpec[]): Promise; 70 | /** 71 | * Opens the dialog. 72 | * 73 | * On desktop, this closes any copies of the dialog open in different windows. 74 | */ 75 | open(handle: ViewHandle): Promise; 76 | /** 77 | * Toggle on whether to fit the dialog size to the content or not. 78 | * When set to false, the dialog is set to 90vw and 80vh 79 | * @default true 80 | */ 81 | setFitToContent(handle: ViewHandle, status: boolean): Promise; 82 | } 83 | -------------------------------------------------------------------------------- /api/JoplinSettings.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { SettingItem, SettingSection } from './types'; 3 | export interface ChangeEvent { 4 | /** 5 | * Setting keys that have been changed 6 | */ 7 | keys: string[]; 8 | } 9 | export type ChangeHandler = (event: ChangeEvent) => void; 10 | /** 11 | * This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user. 12 | * 13 | * Settings are essentially key/value pairs. 14 | * 15 | * Note: Currently this API does **not** provide access to Joplin's built-in settings. This is by design as plugins that modify user settings could give unexpected results 16 | * 17 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/settings) 18 | */ 19 | export default class JoplinSettings { 20 | private plugin_; 21 | constructor(plugin: Plugin); 22 | /** 23 | * Registers new settings. 24 | * Note that registering a setting item is dynamic and will be gone next time Joplin starts. 25 | * What it means is that you need to register the setting every time the plugin starts (for example in the onStart event). 26 | * The setting value however will be preserved from one launch to the next so there is no risk that it will be lost even if for some 27 | * reason the plugin fails to start at some point. 28 | */ 29 | registerSettings(settings: Record): Promise; 30 | /** 31 | * @deprecated Use joplin.settings.registerSettings() 32 | * 33 | * Registers a new setting. 34 | */ 35 | registerSetting(key: string, settingItem: SettingItem): Promise; 36 | /** 37 | * Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts. 38 | */ 39 | registerSection(name: string, section: SettingSection): Promise; 40 | /** 41 | * Gets setting values (only applies to setting you registered from your plugin) 42 | */ 43 | values(keys: string[] | string): Promise>; 44 | /** 45 | * @deprecated Use joplin.settings.values() 46 | * 47 | * Gets a setting value (only applies to setting you registered from your plugin) 48 | */ 49 | value(key: string): Promise; 50 | /** 51 | * Sets a setting value (only applies to setting you registered from your plugin) 52 | */ 53 | setValue(key: string, value: any): Promise; 54 | /** 55 | * Gets global setting values, including app-specific settings and those set by other plugins. 56 | * 57 | * The list of available settings is not documented yet, but can be found by looking at the source code: 58 | * 59 | * https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts 60 | */ 61 | globalValues(keys: string[]): Promise; 62 | /** 63 | * @deprecated Use joplin.settings.globalValues() 64 | */ 65 | globalValue(key: string): Promise; 66 | /** 67 | * Called when one or multiple settings of your plugin have been changed. 68 | * - For performance reasons, this event is triggered with a delay. 69 | * - You will only get events for your own plugin settings. 70 | */ 71 | onChange(handler: ChangeHandler): Promise; 72 | } 73 | -------------------------------------------------------------------------------- /tests/shortcuts.test.ts: -------------------------------------------------------------------------------- 1 | import Shortcuts from "../src/tools/shortcuts"; 2 | import JoplinRepo from "../src/repo/joplinrepo"; 3 | import ServicePool from "../src/services/servicepool"; 4 | import { LinkMonad } from "../src/types/link"; 5 | import { ShortcutsStorageValidator } from "../src/tools/shortcuts/types"; 6 | import LinkListModel from "../src/models/linklistmodel"; 7 | 8 | jest.mock("../src/repo/joplinrepo"); 9 | // eslint-disable-next-line func-names 10 | jest.mock("../src/repo/platformrepo", () => function () { 11 | this.isLinux = () => true; 12 | }); 13 | 14 | describe("Shortcuts Tool", () => { 15 | test("removeNote - it should ask for confirmation", async () => { 16 | const joplinRepo: any = new JoplinRepo(); 17 | const servicePool = new ServicePool(joplinRepo); 18 | const tool = new Shortcuts(servicePool); 19 | 20 | const ids = ["1", "2", "3"]; 21 | const model = new LinkListModel(); 22 | ids.forEach((id) => { 23 | const link = LinkMonad.createNoteLink(id, `title:${id}`); 24 | model.push(link); 25 | }); 26 | 27 | tool.linkListModel = model; 28 | joplinRepo.dialogOpen.mockReturnValue({ id: "ok" }); 29 | 30 | await tool.removeLink("1"); 31 | 32 | expect(tool.linkListModel.links.length).toBe(ids.length - 1); 33 | }); 34 | 35 | test("removeNote - user could refuse to remove", async () => { 36 | const joplinRepo: any = new JoplinRepo(); 37 | const servicePool = new ServicePool(joplinRepo); 38 | const tool = new Shortcuts(servicePool); 39 | 40 | const ids = ["1", "2", "3"]; 41 | const model = new LinkListModel(); 42 | ids.forEach((id) => { 43 | const link = LinkMonad.createNoteLink(id, `title:${id}`); 44 | model.push(link); 45 | }); 46 | 47 | tool.linkListModel = model; 48 | joplinRepo.dialogOpen.mockReturnValue({ id: "cancel" }); 49 | 50 | await tool.removeLink("1"); 51 | 52 | expect(tool.linkListModel.links.length).toBe(ids.length); 53 | }); 54 | }); 55 | 56 | describe("shortcuts storage", () => { 57 | test("validate - it should return true if the link is valid", () => { 58 | const storage = { 59 | version: 1, 60 | shortcuts: [ 61 | { 62 | id: "123", 63 | title: "123", 64 | type: "note", 65 | isTodo: false, 66 | isTodoCompleted: false, 67 | }, 68 | { // isTodo and isTodoCompleted is optional fields 69 | id: "fd43ba75224e4475b2a8a29662e1c008", 70 | title: "Inbox", 71 | type: "FolderLink", 72 | }, 73 | { 74 | id: "ffc86962b2d34474bb774b52d3f07373", 75 | title: "Test Note", 76 | type: "NoteLink", 77 | }, 78 | ], 79 | }; 80 | 81 | const validator = new ShortcutsStorageValidator(); 82 | const result = validator.validate(storage); 83 | expect(result).toBe(true); 84 | }); 85 | 86 | test("validate - it should return false for invalid format", () => { 87 | const validator = new ShortcutsStorageValidator(); 88 | expect(validator.validate({})).toBe(false); 89 | expect(validator.validate(undefined)).toBe(false); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/linklistmodel.test.ts: -------------------------------------------------------------------------------- 1 | import LinkListModel from "../src/models/linklistmodel"; 2 | import { LinkMonad } from "../src/types/link"; 3 | 4 | test("push", () => { 5 | const model = new LinkListModel(); 6 | expect(model.links).toStrictEqual([]); 7 | const link = LinkMonad.createNoteLink("id", "title"); 8 | model.push(link); 9 | expect(model.links).toStrictEqual([link]); 10 | }); 11 | 12 | test("push duplicated should remove", () => { 13 | const ids = ["1", "2", "3"]; 14 | const model = new LinkListModel(); 15 | ids.forEach((id) => { 16 | const link = LinkMonad.createNoteLink(id, "title"); 17 | model.push(link); 18 | }); 19 | 20 | const link = LinkMonad.createNoteLink("1", "title"); 21 | model.push(link); 22 | 23 | expect(model.links.length).toBe(3); 24 | expect(model.links[2].id).toBe("1"); 25 | }); 26 | 27 | test("reoreder", () => { 28 | const ids = ["1", "2", "3"]; 29 | 30 | const model = new LinkListModel(); 31 | 32 | ids.forEach((id) => { 33 | const link = LinkMonad.createNoteLink(id, "title"); 34 | model.push(link); 35 | }); 36 | 37 | model.reorder(["2", "3"]); 38 | 39 | expect( 40 | model.links.map((link) => link.id), 41 | ).toStrictEqual( 42 | ["2", "3", "1"], 43 | ); 44 | }); 45 | 46 | test("update", () => { 47 | const ids = ["1", "2", "3"]; 48 | const title = "new title"; 49 | 50 | const model = new LinkListModel(); 51 | 52 | ids.forEach((id) => { 53 | const link = LinkMonad.createNoteLink(id, "title"); 54 | model.push(link); 55 | }); 56 | 57 | const res = model.update("2", { 58 | title, 59 | }); 60 | 61 | expect(res).toBe(true); 62 | 63 | expect( 64 | model.links[1].title, 65 | ).toEqual( 66 | title, 67 | ); 68 | }); 69 | 70 | test("update - return false if nothing changed", () => { 71 | const ids = ["1", "2", "3"]; 72 | const title = "title"; 73 | 74 | const model = new LinkListModel(); 75 | 76 | ids.forEach((id) => { 77 | const link = LinkMonad.createNoteLink(id, "title"); 78 | model.push(link); 79 | }); 80 | 81 | const res = model.update("2", { 82 | title, 83 | }); 84 | 85 | expect(res).toBe(false); 86 | 87 | expect( 88 | model.links[1].title, 89 | ).toEqual( 90 | title, 91 | ); 92 | }); 93 | 94 | test("dehydrate", () => { 95 | const model = new LinkListModel(); 96 | 97 | model.push(LinkMonad.createNoteLink("1", "1")); 98 | model.push(LinkMonad.createNoteLink("2", "2")); 99 | const json = model.dehydrate(); 100 | const expected = [ 101 | { 102 | id: "1", title: "1", type: "NoteLink", isTodo: false, isTodoCompleted: false, 103 | }, 104 | { 105 | id: "2", title: "2", type: "NoteLink", isTodo: false, isTodoCompleted: false, 106 | }, 107 | ]; 108 | expect(json).toStrictEqual(expected); 109 | }); 110 | 111 | test("rehydrate", () => { 112 | const input = [{ id: "1", title: "1", type: "NoteLink" }, { id: "2", title: "2", type: "NoteLink" }]; 113 | const model = new LinkListModel(); 114 | 115 | model.rehydrate(input); 116 | 117 | expect(model.links.length).toBe(2); 118 | expect(model.links[1].id).toBe("2"); 119 | }); 120 | 121 | test("rehydrate could filter invalid data", () => { 122 | const input = [{}, { id: "1", title: "1", type: "NoteLink" }, { id: "2", title: "2", type: "NoteLink" }]; 123 | const model = new LinkListModel(); 124 | 125 | model.rehydrate(input); 126 | 127 | expect(model.links.length).toBe(2); 128 | expect(model.links[1].id).toBe("2"); 129 | }); 130 | -------------------------------------------------------------------------------- /tests/randomnote.test.ts: -------------------------------------------------------------------------------- 1 | import RandomNoteTool from "../src/tools/randomnote/randomnotetool"; 2 | import JoplinRepo from "../src/repo/joplinrepo"; 3 | import ServicePool from "../src/services/servicepool"; 4 | 5 | describe("RandomNoteTool", () => { 6 | let joplinRepo: any; 7 | let servicePool: ServicePool; 8 | let randomNoteTool: RandomNoteTool; 9 | let joplinService: any; 10 | 11 | beforeEach(() => { 12 | joplinRepo = new JoplinRepo(); 13 | servicePool = new ServicePool(joplinRepo); 14 | servicePool.joplinService = { 15 | getAllNoteIds: jest.fn(), 16 | openNote: jest.fn(), 17 | } as any; 18 | randomNoteTool = new RandomNoteTool(servicePool); 19 | joplinService = servicePool.joplinService; 20 | }); 21 | 22 | describe("getNoteIds", () => { 23 | test("should fetch note IDs from JoplinService when cache is null", async () => { 24 | const mockNoteIds = ["note1", "note2", "note3"]; 25 | joplinService.getAllNoteIds.mockResolvedValueOnce(mockNoteIds); 26 | 27 | const result = await randomNoteTool.getNoteIds(); 28 | 29 | expect(result).toEqual(mockNoteIds); 30 | expect(joplinService.getAllNoteIds).toHaveBeenCalledTimes(1); 31 | }); 32 | 33 | test("should use cached note IDs when cache is valid", async () => { 34 | const mockNoteIds = ["note1", "note2", "note3"]; 35 | joplinService.getAllNoteIds.mockResolvedValueOnce(mockNoteIds); 36 | 37 | await randomNoteTool.getNoteIds(); 38 | 39 | const result = await randomNoteTool.getNoteIds(); 40 | 41 | expect(result).toEqual(mockNoteIds); 42 | expect(joplinService.getAllNoteIds).toHaveBeenCalledTimes(1); 43 | }); 44 | 45 | test("should refresh cache in background when cache is expired", async () => { 46 | const mockNoteIds = ["note1", "note2", "note3"]; 47 | const newMockNoteIds = ["note4", "note5", "note6"]; 48 | 49 | joplinService.getAllNoteIds.mockResolvedValueOnce(mockNoteIds); 50 | 51 | await randomNoteTool.getNoteIds(); 52 | 53 | const originalDateNow = Date.now; 54 | Date.now = jest.fn(() => originalDateNow() + 6 * 60 * 1000); // 6 minutes later 55 | 56 | joplinService.getAllNoteIds.mockResolvedValueOnce(newMockNoteIds); 57 | 58 | const result = await randomNoteTool.getNoteIds(); 59 | 60 | expect(result).toEqual(mockNoteIds); 61 | expect(joplinService.getAllNoteIds).toHaveBeenCalledTimes(2); 62 | 63 | Date.now = originalDateNow; 64 | }); 65 | 66 | test("should handle errors when fetching note IDs", async () => { 67 | joplinService.getAllNoteIds.mockRejectedValueOnce(new Error("Test error")); 68 | 69 | await expect(randomNoteTool.getNoteIds()).rejects.toThrow("Test error"); 70 | }); 71 | }); 72 | 73 | describe("openRandomNote", () => { 74 | test("openRandomNote should handle empty note list", async () => { 75 | joplinService.getAllNoteIds.mockResolvedValueOnce([]); 76 | 77 | await randomNoteTool.openRandomNote(); 78 | 79 | expect(joplinService.openNote).not.toHaveBeenCalled(); 80 | }); 81 | 82 | test("openRandomNote should open a random note", async () => { 83 | const mockNoteIds = ["note1", "note2", "note3"]; 84 | joplinService.getAllNoteIds.mockResolvedValueOnce(mockNoteIds); 85 | 86 | const mathRandomSpy = jest.spyOn(Math, "random").mockReturnValue(0.5); 87 | 88 | await randomNoteTool.openRandomNote(); 89 | 90 | expect(joplinService.openNote).toHaveBeenCalledWith("note2"); 91 | 92 | mathRandomSpy.mockRestore(); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /api/JoplinImaging.d.ts: -------------------------------------------------------------------------------- 1 | import { Rectangle } from './types'; 2 | export interface CreateFromBufferOptions { 3 | width?: number; 4 | height?: number; 5 | scaleFactor?: number; 6 | } 7 | export interface CreateFromPdfOptions { 8 | /** 9 | * The first page to export. Defaults to `1`, the first page in 10 | * the document. 11 | */ 12 | minPage?: number; 13 | /** 14 | * The number of the last page to convert. Defaults to the last page 15 | * if not given. 16 | * 17 | * If `maxPage` is greater than the number of pages in the PDF, all pages 18 | * in the PDF will be converted to images. 19 | */ 20 | maxPage?: number; 21 | scaleFactor?: number; 22 | } 23 | export interface PdfInfo { 24 | pageCount: number; 25 | } 26 | export interface Implementation { 27 | createFromPath: (path: string) => Promise; 28 | createFromPdf: (path: string, options: CreateFromPdfOptions) => Promise; 29 | getPdfInfo: (path: string) => Promise; 30 | } 31 | export interface ResizeOptions { 32 | width?: number; 33 | height?: number; 34 | quality?: 'good' | 'better' | 'best'; 35 | } 36 | export type Handle = string; 37 | /** 38 | * Provides imaging functions to resize or process images. You create an image 39 | * using one of the `createFrom` functions, then use the other functions to 40 | * process the image. 41 | * 42 | * Images are associated with a handle which is what will be available to the 43 | * plugin. Once you are done with an image, free it using the `free()` function. 44 | * 45 | * [View the 46 | * example](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/imaging/src/index.ts) 47 | * 48 | * desktop 49 | */ 50 | export default class JoplinImaging { 51 | private implementation_; 52 | private images_; 53 | constructor(implementation: Implementation); 54 | private createImageHandle; 55 | private imageByHandle; 56 | private cacheImage; 57 | /** 58 | * Creates an image from the provided path. Note that images and PDFs are supported. If you 59 | * provide a URL instead of a local path, the file will be downloaded first then converted to an 60 | * image. 61 | */ 62 | createFromPath(filePath: string): Promise; 63 | createFromResource(resourceId: string): Promise; 64 | createFromPdfPath(path: string, options?: CreateFromPdfOptions): Promise; 65 | createFromPdfResource(resourceId: string, options?: CreateFromPdfOptions): Promise; 66 | getPdfInfoFromPath(path: string): Promise; 67 | getPdfInfoFromResource(resourceId: string): Promise; 68 | getSize(handle: Handle): Promise; 69 | resize(handle: Handle, options?: ResizeOptions): Promise; 70 | crop(handle: Handle, rectangle: Rectangle): Promise; 71 | toPngFile(handle: Handle, filePath: string): Promise; 72 | /** 73 | * Quality is between 0 and 100 74 | */ 75 | toJpgFile(handle: Handle, filePath: string, quality?: number): Promise; 76 | private tempFilePath; 77 | /** 78 | * Creates a new Joplin resource from the image data. The image will be 79 | * first converted to a JPEG. 80 | */ 81 | toJpgResource(handle: Handle, resourceProps: any, quality?: number): Promise; 82 | /** 83 | * Creates a new Joplin resource from the image data. The image will be 84 | * first converted to a PNG. 85 | */ 86 | toPngResource(handle: Handle, resourceProps: any): Promise; 87 | /** 88 | * Image data is not automatically deleted by Joplin so make sure you call 89 | * this method on the handle once you are done. 90 | */ 91 | free(handles: Handle[] | Handle): Promise; 92 | } 93 | -------------------------------------------------------------------------------- /src/tools/scratchpad/index.ts: -------------------------------------------------------------------------------- 1 | import { SettingItemType, MenuItem } from "api/types"; 2 | import { t } from "i18next"; 3 | import PlatformRepo from "src/repo/platformrepo"; 4 | import ServicePool from "src/services/servicepool"; 5 | import Tool from "../tool"; 6 | 7 | const ScratchPadContent = "dddot.settings.scratchpad.content"; 8 | const ScratchPadHeight = "dddot.settings.scratchpad.height"; 9 | 10 | export default class ScratchPad extends Tool { 11 | platformRepo: PlatformRepo; 12 | 13 | constructor(servicePool: ServicePool) { 14 | super(servicePool); 15 | this.platformRepo = servicePool.platformRepo; 16 | } 17 | 18 | get title() { 19 | return t("scratchpad.title"); 20 | } 21 | 22 | settings(section: string) { 23 | return { 24 | [ScratchPadContent]: { 25 | value: "", 26 | type: SettingItemType.String, 27 | public: false, 28 | label: "Scratchpad content", 29 | section, 30 | }, 31 | [ScratchPadHeight]: { 32 | value: 200, 33 | type: SettingItemType.Int, 34 | public: true, 35 | label: t("scratchpad.settings.height"), 36 | minValue: 50, 37 | step: 10, 38 | section, 39 | }, 40 | }; 41 | } 42 | 43 | async start() { 44 | } 45 | 46 | async onMessage(message: any) { 47 | switch (message.type) { 48 | case "scratchpad.loadTextArea": 49 | return this.joplinRepo.settingsLoad(ScratchPadContent, ""); 50 | case "scratchpad.saveTextArea": 51 | return this.joplinRepo.settingsSave(ScratchPadContent, message.value); 52 | case "scratchpad.onReady": 53 | return this.onReady(); 54 | case "scratchpad.tool.setHeight": 55 | return this.setHeight(message.height); 56 | case "scratchpad.getContextMenuEnabled": 57 | return this.getContextMenuEnabled(); 58 | default: 59 | return undefined; 60 | } 61 | } 62 | 63 | async setHeight(height: Number) { 64 | const { 65 | joplinRepo, 66 | } = this; 67 | 68 | await joplinRepo.settingsSave(ScratchPadHeight, height); 69 | } 70 | 71 | async onReady() { 72 | const { 73 | joplinRepo, 74 | } = this; 75 | 76 | const content = await this.joplinRepo.settingsLoad(ScratchPadContent, ""); 77 | const height = await joplinRepo.settingsLoad(ScratchPadHeight, 200); 78 | return { content, height }; 79 | } 80 | 81 | get key() { 82 | return "scratchpad"; 83 | } 84 | 85 | toggleScratchPadFocus() { 86 | this.joplinRepo.panelPostMessage({ 87 | type: "scratchpad.worker.toggleFocus", 88 | }); 89 | } 90 | 91 | async registerCommands(): Promise { 92 | const command = "dddot.cmd.toggleScratchPadFocus"; 93 | await this.joplinRepo.commandsRegister({ 94 | name: command, 95 | label: "Switch Focus between ScratchPad and Editor", 96 | iconName: "fas", 97 | execute: async () => this.toggleScratchPadFocus(), 98 | }); 99 | 100 | return [ 101 | { 102 | commandName: command, 103 | accelerator: this.platformRepo.isMac() ? "Shift+Cmd+Enter" : "Ctrl+Shift+Enter", 104 | }, 105 | ]; 106 | } 107 | 108 | async getContextMenuEnabled() { 109 | // Check the global setting for plugin webview isolation 110 | const isIsolated = await this.joplinRepo.settingsLoadGlobal("featureFlag.plugins.isolatePluginWebViews", false); 111 | return { contextMenuEnabled: !isIsolated }; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /api/JoplinWorkspace.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { FolderEntity } from '../../database/types'; 3 | import { Disposable, EditContextMenuFilterObject, FilterHandler } from './types'; 4 | declare enum ItemChangeEventType { 5 | Create = 1, 6 | Update = 2, 7 | Delete = 3 8 | } 9 | interface ItemChangeEvent { 10 | id: string; 11 | event: ItemChangeEventType; 12 | } 13 | interface ResourceChangeEvent { 14 | id: string; 15 | } 16 | interface NoteContentChangeEvent { 17 | note: any; 18 | } 19 | interface NoteSelectionChangeEvent { 20 | value: string[]; 21 | } 22 | interface NoteAlarmTriggerEvent { 23 | noteId: string; 24 | } 25 | interface SyncCompleteEvent { 26 | withErrors: boolean; 27 | } 28 | type WorkspaceEventHandler = (event: EventType) => void; 29 | type ItemChangeHandler = WorkspaceEventHandler; 30 | type SyncStartHandler = () => void; 31 | type ResourceChangeHandler = WorkspaceEventHandler; 32 | /** 33 | * The workspace service provides access to all the parts of Joplin that 34 | * are being worked on - i.e. the currently selected notes or notebooks as 35 | * well as various related events, such as when a new note is selected, or 36 | * when the note content changes. 37 | * 38 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins) 39 | */ 40 | export default class JoplinWorkspace { 41 | private store; 42 | private plugin; 43 | constructor(plugin: Plugin, store: any); 44 | /** 45 | * Called when a new note or notes are selected. 46 | */ 47 | onNoteSelectionChange(callback: WorkspaceEventHandler): Promise; 48 | /** 49 | * Called when the content of a note changes. 50 | * @deprecated Use `onNoteChange()` instead, which is reliably triggered whenever the note content, or any note property changes. 51 | */ 52 | onNoteContentChange(callback: WorkspaceEventHandler): Promise; 53 | /** 54 | * Called when the content of the current note changes. 55 | */ 56 | onNoteChange(handler: ItemChangeHandler): Promise; 57 | /** 58 | * Called when a resource is changed. Currently this handled will not be 59 | * called when a resource is added or deleted. 60 | */ 61 | onResourceChange(handler: ResourceChangeHandler): Promise; 62 | /** 63 | * Called when an alarm associated with a to-do is triggered. 64 | */ 65 | onNoteAlarmTrigger(handler: WorkspaceEventHandler): Promise; 66 | /** 67 | * Called when the synchronisation process is starting. 68 | */ 69 | onSyncStart(handler: SyncStartHandler): Promise; 70 | /** 71 | * Called when the synchronisation process has finished. 72 | */ 73 | onSyncComplete(callback: WorkspaceEventHandler): Promise; 74 | /** 75 | * Called just before the editor context menu is about to open. Allows 76 | * adding items to it. 77 | * 78 | * desktop 79 | */ 80 | filterEditorContextMenu(handler: FilterHandler): void; 81 | /** 82 | * Gets the currently selected note. Will be `null` if no note is selected. 83 | */ 84 | selectedNote(): Promise; 85 | /** 86 | * Gets the currently selected folder. In some cases, for example during 87 | * search or when viewing a tag, no folder is actually selected in the user 88 | * interface. In that case, that function would return the last selected 89 | * folder. 90 | */ 91 | selectedFolder(): Promise; 92 | /** 93 | * Gets the IDs of the selected notes (can be zero, one, or many). Use the data API to retrieve information about these notes. 94 | */ 95 | selectedNoteIds(): Promise; 96 | } 97 | export {}; 98 | -------------------------------------------------------------------------------- /src/panel.css: -------------------------------------------------------------------------------- 1 | .dddot-hidden { 2 | display: none; 3 | } 4 | 5 | .dddot-tool-header { 6 | margin-top: 4px; 7 | margin-bottom: 2px; 8 | margin-left: 4px; 9 | display: flex; 10 | justify-content: space-between; 11 | padding-right: 8px; 12 | } 13 | 14 | .dddot-tool-header h3 { 15 | margin-top: 8px; 16 | margin-bottom: 4px; 17 | } 18 | 19 | .dddot-tool-header i { 20 | font-size: 12px; 21 | } 22 | 23 | .dddot-tool { 24 | padding-bottom: 4px; 25 | } 26 | 27 | .dddot-tool-help-text { 28 | padding-left: 20px; 29 | padding-top: 4px; 30 | padding-bottom: 4px; 31 | } 32 | 33 | 34 | .dddot-note-dragging { 35 | background-color: var(--joplin-background-color-hover3); 36 | } 37 | 38 | .dddot-note-item { 39 | display: block; 40 | padding-left: 20px; 41 | padding-right: 20px; 42 | position: relative; 43 | text-overflow: clip; 44 | overflow-x: clip; 45 | } 46 | 47 | .dddot-note-item:hover { 48 | background: var(--joplin-background-color-hover3); 49 | } 50 | 51 | .dddot-note-item a { 52 | text-decoration: none; 53 | user-select: none; 54 | -webkit-user-drag: none; 55 | font-family: var(--joplin-font-family); 56 | color: var(--joplin-color3); 57 | height: 28px; 58 | line-height: 28px; 59 | white-space: nowrap; 60 | } 61 | 62 | .dddot-note-item div { 63 | overflow-x: hidden; 64 | } 65 | 66 | .dddot-note-item a:hover { 67 | text-decoration: none; 68 | } 69 | 70 | .dddot-note-item a:visited { 71 | text-decoration: none; 72 | } 73 | 74 | .dddot-note-item a:active { 75 | text-decoration: none; 76 | } 77 | 78 | .dddot-note-item:not(:first-child):before { 79 | content: " "; 80 | position: absolute; 81 | left: 20px; 82 | right: 20px; 83 | top: -1px; 84 | height: 1px; 85 | background-color: var(--joplin-divider-color); 86 | } 87 | 88 | .dddot-sortable-ghost { 89 | opacity: 0; 90 | } 91 | 92 | .dddot-notedialog-container { 93 | z-index: 100; 94 | position: absolute; 95 | top: 0px; 96 | left: 0px; 97 | right: 0px; 98 | bottom: 0px; 99 | background-color: var(--joplin-background-color); 100 | } 101 | 102 | .dddot-notedialog-header { 103 | display: flex; 104 | align-items: center; 105 | margin-left: 4px; 106 | } 107 | 108 | .dddot-notedialog-close-button-holder { 109 | margin-right: 4px; 110 | padding-left: 8px; 111 | } 112 | 113 | .dddot-notedialog-title { 114 | flex-grow: 1; 115 | white-space: nowrap; 116 | overflow-x: hidden; 117 | } 118 | 119 | .dddot-notedialog-container .CodeMirror { 120 | border: 1px solid #000; 121 | border-radius: 2px; 122 | margin-left: 4px; 123 | margin-right: 4px; 124 | } 125 | 126 | .dddot-notedialog-container .CodeMirror-wrap pre { 127 | word-break: break-word; 128 | } 129 | 130 | .dddot-notedialog-texarea-handle { 131 | width: 100%; 132 | height: 20px; 133 | line-height: 20px; 134 | text-align: center; 135 | cursor: ns-resize; 136 | } 137 | 138 | .dddot-text-decoration-none a { 139 | text-decoration: none; 140 | } 141 | 142 | .dddot-text-decoration-none a:hover { 143 | text-decoration: none; 144 | } 145 | 146 | .dddot-text-decoration-none a:visited { 147 | text-decoration: none; 148 | } 149 | 150 | .dddot-text-decoration-none a:active { 151 | text-decoration: none; 152 | } 153 | 154 | .dddot-clickbable { 155 | -webkit-transition: opacity 1s ease-in-out; 156 | -moz-transition: opacity 1s ease-in-out; 157 | -ms-transition: opacity 1s ease-in-out; 158 | -o-transition: opacity 1s ease-in-out; 159 | transition: opacity 1s ease-in-out; 160 | opacity: 1; 161 | } 162 | 163 | .dddot-clickable:hover { 164 | opacity: 0.5; 165 | } 166 | 167 | .dddot-clickable:active { 168 | opacity: 0.7; 169 | } 170 | 171 | /* Scrollbar style 172 | https://github.com/laurent22/joplin/blob/571147acbbf9ea61fd287ba05008727201bea951/packages/app-desktop/main.scss#L29 173 | */ 174 | 175 | ::-webkit-scrollbar { 176 | width: 7px; 177 | height: 7px; 178 | } 179 | ::-webkit-scrollbar-corner { 180 | background: none; 181 | } 182 | ::-webkit-scrollbar-track { 183 | border: none; 184 | } 185 | ::-webkit-scrollbar-thumb { 186 | background: rgba(100, 100, 100, 0.3); 187 | border-radius: 5px; 188 | } 189 | ::-webkit-scrollbar-track:hover { 190 | background: rgba(0, 0, 0, 0.1); 191 | } 192 | ::-webkit-scrollbar-thumb:hover { 193 | background: rgba(100, 100, 100, 0.7); 194 | } 195 | 196 | /* End of Scrollbar style */ -------------------------------------------------------------------------------- /api/JoplinCommands.d.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * This class allows executing or registering new Joplin commands. Commands 5 | * can be executed or associated with 6 | * {@link JoplinViewsToolbarButtons | toolbar buttons} or 7 | * {@link JoplinViewsMenuItems | menu items}. 8 | * 9 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) 10 | * 11 | * ## Executing Joplin's internal commands 12 | * 13 | * It is also possible to execute internal Joplin's commands which, as of 14 | * now, are not well documented. You can find the list directly on GitHub 15 | * though at the following locations: 16 | * 17 | * * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands) 18 | * * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands) 19 | * * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts) 20 | * 21 | * To view what arguments are supported, you can open any of these files 22 | * and look at the `execute()` command. 23 | * 24 | * Note that many of these commands only work on desktop. The more limited list of mobile 25 | * commands can be found in these places: 26 | * 27 | * * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-mobile/commands) 28 | * * [Editor commands](https://github.com/laurent22/joplin/blob/dev/packages/app-mobile/components/NoteEditor/commandDeclarations.ts) 29 | * 30 | * ## Executing editor commands 31 | * 32 | * There might be a situation where you want to invoke editor commands 33 | * without using a {@link JoplinContentScripts | contentScript}. For this 34 | * reason Joplin provides the built in `editor.execCommand` command. 35 | * 36 | * `editor.execCommand` should work with any core command in both the 37 | * [CodeMirror](https://codemirror.net/doc/manual.html#execCommand) and 38 | * [TinyMCE](https://www.tiny.cloud/docs/api/tinymce/tinymce.editorcommands/#execcommand) editors, 39 | * as well as most functions calls directly on a CodeMirror editor object (extensions). 40 | * 41 | * * [CodeMirror commands](https://codemirror.net/doc/manual.html#commands) 42 | * * [TinyMCE core editor commands](https://www.tiny.cloud/docs/advanced/editor-command-identifiers/#coreeditorcommands) 43 | * 44 | * `editor.execCommand` supports adding arguments for the commands. 45 | * 46 | * ```typescript 47 | * await joplin.commands.execute('editor.execCommand', { 48 | * name: 'madeUpCommand', // CodeMirror and TinyMCE 49 | * args: [], // CodeMirror and TinyMCE 50 | * ui: false, // TinyMCE only 51 | * value: '', // TinyMCE only 52 | * }); 53 | * ``` 54 | * 55 | * [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) 56 | * 57 | */ 58 | export default class JoplinCommands { 59 | private plugin_; 60 | constructor(plugin_: Plugin); 61 | /** 62 | * Executes the given command. 63 | * 64 | * The command can take any number of arguments, and the supported 65 | * arguments will vary based on the command. For custom commands, this 66 | * is the `args` passed to the `execute()` function. For built-in 67 | * commands, you can find the supported arguments by checking the links 68 | * above. 69 | * 70 | * ```typescript 71 | * // Create a new note in the current notebook: 72 | * await joplin.commands.execute('newNote'); 73 | * 74 | * // Create a new sub-notebook under the provided notebook 75 | * // Note: internally, notebooks are called "folders". 76 | * await joplin.commands.execute('newFolder', "SOME_FOLDER_ID"); 77 | * ``` 78 | */ 79 | execute(commandName: string, ...args: any[]): Promise; 80 | /** 81 | * Registers a new command. 82 | * 83 | * ```typescript 84 | * // Register a new commmand called "testCommand1" 85 | * 86 | * await joplin.commands.register({ 87 | * name: 'testCommand1', 88 | * label: 'My Test Command 1', 89 | * iconName: 'fas fa-music', 90 | * execute: () => { 91 | * alert('Testing plugin command 1'); 92 | * }, 93 | * }); 94 | * ``` 95 | */ 96 | register(command: Command): Promise; 97 | } 98 | -------------------------------------------------------------------------------- /src/tools/randomnote/randomnotetool.ts: -------------------------------------------------------------------------------- 1 | import NoteDialogService from "src/services/notedialog/notedialogservice"; 2 | import { MenuItem } from "api/types"; 3 | import { t } from "i18next"; 4 | import PlatformRepo from "src/repo/platformrepo"; 5 | import Tool from "../tool"; 6 | import ToolbarService from "../../services/toolbar/toolbarservice"; 7 | import ServicePool from "../../services/servicepool"; 8 | 9 | interface NoteIdCache { 10 | noteIds: string[]; 11 | timestamp: number; 12 | } 13 | 14 | export default class RandomNoteTool extends Tool { 15 | toolbarService: ToolbarService; 16 | 17 | noteDialogService: NoteDialogService; 18 | 19 | platformRepo: PlatformRepo; 20 | 21 | private noteIdCache: NoteIdCache | null = null; 22 | 23 | private readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes 24 | 25 | constructor(servicePool: ServicePool) { 26 | super(servicePool); 27 | this.toolbarService = servicePool.toolbarService; 28 | this.noteDialogService = servicePool.noteDialogService; 29 | this.joplinRepo = servicePool.joplinRepo; 30 | this.platformRepo = servicePool.platformRepo; 31 | } 32 | 33 | async start() { 34 | } 35 | 36 | async onLoaded() { 37 | this.toolbarService.addToolbarItem({ 38 | name: "Random Note", 39 | icon: "fa-dice", 40 | tooltip: "Open Random Note", 41 | onClick: { 42 | type: "randomnote.tool.openRandomNote", 43 | }, 44 | onContextMenu: { 45 | type: "randomnote.tool.openRandomNoteByNoteDialog", 46 | }, 47 | }); 48 | } 49 | 50 | get hasView() { 51 | return false; 52 | } 53 | 54 | get title() { 55 | return "randomnote.title"; 56 | } 57 | 58 | get key() { 59 | return "randomnote"; 60 | } 61 | 62 | async onMessage(message : any) { 63 | const { type } = message; 64 | switch (type) { 65 | case "randomnote.tool.openRandomNote": 66 | return this.openRandomNote(); 67 | case "randomnote.tool.openRandomNoteByNoteDialog": 68 | break; 69 | default: break; 70 | } 71 | return undefined; 72 | } 73 | 74 | private isCacheValid(): boolean { 75 | if (!this.noteIdCache) { 76 | return false; 77 | } 78 | 79 | const now = Date.now(); 80 | return now - this.noteIdCache.timestamp < this.CACHE_DURATION_MS; 81 | } 82 | 83 | public async getNoteIds(): Promise { 84 | if (this.noteIdCache == null) { 85 | this.noteIdCache = { 86 | noteIds: await this.joplinService.getAllNoteIds(), 87 | timestamp: Date.now(), 88 | }; 89 | return this.noteIdCache.noteIds; 90 | } 91 | 92 | if (this.isCacheValid()) { 93 | return this.noteIdCache.noteIds; 94 | } 95 | const { noteIds } = (this.noteIdCache); 96 | 97 | this.joplinService.getAllNoteIds().then((refreshedNoteIds) => { 98 | this.noteIdCache = { 99 | noteIds: refreshedNoteIds, 100 | timestamp: Date.now(), 101 | }; 102 | }); 103 | return noteIds; 104 | } 105 | 106 | async openRandomNote() { 107 | try { 108 | const noteIds = await this.getNoteIds(); 109 | 110 | if (noteIds.length === 0) { 111 | return; 112 | } 113 | 114 | const randomIndex = Math.floor(Math.random() * noteIds.length); 115 | const randomNoteId = noteIds[randomIndex]; 116 | 117 | this.joplinService.openNote(randomNoteId); 118 | } catch (error) { 119 | console.error("Error opening random note:", error); 120 | } 121 | } 122 | 123 | async registerCommands(): Promise { 124 | const command = "dddot.cmd.openRandomNote"; 125 | await this.joplinRepo.commandsRegister({ 126 | name: command, 127 | label: t("randomnote.open_random_note"), 128 | iconName: "fas", 129 | execute: async () => this.openRandomNote(), 130 | }); 131 | 132 | return [{ 133 | commandName: command, 134 | accelerator: this.platformRepo.isMac() ? "Cmd+R" : "Ctrl+R", 135 | }]; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/repo/joplinrepo.ts: -------------------------------------------------------------------------------- 1 | /** Wrapper of Joplin API 2 | * 3 | * Reference: 4 | * joplin.data 5 | * https://joplinapp.org/api/references/plugin_api/classes/joplindata.html 6 | */ 7 | 8 | import joplin from "api"; 9 | import { 10 | ButtonSpec, DialogResult, ViewHandle, CreateMenuItemOptions, MenuItemLocation, 11 | MenuItem, 12 | } from "api/types"; 13 | 14 | export enum ItemChangeEventType { 15 | Create = 1, 16 | Update = 2, 17 | Delete = 3 18 | } 19 | 20 | export default class JoplinRepo { 21 | panelView: string; 22 | 23 | toastCounter = 0; 24 | 25 | constructor(panelView = "") { 26 | this.panelView = panelView; 27 | } 28 | 29 | async settingsSave(key: string, value: any) { 30 | return joplin.settings.setValue(key, value); 31 | } 32 | 33 | async settingsLoad(key: string, defaults: any) { 34 | return await joplin.settings.value(key) ?? defaults; 35 | } 36 | 37 | async settingsLoadGlobal(key: string, defaults: any) { 38 | return await joplin.settings.globalValue(key) ?? defaults; 39 | } 40 | 41 | async dataPost(path: any, query?: any, data ?: any) { 42 | return joplin.data.post(path, query, data); 43 | } 44 | 45 | async dataGet(path: any, query?: any) { 46 | return joplin.data.get(path, query); 47 | } 48 | 49 | async dataPut(path: any, query?: any, data ?: any) { 50 | return joplin.data.put(path, query, data); 51 | } 52 | 53 | async workspaceSelectedNote() { 54 | return joplin.workspace.selectedNote(); 55 | } 56 | 57 | async workspaceSelectedNoteIds() { 58 | return joplin.workspace.selectedNoteIds(); 59 | } 60 | 61 | async workspaceSelectedFolder() { 62 | return joplin.workspace.selectedFolder(); 63 | } 64 | 65 | async workspaceOnNoteChange(listener) { 66 | return joplin.workspace.onNoteChange(listener); 67 | } 68 | 69 | async workspaceOnNoteSelectionChange(listener) { 70 | return joplin.workspace.onNoteSelectionChange(listener); 71 | } 72 | 73 | async panelPostMessage(message: any) { 74 | joplin.views.panels.postMessage(this.panelView, message); 75 | } 76 | 77 | async dialogCreate(id: string): Promise { 78 | return joplin.views.dialogs.create(id); 79 | } 80 | 81 | async dialogShowMessageBox(message: string) { 82 | return joplin.views.dialogs.showMessageBox(message); 83 | } 84 | 85 | async dialogSetButtons(handle: ViewHandle, buttons: ButtonSpec[]): Promise { 86 | return joplin.views.dialogs.setButtons(handle, buttons); 87 | } 88 | 89 | async dialogSetHtml(handle: ViewHandle, html: string): Promise { 90 | return joplin.views.dialogs.setHtml(handle, html); 91 | } 92 | 93 | async dialogOpen(handle: ViewHandle): Promise { 94 | return joplin.views.dialogs.open(handle); 95 | } 96 | 97 | async dialogsCreate(id: string) { 98 | return joplin.views.dialogs.create(id); 99 | } 100 | 101 | async commandsRegister(options: any) { 102 | return joplin.commands.register(options); 103 | } 104 | 105 | async commandsExecute(command: string, ...args: any[]) { 106 | return joplin.commands.execute(command, ...args); 107 | } 108 | 109 | async menuItemsCreate( 110 | id: string, 111 | commandName: string, 112 | location?: MenuItemLocation, 113 | options?: CreateMenuItemOptions, 114 | ) { 115 | return joplin.views.menuItems.create(id, commandName, location, options); 116 | } 117 | 118 | async menusCreate( 119 | id: string, 120 | title: string, 121 | menuItems: MenuItem[], 122 | ) { 123 | return joplin.views.menus.create(id, title, menuItems); 124 | } 125 | 126 | async toast( 127 | message: string, 128 | type: "success" | "error" | "info" = "success", 129 | duration: number = 3000, 130 | ) { 131 | try { 132 | await (joplin.views.dialogs as any).showToast( 133 | { 134 | message, 135 | // eslint-disable-next-line no-plusplus 136 | duration: duration + (this.toastCounter++ % 50), 137 | type, 138 | }, 139 | ); 140 | } catch { 141 | // Ignore 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /api/JoplinViewsEditor.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ActivationCheckCallback, ViewHandle, UpdateCallback } from './types'; 3 | /** 4 | * Allows creating alternative note editors. You can create a view to handle loading and saving the 5 | * note, and do your own rendering. 6 | * 7 | * Although it may be used to implement an alternative text editor, the more common use case may be 8 | * to render the note in a different, graphical way - for example displaying a graph, and 9 | * saving/loading the graph data in the associated note. In that case, you would detect whether the 10 | * current note contains graph data and, in this case, you'd display your viewer. 11 | * 12 | * Terminology: An editor is **active** when it can be used to edit the current note. Note that it 13 | * doesn't necessarily mean that your editor is visible - it just means that the user has the option 14 | * to switch to it (via the "toggle editor" button). A **visible** editor is active and is currently 15 | * being displayed. 16 | * 17 | * To implement an editor you need to listen to two events: 18 | * 19 | * - `onActivationCheck`: This is a way for the app to know whether your editor should be active or 20 | * not. Return `true` from this handler to activate your editor. 21 | * 22 | * - `onUpdate`: When this is called you should update your editor based on the current note 23 | * content. Call `joplin.workspace.selectedNote()` to get the current note. 24 | * 25 | * - `showEditorPlugin` and `toggleEditorPlugin` commands. Additionally you can use these commands 26 | * to display your editor via `joplin.commands.execute('showEditorPlugin')`. This is not always 27 | * necessary since the user can switch to your editor using the "toggle editor" button, however 28 | * you may want to programmatically display the editor in some cases - for example when creating a 29 | * new note specific to your editor. 30 | * 31 | * Note that only one editor view can be active at a time. This is why it is important not to 32 | * activate your view if it's not relevant to the current note. If more than one is active, it is 33 | * undefined which editor is going to be used to display the note. 34 | * 35 | * For an example of editor plugin, see the [YesYouKan 36 | * plugin](https://github.com/joplin/plugin-yesyoukan/blob/master/src/index.ts). In particular, 37 | * check the logic around `onActivationCheck` and `onUpdate` since this is the entry points for 38 | * using this API. 39 | */ 40 | export default class JoplinViewsEditors { 41 | private store; 42 | private plugin; 43 | private activationCheckHandlers_; 44 | constructor(plugin: Plugin, store: any); 45 | private controller; 46 | /** 47 | * Creates a new editor view 48 | */ 49 | create(id: string): Promise; 50 | /** 51 | * Sets the editor HTML content 52 | */ 53 | setHtml(handle: ViewHandle, html: string): Promise; 54 | /** 55 | * Adds and loads a new JS or CSS file into the panel. 56 | */ 57 | addScript(handle: ViewHandle, scriptPath: string): Promise; 58 | /** 59 | * See [[JoplinViewPanels]] 60 | */ 61 | onMessage(handle: ViewHandle, callback: Function): Promise; 62 | /** 63 | * Emitted when the editor can potentially be activated - this is for example when the current 64 | * note is changed, or when the application is opened. At that point you should check the 65 | * current note and decide whether your editor should be activated or not. If it should, return 66 | * `true`, otherwise return `false`. 67 | */ 68 | onActivationCheck(handle: ViewHandle, callback: ActivationCheckCallback): Promise; 69 | /** 70 | * Emitted when your editor content should be updated. This is for example when the currently 71 | * selected note changes, or when the user makes the editor visible. 72 | */ 73 | onUpdate(handle: ViewHandle, callback: UpdateCallback): Promise; 74 | /** 75 | * See [[JoplinViewPanels]] 76 | */ 77 | postMessage(handle: ViewHandle, message: any): void; 78 | /** 79 | * Tells whether the editor is active or not. 80 | */ 81 | isActive(handle: ViewHandle): Promise; 82 | /** 83 | * Tells whether the editor is effectively visible or not. If the editor is inactive, this will 84 | * return `false`. If the editor is active and the user has switched to it, it will return 85 | * `true`. Otherwise it will return `false`. 86 | */ 87 | isVisible(handle: ViewHandle): Promise; 88 | } 89 | -------------------------------------------------------------------------------- /tests/markdownparserservice.test.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownParserService } from "../src/services/markdownparser/markdownparserservice"; 2 | import { OutlineType } from "../src/types/outline"; 3 | 4 | describe("MarkdownParserService", () => { 5 | describe("parseOutlines", () => { 6 | it("It should able to parse headling", async () => { 7 | const service = new MarkdownParserService(); 8 | const sample = ` 9 | # Title 10 | 123 11 | ## Subtitle 1 12 | 456 13 | # Title 2 14 | `; 15 | const outlines = await service.parseOutlines(sample); 16 | 17 | expect(outlines).toStrictEqual([ 18 | { 19 | type: OutlineType.Heading, 20 | title: "Title", 21 | level: 1, 22 | lineno: 1, 23 | slug: "title", 24 | children: [ 25 | { 26 | type: OutlineType.Heading, 27 | title: "Subtitle 1", 28 | level: 2, 29 | lineno: 3, 30 | slug: "subtitle-1", 31 | children: [], 32 | }, 33 | ], 34 | }, 35 | { 36 | type: OutlineType.Heading, 37 | title: "Title 2", 38 | level: 1, 39 | slug: "title-2", 40 | lineno: 5, 41 | children: [], 42 | }, 43 | ]); 44 | }); 45 | 46 | it("It should able to parse links with specific schema", async () => { 47 | const service = new MarkdownParserService(); 48 | const sample = ` 49 | [Link 1](https://file1.md) 50 | 51 | [Link 2](file://file1.md) 52 | `; 53 | const outlines = await service.parseOutlines(sample, ["file"]); 54 | 55 | expect(outlines).toStrictEqual([ 56 | { 57 | type: OutlineType.Link, 58 | title: "Link 2", 59 | level: 1, 60 | lineno: 3, 61 | slug: "", 62 | link: "file://file1.md", 63 | children: [], 64 | }, 65 | ]); 66 | }); 67 | 68 | it("It should able to parse links and heading together", async () => { 69 | const service = new MarkdownParserService(); 70 | const sample = ` 71 | # Title 1 72 | [Link 1](https://file1.md) 73 | ## Subtitle 1 74 | `; 75 | const outlines = await service.parseOutlines(sample, ["https"]); 76 | 77 | expect(outlines).toStrictEqual([ 78 | { 79 | type: OutlineType.Heading, 80 | title: "Title 1", 81 | level: 1, 82 | lineno: 1, 83 | slug: "title-1", 84 | children: [ 85 | { 86 | type: OutlineType.Link, 87 | title: "Link 1", 88 | level: 2, 89 | lineno: 2, 90 | slug: "", 91 | children: [], 92 | link: "https://file1.md", 93 | }, 94 | { 95 | type: OutlineType.Heading, 96 | title: "Subtitle 1", 97 | level: 2, 98 | lineno: 3, 99 | slug: "subtitle-1", 100 | children: [], 101 | }, 102 | ], 103 | }, 104 | ]); 105 | }); 106 | }); 107 | }); 108 | 109 | // test("parseLinks", async () => { 110 | // const service = new MarkdownParserService(); 111 | // const sample = ` 112 | // [google](https://google.com) 113 | 114 | // `; 115 | // const headings = await service.parseHeadings(sample); 116 | 117 | // expect(headings).toStrictEqual([ 118 | // ]); 119 | // }); 120 | 121 | test("slug", () => { 122 | const service = new MarkdownParserService(); 123 | 124 | expect(service.slug("Title")).toStrictEqual("title"); 125 | expect(service.slug("♥")).toStrictEqual("hearts"); 126 | expect(service.slug("🔴 Important note")).toStrictEqual("red_circle-important-note"); 127 | }); 128 | -------------------------------------------------------------------------------- /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 `["resourceName", "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 | -------------------------------------------------------------------------------- /src/tools/recentnotes/index.ts: -------------------------------------------------------------------------------- 1 | import { SettingItemType } from "api/types"; 2 | import { t } from "i18next"; 3 | import Tool from "../tool"; 4 | import LinkListModel from "../../models/linklistmodel"; 5 | import { ItemChangeEventType } from "../../repo/joplinrepo"; 6 | import { LinkMonad } from "../../types/link"; 7 | 8 | const RecentNotesContentSetting = "dddot.settings.recentnotes.content"; 9 | const RecentNotesMaxNotesSetting = "dddot.settings.recentnotes.maxnotes"; 10 | const RecentNotesMaxNotesSettingDefaultValue = 5; 11 | 12 | export default class RecentNotes extends Tool { 13 | linkListModel: LinkListModel = new LinkListModel(); 14 | 15 | settings(section: string) { 16 | return { 17 | [RecentNotesContentSetting]: { 18 | value: [], 19 | type: SettingItemType.Object, 20 | public: false, 21 | label: "Recent Notes", 22 | section, 23 | }, 24 | [RecentNotesMaxNotesSetting]: { 25 | value: RecentNotesMaxNotesSettingDefaultValue, 26 | type: SettingItemType.Int, 27 | public: true, 28 | label: "Recent Notes - Max number of notes", 29 | section, 30 | }, 31 | }; 32 | } 33 | 34 | async start() { 35 | this.linkListModel.rehydrate(await this.joplinRepo.settingsLoad( 36 | RecentNotesContentSetting, 37 | [], 38 | )); 39 | 40 | await this.joplinRepo.workspaceOnNoteSelectionChange(async () => { 41 | const activeNote = await this.joplinRepo.workspaceSelectedNote(); 42 | await this.insertLink(activeNote); 43 | }); 44 | 45 | await this.joplinRepo.workspaceOnNoteChange(async (change) => { 46 | const { id, event } = change; 47 | if (event === ItemChangeEventType.Update) { 48 | const note = await this.joplinService.getNote(id); 49 | const { 50 | title, 51 | } = note; 52 | if (this.linkListModel.update(id, { title })) { 53 | await this.save(); 54 | this.refresh(); 55 | } 56 | } 57 | }); 58 | } 59 | 60 | async save() { 61 | await this.joplinRepo.settingsSave( 62 | RecentNotesContentSetting, 63 | this.linkListModel.dehydrate(), 64 | ); 65 | } 66 | 67 | async refresh() { 68 | const links = this.read(); 69 | const message = { 70 | type: "recentnotes.refresh", 71 | links, 72 | }; 73 | 74 | this.joplinRepo.panelPostMessage(message); 75 | return links; 76 | } 77 | 78 | read() { 79 | return this.linkListModel.links.map((link) => ({ 80 | id: link.id, 81 | title: link.title, 82 | type: link.type, 83 | isTodo: link.isTodo, 84 | isTodoCompleted: link.isTodoCompleted, 85 | })); 86 | } 87 | 88 | async insertLink(note) { 89 | const prependLink = LinkMonad.createNoteLinkFromRawData(note); 90 | 91 | this.linkListModel.unshift(prependLink); 92 | 93 | await this.truncate(); 94 | await this.save(); 95 | await this.refresh(); 96 | } 97 | 98 | async onMessage(message: any) { 99 | switch (message.type) { 100 | case "recentnotes.onReady": 101 | return this.onReady(); 102 | case "recentnotes.tool.openNoteDetailDialog": 103 | return this.openNoteDetailDialog(message.noteId); 104 | default: 105 | break; 106 | } 107 | return undefined; 108 | } 109 | 110 | async openNoteDetailDialog(noteId: string) { 111 | const { 112 | noteDialogService, 113 | } = this.servicePool; 114 | 115 | await noteDialogService.open(noteId); 116 | } 117 | 118 | get title() { 119 | return t("recentnotes.title"); 120 | } 121 | 122 | async onReady() { 123 | await this.truncate(); 124 | return this.read(); 125 | } 126 | 127 | async truncate() { 128 | const maxVisibleItemCount = await this.joplinRepo.settingsLoad( 129 | RecentNotesMaxNotesSetting, 130 | RecentNotesMaxNotesSettingDefaultValue, 131 | ); 132 | this.linkListModel.links = this.linkListModel.links.slice(0, maxVisibleItemCount); 133 | } 134 | 135 | get key() { 136 | return "recentnotes"; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/services/linkgraph/linkgraphservice.ts: -------------------------------------------------------------------------------- 1 | import LinkGraphNode from "./linkgraphnode"; 2 | import JoplinService from "../joplin/joplinservice"; 3 | import LinkGraphUpdateQueue from "./linkgraphupdatequeue"; 4 | import { Link } from "../../types/link"; 5 | 6 | const UpdateInterval = 100; 7 | 8 | export interface QueryBacklinksResult { 9 | isCache: boolean; 10 | backlinks: Link[]; 11 | } 12 | 13 | async function* bfs(start: string, fetch: (id: string) => Promise) { 14 | const added = new Set(); 15 | const queue: string[] = []; 16 | queue.push(start); 17 | added.add(start); 18 | 19 | while (queue.length > 0) { 20 | const current = queue.shift(); 21 | const node = await fetch(current); 22 | yield node; 23 | const backlinks = node?.backlinks ?? []; 24 | backlinks.forEach((link) => { 25 | if (!added.has(link.id)) { 26 | added.add(link.id); 27 | queue.push(link.id); 28 | } 29 | }); 30 | } 31 | } 32 | 33 | export default class LinkGraphService { 34 | joplinService: JoplinService; 35 | 36 | graph: Map; 37 | 38 | listeners: ((node: LinkGraphNode)=>void)[]; 39 | 40 | updateQueue: LinkGraphUpdateQueue; 41 | 42 | constructor(joplinService: JoplinService) { 43 | this.joplinService = joplinService; 44 | this.graph = new Map(); 45 | this.listeners = []; 46 | this.updateQueue = new LinkGraphUpdateQueue( 47 | this.joplinService, 48 | this.graph, 49 | UpdateInterval, 50 | ); 51 | } 52 | 53 | queryBacklinks(id: string) { 54 | this.runQueryBacklinks(id); 55 | return this.graph.get(id); 56 | } 57 | 58 | onNodeUpdated(listener: (node: LinkGraphNode)=>void) { 59 | this.listeners.push(listener); 60 | } 61 | 62 | private async runQueryBacklinks(id: string) { 63 | const links = await this.joplinService.searchBacklinks(id); 64 | 65 | const notify = (linkage: LinkGraphNode) => { 66 | this.listeners.forEach((listener) => { 67 | listener(linkage); 68 | }); 69 | }; 70 | 71 | if (this.graph.has(id)) { 72 | const currentNode = this.graph.get(id); 73 | 74 | const newNode = { ...currentNode }; 75 | newNode.backlinks = links; 76 | 77 | if (JSON.stringify(currentNode) !== JSON.stringify(newNode)) { 78 | this.graph.set(id, newNode); 79 | notify(newNode); 80 | } 81 | } else { 82 | const node = new LinkGraphNode(); 83 | node.id = id; 84 | node.backlinks = links; 85 | this.graph.set(id, node); 86 | notify(node); 87 | } 88 | } 89 | 90 | async* queryBacklinksAdv(id: string, recursive = false) { 91 | const backlinks = this.graph.has(id) ? this.graph.get(id).backlinks : []; 92 | const backlinksSet = new Set(backlinks.map((link) => link.id)); 93 | backlinksSet.add(id); 94 | 95 | const deduplicate = (links: Link[], isCache: boolean) => { 96 | const filterLinks = links.filter((link) => !backlinksSet.has(link.id)); 97 | const set = new Set(filterLinks.map((link) => link.id)); 98 | set.forEach(backlinksSet.add, set); 99 | return { 100 | isCache, 101 | backlinks: filterLinks, 102 | }; 103 | }; 104 | 105 | yield { 106 | isCache: true, 107 | backlinks, 108 | }; 109 | 110 | const node = await this.updateQueue.enqueue(id); 111 | const updates = node.backlinks.filter( 112 | (link) => backlinks.findIndex((item) => item.id === link.id) < 0, 113 | ); 114 | 115 | yield deduplicate(updates, false); 116 | 117 | if (!recursive) { 118 | return; 119 | } 120 | 121 | // eslint-disable-next-line 122 | for await (const travlingNode of bfs(id, async (nodeId: string) => { 123 | return this.graph.get(nodeId); 124 | })) { 125 | if (travlingNode !== undefined && travlingNode.id !== id) { 126 | yield deduplicate(travlingNode?.backlinks ?? [], true); 127 | } 128 | } 129 | 130 | // eslint-disable-next-line 131 | for await (const travlingNode of bfs(id, async (nodeId: string) => { 132 | return this.updateQueue.enqueue(nodeId); 133 | })) { 134 | if (travlingNode !== undefined && travlingNode.id !== id) { 135 | yield deduplicate(travlingNode?.backlinks ?? [], false); 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/sandbox/dddot.js: -------------------------------------------------------------------------------- 1 | async function sleep(time) { 2 | return new Promise((resolve) => { setTimeout(resolve, time); }); 3 | } 4 | 5 | async function waitUntilLoaded(components) { 6 | for (;;) { 7 | const loadedComponents = components.filter((component) => component in window); 8 | if (loadedComponents.length === components.length) { 9 | break; 10 | } 11 | await sleep(100); 12 | } 13 | } 14 | 15 | async function waitUntilCreated(id) { 16 | for (;;) { 17 | const container = document.getElementById(id); 18 | if (container) { 19 | break; 20 | } 21 | await sleep(100); 22 | } 23 | } 24 | 25 | class DDDot { 26 | static X_JOP_NOTE_IDS = "text/x-jop-note-ids"; 27 | 28 | static X_JOP_FOLDER_IDS = "text/x-jop-folder-ids"; 29 | 30 | static panelMessageCallbacks = {}; 31 | 32 | static eventListeners = []; 33 | 34 | static fullSceenDialog = undefined; 35 | 36 | static listenPanelMessage() { 37 | webviewApi.onMessage((payload) => { 38 | const { message } = payload; 39 | const { type } = message; 40 | 41 | if (!Object.prototype.hasOwnProperty.call(this.panelMessageCallbacks, type)) { 42 | console.warn(`Message[${type}] is not registered`); 43 | return; 44 | } 45 | this.panelMessageCallbacks[type](message); 46 | }); 47 | } 48 | 49 | static async load() { 50 | this.listenPanelMessage(); 51 | this.onMessage("dddot.start", (message) => { 52 | this.start(message); 53 | }); 54 | this.onMessage("dddot.setToolOrder", (message) => { 55 | this.setToolOrder(message); 56 | }); 57 | this.onMessage("dddot.logError", (message) => { 58 | this.logError(message); 59 | }); 60 | 61 | const components = ["CodeMirror", "CodeMirror5Manager", "App"]; 62 | await waitUntilLoaded(components); 63 | 64 | this.postMessage({ 65 | type: "dddot.onLoaded", 66 | }); 67 | } 68 | 69 | static async start(message) { 70 | const { 71 | tools, theme, serviceWorkerFunctions, locale, 72 | } = message; 73 | 74 | const codeMirror5Manager = new CodeMirror5Manager(); 75 | codeMirror5Manager.init(theme); 76 | 77 | App.setupLocale(locale); 78 | App.render("dddot-app", tools); 79 | 80 | await waitUntilCreated("dddot-panel-container"); 81 | 82 | const workerFunctionNames = tools.map((tool) => tool.workerFunctionName); 83 | 84 | await waitUntilLoaded(workerFunctionNames); 85 | 86 | await Promise.all(tools.map(async (tool) => { 87 | const { 88 | workerFunctionName, 89 | enabled, 90 | } = tool; 91 | 92 | if (enabled) { 93 | await window[workerFunctionName]({ theme }); 94 | } 95 | })); 96 | 97 | await waitUntilLoaded(serviceWorkerFunctions); 98 | 99 | await Promise.all(serviceWorkerFunctions.map(async (serviceWorkerFunction) => { 100 | await window[serviceWorkerFunction]({ theme }); 101 | })); 102 | } 103 | 104 | static setToolOrder(message) { 105 | const { toolIds } = message; 106 | App.setToolsOrder(toolIds); 107 | } 108 | 109 | static logError(message) { 110 | const { error } = message; 111 | console.error(error); 112 | } 113 | 114 | static onMessage(type, callback) { 115 | this.panelMessageCallbacks[type] = callback; 116 | } 117 | 118 | static async postMessage(message) { 119 | if (typeof message === "string" || message instanceof String) { 120 | return webviewApi.postMessage(JSON.parse(message)); 121 | } 122 | return webviewApi.postMessage(message); 123 | } 124 | 125 | static async createNoteLink(noteId) { 126 | const response = await webviewApi.postMessage({ 127 | type: "dddot.getNote", 128 | noteId, 129 | }); 130 | const { 131 | note, 132 | } = response; 133 | const { 134 | title, 135 | } = note; 136 | 137 | return `[${title}](:/${noteId})`; 138 | } 139 | 140 | static async openNote(noteId) { 141 | await webviewApi.postMessage({ 142 | type: "dddot.openNote", 143 | noteId, 144 | }); 145 | } 146 | 147 | static postEvent(event) { 148 | this.eventListeners.forEach((listener) => { 149 | try { 150 | listener(event); 151 | } catch (e) { 152 | // ignore the error 153 | } 154 | }); 155 | } 156 | 157 | static onEvent(listener) { 158 | this.eventListeners.push(listener); 159 | } 160 | } 161 | DDDot.load(); 162 | -------------------------------------------------------------------------------- /src/sandbox/section.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDrag, useDrop } from "react-dnd"; 3 | import { ToolInfo } from "../types/toolinfo"; 4 | import { DragItemType } from "../types/drag"; 5 | import { SmallIconButton } from "../views/smalliconbutton"; 6 | import { ExpandButton } from "../views/expandbutton"; 7 | 8 | type Props = { 9 | tool: ToolInfo; 10 | index: number; 11 | moveListItem: (dragIndex: number, hoverIndex: number) => void; 12 | children?: React.ReactNode; 13 | } 14 | 15 | export function useSectionState(props: Props) { 16 | const { 17 | tool, 18 | index, 19 | moveListItem, 20 | children, 21 | } = props; 22 | 23 | const [isExpanded, setIsExpanded] = React.useState(true); 24 | 25 | const [{ isDragging }, dragRef, dragPreviewRef] = useDrag({ 26 | type: DragItemType.Section, 27 | item: { tool, index }, 28 | collect: (monitor) => ({ 29 | isDragging: monitor.isDragging(), 30 | }), 31 | }); 32 | const ref = React.useRef(null); 33 | 34 | const [_, dropRef] = useDrop({ 35 | accept: DragItemType.Section, 36 | hover: (item: any, monitor) => { 37 | // Ref: https://dev.to/crishanks/transfer-lists-with-react-dnd-3ifo 38 | const dragIndex = item.index; 39 | const hoverIndex = index; 40 | if (dragIndex === undefined) return; 41 | if (dragIndex === hoverIndex) return; 42 | const hoverBoundingRect = ref.current?.getBoundingClientRect(); 43 | const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; 44 | const hoverActualY = monitor.getClientOffset().y - hoverBoundingRect.top; 45 | 46 | // if dragging down, continue only when hover is smaller than middle Y 47 | if (dragIndex < hoverIndex && hoverActualY < hoverMiddleY) return; 48 | // if dragging up, continue only when hover is bigger than middle Y 49 | if (dragIndex > hoverIndex && hoverActualY > hoverMiddleY) return; 50 | 51 | moveListItem(dragIndex, hoverIndex); 52 | // eslint-disable-next-line no-param-reassign 53 | item.index = hoverIndex; 54 | }, 55 | }); 56 | 57 | const itemRef = dragPreviewRef(dropRef(ref)); 58 | 59 | const onExpandClick = React.useCallback(() => { 60 | setIsExpanded((prev) => !prev); 61 | }, []); 62 | 63 | const onBackgroundClick = React.useCallback((e: React.MouseEvent) => { 64 | if (e.detail >= 2 && e.detail % 2 === 0) { 65 | setIsExpanded((prev) => !prev); 66 | } 67 | }, []); 68 | 69 | return { 70 | tool, 71 | itemRef, 72 | dragRef, 73 | dragPreviewRef, 74 | isExpanded, 75 | setIsExpanded, 76 | onExpandClick, 77 | isDragging, 78 | children, 79 | onBackgroundClick, 80 | index, // Return index to make sure it could trigger SecionImpl to re-render after DnD 81 | }; 82 | } 83 | 84 | export function SectionImpl(props: ReturnType) { 85 | const { 86 | tool, 87 | onExpandClick, 88 | itemRef, 89 | isDragging, 90 | onBackgroundClick, 91 | } = props; 92 | 93 | const opacity = isDragging ? 0 : 1; 94 | 95 | return ( 96 |
98 |
99 |

{tool.title}

100 |
101 | { 102 | tool.extraButtons.map((button, index) => ( 103 | 104 | { 107 | DDDot.postMessage(button.message); 108 | }} 109 | /> 110 | 111 | )) 112 | } 113 | 117 |
118 |
119 | { 120 | props.isExpanded && ( 121 |
122 | {props.children} 123 |
124 | ) 125 | } 126 |
127 | ); 128 | } 129 | 130 | export function Section(props: Props) { 131 | const states = useSectionState(props); 132 | return ; 133 | } 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Joplin DDDot 2 | =========== 3 | 4 | DDDot is a Joplin plugin to provide a set of tools like recent notes, shortcuts, scratchpad, and .... in a single sidebar. 5 | 6 | **Main Features**: 7 | 8 | 1. Recent Notes - Show recently opened notes 9 | 2. Shortcuts - A bookmark of faviour notes 10 | 3. Scratchpad - Write something quickly without bothering to find the right note to insert 11 | 4. Backlinks - Show the backlinks of the selected note 12 | 5. Outline - Show the table of content of the selected note 13 | 6. Daily Note - Create / Open a note for today. 14 | 7. Random Note - Open a random note 15 | 16 | ![Screenshot](https://user-images.githubusercontent.com/82716/193397815-c3cbfa48-0507-4341-8125-2bdb63877b3c.png) 17 | 18 | **Minor Features** 19 | 20 | - Support to enable/disable an individual tool 21 | - Support changing the tool order by drag and drop 22 | - Support dark theme 23 | 24 | # User Guide 25 | 26 | ## General Usage 27 | 28 | ### Toggle the visibility of DDDot 29 | 30 | Press the DDDot icon in the toolbar 31 | 32 | ![Screenshot](https://raw.githubusercontent.com/benlau/joplin-plugin-dddot/master/docs/toggle-visibility.png) 33 | 34 | ### Enable/disable a tool 35 | 36 | Launch Preference and open DDDot section. It will show the options to enable/disable a tool 37 | 38 | ### Draggable link 39 | 40 | The links in Recent Notes and Backlinks are draggable. You may drag it to the note editor to insert the link there. 41 | 42 | ## Shortcuts 43 | 44 | - Add a shortcut to a note - Drag a note from the note list over the Shortcuts area. 45 | - Add a shortcut to a notebook - Drag a folder from the Notebooks list over the Shortcuts area. 46 | - Remove shortcut - Right-click on a shortcut. It will prompt a dialog for confirmation 47 | - Import/Export shortcut list 48 | 49 | Add a shortcut to search 50 | 51 | Install the [Embed search](https://discourse.joplinapp.org/t/embed-any-search-with-content/14328) plugin 52 | Create a note with embed search 53 | Drag the note from the note list over the Shortcuts tool 54 | 55 | ## Outline 56 | 57 | ![image](https://github.com/benlau/joplin-plugin-dddot/assets/82716/a90087a5-1e95-4b75-a690-38ef472302f5) 58 | 59 | **Features**: 60 | 61 | - Show the table of content of the selected note 62 | - Click on the item will go to the section 63 | - Press the "Copy" button to copy the link of the section 64 | - Manual/Auto Resize Mode 65 | - Link filter 66 | 67 | **Manual vs Auto Resize Mode** 68 | 69 | The Outline tool has a fixed height by default. Users could adjust it by dragging the border or clicking the "Resize Height to Fit Content" button. 70 | 71 | Users may change it to be auto resized via the Joplin Plugin settings. 72 | 73 | **Link filter** 74 | 75 | ![image](https://github.com/benlau/joplin-plugin-dddot/assets/82716/bdf1a47f-cb9a-4257-8b7c-d99ce8b0629a) 76 | 77 | The Outline tool support to display more than just headings; it can also show links within the note. To configure this, go to Settings > DDDot > Include URL with schemas (comma-separated, e.g., http, https, file). Here, you can specify the types of links you want to appear in the Outline. 78 | 79 | 80 | 81 | 82 | ## Daily Note 83 | 84 | This tool puts a button at the top of the DDDot panel that will create a note for today. If it exists then it will just open it. The title will be set to today in your preferred format. 85 | 86 | By default, it is not using 0:00 as the start time of a day. It is set to 07:00. You may change the option via the preference interface. Moreover, You may assign a shortcut key to the `dddot.cmd.openDailyNote` command to trigger the function. (Default: Cmd+O / Ctrl+O) 87 | 88 | It will create the note according to the default notebook setting. If it is not specified, it will create the note in the first notebook. You may change the default notebook in the preference interface by setting the name of the notebook/folder (e.g. `Inbox`, `Welcome ! (Desktop`). In case you have notebooks/folders with the same name, it will create the note in the first notebook/folder. 89 | 90 | ## Random Note 91 | 92 | This tool puts a button at the top of the DDDot panel that will open a random note. It also registered a command of `dddot.cmd.openRandomNote`, you may assign a shortcut key to trigger this feature. (Default: Cmd+R / Ctrl+R) 93 | 94 | ## Note Quick View 95 | 96 | Right click on the items inside Recent Notes and Backlinks will open a Note Quick View for browsing the content. The viewer is read-only but you could manipulate the opened note with the following operations: 97 | 98 | ![image](https://user-images.githubusercontent.com/82716/193398035-f0186a69-d284-4a34-b4f0-d247b214ddfd.png) 99 | 100 | **Cut and append selected text** 101 | 102 | It will cut the selected text from your note editor and append it to the opened note in the Quick View. If no text selected, it will do nothing. 103 | 104 | **Append note link** 105 | 106 | It will copy the link of the note in the editor in markdown format, and then append to the opened note in quick view. 107 | 108 | **Swap** 109 | 110 | Swap the opened note in the note editor and the quick view. 111 | 112 | -------------------------------------------------------------------------------- /src/services/notedialog/notedialogview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { t } from "i18next"; 3 | import { NoteLink } from "../../views/notelink"; 4 | import { PrimaryButton } from "../../views/primarybutton"; 5 | import { Overlay } from "../../views/overlay"; 6 | 7 | type Props = { 8 | title?: string; 9 | noteId?: string; 10 | content?: string; 11 | onClose: () => void; 12 | } 13 | 14 | type State = { 15 | cm: any; 16 | } 17 | 18 | export function CommandButton(props: { 19 | noteId: string, 20 | command: string, title: string, tooltip: string, alignment: string}) { 21 | const { 22 | noteId, 23 | command, 24 | title, 25 | tooltip, 26 | } = props; 27 | 28 | const { alignment } = props; 29 | const position = alignment !== "" ? `tooltip-${alignment}` : ""; 30 | 31 | const className = `${position}`; 32 | 33 | const onClick = React.useCallback(() => { 34 | DDDot.postMessage({ 35 | type: "notedialog.service.command", 36 | command, 37 | noteId, 38 | }); 39 | }, [noteId, command]); 40 | 41 | return ( 42 | 43 | {t(title)} 44 | 45 | ); 46 | } 47 | 48 | const commands = [ 49 | ["append-selected-text", 50 | "notedialog.cut_append_selected_text", 51 | "notedialog.cut_append_selected_text_tooltip"], 52 | ["append-note-link", 53 | "notedialog.append_note_link", 54 | "notedialog.append_note_link_tooltip"], 55 | ]; 56 | 57 | export function NoteDialogView(props: Props) { 58 | const { 59 | title, 60 | noteId, 61 | content, 62 | } = props; 63 | 64 | const textareaRef = React.useRef(null); 65 | 66 | const state = React.useRef({ 67 | cm: null, 68 | }); 69 | 70 | const updateContent = React.useCallback((newContent?: string) => { 71 | const { cm } = state.current; 72 | 73 | if (cm && newContent != null) { 74 | const originalContent = state.current.cm.getValue(); 75 | cm.setValue(newContent); 76 | 77 | if (originalContent !== "") { 78 | const lastLine = cm.lastLine(); 79 | const rect = cm.charCoords({ line: lastLine, ch: 0 }); 80 | const height = rect.bottom; 81 | cm.scrollTo(null, height); 82 | } 83 | } 84 | }, []); 85 | 86 | React.useEffect(() => { 87 | const textArea = textareaRef.current; 88 | const cm = CodeMirror5Manager.instance.create(textArea, { 89 | mode: "markdown", 90 | lineWrapping: true, 91 | highlightFormatting: true, 92 | readOnly: true, 93 | theme: CodeMirror5Manager.instance.themeName, 94 | }); 95 | cm.setSize(null, "100%"); 96 | state.current.cm = cm; 97 | }, []); 98 | 99 | React.useEffect(() => { 100 | updateContent(content); 101 | }, [content, updateContent]); 102 | 103 | return ( 104 | ) 107 | } 108 | onClose={props.onClose}> 109 | <> 110 |
111 |
112 | 113 |
114 |
115 |
116 | 123 |
124 |
125 |
126 |

{t("notedialog.note_editor")} ⮕ {t("notedialog.quick_view")}

127 | { 128 | // eslint-disable-next-line @typescript-eslint/no-shadow 129 | commands.map(([command, title, tooltip], index) => ( 130 |
131 | 137 |
138 | )) 139 | } 140 |
141 |
142 | 143 |
144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/tools/shortcuts/index.ts: -------------------------------------------------------------------------------- 1 | import { SettingItemType } from "api/types"; 2 | import { t } from "i18next"; 3 | import Tool from "../tool"; 4 | import LinkListModel from "../../models/linklistmodel"; 5 | import { ItemChangeEventType } from "../../repo/joplinrepo"; 6 | import JoplinService from "../../services/joplin/joplinservice"; 7 | import { Link } from "../../types/link"; 8 | 9 | const ShortcutsContent = "dddot.settings.shortcuts.content"; 10 | 11 | export default class Shortcuts extends Tool { 12 | linkListModel: LinkListModel = new LinkListModel(); 13 | 14 | settings(section: string) { 15 | return { 16 | [ShortcutsContent]: { 17 | value: [], 18 | type: SettingItemType.Object, 19 | public: false, 20 | label: "Shortcuts", 21 | section, 22 | }, 23 | }; 24 | } 25 | 26 | async start() { 27 | this.linkListModel.rehydrate(await this.joplinRepo.settingsLoad(ShortcutsContent, [])); 28 | 29 | await this.joplinRepo.workspaceOnNoteChange(async (change) => { 30 | const { id, event } = change; 31 | if (event === ItemChangeEventType.Update) { 32 | const note = await this.joplinService.getNote(id); 33 | const { 34 | title, 35 | } = note; 36 | if (this.linkListModel.update(id, { title })) { 37 | await this.save(); 38 | this.refresh(this.read()); 39 | } 40 | } 41 | }); 42 | } 43 | 44 | async onMessage(message: any) { 45 | switch (message.type) { 46 | case "shortcuts.onReady": 47 | return this.read(); 48 | case "shortcuts.onNoteDropped": 49 | return this.pushNote(message.noteId); 50 | case "shortcuts.tool.removeLink": 51 | this.removeLink(message.id); 52 | return undefined; 53 | case "shortcuts.importShortcuts": 54 | this.importShortcuts(message.shortcuts); 55 | break; 56 | case "shortcuts.onImportExportClicked": 57 | this.showOverlay(); 58 | return undefined; 59 | case "shortcuts.onOrderChanged": 60 | await this.onOrderChanged(message.linkIds); 61 | break; 62 | case "shortcuts.tool.pushFolder": 63 | return this.pushFolder(message.folderId); 64 | default: 65 | break; 66 | } 67 | return undefined; 68 | } 69 | 70 | read(): Link[] { 71 | return this.linkListModel.links.map((link) => ({ 72 | id: link.id, 73 | title: link.title, 74 | type: link.type, 75 | isTodo: link.isTodo, 76 | isTodoCompleted: link.isTodoCompleted, 77 | })); 78 | } 79 | 80 | async refresh(links: Link[]) { 81 | this.joplinRepo.panelPostMessage({ 82 | type: "shortcuts.refresh", 83 | links, 84 | }); 85 | } 86 | 87 | async pushNote(noteId: string) { 88 | const link = await this.joplinService.createNoteLink(noteId); 89 | this.linkListModel.push(link); 90 | await this.save(); 91 | 92 | this.refresh(this.read()); 93 | } 94 | 95 | async removeLink(id: string) { 96 | const result = await this.joplinService.showMessageBox("Are you sure to remove this shortcut?"); 97 | if (result === JoplinService.Cancel) { 98 | return; 99 | } 100 | 101 | this.linkListModel.remove(id); 102 | await this.save(); 103 | this.refresh(this.read()); 104 | } 105 | 106 | async pushFolder(folderId: string) { 107 | const link = await this.joplinService.createFolderLink(folderId); 108 | this.linkListModel.push(link); 109 | await this.save(); 110 | 111 | this.refresh(this.read()); 112 | } 113 | 114 | async onOrderChanged(linkIds: string[]) { 115 | this.linkListModel.reorder(linkIds); 116 | await this.save(); 117 | } 118 | 119 | async save() { 120 | await this.joplinRepo.settingsSave(ShortcutsContent, this.linkListModel.dehydrate()); 121 | } 122 | 123 | get key() { 124 | return "shortcuts"; 125 | } 126 | 127 | get title() { 128 | return t("shortcuts.title"); 129 | } 130 | 131 | async queryExtraButtons() { 132 | return [ 133 | { 134 | tooltip: t("shortcuts.import_export_tooltip"), 135 | icon: "fas fa-file-export", 136 | message: { 137 | type: "shortcuts.onImportExportClicked", 138 | }, 139 | }, 140 | ]; 141 | } 142 | 143 | showOverlay() { 144 | this.joplinRepo.panelPostMessage({ 145 | type: "shortcuts.showOverlay", 146 | }); 147 | } 148 | 149 | async importShortcuts(shortcuts: Link[]) { 150 | this.linkListModel.rehydrate(shortcuts); 151 | await this.save(); 152 | this.refresh(this.read()); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/services/notedialog/notedialogservice.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemLocation, MenuItem } from "api/types"; 2 | import { t } from "i18next"; 3 | import TimerRepo from "../../repo/timerrepo"; 4 | import JoplinService from "../joplin/joplinservice"; 5 | import RendererService from "../renderer/rendererservice"; 6 | 7 | export default class NoteDialogService { 8 | joplinService: JoplinService; 9 | 10 | rendererService: RendererService; 11 | 12 | timerRepo: TimerRepo; 13 | 14 | constructor( 15 | joplinService: JoplinService, 16 | rendererService: RendererService, 17 | timerRepo: TimerRepo = new TimerRepo(), 18 | ) { 19 | this.joplinService = joplinService; 20 | this.rendererService = rendererService; 21 | this.timerRepo = timerRepo; 22 | } 23 | 24 | async registerCommands(): Promise { 25 | const { 26 | joplinRepo, 27 | } = this.joplinService; 28 | 29 | const command = "dddot.cmd.openNoteInSideBar"; 30 | 31 | await joplinRepo.commandsRegister({ 32 | name: command, 33 | label: t("notedialog.open_note_dddot"), 34 | iconName: "fas", 35 | execute: async (noteIds:string[]) => { 36 | if (noteIds.length === 0) { 37 | return; 38 | } 39 | 40 | const noteId = noteIds[0]; 41 | 42 | this.open(noteId); 43 | }, 44 | }); 45 | 46 | await joplinRepo.menuItemsCreate( 47 | `${command}:NoteListContextMenu`, 48 | command, 49 | MenuItemLocation.NoteListContextMenu, 50 | ); 51 | 52 | return []; 53 | } 54 | 55 | async open(noteId: string) { 56 | const { 57 | joplinRepo, 58 | } = this.joplinService; 59 | const note = await this.joplinService.getNote(noteId, ["title", "body"]); 60 | const { 61 | title, 62 | body, 63 | } = note; 64 | 65 | const message = { 66 | type: "notedialog.worker.open", 67 | noteId, 68 | title, 69 | content: body, 70 | }; 71 | 72 | await joplinRepo.panelPostMessage(message); 73 | } 74 | 75 | async openAndWaitOpened(noteId: string, timeout = TimerRepo.DEFAULT_TIMEOUT) { 76 | await this.timerRepo.tryWaitUntilTimeout(async () => { 77 | try { 78 | await this.open(noteId); 79 | return true; 80 | } catch (e) { 81 | return false; 82 | } 83 | }, timeout); 84 | } 85 | 86 | async onMessage(message: any) { 87 | const { 88 | type, 89 | } = message; 90 | 91 | switch (type) { 92 | case "notedialog.service.command": 93 | return this.runCommand(message); 94 | default: 95 | break; 96 | } 97 | return undefined; 98 | } 99 | 100 | async runCommand(message: any) { 101 | const { 102 | command, 103 | } = message; 104 | 105 | switch (command) { 106 | case "append-selected-text": 107 | return this.appendSelectedText(message); 108 | case "append-note-link": 109 | return this.appendNoteLink(message); 110 | case "swap": 111 | return this.swap(message); 112 | default: 113 | break; 114 | } 115 | return undefined; 116 | } 117 | 118 | async appendSelectedText(message: any) { 119 | const { 120 | noteId, 121 | } = message; 122 | const { 123 | joplinService, 124 | } = this; 125 | const { joplinRepo } = this.joplinService; 126 | 127 | const selectedText = (await joplinRepo.commandsExecute("selectedText") as string); 128 | if (selectedText === undefined || selectedText === "") { 129 | return; 130 | } 131 | await joplinRepo.commandsExecute("textCut"); 132 | 133 | const newBody = await joplinService.appendTextToNote(noteId, `\n${selectedText}`); 134 | 135 | this.refresh(newBody); 136 | } 137 | 138 | async appendNoteLink(message: any) { 139 | const { 140 | noteId, 141 | } = message; 142 | const { 143 | joplinService, 144 | rendererService, 145 | } = this; 146 | const { joplinRepo } = this.joplinService; 147 | 148 | const activeNote = await joplinRepo.workspaceSelectedNote(); 149 | if (activeNote === undefined) { 150 | return; 151 | } 152 | 153 | const noteLink = rendererService.renderInlineMarkdownLink(activeNote.id, activeNote.title); 154 | 155 | const newBody = await joplinService.appendTextToNote(noteId, `\n${noteLink}`); 156 | 157 | this.refresh(newBody); 158 | } 159 | 160 | async swap(message) { 161 | const { 162 | noteId, 163 | } = message; 164 | const { 165 | joplinService, 166 | } = this; 167 | 168 | const { joplinRepo } = this.joplinService; 169 | 170 | const activeNote = await joplinRepo.workspaceSelectedNote(); 171 | 172 | if (activeNote === undefined || activeNote.id === noteId) { 173 | return; 174 | } 175 | 176 | this.open(activeNote.id); 177 | joplinService.openNote(noteId); 178 | } 179 | 180 | refresh(content: string) { 181 | const { joplinRepo } = this.joplinService; 182 | 183 | const message = { 184 | type: "notedialog.worker.refresh", 185 | content, 186 | }; 187 | joplinRepo.panelPostMessage(message); 188 | } 189 | } 190 | --------------------------------------------------------------------------------