I was working on Gallery Casale and was originally just pulling in the latest post featured images, which was nice enough, but I wanted a few more options. When using Bricks, I used the API query feature to retrieve images from my Flickr account, but I wanted to see if it was possible to use Etch instead. A couple of people had done work getting other JSON data into Etch, so I borrowed their ideas and the Flickr API documentation (a challenging resource) to build my prompt for ChatGPT. It only took a couple of reworks, and I now have a way to populate the latest images and a specific album.

I opted not to make this a plugin, since the API query functionality is considered a high priority.

The latest images

I use WPCodeBox for this. Once you add it, configure your API Key and user ID, then go to [your-website-url]/wp-admin/?fw_flickr_sync=1 while logged in as the admin. This will initiate the data and set a CRON job to run hourly.

<?php
/**
 * Flickr → Etch Loop Library Sync (WPCodeBox-safe)
 * Writes/updates a Loop Library entry in the `etch_loops` option.
 */

if (!defined('ABSPATH')) exit;

/** ====== CONFIG (edit API key and USER ID) ====== */
define('FW_FLICKR_API_KEY',  'PUT_YOUR_FLICKR_API_KEY_HERE');
define('FW_FLICKR_USER_ID',  'PUT YOUR UNDER ID HERE');

define('FW_LOOP_SLUG',       'fw-flickr-feed'); // internal array key in etch_loops
define('FW_LOOP_KEY',        'fw_flickr_feed'); // Etch loop key (underscores only)

define('FW_PER_PAGE',        48);

define('FW_CRON_HOOK',       'fw_flickr_to_etch_sync');
define('FW_LAST_UPLOAD_OPT', 'fw_flickr_last_upload_ts');

/**
 * Ensure cron is scheduled (WPCodeBox doesn't have activation hooks).
 */
add_action('init', function () {
  if (wp_next_scheduled(FW_CRON_HOOK)) return;
  wp_schedule_event(time() + 120, 'hourly', FW_CRON_HOOK);
}, 5);

/**
 * Manual run: /wp-admin/?fw_flickr_sync=1 (admins only)
 */
add_action('admin_init', function () {
  if (!current_user_can('manage_options')) return;
  if (!isset($_GET['fw_flickr_sync'])) return;

  fw_flickr_sync_to_etch(true);

  wp_safe_redirect(remove_query_arg('fw_flickr_sync'));
  exit;
});

add_action(FW_CRON_HOOK, function () {
  fw_flickr_sync_to_etch(false);
});

/**
 * Sync Flickr → etch_loops.
 */
