113 changes: 113 additions & 0 deletions plugins/cm4all-wp-impex/inc/impex-export-extension-attachments.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once ABSPATH . '/wp-admin/includes/export.php';

require_once __DIR__ . '/interface-impex-named-item.php';
require_once __DIR__ . '/class-impex.php';

use cm4all\wp\impex\Impex;

function __AttachmentsExportProviderCallback(array $options, ImpexExportTransformationContext $transformationContext): \Generator
{
$attachments = \get_posts(['post_type' => 'attachment', 'numberposts' => -1, 'post_status' => null, 'post_parent' => null]);

foreach ($attachments as $attachment) {
$mediaRelPath = substr(\get_attached_file($attachment->ID), strlen(\wp_get_upload_dir()['basedir']) + 1);

$targetFile = $transformationContext->path . '/' . $mediaRelPath;

global $wp_filesystem;
\WP_Filesystem();

// ensure target file directory exists
$wp_filesystem->exists(dirname($targetFile)) || \wp_mkdir_p(dirname($targetFile));
$successfulCopied = $wp_filesystem->copy(\get_attached_file($attachment->ID), $targetFile);

yield [
Impex::SLICE_TAG => AttachmentsExporter::SLICE_TAG,
Impex::SLICE_VERSION => AttachmentsExporter::VERSION,
Impex::SLICE_TYPE => Impex::SLICE_TYPE_RESOURCE,
Impex::SLICE_META => [
'name' => $attachment->post_title,
Impex::SLICE_META_ENTITY => AttachmentsExporter::SLICE_META_ENTITY_ATTACHMENT,
'options' => $options,
'data' => (array)$attachment,
],
Impex::SLICE_DATA => $mediaRelPath,
];
}
}

/**
* @TODO: convert to enum if enums once are available in PHP
*/
interface AttachmentsExporter
{
const SLICE_TAG = 'attachment';
const SLICE_META_ENTITY_ATTACHMENT = 'attachment';

const PROVIDER_NAME = self::class;

const VERSION = '1.0.0';
}

function __registerAttachmentsExportProvider()
{
$provider = Impex::getInstance()->Export->addProvider(AttachmentsExporter::PROVIDER_NAME, __NAMESPACE__ . '\__AttachmentsExportProviderCallback');
return $provider;
}

\add_action(
hook_name: Impex::WP_ACTION_REGISTER_PROVIDERS,
callback: __NAMESPACE__ . '\__registerAttachmentsExportProvider',
);

\add_action(
hook_name: 'rest_api_init',
callback: function () {
require_once __DIR__ . '/class-impex-export-rest-controller.php';

\add_filter(
hook_name: ImpexExportRESTController::WP_FILTER_EXPORT_SLICE_REST_MARSHAL,
callback: function (array $serialized_slice, ImpexExportTransformationContext $transformationContext) {
if (
$serialized_slice[Impex::SLICE_TAG] === AttachmentsExporter::SLICE_TAG &&
$serialized_slice[Impex::SLICE_META][Impex::SLICE_META_ENTITY] === AttachmentsExporter::SLICE_META_ENTITY_ATTACHMENT &&
$serialized_slice[Impex::SLICE_TYPE] === Impex::SLICE_TYPE_RESOURCE
) {
$serialized_slice['_links'] ??= [];

$serialized_slice['_links']['self'] ??= [];

$serialized_slice['_links']['self'][] = [
'href' => $transformationContext->url . '/' . $serialized_slice[Impex::SLICE_DATA],
'tag' => AttachmentsExporter::SLICE_TAG,
'provider' => AttachmentsExporter::PROVIDER_NAME,
];
}
return $serialized_slice;
},
accepted_args: 2,
);
},
);

\add_action(
hook_name: Impex::WP_ACTION_ENQUEUE_IMPEX_PROVIDER_SCRIPT,
callback: function ($client_asset_handle, $in_footer) {
\cm4all\wp\impex\wp_enqueue_script(
handle: strtolower(str_replace('\\', '-', AttachmentsExporter::PROVIDER_NAME)),
deps: [$client_asset_handle, $client_asset_handle . '-debug'],
pluginRelativePath: 'dist/wp.impex.extension.export.attachments.js',
in_footer: $in_footer
);
},
accepted_args: 2,
);
530 changes: 530 additions & 0 deletions plugins/cm4all-wp-impex/inc/impex-export-extension-content.php

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions plugins/cm4all-wp-impex/inc/impex-export-extension-db-tables.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

/**
* caveat : wildcard selectors will work only for regular (non-temporary) tables.
* you can workaround this by providing the explizit table/view names instead of a wildcard
*/

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once ABSPATH . '/wp-admin/includes/export.php';

require_once __DIR__ . '/interface-impex-named-item.php';
require_once __DIR__ . '/class-impex.php';

use cm4all\wp\impex\Impex;

function __DbTablesExportProviderCallback(array $options, ImpexExportTransformationContext $transformationContext): \Generator
{
global $wpdb;
static $db_table_names = null;

$selectors = $options[DbTablesExporter::OPTION_SELECTOR] ?? null;

// ensure selector is valid
if (!(is_array($selectors) || is_string($selectors))) {
throw new ImpexExportRuntimeException(sprintf('dont know how to handle export option DbTablesExporter::OPTION_SELECTOR(=%s)', json_encode($selectors)));
}

// normalize selector to type array
if (is_string($selectors)) {
$selectors = [$selectors];
}

foreach ($selectors as $selector) {
if (str_contains($selector, '[') || str_contains($selector, '*') || str_contains($selector, '?')) {
$db_table_names ??= $wpdb->get_col('SHOW TABLES') ?? [];

foreach ($db_table_names as $db_table_name) {
if (fnmatch($wpdb->prefix . $selector, $db_table_name)) {
yield from __dbTableProvider(array_merge($options, [DbTablesExporter::OPTION_SELECTOR => substr($db_table_name, strlen($wpdb->prefix))]));
}
}
} else {
yield from __dbTableProvider(array_merge($options, [DbTablesExporter::OPTION_SELECTOR => $selector]));
}
}
}

function __dbTableProvider(array $options): \Generator
{
$table = $options[DbTablesExporter::OPTION_SELECTOR];

global $wpdb;

// see https://stackoverflow.com/questions/4294507/how-to-dump-mysql-table-structure-without-data-with-a-sql-query/12448816
$table_ddl = $wpdb->get_var("SHOW CREATE TABLE {$wpdb->prefix}$table", 1,);
// normalize table name
$table_ddl = str_replace($wpdb->prefix, '%prefix%', $table_ddl,);
// inject table create failover
$table_ddl = str_replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $table_ddl,);

// yield tabel ddl chunk
yield [
Impex::SLICE_TAG => DbTablesExporter::SLICE_TAG,
Impex::SLICE_VERSION => DbTablesExporter::VERSION,
Impex::SLICE_META => [
'name' => $table,
'entity' => DbTablesExporter::SLICE_META_ENTITY_TABLE,
'options' => $options,
],
Impex::SLICE_DATA => $table_ddl,
];

// yield a slice for each db rows chunk
$chunk_max_items = $options[DbTablesExporter::OPTION_SLICE_MAX_ITEMS] ?? DbTablesExporter::OPTION_SLICE_MAX_ITEMS_DEFAULT;
if ($chunk_max_items > 0) {
$rows = $wpdb->get_results("SELECT * from {$wpdb->prefix}$table");
$chunk_count = ceil(count($rows) / $chunk_max_items);

for ($chunk = 0; $chunk < $chunk_count; $chunk++) {
// index of first item in this chunk
$chunk_ofs = $chunk * $chunk_max_items;
// amount of items to yield in this chunk
$chunk_item_count = min($chunk_max_items, count($rows) - $chunk_ofs);

yield [
Impex::SLICE_TAG => DbTablesExporter::SLICE_TAG,
Impex::SLICE_VERSION => DbTablesExporter::VERSION,
Impex::SLICE_META => [
'name' => $table,
'entity' => DbTablesExporter::SLICE_META_ENTITY_ROWS,
],
Impex::SLICE_DATA => array_slice($rows, $chunk_ofs, $chunk_item_count)
];
}
}
}

/**
* @TODO: convert to enum if enums once are available in PHP
*/
interface DbTablesExporter
{
const SLICE_TAG = 'db-table';
const SLICE_META_ENTITY_TABLE = 'db-table-entity-table';
const SLICE_META_ENTITY_ROWS = 'db-table-entity-rows';

const OPTION_SELECTOR = 'db-tables-export-option-selector';

const OPTION_SLICE_MAX_ITEMS = 'db-tables-export-option-chunk-max-items';
const OPTION_SLICE_MAX_ITEMS_DEFAULT = 50;

const PROVIDER_NAME = self::class;

const VERSION = '1.0.0';
}

function __registerDbTablesExportProvider()
{
$provider = Impex::getInstance()->Export->addProvider(DbTablesExporter::PROVIDER_NAME, __NAMESPACE__ . '\__DbTablesExportProviderCallback');
return $provider;
}

\add_action(
hook_name: Impex::WP_ACTION_REGISTER_PROVIDERS,
callback: __NAMESPACE__ . '\__registerDbTablesExportProvider',
);
105 changes: 105 additions & 0 deletions plugins/cm4all-wp-impex/inc/impex-export-extension-wp-options.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once ABSPATH . '/wp-admin/includes/export.php';

require_once __DIR__ . '/interface-impex-named-item.php';
require_once __DIR__ . '/class-impex.php';

use cm4all\wp\impex\Impex;

function __matchesSelector(string $wpOptionName, array|string $selectors): bool
{
// normalize selector to array of selectors
if (is_string($selectors)) {
$selectors = [$selectors];
}

if (is_array($selectors)) {
foreach ($selectors as $selector) {
if (fnmatch($selector, $wpOptionName)) {
return true;
}
}
}

return false;
}

function __WpOptionsExporterProviderCallback(array $options, ImpexExportTransformationContext $transformationContext): \Generator
{
$selector = $options[WpOptionsExporter::OPTION_SELECTOR] ?? null;

// ensure selector is valid
if (!(is_array($selector) || is_string($selector))) {
throw new ImpexExportRuntimeException(sprintf('dont know how to handle export option WpOptionsExporter::OPTION_SELECTOR(=%s)', json_encode($selector)));
}

$chunk_max_items = $options[WpOptionsExporter::OPTION_SLICE_MAX_ITEMS] ?? WpOptionsExporter::OPTION_SLICE_MAX_ITEMS_DEFAULT;
$chunks = [];
$current_chunk = [];
foreach (\wp_load_alloptions() as $wpOptionName => $wpOptionValue) {
if (__matchesSelector($wpOptionName, $selector)) {
// CAVEAT: we cannot use $wpOptionValue since its not automagically deserialized
// $wpOptionValue transports just the plain serialization string, so we need to utilize
// \get_option to get the correct value
// otherwise array and object values will not be coeectly exported
$current_chunk[$wpOptionName] = \get_option($wpOptionName);

if (count($current_chunk) === $chunk_max_items) {
$chunks[] = $current_chunk;
$current_chunk = [];
}
}
}

if (count($current_chunk) > 0) {
$chunks[] = $current_chunk;
}

foreach ($chunks as $chunk) {
yield [
Impex::SLICE_TAG => WpOptionsExporter::SLICE_TAG,
Impex::SLICE_VERSION => WpOptionsExporter::VERSION,
Impex::SLICE_META => [
Impex::SLICE_META_ENTITY => WpOptionsExporter::SLICE_META_ENTITY_WP_OPTIONS,
'options' => $options,
],
Impex::SLICE_DATA => $chunk,
];
}
}

