1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-05 00:12:33 +02:00

Compare commits

..

9 Commits

Author SHA1 Message Date
Roman Musin
ea878fb614 Mobile: Long press on images or other attachments to share them (#3367) 2020-10-12 10:25:59 +01:00
Anton Tuchkov
5fd0408365 Desktop: Added Thunderbird count for desktop client (#3880) 2020-10-12 10:13:41 +01:00
Laurent Cozic
2d099b2bed Merge branch 'dev' of github.com:laurent22/joplin into dev 2020-10-12 10:03:54 +01:00
Caleb John
3c0b9ee522 Desktop: Actually enter insert mode after pressing o/O in CodeMirror vim mode (#3897) 2020-10-12 10:01:40 +01:00
Rasul Kireev
987890ba98 Docs: Fixed broken link in plugins.md (#3901)
The link to the plugin tutorial was broken.
2020-10-12 09:40:06 +01:00
Ji-Hyeon Gim
3358c46122 All: Translation: Update ko.po (#3890)
It updates fuzzy/missing translations for Korean.

Signed-off-by: Ji-Hyeon Gim <potatogim@potatogim.net>
2020-10-11 22:42:18 -04:00
Laurent Cozic
d819e6ee0c Merge branch 'release-1.2' into dev 2020-10-11 22:12:42 +01:00
Laurent Cozic
b66be79351 ios-v10.2.1 2020-10-11 21:56:19 +01:00
Laurent Cozic
433fa21069 Mobile: Upgrade slug package to fix btoa bug 2020-10-11 19:54:13 +01:00
22 changed files with 163 additions and 81 deletions

View File

@@ -199,6 +199,7 @@ ReactNativeClient/lib/checkPermissions.js
ReactNativeClient/lib/commands/historyBackward.js
ReactNativeClient/lib/commands/historyForward.js
ReactNativeClient/lib/commands/synchronize.js
ReactNativeClient/lib/components/BackButtonDialogBox.js
ReactNativeClient/lib/components/screens/UpgradeSyncTargetScreen.js
ReactNativeClient/lib/errorUtils.js
ReactNativeClient/lib/eventManager.js

1
.gitignore vendored
View File

@@ -193,6 +193,7 @@ ReactNativeClient/lib/checkPermissions.js
ReactNativeClient/lib/commands/historyBackward.js
ReactNativeClient/lib/commands/historyForward.js
ReactNativeClient/lib/commands/synchronize.js
ReactNativeClient/lib/components/BackButtonDialogBox.js
ReactNativeClient/lib/components/screens/UpgradeSyncTargetScreen.js
ReactNativeClient/lib/errorUtils.js
ReactNativeClient/lib/eventManager.js

View File

@@ -15,6 +15,8 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.1\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
#: ElectronClient/services/plugins/UserWebviewDialogButtonBar.js:20
#: ElectronClient/checkForUpdates.js:139
@@ -799,7 +801,6 @@ msgid "Toggle sidebar"
msgstr "사이드바 표시 전환"
#: ElectronClient/gui/MainScreen/commands/toggleEditors.js:17
#, fuzzy
msgid "Toggle editors"
msgstr "편집기 배치 형태 전환"
@@ -1389,9 +1390,9 @@ msgstr ""
#: ElectronClient/gui/KeymapConfig/KeymapConfigScreen.js:62
#: ReactNativeClient/lib/services/KeymapService.js:142
#, fuzzy, javascript-format
#, javascript-format
msgid "Error: %s"
msgstr "오류"
msgstr "오류: %s"
#: ElectronClient/gui/KeymapConfig/KeymapConfigScreen.js:125
msgid "Command"
@@ -1463,9 +1464,8 @@ msgid "Exit"
msgstr "종료"
#: ElectronClient/commands/copyDevCommand.js:17
#, fuzzy
msgid "Copy dev mode command to clipboard"
msgstr "경로를 클립보드에 복사"
msgstr "개발자 모드 명령을 클립보드에 복사"
#: ElectronClient/commands/startExternalEditing.js:18
msgid "Edit in external editor"
@@ -1477,13 +1477,12 @@ msgid "Error opening note in editor: %s"
msgstr "편집기에서 노트를 열 수 없는 오류가 발생하였습니다: %s"
#: ElectronClient/commands/toggleExternalEditing.js:16
#, fuzzy
msgid "Toggle external editing"
msgstr "외부 편집을 그만 두시려면 클릭하세요"
msgstr "외부 편집 전환"
#: ElectronClient/commands/toggleExternalEditing.js:42
msgid "Stop"
msgstr ""
msgstr "중지"
#: ElectronClient/commands/stopExternalEditing.js:16
msgid "Stop external editing"
@@ -2203,9 +2202,9 @@ msgid "Cannot load \"%s\" module for format \"%s\" and output \"%s\""
msgstr "\"%s\" 모듈(\"%s\" 포맷과 \"%s\" 출력을 위한)을 불러올 수 없습니다"
#: ReactNativeClient/lib/services/interop/InteropService.js:152
#, fuzzy, javascript-format
#, javascript-format
msgid "Cannot load \"%s\" module for format \"%s\" and target \"%s\""
msgstr "\"%s\" 모듈(\"%s\" 포맷과 \"%s\" 출력을 위한)을 불러올 수 없습니다"
msgstr "\"%s\" 모듈(\"%s\" 포맷과 \"%s\" 대상을 위한)을 불러올 수 없습니다"
#: ReactNativeClient/lib/services/interop/InteropService.js:187
#, javascript-format
@@ -2221,25 +2220,24 @@ msgid "Restored Notes"
msgstr "복구된 노트들"
#: ReactNativeClient/lib/services/KeymapService.js:236
#, fuzzy
msgid "command"
msgstr "명령"
#: ReactNativeClient/lib/services/KeymapService.js:236
#: ReactNativeClient/lib/services/KeymapService.js:241
#, fuzzy, javascript-format
#, javascript-format
msgid "\"%s\" is missing the required \"%s\" property."
msgstr "키맵 항목 %s에 필수 속성인 \"명령\"이 없습니다."
msgstr "\"%s\"에 \"%s\" 필수 속성이 없습니다."
#: ReactNativeClient/lib/services/KeymapService.js:241
#: ReactNativeClient/lib/services/KeymapService.js:248
msgid "accelerator"
msgstr ""
msgstr "단축키"
#: ReactNativeClient/lib/services/KeymapService.js:248
#, fuzzy, javascript-format
#, javascript-format
msgid "Invalid %s: %s."
msgstr "잘못된 답: %s"
msgstr "유효하지 않은 %s: %s."
#: ReactNativeClient/lib/services/KeymapService.js:266
#, javascript-format

View File

@@ -5901,9 +5901,9 @@
}
},
"slug": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/slug/-/slug-3.3.4.tgz",
"integrity": "sha512-VpHbtRCEWmgaZsrZcTsVl/Dhw98lcrOYDO17DNmJCNpppI6s3qJvnNu2Q3D4L84/2bi6vkW40mjNQI9oGQsflg=="
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/slug/-/slug-3.5.0.tgz",
"integrity": "sha512-+pZLDhMtmAc+ZcojQSMlUKDZBYmvhZiZmK8Ffx/D3Q/MIMHPDBAMbWvWN8vJb9xl2MfbDdRWxFzrdOhBiyVpow=="
},
"snapdragon": {
"version": "0.8.2",

View File

@@ -102,7 +102,7 @@
"sax": "^1.2.4",
"server-destroy": "^1.0.1",
"sharp": "^0.23.2",
"slug": "^3.3.4",
"slug": "^3.5.0",
"sprintf-js": "^1.1.1",
"sqlite3": "^4.1.1",
"string-padding": "^1.0.2",

View File

@@ -141,7 +141,7 @@ export default function useListIdent(CodeMirror: any) {
cm.setCursor({ line: anchor.line, ch: line.length });
cm.execCommand('insertListElement');
cm.setOption('disableInput', true);
CodeMirror.Vim.handleKey(cm, 'i', 'macro');
};
CodeMirror.commands.insertListElement = function(cm: any) {

View File

@@ -361,6 +361,17 @@ class SideBarComponent extends React.Component<Props, State> {
renderFolderItem(folder:any, selected:boolean, hasChildren:boolean, depth:number) {
const anchorRef = this.anchorItemRef('folder', folder.id);
const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0;
let noteCount = folder.note_count;
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
if (isExpanded) {
for (let i = 0; i < this.props.folders.length; i++) {
if (this.props.folders[i].parent_id === folder.id) {
noteCount -= this.props.folders[i].note_count;
}
}
}
return <FolderItem
key={folder.id}
@@ -369,10 +380,10 @@ class SideBarComponent extends React.Component<Props, State> {
themeId={this.props.themeId}
depth={depth}
selected={selected}
isExpanded={this.props.collapsedFolderIds.indexOf(folder.id) < 0}
isExpanded={isExpanded}
hasChildren={hasChildren}
anchorRef={anchorRef}
noteCount={folder.note_count}
noteCount={noteCount}
onFolderDragStart_={this.onFolderDragStart_}
onFolderDragOver_={this.onFolderDragOver_}
onFolderDrop_={this.onFolderDrop_}

View File

@@ -1,6 +1,6 @@
{
"name": "Joplin",
"version": "1.3.2",
"version": "1.3.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -10685,9 +10685,9 @@
}
},
"slug": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/slug/-/slug-3.3.4.tgz",
"integrity": "sha512-VpHbtRCEWmgaZsrZcTsVl/Dhw98lcrOYDO17DNmJCNpppI6s3qJvnNu2Q3D4L84/2bi6vkW40mjNQI9oGQsflg=="
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/slug/-/slug-3.5.0.tgz",
"integrity": "sha512-+pZLDhMtmAc+ZcojQSMlUKDZBYmvhZiZmK8Ffx/D3Q/MIMHPDBAMbWvWN8vJb9xl2MfbDdRWxFzrdOhBiyVpow=="
},
"smalltalk": {
"version": "2.5.1",

View File

@@ -1,6 +1,6 @@
{
"name": "Joplin",
"version": "1.3.2",
"version": "1.3.1",
"description": "Joplin for Desktop",
"main": "main.js",
"scripts": {
@@ -196,7 +196,7 @@
"roboto-fontface": "^0.10.0",
"sax": "^1.2.4",
"server-destroy": "^1.0.1",
"slug": "^3.3.4",
"slug": "^3.5.0",
"smalltalk": "^2.5.1",
"sprintf-js": "^1.1.1",
"sqlite3": "^4.1.1",

View File

@@ -100,7 +100,7 @@ The Web Clipper is a browser extension that allows you to save web pages and scr
- [Note History spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/history.md)
- [Sync Lock spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/sync_lock.md)
- [Plugin Architecture spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/plugins.md)
- [Search Sorting spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/search_sorting.md)
- [Search Sorting spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/search_sorting.md)
- Google Summer of Code 2020

View File

@@ -337,7 +337,7 @@
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 54;
CURRENT_PROJECT_VERSION = 55;
DEAD_CODE_STRIPPING = NO;
DEVELOPMENT_TEAM = A9BXAFS6CT;
HEADER_SEARCH_PATHS = (
@@ -380,7 +380,7 @@
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 54;
CURRENT_PROJECT_VERSION = 55;
DEVELOPMENT_TEAM = A9BXAFS6CT;
HEADER_SEARCH_PATHS = (
"$(inherited)",

View File

@@ -0,0 +1,24 @@
const { BackButtonService } = require('lib/services/back-button.js');
const DialogBox = require('react-native-dialogbox').default;
export default class BackButtonDialogBox extends DialogBox {
constructor() {
super();
this.backHandler_ = () => {
if (this.state.isVisible) {
this.close();
return true;
}
return false;
};
}
componentDidMount() {
BackButtonService.addHandler(this.backHandler_);
}
componentWillUnmount() {
BackButtonService.removeHandler(this.backHandler_);
}
}

View File

@@ -2,15 +2,20 @@ import Async from 'react-async';
const React = require('react');
const Component = React.Component;
const { Platform, View, Text } = require('react-native');
const { Platform, View, Text, ToastAndroid } = require('react-native');
const { WebView } = require('react-native-webview');
const { themeStyle } = require('lib/components/global-style.js');
const Setting = require('lib/models/Setting').default;
const { _ } = require('lib/locale.js');
const { reg } = require('lib/registry.js');
const shim = require('lib/shim').default;
const { assetsToHeaders } = require('lib/joplin-renderer');
const shared = require('lib/components/shared/note-screen-shared.js');
const markupLanguageUtils = require('lib/markupLanguageUtils');
const { dialogs } = require('lib/dialogs.js');
const BackButtonDialogBox = require('lib/components/BackButtonDialogBox').default;
const Resource = require('lib/models/Resource.js');
const Share = require('react-native-share').default;
class NoteBodyViewer extends Component {
constructor() {
@@ -64,6 +69,8 @@ class NoteBodyViewer extends Component {
resources: this.props.noteResources,
codeTheme: theme.codeThemeCss,
postMessageSyntax: 'window.joplinPostMessage_',
enableLongPress: shim.isReactNative(),
longPressDelay: 500, // TODO use system value
};
const result = await this.markupToHtml_.render(
@@ -203,6 +210,42 @@ class NoteBodyViewer extends Component {
return this.forceUpdate_;
}
async onResourceLongPress(msg) {
try {
const resourceId = msg.split(':')[1];
const resource = await Resource.load(resourceId);
const name = resource.title ? resource.title : resource.file_name;
const action = await dialogs.pop(this, name, [
{ text: _('Open'), id: 'open' },
{ text: _('Share'), id: 'share' },
]);
if (action === 'open') {
this.props.onJoplinLinkClick(`joplin://${resourceId}`);
} else if (action === 'share') {
const filename = resource.file_name ?
`${resource.file_name}.${resource.file_extension}` :
resource.title;
const targetPath = `${Setting.value('resourceDir')}/${filename}`;
await shim.fsDriver().copy(Resource.fullPath(resource), targetPath);
await Share.open({
type: resource.mime,
filename: resource.title,
url: `file://${targetPath}`,
failOnCancel: false,
});
await shim.fsDriver().remove(targetPath);
}
} catch (e) {
reg.logger().error('Could not handle link long press', e);
ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
}
}
render() {
// Note: useWebKit={false} is needed to go around this bug:
// https://github.com/react-native-community/react-native-webview/issues/376
@@ -256,7 +299,9 @@ class NoteBodyViewer extends Component {
msg = msg.split(':');
const resourceId = msg[1];
if (this.props.onMarkForDownload) this.props.onMarkForDownload({ resourceId: resourceId });
} else {
} else if (msg.startsWith('longclick:')) {
this.onResourceLongPress(msg);
} else if (msg.startsWith('joplin:')) {
this.props.onJoplinLinkClick(msg);
}
}}
@@ -264,6 +309,11 @@ class NoteBodyViewer extends Component {
);
}}
</Async>
<BackButtonDialogBox
ref={dialogbox => {
this.dialogbox = dialogbox;
}}
/>
</View>
);
}

View File

@@ -16,8 +16,22 @@ function installRule(markdownIt, mdOptions, ruleOptions) {
const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl);
if (typeof r === 'string') return r;
if (r) return `<img data-from-md ${htmlUtils.attributesHtml(Object.assign({}, r, { title: title }))}/>`;
if (r) {
let js = '';
if (ruleOptions.enableLongPress) {
const longPressDelay = ruleOptions.longPressDelay ? ruleOptions.longPressDelay : 500;
const id = r['data-resource-id'];
const longPressHandler = `${ruleOptions.postMessageSyntax}('longclick:${id}')`;
const touchStart = `t=setTimeout(()=>{t=null; ${longPressHandler};}, ${longPressDelay});`;
const touchEnd = 'if (!!t) clearTimeout(t); t=null';
js = ` ontouchstart="${touchStart}" ontouchend="${touchEnd}"`;
}
return `<img data-from-md ${htmlUtils.attributesHtml(Object.assign({}, r, { title: title }))}${js}/>`;
}
return defaultRender(tokens, idx, options, env, self);
};
}

View File

@@ -64,12 +64,24 @@ function installRule(markdownIt, mdOptions, ruleOptions) {
href = href.replace(/'/g, '%27');
let js = `${ruleOptions.postMessageSyntax}(${JSON.stringify(href)}, { resourceId: ${JSON.stringify(resourceId)} }); return false;`;
if (ruleOptions.enableLongPress && !!resourceId) {
const longPressDelay = ruleOptions.longPressDelay ? ruleOptions.longPressDelay : 500;
const onClick = `${ruleOptions.postMessageSyntax}(${JSON.stringify(href)})`;
const onLongClick = `${ruleOptions.postMessageSyntax}("longclick:${resourceId}")`;
const touchStart = `t=setTimeout(()=>{t=null; ${onLongClick};}, ${longPressDelay});`;
const touchEnd = `if (!!t) {clearTimeout(t); t=null; ${onClick};}`;
js = `ontouchstart='${touchStart}' ontouchend='${touchEnd}'`;
}
if (hrefAttr.indexOf('#') === 0 && href.indexOf('#') === 0) js = ''; // If it's an internal anchor, don't add any JS since the webview is going to handle navigating to the right place
if (ruleOptions.plainResourceRendering || pluginOptions.linkRenderingType === 2) {
return `<a data-from-md ${resourceIdAttr} title='${htmlentities(title)}' href='${htmlentities(href)}' type='${htmlentities(mime)}'>`;
} else {
return `<a data-from-md ${resourceIdAttr} title='${htmlentities(title)}' href='${hrefAttr}' onclick='${js}' type='${htmlentities(mime)}'>${icon}`;
return `<a data-from-md ${resourceIdAttr} title='${htmlentities(title)}' href='${hrefAttr}' ${js} type='${htmlentities(mime)}'>${icon}`;
}
};
}

View File

@@ -38,6 +38,6 @@
"md5": "^2.2.1",
"memory-cache": "^0.2.0",
"mermaid": "^8.8.1",
"slug": "^3.3.4"
"slug": "^3.5.0"
}
}

View File

@@ -25,6 +25,7 @@ export interface CommandRuntime {
// Used for the (optional) toolbar button title
title?(props:any):string,
// props?:any
}
export interface CommandDeclaration {

View File

@@ -88,9 +88,10 @@ export default class MenuUtils {
}
public commandToStatefulMenuItem(commandName:string, props:any = null):MenuItem {
return this.commandToMenuItem(commandName, () => {
const output = this.commandsToMenuItems([commandName], () => {
return this.service.execute(commandName, props ? props : {});
});
return output[commandName];
}
public commandsToMenuItems(commandNames:string[], onClick:Function):MenuItems {

View File

@@ -5063,6 +5063,7 @@
"minimatch": {
"version": "3.0.4",
"bundled": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -9286,6 +9287,11 @@
"base64-js": "*"
}
},
"react-native-share": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-3.3.3.tgz",
"integrity": "sha512-KFyGe7hnD7ZDJCz1kWTI5iv2MfY5weIz+aTzqyomyxxFb73rHlCRHa+NRNYvnpTntOjnqyB0/GFf1W2J9y6vJQ=="
},
"react-native-side-menu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/react-native-side-menu/-/react-native-side-menu-1.1.3.tgz",
@@ -10171,9 +10177,9 @@
"integrity": "sha1-VusCfWW00tzmyy4tMsTUr8nh1wc="
},
"slug": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/slug/-/slug-3.4.0.tgz",
"integrity": "sha512-s234DYtuRCkzVNL8dL9BRFNmlZUF9NUGjxWG+wwBKPzUFUADrhkjKGVNhDaZs2Lc+UKh3085KItaKilwJA9I2Q=="
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/slug/-/slug-3.5.0.tgz",
"integrity": "sha512-+pZLDhMtmAc+ZcojQSMlUKDZBYmvhZiZmK8Ffx/D3Q/MIMHPDBAMbWvWN8vJb9xl2MfbDdRWxFzrdOhBiyVpow=="
},
"snapdragon": {
"version": "0.8.2",

View File

@@ -77,6 +77,7 @@
"react-native-push-notification": "git+https://github.com/laurent22/react-native-push-notification.git",
"react-native-quick-actions": "^0.3.13",
"react-native-securerandom": "^1.0.0-rc.0",
"react-native-share": "^3.3.3",
"react-native-side-menu": "^1.1.3",
"react-native-sqlite-storage": "^4.1.0",
"react-native-vector-icons": "^6.6.0",
@@ -86,7 +87,7 @@
"redux": "4.0.0",
"reselect": "^4.0.0",
"rn-fetch-blob": "^0.12.0",
"slug": "^3.4.0",
"slug": "^3.5.0",
"stream": "0.0.2",
"string-natural-compare": "^2.0.2",
"string-padding": "^1.0.2",

View File

@@ -1,38 +0,0 @@
diff --git a/node_modules/slug/slug.js b/node_modules/slug/slug.js
index b40320b..2be9650 100644
--- a/node_modules/slug/slug.js
+++ b/node_modules/slug/slug.js
@@ -51,13 +51,31 @@
throw new Error('String "' + str + '" reaches code believed to be unreachable; please open an issue at https://github.com/Trott/slug/issues/new')
}
- if (typeof window === 'undefined') {
+ if (typeof window !== 'undefined' && window.btoa) {
+ base64 = function (input) {
+ return btoa(unescape(encodeURIComponent(input)))
+ }
+ } else if (typeof Buffer !== 'undefined') {
base64 = function (input) {
return Buffer.from(input).toString('base64')
}
} else {
+ // Polyfill for environments that don't have any btoa or Buffer class (eg. React Native)
+ // Copied from https://github.com/davidchambers/Base64.js/blob/a121f75bb10c8dd5d557886c4b1069b31258d230/base64.js#L22
base64 = function (input) {
- return btoa(unescape(encodeURIComponent(input)))
+ var str = unescape(encodeURIComponent(input + ''))
+ for (
+ var block, charCode, idx = 0, map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=', output = '';
+ str.charAt(idx | 0) || (map = '=', idx % 1);
+ output += map.charAt(63 & block >> 8 - idx % 1 * 8)
+ ) {
+ charCode = str.charCodeAt(idx += 3 / 4)
+ if (charCode > 0xFF) {
+ throw new Error("'btoa' failed: The string to be encoded contains characters outside of the Latin1 range.")
+ }
+ block = block << 8 | charCode
+ }
+ return output
}
}

View File

@@ -43,5 +43,5 @@ Restart the app, and Joplin should load the plugin and execute its `onStart` han
# Next steps
- You might want to check the [plugin tutorial](https://github.com/laurent22/joplin/blob/dev/readme/api/tutorials/toc_plugin/) to get a good overview of how to create a complete plugin and how to use the plugin API.
- You might want to check the [plugin tutorial](https://github.com/laurent22/joplin/blob/dev/readme/api/tutorials/toc_plugin.md) to get a good overview of how to create a complete plugin and how to use the plugin API.
- For more information about the plugin API, check the [Plugin API reference](https://joplinapp.org/api/references/plugin_api/classes/joplin.html).