function fw_flickr_sync_to_etch($force_full = false) {
  if (!FW_FLICKR_API_KEY || FW_FLICKR_API_KEY === 'PUT_YOUR_FLICKR_API_KEY_HERE') return;

  $etch_loops = get_option('etch_loops');
  if (!is_array($etch_loops)) {
    // Etch not active or option not present yet.
    return;
  }

  // Existing stored items (what's currently in the Etch loop entry)
  $existing = array();
  if (isset($etch_loops[FW_LOOP_SLUG]['config']['data']) && is_array($etch_loops[FW_LOOP_SLUG]['config']['data'])) {
    $existing = $etch_loops[FW_LOOP_SLUG]['config']['data'];
  }

  $last_upload = (int) get_option(FW_LAST_UPLOAD_OPT, 0);

  // You asked for: url_l,url_m,description
  // Add date_upload so we can detect new data / sort.
  $extras = 'url_l,url_m,description,date_upload';

  $need_fill = $force_full || (count($existing) < (int) FW_PER_PAGE);

  $new_items = array();
  $max_upload = $last_upload;

  if ($need_fill) {
    /**
     * FILL MODE:
     * Fetch pages starting at page 1 until we have PER_PAGE items or run out.
     * (Still merge with existing so we don't erase stored items while filling.)
     */
    $page = 1;
    $safety_max_pages = 10; // avoids runaway if something weird happens

    while (count($new_items) < (int) FW_PER_PAGE && $page <= $safety_max_pages) {
      $batch = fw_flickr_fetch_public_photos_page($page, (int) FW_PER_PAGE, $extras);
      if (empty($batch)) break;

      // append batch into new_items (dedupe within batch stream)
      foreach ($batch as $it) {
        if (empty($it['id'])) continue;
        $new_items[$it['id']] = $it; // keyed by id to dedupe
        $ts = isset($it['dateupload']) ? (int) $it['dateupload'] : 0;
        if ($ts > $max_upload) $max_upload = $ts;
        if (count($new_items) >= (int) FW_PER_PAGE) break;
      }

      $page++;
    }

    $new_items = array_values($new_items);

  } else {
    /**
     * INCREMENTAL MODE:
     * Stored already has >= PER_PAGE, so only fetch new uploads.
     */
    if ($last_upload > 0) {
      $args = array(
        'method'         => 'flickr.people.getPublicPhotos',
        'api_key'        => FW_FLICKR_API_KEY,
        'user_id'        => FW_FLICKR_USER_ID,
        'format'         => 'json',
        'nojsoncallback' => '1',
        'per_page'       => (string) FW_PER_PAGE,
        'page'           => '1',
        'extras'         => $extras,
        'min_upload_date'=> (string) ($last_upload + 1),
      );

      $url = add_query_arg($args, 'https://api.flickr.com/services/rest/');

      $res = wp_remote_get($url, array(
        'timeout' => 20,
        'headers' => array(
          'User-Agent' => 'WordPress; Flickr→Etch Sync; ' . home_url('/'),
          'Accept'     => 'application/json',
        ),
      ));

      if (is_wp_error($res)) return;

      $code = (int) wp_remote_retrieve_response_code($res);
      if ($code < 200 || $code >= 300) return;

      $data = json_decode(wp_remote_retrieve_body($res), true);
      $photos = isset($data['photos']['photo']) ? $data['photos']['photo'] : null;
      if (!is_array($photos) || empty($photos)) return; // no new uploads

      foreach ($photos as $p) {
        $it = fw_flickr_normalize_photo($p);
        if (!$it) continue;
        $new_items[] = $it;

        $ts = isset($it['dateupload']) ? (int) $it['dateupload'] : 0;
        if ($ts > $max_upload) $max_upload = $ts;
      }
    } else {
      // No last upload stored yet; treat as fill.
      return fw_flickr_sync_to_etch(true);
    }
  }

  // Merge: new first, then existing; de-dupe by id
  $merged = array();
  $seen = array();

  foreach (array_merge($new_items, $existing) as $it) {
    if (!is_array($it)) continue;
    $id = isset($it['id']) ? (string) $it['id'] : '';
    if (!$id || isset($seen[$id])) continue;
    $seen[$id] = true;
    $merged[] = $it;
  }

  // Sort newest first
  usort($merged, function ($a, $b) {
    return ((int) ($b['dateupload'] ?? 0)) <=> ((int) ($a['dateupload'] ?? 0));
  });

  // Your rule:
  // - if > PER_PAGE: trim to latest PER_PAGE
  // - else: keep all (do not erase while still filling)
  if (count($merged) > (int) FW_PER_PAGE) {
    $merged = array_slice($merged, 0, (int) FW_PER_PAGE);
  }

  $entry = array(
    'name'   => 'Flickr Feed',
    'key'    => FW_LOOP_KEY,
    'global' => true,
    'config' => array(
      'type' => 'json',
      'data' => $merged,
    ),
  );

  // Only write if changed
  $current = get_option('etch_loops');
  $current_entry = (is_array($current) && isset($current[FW_LOOP_SLUG])) ? $current[FW_LOOP_SLUG] : null;

  if (maybe_serialize($current_entry) !== maybe_serialize($entry)) {
    $etch_loops[FW_LOOP_SLUG] = $entry;
    update_option('etch_loops', $etch_loops);
  }

  update_option(FW_LAST_UPLOAD_OPT, $max_upload, false);
}

/**
 * Fetch one page of public photos and normalize.
 */
function fw_flickr_fetch_public_photos_page($page, $per_page, $extras) {
  $args = array(
    'method'         => 'flickr.people.getPublicPhotos',
    'api_key'        => FW_FLICKR_API_KEY,
    'user_id'        => FW_FLICKR_USER_ID,
    'format'         => 'json',
    'nojsoncallback' => '1',
    'per_page'       => (string) $per_page,
    'page'           => (string) $page,
    'extras'         => (string) $extras,
  );

  $url = add_query_arg($args, 'https://api.flickr.com/services/rest/');

  $res = wp_remote_get($url, array(
    'timeout' => 20,
    'headers' => array(
      'User-Agent' => 'WordPress; Flickr→Etch Sync; ' . home_url('/'),
      'Accept'     => 'application/json',
    ),
  ));

  if (is_wp_error($res)) return array();

  $code = (int) wp_remote_retrieve_response_code($res);
  if ($code < 200 || $code >= 300) return array();

  $data = json_decode(wp_remote_retrieve_body($res), true);
  $photos = isset($data['photos']['photo']) ? $data['photos']['photo'] : null;
  if (!is_array($photos) || empty($photos)) return array();

  $out = array();
  foreach ($photos as $p) {
    $it = fw_flickr_normalize_photo($p);
    if ($it) $out[] = $it;
  }
  return $out;
}

