Content Explorer
Get Pro

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 domaincontent-explorer
REST namespacecontent-explorer/v1
PHP namespacesContentExplorer\\ (Free) / ContentExplorerPro\\ (Pro)
Capabilitiesupload_files to read the tree and file media, manage_categories to reshape it, manage_options for settings
Plugin version1.0.0
DB schema version1.9.0
RequiresWordPress 6.2+ (tested to 7.0), PHP 8.2+, MySQL or MariaDB
LicenseGPL 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) and src-pro/ (Pro). The Free zip stages everything except src-pro/, apps/admin-pro/ and assets/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), keeping wp_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.

  1. Upload the zip under Plugins → Add New → Upload, or drop the folder in wp-content/plugins/.
  2. Activate. The schema is created/upgraded automatically on the next admin load (compared against the stored content_explorer_db_version option).
  3. 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.
  4. 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.

EventWhat happens
Network activationLoops get_sites(['number' => 0]) and runs Migrator::migrate() + default options + BackupScheduler::reconcile() under switch_to_blog for every site. Idempotent.
New site joins the networkwp_initialize_site action eagerly provisions tables for the new site, so its first REST / AJAX / cron / admin request finds the schema ready.
Per-site activationWhen 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 deletionwp_uninitialize_site drops every wp_{id}_content_explorer_* table and clears the site’s cron events.
Network deactivationIterates sites and clears the plugin’s scheduled cron on each, so no stale tick fires while the plugin is dormant. Schema stays put.
Network uninstalluninstall.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.

FeatureTierFlag
Folder tree, nesting, reorder, drag-to-folderFree-
Folder colors, manual sort, counts, searchFree-
Bulk create, Manage Folders table, bulk move / recolor / deleteFree-
Keyboard navigation and shortcutsFree-
Import from FileBird / CatFolders / RML / Premio / MLOFree-
SVG upload + sanitization + renderingFree-
Sidebar sizing and radii (Appearance tab)Free-
WP-CLI, reset / danger zone, multisite lifecycleFree-
Display themesProdisplay
Per-user foldersProper_user
Folder download (ZIP)Profolder_download
Folder iconsProfolder_icon
Backups (snapshots & restore)Probackups
Export / import the tree as JSON or CSVProfolder_export
Share linksProshare_links
Smart FoldersProsmart_folders
Multiple folders per filePromulti_folder
Pinned (favorite) foldersPropinned_folders
Folder Gallery blockPro *gallery
Folder Posts blockPro *posts_block
Document Library block + shortcodePro *document_library
Folders for post types (CPT)Pro *cpt_folders
ACF folder fieldPro *acf
SVG advanced toolsProsvg_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.

GateDefault capCovers
content_explorer/capabilities/manageupload_filesRead the tree, see the sidebar, file media into folders. Held by every Author.
content_explorer/capabilities/editmanage_categoriesReshape the tree: create, rename, move, delete, reorder, bulk ops, import, smart-folder CRUD. Editors and Administrators by default.
content_explorer/capabilities/settingsmanage_optionsPOST /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.

Let any uploader reshape the tree
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)

MethodRoutePurpose
GET/foldersThe tree for one type. Args: type (attachment / smart / a CPT slug), counts.
POST/foldersCreate 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/bulkCreate many folders in one call (the bulk-create textarea).
POST/folders/reorderPersist sibling order after a drag.
POST/folders/bulk-deleteDelete a selection from the Manage Folders table.
POST/folders/bulk-updateBulk move or recolor a selection.
GET/folders/exportSerialize the tree (or a selection) as JSON or CSV. Gated on folder_export.
POST/folders/importRead a JSON or CSV export back, reusing folders along each path.
POST/folders/{id}/attachmentsReplace the folder membership of the given attachments (plain move).
PATCH/folders/{id}/attachmentsAdd membership without removing the existing one (multi-folder).
DELETE/folders/{id}/attachmentsDetach attachments from this folder only.
GET/attachmentsPaginated attachments for a folder, used by the block editor pickers and the Document Library editor preview.

Import, settings, backups (Free)

MethodRoutePurpose
GET/import/sourcesDetected third-party plugins with their folder and media counts.
POST/importRun one importer. Args: source, parent. Idempotent and non-destructive.
GET/settingsRead the whole settings object (option content_explorer_settings).
POST/settingsWrite settings. Needs the settings gate.
POST/resetDanger zone. Delegates to Support\Resetter::reset_all() and fires content_explorer/data_reset.
GET/backupsList snapshots (POST creates one). Pro feature, Free schema.
DELETE/backups/{id}Delete one snapshot.
POST/backups/{id}/restoreRestore a snapshot. Takes a pre-restore safety snapshot first.
GET/backups/{id}/downloadStream the snapshot as JSON.

Smart folders, pins, CPT folders (Pro)

MethodRoutePurpose
POST/smart-foldersCreate 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}/countLive match count for a saved folder.
POST/smart-folders/previewMatch count for an unsaved rule draft, as the dialog is edited.
GET/smart-folders/{id}/objectsDeveloper endpoint: paginated, hydrated matches. Args: page, per_page, orderby, order, fields.
POST/folders/{id}/pinPin a folder for the current user. DELETE unpins.
POST/folders/{id}/objectsFile posts or products into a CPT folder. PATCH adds, DELETE detaches.
GET/post-folders/sourcesPost types that are opted in to folders, with their counts.

Download, shares, SVG (Pro)

MethodRoutePurpose
GET/folders/{id}/download/estimateFile count and byte total, so the UI can pick quick-stream or a job.
GET/folders/{id}/downloadStream the ZIP inline for a small folder.
POST/folders/{id}/download/jobsQueue a background ZIP build for a large folder.
GET/download/jobsThe 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}/fileServe the built ZIP. Token-verified, so the browser can fetch it directly.
GET/folders/{id}/sharesShare links for one folder. POST creates one.
GET/sharesEvery share link on the site (the Shares settings tab).
PATCH/shares/{id}Change expiry, password or permissions. DELETE revokes.
GET/svg/summaryHow many SVGs exist and how many are unsanitized.
POST/svg/sanitizeRe-run the sanitizer over existing SVGs, in batches.

Public, token-verified (Pro)

MethodRoutePurpose
GET/gallery/load-moreNext page of a Folder Gallery block.
GET/posts/load-moreNext page of a Folder Posts block.
GET/document-library/dataRows for a Document Library page.
GET/document-library/browseStep 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)

MethodRoutePurpose
GET/licenseCurrent status. Returns the masked key only, never the key or instance token.
POST/licenseActivate a key against this site_url.
DELETE/licenseDeactivate 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

HookTypeWhen
content_explorer/loadedactionEnd of Free boot. The safe place to register your own integration.
content_explorer/rest/register_routesactionRegister your own REST controllers on the plugin namespace.
content_explorer/data_resetactionThe danger zone (or wp content-explorer reset) wiped everything. Drop your own state too.
content_explorer/import/completedactionAn importer finished. Receives the source key and the counts.
content_explorer/admin/screen_contextfilterAdjust which admin screens mount the sidebar.

Gating

HookTypeWhen
content_explorer/feature/<name>filterPer-feature gate. The 16 names are in the feature matrix above.
content_explorer_pro/is_licensedfilterLegacy whole-Pro override, still honored. Returning true unlocks everything.
content_explorer/capabilities/managefilterThe read / file gate. Default upload_files.
content_explorer/capabilities/editfilterThe reshape gate. Default manage_categories.
content_explorer/capabilities/settingsfilterThe settings gate. Default manage_options.
content_explorer/capabilities/access_folderfilterPer-folder veto. Receives (bool $allowed, int $folder_id).
content_explorer/capabilities/list_othersfilterWhether the caller sees attachments uploaded by other users.
content_explorer/capabilities/list_others_objectsfilterThe same question for posts and CPT items.
content_explorer/listing/scope_to_authorfilterForce the media listing to one author, overriding the capability answer.
content_explorer/listing/object_author_scopefilterThe post-side equivalent, applied to the CPT folder counts and listings.

Folders & listings

HookTypeWhen
content_explorer/folder/display_namefilterRewrite a folder name on read. Receives (string $name, int $id). WPML and Polylang translation hangs here.
content_explorer/folder/seenactionA folder name was read. Receives (int $id, string $name). Used to register translatable strings.
content_explorer/folder/specialsfilterAdd or remove the virtual rows (All files, Uncategorized).
content_explorer/folder/counts_by_folderfilterOverride the item counts, for example to cache them yourself.
content_explorer/rest/foldersfilterThe serialized tree, just before it leaves GET /folders.
content_explorer/rest/smart_foldersfilterThe same for the smart-folder list.
content_explorer/rest/pinned_folder_idsfilterThe caller’s pinned ids.
content_explorer/media/visible_folder_idsfilterRestrict which folders the media library may filter to.
content_explorer/smart_folder/object_itemfilterPer-row augmentation for /smart-folders/{id}/objects. Receives (array $item, ?WP_Post $post, SmartQuery $smart).

Pro subsystems