/**
* @TODO: convert to enum if enums once are available in PHP
*/
interface WpOptionsExporter
{
const SLICE_TAG = 'wp-options';
const SLICE_META_ENTITY_WP_OPTIONS = self::SLICE_TAG;

const OPTION_SELECTOR = 'wp-options-export-option-selector';
const OPTION_SLICE_MAX_ITEMS = 'wp-options-export-option-chunk-max-items';
const OPTION_SLICE_MAX_ITEMS_DEFAULT = 50;

const PROVIDER_NAME = self::class;

const VERSION = '1.0.0';
}

function __registerWpOptionsExportProvider()
{
$provider = Impex::getInstance()->Export->addProvider(WpOptionsExporter::PROVIDER_NAME, __NAMESPACE__ . '\__WpOptionsExporterProviderCallback');
return $provider;
}

\add_action(
hook_name: Impex::WP_ACTION_REGISTER_PROVIDERS,
callback: __NAMESPACE__ . '\__registerWpOptionsExportProvider',
);
276 changes: 276 additions & 0 deletions plugins/cm4all-wp-impex/inc/impex-import-extension-attachment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once ABSPATH . '/wp-admin/includes/import.php';

require_once __DIR__ . '/interface-impex-named-item.php';
require_once __DIR__ . '/class-impex.php';

use cm4all\wp\impex\Impex;

/**
* @TODO: we can declare the contents of an array (https://bleepcoder.com/plugin-php/617134322/broken-phpdoc-for-closures)
* // @param array{a: int, b: string} $bar
*/
function __AttachmentImportProviderCallback(array $slice, array $options, ImpexImportTransformationContext $transformationContext): bool
{
if ($slice[Impex::SLICE_TAG] === AttachmentsExporter::SLICE_TAG) {
if (($slice[Impex::SLICE_TYPE] === Impex::SLICE_TYPE_RESOURCE) && $slice[Impex::SLICE_META][Impex::SLICE_META_ENTITY] === AttachmentsExporter::SLICE_META_ENTITY_ATTACHMENT) {
if ($slice[Impex::SLICE_VERSION] !== AttachmentsExporter::VERSION) {
throw new ImpexImportRuntimeException(sprintf('Dont know how to import slice(tag="%s", version="%s") : unsupported version. current version is "%s"', AttachmentsExporter::SLICE_TAG, $slice[Impex::SLICE_VERSION], AttachmentsExporter::VERSION));
}

$attachmentImporter = new __AttachmentImporter($slice, $options, $transformationContext);
$attachmentImporter();

return true;
}
}

return false;
}

interface AttachmentImporter
{
const PROVIDER_NAME = self::class;

const WP_FILTER_IMPORT_REST_SLICE_UPLOAD_FILE = "AttachmentImporter";

const OPTION_OVERWRITE = 'wp-attachment-import-option-overwrite';
const OPTION_OVERWRITE_DEFAULT = true;
}

function __registerAttachmentImportProvider()
{
$provider = Impex::getInstance()->Import->addProvider(AttachmentImporter::PROVIDER_NAME, __NAMESPACE__ . '\__AttachmentImportProviderCallback');
return $provider;
}

\add_action(
hook_name: Impex::WP_ACTION_REGISTER_PROVIDERS,
callback: __NAMESPACE__ . '\__registerAttachmentImportProvider',
);

class __AttachmentImporter
{
protected $url_remap = [];

function __construct(protected array $slice, protected array $options, protected ImpexImportTransformationContext $transformationContext)
{
}

protected function delete_existing_attachments_matching_upload()
{
$file_name = basename(parse_url($this->slice[Impex::SLICE_META]['data']['guid'], PHP_URL_PATH));

//$uploads = \wp_upload_dir($this->slice[Impex::SLICE_META]['post_date']);
$post_date = $this->slice[Impex::SLICE_META]['data']['post_date'];
$uploads = \wp_upload_dir($post_date);
if (!($uploads && false === $uploads['error'])) {
return;
}

$url = $uploads['url'] . "/$file_name";

global $wpdb;

foreach ($wpdb->get_col($wpdb->prepare("select ID from wp_posts where post_type='attachment' and guid =%s", $url)) as $ID) {
$success = \wp_delete_attachment($ID, true);
if ($success == false || $success == null) {
throw new ImpexImportRuntimeException(sprintf('import attachment : failed to remove existing attachment(ID="%s") referencing with same attachment url(="%s")', $ID, $url));
}
}
}

function __invoke()
{
$overwrite = $this->options[AttachmentImporter::OPTION_OVERWRITE] ?? AttachmentImporter::OPTION_OVERWRITE_DEFAULT;
if ($overwrite) {
// ensure existing attachments referencing same file are removed before inserting ours
$this->delete_existing_attachments_matching_upload();
}

$post = $this->slice[Impex::SLICE_META]['data'];
$url = $post['guid'];

/*
code more or less duplicated from wordpress-importer function process_attachment
https://github.com/WordPress/wordpress-importer/blob/e05f678835c60030ca23c9a186f50999e198a360/src/class-wp-import.php#L1009
*/

$upload = $this->fetch_remote_file($url, $post, $this->slice);
if (\is_wp_error($upload)) {
return $upload;
}

$post['guid'] = $upload['url'];
unset($post['ID']);

// as per wp-admin/includes/upload.php
$post_id = \wp_insert_attachment($post, $upload['file']);

// required if non admin user imports attachments
if (!function_exists('wp_crop_image')) {
include(ABSPATH . 'wp-admin/includes/image.php');
}

/*$attachment_invalid =*/
\wp_update_attachment_metadata($post_id, \wp_generate_attachment_metadata($post_id, $upload['file']));

/*
if ($attachment_invalid === false) {
return new \WP_Error('wp_update_attachment_metadata', 'attachment metadata invalid');
}
*/

// remap resized image URLs, works by stripping the extension and remapping the URL stub.
if (preg_match('!^image/!', $post['post_mime_type'])) {
$parts = pathinfo($url);
$name = basename($parts['basename'], ".{$parts['extension']}"); // PATHINFO_FILENAME in PHP 5.2

$parts_new = pathinfo($upload['url']);
$name_new = basename($parts_new['basename'], ".{$parts_new['extension']}");

$this->url_remap[$parts['dirname'] . '/' . $name] = $parts_new['dirname'] . '/' . $name_new;
}

// @TODO: any change to do this AFTER all images are uploaded ?
$this->backfill_attachment_urls();
}

/**
* patched version of wordpress-importer fetch_remote_file()
*/
function fetch_remote_file($url, $post, $slice)
{
// Extract the file name from the URL.
$file_name = basename(parse_url($url, PHP_URL_PATH));

$uploads = wp_upload_dir($post['post_date']);
if (!($uploads && false === $uploads['error'])) {
return new \WP_Error('upload_dir_error', $uploads['error']);
}

$file_name = wp_unique_filename($uploads['path'], $file_name);
$new_file = $uploads['path'] . "/$file_name";

// Copy the file to the uploads dir.
copy(
$this->transformationContext->path . '/' . $this->slice[Impex::SLICE_DATA],
$new_file,
);

// Set correct file permissions.
$stat = stat(dirname($new_file));
$perms = $stat['mode'] & 0000666;
chmod($new_file, $perms);

$upload = [
'file' => $new_file,
'url' => $uploads['url'] . "/$file_name",
'type' => $post['post_mime_type'],
'error' => false,
];

// keep track of the old and new urls so we can substitute them later
$this->url_remap[$url] = $upload['url'];
$this->url_remap[$post['guid']] = $upload['url']; // r13735, really needed?
/*
// keep track of the destination if the remote url is redirected somewhere else
if (isset($headers['x-final-location']) && $headers['x-final-location'] != $url) {
$this->url_remap[$headers['x-final-location']] = $upload['url'];
}
*/

return $upload;
}

/**
* patched version of wordpress-importer backfill_attachment_urls()
* @see https://github.com/WordPress/wordpress-importer/blob/e05f678835c60030ca23c9a186f50999e198a360/src/class-wp-import.php#L1265
*/
// @TODO: any change to do this AFTER all images are uploaded ?
function backfill_attachment_urls()
{
global $wpdb;
// make sure we do the shortest urls first, in case one is a substring of another
uksort($this->url_remap, [&$this, 'cmpr_strlen']);

foreach ($this->url_remap as $from_url => $to_url) {
// remap urls in post_content
// replace from_url => to_url and also for block attributes in json notation
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->posts} SET post_content = REPLACE( REPLACE(post_content, %s, %s), %s, %s)", $from_url, $to_url, json_encode($from_url), json_encode($to_url)));
// remap enclosure urls
$result = $wpdb->query($wpdb->prepare("UPDATE {$wpdb->postmeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_key='enclosure'", $from_url, $to_url));
}
}

// return the difference in length between two strings
function cmpr_strlen($a, $b)
{
return strlen($a) - strlen($b);
}
}

\add_action(
hook_name: 'rest_api_init',
callback: function () {
require_once __DIR__ . '/class-impex-import-rest-controller.php';
\add_filter(
hook_name: ImpexImportRESTController::WP_FILTER_IMPORT_REST_SLICE_UPLOAD,
callback: function (array $slice, ImpexImportTransformationContext $transformationContext, \WP_REST_Request $request) {
if (
$slice[Impex::SLICE_TAG] === AttachmentsExporter::SLICE_TAG
) {
$files = $request->get_file_params();
if (!is_array($files)) {
throw new \WP_Error('bad-request', __('Multipart file upload is missing in request', 'cm4all-wp-impex'), ['status' => 400]);
}

$attachmentFile = $files[AttachmentImporter::WP_FILTER_IMPORT_REST_SLICE_UPLOAD_FILE] ?? null;
if ($attachmentFile === null) {
throw new ImpexImportRuntimeException(sprintf('Multipart file upload "%s" is missing in request', AttachmentImporter::WP_FILTER_IMPORT_REST_SLICE_UPLOAD_FILE));
}

$to = $transformationContext->path . '/' . $slice[Impex::SLICE_DATA];
$success = \wp_mkdir_p(dirname($to));
$success = rename($attachmentFile["tmp_name"], $to);

if ($success === null) {
throw new ImpexImportRuntimeException(sprintf('Failed to move uploaded attachment(=%) to impex import directory : %s', $attachmentFile["tmp_name"], $to));
}

unset($files[AttachmentImporter::WP_FILTER_IMPORT_REST_SLICE_UPLOAD_FILE]);
$request->set_file_params($files);
}
return $slice;
},
accepted_args: 3,
);
},
);

