pipeline cards, context menus, presets, settings overhaul
rewrote pipeline as draggable card strip with per-rule config popovers, added right-click menus to pipeline cards, sidebar tree, and file list, preset import/export with BRU format support, new rules (hash, swap, truncate, sanitize, padding, randomize, text editor, folder name, transliterate), settings dialog with all sections, overlay collision containment, tooltips on icon buttons, empty pipeline default
This commit is contained in:
694
ui/src/components/pipeline/PipelineStrip.tsx
Normal file
694
ui/src/components/pipeline/PipelineStrip.tsx
Normal file
@@ -0,0 +1,694 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||
import { useRuleStore, type PipelineRule } from "@/stores/ruleStore";
|
||||
import { useFileStore } from "@/stores/fileStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconRegex,
|
||||
IconEraser,
|
||||
IconTextPlus,
|
||||
IconLetterCase,
|
||||
IconNumbers,
|
||||
IconCalendar,
|
||||
IconArrowsExchange,
|
||||
IconFileTypography,
|
||||
IconTrash,
|
||||
IconX,
|
||||
IconEdit,
|
||||
IconHash,
|
||||
IconFolder,
|
||||
IconLanguage,
|
||||
IconSortAscendingNumbers,
|
||||
IconCut,
|
||||
IconDice,
|
||||
IconArrowsRightLeft,
|
||||
IconShieldCheck,
|
||||
IconCopy,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
} from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ReplaceConfig } from "./configs/ReplaceConfig";
|
||||
import { RegexConfig } from "./configs/RegexConfig";
|
||||
import { RemoveConfig } from "./configs/RemoveConfig";
|
||||
import { AddConfig } from "./configs/AddConfig";
|
||||
import { CaseConfig } from "./configs/CaseConfig";
|
||||
import { NumberingConfig } from "./configs/NumberingConfig";
|
||||
import { ExtensionConfig } from "./configs/ExtensionConfig";
|
||||
import { DateConfig } from "./configs/DateConfig";
|
||||
import { MovePartsConfig } from "./configs/MovePartsConfig";
|
||||
import { TextEditorConfig } from "./configs/TextEditorConfig";
|
||||
import { HashConfig } from "./configs/HashConfig";
|
||||
import { FolderNameConfig } from "./configs/FolderNameConfig";
|
||||
import { TransliterateConfig } from "./configs/TransliterateConfig";
|
||||
import { PaddingConfig } from "./configs/PaddingConfig";
|
||||
import { TruncateConfig } from "./configs/TruncateConfig";
|
||||
import { RandomizeConfig } from "./configs/RandomizeConfig";
|
||||
import { SwapConfig } from "./configs/SwapConfig";
|
||||
import { SanitizeConfig } from "./configs/SanitizeConfig";
|
||||
|
||||
const ruleTypes = [
|
||||
{ id: "add", label: "Add", icon: IconTextPlus, desc: "Insert text" },
|
||||
{ id: "case", label: "Case", icon: IconLetterCase, desc: "Change letter case" },
|
||||
{ id: "date", label: "Date", icon: IconCalendar, desc: "Insert date/time info" },
|
||||
{ id: "text_editor", label: "Editor", icon: IconEdit, desc: "Edit names as text" },
|
||||
{ id: "extension", label: "Extension", icon: IconFileTypography, desc: "Change file extension" },
|
||||
{ id: "folder_name", label: "Folder", icon: IconFolder, desc: "Insert parent folder name" },
|
||||
{ id: "hash", label: "Hash", icon: IconHash, desc: "Add file content hash" },
|
||||
{ id: "move_parts", label: "Move", icon: IconArrowsExchange, desc: "Move or swap parts" },
|
||||
{ id: "numbering", label: "Number", icon: IconNumbers, desc: "Add sequence numbers" },
|
||||
{ id: "padding", label: "Padding", icon: IconSortAscendingNumbers, desc: "Pad numbers in names" },
|
||||
{ id: "randomize", label: "Random", icon: IconDice, desc: "Add random characters" },
|
||||
{ id: "regex", label: "Regex", icon: IconRegex, desc: "Pattern matching" },
|
||||
{ id: "remove", label: "Remove", icon: IconEraser, desc: "Strip characters" },
|
||||
{ id: "replace", label: "Replace", icon: IconReplace, desc: "Find and replace text" },
|
||||
{ id: "sanitize", label: "Sanitize", icon: IconShieldCheck, desc: "Clean up filenames" },
|
||||
{ id: "swap", label: "Swap", icon: IconArrowsRightLeft, desc: "Swap parts around delimiter" },
|
||||
{ id: "transliterate", label: "Translit", icon: IconLanguage, desc: "Non-ASCII to ASCII" },
|
||||
{ id: "truncate", label: "Truncate", icon: IconCut, desc: "Limit filename length" },
|
||||
] as const;
|
||||
|
||||
const ruleIconMap: Record<string, typeof IconReplace> = {
|
||||
replace: IconReplace,
|
||||
regex: IconRegex,
|
||||
remove: IconEraser,
|
||||
add: IconTextPlus,
|
||||
case: IconLetterCase,
|
||||
numbering: IconNumbers,
|
||||
date: IconCalendar,
|
||||
move_parts: IconArrowsExchange,
|
||||
extension: IconFileTypography,
|
||||
text_editor: IconEdit,
|
||||
hash: IconHash,
|
||||
folder_name: IconFolder,
|
||||
transliterate: IconLanguage,
|
||||
padding: IconSortAscendingNumbers,
|
||||
truncate: IconCut,
|
||||
randomize: IconDice,
|
||||
swap: IconArrowsRightLeft,
|
||||
sanitize: IconShieldCheck,
|
||||
};
|
||||
|
||||
function getRuleSummary(rule: Record<string, unknown>): string {
|
||||
const type = rule.type as string;
|
||||
switch (type) {
|
||||
case "replace": {
|
||||
const s = rule.search as string;
|
||||
const r = rule.replace_with as string;
|
||||
if (!s) return "Not configured";
|
||||
return `"${s}" -> "${r}"`;
|
||||
}
|
||||
case "regex": {
|
||||
const p = rule.pattern as string;
|
||||
if (!p) return "Not configured";
|
||||
return `/${p}/`;
|
||||
}
|
||||
case "remove": {
|
||||
const parts: string[] = [];
|
||||
if ((rule.first_n as number) > 0) parts.push(`First ${rule.first_n}`);
|
||||
if ((rule.last_n as number) > 0) parts.push(`Last ${rule.last_n}`);
|
||||
if (rule.crop_before) parts.push("Crop");
|
||||
if (rule.remove_pattern) parts.push("Pattern");
|
||||
const trim = rule.trim as Record<string, boolean> | undefined;
|
||||
if (trim && Object.values(trim).some(Boolean)) parts.push("Trim");
|
||||
return parts.length > 0 ? parts.join(", ") : "Not configured";
|
||||
}
|
||||
case "add": {
|
||||
const parts: string[] = [];
|
||||
if (rule.prefix) parts.push(`"${rule.prefix}"+`);
|
||||
if (rule.suffix) parts.push(`+"${rule.suffix}"`);
|
||||
return parts.length > 0 ? parts.join(" ") : "Not configured";
|
||||
}
|
||||
case "case":
|
||||
return (rule.mode as string) === "Same" ? "Not configured" : (rule.mode as string);
|
||||
case "numbering":
|
||||
return (rule.mode as string) === "None" ? "Not configured" : `${rule.mode} from ${rule.start}`;
|
||||
case "extension":
|
||||
return (rule.mode as string) === "Same" ? "Not configured" : (rule.mode as string);
|
||||
case "date":
|
||||
return (rule.mode as string) === "None" ? "Not configured" : `${rule.source} ${rule.mode}`;
|
||||
case "move_parts": {
|
||||
const len = rule.source_length as number;
|
||||
if (!len) return "Not configured";
|
||||
return `${rule.copy_mode ? "Copy" : "Move"} ${len} chars`;
|
||||
}
|
||||
case "text_editor": {
|
||||
const names = rule.names as string[];
|
||||
if (!names?.length) return "Not configured";
|
||||
return `${names.length} name${names.length !== 1 ? "s" : ""}`;
|
||||
}
|
||||
case "hash":
|
||||
return (rule.mode as string) === "None" ? "Not configured" : `${rule.algorithm} ${rule.mode}`;
|
||||
case "folder_name":
|
||||
return (rule.mode as string) === "None" ? "Not configured" : `Level ${rule.level} ${rule.mode}`;
|
||||
case "transliterate":
|
||||
return "ASCII conversion";
|
||||
case "padding":
|
||||
return `Width ${rule.width}, pad '${rule.pad_char}'`;
|
||||
case "truncate":
|
||||
return `Max ${rule.max_length} chars`;
|
||||
case "randomize":
|
||||
return `${rule.format} ${rule.mode}`;
|
||||
case "swap":
|
||||
return (rule.delimiter as string) ? `Split on "${rule.delimiter}"` : "Not configured";
|
||||
case "sanitize": {
|
||||
const parts: string[] = [];
|
||||
if (rule.illegal_chars) parts.push("Illegal");
|
||||
if ((rule.spaces_to as string) !== "None") parts.push("Spaces");
|
||||
if (rule.strip_diacritics) parts.push("Accents");
|
||||
if (rule.normalize_unicode) parts.push("Unicode");
|
||||
if (rule.strip_zero_width) parts.push("ZW");
|
||||
if (rule.collapse_whitespace) parts.push("Collapse");
|
||||
if (rule.trim_dots_spaces) parts.push("Trim");
|
||||
return parts.length > 0 ? parts.join(", ") : "Not configured";
|
||||
}
|
||||
default:
|
||||
return "...";
|
||||
}
|
||||
}
|
||||
|
||||
function isRuleActive(rule: Record<string, unknown>): boolean {
|
||||
const type = rule.type as string;
|
||||
if (!rule.enabled) return false;
|
||||
switch (type) {
|
||||
case "replace": return !!(rule.search as string);
|
||||
case "regex": return !!(rule.pattern as string);
|
||||
case "remove": {
|
||||
const trim = rule.trim as Record<string, boolean> | undefined;
|
||||
const hasTrim = trim && Object.values(trim).some(Boolean);
|
||||
return (rule.first_n as number) > 0 || (rule.last_n as number) > 0 || (rule.from as number) !== (rule.to as number) || !!(rule.collapse_chars) || !!(rule.remove_pattern) || !!(rule.crop_before) || !!(rule.crop_after) || !!hasTrim;
|
||||
}
|
||||
case "add": return !!(rule.prefix as string) || !!(rule.suffix as string) || !!(rule.insert as string) || !!(rule.inserts as unknown[])?.length;
|
||||
case "case": return (rule.mode as string) !== "Same";
|
||||
case "numbering": return (rule.mode as string) !== "None";
|
||||
case "extension": return (rule.mode as string) !== "Same" || !!(rule.mapping as unknown[])?.length;
|
||||
case "date": return (rule.mode as string) !== "None";
|
||||
case "move_parts": return (rule.source_length as number) > 0;
|
||||
case "text_editor": return !!(rule.names as string[])?.length;
|
||||
case "hash": return (rule.mode as string) !== "None";
|
||||
case "folder_name": return (rule.mode as string) !== "None";
|
||||
case "transliterate": return true;
|
||||
case "padding": return true;
|
||||
case "truncate": return true;
|
||||
case "randomize": return true;
|
||||
case "swap": return !!(rule.delimiter as string);
|
||||
case "sanitize": return !!(rule.illegal_chars) || (rule.spaces_to as string) !== "None" || !!(rule.strip_diacritics) || !!(rule.normalize_unicode) || !!(rule.strip_zero_width) || !!(rule.collapse_whitespace) || !!(rule.trim_dots_spaces);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ConfigContent({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId));
|
||||
if (!rule) return null;
|
||||
switch (rule.config.type) {
|
||||
case "replace": return <ReplaceConfig ruleId={ruleId} />;
|
||||
case "regex": return <RegexConfig ruleId={ruleId} />;
|
||||
case "remove": return <RemoveConfig ruleId={ruleId} />;
|
||||
case "add": return <AddConfig ruleId={ruleId} />;
|
||||
case "case": return <CaseConfig ruleId={ruleId} />;
|
||||
case "numbering": return <NumberingConfig ruleId={ruleId} />;
|
||||
case "extension": return <ExtensionConfig ruleId={ruleId} />;
|
||||
case "date": return <DateConfig ruleId={ruleId} />;
|
||||
case "move_parts": return <MovePartsConfig ruleId={ruleId} />;
|
||||
case "text_editor": return <TextEditorConfig ruleId={ruleId} />;
|
||||
case "hash": return <HashConfig ruleId={ruleId} />;
|
||||
case "folder_name": return <FolderNameConfig ruleId={ruleId} />;
|
||||
case "transliterate": return <TransliterateConfig ruleId={ruleId} />;
|
||||
case "padding": return <PaddingConfig ruleId={ruleId} />;
|
||||
case "truncate": return <TruncateConfig ruleId={ruleId} />;
|
||||
case "randomize": return <RandomizeConfig ruleId={ruleId} />;
|
||||
case "swap": return <SwapConfig ruleId={ruleId} />;
|
||||
case "sanitize": return <SanitizeConfig ruleId={ruleId} />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function PopoverArrowWithBorder() {
|
||||
return (
|
||||
<div className="absolute left-1/2 -translate-x-1/2" style={{ bottom: -14 }}>
|
||||
<svg width={32} height={14} viewBox="0 0 32 14">
|
||||
<polygon points="0,0 16,14 32,0" className="fill-border" />
|
||||
</svg>
|
||||
<svg width={30} height={13} viewBox="0 0 30 13" className="absolute left-[1px]" style={{ top: 0 }}>
|
||||
<polygon points="0,0 15,13 30,0" className="fill-popover" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; index: number; onScrollToCenter: (el: HTMLElement) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tooltipsReady, setTooltipsReady] = useState(false);
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
const removeRule = useRuleStore((s) => s.removeRule);
|
||||
const resetRule = useRuleStore((s) => s.resetRule);
|
||||
const duplicateRule = useRuleStore((s) => s.duplicateRule);
|
||||
const reorderPipeline = useRuleStore((s) => s.reorderPipeline);
|
||||
const pipeline = useRuleStore((s) => s.pipeline);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setSortableRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: rule.id });
|
||||
|
||||
const setNodeRef = useCallback((node: HTMLElement | null) => {
|
||||
setSortableRef(node);
|
||||
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node as HTMLDivElement | null;
|
||||
}, [setSortableRef]);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTooltipsReady(false);
|
||||
const timer = setTimeout(() => setTooltipsReady(true), 700);
|
||||
if (cardRef.current) onScrollToCenter(cardRef.current);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setTooltipsReady(false);
|
||||
}
|
||||
}, [open, onScrollToCenter]);
|
||||
|
||||
const Icon = ruleIconMap[rule.config.type] || IconReplace;
|
||||
const active = isRuleActive(rule.config as unknown as Record<string, unknown>);
|
||||
const summary = getRuleSummary(rule.config as unknown as Record<string, unknown>);
|
||||
const typeInfo = ruleTypes.find((r) => r.id === rule.config.type);
|
||||
const pipelineIndex = pipeline.findIndex((r) => r.id === rule.id);
|
||||
const canMoveLeft = pipelineIndex > 0;
|
||||
const canMoveRight = pipelineIndex < pipeline.length - 1;
|
||||
const stateLabel = !rule.config.enabled ? "disabled" : active ? "active" : "inactive";
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div>
|
||||
<PopoverTrigger asChild>
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
whileHover={{ y: -3, transition: { duration: 0.15 } }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
aria-label={`${typeInfo?.label || rule.config.type} rule, step ${index + 1}, ${stateLabel}. Press Space to reorder.`}
|
||||
aria-roledescription="sortable"
|
||||
className={cn(
|
||||
"flex items-start gap-3 px-4 py-4 rounded-xl border text-left shrink-0 transition-colors min-w-[200px] max-w-[260px]",
|
||||
"cursor-grab group relative overflow-hidden",
|
||||
isDragging && "cursor-grabbing",
|
||||
open && "ring-2 ring-primary/40 border-primary/50 shadow-lg shadow-primary/5",
|
||||
active && rule.config.enabled
|
||||
? "border-primary/25 bg-primary/[0.04]"
|
||||
: "border-border bg-card hover:bg-muted/50",
|
||||
!rule.config.enabled && "opacity-45",
|
||||
)}
|
||||
>
|
||||
{/* step number - bottom right */}
|
||||
<div className="absolute bottom-2.5 right-3 text-[15px] font-mono text-muted-foreground/40">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mt-0.5 p-2.5 rounded-lg transition-colors relative",
|
||||
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<Icon size={22} stroke={1.5} />
|
||||
{/* non-color active indicator - small filled dot */}
|
||||
{active && rule.config.enabled && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-primary border border-card" aria-hidden="true" />
|
||||
)}
|
||||
{/* non-color disabled indicator - diagonal line */}
|
||||
{!rule.config.enabled && (
|
||||
<span className="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
||||
<span className="w-full h-0.5 bg-muted-foreground/60 rotate-[-45deg]" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"text-sm font-medium leading-tight",
|
||||
active ? "text-foreground" : "text-muted-foreground"
|
||||
)}>
|
||||
{typeInfo?.label || rule.config.type}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-tight truncate">
|
||||
{summary}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={rule.config.enabled}
|
||||
onCheckedChange={(checked) => updateRule(rule.id, { enabled: checked })}
|
||||
className="mt-1 scale-75 origin-top-right"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`${rule.config.enabled ? "Disable" : "Enable"} ${typeInfo?.label || rule.config.type} rule`}
|
||||
/>
|
||||
</motion.button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={24}
|
||||
onOpenAutoFocus={(e) => {
|
||||
// focus first interactive element inside the config panel
|
||||
e.preventDefault();
|
||||
requestAnimationFrame(() => {
|
||||
const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector<HTMLElement>(
|
||||
".p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']"
|
||||
);
|
||||
el?.focus();
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
rule.config.type === "text_editor" ? "w-[560px]" : "w-[440px]",
|
||||
"p-0 shadow-xl shadow-black/10 dark:shadow-black/30 !overflow-visible relative",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-1.5 rounded-md bg-primary/10">
|
||||
<Icon size={18} stroke={1.5} className="text-primary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{typeInfo?.label || rule.config.type}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Step {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{tooltipsReady ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => resetRule(rule.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset to defaults"
|
||||
>
|
||||
<IconEraser size={20} stroke={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Reset to defaults</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => resetRule(rule.id)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset to defaults"
|
||||
>
|
||||
<IconEraser size={20} stroke={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{tooltipsReady ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => { removeRule(rule.id); setOpen(false); }}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label="Remove step"
|
||||
>
|
||||
<IconTrash size={20} stroke={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Remove step</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => { removeRule(rule.id); setOpen(false); }}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label="Remove step"
|
||||
>
|
||||
<IconTrash size={20} stroke={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{tooltipsReady ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={20} stroke={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={20} stroke={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="p-4"
|
||||
>
|
||||
<ConfigContent ruleId={rule.id} />
|
||||
</motion.div>
|
||||
<PopoverArrowWithBorder />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem
|
||||
onClick={() => updateRule(rule.id, { enabled: !rule.config.enabled })}
|
||||
className="gap-2 text-[13px]"
|
||||
>
|
||||
{rule.config.enabled
|
||||
? <><IconPlayerPause size={15} stroke={1.5} /> Disable</>
|
||||
: <><IconPlayerPlay size={15} stroke={1.5} /> Enable</>
|
||||
}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => resetRule(rule.id)} className="gap-2 text-[13px]">
|
||||
<IconEraser size={15} stroke={1.5} /> Reset to defaults
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => duplicateRule(rule.id)} className="gap-2 text-[13px]">
|
||||
<IconCopy size={15} stroke={1.5} /> Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => canMoveLeft && reorderPipeline(pipelineIndex, pipelineIndex - 1)}
|
||||
disabled={!canMoveLeft}
|
||||
className="gap-2 text-[13px]"
|
||||
>
|
||||
<IconChevronLeft size={15} stroke={1.5} /> Move left
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => canMoveRight && reorderPipeline(pipelineIndex, pipelineIndex + 1)}
|
||||
disabled={!canMoveRight}
|
||||
className="gap-2 text-[13px]"
|
||||
>
|
||||
<IconChevronRight size={15} stroke={1.5} /> Move right
|
||||
</ContextMenuItem>
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => removeRule(rule.id)}
|
||||
className="gap-2 text-[13px] text-destructive focus:text-destructive"
|
||||
>
|
||||
<IconTrash size={15} stroke={1.5} /> Remove
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PipelineStrip() {
|
||||
const pipeline = useRuleStore((s) => s.pipeline);
|
||||
const addRule = useRuleStore((s) => s.addRule);
|
||||
const reorderPipeline = useRuleStore((s) => s.reorderPipeline);
|
||||
const requestPreview = useRuleStore((s) => s.requestPreview);
|
||||
const currentPath = useFileStore((s) => s.currentPath);
|
||||
const sortedFilePaths = useFileStore((s) => s.sortedFilePaths);
|
||||
const showDisabledRules = useSettingsStore((s) => s.showDisabledRules);
|
||||
|
||||
const visiblePipeline = showDisabledRules === "hidden"
|
||||
? pipeline.filter((r) => r.config.enabled)
|
||||
: pipeline;
|
||||
|
||||
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||
|
||||
const scrollToCenter = useCallback((el: HTMLElement) => {
|
||||
const instance = osRef.current?.osInstance();
|
||||
if (!instance) return;
|
||||
const viewport = instance.elements().viewport;
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const elCenter = elRect.left + elRect.width / 2;
|
||||
const viewportCenter = viewportRect.left + viewportRect.width / 2;
|
||||
const scrollLeft = viewport.scrollLeft + (elCenter - viewportCenter);
|
||||
viewport.scrollTo({ left: scrollLeft, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = pipeline.findIndex((r) => r.id === active.id);
|
||||
const newIndex = pipeline.findIndex((r) => r.id === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
reorderPipeline(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPath) {
|
||||
requestPreview(currentPath);
|
||||
}
|
||||
}, [pipeline, currentPath, sortedFilePaths, requestPreview]);
|
||||
|
||||
const isEmpty = visiblePipeline.length === 0;
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/30 h-[104px]" role="region" aria-label="Rename pipeline">
|
||||
<OverlayScrollbarsComponent
|
||||
ref={osRef}
|
||||
options={{ overflow: { x: isEmpty ? "hidden" : "scroll", y: "hidden" }, scrollbars: { autoHide: "move", autoHideDelay: 600 } }}
|
||||
className="px-4 py-4 h-full"
|
||||
>
|
||||
<div className={cn("flex items-center gap-3 h-full", isEmpty ? "justify-center" : "w-max")}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={visiblePipeline.map((r) => r.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visiblePipeline.map((rule, index) => (
|
||||
<PipelineCard key={rule.id} rule={rule} index={index} onScrollToCenter={scrollToCenter} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div className="shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="shrink-0 gap-2 text-sm border-dashed hover:border-primary hover:text-primary transition-colors h-auto py-4 px-5"
|
||||
>
|
||||
<IconPlus size={18} stroke={1.5} />
|
||||
Add Rule
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="top" className="w-[420px] p-2">
|
||||
<DropdownMenuLabel className="px-2">Add a rule to the pipeline</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
{ruleTypes.map((rt) => (
|
||||
<DropdownMenuItem
|
||||
key={rt.id}
|
||||
className="gap-2 py-2 px-2 cursor-pointer rounded-md"
|
||||
onClick={() => addRule(rt.id)}
|
||||
>
|
||||
<div className="p-1 rounded-md bg-muted shrink-0">
|
||||
<rt.icon size={14} stroke={1.5} />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[13px] font-medium leading-tight">{rt.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-tight truncate">{rt.desc}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
ui/src/components/pipeline/configs/AddConfig.tsx
Normal file
95
ui/src/components/pipeline/configs/AddConfig.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import type { AddConfig as AddConfigType } from "@/types/rules";
|
||||
|
||||
export function AddConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as AddConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<AddConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
const addInsert = () => {
|
||||
update({ inserts: [...rule.inserts, { position: 0, text: "" }] });
|
||||
};
|
||||
|
||||
const removeInsert = (idx: number) => {
|
||||
update({ inserts: rule.inserts.filter((_, i) => i !== idx) });
|
||||
};
|
||||
|
||||
const updateInsert = (idx: number, changes: Partial<{ position: number; text: string }>) => {
|
||||
update({
|
||||
inserts: rule.inserts.map((ins, i) => (i === idx ? { ...ins, ...changes } : ins)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Prefix</span>
|
||||
<Input
|
||||
value={rule.prefix}
|
||||
onChange={(e) => update({ prefix: e.target.value })}
|
||||
placeholder="Add before..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Suffix</span>
|
||||
<Input
|
||||
value={rule.suffix}
|
||||
onChange={(e) => update({ suffix: e.target.value })}
|
||||
placeholder="Add after..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span className="text-xs text-muted-foreground">Insert text</span>
|
||||
<Input
|
||||
value={rule.insert}
|
||||
onChange={(e) => update({ insert: e.target.value })}
|
||||
placeholder="Text to insert..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col w-20 gap-1">
|
||||
<span className="text-xs text-muted-foreground">At position</span>
|
||||
<NumberInput value={rule.insert_at} onChange={(v) => update({ insert_at: v })} min={0} />
|
||||
</label>
|
||||
</div>
|
||||
{rule.inserts.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Extra inserts</span>
|
||||
{rule.inserts.map((ins, idx) => (
|
||||
<div key={idx} className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={ins.text}
|
||||
onChange={(e) => updateInsert(idx, { text: e.target.value })}
|
||||
placeholder="Text..."
|
||||
className="h-7 text-xs font-mono flex-1"
|
||||
/>
|
||||
<NumberInput value={ins.position} onChange={(v) => updateInsert(idx, { position: v })} min={0} />
|
||||
<button onClick={() => removeInsert(idx)} className="text-muted-foreground hover:text-destructive p-0.5">
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.word_space} onCheckedChange={(c) => update({ word_space: !!c })} />
|
||||
Add spaces between words
|
||||
</label>
|
||||
<button onClick={addInsert} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-auto">
|
||||
<IconPlus size={14} /> Insert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
ui/src/components/pipeline/configs/CaseConfig.tsx
Normal file
65
ui/src/components/pipeline/configs/CaseConfig.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { CaseConfig as CaseConfigType } from "@/types/rules";
|
||||
|
||||
const basicModes = [
|
||||
{ value: "Same", label: "Same" },
|
||||
{ value: "Upper", label: "UPPER" },
|
||||
{ value: "Lower", label: "lower" },
|
||||
{ value: "Title", label: "Title" },
|
||||
{ value: "Sentence", label: "Sent." },
|
||||
{ value: "SmartTitle", label: "Smart" },
|
||||
{ value: "Invert", label: "iNVERT" },
|
||||
{ value: "Random", label: "rAnD" },
|
||||
] as const;
|
||||
|
||||
const devModes = [
|
||||
{ value: "CamelCase", label: "camelCase" },
|
||||
{ value: "PascalCase", label: "Pascal" },
|
||||
{ value: "SnakeCase", label: "snake_case" },
|
||||
{ value: "KebabCase", label: "kebab-case" },
|
||||
{ value: "DotCase", label: "dot.case" },
|
||||
] as const;
|
||||
|
||||
export function CaseConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as CaseConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<CaseConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
const devValues = ["CamelCase", "PascalCase", "SnakeCase", "KebabCase", "DotCase"];
|
||||
const isDevMode = devValues.includes(rule.mode);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Text case</span>
|
||||
<SegmentedControl
|
||||
value={isDevMode ? ("" as typeof rule.mode) : rule.mode}
|
||||
onChange={(m) => update({ mode: m })}
|
||||
options={basicModes}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Developer case</span>
|
||||
<SegmentedControl
|
||||
value={isDevMode ? rule.mode : ("" as typeof rule.mode)}
|
||||
onChange={(m) => update({ mode: m })}
|
||||
options={devModes}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Exceptions (comma-separated)</span>
|
||||
<Input
|
||||
value={rule.exceptions}
|
||||
onChange={(e) => update({ exceptions: e.target.value })}
|
||||
placeholder="e.g. USB, HTML, API"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
ui/src/components/pipeline/configs/DateConfig.tsx
Normal file
86
ui/src/components/pipeline/configs/DateConfig.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { DateConfig as DateConfigType } from "@/types/rules";
|
||||
|
||||
const dateModes = [
|
||||
{ value: "None", label: "None" },
|
||||
{ value: "Prefix", label: "Prefix" },
|
||||
{ value: "Suffix", label: "Suffix" },
|
||||
{ value: "Insert", label: "Insert" },
|
||||
] as const;
|
||||
|
||||
const dateSources = [
|
||||
{ value: "Created", label: "Created" },
|
||||
{ value: "Modified", label: "Modified" },
|
||||
{ value: "Accessed", label: "Accessed" },
|
||||
{ value: "ExifTaken", label: "EXIF" },
|
||||
{ value: "Current", label: "Now" },
|
||||
] as const;
|
||||
|
||||
const dateFormats = [
|
||||
{ value: "YMD", label: "Y-M-D" },
|
||||
{ value: "DMY", label: "D-M-Y" },
|
||||
{ value: "MDY", label: "M-D-Y" },
|
||||
{ value: "YM", label: "Y-M" },
|
||||
{ value: "MY", label: "M-Y" },
|
||||
{ value: "Y", label: "Year" },
|
||||
] as const;
|
||||
|
||||
export function DateConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as DateConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<DateConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Position</span>
|
||||
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={dateModes} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Source</span>
|
||||
<SegmentedControl value={rule.source} onChange={(s) => update({ source: s })} options={dateSources} size="sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Format</span>
|
||||
<SegmentedControl value={rule.format} onChange={(f) => update({ format: f })} options={dateFormats} size="sm" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Date separator</span>
|
||||
<Input
|
||||
value={rule.separator}
|
||||
onChange={(e) => update({ separator: e.target.value })}
|
||||
placeholder="-"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Name separator</span>
|
||||
<Input
|
||||
value={rule.segment_separator}
|
||||
onChange={(e) => update({ segment_separator: e.target.value })}
|
||||
placeholder="_"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Custom format (overrides preset)</span>
|
||||
<Input
|
||||
value={rule.custom_format || ""}
|
||||
onChange={(e) => update({ custom_format: e.target.value || null })}
|
||||
placeholder="%Y-%m-%d_%H%M%S"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.include_time} onCheckedChange={(c) => update({ include_time: !!c })} />
|
||||
Include time
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
ui/src/components/pipeline/configs/ExtensionConfig.tsx
Normal file
98
ui/src/components/pipeline/configs/ExtensionConfig.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import type { ExtensionConfig as ExtensionConfigType } from "@/types/rules";
|
||||
|
||||
const extModes = [
|
||||
{ value: "Same", label: "Same" },
|
||||
{ value: "Lower", label: "lower" },
|
||||
{ value: "Upper", label: "UPPER" },
|
||||
{ value: "Title", label: "Title" },
|
||||
{ value: "Extra", label: "Extra" },
|
||||
{ value: "Remove", label: "Remove" },
|
||||
{ value: "Fixed", label: "Fixed" },
|
||||
] as const;
|
||||
|
||||
export function ExtensionConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as ExtensionConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<ExtensionConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
const mappings = rule.mapping || [];
|
||||
|
||||
const addMapping = () => {
|
||||
update({ mapping: [...mappings, ["", ""]] });
|
||||
};
|
||||
|
||||
const removeMapping = (idx: number) => {
|
||||
const next = mappings.filter((_, i) => i !== idx);
|
||||
update({ mapping: next.length > 0 ? next : null });
|
||||
};
|
||||
|
||||
const updateMapping = (idx: number, pos: 0 | 1, val: string) => {
|
||||
const next = mappings.map((m, i) => {
|
||||
if (i !== idx) return m;
|
||||
const copy: [string, string] = [...m];
|
||||
copy[pos] = val;
|
||||
return copy;
|
||||
});
|
||||
update({ mapping: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Mode</span>
|
||||
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={extModes} size="sm" />
|
||||
</div>
|
||||
{rule.mode === "Fixed" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">New extension</span>
|
||||
<Input
|
||||
value={rule.fixed_value}
|
||||
onChange={(e) => update({ fixed_value: e.target.value })}
|
||||
placeholder="e.g. txt, jpg, png"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{mappings.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Extension mappings</span>
|
||||
{mappings.map((m, idx) => (
|
||||
<div key={idx} className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={m[0]}
|
||||
onChange={(e) => updateMapping(idx, 0, e.target.value)}
|
||||
placeholder="From..."
|
||||
className="h-7 text-xs font-mono flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{"->"}</span>
|
||||
<Input
|
||||
value={m[1]}
|
||||
onChange={(e) => updateMapping(idx, 1, e.target.value)}
|
||||
placeholder="To..."
|
||||
className="h-7 text-xs font-mono flex-1"
|
||||
/>
|
||||
<button onClick={() => removeMapping(idx)} className="text-muted-foreground hover:text-destructive p-0.5">
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.multi_extension} onCheckedChange={(c) => update({ multi_extension: !!c })} />
|
||||
Multi-extension (e.g. .tar.gz)
|
||||
</label>
|
||||
<button onClick={addMapping} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-auto">
|
||||
<IconPlus size={14} /> Mapping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
ui/src/components/pipeline/configs/FolderNameConfig.tsx
Normal file
38
ui/src/components/pipeline/configs/FolderNameConfig.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { FolderNameConfig as FolderNameConfigType } from "@/types/rules";
|
||||
|
||||
const folderModes = [
|
||||
{ value: "None", label: "None" },
|
||||
{ value: "Prefix", label: "Prefix" },
|
||||
{ value: "Suffix", label: "Suffix" },
|
||||
{ value: "Replace", label: "Replace" },
|
||||
] as const;
|
||||
|
||||
export function FolderNameConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as FolderNameConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<FolderNameConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Position</span>
|
||||
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={folderModes} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Folder level (1 = parent)</span>
|
||||
<NumberInput value={rule.level} onChange={(v) => update({ level: v })} min={1} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Separator</span>
|
||||
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
ui/src/components/pipeline/configs/HashConfig.tsx
Normal file
53
ui/src/components/pipeline/configs/HashConfig.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { HashConfig as HashConfigType } from "@/types/rules";
|
||||
|
||||
const hashModes = [
|
||||
{ value: "None", label: "None" },
|
||||
{ value: "Prefix", label: "Prefix" },
|
||||
{ value: "Suffix", label: "Suffix" },
|
||||
{ value: "Replace", label: "Replace" },
|
||||
] as const;
|
||||
|
||||
const algorithms = [
|
||||
{ value: "MD5", label: "MD5" },
|
||||
{ value: "SHA1", label: "SHA1" },
|
||||
{ value: "SHA256", label: "SHA256" },
|
||||
] as const;
|
||||
|
||||
export function HashConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as HashConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<HashConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Position</span>
|
||||
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={hashModes} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Algorithm</span>
|
||||
<SegmentedControl value={rule.algorithm} onChange={(a) => update({ algorithm: a })} options={algorithms} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Hash length (0 = full)</span>
|
||||
<NumberInput value={rule.length} onChange={(v) => update({ length: v })} min={0} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Separator</span>
|
||||
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.uppercase} onCheckedChange={(c) => update({ uppercase: !!c })} />
|
||||
Uppercase hex
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
ui/src/components/pipeline/configs/MovePartsConfig.tsx
Normal file
83
ui/src/components/pipeline/configs/MovePartsConfig.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { MovePartsConfig as MovePartsConfigType } from "@/types/rules";
|
||||
|
||||
const selectionModes = [
|
||||
{ value: "Chars", label: "Chars" },
|
||||
{ value: "Words", label: "Words" },
|
||||
{ value: "Regex", label: "Regex" },
|
||||
] as const;
|
||||
|
||||
const targetModes = [
|
||||
{ value: "None", label: "None" },
|
||||
{ value: "Start", label: "Start" },
|
||||
{ value: "End", label: "End" },
|
||||
] as const;
|
||||
|
||||
export function MovePartsConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as MovePartsConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<MovePartsConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
const targetValue = typeof rule.target === "string" ? rule.target : "Position";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Selection mode</span>
|
||||
<SegmentedControl value={rule.selection_mode} onChange={(m) => update({ selection_mode: m })} options={selectionModes} />
|
||||
</div>
|
||||
{rule.selection_mode === "Regex" ? (
|
||||
<div className="grid grid-cols-[1fr_80px] gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Pattern</span>
|
||||
<Input
|
||||
value={rule.regex_pattern || ""}
|
||||
onChange={(e) => update({ regex_pattern: e.target.value || null })}
|
||||
placeholder="Regex..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Group</span>
|
||||
<NumberInput value={rule.regex_group} onChange={(v) => update({ regex_group: v })} min={0} />
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">From {rule.selection_mode === "Words" ? "word" : "pos"}</span>
|
||||
<NumberInput value={rule.source_from} onChange={(v) => update({ source_from: v })} min={0} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Length</span>
|
||||
<NumberInput value={rule.source_length} onChange={(v) => update({ source_length: v })} min={0} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Target</span>
|
||||
<SegmentedControl value={targetValue} onChange={(t) => update({ target: t as "None" | "Start" | "End" })} options={targetModes} />
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Separator</span>
|
||||
<Input
|
||||
value={rule.separator}
|
||||
onChange={(e) => update({ separator: e.target.value })}
|
||||
placeholder=""
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.copy_mode} onCheckedChange={(c) => update({ copy_mode: !!c })} />
|
||||
Copy (keep original)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
ui/src/components/pipeline/configs/NumberingConfig.tsx
Normal file
80
ui/src/components/pipeline/configs/NumberingConfig.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { NumberingConfig as NumberingConfigType } from "@/types/rules";
|
||||
|
||||
const numberModes = [
|
||||
{ value: "None", label: "None" },
|
||||
{ value: "Prefix", label: "Prefix" },
|
||||
{ value: "Suffix", label: "Suffix" },
|
||||
{ value: "Both", label: "Both" },
|
||||
{ value: "Insert", label: "Insert" },
|
||||
] as const;
|
||||
|
||||
const bases = [
|
||||
{ value: "Decimal", label: "Dec" },
|
||||
{ value: "Hex", label: "Hex" },
|
||||
{ value: "Octal", label: "Oct" },
|
||||
{ value: "Binary", label: "Bin" },
|
||||
{ value: "Alpha", label: "Alpha" },
|
||||
{ value: "Roman", label: "Roman" },
|
||||
] as const;
|
||||
|
||||
export function NumberingConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<NumberingConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Position</span>
|
||||
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={numberModes} />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Start</span>
|
||||
<NumberInput value={rule.start} onChange={(v) => update({ start: v })} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Step</span>
|
||||
<NumberInput value={rule.increment} onChange={(v) => update({ increment: v })} min={1} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Padding</span>
|
||||
<NumberInput value={rule.padding} onChange={(v) => update({ padding: v })} min={1} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Separator</span>
|
||||
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Base</span>
|
||||
<SegmentedControl value={rule.base} onChange={(b) => update({ base: b })} options={bases} size="sm" />
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Custom format (overrides base)</span>
|
||||
<Input
|
||||
value={rule.custom_format || ""}
|
||||
onChange={(e) => update({ custom_format: e.target.value || null })}
|
||||
placeholder="e.g. {n:03}"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.per_folder} onCheckedChange={(c) => update({ per_folder: !!c })} />
|
||||
Per folder
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} />
|
||||
Reverse order
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
ui/src/components/pipeline/configs/PaddingConfig.tsx
Normal file
36
ui/src/components/pipeline/configs/PaddingConfig.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { PaddingConfig as PaddingConfigType } from "@/types/rules";
|
||||
|
||||
export function PaddingConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as PaddingConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<PaddingConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Width</span>
|
||||
<NumberInput value={rule.width} onChange={(v) => update({ width: v })} min={1} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Pad character</span>
|
||||
<Input
|
||||
value={rule.pad_char}
|
||||
onChange={(e) => update({ pad_char: e.target.value.slice(0, 1) || "0" })}
|
||||
maxLength={1}
|
||||
className="h-8 text-xs font-mono text-center"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.pad_all} onCheckedChange={(c) => update({ pad_all: !!c })} />
|
||||
Pad all numbers (not just first)
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
ui/src/components/pipeline/configs/RandomizeConfig.tsx
Normal file
52
ui/src/components/pipeline/configs/RandomizeConfig.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { RandomizeConfig as RandomizeConfigType } from "@/types/rules";
|
||||
|
||||
const randomModes = [
|
||||
{ value: "Replace", label: "Replace" },
|
||||
{ value: "Prefix", label: "Prefix" },
|
||||
{ value: "Suffix", label: "Suffix" },
|
||||
] as const;
|
||||
|
||||
const randomFormats = [
|
||||
{ value: "Hex", label: "Hex" },
|
||||
{ value: "Alpha", label: "Alpha" },
|
||||
{ value: "AlphaNum", label: "AlphaNum" },
|
||||
{ value: "UUID", label: "UUID" },
|
||||
] as const;
|
||||
|
||||
export function RandomizeConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RandomizeConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<RandomizeConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Position</span>
|
||||
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={randomModes} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Format</span>
|
||||
<SegmentedControl value={rule.format} onChange={(f) => update({ format: f })} options={randomFormats} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{rule.format !== "UUID" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Length</span>
|
||||
<NumberInput value={rule.length} onChange={(v) => update({ length: v })} min={1} />
|
||||
</label>
|
||||
)}
|
||||
{rule.mode !== "Replace" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Separator</span>
|
||||
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
ui/src/components/pipeline/configs/RegexConfig.tsx
Normal file
47
ui/src/components/pipeline/configs/RegexConfig.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { RegexConfig as RegexConfigType } from "@/types/rules";
|
||||
|
||||
export function RegexConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RegexConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<RegexConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span className="text-xs text-muted-foreground">Pattern</span>
|
||||
<Input
|
||||
value={rule.pattern}
|
||||
onChange={(e) => update({ pattern: e.target.value })}
|
||||
placeholder="Regex pattern..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span className="text-xs text-muted-foreground">Replace with</span>
|
||||
<Input
|
||||
value={rule.replace_with}
|
||||
onChange={(e) => update({ replace_with: e.target.value })}
|
||||
placeholder="Replacement..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.case_insensitive} onCheckedChange={(c) => update({ case_insensitive: !!c })} />
|
||||
Case insensitive
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Match limit</span>
|
||||
<NumberInput value={rule.match_limit ?? 0} onChange={(v) => update({ match_limit: v || null })} min={0} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
ui/src/components/pipeline/configs/RemoveConfig.tsx
Normal file
113
ui/src/components/pipeline/configs/RemoveConfig.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { RemoveConfig as RemoveConfigType } from "@/types/rules";
|
||||
|
||||
const removeModes = [
|
||||
{ value: "Chars", label: "Chars" },
|
||||
{ value: "Words", label: "Words" },
|
||||
] as const;
|
||||
|
||||
export function RemoveConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RemoveConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<RemoveConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
const label = rule.mode === "Words" ? "words" : "chars";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Mode</span>
|
||||
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={removeModes} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">First N {label}</span>
|
||||
<NumberInput value={rule.first_n} onChange={(v) => update({ first_n: v })} min={0} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Last N {label}</span>
|
||||
<NumberInput value={rule.last_n} onChange={(v) => update({ last_n: v })} min={0} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">From position</span>
|
||||
<NumberInput value={rule.from} onChange={(v) => update({ from: v })} min={0} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">To position</span>
|
||||
<NumberInput value={rule.to} onChange={(v) => update({ to: v })} min={0} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Crop before</span>
|
||||
<Input
|
||||
value={rule.crop_before || ""}
|
||||
onChange={(e) => update({ crop_before: e.target.value || null })}
|
||||
placeholder="Text..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Crop after</span>
|
||||
<Input
|
||||
value={rule.crop_after || ""}
|
||||
onChange={(e) => update({ crop_after: e.target.value || null })}
|
||||
placeholder="Text..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.trim.digits} onCheckedChange={(c) => update({ trim: { ...rule.trim, digits: !!c } })} />
|
||||
Digits
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.trim.spaces} onCheckedChange={(c) => update({ trim: { ...rule.trim, spaces: !!c } })} />
|
||||
Spaces
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.trim.symbols} onCheckedChange={(c) => update({ trim: { ...rule.trim, symbols: !!c } })} />
|
||||
Symbols
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.trim.accents} onCheckedChange={(c) => update({ trim: { ...rule.trim, accents: !!c } })} />
|
||||
Accents
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.trim.lead_dots} onCheckedChange={(c) => update({ trim: { ...rule.trim, lead_dots: !!c } })} />
|
||||
Leading dots
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Collapse chars</span>
|
||||
<Input
|
||||
value={rule.collapse_chars || ""}
|
||||
onChange={(e) => update({ collapse_chars: e.target.value || null })}
|
||||
placeholder="e.g. -_"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Remove pattern</span>
|
||||
<Input
|
||||
value={rule.remove_pattern || ""}
|
||||
onChange={(e) => update({ remove_pattern: e.target.value || null })}
|
||||
placeholder="Regex..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.allow_empty} onCheckedChange={(c) => update({ allow_empty: !!c })} />
|
||||
Allow removing everything
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
ui/src/components/pipeline/configs/ReplaceConfig.tsx
Normal file
65
ui/src/components/pipeline/configs/ReplaceConfig.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { ReplaceConfig as ReplaceConfigType } from "@/types/rules";
|
||||
|
||||
export function ReplaceConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as ReplaceConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<ReplaceConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span className="text-xs text-muted-foreground">Find</span>
|
||||
<Input
|
||||
value={rule.search}
|
||||
onChange={(e) => update({ search: e.target.value })}
|
||||
placeholder="Text to find..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span className="text-xs text-muted-foreground">Replace with</span>
|
||||
<Input
|
||||
value={rule.replace_with}
|
||||
onChange={(e) => update({ replace_with: e.target.value })}
|
||||
placeholder="Replacement..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.match_case} onCheckedChange={(c) => update({ match_case: !!c })} />
|
||||
Match case
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.first_only} onCheckedChange={(c) => update({ first_only: !!c })} />
|
||||
First only
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.use_regex} onCheckedChange={(c) => update({ use_regex: !!c })} />
|
||||
Regex
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Scope start</span>
|
||||
<NumberInput value={rule.scope_start ?? 0} onChange={(v) => update({ scope_start: v || null })} min={0} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Scope end</span>
|
||||
<NumberInput value={rule.scope_end ?? 0} onChange={(v) => update({ scope_end: v || null })} min={0} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Occurrence</span>
|
||||
<NumberInput value={rule.occurrence ?? 0} onChange={(v) => update({ occurrence: v || null })} min={0} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
ui/src/components/pipeline/configs/SanitizeConfig.tsx
Normal file
53
ui/src/components/pipeline/configs/SanitizeConfig.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { SanitizeConfig as SanitizeConfigType } from "@/types/rules";
|
||||
|
||||
const spaceModes = [
|
||||
{ value: "None", label: "Keep" },
|
||||
{ value: "Underscores", label: "_" },
|
||||
{ value: "Dashes", label: "-" },
|
||||
{ value: "Dots", label: "." },
|
||||
] as const;
|
||||
|
||||
export function SanitizeConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as SanitizeConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<SanitizeConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Spaces</span>
|
||||
<SegmentedControl value={rule.spaces_to} onChange={(s) => update({ spaces_to: s })} options={spaceModes} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.illegal_chars} onCheckedChange={(c) => update({ illegal_chars: !!c })} />
|
||||
Remove illegal characters
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.trim_dots_spaces} onCheckedChange={(c) => update({ trim_dots_spaces: !!c })} />
|
||||
Trim leading/trailing dots and spaces
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.collapse_whitespace} onCheckedChange={(c) => update({ collapse_whitespace: !!c })} />
|
||||
Collapse repeated separators
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.strip_diacritics} onCheckedChange={(c) => update({ strip_diacritics: !!c })} />
|
||||
Strip diacritics/accents
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.strip_zero_width} onCheckedChange={(c) => update({ strip_zero_width: !!c })} />
|
||||
Strip zero-width characters
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||
<Checkbox checked={rule.normalize_unicode} onCheckedChange={(c) => update({ normalize_unicode: !!c })} />
|
||||
Normalize unicode (NFC)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
ui/src/components/pipeline/configs/SwapConfig.tsx
Normal file
45
ui/src/components/pipeline/configs/SwapConfig.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { SwapConfig as SwapConfigType } from "@/types/rules";
|
||||
|
||||
const swapOccurrences = [
|
||||
{ value: "First", label: "First" },
|
||||
{ value: "Last", label: "Last" },
|
||||
] as const;
|
||||
|
||||
export function SwapConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as SwapConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<SwapConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Split on</span>
|
||||
<Input
|
||||
value={rule.delimiter}
|
||||
onChange={(e) => update({ delimiter: e.target.value })}
|
||||
placeholder="e.g. , or -"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Rejoin with (blank = same)</span>
|
||||
<Input
|
||||
value={rule.new_delimiter ?? ""}
|
||||
onChange={(e) => update({ new_delimiter: e.target.value || null })}
|
||||
placeholder="Same as split"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Split at occurrence</span>
|
||||
<SegmentedControl value={rule.occurrence} onChange={(o) => update({ occurrence: o })} options={swapOccurrences} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
ui/src/components/pipeline/configs/TextEditorConfig.tsx
Normal file
85
ui/src/components/pipeline/configs/TextEditorConfig.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { useFileStore } from "@/stores/fileStore";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules";
|
||||
|
||||
export function TextEditorConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TextEditorConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
const sortedFilePaths = useFileStore((s) => s.sortedFilePaths);
|
||||
const selectedFiles = useFileStore((s) => s.selectedFiles);
|
||||
const files = useFileStore((s) => s.files);
|
||||
|
||||
const [text, setText] = useState("");
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// get selected file stems in display order
|
||||
const fileMap = new Map(files.map((f) => [f.path, f]));
|
||||
const selectedStems = sortedFilePaths
|
||||
.filter((p) => selectedFiles.has(p))
|
||||
.map((p) => fileMap.get(p)?.stem ?? "");
|
||||
|
||||
const lineCount = text ? text.split("\n").length : 0;
|
||||
const fileCount = selectedStems.length;
|
||||
const mismatch = lineCount !== fileCount && text.length > 0;
|
||||
|
||||
// populate textarea with current stems when first opened or when selection changes and user hasn't edited
|
||||
useEffect(() => {
|
||||
if (!rule) return;
|
||||
if (rule.names.length === 0 && !initialized) {
|
||||
const joined = selectedStems.join("\n");
|
||||
setText(joined);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [rule, selectedStems.length, initialized]);
|
||||
|
||||
// sync rule names when first loaded with existing data
|
||||
useEffect(() => {
|
||||
if (!rule) return;
|
||||
if (rule.names.length > 0 && !initialized) {
|
||||
setText(rule.names.join("\n"));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [rule, initialized]);
|
||||
|
||||
if (!rule) return null;
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
setText(val);
|
||||
const names = val.split("\n");
|
||||
updateRule(ruleId, { names });
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
const joined = selectedStems.join("\n");
|
||||
setText(joined);
|
||||
updateRule(ruleId, { names: selectedStems });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lineCount} line{lineCount !== 1 ? "s" : ""} / {fileCount} file{fileCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleLoad} className="h-6 text-xs px-2">
|
||||
Load current names
|
||||
</Button>
|
||||
</div>
|
||||
{mismatch && (
|
||||
<div className="text-xs text-amber-500 bg-amber-500/10 rounded px-2 py-1">
|
||||
Line count ({lineCount}) doesn't match selected files ({fileCount}).
|
||||
Extra lines will be ignored, missing lines will keep original names.
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[360px] max-h-[600px] rounded-lg border border-input bg-transparent p-3 text-xs font-mono leading-relaxed resize-y outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
|
||||
placeholder={"One filename per line...\nLine 1 = first selected file\nLine 2 = second selected file\n..."}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
ui/src/components/pipeline/configs/TransliterateConfig.tsx
Normal file
10
ui/src/components/pipeline/configs/TransliterateConfig.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {} from "@/types/rules";
|
||||
|
||||
export function TransliterateConfig({ ruleId: _ }: { ruleId: string }) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Converts non-ASCII characters to their closest ASCII equivalents.
|
||||
No configuration needed - just enable the rule.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
ui/src/components/pipeline/configs/TruncateConfig.tsx
Normal file
41
ui/src/components/pipeline/configs/TruncateConfig.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useRuleStore } from "@/stores/ruleStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NumberInput } from "@/components/ui/number-input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { TruncateConfig as TruncateConfigType } from "@/types/rules";
|
||||
|
||||
const truncateFrom = [
|
||||
{ value: "End", label: "From end" },
|
||||
{ value: "Start", label: "From start" },
|
||||
] as const;
|
||||
|
||||
export function TruncateConfig({ ruleId }: { ruleId: string }) {
|
||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TruncateConfigType | undefined;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
if (!rule) return null;
|
||||
const update = (changes: Partial<TruncateConfigType>) => updateRule(ruleId, changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Max length</span>
|
||||
<NumberInput value={rule.max_length} onChange={(v) => update({ max_length: v })} min={1} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Truncation marker</span>
|
||||
<Input
|
||||
value={rule.suffix}
|
||||
onChange={(e) => update({ suffix: e.target.value })}
|
||||
placeholder="e.g. ..."
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Truncate from</span>
|
||||
<SegmentedControl value={rule.from} onChange={(f) => update({ from: f })} options={truncateFrom} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user