diff --git a/packages/app-desktop/gui/TagList.tsx b/packages/app-desktop/gui/TagList.tsx index 7fefb722c..5cfa9db6f 100644 --- a/packages/app-desktop/gui/TagList.tsx +++ b/packages/app-desktop/gui/TagList.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useMemo } from 'react'; import { AppState } from '../app.reducer'; import TagItem from './TagItem'; +import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator'; const { connect } = require('react-redux'); const { themeStyle } = require('@joplin/lib/theme'); @@ -13,6 +14,7 @@ interface Props { } function TagList(props: Props) { + const collatorLocale = getCollatorLocale(); const style = useMemo(() => { const theme = themeStyle(props.themeId); @@ -29,13 +31,13 @@ function TagList(props: Props) { const tags = useMemo(() => { const output = props.items.slice(); - + const collator = getCollator(collatorLocale); output.sort((a: any, b: any) => { - return a.title < b.title ? -1 : +1; + return collator.compare(a.title, b.title); }); return output; - }, [props.items]); + }, [props.items, collatorLocale]); const tagItems = useMemo(() => { const output = []; diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index ce4477b44..1c65ae59b 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -483,6 +483,11 @@ export default class BaseApplication { refreshNotes = true; } + if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'locale') { + refreshNotes = true; + doRefreshFolders = 'now'; + } + if (action.type === 'SMART_FILTER_SELECT') { refreshNotes = true; refreshNotesUseSelectedNoteId = true; diff --git a/packages/lib/components/shared/side-menu-shared.ts b/packages/lib/components/shared/side-menu-shared.ts index 6a5926614..35a13849b 100644 --- a/packages/lib/components/shared/side-menu-shared.ts +++ b/packages/lib/components/shared/side-menu-shared.ts @@ -2,6 +2,7 @@ import Folder from '../../models/Folder'; import BaseModel from '../../BaseModel'; import { FolderEntity, TagEntity } from '../../services/database/types'; import { getDisplayParentId, getTrashFolderId } from '../../services/trash'; +import { getCollator } from '../../models/utils/getCollator'; interface Props { folders: FolderEntity[]; @@ -72,6 +73,7 @@ export const renderFolders = (props: Props, renderItem: RenderFolderItem) => { export const renderTags = (props: Props, renderItem: RenderTagItem) => { const tags = props.tags.slice(); + const collator = getCollator(); tags.sort((a, b) => { // It seems title can sometimes be undefined (perhaps when syncing // and before tag has been decrypted?). It would be best to find @@ -83,7 +85,7 @@ export const renderTags = (props: Props, renderItem: RenderTagItem) => { // Note: while newly created tags are normalized and lowercase // imported tags might be any case, so we need to do case-insensitive // sort. - return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1; + return collator.compare(a.title, b.title); }); const tagItems = []; const order: string[] = []; diff --git a/packages/lib/models/Folder.test.ts b/packages/lib/models/Folder.test.ts index c877abe8a..417e45952 100644 --- a/packages/lib/models/Folder.test.ts +++ b/packages/lib/models/Folder.test.ts @@ -223,6 +223,34 @@ describe('models/Folder', () => { expect(sortedFolderTree[2].id).toBe(f6.id); })); + it('should sort folders with special chars alphabetically', (async () => { + const unsortedFolderTitles = ['ç', 'd', 'c', 'Ä', 'b', 'a'].map(firstChar => `${firstChar} folder`); + for (const folderTitle of unsortedFolderTitles) { + await Folder.save({ title: folderTitle }); + } + + const folders = await Folder.allAsTree(); + const sortedFolderTree = await Folder.sortFolderTree(folders); + + // same set of titles, but in alphabetical order + const sortedFolderTitles = ['a', 'Ä', 'b', 'c', 'ç', 'd'].map(firstChar => `${firstChar} folder`); + expect(sortedFolderTree.map(f => f.title)).toEqual(sortedFolderTitles); + })); + + it('should sort numbers ascending', (async () => { + const unsortedFolderTitles = ['10', '1', '2'].map(firstChar => `${firstChar} folder`); + for (const folderTitle of unsortedFolderTitles) { + await Folder.save({ title: folderTitle }); + } + + const folders = await Folder.allAsTree(); + const sortedFolderTree = await Folder.sortFolderTree(folders); + + // same set of titles, but in ascending order + const sortedFolderTitles = ['1', '2', '10'].map(firstChar => `${firstChar} folder`); + expect(sortedFolderTree.map(f => f.title)).toEqual(sortedFolderTitles); + })); + it('should not allow setting a folder parent as itself', (async () => { const f1 = await Folder.save({ title: 'folder1' }); const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true })); diff --git a/packages/lib/models/Folder.ts b/packages/lib/models/Folder.ts index faaf2bb7a..38d163b6d 100644 --- a/packages/lib/models/Folder.ts +++ b/packages/lib/models/Folder.ts @@ -13,9 +13,11 @@ import syncDebugLog from '../services/synchronizer/syncDebugLog'; import ResourceService from '../services/ResourceService'; import { LoadOptions } from './utils/types'; import ActionLogger from '../utils/ActionLogger'; + import { getTrashFolder } from '../services/trash'; import getConflictFolderId from './utils/getConflictFolderId'; import getTrashFolderId from '../services/trash/getTrashFolderId'; +import { getCollator } from './utils/getCollator'; const { substrWithEllipsis } = require('../string-utils.js'); const logger = Logger.create('models/Folder'); @@ -298,8 +300,18 @@ export default class Folder extends BaseItem { return output; } + public static handleTitleNaturalSorting(items: FolderEntity[], options: any) { + if (options.order?.length > 0 && options.order[0].by === 'title') { + const collator = getCollator(); + items.sort((a, b) => ((options.order[0].dir === 'ASC') ? 1 : -1) * collator.compare(a.title, b.title)); + } + } + public static async all(options: FolderLoadOptions = null) { let output: FolderEntity[] = await super.all(options); + if (options) { + this.handleTitleNaturalSorting(output, options); + } if (options && options.includeDeleted === false) { output = output.filter(f => !f.deleted_time); @@ -768,9 +780,10 @@ export default class Folder extends BaseItem { const output = folders ? folders : await this.allAsTree(); const sortFoldersAlphabetically = (folders: FolderEntityWithChildren[]) => { + const collator = getCollator(); folders.sort((a: FolderEntityWithChildren, b: FolderEntityWithChildren) => { if (a.parent_id === b.parent_id) { - return a.title.localeCompare(b.title, undefined, { sensitivity: 'accent' }); + return collator.compare(a.title, b.title); } return 0; }); diff --git a/packages/lib/models/Note.ts b/packages/lib/models/Note.ts index d5422b87b..1e425d536 100644 --- a/packages/lib/models/Note.ts +++ b/packages/lib/models/Note.ts @@ -18,6 +18,7 @@ import { pull, removeElement, unique } from '../ArrayUtils'; import { LoadOptions, SaveOptions } from './utils/types'; import ActionLogger from '../utils/ActionLogger'; import { getDisplayParentId, getTrashFolderId } from '../services/trash'; +import { getCollator } from './utils/getCollator'; const urlUtils = require('../urlUtils.js'); const { isImageMimeType } = require('../resourceUtils'); const { MarkupToHtml } = require('@joplin/renderer'); @@ -294,8 +295,7 @@ export default class Note extends BaseItem { return noteFieldComp(a.id, b.id); }; - - const collator = this.getNaturalSortingCollator(); + const collator = getCollator(); return notes.sort((a: NoteEntity, b: NoteEntity) => { if (noteOnTop(a) && !noteOnTop(b)) return -1; @@ -1121,15 +1121,11 @@ export default class Note extends BaseItem { public static handleTitleNaturalSorting(items: NoteEntity[], options: any) { if (options.order.length > 0 && options.order[0].by === 'title') { - const collator = this.getNaturalSortingCollator(); + const collator = getCollator(); items.sort((a, b) => ((options.order[0].dir === 'ASC') ? 1 : -1) * collator.compare(a.title, b.title)); } } - public static getNaturalSortingCollator() { - return new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); - } - public static async createConflictNote(sourceNote: NoteEntity, changeSource: number): Promise { const conflictNote = { ...sourceNote }; delete conflictNote.id; diff --git a/packages/lib/models/utils/getCollator.ts b/packages/lib/models/utils/getCollator.ts new file mode 100644 index 000000000..99a016496 --- /dev/null +++ b/packages/lib/models/utils/getCollator.ts @@ -0,0 +1,12 @@ +import { currentLocale, languageCodeOnly } from '../../locale'; + +function getCollator(locale: string = getCollatorLocale()) { + return new Intl.Collator(locale, { numeric: true, sensitivity: 'accent' }); +} + +function getCollatorLocale() { + const collatorLocale = languageCodeOnly(currentLocale()); + return collatorLocale; +} + +export { getCollator, getCollatorLocale };