mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 00:39:13 +00:00
feat(filters): add form validation (#890)
* feat(filters): add form validation * feat(filters): show toast on validation error * chore: update package.json
This commit is contained in:
parent
83e9232b98
commit
124031f510
5 changed files with 146 additions and 37 deletions
|
@ -31,7 +31,9 @@
|
||||||
"react-tooltip": "^5.5.2",
|
"react-tooltip": "^5.5.2",
|
||||||
"stacktracey": "^2.1.8",
|
"stacktracey": "^2.1.8",
|
||||||
"vite-plugin-pwa": "^0.14.6",
|
"vite-plugin-pwa": "^0.14.6",
|
||||||
"workbox-window": "^6.5.4"
|
"workbox-window": "^6.5.4",
|
||||||
|
"zod": "^3.21.4",
|
||||||
|
"zod-formik-adapter": "^1.2.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
|
|
|
@ -116,21 +116,27 @@ export const IndexerMultiSelect = ({
|
||||||
<Field name={name} type="select" multiple={true}>
|
<Field name={name} type="select" multiple={true}>
|
||||||
{({
|
{({
|
||||||
field,
|
field,
|
||||||
|
meta,
|
||||||
form: { setFieldValue }
|
form: { setFieldValue }
|
||||||
}: FieldProps) => (
|
}: FieldProps) => (
|
||||||
<RMSC
|
<>
|
||||||
{...field}
|
<RMSC
|
||||||
options={options}
|
{...field}
|
||||||
labelledBy={name}
|
options={options}
|
||||||
value={field.value && field.value.map((item: IndexerMultiSelectOption) => ({
|
labelledBy={name}
|
||||||
value: item.id, label: item.name
|
value={field.value && field.value.map((item: IndexerMultiSelectOption) => ({
|
||||||
}))}
|
value: item.id, label: item.name
|
||||||
onChange={(values: MultiSelectOption[]) => {
|
}))}
|
||||||
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
|
onChange={(values: MultiSelectOption[]) => {
|
||||||
setFieldValue(field.name, item);
|
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
|
||||||
}}
|
setFieldValue(field.name, item);
|
||||||
className={settingsContext.darkTheme ? "dark" : ""}
|
}}
|
||||||
/>
|
className={settingsContext.darkTheme ? "dark" : ""}
|
||||||
|
/>
|
||||||
|
{meta.touched && meta.error && (
|
||||||
|
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
@ -153,6 +159,7 @@ export function DownloadClientSelect({
|
||||||
<Field name={name} type="select">
|
<Field name={name} type="select">
|
||||||
{({
|
{({
|
||||||
field,
|
field,
|
||||||
|
meta,
|
||||||
form: { setFieldValue }
|
form: { setFieldValue }
|
||||||
}: FieldProps) => (
|
}: FieldProps) => (
|
||||||
<Listbox
|
<Listbox
|
||||||
|
@ -231,6 +238,9 @@ export function DownloadClientSelect({
|
||||||
))}
|
))}
|
||||||
</Listbox.Options>
|
</Listbox.Options>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
{meta.touched && meta.error && (
|
||||||
|
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,20 +1,24 @@
|
||||||
|
import React, { Fragment, useEffect, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Field, FieldArray, FieldProps, FormikValues, useFormikContext } from "formik";
|
||||||
|
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
|
||||||
|
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionContentLayoutOptions,
|
||||||
|
ActionRtorrentRenameOptions,
|
||||||
|
ActionTypeNameMap,
|
||||||
|
ActionTypeOptions
|
||||||
|
} from "@domain/constants";
|
||||||
import { AlertWarning } from "@components/alerts";
|
import { AlertWarning } from "@components/alerts";
|
||||||
import { DownloadClientSelect, NumberField, Select, SwitchGroup, TextField } from "@components/inputs";
|
import { DownloadClientSelect, NumberField, Select, SwitchGroup, TextField } from "@components/inputs";
|
||||||
import { ActionContentLayoutOptions, ActionRtorrentRenameOptions, ActionTypeNameMap, ActionTypeOptions } from "@domain/constants";
|
|
||||||
import React, { Fragment, useRef, useEffect, useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
import { Field, FieldArray, FieldProps, FormikValues } from "formik";
|
|
||||||
import { EmptyListState } from "@components/emptystates";
|
import { EmptyListState } from "@components/emptystates";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
|
|
||||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
|
||||||
import { DeleteModal } from "@components/modals";
|
import { DeleteModal } from "@components/modals";
|
||||||
import { CollapsableSection } from "./details";
|
import { CollapsableSection } from "./details";
|
||||||
import { CustomTooltip } from "@components/tooltips/CustomTooltip";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useFormikContext } from "formik";
|
|
||||||
import { TextArea } from "@components/inputs/input";
|
import { TextArea } from "@components/inputs/input";
|
||||||
|
|
||||||
interface FilterActionsProps {
|
interface FilterActionsProps {
|
||||||
|
@ -29,7 +33,8 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
|
||||||
{ refetchOnWindowFocus: false }
|
{ refetchOnWindowFocus: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const newAction = {
|
const newAction: Action = {
|
||||||
|
id: 0,
|
||||||
name: "new action",
|
name: "new action",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
type: "TEST",
|
type: "TEST",
|
||||||
|
@ -43,7 +48,7 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
|
||||||
paused: false,
|
paused: false,
|
||||||
ignore_rules: false,
|
ignore_rules: false,
|
||||||
skip_hash_check: false,
|
skip_hash_check: false,
|
||||||
content_layout: "",
|
content_layout: "" || undefined,
|
||||||
limit_upload_speed: 0,
|
limit_upload_speed: 0,
|
||||||
limit_download_speed: 0,
|
limit_download_speed: 0,
|
||||||
limit_ratio: 0,
|
limit_ratio: 0,
|
||||||
|
@ -57,8 +62,8 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
|
||||||
webhook_type: "",
|
webhook_type: "",
|
||||||
webhook_method: "",
|
webhook_method: "",
|
||||||
webhook_data: "",
|
webhook_data: "",
|
||||||
webhook_headers: []
|
webhook_headers: [],
|
||||||
// client_id: 0,
|
client_id: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -108,7 +113,7 @@ interface TypeFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||||
const { setFieldValue } = useFormikContext();
|
const { setFieldValue } = useFormikContext();
|
||||||
|
|
||||||
const resetClientField = (action: Action, idx: number, prevActionType: string): void => {
|
const resetClientField = (action: Action, idx: number, prevActionType: string): void => {
|
||||||
const fieldName = `actions.${idx}.client_id`;
|
const fieldName = `actions.${idx}.client_id`;
|
||||||
|
@ -127,7 +132,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||||
action.type === "READARR" ||
|
action.type === "READARR" ||
|
||||||
action.type === "SABNZBD"
|
action.type === "SABNZBD"
|
||||||
)) {
|
)) {
|
||||||
setFieldValue(fieldName, ""); // Reset the client_id field value
|
setFieldValue(fieldName, 0); // Reset the client_id field value
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -137,7 +142,8 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||||
resetClientField(action, idx, prevActionType);
|
resetClientField(action, idx, prevActionType);
|
||||||
}
|
}
|
||||||
setPrevActionType(action.type);
|
setPrevActionType(action.type);
|
||||||
}, [action.type, idx, setFieldValue]);
|
}, [action.type, idx, setFieldValue]);
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "TEST":
|
case "TEST":
|
||||||
return (
|
return (
|
||||||
|
@ -209,7 +215,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||||
label="Save path"
|
label="Save path"
|
||||||
columns={6}
|
columns={6}
|
||||||
placeholder="eg. /full/path/to/download_folder"
|
placeholder="eg. /full/path/to/download_folder"
|
||||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.save_path`} clickable={true}><div><p>Set a custom save path for this action. Automatic Torrent Management will take care of this if using qBittorrent with categories.</p><br /><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div></CustomTooltip>} />
|
tooltip={<div><p>Set a custom save path for this action. Automatic Torrent Management will take care of this if using qBittorrent with categories.</p><br /><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div>} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -219,13 +225,13 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||||
label="Category"
|
label="Category"
|
||||||
columns={6}
|
columns={6}
|
||||||
placeholder="eg. category"
|
placeholder="eg. category"
|
||||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.category`} clickable={true}><div><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div></CustomTooltip>} />
|
tooltip={<div><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div>} />
|
||||||
<TextField
|
<TextField
|
||||||
name={`actions.${idx}.tags`}
|
name={`actions.${idx}.tags`}
|
||||||
label="Tags"
|
label="Tags"
|
||||||
columns={6}
|
columns={6}
|
||||||
placeholder="eg. tag1,tag2"
|
placeholder="eg. tag1,tag2"
|
||||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.tags`} clickable={true}><div><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div></CustomTooltip>} />
|
tooltip={<div><p>The field can use macros to transform/add values from metadata:</p><a href='https://autobrr.com/filters/actions#macros' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/actions#macros</a></div>} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollapsableSection title="Rules" subtitle="client options">
|
<CollapsableSection title="Rules" subtitle="client options">
|
||||||
|
@ -266,7 +272,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||||
<SwitchGroup
|
<SwitchGroup
|
||||||
name={`actions.${idx}.ignore_rules`}
|
name={`actions.${idx}.ignore_rules`}
|
||||||
label="Ignore client rules"
|
label="Ignore client rules"
|
||||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.ignore_rules`} clickable={true}><div><p>Choose to ignore rules set in <Link className='text-blue-400 visited:text-blue-400' to="/settings/clients">Client Settings</Link>.</p></div></CustomTooltip>} />
|
tooltip={<div><p>Choose to ignore rules set in <Link className='text-blue-400 visited:text-blue-400' to="/settings/clients">Client Settings</Link>.</p></div>} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-6">
|
<div className="col-span-6">
|
||||||
<Select
|
<Select
|
||||||
|
@ -508,7 +514,7 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||||
label="Category"
|
label="Category"
|
||||||
columns={6}
|
columns={6}
|
||||||
placeholder="eg. category"
|
placeholder="eg. category"
|
||||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.category`} clickable={true}><p>Category must exist already.</p></CustomTooltip>} />
|
tooltip={<p>Category must exist already.</p>} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -626,7 +632,7 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
|
||||||
label="Type"
|
label="Type"
|
||||||
optionDefaultText="Select type"
|
optionDefaultText="Select type"
|
||||||
options={ActionTypeOptions}
|
options={ActionTypeOptions}
|
||||||
tooltip={<CustomTooltip anchorId={`actions.${idx}.type`} clickable={true}><div><p>Select the download client type for this action.</p></div></CustomTooltip>}
|
tooltip={<div><p>Select the download client type for this action.</p></div>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField name={`actions.${idx}.name`} label="Name" columns={6} />
|
<TextField name={`actions.${idx}.name`} label="Name" columns={6} />
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import React, { useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { Form, Formik, FormikValues, useFormikContext } from "formik";
|
import { Form, Formik, FormikValues, useFormikContext } from "formik";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toFormikValidationSchema } from "zod-formik-adapter";
|
||||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
|
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -140,6 +142,71 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FormErrorNotification = () => {
|
||||||
|
const { isValid, isValidating, isSubmitting, errors } = useFormikContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isValid && !isValidating && isSubmitting) {
|
||||||
|
console.log("validation errors: ", errors);
|
||||||
|
toast.custom((t) => <Toast type="error" body={`Validation error. Check fields: ${Object.keys(errors)}`} t={t}/>);
|
||||||
|
}
|
||||||
|
}, [isSubmitting, isValid, isValidating]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedClientType = ["QBITTORRENT", "DELUGE_V1", "DELUGE_V2", "RTORRENT", "TRANSMISSION","PORLA", "RADARR", "SONARR", "LIDARR", "WHISPARR", "READARR", "SABNZBD"];
|
||||||
|
|
||||||
|
const actionSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.enum(["QBITTORRENT", "DELUGE_V1", "DELUGE_V2", "RTORRENT", "TRANSMISSION","PORLA", "RADARR", "SONARR", "LIDARR", "WHISPARR", "READARR", "SABNZBD", "TEST", "EXEC", "WATCH_FOLDER", "WEBHOOK"]),
|
||||||
|
client_id: z.number().optional(),
|
||||||
|
exec_cmd: z.string().optional(),
|
||||||
|
exec_args: z.string().optional(),
|
||||||
|
watch_folder: z.string().optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
tags: z.string().optional(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
save_path: z.string().optional(),
|
||||||
|
paused: z.boolean().optional(),
|
||||||
|
ignore_rules: z.boolean().optional(),
|
||||||
|
limit_upload_speed: z.number().optional(),
|
||||||
|
limit_download_speed: z.number().optional(),
|
||||||
|
limit_ratio: z.number().optional(),
|
||||||
|
limit_seed_time: z.number().optional(),
|
||||||
|
reannounce_skip: z.boolean().optional(),
|
||||||
|
reannounce_delete: z.boolean().optional(),
|
||||||
|
reannounce_interval: z.number().optional(),
|
||||||
|
reannounce_max_attempts: z.number().optional(),
|
||||||
|
webhook_host: z.string().optional(),
|
||||||
|
webhook_type: z.string().optional(),
|
||||||
|
webhook_method: z.string().optional(),
|
||||||
|
webhook_data: z.string().optional()
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (allowedClientType.includes(value.type)) {
|
||||||
|
if (value.client_id === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
message: "Must select client",
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["client_id"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexerSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define the schema for the entire object
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
indexers: z.array(indexerSchema).min(1, { message: "Must select at least one indexer" }),
|
||||||
|
actions: z.array(actionSchema)
|
||||||
|
});
|
||||||
|
|
||||||
export default function FilterDetails() {
|
export default function FilterDetails() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
@ -209,6 +276,9 @@ export default function FilterDetails() {
|
||||||
if (a.type === "WEBHOOK") {
|
if (a.type === "WEBHOOK") {
|
||||||
a.webhook_method = "POST";
|
a.webhook_method = "POST";
|
||||||
a.webhook_type = "JSON";
|
a.webhook_type = "JSON";
|
||||||
|
} else {
|
||||||
|
a.webhook_method = "";
|
||||||
|
a.webhook_type = "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -312,9 +382,11 @@ export default function FilterDetails() {
|
||||||
} as Filter}
|
} as Filter}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
enableReinitialize={true}
|
enableReinitialize={true}
|
||||||
|
validationSchema={toFormikValidationSchema(schema)}
|
||||||
>
|
>
|
||||||
{({ values, dirty, resetForm }) => (
|
{({ values, dirty, resetForm }) => (
|
||||||
<Form>
|
<Form>
|
||||||
|
<FormErrorNotification />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<General />} />
|
<Route index element={<General />} />
|
||||||
<Route path="movies-tv" element={<MoviesTv />} />
|
<Route path="movies-tv" element={<MoviesTv />} />
|
||||||
|
|
|
@ -6973,6 +6973,8 @@ __metadata:
|
||||||
vite: ^4.2.1
|
vite: ^4.2.1
|
||||||
vite-plugin-pwa: ^0.14.6
|
vite-plugin-pwa: ^0.14.6
|
||||||
workbox-window: ^6.5.4
|
workbox-window: ^6.5.4
|
||||||
|
zod: ^3.21.4
|
||||||
|
zod-formik-adapter: ^1.2.0
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
@ -7272,3 +7274,20 @@ __metadata:
|
||||||
checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700
|
checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"zod-formik-adapter@npm:^1.2.0":
|
||||||
|
version: 1.2.0
|
||||||
|
resolution: "zod-formik-adapter@npm:1.2.0"
|
||||||
|
peerDependencies:
|
||||||
|
formik: ^2.2.9
|
||||||
|
zod: ^3.14.4
|
||||||
|
checksum: 41f9dc4550fb529bdeb9de2711a7e307140c44c5b65c28f7349da9b030276c3747962b15fbbc50b6fda1959b3a3fdcaff707f1cdfef0b0b6a3e745d017c8f73a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"zod@npm:^3.21.4":
|
||||||
|
version: 3.21.4
|
||||||
|
resolution: "zod@npm:3.21.4"
|
||||||
|
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue