Skip to content

Commit a971d7d

Browse files
committed
feat: implement script import functionality and validation logic
1 parent 9bfde68 commit a971d7d

4 files changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Fetches and returns the contents of a script from a given URL.
3+
* @param url - The URL of the script to import.
4+
* @returns The script code as a string.
5+
* @throws If the fetch fails or the URL is invalid.
6+
*/
7+
export async function importScript(url: string): Promise<string> {
8+
if (!url || typeof url !== "string") {
9+
throw new TypeError("A valid URL string must be provided.");
10+
}
11+
12+
const controller = new AbortController();
13+
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
14+
15+
try {
16+
const response = await fetch(url, { signal: controller.signal });
17+
if (!response.ok) {
18+
throw new Error(
19+
`Failed to import script: ${response.status} ${response.statusText}`
20+
);
21+
}
22+
return await response.text();
23+
} catch (error) {
24+
throw new Error(`Error importing script: ${(error as Error).message}`);
25+
} finally {
26+
clearTimeout(timeout);
27+
}
28+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import {
2+
createAsyncThunk,
3+
createSlice,
4+
nanoid,
5+
PayloadAction,
6+
} from "@reduxjs/toolkit";
7+
import { importScript } from "./api/scriptApi";
8+
import { ScriptExecution, ScriptsState, UserScript } from "./types";
9+
import { validateScript } from "./utils";
10+
11+
// Async thunk to add a new user script by importing from a URL or direct input
12+
export const addUserScript = createAsyncThunk<
13+
UserScript,
14+
{
15+
url?: string;
16+
code?: string;
17+
name: string;
18+
description: string;
19+
urlPatterns: string[];
20+
runAt: "document-start" | "document-ready" | "document-end";
21+
},
22+
{ rejectValue: string }
23+
>("scripts/createUserScript", async (args, { rejectWithValue }) => {
24+
try {
25+
let code: string | undefined;
26+
27+
if (args.code) {
28+
code = args.code;
29+
} else if (args.url) {
30+
code = await importScript(args.url);
31+
} else {
32+
return rejectWithValue("Either 'url' or 'code' must be provided.");
33+
}
34+
35+
// Validate the script code before creating the script
36+
const validation = validateScript(code);
37+
if (!validation.valid) {
38+
return rejectWithValue(validation.error ?? "Unknown validation error");
39+
}
40+
41+
const now = new Date().toISOString();
42+
return {
43+
id: nanoid(),
44+
name: args.name,
45+
description: args.description,
46+
runAt: args.runAt,
47+
code,
48+
urlPatterns: args.urlPatterns,
49+
enabled: true,
50+
createdAt: now,
51+
updatedAt: now,
52+
};
53+
} catch (error) {
54+
return rejectWithValue((error as Error).message);
55+
}
56+
});
57+
58+
const initialState: ScriptsState = {
59+
userScripts: {},
60+
executions: {},
61+
isLoading: false,
62+
error: null,
63+
};
64+
65+
const scriptsSlice = createSlice({
66+
name: "scripts",
67+
initialState,
68+
reducers: {
69+
updateUserScript: {
70+
reducer: (
71+
state,
72+
action: PayloadAction<{
73+
id: string;
74+
updates: Partial<UserScript>;
75+
updatedAt: string;
76+
}>
77+
) => {
78+
const { id, updates, updatedAt } = action.payload;
79+
const script = state.userScripts[id];
80+
if (script) {
81+
Object.assign(script, { ...updates, updatedAt });
82+
}
83+
},
84+
prepare: (id: string, updates: Partial<UserScript>) => {
85+
return {
86+
payload: {
87+
id,
88+
updates,
89+
updatedAt: new Date().toISOString(),
90+
},
91+
};
92+
},
93+
},
94+
95+
deleteUserScript: (state, action: PayloadAction<string>) => {
96+
const scriptId = action.payload;
97+
// Remove the script
98+
delete state.userScripts[scriptId];
99+
// Also remove related execution logs
100+
Object.entries(state.executions).forEach(([id, execution]) => {
101+
if (execution.scriptId === scriptId) {
102+
delete state.executions[id];
103+
}
104+
});
105+
},
106+
107+
toggleUserScript: {
108+
reducer: (
109+
state,
110+
action: PayloadAction<{ id: string; updatedAt: string }>
111+
) => {
112+
const script = state.userScripts[action.payload.id];
113+
if (script) {
114+
script.enabled = !script.enabled;
115+
script.updatedAt = action.payload.updatedAt;
116+
}
117+
},
118+
prepare: (id: string) => ({
119+
payload: {
120+
id,
121+
updatedAt: new Date().toISOString(),
122+
},
123+
}),
124+
},
125+
126+
logScriptExecution: (state, action: PayloadAction<ScriptExecution>) => {
127+
state.executions[action.payload.id] = action.payload;
128+
},
129+
130+
clearExecutionLogs: (state) => {
131+
state.executions = {};
132+
},
133+
134+
setError: (state, action: PayloadAction<string | null>) => {
135+
state.error = action.payload;
136+
},
137+
},
138+
139+
extraReducers: (builder) => {
140+
builder
141+
.addCase(addUserScript.pending, (state) => {
142+
state.isLoading = true;
143+
state.error = null;
144+
})
145+
.addCase(addUserScript.fulfilled, (state, action) => {
146+
state.isLoading = false;
147+
state.userScripts[action.payload.id] = action.payload;
148+
})
149+
.addCase(addUserScript.rejected, (state, action) => {
150+
state.isLoading = false;
151+
state.error = action.error.message || "Script import failed!";
152+
});
153+
},
154+
});
155+
156+
export const {
157+
updateUserScript,
158+
deleteUserScript,
159+
toggleUserScript,
160+
logScriptExecution,
161+
clearExecutionLogs,
162+
setError,
163+
} = scriptsSlice.actions;
164+
165+
export default scriptsSlice.reducer;

src/features/scripts/types.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export interface UserScript {
2+
id: string;
3+
name: string;
4+
description?: string;
5+
code: string;
6+
urlPatterns: string[];
7+
enabled: boolean;
8+
runAt: "document-start" | "document-ready" | "document-end";
9+
createdAt: string;
10+
updatedAt: string;
11+
}
12+
13+
export interface ScriptExecution {
14+
id: string;
15+
scriptId: string;
16+
url: string;
17+
timestamp: string;
18+
success: boolean;
19+
error?: string;
20+
executionTime?: number;
21+
}
22+
23+
export interface ScriptsState {
24+
userScripts: Record<string, UserScript>;
25+
executions: Record<string, ScriptExecution>;
26+
isLoading: boolean;
27+
error: string | null;
28+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export interface ScriptValidationResult {
2+
valid: boolean;
3+
error: string | null;
4+
}
5+
6+
/**
7+
* Validates the syntax of a given script code string.
8+
*
9+
* @param code - The script code to validate.
10+
* @returns An object containing the validation result:
11+
* - `valid`: `true` if the code is syntactically correct, `false` otherwise.
12+
* - `error`: The error message if validation fails, or `null` if validation succeeds.
13+
*/
14+
export function validateScript(code: string): ScriptValidationResult {
15+
try {
16+
new Function(code); // Basic syntax check
17+
return { valid: true, error: null };
18+
} catch (error) {
19+
return { valid: false, error: (error as Error).message };
20+
}
21+
}

0 commit comments

Comments
 (0)