diff --git a/CliClient/tests/services_InteropService.js b/CliClient/tests/services_InteropService.js index 2b030c771..2a6bb9bd6 100644 --- a/CliClient/tests/services_InteropService.js +++ b/CliClient/tests/services_InteropService.js @@ -79,6 +79,45 @@ describe('services_InteropService', function() { fieldsEqual(folder3, folder1, fieldNames); })); + it('should import folders and de-duplicate titles when needed', asyncTest(async () => { + const service = new InteropService(); + const folder1 = await Folder.save({ title: 'folder' }); + const folder2 = await Folder.save({ title: 'folder' }); + const filePath = `${exportDir()}/test.jex`; + await service.export({ path: filePath }); + + await Folder.delete(folder1.id); + await Folder.delete(folder2.id); + + await service.import({ path: filePath }); + + const allFolders = await Folder.all(); + expect(allFolders.map(f => f.title).sort().join(' - ')).toBe('folder - folder (1)'); + })); + + it('should import folders, and only de-duplicate titles when needed', asyncTest(async () => { + const service = new InteropService(); + const folder1 = await Folder.save({ title: 'folder1' }); + const folder2 = await Folder.save({ title: 'folder2' }); + const sub1 = await Folder.save({ title: 'Sub', parent_id: folder1.id }); + const sub2 = await Folder.save({ title: 'Sub', parent_id: folder2.id }); + const filePath = `${exportDir()}/test.jex`; + await service.export({ path: filePath }); + + await Folder.delete(folder1.id); + await Folder.delete(folder2.id); + + await service.import({ path: filePath }); + + const importedFolder1 = await Folder.loadByTitle('folder1'); + const importedFolder2 = await Folder.loadByTitle('folder2'); + const importedSub1 = await Folder.load((await Folder.childrenIds(importedFolder1.id))[0]); + const importedSub2 = await Folder.load((await Folder.childrenIds(importedFolder2.id))[0]); + + expect(importedSub1.title).toBe('Sub'); + expect(importedSub2.title).toBe('Sub'); + })); + it('should export and import folders and notes', asyncTest(async () => { const service = new InteropService(); const folder1 = await Folder.save({ title: 'folder1' }); diff --git a/ReactNativeClient/lib/BaseModel.js b/ReactNativeClient/lib/BaseModel.js index 2aa424ba7..7b6ba9383 100644 --- a/ReactNativeClient/lib/BaseModel.js +++ b/ReactNativeClient/lib/BaseModel.js @@ -284,6 +284,21 @@ class BaseModel { return this.modelSelectOne(sql, [fieldValue]); } + static loadByFields(fields, options = null) { + if (!options) options = {}; + if (!('caseInsensitive' in options)) options.caseInsensitive = false; + if (!options.fields) options.fields = '*'; + const whereSql = []; + const params = []; + for (const fieldName in fields) { + whereSql.push(`\`${fieldName}\` = ?`); + params.push(fields[fieldName]); + } + let sql = `SELECT ${this.db().escapeFields(options.fields)} FROM \`${this.tableName()}\` WHERE ${whereSql.join(' AND ')}`; + if (options.caseInsensitive) sql += ' COLLATE NOCASE'; + return this.modelSelectOne(sql, params); + } + static loadByTitle(fieldValue) { return this.modelSelectOne(`SELECT * FROM \`${this.tableName()}\` WHERE \`title\` = ?`, [fieldValue]); } diff --git a/ReactNativeClient/lib/models/BaseItem.js b/ReactNativeClient/lib/models/BaseItem.js index 60f05ac93..08bd89d1f 100644 --- a/ReactNativeClient/lib/models/BaseItem.js +++ b/ReactNativeClient/lib/models/BaseItem.js @@ -29,11 +29,21 @@ class BaseItem extends BaseModel { throw new Error(`Invalid class name: ${className}`); } - static async findUniqueItemTitle(title) { + static async findUniqueItemTitle(title, parentId = null) { let counter = 1; let titleToTry = title; while (true) { - const item = await this.loadByField('title', titleToTry); + let item = null; + + if (parentId !== null) { + item = await this.loadByFields({ + title: titleToTry, + parent_id: parentId, + }); + } else { + item = await this.loadByField('title', titleToTry); + } + if (!item) return titleToTry; titleToTry = `${title} (${counter})`; counter++; diff --git a/ReactNativeClient/lib/services/InteropService_Importer_Raw.js b/ReactNativeClient/lib/services/InteropService_Importer_Raw.js index ceffdbaba..fe3b5aa9a 100644 --- a/ReactNativeClient/lib/services/InteropService_Importer_Raw.js +++ b/ReactNativeClient/lib/services/InteropService_Importer_Raw.js @@ -46,7 +46,7 @@ class InteropService_Importer_Raw extends InteropService_Importer_Base { let defaultFolder_ = null; const defaultFolder = async () => { if (defaultFolder_) return defaultFolder_; - const folderTitle = await Folder.findUniqueItemTitle(this.options_.defaultFolderTitle ? this.options_.defaultFolderTitle : 'Imported'); + const folderTitle = await Folder.findUniqueItemTitle(this.options_.defaultFolderTitle ? this.options_.defaultFolderTitle : 'Imported', ''); // eslint-disable-next-line require-atomic-updates defaultFolder_ = await Folder.save({ title: folderTitle }); return defaultFolder_; @@ -96,7 +96,7 @@ class InteropService_Importer_Raw extends InteropService_Importer_Base { if (!itemIdMap[item.id]) itemIdMap[item.id] = uuid.create(); item.id = itemIdMap[item.id]; - item.title = await Folder.findUniqueItemTitle(item.title); + item.title = await Folder.findUniqueItemTitle(item.title, item.parent_id); if (item.parent_id) { await setFolderToImportTo(item.parent_id);