Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
8793fe6
Implement separate image libraries with validated uploads
alcatron Oct 11, 2026
7a6f5b8
Preserve full-width image library dialog layout
alcatron Oct 11, 2026
eaf031b
Protect image references and expose library usage metadata
alcatron Oct 11, 2026
d74b3eb
Clean up incomplete image writes in the finalizer
alcatron Oct 11, 2026
4548ed4
Guard image library parents and reuse configuration reference indexes
alcatron Oct 11, 2026
1d14e0c
Reuse the image reference snapshot after deletion
alcatron Oct 11, 2026
b1f1e2a
Validate references before persisting image uploads
alcatron Oct 11, 2026
7db90e4
Synchronize image deletion with portable atomic configuration saves
alcatron Oct 12, 2026
7f3db7f
Verify atomic saves preserve template and traffic objects
alcatron Oct 12, 2026
075099e
Keep atomic-save regression checks portable across platforms
alcatron Oct 12, 2026
ad6a77b
Accept uppercase raster extensions when saving backgrounds
alcatron Oct 12, 2026
1303b62
Synchronize repairs and config deletion with destination directory locks
alcatron Oct 12, 2026
17a8546
Return a retry when configuration deletion encounters a busy directory
alcatron Oct 12, 2026
92520cd
Merge updated develop and preserve editor changes
alcatron Oct 12, 2026
3ec4757
Merge develop after gallery resize landed
alcatron Oct 12, 2026
04a44cd
Surface editor save failures and isolate upgrade regression stubs
alcatron Oct 12, 2026
25e27e1
Compare configuration revisions before saving edits and repairs
alcatron Oct 12, 2026
fff23c6
Protect literal image includes and prevent failed clone registrations
alcatron Oct 12, 2026
d558f0e
Clean failed clone registrations and preserve successful raw edits
alcatron Oct 12, 2026
f195c65
Refresh translations for registration failure reporting
alcatron Oct 12, 2026
858c245
Cover legacy background and icon repair failure paths
alcatron Oct 12, 2026
e59aba9
Bind clone IDs in prepared registration statements
alcatron Oct 13, 2026
35b0d2a
Render controls and revisions from one editor snapshot
alcatron Oct 13, 2026
07cf58b
Document the editor snapshot argument
alcatron Oct 13, 2026
8107a22
Reject lost copy sources and finish raw saves before rendering
alcatron Oct 13, 2026
f5b97d6
Keep logical configuration mutex for symlink writes
alcatron Oct 13, 2026
6c0ed54
Preserve selected custom images when refreshing libraries
alcatron Oct 13, 2026
b5847a8
Guard included snapshots and deleted image selections at commit time
alcatron Oct 13, 2026
c3583ae
Keep read-only dependency checks and creation retry guidance safe
alcatron Oct 13, 2026
42d95c1
Validate serialized image paths and lock physical reference sources
alcatron Oct 13, 2026
a2120dc
Address repair, serialization and dialog submission review findings
alcatron Oct 13, 2026
5b4098a
Preserve every dialog submit action during placement and Enter saves
alcatron Oct 13, 2026
1001f1d
Merge develop and address save and rendering review findings
alcatron Oct 13, 2026
aa0a3db
Validate existing repair assets and inherited clone images
alcatron Oct 13, 2026
34d9d84
Publish complete uploads and validate included clone images
alcatron Oct 13, 2026
0110a1a
Require mutation revisions and keep clone diagnostics server-side
alcatron Oct 14, 2026
ac27769
Bind new map copies to source snapshots
alcatron Oct 14, 2026
b2193d3
Preserve root identity and recoverable clone configuration
alcatron Oct 14, 2026
684ffc9
Merge develop into feat/images-20261011
TheWitness Oct 14, 2026
6ce7fff
Validate image mutation revisions and filter unsupported library files
alcatron Oct 14, 2026
f07f47a
Validate raster contents in initial image selectors
alcatron Oct 14, 2026
13ef8f2
Align image identities, include locks and symlink selectors
alcatron Oct 14, 2026
a4bdb07
Remove dangling image fixture symlinks during cleanup
alcatron Oct 14, 2026
ae1ada0
Keep image and include directive parsing within individual lines
alcatron Oct 14, 2026
596b665
Reject non-regular configuration reference sources before reading
alcatron Oct 14, 2026
6c01c0f
Merge develop colours and pulsing into image libraries
alcatron Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++!
* Fix Safari rendering of high-traffic link glows using native SVG filters
* Make configuration saves and repairs atomic; coordinate writes, registration and deletion with destination-directory locks and report failed editor saves

