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