1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-17 18:44:45 +02:00

Desktop: Resolves #5168: Add support for callback URLs (#5416)

This commit is contained in:
Roman Musin 2021-10-16 10:07:41 +01:00 committed by GitHub
parent 4322acc5b7
commit 6879481fd5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 243 additions and 15 deletions

View File

@ -693,9 +693,6 @@ packages/app-desktop/services/commands/stateToWhenClauseContext.js.map
packages/app-desktop/services/commands/types.d.ts
packages/app-desktop/services/commands/types.js
packages/app-desktop/services/commands/types.js.map
packages/app-desktop/services/e2ee.d.ts
packages/app-desktop/services/e2ee.js
packages/app-desktop/services/e2ee.js.map
packages/app-desktop/services/plugins/PlatformImplementation.d.ts
packages/app-desktop/services/plugins/PlatformImplementation.js
packages/app-desktop/services/plugins/PlatformImplementation.js.map
@ -945,6 +942,12 @@ packages/lib/TaskQueue.js.map
packages/lib/array.d.ts
packages/lib/array.js
packages/lib/array.js.map
packages/lib/callbackUrlUtils.d.ts
packages/lib/callbackUrlUtils.js
packages/lib/callbackUrlUtils.js.map
packages/lib/callbackUrlUtils.test.d.ts
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.test.js.map
packages/lib/commands/historyBackward.d.ts
packages/lib/commands/historyBackward.js
packages/lib/commands/historyBackward.js.map

9
.gitignore vendored
View File

@ -676,9 +676,6 @@ packages/app-desktop/services/commands/stateToWhenClauseContext.js.map
packages/app-desktop/services/commands/types.d.ts
packages/app-desktop/services/commands/types.js
packages/app-desktop/services/commands/types.js.map
packages/app-desktop/services/e2ee.d.ts
packages/app-desktop/services/e2ee.js
packages/app-desktop/services/e2ee.js.map
packages/app-desktop/services/plugins/PlatformImplementation.d.ts
packages/app-desktop/services/plugins/PlatformImplementation.js
packages/app-desktop/services/plugins/PlatformImplementation.js.map
@ -928,6 +925,12 @@ packages/lib/TaskQueue.js.map
packages/lib/array.d.ts
packages/lib/array.js
packages/lib/array.js.map
packages/lib/callbackUrlUtils.d.ts
packages/lib/callbackUrlUtils.js
packages/lib/callbackUrlUtils.js.map
packages/lib/callbackUrlUtils.test.d.ts
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.test.js.map
packages/lib/commands/historyBackward.d.ts
packages/lib/commands/historyBackward.js
packages/lib/commands/historyBackward.js.map

View File

@ -187,7 +187,7 @@ if command -v lsb_release &> /dev/null; then
# Linux Mint 4 Debbie is based on Debian 10 and requires the same param handling.
if [[ $DISTVER =~ Debian1. ]] || [ "$DISTVER" = "Linuxmint4" ] && [ "$DISTCODENAME" = "debbie" ] || [ "$DISTVER" = "CentOS" ] && [[ "$DISTMAJOR" =~ 6|7 ]]
then
SANDBOXPARAM=" --no-sandbox"
SANDBOXPARAM="--no-sandbox"
fi
fi
@ -206,7 +206,21 @@ then
# On some systems this directory doesn't exist by default
mkdir -p ~/.local/share/applications
echo -e "[Desktop Entry]\nEncoding=UTF-8\nName=Joplin\nComment=Joplin for Desktop\nExec=${HOME}/.joplin/Joplin.AppImage${SANDBOXPARAM}\nIcon=joplin\nStartupWMClass=Joplin\nType=Application\nCategories=Office;" >> ~/.local/share/applications/appimagekit-joplin.desktop
# Tabs specifically, and not spaces, are needed for indentation with Bash heredocs
cat >> ~/.local/share/applications/appimagekit-joplin.desktop <<-EOF
[Desktop Entry]
Encoding=UTF-8
Name=Joplin
Comment=Joplin for Desktop
Exec=${HOME}/.joplin/Joplin.AppImage ${SANDBOXPARAM}
Icon=joplin
StartupWMClass=Joplin
Type=Application
Categories=Office;
MimeType=x-scheme-handler/joplin;
EOF
# Update application icons
[[ `command -v update-desktop-database` ]] && update-desktop-database ~/.local/share/applications && update-desktop-database ~/.local/share/icons
print "${COLOR_GREEN}OK${COLOR_RESET}"

