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

Desktop: Added support for templates (#1647)

* First pass of adding support for templates

* remove default value from template prompt

* Add template placeholder text

* Add mustache templates with datetime support for new notes

* Moved template code to utils, added separate prompt for templates

* Add templates to menu and allow for keyboad only use

* update template prompt for dark theme

* update with laurents suggestions, add refresh button

* revert template command, remove new note prompt
This commit is contained in:
Caleb John 2019-07-20 15:13:10 -06:00 committed by Laurent Cozic
parent e29fb3eb66
commit cd5d412c69
12 changed files with 263 additions and 16 deletions

View File

@ -26,11 +26,13 @@ const ResourceService = require('lib/services/ResourceService');
const ClipperServer = require('lib/ClipperServer');
const ExternalEditWatcher = require('lib/services/ExternalEditWatcher');
const { bridge } = require('electron').remote.require('./bridge');
const { shell } = require('electron');
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const PluginManager = require('lib/services/PluginManager');
const RevisionService = require('lib/services/RevisionService');
const MigrationService = require('lib/services/MigrationService');
const TemplateUtils = require('lib/TemplateUtils');
const pluginClasses = [
require('./plugins/GotoAnything.min'),
@ -209,7 +211,7 @@ class Application extends BaseApplication {
// The bridge runs within the main process, with its own instance of locale.js
// so it needs to be set too here.
bridge().setLocale(Setting.value('locale'));
this.refreshMenu();
await this.refreshMenu();
}
if (action.type == 'SETTING_UPDATE_ONE' && action.key == 'showTrayIcon' || action.type == 'SETTING_UPDATE_ALL') {
@ -246,10 +248,10 @@ class Application extends BaseApplication {
return result;
}
refreshMenu() {
async refreshMenu() {
const screen = this.lastMenuScreen_;
this.lastMenuScreen_ = null;
this.updateMenu(screen);
await this.updateMenu(screen);
}
focusElement_(target) {
@ -260,7 +262,7 @@ class Application extends BaseApplication {
});
}
updateMenu(screen) {
async updateMenu(screen) {
if (this.lastMenuScreen_ === screen) return;
const sortNoteFolderItems = (type) => {
@ -328,6 +330,7 @@ class Application extends BaseApplication {
const exportItems = [];
const preferencesItems = [];
const toolsItemsFirst = [];
const templateItems = [];
const ioService = new InteropService();
const ioModules = ioService.modules();
for (let i = 0; i < ioModules.length; i++) {
@ -504,6 +507,57 @@ class Application extends BaseApplication {
screens: ['Main'],
});
const templateDirExists = await shim.fsDriver().exists(Setting.value('templateDir'));
templateItems.push({
label: _('Create note from template'),
visible: templateDirExists,
click: () => {
this.dispatch({
type: 'WINDOW_COMMAND',
name: 'selectTemplate',
noteType: 'note',
});
}
}, {
label: _('Create to-do from template'),
visible: templateDirExists,
click: () => {
this.dispatch({
type: 'WINDOW_COMMAND',
name: 'selectTemplate',
noteType: 'todo',
});
}
}, {
label: _('Insert template'),
visible: templateDirExists,
accelerator: 'CommandOrControl+Alt+I',
click: () => {
this.dispatch({
type: 'WINDOW_COMMAND',
name: 'selectTemplate',
});
}
}, {
label: _('Open template directory'),
click: () => {
const templateDir = Setting.value('templateDir');
if (!templateDirExists) shim.fsDriver().mkdir(templateDir);
shell.openItem(templateDir);
}
}, {
label: _('Refresh templates'),
click: async () => {
const templates = await TemplateUtils.loadTemplates(Setting.value('templateDir'));
this.store().dispatch({
type: 'TEMPLATE_UPDATE_ALL',
templates: templates
});
}
});
const toolsItems = toolsItemsFirst.concat(preferencesItems);
function _checkForUpdates(ctx) {
@ -563,6 +617,13 @@ class Application extends BaseApplication {
shim.isMac() ? noItem : newNotebookItem, {
type: 'separator',
visible: shim.isMac() ? false : true
}, {
label: _('Templates'),
visible: shim.isMac() ? false : true,
submenu: templateItems,
}, {
type: 'separator',
visible: shim.isMac() ? false : true
}, {
label: _('Import'),
visible: shim.isMac() ? false : true,
@ -613,6 +674,11 @@ class Application extends BaseApplication {
platforms: ['darwin'],
accelerator: 'Command+W',
selector: 'performClose:',
}, {
type: 'separator',
}, {
label: _('Templates'),
submenu: templateItems,
}, {
type: 'separator',
}, {
@ -1080,6 +1146,13 @@ class Application extends BaseApplication {
css: cssString
});
const templates = await TemplateUtils.loadTemplates(Setting.value('templateDir'));
this.store().dispatch({
type: 'TEMPLATE_UPDATE_ALL',
templates: templates
});
// Note: Auto-update currently doesn't work in Linux: it downloads the update
// but then doesn't install it on exit.
if (shim.isWindows() || shim.isMac()) {

View File

@ -74,12 +74,13 @@ class MainScreenComponent extends React.Component {
async doCommand(command) {
if (!command) return;
const createNewNote = async (title, isTodo) => {
const createNewNote = async (template, isTodo) => {
const folderId = Setting.value('activeFolderId');
if (!folderId) return;
const newNote = {
parent_id: folderId,
template: template,
is_todo: isTodo ? 1 : 0,
};
@ -272,6 +273,30 @@ class MainScreenComponent extends React.Component {
eventManager.emit('alarmChange', { noteId: note.id });
}
this.setState({ promptOptions: null });
}
},
});
} else if (command.name === 'selectTemplate') {
this.setState({
promptOptions: {
label: _('Template file:'),
inputType: 'dropdown',
value: this.props.templates[0], // Need to start with some value
autocomplete: this.props.templates,
onClose: async (answer) => {
if (answer) {
if (command.noteType === 'note' || command.noteType === 'todo') {
createNewNote(answer.value, command.noteType === 'todo');
} else {
this.props.dispatch({
type: 'WINDOW_COMMAND',
name: 'insertTemplate',
value: answer.value,
});
}
}
this.setState({ promptOptions: null });
}
},
@ -523,6 +548,7 @@ const mapStateToProps = (state) => {
selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null,
plugins: state.plugins,
noteDevToolsVisible: state.noteDevToolsVisible,
templates: state.templates,
};
};

View File

@ -40,6 +40,7 @@ const DecryptionWorker = require('lib/services/DecryptionWorker');
const ModelCache = require('lib/services/ModelCache');
const NoteTextViewer = require('./NoteTextViewer.min');
const NoteRevisionViewer = require('./NoteRevisionViewer.min');
const TemplateUtils = require('lib/TemplateUtils');
require('brace/mode/markdown');
// https://ace.c9.io/build/kitchen-sink.html
@ -452,14 +453,18 @@ class NoteTextComponent extends React.Component {
const stateNoteId = this.state.note ? this.state.note.id : null;
let noteId = null;
let note = null;
let newNote = null;
let loadingNewNote = true;
let parentFolder = null;
let noteTags = [];
let scrollPercent = 0;
if (props.newNote) {
note = Object.assign({}, props.newNote);
// assign new note and prevent body from being null
note = Object.assign({}, props.newNote, {body: ''});
this.lastLoadedNoteId_ = null;
if (note.template)
note.body = TemplateUtils.render(note.template);
} else {
noteId = props.noteId;
@ -1012,6 +1017,8 @@ class NoteTextComponent extends React.Component {
fn = this.commandShowLocalSearch;
} else if (command.name === 'textCode') {
fn = this.commandTextCode;
} else if (command.name === 'insertTemplate') {
fn = () => { return this.commandTemplate(command.value); };
}
}
@ -1349,6 +1356,10 @@ class NoteTextComponent extends React.Component {
this.wrapSelectionWithStrings('`', '`');
}
commandTemplate(value) {
this.wrapSelectionWithStrings(TemplateUtils.render(value));
}
addListItem(string1, string2 = '', defaultText = '') {
const currentLine = this.selectionRangeCurrentLine();
let newLine = '\n'
@ -1920,6 +1931,7 @@ const mapStateToProps = (state) => {
customCss: state.customCss,
lastEditorScrollPercents: state.lastEditorScrollPercents,
historyNotes: state.historyNotes,
templates: state.templates,
};
};

View File

@ -6,6 +6,7 @@ const { themeStyle } = require('../theme.js');
const { time } = require('lib/time-utils.js');
const Datetime = require('react-datetime');
const CreatableSelect = require('react-select/lib/Creatable').default;
const Select = require('react-select').default;
const makeAnimated = require('react-select/lib/animated').default;
class PromptDialog extends React.Component {
@ -101,7 +102,7 @@ class PromptDialog extends React.Component {
borderColor: theme.dividerColor,
};
this.styles_.tagList = {
this.styles_.select = {
control: (provided) => (Object.assign(provided, {
minWidth: width * 0.2,
maxWidth: width * 0.5,
@ -115,6 +116,10 @@ class PromptDialog extends React.Component {
fontFamily: theme.fontFamily,
backgroundColor: theme.backgroundColor,
})),
option: (provided) => (Object.assign(provided, {
color: theme.color,
fontFamily: theme.fontFamily,
})),
multiValueLabel: (provided) => (Object.assign(provided, {
fontFamily: theme.fontFamily,
})),
@ -123,14 +128,22 @@ class PromptDialog extends React.Component {
})),
};
this.styles_.tagListTheme = (tagTheme) => (Object.assign(tagTheme, {
this.styles_.selectTheme = (tagTheme) => (Object.assign(tagTheme, {
borderRadius: 2,
colors: Object.assign(tagTheme.colors, {
primary: theme.raisedBackgroundColor,
primary25: theme.raisedBackgroundColor,
neutral0: theme.backgroundColor,
neutral5: theme.backgroundColor,
neutral10: theme.raisedBackgroundColor,
neutral20: theme.raisedBackgroundColor,
neutral30: theme.raisedBackgroundColor,
neutral40: theme.color,
neutral50: theme.color,
neutral60: theme.color,
neutral70: theme.color,
neutral80: theme.color,
neutral90: theme.color,
danger: theme.backgroundColor,
dangerLight: theme.colorError2,
}),
@ -179,14 +192,19 @@ class PromptDialog extends React.Component {
this.setState({ answer: momentObject });
}
const onTagsChange = (newTags) => {
this.setState({ answer: newTags });
const onSelectChange = (newValue) => {
this.setState({ answer: newValue });
this.focusInput_ = true;
}
const onKeyDown = (event) => {
if (event.key === 'Enter' && this.props.inputType !== 'tags') {
onClose(true);
if (event.key === 'Enter') {
if (this.props.inputType !== 'tags' && this.props.inputType !== 'dropdown') {
onClose(true);
} else if (this.answerInput_.current && !this.answerInput_.current.state.menuIsOpen) {
// The menu will be open if the user is selecting a new item
onClose(true);
}
} else if (event.key === 'Escape') {
onClose(false);
}
@ -206,8 +224,8 @@ class PromptDialog extends React.Component {
/>
} else if (this.props.inputType === 'tags') {
inputComp = <CreatableSelect
styles={styles.tagList}
theme={styles.tagListTheme}
styles={styles.select}
theme={styles.selectTheme}
ref={this.answerInput_}
value={this.state.answer}
placeholder=""
@ -216,7 +234,20 @@ class PromptDialog extends React.Component {
isClearable={false}
backspaceRemovesValue={true}
options={this.props.autocomplete}
onChange={onTagsChange}
onChange={onSelectChange}
onKeyDown={(event) => onKeyDown(event)}
/>
} else if (this.props.inputType === 'dropdown') {
inputComp = <Select
styles={styles.select}
theme={styles.selectTheme}
ref={this.answerInput_}
components={makeAnimated()}
value={this.props.answer}
defaultValue={this.props.defaultValue}
isClearable={false}
options={this.props.autocomplete}
onChange={onSelectChange}
onKeyDown={(event) => onKeyDown(event)}
/>
} else {

View File

@ -4453,6 +4453,11 @@
}
}
},
"mustache": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-3.0.1.tgz",
"integrity": "sha512-jFI/4UVRsRYdUbuDTKT7KzfOp7FiD5WzYmmwNwXyUVypC0xjoTL78Fqc0jHUPIvvGD+6DQSPHIt1NE7D1ArsqA=="
},
"nan": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz",

View File

@ -125,6 +125,7 @@
"mime": "^2.3.1",
"moment": "^2.22.2",
"multiparty": "^4.2.1",
"mustache": "^3.0.1",
"node-fetch": "^1.7.3",
"node-notifier": "^5.2.1",
"promise": "^8.0.1",

View File

@ -47,6 +47,18 @@ table td, table th {
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
.fade_out {
-webkit-transition: 0.15s;
transition: 0.15s;
opacity: 0;
}
.fade_in {
-webkit-transition: 0.3s;
transition: 0.3s;
opacity: 1;
}
/* By default, the Ice Editor displays invalid characters, such as non-breaking spaces
as red boxes, but since those are actually valid characters and common in imported
Evernote data, we hide them here. */
@ -127,4 +139,4 @@ table td, table th {
.header .title {
display: none;
}
}
}

View File

@ -316,6 +316,25 @@ It is generally recommended to enter the notes as Markdown as it makes the notes
Rendered markdown can be customized by placing a userstyle file in the profile directory `~/.config/joplin-desktop/userstyle.css` (This path might be different on your device - check at the top of the Config screen for the exact path). This file supports standard CSS syntax. Joplin ***must*** be restarted for the new css to be applied, please ensure that Joplin is not closing to the tray, but is actually exiting. Note that this file is used only when display the notes, **not when printing or exporting to PDF**. This is because printing has a lot more restrictions (for example, printing white text over a black background is usually not wanted), so special rules are applied to make it look good when printing, and a userstyle.css would interfer with that.
## New Note Templates
Templates can be used for new notes by creating a templates folder in `~/.config/joplin-desktop/` and placing markdown template files into it. For example creating the file `hours.md` in the directory `~/.config/joplin-desktop/templates/` with the contents:
```markdown
Date: {{date}}
Hours:
Details:
```
When creating a new note you will now be prompted to insert a template that contains the above text (and {{date}} replaced with today's date). Templates can also be inserted from the menu (File->Templates).
The currently supported template variables are:
| Variable | Description | Example |
| {{date}} | Today's date formatted based on the settings format | 2019-01-01 |
| {{time}} | Current time formatted based on the settings format | 13:00 |
| {{datetime}} | Current date and time formatted based on the settings format | 01/01/19 1:00 PM |
| {{#custom_datetime}} | Current date and/or time formatted based on a supplied string (using [moment.js](https://momentjs.com/) formatting) | {{#custom_datetime}}M d{{/custom_datetime}} |
# Searching
Joplin implements the SQLite Full Text Search (FTS4) extension. It means the content of all the notes is indexed in real time and search queries return results very fast. Both [Simple FTS Queries](https://www.sqlite.org/fts3.html#simple_fts_queries) and [Full-Text Index Queries](https://www.sqlite.org/fts3.html#full_text_index_queries) are supported. See below for the list of supported queries:

View File

@ -546,6 +546,7 @@ class BaseApplication {
Setting.setConstant('env', initArgs.env);
Setting.setConstant('profileDir', profileDir);
Setting.setConstant('templateDir', profileDir + '/templates');
Setting.setConstant('resourceDirName', resourceDirName);
Setting.setConstant('resourceDir', resourceDir);
Setting.setConstant('tempDir', tempDir);

View File

@ -0,0 +1,59 @@
const fs = require('fs-extra');
const { shim } = require('lib/shim.js');
const { time } = require('lib/time-utils.js');
const Mustache = require('mustache');
const TemplateUtils = {};
// new template variables can be added here
// If there are too many, this should be moved to a new file
const view = {
date: time.formatMsToLocal(new Date().getTime(), time.dateFormat()),
time: time.formatMsToLocal(new Date().getTime(), time.timeFormat()),
datetime: time.formatMsToLocal(new Date().getTime()),
custom_datetime: () => { return (text, render) => {
return render(time.formatMsToLocal(new Date().getTime(), text));
}},
}
// Mustache escapes strings (including /) with the html code by default
// This isn't useful for markdown so it's disabled
Mustache.escape = (text) => { return text; }
TemplateUtils.render = function(input) {
return Mustache.render(input, view);
}
TemplateUtils.loadTemplates = async function(filePath) {
let templates = [];
let files = [];
if (await shim.fsDriver().exists(filePath)) {
try {
files = await shim.fsDriver().readDirStats(filePath);
} catch (error) {
let msg = error.message ? error.message : '';
msg = 'Could not read template names from ' + filePath + '\n' + msg;
error.message = msg;
throw error;
}
files.forEach(async (file) => {
if (file.path.endsWith('.md')) {
try {
let fileString = await shim.fsDriver().readFile(filePath + '/' + file.path, 'utf-8');
templates.push({label: file.path, value: fileString});
} catch (error) {
let msg = error.message ? error.message : '';
msg = 'Could not load template ' + file.path + '\n' + msg;
error.message = msg;
throw error;
}
}
});
}
return templates;
}
module.exports = TemplateUtils;

View File

@ -648,6 +648,7 @@ Setting.constants_ = {
resourceDirName: '',
resourceDir: '',
profileDir: '',
templateDir: '',
tempDir: '',
openDevTools: false,
}

View File

@ -33,6 +33,7 @@ const defaultState = {
hasDisabledSyncItems: false,
newNote: null,
customCss: '',
templates: [],
collapsedFolderIds: [],
clipperServer: {
startState: 'idle',
@ -713,6 +714,12 @@ const reducer = (state = defaultState, action) => {
newState = Object.assign({}, state);
newState.customCss = action.css;
break;
case 'TEMPLATE_UPDATE_ALL':
newState = Object.assign({}, state);
newState.templates = action.templates;
break;
case 'SET_NOTE_TAGS':
newState = Object.assign({}, state);