1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

All: Allow sorting notes by various fields

This commit is contained in:
Laurent Cozic 2018-02-22 18:58:15 +00:00
parent 74d255c056
commit 8a96cf3434
13 changed files with 252 additions and 51 deletions

View File

@ -175,6 +175,22 @@ class Application extends BaseApplication {
updateMenu(screen) {
if (this.lastMenuScreen_ === screen) return;
const sortNoteItems = [];
const sortNoteOptions = Setting.enumOptions('notes.sortOrder.field');
for (let field in sortNoteOptions) {
if (!sortNoteOptions.hasOwnProperty(field)) continue;
sortNoteItems.push({
label: sortNoteOptions[field],
screens: ['Main'],
type: 'checkbox',
checked: Setting.value('notes.sortOrder.field') === field,
click: () => {
Setting.setValue('notes.sortOrder.field', field);
this.refreshMenu();
}
});
}
const template = [
{
label: _('File'),
@ -287,6 +303,29 @@ class Application extends BaseApplication {
name: 'toggleVisiblePanes',
});
}
}, {
type: 'separator',
screens: ['Main'],
}, {
label: Setting.settingMetadata('notes.sortOrder.field').label(),
screens: ['Main'],
submenu: sortNoteItems,
}, {
label: Setting.settingMetadata('notes.sortOrder.reverse').label(),
type: 'checkbox',
checked: Setting.setValue('notes.sortOrder.reverse'),
screens: ['Main'],
click: () => {
Setting.setValue('notes.sortOrder.reverse', !Setting.value('notes.sortOrder.reverse'));
},
}, {
label: Setting.settingMetadata('uncompletedTodosOnTop').label(),
type: 'checkbox',
checked: Setting.setValue('uncompletedTodosOnTop'),
screens: ['Main'],
click: () => {
Setting.setValue('uncompletedTodosOnTop', !Setting.value('uncompletedTodosOnTop'));
},
}],
}, {
label: _('Tools'),

View File

@ -604,7 +604,7 @@ class NoteTextComponent extends React.Component {
let bodyToRender = body;
if (!bodyToRender.trim() && visiblePanes.indexOf('viewer') >= 0 && visiblePanes.indexOf('editor') < 0) {
// Fixes https://github.com/laurent22/joplin/issues/217
bodyToRender = '*' + _('This not has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout')) + '*';
bodyToRender = '*' + _('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout')) + '*';
}
const html = this.mdToHtml().render(bodyToRender, theme, mdOptions);

View File

@ -1,5 +1,5 @@
const { createStore, applyMiddleware } = require('redux');
const { reducer, defaultState } = require('lib/reducer.js');
const { reducer, defaultState, stateUtils } = require('lib/reducer.js');
const { JoplinDatabase } = require('lib/joplin-database.js');
const { Database } = require('lib/database.js');
const { FoldersScreenUtils } = require('lib/folders-screen-utils.js');
@ -184,8 +184,9 @@ class BaseApplication {
this.logger().debug('Refreshing notes:', parentType, parentId);
let options = {
order: state.notesOrder,
order: stateUtils.notesOrder(state.settings),
uncompletedTodosOnTop: Setting.value('uncompletedTodosOnTop'),
caseInsensitive: true,
};
const source = JSON.stringify({
@ -255,14 +256,31 @@ class BaseApplication {
const result = next(action);
const newState = store.getState();
let refreshNotes = false;
if (action.type == 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE') {
Setting.setValue('activeFolderId', newState.selectedFolderId);
this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null;
await this.refreshNotes(newState);
refreshNotes = true;
}
if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop' || action.type == 'SETTING_UPDATE_ALL') {
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop') || action.type == 'SETTING_UPDATE_ALL')) {
refreshNotes = true;
}
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key.indexOf('notes.sortOrder') === 0) || action.type == 'SETTING_UPDATE_ALL')) {
refreshNotes = true;
}
if (action.type == 'TAG_SELECT' || action.type === 'TAG_DELETE') {
refreshNotes = true;
}
if (action.type == 'SEARCH_SELECT' || action.type === 'SEARCH_DELETE') {
refreshNotes = true;
}
if (refreshNotes) {
await this.refreshNotes(newState);
}
@ -288,14 +306,6 @@ class BaseApplication {
}
}
if (action.type == 'TAG_SELECT' || action.type === 'TAG_DELETE') {
await this.refreshNotes(newState);
}
if (action.type == 'SEARCH_SELECT' || action.type === 'SEARCH_DELETE') {
await this.refreshNotes(newState);
}
if (action.type === 'NOTE_UPDATE_ONE') {
// If there is a conflict, we refresh the folders so as to display "Conflicts" folder
if (action.note && action.note.is_conflict) {
@ -303,11 +313,6 @@ class BaseApplication {
}
}
// if (action.type === 'NOTE_DELETE') {
// // Update folders if a note is deleted in case the deleted note was a conflict
// await FoldersScreenUtils.refreshFolders();
// }
if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'sync.interval' || action.type == 'SETTING_UPDATE_ALL') {
reg.setupRecurrentSync();
}

