diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..e3418051fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,4 @@ +👉 Please follow one of these issue templates: +- https://github.com/laurent22/joplin/issues/new/choose + +Note: to keep the backlog clean and actionable, issues may be immediately closed if they do not follow one of the above issue templates. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..0df67c731b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,43 @@ +--- +name: "🐛 Bug Report" +about: Report a reproducible bug or regression in Joplin. +title: '' +labels: 'bug' + +--- + + + +## Environment + +Joplin version: +Platform: +OS specifcs: + + +## Steps To Reproduce + +1. +2. + + + +Describe what you expected to happen: + + + +## Logfile + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..17a04a9cfe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,33 @@ +--- +name: Feature request +about: Suggest a feature for Joplin. +title: '[Feature request] ' +labels: 'feature request' + +--- + + + +## Has it been discussed in the forum? Link to topic. + + + +## Is your feature request related to a problem? Please describe. + + + +## Describe the solution you'd like + + + +## Describe alternatives you've considered + + + +## Additional context + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000000..4cf79401e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,29 @@ +--- +name: "🤔 Questions and Help" +about: The issue tracker is not for questions. Please ask questions on https://discourse.joplinapp.org/. +title: 'Question: ' +labels: 'question' + +--- + +🚨 The issue tracker is not for questions. 🚨 + +As it happens, support requests that are created as issues are likely to be closed. We want to make sure you are able to find the help you seek. + +## Questions and Help + +Please read the [documentation](https://joplinapp.org/) and [FAQ](https://joplinapp.org/faq/) first. + +### https://discourse.joplinapp.org/ + +If you have still questions related to Joplin, please open a topic in the [forum](https://discourse.joplinapp.org/). +You can use your GitHub credentials to login to the forum. + +## Links + +- Documentation: https://joplinapp.org +- FAQ: https://joplinapp.org/faq/ +- Forum: https://discourse.joplinapp.org +- How to enable end-to-end encryption: https://joplinapp.org/e2ee/ +- API documentation: https://joplinapp.org/api/ +- How to enable debug mode: https://joplinapp.org/debugging/ diff --git a/docs/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE similarity index 100% rename from docs/PULL_REQUEST_TEMPLATE rename to .github/PULL_REQUEST_TEMPLATE diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000..16fd3cfc62 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,25 @@ +# Configuration for probot-stale - https://github.com/probot/stale +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 90 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - "good first issue" + - "essential" + - "essential-reviewed" + - "help wanted" + - "nice to have" + - "upstream" + - "backlog" +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + Hey there, it looks like there has been no activity on this issue recently. Has the issue been fixed, or does it still require the community's attention? This issue may be closed if no further activity occurs. + You may also label this issue as "backlog" and I will leave it open. + Thank you for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: > + Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please feel free to create a new issue with up-to-date information. +only: issues diff --git a/CliClient/locales/de_DE.po b/CliClient/locales/de_DE.po index 092e29efa2..574cd95251 100644 --- a/CliClient/locales/de_DE.po +++ b/CliClient/locales/de_DE.po @@ -1985,7 +1985,7 @@ msgstr "" #, javascript-format msgid "Links with protocol \"%s\" are not supported" -msgstr "" +msgstr "Verweise mit dem Protokoll \"%s\" sind nicht unterstützt" #, javascript-format msgid "Unsupported image type: %s" diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js index 39ae632eba..f615cf6aca 100644 --- a/ElectronClient/app/app.js +++ b/ElectronClient/app/app.js @@ -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', }, { @@ -1081,6 +1147,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()) { diff --git a/ElectronClient/app/gui/MainScreen.jsx b/ElectronClient/app/gui/MainScreen.jsx index a123a038eb..e6a812e1f2 100644 --- a/ElectronClient/app/gui/MainScreen.jsx +++ b/ElectronClient/app/gui/MainScreen.jsx @@ -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, }; }; diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx index 5b0fd6c741..a7f4c25573 100644 --- a/ElectronClient/app/gui/NoteText.jsx +++ b/ElectronClient/app/gui/NoteText.jsx @@ -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 @@ -470,14 +471,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; @@ -1024,6 +1029,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); }; } } @@ -1361,6 +1368,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' @@ -1936,6 +1947,7 @@ const mapStateToProps = (state) => { customCss: state.customCss, lastEditorScrollPercents: state.lastEditorScrollPercents, historyNotes: state.historyNotes, + templates: state.templates, }; }; diff --git a/ElectronClient/app/gui/PromptDialog.jsx b/ElectronClient/app/gui/PromptDialog.jsx index 288ebf7bbf..17dbcca436 100644 --- a/ElectronClient/app/gui/PromptDialog.jsx +++ b/ElectronClient/app/gui/PromptDialog.jsx @@ -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 = onKeyDown(event)} + /> + } else if (this.props.inputType === 'dropdown') { + inputComp =