1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-09-16 08:56:40 +02:00

Improved autocompletion handling

This commit is contained in:
Laurent Cozic
2017-08-04 18:02:43 +02:00
parent 9ed22265ba
commit 2868a28422
16 changed files with 207 additions and 168 deletions

View File

@@ -14,7 +14,7 @@ import { _, setLocale, defaultLocale, closestSupportedLocale } from 'lib/locale.
import os from 'os'; import os from 'os';
import fs from 'fs-extra'; import fs from 'fs-extra';
import yargParser from 'yargs-parser'; import yargParser from 'yargs-parser';
import { handleAutocompletion } from './autocompletion.js'; import { handleAutocompletion, installAutocompletionFile } from './autocompletion.js';
import { cliUtils } from './cli-utils.js'; import { cliUtils } from './cli-utils.js';
class Application { class Application {
@@ -157,6 +157,12 @@ class Application {
continue; continue;
} }
if (arg == '--ac-install') {
this.autocompletion_.install = true;
argv.splice(0, 1);
continue;
}
if (arg == '--ac-current') { if (arg == '--ac-current') {
if (!nextArg) throw new Error(_('Usage: %s', '--ac-current <num>')); if (!nextArg) throw new Error(_('Usage: %s', '--ac-current <num>'));
this.autocompletion_.current = nextArg; this.autocompletion_.current = nextArg;
@@ -309,6 +315,8 @@ class Application {
let initArgs = startFlags.matched; let initArgs = startFlags.matched;
if (argv.length) this.showPromptString_ = false; if (argv.length) this.showPromptString_ = false;
Setting.setConstant('appName', initArgs.env == 'dev' ? 'joplindev' : 'joplin');
const profileDir = initArgs.profileDir ? initArgs.profileDir : os.homedir() + '/.config/' + Setting.value('appName'); const profileDir = initArgs.profileDir ? initArgs.profileDir : os.homedir() + '/.config/' + Setting.value('appName');
const resourceDir = profileDir + '/resources'; const resourceDir = profileDir + '/resources';
const tempDir = profileDir + '/tmp'; const tempDir = profileDir + '/tmp';
@@ -364,11 +372,26 @@ class Application {
Setting.setValue('activeFolderId', this.currentFolder_ ? this.currentFolder_.id : ''); Setting.setValue('activeFolderId', this.currentFolder_ ? this.currentFolder_.id : '');
if (this.autocompletion_.active) { if (this.autocompletion_.active) {
let items = await handleAutocompletion(this.autocompletion_); if (this.autocompletion_.install) {
for (let i = 0; i < items.length; i++) { try {
items[i] = items[i].replace(/ /g, '\\ '); installAutocompletionFile(Setting.value('appName'), Setting.value('profileDir'));
} catch (error) {
if (error.code == 'shellNotSupported') {
console.info(error.message);
return;
}
throw error;
}
} else {
let items = await handleAutocompletion(this.autocompletion_);
if (!items.length) return;
for (let i = 0; i < items.length; i++) {
items[i] = items[i].replace(/ /g, '\\ ');
}
//console.info(items);
console.info(items.join("\n"));
} }
console.info(items.join("\n"));
return; return;
} }

View File

@@ -1,14 +1,57 @@
import { app } from './app.js'; import { app } from './app.js';
import { Note } from 'lib/models/note.js'; import { Note } from 'lib/models/note.js';
import { Folder } from 'lib/models/folder.js'; import { Folder } from 'lib/models/folder.js';
import { Tag } from 'lib/models/tag.js';
import { cliUtils } from './cli-utils.js'; import { cliUtils } from './cli-utils.js';
import { _ } from 'lib/locale.js';
import fs from 'fs-extra';
import os from 'os';
import yargParser from 'yargs-parser'; import yargParser from 'yargs-parser';
function autocompletionFileContent(appName) {
let content = fs.readFileSync(__dirname + '/autocompletion_template.txt', 'utf8');
content = content.replace(/\|__APPNAME__\|/g, appName);
return content;
}
function installAutocompletionFile(appName, profileDir) {
if (process.env.SHELL.indexOf('bash') < 0) {
let error = new Error(_('Only Bash is currently supported for autocompletion.'));
error.code = 'shellNotSupported';
throw error;
}
const content = autocompletionFileContent(appName);
const filePath = profileDir + '/autocompletion.sh';
fs.writeFileSync(filePath, content);
const bashProfilePath = os.homedir() + '/.bashrc';
let bashrcContent = fs.readFileSync(bashProfilePath, 'utf8');
const lineToAdd = 'source ' + filePath;
console.info(_('Adding autocompletion script to: "%s"', bashProfilePath));
if (bashrcContent.indexOf(lineToAdd) >= 0) {
console.info(_('Autocompletion script is already installed.'));
} else {
bashrcContent += "\n" + lineToAdd + "\n";
fs.writeFileSync(bashProfilePath, bashrcContent);
console.info(_('Autocompletion has been installed.'));
}
console.info(_('Sourcing "%s"...', filePath));
const spawnSync = require('child_process').spawnSync;
spawnSync('source', [filePath]);
}
async function handleAutocompletion(autocompletion) { async function handleAutocompletion(autocompletion) {
let args = autocompletion.line.slice(); let args = autocompletion.line.slice();
args.splice(0, 1); args.splice(0, 1);
let current = autocompletion.current - 1; let current = autocompletion.current - 1;
const currentWord = args[current]; const currentWord = args[current] ? args[current] : '';
// Auto-complete the command name // Auto-complete the command name
@@ -32,21 +75,21 @@ async function handleAutocompletion(autocompletion) {
// Auto-complete the command options // Auto-complete the command options
if (!currentWord) return []; if (currentWord) {
const includeLongs = currentWord.length == 1 ? currentWord.substr(0, 1) == '-' : currentWord.substr(0, 2) == '--';
const includeShorts = currentWord.length <= 2 && currentWord.substr(0, 1) == '-' && currentWord.substr(0, 2) != '--';
const includeLongs = currentWord.length == 1 ? currentWord.substr(0, 1) == '-' : currentWord.substr(0, 2) == '--'; if (includeLongs || includeShorts) {
const includeShorts = currentWord.length <= 2 && currentWord.substr(0, 1) == '-' && currentWord.substr(0, 2) != '--'; const output = [];
for (let i = 0; i < options.length; i++) {
if (includeLongs || includeShorts) { const flags = cliUtils.parseFlags(options[i][0]);
const output = []; const long = flags.long ? '--' + flags.long : null;
for (let i = 0; i < options.length; i++) { const short = flags.short ? '-' + flags.short : null;
const flags = cliUtils.parseFlags(options[i][0]); if (includeLongs && long && long.indexOf(currentWord) === 0) output.push(long);
const long = flags.long ? '--' + flags.long : null; if (includeShorts && short && short.indexOf(currentWord) === 0) output.push(short);
const short = flags.short ? '-' + flags.short : null; }
if (includeLongs && long && long.indexOf(currentWord) === 0) output.push(long); return output;
if (includeShorts && short && short.indexOf(currentWord) === 0) output.push(short);
} }
return output;
} }
// Auto-complete the command arguments // Auto-complete the command arguments
@@ -70,7 +113,7 @@ async function handleAutocompletion(autocompletion) {
let argName = cmdUsage[argIndex]; let argName = cmdUsage[argIndex];
argName = cliUtils.parseCommandArg(argName).name; argName = cliUtils.parseCommandArg(argName).name;
if (argName == 'note') { if (argName == 'note' || argName == 'note-pattern') {
if (!app().currentFolder()) return []; if (!app().currentFolder()) return [];
const notes = await Note.previews(app().currentFolder().id, { titlePattern: currentWord + '*' }); const notes = await Note.previews(app().currentFolder().id, { titlePattern: currentWord + '*' });
return notes.map((n) => n.title); return notes.map((n) => n.title);
@@ -81,7 +124,29 @@ async function handleAutocompletion(autocompletion) {
return folders.map((n) => n.title); return folders.map((n) => n.title);
} }
if (argName == 'tag') {
let tags = await Tag.search({ titlePattern: currentWord + '*' });
return tags.map((n) => n.title);
}
if (argName == 'tag-command') {
return filterList(['add', 'remove', 'list'], currentWord);
}
if (argName == 'todo-command') {
return filterList(['toggle', 'clear'], currentWord);
}
return []; return [];
} }
export { handleAutocompletion }; function filterList(list, currentWord) {
let output = [];
for (let i = 0; i < list.length; i++) {
if (list[i].indexOf(currentWord) !== 0) continue;
output.push(list[i]);
}
return output;
}
export { handleAutocompletion, installAutocompletionFile };

View File

@@ -0,0 +1,40 @@
export IFS=$'\n'
_|__APPNAME__|_completion() {
# COMP_WORDS contains each word in the current command, the last one
# being the one that needs to be completed. Convert this array
# to an "escaped" line which can be passed to the joplin CLI
# which will provide the possible autocompletion words.
ESCAPED_LINE=""
for WORD in "${COMP_WORDS[@]}"
do
if [[ -n $ESCAPED_LINE ]]; then
ESCAPED_LINE="$ESCAPED_LINE|__SEP__|"
fi
WORD="${WORD/\"/|__QUOTE__|}"
ESCAPED_LINE="$ESCAPED_LINE$WORD"
done
# Call joplin with the --autocompletion flag to retrieve the autocompletion
# candidates (each on its own line), and put these into COMREPLY.
# echo "joplindev --autocompletion --ac-current "$COMP_CWORD" --ac-line "$ESCAPED_LINE"" > ~/test.txt
COMPREPLY=()
while read -r line; do
COMPREPLY+=("$line")
done <<< "$(|__APPNAME__| --autocompletion --ac-current "$COMP_CWORD" --ac-line "$ESCAPED_LINE")"
# If there's only one element and it's empty, make COMREPLY
# completely empty so that default completion takes over
# (i.e. regular file completion)
# https://stackoverflow.com/a/19062943/561309
if [[ -z ${COMPREPLY[0]} ]]; then
COMPREPLY=()
fi
}
complete -o default -F _|__APPNAME__|_completion |__APPNAME__|

View File

@@ -1,32 +0,0 @@
import { BaseCommand } from './base-command.js';
import { app } from './app.js';
import { _ } from 'lib/locale.js';
import { Setting } from 'lib/models/setting.js';
class Command extends BaseCommand {
usage() {
return 'alias <name> <command>';
}
description() {
return 'Creates a new command alias which can then be used as a regular command (eg. `alias ll "ls -l"`)';
}
async action(args) {
let aliases = Setting.value('aliases').trim();
aliases = aliases.length ? JSON.parse(aliases) : [];
aliases.push({
name: args.name,
command: args.command,
});
Setting.setValue('aliases', JSON.stringify(aliases));
}
enabled() {
return false; // Doesn't work properly at the moment
}
}
module.exports = Command;

View File

@@ -1,54 +0,0 @@
import { BaseCommand } from './base-command.js';
import { app } from './app.js';
import { _ } from 'lib/locale.js';
import { Note } from 'lib/models/note.js';
class Command extends BaseCommand {
usage() {
return 'autocompletion <type> [arg1]';
}
description() {
return 'Helper for autocompletion';
}
options() {
return [
[ '--before <before>', 'before' ],
[ '--multiline', 'multiline' ],
];
}
hidden() {
return true;
}
async action(args) {
console.info(args);
let output = [];
if (args.type == 'notes') {
// TODO:
if (!app().currentFolder()) throw new Error('no current folder');
let options = {};
// this.log(JSON.stringify(['XX'+args.options.before+'XX', 'aa','bb']));
// return;
//console.info(args.options.before);
if (args.options.before) options.titlePattern = args.options.before + '*';
const notes = await Note.previews(app().currentFolder().id, options);
output = notes.map((n) => n.title);
}
if (args.options.multiline) {
output = output.map((s) => s.replace(/ /g, '\\ '));
this.log(output.join("\n"));
} else {
this.log(JSON.stringify(output));
}
}
}
module.exports = Command;

View File

@@ -9,7 +9,7 @@ import { autocompleteItems } from './autocomplete.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'geoloc <title>'; return 'geoloc <note>';
} }
description() { description() {
@@ -21,7 +21,7 @@ class Command extends BaseCommand {
} }
async action(args) { async action(args) {
let title = args['title']; let title = args['note'];
let item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() }); let item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
if (!item) throw new Error(_('Cannot find "%s".', title)); if (!item) throw new Error(_('Cannot find "%s".', title));

View File

@@ -13,11 +13,11 @@ import { cliUtils } from './cli-utils.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'ls [pattern]'; return 'ls [note-pattern]';
} }
description() { description() {
return _('Displays the notes in [notebook]. Use `ls /` to display the list of notebooks.'); return _('Displays the notes in the current notebook. Use `ls /` to display the list of notebooks.');
} }
options() { options() {
@@ -36,7 +36,7 @@ class Command extends BaseCommand {
} }
async action(args) { async action(args) {
let pattern = args['pattern']; let pattern = args['note-pattern'];
let items = []; let items = [];
let options = args.options; let options = args.options;

View File

@@ -7,7 +7,7 @@ import { reg } from 'lib/registry.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'mkbook <notebook>'; return 'mkbook <new-notebook>';
} }
description() { description() {
@@ -19,7 +19,7 @@ class Command extends BaseCommand {
} }
async action(args) { async action(args) {
let folder = await Folder.save({ title: args['notebook'] }, { userSideValidation: true }); let folder = await Folder.save({ title: args['new-notebook'] }, { userSideValidation: true });
app().switchCurrentFolder(folder); app().switchCurrentFolder(folder);
} }

View File

@@ -6,7 +6,7 @@ import { Note } from 'lib/models/note.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'mknote <note>'; return 'mknote <new-note>';
} }
description() { description() {
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
if (!app().currentFolder()) throw new Error(_('Notes can only be created within a notebook.')); if (!app().currentFolder()) throw new Error(_('Notes can only be created within a notebook.'));
let note = { let note = {
title: args.note, title: args['new-note'],
parent_id: app().currentFolder().id, parent_id: app().currentFolder().id,
}; };

View File

@@ -6,7 +6,7 @@ import { Note } from 'lib/models/note.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'mktodo <note>'; return 'mktodo <new-todo>';
} }
description() { description() {
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
if (!app().currentFolder()) throw new Error(_('Notes can only be created within a notebook.')); if (!app().currentFolder()) throw new Error(_('Notes can only be created within a notebook.'));
let note = { let note = {
title: args.note, title: args['new-todo'],
parent_id: app().currentFolder().id, parent_id: app().currentFolder().id,
is_todo: 1, is_todo: 1,
}; };

View File

@@ -9,11 +9,11 @@ import { autocompleteItems } from './autocomplete.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'mv <pattern> <destination>'; return 'mv <note-pattern> [notebook]';
} }
description() { description() {
return _('Moves the notes matching <pattern> to <destination>. If <pattern> is a note, it will be moved to the notebook <destination>. If <pattern> is a notebook, it will be renamed to <destination>.'); return _('Moves the notes matching <note-pattern> to [notebook].');
} }
autocomplete() { autocomplete() {
@@ -21,26 +21,17 @@ class Command extends BaseCommand {
} }
async action(args) { async action(args) {
const pattern = args['pattern']; const pattern = args['note-pattern'];
const destination = args['destination']; const destination = args['notebook'];
const item = await app().guessTypeAndLoadItem(pattern); const folder = await Folder.loadByField('title', destination);
if (!folder) throw new Error(_('Cannot find "%s".', destination));
if (!item) throw new Error(_('Cannot find "%s".', pattern)); const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
if (item.type_ == BaseModel.TYPE_FOLDER) { for (let i = 0; i < notes.length; i++) {
await Folder.save({ id: item.id, title: destination }, { userSideValidation: true }); await Note.moveToFolder(notes[i].id, folder.id);
await app().refreshCurrentFolder();
} else { // TYPE_NOTE
const folder = await Folder.loadByField('title', destination);
if (!folder) throw new Error(_('Cannot find "%s".', destination));
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
for (let i = 0; i < notes.length; i++) {
await Note.moveToFolder(notes[i].id, folder.id);
}
} }
} }

View File

@@ -10,11 +10,11 @@ import { autocompleteItems } from './autocomplete.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'rm <pattern>'; return 'rm <note-pattern>';
} }
description() { description() {
return _('Deletes the items matching <pattern>.'); return _('Deletes the notes matching <note-pattern>.');
} }
autocomplete() { autocomplete() {
@@ -24,30 +24,29 @@ class Command extends BaseCommand {
options() { options() {
return [ return [
['-f, --force', _('Deletes the items without asking for confirmation.')], ['-f, --force', _('Deletes the items without asking for confirmation.')],
['-r, --recursive', _('Deletes a notebook.')],
]; ];
} }
async action(args) { async action(args) {
const pattern = args['pattern'].toString(); const pattern = args['note-pattern'];
const recursive = args.options && args.options.recursive === true; const recursive = args.options && args.options.recursive === true;
const force = true || args.options && args.options.force === true; // TODO const force = true || args.options && args.options.force === true; // TODO
if (recursive) { // if (recursive) {
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern); // const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
if (!folder) throw new Error(_('Cannot find "%s".', pattern)); // if (!folder) throw new Error(_('Cannot find "%s".', pattern));
//const ok = force ? true : await vorpalUtils.cmdPromptConfirm(this, _('Delete notebook "%s"?', folder.title)); // //const ok = force ? true : await vorpalUtils.cmdPromptConfirm(this, _('Delete notebook "%s"?', folder.title));
if (!ok) return; // if (!ok) return;
await Folder.delete(folder.id); // await Folder.delete(folder.id);
await app().refreshCurrentFolder(); // await app().refreshCurrentFolder();
} else { // } else {
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern)); const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
const ok = force ? true : await vorpalUtils.cmdPromptConfirm(this, _('%d notes match this pattern. Delete them?', notes.length)); if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
if (!ok) return; const ok = force ? true : await vorpalUtils.cmdPromptConfirm(this, _('%d notes match this pattern. Delete them?', notes.length));
let ids = notes.map((n) => n.id); if (!ok) return;
await Note.batchDelete(ids); let ids = notes.map((n) => n.id);
} await Note.batchDelete(ids);
} }
} }

View File

@@ -21,6 +21,10 @@ class Command extends BaseCommand {
return { data: autocompleteItems }; return { data: autocompleteItems };
} }
hidden() {
return true;
}
async action(args) { async action(args) {
let title = args['note']; let title = args['note'];
let propName = args['name']; let propName = args['name'];

View File

@@ -7,11 +7,11 @@ import { BaseModel } from 'lib/base-model.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'tag <command> [tag] [note]'; return 'tag <tag-command> [tag] [note]';
} }
description() { description() {
return _('<command> can be "add", "remove" or "list" to assign or remove [tag] from [note], or to list the notes associated with [tag]. The command `tag list` can be used to list all the tags.'); return _('<tag-command> can be "add", "remove" or "list" to assign or remove [tag] from [note], or to list the notes associated with [tag]. The command `tag list` can be used to list all the tags.');
} }
async action(args) { async action(args) {
@@ -22,21 +22,23 @@ class Command extends BaseCommand {
notes = await app().loadItems(BaseModel.TYPE_NOTE, args.note); notes = await app().loadItems(BaseModel.TYPE_NOTE, args.note);
} }
if (args.command == 'remove' && !tag) throw new Error(_('Cannot find "%s".', args.tag)); const command = args['tag-command'];
if (args.command == 'add') { if (command == 'remove' && !tag) throw new Error(_('Cannot find "%s".', args.tag));
if (command == 'add') {
if (!notes.length) throw new Error(_('Cannot find "%s".', args.note)); if (!notes.length) throw new Error(_('Cannot find "%s".', args.note));
if (!tag) tag = await Tag.save({ title: args.tag }); if (!tag) tag = await Tag.save({ title: args.tag });
for (let i = 0; i < notes.length; i++) { for (let i = 0; i < notes.length; i++) {
await Tag.addNote(tag.id, notes[i].id); await Tag.addNote(tag.id, notes[i].id);
} }
} else if (args.command == 'remove') { } else if (command == 'remove') {
if (!tag) throw new Error(_('Cannot find "%s".', args.tag)); if (!tag) throw new Error(_('Cannot find "%s".', args.tag));
if (!notes.length) throw new Error(_('Cannot find "%s".', args.note)); if (!notes.length) throw new Error(_('Cannot find "%s".', args.note));
for (let i = 0; i < notes.length; i++) { for (let i = 0; i < notes.length; i++) {
await Tag.removeNote(tag.id, notes[i].id); await Tag.removeNote(tag.id, notes[i].id);
} }
} else if (args.command == 'list') { } else if (command == 'list') {
if (tag) { if (tag) {
let notes = await Tag.notes(tag.id); let notes = await Tag.notes(tag.id);
notes.map((note) => { this.log(note.title); }); notes.map((note) => { this.log(note.title); });
@@ -45,7 +47,7 @@ class Command extends BaseCommand {
tags.map((tag) => { this.log(tag.title); }); tags.map((tag) => { this.log(tag.title); });
} }
} else { } else {
throw new Error(_('Invalid command: "%s"', args.command)); throw new Error(_('Invalid command: "%s"', command));
} }
} }

View File

@@ -10,11 +10,11 @@ import { autocompleteItems } from './autocomplete.js';
class Command extends BaseCommand { class Command extends BaseCommand {
usage() { usage() {
return 'todo <action> <pattern>'; return 'todo <todo-command> <note-pattern>';
} }
description() { description() {
return _('<action> can either be "toggle" or "clear". Use "toggle" to toggle the given todo between completed and uncompleted state (If the target is a regular note it will be converted to a todo). Use "clear" to convert the todo back to a regular note.'); return _('<todo-command> can either be "toggle" or "clear". Use "toggle" to toggle the given todo between completed and uncompleted state (If the target is a regular note it will be converted to a todo). Use "clear" to convert the todo back to a regular note.');
} }
autocomplete() { autocomplete() {
@@ -22,8 +22,8 @@ class Command extends BaseCommand {
} }
async action(args) { async action(args) {
const action = args.action; const action = args['todo-command'];
const pattern = args.pattern; const pattern = args['note-pattern'];
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern); const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern)); if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));

View File

@@ -7,6 +7,7 @@ mkdir -p "$CLIENT_DIR/build"
rm -f "$CLIENT_DIR/app/lib" rm -f "$CLIENT_DIR/app/lib"
ln -s "$CLIENT_DIR/../ReactNativeClient/lib" "$CLIENT_DIR/app" ln -s "$CLIENT_DIR/../ReactNativeClient/lib" "$CLIENT_DIR/app"
cp "$CLIENT_DIR/package.json" "$CLIENT_DIR/build" cp "$CLIENT_DIR/package.json" "$CLIENT_DIR/build"
cp "$CLIENT_DIR/app/autocompletion_template.txt" "$CLIENT_DIR/build"
npm run build npm run build
#yarn run build #yarn run build