View File

@ -0,0 +1,81 @@
const React = require('react');
const { Text, Modal, View, StyleSheet, Button } = require('react-native');
const { themeStyle } = require('lib/components/global-style.js');
const { _ } = require('lib/locale');
class ModalDialog extends React.Component {
constructor() {
super();
this.styles_ = {};
}
styles() {
const themeId = this.props.theme;
const theme = themeStyle(themeId);
if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {};
let styles = {
modalWrapper: {
flex: 1,
justifyContent: 'center',
},
modalContentWrapper: {
flex:1,
flexDirection: 'column',
backgroundColor: theme.backgroundColor,
borderWidth: 1,
borderColor:theme.dividerColor,
margin: 20,
padding: 10,
},
modalContentWrapper2: {
paddingTop: 10,
flex:1,
},
title: {
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
paddingBottom: 10,
},
buttonRow: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: theme.dividerColor,
paddingTop: 10,
},
};
this.styles_[themeId] = StyleSheet.create(styles);
return this.styles_[themeId];
}
render() {
const ContentComponent = this.props.ContentComponent;
return (
<View style={this.styles().modalWrapper}>
<Modal transparent={true} visible={true} onRequestClose={() => { }} >
<View style={this.styles().modalContentWrapper}>
<Text style={this.styles().title}>Title</Text>
<View style={this.styles().modalContentWrapper2}>
{ContentComponent}
</View>
<View style={this.styles().buttonRow}>
<View style={{flex:1}}>
<Button title={_('OK')} onPress={() => {}}></Button>
</View>
<View style={{flex:1, marginLeft: 5}}>
<Button title={_('Cancel')} onPress={() => {}}></Button>
</View>
</View>
</View>
</Modal>
</View>
);
}
}
module.exports = ModalDialog;

View File

