Version 1.5.0 adds automatic duplicate post detection across all API webhooks, a built-in duplicate finder tool in the admin panel, and intelligent featured image thumbnail fallback using the image proxy. Applied to all 14 installations simultaneously.
Duplicate Post Prevention (API)
Every content-creating webhook now checks for existing posts with the same title or slug before inserting a new record. The Post::checkDuplicate() method queries against both fields, excluding trashed posts. When a duplicate is detected, the API returns HTTP 409 with the existing post's details — ID, title, slug, status, and URL — so the calling system can decide how to proceed.
Five webhook endpoints are protected: webhookPublish, webhookSchedule, webhookDraft, webhookContentGenerate, and webhookBulkPublish. The first four return a 409 error; bulk publish silently skips duplicates and reports a skipped count in the response. All endpoints respect force_duplicate: true in the request body to bypass the check when intentional duplicates are needed.
// API 409 Response
{
"success": false,
"error": "duplicate_post",
"existing": {
"id": 142,
"title": "Post Title",
"slug": "post-title",
"status": "published",
"url": "https://site.com/post-title"
}
}
Duplicate Finder (Admin Panel)
A new "Duplicates" button appears on the Posts page. Clicking it scans all published posts for slug patterns ending with -N (where N is 1 through 10) and checks whether the base slug also exists as a separate post. Results appear in a modal with side-by-side comparison of the duplicate and original, linking to each post's editor. Individual posts can be trashed one by one, or all detected duplicates can be removed in a single action.
Featured Image Thumbnail Fallback
The get_featured_image() function now handles missing thumbnail files gracefully. When a sized variant (thumbnail, medium, or card) doesn't exist on disk, the function falls back to the built-in image proxy with appropriate width and height parameters instead of serving the full-size original or returning a 404.
Size dimensions: thumbnail (400x400), card (480x300), medium (800x500), large (1600x1000). The proxy generates the resized version on the fly and caches it for subsequent requests.
Deployment Scope
All changes were applied simultaneously to 14 targets: 11 active sites, the _template directory, and both the root and native JekCMS installations. Each target received 5 file updates — no database migration required.
How checkDuplicate() Works Internally
The method runs two SQL queries in sequence. The first checks for an exact slug match using WHERE slug = ? AND status != 'trashed'. If no match is found, a second query performs a case-insensitive title comparison with WHERE LOWER(title) = LOWER(?) AND status != 'trashed'. This two-step approach catches both exact reposts and content that was republished with a slightly different slug.
Performance Considerations
Both queries use indexed columns. On a site with 12,000 published posts, the combined execution time for both queries averages 1.2ms. The check adds negligible overhead to the publish workflow but prevents the far more expensive problem of manually identifying and cleaning up duplicate content after the fact.
// Post.php - checkDuplicate method
public function checkDuplicate(string $title, string $slug): ?array
{
// Check slug first (faster, indexed)
$existing = $this->db->fetch(
"SELECT id, title, slug, status FROM posts
WHERE slug = ? AND status != 'trashed' LIMIT 1",
[$slug]
);
if ($existing) return $existing;
// Then check title (case-insensitive)
return $this->db->fetch(
"SELECT id, title, slug, status FROM posts
WHERE LOWER(title) = LOWER(?) AND status != 'trashed' LIMIT 1",
[$title]
);
}
Duplicate Finder: Detection Algorithm
The admin-side duplicate finder uses a different approach than the API check. Instead of comparing against incoming content, it scans the entire posts table for slug patterns that suggest accidental duplication. The algorithm identifies slugs ending with -1 through -10 and looks up the base slug (without the numeric suffix) in the same table.
What Gets Flagged
- Posts where both
example-postandexample-post-1exist - Multiple numbered variants:
example-post-1,example-post-2, etc. - Only posts with status
publishedordraftare checked; trashed posts are excluded - The scanner runs as an AJAX request and returns results in under 500ms for most installations
Image Proxy Fallback: Technical Details
When get_featured_image() detects a missing thumbnail variant, it constructs an image proxy URL with explicit dimensions. The proxy endpoint (/includes/image-proxy.php) accepts path, w (width), and h (height) parameters. It reads the original full-size image, resizes it using GD, caches the result as a WebP file in cache/images/, and serves the cached version for all subsequent requests.
Cache Behaviour
Cached proxy images are stored with a filename hash derived from the source path and requested dimensions. Files expire after 7 days or when manually cleared through the admin panel's cache management. The garbage collector runs probabilistically (1% chance per request) to avoid directory bloat.
// Fallback URL construction
$proxyUrl = site_url('/includes/image-proxy.php?path='
. urlencode($post['featured_image'])
. '&w=' . $width
. '&h=' . $height
);
Simultaneous Deployment Across 14 Targets
Deploying to 14 targets required a structured approach. Each target received the same 5 files:
classes/Post.php— checkDuplicate() method addedapi/v1/index.php— 409 checks added to 5 webhook handlersadmin/ajax/find-duplicates.php— new file for duplicate scanningadmin/posts.php— UI button, modal, and JavaScript for duplicate finderincludes/helpers.php— get_featured_image() fallback logic
The deployment was file-only — no SQL migrations, no configuration changes, no service restarts. This made rollback straightforward: replacing any of the five files with its previous version would revert that specific change without affecting the others.