mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-16 20:37:26 +00:00
support uploading csv or txt to sudo commands and groups
This commit is contained in:
5
.cursor/rules/Components.mdc
Normal file
5
.cursor/rules/Components.mdc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
|
||||||
@@ -2164,6 +2164,21 @@
|
|||||||
"sshCreateHomeDir": "Create Home Directory",
|
"sshCreateHomeDir": "Create Home Directory",
|
||||||
"sshUnixGroups": "Unix Groups",
|
"sshUnixGroups": "Unix Groups",
|
||||||
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
|
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
|
||||||
|
"roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file",
|
||||||
|
"roleTextImportTitle": "Import from File",
|
||||||
|
"roleTextImportDescription": "Importing {fileName} into {fieldLabel}.",
|
||||||
|
"roleTextImportSkipHeader": "Skip First Row (Header)",
|
||||||
|
"roleTextImportOverride": "Replace Existing",
|
||||||
|
"roleTextImportAppend": "Append to Existing",
|
||||||
|
"roleTextImportMode": "Import Mode",
|
||||||
|
"roleTextImportPreview": "Preview",
|
||||||
|
"roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}",
|
||||||
|
"roleTextImportTotalCount": "{existing} existing + {imported} imported = {total} total",
|
||||||
|
"roleTextImportConfirm": "Import",
|
||||||
|
"roleTextImportInvalidFile": "Unsupported file type",
|
||||||
|
"roleTextImportInvalidFileDescription": "Only .txt and .csv files are supported.",
|
||||||
|
"roleTextImportEmpty": "No items found in file",
|
||||||
|
"roleTextImportEmptyDescription": "The file does not contain any importable items.",
|
||||||
"retryAttempts": "Retry Attempts",
|
"retryAttempts": "Retry Attempts",
|
||||||
"expectedResponseCodes": "Expected Response Codes",
|
"expectedResponseCodes": "Expected Response Codes",
|
||||||
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
||||||
|
|||||||
@@ -15,10 +15,20 @@ import {
|
|||||||
OptionSelect,
|
OptionSelect,
|
||||||
type OptionSelectOption
|
type OptionSelectOption
|
||||||
} from "@app/components/OptionSelect";
|
} from "@app/components/OptionSelect";
|
||||||
|
import { TextFileImportDialog } from "@app/components/TextFileImportDialog";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import {
|
||||||
|
getTextImportFileType,
|
||||||
|
isSupportedTextImportFile,
|
||||||
|
parseTextFileItems,
|
||||||
|
readFileAsText,
|
||||||
|
type TextImportFileType
|
||||||
|
} from "@app/lib/roleFormTextImport";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -98,6 +108,15 @@ type RoleFormProps = {
|
|||||||
formId?: string;
|
formId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RoleTextImportField = "sshSudoCommands" | "sshUnixGroups";
|
||||||
|
|
||||||
|
type PendingTextImport = {
|
||||||
|
field: RoleTextImportField;
|
||||||
|
fileName: string;
|
||||||
|
fileType: TextImportFileType;
|
||||||
|
rawContent: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function RoleForm({
|
export function RoleForm({
|
||||||
variant,
|
variant,
|
||||||
role,
|
role,
|
||||||
@@ -187,6 +206,10 @@ export function RoleForm({
|
|||||||
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||||
const sshSudoMode = form.watch("sshSudoMode");
|
const sshSudoMode = form.watch("sshSudoMode");
|
||||||
const isAdminRole = variant === "edit" && role?.isAdmin === true;
|
const isAdminRole = variant === "edit" && role?.isAdmin === true;
|
||||||
|
const [pendingImport, setPendingImport] =
|
||||||
|
useState<PendingTextImport | null>(null);
|
||||||
|
const [dragOverField, setDragOverField] =
|
||||||
|
useState<RoleTextImportField | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sshDisabled) {
|
if (sshDisabled) {
|
||||||
@@ -194,6 +217,78 @@ export function RoleForm({
|
|||||||
}
|
}
|
||||||
}, [sshDisabled, form]);
|
}, [sshDisabled, form]);
|
||||||
|
|
||||||
|
async function handleFileDrop(
|
||||||
|
file: File,
|
||||||
|
field: RoleTextImportField
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isSupportedTextImportFile(file)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("roleTextImportInvalidFile"),
|
||||||
|
description: t("roleTextImportInvalidFileDescription")
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileType = getTextImportFileType(file);
|
||||||
|
if (!fileType) return;
|
||||||
|
|
||||||
|
const rawContent = await readFileAsText(file);
|
||||||
|
const parser =
|
||||||
|
field === "sshSudoCommands" ? parseSudoCommands : parseUnixGroups;
|
||||||
|
const items = parseTextFileItems({
|
||||||
|
content: rawContent,
|
||||||
|
fileType,
|
||||||
|
skipHeader: false,
|
||||||
|
parser
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("roleTextImportEmpty"),
|
||||||
|
description: t("roleTextImportEmptyDescription")
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingImport({
|
||||||
|
field,
|
||||||
|
fileName: file.name,
|
||||||
|
fileType,
|
||||||
|
rawContent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextImportDropHandlers(field: RoleTextImportField) {
|
||||||
|
return {
|
||||||
|
onDragOver: (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!sshDisabled) {
|
||||||
|
setDragOverField(field);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragLeave: (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragOverField((current) =>
|
||||||
|
current === field ? null : current
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDrop: (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setDragOverField(null);
|
||||||
|
if (sshDisabled) return;
|
||||||
|
|
||||||
|
const file = event.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
void handleFileDrop(file, field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -443,8 +538,23 @@ export function RoleForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
|
{...getTextImportDropHandlers(
|
||||||
|
"sshSudoCommands"
|
||||||
|
)}
|
||||||
|
placeholder={
|
||||||
|
sshDisabled
|
||||||
|
? undefined
|
||||||
|
: t(
|
||||||
|
"roleTextFieldPlaceholder"
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={sshDisabled}
|
disabled={sshDisabled}
|
||||||
className="h-20 min-h-20"
|
className={cn(
|
||||||
|
"h-20 min-h-20",
|
||||||
|
dragOverField ===
|
||||||
|
"sshSudoCommands" &&
|
||||||
|
"border-primary"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -469,8 +579,23 @@ export function RoleForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
|
{...getTextImportDropHandlers(
|
||||||
|
"sshUnixGroups"
|
||||||
|
)}
|
||||||
|
placeholder={
|
||||||
|
sshDisabled
|
||||||
|
? undefined
|
||||||
|
: t(
|
||||||
|
"roleTextFieldPlaceholder"
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={sshDisabled}
|
disabled={sshDisabled}
|
||||||
className="h-20 min-h-20"
|
className={cn(
|
||||||
|
"h-20 min-h-20",
|
||||||
|
dragOverField ===
|
||||||
|
"sshUnixGroups" &&
|
||||||
|
"border-primary"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -521,6 +646,38 @@ export function RoleForm({
|
|||||||
</HorizontalTabs>
|
</HorizontalTabs>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
{pendingImport && (
|
||||||
|
<TextFileImportDialog
|
||||||
|
key={`${pendingImport.field}-${pendingImport.fileName}`}
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setPendingImport(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fileName={pendingImport.fileName}
|
||||||
|
fileType={pendingImport.fileType}
|
||||||
|
rawContent={pendingImport.rawContent}
|
||||||
|
currentValue={form.watch(pendingImport.field) ?? ""}
|
||||||
|
fieldLabel={
|
||||||
|
pendingImport.field === "sshSudoCommands"
|
||||||
|
? t("sshSudoCommands")
|
||||||
|
: t("sshUnixGroups")
|
||||||
|
}
|
||||||
|
parser={
|
||||||
|
pendingImport.field === "sshSudoCommands"
|
||||||
|
? parseSudoCommands
|
||||||
|
: parseUnixGroups
|
||||||
|
}
|
||||||
|
onConfirm={(value) => {
|
||||||
|
form.setValue(pendingImport.field, value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true
|
||||||
|
});
|
||||||
|
setPendingImport(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
189
src/components/TextFileImportDialog.tsx
Normal file
189
src/components/TextFileImportDialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import {
|
||||||
|
OptionSelect,
|
||||||
|
type OptionSelectOption
|
||||||
|
} from "@app/components/OptionSelect";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||||
|
import { Textarea } from "@app/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
applyTextImport,
|
||||||
|
parsePreviewLines,
|
||||||
|
parseTextFileItems,
|
||||||
|
type TextImportFileType,
|
||||||
|
type TextImportMode
|
||||||
|
} from "@app/lib/roleFormTextImport";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type TextFileImportDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
fileName: string;
|
||||||
|
fileType: TextImportFileType;
|
||||||
|
rawContent: string;
|
||||||
|
currentValue: string;
|
||||||
|
fieldLabel: string;
|
||||||
|
parser: (value: string | undefined) => string[];
|
||||||
|
onConfirm: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TextFileImportDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
fileName,
|
||||||
|
fileType,
|
||||||
|
rawContent,
|
||||||
|
currentValue,
|
||||||
|
fieldLabel,
|
||||||
|
parser,
|
||||||
|
onConfirm
|
||||||
|
}: TextFileImportDialogProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [editablePreview, setEditablePreview] = useState("");
|
||||||
|
const [skipHeader, setSkipHeader] = useState(false);
|
||||||
|
const [mode, setMode] = useState<TextImportMode>("override");
|
||||||
|
|
||||||
|
const parsedFromFile = useMemo(
|
||||||
|
() =>
|
||||||
|
parseTextFileItems({
|
||||||
|
content: rawContent,
|
||||||
|
fileType,
|
||||||
|
skipHeader,
|
||||||
|
parser
|
||||||
|
}),
|
||||||
|
[rawContent, fileType, skipHeader, parser]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditablePreview(parsedFromFile.join("\n"));
|
||||||
|
}, [parsedFromFile]);
|
||||||
|
|
||||||
|
const importedItems = useMemo(
|
||||||
|
() => parsePreviewLines(editablePreview),
|
||||||
|
[editablePreview]
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingCount = useMemo(
|
||||||
|
() => parser(currentValue).length,
|
||||||
|
[currentValue, parser]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCount =
|
||||||
|
mode === "append"
|
||||||
|
? existingCount + importedItems.length
|
||||||
|
: importedItems.length;
|
||||||
|
|
||||||
|
const modeOptions: OptionSelectOption<TextImportMode>[] = [
|
||||||
|
{
|
||||||
|
value: "override",
|
||||||
|
label: t("roleTextImportOverride")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "append",
|
||||||
|
label: t("roleTextImportAppend")
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
onConfirm(
|
||||||
|
applyTextImport({
|
||||||
|
currentValue,
|
||||||
|
imported: importedItems,
|
||||||
|
mode,
|
||||||
|
parser
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{t("roleTextImportTitle")}</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("roleTextImportDescription", {
|
||||||
|
fileName,
|
||||||
|
fieldLabel
|
||||||
|
})}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
|
||||||
|
<CredenzaBody>
|
||||||
|
{fileType === "csv" && (
|
||||||
|
<CheckboxWithLabel
|
||||||
|
checked={skipHeader}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked !== "indeterminate") {
|
||||||
|
setSkipHeader(checked);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={t("roleTextImportSkipHeader")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("roleTextImportPreview")}
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={editablePreview}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditablePreview(event.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("roleTextImportEmpty")}
|
||||||
|
className="min-h-32 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OptionSelect<TextImportMode>
|
||||||
|
label={t("roleTextImportMode")}
|
||||||
|
options={modeOptions}
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
cols={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{mode === "append"
|
||||||
|
? t("roleTextImportTotalCount", {
|
||||||
|
existing: existingCount,
|
||||||
|
imported: importedItems.length,
|
||||||
|
total: totalCount
|
||||||
|
})
|
||||||
|
: t("roleTextImportItemCount", {
|
||||||
|
count: importedItems.length
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</CredenzaBody>
|
||||||
|
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
{t("close")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={importedItems.length === 0}
|
||||||
|
>
|
||||||
|
{t("roleTextImportConfirm")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/lib/roleFormTextImport.ts
Normal file
110
src/lib/roleFormTextImport.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
export type TextImportFileType = "txt" | "csv";
|
||||||
|
export type TextImportMode = "override" | "append";
|
||||||
|
|
||||||
|
export function getTextImportFileType(file: File): TextImportFileType | null {
|
||||||
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
if (extension === "txt" || extension === "csv") {
|
||||||
|
return extension;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedTextImportFile(file: File): boolean {
|
||||||
|
return getTextImportFileType(file) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileAsText(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const result = event.target?.result;
|
||||||
|
if (typeof result === "string") {
|
||||||
|
resolve(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error("Failed to read file"));
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvFirstCell(row: string): string {
|
||||||
|
const trimmed = row.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
|
||||||
|
if (trimmed.startsWith('"')) {
|
||||||
|
let index = 1;
|
||||||
|
let cell = "";
|
||||||
|
while (index < trimmed.length) {
|
||||||
|
if (trimmed[index] === '"') {
|
||||||
|
if (trimmed[index + 1] === '"') {
|
||||||
|
cell += '"';
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cell += trimmed[index];
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return cell.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const commaIndex = trimmed.indexOf(",");
|
||||||
|
return (commaIndex === -1 ? trimmed : trimmed.slice(0, commaIndex)).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCsvFirstColumn(
|
||||||
|
content: string,
|
||||||
|
skipHeader: boolean
|
||||||
|
): string[] {
|
||||||
|
const rows = content.split(/\r?\n/).filter((line) => line.trim());
|
||||||
|
const dataRows = skipHeader ? rows.slice(1) : rows;
|
||||||
|
|
||||||
|
return dataRows.map(parseCsvFirstCell).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTextFileItems({
|
||||||
|
content,
|
||||||
|
fileType,
|
||||||
|
skipHeader,
|
||||||
|
parser
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
fileType: TextImportFileType;
|
||||||
|
skipHeader: boolean;
|
||||||
|
parser: (value: string | undefined) => string[];
|
||||||
|
}): string[] {
|
||||||
|
if (fileType === "csv") {
|
||||||
|
return parseCsvFirstColumn(content, skipHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parser(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePreviewLines(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(/\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTextImport({
|
||||||
|
currentValue,
|
||||||
|
imported,
|
||||||
|
mode,
|
||||||
|
parser
|
||||||
|
}: {
|
||||||
|
currentValue: string;
|
||||||
|
imported: string[];
|
||||||
|
mode: TextImportMode;
|
||||||
|
parser: (value: string | undefined) => string[];
|
||||||
|
}): string {
|
||||||
|
if (mode === "override") {
|
||||||
|
return imported.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = parser(currentValue);
|
||||||
|
return [...existing, ...imported].join("\n");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user