diff --git a/CliClient/app/autocompletion.js b/CliClient/app/autocompletion.js index 73836e090a..9e8055d9e4 100644 --- a/CliClient/app/autocompletion.js +++ b/CliClient/app/autocompletion.js @@ -95,8 +95,8 @@ async function handleAutocompletionPromise(line) { } if (argName == 'tag') { - const tags = await Tag.search({ titlePattern: `${next}*` }); - l.push(...tags.map(n => n.title)); + const tags = await Tag.search({ fullTitleRegex: `${next}.*` }); + l.push(...tags.map(tag => Tag.getCachedFullTitle(tag.id))); } if (argName == 'file') { diff --git a/CliClient/app/command-tag.js b/CliClient/app/command-tag.js index d199ff52da..54676af251 100644 --- a/CliClient/app/command-tag.js +++ b/CliClient/app/command-tag.js @@ -34,7 +34,7 @@ class Command extends BaseCommand { if (command == 'add') { if (!notes.length) throw new Error(_('Cannot find "%s".', args.note)); - if (!tag) tag = await Tag.save({ title: args.tag }, { userSideValidation: true }); + if (!tag) tag = await Tag.saveNested({}, args.tag, { userSideValidation: true }); for (let i = 0; i < notes.length; i++) { await Tag.addNote(tag.id, notes[i].id); } @@ -72,7 +72,7 @@ class Command extends BaseCommand { } else { const tags = await Tag.all(); tags.map(tag => { - this.stdout(tag.title); + this.stdout(Tag.getCachedFullTitle(tag.id)); }); } } else if (command == 'notetags') { diff --git a/CliClient/tests/models_Tag.js b/CliClient/tests/models_Tag.js index 843144f990..0be018a2e7 100644 --- a/CliClient/tests/models_Tag.js +++ b/CliClient/tests/models_Tag.js @@ -59,7 +59,7 @@ describe('models_Tag', function() { expect(tags.length).toBe(0); })); - it('should return tags with note counts', asyncTest(async () => { + it('should return correct note counts', asyncTest(async () => { const folder1 = await Folder.save({ title: 'folder1' }); const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id }); const note2 = await Note.save({ title: 'ma 2nd note', parent_id: folder1.id }); @@ -68,13 +68,13 @@ describe('models_Tag', function() { let tags = await Tag.allWithNotes(); expect(tags.length).toBe(1); - expect(tags[0].note_count).toBe(2); + expect(Tag.getCachedNoteCount(tags[0].id)).toBe(2); await Note.delete(note1.id); tags = await Tag.allWithNotes(); expect(tags.length).toBe(1); - expect(tags[0].note_count).toBe(1); + expect(Tag.getCachedNoteCount(tags[0].id)).toBe(1); await Note.delete(note2.id); @@ -82,21 +82,6 @@ describe('models_Tag', function() { expect(tags.length).toBe(0); })); - it('should load individual tags with note count', asyncTest(async () => { - const folder1 = await Folder.save({ title: 'folder1' }); - const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id }); - const note2 = await Note.save({ title: 'ma 2nd note', parent_id: folder1.id }); - const tag = await Tag.save({ title: 'mytag' }); - await Tag.addNote(tag.id, note1.id); - - let tagWithCount = await Tag.loadWithCount(tag.id); - expect(tagWithCount.note_count).toBe(1); - - await Tag.addNote(tag.id, note2.id); - tagWithCount = await Tag.loadWithCount(tag.id); - expect(tagWithCount.note_count).toBe(2); - })); - it('should get common tags for set of notes', asyncTest(async () => { const folder1 = await Folder.save({ title: 'folder1' }); const taga = await Tag.save({ title: 'mytaga' }); @@ -150,4 +135,211 @@ describe('models_Tag', function() { expect(commonTagIds.includes(tagc.id)).toBe(true); })); + it('should create parent tags', asyncTest(async () => { + const tag = await Tag.saveNested({}, 'tag1/subtag1/subtag2'); + expect(tag).not.toEqual(null); + + let parent_tag = await Tag.loadByTitle('tag1/subtag1'); + expect(parent_tag).not.toEqual(null); + + parent_tag = await Tag.loadByTitle('tag1'); + expect(parent_tag).not.toEqual(null); + })); + + it('should should find notes tagged with descendant tag', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const tag0 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag'); + const tag1 = await Tag.loadByTitle('tag1/subtag1'); + + const note0 = await Note.save({ title: 'my note 0', parent_id: folder1.id }); + const note1 = await Note.save({ title: 'my note 1', parent_id: folder1.id }); + + await Tag.addNote(tag0.id, note0.id); + await Tag.addNote(tag1.id, note1.id); + + const parent_tag = await Tag.loadByTitle('tag1'); + const noteIds = await Tag.noteIds(parent_tag.id); + expect(noteIds.includes(note0.id)).toBe(true); + expect(noteIds.includes(note1.id)).toBe(true); + })); + + it('should untag descendant tags', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const tag0 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag'); + const parent_tag = await Tag.loadByTitle('tag1'); + const note0 = await Note.save({ title: 'my note 0', parent_id: folder1.id }); + + await Tag.addNote(tag0.id, note0.id); + let tagIds = await NoteTag.tagIdsByNoteId(note0.id); + expect(tagIds.includes(tag0.id)).toBe(true); + + await Tag.untagAll(parent_tag.id); + tagIds = await NoteTag.tagIdsByNoteId(note0.id); + expect(tagIds.length).toBe(0); + })); + + it('should count note_tags of descendant tags', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const tag0 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag'); + let parent_tag = await Tag.loadByTitle('tag1'); + + const note0 = await Note.save({ title: 'my note 0', parent_id: folder1.id }); + await Tag.addNote(tag0.id, note0.id); + + parent_tag = await Tag.loadWithCount(parent_tag.id); + expect(Tag.getCachedNoteCount(parent_tag.id)).toBe(1); + })); + + it('should delete descendant tags', asyncTest(async () => { + let tag1 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag'); + let tag1_subtag1 = await Tag.loadByTitle('tag1/subtag1'); + expect(tag1).toBeDefined(); + expect(tag1_subtag1).toBeDefined(); + + let parent_tag = await Tag.loadByTitle('tag1'); + await Tag.delete(parent_tag.id); + + parent_tag = await Tag.loadByTitle('tag1'); + expect(parent_tag).not.toBeDefined(); + tag1_subtag1 = await Tag.loadByTitle('tag1/subtag1'); + expect(tag1_subtag1).not.toBeDefined(); + tag1 = await Tag.loadByTitle('tag1/subtag1/subsubtag'); + expect(tag1).not.toBeDefined(); + })); + + it('should delete noteless parent tags', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const note0 = await Note.save({ title: 'my note 0', parent_id: folder1.id }); + const subsubtag = await Tag.saveNested({}, 'tag1/subtag1/subsubtag'); + await Tag.addNote(subsubtag.id, note0.id); + let tag1_subtag1 = await Tag.loadByTitle('tag1/subtag1'); + + // This will remove the link from tag1 to subsubtag1 (which is removed) + // So tag1 is noteless and should also be removed + await Tag.delete(tag1_subtag1.id); + + const parent_tag = await Tag.loadByTitle('tag1'); + expect(parent_tag).not.toBeDefined(); + tag1_subtag1 = await Tag.loadByTitle('tag1/subtag1'); + expect(tag1_subtag1).not.toBeDefined(); + const tag1 = await Tag.loadByTitle('tag1/subtag1/subsubtag'); + expect(tag1).not.toBeDefined(); + })); + + it('renaming should change prefix in descendant tags', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const note0 = await Note.save({ title: 'my note 0', parent_id: folder1.id }); + + const tag1 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag'); + const subtag2 = await Tag.saveNested({}, 'tag1/subtag2'); + const subtag1 = await Tag.loadByTitle('tag1/subtag1'); + const tag1_parent = await Tag.loadByTitle('tag1'); + + await Tag.setNoteTagsByIds(note0.id, [tag1.id, subtag2.id]); + await Tag.renameNested(tag1_parent, 'tag2'); + + expect(Tag.getCachedFullTitle((await Tag.loadWithCount(tag1_parent.id)).id)).toBe('tag2'); + expect(Tag.getCachedFullTitle((await Tag.loadWithCount(tag1.id)).id)).toBe('tag2/subtag1/subsubtag'); + expect(Tag.getCachedFullTitle((await Tag.loadWithCount(subtag1.id)).id)).toBe('tag2/subtag1'); + expect(Tag.getCachedFullTitle((await Tag.loadWithCount(subtag2.id)).id)).toBe('tag2/subtag2'); + })); + + it('renaming parent prefix should branch-out to two hierarchies', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const note1 = await Note.save({ title: 'my note 1', parent_id: folder1.id }); + const note2 = await Note.save({ title: 'my note 2', parent_id: folder1.id }); + const subsubtag1 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag1'); + const subsubtag2 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag2'); + await Tag.addNote(subsubtag1.id, note1.id); + await Tag.addNote(subsubtag2.id, note2.id); + + await Tag.renameNested(subsubtag1, 'tag1/subtag2/subsubtag1'); + + const subtag1 = await Tag.loadByTitle('tag1/subtag1'); + const subtag2 = await Tag.loadByTitle('tag1/subtag2'); + expect(subtag1).toBeDefined(); + expect(subtag2).toBeDefined(); + })); + + it('renaming parent prefix to existing tag should remove unused old tag', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const note1 = await Note.save({ title: 'my note 1', parent_id: folder1.id }); + const subsubtag1 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag1'); + const subsubtag2 = await Tag.saveNested({}, 'tag1/subtag2/subsubtag2'); + await Tag.addNote(subsubtag2.id, note1.id); + + await Tag.renameNested(subsubtag1, 'tag1/subtag2/subsubtag1'); + + expect((await Tag.loadByTitle('tag1/subtag1'))).not.toBeDefined(); + })); + + it('moving tag should change prefix name', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const note1 = await Note.save({ title: 'my note 1', parent_id: folder1.id }); + const subsubtag1 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag1'); + const tag2 = await Tag.saveNested({}, 'tag2'); + await Tag.setNoteTagsByIds(note1.id, [tag2.id, subsubtag1.id]); + + await Tag.moveTag(subsubtag1.id, tag2.id); + + expect(Tag.getCachedFullTitle((await Tag.loadWithCount(subsubtag1.id)).id)).toBe('tag2/subsubtag1'); + })); + + it('moving tag to itself or its descendant throws error', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const note1 = await Note.save({ title: 'my note 1', parent_id: folder1.id }); + const subsubtag1 = await Tag.saveNested({}, 'tag1/subtag1/subsubtag1'); + await Tag.addNote(subsubtag1.id, note1.id); + + const tag1 = await Tag.loadByTitle('tag1'); + + let hasThrown = await checkThrowAsync(async () => await Tag.moveTag(tag1.id, subsubtag1.id)); + expect(hasThrown).toBe(true); + hasThrown = await checkThrowAsync(async () => await Tag.moveTag(tag1.id, tag1.id)); + expect(hasThrown).toBe(true); + })); + + it('renaming tag as a child of itself creates new parent', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const note1 = await Note.save({ title: 'my note 1', parent_id: folder1.id }); + const subtag1 = await Tag.saveNested({}, 'tag1/subtag1'); + await Tag.addNote(subtag1.id, note1.id); + + const a = await Tag.renameNested(subtag1, 'tag1/subtag1/a/subtag1'); + + const subtag1_renamed = await Tag.loadByTitle('tag1/subtag1/a/subtag1'); + expect(subtag1_renamed.id).toBe(subtag1.id); + const subtag1_new = await Tag.loadByTitle('tag1/subtag1'); + expect(subtag1_new.id).not.toBe(subtag1.id); + })); + + it('should search by full title regex', asyncTest(async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const note1 = await Note.save({ title: 'my note 1', parent_id: folder1.id }); + const abc = await Tag.saveNested({}, 'a/b/c'); + const adef = await Tag.saveNested({}, 'a/d/e/f'); + + await Tag.setNoteTagsByIds(note1.id, [abc.id, adef.id]); + + expect((await Tag.search({ fullTitleRegex: '.*c.*' })).length).toBe(1); + expect((await Tag.search({ fullTitleRegex: '.*b.*' })).length).toBe(2); + expect((await Tag.search({ fullTitleRegex: '.*b/c.*' })).length).toBe(1); + expect((await Tag.search({ fullTitleRegex: '.*a.*' })).length).toBe(6); + expect((await Tag.search({ fullTitleRegex: '.*a/d.*' })).length).toBe(3); + })); + + it('creating tags with the same name at the same level should throw exception', asyncTest(async () => { + // Should not complain when creating at different levels + await Tag.saveNested({}, 'a/b/c'); + await Tag.saveNested({}, 'a/d/e/c'); + await Tag.saveNested({}, 'c'); + + // Should complain when creating at the same level + let hasThrown = await checkThrowAsync(async () => await Tag.saveNested({}, 'a/d', { userSideValidation: true })); + expect(hasThrown).toBe(true); + hasThrown = await checkThrowAsync(async () => await Tag.saveNested({}, 'a', { userSideValidation: true })); + expect(hasThrown).toBe(true); + hasThrown = await checkThrowAsync(async () => await Tag.saveNested({}, 'a/b/c', { userSideValidation: true })); + expect(hasThrown).toBe(true); + })); }); diff --git a/CliClient/tests/synchronizer.js b/CliClient/tests/synchronizer.js index edcfec69d0..c01126e234 100644 --- a/CliClient/tests/synchronizer.js +++ b/CliClient/tests/synchronizer.js @@ -578,6 +578,89 @@ describe('synchronizer', function() { await shoudSyncTagTest(true); })); + it('should sync tag deletion', asyncTest(async () => { + const f1 = await Folder.save({ title: 'folder' }); + const n1 = await Note.save({ title: 'mynote', parent_id: f1.id }); + const tag = await Tag.saveNested({}, 'a/b/c'); + await Tag.addNote(tag.id, n1.id); + await synchronizer().start(); + + await switchClient(2); + await synchronizer().start(); + let taga = await Tag.loadByTitle('a'); + let tagb = await Tag.loadByTitle('a/b'); + let tagc = await Tag.loadByTitle('a/b/c'); + expect(taga).toBeDefined(); + expect(tagb).toBeDefined(); + expect(tagc).toBeDefined(); + + // Should remove both parent and children tags in this case + await Tag.delete(tagb.id); + await synchronizer().start(); + + await switchClient(1); + await synchronizer().start(); + taga = await Tag.loadByTitle('a'); + tagb = await Tag.loadByTitle('a/b'); + tagc = await Tag.loadByTitle('a/b/c'); + expect(taga).not.toBeDefined(); + expect(tagb).not.toBeDefined(); + expect(tagc).not.toBeDefined(); + })); + + it('should sync child tag deletion', asyncTest(async () => { + const f1 = await Folder.save({ title: 'folder' }); + const n1 = await Note.save({ title: 'mynote', parent_id: f1.id }); + const tag1 = await Tag.saveNested({}, 'a/b/c/d'); + const tag2 = await Tag.saveNested({}, 'a/b/d'); + await Tag.addNote(tag1.id, n1.id); + await Tag.addNote(tag2.id, n1.id); + let taga = await Tag.loadByTitle('a'); + let tagb = await Tag.loadByTitle('a/b'); + let tagc = await Tag.loadByTitle('a/b/c'); + let tagabcd = await Tag.loadByTitle('a/b/c/d'); + let tagabd = await Tag.loadByTitle('a/b/d'); + await synchronizer().start(); + + await switchClient(2); + await synchronizer().start(); + const taga2 = await Tag.loadByTitle('a'); + const tagb2 = await Tag.loadByTitle('a/b'); + const tagc2 = await Tag.loadByTitle('a/b/c'); + const tagabcd2 = await Tag.loadByTitle('a/b/c/d'); + const tagabd2 = await Tag.loadByTitle('a/b/d'); + expect(taga2).toBeDefined(); + expect(tagb2).toBeDefined(); + expect(tagc2).toBeDefined(); + expect(tagabcd2).toBeDefined(); + expect(tagabd2).toBeDefined(); + expect(taga2.id).toBe(taga.id); + expect(tagb2.id).toBe(tagb.id); + expect(tagc2.id).toBe(tagc.id); + expect(tagabcd2.id).toBe(tagabcd.id); + expect(tagabd2.id).toBe(tagabd.id); + + // Should remove children tags in this case + await Tag.delete(tagc.id); + await synchronizer().start(); + + await switchClient(1); + await synchronizer().start(); + taga = await Tag.loadByTitle('a'); + tagb = await Tag.loadByTitle('a/b'); + tagc = await Tag.loadByTitle('a/b/c'); + tagabcd = await Tag.loadByTitle('a/b/c/d'); + tagabd = await Tag.loadByTitle('a/b/d'); + expect(taga).toBeDefined(); + expect(tagb).toBeDefined(); + expect(tagc).not.toBeDefined(); + expect(tagabcd).not.toBeDefined(); + expect(tagabd).toBeDefined(); + expect(taga.id).toBe(taga2.id); + expect(tagb.id).toBe(tagb2.id); + expect(tagabd.id).toBe(tagabd2.id); + })); + it('should not sync notes with conflicts', asyncTest(async () => { const f1 = await Folder.save({ title: 'folder' }); const n1 = await Note.save({ title: 'mynote', parent_id: f1.id, is_conflict: 1 }); diff --git a/Clipper/popup/src/App.js b/Clipper/popup/src/App.js index f882f752a7..5dd4583125 100644 --- a/Clipper/popup/src/App.js +++ b/Clipper/popup/src/App.js @@ -368,7 +368,7 @@ class AppComponent extends Component { const tagDataListOptions = []; for (let i = 0; i < this.props.tags.length; i++) { const tag = this.props.tags[i]; - tagDataListOptions.push(); + tagDataListOptions.push(); } let simplifiedPageButtonLabel = 'Clip simplified page'; diff --git a/Clipper/popup/src/bridge.js b/Clipper/popup/src/bridge.js index 68009f2a6d..b83811047b 100644 --- a/Clipper/popup/src/bridge.js +++ b/Clipper/popup/src/bridge.js @@ -150,7 +150,7 @@ class Bridge { const folders = await this.folderTree(); this.dispatch({ type: 'FOLDERS_SET', folders: folders }); - const tags = await this.clipperApiExec('GET', 'tags'); + const tags = await this.clipperApiExec('GET', 'tags', { fields: 'full_title' }); this.dispatch({ type: 'TAGS_SET', tags: tags }); bridge().restoreState(); diff --git a/ElectronClient/app.js b/ElectronClient/app.js index 76d773871f..0da27feab1 100644 --- a/ElectronClient/app.js +++ b/ElectronClient/app.js @@ -1186,6 +1186,11 @@ class Application extends BaseApplication { ids: Setting.value('collapsedFolderIds'), }); + this.store().dispatch({ + type: 'TAG_SET_COLLAPSED_ALL', + ids: Setting.value('collapsedTagIds'), + }); + // Loads custom Markdown preview styles const cssString = await CssUtils.loadCustomCss(`${Setting.value('profileDir')}/userstyle.css`); this.store().dispatch({ diff --git a/ElectronClient/gui/MainScreen/commands/renameTag.ts b/ElectronClient/gui/MainScreen/commands/renameTag.ts index 52d55a5de9..b599b311be 100644 --- a/ElectronClient/gui/MainScreen/commands/renameTag.ts +++ b/ElectronClient/gui/MainScreen/commands/renameTag.ts @@ -16,12 +16,11 @@ export const runtime = (comp:any):CommandRuntime => { comp.setState({ promptOptions: { label: _('Rename tag:'), - value: tag.title, + value: Tag.getCachedFullTitle(tag.id), onClose: async (answer:string) => { if (answer !== null) { try { - tag.title = answer; - await Tag.save(tag, { fields: ['title'], userSideValidation: true }); + await Tag.renameNested(tag, answer); } catch (error) { bridge().showErrorMessageBox(error.message); } diff --git a/ElectronClient/gui/MainScreen/commands/setTags.ts b/ElectronClient/gui/MainScreen/commands/setTags.ts index d2d5cbda6d..9c1905fcd5 100644 --- a/ElectronClient/gui/MainScreen/commands/setTags.ts +++ b/ElectronClient/gui/MainScreen/commands/setTags.ts @@ -1,6 +1,7 @@ import { CommandRuntime, CommandDeclaration } from '../../../lib/services/CommandService'; const Tag = require('lib/models/Tag'); const { _ } = require('lib/locale'); +const { bridge } = require('electron').remote.require('./bridge'); export const declaration:CommandDeclaration = { name: 'setTags', @@ -14,7 +15,7 @@ export const runtime = (comp:any):CommandRuntime => { const tags = await Tag.commonTagsByNoteIds(noteIds); const startTags = tags .map((a:any) => { - return { value: a.id, label: a.title }; + return { value: a.id, label: Tag.getCachedFullTitle(a.id) }; }) .sort((a:any, b:any) => { // sensitivity accent will treat accented characters as differemt @@ -23,7 +24,7 @@ export const runtime = (comp:any):CommandRuntime => { }); const allTags = await Tag.allWithNotes(); const tagSuggestions = allTags.map((a:any) => { - return { value: a.id, label: a.title }; + return { value: a.id, label: Tag.getCachedFullTitle(a.id) }; }) .sort((a:any, b:any) => { // sensitivity accent will treat accented characters as differemt @@ -42,21 +43,25 @@ export const runtime = (comp:any):CommandRuntime => { const endTagTitles = answer.map(a => { return a.label.trim(); }); - if (noteIds.length === 1) { - await Tag.setNoteTagsByTitles(noteIds[0], endTagTitles); - } else { - const startTagTitles = startTags.map((a:any) => { return a.label.trim(); }); - const addTags = endTagTitles.filter((value:string) => !startTagTitles.includes(value)); - const delTags = startTagTitles.filter((value:string) => !endTagTitles.includes(value)); + try { + if (noteIds.length === 1) { + await Tag.setNoteTagsByTitles(noteIds[0], endTagTitles); + } else { + const startTagTitles = startTags.map((a:any) => { return a.label.trim(); }); + const addTags = endTagTitles.filter((value:string) => !startTagTitles.includes(value)); + const delTags = startTagTitles.filter((value:string) => !endTagTitles.includes(value)); - // apply the tag additions and deletions to each selected note - for (let i = 0; i < noteIds.length; i++) { - const tags = await Tag.tagsByNoteId(noteIds[i]); - let tagTitles = tags.map((a:any) => { return a.title; }); - tagTitles = tagTitles.concat(addTags); - tagTitles = tagTitles.filter((value:string) => !delTags.includes(value)); - await Tag.setNoteTagsByTitles(noteIds[i], tagTitles); + // apply the tag additions and deletions to each selected note + for (let i = 0; i < noteIds.length; i++) { + const tags = await Tag.tagsByNoteId(noteIds[i]); + let tagTitles = tags.map((a:any) => { return Tag.getCachedFullTitle(a.id); }); + tagTitles = tagTitles.concat(addTags); + tagTitles = tagTitles.filter((value:string) => !delTags.includes(value)); + await Tag.setNoteTagsByTitles(noteIds[i], tagTitles); + } } + } catch (error) { + bridge().showErrorMessageBox(error.message); } } comp.setState({ promptOptions: null }); diff --git a/ElectronClient/gui/SideBar/SideBar.jsx b/ElectronClient/gui/SideBar/SideBar.jsx index 4c4b0926f9..f456cab806 100644 --- a/ElectronClient/gui/SideBar/SideBar.jsx +++ b/ElectronClient/gui/SideBar/SideBar.jsx @@ -14,7 +14,7 @@ const { bridge } = require('electron').remote.require('./bridge'); const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; const InteropServiceHelper = require('../../InteropServiceHelper.js'); -const { substrWithEllipsis } = require('lib/string-utils'); +const { substrWithEllipsis, substrStartWithEllipsis } = require('lib/string-utils'); const { ALL_NOTES_FILTER_ID } = require('lib/reserved-ids'); const commands = [ @@ -64,11 +64,26 @@ class SideBarComponent extends React.Component { const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids')); for (let i = 0; i < folderIds.length; i++) { + if (folderId === folderIds[i]) continue; await Folder.moveToFolder(folderIds[i], folderId); } } }; + this.onTagDragStart_ = event => { + const tagId = event.currentTarget.getAttribute('tagid'); + if (!tagId) return; + + event.dataTransfer.setDragImage(new Image(), 1, 1); + event.dataTransfer.clearData(); + event.dataTransfer.setData('text/x-jop-tag-ids', JSON.stringify([tagId])); + }; + + this.onTagDragOver_ = event => { + if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault(); + if (event.dataTransfer.types.indexOf('text/x-jop-tag-ids') >= 0) event.preventDefault(); + }; + this.onTagDrop_ = async event => { const tagId = event.currentTarget.getAttribute('tagid'); const dt = event.dataTransfer; @@ -81,6 +96,18 @@ class SideBarComponent extends React.Component { for (let i = 0; i < noteIds.length; i++) { await Tag.addNote(tagId, noteIds[i]); } + } else if (dt.types.indexOf('text/x-jop-tag-ids') >= 0) { + event.preventDefault(); + + const tagIds = JSON.parse(dt.getData('text/x-jop-tag-ids')); + try { + for (let i = 0; i < tagIds.length; i++) { + if (tagId === tagIds[i]) continue; + await Tag.moveTag(tagIds[i], tagId); + } + } catch (error) { + bridge().showErrorMessageBox(error.message); + } } }; @@ -93,6 +120,15 @@ class SideBarComponent extends React.Component { }); }; + this.onTagToggleClick_ = async event => { + const tagId = event.currentTarget.getAttribute('tagid'); + + this.props.dispatch({ + type: 'TAG_TOGGLE', + id: tagId, + }); + }; + this.folderItemsOrder_ = []; this.tagItemsOrder_ = []; @@ -206,10 +242,6 @@ class SideBarComponent extends React.Component { }, }; - style.tagItem = Object.assign({}, style.listItem); - style.tagItem.paddingLeft = 23; - style.tagItem.height = itemHeight; - return style; } @@ -241,7 +273,7 @@ class SideBarComponent extends React.Component { buttonLabel = _('Delete'); } else if (itemType === BaseModel.TYPE_TAG) { const tag = await Tag.load(itemId); - deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32)); + deleteMessage = _('Remove tag "%s" and its descendant tags from all notes?', substrStartWithEllipsis(Tag.getCachedFullTitle(tag.id), -32, 32)); } else if (itemType === BaseModel.TYPE_SEARCH) { deleteMessage = _('Remove this search from the sidebar?'); } @@ -365,15 +397,46 @@ class SideBarComponent extends React.Component { return
({count})
; } - folderItem(folder, selected, hasChildren, depth) { - let style = Object.assign({}, this.style().listItem); - if (folder.id === Folder.conflictFolderId()) style = Object.assign(style, this.style().conflictFolder); + renderItem(itemType, item, selected, hasChildren, depth) { + let itemTitle = ''; + let collapsedIds = null; + const jsxItemIdAttribute = {}; + let anchorRef = null; + let noteCount = ''; + let onDragStart = null; + let onDragOver = null; + let onDrop = null; + let onItemClick = null; + let onItemToggleClick = null; + if (itemType === BaseModel.TYPE_FOLDER) { + itemTitle = Folder.displayTitle(item); + collapsedIds = this.props.collapsedFolderIds; + jsxItemIdAttribute.folderid = item.id; + anchorRef = this.anchorItemRef('folder', item.id); + noteCount = item.note_count ? this.noteCountElement(item.note_count) : ''; + onDragStart = this.onFolderDragStart_; + onDragOver = this.onFolderDragOver_; + onDrop = this.onFolderDrop_; + onItemClick = this.folderItem_click.bind(this); + onItemToggleClick = this.onFolderToggleClick_; + } else { + itemTitle = Tag.displayTitle(item); + collapsedIds = this.props.collapsedTagIds; + jsxItemIdAttribute.tagid = item.id; + anchorRef = this.anchorItemRef('tag', item.id); + noteCount = Setting.value('showNoteCounts') ? this.noteCountElement(Tag.getCachedNoteCount(item.id)) : ''; + onDragStart = this.onTagDragStart_; + onDragOver = this.onTagDragOver_; + onDrop = this.onTagDrop_; + onItemClick = this.tagItem_click.bind(this); + onItemToggleClick = this.onTagToggleClick_; + } - const itemTitle = Folder.displayTitle(folder); + let style = Object.assign({}, this.style().listItem); + if (item.id === Folder.conflictFolderId()) style = Object.assign(style, this.style().conflictFolder); let containerStyle = Object.assign({}, this.style().listItemContainer); if (selected) containerStyle = Object.assign(containerStyle, this.style().listItemSelected); - containerStyle.paddingLeft = 8 + depth * 15; const expandLinkStyle = Object.assign({}, this.style().listItemExpandIcon); @@ -381,35 +444,32 @@ class SideBarComponent extends React.Component { visibility: hasChildren ? 'visible' : 'hidden', }; - const iconName = this.props.collapsedFolderIds.indexOf(folder.id) >= 0 ? 'fa-chevron-right' : 'fa-chevron-down'; + const iconName = collapsedIds.indexOf(item.id) >= 0 ? 'fa-chevron-right' : 'fa-chevron-down'; const expandIcon = ; const expandLink = hasChildren ? ( - + {expandIcon} ) : ( {expandIcon} ); - const anchorRef = this.anchorItemRef('folder', folder.id); - const noteCount = folder.note_count ? this.noteCountElement(folder.note_count) : ''; - return ( -
+
{expandLink} this.itemContextMenu(event)} style={style} - folderid={folder.id} + {...jsxItemIdAttribute} onClick={() => { - this.folderItem_click(folder); + onItemClick(item); }} - onDoubleClick={this.onFolderToggleClick_} + onDoubleClick={onItemToggleClick} > {itemTitle} {noteCount} @@ -417,34 +477,6 @@ class SideBarComponent extends React.Component { ); } - tagItem(tag, selected) { - let style = Object.assign({}, this.style().tagItem); - if (selected) style = Object.assign(style, this.style().listItemSelected); - - const anchorRef = this.anchorItemRef('tag', tag.id); - const noteCount = Setting.value('showNoteCounts') ? this.noteCountElement(tag.note_count) : ''; - - return ( - this.itemContextMenu(event)} - tagid={tag.id} - key={tag.id} - style={style} - onDrop={this.onTagDrop_} - onClick={() => { - this.tagItem_click(tag); - }} - > - {Tag.displayTitle(tag)} {noteCount} - - ); - } - // searchItem(search, selected) { // let style = Object.assign({}, this.style().listItem); // if (selected) style = Object.assign(style, this.style().listItemSelected); @@ -669,7 +701,7 @@ class SideBarComponent extends React.Component { ); if (this.props.folders.length) { - const result = shared.renderFolders(this.props, this.folderItem.bind(this)); + const result = shared.renderFolders(this.props, this.renderItem.bind(this, BaseModel.TYPE_FOLDER)); const folderItems = result.items; this.folderItemsOrder_ = result.order; items.push( @@ -682,11 +714,12 @@ class SideBarComponent extends React.Component { items.push( this.makeHeader('tagHeader', _('Tags'), 'fa-tags', { toggleblock: 1, + onDrop: this.onTagDrop_, }) ); if (this.props.tags.length) { - const result = shared.renderTags(this.props, this.tagItem.bind(this)); + const result = shared.renderTags(this.props, this.renderItem.bind(this, BaseModel.TYPE_TAG)); const tagItems = result.items; this.tagItemsOrder_ = result.order; @@ -754,6 +787,7 @@ const mapStateToProps = state => { locale: state.settings.locale, theme: state.settings.theme, collapsedFolderIds: state.collapsedFolderIds, + collapsedTagIds: state.collapsedTagIds, decryptionWorker: state.decryptionWorker, resourceFetcher: state.resourceFetcher, sidebarVisibility: state.sidebarVisibility, diff --git a/ElectronClient/gui/TagList.jsx b/ElectronClient/gui/TagList.jsx index 2997ada860..fdb74ea021 100644 --- a/ElectronClient/gui/TagList.jsx +++ b/ElectronClient/gui/TagList.jsx @@ -2,6 +2,7 @@ const React = require('react'); const { connect } = require('react-redux'); const { themeStyle } = require('lib/theme'); const TagItem = require('./TagItem.min.js'); +const Tag = require('lib/models/Tag.js'); class TagListComponent extends React.Component { render() { @@ -21,12 +22,12 @@ class TagListComponent extends React.Component { if (tags && tags.length > 0) { // Sort by id for now, but probably needs to be changed in the future. tags.sort((a, b) => { - return a.title < b.title ? -1 : +1; + return Tag.getCachedFullTitle(a.id) < Tag.getCachedFullTitle(b.id) ? -1 : +1; }); for (let i = 0; i < tags.length; i++) { const props = { - title: tags[i].title, + title: Tag.getCachedFullTitle(tags[i].id), key: tags[i].id, }; tagItems.push(); diff --git a/ElectronClient/plugins/GotoAnything.jsx b/ElectronClient/plugins/GotoAnything.jsx index bd40cc0e7f..cdf08d6075 100644 --- a/ElectronClient/plugins/GotoAnything.jsx +++ b/ElectronClient/plugins/GotoAnything.jsx @@ -196,8 +196,9 @@ class Dialog extends React.PureComponent { if (this.state.query.indexOf('#') === 0) { // TAGS listType = BaseModel.TYPE_TAG; - searchQuery = `*${this.state.query.split(' ')[0].substr(1).trim()}*`; - results = await Tag.searchAllWithNotes({ titlePattern: searchQuery }); + searchQuery = this.state.query.split(' ')[0].substr(1).trim(); + results = await Tag.search({ fullTitleRegex: `.*${searchQuery}.*` }); + results = results.map(tag => Object.assign({}, tag, { title: Tag.getCachedFullTitle(tag.id) })); } else if (this.state.query.indexOf('@') === 0) { // FOLDERS listType = BaseModel.TYPE_FOLDER; searchQuery = `*${this.state.query.split(' ')[0].substr(1).trim()}*`; @@ -299,6 +300,18 @@ class Dialog extends React.PureComponent { } } + if (this.state.listType === BaseModel.TYPE_TAG) { + const tagPath = await Tag.tagPath(this.props.tags, item.parent_id); + + for (const tag of tagPath) { + this.props.dispatch({ + type: 'TAG_SET_COLLAPSED', + id: tag.id, + collapsed: false, + }); + } + } + if (this.state.listType === BaseModel.TYPE_NOTE) { this.props.dispatch({ type: 'FOLDER_AND_NOTE_SELECT', @@ -443,6 +456,7 @@ class Dialog extends React.PureComponent { const mapStateToProps = (state) => { return { folders: state.folders, + tags: state.tags, theme: state.settings.theme, }; }; diff --git a/ReactNativeClient/lib/components/screens/NoteTagsDialog.js b/ReactNativeClient/lib/components/screens/NoteTagsDialog.js index 9a172e45f8..989278f87d 100644 --- a/ReactNativeClient/lib/components/screens/NoteTagsDialog.js +++ b/ReactNativeClient/lib/components/screens/NoteTagsDialog.js @@ -103,13 +103,13 @@ class NoteTagsDialogComponent extends React.Component { const tagListData = this.props.tags.map(tag => { return { id: tag.id, - title: tag.title, + title: Tag.getCachedFullTitle(tag.id), selected: tagIds.indexOf(tag.id) >= 0, }; }); tagListData.sort((a, b) => { - return naturalCompare.caseInsensitive(a.title, b.title); + return naturalCompare.caseInsensitive(Tag.getCachedFullTitle(a.id), Tag.getCachedFullTitle(b.id)); }); this.setState({ tagListData: tagListData }); diff --git a/ReactNativeClient/lib/components/screens/notes.js b/ReactNativeClient/lib/components/screens/notes.js index ef39b043e6..8dfd1ad990 100644 --- a/ReactNativeClient/lib/components/screens/notes.js +++ b/ReactNativeClient/lib/components/screens/notes.js @@ -183,7 +183,8 @@ class NotesScreenComponent extends BaseScreenComponent { if (props.notesParentType == 'Folder') { output = Folder.byId(props.folders, props.selectedFolderId); } else if (props.notesParentType == 'Tag') { - output = Tag.byId(props.tags, props.selectedTagId); + const tag = Tag.byId(props.tags, props.selectedTagId); + output = Object.assign({}, tag, { title: Tag.getCachedFullTitle(tag.id) }); } else if (props.notesParentType == 'SmartFilter') { output = { id: this.props.selectedSmartFilterId, title: _('All notes') }; } else { diff --git a/ReactNativeClient/lib/components/screens/tags.js b/ReactNativeClient/lib/components/screens/tags.js index d66fc550fe..5045b5684e 100644 --- a/ReactNativeClient/lib/components/screens/tags.js +++ b/ReactNativeClient/lib/components/screens/tags.js @@ -70,7 +70,7 @@ class TagsScreenComponent extends BaseScreenComponent { }} > - {tag.title} + {Tag.getCachedFullTitle(tag.id)} ); @@ -83,7 +83,7 @@ class TagsScreenComponent extends BaseScreenComponent { async componentDidMount() { const tags = await Tag.allWithNotes(); tags.sort((a, b) => { - return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1; + return Tag.getCachedFullTitle(a.id).toLowerCase() < Tag.getCachedFullTitle(b.id).toLowerCase() ? -1 : +1; }); this.setState({ tags: tags }); } diff --git a/ReactNativeClient/lib/components/shared/reduxSharedMiddleware.js b/ReactNativeClient/lib/components/shared/reduxSharedMiddleware.js index b88b00d510..6bd867ddb0 100644 --- a/ReactNativeClient/lib/components/shared/reduxSharedMiddleware.js +++ b/ReactNativeClient/lib/components/shared/reduxSharedMiddleware.js @@ -15,6 +15,10 @@ 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(); } diff --git a/ReactNativeClient/lib/components/shared/side-menu-shared.js b/ReactNativeClient/lib/components/shared/side-menu-shared.js index 5de8d7675f..b098ef72f3 100644 --- a/ReactNativeClient/lib/components/shared/side-menu-shared.js +++ b/ReactNativeClient/lib/components/shared/side-menu-shared.js @@ -1,39 +1,55 @@ -const Folder = require('lib/models/Folder'); +const BaseItem = require('lib/models/BaseItem'); const BaseModel = require('lib/BaseModel'); const shared = {}; -function folderHasChildren_(folders, folderId) { - for (let i = 0; i < folders.length; i++) { - const folder = folders[i]; - if (folder.parent_id === folderId) return true; +function itemHasChildren_(items, itemId) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.parent_id === itemId) return true; } return false; } -function folderIsVisible(folders, folderId, collapsedFolderIds) { - if (!collapsedFolderIds || !collapsedFolderIds.length) return true; +function itemIsVisible(items, itemId, collapsedItemIds) { + if (!collapsedItemIds || !collapsedItemIds.length) return true; while (true) { - 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; + 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; } } -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)); +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)); if (hasChildren) { - const result = renderFoldersRecursive_(props, renderItem, items, folder.id, depth + 1, order); + const result = renderItemsRecursive_(props, renderItem, items, item.id, depth + 1, order, itemType); items = result.items; order = result.order; } @@ -45,25 +61,11 @@ function renderFoldersRecursive_(props, renderItem, items, parentId, depth, orde } shared.renderFolders = function(props, renderItem) { - return renderFoldersRecursive_(props, renderItem, [], '', 0, []); + return renderItemsRecursive_(props, renderItem, [], '', 0, [], BaseModel.TYPE_FOLDER); }; shared.renderTags = function(props, renderItem) { - 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, - }; + return renderItemsRecursive_(props, renderItem, [], '', 0, [], BaseModel.TYPE_TAG); }; // shared.renderSearches = function(props, renderItem) { diff --git a/ReactNativeClient/lib/components/side-menu-content.js b/ReactNativeClient/lib/components/side-menu-content.js index 5d4e0dc584..1dc7d9df18 100644 --- a/ReactNativeClient/lib/components/side-menu-content.js +++ b/ReactNativeClient/lib/components/side-menu-content.js @@ -402,6 +402,7 @@ 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, }; diff --git a/ReactNativeClient/lib/import-enex.js b/ReactNativeClient/lib/import-enex.js index cc5e95a3c6..d1cf16c5a9 100644 --- a/ReactNativeClient/lib/import-enex.js +++ b/ReactNativeClient/lib/import-enex.js @@ -185,7 +185,7 @@ async function saveNoteTags(note) { const tagTitle = note.tags[i]; let tag = await Tag.loadByTitle(tagTitle); - if (!tag) tag = await Tag.save({ title: tagTitle }); + if (!tag) tag = await Tag.saveNested({}, tagTitle); await Tag.addNote(tag.id, note.id); diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js index 62f5a1a7ed..6ddfd06923 100644 --- a/ReactNativeClient/lib/joplin-database.js +++ b/ReactNativeClient/lib/joplin-database.js @@ -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]; + 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]; let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion); @@ -735,6 +735,13 @@ 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 { diff --git a/ReactNativeClient/lib/migrations/31.js b/ReactNativeClient/lib/migrations/31.js new file mode 100644 index 0000000000..b99b46fbd2 --- /dev/null +++ b/ReactNativeClient/lib/migrations/31.js @@ -0,0 +1,33 @@ +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; diff --git a/ReactNativeClient/lib/models/Folder.js b/ReactNativeClient/lib/models/Folder.js index d995d46b7b..eedab8c8e9 100644 --- a/ReactNativeClient/lib/models/Folder.js +++ b/ReactNativeClient/lib/models/Folder.js @@ -4,6 +4,7 @@ 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 { @@ -263,22 +264,7 @@ class Folder extends BaseItem { } static folderPath(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; + return nestedPath(folders, folderId); } static folderPathString(folders, folderId, maxTotalLength = 80) { diff --git a/ReactNativeClient/lib/models/Migration.js b/ReactNativeClient/lib/models/Migration.js index 8e7964c457..585c5caffa 100644 --- a/ReactNativeClient/lib/models/Migration.js +++ b/ReactNativeClient/lib/models/Migration.js @@ -3,6 +3,7 @@ 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 { diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index 2876a9fcad..b762a23982 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -456,6 +456,7 @@ 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 }, diff --git a/ReactNativeClient/lib/models/Tag.js b/ReactNativeClient/lib/models/Tag.js index 2bb506fa34..d1091410a0 100644 --- a/ReactNativeClient/lib/models/Tag.js +++ b/ReactNativeClient/lib/models/Tag.js @@ -2,8 +2,23 @@ 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'; @@ -14,10 +29,17 @@ class Tag extends BaseItem { } static async noteIds(tagId) { - const rows = await this.db().selectAll('SELECT note_id FROM note_tags WHERE tag_id = ?', [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 output = []; for (let i = 0; i < rows.length; i++) { - output.push(rows[i].note_id); + const noteId = rows[i].note_id; + if (output.includes(noteId)) { + continue; + } + output.push(noteId); } return output; } @@ -36,8 +58,76 @@ 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) { + 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]); + } + } + 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); @@ -48,9 +138,30 @@ 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, @@ -66,6 +177,11 @@ 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), @@ -80,15 +196,69 @@ 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 loadWithCount(tagId) { - const sql = 'SELECT * FROM tags_with_note_count WHERE id = ?'; - return this.modelSelectOne(sql, [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 async hasNote(tagId, noteId) { @@ -97,19 +267,28 @@ class Tag extends BaseItem { } static async allWithNotes() { - return await Tag.modelSelectAll('SELECT * FROM tags_with_note_count'); + let tags = await Tag.all(); + + tags = tags.filter((tag) => Tag.getCachedNoteCount(tag.id) > 0); + 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 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 tagsByNoteId(noteId) { const tagIds = await NoteTag.tagIdsByNoteId(noteId); - return this.modelSelectAll(`SELECT * FROM tags WHERE id IN ("${tagIds.join('","')}")`); + const tags = await this.allWithNotes(); + return tags.filter((tag) => tagIds.includes(tag.id)); } static async commonTagsByNoteIds(noteIds) { @@ -124,16 +303,37 @@ class Tag extends BaseItem { break; } } - return this.modelSelectAll(`SELECT * FROM tags WHERE id IN ("${commonTagIds.join('","')}")`); + const tags = await this.allWithNotes(); + return tags.filter((tag) => commonTagIds.includes(tag.id)); } static async loadByTitle(title) { - return this.loadByField('title', title, { caseInsensitive: true }); + // 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; } static async addNoteTagByTitle(noteId, tagTitle) { let tag = await this.loadByTitle(tagTitle); - if (!tag) tag = await Tag.save({ title: tagTitle }, { userSideValidation: true }); + if (!tag) tag = await Tag.saveNested({}, tagTitle, { userSideValidation: true }); return await this.addNote(tag.id, noteId); } @@ -145,13 +345,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.save({ title: title }, { userSideValidation: true }); + if (!tag) tag = await Tag.saveNested({}, title, { userSideValidation: true }); await this.addNote(tag.id, noteId); addedTitles.push(title); } for (let i = 0; i < previousTags.length; i++) { - if (addedTitles.indexOf(previousTags[i].title.toLowerCase()) < 0) { + if (addedTitles.indexOf(Tag.getCachedFullTitle(previousTags[i].id).toLowerCase()) < 0) { await this.removeNote(previousTags[i].id, noteId); } } @@ -174,23 +374,130 @@ 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(); - 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)); + // 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)); + } } } - return super.save(o, options).then(tag => { + const tag = await 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; } } diff --git a/ReactNativeClient/lib/nested-utils.js b/ReactNativeClient/lib/nested-utils.js new file mode 100644 index 0000000000..e361164f5d --- /dev/null +++ b/ReactNativeClient/lib/nested-utils.js @@ -0,0 +1,22 @@ +/* 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 }; diff --git a/ReactNativeClient/lib/reducer.js b/ReactNativeClient/lib/reducer.js index 382e678f8a..ecb888aa5f 100644 --- a/ReactNativeClient/lib/reducer.js +++ b/ReactNativeClient/lib/reducer.js @@ -38,6 +38,7 @@ const defaultState = { customCss: '', templates: [], collapsedFolderIds: [], + collapsedTagIds: [], clipperServer: { startState: 'idle', port: null, @@ -172,20 +173,24 @@ function stateHasEncryptedItems(state) { return false; } -function folderSetCollapsed(state, action) { - const collapsedFolderIds = state.collapsedFolderIds.slice(); - const idx = collapsedFolderIds.indexOf(action.id); +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); if (action.collapsed) { if (idx >= 0) return state; - collapsedFolderIds.push(action.id); + collapsedItemIds.push(action.id); } else { if (idx < 0) return state; - collapsedFolderIds.splice(idx, 1); + collapsedItemIds.splice(idx, 1); } const newState = Object.assign({}, state); - newState.collapsedFolderIds = collapsedFolderIds; + newState[collapsedItemsKey] = collapsedItemIds; return newState; } @@ -768,14 +773,14 @@ const reducer = (state = defaultState, action) => { break; case 'FOLDER_SET_COLLAPSED': - newState = folderSetCollapsed(state, action); + newState = itemSetCollapsed(state, action); break; case 'FOLDER_TOGGLE': if (state.collapsedFolderIds.indexOf(action.id) >= 0) { - newState = folderSetCollapsed(state, Object.assign({ collapsed: false }, action)); + newState = itemSetCollapsed(state, Object.assign({ collapsed: false }, action)); } else { - newState = folderSetCollapsed(state, Object.assign({ collapsed: true }, action)); + newState = itemSetCollapsed(state, Object.assign({ collapsed: true }, action)); } break; @@ -784,6 +789,23 @@ 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; diff --git a/ReactNativeClient/lib/services/rest/Api.js b/ReactNativeClient/lib/services/rest/Api.js index e315c21686..3c7cb0f4ad 100644 --- a/ReactNativeClient/lib/services/rest/Api.js +++ b/ReactNativeClient/lib/services/rest/Api.js @@ -284,6 +284,67 @@ 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); } diff --git a/ReactNativeClient/lib/string-utils.js b/ReactNativeClient/lib/string-utils.js index 3314eee6e5..473b305126 100644 --- a/ReactNativeClient/lib/string-utils.js +++ b/ReactNativeClient/lib/string-utils.js @@ -264,6 +264,11 @@ 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/); @@ -285,4 +290,4 @@ function scriptType(s) { return 'en'; } -module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, nextWhitespaceIndex, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon); +module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, substrStartWithEllipsis, nextWhitespaceIndex, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon); diff --git a/ReactNativeClient/lib/synchronizer.js b/ReactNativeClient/lib/synchronizer.js index 56e7f08ac2..f456e04943 100644 --- a/ReactNativeClient/lib/synchronizer.js +++ b/ReactNativeClient/lib/synchronizer.js @@ -1,6 +1,7 @@ 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'); @@ -776,7 +777,15 @@ class Synchronizer { } const ItemClass = BaseItem.itemClass(local.type_); - await ItemClass.delete(local.id, { trackDeleted: false, changeSource: ItemChange.SOURCE_SYNC }); + 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 }); + } } } diff --git a/ReactNativeClient/root.js b/ReactNativeClient/root.js index 0621004783..80d27fce7d 100644 --- a/ReactNativeClient/root.js +++ b/ReactNativeClient/root.js @@ -536,6 +536,11 @@ async function initialize(dispatch) { ids: Setting.value('collapsedFolderIds'), }); + dispatch({ + type: 'TAG_SET_COLLAPSED_ALL', + ids: Setting.value('collapsedTagIds'), + }); + if (!folder) { dispatch(DEFAULT_ROUTE); } else {