mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 16:59:12 +00:00
feat(filters): import/export functionality (#755)
* initial commit * import working * some more changes * import is working * added text field for import * made exported json look pretty * use filter name as title in export takes the name of the exported filter and add it as title to the json wont be used for anything on import * snake case for title * visual improvements * added export function to filter dropdown * added import to filter list * include empty values on export this is needed for the import to work * styled the add button * reduced needed values for const defaultFilter this is the minimum required for successful import * reduced defaultFilter to bits * Made export and import require minimum values added "version": "1.0", to export json * changed filter name * made the import textfield dynamic * incremental numbering for imported filter names Updated the filter import logic to check for existing filter names and appending incremental number to the filter name if a conflict is found * reverted changes in details.tsx * Improved code comments a bit * add icon and tooltip to filter.actions_count === 0 * changed the 0-action icon to a red animate-ping - made the tooltip trigger on both the name and the animate-ping hover - improved colors a bit * fixed bg color for textarea made the focus ring less intrusive
This commit is contained in:
parent
603828be9d
commit
4449df66aa
2 changed files with 254 additions and 19 deletions
|
@ -25,8 +25,6 @@ import { APIClient } from "../../api/APIClient";
|
|||
import { useToggle } from "../../hooks/hooks";
|
||||
import { classNames } from "../../utils";
|
||||
|
||||
import { CustomTooltip } from "../../components/tooltips/CustomTooltip";
|
||||
|
||||
import {
|
||||
CheckboxField,
|
||||
IndexerMultiSelect,
|
||||
|
@ -319,7 +317,7 @@ export default function FilterDetails() {
|
|||
);
|
||||
}
|
||||
|
||||
export function General() {
|
||||
export function General(){
|
||||
const { isLoading, data: indexers } = useQuery(
|
||||
["filters", "indexer_list"],
|
||||
() => APIClient.indexers.getOptions(),
|
||||
|
@ -334,7 +332,6 @@ export function General() {
|
|||
return (
|
||||
<div>
|
||||
<div className="mt-6 lg:pb-8">
|
||||
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
|
||||
|
||||
|
@ -360,7 +357,6 @@ export function General() {
|
|||
<div className="border-t dark:border-gray-700">
|
||||
<SwitchGroup name="enabled" label="Enabled" description="Enable or disable this filter." />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,15 +3,22 @@ import { Link } from "react-router-dom";
|
|||
import { toast } from "react-hot-toast";
|
||||
import { Listbox, Menu, Switch, Transition } from "@headlessui/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { FormikValues } from "formik";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
|
||||
import {
|
||||
ArrowsRightLeftIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
PlusIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon
|
||||
TrashIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { queryClient } from "../../App";
|
||||
|
@ -22,6 +29,7 @@ import { APIClient } from "../../api/APIClient";
|
|||
import Toast from "../../components/notifications/Toast";
|
||||
import { EmptyListState } from "../../components/emptystates";
|
||||
import { DeleteModal } from "../../components/modals";
|
||||
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
type FilterListState = {
|
||||
indexerFilter: string[],
|
||||
|
@ -71,9 +79,69 @@ const FilterListReducer = (state: FilterListState, action: Actions): FilterListS
|
|||
}
|
||||
};
|
||||
|
||||
export default function Filters() {
|
||||
interface FilterProps {
|
||||
values?: FormikValues;
|
||||
}
|
||||
|
||||
export default function Filters({}: FilterProps){
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createFilterIsOpen, toggleCreateFilter] = useToggle(false);
|
||||
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importJson, setImportJson] = useState("");
|
||||
|
||||
// This function handles the import of a filter from a JSON string
|
||||
const handleImportJson = async () => {
|
||||
try {
|
||||
const importedData = JSON.parse(importJson);
|
||||
|
||||
// Extract the filter data and name from the imported object
|
||||
const importedFilter = importedData.data;
|
||||
const filterName = importedData.name;
|
||||
|
||||
// Check if the required properties are present and add them with default values if they are missing
|
||||
const requiredProperties = ["resolutions", "sources", "codecs", "containers"];
|
||||
requiredProperties.forEach((property) => {
|
||||
if (!importedFilter.hasOwnProperty(property)) {
|
||||
importedFilter[property] = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch existing filters from the API
|
||||
const existingFilters = await APIClient.filters.getAll();
|
||||
|
||||
// Create a unique filter title by appending an incremental number if title is taken by another filter
|
||||
let nameCounter = 0;
|
||||
let uniqueFilterName = filterName;
|
||||
while (existingFilters.some((filter) => filter.name === uniqueFilterName)) {
|
||||
nameCounter++;
|
||||
uniqueFilterName = `${filterName}-${nameCounter}`;
|
||||
}
|
||||
|
||||
// Create a new filter using the API
|
||||
const newFilter: Filter = {
|
||||
...importedFilter,
|
||||
name: uniqueFilterName
|
||||
};
|
||||
|
||||
await APIClient.filters.create(newFilter);
|
||||
|
||||
// Update the filter list
|
||||
queryClient.invalidateQueries("filters");
|
||||
|
||||
toast.custom((t) => <Toast type="success" body="Filter imported successfully." t={t} />);
|
||||
setShowImportModal(false);
|
||||
} catch (error) {
|
||||
// Log the error and show an error toast message
|
||||
console.error("Error:", error);
|
||||
toast.custom((t) => <Toast type="error" body="Failed to import JSON data. Please check your input." t={t} />);
|
||||
}
|
||||
};
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
|
||||
|
@ -83,17 +151,66 @@ export default function Filters() {
|
|||
<h1 className="text-3xl font-bold text-black dark:text-white">
|
||||
Filters
|
||||
</h1>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
className="relative inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-l-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
onClick={toggleCreateFilter}
|
||||
>
|
||||
Add new
|
||||
<PlusIcon className="h-5 w-5 mr-1" />
|
||||
Add Filter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-2 py-2 border-l border-spacing-1 dark:border-black shadow-sm text-sm font-medium rounded-r-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
>
|
||||
<ChevronDownIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-46 bg-white dark:bg-gray-700 rounded-md shadow-lg z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
>
|
||||
Import Filter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{showImportModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="w-1/2 md:w-1/2 bg-white dark:bg-gray-800 p-6 rounded-md shadow-lg">
|
||||
<h2 className="text-lg font-medium mb-4 text-black dark:text-white">Import Filter JSON</h2>
|
||||
<textarea
|
||||
className="form-input block w-full resize-y rounded-md border-gray-300 dark:bg-gray-800 dark:border-gray-600 shadow-sm text-sm font-medium text-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-blue-500 dark:focus:ring-blue-500 mb-4"
|
||||
placeholder="Paste JSON data here"
|
||||
value={importJson}
|
||||
onChange={(event) => setImportJson(event.target.value)}
|
||||
style={{ minHeight: "30vh", maxHeight: "50vh" }}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
onClick={() => setShowImportModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-4 relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
onClick={handleImportJson}
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterList toggleCreateFilter={toggleCreateFilter} />
|
||||
</main>
|
||||
|
@ -157,7 +274,7 @@ function FilterList({ toggleCreateFilter }: any) {
|
|||
{data && data.length > 0 ? (
|
||||
<ol className="min-w-full">
|
||||
{filtered.filtered.map((filter: Filter, idx) => (
|
||||
<FilterListItem filter={filter} key={filter.id} idx={idx} />
|
||||
<FilterListItem filter={filter} values={filter} key={filter.id} idx={idx} />
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
|
@ -201,14 +318,86 @@ const StatusButton = ({ data, label, value, currentValue, dispatch }: StatusButt
|
|||
};
|
||||
|
||||
interface FilterItemDropdownProps {
|
||||
values: FormikValues;
|
||||
filter: Filter;
|
||||
onToggle: (newState: boolean) => void;
|
||||
}
|
||||
|
||||
const FilterItemDropdown = ({
|
||||
filter,
|
||||
onToggle
|
||||
}: FilterItemDropdownProps) => {
|
||||
const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
||||
|
||||
// This function handles the export of a filter to a JSON string
|
||||
const handleExportJson = useCallback(async () => {
|
||||
try {
|
||||
type CompleteFilterType = {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
indexers: any;
|
||||
actions: any;
|
||||
actions_count: any;
|
||||
external_script_enabled: any;
|
||||
external_script_cmd: any;
|
||||
external_script_args: any;
|
||||
external_script_expect_status: any;
|
||||
external_webhook_enabled: any;
|
||||
external_webhook_host: any;
|
||||
external_webhook_data: any;
|
||||
external_webhook_expect_status: any;
|
||||
};
|
||||
|
||||
const completeFilter = await APIClient.filters.getByID(filter.id) as Partial<CompleteFilterType>;
|
||||
|
||||
// Extract the filter name and remove unwanted properties
|
||||
const title = completeFilter.name;
|
||||
delete completeFilter.name;
|
||||
delete completeFilter.id;
|
||||
delete completeFilter.created_at;
|
||||
delete completeFilter.updated_at;
|
||||
delete completeFilter.actions_count;
|
||||
delete completeFilter.indexers;
|
||||
delete completeFilter.actions;
|
||||
delete completeFilter.external_script_enabled;
|
||||
delete completeFilter.external_script_cmd;
|
||||
delete completeFilter.external_script_args;
|
||||
delete completeFilter.external_script_expect_status;
|
||||
delete completeFilter.external_webhook_enabled;
|
||||
delete completeFilter.external_webhook_host;
|
||||
delete completeFilter.external_webhook_data;
|
||||
delete completeFilter.external_webhook_expect_status;
|
||||
|
||||
// Remove properties with default values from the exported filter to minimize the size of the JSON string
|
||||
["enabled", "priority", "smart_episode", "resolutions", "sources", "codecs", "containers"].forEach((key) => {
|
||||
const value = completeFilter[key as keyof CompleteFilterType];
|
||||
if (["enabled", "priority", "smart_episode"].includes(key) && (value === false || value === 0)) {
|
||||
delete completeFilter[key as keyof CompleteFilterType];
|
||||
} else if (["resolutions", "sources", "codecs", "containers"].includes(key) && Array.isArray(value) && value.length === 0) {
|
||||
delete completeFilter[key as keyof CompleteFilterType];
|
||||
}
|
||||
});
|
||||
|
||||
// Create a JSON string from the filter data, including a name and version
|
||||
const json = JSON.stringify(
|
||||
{
|
||||
"name": title,
|
||||
"version": "1.0",
|
||||
data: completeFilter
|
||||
},
|
||||
null,
|
||||
4
|
||||
);
|
||||
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
toast.custom((t) => <Toast type="success" body="Filter copied to clipboard." t={t} />);
|
||||
}, () => {
|
||||
toast.custom((t) => <Toast type="error" body="Failed to copy JSON to clipboard." t={t} />);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.custom((t) => <Toast type="error" body="Failed to get filter data." t={t} />);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
const cancelModalButtonRef = useRef(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
@ -289,6 +478,26 @@ const FilterItemDropdown = ({
|
|||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={classNames(
|
||||
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
|
||||
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
|
||||
)}
|
||||
onClick={handleExportJson}
|
||||
>
|
||||
<ArrowDownTrayIcon
|
||||
className={classNames(
|
||||
active ? "text-white" : "text-blue-500",
|
||||
"w-5 h-5 mr-2"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Export JSON
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
|
@ -360,10 +569,11 @@ const FilterItemDropdown = ({
|
|||
|
||||
interface FilterListItemProps {
|
||||
filter: Filter;
|
||||
values: FormikValues;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
function FilterListItem({ filter, idx }: FilterListItemProps) {
|
||||
function FilterListItem({ filter, values, idx }: FilterListItemProps) {
|
||||
const [enabled, setEnabled] = useState(filter.enabled);
|
||||
|
||||
const updateMutation = useMutation(
|
||||
|
@ -426,7 +636,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
|||
{filter.name}
|
||||
</Link>
|
||||
</span>
|
||||
<div className="flex-col">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 break-words whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Priority: {filter.priority}
|
||||
</span>
|
||||
|
@ -435,7 +645,35 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
|||
to={`${filter.id.toString()}/actions`}
|
||||
className="hover:text-black dark:hover:text-gray-300"
|
||||
>
|
||||
<span className={classNames(filter.actions_count == 0 ? "text-red-500" : "")}>Actions: {filter.actions_count}</span>
|
||||
<span
|
||||
id={`tooltip-actions-${filter.id}`}
|
||||
className="flex items-center hover:cursor-pointer"
|
||||
>
|
||||
<span className={classNames(filter.actions_count == 0 ? "text-red-500" : "")}>
|
||||
<span
|
||||
className={
|
||||
classNames(
|
||||
filter.actions_count == 0 ? "hover:text-red-400 dark:hover:text-red-400" : ""
|
||||
)
|
||||
}
|
||||
>
|
||||
Actions: {filter.actions_count}
|
||||
</span>
|
||||
</span>
|
||||
{filter.actions_count === 0 && (
|
||||
<>
|
||||
<span className="mr-2 ml-2 flex h-3 w-3 relative">
|
||||
<span className="animate-ping inline-flex h-full w-full rounded-full dark:bg-red-500 bg-red-400 opacity-75" />
|
||||
<span
|
||||
className="inline-flex absolute rounded-full h-3 w-3 dark:bg-red-500 bg-red-400"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-500">
|
||||
<Tooltip style={{ width: "350px", fontSize: "12px", textTransform: "none", fontWeight: "normal", borderRadius: "0.375rem", backgroundColor: "#34343A", color: "#fff", opacity: "1", whiteSpace: "pre-wrap", overflow: "hidden", textOverflow: "ellipsis" }} delayShow={100} delayHide={150} data-html={true} place="right" anchorId={`tooltip-actions-${filter.id}`} html="<p>You need to setup an action in the filter otherwise you will not get any snatches.</p>" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -445,6 +683,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
|||
</span>
|
||||
<span className="min-w-fit px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<FilterItemDropdown
|
||||
values={values}
|
||||
filter={filter}
|
||||
onToggle={toggleActive}
|
||||
/>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue