+ render_past_file_jobs_table( $settings ); ?>
connection_error ) ) : ?>
- settings_preview ) ) : ?>
+ settings_preview ) && ( $this->import_preview_channel === 'api' || $this->import_preview_channel === 'file' ) ) : ?>
render_import_modal_and_progress(); ?>
@@ -919,9 +930,58 @@ private function process_file_upload( array $settings ) : void {
return;
}
- $transient_key = 'dt_migration_file_payload_' . get_current_user_id();
- set_transient( $transient_key, $payload, 15 * MINUTE_IN_SECONDS );
+ if ( ! class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) ) {
+ $this->connection_error = esc_html__( 'Migration file jobs are not available. Please reload the page.', 'disciple-tools-migration' );
+ return;
+ }
+
+ $raw_name = isset( $_FILES['dt_migration_import_file']['name'] ) ? (string) wp_unslash( $_FILES['dt_migration_import_file']['name'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Client-provided upload name; sanitized with sanitize_file_name() on next line. Nonce verified above.
+ $filename = ( $raw_name !== '' ) ? sanitize_file_name( $raw_name ) : 'export.json';
+ $job_id = Disciple_Tools_Migration_File_Job_Store::create_job( get_current_user_id(), $payload, $filename );
+ if ( $job_id === '' ) {
+ $this->connection_error = esc_html__( 'Could not store the migration file for import. The server may be out of space or the export may be too large for your database settings.', 'disciple-tools-migration' );
+ return;
+ }
+ $this->active_file_job_id = $job_id;
+ $this->build_file_preview_state_from_payload( $payload );
+ if ( class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) ) {
+ Disciple_Tools_Migration_File_Job_Store::prune_expired_jobs();
+ }
+ }
+
+ /**
+ * Fills preview state from a GET ?file_job= request (retry a past job).
+ *
+ * @param array $settings
+ * @return void
+ */
+ private function try_load_file_job_from_query( array $settings ) : void {
+ if ( ! class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) || $this->settings_preview !== null ) {
+ return;
+ }
+ if ( ! isset( $_GET['file_job'] ) ) {
+ return;
+ }
+ $raw = sanitize_text_field( wp_unslash( $_GET['file_job'] ) );
+ $id = Disciple_Tools_Migration_File_Job_Store::sanitize_job_id( $raw );
+ if ( $id === '' ) {
+ return;
+ }
+ $user_id = get_current_user_id();
+ $payload = Disciple_Tools_Migration_File_Job_Store::get_payload( $user_id, $id );
+ if ( $payload === null || ! Disciple_Tools_Migration_File_Job_Store::is_valid_migration_payload( $payload ) ) {
+ $this->connection_error = esc_html__( 'That migration file job was not found, completed without a retriable copy, or you do not have access. Upload the JSON again.', 'disciple-tools-migration' );
+ return;
+ }
+ $this->active_file_job_id = $id;
+ $this->build_file_preview_state_from_payload( $payload );
+ }
+ /**
+ * @param array $payload Full export payload.
+ * @return void
+ */
+ private function build_file_preview_state_from_payload( array $payload ) : void {
$dt_settings = $payload['export']['dt_settings'] ?? [];
$post_types = $dt_settings['dt_post_types_settings']['values'] ?? [];
$tiles_all = $dt_settings['dt_tiles_settings']['values'] ?? [];
@@ -947,4 +1007,111 @@ private function process_file_upload( array $settings ) : void {
$this->import_preview_channel = 'file';
}
+
+ /**
+ * Renders a table of past file import jobs for the current user.
+ *
+ * @param array $settings
+ * @return void
+ */
+ private function render_past_file_jobs_table( array $settings ) : void {
+ if ( ! class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) || empty( $settings['enabled'] ) ) {
+ return;
+ }
+ $user_id = get_current_user_id();
+ $rows = Disciple_Tools_Migration_File_Job_Store::list_jobs_for_user( $user_id );
+ $page_token = Disciple_Tools_Migration_Menu::instance()->token;
+ $url_base = add_query_arg(
+ [
+ 'page' => $page_token,
+ 'tab' => 'import',
+ ],
+ admin_url( 'admin.php' )
+ );
+ ?>
+
|
@@ -187,7 +214,25 @@ public function process_form_fields(): void {
$settings['allowed_items']['records'][ $post_type ] = ! empty( $incoming_records[ $post_type ] );
}
+ $days = isset( $post_vars['dt_migration_file_job_max_age_days'] ) ? (int) $post_vars['dt_migration_file_job_max_age_days'] : 7;
+ if ( $days < 1 ) {
+ $days = 1;
+ }
+ if ( $days > 365 ) {
+ $days = 365;
+ }
+ if ( ! isset( $settings['file'] ) || ! is_array( $settings['file'] ) ) {
+ $settings['file'] = [
+ 'compression' => 'zip',
+ ];
+ }
+ $settings['file']['job_max_age_days'] = $days;
+
Disciple_Tools_Migration_Menu::update_settings( $settings );
+
+ if ( class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) ) {
+ Disciple_Tools_Migration_File_Job_Store::prune_expired_jobs();
+ }
}
/**
@@ -224,4 +269,4 @@ public function right_column( array $settings ) {
= phases.length ) {
+ notifyFileJobCompleteIfNeeded();
setImportSpinner( false );
setProgress( 100 );
$currentPhase.text(
@@ -425,6 +486,7 @@
markStepActive( currentPhaseIndex );
runPhase( phase ).then( function( result ) {
if ( result && result.cancelled ) {
+ notifyFileJobCancelledIfNeeded();
setImportSpinner( false );
$currentPhase.text( 'Import cancelled.' );
$cancelImport.hide();
@@ -435,6 +497,7 @@
currentPhaseIndex++;
startNextPhase();
} ).catch( function( err ) {
+ notifyFileJobFailedIfNeeded();
setImportSpinner( false );
$currentPhase.text( 'Import failed.' );
showError( err );
@@ -480,6 +543,36 @@
$pfProceed = $( '.dt-migration-preflight-proceed' );
$pfClose = $( '.dt-migration-preflight-close' );
+ // Recent-jobs Delete works on any page with the jobs table, even when no
+ // upload preview is visible. Register before the modal guard below.
+ $( document ).on( 'click', '.dt-migration-file-job-delete', function( e ) {
+ e.preventDefault();
+ const $btn = $( this );
+ const id = $btn.data( 'job-id' );
+ if ( ! id || ! window.confirm( t( 'deleteFileJobConfirm', 'Delete this file migration job and its stored data?' ) ) ) {
+ return;
+ }
+ if ( typeof dtMigrationImport === 'undefined' ) {
+ return;
+ }
+ $btn.prop( 'disabled', true );
+ $.post( dtMigrationImport.ajaxUrl, {
+ action: 'dt_migration_file_job_delete',
+ nonce: dtMigrationImport.nonce,
+ job_id: id
+ } ).done( function( r ) {
+ if ( r.success ) {
+ window.location.reload();
+ return;
+ }
+ window.alert( ( r.data && r.data.message ) ? r.data.message : t( 'deleteFileJobFailed', 'Could not delete the job.' ) );
+ $btn.prop( 'disabled', false );
+ } ).fail( function() {
+ window.alert( t( 'deleteFileJobFailed', 'Could not delete the job.' ) );
+ $btn.prop( 'disabled', false );
+ } );
+ } );
+
if ( ! $modal.length || ! $( '.dt-migration-start-import' ).length ) {
return;
}
@@ -543,6 +636,7 @@
const checked = $( this ).prop( 'checked' );
$section.find( '.dt-migration-record-checkbox' ).prop( 'checked', checked );
} );
+
}
$( document ).ready( init );
diff --git a/disciple-tools-migration.php b/disciple-tools-migration.php
index 3ff3bfa..a4955e1 100755
--- a/disciple-tools-migration.php
+++ b/disciple-tools-migration.php
@@ -21,6 +21,14 @@
exit; // Exit if accessed directly
}
+/**
+ * Default max age (days) for stored file migration jobs when option is unset.
+ * Overridden by Settings > File import jobs.
+ */
+if ( ! defined( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS' ) ) {
+ define( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS', 7 );
+}
+
/**
* Gets the instance of the Disciple_Tools_Migration_Plugin class.
*
@@ -105,6 +113,7 @@ private function __construct() {
require_once 'includes/class-dt-migration-import-engine.php';
require_once 'includes/class-dt-migration-preflight.php';
}
+ require_once 'includes/class-dt-migration-file-job-store.php';
if ( is_admin() ) {
require_once 'includes/class-dt-migration-export-file.php';
@@ -117,6 +126,9 @@ private function __construct() {
new Disciple_Tools_Migration_Import_Ajax();
}
+ add_action( 'init', [ $this, 'maybe_schedule_file_job_prune' ] );
+ add_action( 'dt_migration_prune_file_jobs', [ $this, 'run_file_job_prune' ] );
+
$this->i18n();
if ( is_admin() ) {
@@ -151,6 +163,32 @@ public static function activation() {
*/
public static function deactivation() {
delete_option( 'dismissed-disciple-tools-migration' );
+ $ts = wp_next_scheduled( 'dt_migration_prune_file_jobs' );
+ if ( $ts ) {
+ wp_unschedule_event( $ts, 'dt_migration_prune_file_jobs' );
+ }
+ }
+
+ /**
+ * Schedules daily prune of expired file migration jobs.
+ *
+ * @return void
+ */
+ public function maybe_schedule_file_job_prune() {
+ if ( ! wp_next_scheduled( 'dt_migration_prune_file_jobs' ) ) {
+ wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'dt_migration_prune_file_jobs' );
+ }
+ }
+
+ /**
+ * Cron: removes file job records older than configured retention.
+ *
+ * @return void
+ */
+ public function run_file_job_prune() {
+ if ( class_exists( 'Disciple_Tools_Migration_File_Job_Store' ) ) {
+ Disciple_Tools_Migration_File_Job_Store::prune_expired_jobs();
+ }
}
/**
diff --git a/documentation/README.md b/documentation/README.md
index 214f472..dc8fa77 100644
--- a/documentation/README.md
+++ b/documentation/README.md
@@ -28,7 +28,7 @@ This folder contains user and reference documentation for moving Disciple.Tools
1. Install and activate the plugin on **source** and **destination** Disciple.Tools sites (see [Overview](user-guide/overview.md)).
2. On the **source** site, open **Extensions (D.T)** → **Migration** → **Settings**: enable migration and select what to export (including record types).
3. On the **source** site, open **Export** and download the JSON package.
-4. Move the file to a secure location and upload it on the **destination** site under **Import**.
+4. Move the file to a secure location and upload it on the **destination** site under **Import** (**Upload & Preview**). The destination creates a **file migration job** (stored in the database) so long runs can complete; you can track status in **Recent file migration jobs** and adjust retention under **Settings** → **File import jobs** (see [Settings and scope](user-guide/settings-and-scope.md)).
5. Optionally run **preflight**, review warnings, then start the import.
### B. API-based (direct site-to-site)
@@ -41,4 +41,11 @@ Both channels respect the same scope rules and the same import semantics on the
## Screenshots
-Illustrations live next to the guides that reference them, under each section’s `imgs/` directory. Filenames are placeholders until real screenshots are added.
+Illustrations live next to the guides that reference them, under each section’s `imgs/` directory.
+
+| File (under `user-guide/imgs/`) | Used in |
+|--------------------------------|---------|
+| `fig-10-settings-file-jobs.png` | [Settings and scope](user-guide/settings-and-scope.md) — **File import jobs** retention |
+| `fig-11-import-recent-file-jobs.png` | [Migration via file](user-guide/migration-via-file.md) — **Recent file migration jobs** table |
+
+Add or replace these PNGs locally if the links in the Markdown are missing or broken in your preview.
diff --git a/documentation/reference/data-and-security.md b/documentation/reference/data-and-security.md
index ad9c7f5..704d051 100644
--- a/documentation/reference/data-and-security.md
+++ b/documentation/reference/data-and-security.md
@@ -25,6 +25,10 @@ API imports use a **bearer token** stored on the destination after a successful
JSON export files contain **PII and ministry data**. Encrypt at rest and limit distribution to trusted operators.
+### File import jobs on the destination
+
+When you **Upload & Preview** on the destination, the plugin stores a copy of the decoded export in the **WordPress database** (per user, as a **job** with metadata) so chunked preflight and import can run without a short time limit. After a **successful** import, the large payload is typically **removed** while a small **success** record may remain in the job list until automatic or manual cleanup. Operators with access to the Migration screens can **delete** jobs from the list; retention in days is configurable under **Settings** → **File import jobs** (see [Settings and scope](../user-guide/settings-and-scope.md)). Treat database backups as including any not-yet-pruned job payloads.
+
## Capabilities
Migration admin UI and REST permission checks rely on **`manage_dt`**. Some user operations may require **`promote_users`** or other WordPress caps when creating or elevating accounts.
diff --git a/documentation/user-guide/imgs/fig-10-settings-file-jobs.png b/documentation/user-guide/imgs/fig-10-settings-file-jobs.png
new file mode 100644
index 0000000..d76eed6
Binary files /dev/null and b/documentation/user-guide/imgs/fig-10-settings-file-jobs.png differ
diff --git a/documentation/user-guide/imgs/fig-11-import-recent-file-jobs.png b/documentation/user-guide/imgs/fig-11-import-recent-file-jobs.png
new file mode 100644
index 0000000..892de44
Binary files /dev/null and b/documentation/user-guide/imgs/fig-11-import-recent-file-jobs.png differ
diff --git a/documentation/user-guide/migration-via-file.md b/documentation/user-guide/migration-via-file.md
index d638990..5a59384 100644
--- a/documentation/user-guide/migration-via-file.md
+++ b/documentation/user-guide/migration-via-file.md
@@ -27,14 +27,34 @@ Use your organization’s secure channel (encrypted storage, controlled access).
## On the destination site
1. Go to **Extensions (D.T)** → **Migration** → **Import**.
-2. In the **file** section, upload the JSON export.
-3. Use **preflight** if you want warnings about collisions or field mismatches (see [Preflight and warnings](preflight-and-warnings.md)).
+2. In the **Upload & preview (JSON file)** section, choose the JSON export and click **Upload & Preview**. The site stores the file as a **file migration job** in the database (not a short browser session) so you can run **preflight** and **import** over a long time without losing the payload mid-run.
+3. Use **preflight** if you want warnings about collisions or field mismatches (see [Preflight and warnings](preflight-and-warnings.md)). Preflight applies to the **active** job (the one you just uploaded or opened via **Retry** — see below).
4. Choose what to apply (settings categories and record types) consistent with the export, then start the import. The UI runs imports in **stages** (settings, then records in **dependency-aware order** — for example types such as people groups and groups before contacts and trainings, then other enabled types).
-
+

+### Recent file migration jobs
+
+Under **Upload & preview (JSON file)**, the **Recent file migration jobs** table lists past uploads for your user account.
+
+| Column | Meaning |
+|--------|---------|
+| **Date** | When the job was created (upload or equivalent). |
+| **File** | Original filename from the upload. |
+| **Status** | Shown as a pill: **Success** (green) when a run finished; **Failed** or **Cancelled** (red) when a run errored or you stopped it; **Ready** or **In progress** (neutral) when not finished or not started. |
+| **Size** | Approximate stored size while the JSON is kept; may show a dash after **Success** because the large payload is cleared to save space. |
+| **Actions** | **Retry** loads that job’s data back into the preview (if the file is still stored). **Delete** removes the job and any stored data for it. |
+
+Completed jobs with **Success** often **cannot** be retried from this list, because the import clears the stored JSON on success. Upload the file again to run another import. Failed or cancelled jobs **can** be retried while the payload still exists.
+
+Automatic removal of old jobs uses the **day limit** on **Settings** → **File import jobs** (see [Settings and scope](settings-and-scope.md)).
+
+
+
+
+
## After import
Verify records, users, and configuration in Disciple.Tools. If something failed mid-run, check the messages in the import UI and use [Troubleshooting](troubleshooting.md).
diff --git a/documentation/user-guide/overview.md b/documentation/user-guide/overview.md
index a9d5bfb..713facb 100644
--- a/documentation/user-guide/overview.md
+++ b/documentation/user-guide/overview.md
@@ -20,7 +20,7 @@ Imports on the destination are designed to **replace** existing data for the sel
## Where to find Migration in WordPress
-Open the WordPress admin, then go to **Extensions (D.T)** → **Migration**. The screen has three tabs: **Settings**, **Export**, and **Import**.
+Open the WordPress admin, then go to **Extensions (D.T)** → **Migration**. The screen has three tabs: **Settings**, **Export**, and **Import**. On **Settings**, you can set how long **file import jobs** stay in the database after a JSON upload; on **Import**, the **Recent file migration jobs** table shows status and optional **Retry** / **Delete** actions (see [Migration via file](migration-via-file.md)).
diff --git a/documentation/user-guide/preflight-and-warnings.md b/documentation/user-guide/preflight-and-warnings.md
index c485adb..5650bbe 100644
--- a/documentation/user-guide/preflight-and-warnings.md
+++ b/documentation/user-guide/preflight-and-warnings.md
@@ -12,6 +12,8 @@ Use preflight when:
You can still start an import without preflight; the UI offers both flows.
+For **file** imports, preflight uses the same **file migration job** as the import (the payload from **Upload & Preview** or **Retry** on [Migration via file](migration-via-file.md)). If the UI says no job is active, upload again or use **Retry** on a job that still has stored data.
+

diff --git a/documentation/user-guide/settings-and-scope.md b/documentation/user-guide/settings-and-scope.md
index 39b59f4..a56b467 100644
--- a/documentation/user-guide/settings-and-scope.md
+++ b/documentation/user-guide/settings-and-scope.md
@@ -36,6 +36,18 @@ For each selected type, importing on a destination **removes existing records of
Save changes with **Save Settings**.
+## File import jobs (retention)
+
+On the same **Settings** tab, the **File import jobs** section controls how long **completed file migration jobs** remain in the database before the site removes them automatically. Each time you use **Upload & Preview** on the **Import** tab, the destination stores the JSON as a **job** (see [Migration via file](migration-via-file.md)) so long imports are not cut off by a short session window.
+
+- **Remove file migration jobs after (days):** enter a value between **1** and **365** (default is **7**). Jobs older than this are pruned on a schedule and when you save these settings. Lower the number if you need to free space sooner; raise it if you want the **Recent file migration jobs** list on the Import tab to show history for longer.
+
+Saving **Settings** also runs a one-time cleanup pass against jobs past this age.
+
+
+
+
+
## API connection storage (destination)
The plugin stores the **source site base URL** and a **JWT** obtained after a successful connection test (see [Migration via API](migration-via-api.md)). **Username and password** used to fetch the token are **not** stored. JWT and URL are kept in the migration settings option for subsequent API import batches.
@@ -47,4 +59,5 @@ The plugin stores the **source site base URL** and a **JWT** obtained after a su
## See also
- [Export tab](migration-via-file.md) — how the download reflects these choices
+- [Migration via file](migration-via-file.md) — **Recent file migration jobs**, **Retry**, and how retention relates to the day limit above
- [REST API capabilities](../reference/rest-api.md) — how the source reports `allowed_items`
diff --git a/documentation/user-guide/troubleshooting.md b/documentation/user-guide/troubleshooting.md
index 36ce40d..3df9c0e 100644
--- a/documentation/user-guide/troubleshooting.md
+++ b/documentation/user-guide/troubleshooting.md
@@ -53,7 +53,27 @@ Re-run **Test Connection** after fixing Server A. Clear old tokens by saving a f
**Symptom:** Download or API batch stalls.
-**Fix:** Increase PHP / web server timeouts where appropriate; for API imports, batches use pagination — retry from the UI. For very large file uploads, check `upload_max_filesize` / `post_max_size`.
+**Fix:** Increase PHP / web server timeouts where appropriate; for API imports, batches use pagination — retry from the UI. For very large file uploads, check `upload_max_filesize` / `post_max_size` and the database’s ability to store large options (or raise `max_allowed_packet` on MySQL if the host requires it for multi‑MB JSON).
+
+## File import: no job or job not found
+
+**Symptom:** Message that the **migration file job** is missing, not found, or no longer has a retriable copy; preflight or import cannot start.
+
+**Common causes:**
+
+- The JSON was **never uploaded** in this session, or the page was opened without using **Upload & Preview** or **Retry** for a job that still has data.
+- The job **completed successfully** and the site **cleared the stored file** to save space (see [Migration via file](migration-via-file.md) — re-upload the export).
+- The job was **deleted** from **Recent file migration jobs** or **aged out** by the **day limit** on **Settings** → **File import jobs**.
+
+**Fix:** Upload the export again, or use **Retry** on a job that still lists retriable data. Adjust the retention day limit if you need jobs to last longer in the list.
+
+## Import progress appears stuck (file import)
+
+**Symptom:** Progress bar or counts stop updating during a file-based import.
+
+**Fix:** Check browser **Network** for failed `admin-ajax.php` requests and read the JSON **response** for the error text. The UI surfaces the same message in the import panel. A **failed** or **cancelled** run updates the **Recent file migration jobs** status so you can **Retry** when the file is still stored. If the import **succeeded** but the list still looks odd, refresh the page — **Success** clears the stored JSON; **size** may show as a dash.
+
+For **nonce** or permission errors, reload the admin page and start again from **Upload & Preview** or **Retry** (not only a hard refresh of a stale tab).
## Theme version notice
@@ -61,11 +81,11 @@ Re-run **Test Connection** after fixing Server A. Clear old tokens by saving a f
**Fix:** Upgrade the **Disciple.Tools theme** to the version required by the plugin (see main plugin file / readme).
-## Import progress appears stuck
+## Import progress appears stuck (general)
**Symptom:** Progress bar or counts stop updating.
-**Fix:** Check browser console and server error logs; session or nonce expiry can interrupt long jobs — refresh and consult whether partial import requires cleanup (records may be half imported depending on stage).
+**Fix:** For **API** imports, JWT expiry or network issues can interrupt long jobs — re-run **Test Connection** and try again. For **file** imports, see [Import progress appears stuck (file import)](#import-progress-appears-stuck-file-import) above. In all cases, check the error text in the import UI, browser **Network** responses, and server error logs; partial imports may leave records half-imported depending on the stage, and you may need cleanup or a targeted re-run.
diff --git a/includes/class-dt-migration-export-file.php b/includes/class-dt-migration-export-file.php
index 3fc630e..585fc70 100644
--- a/includes/class-dt-migration-export-file.php
+++ b/includes/class-dt-migration-export-file.php
@@ -37,7 +37,8 @@ public static function build_export( array $record_options = [] ) : array {
$export_block['system_users'] = Disciple_Tools_Migration_System_Users::build_export_payload();
}
- $records = [];
+ $records = [];
+ $post_user_meta = [];
$allowed_records = $allowed['records'] ?? [];
if ( ! empty( $allowed_records ) && is_array( $allowed_records ) ) {
foreach ( $allowed_records as $post_type => $enabled ) {
@@ -49,7 +50,9 @@ public static function build_export( array $record_options = [] ) : array {
$min_id = isset( $opts['min_id'] ) ? absint( $opts['min_id'] ) : 0;
$max_id = isset( $opts['max_id'] ) ? absint( $opts['max_id'] ) : 0;
- $records[ $post_type ] = self::fetch_records( $post_type, $limit, $min_id, $max_id );
+ $ids = self::get_record_ids( $post_type, $limit, $min_id, $max_id );
+ $records[ $post_type ] = self::fetch_records_for_ids( $post_type, $ids );
+ $post_user_meta[ $post_type ] = self::fetch_post_user_meta( $ids );
}
}
@@ -62,8 +65,9 @@ public static function build_export( array $record_options = [] ) : array {
'mode' => 'file',
'allowed_items' => $allowed,
],
- 'export' => $export_block,
- 'records' => $records,
+ 'export' => $export_block,
+ 'records' => $records,
+ 'post_user_meta' => $post_user_meta,
];
}
@@ -79,19 +83,92 @@ public static function build_export( array $record_options = [] ) : array {
*/
public static function fetch_records( string $post_type, int $limit = 0, int $min_id = 0, int $max_id = 0 ) : array {
$ids = self::get_record_ids( $post_type, $limit, $min_id, $max_id );
- $records = [];
- foreach ( $ids as $post_id ) {
- $post = DT_Posts::get_post( $post_type, $post_id, true, false );
- if ( ! is_wp_error( $post ) && is_array( $post ) ) {
- if ( class_exists( 'Disciple_Tools_Migration_Import_Engine' ) ) {
- $post = Disciple_Tools_Migration_Import_Engine::attach_migration_comments_to_record( $post_type, $post );
+ return self::fetch_records_for_ids( $post_type, $ids );
+ }
+
+ /**
+ * Fetches records for a fixed list of post IDs.
+ *
+ * Switches the current user to 0 around each DT_Posts::get_post() call so the
+ * dt_post_user_meta query inside the theme returns no rows. This keeps
+ * exporter-specific private meta (tasks, private favorites/inputs/etc.) out of
+ * the per-record payload — full per-user private state is exported separately
+ * via {@see self::fetch_post_user_meta()}.
+ *
+ * @param string $post_type
+ * @param int[] $ids
+ * @return array
+ */
+ private static function fetch_records_for_ids( string $post_type, array $ids ) : array {
+ $records = [];
+ $original_user_id = get_current_user_id();
+ wp_set_current_user( 0 );
+ try {
+ foreach ( $ids as $post_id ) {
+ $post = DT_Posts::get_post( $post_type, (int) $post_id, false, false );
+ if ( ! is_wp_error( $post ) && is_array( $post ) ) {
+ if ( class_exists( 'Disciple_Tools_Migration_Import_Engine' ) ) {
+ $post = Disciple_Tools_Migration_Import_Engine::attach_migration_comments_to_record( $post_type, $post );
+ }
+ $records[] = $post;
}
- $records[] = $post;
}
+ } finally {
+ wp_set_current_user( $original_user_id );
}
return $records;
}
+ /**
+ * Dumps wp_dt_post_user_meta rows for the given post IDs.
+ *
+ * Carries every per-user private value (tasks, private favorites, private inputs,
+ * private link/multi_select/key_select/etc.) regardless of who created it. The
+ * source-side primary key id is stripped — re-imports MUST NOT re-use it.
+ *
+ * @param int[] $post_ids
+ * @return array
+ */
+ public static function fetch_post_user_meta( array $post_ids ) : array {
+ if ( empty( $post_ids ) ) {
+ return [];
+ }
+ global $wpdb;
+ $post_ids = array_values( array_map( 'intval', $post_ids ) );
+ $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT post_id, user_id, meta_key, meta_value, date, category
+ FROM {$wpdb->dt_post_user_meta}
+ WHERE post_id IN ( $placeholders )
+ ORDER BY post_id ASC, user_id ASC, id ASC",
+ $post_ids
+ ),
+ ARRAY_A
+ );
+ // phpcs:enable
+
+ if ( ! is_array( $rows ) ) {
+ return [];
+ }
+
+ return array_map(
+ static function ( array $row ) : array {
+ return [
+ 'post_id' => (int) $row['post_id'],
+ 'user_id' => (int) $row['user_id'],
+ 'meta_key' => (string) $row['meta_key'],
+ 'meta_value' => $row['meta_value'],
+ 'date' => $row['date'],
+ 'category' => $row['category'],
+ ];
+ },
+ $rows
+ );
+ }
+
/**
* Gets post IDs for a post type with optional limit and ID range.
* Always ORDER BY ID ASC.
diff --git a/includes/class-dt-migration-file-job-store.php b/includes/class-dt-migration-file-job-store.php
new file mode 100644
index 0000000..53f30fe
--- /dev/null
+++ b/includes/class-dt-migration-file-job-store.php
@@ -0,0 +1,357 @@
+ File import jobs).
+ */
+ public const MAX_AGE_DAYS_DEFAULT = 7;
+
+ /**
+ * Sanitize a job id (UUID).
+ *
+ * @param string $job_id
+ * @return string
+ */
+ public static function sanitize_job_id( string $job_id ) : string {
+ $job_id = strtolower( trim( $job_id ) );
+ if ( ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $job_id ) ) {
+ return '';
+ }
+ return $job_id;
+ }
+
+ /**
+ * @return array>
+ */
+ private static function get_registry() : array {
+ $raw = get_option( self::REGISTRY_OPTION, [] );
+ return is_array( $raw ) ? $raw : [];
+ }
+
+ /**
+ * @param array> $registry
+ * @return void
+ */
+ private static function save_registry( array $registry ) : void {
+ if ( $registry === [] ) {
+ delete_option( self::REGISTRY_OPTION );
+ return;
+ }
+ if ( get_option( self::REGISTRY_OPTION, null ) === false ) {
+ add_option( self::REGISTRY_OPTION, $registry, '', 'no' );
+ } else {
+ update_option( self::REGISTRY_OPTION, $registry, false );
+ }
+ }
+
+ /**
+ * @param string $job_id
+ * @return string
+ */
+ public static function payload_option_name( string $job_id ) : string {
+ return self::PAYLOAD_KEY_PREFIX . $job_id;
+ }
+
+ /**
+ * @param int $user_id
+ * @param array $payload Full migration JSON (decoded).
+ * @param string $label Original filename or short label.
+ * @return string Job id (UUID) or empty string on failure.
+ */
+ public static function create_job( int $user_id, array $payload, string $label ) : string {
+ $job_id = self::sanitize_job_id( wp_generate_uuid4() );
+ if ( $job_id === '' ) {
+ return '';
+ }
+
+ $key = self::payload_option_name( $job_id );
+ $ok = add_option( $key, $payload, '', 'no' );
+ if ( ! $ok ) {
+ delete_option( $key );
+ if ( ! add_option( $key, $payload, '', 'no' ) ) {
+ return '';
+ }
+ }
+
+ $json = wp_json_encode( $payload );
+ $size = is_string( $json ) ? strlen( $json ) : 0;
+
+ $registry = self::get_registry();
+ $now = time();
+ $registry[ $job_id ] = [
+ 'user_id' => $user_id,
+ 'stored_at' => $now,
+ 'updated_at' => $now,
+ 'status' => self::STATUS_READY,
+ 'label' => sanitize_file_name( $label ),
+ 'byte_size' => $size,
+ ];
+ self::save_registry( $registry );
+
+ return $job_id;
+ }
+
+ /**
+ * @param int $user_id
+ * @param string $job_id
+ * @return array|null
+ */
+ public static function get_job_meta( int $user_id, string $job_id ) : ?array {
+ $job_id = self::sanitize_job_id( $job_id );
+ if ( $job_id === '' ) {
+ return null;
+ }
+ $registry = self::get_registry();
+ $row = $registry[ $job_id ] ?? null;
+ if ( ! is_array( $row ) || (int) ( $row['user_id'] ?? 0 ) !== $user_id ) {
+ return null;
+ }
+ return $row;
+ }
+
+ /**
+ * @param string $job_id Job UUID.
+ * @return bool
+ */
+ public static function job_has_stored_payload( string $job_id ) : bool {
+ $job_id = self::sanitize_job_id( $job_id );
+ if ( $job_id === '' ) {
+ return false;
+ }
+ $name = self::payload_option_name( $job_id );
+ $val = get_option( $name, null );
+ return is_array( $val ) && ! empty( $val );
+ }
+
+ /**
+ * @param int $user_id
+ * @param string $job_id
+ * @return array|null Full payload or null.
+ */
+ public static function get_payload( int $user_id, string $job_id ) : ?array {
+ $job_id = self::sanitize_job_id( $job_id );
+ if ( $job_id === '' ) {
+ return null;
+ }
+ if ( self::get_job_meta( $user_id, $job_id ) === null ) {
+ return null;
+ }
+ $name = self::payload_option_name( $job_id );
+ $val = get_option( $name, null );
+ if ( ! is_array( $val ) ) {
+ return null;
+ }
+ return $val;
+ }
+
+ /**
+ * @param int $user_id
+ * @param string $job_id
+ * @return bool
+ */
+ public static function job_exists_for_user( int $user_id, string $job_id ) : bool {
+ return self::get_job_meta( $user_id, $job_id ) !== null;
+ }
+
+ /**
+ * @param int $user_id User ID.
+ * @param string $job_id Job UUID.
+ * @param string $status One of the STATUS_* constants.
+ * @return void
+ */
+ public static function set_status( int $user_id, string $job_id, string $status ) : void {
+ $job_id = self::sanitize_job_id( $job_id );
+ if ( $job_id === '' ) {
+ return;
+ }
+ $allowed = [
+ self::STATUS_READY,
+ self::STATUS_RUNNING,
+ self::STATUS_SUCCESS,
+ self::STATUS_FAILED,
+ self::STATUS_CANCELLED,
+ ];
+ if ( ! in_array( $status, $allowed, true ) ) {
+ return;
+ }
+ $registry = self::get_registry();
+ if ( ! isset( $registry[ $job_id ] ) || (int) ( $registry[ $job_id ]['user_id'] ?? 0 ) !== $user_id ) {
+ return;
+ }
+ $registry[ $job_id ]['status'] = $status;
+ $registry[ $job_id ]['updated_at'] = time();
+ self::save_registry( $registry );
+ }
+
+ /**
+ * Marks success and drops the stored payload to save space; keeps registry row.
+ *
+ * @param int $user_id
+ * @param string $job_id
+ * @return void
+ */
+ public static function mark_success_and_clear_payload( int $user_id, string $job_id ) : void {
+ $job_id = self::sanitize_job_id( $job_id );
+ if ( $job_id === '' ) {
+ return;
+ }
+ $registry = self::get_registry();
+ if ( ! isset( $registry[ $job_id ] ) || (int) ( $registry[ $job_id ]['user_id'] ?? 0 ) !== $user_id ) {
+ return;
+ }
+ delete_option( self::payload_option_name( $job_id ) );
+ $registry[ $job_id ]['status'] = self::STATUS_SUCCESS;
+ $registry[ $job_id ]['byte_size'] = 0;
+ $registry[ $job_id ]['updated_at'] = time();
+ self::save_registry( $registry );
+ }
+
+ /**
+ * @param int $user_id
+ * @param string $job_id
+ * @return void
+ */
+ public static function delete_job( int $user_id, string $job_id ) : void {
+ $job_id = self::sanitize_job_id( $job_id );
+ if ( $job_id === '' ) {
+ return;
+ }
+ $registry = self::get_registry();
+ if ( ! isset( $registry[ $job_id ] ) || (int) ( $registry[ $job_id ]['user_id'] ?? 0 ) !== $user_id ) {
+ return;
+ }
+ unset( $registry[ $job_id ] );
+ self::save_registry( $registry );
+ delete_option( self::payload_option_name( $job_id ) );
+ }
+
+ /**
+ * @param int $user_id
+ * @return array> List of rows with job_id key added.
+ */
+ public static function list_jobs_for_user( int $user_id ) : array {
+ $registry = self::get_registry();
+ $out = [];
+ foreach ( $registry as $job_id => $row ) {
+ if ( ! is_string( $job_id ) || ! is_array( $row ) ) {
+ continue;
+ }
+ if ( (int) ( $row['user_id'] ?? 0 ) !== $user_id ) {
+ continue;
+ }
+ $row['job_id'] = $job_id;
+ $out[] = $row;
+ }
+ usort(
+ $out,
+ static function ( $a, $b ) {
+ return ( (int) ( $b['stored_at'] ?? 0 ) ) <=> ( (int) ( $a['stored_at'] ?? 0 ) );
+ }
+ );
+ return $out;
+ }
+
+ /**
+ * Max retention days from plugin settings, clamped 1..365.
+ *
+ * @return int
+ */
+ public static function get_max_age_days() : int {
+ $raw = 0;
+ if ( class_exists( 'Disciple_Tools_Migration_Menu', false ) ) {
+ $settings = Disciple_Tools_Migration_Menu::get_settings();
+ $file = isset( $settings['file'] ) && is_array( $settings['file'] ) ? $settings['file'] : [];
+ $raw = (int) ( $file['job_max_age_days'] ?? 0 );
+ } else {
+ $opt = get_option( 'dt_migration_settings', [] );
+ if ( is_array( $opt ) && isset( $opt['file'] ) && is_array( $opt['file'] ) ) {
+ $raw = (int) ( $opt['file']['job_max_age_days'] ?? 0 );
+ }
+ }
+ if ( $raw < 1 ) {
+ $raw = (int) ( defined( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS' ) ? constant( 'DT_MIGRATION_FILE_JOB_MAX_AGE_DAYS' ) : self::MAX_AGE_DAYS_DEFAULT );
+ }
+ if ( $raw < 1 ) {
+ $raw = self::MAX_AGE_DAYS_DEFAULT;
+ }
+ $raw = (int) apply_filters( 'dt_migration_file_job_max_age_days', $raw );
+ if ( $raw < 1 ) {
+ $raw = 1;
+ }
+ if ( $raw > 365 ) {
+ $raw = 365;
+ }
+ return $raw;
+ }
+
+ /**
+ * Removes registry rows and payload options past retention.
+ *
+ * @return int Number of jobs removed.
+ */
+ public static function prune_expired_jobs() : int {
+ $max_days = self::get_max_age_days();
+ $cut = time() - ( $max_days * DAY_IN_SECONDS );
+ $registry = self::get_registry();
+ $to_remove = [];
+ foreach ( $registry as $job_id => $row ) {
+ if ( ! is_string( $job_id ) || ! is_array( $row ) ) {
+ continue;
+ }
+ $t = (int) ( $row['stored_at'] ?? 0 );
+ if ( $t >= $cut ) {
+ continue;
+ }
+ $to_remove[] = [
+ 'job_id' => $job_id,
+ 'user_id' => (int) ( $row['user_id'] ?? 0 ),
+ ];
+ }
+ $removed = 0;
+ foreach ( $to_remove as $item ) {
+ self::delete_job( $item['user_id'], $item['job_id'] );
+ $removed++;
+ }
+ return $removed;
+ }
+
+ /**
+ * Validates export block shape (same as upload handler).
+ *
+ * @param array $payload
+ * @return bool
+ */
+ public static function is_valid_migration_payload( array $payload ) : bool {
+ $export_block = $payload['export'] ?? [];
+ $has_dt_settings = ! empty( $export_block['dt_settings'] );
+ $has_users_block = array_key_exists( 'system_users', $export_block ) && is_array( $export_block['system_users'] );
+ return is_array( $export_block ) && ( $has_dt_settings || $has_users_block );
+ }
+}
diff --git a/includes/class-dt-migration-import-engine.php b/includes/class-dt-migration-import-engine.php
index c64e1e8..3a2d6ef 100644
--- a/includes/class-dt-migration-import-engine.php
+++ b/includes/class-dt-migration-import-engine.php
@@ -821,6 +821,83 @@ public static function apply_deferred_group_connections() : array {
*
* @return int Number deleted, or -1 on error.
*/
+ /**
+ * Imports per-user private meta (dt_post_user_meta) for a slice of post IDs.
+ *
+ * Replace semantics: deletes all existing dt_post_user_meta rows for the in-scope
+ * post IDs, then inserts the rows from the export whose post_id is in scope.
+ * The source-side user_id is remapped via {@see Disciple_Tools_Migration_System_Users::remap_user_id()}.
+ *
+ * Rows whose remapped user does not exist on the target are skipped with a logged warning.
+ * Rows whose post_id is not in scope are silently ignored (they belong to a different batch).
+ *
+ * @param array $rows
+ * @param int[] $post_ids_in_scope Post IDs covered by the current batch (already inserted).
+ * @return array{ inserted:int, errors: string[] }
+ */
+ public static function import_post_user_meta_for_posts( array $rows, array $post_ids_in_scope ) : array {
+ $result = [ 'inserted' => 0, 'errors' => [] ];
+
+ $post_ids = array_values( array_unique( array_filter( array_map( 'intval', $post_ids_in_scope ) ) ) );
+ if ( empty( $post_ids ) ) {
+ return $result;
+ }
+
+ global $wpdb;
+ $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->dt_post_user_meta} WHERE post_id IN ( $placeholders )",
+ $post_ids
+ )
+ );
+ // phpcs:enable
+
+ if ( empty( $rows ) ) {
+ return $result;
+ }
+
+ $scope = array_flip( $post_ids );
+ foreach ( $rows as $row ) {
+ $pid = isset( $row['post_id'] ) ? (int) $row['post_id'] : 0;
+ if ( $pid <= 0 || ! isset( $scope[ $pid ] ) ) {
+ continue;
+ }
+ $source_uid = isset( $row['user_id'] ) ? (int) $row['user_id'] : 0;
+ if ( $source_uid <= 0 ) {
+ continue;
+ }
+ $target_uid = Disciple_Tools_Migration_System_Users::remap_user_id( $source_uid );
+ if ( $target_uid <= 0 || ! get_user_by( 'id', $target_uid ) ) {
+ $result['errors'][] = sprintf(
+ /* translators: 1: post ID, 2: source user ID */
+ __( 'Skipped post_user_meta row (post #%1$d): no target user for source user %2$d.', 'disciple-tools-migration' ),
+ $pid,
+ $source_uid
+ );
+ continue;
+ }
+
+ $ok = $wpdb->insert(
+ $wpdb->dt_post_user_meta,
+ [
+ 'user_id' => $target_uid,
+ 'post_id' => $pid,
+ 'meta_key' => (string) ( $row['meta_key'] ?? '' ),
+ 'meta_value' => $row['meta_value'] ?? '',
+ 'date' => $row['date'] ?? null,
+ 'category' => $row['category'] ?? null,
+ ]
+ );
+ if ( $ok ) {
+ ++$result['inserted'];
+ }
+ }
+
+ return $result;
+ }
+
public static function delete_posts_by_type( string $post_type ) : int {
$query = new WP_Query(
[
@@ -1264,6 +1341,40 @@ private static function normalize_multi_select_for_import( $value ) : array {
return [ 'values' => $values, 'force_values' => true ];
}
+ /**
+ * Normalizes a link field from export format to DT_Posts update format.
+ *
+ * Export returns links as [{ type, value, meta_id }, ...].
+ * DT_Posts::update_post expects { values: [ { type, value }, ... ], force_values?: true }.
+ *
+ * meta_id is stripped: it refers to the source-site postmeta row, and passing it would
+ * send the entry through the "update existing" branch and update the wrong row (or none).
+ *
+ * @param mixed $value Link data from export.
+ * @return array{ values: array, force_values: bool }
+ */
+ private static function normalize_link_for_import( $value ) : array {
+ if ( is_array( $value ) && isset( $value['values'] ) ) {
+ return [
+ 'values' => array_values( (array) $value['values'] ),
+ 'force_values' => ! empty( $value['force_values'] ),
+ ];
+ }
+ $values = [];
+ if ( is_array( $value ) ) {
+ foreach ( $value as $item ) {
+ if ( ! is_array( $item ) || ! isset( $item['type'] ) || ! array_key_exists( 'value', $item ) ) {
+ continue;
+ }
+ $values[] = [
+ 'type' => (string) $item['type'],
+ 'value' => $item['value'],
+ ];
+ }
+ }
+ return [ 'values' => $values, 'force_values' => true ];
+ }
+
/**
* Extracts the value from a multi-select, location, or tags item.
*
@@ -1340,6 +1451,8 @@ private static function prepare_record_fields_for_import( array $record, string
$connection_types = [];
$multi_select_types = [];
$user_select_types = [];
+ $link_types = [];
+ $private_field_types = [];
if ( class_exists( 'DT_Posts' ) ) {
$post_settings = DT_Posts::get_post_settings( $post_type, false );
$connection_types = (array) ( $post_settings['connection_types'] ?? [] );
@@ -1349,8 +1462,15 @@ private static function prepare_record_fields_for_import( array $record, string
if ( ! isset( $fs['type'] ) ) {
continue;
}
+ // Private field values flow through the post_user_meta block, never through records.
+ if ( $fs['type'] === 'task' || ! empty( $fs['private'] ) ) {
+ $private_field_types[] = $fk;
+ continue;
+ }
if ( $fs['type'] === 'user_select' ) {
$user_select_types[] = $fk;
+ } elseif ( $fs['type'] === 'link' ) {
+ $link_types[] = $fk;
} elseif ( in_array( $fs['type'], $values_based_types, true ) ) {
$multi_select_types[] = $fk;
}
@@ -1361,12 +1481,17 @@ private static function prepare_record_fields_for_import( array $record, string
if ( in_array( $key, $exclude, true ) ) {
continue;
}
+ if ( in_array( $key, $private_field_types, true ) ) {
+ continue;
+ }
if ( in_array( $key, $connection_types, true ) ) {
$fields[ $key ] = self::normalize_connection_for_import( $value );
} elseif ( in_array( $key, $multi_select_types, true ) ) {
$fields[ $key ] = self::normalize_multi_select_for_import( $value );
} elseif ( in_array( $key, $user_select_types, true ) ) {
$fields[ $key ] = self::normalize_and_remap_user_select_for_import( $value );
+ } elseif ( in_array( $key, $link_types, true ) ) {
+ $fields[ $key ] = self::normalize_link_for_import( $value );
} else {
$fields[ $key ] = self::normalize_field_value_for_import( $value );
}
diff --git a/includes/class-dt-migration-system-users.php b/includes/class-dt-migration-system-users.php
index 12d5ae9..c8fe49f 100644
--- a/includes/class-dt-migration-system-users.php
+++ b/includes/class-dt-migration-system-users.php
@@ -160,8 +160,17 @@ static function ( $a, $b ) {
$out['map'][ (string) $old_id ] = (int) $existing->ID;
++$out['mapped_existing'];
+ $norm_roles = self::normalized_roles_from_row( $row );
+
+ if ( is_multisite() && ! is_user_member_of_blog( $existing->ID, get_current_blog_id() ) ) {
+ $add_err = self::add_existing_user_to_current_blog( $existing->ID, $norm_roles );
+ if ( $add_err !== null ) {
+ return array_merge( $out, [ 'error' => $add_err ] );
+ }
+ }
+
if ( $is_src_admin ) {
- $roles_err = self::assign_roles_to_wp_user( $existing->ID, self::normalized_roles_from_row( $row ) );
+ $roles_err = self::assign_roles_to_wp_user( $existing->ID, $norm_roles );
if ( $roles_err !== null ) {
return array_merge( $out, [ 'error' => $roles_err ] );
}
@@ -349,6 +358,63 @@ private static function assign_roles_to_wp_user( int $user_id, array $roles ) :
return null;
}
+ /**
+ * Adds an existing network user to the current subsite using their source roles.
+ *
+ * Multisite-only. Skips when user is already a member of the current blog.
+ * Uses add_user_to_blog() for the primary role so the standard hooks fire,
+ * then adds any extra roles via WP_User::add_role(). Falls back to the site's
+ * default role when the export carries no usable role.
+ *
+ * @param int $user_id Target site user ID.
+ * @param string[] $roles Sanitized role slugs from the export row.
+ * @return string|null Error message, or null on success / no-op.
+ */
+ private static function add_existing_user_to_current_blog( int $user_id, array $roles ) : ?string {
+ $roles = array_values( array_filter( array_map( 'sanitize_key', $roles ) ) );
+
+ if ( in_array( 'administrator', $roles, true ) && ! current_user_can( 'promote_users' ) ) {
+ return __( 'You do not have permission to assign the administrator role.', 'disciple-tools-migration' );
+ }
+
+ $wp_roles = wp_roles();
+ foreach ( $roles as $slug ) {
+ if ( ! $wp_roles->is_role( $slug ) ) {
+ return sprintf(
+ /* translators: %s: role slug from export */
+ __( 'Unknown or invalid role in export: %s', 'disciple-tools-migration' ),
+ $slug
+ );
+ }
+ }
+
+ if ( empty( $roles ) ) {
+ $default_role = sanitize_key( (string) get_option( 'default_role', 'subscriber' ) );
+ if ( ! $wp_roles->is_role( $default_role ) ) {
+ $default_role = 'subscriber';
+ }
+ $primary = $default_role;
+ $extras = [];
+ } else {
+ $primary = array_shift( $roles );
+ $extras = $roles;
+ }
+
+ $result = add_user_to_blog( get_current_blog_id(), $user_id, $primary );
+ if ( is_wp_error( $result ) ) {
+ return $result->get_error_message();
+ }
+
+ if ( ! empty( $extras ) ) {
+ $user = new WP_User( $user_id );
+ foreach ( $extras as $extra_role ) {
+ $user->add_role( $extra_role );
+ }
+ }
+
+ return null;
+ }
+
/**
* @param array $row
* @return int|WP_Error
diff --git a/rest-api/rest-api.php b/rest-api/rest-api.php
index 563062d..238500b 100644
--- a/rest-api/rest-api.php
+++ b/rest-api/rest-api.php
@@ -366,24 +366,35 @@ public function records_batch( WP_REST_Request $request ) : WP_REST_Response {
$ids = $wp_query->posts ?? [];
$total = (int) ( $wp_query->found_posts ?? 0 );
- $records = [];
- foreach ( $ids as $post_id ) {
- $post = DT_Posts::get_post( $post_type, (int) $post_id, true, false );
- if ( ! is_wp_error( $post ) && is_array( $post ) ) {
- if ( class_exists( 'Disciple_Tools_Migration_Import_Engine' ) ) {
- $post = Disciple_Tools_Migration_Import_Engine::attach_migration_comments_to_record( $post_type, $post );
+ $records = [];
+ $original_user_id = get_current_user_id();
+ wp_set_current_user( 0 );
+ try {
+ foreach ( $ids as $post_id ) {
+ $post = DT_Posts::get_post( $post_type, (int) $post_id, false, false );
+ if ( ! is_wp_error( $post ) && is_array( $post ) ) {
+ if ( class_exists( 'Disciple_Tools_Migration_Import_Engine' ) ) {
+ $post = Disciple_Tools_Migration_Import_Engine::attach_migration_comments_to_record( $post_type, $post );
+ }
+ $records[] = $post;
}
- $records[] = $post;
}
+ } finally {
+ wp_set_current_user( $original_user_id );
}
+ $post_user_meta = class_exists( 'Disciple_Tools_Migration_Export_File' )
+ ? Disciple_Tools_Migration_Export_File::fetch_post_user_meta( array_map( 'intval', $ids ) )
+ : [];
+
return new WP_REST_Response(
[
- 'records' => $records,
- 'total' => $total,
- 'offset' => $offset,
- 'limit' => $limit,
- 'has_more' => ( $offset + count( $records ) ) < $total,
+ 'records' => $records,
+ 'post_user_meta' => $post_user_meta,
+ 'total' => $total,
+ 'offset' => $offset,
+ 'limit' => $limit,
+ 'has_more' => ( $offset + count( $records ) ) < $total,
],
200
);
|