@ -283,6 +283,16 @@ class ScreenHeaderComponent extends Component {
);
}
function sortButton(styles, onPress) {
return (
<TouchableOpacity onPress={onPress}>
<View style={styles.iconButton}>
<Icon name='md-funnel' style={styles.topIcon} />
</View>
</TouchableOpacity>
);
}
let key = 0;
let menuOptionComponents = [];
@ -424,6 +434,7 @@ class ScreenHeaderComponent extends Component {
const backButtonComp = backButton(this.styles(), () => this.backButton_press(), !this.props.historyCanGoBack);
const searchButtonComp = this.props.noteSelectionEnabled ? null : searchButton(this.styles(), () => this.searchButton_press());
const deleteButtonComp = this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press()) : null;
const sortButtonComp = this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
const windowHeight = Dimensions.get('window').height - 50;
const menuComp = (
@ -448,6 +459,7 @@ class ScreenHeaderComponent extends Component {
{ titleComp }
{ searchButtonComp }
{ deleteButtonComp }
{ sortButtonComp }
{ menuComp }
</View>
{ warningComp }

View File

@ -65,9 +65,9 @@ class NoteScreenComponent extends BaseScreenComponent {
const saveDialog = async () => {
if (this.isModified()) {
let buttonId = await dialogs.pop(this, _('This note has been modified:'), [
{ title: _('Save changes'), id: 'save' },
{ title: _('Discard changes'), id: 'discard' },
{ title: _('Cancel'), id: 'cancel' },
{ text: _('Save changes'), id: 'save' },
{ text: _('Discard changes'), id: 'discard' },
{ text: _('Cancel'), id: 'cancel' },
]);
if (buttonId == 'cancel') return true;

View File

@ -1,5 +1,6 @@
const React = require('react'); const Component = React.Component;
const { View, Button } = require('react-native');
const { View, Button, Text } = require('react-native');
const { stateUtils } = require('lib/reducer.js');
const { connect } = require('react-redux');
const { reg } = require('lib/registry.js');
const { Log } = require('lib/log.js');
@ -10,7 +11,7 @@ const Note = require('lib/models/Note.js');
const Setting = require('lib/models/Setting.js');
const { themeStyle } = require('lib/components/global-style.js');
const { ScreenHeader } = require('lib/components/screen-header.js');
const { MenuOption, Text } = require('react-native-popup-menu');
const { MenuOption } = require('react-native-popup-menu');
const { _ } = require('lib/locale.js');
const { ActionButton } = require('lib/components/action-button.js');
const { dialogs } = require('lib/dialogs.js');
@ -23,6 +24,43 @@ class NotesScreenComponent extends BaseScreenComponent {
return { header: null };
}
constructor() {
super();
this.sortButton_press = async () => {
const buttons = [];
const sortNoteOptions = Setting.enumOptions('notes.sortOrder.field');
const makeCheckboxText = function(selected, sign, label) {
const s = sign === 'tick' ? '✓' : '⬤'
return (selected ? (s + ' ') : '') + label;
}
for (let field in sortNoteOptions) {
if (!sortNoteOptions.hasOwnProperty(field)) continue;
buttons.push({
text: makeCheckboxText(Setting.value('notes.sortOrder.field') === field, 'bullet', sortNoteOptions[field]),
id: { name: 'notes.sortOrder.field', value: field },
});
}
buttons.push({
text: makeCheckboxText(Setting.value('notes.sortOrder.reverse'), 'tick', '[ ' + Setting.settingMetadata('notes.sortOrder.reverse').label() + ' ]'),
id: { name: 'notes.sortOrder.reverse', value: !Setting.value('notes.sortOrder.reverse') },
});
buttons.push({
text: makeCheckboxText(Setting.value('uncompletedTodosOnTop'), 'tick', '[ ' + Setting.settingMetadata('uncompletedTodosOnTop').label() + ' ]'),
id: { name: 'uncompletedTodosOnTop', value: !Setting.value('uncompletedTodosOnTop') },
});
const r = await dialogs.pop(this, Setting.settingMetadata('notes.sortOrder.field').label(), buttons);
if (!r) return;
Setting.setValue(r.name, r.value);
}
}
async componentDidMount() {
await this.refreshNotes();
}
@ -42,6 +80,7 @@ class NotesScreenComponent extends BaseScreenComponent {
let options = {
order: props.notesOrder,
uncompletedTodosOnTop: props.uncompletedTodosOnTop,
caseInsensitive: true,
};
const parent = this.parentItem(props);
@ -155,6 +194,7 @@ class NotesScreenComponent extends BaseScreenComponent {
title={title}
menuOptions={this.menuOptions()}
parentComponent={thisComp}
sortButton_press={this.sortButton_press}
folderPickerOptions={{
enabled: this.props.noteSelectionEnabled,
mustSelect: true,
@ -178,11 +218,11 @@ const NotesScreen = connect(
selectedTagId: state.selectedTagId,
notesParentType: state.notesParentType,
notes: state.notes,
notesOrder: state.notesOrder,
notesSource: state.notesSource,
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
theme: state.settings.theme,
noteSelectionEnabled: state.noteSelectionEnabled,
notesOrder: stateUtils.notesOrder(state.settings),
};
}
)(NotesScreenComponent)

View File

@ -33,17 +33,20 @@ dialogs.confirm = (parentComponent, message) => {
});
};
dialogs.pop = (parentComponent, message, buttons) => {
dialogs.pop = (parentComponent, message, buttons, options = null) => {
if (!parentComponent) throw new Error('parentComponent is required');
if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!');
if (!options) options = {};
if (!('buttonFlow' in options)) options.buttonFlow = 'auto';
return new Promise((resolve, reject) => {
Keyboard.dismiss();
let btns = [];
for (let i = 0; i < buttons.length; i++) {
btns.push({
text: buttons[i].title,
text: buttons[i].text,
callback: () => {
parentComponent.dialogbox.close();
resolve(buttons[i].id);
@ -54,6 +57,7 @@ dialogs.pop = (parentComponent, message, buttons) => {
parentComponent.dialogbox.pop({
content: message,
btns: btns,
buttonFlow: options.buttonFlow,
});
});
}

View File

@ -15,6 +15,16 @@ class Note extends BaseItem {
return 'notes';
}
static fieldToLabel(field) {
const fieldsToLabels = {
title: 'title',
user_updated_time: 'updated date',
user_created_time: 'created date',
};
return field in fieldsToLabels ? fieldsToLabels[field] : field;
}
static async serialize(note, type = null, shownKeys = null) {
let fieldNames = this.fieldNames();
fieldNames.push('type_');

View File

@ -5,6 +5,7 @@ const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
const { time } = require('lib/time-utils.js');
const { sprintf } = require('sprintf-js');
const ObjectUtils = require('lib/ObjectUtils');
const { toTitleCase } = require('lib/string-utils.js');
const { _, supportedLocalesToLanguages, defaultLocale } = require('lib/locale.js');
class Setting extends BaseModel {
@ -20,6 +21,10 @@ class Setting extends BaseModel {
static metadata() {
if (this.metadata_) return this.metadata_;
// A "public" setting means that it will show up in the various config screens (or config command for the CLI tool), however
// if if private a setting might still be handled and modified by the app. For instance, the settings related to sorting notes are not
// public for the mobile and desktop apps because they are handled separately in menus.
this.metadata_ = {
'activeFolderId': { value: '', type: Setting.TYPE_STRING, public: false },
'firstStart': { value: true, type: Setting.TYPE_BOOL, public: false },
@ -50,16 +55,17 @@ class Setting extends BaseModel {
output[Setting.THEME_DARK] = _('Dark');
return output;
}},
// 'logLevel': { value: Logger.LEVEL_INFO, type: Setting.TYPE_STRING, isEnum: true, public: true, label: () => _('Log level'), options: () => {
// return Logger.levelEnum();
// }},
// Not used for now:
// 'todoFilter': { value: 'all', type: Setting.TYPE_STRING, isEnum: true, public: false, appTypes: ['mobile'], label: () => _('Todo filter'), options: () => ({
// all: _('Show all'),
// recent: _('Non-completed and recently completed ones'),
// nonCompleted: _('Non-completed ones only'),
// })},
'uncompletedTodosOnTop': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Show uncompleted to-dos on top of the lists') },
'uncompletedTodosOnTop': { value: true, type: Setting.TYPE_BOOL, public: true, appTypes: ['cli'], label: () => _('Uncompleted to-dos on top') },
'notes.sortOrder.field': { value: 'user_updated_time', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['cli'], label: () => _('Sort notes by'), options: () => {
const Note = require('lib/models/Note');
const noteSortFields = ['user_updated_time', 'user_created_time', 'title'];
const options = {};
for (let i = 0; i < noteSortFields.length; i++) {
options[noteSortFields[i]] = toTitleCase(Note.fieldToLabel(noteSortFields[i]));
}
return options;
}},
'notes.sortOrder.reverse': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] },
'trackLocation': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Save geo-location with notes') },
'newTodoFocus': { value: 'title', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('When creating a new to-do:'), options: () => {
return {

View File

@ -19,19 +19,24 @@ const defaultState = {
showSideMenu: false,
screens: {},
historyCanGoBack: false,
notesOrder: [
{ by: 'user_updated_time', dir: 'DESC' },
],
syncStarted: false,
syncReport: {},
searchQuery: '',
settings: {},
appState: 'starting',
//windowContentSize: { width: 0, height: 0 },
hasDisabledSyncItems: false,
newNote: null,
};
const stateUtils = {};
stateUtils.notesOrder = function(stateSettings) {
return [{
by: stateSettings['notes.sortOrder.field'],
dir: stateSettings['notes.sortOrder.reverse'] ? 'DESC' : 'ASC',
}];
}
function arrayHasEncryptedItems(array) {
for (let i = 0; i < array.length; i++) {
if (!!array[i].encryption_applied) return true;
@ -90,8 +95,6 @@ function handleItemDelete(state, action) {
}
function updateOneItem(state, action) {
// let newItems = action.type === 'TAG_UPDATE_ONE' ? state.tags.splice(0) : state.folders.splice(0);
// let item = action.type === 'TAG_UPDATE_ONE' ? action.tag : action.folder;
let itemsKey = null;
if (action.type === 'TAG_UPDATE_ONE') itemsKey = 'tags';
if (action.type === 'FOLDER_UPDATE_ONE') itemsKey = 'folders';
@ -116,12 +119,6 @@ function updateOneItem(state, action) {
newState[itemsKey] = newItems;
// if (action.type === 'TAG_UPDATE_ONE') {
// newState.tags = newItems;
// } else {
// newState.folders = newItems;
// }
return newState;
}
@ -316,7 +313,8 @@ const reducer = (state = defaultState, action) => {
}
}
newNotes = Note.sortNotes(newNotes, state.notesOrder, newState.settings.uncompletedTodosOnTop);
//newNotes = Note.sortNotes(newNotes, state.notesOrder, newState.settings.uncompletedTodosOnTop);
newNotes = Note.sortNotes(newNotes, stateUtils.notesOrder(state.settings), newState.settings.uncompletedTodosOnTop);
newState = Object.assign({}, state);
newState.notes = newNotes;
@ -481,4 +479,4 @@ const reducer = (state = defaultState, action) => {
return newState;
}
module.exports = { reducer, defaultState };
module.exports = { reducer, defaultState, stateUtils };

View File

@ -201,4 +201,9 @@ function padLeft(string, length, padString) {
return string;
}
module.exports = { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft };
function toTitleCase(string) {
if (!string) return string;
return string.charAt(0).toUpperCase() + string.slice(1);
}
module.exports = { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase };

View File

@ -21,6 +21,7 @@
"docs/*.html",
"docs/*.svg",
"ReactNativeClient/lib/mime-utils.js",
"_mydocs/EnexSamples/*.enex",
],
"folder_exclude_patterns":
[