\add_action(
hook_name: Impex::WP_ACTION_ENQUEUE_IMPEX_PROVIDER_SCRIPT,
callback: function ($client_asset_handle, $in_footer) {
$HANDLE = strtolower(str_replace('\\', '-', AttachmentImporter::PROVIDER_NAME));
\cm4all\wp\impex\wp_enqueue_script(
handle: $HANDLE,
deps: [$client_asset_handle, $client_asset_handle . '-debug'],
pluginRelativePath: 'dist/wp.impex.extension.import.attachment.js',
in_footer: $in_footer
);

\wp_add_inline_script(
$HANDLE,
sprintf('wp.impex.extension.import.attachment.WP_FILTER_IMPORT_REST_SLICE_UPLOAD_FILE=%s;', \wp_json_encode(AttachmentImporter::WP_FILTER_IMPORT_REST_SLICE_UPLOAD_FILE)),
);
},
accepted_args: 2,
);
850 changes: 850 additions & 0 deletions plugins/cm4all-wp-impex/inc/impex-import-extension-content.php

Large diffs are not rendered by default.

207 changes: 207 additions & 0 deletions plugins/cm4all-wp-impex/inc/impex-import-extension-db-table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once ABSPATH . '/wp-admin/includes/import.php';

require_once __DIR__ . '/interface-impex-named-item.php';
require_once __DIR__ . '/class-impex.php';

use cm4all\wp\impex\Impex;

function __DbTableImportProviderCallback(array $slice, array $options, ImpexImportTransformationContext $transformationContext): bool
{
global $wpdb;

if ($slice[Impex::SLICE_TAG] === DbTablesExporter::SLICE_TAG) {
if ($slice[Impex::SLICE_VERSION] !== DbTablesExporter::VERSION) {
throw new ImpexImportRuntimeException(sprintf('Dont know how to import slice(tag="%s", version="%s") : unsupported version. current version is "%s"', DbTablesExporter::SLICE_TAG, $slice[Impex::SLICE_VERSION], DbTablesExporter::VERSION));
}

// @TODO: assume same database server/connection for now
$target_wpdb = $wpdb;

$option_db_tableprefix = $options[DbTableImporter::OPTION_SELECTORPREFIX] ?? $wpdb->prefix;

/**
* @TODO: how to handle report
* @var callable $log
*/
$log = function (string $message, mixed $context = null) use ($options) {
isset($options[Impex::OPTION_LOG]) && call_user_func($options[Impex::OPTION_LOG], $message, $context);
};

$slice_meta = $slice[Impex::SLICE_META];
$target_table_name = $option_db_tableprefix . $slice_meta['name'];

switch ($slice_meta[Impex::SLICE_META_ENTITY]) {
case DbTablesExporter::SLICE_META_ENTITY_TABLE: {
$option_db_truncate = $options[DbTableImporter::OPTION_TRUNCATE] ?? DbTableImporter::OPTION_TRUNCATE_DEFAULT;
$option_db_overwrite_definition = $options[DbTableImporter::OPTION_OVERWRITE_DEFINITION] ?? DbTableImporter::OPTION_OVERWRITE_DEFINITION_DEFAULT;

$ddl = $slice[Impex::SLICE_DATA];

if ($target_wpdb->query('START TRANSACTION') !== false) {
try {
// @TODO: should we disable keys before and reenable them afterwards ? 'ALTER TABLE ' . $target_table_name . ' ENABLE KEYS'

// (optional) drop table
if ($option_db_overwrite_definition) {
$queryRetval = $target_wpdb->query("DROP TABLE IF EXISTS $target_table_name");
$queryRetval === false && throw new RollbackSignal();

$log(sprintf('%s : %s', $target_wpdb->last_query, $queryRetval));
}

// create table if not exists
$queryRetval = $target_wpdb->query(strtr($ddl, ['%prefix%' => $option_db_tableprefix]));
$queryRetval === false && throw new RollbackSignal();

$log(sprintf('%s : %s', $target_wpdb->last_query, $queryRetval));

// (optional) truncate data
if (!$option_db_overwrite_definition && $option_db_truncate) {
$queryRetval = $target_wpdb->query($target_wpdb->prepare('TRUNCATE TABLE %s', $target_table_name));
$queryRetval === false && throw new RollbackSignal();

$log(sprintf('%s : %s', $target_wpdb->last_query, $queryRetval));
}

/* @TODO: check later if the snippet below makes sense in our case
// we could copy the values "in place" using sql
// use CREATE TABLE ... LIKE ... to keep keys, defaults, ...
$ret = $target_wpdb->query(
'CREATE TABLE ' .
$task->arguments->target_table .
' LIKE ' .
$task->arguments->source_table
);
*/

// should we copy data in place from table to table ?
if (isset($options[DbTableImporter::OPTION_COPY_DATA_FROM_TABLE_WITH_PREFIX])) {
$queryRetval = $target_wpdb->query(strtr(
'INSERT INTO %target_table% SELECT * FROM %source_table%',
[
'%target_table%' => $target_table_name,
'%source_table%' => $options[DbTableImporter::OPTION_COPY_DATA_FROM_TABLE_WITH_PREFIX] . $slice_meta['name'],
]
));
$queryRetval === false && throw new RollbackSignal();

$log(sprintf('%s : %s', $target_wpdb->last_query, $queryRetval));
}

$target_wpdb->query('COMMIT');

return true;
} catch (RollbackSignal $signal) {
$target_wpdb->query('ROLLBACK');
throw $signal;
}
}

return false;
}
case DbTablesExporter::SLICE_META_ENTITY_ROWS: {
// if data where already copied from table to table inside the database
// we can skip proceeding the rows
if (!isset($options[DbTableImporter::OPTION_COPY_DATA_FROM_TABLE_WITH_PREFIX])) {

$option_db_overwrite_data = $options[DbTableImporter::OPTION_OVERWRITE_DATA] ?? DbTableImporter::OPTION_OVERWRITE_DATA_DEFAULT;

$rows = $slice[Impex::SLICE_DATA];

// TODO: implement optimized in-db-copy procedure when special option provided
if (count($rows)) {
$table_column_names = implode('`, `', array_keys((array)$rows[0]));

/*
see https://thispointer.com/insert-into-a-mysql-table-or-update-if-exists/#three
> REPLACE works similar to INSERT. The difference is: If the new row to be inserted has the same value
of the PRIMARY KEY or the UNIQUE index as the existing row, in that case, the old row gets deleted first
before inserting the new one.
REPLACE INTO customer_data(customer_id, customer_name, customer_place) VALUES(2, "Hevika","Atlanta");
*/
$placeholders = str_repeat('%s, ', count(array_keys((array)$rows[0])) - 1) . '%s';
$dml = strtr('%insert_or_replace% INTO `%name%`(`%column_names%`) VALUES(%values_placeholder%)', [
'%insert_or_replace%' => $option_db_overwrite_data ? 'REPLACE' : 'INSERT',
'%name%' => $target_table_name,
'%column_names%' => $table_column_names,
'%values_placeholder%' => $placeholders,
]);

foreach ($rows as $row) {
// @TODO: consider using https://developer.wordpress.org/reference/classes/wpdb/#replace-row
// instead of populating a sql statement manually - it seems much shorter and easier to read
$preparedStatement = $target_wpdb->prepare($dml, (array)$row);

$queryRetval = $target_wpdb->query($preparedStatement);
if ($queryRetval === false) {
$log(sprintf('%s : %s', $target_wpdb->last_query, $queryRetval));
throw new RollbackSignal();
}
}
}
}
return true;
}
default: {
throw new ImpexImportRuntimeException(sprintf('dont now how to handle slice meta entity : %s', $slice_meta['entity']), $slice);
}
}

return true;
}

return false;
}

class RollbackSignal extends ImpexImportRuntimeException
{
function __construct($msg = "")
{
global $wpdb;

if ($msg === "" && $wpdb->last_error) {
parent::__construct("{$wpdb->last_query} : $wpdb->last_error");
}
}
}

interface DbTableImporter
{
const OPTION_TRUNCATE = 'db-table-import-option-truncate';
const OPTION_TRUNCATE_DEFAULT = true;

const OPTION_OVERWRITE_DATA = 'db-table-import-option-overwrite_data';
const OPTION_OVERWRITE_DATA_DEFAULT = true;

const OPTION_COPY_DATA_FROM_TABLE_WITH_PREFIX = 'db-table-import-option-copy-data-from-table-with-prefix';

const OPTION_OVERWRITE_DEFINITION = 'db-table-import-option-overwrite-definition';
const OPTION_OVERWRITE_DEFINITION_DEFAULT = true;

const OPTION_SELECTORPREFIX = 'db-table-import-option-tableprefix';

const PROVIDER_NAME = self::class;
}

function __registerDbTableImportProvider()
{
$provider = Impex::getInstance()->Import->addProvider(DbTableImporter::PROVIDER_NAME, __NAMESPACE__ . '\__DbTableImportProviderCallback');
return $provider;
}

\add_action(
hook_name: Impex::WP_ACTION_REGISTER_PROVIDERS,
callback: __NAMESPACE__ . '\__registerDbTableImportProvider',
);
50 changes: 50 additions & 0 deletions plugins/cm4all-wp-impex/inc/impex-import-extension-wp-options.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once ABSPATH . '/wp-admin/includes/import.php';

require_once __DIR__ . '/interface-impex-named-item.php';
require_once __DIR__ . '/class-impex.php';

use cm4all\wp\impex\Impex;

function __WpOptionsImportProviderCallback(array $slice, array $options, ImpexImportTransformationContext $transformationContext): bool
{
if ($slice[Impex::SLICE_TAG] === WpOptionsExporter::SLICE_TAG) {
if ($slice[Impex::SLICE_META][Impex::SLICE_META_ENTITY] === WpOptionsExporter::SLICE_META_ENTITY_WP_OPTIONS) {
if ($slice[Impex::SLICE_VERSION] !== WpOptionsExporter::VERSION) {
throw new ImpexImportRuntimeException(sprintf('Dont know how to import slice(tag="%s", version="%s") : unsupported version. current version is "%s"', WpOptionsExporter::SLICE_TAG, $slice[Impex::SLICE_VERSION], WpOptionsExporter::VERSION));
}

foreach ($slice[Impex::SLICE_DATA] as $wpOptionName => $wpOptionValue) {
\update_option($wpOptionName, $wpOptionValue);
}

return true;
}
}

return false;
}

interface WpOptionsImporter
{
const PROVIDER_NAME = self::class;
}

function __registerWpOptionsImportProvider()
{
$provider = Impex::getInstance()->Import->addProvider(WpOptionsImporter::PROVIDER_NAME, __NAMESPACE__ . '\__WpOptionsImportProviderCallback');
return $provider;
}

\add_action(
hook_name: Impex::WP_ACTION_REGISTER_PROVIDERS,
callback: __NAMESPACE__ . '\__registerWpOptionsImportProvider',
);
16 changes: 16 additions & 0 deletions plugins/cm4all-wp-impex/inc/interface-impex-named-item.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}


/**
* @property-read $name
*/
interface ImpexNamedItem
{
}
17 changes: 17 additions & 0 deletions plugins/cm4all-wp-impex/inc/interface-impex-rest-controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

