Create Elementor migration/upgrade scripts

Every now and then, you may need to migrate something on existing Elementor widgets. One solution would be to go through every single template and change it by hand, but we’re not savages.

Note that migration in this context means that we have the widget in place but we update some of its values.

So we use code! Since I didn’t found any resource on how this could be accomplished, here is a short how to.

You can use this with either Elementor Pro or the free version (although the Pro version have some goodies built in).

In your plugin file, you add this:

// if you're using the free version:
add_action('elementor/init', 'MyUpgradeManager::instance', 99);

// if you're using the Pro version:
add_action('elementor_pro/init', 'MyUpgradeManager::instance', 99);

Next up, you need to create two classes: MyUpgradeManager and MyUpgrades. It’s recommended that you set up an autoloader for your classes, but that’s a story for another time (or you can head to Composer documentation and do your thing).

First class is the MyUpgradeManager, which only register the actions. Nothing fancy here:

<?php

!defined('ABSPATH') && die();

use Elementor\Core\Upgrade\Manager as ElementorUpgradeManager;

class MyUpgradeManager extends ElementorUpgradeManager
{
    public function get_action()
    {
        return 'elementor-my-upgrade-manager';
    }

    public function get_plugin_name()
    {
        return 'my-elementor-widgets';
    }

    public function get_plugin_label()
    {
        return esc_html__('My Elementor Widgets');
    }

    public function get_updater_label()
    {
        return esc_html__('My Elementor Widgets Data Updater');
    }

    public function get_new_version()
    {
        return '1.0.0'; 
    }

    public function get_version_option_name()
    {
        return 'elementor_my_widgets_version';
    }

    public function get_upgrades_class()
    {
        // if you're using namespaces, you need to add the full path: my\namespace\MyUpgrades
        return 'MyUpgrades'; 
    }
}

Next up, the class that actually do the changes:

<?php

use Elementor\Core\Upgrade\Upgrade_Utils;

!defined('ABSPATH') && die();

class MyUpgrades
{
    public static function do_my_changes($element, $args)
    {
        if (empty($element['widgetType']) || $args['widget_id'] !== $element['widgetType']) {
            return $element;
        }

        $element['settings']['components_to_show'] ??= [];
        $element['settings']['layout_type'] ??= $element['settings']['type'] ?? 'three_wide';

        return $element;
    }

    /**
     * Remember `get_new_version()` method from the previous class?
     * We convert that version from dots to underscore, we prefix it with `_v_` and we add the changes.
     * Note that `_v_0_0_1_changes_will_not_be_applied()` will NOT execute, as the version 0.0.1 is 
     * lower than Manager version (1.0.0)
     */
    public static function _v_0_0_1_changes_will_not_be_applied($updater)
    {
        // do nothing
    }

    public static function _v_1_0_0_my_changes($updater)
    {
        $changes = [
            [
                'callback' => ['ElementorPro\Core\Upgrade\Upgrades', '_rename_widget_settings'],
                'control_ids' => [
                    'type' => 'layout_type',
                ],
            ],

            [
                'callback' => ['MyUpgrades', 'do_my_changes'],
                'control_ids' => [],
            ],
        ];

        return Upgrade_Utils::_update_widget_settings('my-widget-name', $updater, $changes);
    }

    // this will run on each version
    public static function _on_each_version( $updater ) {}
}

What does this class do?

Inside _v_1_0_0_my_changes method, it takes a custom widget, called my-widget-name and do a couple of changes, as you can see in the $changes variable:

  1. Firstly, it will rename an existing control called type to layout_type, as it’s more suggestive on what that will do;
  2. Secondly, it will do some custom changes, as you can see inside do_my_changes method (in this case, it will set some default values)

You need to make sure that the first param of Upgrade_Utils::_update_widget_settings('my-widget-name', $updater, $changes); will match your widget name.

You can add as many methods as you want and you can get inspired by the Elementor upgrades class on what can be done.

WP CLI

In order to make it work on WP-CLI, you need to add an extra class:

class UpdateCliCommand extends UpdateBase {
    protected function get_update_db_manager_class() {
        return 'MyUpgradeManager'; // you use the same class name as above **including** namespace, if that's the case
    }
}

You also need to add the following after the initialization:

