[feature/frogend] (Mastodon) domain block CSV import (#1390)
* checkbox-list styling with taller <p> element * CSV import/export, UI/UX improvements to import-export interface * minor styling tweaks * csv export, clean up export type branching * abstract domain block entry validation * foundation for PSL check + suggestions * Squashed commit of the following: commite3655ba4fb
Author: f0x <f0x@cthu.lu> Date: Tue Jan 31 15:19:10 2023 +0100 let debug depend on env (prod/debug) again commit79c792b832
Author: f0x <f0x@cthu.lu> Date: Tue Jan 31 00:34:01 2023 +0100 update checklist components commit4367960fe4
Author: f0x <f0x@cthu.lu> Date: Mon Jan 30 23:46:20 2023 +0100 checklist performance improvements commit204a4c02d1
Author: f0x <f0x@cthu.lu> Date: Mon Jan 30 20:05:34 2023 +0100 checklist field: use reducer for state * remove debug logging * show and use domain block suggestion * restructure import/export buttons * updating suggestions * suggestion overview * restructure check-list behavior, domain import/export
This commit is contained in:
parent
49beb17a8f
commit
a59dc855d9
|
@ -315,7 +315,7 @@ input, select, textarea, .input {
|
||||||
border-color: $input-focus-border;
|
border-color: $input-focus-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:invalid {
|
&:invalid, .invalid & {
|
||||||
border-color: $input-error-border;
|
border-color: $input-error-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -66,10 +66,15 @@ skulk({
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
debug: false,
|
|
||||||
entryFile: "settings",
|
entryFile: "settings",
|
||||||
outputFile: "settings.js",
|
outputFile: "settings.js",
|
||||||
prodCfg: prodCfg,
|
prodCfg: prodCfg,
|
||||||
|
transform: [
|
||||||
|
["babelify", {
|
||||||
|
global: true,
|
||||||
|
ignore: [/node_modules\/(?!nanoid)/]
|
||||||
|
}]
|
||||||
|
],
|
||||||
presets: [
|
presets: [
|
||||||
"react",
|
"react",
|
||||||
["postcss", {
|
["postcss", {
|
||||||
|
|
|
@ -19,9 +19,12 @@
|
||||||
"langs": "^2.0.0",
|
"langs": "^2.0.0",
|
||||||
"match-sorter": "^6.3.1",
|
"match-sorter": "^6.3.1",
|
||||||
"modern-normalize": "^1.1.0",
|
"modern-normalize": "^1.1.0",
|
||||||
|
"nanoid": "^4.0.0",
|
||||||
|
"papaparse": "^5.3.2",
|
||||||
"photoswipe": "^5.3.3",
|
"photoswipe": "^5.3.3",
|
||||||
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
||||||
"photoswipe-video-plugin": "^1.0.2",
|
"photoswipe-video-plugin": "^1.0.2",
|
||||||
|
"psl": "^1.9.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
|
|
|
@ -129,14 +129,16 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
|
||||||
title: "No emoji selected, cannot perform any actions"
|
title: "No emoji selected, cannot perform any actions"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkListExtraProps = React.useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="parsed">
|
<div className="parsed">
|
||||||
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
|
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
|
||||||
<form onSubmit={formSubmit}>
|
<form onSubmit={formSubmit}>
|
||||||
<CheckList
|
<CheckList
|
||||||
field={form.selectedEmoji}
|
field={form.selectedEmoji}
|
||||||
Component={EmojiEntry}
|
EntryComponent={EmojiEntry}
|
||||||
localEmojiCodes={localEmojiCodes}
|
getExtraProps={checkListExtraProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
|
@ -170,7 +172,7 @@ function ErrorList({ errors }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) {
|
function EmojiEntry({ entry: emoji, onChange, extraProps: { localEmojiCodes } }) {
|
||||||
const shortcodeField = useTextInput("shortcode", {
|
const shortcodeField = useTextInput("shortcode", {
|
||||||
defaultValue: emoji.shortcode,
|
defaultValue: emoji.shortcode,
|
||||||
validator: function validateShortcode(code) {
|
validator: function validateShortcode(code) {
|
||||||
|
@ -181,9 +183,16 @@ function EmojiEntry({ entry: emoji, localEmojiCodes, onChange }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (emoji.valid != shortcodeField.valid) {
|
||||||
onChange({ valid: shortcodeField.valid });
|
onChange({ valid: shortcodeField.valid });
|
||||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
}
|
||||||
}, [shortcodeField.valid]);
|
}, [onChange, emoji.valid, shortcodeField.valid]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
shortcodeField.validate();
|
||||||
|
// only need this update if it's the emoji.checked that updated, not shortcodeField
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [emoji.checked]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,307 +0,0 @@
|
||||||
/*
|
|
||||||
GoToSocial
|
|
||||||
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const React = require("react");
|
|
||||||
const { Switch, Route, Redirect, useLocation } = require("wouter");
|
|
||||||
|
|
||||||
const query = require("../../lib/query");
|
|
||||||
|
|
||||||
const {
|
|
||||||
useTextInput,
|
|
||||||
useBoolInput,
|
|
||||||
useRadioInput,
|
|
||||||
useCheckListInput
|
|
||||||
} = require("../../lib/form");
|
|
||||||
|
|
||||||
const useFormSubmit = require("../../lib/form/submit");
|
|
||||||
|
|
||||||
const {
|
|
||||||
TextInput,
|
|
||||||
TextArea,
|
|
||||||
Checkbox,
|
|
||||||
Select,
|
|
||||||
RadioGroup
|
|
||||||
} = require("../../components/form/inputs");
|
|
||||||
|
|
||||||
const CheckList = require("../../components/check-list");
|
|
||||||
const MutationButton = require("../../components/form/mutation-button");
|
|
||||||
const isValidDomain = require("is-valid-domain");
|
|
||||||
const FormWithData = require("../../lib/form/form-with-data");
|
|
||||||
const { Error } = require("../../components/error");
|
|
||||||
|
|
||||||
const baseUrl = "/settings/admin/federation/import-export";
|
|
||||||
|
|
||||||
module.exports = function ImportExport() {
|
|
||||||
const [updateFromFile, setUpdateFromFile] = React.useState(false);
|
|
||||||
const form = {
|
|
||||||
domains: useTextInput("domains"),
|
|
||||||
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
|
|
||||||
};
|
|
||||||
|
|
||||||
const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
|
|
||||||
const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
|
|
||||||
|
|
||||||
function fileChanged(e) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = function (read) {
|
|
||||||
form.domains.setter(read.target.result);
|
|
||||||
setUpdateFromFile(true);
|
|
||||||
};
|
|
||||||
reader.readAsText(e.target.files[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (exportResult.isSuccess) {
|
|
||||||
form.domains.setter(exportResult.data);
|
|
||||||
}
|
|
||||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
||||||
}, [exportResult]);
|
|
||||||
|
|
||||||
const [_location, setLocation] = useLocation();
|
|
||||||
|
|
||||||
if (updateFromFile) {
|
|
||||||
setUpdateFromFile(false);
|
|
||||||
submitParse();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Switch>
|
|
||||||
<Route path={`${baseUrl}/list`}>
|
|
||||||
{!parseResult.isSuccess && <Redirect to={baseUrl} />}
|
|
||||||
|
|
||||||
<h1>
|
|
||||||
<span className="button" onClick={() => {
|
|
||||||
parseResult.reset();
|
|
||||||
setLocation(baseUrl);
|
|
||||||
}}>
|
|
||||||
< back
|
|
||||||
</span> Confirm import:
|
|
||||||
</h1>
|
|
||||||
<FormWithData
|
|
||||||
dataQuery={query.useInstanceBlocksQuery}
|
|
||||||
DataForm={ImportList}
|
|
||||||
list={parseResult.data}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route>
|
|
||||||
{parseResult.isSuccess && <Redirect to={`${baseUrl}/list`} />}
|
|
||||||
<h2>Import / Export suspended domains</h2>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<form onSubmit={submitParse}>
|
|
||||||
<TextArea
|
|
||||||
field={form.domains}
|
|
||||||
label="Domains, one per line (plaintext) or JSON"
|
|
||||||
placeholder={`google.com\nfacebook.com`}
|
|
||||||
rows={8}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="row">
|
|
||||||
<MutationButton label="Import" result={parseResult} showError={false} />
|
|
||||||
<button type="button" className="with-padding">
|
|
||||||
<label>
|
|
||||||
Import file
|
|
||||||
<input className="hidden" type="file" onChange={fileChanged} accept="application/json,text/plain" />
|
|
||||||
</label>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<form onSubmit={submitExport}>
|
|
||||||
<div className="row">
|
|
||||||
<MutationButton name="export" label="Export" result={exportResult} showError={false} />
|
|
||||||
<MutationButton name="export-file" label="Export file" result={exportResult} showError={false} />
|
|
||||||
<Select
|
|
||||||
field={form.exportType}
|
|
||||||
options={<>
|
|
||||||
<option value="plain">Text</option>
|
|
||||||
<option value="json">JSON</option>
|
|
||||||
</>}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{parseResult.error && <Error error={parseResult.error} />}
|
|
||||||
{exportResult.error && <Error error={exportResult.error} />}
|
|
||||||
</div>
|
|
||||||
</Route>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function ImportList({ list, data: blockedInstances }) {
|
|
||||||
const hasComment = React.useMemo(() => {
|
|
||||||
let hasPublic = false;
|
|
||||||
let hasPrivate = false;
|
|
||||||
|
|
||||||
list.some((entry) => {
|
|
||||||
if (entry.public_comment?.length > 0) {
|
|
||||||
hasPublic = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.private_comment?.length > 0) {
|
|
||||||
hasPrivate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasPublic && hasPrivate;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasPublic && hasPrivate) {
|
|
||||||
return { both: true };
|
|
||||||
} else if (hasPublic) {
|
|
||||||
return { type: "public_comment" };
|
|
||||||
} else if (hasPrivate) {
|
|
||||||
return { type: "private_comment" };
|
|
||||||
} else {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}, [list]);
|
|
||||||
|
|
||||||
const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" });
|
|
||||||
let commentName = "";
|
|
||||||
if (showComment.value == "public_comment") { commentName = "Public comment"; }
|
|
||||||
if (showComment.value == "private_comment") { commentName = "Private comment"; }
|
|
||||||
|
|
||||||
const form = {
|
|
||||||
domains: useCheckListInput("domains", {
|
|
||||||
entries: list,
|
|
||||||
uniqueKey: "domain"
|
|
||||||
}),
|
|
||||||
obfuscate: useBoolInput("obfuscate"),
|
|
||||||
privateComment: useTextInput("private_comment", {
|
|
||||||
defaultValue: `Imported on ${new Date().toLocaleString()}`
|
|
||||||
}),
|
|
||||||
privateCommentBehavior: useRadioInput("private_comment_behavior", {
|
|
||||||
defaultValue: "append",
|
|
||||||
options: {
|
|
||||||
append: "Append to",
|
|
||||||
replace: "Replace"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
publicComment: useTextInput("public_comment"),
|
|
||||||
publicCommentBehavior: useRadioInput("public_comment_behavior", {
|
|
||||||
defaultValue: "append",
|
|
||||||
options: {
|
|
||||||
append: "Append to",
|
|
||||||
replace: "Replace"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<form onSubmit={importDomains} className="suspend-import-list">
|
|
||||||
<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span>
|
|
||||||
|
|
||||||
{hasComment.both &&
|
|
||||||
<Select field={showComment} options={
|
|
||||||
<>
|
|
||||||
<option value="public_comment">Show public comments</option>
|
|
||||||
<option value="private_comment">Show private comments</option>
|
|
||||||
</>
|
|
||||||
} />
|
|
||||||
}
|
|
||||||
|
|
||||||
<CheckList
|
|
||||||
field={form.domains}
|
|
||||||
Component={DomainEntry}
|
|
||||||
header={
|
|
||||||
<>
|
|
||||||
<b>Domain</b>
|
|
||||||
<b></b>
|
|
||||||
<b>{commentName}</b>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
blockedInstances={blockedInstances}
|
|
||||||
commentType={showComment.value}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextArea
|
|
||||||
field={form.privateComment}
|
|
||||||
label="Private comment"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<RadioGroup
|
|
||||||
field={form.privateCommentBehavior}
|
|
||||||
label="imported private comment"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextArea
|
|
||||||
field={form.publicComment}
|
|
||||||
label="Public comment"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<RadioGroup
|
|
||||||
field={form.publicCommentBehavior}
|
|
||||||
label="imported public comment"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
field={form.obfuscate}
|
|
||||||
label="Obfuscate domains in public lists"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MutationButton label="Import" result={importResult} />
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DomainEntry({ entry, onChange, blockedInstances, commentType }) {
|
|
||||||
const domainField = useTextInput("domain", {
|
|
||||||
defaultValue: entry.domain,
|
|
||||||
validator: (value) => {
|
|
||||||
return (entry.checked && !isValidDomain(value, { wildcard: true, allowUnicode: true }))
|
|
||||||
? "Invalid domain"
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
onChange({ valid: domainField.valid });
|
|
||||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
||||||
}, [domainField.valid]);
|
|
||||||
|
|
||||||
let icon = null;
|
|
||||||
|
|
||||||
if (blockedInstances[domainField.value] != undefined) {
|
|
||||||
icon = (
|
|
||||||
<>
|
|
||||||
<i className="fa fa-history already-blocked" aria-hidden="true" title="Domain block already exists"></i>
|
|
||||||
<span className="sr-only">Domain block already exists.</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
field={domainField}
|
|
||||||
onChange={(e) => {
|
|
||||||
domainField.onChange(e);
|
|
||||||
onChange({ domain: e.target.value, checked: true });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span id="icon">{icon}</span>
|
|
||||||
<p>{entry[commentType]}</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const React = require("react");
|
||||||
|
|
||||||
|
module.exports = function ExportFormatTable() {
|
||||||
|
return (
|
||||||
|
<table className="export-format-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowSpan={2} />
|
||||||
|
<th colSpan={2}>Includes</th>
|
||||||
|
<th colSpan={2}>Importable by</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Public comment</th>
|
||||||
|
<th>GoToSocial</th>
|
||||||
|
<th>Mastodon</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<Format name="Text" info={[true, false, true, false]} />
|
||||||
|
<Format name="JSON" info={[true, true, true, false]} />
|
||||||
|
<Format name="CSV" info={[true, true, true, true]} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Format({ name, info }) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td><b>{name}</b></td>
|
||||||
|
{info.map((b, key) => <td key={key} className="bool">{bool(b)}</td>)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bool(val) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<i className={`fa fa-${val ? "check" : "times"}`} aria-hidden="true"></i>
|
||||||
|
<span className="sr-only">{val ? "Yes" : "No"}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,123 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const React = require("react");
|
||||||
|
|
||||||
|
const query = require("../../../lib/query");
|
||||||
|
const useFormSubmit = require("../../../lib/form/submit");
|
||||||
|
|
||||||
|
const {
|
||||||
|
TextArea,
|
||||||
|
Select,
|
||||||
|
} = require("../../../components/form/inputs");
|
||||||
|
|
||||||
|
const MutationButton = require("../../../components/form/mutation-button");
|
||||||
|
|
||||||
|
const { Error } = require("../../../components/error");
|
||||||
|
const ExportFormatTable = require("./export-format-table");
|
||||||
|
|
||||||
|
module.exports = function ImportExportForm({ form, submitParse, parseResult }) {
|
||||||
|
const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
|
||||||
|
|
||||||
|
const [updateFromFile, setUpdateFromFile] = React.useState(false);
|
||||||
|
|
||||||
|
function fileChanged(e) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function (read) {
|
||||||
|
form.domains.setter(read.target.result);
|
||||||
|
setUpdateFromFile(true);
|
||||||
|
};
|
||||||
|
reader.readAsText(e.target.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (exportResult.isSuccess) {
|
||||||
|
form.domains.setter(exportResult.data);
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||||
|
}, [exportResult]);
|
||||||
|
|
||||||
|
if (updateFromFile) {
|
||||||
|
setUpdateFromFile(false);
|
||||||
|
submitParse();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Import / Export suspended domains</h1>
|
||||||
|
<p>
|
||||||
|
This page can be used to import and export lists of domains to suspend.
|
||||||
|
Exports can be done in various formats, with varying functionality and support in other software.
|
||||||
|
Imports will automatically detect what format is being processed.
|
||||||
|
</p>
|
||||||
|
<ExportFormatTable />
|
||||||
|
<div className="import-export">
|
||||||
|
<TextArea
|
||||||
|
field={form.domains}
|
||||||
|
label="Domains"
|
||||||
|
placeholder={`google.com\nfacebook.com`}
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="button-grid">
|
||||||
|
<MutationButton
|
||||||
|
label="Import"
|
||||||
|
type="button"
|
||||||
|
onClick={() => submitParse()}
|
||||||
|
result={parseResult}
|
||||||
|
showError={false}
|
||||||
|
/>
|
||||||
|
<label className="button">
|
||||||
|
Import file
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={fileChanged}
|
||||||
|
accept="application/json,text/plain,text/csv"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<b /> {/* grid filler */}
|
||||||
|
<MutationButton
|
||||||
|
label="Export"
|
||||||
|
type="button"
|
||||||
|
onClick={() => submitExport("export")}
|
||||||
|
result={exportResult} showError={false}
|
||||||
|
/>
|
||||||
|
<MutationButton label="Export to file" type="button" onClick={() => submitExport("export-file")} result={exportResult} showError={false} />
|
||||||
|
<div className="export-file">
|
||||||
|
<span>
|
||||||
|
as
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
field={form.exportType}
|
||||||
|
options={<>
|
||||||
|
<option value="plain">Text</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
</>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parseResult.error && <Error error={parseResult.error} />}
|
||||||
|
{exportResult.error && <Error error={exportResult.error} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const React = require("react");
|
||||||
|
const { Switch, Route, Redirect, useLocation } = require("wouter");
|
||||||
|
|
||||||
|
const query = require("../../../lib/query");
|
||||||
|
|
||||||
|
const {
|
||||||
|
useTextInput,
|
||||||
|
} = require("../../../lib/form");
|
||||||
|
|
||||||
|
const useFormSubmit = require("../../../lib/form/submit");
|
||||||
|
|
||||||
|
const ProcessImport = require("./process");
|
||||||
|
const ImportExportForm = require("./form");
|
||||||
|
|
||||||
|
const baseUrl = "/settings/admin/federation/import-export";
|
||||||
|
|
||||||
|
module.exports = function ImportExport() {
|
||||||
|
const form = {
|
||||||
|
domains: useTextInput("domains"),
|
||||||
|
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
|
||||||
|
};
|
||||||
|
|
||||||
|
const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
|
||||||
|
|
||||||
|
const [_location, setLocation] = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route path={`${baseUrl}/process`}>
|
||||||
|
{parseResult.isSuccess ? (
|
||||||
|
<>
|
||||||
|
<h1>
|
||||||
|
<span className="button" onClick={() => {
|
||||||
|
parseResult.reset();
|
||||||
|
setLocation(baseUrl);
|
||||||
|
}}>
|
||||||
|
< back
|
||||||
|
</span> Confirm import:
|
||||||
|
</h1>
|
||||||
|
<ProcessImport
|
||||||
|
list={parseResult.data}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : <Redirect to={baseUrl} />}
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route>
|
||||||
|
{!parseResult.isSuccess ? (
|
||||||
|
<ImportExportForm
|
||||||
|
form={form}
|
||||||
|
submitParse={submitParse}
|
||||||
|
parseResult={parseResult}
|
||||||
|
/>
|
||||||
|
) : <Redirect to={`${baseUrl}/process`} />}
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,327 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const React = require("react");
|
||||||
|
|
||||||
|
const query = require("../../../lib/query");
|
||||||
|
const { isValidDomainBlock, hasBetterScope } = require("../../../lib/domain-block");
|
||||||
|
|
||||||
|
const {
|
||||||
|
useTextInput,
|
||||||
|
useBoolInput,
|
||||||
|
useRadioInput,
|
||||||
|
useCheckListInput
|
||||||
|
} = require("../../../lib/form");
|
||||||
|
|
||||||
|
const useFormSubmit = require("../../../lib/form/submit");
|
||||||
|
|
||||||
|
const {
|
||||||
|
TextInput,
|
||||||
|
TextArea,
|
||||||
|
Checkbox,
|
||||||
|
Select,
|
||||||
|
RadioGroup
|
||||||
|
} = require("../../../components/form/inputs");
|
||||||
|
|
||||||
|
const CheckList = require("../../../components/check-list");
|
||||||
|
const MutationButton = require("../../../components/form/mutation-button");
|
||||||
|
const FormWithData = require("../../../lib/form/form-with-data");
|
||||||
|
|
||||||
|
module.exports = React.memo(
|
||||||
|
function ProcessImport({ list }) {
|
||||||
|
return (
|
||||||
|
<div className="without-border">
|
||||||
|
<FormWithData
|
||||||
|
dataQuery={query.useInstanceBlocksQuery}
|
||||||
|
DataForm={ImportList}
|
||||||
|
list={list}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function ImportList({ list, data: blockedInstances }) {
|
||||||
|
const hasComment = React.useMemo(() => {
|
||||||
|
let hasPublic = false;
|
||||||
|
let hasPrivate = false;
|
||||||
|
|
||||||
|
list.some((entry) => {
|
||||||
|
if (entry.public_comment?.length > 0) {
|
||||||
|
hasPublic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.private_comment?.length > 0) {
|
||||||
|
hasPrivate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasPublic && hasPrivate;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasPublic && hasPrivate) {
|
||||||
|
return { both: true };
|
||||||
|
} else if (hasPublic) {
|
||||||
|
return { type: "public_comment" };
|
||||||
|
} else if (hasPrivate) {
|
||||||
|
return { type: "private_comment" };
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}, [list]);
|
||||||
|
|
||||||
|
const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" });
|
||||||
|
|
||||||
|
const form = {
|
||||||
|
domains: useCheckListInput("domains", { entries: list }),
|
||||||
|
obfuscate: useBoolInput("obfuscate"),
|
||||||
|
privateComment: useTextInput("private_comment", {
|
||||||
|
defaultValue: `Imported on ${new Date().toLocaleString()}`
|
||||||
|
}),
|
||||||
|
privateCommentBehavior: useRadioInput("private_comment_behavior", {
|
||||||
|
defaultValue: "append",
|
||||||
|
options: {
|
||||||
|
append: "Append to",
|
||||||
|
replace: "Replace"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
publicComment: useTextInput("public_comment"),
|
||||||
|
publicCommentBehavior: useRadioInput("public_comment_behavior", {
|
||||||
|
defaultValue: "append",
|
||||||
|
options: {
|
||||||
|
append: "Append to",
|
||||||
|
replace: "Replace"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={importDomains} className="suspend-import-list">
|
||||||
|
<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span>
|
||||||
|
|
||||||
|
{hasComment.both &&
|
||||||
|
<Select field={showComment} options={
|
||||||
|
<>
|
||||||
|
<option value="public_comment">Show public comments</option>
|
||||||
|
<option value="private_comment">Show private comments</option>
|
||||||
|
</>
|
||||||
|
} />
|
||||||
|
}
|
||||||
|
|
||||||
|
<DomainCheckList
|
||||||
|
field={form.domains}
|
||||||
|
blockedInstances={blockedInstances}
|
||||||
|
commentType={showComment.value}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
field={form.privateComment}
|
||||||
|
label="Private comment"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<RadioGroup
|
||||||
|
field={form.privateCommentBehavior}
|
||||||
|
label="imported private comment"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
field={form.publicComment}
|
||||||
|
label="Public comment"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<RadioGroup
|
||||||
|
field={form.publicCommentBehavior}
|
||||||
|
label="imported public comment"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
field={form.obfuscate}
|
||||||
|
label="Obfuscate domains in public lists"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MutationButton label="Import" result={importResult} />
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainCheckList({ field, blockedInstances, commentType }) {
|
||||||
|
const getExtraProps = React.useCallback((entry) => {
|
||||||
|
return {
|
||||||
|
comment: entry[commentType],
|
||||||
|
alreadyExists: blockedInstances[entry.domain] != undefined
|
||||||
|
};
|
||||||
|
}, [blockedInstances, commentType]);
|
||||||
|
|
||||||
|
const entriesWithSuggestions = React.useMemo(() => (
|
||||||
|
Object.values(field.value).filter((entry) => entry.suggest)
|
||||||
|
), [field.value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CheckList
|
||||||
|
field={field}
|
||||||
|
header={<>
|
||||||
|
<b>Domain</b>
|
||||||
|
<b></b>
|
||||||
|
<b>
|
||||||
|
{commentType == "public_comment" && "Public comment"}
|
||||||
|
{commentType == "private_comment" && "Private comment"}
|
||||||
|
</b>
|
||||||
|
</>}
|
||||||
|
EntryComponent={DomainEntry}
|
||||||
|
getExtraProps={getExtraProps}
|
||||||
|
/>
|
||||||
|
<UpdateHint
|
||||||
|
entries={entriesWithSuggestions}
|
||||||
|
updateEntry={field.onChange}
|
||||||
|
updateMultiple={field.updateMultiple}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateHint = React.memo(
|
||||||
|
function UpdateHint({ entries, updateEntry, updateMultiple }) {
|
||||||
|
if (entries.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeAll() {
|
||||||
|
updateMultiple(
|
||||||
|
entries.map((entry) => [entry.key, { domain: entry.suggest, suggest: null }])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="update-hints">
|
||||||
|
<p>
|
||||||
|
{entries.length} {entries.length == 1 ? "entry uses" : "entries use"} a specific subdomain,
|
||||||
|
which you might want to change to the main domain, as that includes all it's (future) subdomains.
|
||||||
|
</p>
|
||||||
|
<div className="hints">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<UpdateableEntry key={entry.key} entry={entry} updateEntry={updateEntry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{entries.length > 0 && <a onClick={changeAll}>change all</a>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const UpdateableEntry = React.memo(
|
||||||
|
function UpdateableEntry({ entry, updateEntry }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="text-cutoff">{entry.domain}</span>
|
||||||
|
<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
|
||||||
|
<span>{entry.suggest}</span>
|
||||||
|
<a role="button" onClick={() =>
|
||||||
|
updateEntry(entry.key, { domain: entry.suggest, suggest: null })
|
||||||
|
}>change</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function domainValidationError(isValid) {
|
||||||
|
return isValid ? "" : "Invalid domain";
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } }) {
|
||||||
|
const domainField = useTextInput("domain", {
|
||||||
|
defaultValue: entry.domain,
|
||||||
|
showValidation: entry.checked,
|
||||||
|
initValidation: domainValidationError(entry.valid),
|
||||||
|
validator: (value) => domainValidationError(isValidDomainBlock(value))
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (entry.valid != domainField.valid) {
|
||||||
|
onChange({ valid: domainField.valid });
|
||||||
|
}
|
||||||
|
}, [onChange, entry.valid, domainField.valid]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (entry.domain != domainField.value) {
|
||||||
|
domainField.setter(entry.domain);
|
||||||
|
}
|
||||||
|
// domainField.setter is enough, eslint wants domainField
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [entry.domain, domainField.setter]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onChange({ suggest: hasBetterScope(domainField.value) });
|
||||||
|
// only need this update if it's the entry.checked that updated, not onChange
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [domainField.value]);
|
||||||
|
|
||||||
|
function clickIcon(e) {
|
||||||
|
if (entry.suggest) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
domainField.setter(entry.suggest);
|
||||||
|
onChange({ domain: entry.suggest, checked: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
field={domainField}
|
||||||
|
onChange={(e) => {
|
||||||
|
domainField.onChange(e);
|
||||||
|
onChange({ domain: e.target.value, checked: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span id="icon" onClick={clickIcon}>
|
||||||
|
<DomainEntryIcon alreadyExists={alreadyExists} suggestion={entry.suggest} onChange={onChange} />
|
||||||
|
</span>
|
||||||
|
<p>{comment}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainEntryIcon({ alreadyExists, suggestion }) {
|
||||||
|
let icon;
|
||||||
|
let text;
|
||||||
|
|
||||||
|
if (suggestion) {
|
||||||
|
icon = "fa-info-circle suggest-changes";
|
||||||
|
text = `Entry targets a specific subdomain, consider changing it to '${suggestion}'.`;
|
||||||
|
} else if (alreadyExists) {
|
||||||
|
icon = "fa-history already-blocked";
|
||||||
|
text = "Domain block already exists.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!icon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<i className={`fa ${icon}`} aria-hidden="true" title={text}></i>
|
||||||
|
<span className="sr-only">{text}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -20,31 +20,62 @@
|
||||||
|
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
|
|
||||||
module.exports = function CheckList({ field, Component, header = " All", ...componentProps }) {
|
module.exports = function CheckList({ field, header = "All", EntryComponent, getExtraProps }) {
|
||||||
return (
|
return (
|
||||||
<div className="checkbox-list list">
|
<div className="checkbox-list list">
|
||||||
<label className="header">
|
<CheckListHeader toggleAll={field.toggleAll}> {header}</CheckListHeader>
|
||||||
<input
|
<CheckListEntries
|
||||||
ref={field.toggleAll.ref}
|
entries={field.value}
|
||||||
type="checkbox"
|
updateValue={field.onChange}
|
||||||
onChange={field.toggleAll.onChange}
|
EntryComponent={EntryComponent}
|
||||||
checked={field.toggleAll.value === 1}
|
getExtraProps={getExtraProps}
|
||||||
/> {header}
|
|
||||||
</label>
|
|
||||||
{Object.values(field.value).map((entry) => (
|
|
||||||
<CheckListEntry
|
|
||||||
key={entry.key}
|
|
||||||
onChange={(value) => field.onChange(entry.key, value)}
|
|
||||||
entry={entry}
|
|
||||||
Component={Component}
|
|
||||||
componentProps={componentProps}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function CheckListEntry({ entry, onChange, Component, componentProps }) {
|
function CheckListHeader({ toggleAll, children }) {
|
||||||
|
return (
|
||||||
|
<label className="header entry">
|
||||||
|
<input
|
||||||
|
ref={toggleAll.ref}
|
||||||
|
type="checkbox"
|
||||||
|
onChange={toggleAll.onChange}
|
||||||
|
/> {children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CheckListEntries = React.memo(
|
||||||
|
function CheckListEntries({ entries, updateValue, EntryComponent, getExtraProps }) {
|
||||||
|
const deferredEntries = React.useDeferredValue(entries);
|
||||||
|
|
||||||
|
return Object.values(deferredEntries).map((entry) => (
|
||||||
|
<CheckListEntry
|
||||||
|
key={entry.key}
|
||||||
|
entry={entry}
|
||||||
|
updateValue={updateValue}
|
||||||
|
EntryComponent={EntryComponent}
|
||||||
|
getExtraProps={getExtraProps}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
React.memo is a performance optimization that only re-renders a CheckListEntry
|
||||||
|
when it's props actually change, instead of every time anything
|
||||||
|
in the list (CheckListEntries) updates
|
||||||
|
*/
|
||||||
|
const CheckListEntry = React.memo(
|
||||||
|
function CheckListEntry({ entry, updateValue, getExtraProps, EntryComponent }) {
|
||||||
|
const onChange = React.useCallback(
|
||||||
|
(value) => updateValue(entry.key, value),
|
||||||
|
[updateValue, entry.key]
|
||||||
|
);
|
||||||
|
|
||||||
|
const extraProps = React.useMemo(() => getExtraProps?.(entry), [getExtraProps, entry]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="entry">
|
<label className="entry">
|
||||||
<input
|
<input
|
||||||
|
@ -52,7 +83,8 @@ function CheckListEntry({ entry, onChange, Component, componentProps }) {
|
||||||
onChange={(e) => onChange({ checked: e.target.checked })}
|
onChange={(e) => onChange({ checked: e.target.checked })}
|
||||||
checked={entry.checked}
|
checked={entry.checked}
|
||||||
/>
|
/>
|
||||||
<Component entry={entry} onChange={onChange} {...componentProps} />
|
<EntryComponent entry={entry} onChange={onChange} extraProps={extraProps} />
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
|
@ -22,9 +22,10 @@ const React = require("react");
|
||||||
|
|
||||||
function TextInput({ label, field, ...inputProps }) {
|
function TextInput({ label, field, ...inputProps }) {
|
||||||
const { onChange, value, ref } = field;
|
const { onChange, value, ref } = field;
|
||||||
|
console.log(field.name, field.valid, field.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-field text">
|
<div className={`form-field text${field.valid ? "" : " invalid"}`}>
|
||||||
<label>
|
<label>
|
||||||
{label}
|
{label}
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const isValidDomain = require("is-valid-domain");
|
||||||
|
const psl = require("psl");
|
||||||
|
|
||||||
|
function isValidDomainBlock(domain) {
|
||||||
|
return isValidDomain(domain, {
|
||||||
|
/*
|
||||||
|
Wildcard prefix *. can be stripped since it's equivalent to not having it,
|
||||||
|
but wildcard anywhere else in the domain is not handled by the backend so it's invalid.
|
||||||
|
*/
|
||||||
|
wildcard: false,
|
||||||
|
allowUnicode: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Still can't think of a better function name for this,
|
||||||
|
but we're checking a domain against the Public Suffix List <https://publicsuffix.org/>
|
||||||
|
to see if we should suggest removing subdomain(s) since they're likely owned/ran by the same party
|
||||||
|
social.example.com -> suggests example.com
|
||||||
|
*/
|
||||||
|
function hasBetterScope(domain) {
|
||||||
|
const lookup = psl.get(domain);
|
||||||
|
if (lookup && lookup != domain) {
|
||||||
|
return lookup;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isValidDomainBlock, hasBetterScope };
|
|
@ -20,111 +20,145 @@
|
||||||
|
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
const syncpipe = require("syncpipe");
|
const syncpipe = require("syncpipe");
|
||||||
|
const { createSlice } = require("@reduxjs/toolkit");
|
||||||
|
const { enableMapSet } = require("immer");
|
||||||
|
|
||||||
function createState(entries, uniqueKey, oldState, defaultValue) {
|
enableMapSet(); // for use in reducers
|
||||||
return syncpipe(entries, [
|
|
||||||
|
const { reducer, actions } = createSlice({
|
||||||
|
name: "checklist",
|
||||||
|
initialState: {}, // not handled by slice itself
|
||||||
|
reducers: {
|
||||||
|
updateAll: (state, { payload: checked }) => {
|
||||||
|
const selectedEntries = new Set();
|
||||||
|
return {
|
||||||
|
entries: syncpipe(state.entries, [
|
||||||
|
(_) => Object.values(_),
|
||||||
|
(_) => _.map((entry) => {
|
||||||
|
if (checked) {
|
||||||
|
selectedEntries.add(entry.key);
|
||||||
|
}
|
||||||
|
return [entry.key, {
|
||||||
|
...entry,
|
||||||
|
checked
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
(_) => Object.fromEntries(_)
|
||||||
|
]),
|
||||||
|
selectedEntries
|
||||||
|
};
|
||||||
|
},
|
||||||
|
update: (state, { payload: { key, value } }) => {
|
||||||
|
if (value.checked !== undefined) {
|
||||||
|
if (value.checked === true) {
|
||||||
|
state.selectedEntries.add(key);
|
||||||
|
} else {
|
||||||
|
state.selectedEntries.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.entries[key] = {
|
||||||
|
...state.entries[key],
|
||||||
|
...value
|
||||||
|
};
|
||||||
|
},
|
||||||
|
updateMultiple: (state, { payload }) => {
|
||||||
|
payload.forEach(([key, value]) => {
|
||||||
|
if (value.checked !== undefined) {
|
||||||
|
if (value.checked === true) {
|
||||||
|
state.selectedEntries.add(key);
|
||||||
|
} else {
|
||||||
|
state.selectedEntries.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.entries[key] = {
|
||||||
|
...state.entries[key],
|
||||||
|
...value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initialState({ entries, uniqueKey, defaultValue }) {
|
||||||
|
const selectedEntries = new Set();
|
||||||
|
return {
|
||||||
|
entries: syncpipe(entries, [
|
||||||
(_) => _.map((entry) => {
|
(_) => _.map((entry) => {
|
||||||
let key = entry[uniqueKey];
|
let key = entry[uniqueKey];
|
||||||
|
let checked = entry.checked ?? defaultValue;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
selectedEntries.add(key);
|
||||||
|
} else {
|
||||||
|
selectedEntries.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
key,
|
key,
|
||||||
{
|
{
|
||||||
...entry,
|
...entry,
|
||||||
key,
|
key,
|
||||||
checked: oldState[key]?.checked ?? entry.checked ?? defaultValue
|
checked
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
(_) => Object.fromEntries(_)
|
(_) => Object.fromEntries(_)
|
||||||
]);
|
]),
|
||||||
}
|
selectedEntries
|
||||||
|
|
||||||
function updateAllState(state, newValue) {
|
|
||||||
return syncpipe(state, [
|
|
||||||
(_) => Object.values(_),
|
|
||||||
(_) => _.map((entry) => [entry.key, {
|
|
||||||
...entry,
|
|
||||||
checked: newValue
|
|
||||||
}]),
|
|
||||||
(_) => Object.fromEntries(_)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateState(state, key, newValue) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
[key]: {
|
|
||||||
...state[key],
|
|
||||||
...newValue
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", defaultValue = false }) {
|
module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", defaultValue = false }) {
|
||||||
const [state, setState] = React.useState({});
|
const [state, dispatch] = React.useReducer(reducer, null,
|
||||||
|
() => initialState({ entries, uniqueKey, defaultValue }) // initial state
|
||||||
|
);
|
||||||
|
|
||||||
const [someSelected, setSomeSelected] = React.useState(false);
|
|
||||||
const [toggleAllState, setToggleAllState] = React.useState(0);
|
|
||||||
const toggleAllRef = React.useRef(null);
|
const toggleAllRef = React.useRef(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
/*
|
if (toggleAllRef.current != null) {
|
||||||
entries changed, update state,
|
let some = state.selectedEntries.size > 0;
|
||||||
re-using old state if available for key
|
|
||||||
*/
|
|
||||||
setState(createState(entries, uniqueKey, state, defaultValue));
|
|
||||||
|
|
||||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
||||||
}, [entries]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
/* Updates (un)check all checkbox, based on shortcode checkboxes
|
|
||||||
Can be 0 (not checked), 1 (checked) or 2 (indeterminate)
|
|
||||||
*/
|
|
||||||
if (toggleAllRef.current == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let values = Object.values(state);
|
|
||||||
/* one or more boxes are checked */
|
|
||||||
let some = values.some((v) => v.checked);
|
|
||||||
|
|
||||||
let all = false;
|
let all = false;
|
||||||
if (some) {
|
if (some) {
|
||||||
/* there's not at least one unchecked box */
|
all = state.selectedEntries.size == Object.values(state.entries).length;
|
||||||
all = !values.some((v) => v.checked == false);
|
|
||||||
}
|
}
|
||||||
|
toggleAllRef.current.checked = all;
|
||||||
setSomeSelected(some);
|
toggleAllRef.current.indeterminate = some && !all;
|
||||||
|
|
||||||
if (some && !all) {
|
|
||||||
setToggleAllState(2);
|
|
||||||
toggleAllRef.current.indeterminate = true;
|
|
||||||
} else {
|
|
||||||
setToggleAllState(all ? 1 : 0);
|
|
||||||
toggleAllRef.current.indeterminate = false;
|
|
||||||
}
|
}
|
||||||
}, [state, toggleAllRef]);
|
// only needs to update when state.selectedEntries changes, not state.entries
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state.selectedEntries]);
|
||||||
|
|
||||||
|
const reset = React.useCallback(
|
||||||
|
() => dispatch(actions.updateAll(defaultValue)),
|
||||||
|
[defaultValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChange = React.useCallback(
|
||||||
|
(key, value) => dispatch(actions.update({ key, value })),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateMultiple = React.useCallback(
|
||||||
|
(entries) => dispatch(actions.updateMultiple(entries)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return React.useMemo(() => {
|
||||||
function toggleAll(e) {
|
function toggleAll(e) {
|
||||||
let selectAll = e.target.checked;
|
let checked = e.target.checked;
|
||||||
|
if (e.target.indeterminate) {
|
||||||
if (toggleAllState == 2) { // indeterminate
|
checked = false;
|
||||||
selectAll = false;
|
|
||||||
}
|
}
|
||||||
|
dispatch(actions.updateAll(checked));
|
||||||
setState(updateAllState(state, selectAll));
|
|
||||||
setToggleAllState(selectAll);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setState(updateAllState(state, defaultValue));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedValues() {
|
function selectedValues() {
|
||||||
return syncpipe(state, [
|
return Array.from((state.selectedEntries)).map((key) => ({
|
||||||
(_) => Object.values(_),
|
...state.entries[key] // returned as new object, because reducer state is immutable
|
||||||
(_) => _.filter((entry) => entry.checked)
|
}));
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign([
|
return Object.assign([
|
||||||
|
@ -133,15 +167,16 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke
|
||||||
{ name }
|
{ name }
|
||||||
], {
|
], {
|
||||||
name,
|
name,
|
||||||
value: state,
|
value: state.entries,
|
||||||
onChange: (key, newValue) => setState(updateState(state, key, newValue)),
|
onChange,
|
||||||
selectedValues,
|
selectedValues,
|
||||||
reset,
|
reset,
|
||||||
someSelected,
|
someSelected: state.someChecked,
|
||||||
|
updateMultiple,
|
||||||
toggleAll: {
|
toggleAll: {
|
||||||
ref: toggleAllRef,
|
ref: toggleAllRef,
|
||||||
value: toggleAllState,
|
|
||||||
onChange: toggleAll
|
onChange: toggleAll
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}, [state, reset, name, onChange, updateMultiple]);
|
||||||
};
|
};
|
|
@ -20,14 +20,30 @@
|
||||||
|
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
|
|
||||||
module.exports = function useTextInput({ name, Name }, { validator, defaultValue = "", dontReset = false } = {}) {
|
module.exports = function useTextInput({ name, Name }, {
|
||||||
|
defaultValue = "",
|
||||||
|
dontReset = false,
|
||||||
|
validator,
|
||||||
|
showValidation = true,
|
||||||
|
initValidation
|
||||||
|
} = {}) {
|
||||||
|
|
||||||
const [text, setText] = React.useState(defaultValue);
|
const [text, setText] = React.useState(defaultValue);
|
||||||
const [valid, setValid] = React.useState(true);
|
|
||||||
const textRef = React.useRef(null);
|
const textRef = React.useRef(null);
|
||||||
|
|
||||||
|
const [validation, setValidation] = React.useState(initValidation ?? "");
|
||||||
|
const [_isValidating, startValidation] = React.useTransition();
|
||||||
|
let valid = validation == "";
|
||||||
|
|
||||||
function onChange(e) {
|
function onChange(e) {
|
||||||
let input = e.target.value;
|
let input = e.target.value;
|
||||||
setText(input);
|
setText(input);
|
||||||
|
|
||||||
|
if (validator) {
|
||||||
|
startValidation(() => {
|
||||||
|
setValidation(validator(input));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
|
@ -38,11 +54,13 @@ module.exports = function useTextInput({ name, Name }, { validator, defaultValue
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (validator && textRef.current) {
|
if (validator && textRef.current) {
|
||||||
let res = validator(text);
|
if (showValidation) {
|
||||||
setValid(res == "");
|
textRef.current.setCustomValidity(validation);
|
||||||
textRef.current.setCustomValidity(res);
|
} else {
|
||||||
|
textRef.current.setCustomValidity("");
|
||||||
}
|
}
|
||||||
}, [text, textRef, validator]);
|
}
|
||||||
|
}, [validation, validator, showValidation]);
|
||||||
|
|
||||||
// Array / Object hybrid, for easier access in different contexts
|
// Array / Object hybrid, for easier access in different contexts
|
||||||
return Object.assign([
|
return Object.assign([
|
||||||
|
@ -62,6 +80,7 @@ module.exports = function useTextInput({ name, Name }, { validator, defaultValue
|
||||||
ref: textRef,
|
ref: textRef,
|
||||||
setter: setText,
|
setter: setText,
|
||||||
valid,
|
valid,
|
||||||
|
validate: () => setValidation(validator(text)),
|
||||||
hasChanged: () => text != defaultValue
|
hasChanged: () => text != defaultValue
|
||||||
});
|
});
|
||||||
};
|
};
|
|
@ -19,8 +19,11 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const Promise = require("bluebird");
|
const Promise = require("bluebird");
|
||||||
const isValidDomain = require("is-valid-domain");
|
|
||||||
const fileDownload = require("js-file-download");
|
const fileDownload = require("js-file-download");
|
||||||
|
const csv = require("papaparse");
|
||||||
|
const { nanoid } = require("nanoid");
|
||||||
|
|
||||||
|
const { isValidDomainBlock, hasBetterScope } = require("../../domain-block");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
replaceCacheOnMutation,
|
replaceCacheOnMutation,
|
||||||
|
@ -31,6 +34,23 @@ const {
|
||||||
function parseDomainList(list) {
|
function parseDomainList(list) {
|
||||||
if (list[0] == "[") {
|
if (list[0] == "[") {
|
||||||
return JSON.parse(list);
|
return JSON.parse(list);
|
||||||
|
} else if (list.startsWith("#domain")) { // Mastodon CSV
|
||||||
|
const { data, errors } = csv.parse(list, {
|
||||||
|
header: true,
|
||||||
|
transformHeader: (header) => header.slice(1), // removes starting '#'
|
||||||
|
skipEmptyLines: true,
|
||||||
|
dynamicTyping: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
let error = "";
|
||||||
|
errors.forEach((err) => {
|
||||||
|
error += `${err.message} (line ${err.row})`;
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
} else {
|
} else {
|
||||||
return list.split("\n").map((line) => {
|
return list.split("\n").map((line) => {
|
||||||
let domain = line.trim();
|
let domain = line.trim();
|
||||||
|
@ -51,7 +71,15 @@ function parseDomainList(list) {
|
||||||
|
|
||||||
function validateDomainList(list) {
|
function validateDomainList(list) {
|
||||||
list.forEach((entry) => {
|
list.forEach((entry) => {
|
||||||
entry.valid = (entry.valid !== false) && isValidDomain(entry.domain, { wildcard: true, allowUnicode: true });
|
if (entry.domain.startsWith("*.")) {
|
||||||
|
// domain block always includes all subdomains, wildcard is meaningless here
|
||||||
|
entry.domain = entry.domain.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.valid = (entry.valid !== false) && isValidDomainBlock(entry.domain);
|
||||||
|
if (entry.valid) {
|
||||||
|
entry.suggest = hasBetterScope(entry.domain);
|
||||||
|
}
|
||||||
entry.checked = entry.valid;
|
entry.checked = entry.valid;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -83,6 +111,9 @@ module.exports = (build) => ({
|
||||||
}).then((deduped) => {
|
}).then((deduped) => {
|
||||||
return validateDomainList(deduped);
|
return validateDomainList(deduped);
|
||||||
}).then((data) => {
|
}).then((data) => {
|
||||||
|
data.forEach((entry) => {
|
||||||
|
entry.key = nanoid(); // unique id that stays stable even if domain gets modified by user
|
||||||
|
});
|
||||||
return { data };
|
return { data };
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
return { error: e.toString() };
|
return { error: e.toString() };
|
||||||
|
@ -91,27 +122,53 @@ module.exports = (build) => ({
|
||||||
}),
|
}),
|
||||||
exportDomainList: build.mutation({
|
exportDomainList: build.mutation({
|
||||||
queryFn: (formData, api, _extraOpts, baseQuery) => {
|
queryFn: (formData, api, _extraOpts, baseQuery) => {
|
||||||
|
let process;
|
||||||
|
|
||||||
|
if (formData.exportType == "json") {
|
||||||
|
process = {
|
||||||
|
transformEntry: (entry) => ({
|
||||||
|
domain: entry.domain,
|
||||||
|
public_comment: entry.public_comment,
|
||||||
|
obfuscate: entry.obfuscate
|
||||||
|
}),
|
||||||
|
stringify: (list) => JSON.stringify(list),
|
||||||
|
extension: ".json",
|
||||||
|
mime: "application/json"
|
||||||
|
};
|
||||||
|
} else if (formData.exportType == "csv") {
|
||||||
|
process = {
|
||||||
|
transformEntry: (entry) => [
|
||||||
|
entry.domain,
|
||||||
|
"suspend", // severity
|
||||||
|
false, // reject_media
|
||||||
|
false, // reject_reports
|
||||||
|
entry.public_comment,
|
||||||
|
entry.obfuscate ?? false
|
||||||
|
],
|
||||||
|
stringify: (list) => csv.unparse({
|
||||||
|
fields: "#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate".split(","),
|
||||||
|
data: list
|
||||||
|
}),
|
||||||
|
extension: ".csv",
|
||||||
|
mime: "text/csv"
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
process = {
|
||||||
|
transformEntry: (entry) => entry.domain,
|
||||||
|
stringify: (list) => list.join("\n"),
|
||||||
|
extension: ".txt",
|
||||||
|
mime: "text/plain"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.try(() => {
|
return Promise.try(() => {
|
||||||
return baseQuery({
|
return baseQuery({
|
||||||
url: `/api/v1/admin/domain_blocks`
|
url: `/api/v1/admin/domain_blocks`
|
||||||
});
|
});
|
||||||
}).then(unwrapRes).then((blockedInstances) => {
|
}).then(unwrapRes).then((blockedInstances) => {
|
||||||
return blockedInstances.map((entry) => {
|
return blockedInstances.map(process.transformEntry);
|
||||||
if (formData.exportType == "json") {
|
|
||||||
return {
|
|
||||||
domain: entry.domain,
|
|
||||||
public_comment: entry.public_comment
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return entry.domain;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).then((exportList) => {
|
}).then((exportList) => {
|
||||||
if (formData.exportType == "json") {
|
return process.stringify(exportList);
|
||||||
return JSON.stringify(exportList);
|
|
||||||
} else {
|
|
||||||
return exportList.join("\n");
|
|
||||||
}
|
|
||||||
}).then((exportAsString) => {
|
}).then((exportAsString) => {
|
||||||
if (formData.action == "export") {
|
if (formData.action == "export") {
|
||||||
return {
|
return {
|
||||||
|
@ -120,7 +177,6 @@ module.exports = (build) => ({
|
||||||
} else if (formData.action == "export-file") {
|
} else if (formData.action == "export-file") {
|
||||||
let domain = new URL(api.getState().oauth.instance).host;
|
let domain = new URL(api.getState().oauth.instance).host;
|
||||||
let date = new Date();
|
let date = new Date();
|
||||||
let mime;
|
|
||||||
|
|
||||||
let filename = [
|
let filename = [
|
||||||
domain,
|
domain,
|
||||||
|
@ -130,15 +186,11 @@ module.exports = (build) => ({
|
||||||
date.getDate().toString().padStart(2, "0"),
|
date.getDate().toString().padStart(2, "0"),
|
||||||
].join("-");
|
].join("-");
|
||||||
|
|
||||||
if (formData.exportType == "json") {
|
fileDownload(
|
||||||
filename += ".json";
|
exportAsString,
|
||||||
mime = "application/json";
|
filename + process.extension,
|
||||||
} else {
|
process.mime
|
||||||
filename += ".txt";
|
);
|
||||||
mime = "text/plain";
|
|
||||||
}
|
|
||||||
|
|
||||||
fileDownload(exportAsString, filename, mime);
|
|
||||||
}
|
}
|
||||||
return { data: null };
|
return { data: null };
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
|
@ -171,6 +223,7 @@ module.exports = (build) => ({
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const internalKeys = new Set("key,suggest,valid,checked".split(","));
|
||||||
function entryProcessor(formData) {
|
function entryProcessor(formData) {
|
||||||
let funcs = [];
|
let funcs = [];
|
||||||
|
|
||||||
|
@ -204,7 +257,7 @@ function entryProcessor(formData) {
|
||||||
entry.obfuscate = formData.obfuscate;
|
entry.obfuscate = formData.obfuscate;
|
||||||
|
|
||||||
Object.entries(entry).forEach(([key, val]) => {
|
Object.entries(entry).forEach(([key, val]) => {
|
||||||
if (val == undefined) {
|
if (internalKeys.has(key) || val == undefined) {
|
||||||
delete entry[key];
|
delete entry[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -69,6 +69,10 @@ section {
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.without-border {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -370,7 +374,8 @@ span.form-info {
|
||||||
|
|
||||||
.checkbox-list {
|
.checkbox-list {
|
||||||
.header, .entry {
|
.header, .entry {
|
||||||
gap: 1rem;
|
display: grid;
|
||||||
|
gap: 0 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -629,7 +634,6 @@ span.form-info {
|
||||||
|
|
||||||
.checkbox-list {
|
.checkbox-list {
|
||||||
.entry {
|
.entry {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto 1fr;
|
grid-template-columns: auto auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -688,9 +692,14 @@ button.with-padding {
|
||||||
|
|
||||||
.suspend-import-list {
|
.suspend-import-list {
|
||||||
.checkbox-list {
|
.checkbox-list {
|
||||||
.header, .entry {
|
.entry {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 25ch auto 1fr;
|
grid-template-columns: auto 25ch auto 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
|
||||||
|
p {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -704,6 +713,10 @@ button.with-padding {
|
||||||
color: $green1;
|
color: $green1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#icon .suggest-changes {
|
||||||
|
color: $orange2;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -711,6 +724,75 @@ button.with-padding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-export {
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-file {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-grid {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: auto auto auto;
|
||||||
|
align-self: start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-hints {
|
||||||
|
background: $list-entry-alternate-bg;
|
||||||
|
border: 0.1rem solid $border-accent;
|
||||||
|
/* border-radius: $br; */
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.hints {
|
||||||
|
max-width: 100%;
|
||||||
|
align-self: start;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1rem 0;
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: auto auto auto auto;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-format-table {
|
||||||
|
width: 100%;
|
||||||
|
background: $list-entry-alternate-bg;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 0.1rem solid $gray1;
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: $list-entry-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.fa-check {
|
||||||
|
color: $green1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-times {
|
||||||
|
color: $error3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.form-field.radio {
|
.form-field.radio {
|
||||||
&, label {
|
&, label {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -723,6 +805,10 @@ button.with-padding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[role="button"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeout {
|
@keyframes fadeout {
|
||||||
from {
|
from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
|
@ -3953,6 +3953,11 @@ nanoid@^3.3.4:
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||||
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||||
|
|
||||||
|
nanoid@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.0.tgz#6e144dee117609232c3f415c34b0e550e64999a5"
|
||||||
|
integrity sha512-IgBP8piMxe/gf73RTQx7hmnhwz0aaEXYakvqZyE302IXW3HyVNhdNGC+O2MwMAVhLEnvXlvKtGbtJf6wvHihCg==
|
||||||
|
|
||||||
natural-compare@^1.4.0:
|
natural-compare@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||||
|
@ -4117,6 +4122,11 @@ pako@~1.0.5:
|
||||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
||||||
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
||||||
|
|
||||||
|
papaparse@^5.3.2:
|
||||||
|
version "5.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.2.tgz#d1abed498a0ee299f103130a6109720404fbd467"
|
||||||
|
integrity sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw==
|
||||||
|
|
||||||
parent-module@^1.0.0:
|
parent-module@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||||
|
@ -4348,6 +4358,11 @@ proxy-addr@~2.0.7:
|
||||||
forwarded "0.2.0"
|
forwarded "0.2.0"
|
||||||
ipaddr.js "1.9.1"
|
ipaddr.js "1.9.1"
|
||||||
|
|
||||||
|
psl@^1.9.0:
|
||||||
|
version "1.9.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
|
||||||
|
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
|
||||||
|
|
||||||
public-encrypt@^4.0.0:
|
public-encrypt@^4.0.0:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0"
|
resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0"
|
||||||
|
|
Loading…
Reference in New Issue