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

Electron: Resolves #755: Added note properties dialog box to view and edit created time, updated time, source URL and geolocation

This commit is contained in:
Laurent Cozic 2018-09-16 19:37:31 +01:00
parent 1b784fe3b0
commit 4e8372174b
11 changed files with 465 additions and 23 deletions

View File

@ -5,6 +5,7 @@ const { SideBar } = require('./SideBar.min.js');
const { NoteList } = require('./NoteList.min.js');
const { NoteText } = require('./NoteText.min.js');
const { PromptDialog } = require('./PromptDialog.min.js');
const NotePropertiesDialog = require('./NotePropertiesDialog.min.js');
const Setting = require('lib/models/Setting.js');
const BaseModel = require('lib/BaseModel.js');
const Tag = require('lib/models/Tag.js');
@ -19,13 +20,24 @@ const eventManager = require('../eventManager');
class MainScreenComponent extends React.Component {
constructor() {
super();
this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this);
}
notePropertiesDialog_close() {
this.setState({ notePropertiesDialogOptions: {} });
}
componentWillMount() {
this.setState({
promptOptions: null,
modalLayer: {
visible: false,
message: '',
}
},
notePropertiesDialogOptions: {},
});
}
@ -189,6 +201,13 @@ class MainScreenComponent extends React.Component {
});
}
} else if (command.name === 'commandNoteProperties') {
this.setState({
notePropertiesDialogOptions: {
noteId: command.noteId,
visible: true,
},
});
} else if (command.name === 'toggleVisiblePanes') {
this.toggleVisiblePanes();
} else if (command.name === 'toggleSidebar') {
@ -412,10 +431,19 @@ class MainScreenComponent extends React.Component {
const modalLayerStyle = Object.assign({}, styles.modalLayer, { display: this.state.modalLayer.visible ? 'block' : 'none' });
const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions;
return (
<div style={style}>
<div style={modalLayerStyle}>{this.state.modalLayer.message}</div>
<NotePropertiesDialog
theme={this.props.theme}
noteId={notePropertiesDialogOptions.noteId}
visible={!!notePropertiesDialogOptions.visible}
onClose={this.notePropertiesDialog_close}
/>
<PromptDialog
autocomplete={promptOptions && ('autocomplete' in promptOptions) ? promptOptions.autocomplete : null}
defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''}
@ -427,6 +455,7 @@ class MainScreenComponent extends React.Component {
visible={!!this.state.promptOptions}
buttons={promptOptions && ('buttons' in promptOptions) ? promptOptions.buttons : null}
inputType={promptOptions && ('inputType' in promptOptions) ? promptOptions.inputType : null} />
<Header style={styles.header} showBackButton={false} items={headerItems} />
{messageComp}
<SideBar style={styles.sideBar} />

View File

@ -0,0 +1,364 @@
const React = require('react');
const { connect } = require('react-redux');
const { _ } = require('lib/locale.js');
const moment = require('moment');
const { themeStyle } = require('../theme.js');
const { time } = require('lib/time-utils.js');
const Datetime = require('react-datetime');
const Note = require('lib/models/Note');
const formatcoords = require('formatcoords');
const { bridge } = require('electron').remote.require('./bridge');
class NotePropertiesDialog extends React.Component {
constructor() {
super();
this.okButton_click = this.okButton_click.bind(this);
this.cancelButton_click = this.cancelButton_click.bind(this);
this.state = {
formNote: null,
editedKey: null,
editedValue: null,
visible: false,
};
this.keyToLabel_ = {
id: _('ID'),
user_created_time: _('Created'),
user_updated_time: _('Updated'),
location: _('Location'),
source_url: _('URL'),
};
}
componentWillReceiveProps(newProps) {
if ('visible' in newProps && newProps.visible !== this.state.visible) {
this.setState({ visible: newProps.visible });
}
if ('noteId' in newProps) {
this.loadNote(newProps.noteId);
}
}
async loadNote(noteId) {
if (!noteId) {
this.setState({ formNote: null });
} else {
const note = await Note.load(noteId);
const formNote = this.noteToFormNote(note);
this.setState({ formNote: formNote });
}
}
latLongFromLocation(location) {
const o = {};
const l = location.split(',');
if (l.length == 2) {
o.latitude = l[0].trim();
o.longitude = l[1].trim();
} else {
o.latitude = '';
o.longitude = '';
}
return o;
}
noteToFormNote(note) {
const formNote = {};
formNote.user_updated_time = time.formatMsToLocal(note.user_updated_time);
formNote.user_created_time = time.formatMsToLocal(note.user_created_time);
formNote.source_url = note.source_url;
formNote.location = '';
if (Number(note.latitude) || Number(note.longitude)) {
formNote.location = note.latitude + ', ' + note.longitude;
}
formNote.id = note.id;
return formNote;
}
formNoteToNote(formNote) {
const note = Object.assign({ id: formNote.id }, this.latLongFromLocation(formNote.location));
note.user_created_time = time.formatLocalToMs(formNote.user_created_time);
note.user_updated_time = time.formatLocalToMs(formNote.user_updated_time);
note.source_url = formNote.source_url;
return note;
}
styles(themeId) {
const styleKey = themeId;
if (styleKey === this.styleKey_) return this.styles_;
const theme = themeStyle(themeId);
this.styles_ = {};
this.styleKey_ = styleKey;
this.styles_.modalLayer = {
zIndex: 9999,
display: 'flex',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'flex-start',
justifyContent: 'center',
};
this.styles_.dialogBox = {
backgroundColor: 'white',
padding: 16,
boxShadow: '6px 6px 20px rgba(0,0,0,0.5)',
marginTop: 20,
}
this.styles_.controlBox = {
marginBottom: '1em',
};
this.styles_.button = {
minWidth: theme.buttonMinWidth,
minHeight: theme.buttonMinHeight,
marginLeft: 5,
};
this.styles_.editPropertyButton = {
color: theme.color,
textDecoration: 'none',
};
this.styles_.dialogTitle = Object.assign({}, theme.h1Style, { marginBottom: '1.2em' });
return this.styles_;
}
async closeDialog(applyChanges) {
if (applyChanges) {
await this.saveProperty();
const note = this.formNoteToNote(this.state.formNote);
note.updated_time = Date.now();
await Note.save(note, { autoTimestamp: false });
} else {
await this.cancelProperty();
}
this.setState({
visible: false,
});
if (this.props.onClose) {
this.props.onClose();
}
}
okButton_click() {
this.closeDialog(true);
}
cancelButton_click() {
this.closeDialog(false);
}
editPropertyButtonClick(key, initialValue) {
this.setState({
editedKey: key,
editedValue: initialValue,
});
setTimeout(() => {
if (this.refs.editField.openCalendar) {
this.refs.editField.openCalendar();
} else {
this.refs.editField.focus();
}
}, 100);
}
async saveProperty() {
if (!this.state.editedKey) return;
return new Promise((resolve, reject) => {
const newFormNote = Object.assign({}, this.state.formNote);
if (this.state.editedKey.indexOf('_time') >= 0) {
const dt = time.anythingToDateTime(this.state.editedValue, new Date());
newFormNote[this.state.editedKey] = time.formatMsToLocal(dt.getTime());
} else {
newFormNote[this.state.editedKey] = this.state.editedValue;
}
this.setState({
formNote: newFormNote,
editedKey: null,
editedValue: null
}, () => { resolve() });
});
}
async cancelProperty() {
return new Promise((resolve, reject) => {
this.setState({
editedKey: null,
editedValue: null
}, () => { resolve() });
});
}
createNoteField(key, value) {
const styles = this.styles(this.props.theme);
const theme = themeStyle(this.props.theme);
const labelComp = <label style={Object.assign({}, theme.textStyle, {marginRight: '1em', width: '6em', display:'inline-block', fontWeight: 'bold'})}>{this.formatLabel(key)}</label>;
let controlComp = null;
let editComp = null;
let editCompHandler = null;
let editCompIcon = null;
const onKeyDown = (event) => {
if (event.keyCode === 13) {
this.saveProperty();
} else if (event.keyCode === 27) {
this.cancelProperty();
}
}
if (this.state.editedKey === key) {
if (key.indexOf('_time') >= 0) {
controlComp = <Datetime
ref="editField"
defaultValue={value}
dateFormat={time.dateFormat()}
timeFormat={time.timeFormat()}
inputProps={{
onKeyDown: (event) => onKeyDown(event, key)
}}
onChange={(momentObject) => {this.setState({ editedValue: momentObject })}}
/>
editCompHandler = () => {this.saveProperty()};
editCompIcon = 'fa-save';
} else {
controlComp = <input
defaultValue={value}
type="text"
ref="editField"
onChange={(event) => {this.setState({ editedValue: event.target.value })}}
onKeyDown={(event) => onKeyDown(event)}
style={{display:'inline-block'}}
/>
}
} else {
let displayedValue = value;
if (key === 'location') {
try {
const dms = formatcoords(value);
displayedValue = dms.format('DDMMss', { decimalPlaces: 0 });
} catch (error) {
displayedValue = '';
}
}
if (['source_url', 'location'].indexOf(key) >= 0) {
let url = '';
if (key === 'source_url') url = value;
if (key === 'location') {
const ll = this.latLongFromLocation(value);
url = Note.geoLocationUrlFromLatLong(ll.latitude, ll.longitude);
}
controlComp = <a href="#" onClick={() => bridge().openExternal(url)} style={theme.urlStyle}>{displayedValue}</a>
} else {
controlComp = <div style={Object.assign({}, theme.textStyle, {display: 'inline-block'})}>{displayedValue}</div>
}
if (key !== 'id') {
editCompHandler = () => {this.editPropertyButtonClick(key, value)};
editCompIcon = 'fa-edit';
}
}
if (editCompHandler) {
editComp = (
<a href="#" onClick={editCompHandler} style={styles.editPropertyButton}>
<i className={'fa ' + editCompIcon} aria-hidden="true" style={{ marginLeft: '.5em'}}></i>
</a>
);
}
return (
<div key={key} style={this.styles_.controlBox} className="note-property-box">
{ labelComp }
{ controlComp }
{ editComp }
</div>
);
}
formatLabel(key) {
if (this.keyToLabel_[key]) return this.keyToLabel_[key];
return key;
}
formatValue(key, note) {
if (key === 'location') {
if (!Number(note.latitude) && !Number(note.longitude)) return null;
const dms = formatcoords(Number(note.latitude), Number(note.longitude))
return dms.format('DDMMss', { decimalPlaces: 0 });
}
if (['user_updated_time', 'user_created_time'].indexOf(key) >= 0) {
return time.formatMsToLocal(note[key]);
}
return note[key];
}
render() {
const style = this.props.style;
const theme = themeStyle(this.props.theme);
const styles = this.styles(this.props.theme);
const formNote = this.state.formNote;
const buttonComps = [];
buttonComps.push(<button key="ok" style={styles.button} onClick={this.okButton_click}>{_('Apply')}</button>);
buttonComps.push(<button key="cancel" style={styles.button} onClick={this.cancelButton_click}>{_('Cancel')}</button>);
const noteComps = [];
const modalLayerStyle = Object.assign({}, styles.modalLayer);
if (!this.state.visible) modalLayerStyle.display = 'none';
if (formNote) {
for (let key in formNote) {
if (!formNote.hasOwnProperty(key)) continue;
const comp = this.createNoteField(key, formNote[key]);
noteComps.push(comp);
}
}
return (
<div style={modalLayerStyle}>
<div style={styles.dialogBox}>
<div style={styles.dialogTitle}>Note properties</div>
<div>{noteComps}</div>
<div style={{ textAlign: 'right', marginTop: 10 }}>
{buttonComps}
</div>
</div>
</div>
);
}
}
module.exports = NotePropertiesDialog;

View File

@ -1145,6 +1145,25 @@ class NoteTextComponent extends React.Component {
type: 'separator',
});
toolbarItems.push({
tooltip: _('Note properties'),
iconName: 'fa-info-circle',
onClick: () => {
const n = this.state.note;
if (!n || !n.id) return;
this.props.dispatch({
type: 'WINDOW_COMMAND',
name: 'commandNoteProperties',
noteId: n.id,
});
},
});
toolbarItems.push({
type: 'separator',
});
toolbarItems.push({
tooltip: _('Hyperlink'),
iconName: 'fa-link',

View File

@ -102,7 +102,8 @@ class PromptDialog extends React.Component {
if (this.props.onClose) {
let outputAnswer = this.state.answer;
if (this.props.inputType === 'datetime') {
outputAnswer = anythingToDate(outputAnswer);
// outputAnswer = anythingToDate(outputAnswer);
outputAnswer = time.anythingToDateTime(outputAnswer);
}
this.props.onClose(accept ? outputAnswer : null, buttonType);
}
@ -113,14 +114,14 @@ class PromptDialog extends React.Component {
this.setState({ answer: event.target.value });
}
const anythingToDate = (o) => {
if (o && o.toDate) return o.toDate();
if (!o) return null;
let m = moment(o, time.dateTimeFormat());
if (m.isValid()) return m.toDate();
m = moment(o, time.dateFormat());
return m.isValid() ? m.toDate() : null;
}
// const anythingToDate = (o) => {
// if (o && o.toDate) return o.toDate();
// if (!o) return null;
// let m = moment(o, time.dateTimeFormat());
// if (m.isValid()) return m.toDate();
// m = moment(o, time.dateFormat());
// return m.isValid() ? m.toDate() : null;
// }
const onDateTimeChange = (momentObject) => {
this.setState({ answer: momentObject });

View File

@ -2693,6 +2693,11 @@
"mime-types": "^2.1.12"
}
},
"formatcoords": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/formatcoords/-/formatcoords-1.1.3.tgz",
"integrity": "sha1-dS8FarL+NMHUrooZBIzgw44W7QM="
},
"fragment-cache": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",

View File

@ -87,6 +87,7 @@
"es6-promise-pool": "^2.5.0",
"follow-redirects": "^1.5.0",
"form-data": "^2.3.2",
"formatcoords": "^1.1.3",
"fs-extra": "^5.0.0",
"highlight.js": "^9.12.0",
"html-entities": "^1.2.1",

View File

@ -77,4 +77,8 @@ table td, table th {
.smalltalk {
font-family: sans-serif;
}
.note-property-box .rdt {
display: inline-block;
}

View File

@ -76,11 +76,15 @@ globalStyle.textStyle2 = Object.assign({}, globalStyle.textStyle, {
color: globalStyle.color2,
});
globalStyle.urlStyle = Object.assign({}, globalStyle.textStyle, { color: "#155BDA", textDecoration: 'underline' });
globalStyle.h1Style = Object.assign({}, globalStyle.textStyle);
globalStyle.h1Style.fontSize *= 1.5;
globalStyle.h1Style.fontWeight = 'bold';
globalStyle.h2Style = Object.assign({}, globalStyle.textStyle);
globalStyle.h2Style.fontSize *= 1.3;
globalStyle.h2Style.fontWeight = 'bold';
globalStyle.toolbarStyle = {
height: globalStyle.toolbarHeight,

View File

@ -287,7 +287,7 @@ class BaseModel {
}
// Remove fields that are not in the `fields` list, if provided.
// Note that things like update_time, user_update_time will still
// Note that things like update_time, user_updated_time will still
// be part of the final list of fields if autoTimestamp is on.
// id also will stay.
if (!options.isNew && options.fields) {
@ -314,6 +314,10 @@ class BaseModel {
// The purpose of user_updated_time is to allow the user to manually set the time of a note (in which case
// options.autoTimestamp will be `false`). However note that if the item is later changed, this timestamp
// will be set again to the current time.
//
// The technique to modify user_updated_time while keeping updated_time current (so that sync can happen) is to
// manually set updated_time when saving and to set autoTimestamp to false, for example:
// Note.save({ id: "...", updated_time: Date.now(), user_updated_time: 1436342618000 }, { autoTimestamp: false })
if (options.autoTimestamp && this.hasField('user_updated_time')) {
o.user_updated_time = timeNow;
}

View File

@ -100,7 +100,12 @@ class Note extends BaseItem {
static geolocationUrl(note) {
if (!('latitude' in note) || !('longitude' in note)) throw new Error('Latitude or longitude is missing');
if (!Number(note.latitude) && !Number(note.longitude)) throw new Error(_('This note does not have geolocation information.'));
return sprintf('https://www.openstreetmap.org/?lat=%s&lon=%s&zoom=20', note.latitude, note.longitude)
return this.geoLocationUrlFromLatLong(note.latitude, note.longitude);
//return sprintf('https://www.openstreetmap.org/?lat=%s&lon=%s&zoom=20', note.latitude, note.longitude);
}
static geoLocationUrlFromLatLong(lat, long) {
return sprintf('https://www.openstreetmap.org/?lat=%s&lon=%s&zoom=20', lat, long)
}
static modelType() {
@ -465,17 +470,6 @@ class Note extends BaseItem {
return note;
}
// Not used?
// static async delete(id, options = null) {
// let r = await super.delete(id, options);
// this.dispatch({
// type: 'NOTE_DELETE',
// id: id,
// });
// }
static async batchDelete(ids, options = null) {
const result = await super.batchDelete(ids, options);
for (let i = 0; i < ids.length; i++) {

View File

@ -60,6 +60,23 @@ class Time {
return moment(ms).format(format);
}
formatLocalToMs(localDateTime, format = null) {
if (format === null) format = this.dateTimeFormat();
const m = moment(localDateTime, format);
if (m.isValid()) return m.toDate().getTime();
throw new Error('Invalid input for formatLocalToMs: ' + localDateTime);
}
// Mostly used as a utility function for the DateTime Electron component
anythingToDateTime(o, defaultValue = null) {
if (o && o.toDate) return o.toDate();
if (!o) return defaultValue;
let m = moment(o, time.dateTimeFormat());
if (m.isValid()) return m.toDate();
m = moment(o, time.dateFormat());
return m.isValid() ? m.toDate() : defaultValue;
}
msleep(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {