Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
7702251
Allow link comments to follow link curves
alcatron Oct 16, 2026
c5a530c
Use 1000M as the inherited link bandwidth default
alcatron Oct 16, 2026
306dabc
Clarify link workflows and add node display-name positioning
alcatron Oct 16, 2026
747a70c
Make hover graphs clickable and sharper on high-density displays
alcatron Oct 16, 2026
adda0ba
Bundle additional 96px network-device node icons
alcatron Oct 16, 2026
7b7d48a
Prepare Weathermap 2.0 identification and release notes
alcatron Oct 16, 2026
f2c69ab
Reference PR #309 in changelog
alcatron Oct 16, 2026
f6536b0
Reference PR #310 in changelog
alcatron Oct 16, 2026
ea57958
Reference PR #311 in changelog
alcatron Oct 16, 2026
793adff
Reference PR #312 in changelog
alcatron Oct 16, 2026
d0233b7
Reference PR #313 in changelog
alcatron Oct 16, 2026
0a2151e
Reference PR #314 in changelog
alcatron Oct 16, 2026
281dca9
Preserve translation template project metadata
alcatron Oct 16, 2026
b60e7df
Preserve translation template project metadata
alcatron Oct 16, 2026
1213c8c
Preserve translation template project metadata
alcatron Oct 16, 2026
9cc2477
Preserve translation template project metadata
alcatron Oct 16, 2026
4f6fbf8
Preserve translation template project metadata
alcatron Oct 16, 2026
f91bf18
Use established changelog categories throughout 2.0-dev
alcatron Oct 16, 2026
a4f0b6b
Preserve Cacti graph layout after Weathermap navigation
alcatron Oct 16, 2026
4a3ae60
Fix traffic glow masking around transparent node icons
alcatron Oct 16, 2026
f3a4ca6
Preserve curved comment alignment and nudge direction
alcatron Oct 16, 2026
c890148
Set plugin working directory in bandwidth regression test
alcatron Oct 16, 2026
d36def3
Preserve configured hover previews when opening properties
alcatron Oct 16, 2026
c60ad71
Fix hover density caching and keyboard interaction
alcatron Oct 16, 2026
37035e7
Preserve curved glyph kerning and reject reversed offset text
alcatron Oct 17, 2026
6b0c716
Add the required license notice to bandwidth regression
alcatron Oct 17, 2026
a9d9cb8
Preserve traffic identity and hover settings in link properties
alcatron Oct 17, 2026
7f93936
Keep mixed-input previews open and protect sharp note text
alcatron Oct 17, 2026
ceb4fe8
Parse traffic scaling without ambiguous numeric repetition
alcatron Oct 17, 2026
940c695
Fall back before inward curvature compresses glyph spacing
alcatron Oct 17, 2026
cb4db22
Honor outer source multipliers and label visible graph pickers
alcatron Oct 17, 2026
fcec824
Complete tooltip blur dismissal and keyboard re-entry
alcatron Oct 17, 2026
29c7bda
Share quarter-circle geometry in rendering regressions
alcatron Oct 17, 2026
5905a60
Use the Cacti request helper for the curve checkbox
alcatron Oct 17, 2026
66c92e3
Resolve Cacti RRA identities and bound editor label presets
alcatron Oct 17, 2026
62b25d1
Ignore stale tooltip events and quoted zoom text
alcatron Oct 17, 2026
7caf4d0
Add two-step Undo and Redo to the map editor
alcatron Oct 17, 2026
5fbf472
Use PHP-generated sessions in editor history tests
alcatron Oct 17, 2026
6e41323
Isolate duplicated tabs and enforce undo history expiry and quota
alcatron Oct 17, 2026
34439f8
Cancel pending hover opens and recognize RRDtool continuations
alcatron Oct 17, 2026
1cc3e3e
Import the history worker directly in browser regression tests
alcatron Oct 17, 2026
1f91da0
Preserve history for unchanged forms and enforce JSON storage quota
alcatron Oct 17, 2026
4d7262e
Keep undo history when raw configuration dialogs are unchanged
alcatron Oct 17, 2026
4805a4d
Preserve untouched colour history and validate included restore assets
alcatron Oct 17, 2026
a5fb6a3
Gate startup saves and preserve scale order and restored title cache
alcatron Oct 17, 2026
e0f338a
Merge develop and consolidate release notes in PR 314
alcatron Oct 17, 2026
a98d037
Merge develop and consolidate release notes in PR 314
alcatron Oct 17, 2026
1c52f1b
Merge develop and consolidate release notes in PR 314
alcatron Oct 17, 2026
4261eed
Merge develop and consolidate release notes in PR 314
alcatron Oct 17, 2026
b07c018
Merge develop and consolidate release notes in PR 314
alcatron Oct 17, 2026
ebd1d10
Merge develop and prepare ordered Weathermap 2.0 release notes
alcatron Oct 17, 2026
e1f1df3
Merge develop and consolidate release notes in PR 314
alcatron Oct 17, 2026
1f4377f
Validate PR 309 in release merge order
alcatron Oct 17, 2026
b3415cc
Validate PR 310 in release merge order
alcatron Oct 17, 2026
b6605e0
Merge curved-comment dependency while keeping colours in Manage Colours
alcatron Oct 17, 2026
db0e967
Validate PR 311 in release merge order
alcatron Oct 17, 2026
f1b623c
Validate PR 312 in release merge order
alcatron Oct 17, 2026
6202385
Validate PR 313 in release merge order
alcatron Oct 17, 2026
16b0c5d
Validate PR 314 in release merge order
alcatron Oct 17, 2026
eef9a6d
Integrate preceding 2.0 PRs so undo and redo can merge last
alcatron Oct 17, 2026
ee19015
Cover both graph-picker JSON request signatures in the fixture
alcatron Oct 17, 2026
386c20f
Include the validated picker regression fixture in the final release
alcatron Oct 17, 2026
a557b17
Resolve current develop conflicts before final undo integration valid…
alcatron Oct 17, 2026
13d2aeb
Resolve 2.0 release merge conflict and clarify Undo changelog
alcatron Oct 17, 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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++!
* feature#311: Simplify link interface/source/hover workflows, limit the form to two previews, add node display-name positioning, move comment colours to Manage Colours and clarify raw-edit controls
* feature#312: Make hover graphs individually clickable and sharper on high-density displays without changing their displayed size
* feature#313: Bundle transparent 96px access-point, NAS, server, printer, CCTV recorder, UPS and CCTV camera node icons
* feature#307: Add two-step Undo and Redo for saved map edits, with keyboard shortcuts and protection against stale restores (PR #316)
* feature#307: Add two-step Undo and Redo per editor tab for saved node, link, note and map-setting edits, with keyboard shortcuts and protection against stale restores (PR #316)
* feature#302: Add traffic colour presets and optional high-load link pulsing
* feature#303: Add separate icon and background image upload and management libraries
* feature#301: Add editable map notes with alignment and sharp text at logical map dimensions
Expand Down
43 changes: 43 additions & 0 deletions css/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,49 @@ body#mainView .wm-icon-choice[aria-pressed="true"] {
border-color: var(--text-color-highlight-a, #2874aa);
}

/* History uses the same square, transparent tiles as the editor toolbar. */
#toolbar ul li.wm-history-controls {
float: right;
display: flex;
gap: 0;
padding: 0;
}
#toolbar .wm-history-controls button {
appearance: none;
box-sizing: border-box;
height: 39px;
min-width: 55px;
margin: 0;
padding: 5px 10px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.3));
border-radius: 0;
background: transparent;
box-shadow: none;
color: inherit;
font: inherit;
line-height: normal;
text-align: center;
white-space: nowrap;
cursor: pointer;
}
#toolbar .wm-history-controls button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
}
#toolbar .wm-history-controls button:disabled {
opacity: .5;
cursor: default;
}
.wm-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Link properties has one scrolling body and a persistent action row. */
#dlgLinkProperties {
overflow: hidden !important;
Expand Down
33 changes: 33 additions & 0 deletions docs/editor-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,36 @@ graph only enables **Add** and **Replace**; it does not apply it automatically.
Delete controls are separate from Save and Cancel. Confirmation identifies the
item by display name and explains what is removed. Deleting a node also removes
its connected map links. Cacti devices and graphs remain.


## Undo and Redo

Use **Undo** and **Redo** at the top right of the editor toolbar to reverse or
reapply the last two saved map changes. Each completed move, placement or saved
dialog counts as one action. Buttons are disabled when no matching action is
available; their tooltips identify the next action. Keyboard shortcuts are
**Ctrl/Cmd+Z** for Undo and **Ctrl/Cmd+Shift+Z** for Redo. Text fields retain their
normal text-editing shortcuts. Save or cancel an open dialog first.

History covers map configuration changes: nodes, connected links, notes, via
points, labels, icons, map properties, colours and raw object configuration.
Undoing a node deletion restores its connected map links too. A new change after
Undo clears Redo. No-op saves and failed saves do not consume a step.

History belongs to the current browser tab, map and authenticated session.
Duplicated tabs start a separate history; reloading the same tab keeps it.
Tab ownership uses Web Locks, with a SharedWorker fallback for HTTP installations.
If neither browser API is available, history stays disabled and normal editing
continues. Changes made elsewhere invalidate it rather than overwriting someone else's
work. Included configuration files and managed images are checked before a
restore, including image references inherited through INCLUDE files. Older
snapshots without a complete image-reference list are discarded safely.
Uploading or deleting library files, deleting map files, Cacti devices,
graphs and personal editor preferences are outside this history.

Snapshots are stored privately outside the web root, not in browser data or
Cacti's concurrently written session rows. They expire after four hours of
inactivity. Storage retains up to eight recently changed maps/tabs and 8 MiB per
session; older histories are discarded when either limit is reached. Temporary
storage must be writable by the PHP service account. No database upgrade is
required.
141 changes: 141 additions & 0 deletions js/editor.history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright (C) 2004-2026 The Cacti Group.
* Licensed under the GNU General Public License, version 2 or later.
*/
(function(root) {
'use strict';

function randomId(win) {
return Array.from(win.crypto.getRandomValues(new Uint8Array(16)), function(byte) { return byte.toString(16).padStart(2, '0'); }).join('');
}

// sessionStorage is copied when a tab is duplicated. An origin-wide lock gives
// each live document exclusive ownership while preserving IDs across reloads.
function claimHistoryId(win, storageKey) {
var id = win.sessionStorage.getItem(storageKey);
if (!/^[a-f0-9]{32}$/.test(id || '')) id = randomId(win);
if (!win.navigator || !win.navigator.locks) {
// SharedWorker also works on HTTP installations where Web Locks is absent.
var worker = new win.SharedWorker(new URL('js/editor.history.worker.js', new URL(win.editor_url, win.location.href)), 'weathermap-history');
return new Promise(function(resolve, reject) {
worker.onerror = reject;
worker.port.onmessage = function(event) {
if (!event.data.granted) { id = randomId(win); claim(); return; }
try { win.sessionStorage.setItem(storageKey, id); }
catch (error) { worker.port.postMessage({release: true}); worker.port.close(); reject(error); return; }
win.addEventListener('pagehide', function() { worker.port.postMessage({release: true}); worker.port.close(); }, {once: true});
win.addEventListener('pageshow', function(event) { if (event.persisted) win.location.reload(); });
resolve(id);
};
function claim() { worker.port.postMessage({key: storageKey + ':' + id}); }
worker.port.start();
claim();
});
}
return new Promise(function(resolve, reject) {
function claim(candidate) {
win.navigator.locks.request(storageKey + ':' + candidate, {ifAvailable: true}, function(lock) {
if (!lock) { claim(randomId(win)); return; }
win.sessionStorage.setItem(storageKey, candidate);
return new Promise(function(release) {
win.addEventListener('pagehide', release, {once: true});
// A document restored from the back/forward cache must reacquire ownership.
win.addEventListener('pageshow', function(event) { if (event.persisted) win.location.reload(); });
resolve(candidate);
});
}).catch(reject);
}
claim(id);
});
}

async function init($, win) {
if (!$('#wm-undo').length || typeof win.WMConfigRevision !== 'string') return;
var id, initializing = true, pending = 0, state = {undo: 0, redo: 0};
var text = win.wmEditorText;

function render() {
['undo', 'redo'].forEach(function(direction) {
var label = direction === 'undo' ? text.historyUndo : text.historyRedo;
$('#wm-' + direction).prop('disabled', pending > 0 || !state[direction])
.attr('title', state[direction] ? label + ': ' + state[direction + 'Label'] : text.historyEmpty);
});
$('#wm-history-status').text(text.historyAvailable.replace('%s', state.undo).replace('%s', state.redo));
}

function request(action) {
var values = {action: action, mapname: $('#mapname').val(), config_revision: win.WMConfigRevision};
if (typeof win.csrfMagicToken !== 'undefined') values.__csrf_magic = win.csrfMagicToken;
return $.ajax({url: win.editor_url, type: 'POST', dataType: 'json', data: values});
}

// All editor save paths, including Manage Colors, receive the tab's history ID.
$.ajaxPrefilter(function(options, original, xhr) {
var destination = new URL(options.url, win.location.href);
var editor = new URL(win.editor_url, win.location.href);
if (options.type.toUpperCase() !== 'POST' || destination.href !== editor.href || options.processData === false) return;
var data = new URLSearchParams(options.data || '');
var action = data.get('action');
var readOnly = ['history_status', 'graphs', 'datasources', 'image_data', 'colour_data', 'delete_image'].indexOf(action) !== -1;
if (initializing && !readOnly) { xhr.abort(); return; }
if (!id) return; // Ownership failure leaves normal editing available.
data.set('history_id', id);
options.data = data.toString();
if (readOnly) return;
pending++;
render();
xhr.always(function() { pending--; render(); });
});

// Prevent editing while the worker/lock owner is being established.
var wasInert = win.document.body.inert;
win.document.body.inert = true;
try {
var storageKey = 'weathermap-history:' + win.location.pathname;
id = await claimHistoryId(win, storageKey);
} catch (error) {
$('#wm-undo,#wm-redo').attr('title', text.historyStorage);
return;
} finally {
initializing = false;
win.document.body.inert = wasInert;
}

function apply(direction) {
if (pending || !state[direction]) return;
if ($('.ui-dialog-content:visible').length) {
win.alert(text.historyDialog);
return;
}
request(direction).done(function() {
// Always fetch a new document, including when the current URL has a fragment.
var url = new URL(win.location.href);
url.searchParams.set('mapname', $('#mapname').val());
url.searchParams.set('action', 'nothing');
url.searchParams.delete('config_revision');
url.searchParams.delete('__csrf_magic');
url.hash = '';
if (win.location.href.split('#')[0] === url.href) win.location.reload();
else win.location.replace(url.href);
}).fail(function(xhr) {
state = {undo: 0, redo: 0};
render();
win.alert(xhr.responseJSON && xhr.responseJSON.error || text.saveError);
});
}

$('#wm-undo').on('click.wmhistory', function() { apply('undo'); });
$('#wm-redo').on('click.wmhistory', function() { apply('redo'); });
$(win.document).on('keydown.wmhistory', function(event) {
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== 'z') return;
if ($(event.target).closest('input,textarea,select,[contenteditable]:not([contenteditable="false"])').length || $('.ui-dialog-content:visible').length) return;
var direction = event.shiftKey ? 'redo' : 'undo';
if (!pending && state[direction]) { event.preventDefault(); apply(direction); }
});
request('history_status').done(function(data) { state = data; render(); })
.fail(function() { $('#wm-undo,#wm-redo').attr('title', text.saveError); });
}

if (typeof module !== 'undefined' && module.exports) module.exports = {init: init, claimHistoryId: claimHistoryId};
else root.jQuery(function() { init(root.jQuery, root); });
})(typeof window !== 'undefined' ? window : globalThis);
24 changes: 24 additions & 0 deletions js/editor.history.worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later. */
'use strict';
// One owner per tab history, including on HTTP where Web Locks is unavailable.
// Only opaque browser IDs live here; map snapshots remain on the server.
var owners = new Map();
function connect(event) {
var port = event.ports[0], owned;
port.onmessage = function(message) {
if (message.data.release) {
if (owned && owners.get(owned) === port) owners.delete(owned);
port.close();
return;
}
var key = message.data.key;
if (typeof key !== 'string' || owned) return;
var granted = !owners.has(key);
if (granted) { owners.set(key, port); owned = key; }
port.postMessage({granted: granted});
};
port.start();
}

if (typeof module !== 'undefined' && module.exports) module.exports = connect;
else self.onconnect = connect;
87 changes: 44 additions & 43 deletions lib/WeatherMap.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -3691,15 +3691,10 @@ function WriteDataFile($filename) {
}
}

/**
* @param string $filename
* @param bool $createOnly Require a new destination.
* @param string|null $expectedRevision Optional request snapshot revision.
* @param resource|null $heldLock Caller-owned destination directory mutex.
* @return bool
/** Generate the normal configuration text without writing a destination.
* @return string|false
*/
function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $heldLock = null) {
require_once __DIR__ . '/WeatherMap.config.php';
function ConfigText() {
$fd = fopen('php://temp', 'w+b');

$output = '';
Expand Down Expand Up @@ -3946,44 +3941,50 @@ 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 $write_ok && $contents !== false && $closed ? $contents : false;
}
return false;
}

/** Save generated configuration using the existing atomic revision checks. */
function WriteConfig($filename, $createOnly = false, $expectedRevision = null, $heldLock = null) {
require_once __DIR__ . '/WeatherMap.config.php';
$contents = $this->ConfigText();
if ($contents === false) return false;

$this->config_asset_conflict = false;
$savedAssets = $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) {
$dependencies[$logical] = $state;
if ($logical !== $state['target']) $dependencies[$state['target']] = $state['revision'];
}
if (!$sameFile && $this->configfile !== '' && is_string($this->config_revision)) {
$logicalSource = \WeathermapConfig\logicalPath($this->configfile);
$physicalSource = realpath($this->configfile) ?: $logicalSource;
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, $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);
}
return $saved;
} else {
wm_warn("Couldn't open config file $filename for writing");

return (false);
return true;
};
$sameFile = \WeathermapConfig\sameFile($filename, $this->configfile);
$dependencies = $this->config_dependencies;
foreach ($this->config_root_identity as $logical => $state) {
$dependencies[$logical] = $state;
if ($logical !== $state['target']) $dependencies[$state['target']] = $state['revision'];
}
if (!$sameFile && $this->configfile !== '' && is_string($this->config_revision)) {
$logicalSource = \WeathermapConfig\logicalPath($this->configfile);
$physicalSource = realpath($this->configfile) ?: $logicalSource;
if (!isset($dependencies[$physicalSource])) $dependencies[$physicalSource] = $this->config_revision;
if ($logicalSource !== $physicalSource && !isset($dependencies[$logicalSource])) $dependencies[$logicalSource] = ['target' => $physicalSource, 'revision' => $this->config_revision];
}
$saved = \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);
}
return $saved;
}

// pre-allocate colour slots for the colours used by the arrows
Expand Down
5 changes: 3 additions & 2 deletions lib/editor.actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
*/

require_once __DIR__ . '/WeatherMap.config.php';
require_once __DIR__ . '/editor.history.php';

/** editor.actions.php
*
Expand Down Expand Up @@ -89,7 +90,7 @@ function wm_editor_read_config($map, $mapfile, $requireRevision = false) {
* @return bool Whether the complete configuration was saved.
*/
function wm_editor_write_config($map, $mapfile, $createOnly = false) {
if (!$map->WriteConfig($mapfile, $createOnly, $map->editor_config_revision)) {
if (!\WeathermapEditorHistory\write($map, $mapfile, $createOnly, $map->editor_config_revision)) {
$current = is_file($mapfile) ? @hash_file('sha256', $mapfile) : false;
$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);
Expand Down Expand Up @@ -1304,7 +1305,7 @@ function wm_editor_colours($mapfile, $action) {
$data = json_decode($payload, true);
if (!is_array($data)) throw new InvalidArgumentException(__('Invalid colour settings.', 'weathermap'));
wm_asset_save_colours($map, $data);
if (!$map->WriteConfig($mapfile, false, $map->config_revision, $lock)) {
if (!\WeathermapEditorHistory\write($map, $mapfile, false, $map->config_revision, $lock)) {
$current = @hash_file('sha256', $mapfile);
if ($current !== $map->config_revision || !\WeathermapConfig\dependenciesMatch(array_merge($map->config_dependencies, $map->config_root_identity))) {
http_response_code(409);
Expand Down
Loading