Localization for routes and permissions, added compression for language/getEntries

This commit is contained in:
Roman 2024-03-29 15:20:45 +01:00
parent 12b8a0b386
commit 0e3d27fa10
15 changed files with 219 additions and 73 deletions

@ -115,7 +115,8 @@ namespace Core\API\Language {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"code" => new StringType("code", 5, true, NULL),
"modules" => new ArrayType("modules", Parameter::TYPE_STRING, true, false)
"modules" => new ArrayType("modules", Parameter::TYPE_STRING, true, false),
"compression" => new StringType("compression", -1, true, NULL, ["gzip", "zlib"])
]);
$this->loginRequired = false;
$this->csrfTokenRequired = false;
@ -156,7 +157,23 @@ namespace Core\API\Language {
}
$this->result["code"] = $code;
$this->result["entries"] = $entries;
$compression = $this->getParam("compression");
if ($compression) {
switch ($compression) {
case "gzip":
$this->result["compressed"] = base64_encode(gzencode(json_encode($entries), 9));
break;
case "zlib":
$this->result["compressed"] = base64_encode(gzcompress(json_encode($entries), 9, ZLIB_ENCODING_DEFLATE));
break;
default:
http_response_code(400);
return $this->createError("Invalid compression method: $compression");
}
} else {
$this->result["entries"] = $entries;
}
return true;
}
}

