2024-01-20 16:29:21 +02:00
import BaseApplication from '@joplin/lib/BaseApplication' ;
import { refreshFolders } from '@joplin/lib/folders-screen-utils.js' ;
import ResourceService from '@joplin/lib/services/ResourceService' ;
import BaseModel , { ModelType } from '@joplin/lib/BaseModel' ;
import Folder from '@joplin/lib/models/Folder' ;
import BaseItem from '@joplin/lib/models/BaseItem' ;
import Note from '@joplin/lib/models/Note' ;
import Tag from '@joplin/lib/models/Tag' ;
import Setting from '@joplin/lib/models/Setting' ;
import { reg } from '@joplin/lib/registry.js' ;
import { fileExtension } from '@joplin/lib/path-utils' ;
import { splitCommandString } from '@joplin/utils' ;
import { _ } from '@joplin/lib/locale' ;
import { pathExists , readFile , readdirSync } from 'fs-extra' ;
import RevisionService from '@joplin/lib/services/RevisionService' ;
import shim from '@joplin/lib/shim' ;
import setupCommand from './setupCommand' ;
import { FolderEntity , NoteEntity } from '@joplin/lib/services/database/types' ;
const { cliUtils } = require ( './cli-utils.js' ) ;
const Cache = require ( '@joplin/lib/Cache' ) ;
const { splitCommandBatch } = require ( '@joplin/lib/string-utils' ) ;
class Application extends BaseApplication {
private commands_ : Record < string , any > = { } ;
private commandMetadata_ : any = null ;
private activeCommand_ : any = null ;
private allCommandsLoaded_ = false ;
private gui_ : any = null ;
private cache_ = new Cache ( ) ;
public gui() {
return this . gui_ ;
}
public commandStdoutMaxWidth() {
return this . gui ( ) . stdoutMaxWidth ( ) ;
}
public async guessTypeAndLoadItem ( pattern : string , options : any = null ) {
let type = BaseModel . TYPE_NOTE ;
if ( pattern . indexOf ( '/' ) === 0 ) {
type = BaseModel . TYPE_FOLDER ;
pattern = pattern . substr ( 1 ) ;
}
return this . loadItem ( type , pattern , options ) ;
}
public async loadItem ( type : ModelType | 'folderOrNote' , pattern : string , options : any = null ) {
const output = await this . loadItems ( type , pattern , options ) ;
if ( output . length > 1 ) {
// output.sort((a, b) => { return a.user_updated_time < b.user_updated_time ? +1 : -1; });
// let answers = { 0: _('[Cancel]') };
// for (let i = 0; i < output.length; i++) {
// answers[i + 1] = output[i].title;
// }
// Not really useful with new UI?
throw new Error ( _ ( 'More than one item match "%s". Please narrow down your query.' , pattern ) ) ;
// let msg = _('More than one item match "%s". Please select one:', pattern);
// const response = await cliUtils.promptMcq(msg, answers);
// if (!response) return null;
// return output[response - 1];
} else {
return output . length ? output [ 0 ] : null ;
}
}
public async loadItems ( type : ModelType | 'folderOrNote' , pattern : string , options : any = null ) : Promise < ( FolderEntity | NoteEntity ) [ ] > {
if ( type === 'folderOrNote' ) {
const folders : FolderEntity [ ] = await this . loadItems ( BaseModel . TYPE_FOLDER , pattern , options ) ;
if ( folders . length ) return folders ;
return await this . loadItems ( BaseModel . TYPE_NOTE , pattern , options ) ;
}
pattern = pattern ? pattern . toString ( ) : '' ;
if ( type === BaseModel . TYPE_FOLDER && ( pattern === Folder . conflictFolderTitle ( ) || pattern === Folder . conflictFolderId ( ) ) ) return [ Folder . conflictFolder ( ) ] ;
if ( ! options ) options = { } ;
const parent = options . parent ? options.parent : app ( ) . currentFolder ( ) ;
const ItemClass = BaseItem . itemClass ( type ) ;
if ( type === BaseModel . TYPE_NOTE && pattern . indexOf ( '*' ) >= 0 ) {
// Handle it as pattern
if ( ! parent ) throw new Error ( _ ( 'No notebook selected.' ) ) ;
return await Note . previews ( parent . id , { titlePattern : pattern } ) ;
} else {
// Single item
let item = null ;
if ( type === BaseModel . TYPE_NOTE ) {
if ( ! parent ) throw new Error ( _ ( 'No notebook has been specified.' ) ) ;
2024-03-09 12:33:05 +02:00
item = await ( ItemClass as typeof Note ) . loadFolderNoteByField ( parent . id , 'title' , pattern ) ;
2024-01-20 16:29:21 +02:00
} else {
item = await ItemClass . loadByTitle ( pattern ) ;
}
if ( item ) return [ item ] ;
item = await ItemClass . load ( pattern ) ; // Load by id
if ( item ) return [ item ] ;
if ( pattern . length >= 2 ) {
return await ItemClass . loadByPartialId ( pattern ) ;
}
}
return [ ] ;
}
public setupCommand ( cmd : string ) {
return setupCommand ( cmd , ( t : string ) = > this . stdout ( t ) , ( ) = > this . store ( ) , ( ) = > this . gui ( ) ) ;
}
public stdout ( text : string ) {
return this . gui ( ) . stdout ( text ) ;
}
public async exit ( code = 0 ) {
const doExit = async ( ) = > {
this . gui ( ) . exit ( ) ;
await super . exit ( code ) ;
} ;
// Give it a few seconds to cancel otherwise exit anyway
shim . setTimeout ( async ( ) = > {
await doExit ( ) ;
} , 5000 ) ;
if ( await reg . syncTarget ( ) . syncStarted ( ) ) {
this . stdout ( _ ( 'Cancelling background synchronisation... Please wait.' ) ) ;
const sync = await reg . syncTarget ( ) . synchronizer ( ) ;
await sync . cancel ( ) ;
}
await doExit ( ) ;
}
public commands ( uiType : string = null ) {
if ( ! this . allCommandsLoaded_ ) {
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
readdirSync ( __dirname ) . forEach ( path = > {
if ( path . indexOf ( 'command-' ) !== 0 ) return ;
if ( path . endsWith ( '.test.js' ) ) return ;
const ext = fileExtension ( path ) ;
if ( ext !== 'js' ) return ;
const CommandClass = require ( ` ./ ${ path } ` ) ;
let cmd = new CommandClass ( ) ;
if ( ! cmd . enabled ( ) ) return ;
cmd = this . setupCommand ( cmd ) ;
this . commands_ [ cmd . name ( ) ] = cmd ;
} ) ;
this . allCommandsLoaded_ = true ;
}
if ( uiType !== null ) {
const temp : Record < string , any > = { } ;
for ( const n in this . commands_ ) {
if ( ! this . commands_ . hasOwnProperty ( n ) ) continue ;
const c = this . commands_ [ n ] ;
if ( ! c . supportsUi ( uiType ) ) continue ;
temp [ n ] = c ;
}
return temp ;
}
return this . commands_ ;
}
public async commandNames() {
const metadata = await this . commandMetadata ( ) ;
const output = [ ] ;
for ( const n in metadata ) {
if ( ! metadata . hasOwnProperty ( n ) ) continue ;
output . push ( n ) ;
}
return output ;
}
public async commandMetadata() {
if ( this . commandMetadata_ ) return this . commandMetadata_ ;
let output = await this . cache_ . getItem ( 'metadata' ) ;
if ( output ) {
this . commandMetadata_ = output ;
return { . . . this . commandMetadata_ } ;
}
const commands = this . commands ( ) ;
output = { } ;
for ( const n in commands ) {
if ( ! commands . hasOwnProperty ( n ) ) continue ;
const cmd = commands [ n ] ;
output [ n ] = cmd . metadata ( ) ;
}
await this . cache_ . setItem ( 'metadata' , output , 1000 * 60 * 60 * 24 ) ;
this . commandMetadata_ = output ;
return { . . . this . commandMetadata_ } ;
}
public hasGui() {
return this . gui ( ) && ! this . gui ( ) . isDummy ( ) ;
}
public findCommandByName ( name : string ) {
if ( this . commands_ [ name ] ) return this . commands_ [ name ] ;
let CommandClass = null ;
try {
CommandClass = require ( ` ${ __dirname } /command- ${ name } .js ` ) ;
} catch ( error ) {
if ( error . message && error . message . indexOf ( 'Cannot find module' ) >= 0 ) {
const e : any = new Error ( _ ( 'No such command: %s' , name ) ) ;
e . type = 'notFound' ;
throw e ;
} else {
throw error ;
}
}
let cmd = new CommandClass ( ) ;
cmd = this . setupCommand ( cmd ) ;
this . commands_ [ name ] = cmd ;
return this . commands_ [ name ] ;
}
public dummyGui() {
return {
isDummy : ( ) = > {
return true ;
} ,
prompt : ( initialText = '' , promptString = '' , options : any = null ) = > {
return cliUtils . prompt ( initialText , promptString , options ) ;
} ,
showConsole : ( ) = > { } ,
maximizeConsole : ( ) = > { } ,
stdout : ( text : string ) = > {
// eslint-disable-next-line no-console
console . info ( text ) ;
} ,
fullScreen : ( ) = > { } ,
exit : ( ) = > { } ,
showModalOverlay : ( ) = > { } ,
hideModalOverlay : ( ) = > { } ,
stdoutMaxWidth : ( ) = > {
return 100 ;
} ,
forceRender : ( ) = > { } ,
termSaveState : ( ) = > { } ,
termRestoreState : ( ) = > { } ,
} ;
}
public async execCommand ( argv : string [ ] ) : Promise < any > {
if ( ! argv . length ) return this . execCommand ( [ 'help' ] ) ;
// reg.logger().debug('execCommand()', argv);
const commandName = argv [ 0 ] ;
this . activeCommand_ = this . findCommandByName ( commandName ) ;
let outException = null ;
try {
if ( this . gui ( ) . isDummy ( ) && ! this . activeCommand_ . supportsUi ( 'cli' ) ) throw new Error ( _ ( 'The command "%s" is only available in GUI mode' , this . activeCommand_ . name ( ) ) ) ;
const cmdArgs = cliUtils . makeCommandArgs ( this . activeCommand_ , argv ) ;
await this . activeCommand_ . action ( cmdArgs ) ;
} catch ( error ) {
outException = error ;
}
this . activeCommand_ = null ;
if ( outException ) throw outException ;
}
public currentCommand() {
return this . activeCommand_ ;
}
public async loadKeymaps() {
interface KeyMapItem {
keys : string [ ] ;
type : 'function' | 'prompt' ;
command : string ;
cursorPosition? : number ;
}
const defaultKeyMap : KeyMapItem [ ] = [
{ keys : [ ':' ] , type : 'function' , command : 'enter_command_line_mode' } ,
{ keys : [ 'TAB' ] , type : 'function' , command : 'focus_next' } ,
{ keys : [ 'SHIFT_TAB' ] , type : 'function' , command : 'focus_previous' } ,
{ keys : [ 'UP' ] , type : 'function' , command : 'move_up' } ,
{ keys : [ 'DOWN' ] , type : 'function' , command : 'move_down' } ,
{ keys : [ 'PAGE_UP' ] , type : 'function' , command : 'page_up' } ,
{ keys : [ 'PAGE_DOWN' ] , type : 'function' , command : 'page_down' } ,
{ keys : [ 'ENTER' ] , type : 'function' , command : 'activate' } ,
{ keys : [ 'DELETE' , 'BACKSPACE' ] , type : 'function' , command : 'delete' } ,
{ keys : [ 'n' ] , type : 'function' , command : 'next_link' } ,
{ keys : [ 'b' ] , type : 'function' , command : 'previous_link' } ,
{ keys : [ 'o' ] , type : 'function' , command : 'open_link' } ,
{ keys : [ ' ' ] , type : 'prompt' , command : 'todo toggle $n' } ,
{ keys : [ 'tc' ] , type : 'function' , command : 'toggle_console' } ,
{ keys : [ 'tm' ] , type : 'function' , command : 'toggle_metadata' } ,
{ keys : [ 'ti' ] , type : 'function' , command : 'toggle_ids' } ,
2024-03-02 16:25:27 +02:00
{ keys : [ 'r' ] , type : 'prompt' , command : 'restore $n' } ,
2024-01-20 16:29:21 +02:00
{ keys : [ '/' ] , type : 'prompt' , command : 'search ""' , cursorPosition : - 2 } ,
{ keys : [ 'mn' ] , type : 'prompt' , command : 'mknote ""' , cursorPosition : - 2 } ,
{ keys : [ 'mt' ] , type : 'prompt' , command : 'mktodo ""' , cursorPosition : - 2 } ,
{ keys : [ 'mb' ] , type : 'prompt' , command : 'mkbook ""' , cursorPosition : - 2 } ,
{ keys : [ 'yn' ] , type : 'prompt' , command : 'cp $n ""' , cursorPosition : - 2 } ,
{ keys : [ 'dn' ] , type : 'prompt' , command : 'mv $n ""' , cursorPosition : - 2 } ,
] ;
// Filter the keymap item by command so that items in keymap.json can override
// the default ones.
const itemsByCommand : Record < string , KeyMapItem > = { } ;
for ( let i = 0 ; i < defaultKeyMap . length ; i ++ ) {
itemsByCommand [ defaultKeyMap [ i ] . command ] = defaultKeyMap [ i ] ;
}
const filePath = ` ${ Setting . value ( 'profileDir' ) } /keymap.json ` ;
if ( await pathExists ( filePath ) ) {
try {
let configString = await readFile ( filePath , 'utf-8' ) ;
configString = configString . replace ( /^\s*\/\/.*/ , '' ) ; // Strip off comments
const keymap = JSON . parse ( configString ) ;
for ( let keymapIndex = 0 ; keymapIndex < keymap . length ; keymapIndex ++ ) {
const item = keymap [ keymapIndex ] ;
itemsByCommand [ item . command ] = item ;
}
} catch ( error ) {
let msg = error . message ? error . message : '' ;
msg = ` Could not load keymap ${ filePath } \ n ${ msg } ` ;
error . message = msg ;
throw error ;
}
}
const output = [ ] ;
for ( const n in itemsByCommand ) {
if ( ! itemsByCommand . hasOwnProperty ( n ) ) continue ;
output . push ( itemsByCommand [ n ] ) ;
}
// Map reserved shortcuts to their equivalent key
// https://github.com/cronvel/terminal-kit/issues/101
for ( let i = 0 ; i < output . length ; i ++ ) {
const newKeys = output [ i ] . keys . map ( k = > {
k = k . replace ( /CTRL_H/g , 'BACKSPACE' ) ;
k = k . replace ( /CTRL_I/g , 'TAB' ) ;
k = k . replace ( /CTRL_M/g , 'ENTER' ) ;
return k ;
} ) ;
output [ i ] . keys = newKeys ;
}
return output ;
}
public async commandList ( argv : string [ ] ) {
if ( argv . length && argv [ 0 ] === 'batch' ) {
const commands = [ ] ;
const commandLines = splitCommandBatch ( await readFile ( argv [ 1 ] , 'utf-8' ) ) ;
for ( const commandLine of commandLines ) {
if ( ! commandLine . trim ( ) ) continue ;
const splitted = splitCommandString ( commandLine . trim ( ) ) ;
commands . push ( splitted ) ;
}
return commands ;
} else {
return [ argv ] ;
}
}
// We need this special case here because by the time the `version` command
// runs, the keychain has already been setup.
public checkIfKeychainEnabled ( argv : string [ ] ) {
return argv . indexOf ( 'version' ) < 0 ;
}
public async start ( argv : string [ ] ) {
const keychainEnabled = this . checkIfKeychainEnabled ( argv ) ;
argv = await super . start ( argv , { keychainEnabled } ) ;
cliUtils . setStdout ( ( object : any ) = > {
return this . stdout ( object ) ;
} ) ;
this . initRedux ( ) ;
// If we have some arguments left at this point, it's a command
// so execute it.
if ( argv . length ) {
this . gui_ = this . dummyGui ( ) ;
this . currentFolder_ = await Folder . load ( Setting . value ( 'activeFolderId' ) ) ;
await this . applySettingsSideEffects ( ) ;
try {
const commands = await this . commandList ( argv ) ;
for ( const command of commands ) {
await this . execCommand ( command ) ;
}
} catch ( error ) {
if ( this . showStackTraces_ ) {
console . error ( error ) ;
} else {
// eslint-disable-next-line no-console
console . info ( error . message ) ;
}
process . exit ( 1 ) ;
}
await Setting . saveAll ( ) ;
// Need to call exit() explicitly, otherwise Node wait for any timeout to complete
// https://stackoverflow.com/questions/18050095
process . exit ( 0 ) ;
} else {
// Otherwise open the GUI
const keymap = await this . loadKeymaps ( ) ;
const AppGui = require ( './app-gui.js' ) ;
this . gui_ = new AppGui ( this , this . store ( ) , keymap ) ;
this . gui_ . setLogger ( this . logger ( ) ) ;
await this . gui_ . start ( ) ;
// Since the settings need to be loaded before the store is created, it will never
// receive the SETTING_UPDATE_ALL even, which mean state.settings will not be
// initialised. So we manually call dispatchUpdateAll() to force an update.
Setting . dispatchUpdateAll ( ) ;
await refreshFolders ( ( action : any ) = > this . store ( ) . dispatch ( action ) ) ;
const tags = await Tag . allWithNotes ( ) ;
ResourceService . runInBackground ( ) ;
RevisionService . instance ( ) . runInBackground ( ) ;
this . dispatch ( {
type : 'TAG_UPDATE_ALL' ,
items : tags ,
} ) ;
this . store ( ) . dispatch ( {
type : 'FOLDER_SELECT' ,
id : Setting.value ( 'activeFolderId' ) ,
} ) ;
this . startRotatingLogMaintenance ( Setting . value ( 'profileDir' ) ) ;
}
}
}
let application_ : Application = null ;
function app() {
if ( application_ ) return application_ ;
application_ = new Application ( ) ;
return application_ ;
}
export default app ;