HookTypeWhen
content_explorer/folder_downloadedactionA folder ZIP was served, by either path.
content_explorer/download_job/readyactionA background ZIP finished building.
content_explorer/download_job/servedactionThe built ZIP was handed to a browser.
content_explorer/download/safe_path_rootsfilterExtra directories the ZIP builder may read from (offloaded media).
content_explorer/share/servedactionA public share page was rendered.
content_explorer/backup/createdactionA snapshot was written (auto, manual or pre-restore).
content_explorer/backup/restoredactionA snapshot was restored.
content_explorer/backup/retentionfilterOverride Keep last N.
content_explorer/backup/before_pruneactionFired for each snapshot about to be pruned.
content_explorer/gallery/cache_ttlfilterGallery render cache lifetime, in seconds.
content_explorer/posts/cache_ttlfilterFolder Posts render cache lifetime.
content_explorer/document_library/cache_ttlfilterDocument Library render cache lifetime.
content_explorer/posts/badge_taxonomyfilterWhich taxonomy supplies the card badge.
content_explorer/posts/eyebrow_termfilterThe term shown above a card title.
content_explorer/svg/should_sanitizefilterSkip sanitizing a particular upload.
content_explorer/svg/sanitizerfilterSwap in your own sanitizer instance.
content_explorer/license/changedactionActivation, deactivation or a heartbeat verdict changed the status.
content_explorer/license/is_activefilterThe licensing package own gate.
content_explorer/license/revalidateactionThe 48-hour cron heartbeat.
content_explorer/license/edition/can_usefilterPer-feature edition check inside the shared licensing package.
Two common overrides
// 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.

content_explorer_get_attachments_in_folder( $folder_id, $args = [] )
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] with folder required, the rest optional
[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"]
AttributeValuesDefault
folderFolder ID (alias: id)required
layoutlist · gridlist
paginationnumbered · loadmore · nonenumbered
per_page1–20020
orderbytitle · date · menu_order · idtitle
orderasc · descasc
subfolders · icon · size · date · downloadBoolean. no / false / 0 hides the columntrue

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.

CommandWhat it does
wp content-explorer folder listPrint 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 sourcesList 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 listList snapshots with their type and timestamp.
wp content-explorer backup createTake a snapshot now. --note=<text> labels it.
wp content-explorer backup restore <id> --yesRestore a snapshot, taking a pre-restore safety snapshot first.
wp content-explorer backup delete <id>Delete one snapshot.
wp content-explorer reset --yesDrop 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.

TableHolds
content_explorer_folderEvery folder, of every kind. The type column discriminates: attachment, smart, or a CPT slug. Indexed (type, parent, ord).
content_explorer_attachment_folderAttachment to folder membership, many-to-many. Composite PK (attachment_id, folder_id) plus a per-folder ord.
content_explorer_object_folderThe same relation for posts, pages and products in CPT folders.
content_explorer_smart_queryOne 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_jobBackground ZIP jobs: owner, status enum, cursor, byte totals.
content_explorer_sharePublic share links: slug, expiry, password hash, permissions, fingerprint.
content_explorer_backupSnapshots of the tree, with the Auto / Manual / Pre-restore type.

What lives outside the tables

KeyWhereHolds
content_explorer_settingsoptionThe whole settings object, read and written by /settings.
content_explorer_db_versionoptionThe applied schema version. Compared against Migrator::DB_VERSION on admin_init.
content_explorer_licenseoption (autoload off)License status, key and instance token. Pro only.
content_explorer_pinned_foldersuser metaPinned 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.

WhatDetail
Shipped locales29 .po/.mo pairs, from ar through zh_TW, plus the generated .json files that wp-i18n reads for the React bundles.
Script translationsEach bundle registers its own handle (content-explorer-admin, content-explorer-main, content-explorer-frontend-doclib), so a locale ships one .json per handle.
Regeneratingmakepot.sh extracts the .pot and rebuilds the .json set. Run it after adding strings.
RTLArabic 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.

TokenValue
CONTENT_EXPLORER_LICENSE_APIhttps://license.flexacommerce.com/api/plugin/v1
CONTENT_EXPLORER_PRODUCT_SLUGcontent-explorer
CONTENT_EXPLORER_UPGRADE_URLhttps://flexacommerce.com/products/content-explorer
Plugin-side filessrc-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 projectionLicense tab shows
pro: trueGreen "Pro edition active" banner and the full license card.
has_build: true, licensed: falseAmber "Activate to unlock Pro" banner and the activate form.
has_build: falseGrey "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_site and on network activation, removed on site deletion and network uninstall.
  • Pro: Smart Folders, for attachments and for post, page and opted-in custom post types. Each smart folder is locked to one post type by content_explorer_smart_query.post_type. Rule fields include author, taxonomy (compiled to an IN subquery on term_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}/objects with its content_explorer/smart_folder/object_item per-row filter. PHP equivalent: SmartQueryResolver->object_ids() and resolve_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.