Better error handling, and beginning of i18n

merge-requests/2/head
gardenapple 3 years ago
parent 2362e45247
commit 86acd52b61
No known key found for this signature in database
GPG Key ID: CAF17E9ABE789268

@ -4,7 +4,7 @@
Firefox Reader Mode in your terminal! CLI tool for Mozilla's Readability library Firefox Reader Mode in your terminal! CLI tool for Mozilla's Readability library
Copyright (C) 2020 gardenapple Copyright (C) 2021 gardenapple
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
@ -19,8 +19,22 @@ Firefox Reader Mode in your terminal! CLI tool for Mozilla's Readability library
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
//const parseArgs = require("minimist");
// GNU gettext gives preference to LANGUAGE, but this order is consistent with Yargs:
const locale = (
process.env.LC_ALL ||
process.env.LC_MESSAGES ||
process.env.LANG ||
process.env.LANGUAGE ||
'en_US'
).replace(/[.:].*/, '');
const yargs = require("yargs"); const yargs = require("yargs");
const __ = require("y18n")({
locale: locale,
updateFiles: false
}).__;
//JSDOM, fs, Readability, and Readability-readerable are loaded on-demand. //JSDOM, fs, Readability, and Readability-readerable are loaded on-demand.
//To-do: lazy loading? //To-do: lazy loading?
@ -97,7 +111,7 @@ function yargsFixPositional(args) {
let args = yargs let args = yargs
.version(false) .version(false)
.command("* [source]", "Process HTML input", (yargs) => { .command("* [source]", __`Process HTML input`, (yargs) => {
yargs.positional("source", { yargs.positional("source", {
desc: "A file, an http(s) URL, or '-' for standard input", desc: "A file, an http(s) URL, or '-' for standard input",
type: "string" type: "string"
@ -132,61 +146,57 @@ let args = yargs
.middleware([ yargsCompatProperties, yargsFixPositional ], true) //middleware seems to be buggy .middleware([ yargsCompatProperties, yargsFixPositional ], true) //middleware seems to be buggy
.option("completion", { .option("completion", {
type: "boolean", type: "boolean",
desc: "Print script for bash/zsh completion" desc: __`Print script for bash/zsh completion`
}) })
.option("version", { .option("version", {
alias: 'V', alias: 'V',
type: "boolean", type: "boolean",
desc: "Print version" desc: __`Print version`
}) })
.option("help", { .option("help", {
alias: 'h', alias: 'h',
desc: "Show help" desc: __`Show help`
})
.option("base", {
alias: 'b',
desc: "Show help"
}) })
.option("output", { .option("output", {
alias: 'o', alias: 'o',
type: "string", type: "string",
desc: "The file to which the result should be output" desc: __`The file to which the result should be output`
}) })
.option("low-confidence", { .option("low-confidence", {
alias: 'l', alias: 'l',
type: "string", type: "string",
desc: "What to do if Readability.js is uncertain about what the core content actually is", desc: __`What to do if Readability.js is uncertain about what the core content actually is`,
//default: "no-op", //don't set default because completion won't work //default: "no-op", //don't set default because completion won't work
choices: ["no-op", "force", "exit"] choices: ["no-op", "force", "exit"]
}) })
.option("properties", { .option("properties", {
alias: 'p', alias: 'p',
type: "array", type: "array",
desc: "Output specific properties of the parsed article", desc: __`Output specific properties of the parsed article`,
choices: ["html-title", "title", "excerpt", "byline", "length", "dir", "html-content", "text-content"] choices: ["html-title", "title", "excerpt", "byline", "length", "dir", "html-content", "text-content"]
}) })
.option("quiet", { .option("quiet", {
alias: 'q', alias: 'q',
type: "boolean", type: "boolean",
desc: "Don't output extra information to stderr", desc: __`Don't output extra information to stderr`,
default: false default: false
}) })
.option("base", { .option("base", {
alias: 'b', alias: 'b',
type: "string", type: "string",
desc: "Set the document URL when parsing standard input or a local file (this affects relative links)" desc: __`Set the document URL when parsing standard input or a local file (this affects relative links)`
}) })
.option("url", { .option("url", {
alias: 'u', alias: 'u',
type: "string", type: "string",
desc: "(deprecated) alias for --base", desc: __`(deprecated) alias for --base`,
hidden: true, hidden: true,
//deprecated: true //completion script does not respect this value, so just say it in the description //deprecated: true //completion script does not respect this value, so just say it in the description
}) })
.option("is-file", { .option("is-file", {
alias: 'f', alias: 'f',
type: "boolean", type: "boolean",
desc: "Interpret SOURCE as a file name rather than a URL", desc: __`Interpret SOURCE as a file name rather than a URL`,
default: false, default: false,
hidden: true, hidden: true,
//deprecated: true //deprecated: true
@ -194,35 +204,35 @@ let args = yargs
.option("is-url", { .option("is-url", {
alias: 'U', alias: 'U',
type: "boolean", type: "boolean",
desc: "(deprecated) Interpret SOURCE as a URL rather than file name", desc: __`(deprecated) Interpret SOURCE as a URL rather than file name`,
hidden: true, hidden: true,
//deprecated: true //deprecated: true
}) })
.option("json", { .option("json", {
alias: 'j', alias: 'j',
type: "boolean", type: "boolean",
desc: "Output properties as a JSON payload" desc: __`Output properties as a JSON payload`
}) })
.epilogue(`The --low-confidence option determines what should be done for documents where Readability can't tell what the core content is: .epilogue(__`The --low-confidence option determines what should be done for documents where Readability can't tell what the core content is:\n` +
no-op When unsure, don't touch the HTML, output as-is. This is incompatible with the --properties option. __` no-op When unsure, don't touch the HTML, output as-is. This is incompatible with the --properties option.\n` +
force Process the document even when unsure (may produce really bad output). __` force Process the document even when unsure (may produce really bad output).\n` +
exit When unsure, exit with an error. __` exit When unsure, exit with an error.\n` +
'\n' +
Default value is "no-op". __`Default value is "no-op".\n` +
'\n' +
'\n' +
The --properties option accepts a comma-separated list of values (with no spaces in-between). Suitable values are: __`The --properties option accepts a comma-separated list of values (with no spaces in-between). Suitable values are:\n` +
html-title Outputs the article's title, wrapped in an <h1> tag. __` html-title Outputs the article's title, wrapped in an <h1> tag.\n` +
title Outputs the title in the format "Title: $TITLE". __` title Outputs the title in the format "Title: $TITLE".\n` +
excerpt Article description, or short excerpt from the content, in the format "Excerpt: $EXCERPT" __` excerpt Article description, or short excerpt from the content, in the format "Excerpt: $EXCERPT".\n` +
byline Author metadata, in the format "Author: $AUTHOR" __` byline Author metadata, in the format "Author: $AUTHOR".\n` +
length Length of the article in characters, in the format "Length: $LENGTH" __` length Length of the article in characters, in the format "Length: $LENGTH".\n` +
dir Content direction, is either "Direction: ltr" or "Direction: rtl" __` dir Content direction, is either "Direction: ltr" or "Direction: rtl".\n` +
html-content Outputs the article's main content as HTML. __` html-content Outputs the article's main content as HTML.\n` +
text-content Outputs the article's main content as plain text. __` text-content Outputs the article's main content as plain text.\n` +
'\n' +
Text-content and Html-content are mutually exclusive, and are always printed last. __`Text-content and Html-content are mutually exclusive, and are always printed last.\n` +
Default value is "html-title,html-content".`) __`Default value is "html-title,html-content".\n`)
.wrap(Math.min(yargs.terminalWidth(), 120)) .wrap(Math.min(yargs.terminalWidth(), 120))
.strict() .strict()
.parse(); .parse();
@ -232,8 +242,11 @@ if (!args["low-confidence"]) {
args['l'] = LowConfidenceMode.noOp; args['l'] = LowConfidenceMode.noOp;
} }
if (args["is-url"]) {
console.error(__`Note: --is-url option is deprecated.`);
}
if (args["url"]) { if (args["url"]) {
console.error("Note: --url option is deprecated, please use --base instead."); console.error(__`Note: --url option is deprecated, please use --base instead.`);
args["base"] = args["url"]; args["base"] = args["url"];
} }
@ -259,7 +272,7 @@ if (args.version) {
let inputArg; let inputArg;
if (!args["source"]) { if (!args["source"]) {
if (process.stdin.isTTY) { if (process.stdin.isTTY) {
console.error("No input provided"); console.error(__`No input provided`);
printUsage(); printUsage();
setErrored(ExitCodes.badUsageCLI); setErrored(ExitCodes.badUsageCLI);
return; return;
@ -275,9 +288,9 @@ let inputFile;
let inputURL; let inputURL;
let inputIsFromStdin = false; let inputIsFromStdin = false;
if (args["is-url"] && !(inputArg.includes("://"))) if (args["is-url"] && inputArg.search(/^\w+:\/\//) != -1)
inputArg = "https://" + inputArg; inputArg = "https://" + inputArg;
if (!args["is-file"] && (inputArg.startsWith("https://") || inputArg.startsWith("http://"))) if (!args["is-file"] && inputArg.search(/^\w+:\/\//) != -1)
inputURL = inputArg; inputURL = inputArg;
else if (inputArg == '-') else if (inputArg == '-')
inputIsFromStdin = true; inputIsFromStdin = true;
@ -321,9 +334,7 @@ if (inputIsFromStdin) {
if (!args["quiet"]) { if (!args["quiet"]) {
console.error("Reading..."); console.error("Reading...");
if (!documentURL) if (!documentURL)
console.error("Warning: piping input with unknown " + console.error(__`Warning: piping input with unknown URL. This means that relative links will be broken. Supply the --base parameter to fix.`)
"URL. This means that relative links will " +
"be broken. Supply the --base parameter to fix.")
} }
read(process.stdin).then(result => { read(process.stdin).then(result => {
const JSDOM = require("jsdom").JSDOM; const JSDOM = require("jsdom").JSDOM;
@ -332,17 +343,10 @@ if (inputIsFromStdin) {
} else { } else {
const JSDOM = require("jsdom").JSDOM; const JSDOM = require("jsdom").JSDOM;
if (!args["quiet"]) if (!args["quiet"])
console.error("Retrieving..."); console.error(__`Retrieving...`);
let promiseGetHTML; let promiseGetHTML;
if (inputURL) { if (inputURL) {
promiseGetHTML = JSDOM.fromURL(inputURL).catch(error => { promiseGetHTML = JSDOM.fromURL(inputURL)
if (error instanceof TypeError) {
console.error(`Invalid URL: ${inputURL}`);
setErrored(ExitCodes.dataError);
}
return Promise.reject();
});
} else if (inputFile) { } else if (inputFile) {
promiseGetHTML = JSDOM.fromFile(inputFile, { promiseGetHTML = JSDOM.fromFile(inputFile, {
url: documentURL url: documentURL
@ -371,14 +375,14 @@ function onLoadDOM(dom) {
if (!shouldParseArticle) { if (!shouldParseArticle) {
if (args["low-confidence"] == LowConfidenceMode.exit) { if (args["low-confidence"] == LowConfidenceMode.exit) {
console.error("Not sure if this document should be processed, exiting"); console.error(__`Not sure if this document should be processed, exiting`);
setErrored(ExitCodes.dataError); setErrored(ExitCodes.dataError);
return; return;
} else { } else {
if (!args["quiet"]) if (!args["quiet"])
console.error("Not sure if this document should be processed. Not processing"); console.error(__`Not sure if this document should be processed. Not processing`);
if (wantedPropertiesCustom) { if (wantedPropertiesCustom) {
console.error("Can't output properties"); console.error(__`Can't output properties`);
setErrored(ExitCodes.dataError); setErrored(ExitCodes.dataError);
return; return;
} }
@ -397,12 +401,12 @@ function onLoadDOM(dom) {
if (shouldParseArticle) { if (shouldParseArticle) {
if (!args["quiet"]) if (!args["quiet"])
console.error("Processing..."); console.error(__`Processing...`);
const reader = new Readability(document); const reader = new Readability(document);
const article = reader.parse(); const article = reader.parse();
if (!article) { if (!article) {
console.error("Couldn't process document."); console.error(__`Couldn't process document.`);
setErrored(ExitCodes.dataError); setErrored(ExitCodes.dataError);
return; return;
} }
@ -427,19 +431,19 @@ function onLoadDOM(dom) {
} }
if (wantedProperties.includes(Properties.title)) { if (wantedProperties.includes(Properties.title)) {
writeStream.write(`Title: ${article.title}\n`); writeStream.write(__`Title: ${article.title}\n`);
} }
if (wantedProperties.includes(Properties.excerpt)) { if (wantedProperties.includes(Properties.excerpt)) {
writeStream.write(`Excerpt: ${article.excerpt}\n`); writeStream.write(__`Excerpt: ${article.excerpt}\n`);
} }
if (wantedProperties.includes(Properties.byline)) { if (wantedProperties.includes(Properties.byline)) {
writeStream.write(`Author: ${article.byline}\n`); writeStream.write(__`Author: ${article.byline}\n`);
} }
if (wantedProperties.includes(Properties.length)) { if (wantedProperties.includes(Properties.length)) {
writeStream.write(`Length: ${article.length}\n`); writeStream.write(__`Length: ${article.length}\n`);
} }
if (wantedProperties.includes(Properties.dir)) { if (wantedProperties.includes(Properties.dir)) {
writeStream.write(`Direction: ${article.dir}\n`); writeStream.write(__`Direction: ${article.dir}\n`);
} }
if (wantedProperties.includes(Properties.htmlTitle)) { if (wantedProperties.includes(Properties.htmlTitle)) {
writeStream.write(`<h1>${escapeHTML(article.title, document)}</h1>\n`); writeStream.write(`<h1>${escapeHTML(article.title, document)}</h1>\n`);
@ -456,25 +460,25 @@ function onLoadDOM(dom) {
} }
function onLoadDOMError(error) { function onLoadDOMError(error) {
//resolved earlier if (error instanceof TypeError && inputURL) {
if (!error) console.error(__`Invalid URL: ${inputURL}`);
return; setErrored(ExitCodes.dataError);
} else if (error.code == "ENOENT") {
if (error.code == "ENOENT") {
console.error(error.message); console.error(error.message);
setErrored(ExitCodes.noInput); setErrored(ExitCodes.noInput);
} else if (error.code == "EACCES") { } else if (error.code == "EACCES") {
console.error(error.message); console.error(error.message);
setErrored(ExitCodes.noPermission); setErrored(ExitCodes.noPermission);
} else if (error.error && error.error.code == "ENOTFOUND") { } else if (error.error && error.error.code == "ENOTFOUND") {
console.error(`Host not found: '${error.error.hostname}'`); console.error(__`Host not found: '${error.error.hostname}'`);
setErrored(ExitCodes.noHost); setErrored(ExitCodes.noHost);
} else if (error.statusCode) { } else if (error.statusCode) {
console.error(`Status error: ${error.response.statusMessage}`); console.error(__`Status error: ${error.response.statusMessage}`);
setErrored(ExitCodes.noHost); setErrored(ExitCodes.noHost);
} else { } else {
console.error(error); console.error(error.message);
if (error.stack) // if (error.stack)
console.error(error.stack); // console.error(error.stack)
setErrored(ExitCodes.dataError);
} }
} }

@ -0,0 +1,49 @@
{
"Process HTML input": "Process HTML input",
"Print script for bash/zsh completion": "Print script for bash/zsh completion",
"Print version": "Print version",
"Show help": "Show help",
"The file to which the result should be output": "The file to which the result should be output",
"What to do if Readability.js is uncertain about what the core content actually is": "What to do if Readability.js is uncertain about what the core content actually is",
"Output specific properties of the parsed article": "Output specific properties of the parsed article",
"Don't output extra information to stderr": "Don't output extra information to stderr",
"Set the document URL when parsing standard input or a local file (this affects relative links)": "Set the document URL when parsing standard input or a local file (this affects relative links)",
"(deprecated) alias for --base": "(deprecated) alias for --base",
"Interpret SOURCE as a file name rather than a URL": "Interpret SOURCE as a file name rather than a URL",
"(deprecated) Interpret SOURCE as a URL rather than file name": "(deprecated) Interpret SOURCE as a URL rather than file name",
"Output properties as a JSON payload": "Output properties as a JSON payload",
"The --low-confidence option determines what should be done for documents where Readability can't tell what the core content is:\n": "The --low-confidence option determines what should be done for documents where Readability can't tell what the core content is:\n",
" no-op When unsure, don't touch the HTML, output as-is. This is incompatible with the --properties option.\n": " no-op When unsure, don't touch the HTML, output as-is. This is incompatible with the --properties option.\n",
" force Process the document even when unsure (may produce really bad output).\n": " force Process the document even when unsure (may produce really bad output).\n",
" exit When unsure, exit with an error.\n": " exit When unsure, exit with an error.\n",
"Default value is \"no-op\".\n": "Default value is \"no-op\".\n",
"The --properties option accepts a comma-separated list of values (with no spaces in-between). Suitable values are:\n": "The --properties option accepts a comma-separated list of values (with no spaces in-between). Suitable values are:\n",
" html-title Outputs the article's title, wrapped in an <h1> tag.\n": " html-title Outputs the article's title, wrapped in an <h1> tag.\n",
" title Outputs the title in the format \"Title: $TITLE\".\n": " title Outputs the title in the format \"Title: $TITLE\".\n",
" excerpt Article description, or short excerpt from the content, in the format \"Excerpt: $EXCERPT\"\n": " excerpt Article description, or short excerpt from the content, in the format \"Excerpt: $EXCERPT\"\n",
" byline Author metadata, in the format \"Author: $AUTHOR\"\n": " byline Author metadata, in the format \"Author: $AUTHOR\"\n",
" length Length of the article in characters, in the format \"Length: $LENGTH\"\n": " length Length of the article in characters, in the format \"Length: $LENGTH\"\n",
" dir Content direction, is either \"Direction: ltr\" or \"Direction: rtl\"\n": " dir Content direction, is either \"Direction: ltr\" or \"Direction: rtl\"\n",
" html-content Outputs the article's main content as HTML.\n": " html-content Outputs the article's main content as HTML.\n",
" text-content Outputs the article's main content as plain text.\n": " text-content Outputs the article's main content as plain text.\n",
"Text-content and Html-content are mutually exclusive, and are always printed last.\n": "Text-content and Html-content are mutually exclusive, and are always printed last.\n",
"Default value is \"html-title,html-content\".\n": "Default value is \"html-title,html-content\".\n",
"No input provided": "No input provided.",
"Note: --is-url option is deprecated.": "Note: --is-url option is deprecated.",
"Note: --url option is deprecated, please use --base instead.": "Note: --url option is deprecated, please use --base instead.",
"Retrieving...": "Retrieving...",
"Warning: piping input with unknown URL. This means that relative links will be broken. Supply the --base parameter to fix.": "Warning: piping input with unknown URL. This means that relative links will be broken. Supply the --base parameter to fix.",
"Not sure if this document should be processed. Not processing": "Not sure if this document should be processed. Not processing",
"Error while retrieving %s:": "Error while retrieving %s:",
"Invalid URL: %s": "Invalid URL: %s",
"Processing...": "Processing...",
"Not sure if this document should be processed, exiting": "Not sure if this document should be processed, exiting",
"Can't output properties": "Can't output properties",
"Couldn't process document.": "Couldn't process document.",
"Title: %s\n": "Title: %s\n",
"Excerpt: %s\n": "Excerpt: %s\n",
"Author: %s\n": "Author: %s\n",
"Length: %s\n": "Length: %s\n",
"Direction: %s\n": "Direction: %s\n",
"Host not found: '%s'": "Host not found: '%s'"
}

@ -0,0 +1,49 @@
{
"Process HTML input": "Обработать входной HTML",
"Print script for bash/zsh completion": "Print script for bash/zsh completion",
"Print version": "Print version",
"Show help": "Show help",
"The file to which the result should be output": "The file to which the result should be output",
"What to do if Readability.js is uncertain about what the core content actually is": "What to do if Readability.js is uncertain about what the core content actually is",
"Output specific properties of the parsed article": "Output specific properties of the parsed article",
"Don't output extra information to stderr": "Don't output extra information to stderr",
"Set the document URL when parsing standard input or a local file (this affects relative links)": "Set the document URL when parsing standard input or a local file (this affects relative links)",
"(deprecated) alias for --base": "(deprecated) alias for --base",
"Interpret SOURCE as a file name rather than a URL": "Interpret SOURCE as a file name rather than a URL",
"(deprecated) Interpret SOURCE as a URL rather than file name": "(deprecated) Interpret SOURCE as a URL rather than file name",
"Output properties as a JSON payload": "Output properties as a JSON payload",
"The --low-confidence option determines what should be done for documents where Readability can't tell what the core content is:\n": "The --low-confidence option determines what should be done for documents where Readability can't tell what the core content is:\n",
" no-op When unsure, don't touch the HTML, output as-is. This is incompatible with the --properties option.\n": " no-op When unsure, don't touch the HTML, output as-is. This is incompatible with the --properties option.\n",
" force Process the document even when unsure (may produce really bad output).\n": " force Process the document even when unsure (may produce really bad output).\n",
" exit When unsure, exit with an error.\n": " exit When unsure, exit with an error.\n",
"Default value is \"no-op\".\n": "Default value is \"no-op\".\n",
"The --properties option accepts a comma-separated list of values (with no spaces in-between). Suitable values are:\n": "The --properties option accepts a comma-separated list of values (with no spaces in-between). Suitable values are:\n",
" html-title Outputs the article's title, wrapped in an <h1> tag.\n": " html-title Outputs the article's title, wrapped in an <h1> tag.\n",
" title Outputs the title in the format \"Title: $TITLE\".\n": " title Outputs the title in the format \"Title: $TITLE\".\n",
" excerpt Article description, or short excerpt from the content, in the format \"Excerpt: $EXCERPT\"\n": " excerpt Article description, or short excerpt from the content, in the format \"Excerpt: $EXCERPT\"\n",
" byline Author metadata, in the format \"Author: $AUTHOR\"\n": " byline Author metadata, in the format \"Author: $AUTHOR\"\n",
" length Length of the article in characters, in the format \"Length: $LENGTH\"\n": " length Length of the article in characters, in the format \"Length: $LENGTH\"\n",
" dir Content direction, is either \"Direction: ltr\" or \"Direction: rtl\"\n": " dir Content direction, is either \"Direction: ltr\" or \"Direction: rtl\"\n",
" html-content Outputs the article's main content as HTML.\n": " html-content Outputs the article's main content as HTML.\n",
" text-content Outputs the article's main content as plain text.\n": " text-content Outputs the article's main content as plain text.\n",
"Text-content and Html-content are mutually exclusive, and are always printed last.\n": "Text-content and Html-content are mutually exclusive, and are always printed last.\n",
"Default value is \"html-title,html-content\".\n": "Default value is \"html-title,html-content\".\n",
"No input provided": "No input provided.",
"Note: --is-url option is deprecated.": "Note: --is-url option is deprecated.",
"Note: --url option is deprecated, please use --base instead.": "Note: --url option is deprecated, please use --base instead.",
"Retrieving...": "Retrieving...",
"Warning: piping input with unknown URL. This means that relative links will be broken. Supply the --base parameter to fix.": "Warning: piping input with unknown URL. This means that relative links will be broken. Supply the --base parameter to fix.",
"Not sure if this document should be processed. Not processing": "Not sure if this document should be processed. Not processing",
"Error while retrieving %s:": "Error while retrieving %s:",
"Invalid URL: %s": "Invalid URL: %s",
"Processing...": "Processing...",
"Not sure if this document should be processed, exiting": "Not sure if this document should be processed, exiting",
"Can't output properties": "Can't output properties",
"Couldn't process document.": "Couldn't process document.",
"Title: %s\n": "Title: %s\n",
"Excerpt: %s\n": "Excerpt: %s\n",
"Author: %s\n": "Author: %s\n",
"Length: %s\n": "Length: %s\n",
"Direction: %s\n": "Direction: %s\n",
"Host not found: '%s'": "Host not found: '%s'"
}

@ -26,6 +26,7 @@
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.4.1", "@mozilla/readability": "^0.4.1",
"jsdom": "^16.4.0", "jsdom": "^16.4.0",
"yargs": "github:gardenappl/yargs#enable-fallback-to-standard-completion" "yargs": "github:gardenappl/yargs#enable-fallback-to-standard-completion",
"y18n": "^5.0.5"
} }
} }

Loading…
Cancel
Save