mirror of
https://github.com/laurent22/joplin.git
synced 2024-11-27 08:21:03 +02:00
Revert "All: Added support for hierarchical/nested tags (#2572)"
This reverts commit e11e57f1d8
.
This commit is contained in:
parent
89e6b680a6
commit
64d7603eed
@ -95,8 +95,8 @@ async function handleAutocompletionPromise(line) {
|
||||
}
|
||||
|
||||
if (argName == 'tag') {
|
||||
const tags = await Tag.search({ fullTitleRegex: `${next}.*` });
|
||||
l.push(...tags.map(tag => Tag.getCachedFullTitle(tag.id)));
|
||||
const tags = await Tag.search({ titlePattern: `${next}*` });
|
||||
l.push(...tags.map(n => n.title));
|
||||
}
|
||||
|
||||
if (argName == 'file') {
|
||||
|
@ -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.saveNested({}, args.tag, { userSideValidation: true });
|
||||
if (!tag) tag = await Tag.save({ title: 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.getCachedFullTitle(tag.id));
|
||||
this.stdout(tag.title);
|
||||
});
|
||||
}
|
||||
} else if (command == 'notetags') {
|
||||
|
@ -59,7 +59,7 @@ describe('models_Tag', function() {
|
||||
expect(tags.length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should return correct note counts', asyncTest(async () => {
|
||||
it('should return tags with 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(Tag.getCachedNoteCount(tags[0].id)).toBe(2);
|
||||
expect(tags[0].note_count).toBe(2);
|
||||
|
||||
await Note.delete(note1.id);
|
||||
|
||||
tags = await Tag.allWithNotes();
|
||||
expect(tags.length).toBe(1);
|
||||
expect(Tag.getCachedNoteCount(tags[0].id)).toBe(1);
|
||||
expect(tags[0].note_count).toBe(1);
|
||||
|
||||
await Note.delete(note2.id);
|
||||
|
||||
@ -82,6 +82,21 @@ 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' });
|
||||
@ -135,211 +150,4 @@ 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);
|
||||
}));
|
||||
});
|
||||
|
@ -578,89 +578,6 @@ 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 });
|
||||
|
@ -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(<option key={tag.id}>{tag.full_title}</option>);
|
||||
tagDataListOptions.push(<option key={tag.id}>{tag.title}</option>);
|
||||
}
|
||||
|
||||
let simplifiedPageButtonLabel = 'Clip simplified page';
|
||||
|
@ -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', { fields: 'full_title' });
|
||||
const tags = await this.clipperApiExec('GET', 'tags');
|
||||
this.dispatch({ type: 'TAGS_SET', tags: tags });
|
||||
|
||||
bridge().restoreState();
|
||||
|
@ -1187,11 +1187,6 @@ 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({
|
||||
|
@ -16,11 +16,12 @@ export const runtime = (comp:any):CommandRuntime => {
|
||||
comp.setState({
|
||||
promptOptions: {
|
||||
label: _('Rename tag:'),
|
||||
value: Tag.getCachedFullTitle(tag.id),
|
||||
value: tag.title,
|
||||
onClose: async (answer:string) => {
|
||||
if (answer !== null) {
|
||||
try {
|
||||
await Tag.renameNested(tag, answer);
|
||||
tag.title = answer;
|
||||
await Tag.save(tag, { fields: ['title'], userSideValidation: true });
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
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',
|
||||
@ -15,7 +14,7 @@ export const runtime = (comp:any):CommandRuntime => {
|
||||
const tags = await Tag.commonTagsByNoteIds(noteIds);
|
||||
const startTags = tags
|
||||
.map((a:any) => {
|
||||
return { value: a.id, label: Tag.getCachedFullTitle(a.id) };
|
||||
return { value: a.id, label: a.title };
|
||||
})
|
||||
.sort((a:any, b:any) => {
|
||||
// sensitivity accent will treat accented characters as differemt
|
||||
@ -24,7 +23,7 @@ export const runtime = (comp:any):CommandRuntime => {
|
||||
});
|
||||
const allTags = await Tag.allWithNotes();
|
||||
const tagSuggestions = allTags.map((a:any) => {
|
||||
return { value: a.id, label: Tag.getCachedFullTitle(a.id) };
|
||||
return { value: a.id, label: a.title };
|
||||
})
|
||||
.sort((a:any, b:any) => {
|
||||
// sensitivity accent will treat accented characters as differemt
|
||||
@ -43,25 +42,21 @@ export const runtime = (comp:any):CommandRuntime => {
|
||||
const endTagTitles = answer.map(a => {
|
||||
return a.label.trim();
|
||||
});
|
||||
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));
|
||||
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 Tag.getCachedFullTitle(a.id); });
|
||||
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 a.title; });
|
||||
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 });
|
||||
|
@ -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, substrStartWithEllipsis } = require('lib/string-utils');
|
||||
const { substrWithEllipsis } = require('lib/string-utils');
|
||||
const { ALL_NOTES_FILTER_ID } = require('lib/reserved-ids');
|
||||
|
||||
const commands = [
|
||||
@ -64,26 +64,11 @@ 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;
|
||||
@ -96,18 +81,6 @@ 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -120,15 +93,6 @@ 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_ = [];
|
||||
|
||||
@ -242,6 +206,10 @@ class SideBarComponent extends React.Component {
|
||||
},
|
||||
};
|
||||
|
||||
style.tagItem = Object.assign({}, style.listItem);
|
||||
style.tagItem.paddingLeft = 23;
|
||||
style.tagItem.height = itemHeight;
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
@ -273,7 +241,7 @@ class SideBarComponent extends React.Component {
|
||||
buttonLabel = _('Delete');
|
||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
||||
const tag = await Tag.load(itemId);
|
||||
deleteMessage = _('Remove tag "%s" and its descendant tags from all notes?', substrStartWithEllipsis(Tag.getCachedFullTitle(tag.id), -32, 32));
|
||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
deleteMessage = _('Remove this search from the sidebar?');
|
||||
}
|
||||
@ -397,46 +365,15 @@ class SideBarComponent extends React.Component {
|
||||
return <div style={this.style().noteCount}>({count})</div>;
|
||||
}
|
||||
|
||||
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_;
|
||||
}
|
||||
|
||||
folderItem(folder, selected, hasChildren, depth) {
|
||||
let style = Object.assign({}, this.style().listItem);
|
||||
if (item.id === Folder.conflictFolderId()) style = Object.assign(style, this.style().conflictFolder);
|
||||
if (folder.id === Folder.conflictFolderId()) style = Object.assign(style, this.style().conflictFolder);
|
||||
|
||||
const itemTitle = Folder.displayTitle(folder);
|
||||
|
||||
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);
|
||||
@ -444,32 +381,35 @@ class SideBarComponent extends React.Component {
|
||||
visibility: hasChildren ? 'visible' : 'hidden',
|
||||
};
|
||||
|
||||
const iconName = collapsedIds.indexOf(item.id) >= 0 ? 'fa-chevron-right' : 'fa-chevron-down';
|
||||
const iconName = this.props.collapsedFolderIds.indexOf(folder.id) >= 0 ? 'fa-chevron-right' : 'fa-chevron-down';
|
||||
const expandIcon = <i style={expandIconStyle} className={`fas ${iconName}`}></i>;
|
||||
const expandLink = hasChildren ? (
|
||||
<a style={expandLinkStyle} href="#" {...jsxItemIdAttribute} onClick={onItemToggleClick}>
|
||||
<a style={expandLinkStyle} href="#" folderid={folder.id} onClick={this.onFolderToggleClick_}>
|
||||
{expandIcon}
|
||||
</a>
|
||||
) : (
|
||||
<span style={expandLinkStyle}>{expandIcon}</span>
|
||||
);
|
||||
|
||||
const anchorRef = this.anchorItemRef('folder', folder.id);
|
||||
const noteCount = folder.note_count ? this.noteCountElement(folder.note_count) : '';
|
||||
|
||||
return (
|
||||
<div className={`list-item-container list-item-depth-${depth}`} style={containerStyle} key={item.id} onDragStart={onDragStart} onDragOver={onDragOver} onDrop={onDrop} draggable={true} {...jsxItemIdAttribute}>
|
||||
<div className={`list-item-container list-item-depth-${depth}`} style={containerStyle} key={folder.id} onDragStart={this.onFolderDragStart_} onDragOver={this.onFolderDragOver_} onDrop={this.onFolderDrop_} draggable={true} folderid={folder.id}>
|
||||
{expandLink}
|
||||
<a
|
||||
ref={anchorRef}
|
||||
className="list-item"
|
||||
href="#"
|
||||
data-id={item.id}
|
||||
data-type={itemType}
|
||||
data-id={folder.id}
|
||||
data-type={BaseModel.TYPE_FOLDER}
|
||||
onContextMenu={event => this.itemContextMenu(event)}
|
||||
style={style}
|
||||
{...jsxItemIdAttribute}
|
||||
folderid={folder.id}
|
||||
onClick={() => {
|
||||
onItemClick(item);
|
||||
this.folderItem_click(folder);
|
||||
}}
|
||||
onDoubleClick={onItemToggleClick}
|
||||
onDoubleClick={this.onFolderToggleClick_}
|
||||
>
|
||||
{itemTitle} {noteCount}
|
||||
</a>
|
||||
@ -477,6 +417,34 @@ 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 (
|
||||
<a
|
||||
className="list-item"
|
||||
href="#"
|
||||
ref={anchorRef}
|
||||
data-id={tag.id}
|
||||
data-type={BaseModel.TYPE_TAG}
|
||||
onContextMenu={event => this.itemContextMenu(event)}
|
||||
tagid={tag.id}
|
||||
key={tag.id}
|
||||
style={style}
|
||||
onDrop={this.onTagDrop_}
|
||||
onClick={() => {
|
||||
this.tagItem_click(tag);
|
||||
}}
|
||||
>
|
||||
{Tag.displayTitle(tag)} {noteCount}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// searchItem(search, selected) {
|
||||
// let style = Object.assign({}, this.style().listItem);
|
||||
// if (selected) style = Object.assign(style, this.style().listItemSelected);
|
||||
@ -701,7 +669,7 @@ class SideBarComponent extends React.Component {
|
||||
);
|
||||
|
||||
if (this.props.folders.length) {
|
||||
const result = shared.renderFolders(this.props, this.renderItem.bind(this, BaseModel.TYPE_FOLDER));
|
||||
const result = shared.renderFolders(this.props, this.folderItem.bind(this));
|
||||
const folderItems = result.items;
|
||||
this.folderItemsOrder_ = result.order;
|
||||
items.push(
|
||||
@ -714,12 +682,11 @@ 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.renderItem.bind(this, BaseModel.TYPE_TAG));
|
||||
const result = shared.renderTags(this.props, this.tagItem.bind(this));
|
||||
const tagItems = result.items;
|
||||
this.tagItemsOrder_ = result.order;
|
||||
|
||||
@ -787,7 +754,6 @@ 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,
|
||||
|
@ -2,7 +2,6 @@ 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() {
|
||||
@ -22,12 +21,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 Tag.getCachedFullTitle(a.id) < Tag.getCachedFullTitle(b.id) ? -1 : +1;
|
||||
return a.title < b.title ? -1 : +1;
|
||||
});
|
||||
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const props = {
|
||||
title: Tag.getCachedFullTitle(tags[i].id),
|
||||
title: tags[i].title,
|
||||
key: tags[i].id,
|
||||
};
|
||||
tagItems.push(<TagItem {...props} />);
|
||||
|
@ -199,9 +199,8 @@ 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.search({ fullTitleRegex: `.*${searchQuery}.*` });
|
||||
results = results.map(tag => Object.assign({}, tag, { title: Tag.getCachedFullTitle(tag.id) }));
|
||||
searchQuery = `*${this.state.query.split(' ')[0].substr(1).trim()}*`;
|
||||
results = await Tag.searchAllWithNotes({ titlePattern: searchQuery });
|
||||
} else if (this.state.query.indexOf('@') === 0) { // FOLDERS
|
||||
listType = BaseModel.TYPE_FOLDER;
|
||||
searchQuery = `*${this.state.query.split(' ')[0].substr(1).trim()}*`;
|
||||
@ -305,18 +304,6 @@ 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',
|
||||
@ -461,7 +448,6 @@ class Dialog extends React.PureComponent {
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
tags: state.tags,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
};
|
||||
|
@ -103,13 +103,13 @@ class NoteTagsDialogComponent extends React.Component {
|
||||
const tagListData = this.props.tags.map(tag => {
|
||||
return {
|
||||
id: tag.id,
|
||||
title: Tag.getCachedFullTitle(tag.id),
|
||||
title: tag.title,
|
||||
selected: tagIds.indexOf(tag.id) >= 0,
|
||||
};
|
||||
});
|
||||
|
||||
tagListData.sort((a, b) => {
|
||||
return naturalCompare.caseInsensitive(Tag.getCachedFullTitle(a.id), Tag.getCachedFullTitle(b.id));
|
||||
return naturalCompare.caseInsensitive(a.title, b.title);
|
||||
});
|
||||
|
||||
this.setState({ tagListData: tagListData });
|
||||
|
@ -183,8 +183,7 @@ class NotesScreenComponent extends BaseScreenComponent {
|
||||
if (props.notesParentType == 'Folder') {
|
||||
output = Folder.byId(props.folders, props.selectedFolderId);
|
||||
} else if (props.notesParentType == 'Tag') {
|
||||
const tag = Tag.byId(props.tags, props.selectedTagId);
|
||||
output = Object.assign({}, tag, { title: Tag.getCachedFullTitle(tag.id) });
|
||||
output = Tag.byId(props.tags, props.selectedTagId);
|
||||
} else if (props.notesParentType == 'SmartFilter') {
|
||||
output = { id: this.props.selectedSmartFilterId, title: _('All notes') };
|
||||
} else {
|
||||
|
@ -70,7 +70,7 @@ class TagsScreenComponent extends BaseScreenComponent {
|
||||
}}
|
||||
>
|
||||
<View style={this.styles().listItem}>
|
||||
<Text style={this.styles().listItemText}>{Tag.getCachedFullTitle(tag.id)}</Text>
|
||||
<Text style={this.styles().listItemText}>{tag.title}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@ -83,7 +83,7 @@ class TagsScreenComponent extends BaseScreenComponent {
|
||||
async componentDidMount() {
|
||||
const tags = await Tag.allWithNotes();
|
||||
tags.sort((a, b) => {
|
||||
return Tag.getCachedFullTitle(a.id).toLowerCase() < Tag.getCachedFullTitle(b.id).toLowerCase() ? -1 : +1;
|
||||
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
this.setState({ tags: tags });
|
||||
}
|
||||
|
@ -15,10 +15,6 @@ const reduxSharedMiddleware = async function(store, next, action) {
|
||||
Setting.setValue('collapsedFolderIds', newState.collapsedFolderIds);
|
||||
}
|
||||
|
||||
if (action.type == 'TAG_SET_COLLAPSED' || action.type == 'TAG_TOGGLE') {
|
||||
Setting.setValue('collapsedTagIds', newState.collapsedTagIds);
|
||||
}
|
||||
|
||||
if (action.type === 'SETTING_UPDATE_ONE' && !!action.key.match(/^sync\.\d+\.path$/)) {
|
||||
reg.resetSyncTarget();
|
||||
}
|
||||
|
@ -1,55 +1,39 @@
|
||||
const BaseItem = require('lib/models/BaseItem');
|
||||
const Folder = require('lib/models/Folder');
|
||||
const BaseModel = require('lib/BaseModel');
|
||||
|
||||
const shared = {};
|
||||
|
||||
function itemHasChildren_(items, itemId) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.parent_id === itemId) return true;
|
||||
function folderHasChildren_(folders, folderId) {
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
if (folder.parent_id === folderId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function itemIsVisible(items, itemId, collapsedItemIds) {
|
||||
if (!collapsedItemIds || !collapsedItemIds.length) return true;
|
||||
function folderIsVisible(folders, folderId, collapsedFolderIds) {
|
||||
if (!collapsedFolderIds || !collapsedFolderIds.length) return true;
|
||||
|
||||
while (true) {
|
||||
const item = BaseModel.byId(items, itemId);
|
||||
if (!item) throw new Error(`No item with id ${itemId}`);
|
||||
if (!item.parent_id) return true;
|
||||
if (collapsedItemIds.indexOf(item.parent_id) >= 0) return false;
|
||||
itemId = item.parent_id;
|
||||
const folder = BaseModel.byId(folders, folderId);
|
||||
if (!folder) throw new Error(`No folder with id ${folder.id}`);
|
||||
if (!folder.parent_id) return true;
|
||||
if (collapsedFolderIds.indexOf(folder.parent_id) >= 0) return false;
|
||||
folderId = folder.parent_id;
|
||||
}
|
||||
}
|
||||
|
||||
function renderItemsRecursive_(props, renderItem, items, parentId, depth, order, itemType) {
|
||||
let itemsKey = '';
|
||||
let notesParentType = '';
|
||||
let collapsedItemsKey = '';
|
||||
let selectedItemKey = '';
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
itemsKey = 'folders';
|
||||
notesParentType = 'Folder';
|
||||
collapsedItemsKey = 'collapsedFolderIds';
|
||||
selectedItemKey = 'selectedFolderId';
|
||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
||||
itemsKey = 'tags';
|
||||
notesParentType = 'Tag';
|
||||
collapsedItemsKey = 'collapsedTagIds';
|
||||
selectedItemKey = 'selectedTagId';
|
||||
}
|
||||
|
||||
const propItems = props[itemsKey];
|
||||
for (let i = 0; i < propItems.length; i++) {
|
||||
const item = propItems[i];
|
||||
if (!BaseItem.getClassByItemType(itemType).idsEqual(item.parent_id, parentId)) continue;
|
||||
if (!itemIsVisible(props[itemsKey], item.id, props[collapsedItemsKey])) continue;
|
||||
const hasChildren = itemHasChildren_(propItems, item.id);
|
||||
order.push(item.id);
|
||||
items.push(renderItem(item, props[selectedItemKey] == item.id && props.notesParentType == notesParentType, hasChildren, depth));
|
||||
function renderFoldersRecursive_(props, renderItem, items, parentId, depth, order) {
|
||||
const folders = props.folders;
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
if (!Folder.idsEqual(folder.parent_id, parentId)) continue;
|
||||
if (!folderIsVisible(props.folders, folder.id, props.collapsedFolderIds)) continue;
|
||||
const hasChildren = folderHasChildren_(folders, folder.id);
|
||||
order.push(folder.id);
|
||||
items.push(renderItem(folder, props.selectedFolderId == folder.id && props.notesParentType == 'Folder', hasChildren, depth));
|
||||
if (hasChildren) {
|
||||
const result = renderItemsRecursive_(props, renderItem, items, item.id, depth + 1, order, itemType);
|
||||
const result = renderFoldersRecursive_(props, renderItem, items, folder.id, depth + 1, order);
|
||||
items = result.items;
|
||||
order = result.order;
|
||||
}
|
||||
@ -61,11 +45,25 @@ function renderItemsRecursive_(props, renderItem, items, parentId, depth, order,
|
||||
}
|
||||
|
||||
shared.renderFolders = function(props, renderItem) {
|
||||
return renderItemsRecursive_(props, renderItem, [], '', 0, [], BaseModel.TYPE_FOLDER);
|
||||
return renderFoldersRecursive_(props, renderItem, [], '', 0, []);
|
||||
};
|
||||
|
||||
shared.renderTags = function(props, renderItem) {
|
||||
return renderItemsRecursive_(props, renderItem, [], '', 0, [], BaseModel.TYPE_TAG);
|
||||
const tags = props.tags.slice();
|
||||
tags.sort((a, b) => {
|
||||
return a.title < b.title ? -1 : +1;
|
||||
});
|
||||
const tagItems = [];
|
||||
const order = [];
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tag = tags[i];
|
||||
order.push(tag.id);
|
||||
tagItems.push(renderItem(tag, props.selectedTagId == tag.id && props.notesParentType == 'Tag'));
|
||||
}
|
||||
return {
|
||||
items: tagItems,
|
||||
order: order,
|
||||
};
|
||||
};
|
||||
|
||||
// shared.renderSearches = function(props, renderItem) {
|
||||
|
@ -402,7 +402,6 @@ const SideMenuContent = connect(state => {
|
||||
// Don't do the opacity animation as it means re-rendering the list multiple times
|
||||
// opacity: state.sideMenuOpenPercent,
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
collapsedTagIds: state.collapsedTagIds,
|
||||
decryptionWorker: state.decryptionWorker,
|
||||
resourceFetcher: state.resourceFetcher,
|
||||
};
|
||||
|
@ -178,7 +178,7 @@ async function saveNoteTags(note) {
|
||||
const tagTitle = note.tags[i];
|
||||
|
||||
let tag = await Tag.loadByTitle(tagTitle);
|
||||
if (!tag) tag = await Tag.saveNested({}, tagTitle);
|
||||
if (!tag) tag = await Tag.save({ title: tagTitle });
|
||||
|
||||
await Tag.addNote(tag.id, note.id);
|
||||
|
||||
|
@ -326,7 +326,7 @@ class JoplinDatabase extends Database {
|
||||
// must be set in the synchronizer too.
|
||||
|
||||
// Note: v16 and v17 don't do anything. They were used to debug an issue.
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30];
|
||||
|
||||
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
||||
|
||||
@ -735,13 +735,6 @@ class JoplinDatabase extends Database {
|
||||
);
|
||||
}
|
||||
|
||||
if (targetVersion == 31) {
|
||||
queries.push('ALTER TABLE tags ADD COLUMN parent_id TEXT NOT NULL DEFAULT ""');
|
||||
// Drop the tag note count view, instead compute note count on the fly
|
||||
queries.push('DROP VIEW tags_with_note_count');
|
||||
queries.push(this.addMigrationFile(31));
|
||||
}
|
||||
|
||||
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
|
||||
|
||||
try {
|
||||
|
@ -1,33 +0,0 @@
|
||||
const Tag = require('lib/models/Tag');
|
||||
|
||||
const script = {};
|
||||
|
||||
script.exec = async function() {
|
||||
const tags = await Tag.all();
|
||||
|
||||
// In case tags with `/` exist, we want to transform them into nested tags
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tag = Object.assign({}, tags[i]);
|
||||
// Remove any starting sequence of '/'
|
||||
tag.title = tag.title.replace(/^\/*/, '');
|
||||
// Remove any ending sequence of '/'
|
||||
tag.title = tag.title.replace(/\/*$/, '');
|
||||
// Trim any sequence of '/'+ to a single '/'
|
||||
tag.title = tag.title.replace(/\/\/+/g, '/');
|
||||
|
||||
const tag_title = tag.title;
|
||||
let other = await Tag.loadByTitle(tag_title);
|
||||
let count = 1;
|
||||
// In case above trimming creates duplicate tags
|
||||
// then add a counter to the dupes
|
||||
while ((other && other.id != tag.id) && count < 1000) {
|
||||
tag.title = `${tag_title}-${count}`;
|
||||
other = await Tag.loadByTitle(tag.title);
|
||||
count++;
|
||||
}
|
||||
|
||||
await Tag.saveNested(tag, tag.title);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = script;
|
@ -4,7 +4,6 @@ const Note = require('lib/models/Note.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const { nestedPath } = require('lib/nested-utils.js');
|
||||
const { substrWithEllipsis } = require('lib/string-utils.js');
|
||||
|
||||
class Folder extends BaseItem {
|
||||
@ -264,7 +263,22 @@ class Folder extends BaseItem {
|
||||
}
|
||||
|
||||
static folderPath(folders, folderId) {
|
||||
return nestedPath(folders, folderId);
|
||||
const idToFolders = {};
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
idToFolders[folders[i].id] = folders[i];
|
||||
}
|
||||
|
||||
const path = [];
|
||||
while (folderId) {
|
||||
const folder = idToFolders[folderId];
|
||||
if (!folder) break; // Shouldn't happen
|
||||
path.push(folder);
|
||||
folderId = folder.parent_id;
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
static folderPathString(folders, folderId, maxTotalLength = 80) {
|
||||
|
@ -3,7 +3,6 @@ const BaseModel = require('lib/BaseModel.js');
|
||||
const migrationScripts = {
|
||||
20: require('lib/migrations/20.js'),
|
||||
27: require('lib/migrations/27.js'),
|
||||
31: require('lib/migrations/31.js'),
|
||||
};
|
||||
|
||||
class Migration extends BaseModel {
|
||||
|
@ -497,7 +497,6 @@ class Setting extends BaseModel {
|
||||
startMinimized: { value: false, type: Setting.TYPE_BOOL, section: 'application', public: true, appTypes: ['desktop'], label: () => _('Start application minimised in the tray icon') },
|
||||
|
||||
collapsedFolderIds: { value: [], type: Setting.TYPE_ARRAY, public: false },
|
||||
collapsedTagIds: { value: [], type: Setting.TYPE_ARRAY, public: false },
|
||||
|
||||
'keychain.supported': { value: -1, type: Setting.TYPE_INT, public: false },
|
||||
'db.ftsEnabled': { value: -1, type: Setting.TYPE_INT, public: false },
|
||||
|
@ -2,23 +2,8 @@ const BaseModel = require('lib/BaseModel.js');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const { nestedPath } = require('lib/nested-utils.js');
|
||||
const { _ } = require('lib/locale');
|
||||
|
||||
// fullTitle cache, which defaults to ''
|
||||
const fullTitleCache = new Proxy({}, {
|
||||
get: function(cache, id) {
|
||||
return cache.hasOwnProperty(id) ? cache[id] : '';
|
||||
},
|
||||
set: function(cache, id, value) {
|
||||
cache[id] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// noteCount cache
|
||||
const noteCountCache = {};
|
||||
|
||||
class Tag extends BaseItem {
|
||||
static tableName() {
|
||||
return 'tags';
|
||||
@ -29,17 +14,10 @@ class Tag extends BaseItem {
|
||||
}
|
||||
|
||||
static async noteIds(tagId) {
|
||||
const nestedTagIds = await Tag.descendantTagIds(tagId);
|
||||
nestedTagIds.push(tagId);
|
||||
|
||||
const rows = await this.db().selectAll(`SELECT note_id FROM note_tags WHERE tag_id IN ("${nestedTagIds.join('","')}")`);
|
||||
const rows = await this.db().selectAll('SELECT note_id FROM note_tags WHERE tag_id = ?', [tagId]);
|
||||
const output = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const noteId = rows[i].note_id;
|
||||
if (output.includes(noteId)) {
|
||||
continue;
|
||||
}
|
||||
output.push(noteId);
|
||||
output.push(rows[i].note_id);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@ -58,76 +36,8 @@ class Tag extends BaseItem {
|
||||
);
|
||||
}
|
||||
|
||||
static async noteCount(tagId) {
|
||||
const noteIds = await Tag.noteIds(tagId);
|
||||
// Make sure the notes exist
|
||||
const notes = await Note.byIds(noteIds);
|
||||
return notes.length;
|
||||
}
|
||||
|
||||
static async updateCachedNoteCountForIds(tagIds) {
|
||||
const tags = await Tag.byIds(tagIds);
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (!tags[i]) continue;
|
||||
noteCountCache[tags[i].id] = await Tag.noteCount(tags[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
static getCachedNoteCount(tagId) {
|
||||
return noteCountCache[tagId];
|
||||
}
|
||||
|
||||
static async childrenTagIds(parentId) {
|
||||
const rows = await this.db().selectAll('SELECT id FROM tags WHERE parent_id = ?', [parentId]);
|
||||
return rows.map(r => r.id);
|
||||
}
|
||||
|
||||
static async descendantTagIds(parentId) {
|
||||
const descendantIds = [];
|
||||
let childrenIds = await Tag.childrenTagIds(parentId);
|
||||
for (let i = 0; i < childrenIds.length; i++) {
|
||||
const childId = childrenIds[i];
|
||||
// Fail-safe in case of a loop in the tag hierarchy.
|
||||
if (descendantIds.includes(childId)) continue;
|
||||
|
||||
descendantIds.push(childId);
|
||||
childrenIds = childrenIds.concat(await Tag.childrenTagIds(childId));
|
||||
}
|
||||
return descendantIds;
|
||||
}
|
||||
|
||||
static async ancestorTags(tag) {
|
||||
const ancestorIds = [];
|
||||
const ancestors = [];
|
||||
while (tag.parent_id != '') {
|
||||
// Fail-safe in case of a loop in the tag hierarchy.
|
||||
if (ancestorIds.includes(tag.parent_id)) break;
|
||||
|
||||
tag = await Tag.load(tag.parent_id);
|
||||
// Fail-safe in case a parent isn't there
|
||||
if (!tag) break;
|
||||
ancestorIds.push(tag.id);
|
||||
ancestors.push(tag);
|
||||
}
|
||||
ancestors.reverse();
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
// Untag all the notes and delete tag
|
||||
static async untagAll(tagId, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!('deleteChildren' in options)) options.deleteChildren = true;
|
||||
|
||||
const tag = await Tag.load(tagId);
|
||||
if (!tag) return; // noop
|
||||
|
||||
if (options.deleteChildren) {
|
||||
const childrenTagIds = await Tag.childrenTagIds(tagId);
|
||||
for (let i = 0; i < childrenTagIds.length; i++) {
|
||||
await Tag.untagAll(childrenTagIds[i]);
|
||||
}
|
||||
}
|
||||
|
||||
static async untagAll(tagId) {
|
||||
const noteTags = await NoteTag.modelSelectAll('SELECT id FROM note_tags WHERE tag_id = ?', [tagId]);
|
||||
for (let i = 0; i < noteTags.length; i++) {
|
||||
await NoteTag.delete(noteTags[i].id);
|
||||
@ -138,30 +48,9 @@ class Tag extends BaseItem {
|
||||
|
||||
static async delete(id, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!('deleteChildren' in options)) options.deleteChildren = true;
|
||||
if (!('deleteNotelessParents' in options)) options.deleteNotelessParents = true;
|
||||
|
||||
const tag = await Tag.load(id);
|
||||
if (!tag) return; // noop
|
||||
|
||||
// Delete children tags
|
||||
if (options.deleteChildren) {
|
||||
const childrenTagIds = await Tag.childrenTagIds(id);
|
||||
for (let i = 0; i < childrenTagIds.length; i++) {
|
||||
await Tag.delete(childrenTagIds[i]);
|
||||
}
|
||||
}
|
||||
|
||||
await super.delete(id, options);
|
||||
|
||||
// Delete ancestor tags that do not have any associated notes left
|
||||
if (options.deleteNotelessParents && tag.parent_id) {
|
||||
const parent = await Tag.loadWithCount(tag.parent_id);
|
||||
if (!parent) {
|
||||
await Tag.delete(tag.parent_id, options);
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatch({
|
||||
type: 'TAG_DELETE',
|
||||
id: id,
|
||||
@ -177,11 +66,6 @@ class Tag extends BaseItem {
|
||||
note_id: noteId,
|
||||
});
|
||||
|
||||
// Update note counts
|
||||
const tagIdsToUpdate = await Tag.ancestorTags(tagId);
|
||||
tagIdsToUpdate.push(tagId);
|
||||
await Tag.updateCachedNoteCountForIds(tagIdsToUpdate);
|
||||
|
||||
this.dispatch({
|
||||
type: 'TAG_UPDATE_ONE',
|
||||
item: await Tag.loadWithCount(tagId),
|
||||
@ -196,69 +80,15 @@ class Tag extends BaseItem {
|
||||
await NoteTag.delete(noteTags[i].id);
|
||||
}
|
||||
|
||||
// Update note counts
|
||||
const tagIdsToUpdate = await Tag.ancestorTags(tagId);
|
||||
tagIdsToUpdate.push(tagId);
|
||||
await Tag.updateCachedNoteCountForIds(tagIdsToUpdate);
|
||||
|
||||
this.dispatch({
|
||||
type: 'NOTE_TAG_REMOVE',
|
||||
item: await Tag.load(tagId),
|
||||
});
|
||||
}
|
||||
|
||||
static async updateCachedFullTitleForIds(tagIds) {
|
||||
const tags = await Tag.byIds(tagIds);
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (!tags[i]) continue;
|
||||
fullTitleCache[tags[i].id] = await Tag.getFullTitle(tags[i]);
|
||||
}
|
||||
}
|
||||
|
||||
static getCachedFullTitle(tagId) {
|
||||
return fullTitleCache[tagId];
|
||||
}
|
||||
|
||||
static async getFullTitle(tag) {
|
||||
const ancestorTags = await Tag.ancestorTags(tag);
|
||||
ancestorTags.push(tag);
|
||||
const ancestorTitles = ancestorTags.map((t) => t.title);
|
||||
return ancestorTitles.join('/');
|
||||
}
|
||||
|
||||
static async load(id, options = null) {
|
||||
const tag = await super.load(id, options);
|
||||
if (!tag) return;
|
||||
// Update noteCount cache
|
||||
noteCountCache[tag.id] = await Tag.noteCount(tag.id);
|
||||
return tag;
|
||||
}
|
||||
|
||||
static async all(options = null) {
|
||||
const tags = await super.all(options);
|
||||
|
||||
for (const tag of tags) {
|
||||
const tagPath = Tag.tagPath(tags, tag.id);
|
||||
const pathTitles = tagPath.map((t) => t.title);
|
||||
const fullTitle = pathTitles.join('/');
|
||||
// When all tags are reloaded we can also cheaply update the cache
|
||||
fullTitleCache[tag.id] = fullTitle;
|
||||
}
|
||||
|
||||
// Update noteCount cache
|
||||
const tagIds = tags.map((tag) => tag.id);
|
||||
await Tag.updateCachedNoteCountForIds(tagIds);
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
static async loadWithCount(tagId) {
|
||||
const tag = await Tag.load(tagId);
|
||||
if (!tag) return;
|
||||
|
||||
// Make tag has notes
|
||||
if ((await Tag.getCachedNoteCount(tagId)) === 0) return;
|
||||
return tag;
|
||||
static loadWithCount(tagId) {
|
||||
const sql = 'SELECT * FROM tags_with_note_count WHERE id = ?';
|
||||
return this.modelSelectOne(sql, [tagId]);
|
||||
}
|
||||
|
||||
static async hasNote(tagId, noteId) {
|
||||
@ -267,28 +97,19 @@ class Tag extends BaseItem {
|
||||
}
|
||||
|
||||
static async allWithNotes() {
|
||||
let tags = await Tag.all();
|
||||
|
||||
tags = tags.filter((tag) => Tag.getCachedNoteCount(tag.id) > 0);
|
||||
return tags;
|
||||
return await Tag.modelSelectAll('SELECT * FROM tags_with_note_count');
|
||||
}
|
||||
|
||||
static async search(options) {
|
||||
let tags = await super.search(options);
|
||||
|
||||
// Apply fullTitleRegex on the full_title
|
||||
if (options && options.fullTitleRegex) {
|
||||
const titleRE = new RegExp(options.fullTitleRegex);
|
||||
tags = tags.filter((tag) => Tag.getCachedFullTitle(tag.id).match(titleRE));
|
||||
}
|
||||
|
||||
return tags;
|
||||
static async searchAllWithNotes(options) {
|
||||
if (!options) options = {};
|
||||
if (!options.conditions) options.conditions = [];
|
||||
options.conditions.push('id IN (SELECT distinct id FROM tags_with_note_count)');
|
||||
return this.search(options);
|
||||
}
|
||||
|
||||
static async tagsByNoteId(noteId) {
|
||||
const tagIds = await NoteTag.tagIdsByNoteId(noteId);
|
||||
const tags = await this.allWithNotes();
|
||||
return tags.filter((tag) => tagIds.includes(tag.id));
|
||||
return this.modelSelectAll(`SELECT * FROM tags WHERE id IN ("${tagIds.join('","')}")`);
|
||||
}
|
||||
|
||||
static async commonTagsByNoteIds(noteIds) {
|
||||
@ -303,37 +124,16 @@ class Tag extends BaseItem {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const tags = await this.allWithNotes();
|
||||
return tags.filter((tag) => commonTagIds.includes(tag.id));
|
||||
return this.modelSelectAll(`SELECT * FROM tags WHERE id IN ("${commonTagIds.join('","')}")`);
|
||||
}
|
||||
|
||||
static async loadByTitle(title) {
|
||||
// When loading by title we need to verify that the path from parent to child exists
|
||||
const sql = `SELECT * FROM \`${this.tableName()}\` WHERE title = ? and parent_id = ? COLLATE NOCASE`;
|
||||
const separator = '/';
|
||||
let i = title.indexOf(separator);
|
||||
let parentId = '';
|
||||
let restTitle = title;
|
||||
while (i !== -1) {
|
||||
const ancestorTitle = restTitle.slice(0,i);
|
||||
restTitle = restTitle.slice(i + 1);
|
||||
|
||||
const ancestorTag = await this.modelSelectOne(sql, [ancestorTitle, parentId]);
|
||||
if (!ancestorTag) return;
|
||||
parentId = ancestorTag.id;
|
||||
|
||||
i = restTitle.indexOf(separator);
|
||||
}
|
||||
const tag = await this.modelSelectOne(sql, [restTitle, parentId]);
|
||||
if (tag) {
|
||||
fullTitleCache[tag.id] = await Tag.getFullTitle(tag);
|
||||
}
|
||||
return tag;
|
||||
return this.loadByField('title', title, { caseInsensitive: true });
|
||||
}
|
||||
|
||||
static async addNoteTagByTitle(noteId, tagTitle) {
|
||||
let tag = await this.loadByTitle(tagTitle);
|
||||
if (!tag) tag = await Tag.saveNested({}, tagTitle, { userSideValidation: true });
|
||||
if (!tag) tag = await Tag.save({ title: tagTitle }, { userSideValidation: true });
|
||||
return await this.addNote(tag.id, noteId);
|
||||
}
|
||||
|
||||
@ -345,13 +145,13 @@ class Tag extends BaseItem {
|
||||
const title = tagTitles[i].trim().toLowerCase();
|
||||
if (!title) continue;
|
||||
let tag = await this.loadByTitle(title);
|
||||
if (!tag) tag = await Tag.saveNested({}, title, { userSideValidation: true });
|
||||
if (!tag) tag = await Tag.save({ title: title }, { userSideValidation: true });
|
||||
await this.addNote(tag.id, noteId);
|
||||
addedTitles.push(title);
|
||||
}
|
||||
|
||||
for (let i = 0; i < previousTags.length; i++) {
|
||||
if (addedTitles.indexOf(Tag.getCachedFullTitle(previousTags[i].id).toLowerCase()) < 0) {
|
||||
if (addedTitles.indexOf(previousTags[i].title.toLowerCase()) < 0) {
|
||||
await this.removeNote(previousTags[i].id, noteId);
|
||||
}
|
||||
}
|
||||
@ -374,130 +174,23 @@ class Tag extends BaseItem {
|
||||
}
|
||||
}
|
||||
|
||||
static tagPath(tags, tagId) {
|
||||
return nestedPath(tags, tagId);
|
||||
}
|
||||
|
||||
static async moveTag(tagId, parentTagId) {
|
||||
if (tagId === parentTagId
|
||||
|| (await Tag.descendantTagIds(tagId)).includes(parentTagId)) {
|
||||
throw new Error(_('Cannot move tag to this location.'));
|
||||
}
|
||||
if (!parentTagId) parentTagId = '';
|
||||
|
||||
const tag = await Tag.load(tagId);
|
||||
if (!tag) return;
|
||||
|
||||
const oldParentTagId = tag.parent_id;
|
||||
// Save new parent id
|
||||
const newTag = await Tag.save({ id: tag.id, parent_id: parentTagId }, { userSideValidation: true });
|
||||
|
||||
if (parentTagId !== oldParentTagId) {
|
||||
// If the parent tag has changed, and the ancestor doesn't
|
||||
// have notes attached, then remove it
|
||||
const oldParentWithCount = await Tag.loadWithCount(oldParentTagId);
|
||||
if (!oldParentWithCount) {
|
||||
await Tag.delete(oldParentTagId, { deleteChildren: false, deleteNotelessParents: true });
|
||||
}
|
||||
}
|
||||
|
||||
return newTag;
|
||||
}
|
||||
|
||||
static async renameNested(tag, newTitle) {
|
||||
const oldParentId = tag.parent_id;
|
||||
|
||||
tag = await Tag.saveNested(tag, newTitle, { fields: ['title', 'parent_id'], userSideValidation: true });
|
||||
|
||||
if (oldParentId !== tag.parent_id) {
|
||||
// If the parent tag has changed, and the ancestor doesn't
|
||||
// have notes attached, then remove it
|
||||
const oldParentWithCount = await Tag.loadWithCount(oldParentId);
|
||||
if (!oldParentWithCount) {
|
||||
await Tag.delete(oldParentId, { deleteChildren: false, deleteNotelessParents: true });
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
static async saveNested(tag, fullTitle, options) {
|
||||
if (!options) options = {};
|
||||
// The following option is used to prevent loops in the tag hierarchy
|
||||
if (!('mainTagId' in options) && tag.id) options.mainTagId = tag.id;
|
||||
|
||||
if (fullTitle.startsWith('/') || fullTitle.endsWith('/')) {
|
||||
throw new Error(_('Tag name cannot start or end with a `/`.'));
|
||||
} else if (fullTitle.includes('//')) {
|
||||
throw new Error(_('Tag name cannot contain `//`.'));
|
||||
}
|
||||
|
||||
const newTag = Object.assign({}, tag);
|
||||
let parentId = '';
|
||||
// Check if the tag is nested using `/` as separator
|
||||
const separator = '/';
|
||||
const i = fullTitle.lastIndexOf(separator);
|
||||
if (i !== -1) {
|
||||
const parentTitle = fullTitle.slice(0,i);
|
||||
newTag.title = fullTitle.slice(i + 1);
|
||||
|
||||
// Try to get the parent tag
|
||||
const parentTag = await Tag.loadByTitle(parentTitle);
|
||||
// The second part of the conditions ensures that we do not create a loop
|
||||
// in the tag hierarchy
|
||||
if (parentTag &&
|
||||
!('mainTagId' in options
|
||||
&& (options.mainTagId === parentTag.id
|
||||
|| (await Tag.descendantTagIds(options.mainTagId)).includes(parentTag.id)))
|
||||
) {
|
||||
parentId = parentTag.id;
|
||||
} else {
|
||||
// Create the parent tag if it doesn't exist
|
||||
const parentOpts = {};
|
||||
if ('mainTagId' in options) parentOpts.mainTagId = options.mainTagId;
|
||||
const parentTag = await Tag.saveNested({}, parentTitle, parentOpts);
|
||||
parentId = parentTag.id;
|
||||
}
|
||||
} else {
|
||||
// Tag is not nested so set the title to full title
|
||||
newTag.title = fullTitle;
|
||||
}
|
||||
|
||||
// Set parent_id
|
||||
newTag.parent_id = parentId;
|
||||
return await Tag.save(newTag, options);
|
||||
}
|
||||
|
||||
static async save(o, options = null) {
|
||||
if (options && options.userSideValidation) {
|
||||
if ('title' in o) {
|
||||
o.title = o.title.trim().toLowerCase();
|
||||
|
||||
// Check that a tag with the same title does not already exist at the same level
|
||||
let parentId = o.parent_id;
|
||||
if (!parentId) parentId = '';
|
||||
const existingCurrentLevelTags = await Tag.byIds(await Tag.childrenTagIds(parentId));
|
||||
const existingTag = existingCurrentLevelTags.find((t) => t.title === o.title);
|
||||
if (existingTag && existingTag.id !== o.id) {
|
||||
const fullTitle = await Tag.getFullTitle(existingTag);
|
||||
throw new Error(_('The tag "%s" already exists. Please choose a different name.', fullTitle));
|
||||
}
|
||||
const existingTag = await Tag.loadByTitle(o.title);
|
||||
if (existingTag && existingTag.id !== o.id) throw new Error(_('The tag "%s" already exists. Please choose a different name.', o.title));
|
||||
}
|
||||
}
|
||||
|
||||
const tag = await super.save(o, options).then(tag => {
|
||||
return super.save(o, options).then(tag => {
|
||||
this.dispatch({
|
||||
type: 'TAG_UPDATE_ONE',
|
||||
item: tag,
|
||||
});
|
||||
return tag;
|
||||
});
|
||||
|
||||
// Update fullTitleCache cache
|
||||
const tagIdsToUpdate = await Tag.descendantTagIds(tag.id);
|
||||
tagIdsToUpdate.push(tag.id);
|
||||
await Tag.updateCachedFullTitleForIds(tagIdsToUpdate);
|
||||
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,22 +0,0 @@
|
||||
/* eslint no-useless-escape: 0*/
|
||||
|
||||
function nestedPath(items, itemId) {
|
||||
const idToItem = {};
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
idToItem[items[i].id] = items[i];
|
||||
}
|
||||
|
||||
const path = [];
|
||||
while (itemId) {
|
||||
const item = idToItem[itemId];
|
||||
if (!item) break; // Shouldn't happen
|
||||
path.push(item);
|
||||
itemId = item.parent_id;
|
||||
}
|
||||
|
||||
path.reverse();
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
module.exports = { nestedPath };
|
@ -39,7 +39,6 @@ const defaultState = {
|
||||
customCss: '',
|
||||
templates: [],
|
||||
collapsedFolderIds: [],
|
||||
collapsedTagIds: [],
|
||||
clipperServer: {
|
||||
startState: 'idle',
|
||||
port: null,
|
||||
@ -174,24 +173,20 @@ function stateHasEncryptedItems(state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function itemSetCollapsed(state, action) {
|
||||
let collapsedItemsKey = null;
|
||||
if (action.type.indexOf('TAG_') !== -1) collapsedItemsKey = 'collapsedTagIds';
|
||||
else if (action.type.indexOf('FOLDER_') !== -1) collapsedItemsKey = 'collapsedFolderIds';
|
||||
|
||||
const collapsedItemIds = state[collapsedItemsKey].slice();
|
||||
const idx = collapsedItemIds.indexOf(action.id);
|
||||
function folderSetCollapsed(state, action) {
|
||||
const collapsedFolderIds = state.collapsedFolderIds.slice();
|
||||
const idx = collapsedFolderIds.indexOf(action.id);
|
||||
|
||||
if (action.collapsed) {
|
||||
if (idx >= 0) return state;
|
||||
collapsedItemIds.push(action.id);
|
||||
collapsedFolderIds.push(action.id);
|
||||
} else {
|
||||
if (idx < 0) return state;
|
||||
collapsedItemIds.splice(idx, 1);
|
||||
collapsedFolderIds.splice(idx, 1);
|
||||
}
|
||||
|
||||
const newState = Object.assign({}, state);
|
||||
newState[collapsedItemsKey] = collapsedItemIds;
|
||||
newState.collapsedFolderIds = collapsedFolderIds;
|
||||
return newState;
|
||||
}
|
||||
|
||||
@ -774,14 +769,14 @@ const reducer = (state = defaultState, action) => {
|
||||
break;
|
||||
|
||||
case 'FOLDER_SET_COLLAPSED':
|
||||
newState = itemSetCollapsed(state, action);
|
||||
newState = folderSetCollapsed(state, action);
|
||||
break;
|
||||
|
||||
case 'FOLDER_TOGGLE':
|
||||
if (state.collapsedFolderIds.indexOf(action.id) >= 0) {
|
||||
newState = itemSetCollapsed(state, Object.assign({ collapsed: false }, action));
|
||||
newState = folderSetCollapsed(state, Object.assign({ collapsed: false }, action));
|
||||
} else {
|
||||
newState = itemSetCollapsed(state, Object.assign({ collapsed: true }, action));
|
||||
newState = folderSetCollapsed(state, Object.assign({ collapsed: true }, action));
|
||||
}
|
||||
break;
|
||||
|
||||
@ -790,23 +785,6 @@ const reducer = (state = defaultState, action) => {
|
||||
newState.collapsedFolderIds = action.ids.slice();
|
||||
break;
|
||||
|
||||
case 'TAG_SET_COLLAPSED':
|
||||
newState = itemSetCollapsed(state, action);
|
||||
break;
|
||||
|
||||
case 'TAG_TOGGLE':
|
||||
if (state.collapsedTagIds.indexOf(action.id) >= 0) {
|
||||
newState = itemSetCollapsed(state, Object.assign({ collapsed: false }, action));
|
||||
} else {
|
||||
newState = itemSetCollapsed(state, Object.assign({ collapsed: true }, action));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'TAG_SET_COLLAPSED_ALL':
|
||||
newState = Object.assign({}, state);
|
||||
newState.collapsedTagIds = action.ids.slice();
|
||||
break;
|
||||
|
||||
case 'TAG_UPDATE_ALL':
|
||||
newState = Object.assign({}, state);
|
||||
newState.tags = action.items;
|
||||
|
@ -284,67 +284,6 @@ class Api {
|
||||
}
|
||||
}
|
||||
|
||||
const checkAndRemoveFullTitleField = function(request) {
|
||||
const fields = this.fields_(request, []);
|
||||
let hasFullTitleField = false;
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
if (fields[i] === 'full_title') {
|
||||
hasFullTitleField = true;
|
||||
// Remove field from field list
|
||||
fields.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the full_title field from the query
|
||||
if (hasFullTitleField) {
|
||||
if (fields.length > 0) {
|
||||
request.query.fields = fields.join(',');
|
||||
} else if (request.query && request.query.fields) {
|
||||
delete request.query.fields;
|
||||
}
|
||||
}
|
||||
|
||||
return hasFullTitleField;
|
||||
}.bind(this);
|
||||
|
||||
// Handle full_title for GET requests
|
||||
const hasFullTitleField = checkAndRemoveFullTitleField(request);
|
||||
|
||||
if (hasFullTitleField && request.method === 'GET' && !id) {
|
||||
let tags = await this.defaultAction_(BaseModel.TYPE_TAG, request, id, link);
|
||||
tags = tags.map(tag => Object.assign({}, tag, { full_title: Tag.getCachedFullTitle(tag.id) }));
|
||||
return tags;
|
||||
}
|
||||
|
||||
if (hasFullTitleField && request.method === 'GET' && id) {
|
||||
let tag = await this.defaultAction_(BaseModel.TYPE_TAG, request, id, link);
|
||||
tag = Object.assign({}, tag, { full_title: Tag.getCachedFullTitle(tag.id) });
|
||||
return tag;
|
||||
}
|
||||
|
||||
// Handle full_title for POST and PUT requests
|
||||
if (request.method === 'PUT' || request.method === 'POST') {
|
||||
const props = this.readonlyProperties(request.method);
|
||||
if (props.includes('full_title')) {
|
||||
if (request.method === 'PUT' && id) {
|
||||
const model = await Tag.load(id);
|
||||
if (!model) throw new ErrorNotFound();
|
||||
let newModel = Object.assign({}, model, request.bodyJson(props));
|
||||
newModel = await Tag.renameNested(newModel, newModel['full_title']);
|
||||
return newModel;
|
||||
}
|
||||
|
||||
if (request.method === 'POST') {
|
||||
const idIdx = props.indexOf('id');
|
||||
if (idIdx >= 0) props.splice(idIdx, 1);
|
||||
const model = request.bodyJson(props);
|
||||
const result = await Tag.saveNested(model, model['full_title'], this.defaultSaveOptions_(model, 'POST'));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.defaultAction_(BaseModel.TYPE_TAG, request, id, link);
|
||||
}
|
||||
|
||||
|
@ -264,11 +264,6 @@ function substrWithEllipsis(s, start, length) {
|
||||
return `${s.substr(start, length - 3)}...`;
|
||||
}
|
||||
|
||||
function substrStartWithEllipsis(s, start, length) {
|
||||
if (s.length <= length) return s;
|
||||
return `...${s.substr(start + 3, length)}`;
|
||||
}
|
||||
|
||||
function nextWhitespaceIndex(s, begin) {
|
||||
// returns index of the next whitespace character
|
||||
const i = s.slice(begin).search(/\s/);
|
||||
@ -290,4 +285,4 @@ function scriptType(s) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, substrStartWithEllipsis, nextWhitespaceIndex, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon);
|
||||
module.exports = Object.assign({ removeDiacritics, substrWithEllipsis, nextWhitespaceIndex, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon);
|
||||
|
@ -1,7 +1,6 @@
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const ItemChange = require('lib/models/ItemChange.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
@ -777,15 +776,7 @@ class Synchronizer {
|
||||
}
|
||||
|
||||
const ItemClass = BaseItem.itemClass(local.type_);
|
||||
if (ItemClass === Tag) {
|
||||
await Tag.delete(local.id, {
|
||||
trackDeleted: false,
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
deleteChildren: false,
|
||||
deleteNotelessParents: false });
|
||||
} else {
|
||||
await ItemClass.delete(local.id, { trackDeleted: false, changeSource: ItemChange.SOURCE_SYNC });
|
||||
}
|
||||
await ItemClass.delete(local.id, { trackDeleted: false, changeSource: ItemChange.SOURCE_SYNC });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -536,11 +536,6 @@ async function initialize(dispatch) {
|
||||
ids: Setting.value('collapsedFolderIds'),
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: 'TAG_SET_COLLAPSED_ALL',
|
||||
ids: Setting.value('collapsedTagIds'),
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
dispatch(DEFAULT_ROUTE);
|
||||
} else {
|
||||
|
Loading…
Reference in New Issue
Block a user