* Add separate icon and background image upload and management libraries
* Add editable map notes with alignment and sharp text at logical map dimensions
* Fix editor placement reset, destructive action controls and theme contrast
* Reduce gallery preview cards by 20% while preserving generated thumbnail resolution
Expand Down
16 changes: 13 additions & 3 deletions css/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,19 @@ body#mainView .ui-dialog textarea.ui-state-default {
color: var(--text-color-lighter, #333) !important;
}

.wm-image-delete { color: #c62828 !important; background: #fff !important; border: 1px solid #c62828 !important; font-size: 20px; }
#dlgImages .wm-image-library { width: 100%; box-sizing: border-box; display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; max-height: 240px; overflow: auto; align-content: start; padding: 4px; }
#dlgImages .wm-image-card { min-width: 0; padding: 8px; border: 1px solid #999; display: flex; flex-direction: column; gap: 6px; overflow-wrap: anywhere; align-items: center; }
#dlgImages .wm-image-card img { width: 100%; max-width: 130px; height: 70px; object-fit: contain; flex: none; }
#dlgImages .wm-image-uploader { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 10px 0; }
/* Use Cacti's own theme heading colours in every grouped editor dialog. */
.dlgProperties .wm-editor-section { display: table; width: 100%; box-sizing: border-box; margin: 10px 0 6px; }
.dlgProperties table .wm-editor-section { margin: 0; }
#wm-images-error:not(:empty) { color: inherit; border-left: 3px solid #c62828; padding-left: 8px; font-weight: bold; }

#dlgImages > .dlgBody { display: block; width: 100%; box-sizing: border-box; }
#dlgImages .wm-editor-section { float: none; }

/* Colour and image libraries. */
#wm-scale-rows input { width: 85px; box-sizing: border-box; }
#wm-scale-rows .wm-range-tag { width: 110px; }
Expand All @@ -362,9 +375,6 @@ body#mainView .ui-dialog textarea.ui-state-default {
.wm-traffic-swatch { width: 55px; height: 22px; border: 1px solid #777; }
#wm-traffic-advanced > summary { cursor: pointer; margin: 14px 0; }

/* Use Cacti's own theme heading colours in every grouped editor dialog. */
.dlgProperties .wm-editor-section { display: table; width: 100%; box-sizing: border-box; margin: 10px 0 6px; }
.dlgProperties table .wm-editor-section { margin: 0; }
#wm-colours-error:not(:empty) { color: inherit; border-left: 3px solid #c62828; padding-left: 8px; font-weight: bold; }

#dlgColours > .dlgBody { display: block; }
Expand Down
66 changes: 65 additions & 1 deletion js/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,7 @@ function manage_images() {
$('#action').val('set_image');

show_dialog('dlgImages');
wmOpenImages();
}

function prefs() {
Expand Down Expand Up @@ -1328,7 +1329,7 @@ function show_dialog(dlg) {

$('#'+dlg).dialog({
autoOpen: true,
width: 600,
width: dlg === 'dlgImages' ? Math.min(900, $(window).width() - 40) : 600,
height: 'auto',
modal: false,
resizable: false,
Expand Down Expand Up @@ -1833,6 +1834,69 @@ function wmFriendlyLinkNames() {
function wmDecodeEditorUrl(value) {
return String(value || '').replace(/&/g, '&');
}
function wmAssetRequest(action, extra, callback, errorId) {
var values = {action: action, mapname: $('#mapname').val()};
if (action !== 'image_data' && typeof WMConfigRevision !== 'undefined') values.config_revision = WMConfigRevision;
if (typeof csrfMagicToken !== 'undefined') values.__csrf_magic = csrfMagicToken;
$.ajax({url: editor_url, type: action === 'image_data' ? 'GET' : 'POST', dataType: 'json', data: $.extend(values, extra),
success: callback, error: function(xhr) { $('#' + errorId).text(xhr.responseJSON && xhr.responseJSON.error || wmEditorText.imageText3); }});
}
function wmAssetImages(images) {
$('#wm-images-objects, #wm-images-backgrounds').empty();
images.forEach(function(item) {
var card = $('<div class="wm-image-card">').appendTo('#wm-images-' + item.kind);
$('<img>').attr({src: item.url, alt: item.name, loading: 'lazy'}).appendTo(card);
$('<span>').text(item.name).appendTo(card);
if (item.width && item.height) $('<span>').text(item.width + ' × ' + item.height + ' px').appendTo(card);
$('<span>').text(item.users && item.users.length ? wmEditorText.imageUsed.replace('%s', item.users.join(', ')) : wmEditorText.imageUnused).appendTo(card);
if (item.deletable !== false) $('<button type="button" class="ui-button wm-image-delete">').text(wmEditorText.imageText0).attr('aria-label', wmEditorText.imageText9.replace('%s', item.name)).appendTo(card).on('click', function() {
if (!window.confirm(wmEditorText.imageText10.replace('%s', item.name))) return;
wmAssetRequest('delete_image', {image_kind: item.kind, image_name: item.name}, function(data) { wmAssetImages(data.images); }, 'wm-images-error');
});
});
['objects', 'backgrounds'].forEach(function(kind) {
var count = images.filter(function(item) { return item.kind === kind; }).length;
$('#wm-images-' + kind + '-count').text(wmEditorText.imageText11.replace('%s', String(count)));
if (!count) $('#wm-images-' + kind).append($('<p>').text(wmEditorText.imageText1));
});
// Rebuild the existing image selectors so uploads are available without reloading the editor.
[{id: 'node_iconfilename', kind: 'objects', label: wmEditorText.imageText14}, {id: 'map_bgfile', kind: 'backgrounds', label: wmEditorText.imageText15}].forEach(function(spec) {
var select = $('#' + spec.id), old = select.val();
var slick = select.data('ddslick');
if (slick) { old = slick.selectedData && slick.selectedData.value; select.ddslick('destroy'); select = $('#' + spec.id); }
var current = select.find('option').filter(function() { return this.value === old; }).first().clone();
select.find('option').filter(function() { return this.value.indexOf('images/' + spec.kind + '/') === 0 && /\.(png|gif|jpe?g)$/i.test(this.value); }).remove();
if (!select.find('option[value="--NONE--"]').length) select.prepend($('<option>').val('--NONE--').text(spec.label));
images.filter(function(item) { return item.kind === spec.kind; }).forEach(function(item) {
select.append($('<option>').val('images/' + item.kind + '/' + item.name).text(item.name).attr({'data-imagesrc': item.url, 'data-description': item.name}));
Comment thread
alcatron marked this conversation as resolved.
});
if (old && !select.find('option').filter(function() { return this.value === old; }).length) {
select.append(current.length ? current : $('<option>').val(old).text(old));
}
select.val(old);
});
$('#dlgImages').dialog('option', {maxHeight: $(window).height() - 80, position: {my: 'center', at: 'center', of: window}});
}
function wmOpenImages() {
$('#wm-images-error').empty();
wmAssetRequest('image_data', {}, function(data) { wmAssetImages(data.images); }, 'wm-images-error');
$('.wm-image-upload').off('click').on('click', function() {
var button = $(this), kind = button.attr('data-kind'), input = $('#wm-image-file-' + kind);
var file = input[0].files[0];
if (!file) { $('#wm-images-error').text(wmEditorText.imageText2); return; }
var data = new FormData();
data.append('action', 'upload_image'); data.append('mapname', $('#mapname').val());
data.append('image_kind', kind); data.append('image_file', file);
if (typeof WMConfigRevision !== 'undefined') data.append('config_revision', WMConfigRevision);
if (typeof csrfMagicToken !== 'undefined') data.append('__csrf_magic', csrfMagicToken);
button.prop('disabled', true);
$.ajax({url: editor_url, type: 'POST', data: data, dataType: 'json', contentType: false, processData: false,
success: function(result) { $('#wm-images-error').text(wmEditorText.imageText13.replace('%s', result.name)); input.val(''); wmAssetImages(result.images); },
error: function(xhr) { $('#wm-images-error').text(xhr.responseJSON && xhr.responseJSON.error || wmEditorText.imageText4); },
complete: function() { button.prop('disabled', false); }});
});
}

var wmAssetPalette = {Black: '#000000', White: '#ffffff', Blue: '#0000ff', Red: '#cc0000', Green: '#008000', Purple: '#800080', Orange: '#b35a00', Grey: '#666666', None: 'none'};
function wmAssetColour(value, allowEmpty, swatches, solid, label) {
var box = $('<div class="wm-asset-colour">'), select = $('<select class="wm-colour-value">').attr('aria-label', label).appendTo(box);
Expand Down
59 changes: 58 additions & 1 deletion lib/WeatherMap.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ class WeatherMap extends WeatherMapBase {
var $included_scale_names = [];
/** @var array Loaded root paths, physical targets and revisions. */
var $config_root_identity = [];
var $config_managed_assets = [];
var $config_asset_conflict = false;
var $editor_config_revision = null;
/** @var mixed */
var $imagefile;
Expand Down Expand Up @@ -2451,6 +2453,45 @@ function DrawTitle($image, $font, $colour) {
$this->imap->addArea('Rectangle', 'TITLE', '', [$x, $y, $x + $boxwidth, $y - $boxheight]);
}

/**
* Collect managed image references from proposed serialized output.
*
* @param string $serialized Proposed output, including raw overrides.
*
* @return string[] Paths relative to the plugin directory.
*/
static function ManagedImagePathsFromConfig($serialized) {
$paths = [];
if (preg_match_all('/^[ \t]*(?:ICON|BACKGROUND)[ \t]+(?:[0-9]+[ \t]+[0-9]+[ \t]+)?([^\r\n]+?)[ \t]*\r?$/mi', $serialized, $matches)) $paths = $matches[1];
require_once __DIR__ . '/editor.assets.php';
$identities = [];
foreach ($paths as $path) {
$identity = wm_asset_managed_path($path, dirname(__DIR__));
if ($identity !== null) $identities[] = $identity;
}
return array_values(array_unique($identities));
}

/**
* Collect managed image references from map objects or proposed output.
*
* @param string|null $serialized Proposed output, including raw overrides.
*
* @return string[] Paths relative to the plugin directory.
*/
function ManagedImagePaths($serialized = null) {
$paths = [$this->background];
foreach ($this->nodes as $node) $paths[] = $node->iconfile;
if ($serialized !== null) return self::ManagedImagePathsFromConfig($serialized);
require_once __DIR__ . '/editor.assets.php';
$identities = [];
foreach ($paths as $path) {
$identity = wm_asset_managed_path($path, dirname(__DIR__));
if ($identity !== null) $identities[] = $identity;
}
return array_values(array_unique($identities));
}

/**
* @param mixed $input
* @param bool $is_include
Expand Down Expand Up @@ -3575,6 +3616,7 @@ function ReadConfig($input, $is_include = false) {

wm_debug('Finished Pre-Processing Plugins...');

if (!$is_include) $this->config_managed_assets = $this->ManagedImagePaths();
return (true);
}

Expand Down Expand Up @@ -3903,6 +3945,20 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $
$contents = stream_get_contents($fd);
$closed = fclose($fd);

$this->config_asset_conflict = false;
$savedAssets = $contents !== false ? $this->ManagedImagePaths($contents) : [];
$sameDestination = \WeathermapConfig\sameFile($filename, $this->configfile);
$newAssets = ($createOnly || !$sameDestination) ? array_unique(array_merge($this->ManagedImagePaths(), $savedAssets)) : array_diff($savedAssets, $this->config_managed_assets);
$validate = function () use ($newAssets) {
foreach ($newAssets as $asset) {
if (preg_match('/\{[^}]+\}/', $asset)) continue;
if (!is_file(dirname(__DIR__) . '/' . $asset)) {
$this->config_asset_conflict = true;
return false;
}
}
return true;
};
$sameFile = \WeathermapConfig\sameFile($filename, $this->configfile);
$dependencies = $this->config_dependencies;
foreach ($this->config_root_identity as $logical => $state) {
Expand All @@ -3915,7 +3971,8 @@ function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $
if (!isset($dependencies[$physicalSource])) $dependencies[$physicalSource] = $this->config_revision;
if ($logicalSource !== $physicalSource && !isset($dependencies[$logicalSource])) $dependencies[$logicalSource] = ['target' => $physicalSource, 'revision' => $this->config_revision];
}
$saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock, $dependencies);
$saved = $write_ok && $contents !== false && $closed && \WeathermapConfig\atomicWrite($filename, $contents, $sameFile, $expectedRevision ?? ($sameFile ? $this->config_revision : null), $createOnly, $heldLock, $dependencies, $validate);
if ($saved) $this->config_managed_assets = $savedAssets;
if ($saved && $sameFile) $this->config_revision = hash('sha256', $contents);
if ($saved) foreach ($this->config_root_identity as $logical => $state) {
if (realpath($filename) === $state['target']) $this->config_root_identity[$logical]['revision'] = hash('sha256', $contents);
Expand Down
66 changes: 65 additions & 1 deletion lib/editor.actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ function wm_editor_read_config($map, $mapfile, $requireRevision = false) {
function wm_editor_write_config($map, $mapfile, $createOnly = false) {
if (!$map->WriteConfig($mapfile, $createOnly, $map->editor_config_revision)) {
$current = is_file($mapfile) ? @hash_file('sha256', $mapfile) : false;
$stale = !\WeathermapConfig\dependenciesMatch(array_merge($map->config_dependencies, $map->config_root_identity)) || $map->editor_config_revision !== null && $current !== false && $current !== $map->editor_config_revision;
$stale = $map->config_asset_conflict || !\WeathermapConfig\dependenciesMatch(array_merge($map->config_dependencies, $map->config_root_identity)) || $map->editor_config_revision !== null && $current !== false && $current !== $map->editor_config_revision;
http_response_code($stale ? 409 : 500);
return false;
}
Expand Down Expand Up @@ -1200,6 +1200,70 @@ function getImageURL($mapname, $selected) {
return $imageurl;
}

/**
* Validate a PHP upload and store its decoded image.
*
* @param string $kind The destination library category.
* @param array $file The PHP upload metadata from $_FILES.
*
* @return string The stored image filename.
*/
function wm_asset_upload_image($kind, $file) {
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) throw new InvalidArgumentException(__('Choose a PNG, JPG or GIF file up to 5 MB.', 'weathermap'));
return wm_asset_store_image($kind, $file['tmp_name'], $file['name']);
}
/**
* Read or mutate an image library and emit a JSON response.
*
* @param string $mapfile The current validated map path.
* @param string $action The library operation requested by the editor.
*
* @return void Errors are returned as HTTP 400 JSON responses.
*/
function wm_editor_assets($mapfile, $action) {
header('Content-Type: application/json');
try {
if ($action !== 'image_data' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') throw new InvalidArgumentException(__('Use POST to save changes.', 'weathermap'));
if ($action !== 'image_data') {
$map = new WeatherMap;
$map->context = 'editor';
if (!wm_editor_read_config($map, $mapfile, true)) {
echo json_encode(['error' => http_response_code() === 409
? __('This map has changed since you opened it. Reload the editor before saving your changes.', 'weathermap')
: __('Cannot read the map configuration.', 'weathermap')]);
return;
}
}
if ($action === 'image_data') {
$result = ['images' => wm_asset_image_list()];
} elseif ($action === 'upload_image') {
$references = wm_asset_image_reference_index();
$name = wm_asset_upload_image(get_nfilter_request_var('image_kind'), $_FILES['image_file'] ?? []);
Comment thread
alcatron marked this conversation as resolved.
$result = ['name' => $name, 'images' => wm_asset_image_list($references)];
} else {
$name = get_nfilter_request_var('image_name');
$path = wm_asset_image_path(get_nfilter_request_var('image_kind'), $name);
require_once __DIR__ . '/WeatherMap.config.php';
$lock = \WeathermapConfig\acquire();
if (!$lock) throw new RuntimeException(__('Could not lock map configurations.', 'weathermap'));
$referenceLocks = [];
try {
$references = wm_asset_locked_reference_index(dirname(__DIR__) . '/configs', $referenceLocks);
if (wm_asset_image_users($name, get_nfilter_request_var('image_kind'), $references)) throw new InvalidArgumentException(__('This image is still referenced by a map configuration.', 'weathermap'));
if (!is_file($path) || !unlink($path)) throw new RuntimeException(__('Could not delete the image.', 'weathermap'));
} finally {
foreach ($referenceLocks as $referenceLock) \WeathermapConfig\release($referenceLock);
\WeathermapConfig\release($lock);
}
$result = ['images' => wm_asset_image_list($references)];
}
echo json_encode($result);
} catch (Throwable $error) {
http_response_code($error instanceof InvalidArgumentException ? 400 : 500);
echo json_encode(['error' => $error->getMessage()]);
}
}

/**
* Read or save editor colour settings and emit a JSON response.
*
Expand Down
Loading