Forráskód Böngészése

Warn about similar items when adding or renaming an item

The items admin page had no way to tell whether a name already existed,
which made near-duplicates easy to create — especially with German
compounds ("Skisocken" vs "Ski Socken") and typos.

Both the add form and the inline rename now show an advisory panel
listing existing items that resemble what is being typed. Matching folds
case, diacritics and punctuation, compares word sets in any order, looks
inside compounds, and falls back to a bounded Levenshtein distance so
typos still match. An exact match is highlighted, but nothing is ever
blocked: the panel only informs, and both writes are unchanged.

The item list is already fetched to render the table, so it is reused as
embedded JSON — no extra query and no AJAX. In the table the panel
floats below the field so rows keep their height while editing, and a
row never flags itself or warns until the name actually differs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sebastian 3 hete
szülő
commit
3e3e354276
1 módosított fájl, 172 hozzáadás és 2 törlés
  1. 172 2
      admin/items.php

+ 172 - 2
admin/items.php

@@ -47,6 +47,26 @@ show_alerts();
 ?>
 <h1>Items</h1>
 
+<style>
+/* Advisory duplicate hint under an item-name field. Never blocks a save. */
+.dup-hint{margin:-.55rem 0 .9rem;border:1.5px solid var(--border);border-radius:var(--radius);
+          background:var(--bg);padding:.4rem 0;font-size:.85rem;}
+.dup-hint-head{padding:.15rem .8rem .3rem;font-size:.72rem;font-weight:600;letter-spacing:.06em;
+               text-transform:uppercase;color:var(--muted);}
+.dup-hint.exact{border-color:rgba(232,197,71,.45);}
+.dup-hint.exact .dup-hint-head{color:var(--accent);}
+.dup-row{display:flex;gap:.5rem;align-items:center;padding:.25rem .8rem;}
+.dup-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+.dup-id{color:var(--muted);font-size:.78rem;}
+/* In the table the hint floats, so a row does not jump in height while editing.
+   The cell's own overflow:hidden would clip it, hence the override. */
+.tbl-fixed tr[data-editing="true"] td{overflow:visible;}
+.dup-anchor{position:relative;}
+.dup-hint-float{position:absolute;z-index:30;top:calc(100% + 4px);left:0;margin:0;
+                min-width:100%;width:max-content;max-width:min(420px,60vw);
+                background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.45);}
+</style>
+
 <div class="card">
   <h2>Add New Item</h2>
   <form method="POST">
@@ -54,7 +74,8 @@ show_alerts();
     <div class="form-row">
       <div>
         <label>Item Name</label>
-        <input type="text" name="itemName" placeholder="e.g. Rain jacket" required>
+        <input type="text" name="itemName" id="new-item-name" autocomplete="off" placeholder="e.g. Rain jacket" required>
+        <div class="dup-hint" id="dup-hint" hidden></div>
       </div>
       <div>
         <label>Group</label>
@@ -100,7 +121,14 @@ show_alerts();
     <td style="color:var(--muted)"><?= $id ?></td>
     <td>
       <div class="view"><?= htmlspecialchars($item['itemName']) ?></div>
-      <div class="edit"><input type="text" name="itemName" form="edit-form-<?= $id ?>" value="<?= htmlspecialchars($item['itemName']) ?>" required></div>
+      <div class="edit">
+        <div class="dup-anchor">
+          <input type="text" name="itemName" form="edit-form-<?= $id ?>" autocomplete="off"
+                 value="<?= htmlspecialchars($item['itemName']) ?>"
+                 data-dup-hint="dup-hint-<?= $id ?>" data-item-id="<?= $id ?>" required>
+          <div class="dup-hint dup-hint-float" id="dup-hint-<?= $id ?>" hidden></div>
+        </div>
+      </div>
     </td>
     <td>
       <div class="view"><span class="badge badge-group"><?= htmlspecialchars($item['groupName']) ?></span></div>
@@ -147,4 +175,146 @@ function cancelEdit(id) {
 }
 </script>
 
