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