Plugin Directory

Changeset 3802902


Ignore:
Timestamp:
11/15/2026 08:24:44 AM (less than one hour ago)
Author:
aayushk777
Message:

Update to version 0.3.0 from GitHub

Location:
transtrix
Files:
18 added
44 edited
1 copied

Legend:

Unmodified
Added
Removed
  • transtrix/tags/0.3.0/assets/admin/src/components/SourceLogo.jsx

    r3759911 r3802902  
    2626        'wp-travel-engine': 'wp-travel-engine.png',
    2727        woocommerce: 'woocommerce.svg',
     28        edd: 'easy-digital-downloads.svg',
    2829};
    2930
  • transtrix/tags/0.3.0/dist/admin.asset.php

    r3790546 r3802902  
    1 <?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-date', 'wp-element', 'wp-i18n'), 'version' => '3911087dc07eb7c69eee');
     1<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-date', 'wp-element', 'wp-i18n'), 'version' => '0aabe6dbf55be35a8696');
  • transtrix/tags/0.3.0/dist/admin.js

    r3790546 r3802902  
    549549                color: #0284c7;
    550550        }
    551 `,Ga=Qa,Za={};Za.wordpress=(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)("circle",{cx:"36",cy:"36",r:"36",fill:"#fff"}),(0,l.jsx)("g",{transform:"translate(6 6) scale(2.5)",fill:"#21759B",children:(0,l.jsx)("path",{d:"M21.469 6.825c.84 1.537 1.318 3.3 1.318 5.175 0 3.979-2.156 7.456-5.363 9.325l3.295-9.527c.615-1.54.82-2.771.82-3.864 0-.405-.026-.78-.07-1.11m-7.981.105c.647-.03 1.232-.105 1.232-.105.582-.075.514-.93-.067-.899 0 0-1.755.135-2.88.135-1.064 0-2.85-.15-2.85-.15-.585-.03-.661.855-.075.885 0 0 .54.061 1.125.09l1.68 4.605-2.37 7.08L5.354 6.9c.649-.03 1.234-.1 1.234-.1.585-.075.516-.93-.065-.896 0 0-1.746.138-2.874.138-.2 0-.438-.008-.69-.015C4.911 3.15 8.235 1.215 12 1.215c2.809 0 5.365 1.072 7.286 2.833-.046-.003-.091-.009-.141-.009-1.06 0-1.812.923-1.812 1.914 0 .89.513 1.643 1.06 2.531.411.72.89 1.643.89 2.977 0 .915-.354 1.994-.821 3.479l-1.075 3.585-3.9-11.61.001.014zM12 22.784c-1.059 0-2.081-.153-3.048-.437l3.237-9.406 3.315 9.087c.024.053.05.101.078.149-1.12.393-2.325.609-3.582.609M1.211 12c0-1.564.336-3.05.935-4.39L7.29 21.709C3.694 19.96 1.212 16.271 1.211 12M12 0C5.385 0 0 5.385 0 12s5.385 12 12 12 12-5.385 12-12S18.615 0 12 0"})})]}),Za["wp-core"]=Za.wordpress,Za.core=Za.wordpress,Za["full-site"]=Za.wordpress;const Ya={wptravelengine:"wp-travel-engine.png","wp-travel-engine":"wp-travel-engine.png",woocommerce:"woocommerce.svg"},Xa=({moduleId:e,size:t=72})=>Ya[e]&&Ia.logosUrl?(0,l.jsx)("img",{src:Ia.logosUrl+Ya[e],alt:"",width:t,height:t,style:{display:"block",margin:"0 auto",borderRadius:Math.round(.22*t)}}):!Za[e]&&Ia.siteLogo?(0,l.jsx)("img",{src:Ia.siteLogo,alt:"",width:t,height:t,style:{display:"block",margin:"0 auto",borderRadius:16,objectFit:"contain"}}):Za[e]?(0,l.jsx)("svg",{viewBox:"0 0 72 72",width:t,height:t,"aria-hidden":"true",style:{display:"block",margin:"0 auto"},children:Za[e]}):(0,l.jsx)("div",{style:{width:t,margin:"0 auto"},children:(0,l.jsx)(Ga,{size:t})}),Ja=Qr.div`
     551`,Ga=Qa,Za={};Za.wordpress=(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)("circle",{cx:"36",cy:"36",r:"36",fill:"#fff"}),(0,l.jsx)("g",{transform:"translate(6 6) scale(2.5)",fill:"#21759B",children:(0,l.jsx)("path",{d:"M21.469 6.825c.84 1.537 1.318 3.3 1.318 5.175 0 3.979-2.156 7.456-5.363 9.325l3.295-9.527c.615-1.54.82-2.771.82-3.864 0-.405-.026-.78-.07-1.11m-7.981.105c.647-.03 1.232-.105 1.232-.105.582-.075.514-.93-.067-.899 0 0-1.755.135-2.88.135-1.064 0-2.85-.15-2.85-.15-.585-.03-.661.855-.075.885 0 0 .54.061 1.125.09l1.68 4.605-2.37 7.08L5.354 6.9c.649-.03 1.234-.1 1.234-.1.585-.075.516-.93-.065-.896 0 0-1.746.138-2.874.138-.2 0-.438-.008-.69-.015C4.911 3.15 8.235 1.215 12 1.215c2.809 0 5.365 1.072 7.286 2.833-.046-.003-.091-.009-.141-.009-1.06 0-1.812.923-1.812 1.914 0 .89.513 1.643 1.06 2.531.411.72.89 1.643.89 2.977 0 .915-.354 1.994-.821 3.479l-1.075 3.585-3.9-11.61.001.014zM12 22.784c-1.059 0-2.081-.153-3.048-.437l3.237-9.406 3.315 9.087c.024.053.05.101.078.149-1.12.393-2.325.609-3.582.609M1.211 12c0-1.564.336-3.05.935-4.39L7.29 21.709C3.694 19.96 1.212 16.271 1.211 12M12 0C5.385 0 0 5.385 0 12s5.385 12 12 12 12-5.385 12-12S18.615 0 12 0"})})]}),Za["wp-core"]=Za.wordpress,Za.core=Za.wordpress,Za["full-site"]=Za.wordpress;const Ya={wptravelengine:"wp-travel-engine.png","wp-travel-engine":"wp-travel-engine.png",woocommerce:"woocommerce.svg",edd:"easy-digital-downloads.svg"},Xa=({moduleId:e,size:t=72})=>Ya[e]&&Ia.logosUrl?(0,l.jsx)("img",{src:Ia.logosUrl+Ya[e],alt:"",width:t,height:t,style:{display:"block",margin:"0 auto",borderRadius:Math.round(.22*t)}}):!Za[e]&&Ia.siteLogo?(0,l.jsx)("img",{src:Ia.siteLogo,alt:"",width:t,height:t,style:{display:"block",margin:"0 auto",borderRadius:16,objectFit:"contain"}}):Za[e]?(0,l.jsx)("svg",{viewBox:"0 0 72 72",width:t,height:t,"aria-hidden":"true",style:{display:"block",margin:"0 auto"},children:Za[e]}):(0,l.jsx)("div",{style:{width:t,margin:"0 auto"},children:(0,l.jsx)(Ga,{size:t})}),Ja=Qr.div`
    552552        display: flex;
    553553        flex-direction: column;
  • transtrix/tags/0.3.0/includes/Cli/Cli_Command.php

    r3790546 r3802902  
    252252         *
    253253         * @since 0.1.0
     254         * @since 0.3.0 Also reports the orders, customers and codes it deleted.
    254255         *
    255256         * @param array<int,string>    $args       Positional arguments.
    … …  
    277278                WP_CLI::success(
    278279                        sprintf(
    279                                 'Deleted %d posts and %d terms, restored %d posts.',
     280                                'Deleted %d posts, %d terms and %d other objects (orders, customers, codes), restored %d posts.',
    280281                                $undone['posts_deleted'],
    281282                                $undone['terms_deleted'],
     283                                $undone['objects_deleted'],
    282284                                $undone['posts_restored']
    283285                        )
  • transtrix/tags/0.3.0/includes/Contracts/Module.php

    r3790546 r3802902  
    212212
    213213        /**
     214         * Top-level upload directories whose files must keep their place.
     215         *
     216         * A store that sells files keeps them in a directory the web server will
     217         * not serve (Easy Digital Downloads' `uploads/edd`). Imported into the
     218         * usual dated directory, a paid file would get a public URL.
     219         *
     220         * @since 0.3.0
     221         *
     222         * @return string[]
     223         */
     224        public function protected_upload_dirs(): array {
     225                return array();
     226        }
     227
     228        /**
    214229         * Last chance to widen or narrow the job before the package is written.
    215230         *
  • transtrix/tags/0.3.0/includes/Import/Restore_Point.php

    r3790546 r3802902  
    137137        public function record_created_object( string $kind, int $id ): void {
    138138                $this->created_objects[ $kind ][] = $id;
     139        }
     140
     141        /**
     142         * Non-post objects this import created so far, kind => ids.
     143         *
     144         * @since 0.3.0
     145         *
     146         * @return array<string,int[]>
     147         */
     148        public function created_objects(): array {
     149                return array_map(
     150                        static function ( $ids ) {
     151                                return array_map( 'intval', (array) $ids );
     152                        },
     153                        $this->created_objects
     154                );
    139155        }
    140156
  • transtrix/tags/0.3.0/includes/Import/Three_Pass_Importer.php

    r3790546 r3802902  
    101101
    102102        /**
     103         * Source attachment URL => source attachment id, built once per step.
     104         *
     105         * @var array<string,int>|null
     106         */
     107        private $media_urls = null;
     108
     109        /**
    103110         * Taxonomies the package uses that this site does not register.
    104111         *
    … …  
    423430
    424431        /**
     432         * Source attachment URL => source attachment id, for attachment URL hints.
     433         *
     434         * @since 0.3.0
     435         *
     436         * @return array<string,int>
     437         */
     438        private function media_urls(): array {
     439                if ( null === $this->media_urls ) {
     440                        $this->media_urls = array();
     441
     442                        foreach ( $this->lists()['media'] as $record ) {
     443                                if ( ! empty( $record['url'] ) && ! empty( $record['id'] ) ) {
     444                                        $this->media_urls[ (string) $record['url'] ] = (int) $record['id'];
     445                                }
     446                        }
     447                }
     448
     449                return $this->media_urls;
     450        }
     451
     452        /**
    425453         * Before the first record: what every later record is keyed by.
    426454         *
    … …  
    500528         *
    501529         * @since 0.2.0
     530         * @since 0.3.0 Also hands over the objects import_payload() created.
    502531         *
    503532         * @param Report $report Outcome report.
    … …  
    512541                // one, before the terms that decide it were linked in pass three.
    513542                $this->job->set_state( 'imported_ids', array_values( $this->imported ) );
     543                // And what import_payload() created, kind => ids: an order's stats are
     544                // rebuilt once, here, not per order.
     545                $this->job->set_state( 'created_objects', $this->restore->created_objects() );
    514546                $this->module->after_import( $this->job, $report );
    515547        }
    … …  
    682714         *
    683715         * @since 0.2.0
     716         * @since 0.3.0 Keeps the module's protected upload directories.
    684717         *
    685718         * @param array<string,mixed> $record One media record.
    … …  
    689722        private function create_media( array $record, Report $report ): void {
    690723                if ( $this->may_upload() ) {
    691                         ( new Media_Importer( $this->package->work_dir() ) )->import( array( $record ), $this->map, $report, $this->restore );
     724                        ( new Media_Importer( $this->package->work_dir(), $this->module->protected_upload_dirs() ) )->import( array( $record ), $this->map, $report, $this->restore );
    692725                }
    693726        }
    … …  
    771804         *
    772805         * @since 0.2.0
     806         * @since 0.3.0 Rewrites attachment URLs too.
    773807         *
    774808         * @param array<string,mixed> $record One post record.
    … …  
    785819                $new_id = $this->imported[ $old_id ];
    786820                $meta   = isset( $record['meta'] ) && is_array( $record['meta'] ) ? $record['meta'] : array();
    787                 $walker = new Reference_Walker();
     821                $walker = new Reference_Walker( $this->media_urls() );
    788822                $map    = $this->map;
    789823
    … …  
    11631197         *
    11641198         * @since 0.1.0
     1199         * @since 0.3.0 Reads `<id>_<number>` under a suffixed hint.
    11651200         *
    11661201         * @param array<string,mixed> $record   One post record.
    … …  
    11831218
    11841219                        foreach ( (array) $meta[ $parsed['key'] ] as $value ) {
    1185                                 foreach ( Hint_Path::collect_at( $value, $parsed['segments'] ) as $id ) {
     1220                                foreach ( Hint_Path::collect_at( $value, $parsed['segments'], $hint->is_suffixed() ) as $id ) {
    11861221                                        if ( ! isset( $in_scope[ $id ] ) ) {
    11871222                                                $missing[] = $id;
  • transtrix/tags/0.3.0/includes/Media/Media_Collector.php

    r3790546 r3802902  
    4040         * @since 0.1.0
    4141         * @since 0.2.0 Also collects the generated sizes and their metadata.
     42         * @since 0.3.0 Records the source URL and upload sub-directory.
    4243         *
    4344         * @param int $attachment_id Attachment id.
    … …  
    6768                $filename      = wp_basename( $file );
    6869                $relative_path = 'media/' . $attachment_id . '/' . $filename;
     70                $subdir        = dirname( (string) get_post_meta( $attachment_id, '_wp_attached_file', true ) );
    6971
    7072                $this->files[ $relative_path ] = array(
    … …  
    8587                        'post_parent'   => (int) $post->post_parent,
    8688                        'post_date_gmt' => $post->post_date_gmt,
     89                        // Where it lived, so a link to it can follow it and a protected
     90                        // directory can keep it.
     91                        'url'           => (string) wp_get_attachment_url( $attachment_id ),
     92                        'subdir'        => '.' === $subdir ? '' : $subdir,
    8793                );
    8894
  • transtrix/tags/0.3.0/includes/Media/Media_Importer.php

    r3790546 r3802902  
    3838
    3939        /**
     40         * Top-level upload directories whose files keep their place.
     41         *
     42         * @var string[]
     43         */
     44        private $protected_dirs;
     45
     46        /**
    4047         * Constructor.
    4148         *
    4249         * @since 0.1.0
    43          *
    44          * @param string $work_dir Trailing-slashed extraction directory.
    45          */
    46         public function __construct( string $work_dir ) {
    47                 $this->work_dir = trailingslashit( $work_dir );
     50         * @since 0.3.0 Takes $protected_dirs.
     51         *
     52         * @param string   $work_dir       Trailing-slashed extraction directory.
     53         * @param string[] $protected_dirs Top-level upload directories whose files keep their place.
     54         */
     55        public function __construct( string $work_dir, array $protected_dirs = array() ) {
     56                $this->work_dir       = trailingslashit( $work_dir );
     57                $this->protected_dirs = $protected_dirs;
    4858        }
    4959
    … …  
    5262         *
    5363         * @since 0.1.0
     64         * @since 0.3.0 A protected file matches only a copy in its protected directory.
    5465         *
    5566         * @param array<int,array<string,mixed>> $records Media records from the package.
    … …  
    6879                        }
    6980
    70                         $existing = $this->find_by_checksum( (string) ( $record['sha256'] ?? '' ) );
     81                        $existing = $this->find_by_checksum( (string) ( $record['sha256'] ?? '' ), $this->protected_subdir( $record ) );
    7182
    7283                        if ( $existing > 0 ) {
    … …  
    93104         *
    94105         * @since 0.1.0
     106         * @since 0.3.0 A protected file only matches a copy inside the same protected directory.
    95107         *
    96108         * @param string $checksum sha256 of the file.
     109         * @param string $subdir   Protected sub-directory the file belongs in, or ''.
    97110         * @return int Attachment id, or 0.
    98111         */
    99         private function find_by_checksum( string $checksum ): int {
     112        private function find_by_checksum( string $checksum, string $subdir = '' ): int {
    100113                if ( '' === $checksum ) {
    101114                        return 0;
    … …  
    106119                                'post_type'              => 'attachment',
    107120                                'post_status'            => 'any',
    108                                 'posts_per_page'         => 1,
     121                                'posts_per_page'         => '' === $subdir ? 1 : 20,
    109122                                'fields'                 => 'ids',
    110123                                'no_found_rows'          => true,
    … …  
    115128                );
    116129
    117                 return empty( $found ) ? 0 : (int) $found[0];
     130                if ( '' === $subdir ) {
     131                        return empty( $found ) ? 0 : (int) $found[0];
     132                }
     133
     134                // A public copy of a paid file is not a match: linking to it would
     135                // hand out the file the protected directory exists to hold back.
     136                $top = strtok( $subdir, '/' ) . '/';
     137
     138                foreach ( $found as $id ) {
     139                        if ( 0 === strpos( (string) get_post_meta( (int) $id, '_wp_attached_file', true ), $top ) ) {
     140                                return (int) $id;
     141                        }
     142                }
     143
     144                return 0;
     145        }
     146
     147        /**
     148         * The upload sub-directory a protected file must keep, or '' for any other file.
     149         *
     150         * The path comes from the package, so only plain directory names pass; a
     151         * protected file with any other path still lands in its protected root.
     152         *
     153         * @since 0.3.0
     154         *
     155         * @param array<string,mixed> $record One media record.
     156         * @return string
     157         */
     158        private function protected_subdir( array $record ): string {
     159                $subdir = (string) ( $record['subdir'] ?? '' );
     160                $top    = explode( '/', $subdir )[0];
     161
     162                if ( '' === $top || ! in_array( $top, $this->protected_dirs, true ) ) {
     163                        return '';
     164                }
     165
     166                return 1 === preg_match( '#^[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*$#', $subdir ) ? $subdir : $top;
    118167        }
    119168
    … …  
    123172         * @since 0.1.0
    124173         * @since 0.2.0 Copies the packaged sizes instead of rebuilding them.
     174         * @since 0.3.0 Keeps a protected file in its protected directory.
    125175         *
    126176         * @param array<string,mixed> $record One media record.
    … …  
    158208                );
    159209
    160                 $upload = wp_handle_sideload( $sideload, array( 'test_form' => false ) );
     210                $subdir   = $this->protected_subdir( $record );
     211                $keep_dir = static function ( array $dirs ) use ( $subdir ): array {
     212                        $dirs['subdir'] = '/' . $subdir;
     213                        $dirs['path']   = $dirs['basedir'] . $dirs['subdir'];
     214                        $dirs['url']    = $dirs['baseurl'] . $dirs['subdir'];
     215
     216                        return $dirs;
     217                };
     218
     219                if ( '' !== $subdir ) {
     220                        add_filter( 'upload_dir', $keep_dir, PHP_INT_MAX );
     221                }
     222
     223                try {
     224                        $upload = wp_handle_sideload( $sideload, array( 'test_form' => false ) );
     225                } finally {
     226                        remove_filter( 'upload_dir', $keep_dir, PHP_INT_MAX );
     227                }
    161228
    162229                if ( ! empty( $upload['error'] ) || empty( $upload['file'] ) ) {
  • transtrix/tags/0.3.0/includes/Modules/Core/Core_Exporter.php

    r3790546 r3802902  
    186186         *
    187187         * @since 0.1.0
     188         * @since 0.3.0 Reads `<id>_<number>` under a suffixed hint.
    188189         *
    189190         * @param int[] $post_ids Selected post ids.
    … …  
    210211                        foreach ( $post_ids as $post_id ) {
    211212                                foreach ( get_post_meta( (int) $post_id, $parsed['key'], false ) as $value ) {
    212                                         $ids = array_merge( $ids, Hint_Path::collect_at( $value, $parsed['segments'] ) );
     213                                        $ids = array_merge( $ids, Hint_Path::collect_at( $value, $parsed['segments'], $hint->is_suffixed() ) );
    213214                                }
    214215                        }
    … …  
    310311
    311312        /**
     313         * Attachment ids behind the URLs at a path.
     314         *
     315         * @since 0.3.0
     316         *
     317         * @param mixed    $value    The whole meta value.
     318         * @param string[] $segments Path to the URLs.
     319         * @return int[]
     320         */
     321        private function url_ids( $value, array $segments ): array {
     322                $ids = array();
     323
     324                Hint_Path::rewrite_at(
     325                        maybe_unserialize( $value ),
     326                        $segments,
     327                        static function ( $url ) use ( &$ids ) {
     328                                if ( is_string( $url ) && '' !== $url ) {
     329                                        $ids[] = attachment_url_to_postid( $url );
     330                                }
     331
     332                                return $url;
     333                        }
     334                );
     335
     336                return $ids;
     337        }
     338
     339        /**
    312340         * Attachment ids a post references, through hints and through its content.
    313341         *
    314342         * @since 0.1.0
     343         * @since 0.3.0 Follows attachment URL hints too.
    315344         *
    316345         * @param int $post_id Post id.
    … …  
    323352                        $hint = Reference_Hint::from( (string) $notation );
    324353
    325                         if ( ! $hint instanceof Reference_Hint || Reference_Hint::TYPE_ATTACHMENT !== $hint->type() ) {
     354                        if ( ! $hint instanceof Reference_Hint || ! in_array( $hint->type(), array( Reference_Hint::TYPE_ATTACHMENT, Reference_Hint::TYPE_ATTACHMENT_URL ), true ) ) {
    326355                                continue;
    327356                        }
    … …  
    332361
    333362                        foreach ( get_post_meta( $post_id, $parsed['key'], false ) as $value ) {
    334                                 $ids = array_merge( $ids, Hint_Path::collect_at( $value, $parsed['segments'] ) );
     363                                $ids = array_merge( $ids, Reference_Hint::TYPE_ATTACHMENT_URL === $hint->type() ? $this->url_ids( $value, $parsed['segments'] ) : Hint_Path::collect_at( $value, $parsed['segments'] ) );
    335364                        }
    336365                }
  • transtrix/tags/0.3.0/includes/Modules/Woo/Woo_Orders.php

    r3790546 r3802902  
    88namespace Transtrix\Modules\Woo;
    99
     10use Transtrix\Import\Account_Importer;
    1011use Transtrix\Import\Restore_Point;
    1112use Transtrix\Reference\Id_Map;
    … …  
    306307         *
    307308         * @since 0.1.0
     309         * @since 0.3.0 The account fields come from Account_Importer.
    308310         *
    309311         * @param int $user_id User id.
    … …  
    311313         */
    312314        private static function customer_record( int $user_id ): ?array {
    313                 $user = get_userdata( $user_id );
    314 
    315                 if ( ! $user instanceof \WP_User ) {
     315                $record = Account_Importer::export( $user_id );
     316
     317                if ( null === $record ) {
    316318                        return null;
    317319                }
    … …  
    319321                $customer = new \WC_Customer( $user_id );
    320322
    321                 return array(
    322                         'id'              => $user_id,
    323                         'user_login'      => $user->user_login,
    324                         'user_email'      => $user->user_email,
    325                         'display_name'    => $user->display_name,
    326                         'first_name'      => $user->first_name,
    327                         'last_name'       => $user->last_name,
    328                         'user_registered' => $user->user_registered,
    329                         'billing'         => $customer->get_billing( 'edit' ),
    330                         'shipping'        => $customer->get_shipping( 'edit' ),
    331                         'is_paying'       => $customer->get_is_paying_customer( 'edit' ),
     323                return array_merge(
     324                        $record,
     325                        array(
     326                                'billing'   => $customer->get_billing( 'edit' ),
     327                                'shipping'  => $customer->get_shipping( 'edit' ),
     328                                'is_paying' => $customer->get_is_paying_customer( 'edit' ),
     329                        )
    332330                );
    333331        }
    … …  
    396394         *
    397395         * @since 0.1.0
     396         * @since 0.3.0 Matches and creates the account through Account_Importer.
    398397         *
    399398         * @param array<string,mixed> $record  Customer record.
    … …  
    404403         */
    405404        private static function import_customer( array $record, Id_Map $map, Restore_Point $restore, Report $report ): void {
    406                 $old_id = (int) ( $record['id'] ?? 0 );
    407                 $email  = sanitize_email( (string) ( $record['user_email'] ?? '' ) );
    408 
    409                 if ( '' === $email ) {
     405                $account = Account_Importer::resolve( $record, 'customer', self::KIND_CUSTOMER, $map, $restore, $report );
     406
     407                if ( Account_Importer::MATCHED === $account['status'] ) {
     408                        $report->add_skipped();
     409                }
     410
     411                if ( Account_Importer::CREATED !== $account['status'] ) {
    410412                        return;
    411413                }
    412414
    413                 $existing = get_user_by( 'email', $email );
    414 
    415                 if ( $existing instanceof \WP_User ) {
    416                         // A staff account with the same email is not this shopper: tying
    417                         // imported orders to an administrator would show them as theirs.
    418                         // Their orders arrive as guest orders, with the email kept.
    419                         if ( array() !== array_diff( (array) $existing->roles, array( 'customer', 'subscriber' ) ) ) {
    420                                 $report->add_warning(
    421                                         $email,
    422                                         __( 'This email belongs to a staff account here, so its orders were imported as guest orders.', 'transtrix' )
    423                                 );
    424                                 return;
    425                         }
    426 
    427                         $map->add( 'user', $old_id, (int) $existing->ID );
    428                         $report->add_skipped();
    429                         return;
    430                 }
    431 
    432                 $login = sanitize_user( (string) ( $record['user_login'] ?? '' ), true );
    433                 $login = '' === $login || username_exists( $login ) ? sanitize_user( strstr( $email, '@', true ) . '-' . wp_generate_password( 4, false, false ), true ) : $login;
    434 
    435                 // wp_insert_user() sends no notification. The password is random: hashes
    436                 // do not travel, so the customer sets a new one with "Lost password".
    437                 $user_id = wp_insert_user(
    438                         array(
    439                                 'user_login'      => $login,
    440                                 'user_email'      => $email,
    441                                 'user_pass'       => wp_generate_password( 24 ),
    442                                 'display_name'    => (string) ( $record['display_name'] ?? '' ),
    443                                 'first_name'      => (string) ( $record['first_name'] ?? '' ),
    444                                 'last_name'       => (string) ( $record['last_name'] ?? '' ),
    445                                 'user_registered' => (string) ( $record['user_registered'] ?? '' ),
    446                                 'role'            => 'customer',
    447                         )
    448                 );
    449 
    450                 if ( is_wp_error( $user_id ) ) {
    451                         $report->add_failure( $email, $user_id->get_error_message() );
    452                         return;
    453                 }
    454 
    455                 $restore->record_created_object( self::KIND_CUSTOMER, (int) $user_id );
    456                 $map->add( 'user', $old_id, (int) $user_id );
    457 
    458                 $customer = new \WC_Customer( (int) $user_id );
     415                $customer = new \WC_Customer( $account['user_id'] );
    459416
    460417                foreach ( array( 'billing', 'shipping' ) as $type ) {
    … …  
    758715         *
    759716         * @since 0.1.0
     717         * @since 0.3.0 Accounts are undone through Account_Importer.
    760718         *
    761719         * @param string $kind KIND_ORDER or KIND_CUSTOMER.
    … …  
    777735
    778736                if ( self::KIND_CUSTOMER === $kind ) {
    779                         require_once ABSPATH . 'wp-admin/includes/user.php';
    780 
    781                         foreach ( $ids as $id ) {
    782                                 // Kept once the account has a life of its own here: a new role,
    783                                 // content they wrote, or an order placed since the import.
    784                                 // Deleting it would take that with it.
    785                                 if ( self::customer_in_use( (int) $id ) ) {
    786                                         continue;
    787                                 }
    788 
    789                                 if ( wp_delete_user( $id ) ) {
    790                                         ++$deleted;
    791                                 }
    792                         }
     737                        $deleted += Account_Importer::rollback( $ids, 'customer', array( __CLASS__, 'has_orders' ) );
    793738                }
    794739
    … …  
    797742
    798743        /**
    799          * Whether an imported customer account is now used for more than the import.
    800          *
    801          * @since 0.1.0
     744         * Whether an account has an order in any status.
     745         *
     746         * @since 0.3.0
    802747         *
    803748         * @param int $user_id User id.
    804749         * @return bool
    805750         */
    806         private static function customer_in_use( int $user_id ): bool {
    807                 $user = get_userdata( $user_id );
    808 
    809                 if ( ! $user instanceof \WP_User ) {
    810                         return true;
    811                 }
    812 
    813                 if ( array( 'customer' ) !== array_values( (array) $user->roles ) || count_user_posts( $user_id, get_post_types(), false ) > 0 ) {
    814                         return true;
    815                 }
    816 
     751        public static function has_orders( int $user_id ): bool {
    817752                return function_exists( 'wc_get_orders' ) && array() !== (array) wc_get_orders(
    818753                        array(
  • transtrix/tags/0.3.0/includes/Package/Package_Writer.php

    r3790546 r3802902  
    8989
    9090        /**
     91         * Meta key prefixes that must not be written: entries given as 'prefix*'.
     92         *
     93         * @var string[]
     94         */
     95        private $excluded_meta_prefixes = array();
     96
     97        /**
    9198         * Constructor.
    9299         *
    93100         * @since 0.1.0
     101         * @since 0.3.0 A trailing `*` on an excluded key excludes every key with that prefix.
    94102         *
    95103         * @param Job      $job                 The export job.
    … …  
    97105         */
    98106        public function __construct( Job $job, array $excluded_meta_keys = array() ) {
    99                 $this->job                = $job;
    100                 $this->report             = new Report();
    101                 $this->media              = new Media_Collector();
    102                 $this->excluded_meta_keys = array_flip( array_map( 'strval', $excluded_meta_keys ) );
     107                $this->job    = $job;
     108                $this->report = new Report();
     109                $this->media  = new Media_Collector();
     110
     111                foreach ( array_map( 'strval', $excluded_meta_keys ) as $key ) {
     112                        if ( '*' === substr( $key, -1 ) ) {
     113                                $this->excluded_meta_prefixes[] = substr( $key, 0, -1 );
     114                        } else {
     115                                $this->excluded_meta_keys[ $key ] = 1;
     116                        }
     117                }
    103118        }
    104119
    … …  
    178193                        // A6: a denied key never reaches the package, so it cannot leak by
    179194                        // being written, inspected in the zip, or shipped to a third party.
    180                         if ( isset( $this->excluded_meta_keys[ $key ] ) ) {
     195                        if ( $this->is_excluded( (string) $key ) ) {
    181196                                continue;
    182197                        }
    … …  
    360375                return ( $parent instanceof \WP_Term ) ? $parent->slug : '';
    361376        }
     377
     378        /**
     379         * Whether the module forbids a meta key, by name or by prefix.
     380         *
     381         * @since 0.3.0
     382         *
     383         * @param string $key Meta key.
     384         * @return bool
     385         */
     386        private function is_excluded( string $key ): bool {
     387                if ( isset( $this->excluded_meta_keys[ $key ] ) ) {
     388                        return true;
     389                }
     390
     391                foreach ( $this->excluded_meta_prefixes as $prefix ) {
     392                        if ( 0 === strpos( $key, $prefix ) ) {
     393                                return true;
     394                        }
     395                }
     396
     397                return false;
     398        }
    362399}
  • transtrix/tags/0.3.0/includes/Reference/Hint_Path.php

    r3790546 r3802902  
    145145         *
    146146         * @since 0.1.0
     147         * @since 0.3.0 Takes $suffixed.
    147148         *
    148149         * @param mixed    $value    The whole meta value.
    149150         * @param string[] $segments Path segments; empty means the whole value.
     151         * @param bool     $suffixed Also read `<id>_<number>` strings.
    150152         * @return int[]
    151153         */
    152         public static function collect_at( $value, array $segments ): array {
     154        public static function collect_at( $value, array $segments, bool $suffixed = false ): array {
    153155                $found = array();
    154156
    … …  
    156158                        $value,
    157159                        $segments,
    158                         static function ( $leaf ) use ( &$found ) {
    159                                 $found = array_merge( $found, self::ids_in( $leaf ) );
     160                        static function ( $leaf ) use ( &$found, $suffixed ) {
     161                                $found = array_merge( $found, self::ids_in( $leaf, $suffixed ) );
    160162
    161163                                return $leaf;
    … …  
    170172         *
    171173         * @since 0.1.0
    172          *
    173          * @param mixed $value Any value.
     174         * @since 0.3.0 Takes $suffixed.
     175         *
     176         * @param mixed $value    Any value.
     177         * @param bool  $suffixed Also read `<id>_<number>` strings, as the id.
    174178         * @return int[]
    175179         */
    176         public static function ids_in( $value ): array {
     180        public static function ids_in( $value, bool $suffixed = false ): array {
    177181                if ( is_string( $value ) && is_serialized( $value ) ) {
    178182                        $value = unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes is false; serialized values are rewritten in place.
    … …  
    183187
    184188                        foreach ( $value as $item ) {
    185                                 $ids = array_merge( $ids, self::ids_in( $item ) );
     189                                $ids = array_merge( $ids, self::ids_in( $item, $suffixed ) );
    186190                        }
    187191
    … …  
    205209                }
    206210
     211                if ( $suffixed && 1 === preg_match( '/^(\d+)_\d+$/', $value, $parts ) ) {
     212                        return array( (int) $parts[1] );
     213                }
     214
    207215                return array();
    208216        }
  • transtrix/tags/0.3.0/includes/Reference/Reference_Hint.php

    r3790546 r3802902  
    2626
    2727        /**
     28         * A URL of an attachment's file, rewritten to the imported attachment's URL.
     29         */
     30        const TYPE_ATTACHMENT_URL = 'attachment_url';
     31
     32        /**
    2833         * Reference type, one of the TYPE_* constants.
    2934         *
    … …  
    5257
    5358        /**
     59         * Whether an id may carry a `_<number>` suffix that is kept as written.
     60         *
     61         * Easy Digital Downloads names "download 123, price option 2" as `123_2`.
     62         * The leading number is the id; the suffix is local to that download.
     63         *
     64         * @var bool
     65         */
     66        private $suffixed = false;
     67
     68        /**
    5469         * Constructor.
    5570         *
    5671         * @since 0.1.0
     72         * @since 0.3.0 Takes $suffixed.
    5773         *
    5874         * @param string $type     Reference type.
    5975         * @param bool   $is_list  Whether the value holds a list of ids.
    6076         * @param string $taxonomy Taxonomy a term hint is confined to, or ''.
    61          */
    62         public function __construct( string $type, bool $is_list = false, string $taxonomy = '' ) {
     77         * @param bool   $suffixed Whether an id may carry a kept `_<number>` suffix.
     78         */
     79        public function __construct( string $type, bool $is_list = false, string $taxonomy = '', bool $suffixed = false ) {
    6380                $this->type     = $type;
    6481                $this->is_list  = $is_list;
    6582                $this->taxonomy = $taxonomy;
     83                $this->suffixed = $suffixed;
    6684        }
    6785
    … …  
    7088         *
    7189         * @since 0.1.0
     90         * @since 0.3.0 A trailing `^` on the type ('post^') allows `<id>_<number>`.
    7291         *
    7392         * @param string $notation For example 'post[]' or 'attachment'.
    … …  
    7796                $is_list  = ( '[]' === substr( $notation, -2 ) );
    7897                $type     = $is_list ? substr( $notation, 0, -2 ) : $notation;
     98                $suffixed = ( '^' === substr( $type, -1 ) );
     99                $type     = $suffixed ? substr( $type, 0, -1 ) : $type;
    79100                $taxonomy = '';
    80101
    … …  
    89110                        self::TYPE_TERM,
    90111                        self::TYPE_USER,
     112                        self::TYPE_ATTACHMENT_URL,
    91113                );
    92114
    … …  
    99121                }
    100122
    101                 return new self( $type, $is_list, $taxonomy );
     123                return new self( $type, $is_list, $taxonomy, $suffixed );
    102124        }
    103125
    … …  
    125147
    126148        /**
     149         * Whether an id may carry a kept `_<number>` suffix.
     150         *
     151         * @since 0.3.0
     152         *
     153         * @return bool
     154         */
     155        public function is_suffixed(): bool {
     156                return $this->suffixed;
     157        }
     158
     159        /**
    127160         * The object type used as the Id_Map bucket.
    128161         *
    … …  
    131164         *
    132165         * @since 0.1.0
     166         * @since 0.3.0 Attachment URL hints use the post bucket too.
    133167         *
    134168         * @return string
    135169         */
    136170        public function map_bucket(): string {
    137                 if ( self::TYPE_ATTACHMENT === $this->type ) {
     171                if ( self::TYPE_ATTACHMENT === $this->type || self::TYPE_ATTACHMENT_URL === $this->type ) {
    138172                        return self::TYPE_POST;
    139173                }
  • transtrix/tags/0.3.0/includes/Reference/Reference_Walker.php

    r3790546 r3802902  
    3030
    3131        /**
     32         * Source attachment URL => source attachment id, for attachment_url hints.
     33         *
     34         * @var array<string,int>
     35         */
     36        private $urls;
     37
     38        /**
     39         * Constructor.
     40         *
     41         * @since 0.3.0
     42         *
     43         * @param array<string,int> $urls Source attachment URL => source attachment id.
     44         */
     45        public function __construct( array $urls = array() ) {
     46                $this->urls = $urls;
     47        }
     48
     49        /**
    3250         * Rewrite every id inside a value.
    3351         *
    … …  
    5472         *
    5573         * @since 0.1.0
     74         * @since 0.3.0 Hands attachment URL hints to remap_url().
    5675         *
    5776         * @param mixed          $value Current node.
    … …  
    6887                }
    6988
     89                if ( Reference_Hint::TYPE_ATTACHMENT_URL === $hint->type() ) {
     90                        return is_string( $value ) ? $this->remap_url( $value, $hint, $map ) : $value;
     91                }
     92
    7093                if ( is_int( $value ) ) {
    7194                        return $this->remap( $value, $hint, $map );
    … …  
    84107         *
    85108         * @since 0.1.0
     109         * @since 0.3.0 Reads `<id>_<number>` under a suffixed hint.
    86110         *
    87111         * @param string         $value Current node.
    … …  
    121145                if ( '' !== $value && ctype_digit( $value ) ) {
    122146                        return (string) $this->remap( (int) $value, $hint, $map );
     147                }
     148
     149                if ( $hint->is_suffixed() && 1 === preg_match( '/^(\d+)(_\d+)$/', $value, $parts ) ) {
     150                        return $this->remap( (int) $parts[1], $hint, $map ) . $parts[2];
    123151                }
    124152
    … …  
    140168
    141169        /**
     170         * Point a source attachment URL at the imported attachment's URL.
     171         *
     172         * A URL the package carries no attachment for (an external file, a path
     173         * typed by hand) is not a reference and is left alone.
     174         *
     175         * @since 0.3.0
     176         *
     177         * @param string         $url  Source-site URL.
     178         * @param Reference_Hint $hint Reference hint.
     179         * @param Id_Map         $map  The id map.
     180         * @return string
     181         */
     182        private function remap_url( string $url, Reference_Hint $hint, Id_Map $map ): string {
     183                if ( ! isset( $this->urls[ $url ] ) ) {
     184                        return $url;
     185                }
     186
     187                $old = (int) $this->urls[ $url ];
     188                $new = $this->remap( $old, $hint, $map );
     189
     190                if ( $new === $old && ! $map->has( $hint->map_bucket(), $old ) ) {
     191                        return $url;
     192                }
     193
     194                $new_url = wp_get_attachment_url( $new );
     195
     196                return is_string( $new_url ) && '' !== $new_url ? $new_url : $url;
     197        }
     198
     199        /**
    142200         * Translate one id, recording it when the map has no entry.
    143201         *
  • transtrix/tags/0.3.0/languages/transtrix.pot

    r3790546 r3802902  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Transtrix – Site Migration, Import & Export 0.2.0\n"
     5"Project-Id-Version: Transtrix – Site Migration, Import & Export 0.3.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/transtrix\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-11-08T06:19:00+00:00\n"
     12"POT-Creation-Date: 2026-11-15T08:23:26+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    272272#: dist/admin.js:979
    273273msgid "Installed plugins, so the new site has the same features."
     274msgstr ""
     275
     276#: includes/Import/Account_Importer.php:73
     277msgid "This email belongs to a staff account here, so its orders were imported as guest orders."
    274278msgstr ""
    275279
    … …  
    282286
    283287#. translators: %s: comma-separated content type names.
    284 #: includes/Import/Three_Pass_Importer.php:438
     288#: includes/Import/Three_Pass_Importer.php:466
    285289#, php-format
    286290msgid "Not imported, because this site has no place to store them: %s."
    287291msgstr ""
    288292
    289 #: includes/Import/Three_Pass_Importer.php:447
     293#: includes/Import/Three_Pass_Importer.php:475
    290294msgid "Orders, customers and store settings in this package were not imported. Only an administrator can import them."
    291295msgstr ""
    292296
    293 #: includes/Import/Three_Pass_Importer.php:452
     297#: includes/Import/Three_Pass_Importer.php:480
    294298#: dist/admin.js:979
    295299#: dist/admin.js:1135
    … …  
    297301msgstr ""
    298302
    299 #: includes/Import/Three_Pass_Importer.php:452
     303#: includes/Import/Three_Pass_Importer.php:480
    300304msgid "Images and files were left out: your account cannot upload files."
    301305msgstr ""
    302306
    303307#. translators: %s: plugin or module name, e.g. WooCommerce.
    304 #: includes/Import/Three_Pass_Importer.php:591
     308#: includes/Import/Three_Pass_Importer.php:623
    305309#, php-format
    306310msgid "This package needs %s, which is not active on this site. Install and activate it, then import again. Nothing has been changed."
    … …  
    308312
    309313#. translators: %s: comma-separated content type names.
    310 #: includes/Import/Three_Pass_Importer.php:628
     314#: includes/Import/Three_Pass_Importer.php:660
    311315#, php-format
    312316msgid "This package contains content types it is not allowed to import: %s. Nothing has been changed."
    … …  
    314318
    315319#. translators: %s: comma-separated content type names.
    316 #: includes/Import/Three_Pass_Importer.php:636
     320#: includes/Import/Three_Pass_Importer.php:668
    317321#, php-format
    318322msgid "This site has no place to store these content types: %s. Activate the plugin that adds them, then import again. Nothing has been changed."
    319323msgstr ""
    320324
    321 #: includes/Import/Three_Pass_Importer.php:731
     325#: includes/Import/Three_Pass_Importer.php:764
    322326msgid "You are not allowed to create or change this item."
    323327msgstr ""
    324328
    325 #: includes/Import/Three_Pass_Importer.php:819
     329#: includes/Import/Three_Pass_Importer.php:853
    326330msgid "This hidden field was left out: only an administrator can import it."
    327331msgstr ""
    328332
    329333#. translators: %s: comma-separated list of ids.
    330 #: includes/Import/Three_Pass_Importer.php:857
     334#: includes/Import/Three_Pass_Importer.php:891
    331335#, php-format
    332336msgid "These linked items could not be found: %s"
    333337msgstr ""
    334338
    335 #: includes/Import/Three_Pass_Importer.php:1030
     339#: includes/Import/Three_Pass_Importer.php:1064
    336340msgid "This site has no place for these categories, so they were not added. Activate the plugin that adds them, then import again."
    337341msgstr ""
    338342
    339 #: includes/Media/Media_Collector.php:50
     343#: includes/Media/Media_Collector.php:51
    340344msgid "This item is not a media file."
    341345msgstr ""
    342346
    343 #: includes/Media/Media_Collector.php:56
     347#: includes/Media/Media_Collector.php:57
    344348msgid "The media file is missing from the server."
    345349msgstr ""
    346350
    347 #: includes/Media/Media_Collector.php:64
     351#: includes/Media/Media_Collector.php:65
    348352msgid "The media file could not be read."
    349353msgstr ""
    350354
    351 #: includes/Media/Media_Importer.php:66
     355#: includes/Media/Media_Importer.php:77
    352356msgid "(unnamed media)"
    353357msgstr ""
    354358
    355 #: includes/Media/Media_Importer.php:66
     359#: includes/Media/Media_Importer.php:77
    356360msgid "This media item has no ID in the package."
    357361msgstr ""
    358362
    359 #: includes/Media/Media_Importer.php:134
     363#: includes/Media/Media_Importer.php:184
    360364msgid "The package does not contain this media file."
    361365msgstr ""
    362366
    363 #: includes/Media/Media_Importer.php:141
     367#: includes/Media/Media_Importer.php:191
    364368msgid "This media file is not a type this site accepts."
    365369msgstr ""
    366370
    367 #: includes/Media/Media_Importer.php:163
     371#: includes/Media/Media_Importer.php:230
    368372msgid "The media file could not be saved."
    369373msgstr ""
    … …  
    380384msgid "Posts, pages and media, with their categories and tags."
    381385msgstr ""
     386
     387#: includes/Modules/EDD/EDD_Customers.php:209
     388msgid "Easy Digital Downloads could not save this customer."
     389msgstr ""
     390
     391#. translators: %s: discount code.
     392#: includes/Modules/EDD/EDD_Discounts.php:190
     393#, php-format
     394msgid "Discount code %s"
     395msgstr ""
     396
     397#: includes/Modules/EDD/EDD_Discounts.php:214
     398msgid "Easy Digital Downloads could not save this discount code."
     399msgstr ""
     400
     401#. translators: %s: comma-separated ids.
     402#: includes/Modules/EDD/EDD_Discounts.php:235
     403#, php-format
     404msgid "Some downloads or categories this code is limited to were not in the package, so those limits were removed: %s"
     405msgstr ""
     406
     407#: includes/Modules/EDD/EDD_Module.php:73
     408#: includes/Modules/EDD/EDD_Module.php:102
     409msgid "Easy Digital Downloads"
     410msgstr ""
     411
     412#. translators: %s: Easy Digital Downloads version.
     413#: includes/Modules/EDD/EDD_Module.php:101
     414#, php-format
     415msgid "Easy Digital Downloads v%s"
     416msgstr ""
     417
     418#: includes/Modules/EDD/EDD_Module.php:113
     419msgid "Downloads with their prices, files and bundles, plus categories, tags and discount codes. Orders, refunds and customers can come too; importing them never charges or emails anyone. Files for sale stay protected on the new site."
     420msgstr ""
     421
     422#: includes/Modules/EDD/EDD_Module.php:204
     423msgid "Discount codes"
     424msgstr ""
     425
     426#: includes/Modules/EDD/EDD_Module.php:207
     427#: includes/Modules/Woo/Woo_Module.php:327
     428msgid "Orders"
     429msgstr ""
     430
     431#: includes/Modules/EDD/EDD_Module.php:208
     432#: includes/Modules/Woo/Woo_Module.php:328
     433msgid "Customers"
     434msgstr ""
     435
     436#: includes/Modules/EDD/EDD_Module.php:347
     437msgid "Easy Digital Downloads has a database upgrade to finish on this site. Open Downloads in the dashboard, finish the upgrade it asks for, then import again. Nothing has been changed."
     438msgstr ""
     439
     440#. translators: %s: comma-separated names of Easy Digital Downloads data tables.
     441#: includes/Modules/EDD/EDD_Module.php:367
     442#, php-format
     443msgid "Easy Digital Downloads is active, but its setup is incomplete: some of its data tables are missing (%s). Deactivate and reactivate Easy Digital Downloads, then import again. Nothing has been changed."
     444msgstr ""
     445
     446#. translators: %d: order number on the source site.
     447#: includes/Modules/EDD/EDD_Orders.php:277
     448#: includes/Modules/Woo/Woo_Orders.php:444
     449#, php-format
     450msgid "Order #%d"
     451msgstr ""
     452
     453#: includes/Modules/EDD/EDD_Orders.php:306
     454msgid "Easy Digital Downloads could not save this order."
     455msgstr ""
     456
     457#: includes/Modules/EDD/EDD_Orders.php:310
     458msgid "A refund of this order could not be saved."
     459msgstr ""
     460
     461#. translators: %d: number of order lines.
     462#: includes/Modules/EDD/EDD_Orders.php:373
     463#, php-format
     464msgid "%d line refers to a download that was not in this package, so it keeps its name and price but links to no download."
     465msgid_plural "%d lines refer to downloads that were not in this package, so they keep their names and prices but link to no download."
     466msgstr[0] ""
     467msgstr[1] ""
    382468
    383469#: includes/Modules/Woo/Woo_Module.php:78
    … …  
    402488msgstr ""
    403489
    404 #: includes/Modules/Woo/Woo_Module.php:327
    405 msgid "Orders"
    406 msgstr ""
    407 
    408 #: includes/Modules/Woo/Woo_Module.php:328
    409 msgid "Customers"
    410 msgstr ""
    411 
    412 #: includes/Modules/Woo/Woo_Orders.php:422
    413 msgid "This email belongs to a staff account here, so its orders were imported as guest orders."
    414 msgstr ""
    415 
    416 #. translators: %d: order number on the source site.
    417 #: includes/Modules/Woo/Woo_Orders.php:487
    418 #, php-format
    419 msgid "Order #%d"
    420 msgstr ""
    421 
    422490#. translators: %d: number of order lines.
    423 #: includes/Modules/Woo/Woo_Orders.php:549
     491#: includes/Modules/Woo/Woo_Orders.php:506
    424492#, php-format
    425493msgid "%d line refers to a product that was not in this package, so it keeps its name and price but links to no product."
    … …  
    502570msgstr ""
    503571
    504 #: includes/Package/Package_Writer.php:167
     572#: includes/Package/Package_Writer.php:182
    505573msgid "This item no longer exists on this site."
    506574msgstr ""
    507575
    508 #: includes/Package/Package_Writer.php:277
    509 #: includes/Package/Package_Writer.php:281
    510 #: includes/Package/Package_Writer.php:288
    511 #: includes/Package/Package_Writer.php:313
    512 #: includes/Package/Package_Writer.php:338
     576#: includes/Package/Package_Writer.php:292
     577#: includes/Package/Package_Writer.php:296
     578#: includes/Package/Package_Writer.php:303
     579#: includes/Package/Package_Writer.php:328
     580#: includes/Package/Package_Writer.php:353
    513581msgid "The package could not be written. Check that the uploads folder has free space."
    514582msgstr ""
    515583
    516 #: includes/Package/Package_Writer.php:320
     584#: includes/Package/Package_Writer.php:335
    517585msgid "The package could not be created. Check that the uploads folder is writable."
    518586msgstr ""
  • transtrix/tags/0.3.0/package.json

    r3790546 r3802902  
    11{
    22  "name": "transtrix",
    3   "version": "0.2.0",
     3  "version": "0.3.0",
    44  "description": "Admin app for Transtrix.",
    55  "license": "GPL-2.0-or-later",
  • transtrix/tags/0.3.0/readme.txt

    r3790546 r3802902  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.2.0
     7Stable tag: 0.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    6363* **WP Travel Engine:** trips with their packages, coupons, extra services and accommodations, plus the destinations, activities, trip types, difficulty levels and trip tags they use. Bookings, customers, payments and enquiries are ticked too, so untick them if you want only the trips. When you bring bookings, Transtrix recounts the seats sold for each trip on the new site.
    6464* **WooCommerce:** products with their variations, images and attributes, plus coupons, categories, tags and shipping classes. Orders, refunds and customers can come too, or you can untick them. An imported order arrives as a record of a past sale: Transtrix charges no card, sends no email, leaves stock alone and keeps the original totals. It works with High-Performance Order Storage on or off.
     65* **Easy Digital Downloads:** downloads with their prices, files and images, plus bundles, discount codes, categories and tags. Paid files stay in the protected Easy Digital Downloads folder on the new site. Orders, refunds and customers can come too, or you can untick them. An imported order arrives as a record of a past sale: Transtrix charges no card, sends no receipt, leaves discount code usage alone and keeps the original totals. Transtrix then recounts sales and earnings for each download and customer on the new site. Needs Easy Digital Downloads 3.0 or newer.
    6566
    6667Transtrix shows a content type only when the plugin behind it is active.
    … …  
    148149= Can I leave bookings, orders and customer data out? =
    149150
    150 Yes. Every content type starts ticked, so untick Bookings, Customers, Payments and Enquiries in WP Travel Engine, or Orders and Customers in WooCommerce, before you build the package.
     151Yes. Every content type starts ticked, so untick Bookings, Customers, Payments and Enquiries in WP Travel Engine, or Orders and Customers in WooCommerce or Easy Digital Downloads, before you build the package.
    151152
    152153= Can I undo a selected-content import? =
    … …  
    154155Yes. Open **History**, find the import and choose **Undo this import**. Transtrix removes what it added and puts back what it changed. Restore points last 30 days by default.
    155156
    156 = Do imported WooCommerce orders charge customers or send emails? =
    157 
    158 No. Transtrix calls no payment gateway, blocks outgoing mail during the import, and does not reduce stock again.
     157= Do imported WooCommerce or Easy Digital Downloads orders charge customers or send emails? =
     158
     159No. Transtrix calls no payment gateway, blocks outgoing mail during the import, and does not reduce stock or use up discount codes again.
    159160
    160161= My export is large. Will it time out? =
    … …  
    182183
    183184== Changelog ==
     185
     186= 0.3.0 - 15th November 2026 =
     187* New Feature: Added migration for Easy Digital Downloads downloads, bundles and discount codes, with their files, images, categories and tags.
     188* New Feature: Added the option to include Easy Digital Downloads orders, refunds and customers. Imported orders charge no card, send no receipt and keep the original totals.
    184189
    185190= 0.2.0 - 8th November 2026 =
    … …  
    201206== Upgrade Notice ==
    202207
     208= 0.3.0 =
     209Adds Easy Digital Downloads: move downloads, files and discount codes, and optionally orders, refunds and customers. Update both the source and destination sites.
     210
    203211= 0.2.0 =
    204212Faster imports that bring image sizes along, no 500-item export limit, and fixes for large uploads and imports that timed out. Update both the source and destination sites.
  • transtrix/tags/0.3.0/transtrix.php

    r3790546 r3802902  
    44 * Plugin URI:        https://transtrix.com/
    55 * Description:       Migrate a whole WordPress site, or move only the pages, posts, trips and products you pick, with their images, categories and links. Works with WP Travel Engine and WooCommerce.
    6  * Version:           0.2.0
     6 * Version:           0.3.0
    77 * Requires at least: 6.6
    88 * Requires PHP:      7.4
    … …  
    1818defined( 'ABSPATH' ) || exit;
    1919
    20 define( 'TRANSTRIX_VERSION', '0.2.0' );
     20define( 'TRANSTRIX_VERSION', '0.3.0' );
    2121define( 'TRANSTRIX_FILE', __FILE__ );
    2222define( 'TRANSTRIX_PATH', plugin_dir_path( __FILE__ ) );
  • transtrix/tags/0.3.0/vendor/composer/autoload_classmap.php

    r3790546 r3802902  
    2424    'Transtrix\\Fullsite\\Serialized_Replacer' => $baseDir . '/includes/Fullsite/Serialized_Replacer.php',
    2525    'Transtrix\\Fullsite\\Tasks' => $baseDir . '/includes/Fullsite/Tasks.php',
     26    'Transtrix\\Import\\Account_Importer' => $baseDir . '/includes/Import/Account_Importer.php',
    2627    'Transtrix\\Import\\Conflict_Resolver' => $baseDir . '/includes/Import/Conflict_Resolver.php',
    2728    'Transtrix\\Import\\Import_Task' => $baseDir . '/includes/Import/Import_Task.php',
    … …  
    3334    'Transtrix\\Modules\\Core\\Core_Importer' => $baseDir . '/includes/Modules/Core/Core_Importer.php',
    3435    'Transtrix\\Modules\\Core\\Core_Module' => $baseDir . '/includes/Modules/Core/Core_Module.php',
     36    'Transtrix\\Modules\\EDD\\EDD_Customers' => $baseDir . '/includes/Modules/EDD/EDD_Customers.php',
     37    'Transtrix\\Modules\\EDD\\EDD_Discounts' => $baseDir . '/includes/Modules/EDD/EDD_Discounts.php',
     38    'Transtrix\\Modules\\EDD\\EDD_Hints' => $baseDir . '/includes/Modules/EDD/EDD_Hints.php',
     39    'Transtrix\\Modules\\EDD\\EDD_Module' => $baseDir . '/includes/Modules/EDD/EDD_Module.php',
     40    'Transtrix\\Modules\\EDD\\EDD_Orders' => $baseDir . '/includes/Modules/EDD/EDD_Orders.php',
     41    'Transtrix\\Modules\\EDD\\EDD_Rows' => $baseDir . '/includes/Modules/EDD/EDD_Rows.php',
    3542    'Transtrix\\Modules\\WPTravelEngine\\WPTravelEngine_Hints' => $baseDir . '/includes/Modules/WPTravelEngine/WPTravelEngine_Hints.php',
    3643    'Transtrix\\Modules\\WPTravelEngine\\WPTravelEngine_Module' => $baseDir . '/includes/Modules/WPTravelEngine/WPTravelEngine_Module.php',
  • transtrix/tags/0.3.0/vendor/composer/autoload_static.php

    r3790546 r3802902  
    3939        'Transtrix\\Fullsite\\Serialized_Replacer' => __DIR__ . '/../..' . '/includes/Fullsite/Serialized_Replacer.php',
    4040        'Transtrix\\Fullsite\\Tasks' => __DIR__ . '/../..' . '/includes/Fullsite/Tasks.php',
     41        'Transtrix\\Import\\Account_Importer' => __DIR__ . '/../..' . '/includes/Import/Account_Importer.php',
    4142        'Transtrix\\Import\\Conflict_Resolver' => __DIR__ . '/../..' . '/includes/Import/Conflict_Resolver.php',
    4243        'Transtrix\\Import\\Import_Task' => __DIR__ . '/../..' . '/includes/Import/Import_Task.php',
    … …  
    4849        'Transtrix\\Modules\\Core\\Core_Importer' => __DIR__ . '/../..' . '/includes/Modules/Core/Core_Importer.php',
    4950        'Transtrix\\Modules\\Core\\Core_Module' => __DIR__ . '/../..' . '/includes/Modules/Core/Core_Module.php',
     51        'Transtrix\\Modules\\EDD\\EDD_Customers' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Customers.php',
     52        'Transtrix\\Modules\\EDD\\EDD_Discounts' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Discounts.php',
     53        'Transtrix\\Modules\\EDD\\EDD_Hints' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Hints.php',
     54        'Transtrix\\Modules\\EDD\\EDD_Module' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Module.php',
     55        'Transtrix\\Modules\\EDD\\EDD_Orders' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Orders.php',
     56        'Transtrix\\Modules\\EDD\\EDD_Rows' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Rows.php',
    5057        'Transtrix\\Modules\\WPTravelEngine\\WPTravelEngine_Hints' => __DIR__ . '/../..' . '/includes/Modules/WPTravelEngine/WPTravelEngine_Hints.php',
    5158        'Transtrix\\Modules\\WPTravelEngine\\WPTravelEngine_Module' => __DIR__ . '/../..' . '/includes/Modules/WPTravelEngine/WPTravelEngine_Module.php',
  • transtrix/tags/0.3.0/vendor/composer/installed.php

    r3790546 r3802902  
    22    'root' => array(
    33        'name' => 'aayushk777/transtrix',
    4         'pretty_version' => 'v0.2.0',
    5         'version' => '0.2.0.0',
    6         'reference' => '0c34b30edd9bcdfb09c998cb88ce418e687bb8de',
     4        'pretty_version' => 'v0.3.0',
     5        'version' => '0.3.0.0',
     6        'reference' => '396d3f7184662acef9e40ff6bbe35d8a691ee2c2',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    1212    'versions' => array(
    1313        'aayushk777/transtrix' => array(
    14             'pretty_version' => 'v0.2.0',
    15             'version' => '0.2.0.0',
    16             'reference' => '0c34b30edd9bcdfb09c998cb88ce418e687bb8de',
     14            'pretty_version' => 'v0.3.0',
     15            'version' => '0.3.0.0',
     16            'reference' => '396d3f7184662acef9e40ff6bbe35d8a691ee2c2',
    1717            'type' => 'wordpress-plugin',
    1818            'install_path' => __DIR__ . '/../../',
  • transtrix/trunk/assets/admin/src/components/SourceLogo.jsx

    r3759911 r3802902  
    2626        'wp-travel-engine': 'wp-travel-engine.png',
    2727        woocommerce: 'woocommerce.svg',
     28        edd: 'easy-digital-downloads.svg',
    2829};
    2930
  • transtrix/trunk/dist/admin.asset.php

    r3790546 r3802902  
    1 <?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-date', 'wp-element', 'wp-i18n'), 'version' => '3911087dc07eb7c69eee');
     1<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-date', 'wp-element', 'wp-i18n'), 'version' => '0aabe6dbf55be35a8696');
  • transtrix/trunk/dist/admin.js

    r3790546 r3802902  
    549549                color: #0284c7;
    550550        }
    551 `,Ga=Qa,Za={};Za.wordpress=(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)("circle",{cx:"36",cy:"36",r:"36",fill:"#fff"}),(0,l.jsx)("g",{transform:"translate(6 6) scale(2.5)",fill:"#21759B",children:(0,l.jsx)("path",{d:"M21.469 6.825c.84 1.537 1.318 3.3 1.318 5.175 0 3.979-2.156 7.456-5.363 9.325l3.295-9.527c.615-1.54.82-2.771.82-3.864 0-.405-.026-.78-.07-1.11m-7.981.105c.647-.03 1.232-.105 1.232-.105.582-.075.514-.93-.067-.899 0 0-1.755.135-2.88.135-1.064 0-2.85-.15-2.85-.15-.585-.03-.661.855-.075.885 0 0 .54.061 1.125.09l1.68 4.605-2.37 7.08L5.354 6.9c.649-.03 1.234-.1 1.234-.1.585-.075.516-.93-.065-.896 0 0-1.746.138-2.874.138-.2 0-.438-.008-.69-.015C4.911 3.15 8.235 1.215 12 1.215c2.809 0 5.365 1.072 7.286 2.833-.046-.003-.091-.009-.141-.009-1.06 0-1.812.923-1.812 1.914 0 .89.513 1.643 1.06 2.531.411.72.89 1.643.89 2.977 0 .915-.354 1.994-.821 3.479l-1.075 3.585-3.9-11.61.001.014zM12 22.784c-1.059 0-2.081-.153-3.048-.437l3.237-9.406 3.315 9.087c.024.053.05.101.078.149-1.12.393-2.325.609-3.582.609M1.211 12c0-1.564.336-3.05.935-4.39L7.29 21.709C3.694 19.96 1.212 16.271 1.211 12M12 0C5.385 0 0 5.385 0 12s5.385 12 12 12 12-5.385 12-12S18.615 0 12 0"})})]}),Za["wp-core"]=Za.wordpress,Za.core=Za.wordpress,Za["full-site"]=Za.wordpress;const Ya={wptravelengine:"wp-travel-engine.png","wp-travel-engine":"wp-travel-engine.png",woocommerce:"woocommerce.svg"},Xa=({moduleId:e,size:t=72})=>Ya[e]&&Ia.logosUrl?(0,l.jsx)("img",{src:Ia.logosUrl+Ya[e],alt:"",width:t,height:t,style:{display:"block",margin:"0 auto",borderRadius:Math.round(.22*t)}}):!Za[e]&&Ia.siteLogo?(0,l.jsx)("img",{src:Ia.siteLogo,alt:"",width:t,height:t,style:{display:"block",margin:"0 auto",borderRadius:16,objectFit:"contain"}}):Za[e]?(0,l.jsx)("svg",{viewBox:"0 0 72 72",width:t,height:t,"aria-hidden":"true",style:{display:"block",margin:"0 auto"},children:Za[e]}):(0,l.jsx)("div",{style:{width:t,margin:"0 auto"},children:(0,l.jsx)(Ga,{size:t})}),Ja=Qr.div`
     551`,Ga=Qa,Za={};Za.wordpress=(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)("circle",{cx:"36",cy:"36",r:"36",fill:"#fff"}),(0,l.jsx)("g",{transform:"translate(6 6) scale(2.5)",fill:"#21759B",children:(0,l.jsx)("path",{d:"M21.469 6.825c.84 1.537 1.318 3.3 1.318 5.175 0 3.979-2.156 7.456-5.363 9.325l3.295-9.527c.615-1.54.82-2.771.82-3.864 0-.405-.026-.78-.07-1.11m-7.981.105c.647-.03 1.232-.105 1.232-.105.582-.075.514-.93-.067-.899 0 0-1.755.135-2.88.135-1.064 0-2.85-.15-2.85-.15-.585-.03-.661.855-.075.885 0 0 .54.061 1.125.09l1.68 4.605-2.37 7.08L5.354 6.9c.649-.03 1.234-.1 1.234-.1.585-.075.516-.93-.065-.896 0 0-1.746.138-2.874.138-.2 0-.438-.008-.69-.015C4.911 3.15 8.235 1.215 12 1.215c2.809 0 5.365 1.072 7.286 2.833-.046-.003-.091-.009-.141-.009-1.06 0-1.812.923-1.812 1.914 0 .89.513 1.643 1.06 2.531.411.72.89 1.643.89 2.977 0 .915-.354 1.994-.821 3.479l-1.075 3.585-3.9-11.61.001.014zM12 22.784c-1.059 0-2.081-.153-3.048-.437l3.237-9.406 3.315 9.087c.024.053.05.101.078.149-1.12.393-2.325.609-3.582.609M1.211 12c0-1.564.336-3.05.935-4.39L7.29 21.709C3.694 19.96 1.212 16.271 1.211 12M12 0C5.385 0 0 5.385 0 12s5.385 12 12 12 12-5.385 12-12S18.615 0 12 0"})})]}),Za["wp-core"]=Za.wordpress,Za.core=Za.wordpress,Za["full-site"]=Za.wordpress;const Ya={wptravelengine:"wp-travel-engine.png","wp-travel-engine":"wp-travel-engine.png",woocommerce:"woocommerce.svg",edd:"easy-digital-downloads.svg"},Xa=({moduleId:e,size:t=72})=>Ya[e]&&Ia.logosUrl?(0,l.jsx)("img",{src:Ia.logosUrl+Ya[e],alt:"",width:t,height:t,style:{display:"block",margin:"0 auto",borderRadius:Math.round(.22*t)}}):!Za[e]&&Ia.siteLogo?(0,l.jsx)("img",{src:Ia.siteLogo,alt:"",width:t,height:t,style:{display:"block",margin:"0 auto",borderRadius:16,objectFit:"contain"}}):Za[e]?(0,l.jsx)("svg",{viewBox:"0 0 72 72",width:t,height:t,"aria-hidden":"true",style:{display:"block",margin:"0 auto"},children:Za[e]}):(0,l.jsx)("div",{style:{width:t,margin:"0 auto"},children:(0,l.jsx)(Ga,{size:t})}),Ja=Qr.div`
    552552        display: flex;
    553553        flex-direction: column;
  • transtrix/trunk/includes/Cli/Cli_Command.php

    r3790546 r3802902  
    252252         *
    253253         * @since 0.1.0
     254         * @since 0.3.0 Also reports the orders, customers and codes it deleted.
    254255         *
    255256         * @param array<int,string>    $args       Positional arguments.
    … …  
    277278                WP_CLI::success(
    278279                        sprintf(
    279                                 'Deleted %d posts and %d terms, restored %d posts.',
     280                                'Deleted %d posts, %d terms and %d other objects (orders, customers, codes), restored %d posts.',
    280281                                $undone['posts_deleted'],
    281282                                $undone['terms_deleted'],
     283                                $undone['objects_deleted'],
    282284                                $undone['posts_restored']
    283285                        )
  • transtrix/trunk/includes/Contracts/Module.php

    r3790546 r3802902  
    212212
    213213        /**
     214         * Top-level upload directories whose files must keep their place.
     215         *
     216         * A store that sells files keeps them in a directory the web server will
     217         * not serve (Easy Digital Downloads' `uploads/edd`). Imported into the
     218         * usual dated directory, a paid file would get a public URL.
     219         *
     220         * @since 0.3.0
     221         *
     222         * @return string[]
     223         */
     224        public function protected_upload_dirs(): array {
     225                return array();
     226        }
     227
     228        /**
    214229         * Last chance to widen or narrow the job before the package is written.
    215230         *
  • transtrix/trunk/includes/Import/Restore_Point.php

    r3790546 r3802902  
    137137        public function record_created_object( string $kind, int $id ): void {
    138138                $this->created_objects[ $kind ][] = $id;
     139        }
     140
     141        /**
     142         * Non-post objects this import created so far, kind => ids.
     143         *
     144         * @since 0.3.0
     145         *
     146         * @return array<string,int[]>
     147         */
     148        public function created_objects(): array {
     149                return array_map(
     150                        static function ( $ids ) {
     151                                return array_map( 'intval', (array) $ids );
     152                        },
     153                        $this->created_objects
     154                );
    139155        }
    140156
  • transtrix/trunk/includes/Import/Three_Pass_Importer.php

    r3790546 r3802902  
    101101
    102102        /**
     103         * Source attachment URL => source attachment id, built once per step.
     104         *
     105         * @var array<string,int>|null
     106         */
     107        private $media_urls = null;
     108
     109        /**
    103110         * Taxonomies the package uses that this site does not register.
    104111         *
    … …  
    423430
    424431        /**
     432         * Source attachment URL => source attachment id, for attachment URL hints.
     433         *
     434         * @since 0.3.0
     435         *
     436         * @return array<string,int>
     437         */
     438        private function media_urls(): array {
     439                if ( null === $this->media_urls ) {
     440                        $this->media_urls = array();
     441
     442                        foreach ( $this->lists()['media'] as $record ) {
     443                                if ( ! empty( $record['url'] ) && ! empty( $record['id'] ) ) {
     444                                        $this->media_urls[ (string) $record['url'] ] = (int) $record['id'];
     445                                }
     446                        }
     447                }
     448
     449                return $this->media_urls;
     450        }
     451
     452        /**
    425453         * Before the first record: what every later record is keyed by.
    426454         *
    … …  
    500528         *
    501529         * @since 0.2.0
     530         * @since 0.3.0 Also hands over the objects import_payload() created.
    502531         *
    503532         * @param Report $report Outcome report.
    … …  
    512541                // one, before the terms that decide it were linked in pass three.
    513542                $this->job->set_state( 'imported_ids', array_values( $this->imported ) );
     543                // And what import_payload() created, kind => ids: an order's stats are
     544                // rebuilt once, here, not per order.
     545                $this->job->set_state( 'created_objects', $this->restore->created_objects() );
    514546                $this->module->after_import( $this->job, $report );
    515547        }
    … …  
    682714         *
    683715         * @since 0.2.0
     716         * @since 0.3.0 Keeps the module's protected upload directories.
    684717         *
    685718         * @param array<string,mixed> $record One media record.
    … …  
    689722        private function create_media( array $record, Report $report ): void {
    690723                if ( $this->may_upload() ) {
    691                         ( new Media_Importer( $this->package->work_dir() ) )->import( array( $record ), $this->map, $report, $this->restore );
     724                        ( new Media_Importer( $this->package->work_dir(), $this->module->protected_upload_dirs() ) )->import( array( $record ), $this->map, $report, $this->restore );
    692725                }
    693726        }
    … …  
    771804         *
    772805         * @since 0.2.0
     806         * @since 0.3.0 Rewrites attachment URLs too.
    773807         *
    774808         * @param array<string,mixed> $record One post record.
    … …  
    785819                $new_id = $this->imported[ $old_id ];
    786820                $meta   = isset( $record['meta'] ) && is_array( $record['meta'] ) ? $record['meta'] : array();
    787                 $walker = new Reference_Walker();
     821                $walker = new Reference_Walker( $this->media_urls() );
    788822                $map    = $this->map;
    789823
    … …  
    11631197         *
    11641198         * @since 0.1.0
     1199         * @since 0.3.0 Reads `<id>_<number>` under a suffixed hint.
    11651200         *
    11661201         * @param array<string,mixed> $record   One post record.
    … …  
    11831218
    11841219                        foreach ( (array) $meta[ $parsed['key'] ] as $value ) {
    1185                                 foreach ( Hint_Path::collect_at( $value, $parsed['segments'] ) as $id ) {
     1220                                foreach ( Hint_Path::collect_at( $value, $parsed['segments'], $hint->is_suffixed() ) as $id ) {
    11861221                                        if ( ! isset( $in_scope[ $id ] ) ) {
    11871222                                                $missing[] = $id;
  • transtrix/trunk/includes/Media/Media_Collector.php

    r3790546 r3802902  
    4040         * @since 0.1.0
    4141         * @since 0.2.0 Also collects the generated sizes and their metadata.
     42         * @since 0.3.0 Records the source URL and upload sub-directory.
    4243         *
    4344         * @param int $attachment_id Attachment id.
    … …  
    6768                $filename      = wp_basename( $file );
    6869                $relative_path = 'media/' . $attachment_id . '/' . $filename;
     70                $subdir        = dirname( (string) get_post_meta( $attachment_id, '_wp_attached_file', true ) );
    6971
    7072                $this->files[ $relative_path ] = array(
    … …  
    8587                        'post_parent'   => (int) $post->post_parent,
    8688                        'post_date_gmt' => $post->post_date_gmt,
     89                        // Where it lived, so a link to it can follow it and a protected
     90                        // directory can keep it.
     91                        'url'           => (string) wp_get_attachment_url( $attachment_id ),
     92                        'subdir'        => '.' === $subdir ? '' : $subdir,
    8793                );
    8894
  • transtrix/trunk/includes/Media/Media_Importer.php

    r3790546 r3802902  
    3838
    3939        /**
     40         * Top-level upload directories whose files keep their place.
     41         *
     42         * @var string[]
     43         */
     44        private $protected_dirs;
     45
     46        /**
    4047         * Constructor.
    4148         *
    4249         * @since 0.1.0
    43          *
    44          * @param string $work_dir Trailing-slashed extraction directory.
    45          */
    46         public function __construct( string $work_dir ) {
    47                 $this->work_dir = trailingslashit( $work_dir );
     50         * @since 0.3.0 Takes $protected_dirs.
     51         *
     52         * @param string   $work_dir       Trailing-slashed extraction directory.
     53         * @param string[] $protected_dirs Top-level upload directories whose files keep their place.
     54         */
     55        public function __construct( string $work_dir, array $protected_dirs = array() ) {
     56                $this->work_dir       = trailingslashit( $work_dir );
     57                $this->protected_dirs = $protected_dirs;
    4858        }
    4959
    … …  
    5262         *
    5363         * @since 0.1.0
     64         * @since 0.3.0 A protected file matches only a copy in its protected directory.
    5465         *
    5566         * @param array<int,array<string,mixed>> $records Media records from the package.
    … …  
    6879                        }
    6980
    70                         $existing = $this->find_by_checksum( (string) ( $record['sha256'] ?? '' ) );
     81                        $existing = $this->find_by_checksum( (string) ( $record['sha256'] ?? '' ), $this->protected_subdir( $record ) );
    7182
    7283                        if ( $existing > 0 ) {
    … …  
    93104         *
    94105         * @since 0.1.0
     106         * @since 0.3.0 A protected file only matches a copy inside the same protected directory.
    95107         *
    96108         * @param string $checksum sha256 of the file.
     109         * @param string $subdir   Protected sub-directory the file belongs in, or ''.
    97110         * @return int Attachment id, or 0.
    98111         */
    99         private function find_by_checksum( string $checksum ): int {
     112        private function find_by_checksum( string $checksum, string $subdir = '' ): int {
    100113                if ( '' === $checksum ) {
    101114                        return 0;
    … …  
    106119                                'post_type'              => 'attachment',
    107120                                'post_status'            => 'any',
    108                                 'posts_per_page'         => 1,
     121                                'posts_per_page'         => '' === $subdir ? 1 : 20,
    109122                                'fields'                 => 'ids',
    110123                                'no_found_rows'          => true,
    … …  
    115128                );
    116129
    117                 return empty( $found ) ? 0 : (int) $found[0];
     130                if ( '' === $subdir ) {
     131                        return empty( $found ) ? 0 : (int) $found[0];
     132                }
     133
     134                // A public copy of a paid file is not a match: linking to it would
     135                // hand out the file the protected directory exists to hold back.
     136                $top = strtok( $subdir, '/' ) . '/';
     137
     138                foreach ( $found as $id ) {
     139                        if ( 0 === strpos( (string) get_post_meta( (int) $id, '_wp_attached_file', true ), $top ) ) {
     140                                return (int) $id;
     141                        }
     142                }
     143
     144                return 0;
     145        }
     146
     147        /**
     148         * The upload sub-directory a protected file must keep, or '' for any other file.
     149         *
     150         * The path comes from the package, so only plain directory names pass; a
     151         * protected file with any other path still lands in its protected root.
     152         *
     153         * @since 0.3.0
     154         *
     155         * @param array<string,mixed> $record One media record.
     156         * @return string
     157         */
     158        private function protected_subdir( array $record ): string {
     159                $subdir = (string) ( $record['subdir'] ?? '' );
     160                $top    = explode( '/', $subdir )[0];
     161
     162                if ( '' === $top || ! in_array( $top, $this->protected_dirs, true ) ) {
     163                        return '';
     164                }
     165
     166                return 1 === preg_match( '#^[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*$#', $subdir ) ? $subdir : $top;
    118167        }
    119168
    … …  
    123172         * @since 0.1.0
    124173         * @since 0.2.0 Copies the packaged sizes instead of rebuilding them.
     174         * @since 0.3.0 Keeps a protected file in its protected directory.
    125175         *
    126176         * @param array<string,mixed> $record One media record.
    … …  
    158208                );
    159209
    160                 $upload = wp_handle_sideload( $sideload, array( 'test_form' => false ) );
     210                $subdir   = $this->protected_subdir( $record );
     211                $keep_dir = static function ( array $dirs ) use ( $subdir ): array {
     212                        $dirs['subdir'] = '/' . $subdir;
     213                        $dirs['path']   = $dirs['basedir'] . $dirs['subdir'];
     214                        $dirs['url']    = $dirs['baseurl'] . $dirs['subdir'];
     215
     216                        return $dirs;
     217                };
     218
     219                if ( '' !== $subdir ) {
     220                        add_filter( 'upload_dir', $keep_dir, PHP_INT_MAX );
     221                }
     222
     223                try {
     224                        $upload = wp_handle_sideload( $sideload, array( 'test_form' => false ) );
     225                } finally {
     226                        remove_filter( 'upload_dir', $keep_dir, PHP_INT_MAX );
     227                }
    161228
    162229                if ( ! empty( $upload['error'] ) || empty( $upload['file'] ) ) {
  • transtrix/trunk/includes/Modules/Core/Core_Exporter.php

    r3790546 r3802902  
    186186         *
    187187         * @since 0.1.0
     188         * @since 0.3.0 Reads `<id>_<number>` under a suffixed hint.
    188189         *
    189190         * @param int[] $post_ids Selected post ids.
    … …  
    210211                        foreach ( $post_ids as $post_id ) {
    211212                                foreach ( get_post_meta( (int) $post_id, $parsed['key'], false ) as $value ) {
    212                                         $ids = array_merge( $ids, Hint_Path::collect_at( $value, $parsed['segments'] ) );
     213                                        $ids = array_merge( $ids, Hint_Path::collect_at( $value, $parsed['segments'], $hint->is_suffixed() ) );
    213214                                }
    214215                        }
    … …  
    310311
    311312        /**
     313         * Attachment ids behind the URLs at a path.
     314         *
     315         * @since 0.3.0
     316         *
     317         * @param mixed    $value    The whole meta value.
     318         * @param string[] $segments Path to the URLs.
     319         * @return int[]
     320         */
     321        private function url_ids( $value, array $segments ): array {
     322                $ids = array();
     323
     324                Hint_Path::rewrite_at(
     325                        maybe_unserialize( $value ),
     326                        $segments,
     327                        static function ( $url ) use ( &$ids ) {
     328                                if ( is_string( $url ) && '' !== $url ) {
     329                                        $ids[] = attachment_url_to_postid( $url );
     330                                }
     331
     332                                return $url;
     333                        }
     334                );
     335
     336                return $ids;
     337        }
     338
     339        /**
    312340         * Attachment ids a post references, through hints and through its content.
    313341         *
    314342         * @since 0.1.0
     343         * @since 0.3.0 Follows attachment URL hints too.
    315344         *
    316345         * @param int $post_id Post id.
    … …  
    323352                        $hint = Reference_Hint::from( (string) $notation );
    324353
    325                         if ( ! $hint instanceof Reference_Hint || Reference_Hint::TYPE_ATTACHMENT !== $hint->type() ) {
     354                        if ( ! $hint instanceof Reference_Hint || ! in_array( $hint->type(), array( Reference_Hint::TYPE_ATTACHMENT, Reference_Hint::TYPE_ATTACHMENT_URL ), true ) ) {
    326355                                continue;
    327356                        }
    … …  
    332361
    333362                        foreach ( get_post_meta( $post_id, $parsed['key'], false ) as $value ) {
    334                                 $ids = array_merge( $ids, Hint_Path::collect_at( $value, $parsed['segments'] ) );
     363                                $ids = array_merge( $ids, Reference_Hint::TYPE_ATTACHMENT_URL === $hint->type() ? $this->url_ids( $value, $parsed['segments'] ) : Hint_Path::collect_at( $value, $parsed['segments'] ) );
    335364                        }
    336365                }
  • transtrix/trunk/includes/Modules/Woo/Woo_Orders.php

    r3790546 r3802902  
    88namespace Transtrix\Modules\Woo;
    99
     10use Transtrix\Import\Account_Importer;
    1011use Transtrix\Import\Restore_Point;
    1112use Transtrix\Reference\Id_Map;
    … …  
    306307         *
    307308         * @since 0.1.0
     309         * @since 0.3.0 The account fields come from Account_Importer.
    308310         *
    309311         * @param int $user_id User id.
    … …  
    311313         */
    312314        private static function customer_record( int $user_id ): ?array {
    313                 $user = get_userdata( $user_id );
    314 
    315                 if ( ! $user instanceof \WP_User ) {
     315                $record = Account_Importer::export( $user_id );
     316
     317                if ( null === $record ) {
    316318                        return null;
    317319                }
    … …  
    319321                $customer = new \WC_Customer( $user_id );
    320322
    321                 return array(
    322                         'id'              => $user_id,
    323                         'user_login'      => $user->user_login,
    324                         'user_email'      => $user->user_email,
    325                         'display_name'    => $user->display_name,
    326                         'first_name'      => $user->first_name,
    327                         'last_name'       => $user->last_name,
    328                         'user_registered' => $user->user_registered,
    329                         'billing'         => $customer->get_billing( 'edit' ),
    330                         'shipping'        => $customer->get_shipping( 'edit' ),
    331                         'is_paying'       => $customer->get_is_paying_customer( 'edit' ),
     323                return array_merge(
     324                        $record,
     325                        array(
     326                                'billing'   => $customer->get_billing( 'edit' ),
     327                                'shipping'  => $customer->get_shipping( 'edit' ),
     328                                'is_paying' => $customer->get_is_paying_customer( 'edit' ),
     329                        )
    332330                );
    333331        }
    … …  
    396394         *
    397395         * @since 0.1.0
     396         * @since 0.3.0 Matches and creates the account through Account_Importer.
    398397         *
    399398         * @param array<string,mixed> $record  Customer record.
    … …  
    404403         */
    405404        private static function import_customer( array $record, Id_Map $map, Restore_Point $restore, Report $report ): void {
    406                 $old_id = (int) ( $record['id'] ?? 0 );
    407                 $email  = sanitize_email( (string) ( $record['user_email'] ?? '' ) );
    408 
    409                 if ( '' === $email ) {
     405                $account = Account_Importer::resolve( $record, 'customer', self::KIND_CUSTOMER, $map, $restore, $report );
     406
     407                if ( Account_Importer::MATCHED === $account['status'] ) {
     408                        $report->add_skipped();
     409                }
     410
     411                if ( Account_Importer::CREATED !== $account['status'] ) {
    410412                        return;
    411413                }
    412414
    413                 $existing = get_user_by( 'email', $email );
    414 
    415                 if ( $existing instanceof \WP_User ) {
    416                         // A staff account with the same email is not this shopper: tying
    417                         // imported orders to an administrator would show them as theirs.
    418                         // Their orders arrive as guest orders, with the email kept.
    419                         if ( array() !== array_diff( (array) $existing->roles, array( 'customer', 'subscriber' ) ) ) {
    420                                 $report->add_warning(
    421                                         $email,
    422                                         __( 'This email belongs to a staff account here, so its orders were imported as guest orders.', 'transtrix' )
    423                                 );
    424                                 return;
    425                         }
    426 
    427                         $map->add( 'user', $old_id, (int) $existing->ID );
    428                         $report->add_skipped();
    429                         return;
    430                 }
    431 
    432                 $login = sanitize_user( (string) ( $record['user_login'] ?? '' ), true );
    433                 $login = '' === $login || username_exists( $login ) ? sanitize_user( strstr( $email, '@', true ) . '-' . wp_generate_password( 4, false, false ), true ) : $login;
    434 
    435                 // wp_insert_user() sends no notification. The password is random: hashes
    436                 // do not travel, so the customer sets a new one with "Lost password".
    437                 $user_id = wp_insert_user(
    438                         array(
    439                                 'user_login'      => $login,
    440                                 'user_email'      => $email,
    441                                 'user_pass'       => wp_generate_password( 24 ),
    442                                 'display_name'    => (string) ( $record['display_name'] ?? '' ),
    443                                 'first_name'      => (string) ( $record['first_name'] ?? '' ),
    444                                 'last_name'       => (string) ( $record['last_name'] ?? '' ),
    445                                 'user_registered' => (string) ( $record['user_registered'] ?? '' ),
    446                                 'role'            => 'customer',
    447                         )
    448                 );
    449 
    450                 if ( is_wp_error( $user_id ) ) {
    451                         $report->add_failure( $email, $user_id->get_error_message() );
    452                         return;
    453                 }
    454 
    455                 $restore->record_created_object( self::KIND_CUSTOMER, (int) $user_id );
    456                 $map->add( 'user', $old_id, (int) $user_id );
    457 
    458                 $customer = new \WC_Customer( (int) $user_id );
     415                $customer = new \WC_Customer( $account['user_id'] );
    459416
    460417                foreach ( array( 'billing', 'shipping' ) as $type ) {
    … …  
    758715         *
    759716         * @since 0.1.0
     717         * @since 0.3.0 Accounts are undone through Account_Importer.
    760718         *
    761719         * @param string $kind KIND_ORDER or KIND_CUSTOMER.
    … …  
    777735
    778736                if ( self::KIND_CUSTOMER === $kind ) {
    779                         require_once ABSPATH . 'wp-admin/includes/user.php';
    780 
    781                         foreach ( $ids as $id ) {
    782                                 // Kept once the account has a life of its own here: a new role,
    783                                 // content they wrote, or an order placed since the import.
    784                                 // Deleting it would take that with it.
    785                                 if ( self::customer_in_use( (int) $id ) ) {
    786                                         continue;
    787                                 }
    788 
    789                                 if ( wp_delete_user( $id ) ) {
    790                                         ++$deleted;
    791                                 }
    792                         }
     737                        $deleted += Account_Importer::rollback( $ids, 'customer', array( __CLASS__, 'has_orders' ) );
    793738                }
    794739
    … …  
    797742
    798743        /**
    799          * Whether an imported customer account is now used for more than the import.
    800          *
    801          * @since 0.1.0
     744         * Whether an account has an order in any status.
     745         *
     746         * @since 0.3.0
    802747         *
    803748         * @param int $user_id User id.
    804749         * @return bool
    805750         */
    806         private static function customer_in_use( int $user_id ): bool {
    807                 $user = get_userdata( $user_id );
    808 
    809                 if ( ! $user instanceof \WP_User ) {
    810                         return true;
    811                 }
    812 
    813                 if ( array( 'customer' ) !== array_values( (array) $user->roles ) || count_user_posts( $user_id, get_post_types(), false ) > 0 ) {
    814                         return true;
    815                 }
    816 
     751        public static function has_orders( int $user_id ): bool {
    817752                return function_exists( 'wc_get_orders' ) && array() !== (array) wc_get_orders(
    818753                        array(
  • transtrix/trunk/includes/Package/Package_Writer.php

    r3790546 r3802902  
    8989
    9090        /**
     91         * Meta key prefixes that must not be written: entries given as 'prefix*'.
     92         *
     93         * @var string[]
     94         */
     95        private $excluded_meta_prefixes = array();
     96
     97        /**
    9198         * Constructor.
    9299         *
    93100         * @since 0.1.0
     101         * @since 0.3.0 A trailing `*` on an excluded key excludes every key with that prefix.
    94102         *
    95103         * @param Job      $job                 The export job.
    … …  
    97105         */
    98106        public function __construct( Job $job, array $excluded_meta_keys = array() ) {
    99                 $this->job                = $job;
    100                 $this->report             = new Report();
    101                 $this->media              = new Media_Collector();
    102                 $this->excluded_meta_keys = array_flip( array_map( 'strval', $excluded_meta_keys ) );
     107                $this->job    = $job;
     108                $this->report = new Report();
     109                $this->media  = new Media_Collector();
     110
     111                foreach ( array_map( 'strval', $excluded_meta_keys ) as $key ) {
     112                        if ( '*' === substr( $key, -1 ) ) {
     113                                $this->excluded_meta_prefixes[] = substr( $key, 0, -1 );
     114                        } else {
     115                                $this->excluded_meta_keys[ $key ] = 1;
     116                        }
     117                }
    103118        }
    104119
    … …  
    178193                        // A6: a denied key never reaches the package, so it cannot leak by
    179194                        // being written, inspected in the zip, or shipped to a third party.
    180                         if ( isset( $this->excluded_meta_keys[ $key ] ) ) {
     195                        if ( $this->is_excluded( (string) $key ) ) {
    181196                                continue;
    182197                        }
    … …  
    360375                return ( $parent instanceof \WP_Term ) ? $parent->slug : '';
    361376        }
     377
     378        /**
     379         * Whether the module forbids a meta key, by name or by prefix.
     380         *
     381         * @since 0.3.0
     382         *
     383         * @param string $key Meta key.
     384         * @return bool
     385         */
     386        private function is_excluded( string $key ): bool {
     387                if ( isset( $this->excluded_meta_keys[ $key ] ) ) {
     388                        return true;
     389                }
     390
     391                foreach ( $this->excluded_meta_prefixes as $prefix ) {
     392                        if ( 0 === strpos( $key, $prefix ) ) {
     393                                return true;
     394                        }
     395                }
     396
     397                return false;
     398        }
    362399}
  • transtrix/trunk/includes/Reference/Hint_Path.php

    r3790546 r3802902  
    145145         *
    146146         * @since 0.1.0
     147         * @since 0.3.0 Takes $suffixed.
    147148         *
    148149         * @param mixed    $value    The whole meta value.
    149150         * @param string[] $segments Path segments; empty means the whole value.
     151         * @param bool     $suffixed Also read `<id>_<number>` strings.
    150152         * @return int[]
    151153         */
    152         public static function collect_at( $value, array $segments ): array {
     154        public static function collect_at( $value, array $segments, bool $suffixed = false ): array {
    153155                $found = array();
    154156
    … …  
    156158                        $value,
    157159                        $segments,
    158                         static function ( $leaf ) use ( &$found ) {
    159                                 $found = array_merge( $found, self::ids_in( $leaf ) );
     160                        static function ( $leaf ) use ( &$found, $suffixed ) {
     161                                $found = array_merge( $found, self::ids_in( $leaf, $suffixed ) );
    160162
    161163                                return $leaf;
    … …  
    170172         *
    171173         * @since 0.1.0
    172          *
    173          * @param mixed $value Any value.
     174         * @since 0.3.0 Takes $suffixed.
     175         *
     176         * @param mixed $value    Any value.
     177         * @param bool  $suffixed Also read `<id>_<number>` strings, as the id.
    174178         * @return int[]
    175179         */
    176         public static function ids_in( $value ): array {
     180        public static function ids_in( $value, bool $suffixed = false ): array {
    177181                if ( is_string( $value ) && is_serialized( $value ) ) {
    178182                        $value = unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes is false; serialized values are rewritten in place.
    … …  
    183187
    184188                        foreach ( $value as $item ) {
    185                                 $ids = array_merge( $ids, self::ids_in( $item ) );
     189                                $ids = array_merge( $ids, self::ids_in( $item, $suffixed ) );
    186190                        }
    187191
    … …  
    205209                }
    206210
     211                if ( $suffixed && 1 === preg_match( '/^(\d+)_\d+$/', $value, $parts ) ) {
     212                        return array( (int) $parts[1] );
     213                }
     214
    207215                return array();
    208216        }
  • transtrix/trunk/includes/Reference/Reference_Hint.php

    r3790546 r3802902  
    2626
    2727        /**
     28         * A URL of an attachment's file, rewritten to the imported attachment's URL.
     29         */
     30        const TYPE_ATTACHMENT_URL = 'attachment_url';
     31
     32        /**
    2833         * Reference type, one of the TYPE_* constants.
    2934         *
    … …  
    5257
    5358        /**
     59         * Whether an id may carry a `_<number>` suffix that is kept as written.
     60         *
     61         * Easy Digital Downloads names "download 123, price option 2" as `123_2`.
     62         * The leading number is the id; the suffix is local to that download.
     63         *
     64         * @var bool
     65         */
     66        private $suffixed = false;
     67
     68        /**
    5469         * Constructor.
    5570         *
    5671         * @since 0.1.0
     72         * @since 0.3.0 Takes $suffixed.
    5773         *
    5874         * @param string $type     Reference type.
    5975         * @param bool   $is_list  Whether the value holds a list of ids.
    6076         * @param string $taxonomy Taxonomy a term hint is confined to, or ''.
    61          */
    62         public function __construct( string $type, bool $is_list = false, string $taxonomy = '' ) {
     77         * @param bool   $suffixed Whether an id may carry a kept `_<number>` suffix.
     78         */
     79        public function __construct( string $type, bool $is_list = false, string $taxonomy = '', bool $suffixed = false ) {
    6380                $this->type     = $type;
    6481                $this->is_list  = $is_list;
    6582                $this->taxonomy = $taxonomy;
     83                $this->suffixed = $suffixed;
    6684        }
    6785
    … …  
    7088         *
    7189         * @since 0.1.0
     90         * @since 0.3.0 A trailing `^` on the type ('post^') allows `<id>_<number>`.
    7291         *
    7392         * @param string $notation For example 'post[]' or 'attachment'.
    … …  
    7796                $is_list  = ( '[]' === substr( $notation, -2 ) );
    7897                $type     = $is_list ? substr( $notation, 0, -2 ) : $notation;
     98                $suffixed = ( '^' === substr( $type, -1 ) );
     99                $type     = $suffixed ? substr( $type, 0, -1 ) : $type;
    79100                $taxonomy = '';
    80101
    … …  
    89110                        self::TYPE_TERM,
    90111                        self::TYPE_USER,
     112                        self::TYPE_ATTACHMENT_URL,
    91113                );
    92114
    … …  
    99121                }
    100122
    101                 return new self( $type, $is_list, $taxonomy );
     123                return new self( $type, $is_list, $taxonomy, $suffixed );
    102124        }
    103125
    … …  
    125147
    126148        /**
     149         * Whether an id may carry a kept `_<number>` suffix.
     150         *
     151         * @since 0.3.0
     152         *
     153         * @return bool
     154         */
     155        public function is_suffixed(): bool {
     156                return $this->suffixed;
     157        }
     158
     159        /**
    127160         * The object type used as the Id_Map bucket.
    128161         *
    … …  
    131164         *
    132165         * @since 0.1.0
     166         * @since 0.3.0 Attachment URL hints use the post bucket too.
    133167         *
    134168         * @return string
    135169         */
    136170        public function map_bucket(): string {
    137                 if ( self::TYPE_ATTACHMENT === $this->type ) {
     171                if ( self::TYPE_ATTACHMENT === $this->type || self::TYPE_ATTACHMENT_URL === $this->type ) {
    138172                        return self::TYPE_POST;
    139173                }
  • transtrix/trunk/includes/Reference/Reference_Walker.php

    r3790546 r3802902  
    3030
    3131        /**
     32         * Source attachment URL => source attachment id, for attachment_url hints.
     33         *
     34         * @var array<string,int>
     35         */
     36        private $urls;
     37
     38        /**
     39         * Constructor.
     40         *
     41         * @since 0.3.0
     42         *
     43         * @param array<string,int> $urls Source attachment URL => source attachment id.
     44         */
     45        public function __construct( array $urls = array() ) {
     46                $this->urls = $urls;
     47        }
     48
     49        /**
    3250         * Rewrite every id inside a value.
    3351         *
    … …  
    5472         *
    5573         * @since 0.1.0
     74         * @since 0.3.0 Hands attachment URL hints to remap_url().
    5675         *
    5776         * @param mixed          $value Current node.
    … …  
    6887                }
    6988
     89                if ( Reference_Hint::TYPE_ATTACHMENT_URL === $hint->type() ) {
     90                        return is_string( $value ) ? $this->remap_url( $value, $hint, $map ) : $value;
     91                }
     92
    7093                if ( is_int( $value ) ) {
    7194                        return $this->remap( $value, $hint, $map );
    … …  
    84107         *
    85108         * @since 0.1.0
     109         * @since 0.3.0 Reads `<id>_<number>` under a suffixed hint.
    86110         *
    87111         * @param string         $value Current node.
    … …  
    121145                if ( '' !== $value && ctype_digit( $value ) ) {
    122146                        return (string) $this->remap( (int) $value, $hint, $map );
     147                }
     148
     149                if ( $hint->is_suffixed() && 1 === preg_match( '/^(\d+)(_\d+)$/', $value, $parts ) ) {
     150                        return $this->remap( (int) $parts[1], $hint, $map ) . $parts[2];
    123151                }
    124152
    … …  
    140168
    141169        /**
     170         * Point a source attachment URL at the imported attachment's URL.
     171         *
     172         * A URL the package carries no attachment for (an external file, a path
     173         * typed by hand) is not a reference and is left alone.
     174         *
     175         * @since 0.3.0
     176         *
     177         * @param string         $url  Source-site URL.
     178         * @param Reference_Hint $hint Reference hint.
     179         * @param Id_Map         $map  The id map.
     180         * @return string
     181         */
     182        private function remap_url( string $url, Reference_Hint $hint, Id_Map $map ): string {
     183                if ( ! isset( $this->urls[ $url ] ) ) {
     184                        return $url;
     185                }
     186
     187                $old = (int) $this->urls[ $url ];
     188                $new = $this->remap( $old, $hint, $map );
     189
     190                if ( $new === $old && ! $map->has( $hint->map_bucket(), $old ) ) {
     191                        return $url;
     192                }
     193
     194                $new_url = wp_get_attachment_url( $new );
     195
     196                return is_string( $new_url ) && '' !== $new_url ? $new_url : $url;
     197        }
     198
     199        /**
    142200         * Translate one id, recording it when the map has no entry.
    143201         *
  • transtrix/trunk/languages/transtrix.pot

    r3790546 r3802902  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Transtrix – Site Migration, Import & Export 0.2.0\n"
     5"Project-Id-Version: Transtrix – Site Migration, Import & Export 0.3.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/transtrix\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-11-08T06:19:00+00:00\n"
     12"POT-Creation-Date: 2026-11-15T08:23:26+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    272272#: dist/admin.js:979
    273273msgid "Installed plugins, so the new site has the same features."
     274msgstr ""
     275
     276#: includes/Import/Account_Importer.php:73
     277msgid "This email belongs to a staff account here, so its orders were imported as guest orders."
    274278msgstr ""
    275279
    … …  
    282286
    283287#. translators: %s: comma-separated content type names.
    284 #: includes/Import/Three_Pass_Importer.php:438
     288#: includes/Import/Three_Pass_Importer.php:466
    285289#, php-format
    286290msgid "Not imported, because this site has no place to store them: %s."
    287291msgstr ""
    288292
    289 #: includes/Import/Three_Pass_Importer.php:447
     293#: includes/Import/Three_Pass_Importer.php:475
    290294msgid "Orders, customers and store settings in this package were not imported. Only an administrator can import them."
    291295msgstr ""
    292296
    293 #: includes/Import/Three_Pass_Importer.php:452
     297#: includes/Import/Three_Pass_Importer.php:480
    294298#: dist/admin.js:979
    295299#: dist/admin.js:1135
    … …  
    297301msgstr ""
    298302
    299 #: includes/Import/Three_Pass_Importer.php:452
     303#: includes/Import/Three_Pass_Importer.php:480
    300304msgid "Images and files were left out: your account cannot upload files."
    301305msgstr ""
    302306
    303307#. translators: %s: plugin or module name, e.g. WooCommerce.
    304 #: includes/Import/Three_Pass_Importer.php:591
     308#: includes/Import/Three_Pass_Importer.php:623
    305309#, php-format
    306310msgid "This package needs %s, which is not active on this site. Install and activate it, then import again. Nothing has been changed."
    … …  
    308312
    309313#. translators: %s: comma-separated content type names.
    310 #: includes/Import/Three_Pass_Importer.php:628
     314#: includes/Import/Three_Pass_Importer.php:660
    311315#, php-format
    312316msgid "This package contains content types it is not allowed to import: %s. Nothing has been changed."
    … …  
    314318
    315319#. translators: %s: comma-separated content type names.
    316 #: includes/Import/Three_Pass_Importer.php:636
     320#: includes/Import/Three_Pass_Importer.php:668
    317321#, php-format
    318322msgid "This site has no place to store these content types: %s. Activate the plugin that adds them, then import again. Nothing has been changed."
    319323msgstr ""
    320324
    321 #: includes/Import/Three_Pass_Importer.php:731
     325#: includes/Import/Three_Pass_Importer.php:764
    322326msgid "You are not allowed to create or change this item."
    323327msgstr ""
    324328
    325 #: includes/Import/Three_Pass_Importer.php:819
     329#: includes/Import/Three_Pass_Importer.php:853
    326330msgid "This hidden field was left out: only an administrator can import it."
    327331msgstr ""
    328332
    329333#. translators: %s: comma-separated list of ids.
    330 #: includes/Import/Three_Pass_Importer.php:857
     334#: includes/Import/Three_Pass_Importer.php:891
    331335#, php-format
    332336msgid "These linked items could not be found: %s"
    333337msgstr ""
    334338
    335 #: includes/Import/Three_Pass_Importer.php:1030
     339#: includes/Import/Three_Pass_Importer.php:1064
    336340msgid "This site has no place for these categories, so they were not added. Activate the plugin that adds them, then import again."
    337341msgstr ""
    338342
    339 #: includes/Media/Media_Collector.php:50
     343#: includes/Media/Media_Collector.php:51
    340344msgid "This item is not a media file."
    341345msgstr ""
    342346
    343 #: includes/Media/Media_Collector.php:56
     347#: includes/Media/Media_Collector.php:57
    344348msgid "The media file is missing from the server."
    345349msgstr ""
    346350
    347 #: includes/Media/Media_Collector.php:64
     351#: includes/Media/Media_Collector.php:65
    348352msgid "The media file could not be read."
    349353msgstr ""
    350354
    351 #: includes/Media/Media_Importer.php:66
     355#: includes/Media/Media_Importer.php:77
    352356msgid "(unnamed media)"
    353357msgstr ""
    354358
    355 #: includes/Media/Media_Importer.php:66
     359#: includes/Media/Media_Importer.php:77
    356360msgid "This media item has no ID in the package."
    357361msgstr ""
    358362
    359 #: includes/Media/Media_Importer.php:134
     363#: includes/Media/Media_Importer.php:184
    360364msgid "The package does not contain this media file."
    361365msgstr ""
    362366
    363 #: includes/Media/Media_Importer.php:141
     367#: includes/Media/Media_Importer.php:191
    364368msgid "This media file is not a type this site accepts."
    365369msgstr ""
    366370
    367 #: includes/Media/Media_Importer.php:163
     371#: includes/Media/Media_Importer.php:230
    368372msgid "The media file could not be saved."
    369373msgstr ""
    … …  
    380384msgid "Posts, pages and media, with their categories and tags."
    381385msgstr ""
     386
     387#: includes/Modules/EDD/EDD_Customers.php:209
     388msgid "Easy Digital Downloads could not save this customer."
     389msgstr ""
     390
     391#. translators: %s: discount code.
     392#: includes/Modules/EDD/EDD_Discounts.php:190
     393#, php-format
     394msgid "Discount code %s"
     395msgstr ""
     396
     397#: includes/Modules/EDD/EDD_Discounts.php:214
     398msgid "Easy Digital Downloads could not save this discount code."
     399msgstr ""
     400
     401#. translators: %s: comma-separated ids.
     402#: includes/Modules/EDD/EDD_Discounts.php:235
     403#, php-format
     404msgid "Some downloads or categories this code is limited to were not in the package, so those limits were removed: %s"
     405msgstr ""
     406
     407#: includes/Modules/EDD/EDD_Module.php:73
     408#: includes/Modules/EDD/EDD_Module.php:102
     409msgid "Easy Digital Downloads"
     410msgstr ""
     411
     412#. translators: %s: Easy Digital Downloads version.
     413#: includes/Modules/EDD/EDD_Module.php:101
     414#, php-format
     415msgid "Easy Digital Downloads v%s"
     416msgstr ""
     417
     418#: includes/Modules/EDD/EDD_Module.php:113
     419msgid "Downloads with their prices, files and bundles, plus categories, tags and discount codes. Orders, refunds and customers can come too; importing them never charges or emails anyone. Files for sale stay protected on the new site."
     420msgstr ""
     421
     422#: includes/Modules/EDD/EDD_Module.php:204
     423msgid "Discount codes"
     424msgstr ""
     425
     426#: includes/Modules/EDD/EDD_Module.php:207
     427#: includes/Modules/Woo/Woo_Module.php:327
     428msgid "Orders"
     429msgstr ""
     430
     431#: includes/Modules/EDD/EDD_Module.php:208
     432#: includes/Modules/Woo/Woo_Module.php:328
     433msgid "Customers"
     434msgstr ""
     435
     436#: includes/Modules/EDD/EDD_Module.php:347
     437msgid "Easy Digital Downloads has a database upgrade to finish on this site. Open Downloads in the dashboard, finish the upgrade it asks for, then import again. Nothing has been changed."
     438msgstr ""
     439
     440#. translators: %s: comma-separated names of Easy Digital Downloads data tables.
     441#: includes/Modules/EDD/EDD_Module.php:367
     442#, php-format
     443msgid "Easy Digital Downloads is active, but its setup is incomplete: some of its data tables are missing (%s). Deactivate and reactivate Easy Digital Downloads, then import again. Nothing has been changed."
     444msgstr ""
     445
     446#. translators: %d: order number on the source site.
     447#: includes/Modules/EDD/EDD_Orders.php:277
     448#: includes/Modules/Woo/Woo_Orders.php:444
     449#, php-format
     450msgid "Order #%d"
     451msgstr ""
     452
     453#: includes/Modules/EDD/EDD_Orders.php:306
     454msgid "Easy Digital Downloads could not save this order."
     455msgstr ""
     456
     457#: includes/Modules/EDD/EDD_Orders.php:310
     458msgid "A refund of this order could not be saved."
     459msgstr ""
     460
     461#. translators: %d: number of order lines.
     462#: includes/Modules/EDD/EDD_Orders.php:373
     463#, php-format
     464msgid "%d line refers to a download that was not in this package, so it keeps its name and price but links to no download."
     465msgid_plural "%d lines refer to downloads that were not in this package, so they keep their names and prices but link to no download."
     466msgstr[0] ""
     467msgstr[1] ""
    382468
    383469#: includes/Modules/Woo/Woo_Module.php:78
    … …  
    402488msgstr ""
    403489
    404 #: includes/Modules/Woo/Woo_Module.php:327
    405 msgid "Orders"
    406 msgstr ""
    407 
    408 #: includes/Modules/Woo/Woo_Module.php:328
    409 msgid "Customers"
    410 msgstr ""
    411 
    412 #: includes/Modules/Woo/Woo_Orders.php:422
    413 msgid "This email belongs to a staff account here, so its orders were imported as guest orders."
    414 msgstr ""
    415 
    416 #. translators: %d: order number on the source site.
    417 #: includes/Modules/Woo/Woo_Orders.php:487
    418 #, php-format
    419 msgid "Order #%d"
    420 msgstr ""
    421 
    422490#. translators: %d: number of order lines.
    423 #: includes/Modules/Woo/Woo_Orders.php:549
     491#: includes/Modules/Woo/Woo_Orders.php:506
    424492#, php-format
    425493msgid "%d line refers to a product that was not in this package, so it keeps its name and price but links to no product."
    … …  
    502570msgstr ""
    503571
    504 #: includes/Package/Package_Writer.php:167
     572#: includes/Package/Package_Writer.php:182
    505573msgid "This item no longer exists on this site."
    506574msgstr ""
    507575
    508 #: includes/Package/Package_Writer.php:277
    509 #: includes/Package/Package_Writer.php:281
    510 #: includes/Package/Package_Writer.php:288
    511 #: includes/Package/Package_Writer.php:313
    512 #: includes/Package/Package_Writer.php:338
     576#: includes/Package/Package_Writer.php:292
     577#: includes/Package/Package_Writer.php:296
     578#: includes/Package/Package_Writer.php:303
     579#: includes/Package/Package_Writer.php:328
     580#: includes/Package/Package_Writer.php:353
    513581msgid "The package could not be written. Check that the uploads folder has free space."
    514582msgstr ""
    515583
    516 #: includes/Package/Package_Writer.php:320
     584#: includes/Package/Package_Writer.php:335
    517585msgid "The package could not be created. Check that the uploads folder is writable."
    518586msgstr ""
  • transtrix/trunk/package.json

    r3790546 r3802902  
    11{
    22  "name": "transtrix",
    3   "version": "0.2.0",
     3  "version": "0.3.0",
    44  "description": "Admin app for Transtrix.",
    55  "license": "GPL-2.0-or-later",
  • transtrix/trunk/readme.txt

    r3790546 r3802902  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.2.0
     7Stable tag: 0.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    6363* **WP Travel Engine:** trips with their packages, coupons, extra services and accommodations, plus the destinations, activities, trip types, difficulty levels and trip tags they use. Bookings, customers, payments and enquiries are ticked too, so untick them if you want only the trips. When you bring bookings, Transtrix recounts the seats sold for each trip on the new site.
    6464* **WooCommerce:** products with their variations, images and attributes, plus coupons, categories, tags and shipping classes. Orders, refunds and customers can come too, or you can untick them. An imported order arrives as a record of a past sale: Transtrix charges no card, sends no email, leaves stock alone and keeps the original totals. It works with High-Performance Order Storage on or off.
     65* **Easy Digital Downloads:** downloads with their prices, files and images, plus bundles, discount codes, categories and tags. Paid files stay in the protected Easy Digital Downloads folder on the new site. Orders, refunds and customers can come too, or you can untick them. An imported order arrives as a record of a past sale: Transtrix charges no card, sends no receipt, leaves discount code usage alone and keeps the original totals. Transtrix then recounts sales and earnings for each download and customer on the new site. Needs Easy Digital Downloads 3.0 or newer.
    6566
    6667Transtrix shows a content type only when the plugin behind it is active.
    … …  
    148149= Can I leave bookings, orders and customer data out? =
    149150
    150 Yes. Every content type starts ticked, so untick Bookings, Customers, Payments and Enquiries in WP Travel Engine, or Orders and Customers in WooCommerce, before you build the package.
     151Yes. Every content type starts ticked, so untick Bookings, Customers, Payments and Enquiries in WP Travel Engine, or Orders and Customers in WooCommerce or Easy Digital Downloads, before you build the package.
    151152
    152153= Can I undo a selected-content import? =
    … …  
    154155Yes. Open **History**, find the import and choose **Undo this import**. Transtrix removes what it added and puts back what it changed. Restore points last 30 days by default.
    155156
    156 = Do imported WooCommerce orders charge customers or send emails? =
    157 
    158 No. Transtrix calls no payment gateway, blocks outgoing mail during the import, and does not reduce stock again.
     157= Do imported WooCommerce or Easy Digital Downloads orders charge customers or send emails? =
     158
     159No. Transtrix calls no payment gateway, blocks outgoing mail during the import, and does not reduce stock or use up discount codes again.
    159160
    160161= My export is large. Will it time out? =
    … …  
    182183
    183184== Changelog ==
     185
     186= 0.3.0 - 15th November 2026 =
     187* New Feature: Added migration for Easy Digital Downloads downloads, bundles and discount codes, with their files, images, categories and tags.
     188* New Feature: Added the option to include Easy Digital Downloads orders, refunds and customers. Imported orders charge no card, send no receipt and keep the original totals.
    184189
    185190= 0.2.0 - 8th November 2026 =
    … …  
    201206== Upgrade Notice ==
    202207
     208= 0.3.0 =
     209Adds Easy Digital Downloads: move downloads, files and discount codes, and optionally orders, refunds and customers. Update both the source and destination sites.
     210
    203211= 0.2.0 =
    204212Faster imports that bring image sizes along, no 500-item export limit, and fixes for large uploads and imports that timed out. Update both the source and destination sites.
  • transtrix/trunk/transtrix.php

    r3790546 r3802902  
    44 * Plugin URI:        https://transtrix.com/
    55 * Description:       Migrate a whole WordPress site, or move only the pages, posts, trips and products you pick, with their images, categories and links. Works with WP Travel Engine and WooCommerce.
    6  * Version:           0.2.0
     6 * Version:           0.3.0
    77 * Requires at least: 6.6
    88 * Requires PHP:      7.4
    … …  
    1818defined( 'ABSPATH' ) || exit;
    1919
    20 define( 'TRANSTRIX_VERSION', '0.2.0' );
     20define( 'TRANSTRIX_VERSION', '0.3.0' );
    2121define( 'TRANSTRIX_FILE', __FILE__ );
    2222define( 'TRANSTRIX_PATH', plugin_dir_path( __FILE__ ) );
  • transtrix/trunk/vendor/composer/autoload_classmap.php

    r3790546 r3802902  
    2424    'Transtrix\\Fullsite\\Serialized_Replacer' => $baseDir . '/includes/Fullsite/Serialized_Replacer.php',
    2525    'Transtrix\\Fullsite\\Tasks' => $baseDir . '/includes/Fullsite/Tasks.php',
     26    'Transtrix\\Import\\Account_Importer' => $baseDir . '/includes/Import/Account_Importer.php',
    2627    'Transtrix\\Import\\Conflict_Resolver' => $baseDir . '/includes/Import/Conflict_Resolver.php',
    2728    'Transtrix\\Import\\Import_Task' => $baseDir . '/includes/Import/Import_Task.php',
    … …  
    3334    'Transtrix\\Modules\\Core\\Core_Importer' => $baseDir . '/includes/Modules/Core/Core_Importer.php',
    3435    'Transtrix\\Modules\\Core\\Core_Module' => $baseDir . '/includes/Modules/Core/Core_Module.php',
     36    'Transtrix\\Modules\\EDD\\EDD_Customers' => $baseDir . '/includes/Modules/EDD/EDD_Customers.php',
     37    'Transtrix\\Modules\\EDD\\EDD_Discounts' => $baseDir . '/includes/Modules/EDD/EDD_Discounts.php',
     38    'Transtrix\\Modules\\EDD\\EDD_Hints' => $baseDir . '/includes/Modules/EDD/EDD_Hints.php',
     39    'Transtrix\\Modules\\EDD\\EDD_Module' => $baseDir . '/includes/Modules/EDD/EDD_Module.php',
     40    'Transtrix\\Modules\\EDD\\EDD_Orders' => $baseDir . '/includes/Modules/EDD/EDD_Orders.php',
     41    'Transtrix\\Modules\\EDD\\EDD_Rows' => $baseDir . '/includes/Modules/EDD/EDD_Rows.php',
    3542    'Transtrix\\Modules\\WPTravelEngine\\WPTravelEngine_Hints' => $baseDir . '/includes/Modules/WPTravelEngine/WPTravelEngine_Hints.php',
    3643    'Transtrix\\Modules\\WPTravelEngine\\WPTravelEngine_Module' => $baseDir . '/includes/Modules/WPTravelEngine/WPTravelEngine_Module.php',
  • transtrix/trunk/vendor/composer/autoload_static.php

    r3790546 r3802902  
    3939        'Transtrix\\Fullsite\\Serialized_Replacer' => __DIR__ . '/../..' . '/includes/Fullsite/Serialized_Replacer.php',
    4040        'Transtrix\\Fullsite\\Tasks' => __DIR__ . '/../..' . '/includes/Fullsite/Tasks.php',
     41        'Transtrix\\Import\\Account_Importer' => __DIR__ . '/../..' . '/includes/Import/Account_Importer.php',
    4142        'Transtrix\\Import\\Conflict_Resolver' => __DIR__ . '/../..' . '/includes/Import/Conflict_Resolver.php',
    4243        'Transtrix\\Import\\Import_Task' => __DIR__ . '/../..' . '/includes/Import/Import_Task.php',
    … …  
    4849        'Transtrix\\Modules\\Core\\Core_Importer' => __DIR__ . '/../..' . '/includes/Modules/Core/Core_Importer.php',
    4950        'Transtrix\\Modules\\Core\\Core_Module' => __DIR__ . '/../..' . '/includes/Modules/Core/Core_Module.php',
     51        'Transtrix\\Modules\\EDD\\EDD_Customers' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Customers.php',
     52        'Transtrix\\Modules\\EDD\\EDD_Discounts' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Discounts.php',
     53        'Transtrix\\Modules\\EDD\\EDD_Hints' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Hints.php',
     54        'Transtrix\\Modules\\EDD\\EDD_Module' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Module.php',
     55        'Transtrix\\Modules\\EDD\\EDD_Orders' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Orders.php',
     56        'Transtrix\\Modules\\EDD\\EDD_Rows' => __DIR__ . '/../..' . '/includes/Modules/EDD/EDD_Rows.php',
    5057        'Transtrix\\Modules\\WPTravelEngine\\WPTravelEngine_Hints' => __DIR__ . '/../..' . '/includes/Modules/WPTravelEngine/WPTravelEngine_Hints.php',
    5158        'Transtrix\\Modules\\WPTravelEngine\\WPTravelEngine_Module' => __DIR__ . '/../..' . '/includes/Modules/WPTravelEngine/WPTravelEngine_Module.php',
  • transtrix/trunk/vendor/composer/installed.php

    r3790546 r3802902  
    22    'root' => array(
    33        'name' => 'aayushk777/transtrix',
    4         'pretty_version' => 'v0.2.0',
    5         'version' => '0.2.0.0',
    6         'reference' => '0c34b30edd9bcdfb09c998cb88ce418e687bb8de',
     4        'pretty_version' => 'v0.3.0',
     5        'version' => '0.3.0.0',
     6        'reference' => '396d3f7184662acef9e40ff6bbe35d8a691ee2c2',
    77        'type' => 'wordpress-plugin',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    1212    'versions' => array(
    1313        'aayushk777/transtrix' => array(
    14             'pretty_version' => 'v0.2.0',
    15             'version' => '0.2.0.0',
    16             'reference' => '0c34b30edd9bcdfb09c998cb88ce418e687bb8de',
     14            'pretty_version' => 'v0.3.0',
     15            'version' => '0.3.0.0',
     16            'reference' => '396d3f7184662acef9e40ff6bbe35d8a691ee2c2',
    1717            'type' => 'wordpress-plugin',
    1818            'install_path' => __DIR__ . '/../../',
Note: See TracChangeset for help on using the changeset viewer.