1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-16 00:14:34 +02:00

Revert "All: Added support for hierarchical/nested tags (#2572)"

This reverts commit e11e57f1d8.
This commit is contained in:
Laurent Cozic
2020-07-28 18:50:34 +01:00
parent 89e6b680a6
commit 64d7603eed
31 changed files with 191 additions and 991 deletions

View File

@ -103,13 +103,13 @@ class NoteTagsDialogComponent extends React.Component {
const tagListData = this.props.tags.map(tag => {
return {
id: tag.id,
title: Tag.getCachedFullTitle(tag.id),
title: tag.title,
selected: tagIds.indexOf(tag.id) >= 0,
};
});
tagListData.sort((a, b) => {
return naturalCompare.caseInsensitive(Tag.getCachedFullTitle(a.id), Tag.getCachedFullTitle(b.id));
return naturalCompare.caseInsensitive(a.title, b.title);
});
this.setState({ tagListData: tagListData });

View File

@ -183,8 +183,7 @@ class NotesScreenComponent extends BaseScreenComponent {
if (props.notesParentType == 'Folder') {
output = Folder.byId(props.folders, props.selectedFolderId);
} else if (props.notesParentType == 'Tag') {
const tag = Tag.byId(props.tags, props.selectedTagId);
output = Object.assign({}, tag, { title: Tag.getCachedFullTitle(tag.id) });
output = Tag.byId(props.tags, props.selectedTagId);
} else if (props.notesParentType == 'SmartFilter') {
output = { id: this.props.selectedSmartFilterId, title: _('All notes') };
} else {

View File

@ -70,7 +70,7 @@ class TagsScreenComponent extends BaseScreenComponent {
}}
>
<View style={this.styles().listItem}>
<Text style={this.styles().listItemText}>{Tag.getCachedFullTitle(tag.id)}</Text>
<Text style={this.styles().listItemText}>{tag.title}</Text>
</View>
</TouchableOpacity>
);
@ -83,7 +83,7 @@ class TagsScreenComponent extends BaseScreenComponent {
async componentDidMount() {
const tags = await Tag.allWithNotes();
tags.sort((a, b) => {
return Tag.getCachedFullTitle(a.id).toLowerCase() < Tag.getCachedFullTitle(b.id).toLowerCase() ? -1 : +1;
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
});
this.setState({ tags: tags });
}

View File

@ -15,10 +15,6 @@ const reduxSharedMiddleware = async function(store, next, action) {
Setting.setValue('collapsedFolderIds', newState.collapsedFolderIds);
}
if (action.type == 'TAG_SET_COLLAPSED' || action.type == 'TAG_TOGGLE') {
Setting.setValue('collapsedTagIds', newState.collapsedTagIds);
}
if (action.type === 'SETTING_UPDATE_ONE' && !!action.key.match(/^sync\.\d+\.path$/)) {
reg.resetSyncTarget();
}

View File

@ -1,55 +1,39 @@
const BaseItem = require('lib/models/BaseItem');
const Folder = require('lib/models/Folder');
const BaseModel = require('lib/BaseModel');
const shared = {};
function itemHasChildren_(items, itemId) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.parent_id === itemId) return true;
function folderHasChildren_(folders, folderId) {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
if (folder.parent_id === folderId) return true;
}
return false;
}
function itemIsVisible(items, itemId, collapsedItemIds) {
if (!collapsedItemIds || !collapsedItemIds.length) return true;
function folderIsVisible(folders, folderId, collapsedFolderIds) {
if (!collapsedFolderIds || !collapsedFolderIds.length) return true;
while (true) {
const item = BaseModel.byId(items, itemId);
if (!item) throw new Error(`No item with id ${itemId}`);
if (!item.parent_id) return true;
if (collapsedItemIds.indexOf(item.parent_id) >= 0) return false;
itemId = item.parent_id;
const folder = BaseModel.byId(folders, folderId);
if (!folder) throw new Error(`No folder with id ${folder.id}`);
if (!folder.parent_id) return true;
if (collapsedFolderIds.indexOf(folder.parent_id) >= 0) return false;
folderId = folder.parent_id;
}
}
function renderItemsRecursive_(props, renderItem, items, parentId, depth, order, itemType) {
let itemsKey = '';
let notesParentType = '';
let collapsedItemsKey = '';
let selectedItemKey = '';
if (itemType === BaseModel.TYPE_FOLDER) {
itemsKey = 'folders';
notesParentType = 'Folder';
collapsedItemsKey = 'collapsedFolderIds';
selectedItemKey = 'selectedFolderId';
} else if (itemType === BaseModel.TYPE_TAG) {
itemsKey = 'tags';
notesParentType = 'Tag';
collapsedItemsKey = 'collapsedTagIds';
selectedItemKey = 'selectedTagId';
}
const propItems = props[itemsKey];
for (let i = 0; i < propItems.length; i++) {
const item = propItems[i];
if (!BaseItem.getClassByItemType(itemType).idsEqual(item.parent_id, parentId)) continue;
if (!itemIsVisible(props[itemsKey], item.id, props[collapsedItemsKey])) continue;
const hasChildren = itemHasChildren_(propItems, item.id);
order.push(item.id);
items.push(renderItem(item, props[selectedItemKey] == item.id && props.notesParentType == notesParentType, hasChildren, depth));
function renderFoldersRecursive_(props, renderItem, items, parentId, depth, order) {
const folders = props.folders;
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
if (!Folder.idsEqual(folder.parent_id, parentId)) continue;
if (!folderIsVisible(props.folders, folder.id, props.collapsedFolderIds)) continue;
const hasChildren = folderHasChildren_(folders, folder.id);
order.push(folder.id);
items.push(renderItem(folder, props.selectedFolderId == folder.id && props.notesParentType == 'Folder', hasChildren, depth));
if (hasChildren) {
const result = renderItemsRecursive_(props, renderItem, items, item.id, depth + 1, order, itemType);
const result = renderFoldersRecursive_(props, renderItem, items, folder.id, depth + 1, order);
items = result.items;
order = result.order;
}
@ -61,11 +45,25 @@ function renderItemsRecursive_(props, renderItem, items, parentId, depth, order,
}
shared.renderFolders = function(props, renderItem) {
return renderItemsRecursive_(props, renderItem, [], '', 0, [], BaseModel.TYPE_FOLDER);
return renderFoldersRecursive_(props, renderItem, [], '', 0, []);
};
shared.renderTags = function(props, renderItem) {
return renderItemsRecursive_(props, renderItem, [], '', 0, [], BaseModel.TYPE_TAG);
const tags = props.tags.slice();
tags.sort((a, b) => {
return a.title < b.title ? -1 : +1;
});
const tagItems = [];
const order = [];
for (let i = 0; i < tags.length; i++) {
const tag = tags[i];
order.push(tag.id);
tagItems.push(renderItem(tag, props.selectedTagId == tag.id && props.notesParentType == 'Tag'));
}
return {
items: tagItems,
order: order,
};
};
// shared.renderSearches = function(props, renderItem) {

View File

@ -402,7 +402,6 @@ const SideMenuContent = connect(state => {
// Don't do the opacity animation as it means re-rendering the list multiple times
// opacity: state.sideMenuOpenPercent,
collapsedFolderIds: state.collapsedFolderIds,
collapsedTagIds: state.collapsedTagIds,
decryptionWorker: state.decryptionWorker,
resourceFetcher: state.resourceFetcher,
};

View File

@ -178,7 +178,7 @@ async function saveNoteTags(note) {
const tagTitle = note.tags[i];
let tag = await Tag.loadByTitle(tagTitle);
if (!tag) tag = await Tag.saveNested({}, tagTitle);
if (!tag) tag = await Tag.save({ title: tagTitle });
await Tag.addNote(tag.id, note.id);

View File

@ -326,7 +326,7 @@ class JoplinDatabase extends Database {
// must be set in the synchronizer too.
// Note: v16 and v17 don't do anything. They were used to debug an issue.
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
@ -735,13 +735,6 @@ class JoplinDatabase extends Database {
);
}
if (targetVersion == 31) {
queries.push('ALTER TABLE tags ADD COLUMN parent_id TEXT NOT NULL DEFAULT ""');
// Drop the tag note count view, instead compute note count on the fly
queries.push('DROP VIEW tags_with_note_count');
queries.push(this.addMigrationFile(31));
}
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
try {

View File

@ -1,33 +0,0 @@
const Tag = require('lib/models/Tag');
const script = {};
script.exec = async function() {
const tags = await Tag.all();
// In case tags with `/` exist, we want to transform them into nested tags
for (let i = 0; i < tags.length; i++) {
const tag = Object.assign({}, tags[i]);
// Remove any starting sequence of '/'
tag.title = tag.title.replace(/^\/*/, '');
// Remove any ending sequence of '/'
tag.title = tag.title.replace(/\/*$/, '');
// Trim any sequence of '/'+ to a single '/'
tag.title = tag.title.replace(/\/\/+/g, '/');
const tag_title = tag.title;
let other = await Tag.loadByTitle(tag_title);
let count = 1;
// In case above trimming creates duplicate tags
// then add a counter to the dupes
while ((other && other.id != tag.id) && count < 1000) {
tag.title = `${tag_title}-${count}`;
other = await Tag.loadByTitle(tag.title);
count++;
}
await Tag.saveNested(tag, tag.title);
}
};
module.exports = script;

View File

@ -4,7 +4,6 @@ const Note = require('lib/models/Note.js');
const { Database } = require('lib/database.js');
const { _ } = require('lib/locale.js');
const BaseItem = require('lib/models/BaseItem.js');
const { nestedPath } = require('lib/nested-utils.js');
const { substrWithEllipsis } = require('lib/string-utils.js');
class Folder extends BaseItem {
@ -264,7 +263,22 @@ class Folder extends BaseItem {
}
static folderPath(folders, folderId) {
return nestedPath(folders, folderId);
const idToFolders = {};
for (let i = 0; i < folders.length; i++) {
idToFolders[folders[i].id] = folders[i];
}
const path = [];
while (folderId) {
const folder = idToFolders[folderId];
if (!folder) break; // Shouldn't happen
path.push(folder);
folderId = folder.parent_id;
}
path.reverse();
return path;
}
static folderPathString(folders, folderId, maxTotalLength = 80) {

View File

@ -3,7 +3,6 @@ const BaseModel = require('lib/BaseModel.js');
const migrationScripts = {
20: require('lib/migrations/20.js'),
27: require('lib/migrations/27.js'),
31: require('lib/migrations/31.js'),
};
class Migration extends BaseModel {

View File

@ -497,7 +497,6 @@ class Setting extends BaseModel {
startMinimized: { value: false, type: Setting.TYPE_BOOL, section: 'application', public: true, appTypes: ['desktop'], label: () => _('Start application minimised in the tray icon') },
collapsedFolderIds: { value: [], type: Setting.TYPE_ARRAY, public: false },
collapsedTagIds: { value: [], type: Setting.TYPE_ARRAY, public: false },
'keychain.supported': { value: -1, type: Setting.TYPE_INT, public: false },
'db.ftsEnabled': { value: -1, type: Setting.TYPE_INT, public: false },

View File

@ -2,23 +2,8 @@ const BaseModel = require('lib/BaseModel.js');
const BaseItem = require('lib/models/BaseItem.js');
const NoteTag = require('lib/models/NoteTag.js');
const Note = require('lib/models/Note.js');
const { nestedPath } = require('lib/nested-utils.js');
const { _ } = require('lib/locale');
// fullTitle cache, which defaults to ''
const fullTitleCache = new Proxy({}, {
get: function(cache, id) {
return cache.hasOwnProperty(id) ? cache[id] : '';
},
set: function(cache, id, value) {
cache[id] = value;
return true;
},
});
// noteCount cache
const noteCountCache = {};
class Tag extends BaseItem {
static tableName() {
return 'tags';
@ -29,17 +14,10 @@ class Tag extends BaseItem {
}
static async noteIds(tagId) {
const nestedTagIds = await Tag.descendantTagIds(tagId);
nestedTagIds.push(tagId);
const rows = await this.db().selectAll(`SELECT note_id FROM note_tags WHERE tag_id IN ("${nestedTagIds.join('","')}")`);
const rows = await this.db().selectAll('SELECT note_id FROM note_tags WHERE tag_id = ?', [tagId]);
const output = [];
for (let i = 0; i < rows.length; i++) {
const noteId = rows[i].note_id;
if (output.includes(noteId)) {
continue;
}
output.push(noteId);
output.push(rows[i].note_id);
}
return output;
}
@ -58,76 +36,8 @@ class Tag extends BaseItem {
);
}
static async noteCount(tagId) {
const noteIds = await Tag.noteIds(tagId);
// Make sure the notes exist
const notes = await Note.byIds(noteIds);
return notes.length;
}
static async updateCachedNoteCountForIds(tagIds) {
const tags = await Tag.byIds(tagIds);
for (let i = 0; i < tags.length; i++) {
if (!tags[i]) continue;
noteCountCache[tags[i].id] = await Tag.noteCount(tags[i].id);
}
}
static getCachedNoteCount(tagId) {
return noteCountCache[tagId];
}
static async childrenTagIds(parentId) {
const rows = await this.db().selectAll('SELECT id FROM tags WHERE parent_id = ?', [parentId]);
return rows.map(r => r.id);
}
static async descendantTagIds(parentId) {
const descendantIds = [];
let childrenIds = await Tag.childrenTagIds(parentId);
for (let i = 0; i < childrenIds.length; i++) {
const childId = childrenIds[i];
// Fail-safe in case of a loop in the tag hierarchy.
if (descendantIds.includes(childId)) continue;
descendantIds.push(childId);
childrenIds = childrenIds.concat(await Tag.childrenTagIds(childId));
}
return descendantIds;
}
static async ancestorTags(tag) {
const ancestorIds = [];
const ancestors = [];
while (tag.parent_id != '') {
// Fail-safe in case of a loop in the tag hierarchy.
if (ancestorIds.includes(tag.parent_id)) break;
tag = await Tag.load(tag.parent_id);
// Fail-safe in case a parent isn't there
if (!tag) break;
ancestorIds.push(tag.id);
ancestors.push(tag);
}
ancestors.reverse();
return ancestors;
}
// Untag all the notes and delete tag
static async untagAll(tagId, options = null) {
if (!options) options = {};
if (!('deleteChildren' in options)) options.deleteChildren = true;
const tag = await Tag.load(tagId);
if (!tag) return; // noop
if (options.deleteChildren) {
const childrenTagIds = await Tag.childrenTagIds(tagId);
for (let i = 0; i < childrenTagIds.length; i++) {
await Tag.untagAll(childrenTagIds[i]);
}
}
static async untagAll(tagId) {
const noteTags = await NoteTag.modelSelectAll('SELECT id FROM note_tags WHERE tag_id = ?', [tagId]);
for (let i = 0; i < noteTags.length; i++) {
await NoteTag.delete(noteTags[i].id);
@ -138,30 +48,9 @@ class Tag extends BaseItem {
static async delete(id, options = null) {
if (!options) options = {};
if (!('deleteChildren' in options)) options.deleteChildren = true;
if (!('deleteNotelessParents' in options)) options.deleteNotelessParents = true;
const tag = await Tag.load(id);
if (!tag) return; // noop
// Delete children tags
if (options.deleteChildren) {
const childrenTagIds = await Tag.childrenTagIds(id);
for (let i = 0; i < childrenTagIds.length; i++) {
await Tag.delete(childrenTagIds[i]);
}
}
await super.delete(id, options);
// Delete ancestor tags that do not have any associated notes left
if (options.deleteNotelessParents && tag.parent_id) {
const parent = await Tag.loadWithCount(tag.parent_id);
if (!parent) {
await Tag.delete(tag.parent_id, options);
}
}
this.dispatch({
type: 'TAG_DELETE',
id: id,
@ -177,11 +66,6 @@ class Tag extends BaseItem {
note_id: noteId,
});
// Update note counts
const tagIdsToUpdate = await Tag.ancestorTags(tagId);
tagIdsToUpdate.push(tagId);
await Tag.updateCachedNoteCountForIds(tagIdsToUpdate);
this.dispatch({
type: 'TAG_UPDATE_ONE',
item: await Tag.loadWithCount(tagId),
@ -196,69 +80,15 @@ class Tag extends BaseItem {
await NoteTag.delete(noteTags[i].id);
}
// Update note counts
const tagIdsToUpdate = await Tag.ancestorTags(tagId);
tagIdsToUpdate.push(tagId);
await Tag.updateCachedNoteCountForIds(tagIdsToUpdate);
this.dispatch({
type: 'NOTE_TAG_REMOVE',
item: await Tag.load(tagId),
});
}
static async updateCachedFullTitleForIds(tagIds) {
const tags = await Tag.byIds(tagIds);
for (let i = 0; i < tags.length; i++) {
if (!tags[i]) continue;
fullTitleCache[tags[i].id] = await Tag.getFullTitle(tags[i]);
}
}
static getCachedFullTitle(tagId) {
return fullTitleCache[tagId];
}
static async getFullTitle(tag) {
const ancestorTags = await Tag.ancestorTags(tag);
ancestorTags.push(tag);
const ancestorTitles = ancestorTags.map((t) => t.title);
return ancestorTitles.join('/');
}
static async load(id, options = null) {
const tag = await super.load(id, options);
if (!tag) return;
// Update noteCount cache
noteCountCache[tag.id] = await Tag.noteCount(tag.id);
return tag;
}
static async all(options = null) {
const tags = await super.all(options);
for (const tag of tags) {
const tagPath = Tag.tagPath(tags, tag.id);
const pathTitles = tagPath.map((t) => t.title);
const fullTitle = pathTitles.join('/');
// When all tags are reloaded we can also cheaply update the cache
fullTitleCache[tag.id] = fullTitle;
}
// Update noteCount cache
const tagIds = tags.map((tag) => tag.id);
await Tag.updateCachedNoteCountForIds(tagIds);
return tags;
}
static async loadWithCount(tagId) {
const tag = await Tag.load(tagId);
if (!tag) return;
// Make tag has notes
if ((await Tag.getCachedNoteCount(tagId)) === 0) return;
return tag;
static loadWithCount(tagId) {
const sql = 'SELECT * FROM tags_with_note_count WHERE id = ?';
return this.modelSelectOne(sql, [tagId]);
}
static async hasNote(tagId, noteId) {
@ -267,28 +97,19 @@ class Tag extends BaseItem {
}
static async allWithNotes() {
let tags = await Tag.all();
tags = tags.filter((tag) => Tag.getCachedNoteCount(tag.id) > 0);
return tags;
return await Tag.modelSelectAll('SELECT * FROM tags_with_note_count');
}
static async search(options) {
let tags = await super.search(options);
// Apply fullTitleRegex on the full_title
if (options && options.fullTitleRegex) {
const titleRE = new RegExp(options.fullTitleRegex);
tags = tags.filter((tag) => Tag.getCachedFullTitle(tag.id).match(titleRE));
}
return tags;
static async searchAllWithNotes(options) {
if (!options) options = {};
if (!options.conditions) options.conditions = [];
options.conditions.push('id IN (SELECT distinct id FROM tags_with_note_count)');
return this.search(options);
}
static async tagsByNoteId(noteId) {
const tagIds = await NoteTag.tagIdsByNoteId(noteId);
const tags = await this.allWithNotes();
return tags.filter((tag) => tagIds.includes(tag.id));
return this.modelSelectAll(`SELECT * FROM tags WHERE id IN ("${tagIds.join('","')}")`);
}
static async commonTagsByNoteIds(noteIds) {
@ -303,37 +124,16 @@ class Tag extends BaseItem {
break;
}
}
const tags = await this.allWithNotes();
return tags.filter((tag) => commonTagIds.includes(tag.id));
return this.modelSelectAll(`SELECT * FROM tags WHERE id IN ("${commonTagIds.join('","')}")`);
}
static async loadByTitle(title) {
// When loading by title we need to verify that the path from parent to child exists
const sql = `SELECT * FROM \`${this.tableName()}\` WHERE title = ? and parent_id = ? COLLATE NOCASE`;
const separator = '/';
let i = title.indexOf(separator);
let parentId = '';
let restTitle = title;
while (i !== -1) {
const ancestorTitle = restTitle.slice(0,i);
restTitle = restTitle.slice(i + 1);
const ancestorTag = await this.modelSelectOne(sql, [ancestorTitle, parentId]);
if (!ancestorTag) return;
parentId = ancestorTag.id;
i = restTitle.indexOf(separator);
}
const tag = await this.modelSelectOne(sql, [restTitle, parentId]);
if (tag) {
fullTitleCache[tag.id] = await Tag.getFullTitle(tag);
}
return tag;
return this.loadByField('title', title, { caseInsensitive: true });
}
static async addNoteTagByTitle(noteId, tagTitle) {
let tag = await this.loadByTitle(tagTitle);
if (!tag) tag = await Tag.saveNested({}, tagTitle, { userSideValidation: true });
if (!tag) tag = await Tag.save({ title: tagTitle }, { userSideValidation: true });
return await this.addNote(tag.id, noteId);
}
@ -345,13 +145,13 @@ class Tag extends BaseItem {
const title = tagTitles[i].trim().toLowerCase();
if (!title) continue;
let tag = await this.loadByTitle(title);
if (!tag) tag = await Tag.saveNested({}, title, { userSideValidation: true });
if (!tag) tag = await Tag.save({ title: title }, { userSideValidation: true });
await this.addNote(tag.id, noteId);
addedTitles.push(title);
}
for (let i = 0; i < previousTags.length; i++) {
if (addedTitles.indexOf(Tag.getCachedFullTitle(previousTags[i].id).toLowerCase()) < 0) {
if (addedTitles.indexOf(previousTags[i].title.toLowerCase()) < 0) {
await this.removeNote(previousTags[i].id, noteId);
}
}
@ -374,130 +174,23 @@ class Tag extends BaseItem {
}
}
static tagPath(tags, tagId) {
return nestedPath(tags, tagId);
}
static async moveTag(tagId, parentTagId) {
if (tagId === parentTagId
|| (await Tag.descendantTagIds(tagId)).includes(parentTagId)) {
throw new Error(_('Cannot move tag to this location.'));
}
if (!parentTagId) parentTagId = '';
const tag = await Tag.load(tagId);
if (!tag) return;
const oldParentTagId = tag.parent_id;
// Save new parent id
const newTag = await Tag.save({ id: tag.id, parent_id: parentTagId }, { userSideValidation: true });
if (parentTagId !== oldParentTagId) {
// If the parent tag has changed, and the ancestor doesn't
// have notes attached, then remove it
const oldParentWithCount = await Tag.loadWithCount(oldParentTagId);
if (!oldParentWithCount) {
await Tag.delete(oldParentTagId, { deleteChildren: false, deleteNotelessParents: true });
}
}
return newTag;
}
static async renameNested(tag, newTitle) {
const oldParentId = tag.parent_id;
tag = await Tag.saveNested(tag, newTitle, { fields: ['title', 'parent_id'], userSideValidation: true });
if (oldParentId !== tag.parent_id) {
// If the parent tag has changed, and the ancestor doesn't
// have notes attached, then remove it
const oldParentWithCount = await Tag.loadWithCount(oldParentId);
if (!oldParentWithCount) {
await Tag.delete(oldParentId, { deleteChildren: false, deleteNotelessParents: true });
}
}
return tag;
}
static async saveNested(tag, fullTitle, options) {
if (!options) options = {};
// The following option is used to prevent loops in the tag hierarchy
if (!('mainTagId' in options) && tag.id) options.mainTagId = tag.id;
if (fullTitle.startsWith('/') || fullTitle.endsWith('/')) {
throw new Error(_('Tag name cannot start or end with a `/`.'));
} else if (fullTitle.includes('//')) {
throw new Error(_('Tag name cannot contain `//`.'));
}
const newTag = Object.assign({}, tag);
let parentId = '';
// Check if the tag is nested using `/` as separator
const separator = '/';
const i = fullTitle.lastIndexOf(separator);
if (i !== -1) {
const parentTitle = fullTitle.slice(0,i);
newTag.title = fullTitle.slice(i + 1);
// Try to get the parent tag
const parentTag = await Tag.loadByTitle(parentTitle);
// The second part of the conditions ensures that we do not create a loop
// in the tag hierarchy
if (parentTag &&
!('mainTagId' in options
&& (options.mainTagId === parentTag.id
|| (await Tag.descendantTagIds(options.mainTagId)).includes(parentTag.id)))
) {
parentId = parentTag.id;
} else {
// Create the parent tag if it doesn't exist
const parentOpts = {};
if ('mainTagId' in options) parentOpts.mainTagId = options.mainTagId;
const parentTag = await Tag.saveNested({}, parentTitle, parentOpts);
parentId = parentTag.id;
}
} else {
// Tag is not nested so set the title to full title
newTag.title = fullTitle;
}
// Set parent_id
newTag.parent_id = parentId;
return await Tag.save(newTag, options);
}
static async save(o, options = null) {
if (options && options.userSideValidation) {
if ('title' in o) {
o.title = o.title.trim().toLowerCase();
// Check that a tag with the same title does not already exist at the same level
let parentId = o.parent_id;
if (!parentId) parentId = '';
const existingCurrentLevelTags = await Tag.byIds(await Tag.childrenTagIds(parentId));
const existingTag = existingCurrentLevelTags.find((t) => t.title === o.title);
if (existingTag && existingTag.id !== o.id) {
const fullTitle = await Tag.getFullTitle(existingTag);
throw new Error(_('The tag "%s" already exists. Please choose a different name.', fullTitle));
}
const existingTag = await Tag.loadByTitle(o.title);
if (existingTag && existingTag.id !== o.id) throw new Error(_('The tag "%s" already exists. Please choose a different name.', o.title));
}
}
const tag = await super.save(o, options).then(tag => {
return super.save(o, options).then(tag => {
this.dispatch({
type: 'TAG_UPDATE_ONE',
item: tag,
});
return tag;
});
// Update fullTitleCache cache
const tagIdsToUpdate = await Tag.descendantTagIds(tag.id);
tagIdsToUpdate.push(tag.id);
await Tag.updateCachedFullTitleForIds(tagIdsToUpdate);
return tag;
}
}

View File

@ -1,22 +0,0 @@
/* eslint no-useless-escape: 0*/
function nestedPath(items, itemId) {
const idToItem = {};
for (let i = 0; i < items.length; i++) {
idToItem[items[i].id] = items[i];
}
const path = [];
while (itemId) {
const item = idToItem[itemId];
if (!item) break; // Shouldn't happen
path.push(item);
itemId = item.parent_id;
}
path.reverse();
return path;
}
module.exports = { nestedPath };

View File

@ -39,7 +39,6 @@ const defaultState = {
customCss: '',
templates: [],
collapsedFolderIds: [],
collapsedTagIds: [],
clipperServer: {
startState: 'idle',
port: null,
@ -174,24 +173,20 @@ function stateHasEncryptedItems(state) {
return false;
}
function itemSetCollapsed(state, action) {
let collapsedItemsKey = null;
if (action.type.indexOf('TAG_') !== -1) collapsedItemsKey = 'collapsedTagIds';
else if (action.type.indexOf('FOLDER_') !== -1) collapsedItemsKey = 'collapsedFolderIds';
const collapsedItemIds = state[collapsedItemsKey].slice();
const idx = collapsedItemIds.indexOf(action.id);
function folderSetCollapsed(state, action) {
const collapsedFolderIds = state.collapsedFolderIds.slice();
const idx = collapsedFolderIds.indexOf(action.id);
if (action.collapsed) {
if (idx >= 0) return state;
collapsedItemIds.push(action.id);
collapsedFolderIds.push(action.id);
} else {
if (idx < 0) return state;
collapsedItemIds.splice(idx, 1);
collapsedFolderIds.splice(idx, 1);
}
const newState = Object.assign({}, state);
newState[collapsedItemsKey] = collapsedItemIds;
newState.collapsedFolderIds = collapsedFolderIds;
return newState;
}
@ -774,14 +769,14 @@ const reducer = (state = defaultState, action) => {
break;
case 'FOLDER_SET_COLLAPSED':
newState = itemSetCollapsed(state, action);
newState = folderSetCollapsed(state, action);
break;
case 'FOLDER_TOGGLE':
if (state.collapsedFolderIds.indexOf(action.id) >= 0) {
newState = itemSetCollapsed(state, Object.assign({ collapsed: false }, action));
newState = folderSetCollapsed(state, Object.assign({ collapsed: false }, action));
} else {
newState = itemSetCollapsed(state, Object.assign({ collapsed: true }, action));
newState = folderSetCollapsed(state, Object.assign({ collapsed: true }, action));
}
break;
@ -790,23 +785,6 @@ const reducer = (state = defaultState, action) => {
newState.collapsedFolderIds = action.ids.slice();
break;
case 'TAG_SET_COLLAPSED':
newState = itemSetCollapsed(state, action);
break;
case 'TAG_TOGGLE':
if (state.collapsedTagIds.indexOf(action.id) >= 0) {
newState = itemSetCollapsed(state, Object.assign({ collapsed: false }, action));
} else {
newState = itemSetCollapsed(state, Object.assign({ collapsed: true }, action));
}
break;
case 'TAG_SET_COLLAPSED_ALL':
newState = Object.assign({}, state);
newState.collapsedTagIds = action.ids.slice();
break;
case 'TAG_UPDATE_ALL':
newState = Object.assign({}, state);
newState.tags = action.items;

View File

@ -284,67 +284,6 @@ class Api {
}
}
const checkAndRemoveFullTitleField = function(request) {
const fields = this.fields_(request, []);
let hasFullTitleField = false;
for (let i = 0; i < fields.length; i++) {
if (fields[i] === 'full_title') {
hasFullTitleField = true;
// Remove field from field list
fields.splice(i, 1);
break;
}
}
// Remove the full_title field from the query
if (hasFullTitleField) {
if (fields.length > 0) {
request.query.fields = fields.join(',');
} else if (request.query && request.query.fields) {
delete request.query.fields;
}
}
return hasFullTitleField;
}.bind(this);
// Handle full_title for GET requests
const hasFullTitleField = checkAndRemoveFullTitleField(request);
if (hasFullTitleField && request.method === 'GET' && !id) {
let tags = await this.defaultAction_(BaseModel.TYPE_TAG, request, id, link);
tags = tags.map(tag => Object.assign({}, tag, { full_title: Tag.getCachedFullTitle(tag.id) }));
return tags;
}
if (hasFullTitleField && request.method === 'GET' && id) {
let tag = await this.defaultAction_(BaseModel.TYPE_TAG, request, id, link);
tag = Object.assign({}, tag, { full_title: Tag.getCachedFullTitle(tag.id) });
return tag;
}
// Handle full_title for POST and PUT requests
if (request.method === 'PUT' || request.method === 'POST') {
const props = this.readonlyProperties(request.method);
if (props.includes('full_title')) {
if (request.method === 'PUT' && id) {
const model = await Tag.load(id);
if (!model) throw new ErrorNotFound();
let newModel = Object.assign({}, model, request.bodyJson(props));
newModel = await Tag.renameNested(newModel, newModel['full_title']);
return newModel;
}
if (request.method === 'POST') {
const idIdx = props.indexOf('id');
if (idIdx >= 0) props.splice(idIdx, 1);
const model = request.bodyJson(props);
const result = await Tag.saveNested(model, model['full_title'], this.defaultSaveOptions_(model, 'POST'));
return result;
}
}
}
return this.defaultAction_(BaseModel.TYPE_TAG, request, id, link);
}

View File

@ -264,11 +264,6 @@ function substrWithEllipsis(s, start, length) {
return `${s.substr(start, length - 3)}...`;
}
function substrStartWithEllipsis(s, start, length) {
if (s.length <= length) return s;
return `...${s.substr(start + 3, length)}`;
}
function nextWhitespaceIndex(s, begin) {
// returns index of the next whitespace character
const i = s.slice(begin).search(/\s/);
@ -290,4 +285,4 @@ function scriptType(s) {
return 'en';
}
module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, substrStartWithEllipsis, nextWhitespaceIndex, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon);
module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, nextWhitespaceIndex, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon);

View File

@ -1,7 +1,6 @@
const BaseItem = require('lib/models/BaseItem.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const Tag = require('lib/models/Tag.js');
const Resource = require('lib/models/Resource.js');
const ItemChange = require('lib/models/ItemChange.js');
const Setting = require('lib/models/Setting.js');
@ -777,15 +776,7 @@ class Synchronizer {
}
const ItemClass = BaseItem.itemClass(local.type_);
if (ItemClass === Tag) {
await Tag.delete(local.id, {
trackDeleted: false,
changeSource: ItemChange.SOURCE_SYNC,
deleteChildren: false,
deleteNotelessParents: false });
} else {
await ItemClass.delete(local.id, { trackDeleted: false, changeSource: ItemChange.SOURCE_SYNC });
}
await ItemClass.delete(local.id, { trackDeleted: false, changeSource: ItemChange.SOURCE_SYNC });
}
}

View File

@ -536,11 +536,6 @@ async function initialize(dispatch) {
ids: Setting.value('collapsedFolderIds'),
});
dispatch({
type: 'TAG_SET_COLLAPSED_ALL',
ids: Setting.value('collapsedTagIds'),
});
if (!folder) {
dispatch(DEFAULT_ROUTE);
} else {