interface ImpexRestController
{
const VERSION = '1';

const NAMESPACE = 'cm4all-wp-impex/v' . self::VERSION;

const BASE_URI = '/' . self::NAMESPACE;
}
80 changes: 80 additions & 0 deletions plugins/cm4all-wp-impex/inc/trait-impex-set.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once __DIR__ . '/interface-impex-named-item.php';
require_once __DIR__ . '/class-impex-runtime-exception.php';

trait ImpexSet /* implements \IteratorAggregate */
{
protected array $_items = [];

protected function _indexByName(string $name): int
{
foreach ($this->_items as $index => $item) {
if ($item->name === $name) {
return $index;
}
}

return -1;
}

public function add(ImpexNamedItem $item): self
{
if ($this->has($item->name)) {
throw new ImpexRuntimeException(sprintf('Cannot add item : An item named "%s" already exists', $item->name));
}

$this->_items[] = $item;

return $this;
}

public function has(string $name): bool
{
return $this->_indexByName($name) !== -1;
}

public function get(string $name): ImpexNamedItem|null
{
$index = $this->_indexByName($name);
return $index !== -1 ? $this->_items[$index] : null;
}

public function remove(string $name): ImpexNamedItem|null
{
$index = $this->_indexByName($name);
if ($index !== -1) {
$item = $this->_items[$index];
array_splice($this->_items, $index, 1);
return $item;
}

return null;
}

public function move(string $name, int $newIndex): bool
{
$index = $this->_indexByName($name);
if ($index !== -1) {
$namedItem = array_splice($this->_items, $index, 1);
array_splice($this->_items, $newIndex, 0, $namedItem);
}

return $index !== -1;
}

/**
* @see \IteratorAggregate
*/
public function getIterator(): \Iterator
{
return new \ArrayIterator(array_values($this->_items));
}
}
37 changes: 37 additions & 0 deletions plugins/cm4all-wp-impex/inc/trait-impex-singleton.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

trait ImpexSingleton
{
private static $instance;

protected function __construct()
{
}

public static function getInstance(): static
{
if (!self::$instance) {
// new self() will refer to the class that uses the trait
self::$instance = new self();
}

return self::$instance;
}

protected function __clone()
{
}
public function __sleep()
{
}
public function __wakeup()
{
}
}
114 changes: 114 additions & 0 deletions plugins/cm4all-wp-impex/inc/wp-dashboard-contributions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

namespace cm4all\wp\impex\wp_admin\dashboard;

use function cm4all\wp\impex\enqueueClientAssets;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once __DIR__ . '/wp-wrapper-functions.php';

const TITLE = 'Impex';
// slug cannot be defined as const since constants need to be available at compile time
// => https://stackoverflow.com/questions/51000541/php-constant-expression-contains-invalid-operations
define('IMPEX_SCREEN_PAGE_SLUG', str_replace("\\", '_', __NAMESPACE__));
// ATTENTION : the '_page' suffix is important !
// otherwise storeing the value via filter 'set-screen-option' will fail
const SCREEN_OPTION_VERBOSE = IMPEX_SCREEN_PAGE_SLUG . '_' . 'verbose_page';

\add_action(
hook_name: "admin_menu",
callback: function () {
$submenu_page_hook_suffix = \add_submenu_page('tools.php', TITLE, TITLE, 'manage_options', IMPEX_SCREEN_PAGE_SLUG, function () {
require_once ABSPATH . 'wp-admin/admin-header.php';

echo sprintf('<div class="wrap" id="%s"></div>', IMPEX_SCREEN_PAGE_SLUG);

require_once ABSPATH . 'wp-admin/admin-footer.php';
}, 0);

\add_action(
hook_name: 'load-' . $submenu_page_hook_suffix,
callback: function () {
\get_current_screen()->add_help_tab(
[
'id' => 'help-overview',
'title' => __('Overview', 'cm4all-wp-impex'),
'content' => '
<p>
Impex provides extensible Import and Export capabilities across plugins / themes by providing a hook mechanism to 3rd-party plugins and themes.
</p>
<p>
Impex supports a pluggable provider interface to export / import custom data. Impex export and import can be customized ab applying filters.
</p>
<p>
By default a self contained, streamable <a href="https://cbor.io/">CBOR</a> archive gets exported (also including media) and vice versa imported.
</p>
',
]
);

\get_current_screen()->add_help_tab(
[
'id' => 'help-export',
'title' => __('Export', 'cm4all-wp-impex'),
'content' => '
TODO
'
]
);

\get_current_screen()->add_help_tab(
[
'id' => 'help-import',
'title' => __('Import', 'cm4all-wp-impex'),
'content' => '
TODO
'
]
);

\get_current_screen()->set_help_sidebar(
sprintf(
'<p><strong>%s</strong></p><p>%s</p><p>%s</p>',
__('For more information:', 'cm4all-wp-impex'),
__('<a href="https://wordpress.org/support/article/tools-screen/">Documentation on Impex</a>', 'cm4all-wp-impex'),
__('<a href="https://wordpress.org/support/">Support</a>', 'cm4all-wp-impex')
)
);

$IN_FOOTER = true;
$IMPEX_CLIENT_HANDLE = enqueueClientAssets($IN_FOOTER);
\cm4all\wp\impex\wp_enqueue_script(
handle: IMPEX_SCREEN_PAGE_SLUG,
pluginRelativePath: 'dist/wp.impex.dashboard.js',
deps: [$IMPEX_CLIENT_HANDLE, 'wp-element', 'wp-api-fetch', 'wp-url', 'wp-i18n', 'wp-components', 'wp-data', 'wp-core-data'],
in_footer: $IN_FOOTER
);
\wp_set_script_translations(
handle: IMPEX_SCREEN_PAGE_SLUG,
domain: $IMPEX_CLIENT_HANDLE,
path: plugin_dir_path(__DIR__) . 'languages'
);

\cm4all\wp\impex\wp_enqueue_style(
handle: IMPEX_SCREEN_PAGE_SLUG,
pluginRelativePath: 'dist/wp.impex.dashboard.css',
deps: [$IMPEX_CLIENT_HANDLE, 'wp-components']
);

/*
prevent loading wp admin forms.css since it breaks gutenberg component styles
wp_register_style doesnt overwrite exiting style registrations so that we need to
- remove the original style
- add a dummy style handle for 'forms'
*/
\wp_deregister_style(handle: 'forms');
\wp_register_style(handle: 'forms', src: '');
},
);
},
);
124 changes: 124 additions & 0 deletions plugins/cm4all-wp-impex/inc/wp-wrapper-functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

namespace cm4all\wp\impex;

/**
* contains various utility functions wrapping general WordPress functions
*/

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

/**
* Retrieves a URL within the wp-impex plugin directory.
*
* @param string $path Optional. Extra path appended to the end of the URL, including
* the relative directory if $plugin is supplied. Default empty.
*
* @see \plugins_url
*/
function plugins_url(string $path = ''): string
{
return \plugins_url($path, __DIR__);
}

/**
* Get the filesystem directory path (with trailing slash) for the wp-impex plugin.
*
* @param string $path (optional) The filename of the plugin
* @return string the filesystem path of the directory contained in wp-impex plugin.
*
* @see \plugin_dir_path
*/
function plugin_dir_path(string $path = ''): string
{
return \plugin_dir_path(__DIR__) . $path;
}

/**
* Register a new script.
*
* Registers a script to be enqueued later using the wp_enqueue_script() function.
*
* @param string $handle Name of the script. Should be unique.
* @param string $pluginRelativePath path of the script relative to the plugin root.
* If source is set to false, script is an alias of other scripts it depends on.
* @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array.
* @param bool $in_footer Optional. Whether to enqueue the script before </body> instead of in the <head>.
* Default 'false'.
* @return bool Whether the script has been registered. True on success, false on failure.
*
* @see \wp_register_script
*/
function wp_register_script(string $handle, string $pluginRelativePath, array $deps = [], bool $in_footer = false): bool
{
if (defined(SCRIPT_DEBUG) && !SCRIPT_DEBUG) {
$pluginRelativePath = preg_replace('/\.js$/', '-min.js', $pluginRelativePath);
}

return \wp_register_script($handle, plugins_url($pluginRelativePath), $deps, filemtime(plugin_dir_path($pluginRelativePath)), $in_footer);
}

/**
* Enqueue a script.
*
* @param string $handle Name of the script. Should be unique.
* @param string $pluginRelativePath path of the script relativeto the pluginroot.
* @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array.
* @param bool $in_footer Optional. Whether to enqueue the script before </body> instead of in the <head>.
* Default 'false'.
*
* \wp_enqueue_script
*/
function wp_enqueue_script(string $handle, string $pluginRelativePath, array $deps = [], bool $in_footer = false)
{
wp_register_script($handle, $pluginRelativePath, $deps, $in_footer);

return \wp_enqueue_script($handle);
}

/**
* Register a CSS stylesheet.
*
* @param string $handle Name of the stylesheet. Should be unique.
* @param string $pluginRelativePath path of the stylesheet relative to the plugin root.
* If source is set to false, stylesheet is an alias of other stylesheets it depends on.
* @param string[] $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array.
* @param string $media Optional. The media for which this stylesheet has been defined.
* Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media queries like
* '(orientation: portrait)' and '(max-width: 640px)'.
* @return bool Whether the style has been registered. True on success, false on failure.
*
* @see \wp_register_style
*/
function wp_register_style(string $handle, string $pluginRelativePath, array $deps = [], string $media = 'all'): bool
{
if (defined(SCRIPT_DEBUG) && !SCRIPT_DEBUG) {
$pluginRelativePath = preg_replace('/\.css$/', '-min.css', $pluginRelativePath);
}

return \wp_register_style($handle, plugins_url($pluginRelativePath), $deps, filemtime(plugin_dir_path($pluginRelativePath)), $media);
}

/**
* Enqueue a CSS stylesheet.
*
* Registers the style if source provided (does NOT overwrite) and enqueues.
*
* @param string $handle Name of the stylesheet. Should be unique.
* @param string $pluginRelativePath path of the stylesheet relative to the plugin root.
* @param string[] $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array.
* @param string $media Optional. The media for which this stylesheet has been defined.
* Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media queries like
* '(orientation: portrait)' and '(max-width: 640px)'.
*
* @see \wp_enqueue_style
*/
function wp_enqueue_style(string $handle, string $pluginRelativePath, array $deps = [], string $media = 'all')
{
wp_register_style($handle, $pluginRelativePath, $deps, $media);

return \wp_enqueue_style($handle);
}
293 changes: 293 additions & 0 deletions plugins/cm4all-wp-impex/languages/cm4all-wp-impex-en_US.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
# Copyright (C) 2021 Lars Gersmann, CM4all
# This file is distributed under the same license as the cm4all-wp-impex plugin.
msgid ""
msgstr ""
"Project-Id-Version: cm4all-wp-impex 1.0.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/cm4all-wp-impex\n"
"POT-Creation-Date: 2022-01-24T08:24:24+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: WP-CLI 2.5.0\n"
"X-Domain: cm4all-wp-impex\n"

#. Plugin Name of the plugin
msgid "cm4all-wp-impex"
msgstr ""

#. Plugin URI of the plugin
msgid "http://dev.intern.cm-ag/trinity/research/cm4all-wp-impex"
msgstr ""

#. Author of the plugin
msgid "Lars Gersmann, CM4all"
msgstr ""

#. Author URI of the plugin
msgid "https://cm4all.com"
msgstr ""

#: inc/class-impex-export-rest-controller.php:142
#: inc/class-impex-export-rest-controller.php:161
msgid "could not find export by id"
msgstr ""

#: inc/class-impex-export-rest-controller.php:175
#: inc/class-impex-import-rest-controller.php:253
msgid "body expected to be a json object"
msgstr ""

#: inc/class-impex-export-rest-controller.php:180
msgid "export profile not found"
msgstr ""

#: inc/class-impex-export-rest-controller.php:208
#: inc/class-impex-import-rest-controller.php:286
msgid "Failed to update export"
msgstr ""

#: inc/class-impex-export-rest-controller.php:227
#: inc/class-impex-import-rest-controller.php:305
msgid "removing impex export failed"
msgstr ""

#: inc/class-impex-export-rest-controller.php:351
msgid "Unique identifier for the export."
msgstr ""

#: inc/class-impex-export-rest-controller.php:358
msgid "The options used to create the export."
msgstr ""

#: inc/class-impex-export-rest-controller.php:366
msgid "The name of the export profile to use."
msgstr ""

#: inc/class-impex-export-rest-controller.php:375
msgid "The date of the export."
msgstr ""

#: inc/class-impex-export-rest-controller.php:382
msgid "The user login of the user creating the export."
msgstr ""

#: inc/class-impex-export-rest-controller.php:388
msgid "The human readable name of the export"
msgstr ""

#: inc/class-impex-export-rest-controller.php:395
msgid "The human readable description of the export"
msgstr ""

#: inc/class-impex-import-rest-controller.php:156
msgid "Multipart parameter slice is missing"
msgstr ""

#: inc/class-impex-import-rest-controller.php:173
msgid "Multipart parameter \"slice\" is expected to contain json"
msgstr ""

#: inc/class-impex-import-rest-controller.php:189
#: inc/class-impex-import-rest-controller.php:214
#: inc/class-impex-import-rest-controller.php:239
msgid "could not find import by id"
msgstr ""

#: inc/class-impex-import-rest-controller.php:429
msgid "Unique identifier for the import."
msgstr ""

#: inc/class-impex-import-rest-controller.php:436
msgid "The options used to create the import."
msgstr ""

#: inc/class-impex-import-rest-controller.php:444
msgid "The name of the import profile to use."
msgstr ""

#: inc/class-impex-import-rest-controller.php:453
msgid "The date of the import."
msgstr ""

#: inc/class-impex-import-rest-controller.php:460
msgid "The user login of the user creating the import."
msgstr ""

#: inc/class-impex-import-rest-controller.php:466
msgid "The name of the import"
msgstr ""

#: inc/class-impex-import-rest-controller.php:473
msgid "The description of the import"
msgstr ""

#: inc/class-impex-profile-rest-controller.php:171
msgid "Unique identifier for the profile."
msgstr ""

#: inc/class-impex-profile-rest-controller.php:177
msgid "Human readable description of the profile."
msgstr ""

#: inc/impex-import-extension-attachment.php:233
msgid "Multipart file upload is missing in request"
msgstr ""

#: inc/wp-dashboard-contributions.php:39
msgid "Overview"
msgstr ""

#: inc/wp-dashboard-contributions.php:57 dist/wp.impex.dashboard.js:354
msgid "Export"
msgstr "(en_US) Export"

#: inc/wp-dashboard-contributions.php:67 dist/wp.impex.dashboard.js:546
msgid "Import"
msgstr "(en_US) Import"

#: inc/wp-dashboard-contributions.php:77
msgid "For more information:"
msgstr "(en_US) For more information:"

#: inc/wp-dashboard-contributions.php:78
msgid ""
"<a href=\"https://wordpress.org/support/article/tools-screen/"
"\">Documentation on Impex</a>"
msgstr ""
"(en_US) <a href=\"https://wordpress.org/support/article/tools-screen/"
"\">Documentation on Impex</a>"

#: inc/wp-dashboard-contributions.php:79
msgid "<a href=\"https://wordpress.org/support/\">Support</a>"
msgstr "(en_US) <a href=\"https://wordpress.org/support/\">Support</a>"

#: dist/wp.impex.dashboard.js:188
msgid "Name"
msgstr ""

#: dist/wp.impex.dashboard.js:189
msgid "Name should be short and human readable"
msgstr ""

#: dist/wp.impex.dashboard.js:193
msgid "Description"
msgstr ""

#: dist/wp.impex.dashboard.js:194
msgid "Description may contain more expressive information describing the item"
msgstr ""

#: dist/wp.impex.dashboard.js:285
msgid "Creating snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:327
msgid "Downloading snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:356
msgid "Create snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:362
msgid "Export Profile"
msgstr "(en_US) Export Profile"

#: dist/wp.impex.dashboard.js:367
msgid "Select an Export profile"
msgstr "(en_US) Select an Export profile"

#: dist/wp.impex.dashboard.js:376
msgid ""
"Export profiles define which Wordpress data should be extracted to the "
"snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:381
msgid "Create Snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:389
msgid "Download snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:390
msgid "Additional actions on this export"
msgstr ""

#: dist/wp.impex.dashboard.js:393 dist/wp.impex.dashboard.js:587
msgid "Edit"
msgstr ""

#: dist/wp.impex.dashboard.js:398
#, fuzzy
msgid "Edit export"
msgstr "(en_US) Export"

#: dist/wp.impex.dashboard.js:407 dist/wp.impex.dashboard.js:601
msgid "Delete"
msgstr ""

#: dist/wp.impex.dashboard.js:412
msgid "Delete export"
msgstr ""

#: dist/wp.impex.dashboard.js:464
msgid "Importing data into Wordpress ..."
msgstr ""

#: dist/wp.impex.dashboard.js:511
msgid "Uploading snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:548
msgid "Upload snapshot to Wordpress"
msgstr ""

#: dist/wp.impex.dashboard.js:554
#, fuzzy
msgid "Import Profile"
msgstr "(en_US) Export Profile"

#: dist/wp.impex.dashboard.js:559
#, fuzzy
msgid "Select an Import profile"
msgstr "(en_US) Select an Export profile"

#: dist/wp.impex.dashboard.js:568
msgid ""
"Import profiles define which Wordpress data should be consumed from the "
"snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:574
msgid "Upload snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:583
msgid "Import uploaded snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:584
msgid "Additional actions on this import"
msgstr ""

#: dist/wp.impex.dashboard.js:592
msgid "Edit import snapshot"
msgstr ""

#: dist/wp.impex.dashboard.js:606
msgid "Delete import"
msgstr ""

#: dist/wp.impex.dashboard.js:607
msgid "Are you really sure to delete import"
msgstr ""

#: dist/wp.impex.dashboard.js:632
msgid "Impex"
msgstr ""
190 changes: 190 additions & 0 deletions plugins/cm4all-wp-impex/plugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php

/**
* Plugin Name: cm4all-wp-impex
* Plugin URI: http://dev.intern.cm-ag/trinity/research/cm4all-wp-impex
* Description : Impex contributes extendable Import / Export functionality to WordPress
* Version: 1.0.0
* Tags: import, export, migration
* Requires PHP: 8.0
* Requires at least: 5.7
* Author: Lars Gersmann, CM4all
* Author URI: https://cm4all.com
* Domain Path: /languages
**/

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

require_once __DIR__ . '/inc/class-impex.php';

require_once __DIR__ . '/inc/impex-export-extension-content.php';
require_once __DIR__ . '/inc/impex-import-extension-content.php';
require_once __DIR__ . '/inc/impex-export-extension-attachments.php';
require_once __DIR__ . '/inc/impex-import-extension-attachment.php';
require_once __DIR__ . '/inc/impex-export-extension-db-tables.php';
require_once __DIR__ . '/inc/impex-import-extension-db-table.php';
require_once __DIR__ . '/inc/impex-export-extension-wp-options.php';
require_once __DIR__ . '/inc/impex-import-extension-wp-options.php';
require_once __DIR__ . '/inc/class-impex-export-profile-rest-controller.php';
require_once __DIR__ . '/inc/class-impex-import-profile-rest-controller.php';
require_once __DIR__ . '/inc/class-impex-export-rest-controller.php';
require_once __DIR__ . '/inc/class-impex-import-rest-controller.php';

require_once __DIR__ . '/inc/wp-dashboard-contributions.php';

/**
* db table creation/upgrade mechanism:
* @see https://codex.wordpress.org/Creating_Tables_with_Plugins
*/

\register_activation_hook(__FILE__, function () {
// ensure required PHP function fnmatch() exists
if (!function_exists('fnmatch')) {
\wp_die('<h3>Plugin activation aborted</h3><p>The <strong>cm4all-wp-impex</strong> plugin requires PHP function <a href="https://www.php.net/manual/en/function.fnmatch.php" target="_blank"><code>fnmatch</code></a> to be available.</p>', 'Plugin Activation Error', ['response' => 500, 'back_link' => TRUE]);
}

Impex::getInstance()->__install();
});

// disable wordpress update notifications in the cloud to suppress php errors in the cloud
// @TODO: remove when its deployed in the cm4all-wordpress plugin
if (str_ends_with($_SERVER['SERVER_NAME'] ?? '', '.s-cm4all.cloud')) {
\add_filter('pre_site_transient_update_core', '\__return_null');
}

\add_action(
hook_name: 'plugins_loaded',
callback: function () {
if (\get_option('impex_version') !== Impex::VERSION) {
Impex::getInstance()->__install();
}

\do_action(Impex::WP_ACTION_REGISTER_PROVIDERS);
\do_action(Impex::WP_ACTION_REGISTER_PROFILES);
},
);

function enqueueClientAssets(bool $in_footer): string
{
static $CLIENT_ASSET_HANDLE;

if (!is_string($CLIENT_ASSET_HANDLE)) {
$CLIENT_ASSET_HANDLE = str_replace('\\', '-', __NAMESPACE__);
\add_action(
hook_name: Impex::WP_ACTION_ENQUEUE_IMPEX_PROVIDER_SCRIPT,
callback: function ($client_asset_handle, $in_footer) {
\cm4all\wp\impex\wp_enqueue_script(
handle: $client_asset_handle,
pluginRelativePath: 'dist/wp.impex.js',
in_footer: $in_footer
);

$DEBUG_HANDLE = $client_asset_handle . '-debug';
\cm4all\wp\impex\wp_enqueue_script(
handle: $DEBUG_HANDLE,
pluginRelativePath: 'dist/wp.impex.debug.js',
deps: [$client_asset_handle],
in_footer: $in_footer
);

$STORE_HANDLE = $client_asset_handle . '-store';
\cm4all\wp\impex\wp_enqueue_script(
handle: $STORE_HANDLE,
pluginRelativePath: 'dist/wp.impex.store.js',
deps: [$client_asset_handle, $DEBUG_HANDLE, 'wp-api-fetch', 'wp-data', 'wp-hooks'],
in_footer: $in_footer
);

// prefetch initial impex data
$discoveryRequest = new \WP_REST_Request('GET', '/');
$discoveryResponse = \rest_do_request($discoveryRequest);

$exportProfilesRequest = new \WP_REST_Request('GET', ImpexRestController::BASE_URI . ImpexExportProfileRESTController::REST_BASE);
$exportProfilesResponse = \rest_do_request($exportProfilesRequest);

$exportsRequest = new \WP_REST_Request('GET', ImpexRestController::BASE_URI . ImpexExportRESTController::REST_BASE);
$exportsResponse = \rest_do_request($exportsRequest);

$importProfilesRequest = new \WP_REST_Request('GET', ImpexRestController::BASE_URI . ImpexImportProfileRESTController::REST_BASE);
$importProfilesResponse = \rest_do_request($importProfilesRequest);

$importsRequest = new \WP_REST_Request('GET', ImpexRestController::BASE_URI . ImpexImportRESTController::REST_BASE);
$importsResponse = \rest_do_request($importsRequest);

$currentUserRequest = new \WP_REST_Request('GET', '/wp/v2/users/me');
$currentUserResponse = \rest_do_request($currentUserRequest);

\wp_add_inline_script(
$STORE_HANDLE,
sprintf(
'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
\wp_json_encode([
$discoveryRequest->get_route() => [
'body' => $discoveryResponse->data,
'headers' => $discoveryResponse->headers,
],
$exportProfilesRequest->get_route() => [
'body' => $exportProfilesResponse->data,
'headers' => $exportProfilesResponse->headers,
],
$exportsRequest->get_route() => [
'body' => $exportsResponse->data,
'headers' => $exportsResponse->headers,
],
$importProfilesRequest->get_route() => [
'body' => $importProfilesResponse->data,
'headers' => $importProfilesResponse->headers,
],
$importsRequest->get_route() => [
'body' => $importsResponse->data,
'headers' => $importsResponse->headers,
],
$currentUserRequest->get_route() => [
'body' => $currentUserResponse->data,
'headers' => $currentUserResponse->headers,
],
])
)
// add store initialization code
. sprintf("\nwp.impex.store.default(%s);", json_encode([
'namespace' => ImpexRestController::NAMESPACE,
'base_uri' => ImpexRestController::BASE_URI,
'site_url' => get_site_url(),
])),
'after'
);

//echo (__('huhu !', 'cm4all-wp-impex'));
},
accepted_args: 2,
);

// register dummy style
wp_register_style(
handle: $CLIENT_ASSET_HANDLE,
pluginRelativePath: 'dist/wp.impex.css'
);
}

\do_action(Impex::WP_ACTION_ENQUEUE_IMPEX_PROVIDER_SCRIPT, $CLIENT_ASSET_HANDLE, $in_footer);
\do_action(Impex::WP_ACTION_ENQUEUE_IMPEX_PROVIDER_STYLE, $CLIENT_ASSET_HANDLE);

return $CLIENT_ASSET_HANDLE;
}

\add_action(
hook_name: 'init',
callback: function () {
\load_plugin_textdomain(domain: 'cm4all-wp-impex', plugin_rel_path: basename(__DIR__) . '/languages/');
},
);

\add_action(Impex::WP_ACTION_REGISTER_PROFILES, function () {
require_once __DIR__ . '/profiles/export-profile-base.php';
require_once __DIR__ . '/profiles/import-profile-all.php';
}, 0);
20 changes: 20 additions & 0 deletions plugins/cm4all-wp-impex/profiles/export-profile-base.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace cm4all\wp\impex\example;

use cm4all\wp\impex\AttachmentsExporter;
use cm4all\wp\impex\ContentExporter;
use cm4all\wp\impex\Impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

$profile = Impex::getInstance()->Export->addProfile('base');
$profile->setDescription('Exports posts/pages including media assets');
// export pages/posts/comments/block patterns/templates/template parts/reusable blocks
$profile->addTask('wordpress content', ContentExporter::PROVIDER_NAME, []);

// export uploads
$profile->addTask('wordpress attachments (uploads)', AttachmentsExporter::PROVIDER_NAME, []);
30 changes: 30 additions & 0 deletions plugins/cm4all-wp-impex/profiles/import-profile-all.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace cm4all\wp\impex;

// Exit if accessed directly.
if (!defined('ABSPATH')) {
exit();
}

$profile = Impex::getInstance()->Import->addProfile('all');
$profile->setDescription('Import everything');

// stupid php ... variable $provider needs to be defined before use statement in addProvider
$provider = null;

$provider = Impex::getInstance()->Import->addProvider('all', function (array $slice, array $options, ImpexImportTransformationContext $transformationContext) use (&$provider): bool {
foreach (Impex::getInstance()->Import->getProviders() as $_provider) {
if ($_provider === $provider) {
continue;
}

if (call_user_func($_provider->callback, $slice, $options, $transformationContext)) {
return true;
}
}

return false;
});

$profile->addTask('main', $provider->name);
93 changes: 93 additions & 0 deletions plugins/cm4all-wp-impex/readme.txt.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
=== ${PLUGIN_NAME} ===
Contributors: ${CONTRIBUTORS}
Plugin Name: ${PLUGIN_NAME}
Plugin URI: ${PLUGIN_URI}
Tags: ${TAGS}
Requires at least: ${REQUIRES_AT_LEAST}
Tested up to: ${REQUIRES_AT_LEAST}
Stable tag: ${VERSION}
Version: ${VERSION}
Requires PHP: ${REQUIRES_PHP}
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Author: ${AUTHOR}
Author URI: ${AUTHOR_URI}

${DESCRIPTION}

== Description ==

Impex is a Wordpress plugin that allows you to import and export data from your WordPress site.

The primary goal for Impex is to provide a **true Open Source Wordpress Plugin** for importing/exporting your Wordpress data (including data from third party plugins/themes).

**This plugin is in a early (but working) stage.**

Impex provides :

* Wordpress hooks for third party plugins to expose their own data to Impex Import / Export without being dependant to Impex.

* a (semi) streaming architecture for large data exports/imports.

* Definition of configurable import/export profiles defining data providers to use and configuration of these data providers

* A user interface for importing / exporting data via Impex WP admin screen

* the Impex API is designed to support resumable/abortable imports and exports.

* Impex provides also a REST API seamlessy integrated into Wordpress

* Impex is explicitly designed for use in managed Wordpress instances

* Impex development relies heavily on PHP unit testing its feature sset to be stable and consistent.

**Your help is welcome !!**

== Frequently Asked Questions ==

= Why is that Plugin written in PHP 8 ? =

Because PHP 8

* is much faster than any previous PHP Version

* allows a much cleaner PHP code

=> We will investigate into transpiling the Plugin to PHP 7.4 in the future.

= Whats the current feature set ? =

* Impex provides right now generic providers for

* import/export Wordpress posts/pages **including attachments/uploads** and custom post types

* import/export configurable database tables (can be used export data from third party plugins/themes)

* import/export configurable wp_options (can be used export data from third party plugins/themes)

* Impex imports and exports data as plain files directly to your local filesystem so that you can operate on the exported data (and attachments) without hassle.

* Impex supports snapshots to allow you to rollback to a previous state of your data without downloading the whole data to your local machine

= Whats planned for the future ? =

* developer documentation integrating your plugin / theme into Impex

* a intuitive user interface allowing to configure import/export profiles and configuration of the used data providers based on React and JSON Schema

* user documentation

* monthly releases

== Screenshots ==

1. This screen shot description corresponds to screenshot-1.(png|jpg|jpeg|gif). Screenshots are stored in the /assets directory.
2. This is the second screen shot

== Changelog ==

${CHANGELOG}

== Upgrade Notice ==

There is currently no upgrade needed.
25 changes: 25 additions & 0 deletions plugins/cm4all-wp-impex/src/components/delete-modal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import components from "@wordpress/components";
import { cancelCircleFilled } from "@wordpress/icons";

import React from "React";

export default function ({ title, doDelete, onRequestClose, children }) {
const onDelete = async () => {
await doDelete();

onRequestClose();
};

return (
<components.Modal
title={title}
icon={cancelCircleFilled}
onRequestClose={() => onRequestClose()}
>
<p>{children}</p>
<components.Button variant="primary" isDestructive onClick={onDelete}>
Delete
</components.Button>
</components.Modal>
);
}
314 changes: 314 additions & 0 deletions plugins/cm4all-wp-impex/src/components/export.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import element from "@wordpress/element";
import components from "@wordpress/components";
import apiFetch from "@wordpress/api-fetch";
import hooks from "@wordpress/hooks";
import url from "@wordpress/url";
import data from "@wordpress/data";
import { __, sprintf } from "@wordpress/i18n";
import Debug from "@cm4all-impex/debug";
import ImpexFilters from "@cm4all-impex/filters";
import { edit, cancelCircleFilled, download } from "@wordpress/icons";

import RenameModal from "./rename-modal.mjs";
import DeleteModal from "./delete-modal.mjs";
import useScreenContext from "./screen-context.mjs";

import Store from "@cm4all-impex/store";

const debug = Debug.default("wp.impex.dashboard.export");
debug("loaded");

//const { __, sprintf } = i18n;