add_action('elementor/init', 'MyUpgradeManager::instance', 99);
+ if (class_exists('\WP_CLI')) {
+     \WP_CLI::add_command('my-plugin elementor update', 'UpdateCliCommand');
+ }

After you do this, you can run wp my-plugin elementor update db to… well, update the db.

Cum migrezi un WordPress multisite?

Una din problemele cu care mă confrunt frecvent este aceea de a migra o instanță de WordPress de pe un domeniu pe altul (e.g. din live în development). La site-uri simple este o operațiune rapidă, wp search-replace url.vechi url.nou, dar lucrurile se complică atunci când este un multisite și/sau are o bază de date imensă.

WP are o mulțime de string-uri serializate în baza de date, deci nu e o soluție atât de simplă să faci un search/replace în dump-ul sql. Prin urmare, vom folosi wp-cli. Pentru că sunt pe Windows, vom folosi PowerShell.

Cum ne interesează ca, în primul rând, să avem o copie funcțională pe local, nu vrem neapărat să înlocuim toate string-urile. Prin urmare, putem sări anumite tabele mai puțin importante. Treaba asta nu reprezintă o problemă foarte mare la o bază de date mică, dar la o DB măricică operațiile de search-replace pot dura ore. (am un multi-site cu aproape 20 de sub-site-uri, DB bate spre 20Gb, am încercat să înlocuiesc toate string-urile și după 30h încă nu terminase…)

Salvezi codul de mai jos într-un fișier, să-i zicem migrate.ps1, și ajustezi primele linii:

  • $wpPath este directorul în care este instanța de WP. Dacă este în directorul din care rulezi scriptul de mai jos, un . este suficient.
  • $network_sites este un array de forma "site vechi" => "site nou". Este important să avem // la început pentru a evita înlocuirile în subdomenii, de exemplu. Dacă site-urile au www în față, adaugi și aia.
  • $primarySiteURL este site-ul principal
  • $tables_to_skip sunt tabelele în care nu vrei să se facă înlocuirile. De cele mai multe ori este safe să sari peste astea (și peste altele, dar astea sunt cele mai mari).
$wpPath = '.'

$network_sites = @{
    "//live-site.url" = "//local-site.url";
    "//subdomain.live-site.url" = "//subdomain.local-site.url";
}

$primarySiteURL = $network_sites["live-site.url"]

$tables_to_skip = "$($dbPrefix)posts,$($dbPrefix)postmeta,'$($dbPrefix)*_posts','$($dbPrefix)*_postmeta','$($dbPrefix)nf3_*','$($dbPrefix)*_nf3_*'"

