Technical reference · v1.0.0
Content Explorer for developers
Architecture, REST API, extension hooks, template helpers, WP-CLI and the database schema. For the end-user walkthrough see the user guide.
Introduction
Overview
Content Explorer organizes the WordPress Media Library (and, with Pro, other post types) into a drag-and-drop folder tree. It is built on a custom two-table schema rather than WordPress taxonomies, and ships a React admin app mounted into the native Media Library.
| Detail | |
|---|---|
| Text domain | content-explorer |
| REST namespace | content-explorer/v1 |
| PHP namespaces | ContentExplorer\\ (Free) / ContentExplorerPro\\ (Pro) |
| Capabilities | upload_files to read the tree and file media, manage_categories to reshape it, manage_options for settings |
| Plugin version | 1.0.0 |
| DB schema version | 1.9.0 |
| Requires | WordPress 6.2+ (tested to 7.0), PHP 8.2+, MySQL or MariaDB |
| License | GPL v2 or later |
Design
Architecture
Free and Pro live in the same codebase and ship as two zips. The difference is which files the distribution includes.
- Superset distribution. One Composer autoload map covers
src/(Free) andsrc-pro/(Pro). The Free zip stages everything exceptsrc-pro/,apps/admin-pro/andassets/dist-pro/. - File-absence gating. Pro is not a runtime flag - in a Free build the Pro classes simply have no files to autoload, so
class_exists(\\ContentExplorerPro\\Plugin::class)is false and no Pro path can run. - License contract.
apply_filters('content_explorer_pro/is_licensed', false)is the single gate Pro reads. A separate license-stub plugin supplies the real answer; without it, Pro defaults back to Free behavior. - No taxonomy bloat. Folders live in dedicated tables indexed on
(type, parent, ord), keepingwp_term*clean and ordered tree reads to one query.
Front-end vs admin
The admin app is React + Vite + Tailwind, mounted into the Backbone-managed Media Library via a bridge. Four visitor-facing surfaces exist, all Pro: the Folder Gallery, Folder Posts and Document Library blocks (the last also available as the [content_explorer_document_library] shortcode), and the public share-link download page.
There are four build targets: apps/admin (Free admin, Vite), apps/admin-pro (Pro admin overlay, Vite), apps/block (the Gutenberg blocks, wp-scripts), and apps/frontend (the Document Library’s visitor-side React, Vite). Each dev-server swap is a marker file; production enqueues read the Vite manifest.
Setup
Installation
Install like any plugin. Both zips use the same slug (content-explorer), so installing Pro overlays a Free install rather than creating a sibling.
- Upload the zip under Plugins → Add New → Upload, or drop the folder in
wp-content/plugins/. - Activate. The schema is created/upgraded automatically on the next admin load (compared against the stored
content_explorer_db_versionoption). - For Pro, activate the site under Content Explorer → Settings → License. The license client lives entirely in
src-pro/, so a Free build has no key store, no REST route and no heartbeat at all. - In development, short-circuit the whole thing with
add_filter('content_explorer_pro/is_licensed', '__return_true')from an mu-plugin.
Requirements
WordPress 6.2+ (tested up to 7.0), PHP 8.2+, MySQL or MariaDB. SVG sanitization uses the vendored enshrined/svg-sanitize library (shipped in both zips’ vendor/). Folder ZIP downloads need PHP’s ZipArchive extension; WooCommerce is optional and only enables product folders and the price field on the Folder Posts block.
Multisite
Every plugin table is read through $wpdb->prefix, so each site in a network gets its own independent folder tree, smart folders, share links, settings, and cron. Nothing leaks across sites. The SiteLifecycle service hooks WordPress’s network lifecycle so the per-site tables exist exactly when WP says the site exists.
No schema changes for multisite
Multisite support is lifecycle only: there is no site_id column on any table, no new index, and DB_VERSION is unchanged. Multisite simply means the same schema is created N times under N different wp_{id}_ prefixes, the same model WP core uses for wp_posts → wp_2_posts → … An existing 1.x single-site install upgrades cleanly: its existing tables become the main site’s table-set, no rows move.
| Event | What happens |
|---|---|
| Network activation | Loops get_sites(['number' => 0]) and runs Migrator::migrate() + default options + BackupScheduler::reconcile() under switch_to_blog for every site. Idempotent. |
| New site joins the network | wp_initialize_site action eagerly provisions tables for the new site, so its first REST / AJAX / cron / admin request finds the schema ready. |
| Per-site activation | When the plugin is activated on a single site only (not network-wide), only that site is provisioned; other sites stay clean. |
| First admin request (lazy safety net) | The admin_init listener calls Migrator::maybe_upgrade(), so any site that somehow missed both the activation loop and wp_initialize_site self-heals. |
| Site deletion | wp_uninitialize_site drops every wp_{id}_content_explorer_* table and clears the site’s cron events. |
| Network deactivation | Iterates sites and clears the plugin’s scheduled cron on each, so no stale tick fires while the plugin is dormant. Schema stays put. |
| Network uninstall | uninstall.php walks every site and drops all per-site tables + options. No wp_{id}_content_explorer_* rows are left behind. |
No admin UI is added under the network admin surface. Every plugin page lives under each site’s wp-admin where it belongs.
What ships where
Feature matrix
Every paid feature publishes one flag, content_explorer/feature/<name>, defaulting to false. src-pro/Plugin.php hooks each one and returns true while the license is active; src/Admin/Enqueue.php copies the answers into window.contentExplorer.features for the React apps. Most Free surfaces render and route their clicks to the upgrade prompt. The three blocks are the exception: they are visitor-facing, so an unlicensed site never calls register_block_type and there is nothing to lock.
| Feature | Tier | Flag |
|---|---|---|
| Folder tree, nesting, reorder, drag-to-folder | Free | - |
| Folder colors, manual sort, counts, search | Free | - |
| Bulk create, Manage Folders table, bulk move / recolor / delete | Free | - |
| Keyboard navigation and shortcuts | Free | - |
| Import from FileBird / CatFolders / RML / Premio / MLO | Free | - |
| SVG upload + sanitization + rendering | Free | - |
| Sidebar sizing and radii (Appearance tab) | Free | - |
| WP-CLI, reset / danger zone, multisite lifecycle | Free | - |
| Display themes | Pro | display |
| Per-user folders | Pro | per_user |
| Folder download (ZIP) | Pro | folder_download |
| Folder icons | Pro | folder_icon |
| Backups (snapshots & restore) | Pro | backups |
| Export / import the tree as JSON or CSV | Pro | folder_export |
| Share links | Pro | share_links |
| Smart Folders | Pro | smart_folders |
| Multiple folders per file | Pro | multi_folder |
| Pinned (favorite) folders | Pro | pinned_folders |
| Folder Gallery block | Pro * | gallery |
| Folder Posts block | Pro * | posts_block |
| Document Library block + shortcode | Pro * | document_library |
| Folders for post types (CPT) | Pro * | cpt_folders |
| ACF folder field | Pro * | acf |
| SVG advanced tools | Pro | svg_advanced |
* No Free surface at all. The flag is published for completeness; nothing in the Free UI reads it.
Anti-piracy is file absence, not a flag
The Free zip ships no src-pro/, apps/admin-pro/, assets/dist-pro/, assets/block/ or assets/dist-frontend/. There is no boolean to flip: the classes that would run a Pro code path have no file to autoload.
Permissions
Capabilities & permissions
Three gates cover every authenticated route and admin surface, each one filterable so a site can move it.
| Gate | Default cap | Covers |
|---|---|---|
content_explorer/capabilities/manage | upload_files | Read the tree, see the sidebar, file media into folders. Held by every Author. |
content_explorer/capabilities/edit | manage_categories | Reshape the tree: create, rename, move, delete, reorder, bulk ops, import, smart-folder CRUD. Editors and Administrators by default. |
content_explorer/capabilities/settings | manage_options | POST /settings, POST /reset, and the settings screen. |
content_explorer/capabilities/access_folder | (bool, int $folder_id) | Per-folder access gate, evaluated on top of the above. |
Filing attachments stays on the manage gate but is additionally checked per item with edit_post, so an Author can file their own uploads and not someone else’s. With per-user folders on, Capabilities::current_owner_scope() returns the caller id and every folder read, every mutation and the underlying WP_Query are narrowed to folders they created; mutations against another owner’s folder return 403.
add_filter(
'content_explorer/capabilities/edit',
static fn (): string => 'upload_files'
);Public endpoints are token-gated, never capability-gated
The gallery, posts and document-library load-more endpoints and the share-link page are reachable by logged-out visitors, so they carry no capability check at all. They verify an HMAC token instead. The token signs only what must be authorized (the published root or the render-attribute tuple); navigation parameters like page, sort and folder are validated (clamped, and guarded against escaping the published subtree) rather than signed, so a visitor paging through a library cannot reach anything an editor did not publish.
HTTP
REST API reference
Every route lives under /wp-json/content-explorer/v1/. Authenticated routes resolve their permission_callback through BaseRestController, so they follow the three gates in Capabilities and move with the filters there. Pro controllers register only on a licensed install, so an unlicensed site answers 404 for the Pro table rather than 403.
Folders (Free)
| Method | Route | Purpose |
|---|---|---|
| GET | /folders | The tree for one type. Args: type (attachment / smart / a CPT slug), counts. |
| POST | /folders | Create one folder (name, parent, color, icon, type). |
| PATCH | /folders/{id} | Rename, recolor, change the icon, or reparent. Loop-forming moves are rejected. |
| DELETE | /folders/{id} | Delete the folder and its subtree. Files are unfiled, never deleted. |
| POST | /folders/bulk | Create many folders in one call (the bulk-create textarea). |
| POST | /folders/reorder | Persist sibling order after a drag. |
| POST | /folders/bulk-delete | Delete a selection from the Manage Folders table. |
| POST | /folders/bulk-update | Bulk move or recolor a selection. |
| GET | /folders/export | Serialize the tree (or a selection) as JSON or CSV. Gated on folder_export. |
| POST | /folders/import | Read a JSON or CSV export back, reusing folders along each path. |
| POST | /folders/{id}/attachments | Replace the folder membership of the given attachments (plain move). |
| PATCH | /folders/{id}/attachments | Add membership without removing the existing one (multi-folder). |
| DELETE | /folders/{id}/attachments | Detach attachments from this folder only. |
| GET | /attachments | Paginated attachments for a folder, used by the block editor pickers and the Document Library editor preview. |
Import, settings, backups (Free)
| Method | Route | Purpose |
|---|---|---|
| GET | /import/sources | Detected third-party plugins with their folder and media counts. |
| POST | /import | Run one importer. Args: source, parent. Idempotent and non-destructive. |
| GET | /settings | Read the whole settings object (option content_explorer_settings). |
| POST | /settings | Write settings. Needs the settings gate. |
| POST | /reset | Danger zone. Delegates to Support\Resetter::reset_all() and fires content_explorer/data_reset. |
| GET | /backups | List snapshots (POST creates one). Pro feature, Free schema. |
| DELETE | /backups/{id} | Delete one snapshot. |
| POST | /backups/{id}/restore | Restore a snapshot. Takes a pre-restore safety snapshot first. |
| GET | /backups/{id}/download | Stream the snapshot as JSON. |
Smart folders, pins, CPT folders (Pro)
| Method | Route | Purpose |
|---|---|---|
| POST | /smart-folders | Create a smart folder. post_type locks which tree it lives in. |
| GET | /smart-folders/{id} | Read its rules. PATCH updates, DELETE removes folder + rules. |
| GET | /smart-folders/{id}/count | Live match count for a saved folder. |
| POST | /smart-folders/preview | Match count for an unsaved rule draft, as the dialog is edited. |
| GET | /smart-folders/{id}/objects | Developer endpoint: paginated, hydrated matches. Args: page, per_page, orderby, order, fields. |
| POST | /folders/{id}/pin | Pin a folder for the current user. DELETE unpins. |
| POST | /folders/{id}/objects | File posts or products into a CPT folder. PATCH adds, DELETE detaches. |
| GET | /post-folders/sources | Post types that are opted in to folders, with their counts. |
Download, shares, SVG (Pro)
| Method | Route | Purpose |
|---|---|---|
| GET | /folders/{id}/download/estimate | File count and byte total, so the UI can pick quick-stream or a job. |
| GET | /folders/{id}/download | Stream the ZIP inline for a small folder. |
| POST | /folders/{id}/download/jobs | Queue a background ZIP build for a large folder. |
| GET | /download/jobs | The caller’s own jobs with status and progress. |
| DELETE | /download/jobs/{id} | Cancel a job or discard a finished one (GET reads a single job). |
| GET | /download/jobs/{id}/file | Serve the built ZIP. Token-verified, so the browser can fetch it directly. |
| GET | /folders/{id}/shares | Share links for one folder. POST creates one. |
| GET | /shares | Every share link on the site (the Shares settings tab). |
| PATCH | /shares/{id} | Change expiry, password or permissions. DELETE revokes. |
| GET | /svg/summary | How many SVGs exist and how many are unsanitized. |
| POST | /svg/sanitize | Re-run the sanitizer over existing SVGs, in batches. |
Public, token-verified (Pro)
| Method | Route | Purpose |
|---|---|---|
| GET | /gallery/load-more | Next page of a Folder Gallery block. |
| GET | /posts/load-more | Next page of a Folder Posts block. |
| GET | /document-library/data | Rows for a Document Library page. |
| GET | /document-library/browse | Step into a subfolder inside a Document Library, within the published subtree. |
What the token signs
These four carry no capability check. Each verifies an HMAC token derived from the published root and the render attributes, so a visitor cannot repoint a library at a folder the editor never published. Page, sort and folder arrive unsigned and are clamped and subtree-checked instead, which is what lets the same token serve every page of a paginated library.
License (Pro)
| Method | Route | Purpose |
|---|---|---|
| GET | /license | Current status. Returns the masked key only, never the key or instance token. |
| POST | /license | Activate a key against this site_url. |
| DELETE | /license | Deactivate and free the seat. |
Extensibility
Hooks & filters
Free publishes the whole contract surface; Pro consumes most of it. Nothing here is namespaced per feature, so a filter added for a Free install keeps working after an upgrade.
Lifecycle
| Hook | Type | When |
|---|---|---|
content_explorer/loaded | action | End of Free boot. The safe place to register your own integration. |
content_explorer/rest/register_routes | action | Register your own REST controllers on the plugin namespace. |
content_explorer/data_reset | action | The danger zone (or wp content-explorer reset) wiped everything. Drop your own state too. |
content_explorer/import/completed | action | An importer finished. Receives the source key and the counts. |
content_explorer/admin/screen_context | filter | Adjust which admin screens mount the sidebar. |
Gating
| Hook | Type | When |
|---|---|---|
content_explorer/feature/<name> | filter | Per-feature gate. The 16 names are in the feature matrix above. |
content_explorer_pro/is_licensed | filter | Legacy whole-Pro override, still honored. Returning true unlocks everything. |
content_explorer/capabilities/manage | filter | The read / file gate. Default upload_files. |
content_explorer/capabilities/edit | filter | The reshape gate. Default manage_categories. |
content_explorer/capabilities/settings | filter | The settings gate. Default manage_options. |
content_explorer/capabilities/access_folder | filter | Per-folder veto. Receives (bool $allowed, int $folder_id). |
content_explorer/capabilities/list_others | filter | Whether the caller sees attachments uploaded by other users. |
content_explorer/capabilities/list_others_objects | filter | The same question for posts and CPT items. |
content_explorer/listing/scope_to_author | filter | Force the media listing to one author, overriding the capability answer. |
content_explorer/listing/object_author_scope | filter | The post-side equivalent, applied to the CPT folder counts and listings. |
Folders & listings
| Hook | Type | When |
|---|---|---|
content_explorer/folder/display_name | filter | Rewrite a folder name on read. Receives (string $name, int $id). WPML and Polylang translation hangs here. |
content_explorer/folder/seen | action | A folder name was read. Receives (int $id, string $name). Used to register translatable strings. |
content_explorer/folder/specials | filter | Add or remove the virtual rows (All files, Uncategorized). |
content_explorer/folder/counts_by_folder | filter | Override the item counts, for example to cache them yourself. |
content_explorer/rest/folders | filter | The serialized tree, just before it leaves GET /folders. |
content_explorer/rest/smart_folders | filter | The same for the smart-folder list. |
content_explorer/rest/pinned_folder_ids | filter | The caller’s pinned ids. |
content_explorer/media/visible_folder_ids | filter | Restrict which folders the media library may filter to. |
content_explorer/smart_folder/object_item | filter | Per-row augmentation for /smart-folders/{id}/objects. Receives (array $item, ?WP_Post $post, SmartQuery $smart). |
Pro subsystems
| Hook | Type | When |
|---|---|---|
content_explorer/folder_downloaded | action | A folder ZIP was served, by either path. |
content_explorer/download_job/ready | action | A background ZIP finished building. |
content_explorer/download_job/served | action | The built ZIP was handed to a browser. |
content_explorer/download/safe_path_roots | filter | Extra directories the ZIP builder may read from (offloaded media). |
content_explorer/share/served | action | A public share page was rendered. |
content_explorer/backup/created | action | A snapshot was written (auto, manual or pre-restore). |
content_explorer/backup/restored | action | A snapshot was restored. |
content_explorer/backup/retention | filter | Override Keep last N. |
content_explorer/backup/before_prune | action | Fired for each snapshot about to be pruned. |
content_explorer/gallery/cache_ttl | filter | Gallery render cache lifetime, in seconds. |
content_explorer/posts/cache_ttl | filter | Folder Posts render cache lifetime. |
content_explorer/document_library/cache_ttl | filter | Document Library render cache lifetime. |
content_explorer/posts/badge_taxonomy | filter | Which taxonomy supplies the card badge. |
content_explorer/posts/eyebrow_term | filter | The term shown above a card title. |
content_explorer/svg/should_sanitize | filter | Skip sanitizing a particular upload. |
content_explorer/svg/sanitizer | filter | Swap in your own sanitizer instance. |
content_explorer/license/changed | action | Activation, deactivation or a heartbeat verdict changed the status. |
content_explorer/license/is_active | filter | The licensing package own gate. |
content_explorer/license/revalidate | action | The 48-hour cron heartbeat. |
content_explorer/license/edition/can_use | filter | Per-feature edition check inside the shared licensing package. |
// Let anyone who can upload also reshape the tree.
add_filter( 'content_explorer/capabilities/edit', static fn (): string => 'upload_files' );
// Unlock Pro on a development site, with no license.
add_filter( 'content_explorer_pro/is_licensed', '__return_true' );Theme code
Template helpers
Pro registers a global helper for reading a folder’s attachments from a theme or template. Always guard it with function_exists(): it only loads on a licensed Pro install.
if ( function_exists( 'content_explorer_get_attachments_in_folder' ) ) {
$images = content_explorer_get_attachments_in_folder(
$folder_id,
[ 'posts_per_page' => 12, 'orderby' => 'date', 'order' => 'DESC' ]
);
foreach ( $images as $image ) {
echo wp_get_attachment_image( $image->ID, 'large' );
}
}Returns the folder’s attachments as an array of post objects. $args is merged into the query, but post__in is forced last so it cannot widen the scope beyond the folder.
Smart folders from PHP
SmartQueryResolver->object_ids() returns the ids a smart folder currently matches, and resolve_query() returns the WP_Query arguments behind them, so a template can add its own clauses rather than re-filtering a finished list. Both are the same code path the /smart-folders/{id}/objects endpoint uses.
Document Library shortcode
[content_explorer_document_library] renders the Document Library outside the block editor (classic editor, widgets, page builders). It is a thin adapter over the block’s render path, so the markup, the HMAC-signed REST navigation and the front-end React enhancement are identical. Registered on a licensed Pro install only.
[content_explorer_document_library folder="123"]
[content_explorer_document_library folder="123"
layout="grid" per_page="24" pagination="loadmore"
orderby="date" order="desc" subfolders="no" download="no"]| Attribute | Values | Default |
|---|---|---|
folder | Folder ID (alias: id) | required |
layout | list · grid | list |
pagination | numbered · loadmore · none | numbered |
per_page | 1–200 | 20 |
orderby | title · date · menu_order · id | title |
order | asc · desc | asc |
subfolders · icon · size · date · download | Boolean. no / false / 0 hides the column | true |
Command line
WP-CLI commands
Commands register under wp content-explorer whenever WP-CLI is loaded. They run as the CLI user, so the capability gates do not apply; everything else (loop checks, idempotent imports, unfiling rather than deleting) behaves exactly as the REST routes do. Add --url= on multisite to pick a site.
| Command | What it does |
|---|---|
wp content-explorer folder list | Print the tree with ids, parents, colors and counts. |
wp content-explorer folder create <name> | Create a folder. Options: --parent=<id>, --folder-color=#rrggbb. |
wp content-explorer folder rename <id> <name> | Rename one folder. |
wp content-explorer folder move <id> --parent=<id> | Reparent. Use --parent=0 for the top level; loops are refused. |
wp content-explorer folder delete <id> | Delete a folder. --reparent-to=<id> keeps its children instead of deleting the subtree. |
wp content-explorer folder assign <ids> --folder=<id> | File a comma-separated list of attachment ids into a folder. |
wp content-explorer import sources | List detected third-party plugins with their folder and media counts. |
wp content-explorer import run <source> | Run one importer (filebird, catfolders, real-media-library, premio-folders, media-library-organizer). --parent=<id> nests the result. |
wp content-explorer backup list | List snapshots with their type and timestamp. |
wp content-explorer backup create | Take a snapshot now. --note=<text> labels it. |
wp content-explorer backup restore <id> --yes | Restore a snapshot, taking a pre-restore safety snapshot first. |
wp content-explorer backup delete <id> | Delete one snapshot. |
wp content-explorer reset --yes | Drop every folder and relation. Same Resetter::reset_all() path as the REST danger zone. |
reset and restore are destructive
Both go through WP_CLI::confirm(), which --yes skips. reset removes folders and relations, not media. backup restore replaces the current tree with the snapshot’s.
Storage
Database schema
Seven tables, each read through $wpdb->prefix, so on multisite every site in the network gets its own set the way wp_posts becomes wp_2_posts. There is no site_id column. The relation and Pro tables are created by Free, so they always exist and a data reset can always clear them.
| Table | Holds |
|---|---|
content_explorer_folder | Every folder, of every kind. The type column discriminates: attachment, smart, or a CPT slug. Indexed (type, parent, ord). |
content_explorer_attachment_folder | Attachment to folder membership, many-to-many. Composite PK (attachment_id, folder_id) plus a per-folder ord. |
content_explorer_object_folder | The same relation for posts, pages and products in CPT folders. |
content_explorer_smart_query | One row per smart folder (PK folder_id): match_type, the rules as JSON, and post_type, which decides which tree the folder lives in. Added in DB 1.9.0 with a DEFAULT, so pre-1.9.0 rows read back as attachment with no data migration. |
content_explorer_download_job | Background ZIP jobs: owner, status enum, cursor, byte totals. |
content_explorer_share | Public share links: slug, expiry, password hash, permissions, fingerprint. |
content_explorer_backup | Snapshots of the tree, with the Auto / Manual / Pre-restore type. |
What lives outside the tables
| Key | Where | Holds |
|---|---|---|
content_explorer_settings | option | The whole settings object, read and written by /settings. |
content_explorer_db_version | option | The applied schema version. Compared against Migrator::DB_VERSION on admin_init. |
content_explorer_license | option (autoload off) | License status, key and instance token. Pro only. |
content_explorer_pinned_folders | user meta | Pinned folder ids, per user. A pin is personal, so it never belongs in the folder table. |
Cron: content_explorer_backup_tick drives scheduled snapshots, content_explorer_cleanup_download_jobs and content_explorer_cleanup_download_tmp sweep finished ZIPs, content_explorer_cleanup_shares retires expired links, and the licensing package adds a content_explorer_two_days interval for its 48-hour heartbeat.
Migrations
Install\Migrator owns DB_VERSION and runs on admin_init, comparing it against the stored option. Every step is written to be idempotent (dbDelta, guarded ADD COLUMN), so a half-finished upgrade repairs itself on the next request. Install\SiteLifecycle provisions a new site’s table set on wp_initialize_site and on network activation, and drops it on wp_uninitialize_site and network uninstall. Bump DB_VERSION whenever the schema changes.
Translation
Internationalization
Text domain content-explorer, domain path /i18n/languages. There is no load_plugin_textdomain() call: WordPress has loaded textdomains just in time since 4.6, so both the PHP and the wp-i18n JavaScript strings resolve on their own.
| What | Detail |
|---|---|
| Shipped locales | 29 .po/.mo pairs, from ar through zh_TW, plus the generated .json files that wp-i18n reads for the React bundles. |
| Script translations | Each bundle registers its own handle (content-explorer-admin, content-explorer-main, content-explorer-frontend-doclib), so a locale ships one .json per handle. |
| Regenerating | makepot.sh extracts the .pot and rebuilds the .json set. Run it after adding strings. |
| RTL | Arabic and Hebrew are shipped; the admin sidebar and both block front ends mirror from the WP admin direction. |
Folder names (WPML and Polylang)
Folder names are user data, so gettext cannot reach them. Support\I18nIntegration bridges them into the string translation APIs instead, and only wires itself up when WPML String Translation or Polylang is actually present. It registers each name under the string context Content Explorer on content_explorer/folder/seen, deduplicated per request, and translates on read through content_explorer/folder/display_name.
Attachment language is left alone
Both plugins already filter attachments by language, so Content Explorer does not. The folder JOIN added by Media\QueryFilter sits alongside that meta filter rather than competing with it, which is why a translated media library still filters correctly by folder.
Pro
Licensing client
The license client is the shared flexa/plugin-licensing package, consumed rather than reimplemented. It lives entirely in src-pro/, so a Free install has no client, no /license route and no heartbeat: there is nothing to patch and nothing to flip.
| Token | Value |
|---|---|
CONTENT_EXPLORER_LICENSE_API | https://license.flexacommerce.com/api/plugin/v1 |
CONTENT_EXPLORER_PRODUCT_SLUG | content-explorer |
CONTENT_EXPLORER_UPGRADE_URL | https://flexacommerce.com/products/content-explorer |
| Plugin-side files | src-pro/License/License.php, apps/admin/src/lib/edition.ts, apps/admin/src/features/license/LicensePanel.tsx and nothing else |
All three constants are declared with defined(...) || define(...) in the bootstrap, so pointing a staging site at another server is a one-liner in wp-config.php. The API base must stay HTTPS: the license key and instance token travel over it.
The three-state License tab
| Edition projection | License tab shows |
|---|---|
pro: true | Green "Pro edition active" banner and the full license card. |
has_build: true, licensed: false | Amber "Activate to unlock Pro" banner and the activate form. |
has_build: false | Grey "Free edition" banner and an upgrade link. No activate form at all. |
Free’s side of that is Enqueue::edition_projection(), which publishes has_build => false when ContentExplorerPro\License\License does not exist. Because src-pro/ is by definition present when the client runs, Config::$pro_build stays null here and is_pro() reduces to “license active”.
Transport failure is not a verdict
A definite “no” from the server (revoked, expired, seat gone) downgrades the license. A timeout, a 5xx or a 429 does not: the last good status stands through a seven-day offline grace window, and the 48-hour heartbeat keeps retrying. Conflating the two would disable a paying site on a flaky network. The browser never talks to the license server directly, and the plugin’s own REST returns the masked key only.
History
Changelog
v1.0.0: first release
Ships DB schema 1.9.0. The schema version moves independently of the plugin version, because both folder trees were developed against a stepwise migrator; a fresh install simply lands on 1.9.0.
- Free: folder tree with nesting, reorder and drag-to-folder; media library and modal filtering; folder colors, manual sort, live counts and search; bulk create and the Manage Folders table with bulk move, recolor and delete; full keyboard navigation with inline rename; settings, danger zone and WP-CLI; dark mode and an a11y pass.
- Free importers: FileBird, CatFolders, Real Media Library, Folders (Premio) and Media Library Organizer, all non-destructive and idempotent.
- Free: SVG upload with sanitization through
enshrined/svg-sanitize, and correct rendering in the library. - Free: multisite lifecycle. Per-site table sets, provisioned on
wp_initialize_siteand on network activation, removed on site deletion and network uninstall. - Pro: Smart Folders, for attachments and for
post,pageand opted-in custom post types. Each smart folder is locked to one post type bycontent_explorer_smart_query.post_type. Rule fields include author, taxonomy (compiled to an IN subquery onterm_relationships) and date (before, after, between, day-boundary inclusive), combined with match-all or match-any. - Pro: multiple folders per file, pinned folders, folder icons, display themes, per-user folders, folder download as a ZIP (quick-stream and background jobs), share links, backups with restore, and JSON/CSV export and import of the tree.
- Pro blocks: Folder Gallery, Folder Posts and Document Library, the last with a
[content_explorer_document_library]shortcode. All three paginate through HMAC-token-gated public endpoints. - Pro: folders for post types, an ACF folder field, SVG advanced tools, and the developer endpoint
GET /smart-folders/{id}/objectswith itscontent_explorer/smart_folder/object_itemper-row filter. PHP equivalent:SmartQueryResolver->object_ids()andresolve_query(). - Download, Share, Folder Gallery and Folder Posts stay attachment-only. They package items of one shape, so a non-matching smart folder is refused at the door with a distinct error code.