@ -19,6 +19,7 @@ return [
"cancel" => "Abbrechen",
"confirm" => "Bestätigen",
"add" => "Hinzufügen",
"select" => "Auswählen",
"ok" => "OK",
"id" => "ID",
"user" => "Benutzer",

@ -0,0 +1,20 @@
<?php
return [
"title" => "Berechtigungen",
"title_short" => "ACL",
"query" => "Suchanfrage",
"add_permission" => "Berechtigung hinzufügen",
"permission" => "Berechtigung",
"everyone" => "Alle",
"method" => "Methode",
"description" => "Beschreibung",
# dialog
"delete_permission_confirm" => "Diese Berechtigung wirklich löschen?",
"edit_permission" => "Berechtigung bearbeiten",
"update_permission_error" => "Fehler beim Aktualisieren der Berechtigung",
"delete_permission_error" => "Fehler beim Löschen der Berechtigung",
"fetch_permission_error" => "Fehler beim Holen der Berechtigungen",
"fetch_group_error" => "Fehler beim Holen der Gruppen",
];

@ -0,0 +1,41 @@
<?php
return [
"title" => "Routen",
"regenerating_cache" => "Lade Cache neu",
"regenerate_cache" => "Cache neuladen",
# table
"route" => "Route",
"type" => "Typ",
"target" => "Ziel",
"extra" => "Extra",
"active" => "Aktiv",
"exact" => "Exakt",
# form
"edit_route_title" => "Route bearbeiten",
"create_route_title" => "Neue Route erstellen",
"pattern" => "Pattern",
"arguments" => "Argumente",
"status_code" => "Status Code",
"json_ok" => "JSON ok!",
"json_err" => "Ungültiger JSON-string!",
"json_not_object" => "Das JSON muss ein Array oder Objekt sein!",
"validate_route" => "Route validieren",
"validate_route_placeholder" => "Einen Pfad eingeben um die Route zu testen",
# data
"type_dynamic" => "Dynamisch",
"type_static" => "Statisch",
"type_redirect_permanently" => "Dauerhaft weiterleiten",
"type_redirect_temporary" => "Temporär weiterleiten",
# dialogs
"fetch_routes_error" => "Fehler beim Holen der Routen",
"enable_route_error" => "Fehler beim Aktivieren der Route",
"disable_route_error" => "Fehler beim Deaktivieren der Route",
"remove_route_error" => "Fehler beim Entfernen der Route",
"regenerate_router_cache_error" => "Fehler beim Erzeugen des Router Caches",
"regenerate_router_cache_success" => "Router Cache erfolgreich erzeugt",
];

@ -30,6 +30,7 @@ return [
"cancel" => "Cancel",
"confirm" => "Confirm",
"add" => "Add",
"select" => "Select",
"close" => "Close",
"ok" => "OK",
"id" => "ID",

@ -0,0 +1,20 @@
<?php
return [
"title" => "Permissions",
"title_short" => "ACL",
"query" => "Search query",
"add_permission" => "Add permission",
"permission" => "Permission",
"everyone" => "Everyone",
"method" => "Method",
"description" => "Description",
# dialog
"delete_permission_confirm" => "Do you really want to delete this permission?",
"edit_permission" => "Edit Permission",
"update_permission_error" => "Error updating permission",
"delete_permission_error" => "Error deleting permission",
"fetch_permission_error" => "Error fetching permissions",
"fetch_group_error" => "Error fetching groups",
];

@ -0,0 +1,41 @@
<?php
return [
"title" => "Routes",
"regenerating_cache" => "Regenerating Cache",
"regenerate_cache" => "Regenerate Cache",
# table
"route" => "Route",
"type" => "Type",
"target" => "Target",
"extra" => "Extra",
"active" => "Active",
"exact" => "Exact",
# form
"edit_route_title" => "Edit Route",
"create_route_title" => "Create new Route",
"pattern" => "Pattern",
"arguments" => "Arguments",
"status_code" => "Status Code",
"json_ok" => "JSON ok!",
"json_err" => "Invalid JSON-string!",
"json_not_object" => "JSON must be Array or Object!",
"validate_route" => "Validate Route",
"validate_route_placeholder" => "Enter a path to test the route",
# data
"type_dynamic" => "Dynamic",
"type_static" => "Static",
"type_redirect_permanently" => "Redirect permanently",
"type_redirect_temporary" => "Redirect temporary",
# dialogs
"fetch_routes_error" => "Error fetching routes",
"enable_route_error" => "Error enabling route",
"disable_route_error" => "Error disabling route",
"remove_route_error" => "Error removing route",
"regenerate_router_cache_error" => "Error regenerating router cache",
"regenerate_router_cache_success" => "Router cache successfully regenerated",
];

@ -45,13 +45,13 @@ export default function AccessControlList(props) {
setFetchACL(false);
props.api.fetchGroups().then(res => {
if (!res.success) {
props.showDialog(res.msg, "Error fetching groups");
props.showDialog(res.msg, L("permissions.fetch_group_error"));
navigate("/admin/dashboard");
} else {
setGroups(res.groups);
props.api.fetchPermissions().then(res => {
if (!res.success) {
props.showDialog(res.msg, "Error fetching permissions");
props.showDialog(res.msg, L("permissions.fetch_permission_error"));
navigate("/admin/dashboard");
} else {
setACL(res.permissions);
@ -67,7 +67,7 @@ export default function AccessControlList(props) {
}, []);
useEffect(() => {
requestModules(props.api, ["general"], currentLocale).then(data => {
requestModules(props.api, ["general", "permissions"], currentLocale).then(data => {
if (!data.success) {
props.showDialog("Error fetching translations: " + data.msg);
}
@ -103,7 +103,7 @@ export default function AccessControlList(props) {
setACL(newACL);
props.api.fetchUser();
} else {
props.showDialog("Error updating permission: " + data.msg);
props.showDialog(data.msg, L("permissions.update_permission_error"));
}
});
}
@ -116,7 +116,7 @@ export default function AccessControlList(props) {
setACL(newACL);
props.api.fetchUser();
} else {
props.showDialog("Error deleting permission: " + data.msg);
props.showDialog(data.msg, L("permissions.delete_permission_error"));
}
})
}, [acl]);
@ -130,7 +130,7 @@ export default function AccessControlList(props) {
setACL(newACL);
props.api.fetchUser();
} else {
props.showDialog("Error updating permission: " + data.msg);
props.showDialog(data.msg, L("permissions.update_permission_error"));
}
})
}, [acl]);
@ -162,11 +162,11 @@ export default function AccessControlList(props) {
disabled={isRestricted(permission.method)}
onClick={() => setDialogData({
open: true,
title: L("Edit permission"),
title: L("permissions.edit_permission"),
inputs: [
{ type: "label", value: L("general.method") + ":" },
{ type: "label", value: L("permissions.method") + ":" },
{ type: "text", name: "method", value: permission.method, disabled: true },
{ type: "label", value: L("general.description") + ":" },
{ type: "label", value: L("permissions.description") + ":" },
{ type: "text", name: "description", value: permission.description, maxLength: 128 }
],
onOption: (option, inputData) => option === 0 && onUpdatePermission(inputData, permission.groups)
@ -177,8 +177,8 @@ export default function AccessControlList(props) {
disabled={isRestricted(permission.method)}
onClick={() => setDialogData({
open: true,
title: L("Do you really want to delete this permission?"),
message: "Method: " + permission.method,
title: L("permissions.delete_permission_confirm"),
message: L("permissions.method") + ": " + permission.method,
onOption: (option) => option === 0 && onDeletePermission(permission.method)
})} >
<Delete />
@ -212,12 +212,12 @@ export default function AccessControlList(props) {
<div className={"container-fluid"}>
<div className={"row mb-2"}>
<div className={"col-sm-6"}>
<h1 className={"m-0 text-dark"}>Access Control List</h1>
<h1 className={"m-0 text-dark"}>{L("permissions.title")}</h1>
</div>
<div className={"col-sm-6"}>
<ol className={"breadcrumb float-sm-right"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active">ACL</li>
<li className="breadcrumb-item active">{L("permissions.title_short")}</li>
</ol>
</div>
</div>
@ -226,10 +226,10 @@ export default function AccessControlList(props) {
<div className={"row"}>
<div className={"col-6"}>
<div className={"form-group"}>
<label>{L("query")}</label>
<label>{L("permissions.query")}</label>
<TextField
className={"form-control"}
placeholder={L("search_query") + "…"}
placeholder={L("permissions.query") + "…"}
value={query}
onChange={e => setQuery(e.target.value)}
variant={"outlined"}
@ -245,7 +245,7 @@ export default function AccessControlList(props) {
<Button variant={"outlined"} className={"m-1"} startIcon={<Add />} disabled={!props.api.hasGroup(USER_GROUP_ADMIN)}
onClick={() => setDialogData({
open: true,
title: L("Add permission"),
title: L("permissions.add_permission"),
inputs: [
{ type: "label", value: L("general.method") + ":" },
{ type: "text", name: "method", value: "", placeholder: L("general.method") },
@ -263,8 +263,8 @@ export default function AccessControlList(props) {
<Table stickyHeader size={"small"} className={"table-striped"}>
<TableHead>
<TableRow>
<TableCell>{L("permission")}</TableCell>
<BorderedColumn align={"center"}><i>{L("everyone")}</i></BorderedColumn>
<TableCell>{L("permissions.permission")}</TableCell>
<BorderedColumn align={"center"}><i>{L("permissions.everyone")}</i></BorderedColumn>
{ groups.map(group => <TableCell key={"group-" + group.id} align={"center"}>
{group.name}
</TableCell>) }

@ -48,7 +48,7 @@ export default function RouteEditView(props) {
const [isSaving, setSaving] = useState(false);
useEffect(() => {
requestModules(props.api, ["general"], currentLocale).then(data => {
requestModules(props.api, ["general", "routes"], currentLocale).then(data => {
if (!data.success) {
props.showDialog("Error fetching translations: " + data.msg);
}
@ -122,15 +122,13 @@ export default function RouteEditView(props) {
<div className={"container-fluid"}>
<ol className={"breadcrumb"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active"><Link to={"/admin/routes"}>Routes</Link></li>
<li className="breadcrumb-item active">{isNewRoute ? "New" : "Edit"}</li>
<li className="breadcrumb-item active"><Link to={"/admin/routes"}>{L("routes.title")}</Link></li>
<li className="breadcrumb-item active">{isNewRoute ? L("general.new") : L("general.edit")}</li>
</ol>
</div>
<div className={"content"}>
<div className={"container-fluid"}>
<h3>{L(isNewRoute ? "Create new Route" : "Edit Route")}</h3>
<div className={"col-sm-12 col-lg-6"}>
</div>
<h3>{L(isNewRoute ? "routes.create_route_title" : "routes.edit_route_title")}</h3>
</div>
</div>
<RouteForm route={route} setRoute={setRoute} />
@ -147,10 +145,10 @@ export default function RouteEditView(props) {
</Button>
</ButtonBar>
<Box mt={3}>
<h5>{L("Validate Route")}</h5>
<h5>{L("routes.validate_route")}</h5>
<MonoSpaceTextField value={routeTest} onChange={e => setRouteTest(e.target.value)}
variant={"outlined"} size={"small"} fullWidth={true}
placeholder={L("Enter a path to test the route…")} />
placeholder={L("routes.validate_route_placeholder") + "…"} />
<pre>
Match: {JSON.stringify(routeTestResult)}
</pre>

@ -1,4 +1,4 @@
import {Box, Checkbox, FormControl, FormControlLabel, FormGroup, Select, styled, TextField} from "@material-ui/core";
import {Checkbox, FormControl, FormControlLabel, Select, styled, TextField} from "@material-ui/core";
import * as React from "react";
import {useCallback, useContext, useEffect, useRef} from "react";
import {LocaleContext} from "shared/locale";
@ -38,30 +38,30 @@ export default function RouteForm(props) {
const elements = [
<RouteFormControl key={"form-control-pattern"} fullWidth={true}>
<label htmlFor={"route-pattern"}>{L("Pattern")}</label>
<label htmlFor={"route-pattern"}>{L("routes.pattern")}</label>
<TextField id={"route-pattern"} variant={"outlined"} size={"small"}
value={route.pattern}
onChange={e => setRoute({...route, pattern: e.target.value})} />
</RouteFormControl>,
<FormGroup key={"form-control-exact"}>
<FormControlLabel label={L("Exact")} control={<Checkbox
<RouteFormControl key={"form-control-exact"}>
<FormControlLabel label={L("routes.exact")} control={<Checkbox
checked={route.exact}
onChange={e => setRoute({...route, exact: e.target.checked})} />} />
</FormGroup>,
<FormGroup key={"form-control-active"}>
<FormControlLabel label={L("Active")} control={<Checkbox
</RouteFormControl>,
<RouteFormControl key={"form-control-active"}>
<FormControlLabel label={L("routes.active")} control={<Checkbox
checked={route.active}
onChange={e => setRoute({...route, active: e.target.checked})} />} />
</FormGroup>,
</RouteFormControl>,
<RouteFormControl key={"form-control-type"} fullWidth={true} size={"small"}>
<label htmlFor={"route-type"}>{L("Type")}</label>
<label htmlFor={"route-type"}>{L("routes.type")}</label>
<Select value={route.type} variant={"outlined"} size={"small"} labelId={"route-type"}
onChange={e => onChangeRouteType(e.target.value)} native>
<option value={""}>Select</option>
<option value={"dynamic"}>Dynamic</option>
<option value={"static"}>Static</option>
<option value={"redirect_permanently"}>Redirect Permanently</option>
<option value={"redirect_temporary"}>Redirect Temporary</option>
<option value={""}>{L("general.select")}</option>
<option value={"dynamic"}>{L("routes.type_dynamic")}</option>
<option value={"static"}>{L("routes.type_static")}</option>
<option value={"redirect_permanently"}>{L("routes.type_redirect_permanently")}</option>
<option value={"redirect_temporary"}>{L("routes.type_redirect_temporary")}</option>
</Select>
</RouteFormControl>,
];
@ -77,7 +77,7 @@ export default function RouteForm(props) {
if (route.type) {
elements.push(
<RouteFormControl key={"form-control-target"} fullWidth={true}>
<label htmlFor={"route-target"}>{L("Target")}</label>
<label htmlFor={"route-target"}>{L("routes.target")}</label>
<TextField id={"route-target"} variant={"outlined"} size={"small"}
value={route.target}
onChange={e => setRoute({...route, target: e.target.value})}/>
@ -95,23 +95,22 @@ export default function RouteForm(props) {
}
elements.push(
<RouteFormControl key={"form-control-extra"} fullWidth={true}>
<label htmlFor={"route-extra"}>{L("Arguments")}</label>
<label htmlFor={"route-extra"}>{L("routes.arguments")}</label>
<textarea id={"route-extra"}
ref={extraRef}
value={extraArgs ?? route.extra}
onChange={e => setRoute({...route, extra: minifyJson(e.target.value)})}/>
<i>{
extraArgs === null ?
"Invalid JSON-string" :
(type !== "object" ?
"JSON must be Array or Object" : "JSON ok!")
L("routes.json_err") :
(type !== "object" ? L("routes.json_not_obj") : L("routes.json_ok"))
}</i>
</RouteFormControl>
);
} else if (route.type === "static") {
elements.push(
<RouteFormControl key={"form-control-extra"} fullWidth={true}>
<label htmlFor={"route-extra"}>{L("Status Code")}</label>
<label htmlFor={"route-extra"}>{L("routes.status_code")}</label>
<TextField id={"route-extra"} variant={"outlined"} size={"small"}
type={"number"} value={route.extra}
onChange={e => setRoute({...route, extra: parseInt(e.target.value) || 200})} />

@ -42,7 +42,7 @@ export default function RouteListView(props) {
setFetchRoutes(false);
props.api.fetchRoutes().then(res => {
if (!res.success) {
props.showDialog(res.msg, "Error fetching routes");
props.showDialog(res.msg, L("routes.fetch_routes_error"));
navigate("/admin/dashboard");
} else {
setRoutes(res.routes);
@ -56,7 +56,7 @@ export default function RouteListView(props) {
}, []);
useEffect(() => {
requestModules(props.api, ["general"], currentLocale).then(data => {
requestModules(props.api, ["general", "routes"], currentLocale).then(data => {
if (!data.success) {
props.showDialog("Error fetching translations: " + data.msg);
}
@ -67,7 +67,7 @@ export default function RouteListView(props) {
if (active) {
props.api.enableRoute(id).then(data => {
if (!data.success) {
props.showDialog(data.msg, L("Error enabling route"));
props.showDialog(data.msg, L("routes.enable_route_error"));
} else {
setRoutes({...routes, [id]: { ...routes[id], active: true }});
}
@ -75,7 +75,7 @@ export default function RouteListView(props) {
} else {
props.api.disableRoute(id).then(data => {
if (!data.success) {
props.showDialog(data.msg, L("Error enabling route"));
props.showDialog(data.msg, L("routes.disable_route_error"));
} else {
setRoutes({...routes, [id]: { ...routes[id], active: false }});
}
@ -86,7 +86,7 @@ export default function RouteListView(props) {
const onDeleteRoute = useCallback(id => {
props.api.deleteRoute(id).then(data => {
if (!data.success) {
props.showDialog(data.msg, L("Error removing route"));
props.showDialog(data.msg, L("routes.remove_route_error"));
} else {
let newRoutes = { ...routes };
delete newRoutes[id];
@ -100,13 +100,13 @@ export default function RouteListView(props) {
setGeneratingCache(true);
props.api.regenerateRouterCache().then(data => {
if (!data.success) {
props.showDialog(data.msg, L("Error regenerating router cache"));
props.showDialog(data.msg, L("routes.regenerate_router_cache_error"));
setGeneratingCache(false);
} else {
setDialogData({
open: true,
title: L("general.success"),
message: L("Router cache successfully regenerated"),
message: L("routes.regenerate_router_cache_success"),
onClose: () => setGeneratingCache(false)
})
}
@ -121,12 +121,12 @@ export default function RouteListView(props) {
<div className={"container-fluid"}>
<div className={"row mb-2"}>
<div className={"col-sm-6"}>
<h1 className={"m-0 text-dark"}>Routes</h1>
<h1 className={"m-0 text-dark"}>{L("routes.title")}</h1>
</div>
<div className={"col-sm-6"}>
<ol className={"breadcrumb float-sm-right"}>
<li className={"breadcrumb-item"}><Link to={"/admin/dashboard"}>Home</Link></li>
<li className="breadcrumb-item active">Routes</li>
<li className="breadcrumb-item active">{L("routes.title")}</li>
</ol>
</div>
</div>
@ -147,7 +147,7 @@ export default function RouteListView(props) {
<Button variant={"outlined"} className={"m-1"} startIcon={<Cached />}
disabled={!props.api.hasPermission("routes/generateCache") || isGeneratingCache}
onClick={onRegenerateCache} >
{isGeneratingCache ? L("regenerating_cache") + "…" : L("regenerate_cache")}
{isGeneratingCache ? L("routes.regenerating_cache") + "…" : L("routes.regenerate_cache")}
</Button>
</div>
</div>
@ -157,12 +157,12 @@ export default function RouteListView(props) {
<TableHead>
<TableRow>
<TableCell>{L("general.id")}</TableCell>
<TableCell>{L("Route")}</TableCell>
<TableCell>{L("Type")}</TableCell>
<TableCell>{L("Target")}</TableCell>
<TableCell>{L("Extra")}</TableCell>
<TableCell align={"center"}>{L("Active")}</TableCell>
<TableCell align={"center"}>{L("Exact")}</TableCell>
<TableCell>{L("routes.route")}</TableCell>
<TableCell>{L("routes.type")}</TableCell>
<TableCell>{L("routes.target")}</TableCell>
<TableCell>{L("routes.extra")}</TableCell>
<TableCell align={"center"}>{L("routes.active")}</TableCell>
<TableCell align={"center"}>{L("routes.exact")}</TableCell>
<TableCell align={"center"}>{L("general.controls")}</TableCell>
</TableRow>
</TableHead>

@ -45,6 +45,7 @@
"date-fns": "^2.29.3",
"material-ui-color-picker": "^3.5.1",
"mini-css-extract-plugin": "^2.7.1",
"pako": "^2.1.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.0.1",
"react-collapse": "^5.1.1",

@ -308,12 +308,12 @@ export default class API {
return res;
}
async getLanguageEntries(modules, code=null, useCache=false) {
async getLanguageEntries(modules, code=null, compression=null) {
if (!Array.isArray(modules)) {
modules = [modules];
}
return this.apiCall("language/getEntries", {code: code, modules: modules});
return this.apiCall("language/getEntries", {code: code, modules: modules, compression: compression});
}
/** ApiKeyAPI **/

@ -1,7 +1,8 @@
import React, {useReducer} from 'react';
import {createContext, useCallback, useState} from "react";
import { enUS as dateFnsEN, de as dateFnsDE } from 'date-fns/locale';
import {getCookie, getParameter} from "./util";
import {encodeText, getCookie, getParameter} from "./util";
import pako from "pako";
const LocaleContext = createContext(null);
@ -109,7 +110,13 @@ function LocaleProvider(props) {
}
if (modules.length > 0) {
let data = await api.apiCall("language/getEntries", { code: code, modules: modules });
let compression = "zlib";
let data = await api.getLanguageEntries(modules, code, compression);
if (compression && data.success) {
data.entries = JSON.parse(pako.inflate(encodeText(atob(data.compressed)), { to: 'string' }));
}
if (useCache) {
if (data && data.success) {

@ -4858,11 +4858,6 @@ date-fns@^2.29.3:
dependencies:
"@babel/runtime" "^7.21.0"
dayjs@^1.11.10:
version "1.11.10"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==
debug@2.6.9, debug@^2.6.0:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -8392,6 +8387,11 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
pako@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86"
integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==
param-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"