export default function Export() {
const { settings, exportProfiles, exports } = data.useSelect((select) => {
const store = select(Store.KEY);
return {
settings: store.getSettings(),
exportProfiles: store.getExportProfiles(),
exports: store.getExports(),
};
});

const [exportProfile, setExportProfile] = element.useState();
const exportProfileSelectRef = element.useRef();

const _setExportProfile = (exportProfileName = null) => {
const exportProfile = exportProfiles.find(
(_) => _.name === exportProfileName
);
setExportProfile(exportProfile);
exportProfileSelectRef.current.title = exportProfile?.description;
};

element.useEffect(() => {
_setExportProfile(exportProfiles?.[0]?.name);
}, [exportProfiles]);

const {
createExport: _createExport,
updateExport,
deleteExport,
} = data.useDispatch(Store.KEY /*, []*/);

const [modal, setModal] = element.useState(null);
const [progress, setProgress] = element.useState(null);

const screenContext = useScreenContext();

console.log({ exportProfile, exportProfiles });
const { currentUser } = data.useSelect((select) => ({
currentUser: select("core").getCurrentUser(),
}));

const createExport = async () => {
const site_url = new URL(settings["site_url"]);

const date = screenContext.currentDateString();
const name = `${site_url.hostname}-${exportProfile.name}-${date}`;
const description = `Export '${exportProfile.name}' created by user '${currentUser.name}' at ${date}`;

setProgress({
component: (
<components.Modal
title={__("Creating snapshot", "cm4all-wp-impex")}
onRequestClose={() => {}}
overlayClassName="blocking"
>
<progress indeterminate="true"></progress>
</components.Modal>
),
});
await _createExport(exportProfile, name, description);
setProgress();
};

const _saveSlicesChunk = async (exportDirHandle, response, chunk) => {
const slices = await response;
debug(`_saveSlicesChunk(chunk=%o) : %o`, chunk, response);

// create chunk sub directory
const chunkDirHandle = await exportDirHandle.getDirectoryHandle(
`chunk-${chunk.toString().padStart(4, "0")}`,
{ create: true }
);

return Promise.all(
slices.map(async (slice, index) => {
const sliceFileHandle = await chunkDirHandle.getFileHandle(
`slice-${index.toString().padStart(4, "0")}.json`,
{ create: true }
);

slice = await hooks.applyFilters(
ImpexFilters.SLICE_REST_UNMARSHAL,
ImpexFilters.NAMESPACE,
slice,
index,
chunkDirHandle
);

// Create a FileSystemWritableFileStream to write to.
const writable = await sliceFileHandle.createWritable();
// Write the contents of the file to the stream.
await writable.write(JSON.stringify(slice, null, " "));
debug("slice(=%o) = %o", index, slice);
// Close the file and write the contents to disk.
await writable.close();
})
);
};

const onDownloadExport = async (_export) => {
let _exportFolderName = null;
// showDirectoryPicker will throw a DOMExxception in case the user pressed cancel
try {
// colons need to be replaced otherwise showDirectoryPicker will fail
_exportFolderName = screenContext.normalizeFilename(_export.name);
} catch {
return;
}

// see https://web.dev/file-system-access/
const exportsDirHandle = await window.showDirectoryPicker({
// You can suggest a default start directory by passing a startIn property to the showSaveFilePicker
startIn: "downloads",
// If an id is specified, the file picker implementation will remember a separate last-used directory for pickers with that same id.
id: _exportFolderName,
});

const exportDirHandle = await exportsDirHandle.getDirectoryHandle(
_exportFolderName,
{
create: true,
}
);
debug("download export %o", _export);

const path = `${settings.base_uri}/export/${_export.id}/slice`;

setProgress({
component: (
<components.Modal
title={__("Downloading snapshot", "cm4all-wp-impex")}
onRequestClose={() => {}}
overlayClassName="blocking"
>
<progress indeterminate="true"></progress>
</components.Modal>
),
});

const initialResponse = await apiFetch({
path,
// parse: false is needed to geta access to the headers
parse: false,
});

const x_wp_total = Number.parseInt(
initialResponse.headers.get("X-WP-Total"),
10
);
const x_wp_total_pages = Number.parseInt(
initialResponse.headers.get("X-WP-TotalPages")
);

const sliceChunks = [
_saveSlicesChunk(exportDirHandle, initialResponse.json(), 1),
];
for (let chunk = 2; chunk <= x_wp_total_pages; chunk++) {
sliceChunks.push(
_saveSlicesChunk(
exportDirHandle,
apiFetch({
path: url.addQueryArgs(path, { page: chunk }),
}),
chunk
)
);
}

await Promise.all(sliceChunks);
setProgress(null);
};

return (
<>
<components.Panel
className="export"
header={__("Export", "cm4all-wp-impex")}
>
<components.PanelBody
title={__("Create snapshot", "cm4all-wp-impex")}
opened
className="create-export-form"
>
<wp.components.SelectControl
ref={exportProfileSelectRef}
disabled={!exportProfiles.length}
label={__("Export Profile", "cm4all-wp-impex")}
value={exportProfile?.name}
onChange={(exportProfileName) =>
_setExportProfile(exportProfileName)
}
options={[
{
name: __("Select an Export profile", "cm4all-wp-impex"),
disabled: true,
},
...exportProfiles,
].map((_) => ({
value: _.disabled ? undefined : _.name,
label: _.name,
disabled: _.disabled,
}))}
help={__(
"Export profiles define which Wordpress data should be extracted to the snapshot",
"cm4all-wp-impex"
)}
></wp.components.SelectControl>

<components.Button
isPrimary
onClick={createExport}
disabled={
!exportProfiles.find((_) => _.name === exportProfile?.name)
}
>
{__("Create Snapshot", "cm4all-wp-impex")}
</components.Button>
</components.PanelBody>
{exports.map((_, index) => (
<components.PanelBody
key={_.id}
title={_.name}
initialOpen={index === 0}
>
<components.PanelRow>
<components.Button
isSecondary
onClick={() => onDownloadExport(_)}
icon={download}
>
{__("Download snapshot", "cm4all-wp-impex")}
</components.Button>
<components.DropdownMenu
// icon={moreVertical}
label={__(
"Additional actions on this export",
"cm4all-wp-impex"
)}
controls={[
{
title: __("Edit", "cm4all-wp-impex"),
icon: edit,
onClick: () =>
setModal({
component: RenameModal,
props: {
title: __("Edit export", "cm4all-wp-impex"),
async doSave(data) {
await updateExport(_.id, data);
},
item: _,
},
}),
},
{
title: __("Delete", "cm4all-wp-impex"),
icon: cancelCircleFilled,
onClick: () =>
setModal({
component: DeleteModal,
props: {
title: __("Delete export", "cm4all-wp-impex"),
children: (
<>
{__("Are you really sure to delete export")}
<code>{_.name}</code>?
</>
),
async doDelete() {
await deleteExport(_.id);
},
},
}),
},
]}
/>
</components.PanelRow>
<components.PanelRow>
<pre>{JSON.stringify(_, null, " ")}</pre>
</components.PanelRow>
</components.PanelBody>
))}
</components.Panel>
{modal && <modal.component {...modal.props} onRequestClose={setModal} />}
{progress && (
<components.Fill name="progress" onRequestClose={() => {}}>
{progress.component}
</components.Fill>
)}
</>
);
}
312 changes: 312 additions & 0 deletions plugins/cm4all-wp-impex/src/components/import.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import element from "@wordpress/element";
import components from "@wordpress/components";
import data from "@wordpress/data";
import url from "@wordpress/url";
import { __, sprintf } from "@wordpress/i18n";
import hooks from "@wordpress/hooks";
import ImpexFilters from "@cm4all-impex/filters";
import Debug from "@cm4all-impex/debug";
import apiFetch from "@wordpress/api-fetch";
import {
edit,
cancelCircleFilled,
upload,
cloudUpload,
} from "@wordpress/icons";
import RenameModal from "./rename-modal.mjs";
import DeleteModal from "./delete-modal.mjs";
import useScreenContext from "./screen-context.mjs";

import Store from "@cm4all-impex/store";

const debug = Debug.default("wp.impex.dashboard.import");
debug("loaded");

export default function Import() {
// @TODO: add dragn drop support for uploading an export ?
// https://medium.com/@650egor/simple-drag-and-drop-file-upload-in-react-2cb409d88929
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFileSystemHandle
// https://wicg.github.io/file-system-access/#drag-and-drop

const { currentUser } = data.useSelect((select) => ({
currentUser: select("core").getCurrentUser(),
}));

const { settings, importProfiles, imports } = data.useSelect((select) => {
const store = select(Store.KEY);
return {
settings: store.getSettings(),
importProfiles: store.getImportProfiles(),
imports: store.getImports(),
};
});

const { createImport, updateImport, deleteImport, consumeImport } =
data.useDispatch(Store.KEY /*, []*/);

const [modal, setModal] = element.useState(null);
const [progress, setProgress] = element.useState(null);

const screenContext = useScreenContext();

const [importProfile, setImportProfile] = element.useState();

const importProfileSelectRef = element.useRef();

const _setImportProfile = (importProfileName = null) => {
const importProfile = importProfiles.find(
(_) => _.name === importProfileName
);
setImportProfile(importProfile);
importProfileSelectRef.current.title = importProfile?.description;
};

element.useEffect(() => {
_setImportProfile(importProfiles?.[0]?.name);
}, [importProfiles]);

const onConsumeImport = async (_import) => {
debug("onConsumeImport(%o)", _import);

setProgress({
component: (
<components.Modal
title={__("Importing data into Wordpress ...", "cm4all-wp-impex")}
onRequestClose={() => {}}
overlayClassName="blocking"
>
<progress indeterminate="true"></progress>
</components.Modal>
),
});

await consumeImport(_import.id, {}, null, null);
setProgress();
};

const _getSliceFiles = async (importDirHandle) => {
const slices = [];
for await (let sliceChunkDirectoryHandle of importDirHandle.values()) {
for await (let sliceFileHandle of sliceChunkDirectoryHandle.values()) {
if (sliceFileHandle.name.match(/^slice-\d+\.json$/)) {
slices.push({
fileHandle: sliceFileHandle,
dirHandle: sliceChunkDirectoryHandle,
});
}
}
}

slices.sort((l, r) => {
const cval = l.dirHandle.name.localeCompare(r.dirHandle.name);

if (cval === 0) {
return l.fileHandle.name.localeCompare(r.fileHandle.name);
}

return cval;
});

return slices;
};

const onUpload = async () => {
let importDirHandle = null;
// showDirectoryPicker will throw a DOMExxception in case the user pressed cancel
try {
// see https://web.dev/file-system-access/
importDirHandle = await window.showDirectoryPicker({
// You can suggest a default start directory by passing a startIn property to the showSaveFilePicker
startIn: "downloads",
});
} catch {
return;
}

debug("upload export %o", importDirHandle.name);

const date = screenContext.currentDateString();
const name = importDirHandle.name;
const description = `Import '${importDirHandle.name}' created by user '${currentUser.name}' at ${date}`;

setProgress({
component: (
<components.Modal
title={__("Uploading snapshot", "cm4all-wp-impex")}
onRequestClose={() => {}}
overlayClassName="blocking"
>
<progress indeterminate="true"></progress>
</components.Modal>
),
});

const _import = (await createImport(name, description, importProfile, []))
.payload;

const sliceFiles = await _getSliceFiles(importDirHandle);

const path = `${settings.base_uri}/import/${_import.id}/slice`;

const sliceUploads = sliceFiles.map(
async ({ fileHandle, dirHandle }, position) => {
const formData = new FormData();
let slice = JSON.parse(await (await fileHandle.getFile()).text());

slice = await hooks.applyFilters(
ImpexFilters.SLICE_REST_UPLOAD,
ImpexFilters.NAMESPACE,
slice,
parseInt(fileHandle.name.match(/^slice-(\d+)\.json$/)[1]),
dirHandle,
formData
);

if (slice) {
debug("upload %o", {
position,
file: fileHandle.name,
dir: dirHandle.name,
});
formData.append("slice", JSON.stringify(slice, null, " "));

return apiFetch({
method: "POST",
path: url.addQueryArgs(path, { position }),
body: formData,

parse: false,
});
}
}
);

const finished = await Promise.all(sliceUploads);
setProgress();
};

return (
<>
<components.Panel
className="import"
header={__("Import", "cm4all-wp-impex")}
>
<components.PanelBody
title={__("Upload snapshot to Wordpress", "cm4all-wp-impex")}
opened
className="upload-import-form"
>
<wp.components.SelectControl
ref={importProfileSelectRef}
disabled={!importProfiles.length}
label={__("Import Profile", "cm4all-wp-impex")}
value={importProfile?.name}
onChange={(importProfileName) =>
_setImportProfile(importProfileName)
}
options={[
{
name: __("Select an Import profile", "cm4all-wp-impex"),
disabled: true,
},
...importProfiles,
].map((_) => ({
value: _.disabled ? undefined : _.name,
label: _.name,
disabled: _.disabled,
}))}
help={__(
"Import profiles define which Wordpress data should be consumed from the snapshot",
"cm4all-wp-impex"
)}
></wp.components.SelectControl>
<components.Button
isPrimary
onClick={onUpload}
icon={upload}
disabled={!importProfile}
>
{__("Upload snapshot", "cm4all-wp-impex")}
</components.Button>
</components.PanelBody>
{imports.map((_, index) => (
<components.PanelBody
key={_.id}
title={_.name}
initialOpen={index === 0}
>
<components.PanelRow>
<components.Button
isDestructive
isPrimary
onClick={() => onConsumeImport(_)}
icon={cloudUpload}
>
{__("Import uploaded snapshot", "cm4all-wp-impex")}
</components.Button>
<components.DropdownMenu
// icon={moreVertical}
label={__(
"Additional actions on this import",
"cm4all-wp-impex"
)}
controls={[
{
title: __("Edit", "cm4all-wp-impex"),
icon: edit,
onClick: () =>
setModal({
component: RenameModal,
props: {
title: __("Edit import snapshot", "cm4all-wp-impex"),
async doSave(data) {
await updateImport(_.id, data);
},
item: _,
},
}),
},
{
title: __("Delete", "cm4all-wp-impex"),
icon: cancelCircleFilled,
onClick: () =>
setModal({
component: DeleteModal,
props: {
title: __("Delete import", "cm4all-wp-impex"),
children: (
<>
{__(
"Are you really sure to delete import",
"cm4all-wp-impex"
)}
<code>{_.name}</code>?
</>
),
async doDelete() {
await deleteImport(_.id);
},
},
}),
},
]}
/>
</components.PanelRow>
<components.PanelRow>
<pre style={{ overflow: "auto" }}>
{JSON.stringify(_, null, " ")}
</pre>
</components.PanelRow>
</components.PanelBody>
))}
</components.Panel>
{modal && <modal.component {...modal.props} onRequestClose={setModal} />}

{progress && (
<components.Fill name="progress" onRequestClose={() => {}}>
{progress.component}
</components.Fill>
)}
</>
);
}
49 changes: 49 additions & 0 deletions plugins/cm4all-wp-impex/src/components/rename-modal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import element from "@wordpress/element";
import components from "@wordpress/components";
import data from "@wordpress/data";
import { __, sprintf } from "@wordpress/i18n";
import { edit } from "@wordpress/icons";

