mirror of https://github.com/laurent22/joplin.git
Merge branch 'dev' into fix_publish_note
commit
b39b445b2c
|
@ -1489,6 +1489,9 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js.map
|
|||
packages/lib/services/keychain/KeychainServiceDriverBase.d.ts
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js.map
|
||||
packages/lib/services/plugins/BasePlatformImplementation.d.ts
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js.map
|
||||
packages/lib/services/plugins/BasePluginRunner.d.ts
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js.map
|
||||
|
@ -1930,6 +1933,9 @@ packages/plugins/ToggleSidebars/api/types.js.map
|
|||
packages/plugins/ToggleSidebars/src/index.d.ts
|
||||
packages/plugins/ToggleSidebars/src/index.js
|
||||
packages/plugins/ToggleSidebars/src/index.js.map
|
||||
packages/react-native-saf-x/src/index.d.ts
|
||||
packages/react-native-saf-x/src/index.js
|
||||
packages/react-native-saf-x/src/index.js.map
|
||||
packages/renderer/HtmlToHtml.d.ts
|
||||
packages/renderer/HtmlToHtml.js
|
||||
packages/renderer/HtmlToHtml.js.map
|
||||
|
|
|
@ -1479,6 +1479,9 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js.map
|
|||
packages/lib/services/keychain/KeychainServiceDriverBase.d.ts
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js.map
|
||||
packages/lib/services/plugins/BasePlatformImplementation.d.ts
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js.map
|
||||
packages/lib/services/plugins/BasePluginRunner.d.ts
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js.map
|
||||
|
@ -1920,6 +1923,9 @@ packages/plugins/ToggleSidebars/api/types.js.map
|
|||
packages/plugins/ToggleSidebars/src/index.d.ts
|
||||
packages/plugins/ToggleSidebars/src/index.js
|
||||
packages/plugins/ToggleSidebars/src/index.js.map
|
||||
packages/react-native-saf-x/src/index.d.ts
|
||||
packages/react-native-saf-x/src/index.js
|
||||
packages/react-native-saf-x/src/index.js.map
|
||||
packages/renderer/HtmlToHtml.d.ts
|
||||
packages/renderer/HtmlToHtml.js
|
||||
packages/renderer/HtmlToHtml.js.map
|
||||
|
|
|
@ -9,11 +9,13 @@ import PluginManager from 'tinymce/core/api/PluginManager';
|
|||
import * as Api from './api/Api';
|
||||
import * as Commands from './api/Commands';
|
||||
import * as Keyboard from './core/Keyboard';
|
||||
import * as Mouse from './core/Mouse'
|
||||
import * as Buttons from './ui/Buttons';
|
||||
|
||||
export default function () {
|
||||
PluginManager.add('joplinLists', function (editor) {
|
||||
Keyboard.setup(editor);
|
||||
Mouse.setup(editor);
|
||||
Buttons.register(editor);
|
||||
Commands.register(editor);
|
||||
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { isJoplinChecklistItem } from '../listModel/JoplinListUtil';
|
||||
|
||||
|
||||
const setup = function (editor) {
|
||||
const editorClickHandler = (event) => {
|
||||
if (!isJoplinChecklistItem(event.target)) return;
|
||||
|
||||
// We only process the click if it's within the checkbox itself (and not the label).
|
||||
// That checkbox, based on
|
||||
// the current styling is in the negative margin, so offsetX is negative when clicking
|
||||
// on the checkbox itself, and positive when clicking on the label. This is strongly
|
||||
// dependent on how the checkbox is styled, so if the style is changed, this might need
|
||||
// to be updated too.
|
||||
// For the styling, see:
|
||||
// packages/renderer/MdToHtml/rules/checkbox.ts
|
||||
//
|
||||
// The previous solution was to use "pointer-event: none", which mostly work, however
|
||||
// it means that links are no longer clickable when they are within the checkbox label.
|
||||
if (event.offsetX >= 0) return;
|
||||
|
||||
editor.execCommand('ToggleJoplinChecklistItem', false, { element: event.target });
|
||||
}
|
||||
editor.on('click', editorClickHandler);
|
||||
};
|
||||
|
||||
export { setup };
|
|
@ -10,7 +10,7 @@ import * as Settings from '../api/Settings';
|
|||
import * as NodeType from '../core/NodeType';
|
||||
import Editor from 'tinymce/core/api/Editor';
|
||||
import { isCustomList } from '../core/Util';
|
||||
import { findContainerListTypeFromEvent, isJoplinChecklistItem } from '../listModel/JoplinListUtil';
|
||||
import { findContainerListTypeFromEvent } from '../listModel/JoplinListUtil';
|
||||
|
||||
const findIndex = function (list, predicate) {
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
|
@ -38,37 +38,11 @@ const listState = function (editor: Editor, listName, options:any = {}) {
|
|||
buttonApi.setActive(listType === options.listType && lists.length > 0 && lists[0].nodeName === listName && !isCustomList(lists[0]));
|
||||
};
|
||||
|
||||
const editorClickHandler = (event) => {
|
||||
if (!isJoplinChecklistItem(event.target)) return;
|
||||
|
||||
// We only process the click if it's within the checkbox itself (and not the label).
|
||||
// That checkbox, based on
|
||||
// the current styling is in the negative margin, so offsetX is negative when clicking
|
||||
// on the checkbox itself, and positive when clicking on the label. This is strongly
|
||||
// dependent on how the checkbox is styled, so if the style is changed, this might need
|
||||
// to be updated too.
|
||||
// For the styling, see:
|
||||
// packages/renderer/MdToHtml/rules/checkbox.ts
|
||||
//
|
||||
// The previous solution was to use "pointer-event: none", which mostly work, however
|
||||
// it means that links are no longer clickable when they are within the checkbox label.
|
||||
if (event.offsetX >= 0) return;
|
||||
|
||||
editor.execCommand('ToggleJoplinChecklistItem', false, { element: event.target });
|
||||
}
|
||||
|
||||
if (options.listType === 'joplinChecklist') {
|
||||
editor.on('click', editorClickHandler);
|
||||
}
|
||||
|
||||
editor.on('NodeChange', nodeChangeHandler);
|
||||
|
||||
return () => {
|
||||
if (options.listType === 'joplinChecklist') {
|
||||
editor.off('click', editorClickHandler);
|
||||
}
|
||||
editor.off('NodeChange', nodeChangeHandler);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<!-- Monthly/Yearly plan A/B testing -->
|
||||
<!--
|
||||
<script src="https://www.googleoptimize.com/optimize.js?id=OPT-PW3ZPK3"></script>
|
||||
-->
|
||||
|
||||
<!-- Donate button A/B testing -->
|
||||
<!--
|
||||
|
|
|
@ -325,6 +325,7 @@
|
|||
"homenote",
|
||||
"hotfolder",
|
||||
"Howver",
|
||||
"hpagent",
|
||||
"Hrvatska",
|
||||
"htmlentities",
|
||||
"htmlfile",
|
||||
|
@ -950,4 +951,4 @@
|
|||
"မြန်မာ",
|
||||
"កម្ពុជា"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ const Folder = require('@joplin/lib/models/Folder').default;
|
|||
const Tag = require('@joplin/lib/models/Tag').default;
|
||||
const BaseModel = require('@joplin/lib/BaseModel').default;
|
||||
const ListWidget = require('tkwidgets/ListWidget.js');
|
||||
const Setting = require('@joplin/lib/models/Setting').default;
|
||||
const _ = require('@joplin/lib/locale')._;
|
||||
|
||||
class FolderListWidget extends ListWidget {
|
||||
|
@ -25,6 +26,18 @@ class FolderListWidget extends ListWidget {
|
|||
output.push('-'.repeat(this.innerWidth));
|
||||
} else if (item.type_ === Folder.modelType()) {
|
||||
output.push(' '.repeat(this.folderDepth(this.folders, item.id)) + Folder.displayTitle(item));
|
||||
if (Setting.value('showNoteCounts')) {
|
||||
let noteCount = item.note_count;
|
||||
// Subtract children note_count from parent folder.
|
||||
if (this.folderHasChildren_(this.folders,item.id)) {
|
||||
for (let i = 0; i < this.folders.length; i++) {
|
||||
if (this.folders[i].parent_id === item.id) {
|
||||
noteCount -= this.folders[i].note_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
output.push(noteCount);
|
||||
}
|
||||
} else if (item.type_ === Tag.modelType()) {
|
||||
output.push(`[${Folder.displayTitle(item)}]`);
|
||||
} else if (item.type_ === BaseModel.TYPE_SEARCH) {
|
||||
|
|
|
@ -26,7 +26,7 @@ const sharp = require('sharp');
|
|||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const shim = require('@joplin/lib/shim').default;
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local.js');
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
|
||||
const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService').default;
|
||||
const envFromArgs = require('@joplin/lib/envFromArgs');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
|
|
|
@ -33,14 +33,14 @@
|
|||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"@joplin/lib": "~2.9",
|
||||
"@joplin/renderer": "~2.9",
|
||||
"aws-sdk": "^2.588.0",
|
||||
"chalk": "^4.1.0",
|
||||
"compare-version": "^0.1.2",
|
||||
|
@ -67,7 +67,7 @@
|
|||
"yargs-parser": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.8",
|
||||
"@joplin/tools": "~2.9",
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
|
|
|
@ -32,6 +32,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function pageTitle() {
|
||||
const titleElements = document.getElementsByTagName('title');
|
||||
if (titleElements.length) return titleElements[0].text.trim();
|
||||
|
@ -204,6 +213,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
if (nodeName === 'embed') {
|
||||
const src = absoluteUrl(node.src);
|
||||
node.setAttribute('src', src);
|
||||
}
|
||||
|
||||
if (nodeName === 'object') {
|
||||
const data = absoluteUrl(node.data);
|
||||
node.setAttribute('data', data);
|
||||
}
|
||||
|
||||
cleanUpElement(convertToMarkup, node, imageSizes, imageIndexes);
|
||||
}
|
||||
}
|
||||
|
@ -317,6 +336,9 @@
|
|||
}
|
||||
|
||||
function readabilityProcess() {
|
||||
|
||||
if (isPagePdf()) throw new Error('Could not parse PDF document with Readability');
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const readability = new Readability(documentForReadability());
|
||||
const article = readability.parse();
|
||||
|
@ -329,6 +351,14 @@
|
|||
};
|
||||
}
|
||||
|
||||
function isPagePdf() {
|
||||
return document.contentType == 'application/pdf';
|
||||
}
|
||||
|
||||
function embedPageUrl() {
|
||||
return `<embed src="${escapeHtml(window.location.href)}" type="${escapeHtml(document.contentType)}" />`;
|
||||
}
|
||||
|
||||
async function prepareCommandResponse(command) {
|
||||
console.info(`Got command: ${command.name}`);
|
||||
const shouldSendToJoplin = !!command.shouldSendToJoplin;
|
||||
|
@ -375,6 +405,10 @@
|
|||
|
||||
} else if (command.name === 'completePageHtml') {
|
||||
|
||||
if (isPagePdf()) {
|
||||
return clippedContentResponse(pageTitle(), embedPageUrl(), getImageSizes(document), getAnchorNames(document));
|
||||
}
|
||||
|
||||
hardcodePreStyles(document);
|
||||
addSvgClass(document);
|
||||
preProcessDocument(document);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Joplin Web Clipper [DEV]",
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
|
||||
"homepage_url": "https://joplinapp.org",
|
||||
"content_security_policy": "script-src 'self'; object-src 'self'",
|
||||
|
|
|
@ -2078,6 +2078,17 @@
|
|||
setup(editor);
|
||||
};
|
||||
|
||||
var setup$2 = function (editor) {
|
||||
var editorClickHandler = function (event) {
|
||||
if (!isJoplinChecklistItem(event.target))
|
||||
return;
|
||||
if (event.offsetX >= 0)
|
||||
return;
|
||||
editor.execCommand('ToggleJoplinChecklistItem', false, { element: event.target });
|
||||
};
|
||||
editor.on('click', editorClickHandler);
|
||||
};
|
||||
|
||||
var findIndex = function (list, predicate) {
|
||||
for (var index = 0; index < list.length; index++) {
|
||||
var element = list[index];
|
||||
|
@ -2100,21 +2111,8 @@
|
|||
var listType = findContainerListTypeFromEvent(e);
|
||||
buttonApi.setActive(listType === options.listType && lists.length > 0 && lists[0].nodeName === listName && !isCustomList(lists[0]));
|
||||
};
|
||||
var editorClickHandler = function (event) {
|
||||
if (!isJoplinChecklistItem(event.target))
|
||||
return;
|
||||
if (event.offsetX >= 0)
|
||||
return;
|
||||
editor.execCommand('ToggleJoplinChecklistItem', false, { element: event.target });
|
||||
};
|
||||
if (options.listType === 'joplinChecklist') {
|
||||
editor.on('click', editorClickHandler);
|
||||
}
|
||||
editor.on('NodeChange', nodeChangeHandler);
|
||||
return function () {
|
||||
if (options.listType === 'joplinChecklist') {
|
||||
editor.off('click', editorClickHandler);
|
||||
}
|
||||
editor.off('NodeChange', nodeChangeHandler);
|
||||
};
|
||||
};
|
||||
|
@ -2158,6 +2156,7 @@
|
|||
function Plugin () {
|
||||
PluginManager.add('joplinLists', function (editor) {
|
||||
setup$1(editor);
|
||||
setup$2(editor);
|
||||
register$1(editor);
|
||||
register(editor);
|
||||
return get(editor);
|
||||
|
|
|
@ -114,7 +114,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
|||
if (syncStarted) return () => {};
|
||||
if (formNote.hasChanged) return () => {};
|
||||
|
||||
reg.logger().debug('Sync has finished and note has never been changed - reloading it');
|
||||
reg.logger().info('Sync has finished and note has never been changed - reloading it');
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
|
|
|
@ -456,6 +456,11 @@ const NoteListComponent = (props: Props) => {
|
|||
useEffect(() => {
|
||||
// When a note list item is styled by userchrome.css, its height is reflected.
|
||||
// Ref. https://github.com/laurent22/joplin/pull/6542
|
||||
if (dragOverTargetNoteIndex !== null) {
|
||||
// When dragged, its height should not be considered.
|
||||
// Ref. https://github.com/laurent22/joplin/issues/6639
|
||||
return;
|
||||
}
|
||||
const noteItem = Object.values<any>(itemAnchorRefs_.current)[0]?.current;
|
||||
const actualItemHeight = noteItem?.getHeight() ?? 0;
|
||||
if (actualItemHeight >= 8) { // To avoid generating too many narrow items
|
||||
|
|
|
@ -26,7 +26,7 @@ const shim = require('@joplin/lib/shim').default;
|
|||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const bridge = require('@electron/remote').require('./bridge').default;
|
||||
const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService').default;
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local.js');
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
|
||||
const React = require('react');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.8.8",
|
||||
"version": "2.9.1",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
@ -105,7 +105,7 @@
|
|||
},
|
||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.8",
|
||||
"@joplin/tools": "~2.9",
|
||||
"@testing-library/react-hooks": "^3.4.2",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
|
@ -137,8 +137,8 @@
|
|||
"@electron/remote": "^2.0.1",
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
"@joeattardi/emoji-button": "^4.6.0",
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"@joplin/lib": "~2.9",
|
||||
"@joplin/renderer": "~2.9",
|
||||
"async-mutex": "^0.1.3",
|
||||
"codemirror": "^5.56.0",
|
||||
"color": "^3.1.2",
|
||||
|
|
|
@ -1,19 +1,12 @@
|
|||
import bridge from '../bridge';
|
||||
import { Implementation as WindowImplementation } from '@joplin/lib/services/plugins/api/JoplinWindow';
|
||||
import { injectCustomStyles } from '@joplin/lib/CssUtils';
|
||||
import { VersionInfo } from '@joplin/lib/services/plugins/api/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation';
|
||||
const { clipboard, nativeImage } = require('electron');
|
||||
|
||||
interface JoplinViewsDialogs {
|
||||
showMessageBox(message: string): Promise<number>;
|
||||
}
|
||||
|
||||
interface JoplinViews {
|
||||
dialogs: JoplinViewsDialogs;
|
||||
}
|
||||
|
||||
interface Joplin {
|
||||
views: JoplinViews;
|
||||
}
|
||||
const packageInfo = require('../../packageInfo');
|
||||
|
||||
interface Components {
|
||||
[key: string]: any;
|
||||
|
@ -22,7 +15,7 @@ interface Components {
|
|||
// PlatformImplementation provides access to platform specific dependencies,
|
||||
// such as the clipboard, message dialog, etc. It allows having the same plugin
|
||||
// API for all platforms, but with different implementations.
|
||||
export default class PlatformImplementation {
|
||||
export default class PlatformImplementation extends BasePlatformImplementation {
|
||||
|
||||
private static instance_: PlatformImplementation;
|
||||
private joplin_: Joplin;
|
||||
|
@ -33,6 +26,14 @@ export default class PlatformImplementation {
|
|||
return this.instance_;
|
||||
}
|
||||
|
||||
public get versionInfo(): VersionInfo {
|
||||
return {
|
||||
version: packageInfo.version,
|
||||
syncVersion: Setting.value('syncVersion'),
|
||||
profileVersion: reg.db().version(),
|
||||
};
|
||||
}
|
||||
|
||||
public get clipboard() {
|
||||
return clipboard;
|
||||
}
|
||||
|
@ -48,6 +49,8 @@ export default class PlatformImplementation {
|
|||
}
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
|
||||
this.components_ = {};
|
||||
|
||||
this.joplin_ = {
|
||||
|
|
|
@ -65,3 +65,5 @@ lib/rnInjectedJs/
|
|||
dist/
|
||||
components/NoteEditor/CodeMirror.bundle.js
|
||||
components/NoteEditor/CodeMirror.bundle.min.js
|
||||
|
||||
utils/fs-driver-android.js
|
||||
|
|
|
@ -147,7 +147,7 @@ android {
|
|||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097668
|
||||
versionName "2.8.1"
|
||||
versionName "2.9.0"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
|
|
@ -38,9 +38,10 @@ class Dropdown extends React.Component {
|
|||
const listTop = Math.min(maxListTop, this.state.headerSize.y + this.state.headerSize.height);
|
||||
|
||||
const wrapperStyle = {
|
||||
width: this.state.headerSize.width,
|
||||
height: listHeight + 2, // +2 for the border (otherwise it makes the scrollbar appear)
|
||||
marginTop: listTop,
|
||||
alignSelf: 'center',
|
||||
marginLeft: this.state.headerSize.x,
|
||||
};
|
||||
|
||||
const itemListStyle = Object.assign({}, this.props.itemListStyle ? this.props.itemListStyle : {}, {
|
||||
|
@ -86,7 +87,6 @@ class Dropdown extends React.Component {
|
|||
if (this.props.labelTransform && this.props.labelTransform === 'trim') headerLabel = headerLabel.trim();
|
||||
|
||||
const closeList = () => {
|
||||
if (this.props.onClose) this.props.onClose();
|
||||
this.setState({ listVisible: false });
|
||||
};
|
||||
|
||||
|
@ -116,7 +116,6 @@ class Dropdown extends React.Component {
|
|||
onPress={() => {
|
||||
this.updateHeaderCoordinates();
|
||||
this.setState({ listVisible: true });
|
||||
if (this.props.onOpen) this.props.onOpen();
|
||||
}}
|
||||
>
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}>
|
||||
|
|
|
@ -10,17 +10,25 @@
|
|||
// from NoteEditor.tsx.
|
||||
|
||||
import { EditorState, Extension } from '@codemirror/state';
|
||||
import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@codemirror/view';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { defaultHighlightStyle, HighlightStyle, tags } from '@codemirror/highlight';
|
||||
import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/history';
|
||||
import { highlightSelectionMatches, search } from '@codemirror/search';
|
||||
import { defaultHighlightStyle, syntaxHighlighting, HighlightStyle } from '@codemirror/language';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { EditorView, drawSelection, highlightSpecialChars, ViewUpdate } from '@codemirror/view';
|
||||
import { undo, redo, history, undoDepth, redoDepth } from '@codemirror/commands';
|
||||
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentOnInput } from '@codemirror/language';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
|
||||
|
||||
interface CodeMirrorResult {
|
||||
editor: EditorView;
|
||||
undo: Function;
|
||||
redo: Function;
|
||||
select: (anchor: number, head: number)=> void;
|
||||
insertText: (text: string)=> void;
|
||||
select(anchor: number, head: number): void;
|
||||
scrollSelectionIntoView(): void;
|
||||
insertText(text: string): void;
|
||||
}
|
||||
|
||||
function postMessage(name: string, data: any) {
|
||||
|
@ -46,7 +54,7 @@ function logMessage(...msg: any[]) {
|
|||
// the app is running. It seems that what appears as ".ͼ1" in the CSS is the
|
||||
// equivalent of "&" in the theme object. So to target ".ͼ1.cm-focused", you'd
|
||||
// use '&.cm-focused' in the theme.
|
||||
const createTheme = (theme: any): Extension => {
|
||||
const createTheme = (theme: any): Extension[] => {
|
||||
const isDarkTheme = theme.appearance === 'dark';
|
||||
|
||||
const baseGlobalStyle: Record<string, string> = {
|
||||
|
@ -91,7 +99,7 @@ const createTheme = (theme: any): Extension => {
|
|||
fontFamily: theme.fontFamily,
|
||||
};
|
||||
|
||||
const syntaxHighlighting = HighlightStyle.define([
|
||||
const highlightingStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: tags.strong,
|
||||
fontWeight: 'bold',
|
||||
|
@ -140,7 +148,11 @@ const createTheme = (theme: any): Extension => {
|
|||
return [
|
||||
baseTheme,
|
||||
appearanceTheme,
|
||||
syntaxHighlighting,
|
||||
syntaxHighlighting(highlightingStyle),
|
||||
|
||||
// If we haven't defined highlighting for tags, fall back
|
||||
// to the default.
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
];
|
||||
};
|
||||
|
||||
|
@ -168,15 +180,20 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
|||
|
||||
const editor = new EditorView({
|
||||
state: EditorState.create({
|
||||
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
|
||||
// for a sample configuration.
|
||||
extensions: [
|
||||
markdown(),
|
||||
createTheme(theme),
|
||||
...createTheme(theme),
|
||||
history(),
|
||||
search(),
|
||||
drawSelection(),
|
||||
highlightSpecialChars(),
|
||||
highlightSelectionMatches(),
|
||||
indentOnInput(),
|
||||
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({ autocapitalize: 'sentence' }),
|
||||
defaultHighlightStyle.fallback,
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (viewUpdate.docChanged) {
|
||||
postMessage('onChange', { value: editor.state.doc.toString() });
|
||||
|
@ -190,6 +207,9 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
|||
postMessage('onSelectionChange', { selection: { start: selStart, end: selEnd } });
|
||||
}
|
||||
}),
|
||||
keymap.of([
|
||||
...defaultKeymap, ...historyKeymap, ...searchKeymap,
|
||||
]),
|
||||
],
|
||||
doc: initialText,
|
||||
}),
|
||||
|
@ -212,6 +232,11 @@ export function initCodeMirror(parentElement: any, initialText: string, theme: a
|
|||
scrollIntoView: true,
|
||||
}));
|
||||
},
|
||||
scrollSelectionIntoView: () => {
|
||||
editor.dispatch(editor.state.update({
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
editor.dispatch(editor.state.replaceSelection(text));
|
||||
},
|
||||
|
|
|
@ -266,11 +266,16 @@ function NoteEditor(props: Props, ref: any) {
|
|||
|
||||
cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, theme);
|
||||
${setInitialSelectionJS}
|
||||
|
||||
// Fixes https://github.com/laurent22/joplin/issues/5949
|
||||
window.onresize = () => {
|
||||
cm.scrollSelectionIntoView();
|
||||
};
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
} finally {
|
||||
true;
|
||||
}
|
||||
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId);
|
||||
|
|
|
@ -60,12 +60,31 @@ class ActionButtonComponent extends React.Component {
|
|||
|
||||
renderIconMultiStates() {
|
||||
const button = this.props.buttons[this.state.buttonIndex];
|
||||
return <Icon name={button.icon} style={styles.actionButtonIcon} />;
|
||||
|
||||
return <Icon
|
||||
name={button.icon}
|
||||
style={styles.actionButtonIcon}
|
||||
accessibilityLabel={button.title}
|
||||
/>;
|
||||
}
|
||||
|
||||
renderIcon() {
|
||||
const mainButton = this.props.mainButton ? this.props.mainButton : {};
|
||||
return mainButton.icon ? <Icon name={mainButton.icon} style={styles.actionButtonIcon} /> : <Icon name="md-add" style={styles.actionButtonIcon} />;
|
||||
const iconName = mainButton.icon ?? 'md-add';
|
||||
|
||||
// Icons don't have alt text by default. We need to add it:
|
||||
const iconTitle = mainButton.title ?? _('Add new');
|
||||
|
||||
// TODO: If the button toggles a sub-menu, state whether the submenu is open
|
||||
// or closed.
|
||||
|
||||
return (
|
||||
<Icon
|
||||
name={iconName}
|
||||
style={styles.actionButtonIcon}
|
||||
accessibilityLabel={iconTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -99,8 +118,14 @@ class ActionButtonComponent extends React.Component {
|
|||
const buttonTitle = button.title ? button.title : '';
|
||||
const key = `${buttonTitle.replace(/\s/g, '_')}_${button.icon}`;
|
||||
buttonComps.push(
|
||||
// TODO: By default, ReactNativeActionButton also adds a title, which is focusable
|
||||
// by the screen reader. As such, each item currently is double-focusable
|
||||
<ReactNativeActionButton.Item key={key} buttonColor={button.color} title={buttonTitle} onPress={button.onPress}>
|
||||
<Icon name={button.icon} style={styles.actionButtonIcon} />
|
||||
<Icon
|
||||
name={button.icon}
|
||||
style={styles.actionButtonIcon}
|
||||
accessibilityLabel={buttonTitle}
|
||||
/>
|
||||
</ReactNativeActionButton.Item>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -61,7 +61,14 @@ class Checkbox extends Component {
|
|||
// if (style.display) thStyle.display = style.display;
|
||||
|
||||
return (
|
||||
<TouchableHighlight onPress={() => this.onPress()} style={thStyle}>
|
||||
<TouchableHighlight
|
||||
onPress={() => this.onPress()}
|
||||
style={thStyle}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{
|
||||
checked: this.state.checked,
|
||||
}}
|
||||
accessibilityLabel={this.props.accessibilityLabel ?? ''}>
|
||||
<Icon name={iconName} style={checkboxIconStyle} />
|
||||
</TouchableHighlight>
|
||||
);
|
||||
|
|
|
@ -6,6 +6,7 @@ const { Checkbox } = require('./checkbox.js');
|
|||
const Note = require('@joplin/lib/models/Note').default;
|
||||
const time = require('@joplin/lib/time').default;
|
||||
const { themeStyle } = require('./global-style.js');
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
|
||||
class NoteItemComponent extends Component {
|
||||
constructor() {
|
||||
|
@ -128,13 +129,20 @@ class NoteItemComponent extends Component {
|
|||
|
||||
const selectionWrapperStyle = isSelected ? this.styles().selectionWrapperSelected : this.styles().selectionWrapper;
|
||||
|
||||
const noteTitle = Note.displayTitle(note);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={() => this.onPress()} onLongPress={() => this.onLongPress()} activeOpacity={0.5}>
|
||||
<View style={selectionWrapperStyle}>
|
||||
<View style={opacityStyle}>
|
||||
<View style={listItemStyle}>
|
||||
<Checkbox style={checkboxStyle} checked={checkboxChecked} onChange={checked => this.todoCheckbox_change(checked)} />
|
||||
<Text style={listItemTextStyle}>{Note.displayTitle(note)}</Text>
|
||||
<Checkbox
|
||||
style={checkboxStyle}
|
||||
checked={checkboxChecked}
|
||||
onChange={checked => this.todoCheckbox_change(checked)}
|
||||
accessibilityLabel={_('to-do: %s', noteTitle)}
|
||||
/>
|
||||
<Text style={listItemTextStyle}>{noteTitle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
@ -29,10 +29,8 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
this.state = { showUndoRedoButtons: true };
|
||||
}
|
||||
|
||||
|
||||
styles() {
|
||||
const themeId = Setting.value('theme');
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
|
@ -227,7 +225,12 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
render() {
|
||||
function sideMenuButton(styles, onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
|
||||
accessibilityLabel={_('Sidebar')}
|
||||
accessibilityHint={_('Show/hide the sidebar')}
|
||||
accessibilityRole="button">
|
||||
<View style={styles.sideMenuButton}>
|
||||
<Icon name="md-menu" style={styles.topIcon} />
|
||||
</View>
|
||||
|
@ -237,9 +240,18 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
|
||||
function backButton(styles, onPress, disabled) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} disabled={disabled}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
|
||||
accessibilityLabel={_('Back')}
|
||||
accessibilityHint={_('Navigate to the previous view')}
|
||||
accessibilityRole="button">
|
||||
<View style={disabled ? styles.backButtonDisabled : styles.backButton}>
|
||||
<Icon name="md-arrow-back" style={styles.topIcon} />
|
||||
<Icon
|
||||
name="md-arrow-back"
|
||||
style={styles.topIcon}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
@ -251,20 +263,31 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
const icon = disabled ? <Icon name="md-checkmark" style={styles.savedButtonIcon} /> : <Image style={styles.saveButtonIcon} source={require('./SaveIcon.png')} />;
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} disabled={disabled} style={{ padding: 0 }}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={{ padding: 0 }}
|
||||
|
||||
accessibilityLabel={_('Save changes')}
|
||||
accessibilityHint={disabled ? _('Any changes have been saved') : null}
|
||||
accessibilityRole="button">
|
||||
<View style={disabled ? styles.saveButtonDisabled : styles.saveButton}>{icon}</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTopButton = (options) => {
|
||||
if (!options.visible || !this.state.showUndoRedoButtons) return null;
|
||||
if (!options.visible) return null;
|
||||
|
||||
const icon = <Icon name={options.iconName} style={this.styles().topIcon} />;
|
||||
const viewStyle = options.disabled ? this.styles().iconButtonDisabled : this.styles().iconButton;
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={options.onPress} style={{ padding: 0 }} disabled={!!options.disabled}>
|
||||
<TouchableOpacity
|
||||
onPress={options.onPress}
|
||||
style={{ padding: 0 }}
|
||||
disabled={!!options.disabled}
|
||||
accessibilityRole="button">
|
||||
<View style={viewStyle}>{icon}</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
@ -289,7 +312,11 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
|
||||
function selectAllButton(styles, onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
|
||||
accessibilityLabel={_('Select all')}
|
||||
accessibilityRole="button">
|
||||
<View style={styles.iconButton}>
|
||||
<Icon name="md-checkmark-circle-outline" style={styles.topIcon} />
|
||||
</View>
|
||||
|
@ -299,7 +326,11 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
|
||||
function searchButton(styles, onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
|
||||
accessibilityLabel={_('Search')}
|
||||
accessibilityRole="button">
|
||||
<View style={styles.iconButton}>
|
||||
<Icon name="md-search" style={styles.topIcon} />
|
||||
</View>
|
||||
|
@ -309,7 +340,15 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
|
||||
function deleteButton(styles, onPress, disabled) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} disabled={disabled}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
|
||||
accessibilityLabel={_('Delete')}
|
||||
accessibilityHint={
|
||||
disabled ? null : _('Delete selected notes')
|
||||
}
|
||||
accessibilityRole="button">
|
||||
<View style={disabled ? styles.iconButtonDisabled : styles.iconButton}>
|
||||
<Icon name="md-trash" style={styles.topIcon} />
|
||||
</View>
|
||||
|
@ -319,7 +358,15 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
|
||||
function duplicateButton(styles, onPress, disabled) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} disabled={disabled}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
|
||||
accessibilityLabel={_('Duplicate')}
|
||||
accessibilityHint={
|
||||
disabled ? null : _('Duplicate selected notes')
|
||||
}
|
||||
accessibilityRole="button">
|
||||
<View style={disabled ? styles.iconButtonDisabled : styles.iconButton}>
|
||||
<Icon name="md-copy" style={styles.topIcon} />
|
||||
</View>
|
||||
|
@ -329,7 +376,11 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
|
||||
function sortButton(styles, onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
|
||||
accessibilityLabel={_('Sort notes by')}
|
||||
accessibilityRole="button">
|
||||
<View style={styles.iconButton}>
|
||||
<Icon name="filter-outline" style={styles.topIcon} />
|
||||
</View>
|
||||
|
@ -424,16 +475,6 @@ class ScreenHeaderComponent extends React.PureComponent {
|
|||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
}}
|
||||
onOpen={() => {
|
||||
this.setState({
|
||||
showUndoRedoButtons: false,
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
this.setState({
|
||||
showUndoRedoButtons: true,
|
||||
});
|
||||
}}
|
||||
onValueChange={async (folderId, itemIndex) => {
|
||||
// If onValueChange is specified, use this as a callback, otherwise do the default
|
||||
// which is to take the selectedNoteIds from the state and move them to the
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
|
||||
import Slider from '@react-native-community/slider';
|
||||
const React = require('react');
|
||||
const { Platform, Linking, View, Switch, StyleSheet, ScrollView, Text, Button, TouchableOpacity, TextInput, Alert, PermissionsAndroid } = require('react-native');
|
||||
const { Platform, Linking, View, Switch, StyleSheet, ScrollView, Text, Button, TouchableOpacity, TextInput, Alert, PermissionsAndroid, TouchableNativeFeedback } = require('react-native');
|
||||
import Setting, { AppType } from '@joplin/lib/models/Setting';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import ReportService from '@joplin/lib/services/ReportService';
|
||||
|
@ -20,6 +21,7 @@ const { Dropdown } = require('../Dropdown.js');
|
|||
const { themeStyle } = require('../global-style.js');
|
||||
const shared = require('@joplin/lib/components/shared/config-shared.js');
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import { openDocumentTree } from '@joplin/react-native-saf-x';
|
||||
const RNFS = require('react-native-fs');
|
||||
|
||||
class ConfigScreenComponent extends BaseScreenComponent {
|
||||
|
@ -37,12 +39,27 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
|||
creatingReport: false,
|
||||
profileExportStatus: 'idle',
|
||||
profileExportPath: '',
|
||||
fileSystemSyncPath: Setting.value('sync.2.path'),
|
||||
};
|
||||
|
||||
this.scrollViewRef_ = React.createRef();
|
||||
|
||||
shared.init(this, reg);
|
||||
|
||||
this.selectDirectoryButtonPress = async () => {
|
||||
try {
|
||||
const doc = await openDocumentTree(true);
|
||||
if (doc?.uri) {
|
||||
this.setState({ fileSystemSyncPath: doc.uri });
|
||||
shared.updateSettingValue(this, 'sync.2.path', doc.uri);
|
||||
} else {
|
||||
throw new Error('User cancelled operation');
|
||||
}
|
||||
} catch (e) {
|
||||
reg.logger().info('Didn\'t pick sync dir: ', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.checkSyncConfig_ = async () => {
|
||||
// to ignore TLS erros we need to chage the global state of the app, if the check fails we need to restore the original state
|
||||
// this call sets the new value and returns the previous one which we can use later to revert the change
|
||||
|
@ -58,8 +75,15 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
|||
};
|
||||
|
||||
this.saveButton_press = async () => {
|
||||
if (this.state.changedSettingKeys.includes('sync.target') && this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('filesystem') && !(await this.checkFilesystemPermission())) {
|
||||
Alert.alert(_('Warning'), _('In order to use file system synchronisation your permission to write to external storage is required.'));
|
||||
if (this.state.changedSettingKeys.includes('sync.target') && this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('filesystem')) {
|
||||
if (Platform.OS === 'android') {
|
||||
if (Platform.Version < 29) {
|
||||
if (!(await this.checkFilesystemPermission())) {
|
||||
Alert.alert(_('Warning'), _('In order to use file system synchronisation your permission to write to external storage is required.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings anyway, even if permission has not been granted
|
||||
}
|
||||
|
||||
|
@ -476,6 +500,20 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
|||
</View>
|
||||
);
|
||||
} else if (md.type == Setting.TYPE_STRING) {
|
||||
if (md.key === 'sync.2.path' && Platform.OS === 'android' && Platform.Version > 28) {
|
||||
return (
|
||||
<TouchableNativeFeedback key={key} onPress={this.selectDirectoryButtonPress} style={this.styles().settingContainer}>
|
||||
<View style={this.styles().settingContainer}>
|
||||
<Text key="label" style={this.styles().settingText}>
|
||||
{md.label()}
|
||||
</Text>
|
||||
<Text style={this.styles().settingControl}>
|
||||
{this.state.fileSystemSyncPath}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableNativeFeedback>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View key={key} style={this.styles().settingContainer}>
|
||||
<Text key="label" style={this.styles().settingText}>
|
||||
|
|
|
@ -250,7 +250,8 @@ class SideMenuContentComponent extends Component {
|
|||
|
||||
let iconWrapper = null;
|
||||
|
||||
const iconName = this.props.collapsedFolderIds.indexOf(folder.id) >= 0 ? 'chevron-down' : 'chevron-up';
|
||||
const collapsed = this.props.collapsedFolderIds.indexOf(folder.id) >= 0;
|
||||
const iconName = collapsed ? 'chevron-down' : 'chevron-up';
|
||||
const iconComp = <Icon name={iconName} style={this.styles().folderIcon} />;
|
||||
|
||||
iconWrapper = !hasChildren ? null : (
|
||||
|
@ -260,6 +261,9 @@ class SideMenuContentComponent extends Component {
|
|||
onPress={() => {
|
||||
if (hasChildren) this.folder_togglePress(folder);
|
||||
}}
|
||||
|
||||
accessibilityLabel={collapsed ? _('Expand folder') : _('Collapse folder')}
|
||||
accessibilityRole="togglebutton"
|
||||
>
|
||||
{iconComp}
|
||||
</TouchableOpacity>
|
||||
|
|
|
@ -498,7 +498,7 @@
|
|||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 12.8.1;
|
||||
MARKETING_VERSION = 12.9.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
@ -526,7 +526,7 @@
|
|||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 12.8.1;
|
||||
MARKETING_VERSION = 12.9.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
@ -674,7 +674,7 @@
|
|||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 12.8.1;
|
||||
MARKETING_VERSION = 12.9.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
|
@ -705,7 +705,7 @@
|
|||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 12.8.1;
|
||||
MARKETING_VERSION = 12.9.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
@ -44,6 +44,7 @@ module.exports = {
|
|||
'@joplin/tools': path.resolve(__dirname, '../tools/'),
|
||||
'@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'),
|
||||
'@joplin/fork-uslug': path.resolve(__dirname, '../fork-uslug/'),
|
||||
'@joplin/react-native-saf-x': path.resolve(__dirname, '../react-native-saf-x/'),
|
||||
},
|
||||
{
|
||||
get: (target, name) => {
|
||||
|
@ -62,5 +63,6 @@ module.exports = {
|
|||
path.resolve(__dirname, '../tools'),
|
||||
path.resolve(__dirname, '../fork-htmlparser2'),
|
||||
path.resolve(__dirname, '../fork-uslug'),
|
||||
path.resolve(__dirname, '../react-native-saf-x'),
|
||||
],
|
||||
};
|
||||
|
|
|
@ -2,10 +2,11 @@
|
|||
"name": "@joplin/app-mobile",
|
||||
"description": "Joplin for Mobile",
|
||||
"license": "MIT",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "react-native start --reset-cache",
|
||||
"android": "react-native run-android",
|
||||
"build": "gulp build",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
|
||||
|
@ -15,8 +16,9 @@
|
|||
"postinstall": "jetify && yarn run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"@joplin/lib": "~2.9",
|
||||
"@joplin/react-native-saf-x": "~2.9",
|
||||
"@joplin/renderer": "~2.9",
|
||||
"@react-native-community/clipboard": "^1.5.0",
|
||||
"@react-native-community/datetimepicker": "^3.0.3",
|
||||
"@react-native-community/geolocation": "^2.0.2",
|
||||
|
@ -70,12 +72,14 @@
|
|||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@codemirror/highlight": "^0.18.4",
|
||||
"@codemirror/history": "^0.18.1",
|
||||
"@codemirror/lang-markdown": "^0.18.4",
|
||||
"@codemirror/state": "^0.18.7",
|
||||
"@codemirror/view": "^0.18.19",
|
||||
"@joplin/tools": "~2.8",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-markdown": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@joplin/tools": "~2.9",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@rollup/plugin-node-resolve": "^13.0.0",
|
||||
"@rollup/plugin-typescript": "^8.2.1",
|
||||
"@types/node": "^14.14.6",
|
||||
|
|
|
@ -70,7 +70,7 @@ const { SideMenuContentNote } = require('./components/side-menu-content-note.js'
|
|||
const { DatabaseDriverReactNative } = require('./utils/database-driver-react-native');
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
const { defaultState } = require('@joplin/lib/reducer');
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local.js');
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine';
|
||||
const WelcomeUtils = require('@joplin/lib/WelcomeUtils');
|
||||
|
@ -872,7 +872,8 @@ class AppComponent extends React.Component {
|
|||
Config: { screen: ConfigScreen },
|
||||
};
|
||||
|
||||
const statusBarStyle = theme.appearance === 'light' ? 'dark-content' : 'light-content';
|
||||
// const statusBarStyle = theme.appearance === 'light-content';
|
||||
const statusBarStyle = 'light-content';
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.backgroundColor }}>
|
||||
|
@ -889,7 +890,8 @@ class AppComponent extends React.Component {
|
|||
}}
|
||||
>
|
||||
<StatusBar barStyle={statusBarStyle} />
|
||||
<MenuContext style={{ flex: 1, backgroundColor: theme.backgroundColor }}>
|
||||
<MenuContext style={{ flex: 1 }}>
|
||||
<SafeAreaView style={{ flex: 0, backgroundColor: theme.backgroundColor2 }}/>
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<View style={{ flex: 1, backgroundColor: theme.backgroundColor }}>
|
||||
<AppNav screens={appNavInit} />
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import FsDriverBase from '@joplin/lib/fs-driver-base';
|
||||
import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base';
|
||||
const RNFetchBlob = require('rn-fetch-blob').default;
|
||||
const RNFS = require('react-native-fs');
|
||||
import RNSAF, { Encoding, DocumentFileDetail } from '@joplin/react-native-saf-x';
|
||||
|
||||
const ANDROID_URI_PREFIX = 'content://';
|
||||
|
||||
function isScopedUri(path: string) {
|
||||
return path.includes(ANDROID_URI_PREFIX);
|
||||
}
|
||||
|
||||
export default class FsDriverRN extends FsDriverBase {
|
||||
public appendFileSync() {
|
||||
|
@ -9,11 +16,17 @@ export default class FsDriverRN extends FsDriverBase {
|
|||
|
||||
// Encoding can be either "utf8" or "base64"
|
||||
public appendFile(path: string, content: any, encoding = 'base64') {
|
||||
if (isScopedUri(path)) {
|
||||
return RNSAF.writeFile(path, content, { encoding: encoding as Encoding, append: true });
|
||||
}
|
||||
return RNFS.appendFile(path, content, encoding);
|
||||
}
|
||||
|
||||
// Encoding can be either "utf8" or "base64"
|
||||
public writeFile(path: string, content: any, encoding = 'base64') {
|
||||
if (isScopedUri(path)) {
|
||||
return RNSAF.writeFile(path, content, { encoding: encoding as Encoding });
|
||||
}
|
||||
// We need to use rn-fetch-blob here due to this bug:
|
||||
// https://github.com/itinance/react-native-fs/issues/700
|
||||
return RNFetchBlob.fs.writeFile(path, content, encoding);
|
||||
|
@ -26,52 +39,105 @@ export default class FsDriverRN extends FsDriverBase {
|
|||
|
||||
// Returns a format compatible with Node.js format
|
||||
private rnfsStatToStd_(stat: any, path: string) {
|
||||
let birthtime;
|
||||
const mtime = stat.lastModified ? new Date(stat.lastModified) : stat.mtime;
|
||||
if (stat.lastModified) {
|
||||
birthtime = new Date(stat.lastModified);
|
||||
} else if (stat.ctime) {
|
||||
// Confusingly, "ctime" normally means "change time" but here it's used as "creation time". Also sometimes it is null
|
||||
birthtime = stat.ctime;
|
||||
} else {
|
||||
birthtime = stat.mtime;
|
||||
}
|
||||
return {
|
||||
birthtime: stat.ctime ? stat.ctime : stat.mtime, // Confusingly, "ctime" normally means "change time" but here it's used as "creation time". Also sometimes it is null
|
||||
mtime: stat.mtime,
|
||||
isDirectory: () => stat.isDirectory(),
|
||||
birthtime,
|
||||
mtime,
|
||||
isDirectory: () => stat.type ? stat.type === 'directory' : stat.isDirectory(),
|
||||
path: path,
|
||||
size: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
public async isDirectory(path: string): Promise<boolean> {
|
||||
return (await this.stat(path)).isDirectory();
|
||||
}
|
||||
|
||||
public async readDirStats(path: string, options: any = null) {
|
||||
if (!options) options = {};
|
||||
if (!('recursive' in options)) options.recursive = false;
|
||||
|
||||
let items = [];
|
||||
const isScoped = isScopedUri(path);
|
||||
|
||||
let stats = [];
|
||||
try {
|
||||
items = await RNFS.readDir(path);
|
||||
if (isScoped) {
|
||||
stats = await RNSAF.listFiles(path);
|
||||
} else {
|
||||
stats = await RNFS.readDir(path);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Could not read directory: ${path}: ${error.message}`);
|
||||
}
|
||||
|
||||
let output: any[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const relativePath = item.path.substr(path.length + 1);
|
||||
output.push(this.rnfsStatToStd_(item, relativePath));
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
const stat = stats[i];
|
||||
const relativePath = (isScoped ? stat.uri : stat.path).substr(path.length + 1);
|
||||
output.push(this.rnfsStatToStd_(stat, relativePath));
|
||||
|
||||
output = await this.readDirStatsHandleRecursion_(path, item, output, options);
|
||||
if (isScoped) {
|
||||
output = await this.readUriDirStatsHandleRecursion_(stat, output, options);
|
||||
} else {
|
||||
output = await this.readDirStatsHandleRecursion_(path, stat, output, options);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
protected async readUriDirStatsHandleRecursion_(stat: DocumentFileDetail, output: DocumentFileDetail[], options: ReadDirStatsOptions) {
|
||||
if (options.recursive && stat.type === 'directory') {
|
||||
const subStats = await this.readDirStats(stat.uri, options);
|
||||
for (let j = 0; j < subStats.length; j++) {
|
||||
const subStat = subStats[j];
|
||||
output.push(subStat);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public async move(source: string, dest: string) {
|
||||
if (isScopedUri(source) && isScopedUri(dest)) {
|
||||
await RNSAF.moveFile(source, dest, { replaceIfDestinationExists: true });
|
||||
} else if (isScopedUri(source) || isScopedUri(dest)) {
|
||||
throw new Error('Move between different storage types not supported');
|
||||
}
|
||||
return RNFS.moveFile(source, dest);
|
||||
}
|
||||
|
||||
public async exists(path: string) {
|
||||
if (isScopedUri(path)) {
|
||||
return RNSAF.exists(path);
|
||||
}
|
||||
return RNFS.exists(path);
|
||||
}
|
||||
|
||||
public async mkdir(path: string) {
|
||||
if (isScopedUri(path)) {
|
||||
await RNSAF.mkdir(path);
|
||||
return;
|
||||
}
|
||||
return RNFS.mkdir(path);
|
||||
}
|
||||
|
||||
public async stat(path: string) {
|
||||
try {
|
||||
const r = await RNFS.stat(path);
|
||||
let r;
|
||||
if (isScopedUri(path)) {
|
||||
r = await RNSAF.stat(path);
|
||||
} else {
|
||||
r = await RNFS.stat(path);
|
||||
}
|
||||
return this.rnfsStatToStd_(r, path);
|
||||
} catch (error) {
|
||||
if (error && ((error.message && error.message.indexOf('exist') >= 0) || error.code === 'ENOENT')) {
|
||||
|
@ -93,6 +159,9 @@ export default class FsDriverRN extends FsDriverBase {
|
|||
}
|
||||
|
||||
public async open(path: string, mode: number) {
|
||||
if (isScopedUri(path)) {
|
||||
throw new Error('open() not implemented in FsDriverAndroid');
|
||||
}
|
||||
// Note: RNFS.read() doesn't provide any way to know if the end of file has been reached.
|
||||
// So instead we stat the file here and use stat.size to manually check for end of file.
|
||||
// Bug: https://github.com/itinance/react-native-fs/issues/342
|
||||
|
@ -112,6 +181,9 @@ export default class FsDriverRN extends FsDriverBase {
|
|||
|
||||
public readFile(path: string, encoding = 'utf8') {
|
||||
if (encoding === 'Buffer') throw new Error('Raw buffer output not supported for FsDriverRN.readFile');
|
||||
if (isScopedUri(path)) {
|
||||
return RNSAF.readFile(path, { encoding: encoding as Encoding });
|
||||
}
|
||||
return RNFS.readFile(path, encoding);
|
||||
}
|
||||
|
||||
|
@ -119,6 +191,12 @@ export default class FsDriverRN extends FsDriverBase {
|
|||
public async copy(source: string, dest: string) {
|
||||
let retry = false;
|
||||
try {
|
||||
if (isScopedUri(source) && isScopedUri(dest)) {
|
||||
await RNSAF.copyFile(source, dest, { replaceIfDestinationExists: true });
|
||||
return;
|
||||
} else if (isScopedUri(source) || isScopedUri(dest)) {
|
||||
throw new Error('Move between different storage types not supported');
|
||||
}
|
||||
await RNFS.copyFile(source, dest);
|
||||
} catch (error) {
|
||||
// On iOS it will throw an error if the file already exist
|
||||
|
@ -131,6 +209,10 @@ export default class FsDriverRN extends FsDriverBase {
|
|||
|
||||
public async unlink(path: string) {
|
||||
try {
|
||||
if (isScopedUri(path)) {
|
||||
await RNSAF.unlink(path);
|
||||
return;
|
||||
}
|
||||
await RNFS.unlink(path);
|
||||
} catch (error) {
|
||||
if (error && ((error.message && error.message.indexOf('exist') >= 0) || error.code === 'ENOENT')) {
|
||||
|
|
|
@ -22,7 +22,9 @@ function shimInit() {
|
|||
shim.sjclModule = require('@joplin/lib/vendor/sjcl-rn.js');
|
||||
|
||||
shim.fsDriver = () => {
|
||||
if (!shim.fsDriver_) shim.fsDriver_ = new FsDriverRN();
|
||||
if (!shim.fsDriver_) {
|
||||
shim.fsDriver_ = new FsDriverRN();
|
||||
}
|
||||
return shim.fsDriver_;
|
||||
};
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"manifest_version": 1,
|
||||
"id": "<%= pluginId %>",
|
||||
"app_min_version": "2.8",
|
||||
"app_min_version": "2.9",
|
||||
"version": "1.0.0",
|
||||
"name": "<%= pluginName %>",
|
||||
"description": "<%= pluginDescription %>",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "generator-joplin",
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"description": "Scaffolds out a new Joplin plugin",
|
||||
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/generator-joplin",
|
||||
"author": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/htmlpack",
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"description": "Pack an HTML file and all its linked resources into a single HTML file",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
plugin_types/
|
||||
markdownUtils.test.js
|
||||
markdownUtils.test.js
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import Setting, { Env } from './models/Setting';
|
||||
import Logger, { TargetType, LoggerWrapper } from './Logger';
|
||||
import shim from './shim';
|
||||
const { setupProxySettings } = require('./shim-init-node');
|
||||
import BaseService from './services/BaseService';
|
||||
import reducer, { setStore } from './reducer';
|
||||
import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node';
|
||||
|
@ -456,6 +457,14 @@ export default class BaseApplication {
|
|||
syswidecas.addCAs(f);
|
||||
}
|
||||
},
|
||||
'net.proxyEnabled': async () => {
|
||||
setupProxySettings({
|
||||
maxConcurrentConnections: Setting.value('sync.maxConcurrentConnections'),
|
||||
proxyTimeout: Setting.value('net.proxyTimeout'),
|
||||
proxyEnabled: Setting.value('net.proxyEnabled'),
|
||||
proxyUrl: Setting.value('net.proxyUrl'),
|
||||
});
|
||||
},
|
||||
|
||||
// Note: this used to run when "encryption.enabled" was changed, but
|
||||
// now we run it anytime any property of the sync target info is
|
||||
|
@ -491,6 +500,9 @@ export default class BaseApplication {
|
|||
sideEffects['locale'] = sideEffects['dateFormat'];
|
||||
sideEffects['encryption.passwordCache'] = sideEffects['syncInfoCache'];
|
||||
sideEffects['encryption.masterPassword'] = sideEffects['syncInfoCache'];
|
||||
sideEffects['sync.maxConcurrentConnections'] = sideEffects['net.proxyEnabled'];
|
||||
sideEffects['sync.proxyTimeout'] = sideEffects['net.proxyEnabled'];
|
||||
sideEffects['sync.proxyUrl'] = sideEffects['net.proxyEnabled'];
|
||||
|
||||
if (action) {
|
||||
const effect = sideEffects[action.key];
|
||||
|
|
|
@ -2,17 +2,20 @@ const TurndownService = require('@joplin/turndown');
|
|||
const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm;
|
||||
import markdownUtils from './markdownUtils';
|
||||
|
||||
const pdfUrlRegex = /[\s\S]*?\.pdf$/i;
|
||||
|
||||
export interface ParseOptions {
|
||||
anchorNames?: string[];
|
||||
preserveImageTagsWithSize?: boolean;
|
||||
baseUrl?: string;
|
||||
disableEscapeContent?: boolean;
|
||||
convertEmbeddedPdfsToLinks?: boolean;
|
||||
}
|
||||
|
||||
export default class HtmlToMd {
|
||||
|
||||
public parse(html: string, options: ParseOptions = {}) {
|
||||
const turndown = new TurndownService({
|
||||
const turndownOpts: any = {
|
||||
headingStyle: 'atx',
|
||||
anchorNames: options.anchorNames ? options.anchorNames.map(n => n.trim().toLowerCase()) : [],
|
||||
codeBlockStyle: 'fenced',
|
||||
|
@ -22,10 +25,36 @@ export default class HtmlToMd {
|
|||
strongDelimiter: '**',
|
||||
br: '',
|
||||
disableEscapeContent: 'disableEscapeContent' in options ? options.disableEscapeContent : false,
|
||||
});
|
||||
};
|
||||
if (options.convertEmbeddedPdfsToLinks) {
|
||||
// Turndown ignores empty <object> tags, so we need to handle this case seperately
|
||||
// https://github.com/mixmark-io/turndown/issues/293#issuecomment-588984202
|
||||
turndownOpts.blankReplacement = (content: string, node: any) => {
|
||||
if (node.matches('object')) {
|
||||
return pdfRule.replacement(content, node, {});
|
||||
}
|
||||
return '\n\n';
|
||||
};
|
||||
}
|
||||
const turndown = new TurndownService(turndownOpts);
|
||||
turndown.use(turndownPluginGfm);
|
||||
turndown.remove('script');
|
||||
turndown.remove('style');
|
||||
const pdfRule = {
|
||||
filter: ['embed', 'object'],
|
||||
replacement: function(_content: string, node: any, _options: any) {
|
||||
// We are setting embedded_pdf as name so that we can later distingish them from normal links and create resources for them.
|
||||
if (node.matches('embed') && node.getAttribute('src') && pdfUrlRegex.test(node.getAttribute('src'))) {
|
||||
return `[embedded_pdf](${node.getAttribute('src')})`;
|
||||
} else if (node.matches('object') && node.getAttribute('data') && pdfUrlRegex.test(node.getAttribute('data'))) {
|
||||
return `[embedded_pdf](${node.getAttribute('data')})`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
};
|
||||
if (options.convertEmbeddedPdfsToLinks) {
|
||||
turndown.addRule('pdf', pdfRule);
|
||||
}
|
||||
let md = turndown.turndown(html);
|
||||
if (options.baseUrl) md = markdownUtils.prependBaseUrl(md, options.baseUrl);
|
||||
return md;
|
||||
|
|
|
@ -2,7 +2,7 @@ const BaseSyncTarget = require('./BaseSyncTarget').default;
|
|||
const { _ } = require('./locale');
|
||||
const Setting = require('./models/Setting').default;
|
||||
const { FileApi } = require('./file-api.js');
|
||||
const { FileApiDriverLocal } = require('./file-api-driver-local.js');
|
||||
const { FileApiDriverLocal } = require('./file-api-driver-local');
|
||||
const Synchronizer = require('./Synchronizer').default;
|
||||
|
||||
class SyncTargetFilesystem extends BaseSyncTarget {
|
||||
|
|
|
@ -23,7 +23,7 @@ class FileApiDriverLocal {
|
|||
}
|
||||
|
||||
fsDriver() {
|
||||
if (!FileApiDriverLocal.fsDriver_) throw new Error('FileApiDriverLocal.fsDriver_ not set!');
|
||||
if (!FileApiDriverLocal.fsDriver_) { throw new Error('FileApiDriverLocal.fsDriver_ not set!'); }
|
||||
return FileApiDriverLocal.fsDriver_;
|
||||
}
|
||||
|
||||
|
@ -93,6 +93,7 @@ class FileApiDriverLocal {
|
|||
}
|
||||
|
||||
async get(path, options) {
|
||||
if (!options) options = {};
|
||||
let output = null;
|
||||
|
||||
try {
|
||||
|
@ -151,7 +152,6 @@ class FileApiDriverLocal {
|
|||
} catch (error) {
|
||||
throw this.fsErrorToJsError_(error, path);
|
||||
}
|
||||
|
||||
// if (!options) options = {};
|
||||
|
||||
// if (options.source === 'file') content = await fs.readFile(options.path);
|
||||
|
@ -223,8 +223,15 @@ class FileApiDriverLocal {
|
|||
}
|
||||
|
||||
async clearRoot(baseDir) {
|
||||
await this.fsDriver().remove(baseDir);
|
||||
await this.fsDriver().mkdir(baseDir);
|
||||
if (baseDir.startsWith('content://')) {
|
||||
const result = await this.list(baseDir);
|
||||
for (const item of result.items) {
|
||||
await this.fsDriver().remove(item.path);
|
||||
}
|
||||
} else {
|
||||
await this.fsDriver().remove(baseDir);
|
||||
await this.fsDriver().mkdir(baseDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,9 @@ const { escapeHtml } = require('./string-utils.js');
|
|||
// https://stackoverflow.com/a/16119722/561309
|
||||
const imageRegex = /<img([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
||||
const anchorRegex = /<a([\s\S]*?)href=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
||||
const embedRegex = /<embed([\s\S]*?)src=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
||||
const objectRegex = /<object([\s\S]*?)data=["']([\s\S]*?)["']([\s\S]*?)>/gi;
|
||||
const pdfUrlRegex = /[\s\S]*?\.pdf$/i;
|
||||
|
||||
const selfClosingElements = [
|
||||
'area',
|
||||
|
@ -61,6 +64,11 @@ class HtmlUtils {
|
|||
return this.extractUrls(imageRegex, html);
|
||||
}
|
||||
|
||||
// Returns the **encoded** URLs, so to be useful they should be decoded again before use.
|
||||
public extractPdfUrls(html: string) {
|
||||
return [...this.extractUrls(embedRegex, html), ...this.extractUrls(objectRegex, html)].filter(url => pdfUrlRegex.test(url));
|
||||
}
|
||||
|
||||
// Returns the **encoded** URLs, so to be useful they should be decoded again before use.
|
||||
public extractAnchorUrls(html: string) {
|
||||
return this.extractUrls(anchorRegex, html);
|
||||
|
@ -87,6 +95,27 @@ class HtmlUtils {
|
|||
});
|
||||
}
|
||||
|
||||
public replaceEmbedUrls(html: string, callback: Function) {
|
||||
if (!html) return '';
|
||||
// We are adding the link as <a> since joplin disabled <embed>, <object> tags due to security reasons.
|
||||
// See: CVE-2020-15930
|
||||
html = html.replace(embedRegex, (_v: string, _before: string, src: string, _after: string) => {
|
||||
const link = callback(src);
|
||||
return `<a href="${link}">${escapeHtml(src)}</a>`;
|
||||
});
|
||||
html = html.replace(objectRegex, (_v: string, _before: string, src: string, _after: string) => {
|
||||
const link = callback(src);
|
||||
return `<a href="${link}">${escapeHtml(src)}</a>`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
public replaceMediaUrls(html: string, callback: Function) {
|
||||
html = this.replaceImageUrls(html, callback);
|
||||
html = this.replaceEmbedUrls(html, callback);
|
||||
return html;
|
||||
}
|
||||
|
||||
// Note that the URLs provided by this function are URL-encoded, which is
|
||||
// usually what you want for web URLs. But if they are file:// URLs and the
|
||||
// file path is going to be used, it will need to be unescaped first. The
|
||||
|
|
|
@ -69,7 +69,7 @@ const markdownUtils = {
|
|||
},
|
||||
|
||||
// Returns the **encoded** URLs, so to be useful they should be decoded again before use.
|
||||
extractFileUrls(md: string, onlyImage: boolean = false): Array<string> {
|
||||
extractFileUrls(md: string, onlyType: string = null): Array<string> {
|
||||
const markdownIt = new MarkdownIt();
|
||||
markdownIt.validateLink = validateLinks; // Necessary to support file:/// links
|
||||
|
||||
|
@ -77,10 +77,16 @@ const markdownUtils = {
|
|||
const tokens = markdownIt.parse(md, env);
|
||||
const output: string[] = [];
|
||||
|
||||
let linkType = onlyType;
|
||||
if (linkType === 'pdf') linkType = 'link_open';
|
||||
|
||||
const searchUrls = (tokens: any[]) => {
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if ((onlyImage === true && token.type === 'image') || (onlyImage === false && (token.type === 'image' || token.type === 'link_open'))) {
|
||||
if ((!onlyType && (token.type === 'link_open' || token.type === 'image')) || (!!onlyType && token.type === onlyType) || (onlyType == 'pdf' && token.type === 'link_open')) {
|
||||
// Pdf embeds are a special case, they are represented as 'link_open' tokens but are marked with 'embedded_pdf' as link name by the parser
|
||||
// We are making sure if its in the proper pdf link format, only then we add it to the list
|
||||
if (onlyType === 'pdf' && !(tokens.length > i + 1 && tokens[i + 1].type === 'text' && tokens[i + 1].content === 'embedded_pdf')) continue;
|
||||
for (let j = 0; j < token.attrs.length; j++) {
|
||||
const a = token.attrs[j];
|
||||
if ((a[0] === 'src' || a[0] === 'href') && a.length >= 2 && a[1]) {
|
||||
|
@ -107,7 +113,11 @@ const markdownUtils = {
|
|||
},
|
||||
|
||||
extractImageUrls(md: string) {
|
||||
return markdownUtils.extractFileUrls(md,true);
|
||||
return markdownUtils.extractFileUrls(md, 'image');
|
||||
},
|
||||
|
||||
extractPdfUrls(md: string) {
|
||||
return markdownUtils.extractFileUrls(md, 'pdf');
|
||||
},
|
||||
|
||||
// The match results has 5 items
|
||||
|
|
|
@ -28,6 +28,17 @@ export class MarkupLanguageUtils {
|
|||
return urls;
|
||||
}
|
||||
|
||||
public extractPdfUrls(language: MarkupLanguage, text: string): string[] {
|
||||
let urls: string[] = [];
|
||||
if (language === MarkupLanguage.Any) {
|
||||
urls = urls.concat(this.lib_(MarkupLanguage.Markdown).extractPdfUrls(text));
|
||||
urls = urls.concat(this.lib_(MarkupLanguage.Html).extractPdfUrls(text));
|
||||
} else {
|
||||
urls = this.lib_(language).extractPdfUrls(text);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Create a new MarkupToHtml instance while injecting options specific to Joplin
|
||||
// desktop and mobile applications.
|
||||
public newMarkupToHtml(_plugins: PluginStates = null, options: Options = null) {
|
||||
|
|
|
@ -864,7 +864,7 @@ class Setting extends BaseModel {
|
|||
public: false,
|
||||
},
|
||||
|
||||
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false, advanced: true, appTypes: [AppType.Desktop], label: () => _('Show note counts') },
|
||||
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false, advanced: true, appTypes: [AppType.Desktop,AppType.Cli], label: () => _('Show note counts') },
|
||||
|
||||
layoutButtonSequence: {
|
||||
value: Setting.LAYOUT_ALL,
|
||||
|
@ -1384,7 +1384,37 @@ class Setting extends BaseModel {
|
|||
label: () => _('Ignore TLS certificate errors'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'net.proxyEnabled': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
advanced: true,
|
||||
section: 'sync',
|
||||
isGlobal: true,
|
||||
public: true,
|
||||
label: () => _('Proxy enabled (beta)'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'net.proxyUrl': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
advanced: true,
|
||||
section: 'sync',
|
||||
isGlobal: true,
|
||||
public: true,
|
||||
label: () => _('Proxy URL (beta)'),
|
||||
description: () => _('e.g "http://my.proxy.com:80". You can also set via environment variables'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'net.proxyTimeout': {
|
||||
value: 1,
|
||||
type: SettingItemType.Int,
|
||||
advanced: true,
|
||||
section: 'sync',
|
||||
isGlobal: true,
|
||||
public: true,
|
||||
label: () => _('proxy timeout (seconds) (beta)'),
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'sync.wipeOutFailSafe': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"description": "Joplin Core library",
|
||||
"author": "Laurent Cozic",
|
||||
"homepage": "",
|
||||
|
@ -33,8 +33,8 @@
|
|||
"@joplin/fork-htmlparser2": "^4.1.40",
|
||||
"@joplin/fork-sax": "^1.2.44",
|
||||
"@joplin/fork-uslug": "^1.0.5",
|
||||
"@joplin/htmlpack": "^2.8.1",
|
||||
"@joplin/renderer": "^2.8.1",
|
||||
"@joplin/htmlpack": "~2.9",
|
||||
"@joplin/renderer": "~2.9",
|
||||
"@joplin/turndown": "^4.0.62",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.44",
|
||||
"@types/nanoid": "^3.0.0",
|
||||
|
@ -53,6 +53,7 @@
|
|||
"follow-redirects": "^1.2.4",
|
||||
"form-data": "^2.1.4",
|
||||
"fs-extra": "^5.0.0",
|
||||
"hpagent": "^1.0.0",
|
||||
"html-entities": "^1.2.1",
|
||||
"html-minifier": "^3.5.15",
|
||||
"image-data-uri": "^2.0.0",
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
// PlatformImplementation provides access to platform specific dependencies,
|
||||
// such as the clipboard, message dialog, etc. It allows having the same plugin
|
||||
|
||||
import { VersionInfo } from './api/types';
|
||||
import { Implementation as WindowImplementation } from './api/JoplinWindow';
|
||||
|
||||
export interface JoplinViewsDialogs {
|
||||
showMessageBox(message: string): Promise<number>;
|
||||
}
|
||||
|
||||
export interface JoplinViews {
|
||||
dialogs: JoplinViewsDialogs;
|
||||
}
|
||||
|
||||
export interface Joplin {
|
||||
views: JoplinViews;
|
||||
}
|
||||
|
||||
// API for all platforms, but with different implementations.
|
||||
export default class BasePlatformImplementation {
|
||||
|
||||
public get versionInfo(): VersionInfo {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public get clipboard(): any {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public get nativeImage(): any {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public get window(): WindowImplementation {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public registerComponent(_name: string, _component: any) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public unregisterComponent(_name: string) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
public get joplin(): Joplin {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
}
|
|
@ -10,6 +10,7 @@ import JoplinSettings from './JoplinSettings';
|
|||
import JoplinContentScripts from './JoplinContentScripts';
|
||||
import JoplinClipboard from './JoplinClipboard';
|
||||
import JoplinWindow from './JoplinWindow';
|
||||
import BasePlatformImplementation from '../BasePlatformImplementation';
|
||||
|
||||
/**
|
||||
* This is the main entry point to the Joplin API. You can access various services using the provided accessors.
|
||||
|
@ -36,8 +37,10 @@ export default class Joplin {
|
|||
private contentScripts_: JoplinContentScripts = null;
|
||||
private clipboard_: JoplinClipboard = null;
|
||||
private window_: JoplinWindow = null;
|
||||
private implementation_: BasePlatformImplementation = null;
|
||||
|
||||
public constructor(implementation: any, plugin: Plugin, store: any) {
|
||||
public constructor(implementation: BasePlatformImplementation, plugin: Plugin, store: any) {
|
||||
this.implementation_ = implementation;
|
||||
this.data_ = new JoplinData();
|
||||
this.plugins_ = new JoplinPlugins(plugin);
|
||||
this.workspace_ = new JoplinWorkspace(store);
|
||||
|
@ -117,4 +120,8 @@ export default class Joplin {
|
|||
// Just a stub. Implementation has to be done within plugin process, in plugin_index.js
|
||||
}
|
||||
|
||||
public async versionInfo() {
|
||||
return this.implementation_.versionInfo;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -221,6 +221,12 @@ export enum ModelType {
|
|||
Command = 16,
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
version: string;
|
||||
profileVersion: number;
|
||||
syncVersion: number;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Menu types
|
||||
// =================================================================
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { PaginationOrderDir } from '../../models/utils/types';
|
||||
import Api, { RequestMethod } from '../../services/rest/Api';
|
||||
import { extractMediaUrls } from './routes/notes';
|
||||
import shim from '../../shim';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, checkThrowAsync, db, msleep, supportDir } from '../../testing/test-utils';
|
||||
import Folder from '../../models/Folder';
|
||||
|
@ -9,6 +10,7 @@ import Tag from '../../models/Tag';
|
|||
import NoteTag from '../../models/NoteTag';
|
||||
import ResourceService from '../../services/ResourceService';
|
||||
import SearchEngine from '../../services/searchengine/SearchEngine';
|
||||
const { MarkupToHtml } = require('@joplin/renderer');
|
||||
import { ResourceEntity } from '../database/types';
|
||||
|
||||
const createFolderForPagination = async (num: number, time: number) => {
|
||||
|
@ -452,6 +454,47 @@ describe('services_rest_Api', function() {
|
|||
expect(response.body).toBe('**Bold text**');
|
||||
}));
|
||||
|
||||
it('should extract media urls from body', (() => {
|
||||
const tests = [
|
||||
{
|
||||
language: MarkupToHtml.MARKUP_LANGUAGE_HTML,
|
||||
body: '<div> <img src="https://example.com/img.png" /> <embed src="https://example.com/sample.pdf"/> <object data="https://example.com/file.PDF"></object> </div>',
|
||||
result: ['https://example.com/img.png', 'https://example.com/sample.pdf', 'https://example.com/file.PDF'],
|
||||
},
|
||||
{
|
||||
language: MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN,
|
||||
body: 'test text \n ![img 1](https://example.com/img1.png) [embedded_pdf](https://example.com/sample1.pdf) [embedded_pdf](https://example.com/file.PDF)',
|
||||
result: ['https://example.com/img1.png', 'https://example.com/sample1.pdf', 'https://example.com/file.PDF'],
|
||||
},
|
||||
{
|
||||
language: MarkupToHtml.MARKUP_LANGUAGE_HTML,
|
||||
body: '<div> <embed src="https://example.com/sample"/> <embed /> <object data="https://example.com/file.pdfff"></object> <a href="https://test.com/file.pdf">Link</a> </div>',
|
||||
result: [],
|
||||
},
|
||||
];
|
||||
tests.forEach((test) => {
|
||||
const urls = extractMediaUrls(test.language, test.body);
|
||||
expect(urls).toEqual(test.result);
|
||||
});
|
||||
}));
|
||||
|
||||
it('should create notes with pdf embeds', (async () => {
|
||||
let response = null;
|
||||
const f = await Folder.save({ title: 'pdf test1' });
|
||||
|
||||
response = await api.route(RequestMethod.POST, 'notes', null, JSON.stringify({
|
||||
title: 'testing PDF embeds',
|
||||
parent_id: f.id,
|
||||
body_html: `<div> <embed src="file://${supportDir}/welcome.pdf" type="application/pdf" /> </div>`,
|
||||
}));
|
||||
|
||||
const resources = await Resource.all();
|
||||
expect(resources.length).toBe(1);
|
||||
|
||||
const resource = resources[0];
|
||||
expect(response.body.indexOf(resource.id) >= 0).toBe(true);
|
||||
}));
|
||||
|
||||
it('should handle tokens', (async () => {
|
||||
api = new Api('mytoken');
|
||||
|
||||
|
|
|
@ -89,6 +89,7 @@ async function requestNoteToNote(requestNote: any) {
|
|||
output.body = await htmlToMdParser().parse(`<div>${requestNote.body_html}</div>`, {
|
||||
baseUrl: baseUrl,
|
||||
anchorNames: requestNote.anchor_names ? requestNote.anchor_names : [],
|
||||
convertEmbeddedPdfsToLinks: true,
|
||||
});
|
||||
output.markup_language = MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN;
|
||||
}
|
||||
|
@ -143,19 +144,20 @@ async function buildNoteStyleSheet(stylesheets: any[]) {
|
|||
return output;
|
||||
}
|
||||
|
||||
async function tryToGuessImageExtFromMimeType(response: any, imagePath: string) {
|
||||
async function tryToGuessExtFromMimeType(response: any, mediaPath: string) {
|
||||
const mimeType = mimeTypeFromHeaders(response.headers);
|
||||
if (!mimeType) return imagePath;
|
||||
if (!mimeType) return mediaPath;
|
||||
|
||||
const newExt = mimeUtils.toFileExtension(mimeType);
|
||||
if (!newExt) return imagePath;
|
||||
if (!newExt) return mediaPath;
|
||||
|
||||
const newImagePath = `${imagePath}.${newExt}`;
|
||||
await shim.fsDriver().move(imagePath, newImagePath);
|
||||
return newImagePath;
|
||||
const newMediaPath = `${mediaPath}.${newExt}`;
|
||||
await shim.fsDriver().move(mediaPath, newMediaPath);
|
||||
return newMediaPath;
|
||||
}
|
||||
|
||||
async function downloadImage(url: string /* , allowFileProtocolImages */) {
|
||||
async function downloadMediaFile(url: string /* , allowFileProtocolImages */) {
|
||||
|
||||
const tempDir = Setting.value('tempDir');
|
||||
|
||||
// The URL we get to download have been extracted from the Markdown document
|
||||
|
@ -163,6 +165,12 @@ async function downloadImage(url: string /* , allowFileProtocolImages */) {
|
|||
|
||||
const isDataUrl = url && url.toLowerCase().indexOf('data:') === 0;
|
||||
|
||||
// PDFs and other heavy resoucres are often served as seperate files insted of data urls, its very unlikely to encounter a pdf as a data url
|
||||
if (isDataUrl && !url.toLowerCase().startsWith('data:image/')) {
|
||||
reg.logger().warn(`Resources in data URL format is only supported for images ${url}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const name = isDataUrl ? md5(`${Math.random()}_${Date.now()}`) : filename(url);
|
||||
let fileExt = isDataUrl ? mimeUtils.toFileExtension(mimeUtils.fromDataUrl(url)) : safeFileExtension(fileExtension(url).toLowerCase());
|
||||
if (!mimeUtils.fromFileExtension(fileExt)) fileExt = ''; // If the file extension is unknown - clear it.
|
||||
|
@ -170,38 +178,38 @@ async function downloadImage(url: string /* , allowFileProtocolImages */) {
|
|||
|
||||
// Append a UUID because simply checking if the file exists is not enough since
|
||||
// multiple resources can be downloaded at the same time (race condition).
|
||||
let imagePath = `${tempDir}/${safeFilename(name)}_${uuid.create()}${fileExt}`;
|
||||
let mediaPath = `${tempDir}/${safeFilename(name)}_${uuid.create()}${fileExt}`;
|
||||
|
||||
try {
|
||||
if (isDataUrl) {
|
||||
await shim.imageFromDataUrl(url, imagePath);
|
||||
await shim.imageFromDataUrl(url, mediaPath);
|
||||
} else if (urlUtils.urlProtocol(url).toLowerCase() === 'file:') {
|
||||
// Can't think of any reason to disallow this at this point
|
||||
// if (!allowFileProtocolImages) throw new Error('For security reasons, this URL with file:// protocol cannot be downloaded');
|
||||
const localPath = fileUriToPath(url);
|
||||
await shim.fsDriver().copy(localPath, imagePath);
|
||||
await shim.fsDriver().copy(localPath, mediaPath);
|
||||
} else {
|
||||
const response = await shim.fetchBlob(url, { path: imagePath, maxRetry: 1 });
|
||||
const response = await shim.fetchBlob(url, { path: mediaPath, maxRetry: 1 });
|
||||
|
||||
// If we could not find the file extension from the URL, try to get it
|
||||
// now based on the Content-Type header.
|
||||
if (!fileExt) imagePath = await tryToGuessImageExtFromMimeType(response, imagePath);
|
||||
if (!fileExt) mediaPath = await tryToGuessExtFromMimeType(response, mediaPath);
|
||||
}
|
||||
return imagePath;
|
||||
return mediaPath;
|
||||
} catch (error) {
|
||||
reg.logger().warn(`Cannot download image at ${url}`, error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImages(urls: string[] /* , allowFileProtocolImages:boolean */) {
|
||||
async function downloadMediaFiles(urls: string[] /* , allowFileProtocolImages:boolean */) {
|
||||
const PromisePool = require('es6-promise-pool');
|
||||
|
||||
const output: any = {};
|
||||
|
||||
const downloadOne = async (url: string) => {
|
||||
const imagePath = await downloadImage(url); // , allowFileProtocolImages);
|
||||
if (imagePath) output[url] = { path: imagePath, originalUrl: url };
|
||||
const mediaPath = await downloadMediaFile(url); // , allowFileProtocolImages);
|
||||
if (mediaPath) output[url] = { path: mediaPath, originalUrl: url };
|
||||
};
|
||||
|
||||
let urlIndex = 0;
|
||||
|
@ -245,27 +253,38 @@ async function removeTempFiles(urls: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
function replaceImageUrlsByResources(markupLanguage: number, md: string, urls: any, imageSizes: any) {
|
||||
function replaceUrlsByResources(markupLanguage: number, md: string, urls: any, imageSizes: any) {
|
||||
const imageSizesIndexes: any = {};
|
||||
|
||||
if (markupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
||||
return htmlUtils.replaceImageUrls(md, (imageUrl: string) => {
|
||||
const urlInfo: any = urls[imageUrl];
|
||||
if (!urlInfo || !urlInfo.resource) return imageUrl;
|
||||
return htmlUtils.replaceMediaUrls(md, (url: string) => {
|
||||
const urlInfo: any = urls[url];
|
||||
if (!urlInfo || !urlInfo.resource) return url;
|
||||
return Resource.internalUrl(urlInfo.resource);
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
return md.replace(/(!\[.*?\]\()([^\s\)]+)(.*?\))/g, (_match: any, before: string, imageUrl: string, after: string) => {
|
||||
const urlInfo = urls[imageUrl];
|
||||
if (!urlInfo || !urlInfo.resource) return before + imageUrl + after;
|
||||
if (!(urlInfo.originalUrl in imageSizesIndexes)) imageSizesIndexes[urlInfo.originalUrl] = 0;
|
||||
return md.replace(/(!?\[.*?\]\()([^\s\)]+)(.*?\))/g, (_match: any, before: string, url: string, after: string) => {
|
||||
let type = 'link';
|
||||
if (before.startsWith('[embedded_pdf]')) {
|
||||
type = 'pdf';
|
||||
} else if (before.startsWith('![')) {
|
||||
type = 'image';
|
||||
}
|
||||
|
||||
const urlInfo = urls[url];
|
||||
if (type === 'link' || !urlInfo || !urlInfo.resource) return before + url + after;
|
||||
|
||||
const resourceUrl = Resource.internalUrl(urlInfo.resource);
|
||||
const imageSizesCollection = imageSizes[urlInfo.originalUrl];
|
||||
if (type === 'pdf') {
|
||||
return `[${markdownUtils.escapeLinkUrl(url)}](${resourceUrl}${after}`;
|
||||
}
|
||||
|
||||
if (!(urlInfo.originalUrl in imageSizesIndexes)) imageSizesIndexes[urlInfo.originalUrl] = 0;
|
||||
const imageSizesCollection = imageSizes[urlInfo.originalUrl];
|
||||
if (!imageSizesCollection) {
|
||||
// In some cases, we won't find the image size information for that particular URL. Normally
|
||||
// Either its not an image or we don't know the size of the image
|
||||
// In some cases, we won't find the image size information for that particular image URL. Normally
|
||||
// it will only happen when using the "Clip simplified page" feature, which can modify the
|
||||
// image URLs (for example it will select a smaller size resolution). In that case, it's
|
||||
// fine to return the image as-is because it has already good dimensions.
|
||||
|
@ -284,6 +303,13 @@ function replaceImageUrlsByResources(markupLanguage: number, md: string, urls: a
|
|||
}
|
||||
}
|
||||
|
||||
export function extractMediaUrls(markupLanguage: number, text: string): string[] {
|
||||
const urls: string[] = [];
|
||||
urls.push(...ArrayUtils.unique(markupLanguageUtils.extractImageUrls(markupLanguage, text)));
|
||||
urls.push(...ArrayUtils.unique(markupLanguageUtils.extractPdfUrls(markupLanguage, text)));
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Note must have been saved first
|
||||
async function attachImageFromDataUrl(note: any, imageDataUrl: string, cropRect: any) {
|
||||
const tempDir = Setting.value('tempDir');
|
||||
|
@ -328,17 +354,17 @@ export default async function(request: Request, id: string = null, link: string
|
|||
|
||||
let note: any = await requestNoteToNote(requestNote);
|
||||
|
||||
const imageUrls = ArrayUtils.unique(markupLanguageUtils.extractImageUrls(note.markup_language, note.body));
|
||||
const mediaUrls = extractMediaUrls(note.markup_language, note.body);
|
||||
|
||||
reg.logger().info(`Request (${requestId}): Downloading images: ${imageUrls.length}`);
|
||||
reg.logger().info(`Request (${requestId}): Downloading media files: ${mediaUrls.length}`);
|
||||
|
||||
let result = await downloadImages(imageUrls); // , allowFileProtocolImages);
|
||||
let result = await downloadMediaFiles(mediaUrls); // , allowFileProtocolImages);
|
||||
|
||||
reg.logger().info(`Request (${requestId}): Creating resources from paths: ${Object.getOwnPropertyNames(result).length}`);
|
||||
|
||||
result = await createResourcesFromPaths(result);
|
||||
await removeTempFiles(result);
|
||||
note.body = replaceImageUrlsByResources(note.markup_language, note.body, result, imageSizes);
|
||||
note.body = replaceUrlsByResources(note.markup_language, note.body, result, imageSizes);
|
||||
|
||||
reg.logger().info(`Request (${requestId}): Saving note...`);
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
const fs = require('fs-extra');
|
||||
const shim = require('./shim').default;
|
||||
const GeolocationNode = require('./geolocation-node').default;
|
||||
const { FileApiDriverLocal } = require('./file-api-driver-local.js');
|
||||
const { FileApiDriverLocal } = require('./file-api-driver-local');
|
||||
const { setLocale, defaultLocale, closestSupportedLocale } = require('./locale');
|
||||
const FsDriverNode = require('./fs-driver-node').default;
|
||||
const mimeUtils = require('./mime-utils.js').mime;
|
||||
|
@ -13,12 +13,15 @@ const urlValidator = require('valid-url');
|
|||
const { _ } = require('./locale');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { HttpProxyAgent, HttpsProxyAgent } = require('hpagent');
|
||||
const toRelative = require('relative');
|
||||
const timers = require('timers');
|
||||
const zlib = require('zlib');
|
||||
const dgram = require('dgram');
|
||||
const { basename, fileExtension, safeFileExtension } = require('./path-utils');
|
||||
|
||||
const proxySettings = {};
|
||||
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile();
|
||||
|
@ -27,6 +30,20 @@ function fileExists(filePath) {
|
|||
}
|
||||
}
|
||||
|
||||
function isUrlHttps(url) {
|
||||
return url.startsWith('https');
|
||||
}
|
||||
|
||||
function resolveProxyUrl(proxyUrl) {
|
||||
return (
|
||||
proxyUrl ||
|
||||
process.env['http_proxy'] ||
|
||||
process.env['https_proxy'] ||
|
||||
process.env['HTTP_PROXY'] ||
|
||||
process.env['HTTPS_PROXY']
|
||||
);
|
||||
}
|
||||
|
||||
// https://github.com/sindresorhus/callsites/blob/main/index.js
|
||||
function callsites() {
|
||||
const _prepareStackTrace = Error.prepareStackTrace;
|
||||
|
@ -64,6 +81,13 @@ const gunzipFile = function(source, destination) {
|
|||
});
|
||||
};
|
||||
|
||||
function setupProxySettings(options) {
|
||||
proxySettings.maxConcurrentConnections = options.maxConcurrentConnections;
|
||||
proxySettings.proxyTimeout = options.proxyTimeout;
|
||||
proxySettings.proxyEnabled = options.proxyEnabled;
|
||||
proxySettings.proxyUrl = options.proxyUrl;
|
||||
}
|
||||
|
||||
function shimInit(options = null) {
|
||||
options = {
|
||||
sharp: null,
|
||||
|
@ -79,6 +103,7 @@ function shimInit(options = null) {
|
|||
const keytar = (shim.isWindows() || shim.isMac()) && !shim.isPortable() ? options.keytar : null;
|
||||
const appVersion = options.appVersion;
|
||||
|
||||
|
||||
shim.setNodeSqlite(options.nodeSqlite);
|
||||
|
||||
shim.fsDriver = () => {
|
||||
|
@ -420,10 +445,11 @@ function shimInit(options = null) {
|
|||
return new Buffer(data).toString('base64');
|
||||
};
|
||||
|
||||
shim.fetch = async function(url, options = null) {
|
||||
shim.fetch = async function(url, options = {}) {
|
||||
const validatedUrl = urlValidator.isUri(url);
|
||||
if (!validatedUrl) throw new Error(`Not a valid URL: ${url}`);
|
||||
|
||||
const resolvedProxyUrl = resolveProxyUrl(proxySettings.proxyUrl);
|
||||
options.agent = (resolvedProxyUrl && proxySettings.proxyEnabled) ? shim.proxyAgent(url, resolvedProxyUrl) : null;
|
||||
return shim.fetchWithRetry(() => {
|
||||
return nodeFetch(url, options);
|
||||
}, options);
|
||||
|
@ -466,6 +492,9 @@ function shimInit(options = null) {
|
|||
headers: headers,
|
||||
};
|
||||
|
||||
const resolvedProxyUrl = resolveProxyUrl(proxySettings.proxyUrl);
|
||||
requestOptions.agent = (resolvedProxyUrl && proxySettings.proxyEnabled) ? shim.proxyAgent(url, resolvedProxyUrl) : null;
|
||||
|
||||
const doFetchOperation = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let file = null;
|
||||
|
@ -572,6 +601,27 @@ function shimInit(options = null) {
|
|||
return url.startsWith('https') ? shim.httpAgent_.https : shim.httpAgent_.http;
|
||||
};
|
||||
|
||||
shim.proxyAgent = (serverUrl, proxyUrl) => {
|
||||
const proxyAgentConfig = {
|
||||
keepAlive: true,
|
||||
maxSockets: proxySettings.maxConcurrentConnections,
|
||||
keepAliveMsecs: 5000,
|
||||
proxy: proxyUrl,
|
||||
timeout: proxySettings.proxyTimeout * 1000,
|
||||
};
|
||||
|
||||
// Based on https://github.com/delvedor/hpagent#usage
|
||||
if (!isUrlHttps(proxyUrl) && !isUrlHttps(serverUrl)) {
|
||||
return new HttpProxyAgent(proxyAgentConfig);
|
||||
} else if (isUrlHttps(proxyUrl) && !isUrlHttps(serverUrl)) {
|
||||
return new HttpProxyAgent(proxyAgentConfig);
|
||||
} else if (!isUrlHttps(proxyUrl) && isUrlHttps(serverUrl)) {
|
||||
return new HttpsProxyAgent(proxyAgentConfig);
|
||||
} else {
|
||||
return new HttpsProxyAgent(proxyAgentConfig);
|
||||
}
|
||||
};
|
||||
|
||||
shim.openOrCreateFile = (filepath, defaultContents) => {
|
||||
// If the file doesn't exist, create it
|
||||
if (!fs.existsSync(filepath)) {
|
||||
|
@ -634,4 +684,4 @@ function shimInit(options = null) {
|
|||
};
|
||||
}
|
||||
|
||||
module.exports = { shimInit };
|
||||
module.exports = { shimInit, setupProxySettings };
|
||||
|
|
|
@ -16,7 +16,7 @@ let isTestingEnv_ = false;
|
|||
// (app-desktop, app-mobile, etc.) since we are sure they won't be dependency to
|
||||
// other packages (unlike the lib which can be included anywhere).
|
||||
//
|
||||
// Regarding the type - althought we import React, we only use it as a type
|
||||
// Regarding the type - although we import React, we only use it as a type
|
||||
// using `typeof React`. This is just to get types in hooks.
|
||||
//
|
||||
// https://stackoverflow.com/a/42816077/561309
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = new Entities().encode;
|
||||
const stringUtilsCommon = require('./string-utils-common.js');
|
||||
|
||||
const defaultDiacriticsRemovalMap = [
|
||||
|
@ -294,16 +296,25 @@ function escapeHtml(s) {
|
|||
// keywords can either be a list of strings, or a list of objects with the format:
|
||||
// { value: 'actualkeyword', type: 'regex/string' }
|
||||
// The function surrounds the keywords wherever they are, even within other words.
|
||||
function surroundKeywords(keywords, text, prefix, suffix) {
|
||||
function surroundKeywords(keywords, text, prefix, suffix, options = null) {
|
||||
options = Object.assign({}, {
|
||||
escapeHtml: false,
|
||||
}, options);
|
||||
|
||||
if (!keywords.length) return text;
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!options.escapeHtml) return s;
|
||||
return htmlentities(s);
|
||||
}
|
||||
|
||||
let regexString = keywords
|
||||
.map(k => {
|
||||
if (k.type === 'regex') {
|
||||
return stringUtilsCommon.replaceRegexDiacritics(k.valueRegex);
|
||||
return escapeHtml(stringUtilsCommon.replaceRegexDiacritics(k.valueRegex));
|
||||
} else {
|
||||
const value = typeof k === 'string' ? k : k.value;
|
||||
return stringUtilsCommon.replaceRegexDiacritics(stringUtilsCommon.pregQuote(value));
|
||||
return escapeHtml(stringUtilsCommon.replaceRegexDiacritics(stringUtilsCommon.pregQuote(value)));
|
||||
}
|
||||
})
|
||||
.join('|');
|
||||
|
|
|
@ -30,7 +30,7 @@ import MasterKey from '../models/MasterKey';
|
|||
import BaseItem from '../models/BaseItem';
|
||||
import { FileApi } from '../file-api';
|
||||
const FileApiDriverMemory = require('../file-api-driver-memory').default;
|
||||
const { FileApiDriverLocal } = require('../file-api-driver-local.js');
|
||||
const { FileApiDriverLocal } = require('../file-api-driver-local');
|
||||
const { FileApiDriverWebDav } = require('../file-api-driver-webdav.js');
|
||||
const { FileApiDriverDropbox } = require('../file-api-driver-dropbox.js');
|
||||
const { FileApiDriverOneDrive } = require('../file-api-driver-onedrive.js');
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/plugin-repo-cli",
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"bin": "./dist/index.js",
|
||||
|
@ -18,8 +18,8 @@
|
|||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joplin/lib": "^2.8.1",
|
||||
"@joplin/tools": "^2.8.1",
|
||||
"@joplin/lib": "~2.9",
|
||||
"@joplin/tools": "~2.9",
|
||||
"fs-extra": "^9.0.1",
|
||||
"gh-release-assets": "^2.0.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
# EditorConfig helps developers define and maintain consistent
|
||||
# coding styles between different editors and IDEs
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
lib/
|
||||
.env
|
|
@ -0,0 +1,3 @@
|
|||
*.pbxproj -text
|
||||
# specific for windows script files
|
||||
*.bat text eol=crlf
|
|
@ -0,0 +1,66 @@
|
|||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# XDE
|
||||
.expo/
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
jsconfig.json
|
||||
|
||||
# Xcode
|
||||
#
|
||||
build/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.xcuserstate
|
||||
project.xcworkspace
|
||||
|
||||
# Android/IJ
|
||||
#
|
||||
.classpath
|
||||
.cxx
|
||||
.gradle
|
||||
.idea
|
||||
.project
|
||||
.settings
|
||||
local.properties
|
||||
android.iml
|
||||
|
||||
# Cocoapods
|
||||
#
|
||||
example/ios/Pods
|
||||
|
||||
# node.js
|
||||
#
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# BUCK
|
||||
buck-out/
|
||||
\.buckd/
|
||||
android/app/libs
|
||||
android/keystores/debug.keystore
|
||||
|
||||
# Expo
|
||||
.expo/*
|
||||
|
||||
# generated by bob
|
||||
lib/
|
||||
.env
|
||||
docs
|
|
@ -0,0 +1,3 @@
|
|||
# Override Yarn command so we can automatically setup the repo on running `yarn`
|
||||
|
||||
yarn-path "scripts/bootstrap.js"
|
|
@ -0,0 +1,196 @@
|
|||
# Contributing
|
||||
|
||||
We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project.
|
||||
|
||||
## Development workflow
|
||||
|
||||
To get started with the project, run `yarn` in the root directory to install the required dependencies for each package:
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
> While it's possible to use [`npm`](https://github.com/npm/cli), the tooling is built around [`yarn`](https://classic.yarnpkg.com/), so you'll have an easier time if you use `yarn` for development.
|
||||
|
||||
While developing, you can run the [example app](/example/) to test your changes. Any changes you make in your library's JavaScript code will be reflected in the example app without a rebuild. If you change any native code, then you'll need to rebuild the example app.
|
||||
|
||||
To start the packager:
|
||||
|
||||
```sh
|
||||
yarn example start
|
||||
```
|
||||
|
||||
To run the example app on Android:
|
||||
|
||||
```sh
|
||||
yarn example android
|
||||
```
|
||||
|
||||
To run the example app on iOS:
|
||||
|
||||
```sh
|
||||
yarn example ios
|
||||
```
|
||||
|
||||
Make sure your code passes TypeScript and ESLint. Run the following to verify:
|
||||
|
||||
```sh
|
||||
yarn typescript
|
||||
yarn lint
|
||||
```
|
||||
|
||||
To fix formatting errors, run the following:
|
||||
|
||||
```sh
|
||||
yarn lint --fix
|
||||
```
|
||||
|
||||
Remember to add tests for your change if possible. Run the unit tests by:
|
||||
|
||||
```sh
|
||||
yarn test
|
||||
```
|
||||
|
||||
To edit the Objective-C files, open `example/ios/SafXExample.xcworkspace` in XCode and find the source files at `Pods > Development Pods > react-native-saf-x`.
|
||||
|
||||
To edit the Kotlin files, open `example/android` in Android studio and find the source files at `reactnativesafx` under `Android`.
|
||||
|
||||
### Commit message convention
|
||||
|
||||
We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages:
|
||||
|
||||
- `fix`: bug fixes, e.g. fix crash due to deprecated method.
|
||||
- `feat`: new features, e.g. add new method to the module.
|
||||
- `refactor`: code refactor, e.g. migrate from class components to hooks.
|
||||
- `docs`: changes into documentation, e.g. add usage example for the module..
|
||||
- `test`: adding or updating tests, e.g. add integration tests using detox.
|
||||
- `chore`: tooling changes, e.g. change CI config.
|
||||
|
||||
Our pre-commit hooks verify that your commit message matches this format when committing.
|
||||
|
||||
### Linting and tests
|
||||
|
||||
[ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing.
|
||||
|
||||
Our pre-commit hooks verify that the linter and tests pass when committing.
|
||||
|
||||
### Publishing to npm
|
||||
|
||||
We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc.
|
||||
|
||||
To publish new versions, run the following:
|
||||
|
||||
```sh
|
||||
yarn release
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
The `package.json` file contains various scripts for common tasks:
|
||||
|
||||
- `yarn bootstrap`: setup project by installing all dependencies and pods.
|
||||
- `yarn typescript`: type-check files with TypeScript.
|
||||
- `yarn lint`: lint files with ESLint.
|
||||
- `yarn test`: run unit tests with Jest.
|
||||
- `yarn example start`: start the Metro server for the example app.
|
||||
- `yarn example android`: run the example app on Android.
|
||||
- `yarn example ios`: run the example app on iOS.
|
||||
|
||||
### Sending a pull request
|
||||
|
||||
> **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github).
|
||||
|
||||
When you're sending a pull request:
|
||||
|
||||
- Prefer small pull requests focused on one change.
|
||||
- Verify that linters and tests are passing.
|
||||
- Review the documentation to make sure it looks good.
|
||||
- Follow the pull request template when opening a pull request.
|
||||
- For pull requests that change the API or implementation, discuss with maintainers first by opening an issue.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
### Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
### Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
### Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
### Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
### Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
#### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
#### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
#### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
#### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
### Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
|
||||
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021 Javad Mnjd
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,563 @@
|
|||
# react-native-saf-x
|
||||
|
||||
<a href="https://www.npmjs.com/package/react-native-saf-x" target="_blank">
|
||||
<img src="https://img.shields.io/npm/v/react-native-saf-x?color=green"/>
|
||||
</a>
|
||||
|
||||
A module to help simplify usage of scoped storages on android.
|
||||
|
||||
intended to use when targeting Android API level 30+
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
npm install react-native-saf-x
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```sh
|
||||
yarn add react-native-saf-x
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
After receiving a content uri using `openDocumentTree` function,
|
||||
you can use this as if like it's a normal path like we are used to.
|
||||
This is the intended behaviour,
|
||||
if you find any problems or if something does not behave as intended,
|
||||
please create an issue.
|
||||
|
||||
Note that each method can reject when there's an unexpected error or when the permission is revoked.
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
import { openDocumentTree, mkdir } from "react-native-saf-x";
|
||||
|
||||
// somewhere in the app .....
|
||||
|
||||
async function testIt() {
|
||||
const doc = await openDocumentTree(true);
|
||||
if (doc && doc.uri) {
|
||||
// user has selected a directory and uri is available
|
||||
// you can save this uri as base directory in your app and reuse it anywhere you want
|
||||
await mkdir(doc.uri + '/foo/bar'); // creates foo/bar folder and subfolder at selected directory
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
For more examples look at example folder in the source.
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Type aliases](#type-aliases)
|
||||
- [Functions](#functions)
|
||||
- [Caveats](#caveats)
|
||||
- [Thanks to](#thanks-to)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
### Type aliases
|
||||
|
||||
- [CreateDocumentOptions](#createdocumentoptions)
|
||||
- [DocumentFileDetail](#documentfiledetail)
|
||||
- [Encoding](#encoding)
|
||||
- [FileOperationOptions](#fileoperationoptions)
|
||||
|
||||
### Functions
|
||||
|
||||
- [copyFile](#copyfile)
|
||||
- [createDocument](#createdocument)
|
||||
- [createFile](#createfile)
|
||||
- [exists](#exists)
|
||||
- [getPersistedUriPermissions](#getpersisteduripermissions)
|
||||
- [hasPermission](#haspermission)
|
||||
- [listFiles](#listfiles)
|
||||
- [mkdir](#mkdir)
|
||||
- [moveFile](#movefile)
|
||||
- [openDocument](#opendocument)
|
||||
- [openDocumentTree](#opendocumenttree)
|
||||
- [readFile](#readfile)
|
||||
- [releasePersistableUriPermission](#releasepersistableuripermission)
|
||||
- [rename](#rename)
|
||||
- [stat](#stat)
|
||||
- [unlink](#unlink)
|
||||
- [writeFile](#writefile)
|
||||
|
||||
## Type aliases
|
||||
|
||||
### CreateDocumentOptions
|
||||
|
||||
Ƭ **CreateDocumentOptions**: [`FileOperationOptions`](#fileoperationoptions) & { `initialName?`: `string` }
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:80](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L80)
|
||||
|
||||
___
|
||||
|
||||
### DocumentFileDetail
|
||||
|
||||
Ƭ **DocumentFileDetail**: `Object`
|
||||
|
||||
#### Type declaration
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `lastModified` | `number` |
|
||||
| `mime?` | `string` |
|
||||
| `name` | `string` |
|
||||
| `size?` | `number` |
|
||||
| `type` | ``"directory"`` \| ``"file"`` |
|
||||
| `uri` | `string` |
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:60](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L60)
|
||||
|
||||
___
|
||||
|
||||
### Encoding
|
||||
|
||||
Ƭ **Encoding**: ``"utf8"`` \| ``"base64"`` \| ``"ascii"``
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:22](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L22)
|
||||
|
||||
___
|
||||
|
||||
### FileOperationOptions
|
||||
|
||||
Ƭ **FileOperationOptions**: `Object`
|
||||
|
||||
#### Type declaration
|
||||
|
||||
| Name | Type | Description |
|
||||
| :------ | :------ | :------ |
|
||||
| `append?` | `boolean` | Append data to the file. If not set file content will be overwritten. |
|
||||
| `encoding?` | [`Encoding`](#encoding) | Defaults to `'utf8'` |
|
||||
| `mimeType?` | `string` | mime type of the file being saved. Defaults to '\*\/\*' |
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:69](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L69)
|
||||
|
||||
## Functions
|
||||
|
||||
### copyFile
|
||||
|
||||
▸ **copyFile**(`srcUri`, `destUri`, `options?`): `Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
Copy file from source uri to destination uri.
|
||||
promise Rejects if destination already exists and `replaceIfDestinationExists` option is not set to true.
|
||||
Does not support moving directories.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `srcUri` | `string` |
|
||||
| `destUri` | `string` |
|
||||
| `options?` | `FileTransferOptions` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:214](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L214)
|
||||
|
||||
___
|
||||
|
||||
### createDocument
|
||||
|
||||
▸ **createDocument**(`data`, `options?`): `Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
Open the Document Picker to save a file.
|
||||
Returns an object of type `DocumentFileDetail` or `null` if user did not select a file.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `data` | `string` |
|
||||
| `options?` | [`CreateDocumentOptions`](#createdocumentoptions) |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:105](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L105)
|
||||
|
||||
___
|
||||
|
||||
### createFile
|
||||
|
||||
▸ **createFile**(`uriString`, `options?`): `Promise`<[`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
Creates an empty file at given uri.
|
||||
Rejects if a file or directory exist at given uri.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
| `options?` | `Pick`<[`FileOperationOptions`](#fileoperationoptions), ``"mimeType"``\> |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<[`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:150](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L150)
|
||||
|
||||
___
|
||||
|
||||
### exists
|
||||
|
||||
▸ **exists**(`uriString`): `Promise`<`boolean`\>
|
||||
|
||||
Check if there's a document located at the given uri.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<`boolean`\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:117](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L117)
|
||||
|
||||
___
|
||||
|
||||
### getPersistedUriPermissions
|
||||
|
||||
▸ **getPersistedUriPermissions**(): `Promise`<`string`[]\>
|
||||
|
||||
Returns a list of all the persisted uri permissions.
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<`string`[]\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:186](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L186)
|
||||
|
||||
___
|
||||
|
||||
### hasPermission
|
||||
|
||||
▸ **hasPermission**(`uriString`): `Promise`<`boolean`\>
|
||||
|
||||
Check if you have permission to access the uri.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<`boolean`\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:112](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L112)
|
||||
|
||||
___
|
||||
|
||||
### listFiles
|
||||
|
||||
▸ **listFiles**(`uriString`): `Promise`<[`DocumentFileDetail`](#documentfiledetail)[]\>
|
||||
|
||||
List all files and folders in a directory uri.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<[`DocumentFileDetail`](#documentfiledetail)[]\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:196](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L196)
|
||||
|
||||
___
|
||||
|
||||
### mkdir
|
||||
|
||||
▸ **mkdir**(`uriString`): `Promise`<[`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
Create a directory at given uri.
|
||||
Automatically creates folders in path if needed.
|
||||
You can use it to create nested directories easily.
|
||||
Rejects if it fails.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<[`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:172](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L172)
|
||||
|
||||
___
|
||||
|
||||
### moveFile
|
||||
|
||||
▸ **moveFile**(`srcUri`, `destUri`, `options?`): `Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
Move file from source uri to destination uri.
|
||||
promise Rejects if destination already exists and `replaceIfDestinationExists` option is not set to true.
|
||||
Does not support moving directories.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `srcUri` | `string` |
|
||||
| `destUri` | `string` |
|
||||
| `options?` | `FileTransferOptions` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:229](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L229)
|
||||
|
||||
___
|
||||
|
||||
### openDocument
|
||||
|
||||
▸ **openDocument**(`persist`): `Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
Open the Document Picker to select a file.
|
||||
Returns an object of type `DocumentFileDetail` or `null` if user did not select a file.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `persist` | `boolean` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:97](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L97)
|
||||
|
||||
___
|
||||
|
||||
### openDocumentTree
|
||||
|
||||
▸ **openDocumentTree**(`persist`): `Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
Open the Document Picker to select a folder. Read/Write Permission will be granted to the selected folder.
|
||||
Returns an object of type `DocumentFileDetail` or `null` if user did not select a folder.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `persist` | `boolean` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<``null`` \| [`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:89](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L89)
|
||||
|
||||
___
|
||||
|
||||
### readFile
|
||||
|
||||
▸ **readFile**(`uriString`, `options?`): `Promise`<`string`\>
|
||||
|
||||
Read contents of the given uri. uri must point to a file.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
| `options?` | `Pick`<[`FileOperationOptions`](#fileoperationoptions), ``"encoding"``\> |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<`string`\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:122](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L122)
|
||||
|
||||
___
|
||||
|
||||
### releasePersistableUriPermission
|
||||
|
||||
▸ **releasePersistableUriPermission**(`uriString`): `Promise`<`void`\>
|
||||
|
||||
Remove a uri from persisted uri permissions list.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<`void`\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:191](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L191)
|
||||
|
||||
___
|
||||
|
||||
### rename
|
||||
|
||||
▸ **rename**(`uriString`, `newName`): `Promise`<`boolean`\>
|
||||
|
||||
Renames the document at given uri.
|
||||
uri can be file or folder.
|
||||
Resolves with `true` if successful and `false` otherwise.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
| `newName` | `string` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<`boolean`\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:181](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L181)
|
||||
|
||||
___
|
||||
|
||||
### stat
|
||||
|
||||
▸ **stat**(`uriString`): `Promise`<[`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
Get details for a file/directory at given uri.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<[`DocumentFileDetail`](#documentfiledetail)\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:201](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L201)
|
||||
|
||||
___
|
||||
|
||||
### unlink
|
||||
|
||||
▸ **unlink**(`uriString`): `Promise`<`boolean`\>
|
||||
|
||||
Removes the file or directory at given uri.
|
||||
Resolves with `true` if delete is successful, `false` otherwise.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<`boolean`\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:162](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L162)
|
||||
|
||||
___
|
||||
|
||||
### writeFile
|
||||
|
||||
▸ **writeFile**(`uriString`, `data`, `options?`): `Promise`<`string`\>
|
||||
|
||||
Writes the given data to the file at given uri.
|
||||
Tries to create the file if does not already exist before writing to it.
|
||||
Resolves with given uriString if successful.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type |
|
||||
| :------ | :------ |
|
||||
| `uriString` | `string` |
|
||||
| `data` | `string` |
|
||||
| `options?` | [`FileOperationOptions`](#fileoperationoptions) |
|
||||
|
||||
#### Returns
|
||||
|
||||
`Promise`<`string`\>
|
||||
|
||||
#### Defined in
|
||||
|
||||
[index.tsx:136](https://github.com/jd1378/react-native-saf-x/blob/e0f8106/src/index.tsx#L136)
|
||||
|
||||
## Caveats
|
||||
|
||||
Due to simplyifing the uri structure usage, file and directories should not have the same name at a given uri.
|
||||
doing so can cause unexpected results.
|
||||
for example in a folder named "foo", do not create "bar" file and "bar" directory making the uri being "foo/bar" for both cases.
|
||||
|
||||
___
|
||||
|
||||
## Thanks to
|
||||
|
||||
- [ammarahm-ed](https://github.com/ammarahm-ed) for his work on [react-native-scoped-storage](https://github.com/ammarahm-ed/react-native-scoped-storage)
|
||||
which helped me jump start this module.
|
||||
|
||||
## Contributing
|
||||
|
||||
See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
|
@ -0,0 +1,60 @@
|
|||
buildscript {
|
||||
if (project == rootProject) {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
jcenter()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.2.2'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
def safeExtGet(prop, fallback) {
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion safeExtGet('SafX_compileSdkVersion', 30)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet('SafX_minSdkVersion', 21)
|
||||
targetSdkVersion safeExtGet('SafX_targetSdkVersion', 30)
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
}
|
||||
}
|
||||
lintOptions {
|
||||
disable 'GradleCompatible'
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
maven {
|
||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||
url("$rootDir/../node_modules/react-native/android")
|
||||
}
|
||||
google()
|
||||
mavenCentral()
|
||||
jcenter()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
//noinspection GradleDynamicVersion
|
||||
implementation "com.facebook.react:react-native:+" // From node_modules
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
android.useAndroidX=true
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
|
@ -0,0 +1,89 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
|
@ -0,0 +1,4 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.reactnativesafx">
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,55 @@
|
|||
package androidx.documentfile.provider;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.util.Log;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import com.reactnativesafx.SafXModule;
|
||||
|
||||
public class DocumentFileHelper {
|
||||
|
||||
// https://www.reddit.com/r/androiddev/comments/orytnx/fixing_treedocumentfilefindfile_lousy_performance/
|
||||
@Nullable
|
||||
public static DocumentFile findFile(
|
||||
Context context, @NonNull DocumentFile documentFile, @NonNull String displayName) {
|
||||
|
||||
if (!(documentFile instanceof TreeDocumentFile)) {
|
||||
return documentFile.findFile(displayName);
|
||||
}
|
||||
|
||||
final ContentResolver resolver = context.getContentResolver();
|
||||
final Uri childrenUri =
|
||||
DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
documentFile.getUri(), DocumentsContract.getDocumentId(documentFile.getUri()));
|
||||
|
||||
try (Cursor c =
|
||||
resolver.query(
|
||||
childrenUri,
|
||||
new String[] {
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
null)) {
|
||||
if (c != null) {
|
||||
while (c.moveToNext()) {
|
||||
if (displayName.equals(c.getString(1))) {
|
||||
return new TreeDocumentFile(
|
||||
documentFile,
|
||||
context,
|
||||
DocumentsContract.buildDocumentUriUsingTree(documentFile.getUri(), c.getString(0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(SafXModule.NAME, "query failed: " + e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,265 @@
|
|||
package com.reactnativesafx;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.module.annotations.ReactModule;
|
||||
import com.reactnativesafx.utils.DocumentHelper;
|
||||
import com.reactnativesafx.utils.GeneralHelper;
|
||||
import com.reactnativesafx.utils.UriHelper;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Arrays;
|
||||
|
||||
@RequiresApi(api = VERSION_CODES.Q)
|
||||
@ReactModule(name = SafXModule.NAME)
|
||||
public class SafXModule extends ReactContextBaseJavaModule {
|
||||
public static final String NAME = "SafX";
|
||||
private final DocumentHelper documentHelper;
|
||||
|
||||
public SafXModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
this.documentHelper = new DocumentHelper(reactContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void openDocumentTree(final boolean persist, final Promise promise) {
|
||||
this.documentHelper.openDocumentTree(persist, promise);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void openDocument(final boolean persist, final Promise promise) {
|
||||
this.documentHelper.openDocument(persist, promise);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void createDocument(
|
||||
final String data,
|
||||
final String encoding,
|
||||
final String initialName,
|
||||
final String mimeType,
|
||||
final Promise promise) {
|
||||
this.documentHelper.createDocument(data, encoding, initialName, mimeType, promise);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void hasPermission(String uriString, final Promise promise) {
|
||||
if (this.documentHelper.hasPermission(uriString)) {
|
||||
promise.resolve(true);
|
||||
} else {
|
||||
promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void exists(String uriString, final Promise promise) {
|
||||
try {
|
||||
promise.resolve(this.documentHelper.exists(uriString));
|
||||
} catch (Exception e) {
|
||||
promise.reject("ERROR", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void readFile(String uriString, String encoding, final Promise promise) {
|
||||
try {
|
||||
DocumentFile file;
|
||||
|
||||
try {
|
||||
file = this.documentHelper.goToDocument(uriString, false, true);
|
||||
} catch (FileNotFoundException e) {
|
||||
promise.reject("ENOENT", "'" + uriString + "' does not exist");
|
||||
return;
|
||||
}
|
||||
if (encoding != null) {
|
||||
if (encoding.equals("ascii")) {
|
||||
WritableArray arr =
|
||||
(WritableArray) this.documentHelper.readFromUri(file.getUri(), encoding);
|
||||
promise.resolve((arr));
|
||||
} else {
|
||||
promise.resolve(this.documentHelper.readFromUri(file.getUri(), encoding));
|
||||
}
|
||||
} else {
|
||||
promise.resolve(this.documentHelper.readFromUri(file.getUri(), "utf8"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void writeFile(
|
||||
String uriString,
|
||||
String data,
|
||||
String encoding,
|
||||
String mimeType,
|
||||
boolean append,
|
||||
final Promise promise) {
|
||||
try {
|
||||
DocumentFile file;
|
||||
|
||||
try {
|
||||
file = this.documentHelper.goToDocument(uriString, false, true);
|
||||
} catch (FileNotFoundException e) {
|
||||
file = this.documentHelper.createFile(uriString, mimeType);
|
||||
}
|
||||
|
||||
byte[] bytes = GeneralHelper.stringToBytes(data, encoding);
|
||||
|
||||
try (OutputStream fout =
|
||||
this.getReactApplicationContext()
|
||||
.getContentResolver()
|
||||
.openOutputStream(file.getUri(), append ? "wa" : "wt")) {
|
||||
fout.write(bytes);
|
||||
}
|
||||
|
||||
promise.resolve(uriString);
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void transferFile(
|
||||
String srcUri, String destUri, boolean replaceIfDestExists, boolean copy, Promise promise) {
|
||||
this.documentHelper.transferFile(srcUri, destUri, replaceIfDestExists, copy, promise);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void rename(String uriString, String newName, final Promise promise) {
|
||||
try {
|
||||
|
||||
DocumentFile doc;
|
||||
try {
|
||||
doc = this.documentHelper.goToDocument(uriString, false, true);
|
||||
} catch (FileNotFoundException e) {
|
||||
promise.reject("ENOENT", "'" + uriString + "' does not exist");
|
||||
return;
|
||||
}
|
||||
|
||||
if (doc.renameTo(newName)) {
|
||||
promise.resolve(true);
|
||||
} else {
|
||||
promise.resolve(false);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void unlink(String uriString, final Promise promise) {
|
||||
try {
|
||||
DocumentFile doc = this.documentHelper.goToDocument(uriString, false, true);
|
||||
boolean result = doc.delete();
|
||||
promise.resolve(result);
|
||||
} catch (FileNotFoundException e) {
|
||||
promise.reject("ENOENT", e.getLocalizedMessage());
|
||||
} catch (SecurityException e) {
|
||||
promise.reject("EPERM", e.getLocalizedMessage());
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void mkdir(String uriString, final Promise promise) {
|
||||
try {
|
||||
DocumentFile dir = this.documentHelper.mkdir(uriString);
|
||||
DocumentHelper.resolveWithDocument(dir, promise, uriString);
|
||||
} catch (IOException e) {
|
||||
promise.reject("EEXIST", e.getLocalizedMessage());
|
||||
} catch (SecurityException e) {
|
||||
promise.reject("EPERM", e.getLocalizedMessage());
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void createFile(String uriString, String mimeType, final Promise promise) {
|
||||
try {
|
||||
DocumentFile createdFile = this.documentHelper.createFile(uriString, mimeType);
|
||||
DocumentHelper.resolveWithDocument(createdFile, promise, uriString);
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void getPersistedUriPermissions(final Promise promise) {
|
||||
String[] uriList =
|
||||
getReactApplicationContext().getContentResolver().getPersistedUriPermissions().stream()
|
||||
.map(uriPermission -> uriPermission.getUri().toString())
|
||||
.toArray(String[]::new);
|
||||
|
||||
WritableArray wa = Arguments.fromArray(uriList);
|
||||
promise.resolve(wa);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void releasePersistableUriPermission(String uriString, final Promise promise) {
|
||||
Uri uriToRevoke = Uri.parse(UriHelper.normalize(uriString));
|
||||
final int takeFlags =
|
||||
(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
this.getReactApplicationContext()
|
||||
.getContentResolver()
|
||||
.releasePersistableUriPermission(uriToRevoke, takeFlags);
|
||||
promise.resolve(null);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void listFiles(String uriString, final Promise promise) {
|
||||
try {
|
||||
DocumentFile doc = this.documentHelper.goToDocument(uriString, false, true);
|
||||
|
||||
WritableMap[] resolvedDocs =
|
||||
Arrays.stream(doc.listFiles())
|
||||
.map(
|
||||
docEntry ->
|
||||
DocumentHelper.resolveWithDocument(
|
||||
docEntry, null, uriString + "/" + docEntry.getName()))
|
||||
.toArray(WritableMap[]::new);
|
||||
WritableArray resolveData = Arguments.fromJavaArgs(resolvedDocs);
|
||||
promise.resolve(resolveData);
|
||||
} catch (FileNotFoundException e) {
|
||||
promise.reject("ENOENT", e.getLocalizedMessage());
|
||||
} catch (SecurityException e) {
|
||||
promise.reject("EPERM", e.getLocalizedMessage());
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void stat(String uriString, final Promise promise) {
|
||||
try {
|
||||
DocumentFile doc = this.documentHelper.goToDocument(uriString, false, true);
|
||||
|
||||
DocumentHelper.resolveWithDocument(doc, promise, uriString);
|
||||
} catch (FileNotFoundException e) {
|
||||
promise.reject("ENOENT", e.getLocalizedMessage());
|
||||
} catch (SecurityException e) {
|
||||
promise.reject("EPERM", e.getLocalizedMessage());
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package com.reactnativesafx;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class SafXPackage implements ReactPackage {
|
||||
@NonNull
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
|
||||
List<NativeModule> modules = new ArrayList<>();
|
||||
modules.add(new SafXModule(reactContext));
|
||||
return modules;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,493 @@
|
|||
package com.reactnativesafx.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.content.UriPermission;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import android.util.Base64;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
import androidx.documentfile.provider.DocumentFileHelper;
|
||||
import com.facebook.react.bridge.ActivityEventListener;
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.List;
|
||||
|
||||
@RequiresApi(api = VERSION_CODES.Q)
|
||||
public class DocumentHelper {
|
||||
|
||||
private static final int DOCUMENT_TREE_REQUEST_CODE = 1;
|
||||
private static final int DOCUMENT_REQUEST_CODE = 2;
|
||||
private static final int DOCUMENT_CREATE_CODE = 3;
|
||||
|
||||
private final ReactApplicationContext context;
|
||||
private ActivityEventListener activityEventListener;
|
||||
|
||||
public DocumentHelper(ReactApplicationContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public void openDocumentTree(final boolean persist, final Promise promise) {
|
||||
try {
|
||||
|
||||
Intent intent = new Intent();
|
||||
intent.setAction(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
|
||||
if (activityEventListener != null) {
|
||||
context.removeActivityEventListener(activityEventListener);
|
||||
activityEventListener = null;
|
||||
}
|
||||
|
||||
activityEventListener =
|
||||
new ActivityEventListener() {
|
||||
@SuppressLint("WrongConstant")
|
||||
@Override
|
||||
public void onActivityResult(
|
||||
Activity activity, int requestCode, int resultCode, Intent intent) {
|
||||
if (requestCode == DOCUMENT_TREE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
|
||||
if (intent != null) {
|
||||
Uri uri = intent.getData();
|
||||
if (persist) {
|
||||
final int takeFlags =
|
||||
intent.getFlags()
|
||||
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
|
||||
context.getContentResolver().takePersistableUriPermission(uri, takeFlags);
|
||||
}
|
||||
|
||||
try {
|
||||
DocumentFile doc = goToDocument(uri.toString(), false);
|
||||
resolveWithDocument(doc, promise, uri.toString());
|
||||
} catch (Exception e) {
|
||||
promise.resolve(null);
|
||||
}
|
||||
} else {
|
||||
promise.resolve(null);
|
||||
}
|
||||
} else {
|
||||
promise.resolve(null);
|
||||
}
|
||||
context.removeActivityEventListener(activityEventListener);
|
||||
activityEventListener = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {}
|
||||
};
|
||||
|
||||
context.addActivityEventListener(activityEventListener);
|
||||
|
||||
Activity activity = context.getCurrentActivity();
|
||||
if (activity != null) {
|
||||
activity.startActivityForResult(intent, DOCUMENT_TREE_REQUEST_CODE);
|
||||
} else {
|
||||
promise.reject("ERROR", "Cannot get current activity, so cannot launch document picker");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
promise.reject("ERROR", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void openDocument(final boolean persist, final Promise promise) {
|
||||
try {
|
||||
|
||||
Intent intent = new Intent();
|
||||
intent.setAction(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
|
||||
if (activityEventListener != null) {
|
||||
context.removeActivityEventListener(activityEventListener);
|
||||
activityEventListener = null;
|
||||
}
|
||||
|
||||
activityEventListener =
|
||||
new ActivityEventListener() {
|
||||
@SuppressLint("WrongConstant")
|
||||
@Override
|
||||
public void onActivityResult(
|
||||
Activity activity, int requestCode, int resultCode, Intent intent) {
|
||||
if (requestCode == DOCUMENT_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
|
||||
if (intent != null) {
|
||||
Uri uri = intent.getData();
|
||||
if (persist) {
|
||||
final int takeFlags =
|
||||
intent.getFlags()
|
||||
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
|
||||
context.getContentResolver().takePersistableUriPermission(uri, takeFlags);
|
||||
}
|
||||
|
||||
try {
|
||||
DocumentFile doc = goToDocument(uri.toString(), false);
|
||||
resolveWithDocument(doc, promise, uri.toString());
|
||||
} catch (Exception e) {
|
||||
promise.resolve(null);
|
||||
}
|
||||
} else {
|
||||
promise.resolve(null);
|
||||
}
|
||||
} else {
|
||||
promise.resolve(null);
|
||||
}
|
||||
context.removeActivityEventListener(activityEventListener);
|
||||
activityEventListener = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {}
|
||||
};
|
||||
|
||||
context.addActivityEventListener(activityEventListener);
|
||||
|
||||
Activity activity = context.getCurrentActivity();
|
||||
if (activity != null) {
|
||||
activity.startActivityForResult(intent, DOCUMENT_REQUEST_CODE);
|
||||
} else {
|
||||
promise.reject("ERROR", "Cannot get current activity, so cannot launch document picker");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
promise.reject("ERROR", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void createDocument(
|
||||
final String data,
|
||||
final String encoding,
|
||||
final String initialName,
|
||||
final String mimeType,
|
||||
final Promise promise) {
|
||||
try {
|
||||
|
||||
Intent intent = new Intent();
|
||||
intent.setAction(Intent.ACTION_CREATE_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
if (initialName != null) {
|
||||
intent.putExtra(Intent.EXTRA_TITLE, initialName);
|
||||
}
|
||||
if (mimeType != null) {
|
||||
intent.setType(mimeType);
|
||||
} else {
|
||||
intent.setType("*/*");
|
||||
}
|
||||
|
||||
if (activityEventListener != null) {
|
||||
context.removeActivityEventListener(activityEventListener);
|
||||
activityEventListener = null;
|
||||
}
|
||||
|
||||
activityEventListener =
|
||||
new ActivityEventListener() {
|
||||
@Override
|
||||
public void onActivityResult(
|
||||
Activity activity, int requestCode, int resultCode, Intent intent) {
|
||||
|
||||
if (requestCode == DOCUMENT_CREATE_CODE && resultCode == Activity.RESULT_OK) {
|
||||
if (intent != null) {
|
||||
Uri uri = intent.getData();
|
||||
|
||||
DocumentFile doc = DocumentFile.fromSingleUri(context, uri);
|
||||
|
||||
try {
|
||||
byte[] bytes = GeneralHelper.stringToBytes(data, encoding);
|
||||
try (OutputStream os = context.getContentResolver().openOutputStream(uri)) {
|
||||
os.write(bytes);
|
||||
}
|
||||
assert doc != null;
|
||||
resolveWithDocument(doc, promise, uri.toString());
|
||||
} catch (Exception e) {
|
||||
promise.reject("ERROR", e.getLocalizedMessage());
|
||||
}
|
||||
} else {
|
||||
promise.resolve(null);
|
||||
}
|
||||
} else {
|
||||
promise.resolve(null);
|
||||
}
|
||||
|
||||
context.removeActivityEventListener(activityEventListener);
|
||||
activityEventListener = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {}
|
||||
};
|
||||
|
||||
context.addActivityEventListener(activityEventListener);
|
||||
|
||||
Activity activity = context.getCurrentActivity();
|
||||
if (activity != null) {
|
||||
activity.startActivityForResult(intent, DOCUMENT_CREATE_CODE);
|
||||
} else {
|
||||
promise.reject("ERROR", "Cannot get current activity, so cannot launch document picker");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
promise.reject("ERROR", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
@SuppressWarnings({"UnusedDeclaration", "UnusedAssignment"})
|
||||
public Object readFromUri(Uri uri, String encoding) throws IOException {
|
||||
byte[] bytes;
|
||||
int bytesRead;
|
||||
int length;
|
||||
|
||||
InputStream inputStream = context.getContentResolver().openInputStream(uri);
|
||||
|
||||
length = inputStream.available();
|
||||
bytes = new byte[length];
|
||||
bytesRead = inputStream.read(bytes);
|
||||
inputStream.close();
|
||||
|
||||
switch (encoding.toLowerCase()) {
|
||||
case "base64":
|
||||
return Base64.encodeToString(bytes, Base64.NO_WRAP);
|
||||
case "ascii":
|
||||
WritableArray asciiResult = Arguments.createArray();
|
||||
for (byte b : bytes) {
|
||||
asciiResult.pushInt(b);
|
||||
}
|
||||
return asciiResult;
|
||||
case "utf8":
|
||||
default:
|
||||
return new String(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean exists(final String uriString) {
|
||||
return this.exists(uriString, false);
|
||||
}
|
||||
|
||||
public boolean exists(final String uriString, final boolean shouldBeFile) {
|
||||
try {
|
||||
DocumentFile fileOrFolder = goToDocument(uriString, false);
|
||||
if (shouldBeFile) {
|
||||
return !fileOrFolder.isDirectory();
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasPermission(String uriString) {
|
||||
// list of all persisted permissions for our app
|
||||
List<UriPermission> uriList = context.getContentResolver().getPersistedUriPermissions();
|
||||
for (UriPermission uriPermission : uriList) {
|
||||
if (permissionMatchesAndHasAccess(uriPermission, UriHelper.normalize(uriString))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean permissionMatchesAndHasAccess(
|
||||
UriPermission permission, String normalizedUriString) {
|
||||
String permittedUri = permission.getUri().toString();
|
||||
return (permittedUri.startsWith(normalizedUriString)
|
||||
|| normalizedUriString.startsWith(permittedUri))
|
||||
&& permission.isReadPermission()
|
||||
&& permission.isWritePermission();
|
||||
}
|
||||
|
||||
private String getPermissionErrorMsg(final String uriString) {
|
||||
return "You don't have read/write permission to access uri: " + uriString;
|
||||
}
|
||||
|
||||
public static WritableMap resolveWithDocument(
|
||||
@NonNull DocumentFile file, Promise promise, String SimplifiedUri) {
|
||||
WritableMap fileMap = Arguments.createMap();
|
||||
fileMap.putString("uri", UriHelper.denormalize(SimplifiedUri));
|
||||
fileMap.putString("name", file.getName());
|
||||
fileMap.putString("type", file.isDirectory() ? "directory" : "file");
|
||||
if (file.isFile()) {
|
||||
fileMap.putString("mime", file.getType());
|
||||
fileMap.putDouble("size", file.length());
|
||||
}
|
||||
fileMap.putDouble("lastModified", file.lastModified());
|
||||
|
||||
if (promise != null) {
|
||||
promise.resolve(fileMap);
|
||||
}
|
||||
return fileMap;
|
||||
}
|
||||
|
||||
public DocumentFile mkdir(String uriString)
|
||||
throws IOException, SecurityException, IllegalArgumentException {
|
||||
return this.mkdir(uriString, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a DocumentFile that is created using DocumentFile.fromTreeUri()
|
||||
*/
|
||||
public DocumentFile mkdir(String uriString, boolean includeLastSegment)
|
||||
throws IOException, SecurityException, IllegalArgumentException {
|
||||
DocumentFile dir = goToDocument(uriString, true, includeLastSegment);
|
||||
assert dir != null;
|
||||
return dir;
|
||||
}
|
||||
|
||||
public DocumentFile createFile(String uriString) throws IOException, SecurityException {
|
||||
return createFile(uriString, null);
|
||||
}
|
||||
|
||||
public DocumentFile createFile(String uriString, String mimeType)
|
||||
throws IOException, SecurityException {
|
||||
if (this.exists(uriString)) {
|
||||
throw new IOException("a file or directory already exist at: " + uriString);
|
||||
}
|
||||
DocumentFile parentDirOfFile = this.mkdir(uriString, false);
|
||||
// it should be safe because user cannot select sd root or primary root
|
||||
// and any other path would have at least one '/' to provide a file name in a folder
|
||||
String fileName = UriHelper.getLastSegment(uriString);
|
||||
if (fileName.indexOf(':') != -1) {
|
||||
throw new IOException(
|
||||
"Invalid file name: Could not extract filename from uri string provided");
|
||||
}
|
||||
DocumentFile createdFile =
|
||||
parentDirOfFile.createFile(
|
||||
mimeType != null && !mimeType.equals("") ? mimeType : "*/*", fileName);
|
||||
if (createdFile == null) {
|
||||
throw new IOException(
|
||||
"File creation failed without any specific error for '" + fileName + "'");
|
||||
}
|
||||
return createdFile;
|
||||
}
|
||||
|
||||
public DocumentFile goToDocument(String uriString, boolean createIfDirectoryNotExist)
|
||||
throws SecurityException, IOException {
|
||||
return goToDocument(uriString, createIfDirectoryNotExist, true);
|
||||
}
|
||||
|
||||
public DocumentFile goToDocument(
|
||||
String unknownUriString, boolean createIfDirectoryNotExist, boolean includeLastSegment)
|
||||
throws SecurityException, IOException {
|
||||
String uriString = UriHelper.normalize(unknownUriString);
|
||||
String baseUri = "";
|
||||
String appendUri;
|
||||
String[] strings = new String[0];
|
||||
|
||||
List<UriPermission> uriList = context.getContentResolver().getPersistedUriPermissions();
|
||||
|
||||
for (UriPermission uriPermission : uriList) {
|
||||
String uriPath = uriPermission.getUri().toString();
|
||||
if (this.permissionMatchesAndHasAccess(uriPermission, uriString)) {
|
||||
baseUri = uriPath;
|
||||
appendUri = Uri.decode(uriString.substring(uriPath.length()));
|
||||
strings = appendUri.split("/");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (baseUri.equals("")) {
|
||||
throw new SecurityException(getPermissionErrorMsg(uriString));
|
||||
}
|
||||
|
||||
if (baseUri.matches("^content://[\\w.]+/document/.*")) {
|
||||
// It's a document picked by user
|
||||
DocumentFile doc = DocumentFile.fromSingleUri(context, Uri.parse(uriString));
|
||||
if (doc != null && doc.isFile() && doc.exists()) {
|
||||
return doc;
|
||||
} else {
|
||||
throw new FileNotFoundException(
|
||||
"Cannot find the given document. File does not exist at '" + uriString + "'");
|
||||
}
|
||||
}
|
||||
|
||||
Uri uri = Uri.parse(baseUri);
|
||||
DocumentFile dir = DocumentFile.fromTreeUri(context, uri);
|
||||
|
||||
int pathSegmentsToTraverseLength = includeLastSegment ? strings.length : strings.length - 1;
|
||||
for (int i = 0; i < pathSegmentsToTraverseLength; i++) {
|
||||
if (!strings[i].equals("")) {
|
||||
assert dir != null;
|
||||
DocumentFile childDoc = DocumentFileHelper.findFile(context, dir, strings[i]);
|
||||
if (childDoc != null) {
|
||||
if (childDoc.isDirectory()) {
|
||||
dir = childDoc;
|
||||
} else if (i == pathSegmentsToTraverseLength - 1) {
|
||||
// we are at the last part to traverse, its our destination, doesn't matter if its a
|
||||
// file or directory
|
||||
dir = childDoc;
|
||||
} else {
|
||||
// child doc is a file
|
||||
throw new IOException(
|
||||
"There's a document with the same name as the one we are trying to traverse at: '"
|
||||
+ childDoc.getUri()
|
||||
+ "'");
|
||||
}
|
||||
} else {
|
||||
if (createIfDirectoryNotExist) {
|
||||
dir = dir.createDirectory(strings[i]);
|
||||
} else {
|
||||
throw new FileNotFoundException(
|
||||
"Cannot traverse to the pointed document. Directory '"
|
||||
+ strings[i]
|
||||
+ "'"
|
||||
+ " does not exist in '"
|
||||
+ dir.getUri()
|
||||
+ "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert dir != null;
|
||||
return dir;
|
||||
}
|
||||
|
||||
public void transferFile(
|
||||
String srcUri, String destUri, boolean replaceIfDestExists, boolean copy, Promise promise) {
|
||||
try {
|
||||
DocumentFile srcDoc = this.goToDocument(srcUri, false, true);
|
||||
|
||||
if (srcDoc.isDirectory()) {
|
||||
throw new IllegalArgumentException("Cannot move directories");
|
||||
}
|
||||
|
||||
DocumentFile destDoc;
|
||||
try {
|
||||
destDoc = this.goToDocument(destUri, false, true);
|
||||
if (!replaceIfDestExists) {
|
||||
throw new IOException("a document with the same name already exists in destination");
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
destDoc = this.createFile(destUri, srcDoc.getType());
|
||||
}
|
||||
|
||||
try (InputStream inStream =
|
||||
this.context.getContentResolver().openInputStream(srcDoc.getUri());
|
||||
OutputStream outStream =
|
||||
this.context.getContentResolver().openOutputStream(destDoc.getUri(), "wt"); ) {
|
||||
byte[] buffer = new byte[1024 * 4];
|
||||
int length;
|
||||
while ((length = inStream.read(buffer)) > 0) {
|
||||
outStream.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!copy) {
|
||||
srcDoc.delete();
|
||||
}
|
||||
|
||||
promise.resolve(resolveWithDocument(destDoc, promise, destUri));
|
||||
} catch (Exception e) {
|
||||
promise.reject("EUNSPECIFIED", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package com.reactnativesafx.utils;
|
||||
|
||||
import android.util.Base64;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class GeneralHelper {
|
||||
/**
|
||||
* String to byte converter method
|
||||
*
|
||||
* @param data Raw data in string format
|
||||
* @param encoding Decoder name
|
||||
* @return Converted data byte array
|
||||
*/
|
||||
public static byte[] stringToBytes(String data, String encoding) {
|
||||
if (encoding != null) {
|
||||
if (encoding.equalsIgnoreCase("ascii")) {
|
||||
return data.getBytes(StandardCharsets.US_ASCII);
|
||||
} else if (encoding.toLowerCase().contains("base64")) {
|
||||
return Base64.decode(data, Base64.NO_WRAP);
|
||||
} else if (encoding.equalsIgnoreCase("utf8")) {
|
||||
return data.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return data.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package com.reactnativesafx.utils;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
@RequiresApi(api = VERSION_CODES.Q)
|
||||
public class UriHelper {
|
||||
public static final String CONTENT_URI_PREFIX = "content://";
|
||||
|
||||
public static String getLastSegment(String uriString) {
|
||||
|
||||
return Uri.parse(Uri.decode(uriString)).getLastPathSegment();
|
||||
}
|
||||
|
||||
public static String normalize(String uriString) {
|
||||
// an abnormal uri example:
|
||||
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
|
||||
// normalized:
|
||||
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
|
||||
|
||||
// uri parts:
|
||||
|
||||
String[] parts = Uri.decode(uriString).split(":");
|
||||
return parts[0] + ":" + parts[1] + Uri.encode(":" + parts[2]);
|
||||
}
|
||||
|
||||
public static String denormalize(String uriString) {
|
||||
// an normalized uri example:
|
||||
// content://com.android.externalstorage.documents/tree/1707-3F0B%3Ajoplin%2Flocks%2F2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
|
||||
// denormalized:
|
||||
// content://com.android.externalstorage.documents/tree/1707-3F0B/Ajoplin/locks/2_2_fa4f9801e9a545a58f1a6c5d3a7cfded.json
|
||||
|
||||
return Uri.decode(normalize(uriString));
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,5 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
|
@ -0,0 +1,14 @@
|
|||
module.exports = {
|
||||
source: 'src',
|
||||
output: 'lib',
|
||||
targets: [
|
||||
'commonjs',
|
||||
'module',
|
||||
[
|
||||
'typescript',
|
||||
{
|
||||
project: 'tsconfig.build.json',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
// Sync object
|
||||
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||
const config = {
|
||||
preset: 'react-native',
|
||||
modulePathIgnorePatterns: [
|
||||
'<rootDir>/example/node_modules',
|
||||
'<rootDir>/lib/',
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = config;
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "@joplin/react-native-saf-x",
|
||||
"version": "2.9.0",
|
||||
"description": "a module to help work with scoped storages on android easily",
|
||||
"main": "src/index",
|
||||
"react-native": "src/index",
|
||||
"source": "src/index",
|
||||
"private": true,
|
||||
"files": [
|
||||
"src",
|
||||
"lib",
|
||||
"android",
|
||||
"ios",
|
||||
"cpp",
|
||||
"react-native-saf-x.podspec",
|
||||
"!lib/typescript/example",
|
||||
"!android/build",
|
||||
"!ios/build",
|
||||
"!**/__tests__",
|
||||
"!**/__fixtures__",
|
||||
"!**/__mocks__"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"linter-precommit": "yarn --cwd ../../ eslint --resolve-plugins-relative-to . --fix packages/react-native-saf-x/**/*.{js,ts,tsx}",
|
||||
"tsc": "tsc --project tsconfig.json"
|
||||
},
|
||||
"keywords": [
|
||||
"react-native",
|
||||
"android",
|
||||
"scoped-storage",
|
||||
"scoped",
|
||||
"storage",
|
||||
"SAF",
|
||||
"storage-access-framework"
|
||||
],
|
||||
"author": "Javad Mnjd (https://github.com/jd1378)",
|
||||
"license": "MIT",
|
||||
"homepage": "",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/react": "^16.9.55",
|
||||
"@types/react-native": "^0.64.4",
|
||||
"jest": "^26.6.3",
|
||||
"react": "17.0.2",
|
||||
"react-native": "0.66.1",
|
||||
"typescript": "^4.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
require "json"
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "react-native-saf-x"
|
||||
s.version = package["version"]
|
||||
s.summary = package["description"]
|
||||
s.homepage = package["homepage"]
|
||||
s.license = package["license"]
|
||||
s.authors = package["author"]
|
||||
|
||||
s.platforms = { :ios => "10.0" }
|
||||
s.source = { :git => "https://github.com/jd1378/react-native-saf-x.git", :tag => "#{s.version}" }
|
||||
|
||||
s.source_files = "ios/**/*.{h,m,mm}"
|
||||
|
||||
s.dependency "React-Core"
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
const os = require('os');
|
||||
const path = require('path');
|
||||
const child_process = require('child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
encoding: 'utf-8',
|
||||
};
|
||||
|
||||
if (os.type() === 'Windows_NT') {
|
||||
options.shell = true;
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
if (process.cwd() !== root || args.length) {
|
||||
// We're not in the root of the project, or additional arguments were passed
|
||||
// In this case, forward the command to `yarn`
|
||||
result = child_process.spawnSync('yarn', args, options);
|
||||
} else {
|
||||
// If `yarn` is run without arguments, perform bootstrap
|
||||
result = child_process.spawnSync('yarn', ['bootstrap'], options);
|
||||
}
|
||||
|
||||
process.exitCode = result.status;
|
|
@ -0,0 +1,264 @@
|
|||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
const LINKING_ERROR =
|
||||
`The package 'react-native-saf-x' doesn't seem to be linked. Make sure: \n\n${
|
||||
Platform.select({ ios: '- You have run \'pod install\'\n', default: '' })
|
||||
}- You rebuilt the app after installing the package\n` +
|
||||
'- You are not using Expo managed workflow\n';
|
||||
|
||||
let SafX: SafxInterface;
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
SafX = (
|
||||
NativeModules.SafX
|
||||
? NativeModules.SafX
|
||||
: new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error(LINKING_ERROR);
|
||||
},
|
||||
}
|
||||
)
|
||||
) as SafxInterface;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
SafX = {};
|
||||
}
|
||||
|
||||
export type Encoding = 'utf8' | 'base64' | 'ascii';
|
||||
|
||||
/** Native interface of the module */
|
||||
interface SafxInterface {
|
||||
openDocumentTree(persist: boolean): Promise<DocumentFileDetail | null>;
|
||||
openDocument(persist: boolean): Promise<DocumentFileDetail | null>;
|
||||
createDocument(
|
||||
data: String,
|
||||
encoding?: String,
|
||||
initialName?: string,
|
||||
mimeType?: String,
|
||||
): Promise<DocumentFileDetail | null>;
|
||||
hasPermission(uriString: string): Promise<boolean>;
|
||||
exists(uriString: string): Promise<boolean>;
|
||||
readFile(uriString: string, encoding?: Encoding): Promise<string>;
|
||||
writeFile(
|
||||
uriString: string,
|
||||
data: string,
|
||||
encoding?: Encoding,
|
||||
mimeType?: string,
|
||||
append?: boolean,
|
||||
): Promise<string>;
|
||||
createFile(uriString: string, mimeType?: String): Promise<DocumentFileDetail>;
|
||||
unlink(uriString: string): Promise<boolean>;
|
||||
mkdir(uriString: string): Promise<DocumentFileDetail>;
|
||||
rename(uriString: string, newName: string): Promise<boolean>;
|
||||
getPersistedUriPermissions(): Promise<Array<string>>;
|
||||
releasePersistableUriPermission(uriString: string): Promise<void>;
|
||||
listFiles(uriString: string): Promise<DocumentFileDetail[]>;
|
||||
stat(uriString: string): Promise<DocumentFileDetail>;
|
||||
transferFile(
|
||||
srcUri: string,
|
||||
destUri: string,
|
||||
replaceIfDestExist: boolean,
|
||||
copy: boolean,
|
||||
): Promise<DocumentFileDetail | null>;
|
||||
}
|
||||
|
||||
export type DocumentFileDetail = {
|
||||
uri: string;
|
||||
name: string;
|
||||
type: 'directory' | 'file';
|
||||
lastModified: number;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type FileOperationOptions = {
|
||||
/** Defaults to `'utf8'` */
|
||||
encoding?: Encoding;
|
||||
|
||||
/** Append data to the file. If not set file content will be overwritten. */
|
||||
append?: boolean;
|
||||
|
||||
/** mime type of the file being saved. Defaults to '\*\/\*' */
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
export type CreateDocumentOptions = FileOperationOptions & {
|
||||
/** initial display name when opening file picker */
|
||||
initialName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the Document Picker to select a folder. Read/Write Permission will be granted to the selected folder.
|
||||
* Returns an object of type `DocumentFileDetail` or `null` if user did not select a folder.
|
||||
*/
|
||||
export function openDocumentTree(persist: boolean) {
|
||||
return SafX.openDocumentTree(persist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Document Picker to select a file.
|
||||
* Returns an object of type `DocumentFileDetail` or `null` if user did not select a file.
|
||||
*/
|
||||
export function openDocument(persist: boolean) {
|
||||
return SafX.openDocument(persist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Document Picker to save a file.
|
||||
* Returns an object of type `DocumentFileDetail` or `null` if user did not select a file.
|
||||
*/
|
||||
export function createDocument(data: string, options?: CreateDocumentOptions) {
|
||||
if (!options) options = {};
|
||||
const { encoding, initialName, mimeType } = options;
|
||||
return SafX.createDocument(data, encoding, initialName, mimeType);
|
||||
}
|
||||
|
||||
/** Check if you have permission to access the uri. */
|
||||
export function hasPermission(uriString: string) {
|
||||
return SafX.hasPermission(uriString);
|
||||
}
|
||||
|
||||
/** Check if there's a document located at the given uri. */
|
||||
export function exists(uriString: string) {
|
||||
return SafX.exists(uriString);
|
||||
}
|
||||
|
||||
/** Read contents of the given uri. uri must point to a file. */
|
||||
export function readFile(
|
||||
uriString: string,
|
||||
options?: Pick<FileOperationOptions, 'encoding'>
|
||||
) {
|
||||
if (!options) options = {};
|
||||
const { encoding } = options;
|
||||
return SafX.readFile(uriString, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given data to the file at given uri.
|
||||
* Tries to create the file if does not already exist before writing to it.
|
||||
* Resolves with given uriString if successful.
|
||||
*/
|
||||
export function writeFile(
|
||||
uriString: string,
|
||||
data: string,
|
||||
options?: FileOperationOptions
|
||||
) {
|
||||
if (!options) options = {};
|
||||
const { encoding, append, mimeType } = options;
|
||||
return SafX.writeFile(uriString, data, encoding, mimeType, !!append);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty file at given uri.
|
||||
* Rejects if a file or directory exist at given uri.
|
||||
*/
|
||||
export function createFile(
|
||||
uriString: string,
|
||||
options?: Pick<FileOperationOptions, 'mimeType'>
|
||||
) {
|
||||
if (!options) options = {};
|
||||
const { mimeType } = options;
|
||||
return SafX.createFile(uriString, mimeType);
|
||||
}
|
||||
|
||||
//
|
||||
// Removes the file or directory at given uri.
|
||||
// Resolves with `true` if delete is successful, `false` otherwise.
|
||||
export function unlink(uriString: string) {
|
||||
return SafX.unlink(uriString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory at given uri.
|
||||
* Automatically creates folders in path if needed.
|
||||
* You can use it to create nested directories easily.
|
||||
* Rejects if it fails.
|
||||
*/
|
||||
export function mkdir(uriString: string) {
|
||||
return SafX.mkdir(uriString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames the document at given uri.
|
||||
* uri can be file or folder.
|
||||
* Resolves with `true` if successful and `false` otherwise.
|
||||
*/
|
||||
export function rename(uriString: string, newName: string) {
|
||||
return SafX.rename(uriString, newName);
|
||||
}
|
||||
|
||||
/** Returns a list of all the persisted uri permissions. */
|
||||
export function getPersistedUriPermissions() {
|
||||
return SafX.getPersistedUriPermissions();
|
||||
}
|
||||
|
||||
/** Remove a uri from persisted uri permissions list. */
|
||||
export function releasePersistableUriPermission(uriString: string) {
|
||||
return SafX.releasePersistableUriPermission(uriString);
|
||||
}
|
||||
|
||||
/** List all files and folders in a directory uri. */
|
||||
export function listFiles(uriString: string) {
|
||||
return SafX.listFiles(uriString);
|
||||
}
|
||||
|
||||
/** Get details for a file/directory at given uri. */
|
||||
export function stat(uriString: string) {
|
||||
return SafX.stat(uriString);
|
||||
}
|
||||
|
||||
type FileTransferOptions = {
|
||||
replaceIfDestinationExists?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy file from source uri to destination uri.
|
||||
* promise Rejects if destination already exists and `replaceIfDestinationExists` option is not set to true.
|
||||
* Does not support moving directories.
|
||||
*/
|
||||
export function copyFile(
|
||||
srcUri: string,
|
||||
destUri: string,
|
||||
options?: FileTransferOptions
|
||||
) {
|
||||
if (!options) options = {};
|
||||
const { replaceIfDestinationExists = false } = options;
|
||||
return SafX.transferFile(srcUri, destUri, replaceIfDestinationExists, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move file from source uri to destination uri.
|
||||
* promise Rejects if destination already exists and `replaceIfDestinationExists` option is not set to true.
|
||||
* Does not support moving directories.
|
||||
*/
|
||||
export function moveFile(
|
||||
srcUri: string,
|
||||
destUri: string,
|
||||
options?: FileTransferOptions
|
||||
) {
|
||||
if (!options) options = {};
|
||||
const { replaceIfDestinationExists = false } = options;
|
||||
return SafX.transferFile(srcUri, destUri, replaceIfDestinationExists, false);
|
||||
}
|
||||
|
||||
export default {
|
||||
openDocumentTree,
|
||||
openDocument,
|
||||
createDocument,
|
||||
hasPermission,
|
||||
exists,
|
||||
readFile,
|
||||
writeFile,
|
||||
createFile,
|
||||
unlink,
|
||||
mkdir,
|
||||
rename,
|
||||
getPersistedUriPermissions,
|
||||
releasePersistableUriPermission,
|
||||
listFiles,
|
||||
stat,
|
||||
copyFile,
|
||||
moveFile,
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
{
|
||||
"extends": "./tsconfig",
|
||||
"exclude": ["example"]
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules",
|
||||
"tests/support/**/*",
|
||||
"tests-build/**/*",
|
||||
"build/**/*",
|
||||
],
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/renderer",
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"description": "The Joplin note renderer, used the mobile and desktop application",
|
||||
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/renderer",
|
||||
"main": "index.js",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "yarn run build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
|
@ -23,8 +23,8 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.40.0",
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"@joplin/lib": "~2.9",
|
||||
"@joplin/renderer": "~2.9",
|
||||
"@koa/cors": "^3.1.0",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
@ -58,7 +58,7 @@
|
|||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.8",
|
||||
"@joplin/tools": "~2.9",
|
||||
"@rmp135/sql-ts": "^1.12.1",
|
||||
"@types/fs-extra": "^8.0.0",
|
||||
"@types/jest": "^26.0.15",
|
||||
|
|
|
@ -31,7 +31,7 @@ export default function(env: Env, models: Models, config: Config, services: Serv
|
|||
{
|
||||
id: TaskId.ProcessUserDeletions,
|
||||
description: taskIdToLabel(TaskId.ProcessUserDeletions),
|
||||
schedule: '0 */6 * * *',
|
||||
schedule: '10 * * * *',
|
||||
run: (_models: Models, services: Services) => services.userDeletion.runMaintenance(),
|
||||
},
|
||||
|
||||
|
|
|
@ -28,6 +28,10 @@ We offer a 14 days trial when the subscription starts so that you can evaluate t
|
|||
|
||||
Click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Manage subscription". Your subscription will be cancelled and you will not be charged on what would have been the next billing period. Please note that we do not cancel accounts over email as we cannot verify your identity, however we can provide assistance if there is an issue.
|
||||
|
||||
## Why was I charged?
|
||||
|
||||
If you have been charged and you didn't expect it the most likely explanation is that the trial ended, after 14 days, and an invoice was emitted. You may have cancelled the subscription after that date, but the invoice is still due. As with everything, cancelling a contract doesn't cancel existing invoices.
|
||||
|
||||
## Further information
|
||||
|
||||
- [Joplin Offical Website](https://joplinapp.org)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { readdir, stat, writeFile } from 'fs-extra';
|
||||
import { chdir, cwd } from 'process';
|
||||
import { execCommand2, rootDir } from './tool-utils';
|
||||
import yargs = require('yargs');
|
||||
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
||||
|
||||
interface LicenseInfo {
|
||||
licenses: string;
|
||||
|
@ -33,14 +35,22 @@ const enforceString = (line: any): string => {
|
|||
};
|
||||
|
||||
async function main() {
|
||||
const argv = await yargs.argv;
|
||||
const pathToCheck = rtrimSlashes(argv._.length ? argv._[0].toString() : '');
|
||||
|
||||
const directories: string[] = [];
|
||||
const packageItems = await readdir(`${rootDir}/packages`);
|
||||
for (const item of packageItems) {
|
||||
const fullPath = `${rootDir}/packages/${item}`;
|
||||
if (pathToCheck && !fullPath.endsWith(pathToCheck)) continue;
|
||||
|
||||
const info = await stat(fullPath);
|
||||
if (info.isDirectory()) directories.push(fullPath);
|
||||
}
|
||||
directories.push(rootDir);
|
||||
|
||||
if (!pathToCheck || rootDir.endsWith(pathToCheck)) {
|
||||
directories.push(rootDir);
|
||||
}
|
||||
|
||||
let licenses: Record<string, LicenseInfo> = {};
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -15,6 +15,8 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 2.4.2\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.tsx:560
|
||||
msgid "- Camera: to allow taking a picture and attaching it to a note."
|
||||
|
@ -299,9 +301,8 @@ msgid "Also displays unset and hidden config variables."
|
|||
msgstr "Visar även inte inställda och dolda konfigurationsvariabler."
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.tsx:203
|
||||
#, fuzzy
|
||||
msgid "Also publish linked notes"
|
||||
msgstr "Sluta publisera anteckningen"
|
||||
msgstr "Publicera även länkade anteckningar"
|
||||
|
||||
#: packages/lib/models/Setting.ts:713
|
||||
msgid "Always"
|
||||
|
@ -874,18 +875,16 @@ msgstr ""
|
|||
"avbryter. Försök igen när du är ansluten till internet."
|
||||
|
||||
#: packages/app-desktop/gui/PromptDialog.min.js:235
|
||||
#, fuzzy
|
||||
msgid "Create"
|
||||
msgstr "Skapad"
|
||||
msgstr "Skapa"
|
||||
|
||||
#: packages/app-mobile/components/note-list.js:101
|
||||
msgid "Create a notebook"
|
||||
msgstr "Skapar en anteckningsbok"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/addProfile.ts:9
|
||||
#, fuzzy
|
||||
msgid "Create new profile..."
|
||||
msgstr "Skapar en ny anteckning."
|
||||
msgstr "Skapa ny profil..."
|
||||
|
||||
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:161
|
||||
msgid "Create notebook"
|
||||
|
@ -1334,7 +1333,7 @@ msgstr "Redigera anteckningsbok"
|
|||
|
||||
#: packages/app-desktop/commands/editProfileConfig.ts:9
|
||||
msgid "Edit profile configuration..."
|
||||
msgstr ""
|
||||
msgstr "Redigera profilkonfiguration..."
|
||||
|
||||
#: packages/app-desktop/gui/NoteContentPropertiesDialog.tsx:138
|
||||
#: packages/lib/models/Setting.ts:876 packages/lib/models/Setting.ts:877
|
||||
|
@ -1364,9 +1363,8 @@ msgstr "Redigerarens monospace teckensnittsfamilj"
|
|||
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:100
|
||||
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:96
|
||||
#, fuzzy
|
||||
msgid "Editor: %s"
|
||||
msgstr "Redigerare"
|
||||
msgstr "Redigerare: %s"
|
||||
|
||||
#: packages/app-cli/app/command-ls.js:31
|
||||
msgid "Either \"text\" or \"json\""
|
||||
|
@ -1680,9 +1678,8 @@ msgid "File system"
|
|||
msgstr "Filsystem"
|
||||
|
||||
#: packages/app-mobile/components/screens/NoteTagsDialog.js:190
|
||||
#, fuzzy
|
||||
msgid "Filter tags"
|
||||
msgstr "Nya taggar:"
|
||||
msgstr "Filtrera taggar"
|
||||
|
||||
#: packages/app-desktop/gui/ExtensionBadge.min.js:10
|
||||
msgid "Firefox Extension"
|
||||
|
@ -2807,6 +2804,8 @@ msgid ""
|
|||
"Please click on \"%s\" to proceed, or set the passwords in the \"%s\" list "
|
||||
"below."
|
||||
msgstr ""
|
||||
"Klicka på \"%s\" för att fortsätta, eller ställ in lösenorden i listan \"%s"
|
||||
"\" nedan."
|
||||
|
||||
#: packages/lib/components/EncryptionConfigScreen/utils.ts:65
|
||||
msgid ""
|
||||
|
@ -2965,9 +2964,8 @@ msgid "Profile"
|
|||
msgstr "Profil"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/addProfile.ts:17
|
||||
#, fuzzy
|
||||
msgid "Profile name:"
|
||||
msgstr "Profil"
|
||||
msgstr "Profilnamn:"
|
||||
|
||||
#: packages/lib/versionInfo.ts:26
|
||||
msgid "Profile Version: %s"
|
||||
|
@ -3195,9 +3193,8 @@ msgstr "Spara alarm"
|
|||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:100
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:93
|
||||
#, fuzzy
|
||||
msgid "Save as %s"
|
||||
msgstr "Spara som..."
|
||||
msgstr "Spara som %s"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:80
|
||||
msgid "Save as..."
|
||||
|
@ -3568,9 +3565,8 @@ msgid "Switch between note and to-do type"
|
|||
msgstr "Växla mellan antecknings- och att-göra-typ"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:437
|
||||
#, fuzzy
|
||||
msgid "Switch profile"
|
||||
msgstr "Exportera profil"
|
||||
msgstr "Byt profil"
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.ts:105
|
||||
msgid "Switch to note type"
|
||||
|
@ -3579,9 +3575,8 @@ msgstr "Byt till anteckningstyp"
|
|||
#: packages/app-desktop/commands/switchProfile1.ts:7
|
||||
#: packages/app-desktop/commands/switchProfile2.ts:7
|
||||
#: packages/app-desktop/commands/switchProfile3.ts:7
|
||||
#, fuzzy
|
||||
msgid "Switch to profile %d"
|
||||
msgstr "Byt till anteckningstyp"
|
||||
msgstr "Byt till profilen %d"
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.ts:114
|
||||
msgid "Switch to to-do type"
|
||||
|
@ -3678,7 +3673,7 @@ msgstr "Tabloid"
|
|||
|
||||
#: packages/app-mobile/components/screens/NoteTagsDialog.js:179
|
||||
msgid "tag1, tag2, ..."
|
||||
msgstr ""
|
||||
msgstr "tagg1, tagg2, ..."
|
||||
|
||||
#: packages/app-cli/app/command-import.js:52
|
||||
#: packages/app-desktop/gui/ImportScreen.min.js:73
|
||||
|
@ -3719,7 +3714,7 @@ msgstr "Appen kommer nu att stängas. Starta om den för att slutföra processen
|
|||
#: packages/app-desktop/app.ts:332
|
||||
msgid ""
|
||||
"The application did not close properly. Would you like to start in safe mode?"
|
||||
msgstr ""
|
||||
msgstr "Programmet stängdes inte ordentligt. Vill du starta i säkert läge?"
|
||||
|
||||
#: packages/lib/onedrive-api-node-utils.js:86
|
||||
msgid ""
|
||||
|
@ -4568,9 +4563,8 @@ msgid "Your data is going to be re-encrypted and synced again."
|
|||
msgstr "Dina data kommer att omkrypteras och synkroniseras igen."
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:271
|
||||
#, fuzzy
|
||||
msgid "Your password is needed to decrypt some of your data."
|
||||
msgstr "Ditt huvudlösenord behövs för att dekryptera några av dina data."
|
||||
msgstr "Ditt lösenord behövs för att dekryptera en del av dina data."
|
||||
|
||||
#: packages/app-cli/app/command-sync.ts:242
|
||||
msgid ""
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/tools",
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"description": "Various tools for Joplin",
|
||||
"main": "index.js",
|
||||
"author": "Laurent Cozic",
|
||||
|
@ -20,8 +20,8 @@
|
|||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joplin/lib": "^2.8.1",
|
||||
"@joplin/renderer": "^2.8.1",
|
||||
"@joplin/lib": "~2.9",
|
||||
"@joplin/renderer": "~2.9",
|
||||
"@types/node-fetch": "1.6.9",
|
||||
"@types/yargs": "16.0.3",
|
||||
"dayjs": "^1.10.7",
|
||||
|
|
|
@ -32,15 +32,19 @@ async function updatePackageVersion(packageFilePath: string, majorMinorVersion:
|
|||
}
|
||||
|
||||
if (options.updateDependenciesVersion) {
|
||||
for (const [name] of Object.entries(content.dependencies)) {
|
||||
if (isJoplinPackage(name)) {
|
||||
content.dependencies[name] = `~${majorMinorVersion}`;
|
||||
if (content.dependencies) {
|
||||
for (const [name] of Object.entries(content.dependencies)) {
|
||||
if (isJoplinPackage(name)) {
|
||||
content.dependencies[name] = `~${majorMinorVersion}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name] of Object.entries(content.devDependencies)) {
|
||||
if (isJoplinPackage(name)) {
|
||||
content.devDependencies[name] = `~${majorMinorVersion}`;
|
||||
if (content.devDependencies) {
|
||||
for (const [name] of Object.entries(content.devDependencies)) {
|
||||
if (isJoplinPackage(name)) {
|
||||
content.devDependencies[name] = `~${majorMinorVersion}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -125,6 +129,7 @@ async function main() {
|
|||
await updatePackageVersion(`${rootDir}/packages/app-mobile/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/generator-joplin/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/htmlpack/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/react-native-saf-x/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/lib/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/plugin-repo-cli/package.json`, majorMinorVersion, options);
|
||||
await updatePackageVersion(`${rootDir}/packages/renderer/package.json`, majorMinorVersion, options);
|
||||
|
@ -138,7 +143,7 @@ async function main() {
|
|||
await updatePluginGeneratorTemplateVersion(`${rootDir}/packages/generator-joplin/generators/app/templates/src/manifest.json`, majorMinorVersion);
|
||||
}
|
||||
|
||||
console.info('Version numbers have been updated. Consider running `yarn i` to update the lock files');
|
||||
console.info('Version numbers have been updated. Consider running `yarn install` to update the lock files');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
|
|
@ -1,5 +1,17 @@
|
|||
# Joplin changelog
|
||||
|
||||
## [v2.9.1](https://github.com/laurent22/joplin/releases/tag/v2.9.1) (Pre-release) - 2022-07-11T09:59:32Z
|
||||
|
||||
- New: Plugins: Added joplin.versionInfo method ([3b35ab6](https://github.com/laurent22/joplin/commit/3b35ab6))
|
||||
- Improved: Add support for proxy ([#6537](https://github.com/laurent22/joplin/issues/6537)) ([#164](https://github.com/laurent22/joplin/issues/164) by Jason Williams)
|
||||
- Improved: Checkbox don't function while checkbox format button hidden from toolbar ([#6567](https://github.com/laurent22/joplin/issues/6567)) ([#6172](https://github.com/laurent22/joplin/issues/6172) by [@SFulpius](https://github.com/SFulpius))
|
||||
- Improved: Update to Electron 18 ([#6496](https://github.com/laurent22/joplin/issues/6496) by [@alexmo1997](https://github.com/alexmo1997))
|
||||
- Fixed: Allow styling note list items using custom CSS ([#6542](https://github.com/laurent22/joplin/issues/6542)) ([#5178](https://github.com/laurent22/joplin/issues/5178) by Kenichi Kobayashi)
|
||||
- Fixed: App can crash with certain combinations of plugins ([#6506](https://github.com/laurent22/joplin/issues/6506))
|
||||
- Fixed: Search field focus is stolen on layout change ([#6514](https://github.com/laurent22/joplin/issues/6514))
|
||||
- Fixed: Search field would not clear as expected ([#6557](https://github.com/laurent22/joplin/issues/6557))
|
||||
- Security: Fixes XSS in GotoAnything dialog ([e797ebb](https://github.com/laurent22/joplin/commit/e797ebb))
|
||||
|
||||
## [v2.8.8](https://github.com/laurent22/joplin/releases/tag/v2.8.8) - 2022-05-17T14:48:06Z
|
||||
|
||||
- Improved: Remove plugin backoff handler for now ([7ec3a7b](https://github.com/laurent22/joplin/commit/7ec3a7b))
|
||||
|
@ -512,7 +524,7 @@ Attention: The default font size has been changed in the Markdown editor. You ca
|
|||
## [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) - 2021-05-10T11:58:14Z
|
||||
|
||||
- Fixed: Fixed pasting of text and images from Word on Windows ([#4916](https://github.com/laurent22/joplin/issues/4916))
|
||||
- Security: Filter out NOSCRIPT tags that could be used to cause an XSS (found by [Jubair Rehman Yousafzai](https://twitter.com/jubairfolder)) ([9c20d59](https://github.com/laurent22/joplin/commit/9c20d59))
|
||||
- Security: Filter out NOSCRIPT tags that could be used to cause an XSS (CVE-2021-33295) (found by [Jubair Rehman Yousafzai](https://twitter.com/newfolderj)) ([9c20d59](https://github.com/laurent22/joplin/commit/9c20d59))
|
||||
|
||||
## [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (Pre-release) - 2021-05-09T18:05:05Z
|
||||
|
||||
|
|
|
@ -20,6 +20,12 @@ You can pass [arguments](https://github.com/laurent22/joplin/blob/dev/Joplin_ins
|
|||
|
||||
<pre><code style="word-break: break-all">wget -O - https://raw.githubusercontent.com/laurent22/joplin/dev/Joplin_install_and_update.sh | bash -s -- --argument1 --argument2</code></pre>
|
||||
|
||||
## Desktop application will not launch on Linux
|
||||
|
||||
If you downloaded the AppImage directly and therefore did not install via the recommended script then it may not be currently allowed to execute and needs to have these permissions set manually (see [AppImage User Guide](https://docs.appimage.org/introduction/quickstart.html#how-to-run-an-AppImage)).
|
||||
|
||||
If execution permissions are correct and it still does not launch then your system may not have the `libfuse2` library that AppImages require to run. This library requirement is inherent to the AppImage format and not Joplin specifically. For more info see [this forum thread](https://discourse.joplinapp.org/t/appimage-incompatibility-in-ubuntu-22-04/25173) which has further detail on the issue and an [Ubuntu specific fix](https://discourse.joplinapp.org/t/appimage-incompatibility-in-ubuntu-22-04/25173/12).
|
||||
|
||||
## How can I edit my note in an external text editor?
|
||||
|
||||
The editor command (may include arguments) defines which editor will be used to open a note. If none is provided it will try to auto-detect the default editor. If this does nothing or you want to change it for Joplin, you need to configure it in the Preferences -> Text editor command.
|
||||
|
|
316
readme/stats.md
316
readme/stats.md
|
@ -1,14 +1,14 @@
|
|||
---
|
||||
updated: 2022-05-19T06:17:18Z
|
||||
updated: 2022-06-18T06:18:07Z
|
||||
---
|
||||
|
||||
# Joplin statistics
|
||||
|
||||
| Name | Value |
|
||||
| ----- | ----- |
|
||||
| Total Windows downloads | 2,431,848 |
|
||||
| Total macOs downloads | 955,091 |
|
||||
| Total Linux downloads | 764,024 |
|
||||
| Total Windows downloads | 2,449,909 |
|
||||
| Total macOs downloads | 962,603 |
|
||||
| Total Linux downloads | 769,002 |
|
||||
| Windows % | 59% |
|
||||
| macOS % | 23% |
|
||||
| Linux % | 18% |
|
||||
|
@ -17,174 +17,174 @@ updated: 2022-05-19T06:17:18Z
|
|||
|
||||
| Version | Date | Windows | macOS | Linux | Total |
|
||||
| ----- | ----- | ----- | ----- | ----- | ----- |
|
||||
| [v2.8.8](https://github.com/laurent22/joplin/releases/tag/v2.8.8) (p) | 2022-05-17T14:48:06Z | 608 | 152 | 85 | 845 |
|
||||
| [v2.8.7](https://github.com/laurent22/joplin/releases/tag/v2.8.7) (p) | 2022-05-06T11:34:27Z | 1,349 | 313 | 345 | 2,007 |
|
||||
| [v2.8.4](https://github.com/laurent22/joplin/releases/tag/v2.8.4) (p) | 2022-04-19T18:00:09Z | 1,456 | 520 | 298 | 2,274 |
|
||||
| [v2.8.2](https://github.com/laurent22/joplin/releases/tag/v2.8.2) (p) | 2022-04-14T11:35:45Z | 1,017 | 244 | 236 | 1,497 |
|
||||
| [v2.7.15](https://github.com/laurent22/joplin/releases/tag/v2.7.15) | 2022-03-17T13:03:23Z | 125,891 | 48,546 | 40,404 | 214,841 |
|
||||
| [v2.7.14](https://github.com/laurent22/joplin/releases/tag/v2.7.14) | 2022-02-27T11:30:53Z | 31,007 | 16,719 | 4,748 | 52,474 |
|
||||
| [v2.7.13](https://github.com/laurent22/joplin/releases/tag/v2.7.13) | 2022-02-24T17:42:12Z | 51,702 | 25,668 | 11,663 | 89,033 |
|
||||
| [v2.7.12](https://github.com/laurent22/joplin/releases/tag/v2.7.12) (p) | 2022-02-14T15:06:14Z | 1,989 | 441 | 434 | 2,864 |
|
||||
| [v2.7.11](https://github.com/laurent22/joplin/releases/tag/v2.7.11) (p) | 2022-02-12T13:00:02Z | 1,173 | 177 | 140 | 1,490 |
|
||||
| [v2.7.10](https://github.com/laurent22/joplin/releases/tag/v2.7.10) (p) | 2022-02-11T18:19:09Z | 799 | 103 | 61 | 963 |
|
||||
| [v2.7.8](https://github.com/laurent22/joplin/releases/tag/v2.7.8) (p) | 2022-01-19T09:35:27Z | 3,089 | 749 | 797 | 4,635 |
|
||||
| [v2.7.7](https://github.com/laurent22/joplin/releases/tag/v2.7.7) (p) | 2022-01-18T14:05:07Z | 1,026 | 136 | 111 | 1,273 |
|
||||
| [v2.7.6](https://github.com/laurent22/joplin/releases/tag/v2.7.6) (p) | 2022-01-17T17:08:28Z | 1,094 | 161 | 91 | 1,346 |
|
||||
| [v2.6.10](https://github.com/laurent22/joplin/releases/tag/v2.6.10) | 2021-12-19T11:31:16Z | 131,179 | 51,100 | 49,043 | 231,322 |
|
||||
| [v2.6.9](https://github.com/laurent22/joplin/releases/tag/v2.6.9) | 2021-12-17T11:57:32Z | 16,076 | 9,454 | 3,145 | 28,675 |
|
||||
| [v2.6.7](https://github.com/laurent22/joplin/releases/tag/v2.6.7) (p) | 2021-12-16T10:47:23Z | 1,218 | 142 | 78 | 1,438 |
|
||||
| [v2.6.6](https://github.com/laurent22/joplin/releases/tag/v2.6.6) (p) | 2021-12-13T12:31:43Z | 1,279 | 232 | 144 | 1,655 |
|
||||
| [v2.6.5](https://github.com/laurent22/joplin/releases/tag/v2.6.5) (p) | 2021-12-13T10:07:04Z | 580 | 32 | 13 | 625 |
|
||||
| [v2.6.4](https://github.com/laurent22/joplin/releases/tag/v2.6.4) (p) | 2021-12-09T19:53:43Z | 1,355 | 267 | 177 | 1,799 |
|
||||
| [v2.6.2](https://github.com/laurent22/joplin/releases/tag/v2.6.2) (p) | 2021-11-18T12:19:12Z | 3,023 | 774 | 673 | 4,470 |
|
||||
| [v2.5.12](https://github.com/laurent22/joplin/releases/tag/v2.5.12) | 2021-11-08T11:07:11Z | 78,868 | 32,421 | 25,160 | 136,449 |
|
||||
| [v2.5.10](https://github.com/laurent22/joplin/releases/tag/v2.5.10) | 2021-11-01T08:22:42Z | 43,970 | 18,975 | 10,029 | 72,974 |
|
||||
| [v2.5.8](https://github.com/laurent22/joplin/releases/tag/v2.5.8) | 2021-10-31T11:38:03Z | 13,005 | 6,535 | 2,286 | 21,826 |
|
||||
| [v2.5.7](https://github.com/laurent22/joplin/releases/tag/v2.5.7) (p) | 2021-10-29T14:47:33Z | 947 | 188 | 138 | 1,273 |
|
||||
| [v2.5.6](https://github.com/laurent22/joplin/releases/tag/v2.5.6) (p) | 2021-10-28T22:03:09Z | 903 | 156 | 88 | 1,147 |
|
||||
| [v2.5.4](https://github.com/laurent22/joplin/releases/tag/v2.5.4) (p) | 2021-10-19T10:10:54Z | 2,204 | 552 | 551 | 3,307 |
|
||||
| [v2.4.12](https://github.com/laurent22/joplin/releases/tag/v2.4.12) | 2021-10-13T17:24:34Z | 43,545 | 19,934 | 9,749 | 73,228 |
|
||||
| [v2.4.9](https://github.com/laurent22/joplin/releases/tag/v2.4.9) | 2021-09-29T19:08:58Z | 56,000 | 23,190 | 15,807 | 94,997 |
|
||||
| [v2.4.8](https://github.com/laurent22/joplin/releases/tag/v2.4.8) (p) | 2021-09-22T19:01:46Z | 7,249 | 1,753 | 511 | 9,513 |
|
||||
| [v2.4.6](https://github.com/laurent22/joplin/releases/tag/v2.4.6) (p) | 2021-09-09T18:57:17Z | 1,977 | 442 | 498 | 2,917 |
|
||||
| [v2.4.9](https://github.com/laurent22/joplin/releases/tag/v2.4.9) | 2021-09-29T19:08:58Z | 56,000 | 23,190 | 15,807 | 94,997 |
|
||||
| [v2.4.8](https://github.com/laurent22/joplin/releases/tag/v2.4.8) (p) | 2021-09-22T19:01:46Z | 7,249 | 1,753 | 511 | 9,513 |
|
||||
| [v2.4.7](https://github.com/laurent22/joplin/releases/tag/v2.4.7) (p) | 2021-09-19T12:53:22Z | 1,296 | 235 | 185 | 1,716 |
|
||||
| [v2.4.6](https://github.com/laurent22/joplin/releases/tag/v2.4.6) (p) | 2021-09-09T18:57:17Z | 1,977 | 442 | 498 | 2,917 |
|
||||
| [v2.4.5](https://github.com/laurent22/joplin/releases/tag/v2.4.5) (p) | 2021-09-06T18:03:28Z | 1,363 | 251 | 207 | 1,821 |
|
||||
| [v2.4.4](https://github.com/laurent22/joplin/releases/tag/v2.4.4) (p) | 2021-08-30T16:02:51Z | 1,637 | 360 | 341 | 2,338 |
|
||||
| [v2.4.3](https://github.com/laurent22/joplin/releases/tag/v2.4.3) (p) | 2021-08-28T15:27:32Z | 1,154 | 186 | 153 | 1,493 |
|
||||
| [v2.4.2](https://github.com/laurent22/joplin/releases/tag/v2.4.2) (p) | 2021-08-27T17:13:21Z | 859 | 131 | 75 | 1,065 |
|
||||
| [v2.4.1](https://github.com/laurent22/joplin/releases/tag/v2.4.1) (p) | 2021-08-21T11:52:30Z | 1,747 | 355 | 318 | 2,420 |
|
||||
| [v2.3.5](https://github.com/laurent22/joplin/releases/tag/v2.3.5) | 2021-08-17T06:43:30Z | 81,236 | 31,355 | 33,063 | 145,654 |
|
||||
| [v2.3.3](https://github.com/laurent22/joplin/releases/tag/v2.3.3) | 2021-08-14T09:19:40Z | 14,684 | 6,856 | 4,036 | 25,576 |
|
||||
| [v2.2.7](https://github.com/laurent22/joplin/releases/tag/v2.2.7) | 2021-08-11T11:03:26Z | 15,093 | 7,489 | 2,572 | 25,154 |
|
||||
| [v2.2.6](https://github.com/laurent22/joplin/releases/tag/v2.2.6) (p) | 2021-08-09T19:29:20Z | 7,850 | 4,603 | 940 | 13,393 |
|
||||
| [v2.2.5](https://github.com/laurent22/joplin/releases/tag/v2.2.5) (p) | 2021-08-07T10:35:24Z | 1,458 | 259 | 189 | 1,906 |
|
||||
| [v2.2.4](https://github.com/laurent22/joplin/releases/tag/v2.2.4) (p) | 2021-08-05T16:42:48Z | 1,173 | 191 | 116 | 1,480 |
|
||||
| [v2.2.2](https://github.com/laurent22/joplin/releases/tag/v2.2.2) (p) | 2021-07-19T10:28:35Z | 3,075 | 719 | 629 | 4,423 |
|
||||
| [v2.1.9](https://github.com/laurent22/joplin/releases/tag/v2.1.9) | 2021-07-19T10:28:43Z | 46,166 | 18,767 | 16,686 | 81,619 |
|
||||
| [v2.2.1](https://github.com/laurent22/joplin/releases/tag/v2.2.1) (p) | 2021-07-09T17:38:25Z | 2,512 | 399 | 375 | 3,286 |
|
||||
| [v2.1.8](https://github.com/laurent22/joplin/releases/tag/v2.1.8) | 2021-07-03T08:25:16Z | 30,077 | 12,166 | 12,704 | 54,947 |
|
||||
| [v2.1.7](https://github.com/laurent22/joplin/releases/tag/v2.1.7) | 2021-06-26T19:48:55Z | 13,910 | 6,382 | 3,607 | 23,899 |
|
||||
| [v2.1.5](https://github.com/laurent22/joplin/releases/tag/v2.1.5) (p) | 2021-06-23T15:08:52Z | 1,494 | 229 | 179 | 1,902 |
|
||||
| [v2.1.3](https://github.com/laurent22/joplin/releases/tag/v2.1.3) (p) | 2021-06-19T16:32:51Z | 1,644 | 290 | 196 | 2,130 |
|
||||
| [v2.0.11](https://github.com/laurent22/joplin/releases/tag/v2.0.11) | 2021-06-16T17:55:49Z | 23,232 | 9,226 | 9,802 | 42,260 |
|
||||
| [v2.0.10](https://github.com/laurent22/joplin/releases/tag/v2.0.10) | 2021-06-16T07:58:29Z | 2,484 | 913 | 368 | 3,765 |
|
||||
| [v2.0.9](https://github.com/laurent22/joplin/releases/tag/v2.0.9) (p) | 2021-06-12T09:30:30Z | 1,551 | 288 | 874 | 2,713 |
|
||||
| [v2.0.8](https://github.com/laurent22/joplin/releases/tag/v2.0.8) (p) | 2021-06-10T16:15:08Z | 1,150 | 224 | 571 | 1,945 |
|
||||
| [v2.0.4](https://github.com/laurent22/joplin/releases/tag/v2.0.4) (p) | 2021-06-02T12:54:17Z | 1,479 | 387 | 371 | 2,237 |
|
||||
| [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 2,686 | 485 | 1,662 | 4,833 |
|
||||
| [v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (p) | 2021-05-15T13:22:58Z | 856 | 266 | 1,017 | 2,139 |
|
||||
| [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 37,921 | 16,255 | 19,387 | 73,563 |
|
||||
| [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 1,190 | 132 | 452 | 1,774 |
|
||||
| [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 1,957 | 303 | 934 | 3,194 |
|
||||
| [v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 2,281 | 433 | 1,282 | 3,996 |
|
||||
| [v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 3,493 | 823 | 2,447 | 6,763 |
|
||||
| [v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 116,306 | 42,769 | 64,279 | 223,354 |
|
||||
| [v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 14,089 | 4,853 | 4,480 | 23,422 |
|
||||
| [v2.8.8](https://github.com/laurent22/joplin/releases/tag/v2.8.8) | 2022-05-17T14:48:06Z | 48,622 | 23,748 | 9,980 | 82,350 |
|
||||
| [v2.8.7](https://github.com/laurent22/joplin/releases/tag/v2.8.7) (p) | 2022-05-06T11:34:27Z | 1,505 | 321 | 359 | 2,185 |
|
||||
| [v2.8.6](https://github.com/laurent22/joplin/releases/tag/v2.8.6) (p) | 2022-05-03T10:08:25Z | 1,227 | 375 | 301 | 1,903 |
|
||||
| [v2.8.5](https://github.com/laurent22/joplin/releases/tag/v2.8.5) (p) | 2022-04-27T13:51:50Z | 1,175 | 330 | 317 | 1,822 |
|
||||
| [v2.8.4](https://github.com/laurent22/joplin/releases/tag/v2.8.4) (p) | 2022-04-19T18:00:09Z | 1,610 | 524 | 298 | 2,432 |
|
||||
| [v2.8.2](https://github.com/laurent22/joplin/releases/tag/v2.8.2) (p) | 2022-04-14T11:35:45Z | 1,137 | 245 | 236 | 1,618 |
|
||||
| [v2.7.15](https://github.com/laurent22/joplin/releases/tag/v2.7.15) | 2022-03-17T13:03:23Z | 150,421 | 56,095 | 50,564 | 257,080 |
|
||||
| [v2.7.14](https://github.com/laurent22/joplin/releases/tag/v2.7.14) | 2022-02-27T11:30:53Z | 31,169 | 16,723 | 4,754 | 52,646 |
|
||||
| [v2.7.13](https://github.com/laurent22/joplin/releases/tag/v2.7.13) | 2022-02-24T17:42:12Z | 51,840 | 25,672 | 11,665 | 89,177 |
|
||||
| [v2.7.12](https://github.com/laurent22/joplin/releases/tag/v2.7.12) (p) | 2022-02-14T15:06:14Z | 2,106 | 441 | 434 | 2,981 |
|
||||
| [v2.7.11](https://github.com/laurent22/joplin/releases/tag/v2.7.11) (p) | 2022-02-12T13:00:02Z | 1,207 | 177 | 140 | 1,524 |
|
||||
| [v2.7.10](https://github.com/laurent22/joplin/releases/tag/v2.7.10) (p) | 2022-02-11T18:19:09Z | 831 | 103 | 61 | 995 |
|
||||
| [v2.7.8](https://github.com/laurent22/joplin/releases/tag/v2.7.8) (p) | 2022-01-19T09:35:27Z | 3,122 | 749 | 797 | 4,668 |
|
||||
| [v2.7.7](https://github.com/laurent22/joplin/releases/tag/v2.7.7) (p) | 2022-01-18T14:05:07Z | 1,061 | 136 | 111 | 1,308 |
|
||||
| [v2.7.6](https://github.com/laurent22/joplin/releases/tag/v2.7.6) (p) | 2022-01-17T17:08:28Z | 1,128 | 161 | 91 | 1,380 |
|
||||
| [v2.6.10](https://github.com/laurent22/joplin/releases/tag/v2.6.10) | 2021-12-19T11:31:16Z | 131,284 | 51,115 | 49,096 | 231,495 |
|
||||
| [v2.6.9](https://github.com/laurent22/joplin/releases/tag/v2.6.9) | 2021-12-17T11:57:32Z | 16,122 | 9,458 | 3,148 | 28,728 |
|
||||
| [v2.6.7](https://github.com/laurent22/joplin/releases/tag/v2.6.7) (p) | 2021-12-16T10:47:23Z | 1,252 | 143 | 79 | 1,474 |
|
||||
| [v2.6.6](https://github.com/laurent22/joplin/releases/tag/v2.6.6) (p) | 2021-12-13T12:31:43Z | 1,313 | 235 | 145 | 1,693 |
|
||||
| [v2.6.5](https://github.com/laurent22/joplin/releases/tag/v2.6.5) (p) | 2021-12-13T10:07:04Z | 612 | 32 | 13 | 657 |
|
||||
| [v2.6.4](https://github.com/laurent22/joplin/releases/tag/v2.6.4) (p) | 2021-12-09T19:53:43Z | 1,389 | 268 | 178 | 1,835 |
|
||||
| [v2.6.2](https://github.com/laurent22/joplin/releases/tag/v2.6.2) (p) | 2021-11-18T12:19:12Z | 3,056 | 774 | 673 | 4,503 |
|
||||
| [v2.5.12](https://github.com/laurent22/joplin/releases/tag/v2.5.12) | 2021-11-08T11:07:11Z | 78,952 | 32,428 | 25,162 | 136,542 |
|
||||
| [v2.5.10](https://github.com/laurent22/joplin/releases/tag/v2.5.10) | 2021-11-01T08:22:42Z | 44,034 | 18,978 | 10,032 | 73,044 |
|
||||
| [v2.5.8](https://github.com/laurent22/joplin/releases/tag/v2.5.8) | 2021-10-31T11:38:03Z | 13,057 | 6,536 | 2,286 | 21,879 |
|
||||
| [v2.5.7](https://github.com/laurent22/joplin/releases/tag/v2.5.7) (p) | 2021-10-29T14:47:33Z | 980 | 188 | 142 | 1,310 |
|
||||
| [v2.5.6](https://github.com/laurent22/joplin/releases/tag/v2.5.6) (p) | 2021-10-28T22:03:09Z | 937 | 156 | 88 | 1,181 |
|
||||
| [v2.5.4](https://github.com/laurent22/joplin/releases/tag/v2.5.4) (p) | 2021-10-19T10:10:54Z | 2,244 | 552 | 551 | 3,347 |
|
||||
| [v2.4.12](https://github.com/laurent22/joplin/releases/tag/v2.4.12) | 2021-10-13T17:24:34Z | 43,624 | 19,938 | 9,753 | 73,315 |
|
||||
| [v2.5.1](https://github.com/laurent22/joplin/releases/tag/v2.5.1) (p) | 2021-10-02T09:51:58Z | 3,369 | 883 | 922 | 5,174 |
|
||||
| [v2.4.9](https://github.com/laurent22/joplin/releases/tag/v2.4.9) | 2021-09-29T19:08:58Z | 56,068 | 23,192 | 15,810 | 95,070 |
|
||||
| [v2.4.8](https://github.com/laurent22/joplin/releases/tag/v2.4.8) (p) | 2021-09-22T19:01:46Z | 7,281 | 1,753 | 511 | 9,545 |
|
||||
| [v2.4.7](https://github.com/laurent22/joplin/releases/tag/v2.4.7) (p) | 2021-09-19T12:53:22Z | 1,328 | 236 | 185 | 1,749 |
|
||||
| [v2.4.6](https://github.com/laurent22/joplin/releases/tag/v2.4.6) (p) | 2021-09-09T18:57:17Z | 2,018 | 442 | 498 | 2,958 |
|
||||
| [v2.4.5](https://github.com/laurent22/joplin/releases/tag/v2.4.5) (p) | 2021-09-06T18:03:28Z | 1,395 | 252 | 207 | 1,854 |
|
||||
| [v2.4.4](https://github.com/laurent22/joplin/releases/tag/v2.4.4) (p) | 2021-08-30T16:02:51Z | 1,669 | 360 | 341 | 2,370 |
|
||||
| [v2.4.3](https://github.com/laurent22/joplin/releases/tag/v2.4.3) (p) | 2021-08-28T15:27:32Z | 1,186 | 186 | 154 | 1,526 |
|
||||
| [v2.4.2](https://github.com/laurent22/joplin/releases/tag/v2.4.2) (p) | 2021-08-27T17:13:21Z | 891 | 131 | 75 | 1,097 |
|
||||
| [v2.4.1](https://github.com/laurent22/joplin/releases/tag/v2.4.1) (p) | 2021-08-21T11:52:30Z | 1,780 | 355 | 318 | 2,453 |
|
||||
| [v2.3.5](https://github.com/laurent22/joplin/releases/tag/v2.3.5) | 2021-08-17T06:43:30Z | 81,338 | 31,363 | 33,071 | 145,772 |
|
||||
| [v2.3.3](https://github.com/laurent22/joplin/releases/tag/v2.3.3) | 2021-08-14T09:19:40Z | 14,753 | 6,856 | 4,036 | 25,645 |
|
||||
| [v2.2.7](https://github.com/laurent22/joplin/releases/tag/v2.2.7) | 2021-08-11T11:03:26Z | 15,154 | 7,489 | 2,572 | 25,215 |
|
||||
| [v2.2.6](https://github.com/laurent22/joplin/releases/tag/v2.2.6) (p) | 2021-08-09T19:29:20Z | 7,903 | 4,603 | 940 | 13,446 |
|
||||
| [v2.2.5](https://github.com/laurent22/joplin/releases/tag/v2.2.5) (p) | 2021-08-07T10:35:24Z | 1,490 | 259 | 189 | 1,938 |
|
||||
| [v2.2.4](https://github.com/laurent22/joplin/releases/tag/v2.2.4) (p) | 2021-08-05T16:42:48Z | 1,205 | 191 | 116 | 1,512 |
|
||||
| [v2.2.2](https://github.com/laurent22/joplin/releases/tag/v2.2.2) (p) | 2021-07-19T10:28:35Z | 3,107 | 719 | 629 | 4,455 |
|
||||
| [v2.1.9](https://github.com/laurent22/joplin/releases/tag/v2.1.9) | 2021-07-19T10:28:43Z | 46,236 | 18,767 | 16,686 | 81,689 |
|
||||
| [v2.2.1](https://github.com/laurent22/joplin/releases/tag/v2.2.1) (p) | 2021-07-09T17:38:25Z | 2,550 | 399 | 375 | 3,324 |
|
||||
| [v2.1.8](https://github.com/laurent22/joplin/releases/tag/v2.1.8) | 2021-07-03T08:25:16Z | 30,144 | 12,168 | 12,706 | 55,018 |
|
||||
| [v2.1.7](https://github.com/laurent22/joplin/releases/tag/v2.1.7) | 2021-06-26T19:48:55Z | 13,962 | 6,382 | 3,607 | 23,951 |
|
||||
| [v2.1.5](https://github.com/laurent22/joplin/releases/tag/v2.1.5) (p) | 2021-06-23T15:08:52Z | 1,526 | 229 | 179 | 1,934 |
|
||||
| [v2.1.3](https://github.com/laurent22/joplin/releases/tag/v2.1.3) (p) | 2021-06-19T16:32:51Z | 1,676 | 290 | 196 | 2,162 |
|
||||
| [v2.0.11](https://github.com/laurent22/joplin/releases/tag/v2.0.11) | 2021-06-16T17:55:49Z | 23,315 | 9,227 | 9,804 | 42,346 |
|
||||
| [v2.0.10](https://github.com/laurent22/joplin/releases/tag/v2.0.10) | 2021-06-16T07:58:29Z | 2,535 | 913 | 368 | 3,816 |
|
||||
| [v2.0.9](https://github.com/laurent22/joplin/releases/tag/v2.0.9) (p) | 2021-06-12T09:30:30Z | 1,590 | 288 | 874 | 2,752 |
|
||||
| [v2.0.8](https://github.com/laurent22/joplin/releases/tag/v2.0.8) (p) | 2021-06-10T16:15:08Z | 1,182 | 224 | 571 | 1,977 |
|
||||
| [v2.0.4](https://github.com/laurent22/joplin/releases/tag/v2.0.4) (p) | 2021-06-02T12:54:17Z | 1,487 | 387 | 371 | 2,245 |
|
||||
| [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 2,729 | 485 | 1,662 | 4,876 |
|
||||
| [v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (p) | 2021-05-15T13:22:58Z | 857 | 266 | 1,017 | 2,140 |
|
||||
| [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 38,000 | 16,257 | 19,389 | 73,646 |
|
||||
| [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 1,222 | 132 | 452 | 1,806 |
|
||||
| [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 1,997 | 303 | 934 | 3,234 |
|
||||
| [v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 2,342 | 433 | 1,282 | 4,057 |
|
||||
| [v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 3,524 | 823 | 2,447 | 6,794 |
|
||||
| [v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 116,452 | 42,777 | 64,286 | 223,515 |
|
||||
| [v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 14,105 | 4,853 | 4,482 | 23,440 |
|
||||
| [v1.7.9](https://github.com/laurent22/joplin/releases/tag/v1.7.9) (p) | 2021-01-28T09:50:21Z | 502 | 133 | 498 | 1,133 |
|
||||
| [v1.7.6](https://github.com/laurent22/joplin/releases/tag/v1.7.6) (p) | 2021-01-27T10:36:05Z | 310 | 93 | 287 | 690 |
|
||||
| [v1.7.5](https://github.com/laurent22/joplin/releases/tag/v1.7.5) (p) | 2021-01-26T09:53:05Z | 388 | 204 | 454 | 1,046 |
|
||||
| [v1.7.4](https://github.com/laurent22/joplin/releases/tag/v1.7.4) (p) | 2021-01-22T17:58:38Z | 693 | 204 | 625 | 1,522 |
|
||||
| [v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 19,542 | 7,691 | 7,599 | 34,832 |
|
||||
| [v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 19,635 | 7,692 | 7,601 | 34,928 |
|
||||
| [v1.7.3](https://github.com/laurent22/joplin/releases/tag/v1.7.3) (p) | 2021-01-20T11:23:50Z | 345 | 76 | 442 | 863 |
|
||||
| [v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 11,428 | 4,634 | 4,542 | 20,604 |
|
||||
| [v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,577 | 3,417 | 4,793 | 20,787 |
|
||||
| [v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 1,349 | 72 | 308 | 1,729 |
|
||||
| [v1.6.4](https://github.com/laurent22/joplin/releases/tag/v1.6.4) (p) | 2021-01-07T19:11:32Z | 391 | 78 | 203 | 672 |
|
||||
| [v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 11,512 | 4,634 | 4,542 | 20,688 |
|
||||
| [v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,588 | 3,417 | 4,793 | 20,798 |
|
||||
| [v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 1,410 | 72 | 308 | 1,790 |
|
||||
| [v1.6.4](https://github.com/laurent22/joplin/releases/tag/v1.6.4) (p) | 2021-01-07T19:11:32Z | 392 | 78 | 203 | 673 |
|
||||
| [v1.6.2](https://github.com/laurent22/joplin/releases/tag/v1.6.2) (p) | 2021-01-04T22:34:55Z | 673 | 228 | 590 | 1,491 |
|
||||
| [v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 11,897 | 5,203 | 5,526 | 22,626 |
|
||||
| [v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 11,984 | 5,203 | 5,526 | 22,713 |
|
||||
| [v1.6.1](https://github.com/laurent22/joplin/releases/tag/v1.6.1) (p) | 2020-12-29T19:37:45Z | 171 | 36 | 168 | 375 |
|
||||
| [v1.5.13](https://github.com/laurent22/joplin/releases/tag/v1.5.13) | 2020-12-29T18:29:15Z | 629 | 218 | 200 | 1,047 |
|
||||
| [v1.5.12](https://github.com/laurent22/joplin/releases/tag/v1.5.12) | 2020-12-28T15:14:08Z | 2,409 | 1,769 | 923 | 5,101 |
|
||||
| [v1.5.12](https://github.com/laurent22/joplin/releases/tag/v1.5.12) | 2020-12-28T15:14:08Z | 2,410 | 1,769 | 923 | 5,102 |
|
||||
| [v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 14,166 | 4,625 | 4,275 | 23,066 |
|
||||
| [v1.5.10](https://github.com/laurent22/joplin/releases/tag/v1.5.10) (p) | 2020-12-26T12:35:36Z | 294 | 107 | 269 | 670 |
|
||||
| [v1.5.9](https://github.com/laurent22/joplin/releases/tag/v1.5.9) (p) | 2020-12-23T18:01:08Z | 327 | 372 | 410 | 1,109 |
|
||||
| [v1.5.8](https://github.com/laurent22/joplin/releases/tag/v1.5.8) (p) | 2020-12-20T09:45:19Z | 566 | 165 | 642 | 1,373 |
|
||||
| [v1.5.7](https://github.com/laurent22/joplin/releases/tag/v1.5.7) (p) | 2020-12-10T12:58:33Z | 889 | 254 | 993 | 2,136 |
|
||||
| [v1.5.4](https://github.com/laurent22/joplin/releases/tag/v1.5.4) (p) | 2020-12-05T12:07:49Z | 692 | 166 | 634 | 1,492 |
|
||||
| [v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 26,660 | 13,465 | 11,679 | 51,804 |
|
||||
| [v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,398 | 3,879 | 3,138 | 18,415 |
|
||||
| [v1.5.4](https://github.com/laurent22/joplin/releases/tag/v1.5.4) (p) | 2020-12-05T12:07:49Z | 693 | 166 | 635 | 1,494 |
|
||||
| [v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 26,752 | 13,475 | 11,679 | 51,906 |
|
||||
| [v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,415 | 3,879 | 3,138 | 18,432 |
|
||||
| [v1.4.16](https://github.com/laurent22/joplin/releases/tag/v1.4.16) | 2020-11-27T19:40:16Z | 1,481 | 829 | 597 | 2,907 |
|
||||
| [v1.4.15](https://github.com/laurent22/joplin/releases/tag/v1.4.15) | 2020-11-27T13:25:43Z | 898 | 486 | 274 | 1,658 |
|
||||
| [v1.4.12](https://github.com/laurent22/joplin/releases/tag/v1.4.12) | 2020-11-23T18:58:07Z | 3,032 | 1,327 | 1,304 | 5,663 |
|
||||
| [v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 1,908 | 160 | 593 | 2,661 |
|
||||
| [v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 631 | 198 | 686 | 1,515 |
|
||||
| [v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 722 | 144 | 404 | 1,270 |
|
||||
| [v1.4.15](https://github.com/laurent22/joplin/releases/tag/v1.4.15) | 2020-11-27T13:25:43Z | 899 | 486 | 274 | 1,659 |
|
||||
| [v1.4.12](https://github.com/laurent22/joplin/releases/tag/v1.4.12) | 2020-11-23T18:58:07Z | 3,032 | 1,328 | 1,304 | 5,664 |
|
||||
| [v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 1,976 | 160 | 593 | 2,729 |
|
||||
| [v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 636 | 198 | 686 | 1,520 |
|
||||
| [v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 726 | 144 | 404 | 1,274 |
|
||||
| [v1.4.7](https://github.com/laurent22/joplin/releases/tag/v1.4.7) (p) | 2020-11-07T18:23:29Z | 520 | 175 | 516 | 1,211 |
|
||||
| [v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 31,926 | 11,337 | 10,516 | 53,779 |
|
||||
| [v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 32,017 | 11,337 | 10,518 | 53,872 |
|
||||
| [v1.3.17](https://github.com/laurent22/joplin/releases/tag/v1.3.17) (p) | 2020-11-06T11:35:15Z | 50 | 27 | 25 | 102 |
|
||||
| [v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 551 | 95 | 55 | 701 |
|
||||
| [v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,456 | 1,301 | 848 | 4,605 |
|
||||
| [v1.3.11](https://github.com/laurent22/joplin/releases/tag/v1.3.11) (p) | 2020-10-31T13:22:20Z | 699 | 189 | 484 | 1,372 |
|
||||
| [v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 561 | 95 | 55 | 711 |
|
||||
| [v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,462 | 1,301 | 848 | 4,611 |
|
||||
| [v1.3.11](https://github.com/laurent22/joplin/releases/tag/v1.3.11) (p) | 2020-10-31T13:22:20Z | 701 | 189 | 484 | 1,374 |
|
||||
| [v1.3.10](https://github.com/laurent22/joplin/releases/tag/v1.3.10) (p) | 2020-10-29T13:27:14Z | 378 | 118 | 319 | 815 |
|
||||
| [v1.3.9](https://github.com/laurent22/joplin/releases/tag/v1.3.9) (p) | 2020-10-23T16:04:26Z | 840 | 246 | 636 | 1,722 |
|
||||
| [v1.3.8](https://github.com/laurent22/joplin/releases/tag/v1.3.8) (p) | 2020-10-21T18:46:29Z | 519 | 120 | 333 | 972 |
|
||||
| [v1.3.7](https://github.com/laurent22/joplin/releases/tag/v1.3.7) (p) | 2020-10-20T11:35:55Z | 297 | 88 | 345 | 730 |
|
||||
| [v1.3.5](https://github.com/laurent22/joplin/releases/tag/v1.3.5) (p) | 2020-10-17T14:26:35Z | 473 | 137 | 409 | 1,019 |
|
||||
| [v1.3.3](https://github.com/laurent22/joplin/releases/tag/v1.3.3) (p) | 2020-10-17T10:56:57Z | 121 | 49 | 36 | 206 |
|
||||
| [v1.3.3](https://github.com/laurent22/joplin/releases/tag/v1.3.3) (p) | 2020-10-17T10:56:57Z | 122 | 49 | 36 | 207 |
|
||||
| [v1.3.2](https://github.com/laurent22/joplin/releases/tag/v1.3.2) (p) | 2020-10-11T20:39:49Z | 667 | 185 | 568 | 1,420 |
|
||||
| [v1.3.1](https://github.com/laurent22/joplin/releases/tag/v1.3.1) (p) | 2020-10-11T15:10:18Z | 85 | 54 | 46 | 185 |
|
||||
| [v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 45,576 | 17,741 | 14,047 | 77,364 |
|
||||
| [v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 816 | 250 | 800 | 1,866 |
|
||||
| [v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 45,679 | 17,741 | 14,047 | 77,467 |
|
||||
| [v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 817 | 250 | 800 | 1,867 |
|
||||
| [v1.2.3](https://github.com/laurent22/joplin/releases/tag/v1.2.3) (p) | 2020-09-29T15:13:02Z | 220 | 68 | 82 | 370 |
|
||||
| [v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 997 | 210 | 642 | 1,849 |
|
||||
| [v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 27,854 | 13,511 | 7,756 | 49,121 |
|
||||
| [v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 1,004 | 210 | 642 | 1,856 |
|
||||
| [v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 27,867 | 13,511 | 7,756 | 49,134 |
|
||||
| [v1.1.3](https://github.com/laurent22/joplin/releases/tag/v1.1.3) (p) | 2020-09-17T10:30:37Z | 568 | 155 | 467 | 1,190 |
|
||||
| [v1.1.2](https://github.com/laurent22/joplin/releases/tag/v1.1.2) (p) | 2020-09-15T12:58:38Z | 380 | 121 | 254 | 755 |
|
||||
| [v1.1.1](https://github.com/laurent22/joplin/releases/tag/v1.1.1) (p) | 2020-09-11T23:32:47Z | 536 | 202 | 353 | 1,091 |
|
||||
| [v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 21,865 | 10,013 | 5,647 | 37,525 |
|
||||
| [v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,695 | 6,426 | 3,022 | 22,143 |
|
||||
| [v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 25,419 | 5,888 | 5,088 | 36,395 |
|
||||
| [v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 807 | 234 | 407 | 1,448 |
|
||||
| [v1.1.2](https://github.com/laurent22/joplin/releases/tag/v1.1.2) (p) | 2020-09-15T12:58:38Z | 381 | 121 | 254 | 756 |
|
||||
| [v1.1.1](https://github.com/laurent22/joplin/releases/tag/v1.1.1) (p) | 2020-09-11T23:32:47Z | 538 | 202 | 353 | 1,093 |
|
||||
| [v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 21,917 | 10,014 | 5,647 | 37,578 |
|
||||
| [v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,703 | 6,426 | 3,023 | 22,152 |
|
||||
| [v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 25,577 | 5,900 | 5,090 | 36,567 |
|
||||
| [v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 814 | 234 | 407 | 1,455 |
|
||||
| [v1.0.237](https://github.com/laurent22/joplin/releases/tag/v1.0.237) (p) | 2020-08-29T15:38:04Z | 596 | 932 | 345 | 1,873 |
|
||||
| [v1.0.236](https://github.com/laurent22/joplin/releases/tag/v1.0.236) (p) | 2020-08-28T09:16:54Z | 321 | 119 | 110 | 550 |
|
||||
| [v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 1,880 | 498 | 928 | 3,306 |
|
||||
| [v1.0.234](https://github.com/laurent22/joplin/releases/tag/v1.0.234) (p) | 2020-08-17T23:13:02Z | 585 | 133 | 107 | 825 |
|
||||
| [v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 44,456 | 18,202 | 12,367 | 75,025 |
|
||||
| [v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 1,891 | 498 | 928 | 3,317 |
|
||||
| [v1.0.234](https://github.com/laurent22/joplin/releases/tag/v1.0.234) (p) | 2020-08-17T23:13:02Z | 587 | 133 | 107 | 827 |
|
||||
| [v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 44,564 | 18,202 | 12,367 | 75,133 |
|
||||
| [v1.0.232](https://github.com/laurent22/joplin/releases/tag/v1.0.232) (p) | 2020-07-28T22:34:40Z | 660 | 231 | 186 | 1,077 |
|
||||
| [v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 40,976 | 15,289 | 9,643 | 65,908 |
|
||||
| [v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 41,011 | 15,289 | 9,643 | 65,943 |
|
||||
| [v1.0.226](https://github.com/laurent22/joplin/releases/tag/v1.0.226) (p) | 2020-07-04T10:21:26Z | 4,922 | 2,261 | 694 | 7,877 |
|
||||
| [v1.0.224](https://github.com/laurent22/joplin/releases/tag/v1.0.224) | 2020-06-20T22:26:08Z | 24,826 | 11,015 | 6,013 | 41,854 |
|
||||
| [v1.0.223](https://github.com/laurent22/joplin/releases/tag/v1.0.223) (p) | 2020-06-20T11:51:27Z | 194 | 120 | 84 | 398 |
|
||||
| [v1.0.224](https://github.com/laurent22/joplin/releases/tag/v1.0.224) | 2020-06-20T22:26:08Z | 24,839 | 11,015 | 6,013 | 41,867 |
|
||||
| [v1.0.223](https://github.com/laurent22/joplin/releases/tag/v1.0.223) (p) | 2020-06-20T11:51:27Z | 194 | 121 | 84 | 399 |
|
||||
| [v1.0.221](https://github.com/laurent22/joplin/releases/tag/v1.0.221) (p) | 2020-06-20T01:44:20Z | 862 | 214 | 216 | 1,292 |
|
||||
| [v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 32,241 | 9,930 | 6,421 | 48,592 |
|
||||
| [v1.0.218](https://github.com/laurent22/joplin/releases/tag/v1.0.218) | 2020-06-07T10:43:34Z | 14,552 | 6,977 | 2,962 | 24,491 |
|
||||
| [v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 32,282 | 9,930 | 6,421 | 48,633 |
|
||||
| [v1.0.218](https://github.com/laurent22/joplin/releases/tag/v1.0.218) | 2020-06-07T10:43:34Z | 14,552 | 6,978 | 2,962 | 24,492 |
|
||||
| [v1.0.217](https://github.com/laurent22/joplin/releases/tag/v1.0.217) (p) | 2020-06-06T15:17:27Z | 232 | 102 | 60 | 394 |
|
||||
| [v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 38,264 | 14,299 | 10,188 | 62,751 |
|
||||
| [v1.0.214](https://github.com/laurent22/joplin/releases/tag/v1.0.214) (p) | 2020-05-21T17:15:15Z | 6,579 | 3,476 | 768 | 10,823 |
|
||||
| [v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 38,357 | 14,299 | 10,188 | 62,844 |
|
||||
| [v1.0.214](https://github.com/laurent22/joplin/releases/tag/v1.0.214) (p) | 2020-05-21T17:15:15Z | 6,593 | 3,476 | 768 | 10,837 |
|
||||
| [v1.0.212](https://github.com/laurent22/joplin/releases/tag/v1.0.212) (p) | 2020-05-21T07:48:39Z | 218 | 76 | 53 | 347 |
|
||||
| [v1.0.211](https://github.com/laurent22/joplin/releases/tag/v1.0.211) (p) | 2020-05-20T08:59:16Z | 307 | 140 | 93 | 540 |
|
||||
| [v1.0.209](https://github.com/laurent22/joplin/releases/tag/v1.0.209) (p) | 2020-05-17T18:32:51Z | 1,399 | 860 | 153 | 2,412 |
|
||||
| [v1.0.207](https://github.com/laurent22/joplin/releases/tag/v1.0.207) (p) | 2020-05-10T16:37:35Z | 1,201 | 271 | 1,022 | 2,494 |
|
||||
| [v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 54,191 | 20,056 | 18,184 | 92,431 |
|
||||
| [v1.0.207](https://github.com/laurent22/joplin/releases/tag/v1.0.207) (p) | 2020-05-10T16:37:35Z | 1,202 | 271 | 1,022 | 2,495 |
|
||||
| [v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 54,231 | 20,056 | 18,184 | 92,471 |
|
||||
| [v1.0.200](https://github.com/laurent22/joplin/releases/tag/v1.0.200) | 2020-04-12T12:17:46Z | 9,566 | 4,897 | 1,908 | 16,371 |
|
||||
| [v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,527 | 5,894 | 3,794 | 29,215 |
|
||||
| [v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 22,677 | 9,620 | 6,008 | 38,305 |
|
||||
| [v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 19,087 | 7,954 | 4,510 | 31,551 |
|
||||
| [v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,539 | 5,894 | 3,794 | 29,227 |
|
||||
| [v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 22,715 | 9,629 | 6,028 | 38,372 |
|
||||
| [v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 19,088 | 7,954 | 4,510 | 31,552 |
|
||||
| [v1.0.194](https://github.com/laurent22/joplin/releases/tag/v1.0.194) (p) | 2020-03-14T00:00:32Z | 1,290 | 1,389 | 522 | 3,201 |
|
||||
| [v1.0.193](https://github.com/laurent22/joplin/releases/tag/v1.0.193) | 2020-03-08T08:58:53Z | 28,700 | 10,916 | 7,404 | 47,020 |
|
||||
| [v1.0.193](https://github.com/laurent22/joplin/releases/tag/v1.0.193) | 2020-03-08T08:58:53Z | 28,705 | 10,918 | 7,408 | 47,031 |
|
||||
| [v1.0.192](https://github.com/laurent22/joplin/releases/tag/v1.0.192) (p) | 2020-03-06T23:27:52Z | 484 | 127 | 94 | 705 |
|
||||
| [v1.0.190](https://github.com/laurent22/joplin/releases/tag/v1.0.190) (p) | 2020-03-06T01:22:22Z | 383 | 96 | 89 | 568 |
|
||||
| [v1.0.190](https://github.com/laurent22/joplin/releases/tag/v1.0.190) (p) | 2020-03-06T01:22:22Z | 384 | 96 | 89 | 569 |
|
||||
| [v1.0.189](https://github.com/laurent22/joplin/releases/tag/v1.0.189) (p) | 2020-03-04T17:27:15Z | 356 | 101 | 99 | 556 |
|
||||
| [v1.0.187](https://github.com/laurent22/joplin/releases/tag/v1.0.187) (p) | 2020-03-01T12:31:06Z | 926 | 235 | 273 | 1,434 |
|
||||
| [v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,349 | 28,679 | 22,562 | 122,590 |
|
||||
| [v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,595 | 5,968 | 2,592 | 26,155 |
|
||||
| [v1.0.187](https://github.com/laurent22/joplin/releases/tag/v1.0.187) (p) | 2020-03-01T12:31:06Z | 926 | 236 | 274 | 1,436 |
|
||||
| [v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,364 | 28,690 | 22,564 | 122,618 |
|
||||
| [v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,596 | 5,968 | 2,593 | 26,157 |
|
||||
| [v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 1,954 | 443 | 709 | 3,106 |
|
||||
| [v1.0.176](https://github.com/laurent22/joplin/releases/tag/v1.0.176) (p) | 2019-12-14T10:36:44Z | 3,128 | 2,539 | 472 | 6,139 |
|
||||
| [v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 73,341 | 16,963 | 16,566 | 106,870 |
|
||||
| [v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,522 | 11,743 | 8,227 | 50,492 |
|
||||
| [v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 73,359 | 16,963 | 16,568 | 106,890 |
|
||||
| [v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,533 | 11,743 | 8,229 | 50,505 |
|
||||
| [v1.0.173](https://github.com/laurent22/joplin/releases/tag/v1.0.173) | 2019-11-11T08:33:35Z | 5,103 | 2,084 | 750 | 7,937 |
|
||||
| [v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,620 | 8,772 | 7,682 | 44,074 |
|
||||
| [v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,167 | 5,926 | 3,758 | 26,851 |
|
||||
| [v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,625 | 8,772 | 7,683 | 44,080 |
|
||||
| [v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,170 | 5,926 | 3,758 | 26,854 |
|
||||
| [v1.0.168](https://github.com/laurent22/joplin/releases/tag/v1.0.168) | 2019-09-25T21:21:38Z | 5,336 | 2,278 | 722 | 8,336 |
|
||||
| [v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 16,825 | 5,709 | 3,707 | 26,241 |
|
||||
| [v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 16,826 | 5,709 | 3,707 | 26,242 |
|
||||
| [v1.0.166](https://github.com/laurent22/joplin/releases/tag/v1.0.166) | 2019-09-09T17:35:54Z | 1,962 | 566 | 240 | 2,768 |
|
||||
| [v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 19,007 | 6,980 | 5,468 | 31,455 |
|
||||
| [v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,308 | 6,357 | 4,140 | 29,805 |
|
||||
| [v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,637 | 7,753 | 8,108 | 46,498 |
|
||||
| [v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,200 | 2,183 | 1,122 | 8,505 |
|
||||
| [v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 19,012 | 6,980 | 5,470 | 31,462 |
|
||||
| [v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,309 | 6,357 | 4,140 | 29,806 |
|
||||
| [v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,644 | 7,755 | 8,109 | 46,508 |
|
||||
| [v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,200 | 2,183 | 1,123 | 8,506 |
|
||||
| [v1.0.158](https://github.com/laurent22/joplin/releases/tag/v1.0.158) | 2019-05-27T19:01:18Z | 9,820 | 3,548 | 1,940 | 15,308 |
|
||||
| [v1.0.157](https://github.com/laurent22/joplin/releases/tag/v1.0.157) | 2019-05-26T17:55:53Z | 2,183 | 849 | 295 | 3,327 |
|
||||
| [v1.0.153](https://github.com/laurent22/joplin/releases/tag/v1.0.153) (p) | 2019-05-15T06:27:29Z | 855 | 107 | 110 | 1,072 |
|
||||
|
@ -193,62 +193,62 @@ updated: 2022-05-19T06:17:18Z
|
|||
| [v1.0.150](https://github.com/laurent22/joplin/releases/tag/v1.0.150) | 2019-05-12T11:27:48Z | 428 | 141 | 72 | 641 |
|
||||
| [v1.0.148](https://github.com/laurent22/joplin/releases/tag/v1.0.148) (p) | 2019-05-08T19:12:24Z | 135 | 61 | 99 | 295 |
|
||||
| [v1.0.145](https://github.com/laurent22/joplin/releases/tag/v1.0.145) | 2019-05-03T09:16:53Z | 7,012 | 2,866 | 1,441 | 11,319 |
|
||||
| [v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,923 | 3,555 | 2,784 | 18,262 |
|
||||
| [v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,727 | 4,570 | 4,731 | 24,028 |
|
||||
| [v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,636 | 4,176 | 3,336 | 21,148 |
|
||||
| [v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,923 | 3,555 | 2,785 | 18,263 |
|
||||
| [v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,735 | 4,571 | 4,731 | 24,037 |
|
||||
| [v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,637 | 4,177 | 3,340 | 21,154 |
|
||||
| [v1.0.139](https://github.com/laurent22/joplin/releases/tag/v1.0.139) (p) | 2019-03-09T10:06:48Z | 128 | 68 | 50 | 246 |
|
||||
| [v1.0.138](https://github.com/laurent22/joplin/releases/tag/v1.0.138) (p) | 2019-03-03T17:23:00Z | 156 | 94 | 88 | 338 |
|
||||
| [v1.0.137](https://github.com/laurent22/joplin/releases/tag/v1.0.137) (p) | 2019-03-03T01:12:51Z | 596 | 63 | 87 | 746 |
|
||||
| [v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,581 | 3,964 | 4,082 | 20,627 |
|
||||
| [v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,594 | 3,964 | 4,082 | 20,640 |
|
||||
| [v1.0.134](https://github.com/laurent22/joplin/releases/tag/v1.0.134) | 2019-02-27T10:21:44Z | 1,472 | 574 | 223 | 2,269 |
|
||||
| [v1.0.132](https://github.com/laurent22/joplin/releases/tag/v1.0.132) | 2019-02-26T23:02:05Z | 1,092 | 457 | 100 | 1,649 |
|
||||
| [v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 9,846 | 3,177 | 2,934 | 15,957 |
|
||||
| [v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 9,852 | 3,177 | 2,934 | 15,963 |
|
||||
| [v1.0.126](https://github.com/laurent22/joplin/releases/tag/v1.0.126) (p) | 2019-02-09T19:46:16Z | 938 | 79 | 121 | 1,138 |
|
||||
| [v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,278 | 3,564 | 1,707 | 15,549 |
|
||||
| [v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,610 | 5,208 | 6,522 | 27,340 |
|
||||
| [v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,279 | 3,564 | 1,707 | 15,550 |
|
||||
| [v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,611 | 5,209 | 6,523 | 27,343 |
|
||||
| [v1.0.119](https://github.com/laurent22/joplin/releases/tag/v1.0.119) | 2018-12-18T12:40:22Z | 8,911 | 3,267 | 2,018 | 14,196 |
|
||||
| [v1.0.118](https://github.com/laurent22/joplin/releases/tag/v1.0.118) | 2019-01-11T08:34:13Z | 722 | 253 | 93 | 1,068 |
|
||||
| [v1.0.117](https://github.com/laurent22/joplin/releases/tag/v1.0.117) | 2018-11-24T12:05:24Z | 16,266 | 4,902 | 6,385 | 27,553 |
|
||||
| [v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 3,564 | 1,129 | 718 | 5,411 |
|
||||
| [v1.0.117](https://github.com/laurent22/joplin/releases/tag/v1.0.117) | 2018-11-24T12:05:24Z | 16,268 | 4,902 | 6,386 | 27,556 |
|
||||
| [v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 3,597 | 1,129 | 718 | 5,444 |
|
||||
| [v1.0.115](https://github.com/laurent22/joplin/releases/tag/v1.0.115) | 2018-11-16T16:52:02Z | 3,662 | 1,308 | 805 | 5,775 |
|
||||
| [v1.0.114](https://github.com/laurent22/joplin/releases/tag/v1.0.114) | 2018-10-24T20:14:10Z | 11,401 | 3,507 | 3,834 | 18,742 |
|
||||
| [v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,109 | 3,320 | 3,687 | 19,116 |
|
||||
| [v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,110 | 3,320 | 3,687 | 19,117 |
|
||||
| [v1.0.110](https://github.com/laurent22/joplin/releases/tag/v1.0.110) | 2018-09-29T12:29:21Z | 966 | 414 | 122 | 1,502 |
|
||||
| [v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,108 | 710 | 332 | 3,150 |
|
||||
| [v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,108 | 710 | 333 | 3,151 |
|
||||
| [v1.0.108](https://github.com/laurent22/joplin/releases/tag/v1.0.108) (p) | 2018-09-29T18:49:29Z | 35 | 27 | 18 | 80 |
|
||||
| [v1.0.107](https://github.com/laurent22/joplin/releases/tag/v1.0.107) | 2018-09-16T19:51:07Z | 7,155 | 2,142 | 1,713 | 11,010 |
|
||||
| [v1.0.106](https://github.com/laurent22/joplin/releases/tag/v1.0.106) | 2018-09-08T15:23:40Z | 4,563 | 1,463 | 322 | 6,348 |
|
||||
| [v1.0.105](https://github.com/laurent22/joplin/releases/tag/v1.0.105) | 2018-09-05T11:29:36Z | 4,661 | 1,595 | 1,461 | 7,717 |
|
||||
| [v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,064 | 4,706 | 7,366 | 27,136 |
|
||||
| [v1.0.105](https://github.com/laurent22/joplin/releases/tag/v1.0.105) | 2018-09-05T11:29:36Z | 4,661 | 1,595 | 1,462 | 7,718 |
|
||||
| [v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,064 | 4,708 | 7,366 | 27,138 |
|
||||
| [v1.0.103](https://github.com/laurent22/joplin/releases/tag/v1.0.103) | 2018-06-21T19:38:13Z | 2,059 | 893 | 684 | 3,636 |
|
||||
| [v1.0.101](https://github.com/laurent22/joplin/releases/tag/v1.0.101) | 2018-06-17T18:35:11Z | 1,315 | 613 | 413 | 2,341 |
|
||||
| [v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 890 | 440 | 250 | 1,580 |
|
||||
| [v1.0.99](https://github.com/laurent22/joplin/releases/tag/v1.0.99) | 2018-06-10T13:18:23Z | 1,260 | 603 | 385 | 2,248 |
|
||||
| [v1.0.97](https://github.com/laurent22/joplin/releases/tag/v1.0.97) | 2018-06-09T19:23:34Z | 317 | 162 | 65 | 544 |
|
||||
| [v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 891 | 440 | 250 | 1,581 |
|
||||
| [v1.0.99](https://github.com/laurent22/joplin/releases/tag/v1.0.99) | 2018-06-10T13:18:23Z | 1,260 | 604 | 385 | 2,249 |
|
||||
| [v1.0.97](https://github.com/laurent22/joplin/releases/tag/v1.0.97) | 2018-06-09T19:23:34Z | 318 | 162 | 65 | 545 |
|
||||
| [v1.0.96](https://github.com/laurent22/joplin/releases/tag/v1.0.96) | 2018-05-26T16:36:39Z | 2,726 | 1,231 | 1,707 | 5,664 |
|
||||
| [v1.0.95](https://github.com/laurent22/joplin/releases/tag/v1.0.95) | 2018-05-25T13:04:30Z | 424 | 225 | 129 | 778 |
|
||||
| [v1.0.94](https://github.com/laurent22/joplin/releases/tag/v1.0.94) | 2018-05-21T20:52:59Z | 1,138 | 591 | 404 | 2,133 |
|
||||
| [v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,796 | 1,219 | 766 | 3,781 |
|
||||
| [v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,796 | 1,225 | 766 | 3,787 |
|
||||
| [v1.0.91](https://github.com/laurent22/joplin/releases/tag/v1.0.91) | 2018-05-10T14:48:04Z | 832 | 558 | 317 | 1,707 |
|
||||
| [v1.0.89](https://github.com/laurent22/joplin/releases/tag/v1.0.89) | 2018-05-09T13:05:05Z | 500 | 238 | 118 | 856 |
|
||||
| [v1.0.89](https://github.com/laurent22/joplin/releases/tag/v1.0.89) | 2018-05-09T13:05:05Z | 500 | 239 | 119 | 858 |
|
||||
| [v1.0.85](https://github.com/laurent22/joplin/releases/tag/v1.0.85) | 2018-05-01T21:08:24Z | 1,657 | 957 | 641 | 3,255 |
|
||||
| [v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 5,263 | 2,538 | 2,665 | 10,466 |
|
||||
| [v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 5,293 | 2,538 | 2,665 | 10,496 |
|
||||
| [v1.0.82](https://github.com/laurent22/joplin/releases/tag/v1.0.82) | 2018-03-31T19:16:31Z | 696 | 412 | 129 | 1,237 |
|
||||
| [v1.0.81](https://github.com/laurent22/joplin/releases/tag/v1.0.81) | 2018-03-28T08:13:58Z | 1,003 | 603 | 790 | 2,396 |
|
||||
| [v1.0.79](https://github.com/laurent22/joplin/releases/tag/v1.0.79) | 2018-03-23T18:00:11Z | 934 | 545 | 389 | 1,868 |
|
||||
| [v1.0.78](https://github.com/laurent22/joplin/releases/tag/v1.0.78) | 2018-03-17T15:27:18Z | 1,315 | 900 | 879 | 3,094 |
|
||||
| [v1.0.77](https://github.com/laurent22/joplin/releases/tag/v1.0.77) | 2018-03-16T15:12:35Z | 182 | 111 | 53 | 346 |
|
||||
| [v1.0.72](https://github.com/laurent22/joplin/releases/tag/v1.0.72) | 2018-03-14T09:44:35Z | 411 | 263 | 65 | 739 |
|
||||
| [v1.0.70](https://github.com/laurent22/joplin/releases/tag/v1.0.70) | 2018-02-28T20:04:30Z | 1,858 | 1,057 | 1,262 | 4,177 |
|
||||
| [v1.0.67](https://github.com/laurent22/joplin/releases/tag/v1.0.67) | 2018-02-19T22:51:08Z | 1,818 | 611 | 0 | 2,429 |
|
||||
| [v1.0.66](https://github.com/laurent22/joplin/releases/tag/v1.0.66) | 2018-02-18T23:09:09Z | 331 | 141 | 90 | 562 |
|
||||
| [v1.0.65](https://github.com/laurent22/joplin/releases/tag/v1.0.65) | 2018-02-17T20:02:25Z | 197 | 135 | 138 | 470 |
|
||||
| [v1.0.70](https://github.com/laurent22/joplin/releases/tag/v1.0.70) | 2018-02-28T20:04:30Z | 1,858 | 1,058 | 1,262 | 4,178 |
|
||||
| [v1.0.67](https://github.com/laurent22/joplin/releases/tag/v1.0.67) | 2018-02-19T22:51:08Z | 1,818 | 612 | 0 | 2,430 |
|
||||
| [v1.0.66](https://github.com/laurent22/joplin/releases/tag/v1.0.66) | 2018-02-18T23:09:09Z | 332 | 141 | 90 | 563 |
|
||||
| [v1.0.65](https://github.com/laurent22/joplin/releases/tag/v1.0.65) | 2018-02-17T20:02:25Z | 197 | 135 | 139 | 471 |
|
||||
| [v1.0.64](https://github.com/laurent22/joplin/releases/tag/v1.0.64) | 2018-02-16T00:58:20Z | 1,088 | 550 | 1,128 | 2,766 |
|
||||
| [v1.0.63](https://github.com/laurent22/joplin/releases/tag/v1.0.63) | 2018-02-14T19:40:36Z | 305 | 167 | 98 | 570 |
|
||||
| [v1.0.62](https://github.com/laurent22/joplin/releases/tag/v1.0.62) | 2018-02-12T20:19:58Z | 566 | 307 | 373 | 1,246 |
|
||||
| [v0.10.61](https://github.com/laurent22/joplin/releases/tag/v0.10.61) | 2018-02-08T18:27:39Z | 975 | 641 | 969 | 2,585 |
|
||||
| [v0.10.60](https://github.com/laurent22/joplin/releases/tag/v0.10.60) | 2018-02-06T13:09:56Z | 725 | 527 | 557 | 1,809 |
|
||||
| [v0.10.54](https://github.com/laurent22/joplin/releases/tag/v0.10.54) | 2018-01-31T20:21:30Z | 1,824 | 1,466 | 328 | 3,618 |
|
||||
| [v0.10.54](https://github.com/laurent22/joplin/releases/tag/v0.10.54) | 2018-01-31T20:21:30Z | 1,824 | 1,466 | 329 | 3,619 |
|
||||
| [v0.10.52](https://github.com/laurent22/joplin/releases/tag/v0.10.52) | 2018-01-31T19:25:18Z | 50 | 640 | 21 | 711 |
|
||||
| [v0.10.51](https://github.com/laurent22/joplin/releases/tag/v0.10.51) | 2018-01-28T18:47:02Z | 1,332 | 1,605 | 332 | 3,269 |
|
||||
| [v0.10.48](https://github.com/laurent22/joplin/releases/tag/v0.10.48) | 2018-01-23T11:19:51Z | 1,968 | 1,758 | 36 | 3,762 |
|
||||
|
@ -256,7 +256,7 @@ updated: 2022-05-19T06:17:18Z
|
|||
| [v0.10.43](https://github.com/laurent22/joplin/releases/tag/v0.10.43) | 2018-01-08T10:12:10Z | 3,445 | 2,362 | 1,213 | 7,020 |
|
||||
| [v0.10.41](https://github.com/laurent22/joplin/releases/tag/v0.10.41) | 2018-01-05T20:38:12Z | 1,040 | 1,555 | 247 | 2,842 |
|
||||
| [v0.10.40](https://github.com/laurent22/joplin/releases/tag/v0.10.40) | 2018-01-02T23:16:57Z | 1,599 | 1,795 | 344 | 3,738 |
|
||||
| [v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,847 | 4,320 | 3,218 | 13,385 |
|
||||
| [v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,858 | 4,331 | 3,228 | 13,417 |
|
||||
| [v0.10.38](https://github.com/laurent22/joplin/releases/tag/v0.10.38) | 2017-12-08T10:12:06Z | 1,054 | 1,240 | 313 | 2,607 |
|
||||
| [v0.10.37](https://github.com/laurent22/joplin/releases/tag/v0.10.37) | 2017-12-07T19:38:05Z | 270 | 855 | 90 | 1,215 |
|
||||
| [v0.10.36](https://github.com/laurent22/joplin/releases/tag/v0.10.36) | 2017-12-05T09:34:40Z | 1,020 | 1,367 | 446 | 2,833 |
|
||||
|
@ -265,11 +265,11 @@ updated: 2022-05-19T06:17:18Z
|
|||
| [v0.10.33](https://github.com/laurent22/joplin/releases/tag/v0.10.33) | 2017-12-02T13:20:39Z | 67 | 670 | 31 | 768 |
|
||||
| [v0.10.31](https://github.com/laurent22/joplin/releases/tag/v0.10.31) | 2017-12-01T09:56:44Z | 898 | 1,462 | 415 | 2,775 |
|
||||
| [v0.10.30](https://github.com/laurent22/joplin/releases/tag/v0.10.30) | 2017-11-30T20:28:16Z | 728 | 1,380 | 428 | 2,536 |
|
||||
| [v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,356 | 1,713 | 884 | 3,953 |
|
||||
| [v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,358 | 1,713 | 884 | 3,955 |
|
||||
| [v0.10.26](https://github.com/laurent22/joplin/releases/tag/v0.10.26) | 2017-11-29T16:02:17Z | 195 | 712 | 269 | 1,176 |
|
||||
| [v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 154 | 708 | 6,578 | 7,440 |
|
||||
| [v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 154 | 708 | 6,589 | 7,451 |
|
||||
| [v0.10.23](https://github.com/laurent22/joplin/releases/tag/v0.10.23) | 2017-11-21T19:38:41Z | 142 | 672 | 44 | 858 |
|
||||
| [v0.10.22](https://github.com/laurent22/joplin/releases/tag/v0.10.22) | 2017-11-20T21:45:57Z | 92 | 658 | 30 | 780 |
|
||||
| [v0.10.21](https://github.com/laurent22/joplin/releases/tag/v0.10.21) | 2017-11-18T00:53:15Z | 59 | 651 | 24 | 734 |
|
||||
| [v0.10.21](https://github.com/laurent22/joplin/releases/tag/v0.10.21) | 2017-11-18T00:53:15Z | 60 | 652 | 24 | 736 |
|
||||
| [v0.10.20](https://github.com/laurent22/joplin/releases/tag/v0.10.20) | 2017-11-17T17:18:25Z | 42 | 662 | 32 | 736 |
|
||||
| [v0.10.19](https://github.com/laurent22/joplin/releases/tag/v0.10.19) | 2017-11-20T18:59:48Z | 31 | 661 | 28 | 720 |
|
||||
| [v0.10.19](https://github.com/laurent22/joplin/releases/tag/v0.10.19) | 2017-11-20T18:59:48Z | 33 | 661 | 28 | 722 |
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue