Skip to content

Commit a95470f

Browse files
committed
Issue #71
- Implemented sanitization of entered text and handling of duplicates
1 parent af897fb commit a95470f

5 files changed

Lines changed: 219 additions & 19 deletions

File tree

src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/prompt_suggestions_controller.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default class extends Controller {
1414
"formModal",
1515
"formInput",
1616
"formTitle",
17+
"formError",
1718
"deleteModal",
1819
];
1920

@@ -54,6 +55,8 @@ export default class extends Controller {
5455
declare readonly formInputTarget: HTMLTextAreaElement;
5556
declare readonly hasFormTitleTarget: boolean;
5657
declare readonly formTitleTarget: HTMLElement;
58+
declare readonly hasFormErrorTarget: boolean;
59+
declare readonly formErrorTarget: HTMLElement;
5760
declare readonly hasDeleteModalTarget: boolean;
5861
declare readonly deleteModalTarget: HTMLElement;
5962

@@ -197,7 +200,7 @@ export default class extends Controller {
197200
return;
198201
}
199202

200-
const text = this.formInputTarget.value.trim();
203+
const text = this.sanitizeText(this.formInputTarget.value);
201204
if (text === "") {
202205
return;
203206
}
@@ -213,11 +216,19 @@ export default class extends Controller {
213216
method = "PUT";
214217
}
215218

216-
const suggestions = await this.sendRequest(url, method, { text });
217-
if (suggestions !== null) {
218-
this.refreshSuggestionsList(suggestions);
219+
const result = await this.sendRequest(url, method, { text });
220+
221+
if (result === null) {
222+
this.showFormError("An unexpected error occurred.");
223+
return;
224+
}
225+
226+
if ("error" in result) {
227+
this.showFormError(result.error);
228+
return;
219229
}
220230

231+
this.refreshSuggestionsList(result.suggestions);
221232
this.hideFormModal();
222233
}
223234

@@ -254,10 +265,10 @@ export default class extends Controller {
254265
}
255266

256267
const url = this.deleteUrlTemplateValue.replace("99999", String(this.deleteIndex));
257-
const suggestions = await this.sendRequest(url, "DELETE");
268+
const result = await this.sendRequest(url, "DELETE");
258269

259-
if (suggestions !== null) {
260-
this.refreshSuggestionsList(suggestions);
270+
if (result !== null && "suggestions" in result) {
271+
this.refreshSuggestionsList(result.suggestions);
261272
}
262273

263274
this.cancelDelete();
@@ -274,21 +285,39 @@ export default class extends Controller {
274285
this.formTitleTarget.textContent = title;
275286
}
276287

288+
this.clearFormError();
277289
this.formInputTarget.value = text;
278290
this.formInputTarget.placeholder = this.placeholderValue;
279291
this.formModalTarget.classList.remove("hidden");
280292

281-
// Focus the textarea after it becomes visible
282293
requestAnimationFrame(() => {
283294
this.formInputTarget.focus();
284295
});
285296
}
286297

298+
private showFormError(message: string): void {
299+
if (this.hasFormErrorTarget) {
300+
this.formErrorTarget.textContent = message;
301+
this.formErrorTarget.classList.remove("hidden");
302+
}
303+
}
304+
305+
private clearFormError(): void {
306+
if (this.hasFormErrorTarget) {
307+
this.formErrorTarget.textContent = "";
308+
this.formErrorTarget.classList.add("hidden");
309+
}
310+
}
311+
287312
/**
288-
* Send a JSON request to the API and return the updated suggestions list.
289-
* Returns null if the request failed.
313+
* Send a JSON request to the API.
314+
* Returns a success object with suggestions, an error object with a message, or null on network failure.
290315
*/
291-
private async sendRequest(url: string, method: string, body?: Record<string, string>): Promise<string[] | null> {
316+
private async sendRequest(
317+
url: string,
318+
method: string,
319+
body?: Record<string, string>,
320+
): Promise<{ suggestions: string[] } | { error: string } | null> {
292321
try {
293322
const options: RequestInit = {
294323
method,
@@ -304,16 +333,16 @@ export default class extends Controller {
304333
}
305334

306335
const response = await fetch(url, options);
307-
308-
if (!response.ok) {
309-
return null;
310-
}
311-
312336
const data = (await response.json()) as {
313337
suggestions?: string[];
338+
error?: string;
314339
};
315340

316-
return data.suggestions ?? null;
341+
if (!response.ok) {
342+
return { error: data.error ?? "Request failed." };
343+
}
344+
345+
return { suggestions: data.suggestions ?? [] };
317346
} catch {
318347
return null;
319348
}
@@ -419,4 +448,15 @@ export default class extends Controller {
419448
this.expandCollapseWrapperTarget.classList.add("hidden");
420449
}
421450
}
451+
452+
private sanitizeText(raw: string): string {
453+
return (
454+
raw
455+
.replace(/[\r\n]+/g, " ")
456+
// eslint-disable-next-line no-control-regex
457+
.replace(/[\x00-\x1F\x7F-\x9F]/g, "")
458+
.replace(/\s{2,}/g, " ")
459+
.trim()
460+
);
461+
}
422462
}

src/ChatBasedContentEditor/Presentation/Resources/templates/_prompt_suggestions.twig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@
9595
{{ stimulus_action('prompt-suggestions', 'handleFormKeydown', 'keydown') }}
9696
rows="5"
9797
class="w-full px-3 py-2 text-sm border border-dark-300 dark:border-dark-600 rounded-md bg-white dark:bg-dark-700 text-dark-900 dark:text-dark-100 placeholder-dark-400 dark:placeholder-dark-500 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"></textarea>
98+
<p {{ stimulus_target('prompt-suggestions', 'formError') }}
99+
class="hidden mt-1 text-xs text-red-600 dark:text-red-400"></p>
98100
<div class="flex justify-end gap-2 mt-4">
99101
<button type="button"
100102
{{ stimulus_action('prompt-suggestions', 'hideFormModal', 'click') }}

src/ChatBasedContentEditor/Presentation/Service/PromptSuggestionsService.php

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,17 @@ public function getSuggestions(?string $workspacePath): array
6363
*/
6464
public function addSuggestion(string $workspacePath, string $text): array
6565
{
66-
$text = trim($text);
66+
$text = $this->sanitize($text);
6767
if ($text === '') {
6868
throw new InvalidArgumentException('Suggestion text must not be empty.');
6969
}
7070

7171
$suggestions = $this->getSuggestions($workspacePath);
72+
73+
if ($this->isDuplicate($text, $suggestions)) {
74+
throw new InvalidArgumentException('This suggestion already exists.');
75+
}
76+
7277
array_unshift($suggestions, $text);
7378

7479
$this->saveSuggestions($workspacePath, $suggestions);
@@ -83,7 +88,7 @@ public function addSuggestion(string $workspacePath, string $text): array
8388
*/
8489
public function updateSuggestion(string $workspacePath, int $index, string $text): array
8590
{
86-
$text = trim($text);
91+
$text = $this->sanitize($text);
8792
if ($text === '') {
8893
throw new InvalidArgumentException('Suggestion text must not be empty.');
8994
}
@@ -94,6 +99,10 @@ public function updateSuggestion(string $workspacePath, int $index, string $text
9499
throw new OutOfRangeException('Suggestion index ' . $index . ' is out of range.');
95100
}
96101

102+
if ($this->isDuplicate($text, $suggestions, $index)) {
103+
throw new InvalidArgumentException('This suggestion already exists.');
104+
}
105+
97106
$suggestions[$index] = $text;
98107
$suggestions = array_values($suggestions);
99108

@@ -122,6 +131,44 @@ public function deleteSuggestion(string $workspacePath, int $index): array
122131
return $suggestions;
123132
}
124133

134+
/**
135+
* @param list<string> $suggestions
136+
*/
137+
private function isDuplicate(string $text, array $suggestions, ?int $excludeIndex = null): bool
138+
{
139+
$normalised = mb_strtolower($text);
140+
141+
foreach ($suggestions as $index => $existing) {
142+
if ($excludeIndex !== null && $index === $excludeIndex) {
143+
continue;
144+
}
145+
146+
if (mb_strtolower($existing) === $normalised) {
147+
return true;
148+
}
149+
}
150+
151+
return false;
152+
}
153+
154+
/**
155+
* Strip newlines, control characters, and collapse whitespace to produce a single-line string.
156+
* Suggestions are stored one per line, so embedded newlines would corrupt the file format.
157+
*/
158+
private function sanitize(string $text): string
159+
{
160+
// Replace newlines with spaces
161+
$text = str_replace(["\r\n", "\r", "\n"], ' ', $text);
162+
163+
// Remove invisible/control characters (Unicode category C) but keep regular spaces
164+
$text = (string) preg_replace('/[\p{C}]+/u', '', $text);
165+
166+
// Collapse multiple spaces into one
167+
$text = (string) preg_replace('/\s{2,}/', ' ', $text);
168+
169+
return trim($text);
170+
}
171+
125172
/**
126173
* Write the suggestions list back to the file.
127174
*

tests/Unit/ChatBasedContentEditor/PromptSuggestionsServiceTest.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,28 @@ public function testAddSuggestionThrowsOnWhitespaceOnlyText(): void
204204
$service->addSuggestion($this->tempDir, ' ');
205205
}
206206

207+
public function testAddSuggestionThrowsOnDuplicate(): void
208+
{
209+
$this->createSuggestionsFile("Existing suggestion\n");
210+
211+
$service = new PromptSuggestionsService();
212+
213+
$this->expectException(InvalidArgumentException::class);
214+
$this->expectExceptionMessage('This suggestion already exists.');
215+
$service->addSuggestion($this->tempDir, 'Existing suggestion');
216+
}
217+
218+
public function testAddSuggestionThrowsOnCaseInsensitiveDuplicate(): void
219+
{
220+
$this->createSuggestionsFile("Create a landing page\n");
221+
222+
$service = new PromptSuggestionsService();
223+
224+
$this->expectException(InvalidArgumentException::class);
225+
$this->expectExceptionMessage('This suggestion already exists.');
226+
$service->addSuggestion($this->tempDir, 'create a LANDING page');
227+
}
228+
207229
// ─── updateSuggestion ──────────────────────────────────────────
208230

209231
public function testUpdateSuggestionReplacesAtIndex(): void
@@ -277,6 +299,27 @@ public function testUpdateSuggestionThrowsOnOutOfBoundsIndex(): void
277299
$service->updateSuggestion($this->tempDir, 5, 'Text');
278300
}
279301

302+
public function testUpdateSuggestionThrowsOnDuplicate(): void
303+
{
304+
$this->createSuggestionsFile("First\nSecond\nThird\n");
305+
306+
$service = new PromptSuggestionsService();
307+
308+
$this->expectException(InvalidArgumentException::class);
309+
$this->expectExceptionMessage('This suggestion already exists.');
310+
$service->updateSuggestion($this->tempDir, 0, 'Second');
311+
}
312+
313+
public function testUpdateSuggestionAllowsSameTextAtSameIndex(): void
314+
{
315+
$this->createSuggestionsFile("First\nSecond\n");
316+
317+
$service = new PromptSuggestionsService();
318+
$result = $service->updateSuggestion($this->tempDir, 0, 'FIRST');
319+
320+
self::assertSame(['FIRST', 'Second'], $result);
321+
}
322+
280323
// ─── deleteSuggestion ──────────────────────────────────────────
281324

282325
public function testDeleteSuggestionRemovesAtIndex(): void

0 commit comments

Comments
 (0)