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);
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;
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");
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() {
const theme = themeStyle(this.props.theme);
@ -49,7 +91,7 @@ class SideBarComponent extends React.Component {
color: theme.color2,
cursor: "default",
opacity: 0.8,
fontFamily: theme.fontFamily,
// fontFamily: theme.fontFamily,
fontSize: theme.fontSize,
textDecoration: "none",
paddingRight: 5,
@ -117,7 +159,7 @@ class SideBarComponent extends React.Component {
let deleteMessage = "";
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) {
deleteMessage = _("Remove this tag from all the notes?");
} 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" }));
const InteropService = require("lib/services/InteropService.js");
@ -214,40 +269,25 @@ class SideBarComponent extends React.Component {
let style = Object.assign({}, this.style().listItem);
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);
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);
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 (
<div style={containerStyle} key={folder.id}>
{ expandIcon }
<div style={containerStyle} key={folder.id} onDragStart={this.onFolderDragStart_} onDragOver={this.onFolderDragOver_} onDrop={this.onFolderDrop_} draggable={true} folderid={folder.id}>
{ expandLink }
<a
className="list-item"
onDragOver={event => {
onDragOver(event, folder);
}}
onDrop={event => {
onDrop(event, folder);
}}
href="#"
data-id={folder.id}
data-type={BaseModel.TYPE_FOLDER}
@ -309,11 +349,11 @@ class SideBarComponent extends React.Component {
return <div style={{ height: 2, backgroundColor: "blue" }} key={key} />;
}
makeHeader(key, label, iconName) {
makeHeader(key, label, iconName, extraProps = {}) {
const style = this.style().header;
const icon = <i style={{ fontSize: style.fontSize * 1.2, marginRight: 5 }} className={"fa " + iconName} />;
return (
<div style={style} key={key}>
<div style={style} key={key} {...extraProps}>
{icon}
{label}
</div>
@ -350,7 +390,10 @@ class SideBarComponent extends React.Component {
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) {
const folderItems = shared.renderFolders(this.props, this.folderItem.bind(this));

View File

@ -44,6 +44,15 @@ class BaseModel {
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) {
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
const e = BaseModel.typeEnum_[i];

View File

@ -107,7 +107,7 @@ class NotesScreenComponent extends BaseScreenComponent {
}
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;
Folder.delete(folderId).then(() => {

View File

@ -1,4 +1,5 @@
const ArrayUtils = require('lib/ArrayUtils');
const Folder = require('lib/models/Folder');
let shared = {};
@ -14,7 +15,7 @@ function renderFoldersRecursive_(props, renderItem, items, parentId, depth) {
const folders = props.folders;
for (let i = 0; i < folders.length; 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);
items.push(renderItem(folder, props.selectedFolderId == folder.id && props.notesParentType == 'Folder', hasChildren, depth));
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) {
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;
@ -79,6 +84,11 @@ class Folder extends BaseItem {
for (let i = 0; i < noteIds.length; 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);
@ -101,6 +111,7 @@ class Folder extends BaseItem {
return {
type_: this.TYPE_FOLDER,
id: this.conflictFolderId(),
parent_id: '',
title: this.conflictFolderTitle(),
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');
}
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
// 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

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><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>Vote for or review the app on <a href="https://alternativeto.net/software/joplin/">alternativeTo</a></li>
</ul>
<script>

View File

@ -491,14 +491,14 @@ $$
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/hr.png" alt=""></td>
<td>Croatian</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>
</tr>
<tr>
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/cz.png" alt=""></td>
<td>Czech</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>
</tr>
<tr>
@ -512,7 +512,7 @@ $$
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/de.png" alt=""></td>
<td>Deutsch</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>
</tr>
<tr>
@ -526,7 +526,7 @@ $$
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/es.png" alt=""></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>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>
</tr>
<tr>
@ -540,7 +540,7 @@ $$
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/es.png" alt=""></td>
<td>Galician</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>
</tr>
<tr>
@ -561,14 +561,14 @@ $$
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/br.png" alt=""></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>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>
</tr>
<tr>
<td><img src="https://joplin.cozic.net/images/flags/country-4x3/ru.png" alt=""></td>
<td>Русский</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>
</tr>
<tr>

View File

@ -22,6 +22,8 @@
"docs/*.svg",
"ReactNativeClient/lib/mime-utils.js",
"_mydocs/EnexSamples/*.enex",
"*.min.css",
"*.min.js",
],
"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).
- [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/)