mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-21 09:38:01 +02:00
844 lines
23 KiB
JavaScript
844 lines
23 KiB
JavaScript
const Logger = require('@joplin/utils/Logger').default;
|
|
const Folder = require('@joplin/lib/models/Folder').default;
|
|
const BaseItem = require('@joplin/lib/models/BaseItem').default;
|
|
const Tag = require('@joplin/lib/models/Tag').default;
|
|
const BaseModel = require('@joplin/lib/BaseModel').default;
|
|
const Note = require('@joplin/lib/models/Note').default;
|
|
const Resource = require('@joplin/lib/models/Resource').default;
|
|
const Setting = require('@joplin/lib/models/Setting').default;
|
|
const reducer = require('@joplin/lib/reducer').default;
|
|
const { defaultState } = require('@joplin/lib/reducer');
|
|
const { splitCommandString } = require('@joplin/utils');
|
|
const { reg } = require('@joplin/lib/registry.js');
|
|
const { _ } = require('@joplin/lib/locale');
|
|
const shim = require('@joplin/lib/shim').default;
|
|
const Entities = require('html-entities').AllHtmlEntities;
|
|
const htmlentities = new Entities().encode;
|
|
|
|
const chalk = require('chalk');
|
|
const tk = require('terminal-kit');
|
|
const TermWrapper = require('tkwidgets/framework/TermWrapper.js');
|
|
const Renderer = require('tkwidgets/framework/Renderer.js');
|
|
const DecryptionWorker = require('@joplin/lib/services/DecryptionWorker').default;
|
|
|
|
const BaseWidget = require('tkwidgets/BaseWidget.js');
|
|
const TextWidget = require('tkwidgets/TextWidget.js');
|
|
const HLayoutWidget = require('tkwidgets/HLayoutWidget.js');
|
|
const VLayoutWidget = require('tkwidgets/VLayoutWidget.js');
|
|
const ReduxRootWidget = require('tkwidgets/ReduxRootWidget.js');
|
|
const WindowWidget = require('tkwidgets/WindowWidget.js');
|
|
|
|
const NoteWidget = require('./gui/NoteWidget.js');
|
|
const ResourceServer = require('./ResourceServer.js');
|
|
const NoteMetadataWidget = require('./gui/NoteMetadataWidget.js');
|
|
const FolderListWidget = require('./gui/FolderListWidget.js');
|
|
const NoteListWidget = require('./gui/NoteListWidget.js');
|
|
const StatusBarWidget = require('./gui/StatusBarWidget').default;
|
|
const ConsoleWidget = require('./gui/ConsoleWidget.js');
|
|
const LinkSelector = require('./LinkSelector.js').default;
|
|
|
|
|
|
class AppGui {
|
|
constructor(app, store, keymap) {
|
|
try {
|
|
this.app_ = app;
|
|
this.store_ = store;
|
|
|
|
BaseWidget.setLogger(app.logger());
|
|
|
|
this.term_ = new TermWrapper(tk.terminal);
|
|
|
|
// Some keys are directly handled by the tkwidget framework
|
|
// so they need to be remapped in a different way.
|
|
this.tkWidgetKeys_ = {
|
|
focus_next: 'TAB',
|
|
focus_previous: 'SHIFT_TAB',
|
|
move_up: 'UP',
|
|
move_down: 'DOWN',
|
|
page_down: 'PAGE_DOWN',
|
|
page_up: 'PAGE_UP',
|
|
};
|
|
|
|
this.renderer_ = null;
|
|
this.logger_ = new Logger();
|
|
this.buildUi();
|
|
|
|
this.renderer_ = new Renderer(this.term(), this.rootWidget_);
|
|
|
|
this.app_.on('modelAction', async event => {
|
|
await this.handleModelAction(event.action);
|
|
});
|
|
|
|
this.keymap_ = this.setupKeymap(keymap);
|
|
|
|
this.inputMode_ = AppGui.INPUT_MODE_NORMAL;
|
|
|
|
this.commandCancelCalled_ = false;
|
|
|
|
this.currentShortcutKeys_ = [];
|
|
this.lastShortcutKeyTime_ = 0;
|
|
|
|
this.linkSelector_ = new LinkSelector();
|
|
|
|
// Recurrent sync is setup only when the GUI is started. In
|
|
// a regular command it's not necessary since the process
|
|
// exits right away.
|
|
reg.setupRecurrentSync();
|
|
DecryptionWorker.instance().scheduleStart();
|
|
} catch (error) {
|
|
if (this.term_) { this.fullScreen(false); }
|
|
console.error(error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
store() {
|
|
return this.store_;
|
|
}
|
|
|
|
renderer() {
|
|
return this.renderer_;
|
|
}
|
|
|
|
async forceRender() {
|
|
this.widget('root').invalidate();
|
|
await this.renderer_.renderRoot();
|
|
}
|
|
|
|
termSaveState() {
|
|
return this.term().saveState();
|
|
}
|
|
|
|
termRestoreState(state) {
|
|
return this.term().restoreState(state);
|
|
}
|
|
|
|
prompt(initialText = '', promptString = ':', options = null) {
|
|
return this.widget('statusBar').prompt(initialText, promptString, options);
|
|
}
|
|
|
|
stdoutMaxWidth() {
|
|
return this.widget('console').innerWidth - 1;
|
|
}
|
|
|
|
isDummy() {
|
|
return false;
|
|
}
|
|
|
|
buildUi() {
|
|
this.rootWidget_ = new ReduxRootWidget(this.store_);
|
|
this.rootWidget_.name = 'root';
|
|
this.rootWidget_.autoShortcutsEnabled = false;
|
|
|
|
const folderList = new FolderListWidget();
|
|
folderList.style = {
|
|
borderBottomWidth: 1,
|
|
borderRightWidth: 1,
|
|
};
|
|
folderList.name = 'folderList';
|
|
folderList.vStretch = true;
|
|
folderList.on('currentItemChange', async event => {
|
|
const item = folderList.currentItem;
|
|
|
|
if (item === '-') {
|
|
const newIndex = event.currentIndex + (event.previousIndex < event.currentIndex ? +1 : -1);
|
|
let nextItem = folderList.itemAt(newIndex);
|
|
if (!nextItem) nextItem = folderList.itemAt(event.previousIndex);
|
|
|
|
if (!nextItem) return; // Normally not possible
|
|
|
|
let actionType = 'FOLDER_SELECT';
|
|
if (nextItem.type_ === BaseModel.TYPE_TAG) actionType = 'TAG_SELECT';
|
|
if (nextItem.type_ === BaseModel.TYPE_SEARCH) actionType = 'SEARCH_SELECT';
|
|
|
|
this.store_.dispatch({
|
|
type: actionType,
|
|
id: nextItem.id,
|
|
});
|
|
} else if (item.type_ === Folder.modelType()) {
|
|
this.store_.dispatch({
|
|
type: 'FOLDER_SELECT',
|
|
id: item ? item.id : null,
|
|
});
|
|
} else if (item.type_ === Tag.modelType()) {
|
|
this.store_.dispatch({
|
|
type: 'TAG_SELECT',
|
|
id: item ? item.id : null,
|
|
});
|
|
} else if (item.type_ === BaseModel.TYPE_SEARCH) {
|
|
this.store_.dispatch({
|
|
type: 'SEARCH_SELECT',
|
|
id: item ? item.id : null,
|
|
});
|
|
}
|
|
});
|
|
this.rootWidget_.connect(folderList, state => {
|
|
return {
|
|
selectedFolderId: state.selectedFolderId,
|
|
selectedTagId: state.selectedTagId,
|
|
selectedSearchId: state.selectedSearchId,
|
|
notesParentType: state.notesParentType,
|
|
folders: state.folders,
|
|
tags: state.tags,
|
|
searches: state.searches,
|
|
};
|
|
});
|
|
|
|
const noteList = new NoteListWidget();
|
|
noteList.name = 'noteList';
|
|
noteList.vStretch = true;
|
|
noteList.style = {
|
|
borderBottomWidth: 1,
|
|
borderLeftWidth: 1,
|
|
borderRightWidth: 1,
|
|
};
|
|
noteList.on('currentItemChange', async () => {
|
|
const note = noteList.currentItem;
|
|
this.store_.dispatch({
|
|
type: 'NOTE_SELECT',
|
|
id: note ? note.id : null,
|
|
});
|
|
});
|
|
this.rootWidget_.connect(noteList, state => {
|
|
return {
|
|
selectedNoteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
|
|
items: state.notes,
|
|
};
|
|
});
|
|
|
|
const noteText = new NoteWidget();
|
|
noteText.hStretch = true;
|
|
noteText.name = 'noteText';
|
|
noteText.style = {
|
|
borderBottomWidth: 1,
|
|
borderLeftWidth: 1,
|
|
};
|
|
this.rootWidget_.connect(noteText, state => {
|
|
return {
|
|
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
|
|
notes: state.notes,
|
|
};
|
|
});
|
|
|
|
const noteMetadata = new NoteMetadataWidget();
|
|
noteMetadata.hStretch = true;
|
|
noteMetadata.name = 'noteMetadata';
|
|
noteMetadata.style = {
|
|
borderBottomWidth: 1,
|
|
borderLeftWidth: 1,
|
|
borderRightWidth: 1,
|
|
};
|
|
this.rootWidget_.connect(noteMetadata, state => {
|
|
return { noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null };
|
|
});
|
|
noteMetadata.hide();
|
|
|
|
const consoleWidget = new ConsoleWidget();
|
|
consoleWidget.hStretch = true;
|
|
consoleWidget.style = {
|
|
borderBottomWidth: 1,
|
|
};
|
|
consoleWidget.hide();
|
|
|
|
const statusBar = new StatusBarWidget();
|
|
statusBar.hStretch = true;
|
|
|
|
const noteLayout = new VLayoutWidget();
|
|
noteLayout.name = 'noteLayout';
|
|
noteLayout.addChild(noteText, { type: 'stretch', factor: 1 });
|
|
noteLayout.addChild(noteMetadata, { type: 'stretch', factor: 1 });
|
|
|
|
const hLayout = new HLayoutWidget();
|
|
hLayout.name = 'hLayout';
|
|
hLayout.addChild(folderList, { type: 'stretch', factor: Setting.value('layout.folderList.factor') });
|
|
hLayout.addChild(noteList, { type: 'stretch', factor: Setting.value('layout.noteList.factor') });
|
|
hLayout.addChild(noteLayout, { type: 'stretch', factor: Setting.value('layout.note.factor') });
|
|
|
|
const vLayout = new VLayoutWidget();
|
|
vLayout.name = 'vLayout';
|
|
vLayout.addChild(hLayout, { type: 'stretch', factor: 2 });
|
|
vLayout.addChild(consoleWidget, { type: 'stretch', factor: 1 });
|
|
vLayout.addChild(statusBar, { type: 'fixed', factor: 1 });
|
|
|
|
const win1 = new WindowWidget();
|
|
win1.addChild(vLayout);
|
|
win1.name = 'mainWindow';
|
|
|
|
this.rootWidget_.addChild(win1);
|
|
}
|
|
|
|
showModalOverlay(text) {
|
|
if (!this.widget('overlayWindow')) {
|
|
const textWidget = new TextWidget();
|
|
textWidget.hStretch = true;
|
|
textWidget.vStretch = true;
|
|
textWidget.text = 'testing';
|
|
textWidget.name = 'overlayText';
|
|
|
|
const win = new WindowWidget();
|
|
win.name = 'overlayWindow';
|
|
win.addChild(textWidget);
|
|
|
|
this.rootWidget_.addChild(win);
|
|
}
|
|
|
|
this.widget('overlayWindow').activate();
|
|
this.widget('overlayText').text = text;
|
|
}
|
|
|
|
hideModalOverlay() {
|
|
if (this.widget('overlayWindow')) this.widget('overlayWindow').hide();
|
|
this.widget('mainWindow').activate();
|
|
}
|
|
|
|
addCommandToConsole(cmd) {
|
|
if (!cmd) return;
|
|
const isConfigPassword = cmd.indexOf('config ') >= 0 && cmd.indexOf('password') >= 0;
|
|
if (isConfigPassword) return;
|
|
this.stdout(chalk.cyan.bold(`> ${cmd}`));
|
|
}
|
|
|
|
setupKeymap(keymap) {
|
|
const output = [];
|
|
|
|
for (let i = 0; i < keymap.length; i++) {
|
|
const item = { ...keymap[i] };
|
|
|
|
if (!item.command) throw new Error(`Missing command for keymap item: ${JSON.stringify(item)}`);
|
|
|
|
if (!('type' in item)) item.type = 'exec';
|
|
|
|
if (item.command in this.tkWidgetKeys_) {
|
|
item.type = 'tkwidgets';
|
|
}
|
|
|
|
item.canRunAlongOtherCommands = item.type === 'function' && ['toggle_metadata', 'toggle_console'].indexOf(item.command) >= 0;
|
|
|
|
output.push(item);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
toggleConsole() {
|
|
this.showConsole(!this.consoleIsShown());
|
|
}
|
|
|
|
showConsole(doShow = true) {
|
|
this.widget('console').show(doShow);
|
|
}
|
|
|
|
hideConsole() {
|
|
this.showConsole(false);
|
|
}
|
|
|
|
consoleIsShown() {
|
|
return this.widget('console').shown;
|
|
}
|
|
|
|
maximizeConsole(doMaximize = true) {
|
|
const consoleWidget = this.widget('console');
|
|
|
|
if (consoleWidget.isMaximized__ === undefined) {
|
|
consoleWidget.isMaximized__ = false;
|
|
}
|
|
|
|
if (consoleWidget.isMaximized__ === doMaximize) return;
|
|
|
|
const constraints = {
|
|
type: 'stretch',
|
|
factor: !doMaximize ? 1 : 4,
|
|
};
|
|
|
|
consoleWidget.isMaximized__ = doMaximize;
|
|
|
|
this.widget('vLayout').setWidgetConstraints(consoleWidget, constraints);
|
|
}
|
|
|
|
minimizeConsole() {
|
|
this.maximizeConsole(false);
|
|
}
|
|
|
|
consoleIsMaximized() {
|
|
return this.widget('console').isMaximized__ === true;
|
|
}
|
|
|
|
showNoteMetadata(show = true) {
|
|
this.widget('noteMetadata').show(show);
|
|
}
|
|
|
|
hideNoteMetadata() {
|
|
this.showNoteMetadata(false);
|
|
}
|
|
|
|
toggleNoteMetadata() {
|
|
this.showNoteMetadata(!this.widget('noteMetadata').shown);
|
|
}
|
|
|
|
toggleFolderIds() {
|
|
this.widget('folderList').toggleShowIds();
|
|
this.widget('noteList').toggleShowIds();
|
|
}
|
|
|
|
widget(name) {
|
|
if (name === 'root') return this.rootWidget_;
|
|
return this.rootWidget_.childByName(name);
|
|
}
|
|
|
|
app() {
|
|
return this.app_;
|
|
}
|
|
|
|
setLogger(l) {
|
|
this.logger_ = l;
|
|
}
|
|
|
|
logger() {
|
|
return this.logger_;
|
|
}
|
|
|
|
keymap() {
|
|
return this.keymap_;
|
|
}
|
|
|
|
keymapItemByKey(key) {
|
|
for (let i = 0; i < this.keymap_.length; i++) {
|
|
const item = this.keymap_[i];
|
|
if (item.keys.indexOf(key) >= 0) return item;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
term() {
|
|
return this.term_;
|
|
}
|
|
|
|
activeListItem() {
|
|
const widget = this.widget('mainWindow').focusedWidget;
|
|
if (!widget) return null;
|
|
|
|
if (widget.name === 'noteList' || widget.name === 'folderList') {
|
|
return widget.currentItem;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async handleModelAction(action) {
|
|
this.logger().info('Action:', action);
|
|
|
|
const state = { ...defaultState };
|
|
state.notes = this.widget('noteList').items;
|
|
|
|
const newState = reducer(state, action);
|
|
|
|
if (newState !== state) {
|
|
this.widget('noteList').items = newState.notes;
|
|
}
|
|
}
|
|
|
|
async processFunctionCommand(cmd) {
|
|
if (cmd === 'activate') {
|
|
const w = this.widget('mainWindow').focusedWidget;
|
|
if (w.name === 'folderList') {
|
|
this.widget('noteList').focus();
|
|
} else if (w.name === 'noteList' || w.name === 'noteText') {
|
|
this.processPromptCommand('edit $n');
|
|
}
|
|
} else if (cmd === 'delete') {
|
|
if (this.widget('folderList').hasFocus) {
|
|
const item = this.widget('folderList').selectedJoplinItem;
|
|
|
|
if (!item) return;
|
|
|
|
if (item.type_ === BaseModel.TYPE_FOLDER) {
|
|
await this.processPromptCommand(`rmbook ${item.id}`);
|
|
} else if (item.type_ === BaseModel.TYPE_TAG) {
|
|
this.stdout(_('To delete a tag, untag the associated notes.'));
|
|
} else if (item.type_ === BaseModel.TYPE_SEARCH) {
|
|
this.store().dispatch({
|
|
type: 'SEARCH_DELETE',
|
|
id: item.id,
|
|
});
|
|
}
|
|
} else if (this.widget('noteList').hasFocus) {
|
|
await this.processPromptCommand('rmnote $n');
|
|
} else {
|
|
this.stdout(_('Please select the note or notebook to be deleted first.'));
|
|
}
|
|
} else if (cmd === 'next_link' || cmd === 'previous_link') {
|
|
const noteText = this.widget('noteText');
|
|
|
|
noteText.render();
|
|
|
|
if (cmd === 'next_link') this.linkSelector_.changeLink(noteText, 1);
|
|
else this.linkSelector_.changeLink(noteText, -1);
|
|
|
|
this.linkSelector_.scrollWidget(noteText);
|
|
|
|
const cursorOffsetX = this.widget('mainWindow').width - noteText.innerWidth - 8;
|
|
const cursorOffsetY = 1 - noteText.scrollTop_;
|
|
|
|
if (this.linkSelector_.link) {
|
|
this.term_.moveTo(
|
|
this.linkSelector_.noteX + cursorOffsetX,
|
|
this.linkSelector_.noteY + cursorOffsetY
|
|
);
|
|
shim.setTimeout(() => this.term_.term().inverse(this.linkSelector_.link), 50);
|
|
}
|
|
} else if (cmd === 'open_link') {
|
|
if (this.widget('noteText').hasFocus) {
|
|
this.linkSelector_.openLink(this.widget('noteText'));
|
|
}
|
|
} else if (cmd === 'toggle_console') {
|
|
if (!this.consoleIsShown()) {
|
|
this.showConsole();
|
|
this.minimizeConsole();
|
|
} else {
|
|
if (this.consoleIsMaximized()) {
|
|
this.hideConsole();
|
|
} else {
|
|
this.maximizeConsole();
|
|
}
|
|
}
|
|
} else if (cmd === 'toggle_metadata') {
|
|
this.toggleNoteMetadata();
|
|
} else if (cmd === 'toggle_ids') {
|
|
this.toggleFolderIds();
|
|
} else if (cmd === 'enter_command_line_mode') {
|
|
const cmd = await this.widget('statusBar').prompt();
|
|
if (!cmd) return;
|
|
this.addCommandToConsole(cmd);
|
|
await this.processPromptCommand(cmd);
|
|
} else {
|
|
throw new Error(`Unknown command: ${cmd}`);
|
|
}
|
|
}
|
|
|
|
async processPromptCommand(cmd) {
|
|
if (!cmd) return;
|
|
cmd = cmd.trim();
|
|
if (!cmd.length) return;
|
|
|
|
// this.logger().debug('Got command: ' + cmd);
|
|
|
|
try {
|
|
const note = this.widget('noteList').currentItem;
|
|
const folder = this.widget('folderList').currentItem;
|
|
const args = splitCommandString(cmd);
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '$n') {
|
|
args[i] = note ? note.id : '';
|
|
} else if (args[i] === '$b') {
|
|
args[i] = folder ? folder.id : '';
|
|
} else if (args[i] === '$c') {
|
|
const item = this.activeListItem();
|
|
args[i] = item ? item.id : '';
|
|
}
|
|
}
|
|
|
|
await this.app().execCommand(args);
|
|
} catch (error) {
|
|
this.stdout(error.message);
|
|
}
|
|
|
|
this.widget('console').scrollBottom();
|
|
|
|
// Invalidate so that the screen is redrawn in case inputting a command has moved
|
|
// the GUI up (in particular due to autocompletion), it's moved back to the right position.
|
|
this.widget('root').invalidate();
|
|
}
|
|
|
|
async updateFolderList() {
|
|
const folders = await Folder.all();
|
|
this.widget('folderList').items = folders;
|
|
}
|
|
|
|
async updateNoteList(folderId) {
|
|
const fields = Note.previewFields();
|
|
fields.splice(fields.indexOf('body'), 1);
|
|
const notes = folderId ? await Note.previews(folderId, { fields: fields }) : [];
|
|
this.widget('noteList').items = notes;
|
|
}
|
|
|
|
async updateNoteText(note) {
|
|
const text = note ? note.body : '';
|
|
this.widget('noteText').text = text;
|
|
}
|
|
|
|
// Any key after which a shortcut is not possible.
|
|
isSpecialKey(name) {
|
|
return [':', 'ENTER', 'DOWN', 'UP', 'LEFT', 'RIGHT', 'DELETE', 'BACKSPACE', 'ESCAPE', 'TAB', 'SHIFT_TAB', 'PAGE_UP', 'PAGE_DOWN'].indexOf(name) >= 0;
|
|
}
|
|
|
|
fullScreen(enable = true) {
|
|
if (enable) {
|
|
this.term().fullscreen();
|
|
this.term().hideCursor();
|
|
this.widget('root').invalidate();
|
|
} else {
|
|
this.term().fullscreen(false);
|
|
this.term().showCursor();
|
|
}
|
|
}
|
|
|
|
stdout(text) {
|
|
if (text === null || text === undefined) return;
|
|
|
|
const lines = text.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const v = typeof lines[i] === 'object' ? JSON.stringify(lines[i]) : lines[i];
|
|
this.widget('console').addLine(v);
|
|
}
|
|
|
|
this.updateStatusBarMessage();
|
|
}
|
|
|
|
exit() {
|
|
this.fullScreen(false);
|
|
this.resourceServer_.stop();
|
|
}
|
|
|
|
updateStatusBarMessage() {
|
|
const consoleWidget = this.widget('console');
|
|
|
|
let msg = '';
|
|
|
|
const text = consoleWidget.lastLine;
|
|
|
|
const cmd = this.app().currentCommand();
|
|
if (cmd) {
|
|
msg += cmd.name();
|
|
if (cmd.cancellable()) msg += ' [Press Ctrl+C to cancel]';
|
|
msg += ': ';
|
|
}
|
|
|
|
if (text && text.length) {
|
|
msg += text;
|
|
}
|
|
|
|
if (msg !== '') this.widget('statusBar').setItemAt(0, msg);
|
|
}
|
|
|
|
async setupResourceServer() {
|
|
const linkStyle = chalk.blue.underline;
|
|
const noteTextWidget = this.widget('noteText');
|
|
const resourceIdRegex = /^:\/[a-f0-9]+$/i;
|
|
const noteLinks = {};
|
|
|
|
const hasProtocol = function(s, protocols) {
|
|
if (!s) return false;
|
|
s = s.trim().toLowerCase();
|
|
for (let i = 0; i < protocols.length; i++) {
|
|
if (s.indexOf(`${protocols[i]}://`) === 0) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// By default, before the server is started, only the regular
|
|
// URLs appear in blue.
|
|
noteTextWidget.markdownRendererOptions = {
|
|
linkUrlRenderer: (index, url) => {
|
|
if (!url) return url;
|
|
|
|
if (resourceIdRegex.test(url)) {
|
|
return url;
|
|
} else if (hasProtocol(url, ['http', 'https'])) {
|
|
return linkStyle(url);
|
|
} else {
|
|
return url;
|
|
}
|
|
},
|
|
};
|
|
|
|
this.resourceServer_ = new ResourceServer();
|
|
this.resourceServer_.setLogger(this.app().logger());
|
|
this.resourceServer_.setLinkHandler(async (path, response) => {
|
|
const link = noteLinks[path];
|
|
|
|
if (link.type === 'url') {
|
|
response.writeHead(302, { Location: link.url });
|
|
return true;
|
|
}
|
|
|
|
if (link.type === 'item') {
|
|
const itemId = link.id;
|
|
const item = await BaseItem.loadItemById(itemId);
|
|
if (!item) throw new Error(`No item with ID ${itemId}`); // Should be nearly impossible
|
|
|
|
if (item.type_ === BaseModel.TYPE_RESOURCE) {
|
|
if (item.mime) response.setHeader('Content-Type', item.mime);
|
|
response.write(await Resource.content(item));
|
|
} else if (item.type_ === BaseModel.TYPE_NOTE) {
|
|
const html = [
|
|
`
|
|
<!DOCTYPE html>
|
|
<html class="client-nojs" lang="en" dir="ltr">
|
|
<head><meta charset="UTF-8"/></head><body>
|
|
`,
|
|
];
|
|
html.push(`<pre>${htmlentities(item.title)}\n\n${htmlentities(item.body)}</pre>`);
|
|
html.push('</body></html>');
|
|
response.write(html.join(''));
|
|
} else {
|
|
throw new Error(`Unsupported item type: ${item.type_}`);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
await this.resourceServer_.start();
|
|
if (!this.resourceServer_.started()) return;
|
|
|
|
noteTextWidget.markdownRendererOptions = {
|
|
linkUrlRenderer: (index, url) => {
|
|
if (!url) return url;
|
|
|
|
if (resourceIdRegex.test(url)) {
|
|
noteLinks[index] = {
|
|
type: 'item',
|
|
id: url.substr(2),
|
|
};
|
|
} else if (hasProtocol(url, ['http', 'https', 'file', 'ftp'])) {
|
|
noteLinks[index] = {
|
|
type: 'url',
|
|
url: url,
|
|
};
|
|
} else if (url.indexOf('#') === 0) {
|
|
return ''; // Anchors aren't supported for now
|
|
} else {
|
|
return url;
|
|
}
|
|
|
|
return linkStyle(`${this.resourceServer_.baseUrl()}/${index}`);
|
|
},
|
|
};
|
|
}
|
|
|
|
async start() {
|
|
const term = this.term();
|
|
|
|
this.fullScreen();
|
|
|
|
try {
|
|
this.setupResourceServer();
|
|
|
|
this.renderer_.start();
|
|
|
|
const statusBar = this.widget('statusBar');
|
|
|
|
term.grabInput();
|
|
|
|
term.on('key', async (name) => {
|
|
// -------------------------------------------------------------------------
|
|
// Handle special shortcuts
|
|
// -------------------------------------------------------------------------
|
|
|
|
if (name === 'CTRL_D') {
|
|
const cmd = this.app().currentCommand();
|
|
|
|
if (cmd && cmd.cancellable() && !this.commandCancelCalled_) {
|
|
this.commandCancelCalled_ = true;
|
|
await cmd.cancel();
|
|
this.commandCancelCalled_ = false;
|
|
}
|
|
|
|
await this.app().exit();
|
|
return;
|
|
}
|
|
|
|
if (name === 'CTRL_C') {
|
|
const cmd = this.app().currentCommand();
|
|
if (!cmd || !cmd.cancellable() || this.commandCancelCalled_) {
|
|
this.stdout(_('Press Ctrl+D or type "exit" to exit the application'));
|
|
} else {
|
|
this.commandCancelCalled_ = true;
|
|
await cmd.cancel();
|
|
this.commandCancelCalled_ = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Build up current shortcut
|
|
// -------------------------------------------------------------------------
|
|
|
|
const now = new Date().getTime();
|
|
|
|
if (now - this.lastShortcutKeyTime_ > 800 || this.isSpecialKey(name)) {
|
|
this.currentShortcutKeys_ = [name];
|
|
} else {
|
|
// If the previous key was a special key (eg. up, down arrow), this new key
|
|
// starts a new shortcut.
|
|
if (this.currentShortcutKeys_.length && this.isSpecialKey(this.currentShortcutKeys_[0])) {
|
|
this.currentShortcutKeys_ = [name];
|
|
} else {
|
|
this.currentShortcutKeys_.push(name);
|
|
}
|
|
}
|
|
|
|
this.lastShortcutKeyTime_ = now;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Process shortcut and execute associated command
|
|
// -------------------------------------------------------------------------
|
|
|
|
const shortcutKey = this.currentShortcutKeys_.join('');
|
|
const keymapItem = this.keymapItemByKey(shortcutKey);
|
|
|
|
// If this command is an alias to another command, resolve to the actual command
|
|
|
|
let processShortcutKeys = !this.app().currentCommand() && keymapItem;
|
|
if (keymapItem && keymapItem.canRunAlongOtherCommands) processShortcutKeys = true;
|
|
if (statusBar.promptActive) processShortcutKeys = false;
|
|
|
|
if (processShortcutKeys) {
|
|
this.logger().debug('Shortcut:', shortcutKey, keymapItem);
|
|
|
|
this.currentShortcutKeys_ = [];
|
|
|
|
if (keymapItem.type === 'function') {
|
|
this.processFunctionCommand(keymapItem.command);
|
|
} else if (keymapItem.type === 'prompt') {
|
|
const promptOptions = {};
|
|
if ('cursorPosition' in keymapItem) promptOptions.cursorPosition = keymapItem.cursorPosition;
|
|
const commandString = await statusBar.prompt(keymapItem.command ? keymapItem.command : '', null, promptOptions);
|
|
this.addCommandToConsole(commandString);
|
|
await this.processPromptCommand(commandString);
|
|
} else if (keymapItem.type === 'exec') {
|
|
this.stdout(keymapItem.command);
|
|
await this.processPromptCommand(keymapItem.command);
|
|
} else if (keymapItem.type === 'tkwidgets') {
|
|
this.widget('root').handleKey(this.tkWidgetKeys_[keymapItem.command]);
|
|
} else {
|
|
throw new Error(`Unknown command type: ${JSON.stringify(keymapItem)}`);
|
|
}
|
|
}
|
|
|
|
// Optimisation: Update the status bar only
|
|
// if the user is not already typing a command:
|
|
if (!statusBar.promptActive) this.updateStatusBarMessage();
|
|
});
|
|
} catch (error) {
|
|
this.fullScreen(false);
|
|
this.logger().error(error);
|
|
console.error(error);
|
|
}
|
|
|
|
process.on('unhandledRejection', (reason, p) => {
|
|
this.fullScreen(false);
|
|
console.error('Unhandled promise rejection', p, 'reason:', reason);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
}
|
|
|
|
AppGui.INPUT_MODE_NORMAL = 1;
|
|
AppGui.INPUT_MODE_META = 2;
|
|
|
|
module.exports = AppGui;
|