feat: new placeholder format, fuzzy word detection, editable word input
- Placeholder format: {{Hund.w:uuid}} / {{Hund.o:uuid}} — label embedded,
no API lookup needed on render/edit load
- resolvePlaceholders handles both new format (fast) and old {{uuid}} (fallback)
- Word auto-detection: fuzzy match via search API + client-side starts-with
+ 60% length ratio (e.g. "Hunde" matches "Hund")
- "Als Wort erstellen" now shows editable input pre-filled with selection,
user can correct before saving
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,18 +22,25 @@ function withPlaceholders(text, wordMap, objectAssignments = {}) {
|
|||||||
let result = text;
|
let result = text;
|
||||||
Object.entries(wordMap).sort((a, b) => b[0].length - a[0].length).forEach(([title, w]) => {
|
Object.entries(wordMap).sort((a, b) => b[0].length - a[0].length).forEach(([title, w]) => {
|
||||||
const re = new RegExp(`\\b${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
|
const re = new RegExp(`\\b${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
|
||||||
result = result.replace(re, `{{${objectAssignments[w.id] || w.id}}}`);
|
result = result.replace(re, match => {
|
||||||
|
const objectId = objectAssignments[w.id];
|
||||||
|
if (objectId) return `{{${match}.o:${objectId}}}`;
|
||||||
|
return `{{${match}.w:${w.id}}}`;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePlaceholders(text, allObjects) {
|
async function resolvePlaceholders(text, allObjects) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
// New format: {{label.w:uuid}} or {{label.o:uuid}} — label is embedded, no lookup needed
|
||||||
|
text = text.replace(/\{\{([^.}]+)\.[wo]:([^}]+)\}\}/g, (_, label) => label);
|
||||||
|
// Old format fallback: {{uuid}}
|
||||||
const matches = [...text.matchAll(/\{\{([^}]+)\}\}/g)];
|
const matches = [...text.matchAll(/\{\{([^}]+)\}\}/g)];
|
||||||
if (!matches.length) return text;
|
if (!matches.length) return text;
|
||||||
const uuids = [...new Set(matches.map(m => m[1]))];
|
const uuids = [...new Set(matches.map(m => m[1]))];
|
||||||
const idMap = {};
|
const idMap = {};
|
||||||
allObjects.forEach(obj => {
|
(allObjects || []).forEach(obj => {
|
||||||
if (uuids.includes(obj.id)) {
|
if (uuids.includes(obj.id)) {
|
||||||
const label = (obj._words || []).slice(0, 2).map(w => w.titel_de).filter(Boolean).join('/') || 'Objekt';
|
const label = (obj._words || []).slice(0, 2).map(w => w.titel_de).filter(Boolean).join('/') || 'Objekt';
|
||||||
idMap[obj.id] = label;
|
idMap[obj.id] = label;
|
||||||
@@ -46,6 +53,15 @@ async function resolvePlaceholders(text, allObjects) {
|
|||||||
return text.replace(/\{\{([^}]+)\}\}/g, (_, id) => idMap[id] || id);
|
return text.replace(/\{\{([^}]+)\}\}/g, (_, id) => idMap[id] || id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Fuzzy word matching ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fuzzyMatch(token, wordTitle, threshold = 0.6) {
|
||||||
|
const t = token.toLowerCase();
|
||||||
|
const w = wordTitle.toLowerCase();
|
||||||
|
if (!t.startsWith(w) && !w.startsWith(t)) return false;
|
||||||
|
return Math.min(t.length, w.length) / Math.max(t.length, w.length) >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── HighlightedTextarea ──────────────────────────────────────────────────────
|
// ─── HighlightedTextarea ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function HighlightedTextarea({ value, onChange, wordMap, rows = 2, placeholder, onSelectionChange }) {
|
function HighlightedTextarea({ value, onChange, wordMap, rows = 2, placeholder, onSelectionChange }) {
|
||||||
@@ -161,12 +177,15 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
const [wordMap, setWordMap] = useState({});
|
const [wordMap, setWordMap] = useState({});
|
||||||
const [objectAssignments, setObjectAssignments] = useState({});
|
const [objectAssignments, setObjectAssignments] = useState({});
|
||||||
const [selection, setSelection] = useState('');
|
const [selection, setSelection] = useState('');
|
||||||
|
const [wordInput, setWordInput] = useState('');
|
||||||
const [creatingWord, setCreatingWord] = useState(false);
|
const [creatingWord, setCreatingWord] = useState(false);
|
||||||
const [yesNoAnswer, setYesNoAnswer] = useState(null);
|
const [yesNoAnswer, setYesNoAnswer] = useState(null);
|
||||||
const [positiveWords, setPositiveWords] = useState([]);
|
const [positiveWords, setPositiveWords] = useState([]);
|
||||||
const [negativeWords, setNegativeWords] = useState([]);
|
const [negativeWords, setNegativeWords] = useState([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setWordInput(selection); }, [selection]);
|
||||||
|
|
||||||
const allText = `${question} ${positive} ${negative}`;
|
const allText = `${question} ${positive} ${negative}`;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allText.trim()) { setWordMap({}); return; }
|
if (!allText.trim()) { setWordMap({}); return; }
|
||||||
@@ -174,8 +193,12 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
||||||
if (!tokens.length) return;
|
if (!tokens.length) return;
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
tokens.map(w => apiFetch(`/words?titel_${lang}=${encodeURIComponent(w)}&limit=1`)
|
tokens.map(async w => {
|
||||||
.then(d => Array.isArray(d) && d.length ? { key: w.toLowerCase(), word: d[0] } : null))
|
const data = await apiFetch(`/words?search=${encodeURIComponent(w)}&limit=5`);
|
||||||
|
const candidates = Array.isArray(data) ? data : [];
|
||||||
|
const match = candidates.find(word => fuzzyMatch(w, word[`titel_${lang}`] || word.titel_de || ''));
|
||||||
|
return match ? { key: w.toLowerCase(), word: match } : null;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const map = {};
|
const map = {};
|
||||||
results.forEach(r => { if (r.status === 'fulfilled' && r.value) map[r.value.key] = r.value.word; });
|
results.forEach(r => { if (r.status === 'fulfilled' && r.value) map[r.value.key] = r.value.word; });
|
||||||
@@ -185,11 +208,12 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
}, [allText, lang]);
|
}, [allText, lang]);
|
||||||
|
|
||||||
async function handleCreateWord() {
|
async function handleCreateWord() {
|
||||||
if (!selection.trim()) return;
|
if (!wordInput.trim()) return;
|
||||||
setCreatingWord(true);
|
setCreatingWord(true);
|
||||||
try {
|
try {
|
||||||
const w = await apiPost('/words', { [`titel_${lang}`]: selection.trim() });
|
const w = await apiPost('/words', { [`titel_${lang}`]: wordInput.trim() });
|
||||||
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
||||||
|
setWordInput('');
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
finally { setCreatingWord(false); }
|
finally { setCreatingWord(false); }
|
||||||
}
|
}
|
||||||
@@ -273,11 +297,18 @@ function PairForm({ objectId, allObjects, onPairSaved }) {
|
|||||||
{type && (
|
{type && (
|
||||||
<div className="space-y-2.5 pt-1 border-t border-indigo-100">
|
<div className="space-y-2.5 pt-1 border-t border-indigo-100">
|
||||||
{selection && (
|
{selection && (
|
||||||
<div className="flex items-center gap-2 bg-white border border-indigo-200 rounded-lg px-2.5 py-1.5">
|
<div className="flex items-center gap-1.5 bg-white border border-indigo-200 rounded-lg px-2.5 py-1.5">
|
||||||
<span className="text-xs text-slate-600 flex-1 truncate">„<strong className="text-indigo-700">{selection}</strong>"</span>
|
<span className="text-xs text-slate-400 shrink-0">„{selection}"→</span>
|
||||||
<button onClick={handleCreateWord} disabled={creatingWord}
|
<input
|
||||||
|
type="text"
|
||||||
|
value={wordInput}
|
||||||
|
onChange={e => setWordInput(e.target.value)}
|
||||||
|
placeholder="Wort eingeben…"
|
||||||
|
className="flex-1 min-w-0 text-xs border border-slate-200 rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-indigo-400"
|
||||||
|
/>
|
||||||
|
<button onClick={handleCreateWord} disabled={creatingWord || !wordInput.trim()}
|
||||||
className="text-xs bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-0.5 rounded font-medium disabled:opacity-50 whitespace-nowrap">
|
className="text-xs bg-indigo-600 hover:bg-indigo-700 text-white px-2 py-0.5 rounded font-medium disabled:opacity-50 whitespace-nowrap">
|
||||||
{creatingWord ? '…' : 'Als Wort erstellen'}
|
{creatingWord ? '…' : '+ Wort'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -392,8 +423,11 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel }) {
|
|||||||
const [wordMap, setWordMap] = useState({});
|
const [wordMap, setWordMap] = useState({});
|
||||||
const [objectAssignments, setObjectAssignments] = useState({});
|
const [objectAssignments, setObjectAssignments] = useState({});
|
||||||
const [selection, setSelection] = useState('');
|
const [selection, setSelection] = useState('');
|
||||||
|
const [wordInput, setWordInput] = useState('');
|
||||||
const [creatingWord, setCreatingWord] = useState(false);
|
const [creatingWord, setCreatingWord] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setWordInput(selection); }, [selection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -433,8 +467,12 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel }) {
|
|||||||
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
const tokens = [...new Set(allText.split(/[\s.,!?;:()\[\]"']+/).filter(w => w.length >= 2))];
|
||||||
if (!tokens.length) return;
|
if (!tokens.length) return;
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
tokens.map(w => apiFetch(`/words?titel_${lang}=${encodeURIComponent(w)}&limit=1`)
|
tokens.map(async w => {
|
||||||
.then(d => Array.isArray(d) && d.length ? { key: w.toLowerCase(), word: d[0] } : null))
|
const data = await apiFetch(`/words?search=${encodeURIComponent(w)}&limit=5`);
|
||||||
|
const candidates = Array.isArray(data) ? data : [];
|
||||||
|
const match = candidates.find(word => fuzzyMatch(w, word[`titel_${lang}`] || word.titel_de || ''));
|
||||||
|
return match ? { key: w.toLowerCase(), word: match } : null;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const map = {};
|
const map = {};
|
||||||
results.forEach(r => { if (r.status === 'fulfilled' && r.value) map[r.value.key] = r.value.word; });
|
results.forEach(r => { if (r.status === 'fulfilled' && r.value) map[r.value.key] = r.value.word; });
|
||||||
@@ -444,11 +482,12 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel }) {
|
|||||||
}, [allText, lang]);
|
}, [allText, lang]);
|
||||||
|
|
||||||
async function handleCreateWord() {
|
async function handleCreateWord() {
|
||||||
if (!selection.trim()) return;
|
if (!wordInput.trim()) return;
|
||||||
setCreatingWord(true);
|
setCreatingWord(true);
|
||||||
try {
|
try {
|
||||||
const w = await apiPost('/words', { [`titel_${lang}`]: selection.trim() });
|
const w = await apiPost('/words', { [`titel_${lang}`]: wordInput.trim() });
|
||||||
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
setWordMap(prev => ({ ...prev, [selection.trim().toLowerCase()]: w }));
|
||||||
|
setWordInput('');
|
||||||
} catch (e) { alert('Fehler: ' + e.message); }
|
} catch (e) { alert('Fehler: ' + e.message); }
|
||||||
finally { setCreatingWord(false); }
|
finally { setCreatingWord(false); }
|
||||||
}
|
}
|
||||||
@@ -526,11 +565,18 @@ function EditPairForm({ pair, allObjects, onSaved, onCancel }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5 pt-1 border-t border-amber-100">
|
<div className="space-y-2.5 pt-1 border-t border-amber-100">
|
||||||
{selection && (
|
{selection && (
|
||||||
<div className="flex items-center gap-2 bg-white border border-amber-200 rounded-lg px-2.5 py-1.5">
|
<div className="flex items-center gap-1.5 bg-white border border-amber-200 rounded-lg px-2.5 py-1.5">
|
||||||
<span className="text-xs text-slate-600 flex-1 truncate">„<strong className="text-amber-700">{selection}</strong>"</span>
|
<span className="text-xs text-slate-400 shrink-0">„{selection}"→</span>
|
||||||
<button onClick={handleCreateWord} disabled={creatingWord}
|
<input
|
||||||
|
type="text"
|
||||||
|
value={wordInput}
|
||||||
|
onChange={e => setWordInput(e.target.value)}
|
||||||
|
placeholder="Wort eingeben…"
|
||||||
|
className="flex-1 min-w-0 text-xs border border-slate-200 rounded px-2 py-0.5 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
<button onClick={handleCreateWord} disabled={creatingWord || !wordInput.trim()}
|
||||||
className="text-xs bg-amber-600 hover:bg-amber-700 text-white px-2 py-0.5 rounded font-medium disabled:opacity-50 whitespace-nowrap">
|
className="text-xs bg-amber-600 hover:bg-amber-700 text-white px-2 py-0.5 rounded font-medium disabled:opacity-50 whitespace-nowrap">
|
||||||
{creatingWord ? '…' : 'Als Wort erstellen'}
|
{creatingWord ? '…' : '+ Wort'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user