# PLUGIN PREVIEW BY TEXTPATTERN.INFO
/**
* smd_imagery
*
* A Textpattern CMS plugin for managing images in the Write panel.
*
* @author Stef Dawson
* @link http://stefdawson.com/
*/
if (txpinterface === 'admin') {
new smd_imagery();
}
// 4.5.x polyfill.
if (!function_exists('send_json_response')) {
function send_json_response($out = '')
{
static $headers_sent = false;
if (!$headers_sent) {
ob_clean();
header('Content-Type: application/json; charset=utf-8');
txp_status_header('200 OK');
$headers_sent = true;
}
if (!is_string($out)) {
$out = json_encode($out);
}
echo $out;
}
}
/**
* Admin interface.
*/
class smd_imagery
{
/**
* The plugin's event as registered in Txp.
*
* @var string
*/
protected $plugin_event = 'smd_imagery';
/**
* Constructor to set up callbacks and environment.
*/
public function __construct()
{
add_privs($this->plugin_event, '1,2,3,4,5,6');
register_callback(array($this, 'welcome'), 'plugin_lifecycle.' . $this->plugin_event);
register_callback(array($this, 'render_ui'), 'article_ui', 'article_image');
register_callback(array($this, 'inject_head'), 'admin_side', 'head_end');
// Handler for calls from the plugin's UI buttons.
register_callback(array($this, 'dispatch'), $this->plugin_event);
// Handlers for 4.6+ core Txp-based events.
// These run 'pre' so they can inject stuff into the POSTed array
// prior to Txp's involvement.
// The reason it's not for earlier versions is that the Article Image field
// wasn't volatile, so the new id values aren't injected.
if (version_compare(txp_version, '4.6.0', '>=')) {
register_callback(array($this, 'replacePOST'), 'article', 'edit', 1);
register_callback(array($this, 'replacePOST'), 'article', 'create', 1);
}
}
/**
* Install/uninstall jumpoff point.
*
* @param string $evt Textpattern event (lifecycle)
* @param string $stp Textpattern step (action)
*/
public function welcome($evt, $stp)
{
switch ($stp) {
case 'deleted':
safe_delete('txp_lang', "event like '" . $this->plugin_event . "%'");
break;
}
return;
}
/**
* Divert plugin callbacks to the correct function.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
*/
public function dispatch($evt, $stp)
{
$available_steps = array(
'fetchImages' => true,
'renderDialog' => true,
'replaceJSON' => true,
'saveState' => true,
);
if (!bouncer($stp, $available_steps)) {
return;
}
$this->$stp($evt, $stp);
}
/**
* Inject style rules / header material into the <head> of the page.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
* @return string Content to inject, or nothing if not the plugin's $event
*/
public function inject_head($evt, $stp)
{
global $event;
// Also add fallback to load jQuery UI in case we're on < Txp 4.6.
if ($event === 'article') {
echo '<style>
.ui-dialog { min-width: 50vw; }
.content-image { padding: 0; border-radius: none; }
img { max-width: 100%; }
.smd_imagery_images { display: flex; flex-wrap: wrap; }
.smd_imagery_image { max-width: 33%; position: relative; }
.smd_imagery_image .destroy { position: absolute; right: 0; z-index: 15; }
.smd_imagery_btn { margin-bottom: 1rem; }
</style>'
.n.script_js(<<<EOJS
window.jQuery.ui || document.write('<link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/smoothness/jquery-ui.css" media="screen" /><script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"><\/script>');
EOJS
);
}
return;
}
/**
* Add buttons to the article image field.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
* @param string $data Original markup
* @param array $rs Accompanyng record set
* @return string HTML
*/
public function render_ui($evt, $stp, $data, $rs)
{
$btn = '<button class="' . $this->plugin_event . '_fetch '.$this->plugin_event.'_btn">' . gTxt('smd_imagery_id_btn') . '</button>'
.n. '<button class="' . $this->plugin_event . '_organise '.$this->plugin_event.'_btn">' . gTxt('smd_imagery_organise_btn') . '</button>'
.n. '<div class="' . $this->plugin_event . '_dialog" title="' . gTxt('smd_imagery_dialog_title') . '">'
.n. '</div>';
$js = script_js(<<< EOJS
jQuery(function() {
/**
* Fetch button handler.
*
* Grabs images by category name and stuffs them directly in the article image field.
*/
jQuery('.{$this->plugin_event}_fetch').on('click', function(ev) {
ev.preventDefault();
var me = jQuery(this);
var body = jQuery('body');
var spinner = jQuery('<span />').addClass('spinner');
// Show feedback while processing.
me.addClass('busy').attr('disabled', true).after(spinner);
body.addClass('busy');
var dest = jQuery('.article-image input');
sendAsyncEvent({
event : '{$this->plugin_event}',
step : 'replaceJSON',
articleImage : dest.val(),
}, function() {}, 'json')
.done(function (data, textStatus, jqXHR) {
dest.val(data);
})
.always(function () {
me.removeClass('busy').removeAttr('disabled');
spinner.remove();
body.removeClass('busy');
});
});
/**
* Throw up a dialog to allow image order to be manipulated by hand.
*/
jQuery('.{$this->plugin_event}_organise').on('click', function(ev) {
ev.preventDefault();
var me = jQuery(this);
var body = jQuery('body');
var spinner = jQuery('<span />').addClass('spinner');
// Show feedback while processing.
me.addClass('busy').attr('disabled', true).after(spinner);
body.addClass('busy');
var dest = jQuery('.{$this->plugin_event}_dialog');
sendAsyncEvent({
event : '{$this->plugin_event}',
step : 'renderDialog',
}, function() {}, 'json')
.done(function (data, textStatus, jqXHR) {
dest.empty().html(data.content);
dest.dialog({
// Expensive, but prevents multiple dialogs appearing
// after article Save.
close: function(event, ui) {
dest.empty().dialog('destroy');
}
});
})
.always(function () {
me.removeClass('busy').removeAttr('disabled');
spinner.remove();
body.removeClass('busy');
});
});
});
EOJS
);
return $data.$btn.$js;
}
/**
* Draw the bare, starting UI for the dialog.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
* @return HTML
*/
public function renderDialog($evt, $stp)
{
global $plugins;
$out = '';
$smdthumb = (is_array($plugins) and in_array('smd_thumbnail', $plugins)) ? '1' : '0';
$thumb_sizes = array();
$thumb_sizes['txp_thumb'] = gTxt('thumbnail');
$thumb_sizes['txp_image'] = gTxt('smd_imagery_image');
if ($smdthumb == '1') {
$rs = smd_thumb_get_profiles();
if ($rs) {
foreach ($rs as $row) {
$thumb_sizes[$row['name']] = $row['name'];
}
}
}
$thumbSelector = selectInput(
'smd_imagery_size',
$thumb_sizes,
get_pref('smd_imagery_size'),
false,
'',
'smd_imagery_size'
);
$catList = event_category_popup('image', '', $this->plugin_event . '_list');
$fldChoices = array(
'article-image' => gTxt('article_image'),
'body' => gTxt('body'),
'excerpt' => gTxt('excerpt'),
);
$cfs = getCustomFields();
foreach ($cfs as $i => $cf_name) {
$fldChoices['custom-'.$i] = get_pref("custom_{$i}_set");
}
$chosenMethod = get_pref('smd_imagery_load_method', 'bycat');
$fldList = selectInput($this->plugin_event . '_field', $fldChoices, get_pref('smd_imagery_field'), false, '', $this->plugin_event . '_field');
$loadChoices = array(
'bycat' => gTxt('smd_imagery_opt_cat'),
'byfld' => gTxt('smd_imagery_opt_fld'),
);
$loadMethod = radioSet($loadChoices, $this->plugin_event . '_load_method', $chosenMethod);
$actions = '<button class="' . $this->plugin_event . '_fetch_img">' . gTxt('smd_imagery_fetch_btn') . '</button>';
$sortable = $this->sortOpts();
$sort = get_pref('smd_imagery_sort_order', key($sortable));
$dir = get_pref('smd_imagery_sort_dir', 'asc');
$listActions = '<span class="smd_imagery_sortopts">'
. selectInput('smd_imagery_sort_order', $sortable, $sort, false, '', 'smd_imagery_sort_order')
. (($dir === 'asc')
? href('<span class="ui-icon ui-icon-arrowthick-1-n smd_imagery_sort_dir"></span> ', '#')
: href('<span class="ui-icon ui-icon-arrowthick-1-s smd_imagery_sort_dir"></span> ', '#')
)
. '</span>';
$panel = '<div class="' . $this->plugin_event . '_images">'
.n. '</div>';
$plate = '<label for="' . $this->plugin_event . '_template">' . gTxt('smd_imagery_template') . '</label>'
.n. '<textarea name="' . $this->plugin_event . '_template" id="' . $this->plugin_event . '_template" class="' . $this->plugin_event . '_template">'.txpspecialchars(get_pref('smd_imagery_template')).'</textarea>';
$result = '<label for="' . $this->plugin_event . '_result">' . gTxt('smd_imagery_result') . '</label>'
.n. '<textarea name="' . $this->plugin_event . '_result" id="' . $this->plugin_event . '_result" class="' . $this->plugin_event . '_result" rows=4"></textarea>';
$js = script_js(<<< EOJS
jQuery(function() {
/**
* Fetch button handler.
*
* Grabs images by category name and shows them in the dialog.
*/
jQuery('.{$this->plugin_event}_fetch_img').on('click', function(ev) {
ev.preventDefault();
var me = jQuery(this);
var body = jQuery('body');
var spinner = jQuery('<span />').addClass('spinner');
// Show feedback while processing.
me.addClass('busy').attr('disabled', true).after(spinner);
body.addClass('busy');
var type = jQuery('[name={$this->plugin_event}_load_method]:checked').val()
var catName = null;
var idList = [];
var nameList = [];
if (type === 'bycat') {
catName = jQuery('#{$this->plugin_event}_list').val();
} else if (type === 'byfld') {
var theField = jQuery('#{$this->plugin_event}_field').val();
var content = jQuery('#' + theField).val();
// Check for an entire list of ids.
var rex = /^([0-9, ]+)$/g;
result = rex.exec(content);
if (result !== null) {
if (typeof(result[1]) != 'undefined') {
idList.push(result[1]);
}
}
// Check for txp:image/txp:images tags and pull out ids/names.
// Very simplistic. The downside to separating passes will only
// become apparent if people mix HTML and Txp tags, as the
// images will not be in source order.
// In reality this probably won't be an issue.
var rex = /<txp:image[s]?.*((id)\s*=\s*"([0-9, ]+)"|(name)\s*=\s*"(.+?)").*?\/?>/g;
while ((result = rex.exec(content)) !== null) {
if (typeof(result) != 'undefined') {
if (typeof(result[3]) != 'undefined') {
// id matches
idList.push(result[3].trim());
} else if (typeof(result[5]) != 'undefined') {
// name matches
nameList.push(result[5].trim());
}
}
}
// Next, check for HTML img tags and extract file ids.
var rex = /<img.*(src)\s*=\s*".*?\/([0-9]+)\..{3,4}".*?\/?>/g;
while ((result = rex.exec(content)) !== null) {
if (typeof(result) != 'undefined') {
if (typeof(result[2]) != 'undefined') {
// id found
idList.push(result[2]);
}
}
}
}
var size = jQuery('#{$this->plugin_event}_size').val();
var dest = jQuery('.{$this->plugin_event}_images');
sendAsyncEvent({
event : '{$this->plugin_event}',
step : 'fetchImages',
type : type,
category : catName,
idList : idList.join(','),
nameList : nameList.join(','),
size : size,
}, function() {}, 'json')
.done(function (data, textStatus, jqXHR) {
// Drag & drop, yeah!
dest.empty().html(data.content).sortable({
tolerance: "pointer",
opacity: 0.9,
create: smd_imagery_result,
change: smd_imagery_result,
update: smd_imagery_result
});
jQuery('.{$this->plugin_event}_image .destroy').click(function(ev) {
ev.preventDefault();
var target = jQuery(this).closest('.{$this->plugin_event}_image');
target.hide('fast', function() { target.remove(); smd_imagery_result(); });
});
smd_imagery_result();
})
.always(function () {
me.removeClass('busy').removeAttr('disabled');
spinner.remove();
body.removeClass('busy');
});
});
/**
* Save pane state: loadMethod.
*/
jQuery('[name={$this->plugin_event}_load_method]').on('change', function(ev) {
var meVal = jQuery(this).filter(':checked').val();
var catObj = jQuery('#{$this->plugin_event}_list');
var fldObj = jQuery('#{$this->plugin_event}_field');
var ordObj = jQuery('.{$this->plugin_event}_sortopts');
if (meVal === 'bycat') {
fldObj.hide();
catObj.show();
ordObj.show();
} else if (meVal === 'byfld') {
fldObj.show();
catObj.hide();
ordObj.hide();
}
smd_imagery_stash(['load_method']);
}).change();
/**
* Save pane state: size, field and sort order.
*/
jQuery('#{$this->plugin_event}_size, #{$this->plugin_event}_field, #{$this->plugin_event}_sort_order').on('change', function() {
var toStash = [
'size',
'field',
'sort_order'
];
smd_imagery_stash(toStash);
});
/**
* Save pane state: sort dir.
*/
jQuery('.{$this->plugin_event}_sort_dir').on('click', function(ev) {
ev.preventDefault();
jQuery(this).toggleClass('ui-icon-arrowthick-1-s ui-icon-arrowthick-1-n');
smd_imagery_stash(['sort_dir']);
});
/**
* Save pane state: template.
*/
jQuery('.{$this->plugin_event}_template').on('blur', function() {
smd_imagery_stash(['plate']);
smd_imagery_result();
});
});
/**
* Store pane state.
*/
function smd_imagery_stash(things) {
sort_dir = jQuery('.{$this->plugin_event}_sort_dir').hasClass('ui-icon-arrowthick-1-n') ? 'asc' : 'desc'
var opts = {
event : '{$this->plugin_event}',
step : 'saveState',
loadMethod : 'null',
size : 'null',
field : 'null',
sort_order : 'null',
sort_dir : 'null',
plate : 'null',
};
jQuery(things).each(function(idx, thing) {
switch (thing) {
case 'load_method':
opts.loadMethod = jQuery('[name={$this->plugin_event}_load_method]:checked').val();
break;
case 'size':
opts.size = jQuery('#{$this->plugin_event}_size').val();
break;
case 'field':
opts.field = jQuery('#{$this->plugin_event}_field').val();
break;
case 'sort_order':
opts.sort_order = jQuery('#{$this->plugin_event}_sort_order').val();
break;
case 'sort_dir':
opts.sort_dir = sort_dir;
break;
case 'plate':
opts.plate = jQuery('.{$this->plugin_event}_template').val();
break;
}
});
sendAsyncEvent(opts);
}
/**
* jQuery UI callback to update the results textarea.
*/
function smd_imagery_result(event, ui) {
var meta = {
"id" : [],
"name" : [],
};
var out = '';
var reps = {};
jQuery('.{$this->plugin_event}_images img').each(function(idx) {
meta.id.push(jQuery(this).data('ref'));
meta.name.push(jQuery(this).data('name'));
});
var imgIdList = meta.id.join(',');
var imgNameList = meta.name.join(',');
var plate = jQuery('.{$this->plugin_event}_template').val();
var re1 = /\{smd_imagery_list_(.+)\}/i;
var re2 = /\{smd_imagery_(id|name)\}/i;
var foundList = plate.match(re1);
var foundSingle = plate.match(re2);
// Default output is an id list, if no matches found.
out = imgIdList;
if (foundList) {
reps["{{$this->plugin_event}_list_id}"] = imgIdList;
reps["{{$this->plugin_event}_list_name}"] = imgNameList;
reps["{{$this->plugin_event}_list_id_quoted}"] = "'" + meta.id.join("','") + "'";
reps["{{$this->plugin_event}_list_name_quoted}"] = "'" + meta.name.join("','") + "'";
out = plate.strtr(reps);
} else if (foundSingle) {
out = '';
jQuery(meta.id).each(function(idx, val) {
reps["{{$this->plugin_event}_id}"] = val;
reps["{{$this->plugin_event}_name}"] = meta.name[idx];
out += plate.strtr(reps) + '\\n';
});
}
jQuery('.{$this->plugin_event}_result').val(out);
}
/**
* Equivalent to PHP's strtr().
*
* From https://gist.github.com/dsheiko/2774533.
*/
String.prototype.strtr = function (replacePairs) {
"use strict";
var str = this.toString(), key, re;
for (key in replacePairs) {
if (replacePairs.hasOwnProperty(key)) {
re = new RegExp(key, "g");
str = str.replace(re, replacePairs[key]);
}
}
return str;
}
EOJS
);
$out = $loadMethod
.br. $catList
.n. $fldList
.n. $thumbSelector
.n. $listActions.$actions
.n. $panel
.n. $plate
.n. $result
.n. $js;
// Have to send the template content separately so it can be rendered
// after the content. Otherwise, jQuery converts self-closed tags to
// fully closed when rendering via the html() method.
send_json_response(array('content' => $out));
}
/**
* Grab the images from the POSTed category.
*
* Permits selection of uncategorised images by deliberately not
* checking if the passed category is empty.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
* @return HTML
*/
public function fetchImages($evt, $stp)
{
global $img_dir;
$type = ps('type');
$where = '';
switch ($type) {
case 'bycat':
$sortable = $this->sortOpts();
$dirable = array('asc', 'desc');
$catName = ps('category');
$sort = get_pref('smd_imagery_sort_order');
$dir = get_pref('smd_imagery_sort_dir');
if (!array_key_exists($sort, $sortable)) {
$sort = key($sortable);
}
if (!in_array($dir, $dirable)) {
$dir = $dirable[0];
}
$orderBy = ' ORDER BY ' . doSlash($sort) . ' ' . doSlash($dir);
$where = "category = '" . doSlash($catName) . "'" . $orderBy;
break;
case 'byfld':
$idList = ps('idList');
$nameList = ps('nameList');
if ($idList) {
$ids = doSlash($idList);
$where = "id IN(" . $ids . ") ORDER BY field(id, " . $ids . ")";
}
if ($nameList) {
$names = implode(', ', quote_list(do_list($nameList)));
$where = "name IN(" . $names . ") ORDER BY field(name, " . $names . ")";
}
break;
}
$size = ps('size');
$img = array();
$rs = safe_rows('*', 'txp_image', $where);
foreach ($rs as $row) {
switch ($size) {
case 'txp_thumb':
// Thumbnail.
$url = imagesrcurl($row['id'], $row['ext'], true);
break;
case 'txp_image':
// Full-size image.
$url = imagesrcurl($row['id'], $row['ext']);
break;
default:
// smd_thumbnail profile.
$url = ihu . $img_dir . '/' . $size . '/' . $row['id'] . $row['ext'];
break;
}
$aspect = ($row['h'] == $row['w']) ? ' square' : (($row['h'] > $row['w']) ? ' portrait' : ' landscape');
$img_info = $row['id'].$row['ext'].' ('.$row['w'].' × '.$row['h'].')';
$img[] = '<div class="'.$this->plugin_event.'_image' . $aspect . '">'
.n. '<button
class="destroy"
title="'.gTxt('delete').'"
aria-label="' . gTxt('delete') . '"><span class="ui-icon ui-icon-close">' . gTxt('delete') . '</button>'
.n. '<img class="content-image"
src="' . $url . '"
alt="' . $img_info . '"
title="' . $img_info . '"
data-ref="' . $row['id'] . '"
data-name="' . $row['name'] . '"
/>'
.n. '</div>';
}
$out = implode(n, $img);
send_json_response(array('content' => $out));
}
/**
* Store the state of the UI for layter recall.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
*/
public function saveState($evt, $stp)
{
$loadMethod = ps('loadMethod');
$size = ps('size');
$field = ps('field');
$sort = ps('sort_order');
$dir = ps('sort_dir');
$plate = ps('plate');
if ($loadMethod !== "null") {
set_pref('smd_imagery_load_method', $loadMethod, $this->plugin_event, 2, null, 0, PREF_PRIVATE);
}
if ($size !== "null") {
set_pref('smd_imagery_size', $size, $this->plugin_event, 2, null, 0, PREF_PRIVATE);
}
if ($field !== "null") {
set_pref('smd_imagery_field', $field, $this->plugin_event, 2, null, 0, PREF_PRIVATE);
}
if ($sort !== "null") {
set_pref('smd_imagery_sort_order', $sort, $this->plugin_event, 2, null, 0, PREF_PRIVATE);
}
if ($dir !== "null") {
set_pref('smd_imagery_sort_dir', $dir, $this->plugin_event, 2, null, 0, PREF_PRIVATE);
}
if ($plate !== "null") {
set_pref('smd_imagery_template', $plate, $this->plugin_event, 2, null, 0, PREF_PRIVATE);
}
send_json_response(array('lm' => $loadMethod, 'sz' => $size, 'fl' => $field, 'so' => $sort, 'dr' => $dir, 'pl' => $plate));
}
/**
* Look up any categories and return all id values via JSON.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
* @return JSON List of IDs
*/
public function replaceJSON($evt, $stp)
{
$data = ps('articleImage');
$idList = $this->replace($data);
send_json_response(json_encode($idList));
}
/**
* Look up any categories and return all id values during save.
*
* @param string $evt Textpattern event (panel)
* @param string $stp Textpattern step (action)
* @return string List of IDs
*/
public function replacePOST($evt, $stp)
{
$data = ps('Image');
$idList = $this->replace($data);
$_POST['Image'] = $idList;
}
/**
* Replace any categories in the passed string with id values.
*
* Any duplicate values are only included once.
*
* @param string $data The list of IDs / cat names
* @return string List of IDs
*/
protected function replace($data)
{
if ($data) {
$reps = array();
// Create an array of IDs/cat names.
$items = do_list($data);
// Get just the category names (if any).
$cats = array_filter($items, array($this, 'filterCats'));
if ($cats) {
// Could use group_concat, but it's MySQL specific. Think of the future.
$rs = safe_rows('id, category', 'txp_image', 'category IN(' . implode(',', quote_list($cats)) . ')');
// Extract IDs into a category-indexed array, as long as they
// haven't been seen before.
foreach ($rs as $row) {
if (!in_array($row['id'], $items)) {
$reps[$row['category']][] = $row['id'];
}
}
// Replace any element in the original $items array that
// matches a category name pulled from the DB, with the
// concatenated list of id values it represents.
if ($reps) {
foreach ($reps as $cat => $ids) {
$pos = array_search($cat, $items);
if ($pos !== false) {
$items[$pos] = implode(',', $ids);
}
}
}
// Squish al items into a single comma-separated list.
$data = implode(',', $items);
}
}
return $data;
}
/**
* array_filter callback to remove purely numeric id values.
*
* @param string $v Value to compare, from array_filter() function
* @return bool
*/
protected function filterCats($v)
{
return !is_numeric($v);
}
/**
* Get a list of valid image fields to sort by.
*
* @return array
*/
protected function sortOpts()
{
return array(
'name' => gTxt('name'),
'id' => gTxt('ID'),
'date' => gTxt('date'),
'author' => gTxt('author'),
'ext' => gTxt('extension'),
'w' => gTxt('width'),
'h' => gTxt('height'),
);
}
}