export default function RenameModal({ title, doSave, item, onRequestClose }) {
const [name, setName] = element.useState(item.name);
const [description, setDescription] = element.useState(item.description);

const onSave = async () => {
await doSave({ name, description });

onRequestClose();
};

return (
<components.Modal
title={title}
icon={edit}
onRequestClose={() => onRequestClose()}
>
<components.TextControl
label={__("Name", "cm4all-wp-impex")}
help={__("Name should be short and human readable", "cm4all-wp-impex")}
value={name}
onChange={setName}
/>

<components.TextareaControl
label={__("Description", "cm4all-wp-impex")}
help={__(
"Description may contain more expressive information describing the item",
"cm4all-wp-impex",
)}
value={description}
onChange={setDescription}
/>

<components.Button
variant="primary"
onClick={onSave}
disabled={name === item.name && description === item.description}
>
Save
</components.Button>
</components.Modal>
);
}
34 changes: 34 additions & 0 deletions plugins/cm4all-wp-impex/src/components/screen-context.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import element from "@wordpress/element";

const ContextProvider = element.createContext();

export default function useScreenContext() {
return element.useContext(ContextProvider);
}

ScreenContext = {
normalizeFilename(fileName) {
return (
fileName
.replace(/[^a-z0-9\-_]/gi, "_")
.replace(/(-+)|(_+)/gi, ($) => $[0])
.toLowerCase()
// allow a maximum of 32 characters
.slice(-32)
);
},
currentDateString() {
const date = new Date();
return `${date.getFullYear()}-${("0" + (date.getMonth() + 1)).slice(-2)}-${(
"0" + date.getDate()
).slice(-2)}-${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
},
};

export function ScreenContextProvider({ children }) {
return (
<ContextProvider.Provider value={ScreenContext}>
{children}
</ContextProvider.Provider>
);
}
65 changes: 65 additions & 0 deletions plugins/cm4all-wp-impex/src/components/screen.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import element from "@wordpress/element";
import components from "@wordpress/components";
import { __, sprintf } from "@wordpress/i18n";
import Debug from "@cm4all-impex/debug";

import Export from "./export.mjs";
import Import from "./import.mjs";

const debug = Debug.default("wp.impex.dashboard.screen");
debug("loaded");

const isFileystemApiAvailable =
typeof window.showDirectoryPicker === "function";

export default function () {
return (
<div>
<h1>{__("Impex", "cm4all-wp-impex")}</h1>

<components.SlotFillProvider>
<components.Flex direction="row" align="top">
<components.FlexItem isBlock>
<Export />
</components.FlexItem>

<components.FlexItem isBlock>
<Import />
</components.FlexItem>
</components.Flex>

<components.Slot name="progress" />

{!isFileystemApiAvailable && (
<components.Modal
title="Ouch - your browser does not support the File System Access API :-("
onRequestClose={() => {}}
>
<p>
Impex Import / Export requires a browser implementing the{" "}
<a href="https://web.dev/file-system-access/">
File System Access API
</a>
.
</p>
<p>
Currently only Chromium based browsers like Chrome, Chromium, MS
Edge are known to support this feature.
</p>
<p>
See{" "}
<a href="https://caniuse.com/mdn-api_window_showdirectorypicker">
here
</a>{" "}
to find the latest list of browsers supporting the{" "}
<a href="https://web.dev/file-system-access/">
File System Access API
</a>{" "}
feature.
</p>
</components.Modal>
)}
</components.SlotFillProvider>
</div>
);
}
20 changes: 20 additions & 0 deletions plugins/cm4all-wp-impex/src/wp.impex.dashboard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import element from "@wordpress/element";
import Debug from "@cm4all-impex/debug";

import Screen from "./components/screen.mjs";
import { ScreenContextProvider } from "./components/screen-context.mjs";

import "./wp.impex.dashboard.scss";

const debug = Debug.default("wp.impex.dashboard");
debug("loaded");

// render impex dashboard only if not error notice (=> wordpress importer plugin is not installed) is shown
if (!document.querySelector(".notice.notice-error")) {
element.render(
<ScreenContextProvider>
<Screen />
</ScreenContextProvider>,
document.getElementById("cm4all_wp_impex_wp_admin_dashboard")
);
}
63 changes: 63 additions & 0 deletions plugins/cm4all-wp-impex/src/wp.impex.dashboard.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
.wrap {
//background-color: cyan;
}

button > svg {
width: 21px;
}

.components-modal__screen-overlay {
&.blocking {
cursor: wait;
}

PROGRESS {
width: 100%;
}

.components-text-control__input,
.components-textarea-control__input {
// fix input widths in rename export modal
width: calc(100% - 16px) !important;
}
}

.components-panel.export,
.components-panel.import {
margin-top: 12px;

.create-export-form,
.upload-import-form {
background-color: rgb(240, 240, 241);

.components-panel__body-title {
pointer-events: none;

.components-panel__body-toggle > * {
visibility: hidden;
}
}
}
}

/// background-color: rgb(240, 240, 241);
///
///

#cm4all_wp_impex_wp_admin_dashboard {
/*
background-color: yellow;
color: blue;
.components-button {
background: green !important;
&.is-primary {
background: red;
}
}
button.components-button.is-primary {
background-color: silver !important;
}
*/
}
3 changes: 3 additions & 0 deletions plugins/cm4all-wp-impex/src/wp.impex.debug.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const Debug = require("debug");

export default Debug;
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import hooks from "@wordpress/hooks";
import Debug from "@cm4all-impex/debug";
import ImpexFilters from "@cm4all-impex/filters";

const debug = Debug.default("wp.impex.attachments");
debug("huhu!");

hooks.addFilter(
ImpexFilters.SLICE_REST_UNMARSHAL,
ImpexFilters.NAMESPACE,
async function (namespace, slice, sliceIndex, chunkDirHandle) {
if (
slice["tag"] === "attachment" &&
slice["meta"]["entity"] === "attachment" &&
slice["type"] === "resource"
) {
const _links_self = slice["_links"]?.["self"];

if (_links_self) {
// download attachments to local folder
for (const entry of _links_self) {
const href = entry["href"];

let path = href.split(/[\\/]/);

const filename =
`slice-${sliceIndex.toString().padStart(4, "0")}-` + path.pop();
//path.push(filename);
//path = path.join("//");

await fetch(href).then(async (response) => {
attachmentFileHandle = await chunkDirHandle.getFileHandle(
filename,
{
create: true,
},
);
const writable = await attachmentFileHandle.createWritable();

await response.body.pipeTo(writable);

// see https://web.dev/file-system-access/
// => pipeTo() closes the destination pipe by default, no need to close it.
// await writable.close();
});
}
}

delete slice["_links"];
/*
slice['_links']['self'][] = [
'href' => slice[Impex::SLICE_META]['data']['guid'],
'tag' => AttachmentsExporter::SLICE_TAG,
'provider' => AttachmentsExporter::PROVIDER_NAME,
];
*/
}

return slice;
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import hooks from "@wordpress/hooks";
import Debug from "@cm4all-impex/debug";
import ImpexFilters from "@cm4all-impex/filters";

const debug = Debug.default("wp.impex.attachments");
debug("huhu!");

hooks.addFilter(
ImpexFilters.SLICE_REST_UPLOAD,
ImpexFilters.NAMESPACE,
async function (namespace, slice, sliceIndex, chunkDirHandle, formData) {
if (
slice["tag"] === "attachment" &&
slice["meta"]["entity"] === "attachment" &&
slice["type"] === "resource"
) {
const localAttachmentFilename =
`slice-${sliceIndex.toString().padStart(4, "0")}-` +
slice["data"].split(/[\\/]/).pop();

const localAttachmentFileHandle = await chunkDirHandle
.getFileHandle(localAttachmentFilename)
.catch((e) => {
console.log(localAttachmentFilename);
return Promise.reject(e);
});

formData.append(
// constant was injected using \wp_add_inline_script
wp.impex.extension.import.attachment
.WP_FILTER_IMPORT_REST_SLICE_UPLOAD_FILE,
await localAttachmentFileHandle.getFile(),
);
}

return slice;
},
);

export default {};
19 changes: 19 additions & 0 deletions plugins/cm4all-wp-impex/src/wp.impex.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
this acts a initial dependency and is right now empty
*/

import "./wp.impex.scss";

const NAMESPACE = "cm4all-impex";

const filters = {
SLICE_REST_UNMARSHAL: "slice_rest_unmarshal",
SLICE_REST_UPLOAD: "slice_rest_upload",
NAMESPACE,
};

const actions = {
NAMESPACE,
};

export { filters, actions };
3 changes: 3 additions & 0 deletions plugins/cm4all-wp-impex/src/wp.impex.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/*
this acts a initial dependency and is right now empty
*/
Loading