The great refactor (#82)
This commit is contained in:
92
source/app/metrics/index.mjs
Normal file
92
source/app/metrics/index.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
//Imports
|
||||
import util from "util"
|
||||
import ejs from "ejs"
|
||||
import SVGO from "svgo"
|
||||
import * as utils from "./utils.mjs"
|
||||
|
||||
//Setup
|
||||
export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false, verify = false, convert = null}, {Plugins, Templates}) {
|
||||
//Compute rendering
|
||||
try {
|
||||
|
||||
//Debug
|
||||
console.debug(`metrics/compute/${login} > start`)
|
||||
console.debug(util.inspect(q, {depth:Infinity, maxStringLength:256}))
|
||||
|
||||
//Load template
|
||||
const template = q.template || conf.settings.templates.default
|
||||
if ((!(template in Templates))||(!(template in conf.templates))||((conf.settings.templates.enabled.length)&&(!conf.settings.templates.enabled.includes(template))))
|
||||
throw new Error("unsupported template")
|
||||
const {image, style, fonts, views, partials} = conf.templates[template]
|
||||
const computer = Templates[template].default || Templates[template]
|
||||
|
||||
//Initialization
|
||||
const pending = []
|
||||
const queries = conf.queries
|
||||
const data = {animated:true, base:{}, config:{}, errors:[], plugins:{}, computed:{}}
|
||||
const imports = {plugins:Plugins, templates:Templates, metadata:conf.metadata, ...utils}
|
||||
|
||||
//Partial parts
|
||||
{
|
||||
data.partials = new Set([
|
||||
...decodeURIComponent(q["config.order"] ?? "").split(",").map(x => x.trim().toLocaleLowerCase()).filter(partial => partials.includes(partial)),
|
||||
...partials,
|
||||
])
|
||||
console.debug(`metrics/compute/${login} > content order : ${[...data.partials]}`)
|
||||
}
|
||||
|
||||
//Executing base plugin and compute metrics
|
||||
console.debug(`metrics/compute/${login} > compute`)
|
||||
await Plugins.base({login, q, data, rest, graphql, plugins, queries, pending, imports}, conf)
|
||||
await computer({login, q, dflags}, {conf, data, rest, graphql, plugins, queries, account:data.account}, {pending, imports})
|
||||
const promised = await Promise.all(pending)
|
||||
|
||||
//Check plugins errors
|
||||
const errors = [...promised.filter(({result = null}) => result?.error), ...data.errors]
|
||||
if (errors.length) {
|
||||
console.warn(`metrics/compute/${login} > ${errors.length} errors !`)
|
||||
if (die)
|
||||
throw new Error(`An error occured during rendering, dying`)
|
||||
else
|
||||
console.warn(util.inspect(errors, {depth:Infinity, maxStringLength:256}))
|
||||
}
|
||||
|
||||
//Rendering and resizing
|
||||
console.debug(`metrics/compute/${login} > render`)
|
||||
let rendered = await ejs.render(image, {...data, s:imports.s, f:imports.format, style, fonts}, {views, async:true})
|
||||
const {resized, mime} = await imports.svgresize(rendered, {paddings:q["config.padding"], convert})
|
||||
rendered = resized
|
||||
|
||||
//Additional SVG transformations
|
||||
if (/svg/.test(mime)) {
|
||||
//Optimize rendering
|
||||
if ((conf.settings?.optimize)&&(!q.raw)) {
|
||||
console.debug(`metrics/compute/${login} > optimize`)
|
||||
const svgo = new SVGO({full:true, plugins:[{cleanupAttrs:true}, {inlineStyles:false}]})
|
||||
const {data:optimized} = await svgo.optimize(rendered)
|
||||
rendered = optimized
|
||||
}
|
||||
//Verify svg
|
||||
if (verify) {
|
||||
console.debug(`metrics/compute/${login} > verify SVG`)
|
||||
const libxmljs = (await import("libxmljs")).default
|
||||
const parsed = libxmljs.parseXml(rendered)
|
||||
if (parsed.errors.length)
|
||||
throw new Error(`Malformed SVG : \n${parsed.errors.join("\n")}`)
|
||||
}
|
||||
}
|
||||
|
||||
//Result
|
||||
console.debug(`metrics/compute/${login} > success`)
|
||||
return {rendered, mime}
|
||||
}
|
||||
//Internal error
|
||||
catch (error) {
|
||||
//User not found
|
||||
if (((Array.isArray(error.errors))&&(error.errors[0].type === "NOT_FOUND")))
|
||||
throw new Error("user not found")
|
||||
//Generic error
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
282
source/app/metrics/metadata.mjs
Normal file
282
source/app/metrics/metadata.mjs
Normal file
@@ -0,0 +1,282 @@
|
||||
//Imports
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import yaml from "js-yaml"
|
||||
import url from "url"
|
||||
|
||||
/** Metadata descriptor parser */
|
||||
export default async function metadata({log = true} = {}) {
|
||||
//Paths
|
||||
const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../../..")
|
||||
const __templates = path.join(__metrics, "source/templates")
|
||||
const __plugins = path.join(__metrics, "source/plugins")
|
||||
|
||||
//Init
|
||||
const logger = log ? console.debug : () => null
|
||||
|
||||
//Load plugins metadata
|
||||
let Plugins = {}
|
||||
logger(`metrics/metadata > loading plugins metadata`)
|
||||
for (const name of await fs.promises.readdir(__plugins)) {
|
||||
if (!(await fs.promises.lstat(path.join(__plugins, name))).isDirectory())
|
||||
continue
|
||||
logger(`metrics/metadata > loading plugin metadata [${name}]`)
|
||||
Plugins[name] = await metadata.plugin({__plugins, name, logger})
|
||||
}
|
||||
//Reorder keys
|
||||
const {base, core, ...plugins} = Plugins
|
||||
Plugins = {base, core, ...plugins}
|
||||
|
||||
//Load templates metadata
|
||||
let Templates = {}
|
||||
logger(`metrics/metadata > loading templates metadata`)
|
||||
for (const name of await fs.promises.readdir(__templates)) {
|
||||
if (!(await fs.promises.lstat(path.join(__templates, name))).isDirectory())
|
||||
continue
|
||||
if (/^@/.test(name))
|
||||
continue
|
||||
logger(`metrics/metadata > loading template metadata [${name}]`)
|
||||
Templates[name] = await metadata.template({__templates, name, plugins, logger})
|
||||
}
|
||||
//Reorder keys
|
||||
const {classic, repository, community, ...templates} = Templates
|
||||
Templates = {classic, repository, ...templates, community}
|
||||
|
||||
//Metadata
|
||||
return {plugins:Plugins, templates:Templates}
|
||||
}
|
||||
|
||||
/** Metadata extractor for templates */
|
||||
metadata.plugin = async function ({__plugins, name, logger}) {
|
||||
try {
|
||||
//Load meta descriptor
|
||||
const raw = `${await fs.promises.readFile(path.join(__plugins, name, "metadata.yml"), "utf-8")}`
|
||||
const {inputs, ...meta} = yaml.load(raw)
|
||||
|
||||
//Inputs parser
|
||||
{
|
||||
meta.inputs = function ({data:{user = null} = {}, q, account}, defaults = {}) {
|
||||
//Support check
|
||||
if (!account)
|
||||
logger(`metrics/inputs > account type not set for plugin ${name}!`)
|
||||
if (account !== "bypass") {
|
||||
if (!meta.supports?.includes(account))
|
||||
throw {error:{message:`Not supported for: ${account}`, instance:new Error()}}
|
||||
if ((q.repo)&&(!meta.supports?.includes("repository")))
|
||||
throw {error:{message:`Not supported for: ${account} repositories`, instance:new Error()}}
|
||||
}
|
||||
//Inputs checks
|
||||
const result = Object.fromEntries(Object.entries(inputs).map(([key, {type, format, default:defaulted, min, max, values}]) => [
|
||||
//Format key
|
||||
metadata.to.query(key, {name}),
|
||||
//Format value
|
||||
(defaulted => {
|
||||
//Default value
|
||||
let value = q[metadata.to.query(key)] ?? q[key] ?? defaulted
|
||||
//Apply type conversion
|
||||
switch (type) {
|
||||
//Booleans
|
||||
case "boolean":{
|
||||
if (/^(?:[Tt]rue|[Oo]n|[Yy]es|1)$/.test(value))
|
||||
return true
|
||||
if (/^(?:[Ff]alse|[Oo]ff|[Nn]o|0)$/.test(value))
|
||||
return false
|
||||
return defaulted
|
||||
}
|
||||
//Numbers
|
||||
case "number":{
|
||||
value = Number(value)
|
||||
if (!Number.isFinite(value))
|
||||
value = defaulted
|
||||
if (Number.isFinite(min))
|
||||
value = Math.max(min, value)
|
||||
if (Number.isFinite(max))
|
||||
value = Math.min(value, max)
|
||||
return value
|
||||
}
|
||||
//Array
|
||||
case "array":{
|
||||
try {
|
||||
value = decodeURIComponent(value)
|
||||
}
|
||||
catch {
|
||||
logger(`metrics/inputs > failed to decode uri : ${value}`)
|
||||
value = defaulted
|
||||
}
|
||||
const separators = {"comma-separated":",", "space-separated":" "}
|
||||
const separator = separators[[format].flat().filter(s => s in separators)[0]] ?? ","
|
||||
return value.split(separator).map(v => v.trim().toLocaleLowerCase()).filter(v => Array.isArray(values) ? values.includes(v) : true).filter(v => v)
|
||||
}
|
||||
//String
|
||||
case "string":{
|
||||
value = value.trim()
|
||||
if (user) {
|
||||
if (value === ".user.login")
|
||||
return user.login
|
||||
if (value === ".user.twitter")
|
||||
return user.twitterUsername
|
||||
if (value === ".user.website")
|
||||
return user.websiteUrl
|
||||
}
|
||||
if ((Array.isArray(values))&&(!values.includes(value)))
|
||||
return defaulted
|
||||
return value
|
||||
}
|
||||
//JSON
|
||||
case "json":{
|
||||
try {
|
||||
value = JSON.parse(value)
|
||||
}
|
||||
catch {
|
||||
logger(`metrics/inputs > failed to parse json : ${value}`)
|
||||
value = JSON.parse(defaulted)
|
||||
}
|
||||
return value
|
||||
}
|
||||
//Token
|
||||
case "token":{
|
||||
return value
|
||||
}
|
||||
//Default
|
||||
default:{
|
||||
return value
|
||||
}
|
||||
}
|
||||
})(defaults[key] ?? defaulted)
|
||||
]))
|
||||
logger(`metrics/inputs > ${name} > ${JSON.stringify(result)}`)
|
||||
return result
|
||||
}
|
||||
Object.assign(meta.inputs, inputs, Object.fromEntries(Object.entries(inputs).map(([key, value]) => [metadata.to.query(key, {name}), value])))
|
||||
}
|
||||
|
||||
//Action metadata
|
||||
{
|
||||
//Extract comments
|
||||
const comments = {}
|
||||
raw.split(/(\r?\n){2,}/m)
|
||||
.map(x => x.trim()).filter(x => x)
|
||||
.map(x => x.split("\n").map(y => y.trim()).join("\n"))
|
||||
.map(x => {
|
||||
const input = x.match(new RegExp(`^\\s*(?<input>${Object.keys(inputs).join("|")}):`, "m"))?.groups?.input ?? null
|
||||
if (input)
|
||||
comments[input] = x.match(new RegExp(`(?<comment>[\\s\\S]*?)(?=(?:${Object.keys(inputs).sort((a, b) => b.length - a.length).join("|")}):)`))?.groups?.comment
|
||||
})
|
||||
|
||||
//Action descriptor
|
||||
meta.action = Object.fromEntries(Object.entries(inputs).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
comment:comments[key] ?? "",
|
||||
descriptor:yaml.dump({[key]:Object.fromEntries(Object.entries(value).filter(([key]) => ["description", "default", "required"].includes(key)))}, {quotingType:'"', noCompatMode:true})
|
||||
}
|
||||
]))
|
||||
|
||||
//Action inputs
|
||||
meta.inputs.action = function ({core}) {
|
||||
//Build query object from inputs
|
||||
const q = {}
|
||||
for (const key of Object.keys(inputs)) {
|
||||
const value = `${core.getInput(key)}`.trim()
|
||||
try {
|
||||
q[key] = decodeURIComponent(value)
|
||||
}
|
||||
catch {
|
||||
logger(`metrics/inputs > failed to decode uri : ${value}`)
|
||||
q[key] = value
|
||||
}
|
||||
}
|
||||
return meta.inputs({q, account:"bypass"})
|
||||
}
|
||||
}
|
||||
|
||||
//Web metadata
|
||||
{
|
||||
meta.web = Object.fromEntries(Object.entries(inputs).map(([key, {type, description:text, example, default:defaulted, min = 0, max = 9999, values}]) => [
|
||||
//Format key
|
||||
metadata.to.query(key),
|
||||
//Value descriptor
|
||||
(() => {
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
return {text, type:"boolean"}
|
||||
case "number":
|
||||
return {text, type:"number", min, max, defaulted}
|
||||
case "array":
|
||||
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
|
||||
case "string":{
|
||||
if (Array.isArray(values))
|
||||
return {text, type:"select", values}
|
||||
else
|
||||
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
|
||||
}
|
||||
case "json":
|
||||
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})()
|
||||
]).filter(([key, value]) => (value)&&(key !== name)))
|
||||
}
|
||||
|
||||
//Readme metadata
|
||||
{
|
||||
//Extract demos
|
||||
const raw = `${await fs.promises.readFile(path.join(__plugins, name, "README.md"), "utf-8")}`
|
||||
const demo = raw.match(/(?<demo><table>[\s\S]*?<[/]table>)/)?.groups?.demo?.replace(/<[/]?(?:table|tr)>/g, "")?.trim() ?? "<td></td>"
|
||||
|
||||
//Readme descriptor
|
||||
meta.readme = {demo}
|
||||
}
|
||||
|
||||
//Icon
|
||||
meta.icon = meta.name.split(" ")[0] ?? null
|
||||
|
||||
//Result
|
||||
return meta
|
||||
}
|
||||
catch (error) {
|
||||
logger(`metrics/metadata > failed to load plugin ${name}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Metadata extractor for templates */
|
||||
metadata.template = async function ({__templates, name, plugins, logger}) {
|
||||
try {
|
||||
//Load meta descriptor
|
||||
const raw = `${await fs.promises.readFile(path.join(__templates, name, "README.md"), "utf-8")}`
|
||||
|
||||
//Compatibility
|
||||
const partials = path.join(__templates, name, "partials")
|
||||
const compatibility = Object.fromEntries(Object.entries(plugins).map(([key]) => [key, false]))
|
||||
if ((fs.existsSync(partials))&&((await fs.promises.lstat(partials)).isDirectory())) {
|
||||
for (let plugin of await fs.promises.readdir(partials)) {
|
||||
plugin = plugin.match(/(?<plugin>^[\s\S]+(?=[.]ejs$))/)?.groups?.plugin ?? null
|
||||
if (plugin in compatibility)
|
||||
compatibility[plugin] = true
|
||||
}
|
||||
}
|
||||
|
||||
//Result
|
||||
return {
|
||||
name:raw.match(/^### (?<name>[\s\S]+?)\n/)?.groups?.name?.trim(),
|
||||
readme:{
|
||||
demo:raw.match(/(?<demo><table>[\s\S]*?<[/]table>)/)?.groups?.demo?.replace(/<[/]?(?:table|tr)>/g, "")?.trim() ?? (name === "community" ? `<td>See <a href="/source/templates/community/README.md">documentation</a> 🌍</td>` : "<td></td>"),
|
||||
compatibility:{...compatibility, base:true},
|
||||
},
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logger(`metrics/metadata > failed to load template ${name}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Metadata converters */
|
||||
metadata.to = {
|
||||
query(key, {name = null} = {}) {
|
||||
key = key.replace(/^plugin_/, "").replace(/_/g, ".")
|
||||
return name ? key.replace(new RegExp(`^(${name}.)`, "g"), "") : key
|
||||
}
|
||||
}
|
||||
200
source/app/metrics/setup.mjs
Normal file
200
source/app/metrics/setup.mjs
Normal file
@@ -0,0 +1,200 @@
|
||||
//Imports
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import util from "util"
|
||||
import url from "url"
|
||||
import processes from "child_process"
|
||||
import metadata from "./metadata.mjs"
|
||||
|
||||
//Templates and plugins
|
||||
const Templates = {}
|
||||
const Plugins = {}
|
||||
|
||||
/** Setup */
|
||||
export default async function ({log = true, nosettings = false, community = {}} = {}) {
|
||||
|
||||
//Paths
|
||||
const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../../..")
|
||||
const __statics = path.join(__metrics, "source/app/web/statics")
|
||||
const __templates = path.join(__metrics, "source/templates")
|
||||
const __plugins = path.join(__metrics, "source/plugins")
|
||||
const __package = path.join(__metrics, "package.json")
|
||||
const __settings = path.join(__metrics, "settings.json")
|
||||
const __modules = path.join(__metrics, "node_modules")
|
||||
|
||||
//Init
|
||||
const logger = log ? console.debug : () => null
|
||||
logger(`metrics/setup > setup`)
|
||||
const conf = {
|
||||
templates:{},
|
||||
queries:{},
|
||||
settings:{},
|
||||
metadata:{},
|
||||
paths:{
|
||||
statics:__statics,
|
||||
templates:__templates,
|
||||
node_modules:__modules,
|
||||
}
|
||||
}
|
||||
|
||||
//Load settings
|
||||
logger(`metrics/setup > load settings.json`)
|
||||
if (fs.existsSync(__settings)) {
|
||||
if (nosettings)
|
||||
logger(`metrics/setup > load settings.json > skipped because no settings is enabled`)
|
||||
else {
|
||||
conf.settings = JSON.parse(`${await fs.promises.readFile(__settings)}`)
|
||||
logger(`metrics/setup > load settings.json > success`)
|
||||
}
|
||||
}
|
||||
else
|
||||
logger(`metrics/setup > load settings.json > (missing)`)
|
||||
if (!conf.settings.templates)
|
||||
conf.settings.templates = {default:"classic", enabled:[]}
|
||||
if (!conf.settings.plugins)
|
||||
conf.settings.plugins = {}
|
||||
conf.settings.community = {...conf.settings.community, ...community}
|
||||
conf.settings.plugins.base = {parts:["header", "activity", "community", "repositories", "metadata"]}
|
||||
if (conf.settings.debug)
|
||||
logger(util.inspect(conf.settings, {depth:Infinity, maxStringLength:256}))
|
||||
|
||||
//Load package settings
|
||||
logger(`metrics/setup > load package.json`)
|
||||
conf.package = JSON.parse(`${await fs.promises.readFile(__package)}`)
|
||||
logger(`metrics/setup > load package.json > success`)
|
||||
|
||||
//Load community templates
|
||||
if ((typeof conf.settings.community.templates === "string")&&(conf.settings.community.templates.length)) {
|
||||
logger(`metrics/setup > parsing community templates list`)
|
||||
conf.settings.community.templates = [...new Set([...decodeURIComponent(conf.settings.community.templates).split(",").map(v => v.trim().toLocaleLowerCase()).filter(v => v)])]
|
||||
}
|
||||
if ((Array.isArray(conf.settings.community.templates))&&(conf.settings.community.templates.length)) {
|
||||
//Clean remote repository
|
||||
logger(`metrics/setup > ${conf.settings.community.templates.length} community templates to install`)
|
||||
await fs.promises.rmdir(path.join(__templates, ".community"), {recursive:true})
|
||||
//Download community templates
|
||||
for (const template of conf.settings.community.templates) {
|
||||
try {
|
||||
//Parse community template
|
||||
logger(`metrics/setup > load community template ${template}`)
|
||||
const {repo, branch, name, trust = false} = template.match(/^(?<repo>[\s\S]+?)@(?<branch>[\s\S]+?):(?<name>[\s\S]+?)(?<trust>[+]trust)?$/)?.groups
|
||||
const command = `git clone --single-branch --branch ${branch} https://github.com/${repo}.git ${path.join(__templates, ".community")}`
|
||||
logger(`metrics/setup > run ${command}`)
|
||||
//Clone remote repository
|
||||
processes.execSync(command, {stdio:"ignore"})
|
||||
//Extract template
|
||||
logger(`metrics/setup > extract ${name} from ${repo}@${branch}`)
|
||||
await fs.promises.rmdir(path.join(__templates, `@${name}`), {recursive:true})
|
||||
await fs.promises.rename(path.join(__templates, ".community/source/templates", name), path.join(__templates, `@${name}`))
|
||||
//JavaScript file
|
||||
if (trust)
|
||||
logger(`metrics/setup > keeping @${name}/template.mjs (unsafe mode is enabled)`)
|
||||
else if (fs.existsSync(path.join(__templates, `@${name}`, "template.mjs"))) {
|
||||
logger(`metrics/setup > removing @${name}/template.mjs`)
|
||||
await fs.promises.unlink(path.join(__templates, `@${name}`, "template.mjs"))
|
||||
}
|
||||
else
|
||||
logger(`metrics/setup > @${name}/template.mjs does not exist`)
|
||||
//Clean remote repository
|
||||
logger(`metrics/setup > clean ${repo}@${branch}`)
|
||||
await fs.promises.rmdir(path.join(__templates, ".community"), {recursive:true})
|
||||
logger(`metrics/setup > loaded community template ${name}`)
|
||||
} catch (error) {
|
||||
logger(`metrics/setup > failed to load community template ${template}`)
|
||||
logger(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
logger(`metrics/setup > no community templates to install`)
|
||||
|
||||
//Load templates
|
||||
for (const name of await fs.promises.readdir(__templates)) {
|
||||
//Search for templates
|
||||
const directory = path.join(__templates, name)
|
||||
if ((!(await fs.promises.lstat(directory)).isDirectory())||(!fs.existsSync(path.join(directory, "partials/_.json"))))
|
||||
continue
|
||||
logger(`metrics/setup > load template [${name}]`)
|
||||
//Cache templates files
|
||||
const files = ["image.svg", "style.css", "fonts.css"].map(file => path.join(__templates, (fs.existsSync(path.join(directory, file)) ? name : "classic"), file))
|
||||
const [image, style, fonts] = await Promise.all(files.map(async file => `${await fs.promises.readFile(file)}`))
|
||||
const partials = JSON.parse(`${await fs.promises.readFile(path.join(directory, "partials/_.json"))}`)
|
||||
conf.templates[name] = {image, style, fonts, partials, views:[directory]}
|
||||
|
||||
//Cache templates scripts
|
||||
Templates[name] = await (async () => {
|
||||
const template = path.join(directory, "template.mjs")
|
||||
const fallback = path.join(__templates, "classic", "template.mjs")
|
||||
return (await import(url.pathToFileURL(fs.existsSync(template) ? template : fallback).href)).default
|
||||
})()
|
||||
logger(`metrics/setup > load template [${name}] > success`)
|
||||
//Debug
|
||||
if (conf.settings.debug) {
|
||||
Object.defineProperty(conf.templates, name, {
|
||||
get() {
|
||||
logger(`metrics/setup > reload template [${name}]`)
|
||||
const [image, style, fonts] = files.map(file => `${fs.readFileSync(file)}`)
|
||||
const partials = JSON.parse(`${fs.readFileSync(path.join(directory, "partials/_.json"))}`)
|
||||
logger(`metrics/setup > reload template [${name}] > success`)
|
||||
return {image, style, fonts, partials, views:[directory]}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//Load plugins
|
||||
for (const name of await fs.promises.readdir(__plugins)) {
|
||||
//Search for plugins
|
||||
const directory = path.join(__plugins, name)
|
||||
if (!(await fs.promises.lstat(directory)).isDirectory())
|
||||
continue
|
||||
//Cache plugins scripts
|
||||
logger(`metrics/setup > load plugin [${name}]`)
|
||||
Plugins[name] = (await import(url.pathToFileURL(path.join(directory, "index.mjs")).href)).default
|
||||
logger(`metrics/setup > load plugin [${name}] > success`)
|
||||
//Register queries
|
||||
const __queries = path.join(directory, "queries")
|
||||
if (fs.existsSync(__queries)) {
|
||||
//Alias for default query
|
||||
const queries = conf.queries[name] = function () {
|
||||
if (!queries[name])
|
||||
throw new ReferenceError(`Default query for ${name} undefined`)
|
||||
return queries[name](...arguments)
|
||||
}
|
||||
//Load queries
|
||||
for (const file of await fs.promises.readdir(__queries)) {
|
||||
//Cache queries
|
||||
const query = file.replace(/[.]graphql$/, "")
|
||||
logger(`metrics/setup > load query [${name}/${query}]`)
|
||||
queries[`_${query}`] = `${await fs.promises.readFile(path.join(__queries, file))}`
|
||||
logger(`metrics/setup > load query [${name}/${query}] > success`)
|
||||
//Debug
|
||||
if (conf.settings.debug) {
|
||||
Object.defineProperty(queries, `_${query}`, {
|
||||
get() {
|
||||
logger(`metrics/setup > reload query [${name}/${query}]`)
|
||||
const raw = `${fs.readFileSync(path.join(__queries, file))}`
|
||||
logger(`metrics/setup > reload query [${name}/${query}] > success`)
|
||||
return raw
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
//Create queries formatters
|
||||
Object.keys(queries).map(query => queries[query.substring(1)] = (vars = {}) => {
|
||||
let queried = queries[query]
|
||||
for (const [key, value] of Object.entries(vars))
|
||||
queried = queried.replace(new RegExp(`[$]${key}`, "g"), value)
|
||||
return queried
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//Load metadata (plugins)
|
||||
conf.metadata = await metadata({log})
|
||||
|
||||
//Conf
|
||||
logger(`metrics/setup > setup > success`)
|
||||
return {Templates, Plugins, conf}
|
||||
|
||||
}
|
||||
124
source/app/metrics/utils.mjs
Normal file
124
source/app/metrics/utils.mjs
Normal file
@@ -0,0 +1,124 @@
|
||||
//Imports
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import paths from "path"
|
||||
import url from "url"
|
||||
import util from "util"
|
||||
import processes from "child_process"
|
||||
import axios from "axios"
|
||||
import puppeteer from "puppeteer"
|
||||
import imgb64 from "image-to-base64"
|
||||
|
||||
export {fs, os, paths, url, util, processes, axios, puppeteer, imgb64}
|
||||
|
||||
/** Returns module __dirname */
|
||||
export function __module(module) {
|
||||
return paths.join(paths.dirname(url.fileURLToPath(module)))
|
||||
}
|
||||
|
||||
/** Plural formatter */
|
||||
export function s(value, end = "") {
|
||||
return value !== 1 ? {y:"ies", "":"s"}[end] : end
|
||||
}
|
||||
|
||||
/** Formatter */
|
||||
export function format(n, {sign = false} = {}) {
|
||||
for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}])
|
||||
if (n/v >= 1)
|
||||
return `${(sign)&&(n > 0) ? "+" : ""}${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}`
|
||||
return `${(sign)&&(n > 0) ? "+" : ""}${n}`
|
||||
}
|
||||
|
||||
/** Bytes formatter */
|
||||
export function bytes(n) {
|
||||
for (const {u, v} of [{u:"E", v:10**18}, {u:"P", v:10**15}, {u:"T", v:10**12}, {u:"G", v:10**9}, {u:"M", v:10**6}, {u:"k", v:10**3}])
|
||||
if (n/v >= 1)
|
||||
return `${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")} ${u}B`
|
||||
return `${n} byte${n > 1 ? "s" : ""}`
|
||||
}
|
||||
|
||||
/** Array shuffler */
|
||||
export function shuffle(array) {
|
||||
for (let i = array.length-1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random()*(i+1))
|
||||
;[array[i], array[j]] = [array[j], array[i]]
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
/** Escape html */
|
||||
export function htmlescape(string, u = {"&":true, "<":true, ">":true, '"':true, "'":true}) {
|
||||
return string
|
||||
.replace(/&(?!(?:amp|lt|gt|quot|apos);)/g, u["&"] ? "&" : "&")
|
||||
.replace(/</g, u["<"] ? "<" : "<")
|
||||
.replace(/>/g, u[">"] ? ">" : ">")
|
||||
.replace(/"/g, u['"'] ? """ : '"')
|
||||
.replace(/'/g, u["'"] ? "'" : "'")
|
||||
}
|
||||
|
||||
/** Expand url */
|
||||
export async function urlexpand(url) {
|
||||
try {
|
||||
return (await axios.get(url)).request.res.responseUrl
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** Run command */
|
||||
export async function run(command, options) {
|
||||
return await new Promise((solve, reject) => {
|
||||
console.debug(`metrics/command > ${command}`)
|
||||
const child = processes.exec(command, options)
|
||||
let [stdout, stderr] = ["", ""]
|
||||
child.stdout.on("data", data => stdout += data)
|
||||
child.stderr.on("data", data => stderr += data)
|
||||
child.on("close", code => {
|
||||
console.debug(`metrics/command > ${command} > exited with code ${code}`)
|
||||
return code === 0 ? solve(stdout) : reject(stderr)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Render svg */
|
||||
export async function svgresize(svg, {paddings = ["6%"], convert} = {}) {
|
||||
//Instantiate browser if needed
|
||||
if (!svgresize.browser) {
|
||||
svgresize.browser = await puppeteer.launch({headless:true, executablePath:process.env.PUPPETEER_BROWSER_PATH, args:["--no-sandbox", "--disable-extensions", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]})
|
||||
console.debug(`metrics/svgresize > started ${await svgresize.browser.version()}`)
|
||||
}
|
||||
//Format padding
|
||||
const [pw = 1, ph] = paddings.map(padding => `${padding}`.substring(0, padding.length-1)).map(value => 1+Number(value)/100)
|
||||
const padding = {width:pw, height:ph ?? pw}
|
||||
console.debug(`metrics/svgresize > padding width*${padding.width}, height*${padding.height}`)
|
||||
//Render through browser and resize height
|
||||
const page = await svgresize.browser.newPage()
|
||||
await page.setContent(svg, {waitUntil:"load"})
|
||||
let mime = "image/svg+xml"
|
||||
let {resized, width, height} = await page.evaluate(async padding => {
|
||||
//Disable animations
|
||||
const animated = !document.querySelector("svg").classList.contains("no-animations")
|
||||
if (animated)
|
||||
document.querySelector("svg").classList.add("no-animations")
|
||||
//Get bounds and resize
|
||||
let {y:height, width} = document.querySelector("svg #metrics-end").getBoundingClientRect()
|
||||
height = Math.ceil(height*padding.height)
|
||||
width = Math.ceil(width*padding.width)
|
||||
//Resize svg
|
||||
document.querySelector("svg").setAttribute("height", height)
|
||||
//Enable animations
|
||||
if (animated)
|
||||
document.querySelector("svg").classList.remove("no-animations")
|
||||
//Result
|
||||
return {resized:new XMLSerializer().serializeToString(document.querySelector("svg")), height, width}
|
||||
}, padding)
|
||||
//Convert if required
|
||||
if (convert) {
|
||||
console.debug(`metrics/svgresize > convert to ${convert}`)
|
||||
resized = await page.screenshot({type:convert, clip:{x:0, y:0, width, height}, omitBackground:true})
|
||||
mime = `image/${convert}`
|
||||
}
|
||||
//Result
|
||||
await page.close()
|
||||
return {resized, mime}
|
||||
}
|
||||
Reference in New Issue
Block a user