/**
 * Normalize one Flickr photo record to your Etch JSON item shape.
 */
function fw_flickr_normalize_photo($p) {
  if (!is_array($p)) return null;

  $id = isset($p['id']) ? (string) $p['id'] : '';
  if (!$id) return null;

  $owner = isset($p['owner']) ? (string) $p['owner'] : '';
  $title = isset($p['title']) ? (string) $p['title'] : '';

  $desc = '';
  if (isset($p['description']['_content'])) {
    $desc = (string) $p['description']['_content'];
  }

  $url_l = !empty($p['url_l']) ? (string) $p['url_l'] : '';
  $url_m = !empty($p['url_m']) ? (string) $p['url_m'] : '';

  $dateupload = isset($p['dateupload']) ? (int) $p['dateupload'] : 0;

  return array(
    'id'          => $id,
    'title'       => $title,
    'url_l'       => $url_l,
    'url_m'       => $url_m,
    'description' => $desc,
    'link'        => ($owner && $id) ? "https://www.flickr.com/photos/{$owner}/{$id}/" : '',
    'dateupload'  => $dateupload,
  );
}

A Specific Album

The trigger for this is /wp-admin/?fw_flickr_album_sync=1. You need to add your API key, user ID, and the Album D.

<?php
/**
 * Flickr Album → Etch Loop Library Sync (WPCodeBox-safe)
 * Creates a separate Loop Library JSON entry: "Flickr Album Feed"
 */

if (!defined('ABSPATH')) exit;

/** ====== CONFIG ====== */
/** ====== CONFIG (edit API key and USER ID) ====== */
define('FW_FLICKR_API_KEY',  'PUT_YOUR_FLICKR_API_KEY_HERE');
define('FW_FLICKR_USER_ID',  'PUT YOUR UNDER ID HERE');
define('FW_FLICKR_PHOTOSET_ID', 'PUT YOUR ALBUM ID HERE'); // album id (no trailing slash)

define('FW_ALBUM_LOOP_SLUG', 'fw-flickr-album-feed');  // internal array key in etch_loops
define('FW_ALBUM_LOOP_KEY',  'fw_flickr_album_feed');  // Etch loop key (underscores only)
define('FW_ALBUM_PER_PAGE',  40);

define('FW_ALBUM_CRON_HOOK', 'fw_flickr_album_to_etch_sync');

/**
 * Ensure cron is scheduled.
 */
add_action('init', function () {
  if (wp_next_scheduled(FW_ALBUM_CRON_HOOK)) return;
  wp_schedule_event(time() + 120, 'hourly', FW_ALBUM_CRON_HOOK);
}, 5);

/**
 * Manual run: /wp-admin/?fw_flickr_album_sync=1 (admins only)
 */
add_action('admin_init', function () {
  if (!current_user_can('manage_options')) return;
  if (!isset($_GET['fw_flickr_album_sync'])) return;

  fw_flickr_album_sync_to_etch();

  wp_safe_redirect(remove_query_arg('fw_flickr_album_sync'));
  exit;
});

add_action(FW_ALBUM_CRON_HOOK, function () {
  fw_flickr_album_sync_to_etch();
});

