mirror of
https://github.com/notion-enhancer/notion-enhancer.git
synced 2025-04-02 19:59:03 +00:00
332 lines
11 KiB
JavaScript
Executable File
332 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* notion-enhancer
|
|
* (c) 2023 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
|
|
* (https://notion-enhancer.github.io/) under the MIT license
|
|
*/
|
|
|
|
import os from "node:os";
|
|
import { createRequire } from "node:module";
|
|
import chalk from "chalk-template";
|
|
import arg from "arg";
|
|
import {
|
|
backupApp,
|
|
enhanceApp,
|
|
getInsertVersion,
|
|
getResourcePath,
|
|
restoreApp,
|
|
setNotionPath,
|
|
} from "./scripts/enhance-desktop-app.mjs";
|
|
import { greaterThan } from "./src/core/updateCheck.mjs";
|
|
import { existsSync } from "node:fs";
|
|
|
|
const nodeRequire = createRequire(import.meta.url),
|
|
manifest = nodeRequire("./package.json");
|
|
|
|
let __quiet, __debug;
|
|
const print = (...args) => __quiet || process.stdout.write(chalk(...args)),
|
|
printObject = (value) => __quiet || console.dir(value, { depth: null }),
|
|
clearLine = `\r\x1b[K`,
|
|
showCursor = `\x1b[?25h`,
|
|
hideCursor = `\x1b[?25l`,
|
|
cursorUp = (n) => `\x1b[${n}A`,
|
|
cursorForward = (n) => `\x1b[${n}C`;
|
|
|
|
let __confirmation;
|
|
const readStdin = () => {
|
|
return new Promise((res) => {
|
|
process.stdin.resume();
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.once("data", (key) => {
|
|
process.stdin.pause();
|
|
res(key);
|
|
});
|
|
});
|
|
},
|
|
promptConfirmation = async (prompt) => {
|
|
let input;
|
|
const validInputs = ["Y", "y", "N", "n"],
|
|
promptLength = ` > ${prompt} [Y/n]: `.length;
|
|
// prevent line clear remove existing stdout
|
|
print`\n`;
|
|
do {
|
|
// clear line and repeat prompt until valid input is received
|
|
print`${cursorUp(1)}${clearLine} {inverse > ${prompt} [Y/n]:} `;
|
|
// autofill prompt response if --yes, --no or --quiet flags passed
|
|
if (validInputs.includes(__confirmation)) {
|
|
input = __confirmation;
|
|
print`${__confirmation}\n`;
|
|
} else input = (await readStdin()).trim();
|
|
if (!input) {
|
|
// default to Y if enter is pressed w/out input
|
|
input = "Y";
|
|
print`${cursorUp(1)}${cursorForward(promptLength)}Y\n`;
|
|
}
|
|
} while (!validInputs.includes(input));
|
|
// move cursor to immediately after input
|
|
print`${cursorUp(1)}${cursorForward(promptLength + 1)}`;
|
|
return input;
|
|
};
|
|
|
|
let __spinner;
|
|
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
stopSpinner = () => {
|
|
if (!__spinner) return;
|
|
clearInterval(__spinner);
|
|
// show cursor and overwrite spinner with arrow on completion
|
|
print`\b{bold.yellow →}\n${showCursor}`;
|
|
__spinner = undefined;
|
|
},
|
|
startSpinner = () => {
|
|
// cleanup prev spinner if necessary
|
|
stopSpinner();
|
|
// hide cursor and print first frame
|
|
print`${hideCursor}{bold.yellow ${spinnerFrames[0]}}`;
|
|
let i = 0;
|
|
__spinner = setInterval(() => {
|
|
i++;
|
|
// overwrite spinner with next frame
|
|
print`\b{bold.yellow ${spinnerFrames[i % spinnerFrames.length]}}`;
|
|
}, 80);
|
|
};
|
|
|
|
const compileOptsToArgSpec = (options) => {
|
|
const args = {};
|
|
for (const [opt, [type]] of options) {
|
|
const aliases = opt.split(", ").map((alias) => alias.split("=")[0]),
|
|
param = aliases[1] ?? aliases[0];
|
|
args[param] = type;
|
|
for (let i = 0; i < aliases.length; i++) {
|
|
if (aliases[i] === param) continue;
|
|
args[aliases[i]] = param;
|
|
}
|
|
}
|
|
return args;
|
|
},
|
|
compileOptsToJsonOutput = (options) => {
|
|
// the structure used to define options above
|
|
// is convenient and compact, but requires additional
|
|
// parsing to understand. this function processes
|
|
// options into a more explicitly defined structure
|
|
return options.map(([opt, [type, description]]) => {
|
|
const option = {
|
|
aliases: opt.split(", ").map((alias) => alias.split("=")[0]),
|
|
type,
|
|
description,
|
|
},
|
|
example = opt
|
|
.split(", ")
|
|
.map((alias) => alias.split("=")[1])
|
|
.find((value) => value);
|
|
if (example) option.example = example;
|
|
return option;
|
|
});
|
|
};
|
|
|
|
let __json;
|
|
const printHelp = (commands, options) => {
|
|
const { name, version, homepage } = manifest,
|
|
usage = `${name} <command> [options]`;
|
|
if (__json) {
|
|
printObject({
|
|
name,
|
|
version,
|
|
homepage,
|
|
usage,
|
|
commands: Object.fromEntries(commands),
|
|
options: compileOptsToJsonOutput(options),
|
|
});
|
|
} else {
|
|
const cmdPad = Math.max(...commands.map(([cmd]) => cmd.length)),
|
|
optPad = Math.max(...options.map((opt) => opt[0].length)),
|
|
parseCmd = (cmd) =>
|
|
chalk` ${cmd[0].padEnd(cmdPad)} {grey :} ${cmd[1]}`,
|
|
parseOpt = (opt) =>
|
|
chalk` ${opt[0].padEnd(optPad)} {grey :} ${opt[1][1]}`;
|
|
print`{bold.whiteBright.underline ${name} v${version}}\n{grey ${homepage}}
|
|
\n{bold.whiteBright USAGE}\n${name} <command> [options]
|
|
\n{bold.whiteBright COMMANDS}\n${commands.map(parseCmd).join("\n")}
|
|
\n{bold.whiteBright OPTIONS}\n${options.map(parseOpt).join("\n")}\n`;
|
|
}
|
|
},
|
|
printVersion = () => {
|
|
if (__json) {
|
|
printObject({
|
|
[manifest.name]: manifest.version,
|
|
node: process.version.slice(1),
|
|
platform: process.platform,
|
|
architecture: process.arch,
|
|
os: os.release(),
|
|
});
|
|
} else {
|
|
const nodeVersion = `node@${process.version}`,
|
|
enhancerVersion = `${manifest.name}@v${manifest.version}`,
|
|
osVersion = `${process.platform}-${process.arch}/${os.release()}`;
|
|
print`${enhancerVersion} via ${nodeVersion} on ${osVersion}\n`;
|
|
}
|
|
};
|
|
|
|
try {
|
|
const commands = [
|
|
// ["command", "description"]
|
|
["apply", "Inject the notion-enhancer into Notion desktop."],
|
|
["remove", "Restore Notion desktop to its pre-enhanced state."],
|
|
["check", "Report Notion desktop's enhancement state."],
|
|
],
|
|
// prettier-ignore
|
|
options = [
|
|
// ["alias, option=example", [type, "description"]]
|
|
["--path=</path/to/notion/resources>", [String, "Manually provide a Notion installation location."]],
|
|
["--no-backup", [Boolean, "Skip backup; enhancement will be irreversible."]],
|
|
["--json", [Boolean, "Output JSON from the `check` and `--version` commands."]],
|
|
["-y, --yes", [Boolean, 'Skip prompts; assume "yes" and run non-interactively.']],
|
|
["-n, --no", [Boolean, 'Skip prompts; assume "no" and run non-interactively.']],
|
|
["-q, --quiet", [Boolean, 'Skip prompts; assume "no" unless -y and hide all output.']],
|
|
["-d, --debug", [Boolean, "Show detailed error messages and keep extracted files."]],
|
|
["-h, --help", [Boolean, "Display usage information for this CLI."]],
|
|
["-v, --version", [Boolean, "Display this CLI's version number."]],
|
|
];
|
|
|
|
const args = arg(compileOptsToArgSpec(options));
|
|
if (args["--debug"]) __debug = true;
|
|
if (args["--quiet"]) __quiet = true;
|
|
if (args["--json"]) __json = true;
|
|
if (args["--no"] || args["--quiet"]) __confirmation = "n";
|
|
if (args["--yes"]) __confirmation = "y";
|
|
if (args["--help"]) printHelp(commands, options), process.exit();
|
|
if (args["--version"]) printVersion(), process.exit();
|
|
if (args["--path"]) setNotionPath(args["--path"]);
|
|
|
|
const appPath = getResourcePath("app.asar"),
|
|
backupPath = getResourcePath("app.asar.bak"),
|
|
insertVersion = await getInsertVersion(),
|
|
updateAvailable = greaterThan(manifest.version, insertVersion);
|
|
|
|
const messages = {
|
|
"notion-found": insertVersion
|
|
? // prettier-ignore
|
|
`Notion desktop found with ${manifest.name} v${insertVersion
|
|
} applied${updateAvailable ? "" : " (up to date)"}.`
|
|
: `Notion desktop found (no enhancements applied).`,
|
|
"notion-not-found": `Notion desktop not found.`,
|
|
|
|
// prettier-ignore
|
|
"update-available": chalk`v${manifest.version
|
|
} is available! To apply, run {underline ${manifest.name} apply -y}.`,
|
|
// prettier-ignore
|
|
"update-confirm": `${updateAvailable ? "Upgrade" : "Downgrade"
|
|
} to ${manifest.name}${manifest.name} v${manifest.version}?`,
|
|
|
|
"backup-found": `Restoring to pre-enhanced state from backup...`,
|
|
"backup-not-found": chalk`No backup found: to restore Notion desktop to its pre-enhanced state,
|
|
uninstall it and reinstall Notion from {underline https://www.notion.so/desktop}.`,
|
|
|
|
"backup-app": `Backing up app before enhancement...`,
|
|
"enhance-app": `Enhancing and patching app sources...`,
|
|
};
|
|
const SUCCESS = chalk`{bold.whiteBright SUCCESS} {green ✔}`,
|
|
FAILURE = chalk`{bold.whiteBright FAILURE} {red ✘}`,
|
|
CANCELLED = chalk`{bold.whiteBright CANCELLED} {red ✘}`,
|
|
INCOMPLETE = Symbol();
|
|
|
|
const interactiveRestore = async () => {
|
|
if (!backupPath || !existsSync(backupPath)) {
|
|
print` {red * ${messages["backup-not-found"]}}\n`;
|
|
return FAILURE;
|
|
}
|
|
print` {grey * ${messages["backup-found"]}} `;
|
|
startSpinner();
|
|
await restoreApp();
|
|
stopSpinner();
|
|
return SUCCESS;
|
|
};
|
|
|
|
const getNotion = () => {
|
|
if (!appPath || !existsSync(appPath)) {
|
|
print` {red * ${messages["notion-not-found"]}}\n`;
|
|
return FAILURE;
|
|
} else {
|
|
print` {grey * ${messages["notion-found"]}}\n`;
|
|
return INCOMPLETE;
|
|
}
|
|
},
|
|
compareVersions = async () => {
|
|
if (!insertVersion) return INCOMPLETE;
|
|
// same version already applied
|
|
if (insertVersion === manifest.version) return SUCCESS;
|
|
// diff version already applied
|
|
print` {grey * ${messages["notion-found"]}}\n`;
|
|
const replace = await promptConfirmation(messages["update-confirm"]);
|
|
print`\n`;
|
|
return ["Y", "y"].includes(replace)
|
|
? (await interactiveRestore()) === SUCCESS
|
|
? INCOMPLETE
|
|
: FAILURE
|
|
: CANCELLED;
|
|
},
|
|
interactiveEnhance = async () => {
|
|
if (!args["--no-backup"]) {
|
|
print` {grey * ${messages["backup-app"]}} `;
|
|
startSpinner();
|
|
await backupApp();
|
|
stopSpinner();
|
|
}
|
|
print` {grey * ${messages["enhance-app"]}} `;
|
|
startSpinner();
|
|
await enhanceApp(__debug);
|
|
stopSpinner();
|
|
return SUCCESS;
|
|
};
|
|
|
|
switch (args["_"][0]) {
|
|
case "apply": {
|
|
print`{bold.whiteBright [${manifest.name.toUpperCase()}] APPLY}\n`;
|
|
let res = getNotion();
|
|
if (res === INCOMPLETE) res = await compareVersions();
|
|
if (res === INCOMPLETE) res = await interactiveEnhance();
|
|
print`${res}\n`;
|
|
break;
|
|
}
|
|
case "remove": {
|
|
print`{bold.whiteBright [${manifest.name.toUpperCase()}] REMOVE}\n`;
|
|
let res = getNotion();
|
|
if (res === INCOMPLETE) {
|
|
res = insertVersion ? await interactiveRestore() : SUCCESS;
|
|
}
|
|
print`${res}\n`;
|
|
break;
|
|
}
|
|
case "check": {
|
|
if (__json) {
|
|
const cliVersion = manifest.version,
|
|
state = { appPath, backupPath, insertVersion, cliVersion };
|
|
if (appPath && !existsSync(appPath)) state.appPath = null;
|
|
if (backupPath && !existsSync(backupPath)) state.backupPath = null;
|
|
printObject(state), process.exit();
|
|
}
|
|
print`{bold.whiteBright [${manifest.name.toUpperCase()}] CHECK}\n`;
|
|
let res = getNotion();
|
|
if (res === INCOMPLETE && updateAvailable) {
|
|
print` {grey * ${messages["update-available"]}}\n`;
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
printHelp(commands, options);
|
|
}
|
|
} catch (err) {
|
|
stopSpinner();
|
|
const message = err.message.split("\n")[0];
|
|
if (__debug) {
|
|
print`{bold.red ${err.name}:} ${message}\n{grey ${err.stack
|
|
.split("\n")
|
|
.splice(1)
|
|
.map((at) => at.replace(/\s{4}/g, " "))
|
|
.join("\n")}}`;
|
|
} else {
|
|
print`{bold.red Error:} ${message} {grey (Run with -d for more information.)}\n`;
|
|
}
|
|
}
|