View File

@ -98,6 +98,7 @@ The Web Clipper is a browser extension that allows you to save web pages and scr
- [What is a conflict?](https://github.com/laurent22/joplin/blob/dev/readme/conflict.md)
- [How to enable debug mode](https://github.com/laurent22/joplin/blob/dev/readme/debugging.md)
- [About the Rich Text editor limitations](https://github.com/laurent22/joplin/blob/dev/readme/rich_text_editor.md)
- [External links](https://github.com/laurent22/joplin/blob/dev/readme/external_links.md)
- [FAQ](https://github.com/laurent22/joplin/blob/dev/readme/faq.md)
- Joplin Cloud

View File

@ -1,6 +1,7 @@
import Logger from '@joplin/lib/Logger';
import { PluginMessage } from './services/plugins/PluginRunner';
import shim from '@joplin/lib/shim';
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
const { BrowserWindow, Tray, screen } = require('electron');
const url = require('url');
@ -30,12 +31,14 @@ export default class ElectronAppWrapper {
private buildDir_: string = null;
private rendererProcessQuitReply_: RendererProcessQuitReply = null;
private pluginWindows_: PluginWindows = {};
private initialCallbackUrl_: string = null;
constructor(electronApp: any, env: string, profilePath: string, isDebugMode: boolean) {
constructor(electronApp: any, env: string, profilePath: string, isDebugMode: boolean, initialCallbackUrl: string) {
this.electronApp_ = electronApp;
this.env_ = env;
this.isDebugMode_ = isDebugMode;
this.profilePath_ = profilePath;
this.initialCallbackUrl_ = initialCallbackUrl;
}
electronApp() {
@ -58,6 +61,10 @@ export default class ElectronAppWrapper {
return this.env_;
}
initialCallbackUrl() {
return this.initialCallbackUrl_;
}
createWindow() {
// Set to true to view errors if the application does not start
const debugEarlyBugs = this.env_ === 'dev' || this.isDebugMode_;
@ -236,7 +243,7 @@ export default class ElectronAppWrapper {
async waitForElectronAppReady() {
if (this.electronApp().isReady()) return Promise.resolve();
return new Promise((resolve) => {
return new Promise<void>((resolve) => {
const iid = setInterval(() => {
if (this.electronApp().isReady()) {
clearInterval(iid);
@ -323,12 +330,18 @@ export default class ElectronAppWrapper {
}
// Someone tried to open a second instance - focus our window instead
this.electronApp_.on('second-instance', () => {
this.electronApp_.on('second-instance', (_e: any, argv: string[]) => {
const win = this.window();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
if (process.platform !== 'darwin') {
const url = argv.find((arg) => isCallbackUrl(arg));
if (url) {
void this.openCallbackUrl(url);
}
}
});
return false;
@ -355,6 +368,17 @@ export default class ElectronAppWrapper {
this.electronApp_.on('activate', () => {
this.win_.show();
});
this.electronApp_.on('open-url', (event: any, url: string) => {
event.preventDefault();
void this.openCallbackUrl(url);
});
}
async openCallbackUrl(url: string) {
this.win_.webContents.send('asynchronous-message', 'openCallbackUrl', {
url: url,
});
}
}

View File

@ -34,6 +34,8 @@ import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog';
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems';
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import ElectronAppWrapper from '../../ElectronAppWrapper';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
import commands from './commands/index';
import invitationRespond from '../../services/share/invitationRespond';
@ -154,6 +156,23 @@ class MainScreenComponent extends React.Component<Props, State> {
this.layoutModeListenerKeyDown = this.layoutModeListenerKeyDown.bind(this);
window.addEventListener('resize', this.window_resize);
ipcRenderer.on('asynchronous-message', (_event: any, message: string, args: any) => {
if (message === 'openCallbackUrl') {
this.openCallbackUrl(args.url);
}
});
const initialCallbackUrl = (bridge().electronApp() as ElectronAppWrapper).initialCallbackUrl();
if (initialCallbackUrl) {
this.openCallbackUrl(initialCallbackUrl);
}
}
private openCallbackUrl(url: string) {
console.log(`openUrl ${url}`);
const { command, params } = parseCallbackUrl(url);
void CommandService.instance().execute(command.toString(), params.id);
}
private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {

View File

@ -20,6 +20,7 @@ import Logger from '@joplin/lib/Logger';
import { FolderEntity } from '@joplin/lib/services/database/types';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { store } from '@joplin/lib/reducer';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
const { connect } = require('react-redux');
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
const { themeStyle } = require('@joplin/lib/theme');
@ -28,6 +29,7 @@ const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const { clipboard } = require('electron');
const logger = Logger.create('Sidebar');
@ -332,10 +334,29 @@ class SidebarComponent extends React.Component<Props, State> {
);
}
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getFolderCallbackUrl(itemId));
},
})
);
}
if (itemType === BaseModel.TYPE_TAG) {
menu.append(new MenuItem(
menuUtils.commandToStatefulMenuItem('renameTag', itemId)
));
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getTagCallbackUrl(itemId));
},
})
);
}
const pluginViews = pluginUtils.viewsByType(this.pluginsRef.current, 'menuItem');

View File

@ -6,6 +6,7 @@ import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import InteropServiceHelper from '../../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import BaseModel from '@joplin/lib/BaseModel';
const bridge = require('@electron/remote').require('./bridge').default;
@ -13,6 +14,7 @@ const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
const { clipboard } = require('electron');
interface ContextMenuProps {
notes: any[];
@ -121,7 +123,6 @@ export default class NoteListUtils {
new MenuItem({
label: _('Copy Markdown link'),
click: async () => {
const { clipboard } = require('electron');
const links = [];
for (let i = 0; i < noteIds.length; i++) {
const note = await Note.load(noteIds[i]);
@ -132,6 +133,17 @@ export default class NoteListUtils {
})
);
if (noteIds.length === 1) {
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getNoteCallbackUrl(noteIds[0]));
},
})
);
}
if ([9, 10].includes(Setting.value('sync.target'))) {
menu.append(
new MenuItem(

View File

@ -8,6 +8,7 @@ const Logger = require('@joplin/lib/Logger').default;
const FsDriverNode = require('@joplin/lib/fs-driver-node').default;
const envFromArgs = require('@joplin/lib/envFromArgs');
const packageInfo = require('./packageInfo.js');
const { isCallbackUrl } = require('@joplin/lib/callbackUrlUtils');
// Electron takes the application name from package.json `name` and
// displays this in the tray icon toolip and message box titles, however in
@ -37,7 +38,11 @@ const env = envFromArgs(process.argv);
const profilePath = profileFromArgs(process.argv);
const isDebugMode = !!process.argv && process.argv.indexOf('--debug') >= 0;
const wrapper = new ElectronAppWrapper(electronApp, env, profilePath, isDebugMode);
electronApp.setAsDefaultProtocolClient('joplin');
const initialCallbackUrl = process.argv.find((arg) => isCallbackUrl(arg));
const wrapper = new ElectronAppWrapper(electronApp, env, profilePath, isDebugMode, initialCallbackUrl);
initBridge(wrapper);

View File

@ -77,13 +77,23 @@
"icon": "../../Assets/macOs.icns",
"target": "dmg",
"hardenedRuntime": true,
"entitlements": "./build-mac/entitlements.mac.inherit.plist"
"entitlements": "./build-mac/entitlements.mac.inherit.plist",
"extendInfo": {
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": ["joplin"],
"CFBundleTypeRole": "Editor",
"CFBundleURLName": "org.joplinapp.x-callback-url"
}
]
}
},
"linux": {
"icon": "../../Assets/LinuxIcons",
"category": "Office",
"desktop": {
"Icon": "joplin"
"Icon": "joplin",
"MimeType": "x-scheme-handler/joplin;"
},
"target": "AppImage"
},

View File

@ -0,0 +1,64 @@
import * as callbackUrlUtils from './callbackUrlUtils';
describe('callbackUrlUtils', function() {
it('should identify valid callback urls', () => {
const url = 'joplin://x-callback-url/123?a=b';
expect(callbackUrlUtils.isCallbackUrl(url)).toBe(true);
});
it('should identify invalid callback urls', () => {
expect(callbackUrlUtils.isCallbackUrl('not-joplin://x-callback-url/123?a=b')).toBe(false);
expect(callbackUrlUtils.isCallbackUrl('joplin://xcallbackurl/123?a=b')).toBe(false);
});
it('should build valid note callback urls', () => {
const noteUrl = callbackUrlUtils.getNoteCallbackUrl('123456');
expect(callbackUrlUtils.isCallbackUrl(noteUrl)).toBe(true);
expect(noteUrl).toBe('joplin://x-callback-url/openNote?id=123456');
});
it('should build valid folder callback urls', () => {
const folderUrl = callbackUrlUtils.getFolderCallbackUrl('123456');
expect(callbackUrlUtils.isCallbackUrl(folderUrl)).toBe(true);
expect(folderUrl).toBe('joplin://x-callback-url/openFolder?id=123456');
});
it('should build valid tag callback urls', () => {
const tagUrl = callbackUrlUtils.getTagCallbackUrl('123456');
expect(callbackUrlUtils.isCallbackUrl(tagUrl)).toBe(true);
expect(tagUrl).toBe('joplin://x-callback-url/openTag?id=123456');
});
it('should parse note callback urls', () => {
const parsed = callbackUrlUtils.parseCallbackUrl('joplin://x-callback-url/openNote?id=123456');
expect(parsed.command).toBe(callbackUrlUtils.CallbackUrlCommand.OpenNote);
expect(parsed.params).toStrictEqual({ id: '123456' });
});
it('should parse folder callback urls', () => {
const parsed = callbackUrlUtils.parseCallbackUrl('joplin://x-callback-url/openFolder?id=123456');
expect(parsed.command).toBe(callbackUrlUtils.CallbackUrlCommand.OpenFolder);
expect(parsed.params).toStrictEqual({ id: '123456' });
});
it('should parse tag callback urls', () => {
const parsed = callbackUrlUtils.parseCallbackUrl('joplin://x-callback-url/openTag?id=123456');
expect(parsed.command).toBe(callbackUrlUtils.CallbackUrlCommand.OpenTag);
expect(parsed.params).toStrictEqual({ id: '123456' });
});
it('should throw an error on invalid input', () => {
expect(() => callbackUrlUtils.parseCallbackUrl('not-a-url'))
.toThrowError('Invalid callback url not-a-url');
expect(() => callbackUrlUtils.parseCallbackUrl('not-joplin://x-callback-url/123?a=b'))
.toThrowError('Invalid callback url not-joplin://x-callback-url/123?a=b');
expect(() => callbackUrlUtils.parseCallbackUrl('joplin://xcallbackurl/123?a=b'))
.toThrowError('Invalid callback url joplin://xcallbackurl/123?a=b');
});
});

View File

@ -0,0 +1,37 @@
const URL = require('url-parse');
export function isCallbackUrl(s: string) {
return s.startsWith('joplin://x-callback-url/');
}
export function getNoteCallbackUrl(noteId: string) {
return `joplin://x-callback-url/openNote?id=${encodeURIComponent(noteId)}`;
}
export function getFolderCallbackUrl(folderId: string) {
return `joplin://x-callback-url/openFolder?id=${encodeURIComponent(folderId)}`;
}
export function getTagCallbackUrl(tagId: string) {
return `joplin://x-callback-url/openTag?id=${encodeURIComponent(tagId)}`;
}
export const enum CallbackUrlCommand {
OpenNote = 'openNote',
OpenFolder = 'openFolder',
OpenTag = 'openTag',
}
export interface CallbackUrlInfo {
command: CallbackUrlCommand;
params: Record<string, string>;
}
export function parseCallbackUrl(s: string): CallbackUrlInfo {
if (!isCallbackUrl(s)) throw new Error(`Invalid callback url ${s}`);
const url = new URL(s, true);
return {
command: url.pathname.substring(url.pathname.lastIndexOf('/') + 1) as CallbackUrlCommand,
params: url.query,
};
}

15
readme/external_links.md Normal file
View File

@ -0,0 +1,15 @@
# External URL links
This feature allows creation of links to notes, folder, and tags. When opening such link Joplin will start, unless it's already running, and open the corresponding item.
To create a link, right click a note, a folder, or a tag in the sidebar and select "Copy external link". The link will be copied to clipboard.
## Link format
* `joplin://x-callback-url/openNote?id=<note id>` for note
* `joplin://x-callback-url/openFolder?id=<folder id>` for folder
* `joplin://x-callback-url/openTag?id=<tag id>` for tag
## Known problems
On macOS if Joplin isn't running it will start but it won't open the note.