+<script>
+// Duplicate hints: purely advisory. They never block a save - they only show
+// which existing items resemble the name being typed, both on the add form and
+// on every inline rename.
+(function () {
+  var ITEMS = <?= json_encode(array_map(function ($i) {
+      return ['id' => (int)$i['itemID'], 'name' => $i['itemName'], 'group' => $i['groupName']];
+  }, $items), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
+
+  var MAX_HITS = 8;
+
+  // Lower-cased, stripped of diacritics and punctuation, so "Rain-Jacket" and
+  // "rain jacket" collapse onto the same key.
+  function norm(s) {
+    return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
+            .replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
+  }
+  // Crude singular, enough to make "socks" and "sock" match.
+  function singular(t) {
+    return t.length > 3 && t.slice(-1) === 's' ? t.slice(0, -1) : t;
+  }
+  function tokens(s) {
+    return norm(s).split(' ').filter(Boolean).map(singular);
+  }
+  function key(s) {
+    return tokens(s).join(' ');
+  }
+
+  ITEMS.forEach(function (it) {
+    it.key    = key(it.name);
+    it.tokens = it.key.split(' ').filter(Boolean);
+  });
+
+  // Levenshtein distance, abandoned as soon as it exceeds `max`.
+  function lev(a, b, max) {
+    var prev = [], cur = [], i, j;
+    for (j = 0; j <= b.length; j++) prev[j] = j;
+    for (i = 1; i <= a.length; i++) {
+      cur = [i];
+      var best = i;
+      for (j = 1; j <= b.length; j++) {
+        cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1,
+                          prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
+        if (cur[j] < best) best = cur[j];
+      }
+      if (best > max) return max + 1;
+      prev = cur;
+    }
+    return prev[b.length];
+  }
+
+  // Higher is more similar; 0 means "not worth showing".
+  function score(qTokens, qKey, it) {
+    if (!qKey) return 0;
+    if (it.key === qKey) return 1000;                        // same item, different spelling
+    if (it.key.indexOf(qKey) === 0) return 500;              // typed name is a prefix
+    if (it.key.indexOf(qKey) !== -1) return 400;             // ...or appears inside
+    var hits = qTokens.filter(function (q) {
+      return it.tokens.some(function (t) {
+        // German compounds mean one word often sits inside another ("Socken"
+        // in "Skisocken"), so substrings count - but only between tokens of
+        // real length, else the "T" of "T-Shirt" matches half the table.
+        return t === q || (q.length >= 3 && t.length >= 3 &&
+                           (t.indexOf(q) !== -1 || q.indexOf(t) !== -1));
+      });
+    }).length;
+    // All words matched, just in another order ("jacket rain" -> "Rain jacket").
+    if (hits) return hits === qTokens.length ? 300 + hits : 100 * hits;
+    // Typos are the usual way a duplicate sneaks in ("sonnenbrile").
+    var slack = qKey.length >= 8 ? 2 : 1;
+    if (Math.abs(it.key.length - qKey.length) <= slack && lev(it.key, qKey, slack) <= slack) {
+      return 200;
+    }
+    return 0;
+  }
+
+  function esc(s) {
+    return s.replace(/[&<>"]/g, function (c) {
+      return {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c];
+    });
+  }
+
+  // Keeps `hint` in sync with what is typed into `input`. A rename passes its
+  // own itemID as `selfId` so the row never flags itself, and stays quiet until
+  // the name actually differs from the one it started with.
+  function attach(input, hint, selfId) {
+    if (!input || !hint) return;
+    var baseline  = key(input.defaultValue || '');
+    var baseClass = hint.className;
+
+    function render() {
+      var qKey = key(input.value.trim());
+      if (qKey.length < 2 || (baseline && qKey === baseline)) { hint.hidden = true; return; }
+
+      var qTokens = qKey.split(' ');
+      var hits = ITEMS.filter(function (it) { return it.id !== selfId; })
+                      .map(function (it) { return {it: it, s: score(qTokens, qKey, it)}; })
+                      .filter(function (h) { return h.s > 0; })
+                      .sort(function (a, b) {
+                        return b.s - a.s || a.it.name.localeCompare(b.it.name);
+                      });
+
+      if (!hits.length) { hint.hidden = true; return; }
+
+      var exact = hits[0].s === 1000;
+      var shown = hits.slice(0, MAX_HITS);
+      var head  = exact
+        ? '⚠ “' + esc(hits[0].it.name) + '” already exists'
+        : shown.length + ' similar item' + (shown.length === 1 ? '' : 's') + ' already in the database';
+
+      var html = '<div class="dup-hint-head">' + head + '</div>';
+      shown.forEach(function (h) {
+        html += '<div class="dup-row">'
+              +   '<span class="dup-id">#' + h.it.id + '</span>'
+              +   '<span class="dup-name">' + esc(h.it.name) + '</span>'
+              +   '<span class="badge badge-group">' + esc(h.it.group) + '</span>'
+              + '</div>';
+      });
+      if (hits.length > shown.length) {
+        html += '<div class="dup-row dup-id">+ ' + (hits.length - shown.length) + ' more</div>';
+      }
+
+      hint.className = baseClass + (exact ? ' exact' : '');
+      hint.innerHTML = html;
+      hint.hidden = false;
+    }
+
+    input.addEventListener('input', render);
+    input.addEventListener('focus', render);
+    render();
+  }
+
+  attach(document.getElementById('new-item-name'), document.getElementById('dup-hint'), 0);
+
+  Array.prototype.forEach.call(
+    document.querySelectorAll('input[data-dup-hint]'),
+    function (input) {
+      attach(input, document.getElementById(input.dataset.dupHint), +input.dataset.itemId);
+    });
+})();
+</script>
+
 <?php admin_foot(); ?>