1
0
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:
Laurent Cozic 2018-05-09 09:53:47 +01:00
parent fa9d7b0408
commit 567596643c
11 changed files with 196 additions and 40 deletions

View File

@ -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);

View 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);
}));
});

View File

@ -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));

View File

@ -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];

View File

@ -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(() => {

View File

@ -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);

View File

@ -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

View File

@ -260,6 +260,7 @@
<li>Consider rating the app on <a href="https://play.google.com/store/apps/details?id=net.cozic.joplin&amp;utm_source=GitHub&amp;utm_campaign=README&amp;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&amp;utm_source=GitHub&amp;utm_campaign=README&amp;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>

View File

@ -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="&#109;&#97;&#105;&#x6c;&#116;&#111;&#58;&#x74;&#114;&#98;&#x75;&#x68;&#111;&#x6d;&#64;&#110;&#101;&#x74;&#46;&#104;&#x72;">&#x74;&#114;&#98;&#x75;&#x68;&#111;&#x6d;&#64;&#110;&#101;&#x74;&#46;&#104;&#x72;</a></td> <td>Hrvoje Mandić <a href="&#109;&#x61;&#x69;&#108;&#x74;&#x6f;&#x3a;&#116;&#x72;&#98;&#x75;&#104;&#111;&#x6d;&#64;&#x6e;&#101;&#x74;&#46;&#104;&#x72;">&#116;&#x72;&#98;&#x75;&#104;&#111;&#x6d;&#64;&#x6e;&#101;&#x74;&#46;&#104;&#x72;</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="&#x6d;&#97;&#x69;&#x6c;&#x74;&#x6f;&#58;&#108;&#117;&#x6b;&#x61;&#x73;&#64;&#x61;&#x69;&#121;&#x61;&#46;&#x63;&#122;">&#108;&#117;&#x6b;&#x61;&#x73;&#64;&#x61;&#x69;&#121;&#x61;&#46;&#x63;&#122;</a></td> <td>Lukas Helebrandt <a href="&#109;&#97;&#x69;&#108;&#116;&#111;&#x3a;&#108;&#117;&#107;&#x61;&#x73;&#64;&#97;&#x69;&#121;&#97;&#x2e;&#x63;&#122;">&#108;&#117;&#107;&#x61;&#x73;&#64;&#97;&#x69;&#121;&#97;&#x2e;&#x63;&#122;</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="&#x6d;&#97;&#105;&#108;&#x74;&#111;&#x3a;&#122;&#x75;&#x70;&#104;&#105;&#x6c;&#105;&#x70;&#64;&#x67;&#x6d;&#x61;&#x69;&#x6c;&#46;&#99;&#x6f;&#109;">&#122;&#x75;&#x70;&#104;&#105;&#x6c;&#105;&#x70;&#64;&#x67;&#x6d;&#x61;&#x69;&#x6c;&#46;&#99;&#x6f;&#109;</a></td> <td>Philipp Zumstein <a href="&#109;&#x61;&#105;&#x6c;&#116;&#111;&#58;&#x7a;&#x75;&#x70;&#x68;&#x69;&#108;&#x69;&#x70;&#64;&#x67;&#x6d;&#x61;&#x69;&#108;&#x2e;&#x63;&#111;&#x6d;">&#x7a;&#x75;&#x70;&#x68;&#x69;&#108;&#x69;&#x70;&#64;&#x67;&#x6d;&#x61;&#x69;&#108;&#x2e;&#x63;&#111;&#x6d;</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="&#x6d;&#97;&#x69;&#x6c;&#x74;&#x6f;&#58;&#x66;&#x40;&#109;&#x72;&#116;&#110;&#46;&#x65;&#x73;">&#x66;&#x40;&#109;&#x72;&#116;&#110;&#46;&#x65;&#x73;</a></td> <td>Fernando Martín <a href="&#x6d;&#97;&#x69;&#x6c;&#x74;&#x6f;&#x3a;&#x66;&#64;&#x6d;&#x72;&#x74;&#x6e;&#x2e;&#101;&#115;">&#x66;&#64;&#x6d;&#x72;&#x74;&#x6e;&#x2e;&#101;&#115;</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="&#x6d;&#97;&#x69;&#x6c;&#x74;&#x6f;&#x3a;&#109;&#97;&#114;&#99;&#x6f;&#115;&#108;&#x61;&#x6e;&#115;&#103;&#x61;&#114;&#x7a;&#97;&#x40;&#103;&#109;&#97;&#x69;&#x6c;&#x2e;&#x63;&#111;&#x6d;">&#109;&#97;&#114;&#99;&#x6f;&#115;&#108;&#x61;&#x6e;&#115;&#103;&#x61;&#114;&#x7a;&#97;&#x40;&#103;&#109;&#97;&#x69;&#x6c;&#x2e;&#x63;&#111;&#x6d;</a></td> <td>Marcos Lans <a href="&#109;&#x61;&#x69;&#108;&#116;&#x6f;&#58;&#x6d;&#x61;&#x72;&#x63;&#111;&#x73;&#x6c;&#x61;&#x6e;&#115;&#x67;&#97;&#x72;&#x7a;&#97;&#64;&#x67;&#x6d;&#97;&#x69;&#108;&#x2e;&#x63;&#x6f;&#109;">&#x6d;&#x61;&#x72;&#x63;&#111;&#x73;&#x6c;&#x61;&#x6e;&#115;&#x67;&#97;&#x72;&#x7a;&#97;&#64;&#x67;&#x6d;&#97;&#x69;&#108;&#x2e;&#x63;&#x6f;&#109;</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="&#109;&#97;&#x69;&#x6c;&#x74;&#x6f;&#x3a;&#x72;&#x6e;&#98;&#97;&#115;&#116;&#111;&#115;&#64;&#x67;&#x6d;&#x61;&#105;&#x6c;&#46;&#x63;&#x6f;&#x6d;">&#x72;&#x6e;&#98;&#97;&#115;&#116;&#111;&#115;&#64;&#x67;&#x6d;&#x61;&#105;&#x6c;&#46;&#x63;&#x6f;&#x6d;</a></td> <td>Renato Nunes Bastos <a href="&#109;&#97;&#105;&#x6c;&#116;&#x6f;&#x3a;&#114;&#x6e;&#98;&#97;&#115;&#116;&#111;&#x73;&#64;&#103;&#109;&#97;&#105;&#x6c;&#x2e;&#x63;&#x6f;&#109;">&#114;&#x6e;&#98;&#97;&#115;&#116;&#111;&#x73;&#64;&#103;&#109;&#97;&#105;&#x6c;&#x2e;&#x63;&#x6f;&#109;</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="&#x6d;&#97;&#x69;&#108;&#x74;&#111;&#58;&#97;&#114;&#x74;&#121;&#x6f;&#x6d;&#46;&#107;&#97;&#114;&#108;&#x6f;&#118;&#64;&#x67;&#x6d;&#x61;&#105;&#108;&#46;&#x63;&#111;&#x6d;">&#97;&#114;&#x74;&#121;&#x6f;&#x6d;&#46;&#107;&#97;&#114;&#108;&#x6f;&#118;&#64;&#x67;&#x6d;&#x61;&#105;&#108;&#46;&#x63;&#111;&#x6d;</a></td> <td>Artyom Karlov <a href="&#109;&#97;&#105;&#108;&#116;&#x6f;&#x3a;&#97;&#114;&#x74;&#121;&#x6f;&#x6d;&#46;&#107;&#x61;&#x72;&#x6c;&#x6f;&#118;&#64;&#x67;&#x6d;&#x61;&#x69;&#108;&#46;&#x63;&#x6f;&#109;">&#97;&#114;&#x74;&#121;&#x6f;&#x6d;&#46;&#107;&#x61;&#x72;&#x6c;&#x6f;&#118;&#64;&#x67;&#x6d;&#x61;&#x69;&#108;&#46;&#x63;&#x6f;&#109;</a></td>
<td>95%</td> <td>95%</td>
</tr> </tr>
<tr> <tr>

View File

@ -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":
[ [

View File

@ -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/)