Skip to content

Commit 489d87a

Browse files
committed
Improve image detection for templates, fix strip images not applying
1 parent 86d29c2 commit 489d87a

10 files changed

Lines changed: 154 additions & 20 deletions

services/backend-api/client/src/features/feedConnections/api/createDiscordChannelConnection.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export interface CreateDiscordChannelConnectionInput {
7070
characterCount: number;
7171
appendString?: string | null;
7272
}> | null;
73+
formatter?: {
74+
formatTables?: boolean | null;
75+
stripImages?: boolean | null;
76+
disableImageLinkPreviews?: boolean | null;
77+
ignoreNewLines?: boolean | null;
78+
} | null;
7379
};
7480
}
7581

services/backend-api/client/src/features/feedConnections/components/AddConnectionDialog/DiscordApplicationWebhookConnectionDialogContent.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export const DiscordApplicationWebhookConnectionDialogContent: React.FC<Props> =
143143
embeds: templateData.embeds,
144144
componentsV2: templateData.componentsV2,
145145
placeholderLimits: templateData.placeholderLimits,
146+
formatter: templateData.formatter,
146147
},
147148
});
148149
}, [feedId, watch, mutateAsync, selectedTemplateId, channelId, detectedFields]);
@@ -214,6 +215,7 @@ export const DiscordApplicationWebhookConnectionDialogContent: React.FC<Props> =
214215
embeds: templateData.embeds,
215216
componentsV2: templateData.componentsV2,
216217
placeholderLimits: templateData.placeholderLimits,
218+
formatter: templateData.formatter,
217219
},
218220
});
219221

services/backend-api/client/src/features/feedConnections/components/AddConnectionDialog/DiscordForumChannelConnectionDialogContent.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export const DiscordForumChannelConnectionDialogContent: React.FC<Props> = ({
146146
embeds: templateData.embeds,
147147
componentsV2: templateData.componentsV2,
148148
placeholderLimits: templateData.placeholderLimits,
149+
formatter: templateData.formatter,
149150
},
150151
});
151152
}, [feedId, watch, mutateAsync, selectedTemplateId, detectedFields]);
@@ -247,6 +248,7 @@ export const DiscordForumChannelConnectionDialogContent: React.FC<Props> = ({
247248
embeds: templateData.embeds,
248249
componentsV2: templateData.componentsV2,
249250
placeholderLimits: templateData.placeholderLimits,
251+
formatter: templateData.formatter,
250252
},
251253
});
252254

services/backend-api/client/src/features/feedConnections/components/AddConnectionDialog/DiscordTextChannelConnectionDialogContent.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export const DiscordTextChannelConnectionDialogContent: React.FC<Props> = ({
175175
embeds: templateData.embeds,
176176
componentsV2: templateData.componentsV2,
177177
placeholderLimits: templateData.placeholderLimits,
178+
formatter: templateData.formatter,
178179
},
179180
});
180181
}, [feedId, watch, mutateAsync, selectedTemplateId, detectedFields]);
@@ -276,6 +277,7 @@ export const DiscordTextChannelConnectionDialogContent: React.FC<Props> = ({
276277
embeds: templateData.embeds,
277278
componentsV2: templateData.componentsV2,
278279
placeholderLimits: templateData.placeholderLimits,
280+
formatter: templateData.formatter,
279281
},
280282
});
281283

services/backend-api/client/src/features/templates/constants/templates.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,6 @@ export const RICH_EMBED_TEMPLATE: Template = {
105105
name: "Description",
106106
content: `{{${descriptionField}}}`,
107107
},
108-
...(linkField
109-
? [
110-
{
111-
type: ComponentType.V2Divider as const,
112-
id: "rich-embed-divider",
113-
name: "Divider",
114-
visual: true,
115-
spacing: 1 as const,
116-
children: [] as [],
117-
},
118-
]
119-
: []),
120108
...(linkField
121109
? [
122110
{

services/backend-api/client/src/features/templates/utils/detectImageField.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ describe("detectImageFields", () => {
5252
];
5353
expect(detectImageFields(articles)).toEqual(["image"]);
5454
});
55+
56+
it("skips values with whitespace (mixed content)", () => {
57+
const articles = [
58+
{
59+
mixedContent:
60+
"https://example.com/photo.png?query=1 submitted by /u/Someone [link] [comments]",
61+
image: "https://example.com/actual.jpg",
62+
},
63+
];
64+
expect(detectImageFields(articles)).toEqual(["image"]);
65+
});
66+
67+
it("skips values with newlines", () => {
68+
const articles = [
69+
{
70+
multiline: "https://example.com/photo.jpg\nSome other text",
71+
image: "https://example.com/actual.jpg",
72+
},
73+
];
74+
expect(detectImageFields(articles)).toEqual(["image"]);
75+
});
5576
});
5677

