2018-03-09 22:59:12 +02:00
|
|
|
const BaseModel = require('lib/BaseModel.js');
|
|
|
|
const { promiseChain } = require('lib/promise-utils.js');
|
|
|
|
const { time } = require('lib/time-utils.js');
|
|
|
|
const Note = require('lib/models/Note.js');
|
|
|
|
const Setting = require('lib/models/Setting.js');
|
|
|
|
const { Database } = require('lib/database.js');
|
|
|
|
const { _ } = require('lib/locale.js');
|
|
|
|
const moment = require('moment');
|
|
|
|
const BaseItem = require('lib/models/BaseItem.js');
|
|
|
|
const lodash = require('lodash');
|
2017-05-15 21:10:00 +02:00
|
|
|
|
2017-06-15 20:18:48 +02:00
|
|
|
class Folder extends BaseItem {
|
2018-03-09 22:59:12 +02:00
|
|
|
|
2017-05-15 21:10:00 +02:00
|
|
|
static tableName() {
|
2018-03-09 22:59:12 +02:00
|
|
|
return 'folders';
|
2017-05-15 21:10:00 +02:00
|
|
|
}
|
|
|
|
|
2017-07-03 21:50:45 +02:00
|
|
|
static modelType() {
|
|
|
|
return BaseModel.TYPE_FOLDER;
|
2017-05-18 21:58:01 +02:00
|
|
|
}
|
2018-03-09 22:59:12 +02:00
|
|
|
|
2017-05-15 21:10:00 +02:00
|
|
|
static newFolder() {
|
|
|
|
return {
|
|
|
|
id: null,
|
2018-03-09 22:59:12 +02:00
|
|
|
title: '',
|
|
|
|
}
|
2017-05-15 21:10:00 +02:00
|
|
|
}
|
|
|
|
|
2017-06-20 00:18:24 +02:00
|
|
|
static noteIds(parentId) {
|
2018-03-09 22:59:12 +02:00
|
|
|
return this.db().selectAll('SELECT id FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]).then((rows) => {
|
|
|
|
let output = [];
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
|
|
let row = rows[i];
|
|
|
|
output.push(row.id);
|
|
|
|
}
|
|
|
|
return output;
|
|
|
|
});
|
2017-05-18 22:31:40 +02:00
|
|
|
}
|
|
|
|
|
2018-05-09 10:53:47 +02:00
|
|
|
static async subFolderIds(parentId) {
|
|
|
|
const rows = await this.db().selectAll('SELECT id FROM folders WHERE parent_id = ?', [parentId]);
|
|
|
|
return rows.map(r => r.id);
|
|
|
|
}
|
|
|
|
|
2017-07-12 22:39:47 +02:00
|
|
|
static async noteCount(parentId) {
|
2018-03-09 22:59:12 +02:00
|
|
|
let r = await this.db().selectOne('SELECT count(*) as total FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]);
|
2017-07-12 22:39:47 +02:00
|
|
|
return r ? r.total : 0;
|
|
|
|
}
|
|
|
|
|
2017-07-13 20:47:31 +02:00
|
|
|
static markNotesAsConflict(parentId) {
|
2018-03-09 22:59:12 +02:00
|
|
|
let query = Database.updateQuery('notes', { is_conflict: 1 }, { parent_id: parentId });
|
2017-07-13 20:47:31 +02:00
|
|
|
return this.db().exec(query);
|
|
|
|
}
|
|
|
|
|
2017-06-25 09:52:25 +02:00
|
|
|
static async delete(folderId, options = null) {
|
2017-07-13 20:47:31 +02:00
|
|
|
if (!options) options = {};
|
2018-03-09 22:59:12 +02:00
|
|
|
if (!('deleteChildren' in options)) options.deleteChildren = true;
|
2017-07-13 20:47:31 +02:00
|
|
|
|
2017-06-25 09:52:25 +02:00
|
|
|
let folder = await Folder.load(folderId);
|
2017-07-13 20:47:31 +02:00
|
|
|
if (!folder) return; // noop
|
|
|
|
|
2018-03-09 22:59:12 +02:00
|
|
|
if (options.deleteChildren) {
|
2017-07-13 20:47:31 +02:00
|
|
|
let noteIds = await Folder.noteIds(folderId);
|
|
|
|
for (let i = 0; i < noteIds.length; i++) {
|
|
|
|
await Note.delete(noteIds[i]);
|
|
|
|
}
|
2018-05-09 10:53:47 +02:00
|
|
|
|
|
|
|
let subFolderIds = await Folder.subFolderIds(folderId);
|
|
|
|
for (let i = 0; i < subFolderIds.length; i++) {
|
|
|
|
await Folder.delete(subFolderIds[i]);
|
|
|
|
}
|
2017-06-25 09:52:25 +02:00
|
|
|
}
|
|
|
|
|
2017-06-25 17:17:40 +02:00
|
|
|
await super.delete(folderId, options);
|
2017-06-25 09:52:25 +02:00
|
|
|
|
|
|
|
this.dispatch({
|
2018-03-09 22:59:12 +02:00
|
|
|
type: 'FOLDER_DELETE',
|
2017-11-08 23:39:07 +02:00
|
|
|
id: folderId,
|
2017-05-16 22:42:23 +02:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2017-07-15 17:35:40 +02:00
|
|
|
static conflictFolderTitle() {
|
2018-03-09 22:59:12 +02:00
|
|
|
return _('Conflicts');
|
2017-07-15 17:35:40 +02:00
|
|
|
}
|
2017-06-18 01:49:52 +02:00
|
|
|
|
2017-07-15 17:35:40 +02:00
|
|
|
static conflictFolderId() {
|
2018-03-09 22:59:12 +02:00
|
|
|
return 'c04f1c7c04f1c7c04f1c7c04f1c7c04f';
|
2017-07-15 17:35:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
static conflictFolder() {
|
|
|
|
return {
|
|
|
|
type_: this.TYPE_FOLDER,
|
|
|
|
id: this.conflictFolderId(),
|
2018-05-09 10:53:47 +02:00
|
|
|
parent_id: '',
|
2017-07-15 17:35:40 +02:00
|
|
|
title: this.conflictFolderTitle(),
|
|
|
|
updated_time: time.unixMs(),
|
2017-08-20 22:11:32 +02:00
|
|
|
user_updated_time: time.unixMs(),
|
2017-07-15 17:35:40 +02:00
|
|
|
};
|
|
|
|
}
|
2017-06-25 11:00:54 +02:00
|
|
|
|
2017-07-15 17:35:40 +02:00
|
|
|
static async all(options = null) {
|
|
|
|
let output = await super.all(options);
|
|
|
|
if (options && options.includeConflictFolder) {
|
|
|
|
let conflictCount = await Note.conflictedCount();
|
|
|
|
if (conflictCount) output.push(this.conflictFolder());
|
|
|
|
}
|
|
|
|
return output;
|
|
|
|
}
|
2017-06-25 11:00:54 +02:00
|
|
|
|
2018-06-10 20:15:40 +02:00
|
|
|
static async childrenIds(folderId, recursive) {
|
|
|
|
if (recursive === false) throw new Error('Not implemented');
|
|
|
|
|
|
|
|
const folders = await this.db().selectAll('SELECT id FROM folders WHERE parent_id = ?', [folderId]);
|
|
|
|
|
|
|
|
let output = [];
|
|
|
|
|
|
|
|
for (let i = 0; i < folders.length; i++) {
|
|
|
|
const f = folders[i];
|
|
|
|
output.push(f.id);
|
|
|
|
const subChildrenIds = await this.childrenIds(f.id, true);
|
|
|
|
output = output.concat(subChildrenIds);
|
|
|
|
}
|
|
|
|
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
|
2018-05-26 16:46:57 +02:00
|
|
|
static async allAsTree(options = null) {
|
|
|
|
const all = await this.all(options);
|
|
|
|
|
|
|
|
// https://stackoverflow.com/a/49387427/561309
|
|
|
|
function getNestedChildren(models, parentId) {
|
|
|
|
const nestedTreeStructure = [];
|
|
|
|
const length = models.length;
|
|
|
|
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
|
|
const model = models[i];
|
|
|
|
|
|
|
|
if (model.parent_id == parentId) {
|
|
|
|
const children = getNestedChildren(models, model.id);
|
|
|
|
|
|
|
|
if (children.length > 0) {
|
|
|
|
model.children = children;
|
|
|
|
}
|
|
|
|
|
|
|
|
nestedTreeStructure.push(model);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nestedTreeStructure;
|
|
|
|
}
|
|
|
|
|
|
|
|
return getNestedChildren(all, '');
|
|
|
|
}
|
|
|
|
|
2018-12-16 18:18:24 +02:00
|
|
|
static buildTree(folders) {
|
|
|
|
const idToFolders = {};
|
|
|
|
for (let i = 0; i < folders.length; i++) {
|
|
|
|
idToFolders[folders[i].id] = folders[i];
|
|
|
|
idToFolders[folders[i].id].children = [];
|
|
|
|
}
|
|
|
|
|
|
|
|
const rootFolders = [];
|
|
|
|
for (let folderId in idToFolders) {
|
|
|
|
if (!idToFolders.hasOwnProperty(folderId)) continue;
|
|
|
|
|
|
|
|
const folder = idToFolders[folderId];
|
|
|
|
if (!folder.parent_id) {
|
|
|
|
rootFolders.push(folder);
|
|
|
|
} else {
|
2018-12-31 18:33:20 +02:00
|
|
|
if (!idToFolders[folder.parent_id]) {
|
|
|
|
// It means the notebook is refering a folder that doesn't exist. In theory it shouldn't happen
|
|
|
|
// but sometimes does - https://github.com/laurent22/joplin/issues/1068#issuecomment-450594708
|
|
|
|
rootFolders.push(folder);
|
|
|
|
} else {
|
|
|
|
idToFolders[folder.parent_id].children.push(folder);
|
|
|
|
}
|
2018-12-16 18:18:24 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return rootFolders;
|
|
|
|
}
|
|
|
|
|
2017-07-15 17:35:40 +02:00
|
|
|
static load(id) {
|
|
|
|
if (id == this.conflictFolderId()) return this.conflictFolder();
|
|
|
|
return super.load(id);
|
2017-06-19 20:58:49 +02:00
|
|
|
}
|
|
|
|
|
2017-06-27 22:16:03 +02:00
|
|
|
static defaultFolder() {
|
2018-03-09 22:59:12 +02:00
|
|
|
return this.modelSelectOne('SELECT * FROM folders ORDER BY created_time DESC LIMIT 1');
|
2017-06-25 01:19:11 +02:00
|
|
|
}
|
|
|
|
|
2018-05-09 10:53:47 +02:00
|
|
|
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 });
|
|
|
|
}
|
|
|
|
|
2017-07-15 17:35:40 +02:00
|
|
|
// 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
|
2018-03-09 22:59:12 +02:00
|
|
|
// are being synced to avoid any strange side-effects. Technically it's possible to
|
2017-07-17 21:19:01 +02:00
|
|
|
// have folders and notes with duplicate titles (or no title), or with reserved words.
|
2017-07-03 20:58:01 +02:00
|
|
|
static async save(o, options = null) {
|
2017-07-17 21:19:01 +02:00
|
|
|
if (!options) options = {};
|
|
|
|
|
|
|
|
if (options.userSideValidation === true) {
|
2018-03-09 22:59:12 +02:00
|
|
|
if (!('duplicateCheck' in options)) options.duplicateCheck = true;
|
|
|
|
if (!('reservedTitleCheck' in options)) options.reservedTitleCheck = true;
|
|
|
|
if (!('stripLeftSlashes' in options)) options.stripLeftSlashes = true;
|
2017-07-17 21:19:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (options.stripLeftSlashes === true && o.title) {
|
2018-03-09 22:59:12 +02:00
|
|
|
while (o.title.length && (o.title[0] == '/' || o.title[0] == "\\")) {
|
2017-07-17 21:19:01 +02:00
|
|
|
o.title = o.title.substr(1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-09-13 21:53:31 +02:00
|
|
|
// We allow folders with duplicate titles so that folders with the same title can exist under different parent folder. For example:
|
|
|
|
//
|
|
|
|
// PHP
|
|
|
|
// Code samples
|
|
|
|
// Doc
|
|
|
|
// Java
|
|
|
|
// My project
|
|
|
|
// Doc
|
|
|
|
|
|
|
|
// if (options.duplicateCheck === true && o.title) {
|
|
|
|
// let existingFolder = await Folder.loadByTitle(o.title);
|
|
|
|
// if (existingFolder && existingFolder.id != o.id) throw new Error(_('A notebook with this title already exists: "%s"', o.title));
|
|
|
|
// }
|
2017-07-03 20:58:01 +02:00
|
|
|
|
2017-07-17 21:19:01 +02:00
|
|
|
if (options.reservedTitleCheck === true && o.title) {
|
2017-07-15 17:35:40 +02:00
|
|
|
if (o.title == Folder.conflictFolderTitle()) throw new Error(_('Notebooks cannot be named "%s", which is a reserved title.', o.title));
|
|
|
|
}
|
|
|
|
|
2018-03-09 22:59:12 +02:00
|
|
|
return super.save(o, options).then((folder) => {
|
2017-07-03 20:03:14 +02:00
|
|
|
this.dispatch({
|
2018-03-09 22:59:12 +02:00
|
|
|
type: 'FOLDER_UPDATE_ONE',
|
2017-12-14 19:58:10 +02:00
|
|
|
item: folder,
|
2017-05-18 22:31:40 +02:00
|
|
|
});
|
2017-07-03 20:03:14 +02:00
|
|
|
return folder;
|
2017-05-18 22:31:40 +02:00
|
|
|
});
|
|
|
|
}
|
2018-03-09 22:59:12 +02:00
|
|
|
|
2017-05-15 21:10:00 +02:00
|
|
|
}
|
|
|
|
|
2018-03-09 22:59:12 +02:00
|
|
|
module.exports = Folder;
|