Să reflecte necesitățile (e.g. poate ai totuși nevoie să înlocuiești datele în postmeta`

Adițional, ajustezi comenzile specifice plugin-urilor tale: elementor, wpml, ce mai ai tu. La sfârșit rulezi scriptul din terminal: ./migrate.ps1.

$wpPath = '.'

$network_sites = @{
    "//live-site.url" = "//local-site.url";
    "//subdomain.live-site.url" = "//subdomain.local-site.url";
}

$primarySiteURL = $network_sites["live-site.url"]

$dbPrefix=$(wp config get table_prefix --path=$wpPath)
$tables_to_skip = "$($dbPrefix)posts,$($dbPrefix)postmeta,'$($dbPrefix)*_posts','$($dbPrefix)*_postmeta','$($dbPrefix)nf3_*','$($dbPrefix)*_nf3_*'"

$network_sites.GetEnumerator() | ForEach-Object {
    $old_site_url = $_.Key
    $new_site_url = $_.Value

    Invoke-Expression  "wp db query ""UPDATE $($dbPrefix)blogs SET domain = '$new_site_url' WHERE domain = '$old_site_url';""  --path=""$wpPath"""

    $old_site_url = "https://$old_site_url"
    $new_site_url = "https://$new_site_url"

    $wp_cmd = "wp search-replace $old_site_url $new_site_url --path=""$wpPath"" --skip-tables=$tables_to_skip"

    echo "Replacing strings in $old_site_url site"
    Invoke-Expression "$wp_cmd --url=$new_site_url"

    echo "Replacing strings across network"
    Invoke-Expression "$wp_cmd --network"

#   echo "Replacing Elementor URLs $old_site_url -> $new_site_url"
#    wp elementor replace_urls $old_site_url $new_site_url --path="$wpPath"

#    echo "Clear WPML cache"
#    wp wpml clear-cache --url=$new_site_url --path="$wpPath"

    echo "Delete transients & WP Cache"
#    wp rocket clean --confirm --url=$new_site_url --path="$wpPath"
    wp transient delete --all --url=$new_site_url --path="$wpPath"
    wp cache flush --url=$new_site_url --path="$wpPath"
}

# echo "Flush Elementor Cache & CSS"
# wp elementor library sync --network --allow-root  --path="$wpPath"
# wp elementor flush_css --network --allow-root  --path="$wpPath"

# echo "Clear WPML cache"
# wp wpml clear-cache --network --allow-root  --path="$wpPath"

echo "Delete transients & WP Cache"

wp transient delete --all --network --allow-root  --path="$wpPath"
wp cache flush --network --allow-root  --path="$wpPath"

wp network meta set 1 siteurl "https://{$primarySiteURL}/"

Bonus: execută o comandă pe toate sub-site-urile

Uneori este nevoie să execuți o comandă în wp-cli pe toate site-urile. Faci un fișiere wpms.ps1, îl plasezi în PATH și pui în el:

 $allArgs = $PsBoundParameters.Values + $args

wp site list --field=url | ForEach-Object {
    echo "running wp $allArgs --url=$_"
    Invoke-Expression "wp $allArgs --url=$_"
}

Actualizarea plugin-urilor WordPress și versionarea acestora

Deși sunt adeptul ideii de a nu ține în Git fișierele care nu-mi aparțin: plugin-uri, fișierele din core etc, sunt unele situații în care este nevoie și de asta.

Prin urmare, nu am acordat prea multă atenție acestui mod de lucru: când e vorba de actualizări disponibile, facem actualizare la tot, trântim un commit cu ce plugin-uri s-au actualizat și aia e.

Doar că abordarea asta nu e cea mai potrivită, după cum am aflat recent: succes în a face un git bisect

Și mi-am dat seama că am la dispoziție o nouă jucărie: PowerShell!

function Wp-Plugins-Update() {
     $plugins = $(wp plugin list --update=available --field=name)

     foreach ($plugin in $plugins) {
        wp plugin update $plugin 
        & git add -Af "wp-content/plugins/$plugin"  --quiet
        & git commit -m "update plugin: $plugin"  --quiet
     }
}

Definești o funcție în %USERPROFILE%\Documents\PowerShell\profile.ps1 și… cam atât.

Execuți Wp-Plugins-Update (din PowerShell) în directorul în care ai instalat WP și îți va actualiza frumușel fiecare plugin. Apoi, pentru fiecare plugin va face un commit.

Cu puțină îndemânare, codul poate fi rescris pentru Bash.

WordPress menus are lost on server migration!

I had this issue for a very long time: a live server with a bunch of menus was allright. Trying to migrate the DB locally (for syncing), usually ended up in a mess, because menus were gone. No matter what tools I was usings, be it mysqldump, migrate db or anything else, menus were GONE! 

Worst part? This only happened only on some sites. On the bright side, I didn’t needed to make this sync too often so wasn’t that bad to do it manually, so I didn’t worry too much. 

Because I didn’t find any patterns, I assumed that is somehow related to a path conversion (given that I’m using a Windows machine).

I was wrong. Partially. Not really. But ignorance is bliss, right?

So fast forward few years. Today. I have this client with a huge site. HUGE. There are a bunch of menus. A lot of specific page menus. There are about 30 to 40 menus. Syncing those manually? Totally not an option! 

I started to dig into WP table structure. Dumping databases in various stages, then diff the whole db see what changes (as a side note, this approach looks pretty intriguing!).

So I managed to pinpoint the place where the menu locations are kept:

INSERT INTO `wp_options` VALUES (143,'theme_mods_MY_THEME_NAME','a:7:{i:0;b:0;s:18:\"nav_menu_locations\";a:4:{s|...

My very first though was: „wait a minute, I’m sure that serialization is messed up!”. Checking then double checking the serialized string and, sure enough, the serialization was correct. 

But then few lines above in the sql dump, I’ve noticed this:

INSERT INTO `wp_options` VALUES (110,'theme_mods_twentyseventeen','a:2

Yes! You see, the menus are stored as theme_mods and the theme is identified by… folder name!

And while on the remote server the theme folder was named MY_THEME_NAME, locally was… MY_THEME_NAME.org

Sure enough, If I ever used other theme modifications (custom styling, custom logo, widgets and so on), the pattern would had been more obvious.

TL;DR: use the same theme folder name on all servers.

Un mod simplu de a implementa „Open-Closed principle” în WordPress

OCP este unul dintre principiile SOLID despre care am mai scris și aici. Vreau să-ți arăt o modalitate simplă prin care poți implementa asta chiar dacă nu ești fan OOP: folosind filtre! Sigur, nu este foarte încapsulată toată povestea, dar cred că este un compromis care merită făcut în majoritatea cazurilor.

La proiectul la care lucrez zilele astea am o chestie care m-a făcut să mă gândesc la treaba asta. Am o listă de vreo 30-40 meta-fields, iar la câteva vreau să aplic câteva transformări, în funcție de tipul lor (e.g. dată, formatare text, galerie etc).

Prima soluție la care te gândești este, probabil, un switch/case:

switch( $key ) {
  case 'description':
    $value = wpautop($value);
    break;
  case 'date':
    $value = \DateTime::createFromFormat(/*...*/);
    break;
  case 'gallery':
    $value = formatMyGalleryField($content);
    break;
  // .....
}

Vezi deja unde ne îndreptăm: pentru fiecare caz în parte avem de adăugat o condiție. Lucrurile încep să se complice și mai mult când vrem să aplicăm două sau mai multe transformări sau când vrem o ordine diferită a transformărilor în funcție de $key.

Filtre!

Toată condiția de mai sus s-ar putea rescrie mai simplu

$value = apply_filters("ntz/transformations/key={$key}", $value, $key ... )

Unul din lucrurile care cred că sunt prea puțin folosite în lumea WP este posibilitatea de a folosi orice caractere în numele filtrelor, motiv pentru care vedem filtre ceva mai greu de citit (decât ar putea fi). De ce ai folosi underscore în loc de slash?

Simplu, nu?

Următorul pas este să adaugi toate filtrele de care ai nevoie:

add_filter('ntz/transformation/key=description', 'wpautop');
add_filter('ntz/transformation/key=date', 'myDateFormatter');
// ....

Vrei să adaugi mai multe transformări pentru același filtru? Nici o problemă!

add_filter('ntz/transformation/key=description', 'myDateFormatter');
add_filter('ntz/transformation/key=description', 'wpautop');
// ....

Ordinea nici măcar nu e foarte importantă, având în vedere că al treila parametru poate fi un integer ce reprezintă ordinea (sau prioritatea) de execuție.

img src

Using TGMPA with GitLab’s CI artifacts

TLDR: set some headers, see below.

TGMPA

TGMPA is a WordPress plugin that helps you to manage your theme dependencies. Just imagine your users experience: instead of ask them to check a README doc that recommends installing various plugins (and no way of enforcing them to do so), this panel will show up everywhere they go:

A basic config would look like this:


add_action('tgmpa_register', function () {
  // if the plugin is not hosted on WP, add * at the begining of the URL
  $plugins = [
    "simple-taxonomy-ordering" => "https://downloads.wordpress.org/plugin/simple-taxonomy-ordering.1.2.4.zip",
  ];

  $required_plugins = [];

  $config = [
    'is_automatic' => true,
    'dismissable' => false,
  ];

  foreach ($plugins as $plugin_name => $plugin_url) {
    $required_plugins[$plugin_name] = [
      'name' => $plugin_name,
      'slug' => $plugin_name,
      'required' => true,
      'force_activation' => true,
    ];

    if (substr($plugin_url, 0, 1) == '*') { // just a trickery to allow specifying a ZIP url
      $required_plugins[$plugin_name]['source'] = substr($plugin_url, 1);
    } else {
      $required_plugins[$plugin_name]['external_url'] = $plugin_url;
    }
  }

  tgmpa($required_plugins, $config);
});

GitLab CI

On almost evey project, I’m using composer for various PHP deps, nodejs for various JS deps and a few Grunt or Gulp tasks. Obviously enough, I don’t want to keep in my repo a huuuge vendor or node_modules folder, so I gitignore these suckers. But… this means that my code is NOT ready to use.

One of the thing I really, really like on GitLab is that I can have a build process run everytime I push some code. Why this is helpful? Well, GitLab CI deals with all these issues and I have the package ready to download from this address (this is called an artifact and is the result of the CI build process):

https://gitlab.com/iamntz/my-plugin-name/-/jobs/artifacts/master/download?job=my_plugin_name

Do you notice the job param? Is the same as the YAML key below!

However, what is the problem? You can’t download this package dirrectly; you must do this either from their UI or via an arcane curl command, that sets a http header:

curl -L --header "PRIVATE-TOKEN: XXYYZZ" "https://gitlab.com/iamntz/my-plugin-name/-/jobs/artifacts/master/download?job=my_plugin_name" -o "my-plugin-name.zip"

You can create a private token on this page. Just create one that only have read_registry scope enabled and set to not expire. Since this token can represent a security issue, please consider creating a dumb user to whom you provide only a read access and add that user to your project (instead of using your token).

.gitlab-ci.yml

image: tetraweb/php:7.1

before_script:
  - docker-php-ext-enable zip
  - php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
  - php composer-setup.php
  - php -r "unlink('composer-setup.php');"
  - php composer.phar install --no-dev -o --prefer-dist # <<< add more tasks below
  - mkdir -p my-plugin-name
  - rsync -av --exclude-from='deploy-exclude-files.txt' . my-plugin-name # <<< this is where you'll have all the files you need to be deployed

my_plugin_name: # <<< this key is important and can be anything
  retry: 2
  cache: # <<< this block is optional, but it will improve build time 
  untracked: true 
  key: ${CI_BUILD_REF_NAME} 
  paths: 
    - node_modules/ 
    - vendor/ 
  artifacts: 
    name: "${CI_BUILD_NAME}_${CI_BUILD_REF_NAME}" 
    untracked: false 
    paths: - my-plugin-name/ 
  script: - echo 'Deploying!' 

In case you wonder, deploy-exclude-files.txt is a file that contains all files and folder you want to be excluded from your final ZIP file. It has one file/folder name per line and that’s all

Once the build process is done, as long as you are authentificated to gitlab, you will be able to access it on `https://gitlab.com/iamntz/my-plugin-name/-/jobs/artifacts/master/download?job=my_plugin_name` url.


Best of both worlds

But a problem arise: how can you specify that build artifact URL in your tgmpa config? Well, that’s easier than you’d expect: just set a header! If you’re digging into TGMPA code, you’ll notice that it uses WP_Http class to download external fiels. This means that… you can set some options!

add_filter('http_request_args', function ($r, $url) { 
  if (preg_match('/gitlab.com\/iamntz/', $url)) { 
    $r['headers']['PRIVATE-TOKEN'] = 'XXYYZZ'; 
  } 
  return $r; 
}, 10, 2); 

After that, you modify your tgmpa config to look like this (notice the * before the URL!):

$plugins = [ 
  "simple-taxonomy-ordering" => "https://downloads.wordpress.org/plugin/simple-taxonomy-ordering.1.2.4.zip",
  "my_plugin_name" => "*https://gitlab.com/iamntz/my-plugin-name/-/jobs/artifacts/master/download?job=my_plugin_name"
];

Yup, is that simple!

However, you may want to pay some attention to the preg_match('/gitlab.com\/iamntz/', $url) pattern and be as specific as you can (e.g. it may conflicts with GitLab’s releases feature). Just play around a little. 😉

Customizing Elementor’s default widgets

If you’re using Elementor for client work, probably you’ll hit some limits soon enough. Like, for example, you can’t customize buttons aspects on all widgets. Although is never documented, it is possible and I’ll show you a way of doing this. The example? Call to action widget (available only on Elementor Pro).

By default, you can only customize some basic stuff and if you want to reuse same style again, you’re kind of screwed.

This is a bit of nonsense, because Elementor already has a button component that you can use it outside of this widget! Why can’t I just use it here?

So, in order to make it work, we will need a couple of things. Elementor uses two ways of rendering content: one is JS based and is used only when you edit the page, the other one is PHP based and is used… everywhere else. Continuă să citești Customizing Elementor’s default widgets

Pre-popularea meniurilor în WordPress

De multe ori se întâmplă să fie nevoie să am meniurile gata pregătite în WP. Să fac un fel de seed la DB. Cum procedez?

În primul rând, facem un array cu elementele de meniu. Majoritatea sunt doar pagini, dar sunt situații în care vrem ceva mai… custom. De exemplu, vrem să adăugăm o anumită clasă pentru un element al meniului:

$menus = [
  'primary' => ['About', 'Stories', 'Community', 'Resources', 'Pet Supplies', 'Subscribe',
    [
      'menu-item-title' => 'Pet Portal',
      'menu-item-classes' => 'button button-hollow',
    ],

    [
      'menu-item-title' => 'Donate',
      'menu-item-classes' => 'button',
    ],
  ],
  'secondary1' => ['Adoption', 'Pet Care', 'Training', 'Youth Programs'],
  'secondary2' => ['Give', 'Take Action', 'Outreach', 'Sponsor'],
  'footer' => ['Contact', 'Careers', 'Articles', 'Events', 'Results'],
];

Cheile array-ului coincid cu meniurile înregistrate deja (să zicem în functions.php): Continuă să citești Pre-popularea meniurilor în WordPress

WordPress: Încărcarea dinamică a articolelor cu un anumit term

Zilele trecute am avut următoarea situație:

  • În backend: o taxonomie custom cu câteva zeci de terms
  • În frontend: afișez ultimele zece articole dintr-un term + buton de încărcare a următorului term.

Prima idee a fost: încarc toate articolele dintr-un foc și fac toggle la vizibilitate cu JS. Dar se ajunge lejer la câteva sute de articole, lucru ce afectează performanța din toate punctele de vedere. Continuă să citești WordPress: Încărcarea dinamică a articolelor cu un anumit term

Selectarea categoriilor cu ajutorul Carbon Fields

Despre Carbon Fields am scris atât pe blogul personal, cât și pe forum și am tot început să-l folosesc la diverse proiecte, având tot felul de provocări, care mai de care mai interesantă. Ultima? Să pot permite selectarea unei singure categorii pentru un anumit post. Soluția e foarte simplă și are câțiva pași simpli. Evident, în loc de category se poate folosi și o taxonomie proprie.

<?php

use \Carbon_Fields\Container;
use \Carbon_Fields\Field;

add_action('after_setup_theme', function () {
  // inițializăm Carbon Fields
  \Carbon_Fields\Carbon_Fields::boot();
});

function getCurrentValue()
{
  if (!is_admin()) {
    // pentru că toate câmpurile sunt parsate și în frontend, nu facem interogările decât în admin
    return [];
  }

  $currentPostID = absint($_GET['post'] ?? 0);

  $mediumTerm = wp_list_pluck((array) get_the_terms($currentPostID, 'category'), 'term_id');
  
  // în cazul în care postul are deja mai multe categorii setate, o întoarcem doar pe prima
  return array_shift($mediumTerm);
}

function getAvailableOptions()
{
  if (!is_admin()) {
    return [];
  }
  $terms = get_terms([
    'taxonomy' => 'category',
    'hide_empty' => false,
  ]);

  $parsedTerms[-1] = __('Select');

  foreach ($terms as $term) {
    // generăm un array de forma "id" => "Nume", pentru a-l putea afișa în select 
    $parsedTerms[$term->term_id] = $term->name;
  }

  return $parsedTerms;
}

add_action('carbon_fields_register_fields', function () {
  Container::make('post_meta', __('My Category'))
    ->where('post_type', '=', 'post')
    ->add_fields([
      Field::make('select', 'my_category', __('Category'))
        ->add_options(getAvailableOptions())
        ->set_required(true),
    ]);
});


add_action('carbon_fields_post_meta_container_saved', function ($postID) {
  $term = absint(carbon_get_the_post_meta('my_category'));
 

  if (!empty($term)) {
    wp_set_post_terms($postID, [$term], 'category', false);
    // https://developer.wordpress.org/reference/functions/wp_set_post_terms/
    // salvăm toată povestea
    // al patrulea argument, `false` poate fi setat ca `true` dacă se dorește 
    // adăugarea mai multor categorii
  }
});


add_filter('register_taxonomy_args', function ($args, $taxonomy) {
  if ($taxonomy === 'category') {
    // ascundem selectorul de categorie
    $args['meta_box_cb'] = false;
  }
  return $args;
}, 10, 3);

windows apple dropbox facebook twitter