aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/server.go75
-rw-r--r--internal/admin/static/admin.css11
-rw-r--r--internal/admin/static/admin.js65
-rw-r--r--internal/admin/static/cardeditor.js6
-rw-r--r--internal/admin/static/dropeditor.js5
-rw-r--r--internal/admin/static/editor.js108
-rw-r--r--internal/admin/static/hazardeditor.js5
-rw-r--r--internal/admin/static/itemeditor.js17
-rw-r--r--internal/admin/static/mobeditor.js8
-rw-r--r--internal/admin/static/moduleeditor.js8
-rw-r--r--internal/admin/static/objecteditor.js23
-rw-r--r--internal/admin/static/techeditor.js2
-rw-r--r--internal/admin/undo.go3
-rw-r--r--internal/admin/yaml_util.go62
14 files changed, 357 insertions, 41 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 53dd5c8..e29de9a 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -117,30 +117,37 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/rooms", s.handleRooms)
apiMux.HandleFunc("/api/rooms/new-empty", s.handleCreateEmptyRoom)
apiMux.HandleFunc("/api/rooms/", s.handleRoomByID)
+ apiMux.HandleFunc("/api/objects/rename", s.handleEntityRename("objects"))
apiMux.HandleFunc("/api/objects/move", s.handleEntityMove("objects"))
apiMux.HandleFunc("/api/objects/dirs", s.handleEntityDirs("objects"))
apiMux.HandleFunc("/api/objects", s.handleObjects)
apiMux.HandleFunc("/api/objects/", s.handleObjectByID)
+ apiMux.HandleFunc("/api/items/rename", s.handleEntityRename("items"))
apiMux.HandleFunc("/api/items/move", s.handleEntityMove("items"))
apiMux.HandleFunc("/api/items/dirs", s.handleEntityDirs("items"))
apiMux.HandleFunc("/api/items", s.handleItems)
apiMux.HandleFunc("/api/items/", s.handleItemByID)
+ apiMux.HandleFunc("/api/mobs/rename", s.handleEntityRename("mobs"))
apiMux.HandleFunc("/api/mobs/move", s.handleEntityMove("mobs"))
apiMux.HandleFunc("/api/mobs/dirs", s.handleEntityDirs("mobs"))
apiMux.HandleFunc("/api/mobs", s.handleMobs)
apiMux.HandleFunc("/api/mobs/", s.handleMobByID)
+ apiMux.HandleFunc("/api/drops/rename", s.handleEntityRename("drops"))
apiMux.HandleFunc("/api/drops/move", s.handleEntityMove("drops"))
apiMux.HandleFunc("/api/drops/dirs", s.handleEntityDirs("drops"))
apiMux.HandleFunc("/api/drops", s.handleDrops)
apiMux.HandleFunc("/api/drops/", s.handleDropByID)
+ apiMux.HandleFunc("/api/hazards/rename", s.handleEntityRename("hazards"))
apiMux.HandleFunc("/api/hazards/move", s.handleEntityMove("hazards"))
apiMux.HandleFunc("/api/hazards/dirs", s.handleEntityDirs("hazards"))
apiMux.HandleFunc("/api/hazards", s.handleHazards)
apiMux.HandleFunc("/api/hazards/", s.handleHazardByID)
+ apiMux.HandleFunc("/api/techs/rename", s.handleEntityRename("techs"))
apiMux.HandleFunc("/api/techs/move", s.handleEntityMove("techs"))
apiMux.HandleFunc("/api/techs/dirs", s.handleEntityDirs("techs"))
apiMux.HandleFunc("/api/techs", s.handleTechs)
apiMux.HandleFunc("/api/techs/", s.handleTechByID)
+ apiMux.HandleFunc("/api/modules/rename", s.handleEntityRename("modules"))
apiMux.HandleFunc("/api/modules/move", s.handleEntityMove("modules"))
apiMux.HandleFunc("/api/modules/dirs", s.handleEntityDirs("modules"))
apiMux.HandleFunc("/api/modules", s.handleModules)
@@ -481,9 +488,10 @@ func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc {
return
}
var body struct {
- Action string `json:"action"`
- Name string `json:"name"`
- Dir string `json:"dir"`
+ Action string `json:"action"`
+ Name string `json:"name"`
+ Dir string `json:"dir"`
+ NewName string `json:"new_name"`
}
if err := readJSON(r, &body); err != nil {
writeJSON(w, map[string]any{"error": "invalid"})
@@ -506,12 +514,50 @@ func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc {
s.undoStack.Push(ch)
}
writeJSON(w, map[string]any{"ok": true, "moved": len(changes) - 1})
+ case "rename_dir":
+ if err := renameEntityDir(s.dataDir, entityType, body.Dir, body.NewName); err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true, "name": body.NewName})
default:
writeJSON(w, map[string]any{"error": "unknown action"})
}
}
}
+func (s *AdminServer) handleEntityRename(entityType string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ ID string `json:"id"`
+ NewID string `json:"new_id"`
+ }
+ if err := readJSON(r, &body); err != nil || body.ID == "" || body.NewID == "" {
+ writeJSON(w, map[string]any{"error": "invalid"})
+ return
+ }
+ oldPath, newPath, data, err := renameEntityFile(s.dataDir, entityType, body.ID, body.NewID)
+ if err != nil {
+ writeJSON(w, map[string]any{"error": err.Error()})
+ return
+ }
+ desc := fmt.Sprintf("rename %s %s to %s", entityType, body.ID, body.NewID)
+ s.undoStack.Push(ChangeDesc{
+ Description: desc,
+ FilePath: oldPath,
+ NewFilePath: newPath,
+ OldContent: data,
+ NewContent: data,
+ })
+ s.invalidateEntityCaches(entityType)
+ writeJSON(w, map[string]any{"ok": true, "id": body.NewID})
+ }
+}
+
func (s *AdminServer) handleEntityMove(entityType string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -542,6 +588,7 @@ func (s *AdminServer) handleEntityMove(entityType string) http.HandlerFunc {
OldContent: data,
NewContent: data,
})
+ s.invalidateEntityCaches(entityType)
writeJSON(w, map[string]any{"ok": true})
}
}
@@ -565,6 +612,28 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) {
}
}
}
+
+ for _, p := range changeFiles(change) {
+ s.invalidateEntityCacheForPath(p)
+ }
+}
+
+func (s *AdminServer) invalidateEntityCaches(entityType string) {
+ switch entityType {
+ case "mobs":
+ s.mobStore.ReloadDefs(s.dataDir)
+ case "drops":
+ behavior.ClearDropIndex()
+ }
+}
+
+func (s *AdminServer) invalidateEntityCacheForPath(path string) {
+ for _, subdir := range []string{"mobs", "drops"} {
+ rel, err := filepath.Rel(filepath.Join(s.dataDir, subdir), path)
+ if err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
+ s.invalidateEntityCaches(subdir)
+ }
+ }
}
func changeFiles(change *ChangeDesc) []string {
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 3c6aaa4..bef3589 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -231,6 +231,17 @@ g.ud-hover:hover text{font-weight:bold}
.cm-menu button:hover{background:var(--accent)}
.cm-menu button.danger{color:#f55}
.cm-menu button.danger:hover{background:var(--danger)}
+.rename-header{display:flex;align-items:center;gap:6px}
+.rename-header-text{margin-bottom:0!important;padding-bottom:0!important;border-bottom:none!important}
+.rename-pencil{font-size:14px;cursor:pointer;color:#888;padding:2px 4px;border-radius:3px;transition:color .15s,transform .15s}
+.rename-pencil:hover{color:#fff;transform:scale(1.1)}
+.rename-edit-row{display:flex;align-items:center;gap:4px}
+.rename-input{flex:1;padding:4px 8px;font-size:14px;font-weight:bold;background:var(--input-bg);color:var(--text);border:1px solid var(--accent);border-radius:3px;font-family:monospace}
+.rename-btn-confirm,.rename-btn-cancel{padding:2px 8px;font-size:13px;border-radius:3px;cursor:pointer;font-family:monospace;line-height:1}
+.rename-btn-confirm{background:var(--success);color:#fff;border:none}
+.rename-btn-cancel{background:var(--danger);color:#fff;border:none}
+.item-rename-input{padding:4px 6px;font-size:12px;background:var(--input-bg);color:var(--text);border:1px solid var(--accent);border-radius:3px;font-family:monospace;width:100%}
+
.panel-left{width:200px;background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column;flex-shrink:0}
.panel-left .panel-header{font-size:11px;color:#aaa;text-transform:uppercase;letter-spacing:.5px;padding:12px 12px 8px;border-bottom:1px solid var(--border);flex-shrink:0}
.panel-left .dc-item{display:block;padding:6px 12px;font-size:11px;cursor:pointer;color:var(--text);border-bottom:1px solid rgba(255,255,255,.03);font-family:monospace}
diff --git a/internal/admin/static/admin.js b/internal/admin/static/admin.js
index f8e9d13..a6bb8b2 100644
--- a/internal/admin/static/admin.js
+++ b/internal/admin/static/admin.js
@@ -52,4 +52,69 @@ window.saveForm = function(url, data, msg) {
}).catch(function(e) { notify('Save failed: '+e.message, 'error'); });
};
+window.entityRename = function(type, oldID, newID) {
+ return API.post('/api/' + type + '/rename', {id: oldID, new_id: newID}).then(function(r) {
+ notify('Renamed ' + oldID + ' to ' + newID, 'success');
+ updateUndoBar();
+ return r;
+ });
+};
+
+window.renderRenameableHeader = function(label, id) {
+ var h = '<div class="rename-header">';
+ h += '<h2 class="rename-header-text">' + esc(label) + ': ' + esc(id) + '</h2>';
+ h += '<span class="rename-pencil" onclick="startHeaderRename(this,\'' + escAttr(id) + '\')" title="Rename">\u270E</span>';
+ h += '<div class="rename-edit-row" style="display:none"></div>';
+ h += '</div>';
+ return h;
+};
+
+window.handleHeaderRename = function(oldID, newID) {
+ var type = window.editorType || '';
+ if (!type) return;
+ entityRename(type, oldID, newID).then(function() {
+ window.currentID = newID;
+ var typeSingular = type.slice(0, -1) + 'ID';
+ if (window[typeSingular] !== undefined) window[typeSingular] = newID;
+ if (window.loadList) window.loadList();
+ if (window.loadItem) window.loadItem(newID);
+ }).catch(function(e) { notify('Rename failed: ' + e.message, 'error'); });
+};
+
+window.startHeaderRename = function(pencilEl, oldID) {
+ var header = pencilEl.parentElement;
+ var textSpan = header.querySelector('.rename-header-text');
+ var editRow = header.querySelector('.rename-edit-row');
+
+ textSpan.style.display = 'none';
+ pencilEl.style.display = 'none';
+ editRow.style.display = 'flex';
+ editRow.innerHTML = '<input class="rename-input" value="' + escAttr(oldID) + '" maxlength="128">' +
+ '<button class="btn btn-sm rename-btn-confirm" title="Confirm">\u2713</button>' +
+ '<button class="btn btn-sm rename-btn-cancel" title="Cancel">\u2717</button>';
+
+ var input = editRow.querySelector('.rename-input');
+ var confirmBtn = editRow.querySelector('.rename-btn-confirm');
+ var cancelBtn = editRow.querySelector('.rename-btn-cancel');
+
+ input.focus();
+ input.select();
+
+ function cancel() {
+ textSpan.style.display = '';
+ pencilEl.style.display = '';
+ editRow.style.display = 'none';
+ }
+
+ function confirm() {
+ var newID = input.value.trim();
+ if (!newID || newID === oldID) { cancel(); return; }
+ if (window.handleHeaderRename) handleHeaderRename(oldID, newID);
+ }
+
+ cancelBtn.onclick = cancel;
+ input.onkeydown = function(e) { if (e.key === 'Enter') confirm(); else if (e.key === 'Escape') cancel(); };
+ confirmBtn.onclick = confirm;
+};
+
document.addEventListener('DOMContentLoaded', function() { updateUndoBar(); });
diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js
index 8ff5eaf..5e0c7cb 100644
--- a/internal/admin/static/cardeditor.js
+++ b/internal/admin/static/cardeditor.js
@@ -199,13 +199,13 @@ function ced_setPathInData(ed, dataPath, value) {
if (!ed || !dataPath) return;
var m = dataPath.match(/^(.+)\[(\d+)\]\.(.+)$/);
if (m) {
- if (!ed[m[1]]) ed[m[1]] = [];
- if (!ed[m[1]][m[2]]) ed[m[1]][m[2]] = {};
+ if (!ed[m[1]] || !Array.isArray(ed[m[1]])) return;
+ if (!ed[m[1]][m[2]]) return;
ed[m[1]][m[2]][m[3]] = value;
} else {
var m2 = dataPath.match(/^(.+)\.(.+)$/);
if (m2) {
- if (!ed[m2[1]]) ed[m2[1]] = {};
+ if (ed[m2[1]] === undefined || ed[m2[1]] === null) return;
ed[m2[1]][m2[2]] = value;
} else {
ed[dataPath] = value;
diff --git a/internal/admin/static/dropeditor.js b/internal/admin/static/dropeditor.js
index 8e463ec..c5da314 100644
--- a/internal/admin/static/dropeditor.js
+++ b/internal/admin/static/dropeditor.js
@@ -56,7 +56,7 @@ function loadDropEditor(id) {
function loadItem(id) { loadDropEditor(id); }
function renderDropEditor(id, data) {
- var html = '<h2>Drop Table: ' + esc(id) + '</h2>';
+ var html = renderRenameableHeader('Drop Table', id);
html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
@@ -155,9 +155,10 @@ function addArrayItem(cardID) {
function removeArrayItem(cardID, idx) {
var sec = DROP_SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec || !sec.isArray) return;
+ ced_syncDOMToData(dropData);
var arr = dropData[sec.path] || [];
arr.splice(idx, 1);
- renderCurrent();
+ renderDropEditor(dropID, dropData);
}
function saveDrop() {
diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js
index 5b2d8ef..a9c1e1c 100644
--- a/internal/admin/static/editor.js
+++ b/internal/admin/static/editor.js
@@ -71,7 +71,7 @@ function renderDirGroup(name, ids, filter) {
}
h += '<div class="dir-items"' + (name && !expanded ? ' style="display:none"' : '') + ' ondragover="dirDragOver(event,\'' + (name || '') + '\')" ondragleave="dirDragLeave(event)" ondrop="dirDrop(event,\'' + (name || '') + '\')">';
displayIds.forEach(function(id) {
- h += '<div class="item' + (id === currentID ? ' active' : '') + '" onclick="loadItem(\'' + esc(id) + '\')" draggable="true" ondragstart="itemDragStart(event,\'' + escAttr(id) + '\')">' + esc(id) + '</div>';
+ h += '<div class="item' + (id === currentID ? ' active' : '') + '" onclick="loadItem(\'' + esc(id) + '\')" draggable="true" ondragstart="itemDragStart(event,\'' + escAttr(id) + '\')" oncontextmenu="showItemContextMenu(event,\'' + escAttr(id) + '\')">' + esc(id) + '</div>';
});
h += '</div>';
h += '</div>';
@@ -272,33 +272,123 @@ function createNewDir() {
function showDirContextMenu(e, dir) {
e.preventDefault();
+ closeContextMenu();
if (dir === 'Root') return;
var overlay = document.createElement('div');
overlay.className = 'cm-overlay';
- overlay.onclick = function() { overlay.remove(); menu.remove(); };
+ overlay.id = '_ctxMenuOverlay';
+ overlay.onclick = function() { closeContextMenu(); };
var menu = document.createElement('div');
menu.className = 'cm-menu';
+ menu.id = '_ctxMenu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
- var delBtn = document.createElement('button');
- delBtn.textContent = 'Delete Directory';
- delBtn.className = 'danger';
- delBtn.onclick = function() {
- overlay.remove(); menu.remove();
+ function addBtn(label, cls, handler) {
+ var btn = document.createElement('button');
+ btn.textContent = label;
+ if (cls) btn.className = cls;
+ btn.onclick = function() { closeContextMenu(); handler(); };
+ menu.appendChild(btn);
+ }
+
+ addBtn('Rename Directory', '', function() { renameDirInline(dir); });
+ addBtn('Delete Directory', 'danger', function() {
API.post('/api/' + editorType + '/dirs', {action: 'delete', dir: dir}).then(function(r) {
notify('Deleted directory ' + dir + (r.moved ? ' (' + r.moved + ' files moved to root)' : ''), 'success');
updateUndoBar();
loadList();
}).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
- };
- menu.appendChild(delBtn);
+ });
document.body.appendChild(overlay);
document.body.appendChild(menu);
}
+function renameDirInline(dir) {
+ var name = prompt('New name for directory "' + dir + '":', dir);
+ if (!name || name === dir) return;
+ API.post('/api/' + editorType + '/dirs', {action: 'rename_dir', dir: dir, new_name: name}).then(function() {
+ notify('Renamed directory ' + dir + ' to ' + name, 'success');
+ loadList();
+ }).catch(function(e) { notify('Rename failed: ' + e.message, 'error'); });
+}
+
+function showItemContextMenu(e, id) {
+ e.preventDefault();
+ e.stopPropagation();
+ closeContextMenu();
+
+ var overlay = document.createElement('div');
+ overlay.className = 'cm-overlay';
+ overlay.id = '_ctxMenuOverlay';
+ overlay.onclick = function() { closeContextMenu(); };
+
+ var menu = document.createElement('div');
+ menu.className = 'cm-menu';
+ menu.id = '_ctxMenu';
+ menu.style.left = e.clientX + 'px';
+ menu.style.top = e.clientY + 'px';
+
+ function addBtn(label, cls, handler) {
+ var btn = document.createElement('button');
+ btn.textContent = label;
+ if (cls) btn.className = cls;
+ btn.onclick = function() { closeContextMenu(); handler(); };
+ menu.appendChild(btn);
+ }
+
+ addBtn('Rename', '', function() { startInlineItemRename(id); });
+ addBtn('Delete', 'danger', function() { deleteItem(id); });
+
+ document.body.appendChild(overlay);
+ document.body.appendChild(menu);
+}
+
+function closeContextMenu() {
+ var o = document.getElementById('_ctxMenuOverlay');
+ var m = document.getElementById('_ctxMenu');
+ if (o) o.remove();
+ if (m) m.remove();
+}
+
+function startInlineItemRename(id) {
+ var el = document.querySelector('.item.active');
+ if (!el) return;
+ var origHTML = el.innerHTML;
+ el.innerHTML = '<input class="item-rename-input" value="' + escAttr(id) + '" maxlength="128">';
+ var input = el.querySelector('.item-rename-input');
+ input.focus();
+ input.select();
+
+ function finish(newID) {
+ newID = (newID || '').trim();
+ if (!newID || newID === id) { el.innerHTML = origHTML; return; }
+ if (allIDs.indexOf(newID) >= 0) {
+ notify('An entity named "' + newID + '" already exists.', 'error');
+ el.innerHTML = origHTML;
+ return;
+ }
+ entityRename(editorType, id, newID).then(function() {
+ if (currentID === id) currentID = newID;
+ var typeIDVar = editorType.slice(0, -1) + 'ID';
+ if (window[typeIDVar] === id) window[typeIDVar] = newID;
+ loadList();
+ if (currentID === newID) loadItem(newID);
+ }).catch(function(e) {
+ notify('Rename failed: ' + e.message, 'error');
+ el.innerHTML = origHTML;
+ });
+ }
+
+ input.onkeydown = function(e) {
+ if (e.key === 'Enter') { input.blur(); }
+ else if (e.key === 'Escape') { el.innerHTML = origHTML; }
+ };
+ input.onblur = function() { finish(input.value); };
+}
+
function dirDragOver(e, dir) {
e.preventDefault();
e.stopPropagation();
diff --git a/internal/admin/static/hazardeditor.js b/internal/admin/static/hazardeditor.js
index a0610dc..ff2857d 100644
--- a/internal/admin/static/hazardeditor.js
+++ b/internal/admin/static/hazardeditor.js
@@ -66,7 +66,7 @@ function loadHazardEditor(id) {
function loadItem(id) { loadHazardEditor(id); }
function renderHazardEditor(id, data) {
- var html = '<h2>Hazard: ' + esc(id) + '</h2>';
+ var html = renderRenameableHeader('Hazard', id);
html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
@@ -156,9 +156,10 @@ function toggleCardWidth(id) {
function removeSection(id) {
var sec = HAZARD_SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
+ ced_syncDOMToData(hazardData);
if (sec.fields) sec.fields.forEach(function(f) { delete hazardData[f[0]]; });
delete addedSections[id];
- renderCurrent();
+ renderHazardEditor(hazardID, hazardData);
}
function addSection() {
diff --git a/internal/admin/static/itemeditor.js b/internal/admin/static/itemeditor.js
index 5ffb0cb..ac76f94 100644
--- a/internal/admin/static/itemeditor.js
+++ b/internal/admin/static/itemeditor.js
@@ -174,7 +174,7 @@ function loadItemEditor(id) {
}
function renderItemEditor(id, data) {
- var html = '<h2>Item: ' + esc(id) + '</h2>';
+ var html = renderRenameableHeader('Item', id);
html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
@@ -460,8 +460,9 @@ function addEquipBlock(blockKey) {
}
function removeEquipBlock(blockKey) {
+ ced_syncDOMToData(itemData);
if (itemData.equipment) delete itemData.equipment[blockKey];
- renderCurrent();
+ renderItemEditor(itemID, itemData);
}
// =========================================================================
@@ -902,6 +903,7 @@ function toggleCardWidth(id) {
function removeSection(id) {
var sec = SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
+ ced_syncDOMToData(itemData);
if (sec.path) {
delete itemData[sec.path];
} else {
@@ -910,7 +912,7 @@ function removeSection(id) {
if (sec.subtables) sec.subtables.forEach(function(st) { delete itemData[st.key]; });
}
delete addedSections[id];
- renderCurrent();
+ renderItemEditor(itemID, itemData);
}
function addSection() {
@@ -952,9 +954,10 @@ function addArrayItem(cardID) {
function removeArrayItem(cardID, idx) {
var sec = SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec || !sec.isArray) return;
+ ced_syncDOMToData(itemData);
var arr = itemData[sec.path] || [];
arr.splice(idx, 1);
- renderCurrent();
+ renderItemEditor(itemID, itemData);
}
// =========================================================================
@@ -991,6 +994,7 @@ function addSubRow(cardID, subKey) {
function removeSubRow(cardID, subKey, idx) {
var sec = SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec) return;
+ ced_syncDOMToData(itemData);
var p = cardPath(sec);
var sectionRoot = p ? (itemData[p] || {}) : itemData;
var raw = sectionRoot[subKey];
@@ -1004,7 +1008,7 @@ function removeSubRow(cardID, subKey, idx) {
}
arr.splice(idx, 1);
sectionRoot[subKey] = arr;
- renderCurrent();
+ renderItemEditor(itemID, itemData);
}
// =========================================================================
@@ -1044,13 +1048,14 @@ function addSubSubRow(cardID, subKey, rowIdx, subSubKey) {
function removeSubSubRow(cardID, subKey, rowIdx, subSubKey, subSubIdx) {
var sec = SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec) return;
+ ced_syncDOMToData(itemData);
var p = cardPath(sec);
var sectionRoot = p ? (itemData[p] || {}) : itemData;
var arr = sectionRoot[subKey] || [];
if (rowIdx >= arr.length) return;
var subArr = arr[rowIdx][subSubKey] || [];
subArr.splice(subSubIdx, 1);
- renderCurrent();
+ renderItemEditor(itemID, itemData);
}
// =========================================================================
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index 73dd16b..debe7e0 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -149,7 +149,7 @@ function loadMob(id) {
}
function renderMobEditor(id, data) {
- var html = '<h2>Mob: ' + esc(id) + '</h2>';
+ var html = renderRenameableHeader('Mob', id);
html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
@@ -574,6 +574,7 @@ function toggleMobCardWidth(id) {
function removeMobSection(id) {
var sec = MOB_SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
+ ced_syncDOMToData(mobData);
if (sec.path) {
delete mobData[sec.path];
} else {
@@ -582,7 +583,7 @@ function removeMobSection(id) {
if (sec.subtables) sec.subtables.forEach(function(st) { delete mobData[st.key]; });
}
delete addedSections[id];
- renderCurrent();
+ renderMobEditor(mobID, mobData);
}
function mobAddSection() {
@@ -635,8 +636,9 @@ function addMobSubRow(cardID, subKey) {
function removeMobSubRow(cardID, subKey, idx) {
var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec) return;
+ ced_syncDOMToData(mobData);
ced_removeSubRow(sec, subKey, idx, mobData, mobCardPath);
- renderCurrent();
+ renderMobEditor(mobID, mobData);
}
function addMobLootRow(kind) {
diff --git a/internal/admin/static/moduleeditor.js b/internal/admin/static/moduleeditor.js
index f1fb0fd..5bcb7bc 100644
--- a/internal/admin/static/moduleeditor.js
+++ b/internal/admin/static/moduleeditor.js
@@ -67,7 +67,7 @@ function loadModuleEditor(id) {
function loadItem(id) { loadModuleEditor(id); }
function renderModuleEditor(id, data) {
- var html = '<h2>Module: ' + esc(id) + '</h2>';
+ var html = renderRenameableHeader('Module', id);
html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
@@ -187,11 +187,12 @@ function toggleCardWidth(id) {
function removeSection(id) {
var sec = MODULE_SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
+ ced_syncDOMToData(moduleData);
if (sec.path) {
delete moduleData[sec.path];
}
delete addedSections[id];
- renderCurrent();
+ renderModuleEditor(moduleID, moduleData);
}
function addSection() {
@@ -227,9 +228,10 @@ function addArrayItem(cardID) {
function removeArrayItem(cardID, idx) {
var sec = MODULE_SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec || !sec.isArray) return;
+ ced_syncDOMToData(moduleData);
var arr = moduleData[sec.path] || [];
arr.splice(idx, 1);
- renderCurrent();
+ renderModuleEditor(moduleID, moduleData);
}
function saveModule() {
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js
index f8ef18a..5444985 100644
--- a/internal/admin/static/objecteditor.js
+++ b/internal/admin/static/objecteditor.js
@@ -157,7 +157,7 @@ function loadObject(id) {
}
function renderObjectEditor(id, data) {
- var html = '<h2>Object: ' + esc(id) + '</h2>';
+ var html = renderRenameableHeader('Object', id);
html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
@@ -729,12 +729,13 @@ function addToolRow(secID, key) {
function removeToolRow(secID, key, idx) {
var sec = SECTIONS.find(function(s) { return s.id === secID; });
if (!sec) return;
+ ced_syncDOMToData(objectData);
var p = cardPath(sec);
var arr = objectData[p] && objectData[p][key];
if (!arr) return;
arr.splice(idx, 1);
if (arr.length === 0) delete objectData[p][key];
- renderCurrent();
+ renderObjectEditor(objectID, objectData);
}
function fetchToolTypes() {
@@ -760,12 +761,13 @@ function toggleCardWidth(id) {
function removeSection(id) {
var sec = SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
+ ced_syncDOMToData(objectData);
if (sec.path) {
delete objectData[sec.path];
} else if (id === 'steal') {
delete objectData.steal;
}
- renderCurrent();
+ renderObjectEditor(objectID, objectData);
}
function addSection() {
@@ -812,9 +814,10 @@ function addArrayItem(cardID) {
function removeArrayItem(cardID, idx) {
var sec = SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec || !sec.isArray) return;
+ ced_syncDOMToData(objectData);
var arr = objectData[sec.path] || [];
arr.splice(idx, 1);
- renderCurrent();
+ renderObjectEditor(objectID, objectData);
}
function addSubRow(cardID, subKey) {
@@ -837,10 +840,11 @@ function addSubRow(cardID, subKey) {
function removeSubRow(cardID, subKey, idx) {
var sec = SECTIONS.find(function(s) { return s.id === cardID; });
if (!sec) return;
+ ced_syncDOMToData(objectData);
var sectionRoot = objectData[sec.path || ''] || objectData;
var arr = sectionRoot[subKey] || [];
arr.splice(idx, 1);
- renderCurrent();
+ renderObjectEditor(objectID, objectData);
}
function addKVRow(cardID, subKey) {
@@ -884,10 +888,11 @@ function hideUseInterField(idx, key) {
window._useInterVis = window._useInterVis || {};
if (!window._useInterVis[idx]) window._useInterVis[idx] = {};
window._useInterVis[idx][key] = false;
+ ced_syncDOMToData(objectData);
if (objectData.use_interactions && objectData.use_interactions[idx]) {
objectData.use_interactions[idx][key] = '';
}
- renderCurrent();
+ renderObjectEditor(objectID, objectData);
}
function showCoreField(key) {
@@ -899,6 +904,7 @@ function showCoreField(key) {
function hideCoreField(key) {
window._coreVis = window._coreVis || {};
window._coreVis[key] = false;
+ ced_syncDOMToData(objectData);
if (key === 'aliases') {
objectData.aliases = [];
} else if (key === 'inroom_description') {
@@ -906,7 +912,7 @@ function hideCoreField(key) {
} else if (key === 'removal_item') {
objectData.removal_item = '';
}
- renderCurrent();
+ renderObjectEditor(objectID, objectData);
}
function showGuardMob() {
@@ -918,8 +924,9 @@ function showGuardMob() {
function hideGuardMob() {
window._stealVis = window._stealVis || {};
window._stealVis.guard_mob = false;
+ ced_syncDOMToData(objectData);
if (objectData.steal) objectData.steal.guard_mob = '';
- renderCurrent();
+ renderObjectEditor(objectID, objectData);
}
function toggleUseInterHelp() {
diff --git a/internal/admin/static/techeditor.js b/internal/admin/static/techeditor.js
index 70f881e..ffb2b7b 100644
--- a/internal/admin/static/techeditor.js
+++ b/internal/admin/static/techeditor.js
@@ -70,7 +70,7 @@ function loadTechEditor(id) {
function loadItem(id) { loadTechEditor(id); }
function renderTechEditor(id, data) {
- var html = '<h2>Tech: ' + esc(id) + '</h2>';
+ var html = renderRenameableHeader('Tech', id);
html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index 0c5ebaa..4c39ec6 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -35,6 +35,9 @@ func (c ChangeDesc) ShortDesc() string {
case c.IsDelete:
return fmt.Sprintf("Deleted %s", filepath.Base(c.FilePath))
case c.NewFilePath != "":
+ if filepath.Dir(c.FilePath) == filepath.Dir(c.NewFilePath) {
+ return fmt.Sprintf("Renamed %s", filepath.Base(c.NewFilePath))
+ }
return fmt.Sprintf("Moved %s", filepath.Base(c.NewFilePath))
default:
return fmt.Sprintf("Edited %s", filepath.Base(c.FilePath))
diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go
index 519a04c..b869ffe 100644
--- a/internal/admin/yaml_util.go
+++ b/internal/admin/yaml_util.go
@@ -214,6 +214,52 @@ func createEntityDir(dataDir, subdir, name string) error {
return os.Mkdir(dirPath, 0755)
}
+func renameEntityFile(dataDir, subdir, oldID, newID string) (string, string, []byte, error) {
+ oldPath, err := findEntityPath(dataDir, subdir, oldID)
+ if err != nil {
+ return "", "", nil, fmt.Errorf("entity not found: %s/%s", subdir, oldID)
+ }
+ dir := filepath.Dir(oldPath)
+ newPath := filepath.Join(dir, newID+".yaml")
+ if oldPath == newPath {
+ return "", "", nil, fmt.Errorf("new name is same as current")
+ }
+ if _, err := os.Stat(newPath); err == nil {
+ return "", "", nil, fmt.Errorf("entity %s already exists", newID)
+ }
+ data, err := os.ReadFile(oldPath)
+ if err != nil {
+ return "", "", nil, fmt.Errorf("read: %w", err)
+ }
+ if err := os.WriteFile(newPath, data, 0644); err != nil {
+ return "", "", nil, fmt.Errorf("write: %w", err)
+ }
+ if err := os.Remove(oldPath); err != nil {
+ return "", "", nil, fmt.Errorf("remove old: %w", err)
+ }
+ return oldPath, newPath, data, nil
+}
+
+func renameEntityDir(dataDir, subdir, oldName, newName string) error {
+ oldName = strings.TrimSpace(oldName)
+ newName = strings.TrimSpace(newName)
+ if oldName == "" || newName == "" || strings.ContainsAny(oldName, "/\\") || oldName == "." || oldName == ".." {
+ return fmt.Errorf("invalid old directory name")
+ }
+ if strings.ContainsAny(newName, "/\\") || newName == "." || newName == ".." {
+ return fmt.Errorf("invalid new directory name")
+ }
+ oldPath := filepath.Join(dataDir, subdir, oldName)
+ if _, err := os.Stat(oldPath); os.IsNotExist(err) {
+ return fmt.Errorf("directory %s does not exist", oldName)
+ }
+ newPath := filepath.Join(dataDir, subdir, newName)
+ if _, err := os.Stat(newPath); err == nil {
+ return fmt.Errorf("directory %s already exists", newName)
+ }
+ return os.Rename(oldPath, newPath)
+}
+
func deleteEntityDir(dataDir, subdir, dir string) ([]ChangeDesc, error) {
dir = strings.TrimSpace(dir)
if dir == "" || strings.ContainsAny(dir, "/\\") || dir == "." || dir == ".." {
@@ -225,13 +271,27 @@ func deleteEntityDir(dataDir, subdir, dir string) ([]ChangeDesc, error) {
return nil, fmt.Errorf("directory not found: %s", dir)
}
+ rootDir := filepath.Join(dataDir, subdir)
+ var clashes []string
+ for _, e := range entries {
+ if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
+ continue
+ }
+ if _, err := os.Stat(filepath.Join(rootDir, e.Name())); err == nil {
+ clashes = append(clashes, e.Name())
+ }
+ }
+ if len(clashes) > 0 {
+ return nil, fmt.Errorf("files already exist in root: %s", strings.Join(clashes, ", "))
+ }
+
var changes []ChangeDesc
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
continue
}
oldPath := filepath.Join(srcDir, e.Name())
- newPath := filepath.Join(dataDir, subdir, e.Name())
+ newPath := filepath.Join(rootDir, e.Name())
data, err := os.ReadFile(oldPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", e.Name(), err)