mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Desktop: Fixes #10077: Special characters in notebooks and tags are not sorted alphabetically (#10085)
Co-authored-by: Martin Dörfelt <martin.d@andix.de> Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
parent
ea29cf4e13
commit
e9ebd845b9
@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { AppState } from '../app.reducer';
|
import { AppState } from '../app.reducer';
|
||||||
import TagItem from './TagItem';
|
import TagItem from './TagItem';
|
||||||
|
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
|
||||||
|
|
||||||
const { connect } = require('react-redux');
|
const { connect } = require('react-redux');
|
||||||
const { themeStyle } = require('@joplin/lib/theme');
|
const { themeStyle } = require('@joplin/lib/theme');
|
||||||
@ -13,6 +14,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TagList(props: Props) {
|
function TagList(props: Props) {
|
||||||
|
const collatorLocale = getCollatorLocale();
|
||||||
const style = useMemo(() => {
|
const style = useMemo(() => {
|
||||||
const theme = themeStyle(props.themeId);
|
const theme = themeStyle(props.themeId);
|
||||||
|
|
||||||
@ -29,13 +31,13 @@ function TagList(props: Props) {
|
|||||||
|
|
||||||
const tags = useMemo(() => {
|
const tags = useMemo(() => {
|
||||||
const output = props.items.slice();
|
const output = props.items.slice();
|
||||||
|
const collator = getCollator(collatorLocale);
|
||||||
output.sort((a: any, b: any) => {
|
output.sort((a: any, b: any) => {
|
||||||
return a.title < b.title ? -1 : +1;
|
return collator.compare(a.title, b.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}, [props.items]);
|
}, [props.items, collatorLocale]);
|
||||||
|
|
||||||
const tagItems = useMemo(() => {
|
const tagItems = useMemo(() => {
|
||||||
const output = [];
|
const output = [];
|
||||||
|
@ -483,6 +483,11 @@ export default class BaseApplication {
|
|||||||
refreshNotes = true;
|
refreshNotes = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'locale') {
|
||||||
|
refreshNotes = true;
|
||||||
|
doRefreshFolders = 'now';
|
||||||
|
}
|
||||||
|
|
||||||
if (action.type === 'SMART_FILTER_SELECT') {
|
if (action.type === 'SMART_FILTER_SELECT') {
|
||||||
refreshNotes = true;
|
refreshNotes = true;
|
||||||
refreshNotesUseSelectedNoteId = true;
|
refreshNotesUseSelectedNoteId = true;
|
||||||
|
@ -2,6 +2,7 @@ import Folder from '../../models/Folder';
|
|||||||
import BaseModel from '../../BaseModel';
|
import BaseModel from '../../BaseModel';
|
||||||
import { FolderEntity, TagEntity } from '../../services/database/types';
|
import { FolderEntity, TagEntity } from '../../services/database/types';
|
||||||
import { getDisplayParentId, getTrashFolderId } from '../../services/trash';
|
import { getDisplayParentId, getTrashFolderId } from '../../services/trash';
|
||||||
|
import { getCollator } from '../../models/utils/getCollator';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
folders: FolderEntity[];
|
folders: FolderEntity[];
|
||||||
@ -72,6 +73,7 @@ export const renderFolders = (props: Props, renderItem: RenderFolderItem) => {
|
|||||||
|
|
||||||
export const renderTags = (props: Props, renderItem: RenderTagItem) => {
|
export const renderTags = (props: Props, renderItem: RenderTagItem) => {
|
||||||
const tags = props.tags.slice();
|
const tags = props.tags.slice();
|
||||||
|
const collator = getCollator();
|
||||||
tags.sort((a, b) => {
|
tags.sort((a, b) => {
|
||||||
// It seems title can sometimes be undefined (perhaps when syncing
|
// It seems title can sometimes be undefined (perhaps when syncing
|
||||||
// and before tag has been decrypted?). It would be best to find
|
// 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
|
// Note: while newly created tags are normalized and lowercase
|
||||||
// imported tags might be any case, so we need to do case-insensitive
|
// imported tags might be any case, so we need to do case-insensitive
|
||||||
// sort.
|
// sort.
|
||||||
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
return collator.compare(a.title, b.title);
|
||||||
});
|
});
|
||||||
const tagItems = [];
|
const tagItems = [];
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
|
@ -223,6 +223,34 @@ describe('models/Folder', () => {
|
|||||||
expect(sortedFolderTree[2].id).toBe(f6.id);
|
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 () => {
|
it('should not allow setting a folder parent as itself', (async () => {
|
||||||
const f1 = await Folder.save({ title: 'folder1' });
|
const f1 = await Folder.save({ title: 'folder1' });
|
||||||
const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
|
const hasThrown = await checkThrowAsync(() => Folder.save({ id: f1.id, parent_id: f1.id }, { userSideValidation: true }));
|
||||||
|
@ -13,9 +13,11 @@ import syncDebugLog from '../services/synchronizer/syncDebugLog';
|
|||||||
import ResourceService from '../services/ResourceService';
|
import ResourceService from '../services/ResourceService';
|
||||||
import { LoadOptions } from './utils/types';
|
import { LoadOptions } from './utils/types';
|
||||||
import ActionLogger from '../utils/ActionLogger';
|
import ActionLogger from '../utils/ActionLogger';
|
||||||
|
|
||||||
import { getTrashFolder } from '../services/trash';
|
import { getTrashFolder } from '../services/trash';
|
||||||
import getConflictFolderId from './utils/getConflictFolderId';
|
import getConflictFolderId from './utils/getConflictFolderId';
|
||||||
import getTrashFolderId from '../services/trash/getTrashFolderId';
|
import getTrashFolderId from '../services/trash/getTrashFolderId';
|
||||||
|
import { getCollator } from './utils/getCollator';
|
||||||
const { substrWithEllipsis } = require('../string-utils.js');
|
const { substrWithEllipsis } = require('../string-utils.js');
|
||||||
|
|
||||||
const logger = Logger.create('models/Folder');
|
const logger = Logger.create('models/Folder');
|
||||||
@ -298,8 +300,18 @@ export default class Folder extends BaseItem {
|
|||||||
return output;
|
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) {
|
public static async all(options: FolderLoadOptions = null) {
|
||||||
let output: FolderEntity[] = await super.all(options);
|
let output: FolderEntity[] = await super.all(options);
|
||||||
|
if (options) {
|
||||||
|
this.handleTitleNaturalSorting(output, options);
|
||||||
|
}
|
||||||
|
|
||||||
if (options && options.includeDeleted === false) {
|
if (options && options.includeDeleted === false) {
|
||||||
output = output.filter(f => !f.deleted_time);
|
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 output = folders ? folders : await this.allAsTree();
|
||||||
|
|
||||||
const sortFoldersAlphabetically = (folders: FolderEntityWithChildren[]) => {
|
const sortFoldersAlphabetically = (folders: FolderEntityWithChildren[]) => {
|
||||||
|
const collator = getCollator();
|
||||||
folders.sort((a: FolderEntityWithChildren, b: FolderEntityWithChildren) => {
|
folders.sort((a: FolderEntityWithChildren, b: FolderEntityWithChildren) => {
|
||||||
if (a.parent_id === b.parent_id) {
|
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;
|
return 0;
|
||||||
});
|
});
|
||||||
|
@ -18,6 +18,7 @@ import { pull, removeElement, unique } from '../ArrayUtils';
|
|||||||
import { LoadOptions, SaveOptions } from './utils/types';
|
import { LoadOptions, SaveOptions } from './utils/types';
|
||||||
import ActionLogger from '../utils/ActionLogger';
|
import ActionLogger from '../utils/ActionLogger';
|
||||||
import { getDisplayParentId, getTrashFolderId } from '../services/trash';
|
import { getDisplayParentId, getTrashFolderId } from '../services/trash';
|
||||||
|
import { getCollator } from './utils/getCollator';
|
||||||
const urlUtils = require('../urlUtils.js');
|
const urlUtils = require('../urlUtils.js');
|
||||||
const { isImageMimeType } = require('../resourceUtils');
|
const { isImageMimeType } = require('../resourceUtils');
|
||||||
const { MarkupToHtml } = require('@joplin/renderer');
|
const { MarkupToHtml } = require('@joplin/renderer');
|
||||||
@ -294,8 +295,7 @@ export default class Note extends BaseItem {
|
|||||||
|
|
||||||
return noteFieldComp(a.id, b.id);
|
return noteFieldComp(a.id, b.id);
|
||||||
};
|
};
|
||||||
|
const collator = getCollator();
|
||||||
const collator = this.getNaturalSortingCollator();
|
|
||||||
|
|
||||||
return notes.sort((a: NoteEntity, b: NoteEntity) => {
|
return notes.sort((a: NoteEntity, b: NoteEntity) => {
|
||||||
if (noteOnTop(a) && !noteOnTop(b)) return -1;
|
if (noteOnTop(a) && !noteOnTop(b)) return -1;
|
||||||
@ -1121,15 +1121,11 @@ export default class Note extends BaseItem {
|
|||||||
|
|
||||||
public static handleTitleNaturalSorting(items: NoteEntity[], options: any) {
|
public static handleTitleNaturalSorting(items: NoteEntity[], options: any) {
|
||||||
if (options.order.length > 0 && options.order[0].by === 'title') {
|
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));
|
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<NoteEntity> {
|
public static async createConflictNote(sourceNote: NoteEntity, changeSource: number): Promise<NoteEntity> {
|
||||||
const conflictNote = { ...sourceNote };
|
const conflictNote = { ...sourceNote };
|
||||||
delete conflictNote.id;
|
delete conflictNote.id;
|
||||||
|
12
packages/lib/models/utils/getCollator.ts
Normal file
12
packages/lib/models/utils/getCollator.ts
Normal file
@ -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 };
|
Loading…
Reference in New Issue
Block a user