function fw_flickr_album_sync_to_etch(): void {
  if (!FW_FLICKR_API_KEY || FW_FLICKR_API_KEY === 'PUT_YOUR_FLICKR_API_KEY_HERE') return;

  $etch_loops = get_option('etch_loops');
  if (!is_array($etch_loops)) return;

  // Existing stored items
  $existing = array();
  if (isset($etch_loops[FW_ALBUM_LOOP_SLUG]['config']['data']) && is_array($etch_loops[FW_ALBUM_LOOP_SLUG]['config']['data'])) {
    $existing = $etch_loops[FW_ALBUM_LOOP_SLUG]['config']['data'];
  }

  $target = (int) FW_ALBUM_PER_PAGE;

  /**
   * Always fetch the latest up-to-N items from the album (page 1..x).
   * If the album contains < N total, we'll just fetch what's available.
   */
  $fetched = array();
  $page = 1;
  $per_page = $target;          // fetch in N-sized pages to minimize calls
  $max_pages = 10;              // safety limit
  $extras = 'url_l,url_m,description,date_upload';

  while (count($fetched) < $target && $page <= $max_pages) {
    $args = array(
      'method'         => 'flickr.photosets.getPhotos',
      'api_key'        => FW_FLICKR_API_KEY,
      'photoset_id'    => FW_FLICKR_PHOTOSET_ID,
      'user_id'        => FW_FLICKR_USER_ID,
      'format'         => 'json',
      'nojsoncallback' => '1',
      'per_page'       => (string) $per_page,
      'page'           => (string) $page,
      'extras'         => $extras,
    );

    $url = add_query_arg($args, 'https://api.flickr.com/services/rest/');

    $res = wp_remote_get($url, array(
      'timeout' => 20,
      'headers' => array(
        'User-Agent' => 'WordPress; Flickr Album→Etch Sync; ' . home_url('/'),
        'Accept'     => 'application/json',
      ),
    ));

    if (is_wp_error($res)) break;

    $code = (int) wp_remote_retrieve_response_code($res);
    if ($code < 200 || $code >= 300) break;

    $data = json_decode(wp_remote_retrieve_body($res), true);
    $photos = $data['photoset']['photo'] ?? null;
    if (!is_array($photos) || empty($photos)) break;

    foreach ($photos as $p) {
      $id = isset($p['id']) ? (string) $p['id'] : '';
      if (!$id) continue;

      $owner = isset($p['owner']) ? (string) $p['owner'] : FW_FLICKR_USER_ID;

      $desc = '';
      if (isset($p['description']['_content'])) {
        $desc = (string) $p['description']['_content'];
      }

      $item = array(
        'id'          => $id,
        'title'       => isset($p['title']) ? (string) $p['title'] : '',
        'url_l'       => !empty($p['url_l']) ? (string) $p['url_l'] : '',
        'url_m'       => !empty($p['url_m']) ? (string) $p['url_m'] : '',
        'description' => $desc,
        'link'        => ($owner && $id) ? "https://www.flickr.com/photos/{$owner}/{$id}/" : '',
        'dateupload'  => isset($p['dateupload']) ? (int) $p['dateupload'] : 0,
      );

      // Dedup fetched by id
      $fetched[$id] = $item;

      if (count($fetched) >= $target) break;
    }

    // If this page returned fewer than per_page, we likely hit the end
    if (count($photos) < $per_page) break;

    $page++;
  }

  $fetched = array_values($fetched);

  /**
   * Merge: fetched latest first, then existing.
   * This is the “append until N” rule when album has < N total.
   */
  $merged = array();
  $seen = array();

  foreach (array_merge($fetched, $existing) as $it) {
    if (!is_array($it)) continue;
    $id = isset($it['id']) ? (string) $it['id'] : '';
    if (!$id || isset($seen[$id])) continue;
    $seen[$id] = true;
    $merged[] = $it;
  }

  // Sort newest first
  usort($merged, function ($a, $b) {
    return ((int) ($b['dateupload'] ?? 0)) <=> ((int) ($a['dateupload'] ?? 0));
  });

  // Your rule: only trim once we exceed N
  if (count($merged) > $target) {
    $merged = array_slice($merged, 0, $target);
  }

  $entry = array(
    'name'   => 'Flickr Album Feed',
    'key'    => FW_ALBUM_LOOP_KEY,
    'global' => true,
    'config' => array(
      'type' => 'json',
      'data' => $merged,
    ),
  );

  // Only write if changed (optional, but good)
  $current = get_option('etch_loops');
  $current_entry = (is_array($current) && isset($current[FW_ALBUM_LOOP_SLUG])) ? $current[FW_ALBUM_LOOP_SLUG] : null;

  if (maybe_serialize($current_entry) !== maybe_serialize($entry)) {
    $etch_loops[FW_ALBUM_LOOP_SLUG] = $entry;
    update_option('etch_loops', $etch_loops);
  }
}