5778
describe("deduplication", () => {
@@ -152,6 +173,71 @@ describe("detectImageFields", () => {
152173
});
153174
});
154175

176+
describe("Reddit CDN deduplication", () => {
177+
it("deduplicates preview.redd.it and i.redd.it URLs with same filename", () => {
178+
const articles = [
179+
{
180+
image__url: "https://i.redd.it/7azztrbwusag1.png",
181+
"extracted::description::anchor3":
182+
"https://preview.redd.it/7azztrbwusag1.png?width=640&crop=smart&auto=webp&s=abc123",
183+
},
184+
];
185+
expect(detectImageFields(articles)).toEqual(["image__url"]);
186+
});
187+
188+
it("deduplicates same Reddit image with different query parameters", () => {
189+
const articles = [
190+
{
191+
thumbnail: "https://preview.redd.it/abc123.jpg?width=108&crop=smart",
192+
image: "https://preview.redd.it/abc123.jpg?width=1080&format=png",
193+
},
194+
];
195+
expect(detectImageFields(articles)).toEqual(["image"]);
196+
});
197+
198+
it("keeps different Reddit images as separate", () => {
199+
const articles = [
200+
{
201+
image1: "https://i.redd.it/first.png",
202+
image2: "https://i.redd.it/second.png",
203+
},
204+
];
205+
expect(detectImageFields(articles)).toEqual(["image1", "image2"]);
206+
});
207+
208+
it("deduplicates redditmedia.com URLs by filename", () => {
209+
const articles = [
210+
{
211+
thumb: "https://a.thumbs.redditmedia.com/abc123.jpg",
212+
full: "https://i.redditmedia.com/abc123.jpg",
213+
},
214+
];
215+
expect(detectImageFields(articles)).toEqual(["full"]);
216+
});
217+
});
218+
219+
describe("query parameter normalization", () => {
220+
it("deduplicates same image with different query parameters", () => {
221+
const articles = [
222+
{
223+
thumb: "https://example.com/photo.jpg?size=small",
224+
full: "https://example.com/photo.jpg?size=large",
225+
},
226+
];
227+
expect(detectImageFields(articles)).toEqual(["full"]);
228+
});
229+
230+
it("deduplicates image with and without query parameters", () => {
231+
const articles = [
232+
{
233+
clean: "https://example.com/photo.jpg",
234+
withParams: "https://example.com/photo.jpg?v=123",
235+
},
236+
];
237+
expect(detectImageFields(articles)).toEqual(["clean"]);
238+
});
239+
});
240+
155241
describe("supported image extensions", () => {
156242
const extensions = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico"];
157243

services/backend-api/client/src/features/templates/utils/detectImageField.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,70 @@ function selectPreferredField(fields: string[]): string {
1717
function isImageUrl(value: unknown): boolean {
1818
if (!value || typeof value !== "string") return false;
1919

20+
const trimmed = value.trim();
21+
22+
// Reject values with whitespace (indicates mixed content, not a pure URL)
23+
if (/\s/.test(trimmed)) return false;
24+
2025
try {
21-
const url = new URL(value);
26+
const url = new URL(trimmed);
2227

2328
return IMAGE_EXTENSIONS.test(url.pathname);
2429
} catch {
2530
return false;
2631
}
2732
}
2833

34+
/**
35+
* Extracts a normalized key for deduplication purposes.
36+
* This handles cases where the same image appears with different subdomains
37+
* or query parameters (e.g., Reddit's preview.redd.it vs i.redd.it).
38+
*/
39+
function getImageDedupeKey(urlString: string): string {
40+
try {
41+
const url = new URL(urlString);
42+
43+
// Extract just the filename from the pathname
44+
const pathParts = url.pathname.split("/");
45+
const filename = pathParts[pathParts.length - 1];
46+
47+
// For Reddit CDN URLs, normalize by using just the filename
48+
// since preview.redd.it and i.redd.it serve the same images
49+
if (url.hostname.endsWith("redd.it") || url.hostname.endsWith("redditmedia.com")) {
50+
return `reddit:${filename}`;
51+
}
52+
53+
// For other URLs, use hostname + pathname (without query params)
54+
// This handles cases where the same image has different query params
55+
return `${url.hostname}${url.pathname}`;
56+
} catch {
57+
return urlString;
58+
}
59+
}
60+
2961
export function detectImageFields(articles: Array<Record<string, unknown>>): string[] {
3062
if (!articles || articles.length === 0) return [];
3163

3264
const selectedFields = new Set<string>();
3365

3466
for (const article of articles) {
35-
// Group fields by their URL value within this article
36-
const urlToFields = new Map<string, string[]>();
67+
// Group fields by their normalized URL key within this article
68+
const dedupeKeyToFields = new Map<string, string[]>();
3769

3870
for (const [field, value] of Object.entries(article)) {
3971
if (field === "id" || field === "idHash") continue;
4072

4173
if (isImageUrl(value)) {
4274
const url = value as string;
43-
const existing = urlToFields.get(url) || [];
75+
const dedupeKey = getImageDedupeKey(url);
76+
const existing = dedupeKeyToFields.get(dedupeKey) || [];
4477
existing.push(field);
45-
urlToFields.set(url, existing);
78+
dedupeKeyToFields.set(dedupeKey, existing);
4679
}
4780
}
4881

49-
// For each unique URL, select the preferred field
50-
for (const fields of urlToFields.values()) {
82+
// For each unique image (by dedupe key), select the preferred field
83+
for (const fields of dedupeKeyToFields.values()) {
5184
const preferredField = selectPreferredField(fields);
5285
selectedFields.add(preferredField);
5386
}

services/backend-api/src/features/feed-connections/dto/create-discord-channel-connection-input.dto.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
ValidateIf,
1111
ValidateNested,
1212
} from "class-validator";
13-
import { DiscordEmbed, DiscordPlaceholderLimitOptions } from "../../../common";
13+
import {
14+
DiscordConnectionFormatterOptions,
15+
DiscordEmbed,
16+
DiscordPlaceholderLimitOptions,
17+
} from "../../../common";
1418

1519
class Webhook {
1620
@IsString()
@@ -94,4 +98,11 @@ export class CreateDiscordChnnnelConnectionInputDto {
9498
@ValidateNested({ each: true })
9599
@Type(() => DiscordPlaceholderLimitOptions)
96100
placeholderLimits?: DiscordPlaceholderLimitOptions[];
101+
102+
@IsOptional()
103+
@Type(() => DiscordConnectionFormatterOptions)
104+
@ValidateNested()
105+
@IsObject()
106+
@ValidateIf((v) => v !== null)
107+
formatter?: DiscordConnectionFormatterOptions | null;
97108
}

services/backend-api/src/features/feed-connections/feed-connections-discord-channels.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export class FeedConnectionsDiscordChannelsController {
7979
embeds,
8080
componentsV2,
8181
placeholderLimits,
82+
formatter,
8283
}: CreateDiscordChnnnelConnectionInputDto,
8384
@DiscordAccessToken()
8485
{ access_token, discord: { id: discordUserId } }: SessionAccessToken
@@ -98,6 +99,7 @@ export class FeedConnectionsDiscordChannelsController {
9899
embeds,
99100
componentsV2,
100101
placeholderLimits,
102+
formatter: formatter || undefined,
101103
},
102104
}
103105
);

services/backend-api/src/features/feed-connections/feed-connections-discord-channels.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export class FeedConnectionsDiscordChannelsService {
216216
embeds?: DiscordChannelConnection["details"]["embeds"];
217217
componentsV2?: DiscordChannelConnection["details"]["componentsV2"];
218218
placeholderLimits?: DiscordChannelConnection["details"]["placeholderLimits"];
219+
formatter?: DiscordChannelConnection["details"]["formatter"];
219220
};
220221
}): Promise<DiscordChannelConnection> {
221222
const connectionId = new Types.ObjectId();
@@ -362,6 +363,7 @@ export class FeedConnectionsDiscordChannelsService {
362363
content: templateData?.content,
363364
componentsV2: validatedComponentsV2,
364365
placeholderLimits: templateData?.placeholderLimits,
366+
formatter: templateData?.formatter || undefined,
365367
},
366368
},
367369
},

0 commit comments

Comments
 (0)