mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Electron: Handle drag and dropping notebooks to change the parent
This commit is contained in:
parent
fa9d7b0408
commit
567596643c
@ -29,7 +29,7 @@ class Command extends BaseCommand {
|
|||||||
|
|
||||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
|
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
|
||||||
if (!folder) throw new Error(_('Cannot find "%s".', pattern));
|
if (!folder) throw new Error(_('Cannot find "%s".', pattern));
|
||||||
const ok = force ? true : await this.prompt(_('Delete notebook? All notes within this notebook will also be deleted.'), { booleanAnswerDefault: 'n' });
|
const ok = force ? true : await this.prompt(_('Delete notebook? All notes and sub-notebooks within this notebook will also be deleted.'), { booleanAnswerDefault: 'n' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
await Folder.delete(folder.id);
|
await Folder.delete(folder.id);
|
||||||
|
55
CliClient/tests/models_Folder.js
Normal file
55
CliClient/tests/models_Folder.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
require('app-module-path').addPath(__dirname);
|
||||||
|
|
||||||
|
const { time } = require('lib/time-utils.js');
|
||||||
|
const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
|
||||||
|
const Folder = require('lib/models/Folder.js');
|
||||||
|
const Note = require('lib/models/Note.js');
|
||||||
|
const BaseModel = require('lib/BaseModel.js');
|
||||||
|
const { shim } = require('lib/shim');
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, p) => {
|
||||||
|
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function allItems() {
|
||||||
|
let folders = await Folder.all();
|
||||||
|
let notes = await Note.all();
|
||||||
|
return folders.concat(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('models_Folder', function() {
|
||||||
|
|
||||||
|
beforeEach(async (done) => {
|
||||||
|
await setupDatabaseAndSynchronizer(1);
|
||||||
|
await switchClient(1);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should tell if a notebook can be nested under another one', asyncTest(async () => {
|
||||||
|
let f1 = await Folder.save({ title: "folder1" });
|
||||||
|
let f2 = await Folder.save({ title: "folder2", parent_id: f1.id });
|
||||||
|
let f3 = await Folder.save({ title: "folder3", parent_id: f2.id });
|
||||||
|
let f4 = await Folder.save({ title: "folder4" });
|
||||||
|
|
||||||
|
expect(await Folder.canNestUnder(f1.id, f2.id)).toBe(false);
|
||||||
|
expect(await Folder.canNestUnder(f2.id, f2.id)).toBe(false);
|
||||||
|
expect(await Folder.canNestUnder(f3.id, f1.id)).toBe(true);
|
||||||
|
expect(await Folder.canNestUnder(f4.id, f1.id)).toBe(true);
|
||||||
|
expect(await Folder.canNestUnder(f2.id, f3.id)).toBe(false);
|
||||||
|
expect(await Folder.canNestUnder(f3.id, f2.id)).toBe(true);
|
||||||
|
expect(await Folder.canNestUnder(f1.id, '')).toBe(true);
|
||||||
|
expect(await Folder.canNestUnder(f2.id, '')).toBe(true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should recursively delete notes and sub-notebooks', asyncTest(async () => {
|
||||||
|
let f1 = await Folder.save({ title: "folder1" });
|
||||||
|
let f2 = await Folder.save({ title: "folder2", parent_id: f1.id });
|
||||||
|
let n1 = await Note.save({ title: 'note1', parent_id: f2.id });
|
||||||
|
|
||||||
|
await Folder.delete(f1.id);
|
||||||
|
|
||||||
|
const all = await allItems();
|
||||||
|
expect(all.length).toBe(0);
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
@ -14,6 +14,48 @@ const MenuItem = bridge().MenuItem;
|
|||||||
const InteropServiceHelper = require("../InteropServiceHelper.js");
|
const InteropServiceHelper = require("../InteropServiceHelper.js");
|
||||||
|
|
||||||
class SideBarComponent extends React.Component {
|
class SideBarComponent extends React.Component {
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.onFolderDragStart_ = (event) => {
|
||||||
|
const folderId = event.currentTarget.getAttribute('folderid');
|
||||||
|
if (!folderId) return;
|
||||||
|
|
||||||
|
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||||
|
event.dataTransfer.clearData();
|
||||||
|
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onFolderDragOver_ = (event) => {
|
||||||
|
if (event.dataTransfer.types.indexOf("text/x-jop-note-ids") >= 0) event.preventDefault();
|
||||||
|
if (event.dataTransfer.types.indexOf("text/x-jop-folder-ids") >= 0) event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onFolderDrop_ = async (event) => {
|
||||||
|
const folderId = event.currentTarget.getAttribute('folderid');
|
||||||
|
const dt = event.dataTransfer;
|
||||||
|
if (!dt) return;
|
||||||
|
|
||||||
|
if (dt.types.indexOf("text/x-jop-note-ids") >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const noteIds = JSON.parse(dt.getData("text/x-jop-note-ids"));
|
||||||
|
for (let i = 0; i < noteIds.length; i++) {
|
||||||
|
await Note.moveToFolder(noteIds[i], folderId);
|
||||||
|
}
|
||||||
|
} else if (dt.types.indexOf("text/x-jop-folder-ids") >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const folderIds = JSON.parse(dt.getData("text/x-jop-folder-ids"));
|
||||||
|
for (let i = 0; i < folderIds.length; i++) {
|
||||||
|
await Folder.moveToFolder(folderIds[i], folderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
style() {
|
style() {
|
||||||
const theme = themeStyle(this.props.theme);
|
const theme = themeStyle(this.props.theme);
|
||||||
|
|
||||||
@ -49,7 +91,7 @@ class SideBarComponent extends React.Component {
|
|||||||
color: theme.color2,
|
color: theme.color2,
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
fontFamily: theme.fontFamily,
|
// fontFamily: theme.fontFamily,
|
||||||
fontSize: theme.fontSize,
|
fontSize: theme.fontSize,
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
paddingRight: 5,
|
paddingRight: 5,
|
||||||
@ -117,7 +159,7 @@ class SideBarComponent extends React.Component {
|
|||||||
|
|
||||||
let deleteMessage = "";
|
let deleteMessage = "";
|
||||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||||
deleteMessage = _("Delete notebook? All notes within this notebook will also be deleted.");
|
deleteMessage = _("Delete notebook? All notes and sub-notebooks within this notebook will also be deleted.");
|
||||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
} else if (itemType === BaseModel.TYPE_TAG) {
|
||||||
deleteMessage = _("Remove this tag from all the notes?");
|
deleteMessage = _("Remove this tag from all the notes?");
|
||||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||||
@ -166,6 +208,19 @@ class SideBarComponent extends React.Component {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// menu.append(
|
||||||
|
// new MenuItem({
|
||||||
|
// label: _("Move"),
|
||||||
|
// click: async () => {
|
||||||
|
// this.props.dispatch({
|
||||||
|
// type: "WINDOW_COMMAND",
|
||||||
|
// name: "renameFolder",
|
||||||
|
// id: itemId,
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
|
||||||
menu.append(new MenuItem({ type: "separator" }));
|
menu.append(new MenuItem({ type: "separator" }));
|
||||||
|
|
||||||
const InteropService = require("lib/services/InteropService.js");
|
const InteropService = require("lib/services/InteropService.js");
|
||||||
@ -214,40 +269,25 @@ class SideBarComponent extends React.Component {
|
|||||||
let style = Object.assign({}, this.style().listItem);
|
let style = Object.assign({}, this.style().listItem);
|
||||||
if (folder.id === Folder.conflictFolderId()) style = Object.assign(style, this.style().conflictFolder);
|
if (folder.id === Folder.conflictFolderId()) style = Object.assign(style, this.style().conflictFolder);
|
||||||
|
|
||||||
const onDragOver = (event, folder) => {
|
|
||||||
if (event.dataTransfer.types.indexOf("text/x-jop-note-ids") >= 0) event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDrop = async (event, folder) => {
|
|
||||||
if (event.dataTransfer.types.indexOf("text/x-jop-note-ids") < 0) return;
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const noteIds = JSON.parse(event.dataTransfer.getData("text/x-jop-note-ids"));
|
|
||||||
for (let i = 0; i < noteIds.length; i++) {
|
|
||||||
await Note.moveToFolder(noteIds[i], folder.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemTitle = Folder.displayTitle(folder);
|
const itemTitle = Folder.displayTitle(folder);
|
||||||
|
|
||||||
let containerStyle = Object.assign({}, this.style().listItemContainer);
|
let containerStyle = Object.assign({}, this.style().listItemContainer);
|
||||||
containerStyle.marginLeft = depth * 5;
|
containerStyle.paddingLeft = containerStyle.paddingLeft + depth * 10;
|
||||||
|
|
||||||
if (selected) containerStyle = Object.assign(containerStyle, this.style().listItemSelected);
|
if (selected) containerStyle = Object.assign(containerStyle, this.style().listItemSelected);
|
||||||
|
|
||||||
const expandIcon = !hasChildren ? null : <a href="#" style={this.style().listItemExpandIcon}>[+]</a>
|
let expandLinkStyle = Object.assign({}, this.style().listItemExpandIcon);
|
||||||
|
let expandIconStyle = {
|
||||||
|
visibility: hasChildren ? 'visible' : 'hidden',
|
||||||
|
}
|
||||||
|
const expandIcon = <i style={expandIconStyle} className="fa fa-plus-square"></i>
|
||||||
|
const expandLink = hasChildren ? <a style={expandLinkStyle} href="#" onClick={() => console.info('click')}>{expandIcon}</a> : <span style={expandLinkStyle}>{expandIcon}</span>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle} key={folder.id}>
|
<div style={containerStyle} key={folder.id} onDragStart={this.onFolderDragStart_} onDragOver={this.onFolderDragOver_} onDrop={this.onFolderDrop_} draggable={true} folderid={folder.id}>
|
||||||
{ expandIcon }
|
{ expandLink }
|
||||||
<a
|
<a
|
||||||
className="list-item"
|
className="list-item"
|
||||||
onDragOver={event => {
|
|
||||||
onDragOver(event, folder);
|
|
||||||
}}
|
|
||||||
onDrop={event => {
|
|
||||||
onDrop(event, folder);
|
|
||||||
}}
|
|
||||||
href="#"
|
href="#"
|
||||||
data-id={folder.id}
|
data-id={folder.id}
|
||||||
data-type={BaseModel.TYPE_FOLDER}
|
data-type={BaseModel.TYPE_FOLDER}
|
||||||
@ -309,11 +349,11 @@ class SideBarComponent extends React.Component {
|
|||||||
return <div style={{ height: 2, backgroundColor: "blue" }} key={key} />;
|
return <div style={{ height: 2, backgroundColor: "blue" }} key={key} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
makeHeader(key, label, iconName) {
|
makeHeader(key, label, iconName, extraProps = {}) {
|
||||||
const style = this.style().header;
|
const style = this.style().header;
|
||||||
const icon = <i style={{ fontSize: style.fontSize * 1.2, marginRight: 5 }} className={"fa " + iconName} />;
|
const icon = <i style={{ fontSize: style.fontSize * 1.2, marginRight: 5 }} className={"fa " + iconName} />;
|
||||||
return (
|
return (
|
||||||
<div style={style} key={key}>
|
<div style={style} key={key} {...extraProps}>
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
@ -350,7 +390,10 @@ class SideBarComponent extends React.Component {
|
|||||||
|
|
||||||
let items = [];
|
let items = [];
|
||||||
|
|
||||||
items.push(this.makeHeader("folderHeader", _("Notebooks"), "fa-folder-o"));
|
items.push(this.makeHeader("folderHeader", _("Notebooks"), "fa-folder-o", {
|
||||||
|
onDrop: this.onFolderDrop_,
|
||||||
|
folderid: '',
|
||||||
|
}));
|
||||||
|
|
||||||
if (this.props.folders.length) {
|
if (this.props.folders.length) {
|
||||||
const folderItems = shared.renderFolders(this.props, this.folderItem.bind(this));
|
const folderItems = shared.renderFolders(this.props, this.folderItem.bind(this));
|
||||||
|
@ -44,6 +44,15 @@ class BaseModel {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer the use of this function to compare IDs as it handles the case where
|
||||||
|
// one ID is null and the other is "", in which case they are actually considered to be the same.
|
||||||
|
static idsEqual(id1, id2) {
|
||||||
|
if (!id1 && !id2) return true;
|
||||||
|
if (!id1 && !!id2) return false;
|
||||||
|
if (!!id1 && !id2) return false;
|
||||||
|
return id1 === id2;
|
||||||
|
}
|
||||||
|
|
||||||
static modelTypeToName(type) {
|
static modelTypeToName(type) {
|
||||||
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
|
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
|
||||||
const e = BaseModel.typeEnum_[i];
|
const e = BaseModel.typeEnum_[i];
|
||||||
|
@ -107,7 +107,7 @@ class NotesScreenComponent extends BaseScreenComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteFolder_onPress(folderId) {
|
deleteFolder_onPress(folderId) {
|
||||||
dialogs.confirm(this, _('Delete notebook? All notes within this notebook will also be deleted.')).then((ok) => {
|
dialogs.confirm(this, _('Delete notebook? All notes and sub-notebooks within this notebook will also be deleted.')).then((ok) => {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
Folder.delete(folderId).then(() => {
|
Folder.delete(folderId).then(() => {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
const ArrayUtils = require('lib/ArrayUtils');
|
const ArrayUtils = require('lib/ArrayUtils');
|
||||||
|
const Folder = require('lib/models/Folder');
|
||||||
|
|
||||||
let shared = {};
|
let shared = {};
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ function renderFoldersRecursive_(props, renderItem, items, parentId, depth) {
|
|||||||
const folders = props.folders;
|
const folders = props.folders;
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
let folder = folders[i];
|
let folder = folders[i];
|
||||||
if (folder.parent_id !== parentId) continue;
|
if (!Folder.idsEqual(folder.parent_id, parentId)) continue;
|
||||||
const hasChildren = folderHasChildren_(folders, folder.id);
|
const hasChildren = folderHasChildren_(folders, folder.id);
|
||||||
items.push(renderItem(folder, props.selectedFolderId == folder.id && props.notesParentType == 'Folder', hasChildren, depth));
|
items.push(renderItem(folder, props.selectedFolderId == folder.id && props.notesParentType == 'Folder', hasChildren, depth));
|
||||||
if (hasChildren) items = renderFoldersRecursive_(props, renderItem, items, folder.id, depth + 1);
|
if (hasChildren) items = renderFoldersRecursive_(props, renderItem, items, folder.id, depth + 1);
|
||||||
|
@ -57,6 +57,11 @@ class Folder extends BaseItem {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async subFolderIds(parentId) {
|
||||||
|
const rows = await this.db().selectAll('SELECT id FROM folders WHERE parent_id = ?', [parentId]);
|
||||||
|
return rows.map(r => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
static async noteCount(parentId) {
|
static async noteCount(parentId) {
|
||||||
let r = await this.db().selectOne('SELECT count(*) as total FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]);
|
let r = await this.db().selectOne('SELECT count(*) as total FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]);
|
||||||
return r ? r.total : 0;
|
return r ? r.total : 0;
|
||||||
@ -79,6 +84,11 @@ class Folder extends BaseItem {
|
|||||||
for (let i = 0; i < noteIds.length; i++) {
|
for (let i = 0; i < noteIds.length; i++) {
|
||||||
await Note.delete(noteIds[i]);
|
await Note.delete(noteIds[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let subFolderIds = await Folder.subFolderIds(folderId);
|
||||||
|
for (let i = 0; i < subFolderIds.length; i++) {
|
||||||
|
await Folder.delete(subFolderIds[i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await super.delete(folderId, options);
|
await super.delete(folderId, options);
|
||||||
@ -101,6 +111,7 @@ class Folder extends BaseItem {
|
|||||||
return {
|
return {
|
||||||
type_: this.TYPE_FOLDER,
|
type_: this.TYPE_FOLDER,
|
||||||
id: this.conflictFolderId(),
|
id: this.conflictFolderId(),
|
||||||
|
parent_id: '',
|
||||||
title: this.conflictFolderTitle(),
|
title: this.conflictFolderTitle(),
|
||||||
updated_time: time.unixMs(),
|
updated_time: time.unixMs(),
|
||||||
user_updated_time: time.unixMs(),
|
user_updated_time: time.unixMs(),
|
||||||
@ -125,6 +136,39 @@ class Folder extends BaseItem {
|
|||||||
return this.modelSelectOne('SELECT * FROM folders ORDER BY created_time DESC LIMIT 1');
|
return this.modelSelectOne('SELECT * FROM folders ORDER BY created_time DESC LIMIT 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async canNestUnder(folderId, targetFolderId) {
|
||||||
|
if (folderId === targetFolderId) return false;
|
||||||
|
|
||||||
|
const conflictFolderId = Folder.conflictFolderId();
|
||||||
|
if (folderId == conflictFolderId || targetFolderId == conflictFolderId) return false;
|
||||||
|
|
||||||
|
if (!targetFolderId) return true;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let folder = await Folder.load(targetFolderId);
|
||||||
|
if (!folder.parent_id) break;
|
||||||
|
if (folder.parent_id === folderId) return false;
|
||||||
|
targetFolderId = folder.parent_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async moveToFolder(folderId, targetFolderId) {
|
||||||
|
if (!(await this.canNestUnder(folderId, targetFolderId))) throw new Error(_('Cannot move notebook to this location'));
|
||||||
|
|
||||||
|
// When moving a note to a different folder, the user timestamp is not updated.
|
||||||
|
// However updated_time is updated so that the note can be synced later on.
|
||||||
|
|
||||||
|
const modifiedFolder = {
|
||||||
|
id: folderId,
|
||||||
|
parent_id: targetFolderId,
|
||||||
|
updated_time: time.unixMs(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Folder.save(modifiedFolder, { autoTimestamp: false });
|
||||||
|
}
|
||||||
|
|
||||||
// These "duplicateCheck" and "reservedTitleCheck" should only be done when a user is
|
// These "duplicateCheck" and "reservedTitleCheck" should only be done when a user is
|
||||||
// manually creating a folder. They shouldn't be done for example when the folders
|
// manually creating a folder. They shouldn't be done for example when the folders
|
||||||
// are being synced to avoid any strange side-effects. Technically it's possible to
|
// are being synced to avoid any strange side-effects. Technically it's possible to
|
||||||
|
@ -260,6 +260,7 @@
|
|||||||
<li>Consider rating the app on <a href="https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1">Google Play</a> or <a href="https://itunes.apple.com/us/app/joplin/id1315599797">App Store</a>.</li>
|
<li>Consider rating the app on <a href="https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1">Google Play</a> or <a href="https://itunes.apple.com/us/app/joplin/id1315599797">App Store</a>.</li>
|
||||||
<li><a href="https://joplin.cozic.net/#localisation">Create of update a translation</a>.</li>
|
<li><a href="https://joplin.cozic.net/#localisation">Create of update a translation</a>.</li>
|
||||||
<li>Help with the <a href="https://github.com/laurent22/joplin">documentation</a>.</li>
|
<li>Help with the <a href="https://github.com/laurent22/joplin">documentation</a>.</li>
|
||||||
|
<li>Vote for or review the app on <a href="https://alternativeto.net/software/joplin/">alternativeTo</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -491,14 +491,14 @@ $$
|
|||||||
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/hr.png" alt=""></td>
|
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/hr.png" alt=""></td>
|
||||||
<td>Croatian</td>
|
<td>Croatian</td>
|
||||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/hr_HR.po">hr_HR</a></td>
|
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/hr_HR.po">hr_HR</a></td>
|
||||||
<td>Hrvoje Mandić <a href="mailto:trbuhom@net.hr">trbuhom@net.hr</a></td>
|
<td>Hrvoje Mandić <a href="mailto:trbuhom@net.hr">trbuhom@net.hr</a></td>
|
||||||
<td>61%</td>
|
<td>61%</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/cz.png" alt=""></td>
|
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/cz.png" alt=""></td>
|
||||||
<td>Czech</td>
|
<td>Czech</td>
|
||||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/cs_CZ.po">cs_CZ</a></td>
|
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/cs_CZ.po">cs_CZ</a></td>
|
||||||
<td>Lukas Helebrandt <a href="mailto:lukas@aiya.cz">lukas@aiya.cz</a></td>
|
<td>Lukas Helebrandt <a href="mailto:lukas@aiya.cz">lukas@aiya.cz</a></td>
|
||||||
<td>96%</td>
|
<td>96%</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -512,7 +512,7 @@ $$
|
|||||||
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/de.png" alt=""></td>
|
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/de.png" alt=""></td>
|
||||||
<td>Deutsch</td>
|
<td>Deutsch</td>
|
||||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po">de_DE</a></td>
|
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po">de_DE</a></td>
|
||||||
<td>Philipp Zumstein <a href="mailto:zuphilip@gmail.com">zuphilip@gmail.com</a></td>
|
<td>Philipp Zumstein <a href="mailto:zuphilip@gmail.com">zuphilip@gmail.com</a></td>
|
||||||
<td>99%</td>
|
<td>99%</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -526,7 +526,7 @@ $$
|
|||||||
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/es.png" alt=""></td>
|
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/es.png" alt=""></td>
|
||||||
<td>Español</td>
|
<td>Español</td>
|
||||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po">es_ES</a></td>
|
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po">es_ES</a></td>
|
||||||
<td>Fernando Martín <a href="mailto:f@mrtn.es">f@mrtn.es</a></td>
|
<td>Fernando Martín <a href="mailto:f@mrtn.es">f@mrtn.es</a></td>
|
||||||
<td>99%</td>
|
<td>99%</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -540,7 +540,7 @@ $$
|
|||||||
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/es.png" alt=""></td>
|
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/es.png" alt=""></td>
|
||||||
<td>Galician</td>
|
<td>Galician</td>
|
||||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/gl_ES.po">gl_ES</a></td>
|
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/gl_ES.po">gl_ES</a></td>
|
||||||
<td>Marcos Lans <a href="mailto:marcoslansgarza@gmail.com">marcoslansgarza@gmail.com</a></td>
|
<td>Marcos Lans <a href="mailto:marcoslansgarza@gmail.com">marcoslansgarza@gmail.com</a></td>
|
||||||
<td>96%</td>
|
<td>96%</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -561,14 +561,14 @@ $$
|
|||||||
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/br.png" alt=""></td>
|
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/br.png" alt=""></td>
|
||||||
<td>Português (Brasil)</td>
|
<td>Português (Brasil)</td>
|
||||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po">pt_BR</a></td>
|
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po">pt_BR</a></td>
|
||||||
<td>Renato Nunes Bastos <a href="mailto:rnbastos@gmail.com">rnbastos@gmail.com</a></td>
|
<td>Renato Nunes Bastos <a href="mailto:rnbastos@gmail.com">rnbastos@gmail.com</a></td>
|
||||||
<td>98%</td>
|
<td>98%</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/ru.png" alt=""></td>
|
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/ru.png" alt=""></td>
|
||||||
<td>Русский</td>
|
<td>Русский</td>
|
||||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/ru_RU.po">ru_RU</a></td>
|
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/ru_RU.po">ru_RU</a></td>
|
||||||
<td>Artyom Karlov <a href="mailto:artyom.karlov@gmail.com">artyom.karlov@gmail.com</a></td>
|
<td>Artyom Karlov <a href="mailto:artyom.karlov@gmail.com">artyom.karlov@gmail.com</a></td>
|
||||||
<td>95%</td>
|
<td>95%</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -22,6 +22,8 @@
|
|||||||
"docs/*.svg",
|
"docs/*.svg",
|
||||||
"ReactNativeClient/lib/mime-utils.js",
|
"ReactNativeClient/lib/mime-utils.js",
|
||||||
"_mydocs/EnexSamples/*.enex",
|
"_mydocs/EnexSamples/*.enex",
|
||||||
|
"*.min.css",
|
||||||
|
"*.min.js",
|
||||||
],
|
],
|
||||||
"folder_exclude_patterns":
|
"folder_exclude_patterns":
|
||||||
[
|
[
|
||||||
|
@ -20,4 +20,5 @@ There are other ways to support the development of Joplin:
|
|||||||
|
|
||||||
- Consider rating the app on [Google Play](https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) or [App Store](https://itunes.apple.com/us/app/joplin/id1315599797).
|
- Consider rating the app on [Google Play](https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) or [App Store](https://itunes.apple.com/us/app/joplin/id1315599797).
|
||||||
- [Create of update a translation](https://joplin.cozic.net/#localisation).
|
- [Create of update a translation](https://joplin.cozic.net/#localisation).
|
||||||
- Help with the [documentation](https://github.com/laurent22/joplin).
|
- Help with the [documentation](https://github.com/laurent22/joplin).
|
||||||
|
- Vote for or review the app on [alternativeTo](https://alternativeto.net/software/joplin/)
|
Loading…
Reference in New Issue
Block a user