feat: new placeholder format {{label.w:id}} / {{label.o:id}}
- Labels are now embedded in sentence text — no DB lookup needed - Objects fetch selections directly from objects table - Pictures resolved via object_pairs join instead of sentence UUID scan - Simpler placeholderMap: only type + selections for objects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
// GET /auth/feed?lang=sv&limit=20
|
// GET /auth/feed?lang=sv&limit=20
|
||||||
// Returns hydrated pairs for the learning feed (end-user JWT allowed).
|
// Returns hydrated pairs for the learning feed.
|
||||||
// {{uuid}} placeholders in sentences are resolved to word/object labels in all languages.
|
// Placeholders in sentences: {{label.w:wordId}} or {{label.o:objectId}}
|
||||||
|
// Pictures come from object_pairs → object_pictures → pictures.
|
||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
@@ -18,11 +19,22 @@ function requireJwt(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract all {{uuid}} placeholder values from a string
|
// Parse {{label.w:uuid}} or {{label.o:uuid}} from a sentence string
|
||||||
function extractUuids(text) {
|
function extractPlaceholders(text) {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
const matches = text.matchAll(/\{\{([0-9a-f-]{36})\}\}/g);
|
return [...text.matchAll(/\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}/g)]
|
||||||
return [...matches].map(m => m[1]);
|
.map(m => ({ label: m[1], type: m[2] === 'w' ? 'word' : 'object', id: m[3] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique IDs of a given type from multiple placeholder arrays
|
||||||
|
function collectIds(lists, filterType) {
|
||||||
|
const ids = new Set();
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const p of list) {
|
||||||
|
if (!filterType || p.type === filterType) ids.add(p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/', requireJwt, async (req, res, next) => {
|
router.get('/', requireJwt, async (req, res, next) => {
|
||||||
@@ -40,8 +52,8 @@ router.get('/', requireJwt, async (req, res, next) => {
|
|||||||
[limit]
|
[limit]
|
||||||
);
|
);
|
||||||
if (!pairsRes.rows.length) return res.json([]);
|
if (!pairsRes.rows.length) return res.json([]);
|
||||||
|
const pairs = pairsRes.rows;
|
||||||
const pairs = pairsRes.rows;
|
const pairIds = pairs.map(p => p.id);
|
||||||
|
|
||||||
// 2. Collect IDs
|
// 2. Collect IDs
|
||||||
const questionIds = [...new Set(pairs.map(p => p.question_id).filter(Boolean))];
|
const questionIds = [...new Set(pairs.map(p => p.question_id).filter(Boolean))];
|
||||||
@@ -54,8 +66,7 @@ router.get('/', requireJwt, async (req, res, next) => {
|
|||||||
const questionsMap = {};
|
const questionsMap = {};
|
||||||
if (questionIds.length) {
|
if (questionIds.length) {
|
||||||
const r = await query(
|
const r = await query(
|
||||||
`SELECT id, sentence_de, sentence_en, sentence_sv
|
`SELECT id, sentence_de, sentence_en, sentence_sv FROM questions WHERE id = ANY($1)`,
|
||||||
FROM questions WHERE id = ANY($1)`,
|
|
||||||
[questionIds]
|
[questionIds]
|
||||||
);
|
);
|
||||||
r.rows.forEach(q => { questionsMap[q.id] = q; });
|
r.rows.forEach(q => { questionsMap[q.id] = q; });
|
||||||
@@ -73,8 +84,7 @@ router.get('/', requireJwt, async (req, res, next) => {
|
|||||||
r.rows.forEach(s => { statementsMap[s.id] = s; });
|
r.rows.forEach(s => { statementsMap[s.id] = s; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Collect all {{uuid}} references from all sentences
|
// 5. Parse all placeholders → find object IDs that need selections
|
||||||
const allUuids = new Set();
|
|
||||||
const allSentences = [
|
const allSentences = [
|
||||||
...Object.values(questionsMap).flatMap(q => [q.sentence_de, q.sentence_en, q.sentence_sv]),
|
...Object.values(questionsMap).flatMap(q => [q.sentence_de, q.sentence_en, q.sentence_sv]),
|
||||||
...Object.values(statementsMap).flatMap(s => [
|
...Object.values(statementsMap).flatMap(s => [
|
||||||
@@ -82,52 +92,34 @@ router.get('/', requireJwt, async (req, res, next) => {
|
|||||||
s.negative_sentence_de, s.negative_sentence_en, s.negative_sentence_sv,
|
s.negative_sentence_de, s.negative_sentence_en, s.negative_sentence_sv,
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
allSentences.forEach(s => extractUuids(s).forEach(u => allUuids.add(u)));
|
const allPlaceholderLists = allSentences.map(s => extractPlaceholders(s));
|
||||||
|
const objectIds = collectIds(allPlaceholderLists, 'object');
|
||||||
|
|
||||||
const uuidArr = [...allUuids];
|
// 6. Fetch selections for object placeholders
|
||||||
|
const selectionsMap = {}; // objectId → selections[]
|
||||||
// 6. Resolve UUIDs → words (direct) or via objects→words
|
if (objectIds.length) {
|
||||||
const placeholderMap = {}; // uuid → { de, en, sv }
|
const r = await query(
|
||||||
if (uuidArr.length) {
|
`SELECT id, selections FROM objects WHERE id = ANY($1)`,
|
||||||
// Direct word lookup
|
[objectIds]
|
||||||
const wordRes = await query(
|
|
||||||
`SELECT id, titel_de AS de, titel_en AS en, titel_sv AS sv FROM words WHERE id = ANY($1)`,
|
|
||||||
[uuidArr]
|
|
||||||
);
|
);
|
||||||
wordRes.rows.forEach(w => {
|
r.rows.forEach(o => { selectionsMap[o.id] = o.selections || []; });
|
||||||
placeholderMap[w.id] = { de: w.de, en: w.en, sv: w.sv, type: 'word', bbox: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Object lookup → get first linked word as label + selections polygon
|
|
||||||
const objUuids = uuidArr.filter(u => !placeholderMap[u]);
|
|
||||||
if (objUuids.length) {
|
|
||||||
const objRes = await query(
|
|
||||||
`SELECT ow.object_id AS id, w.titel_de AS de, w.titel_en AS en, w.titel_sv AS sv,
|
|
||||||
o.selections
|
|
||||||
FROM object_words ow
|
|
||||||
JOIN words w ON w.id = ow.word_id
|
|
||||||
JOIN objects o ON o.id = ow.object_id
|
|
||||||
WHERE ow.object_id = ANY($1)`,
|
|
||||||
[objUuids]
|
|
||||||
);
|
|
||||||
// First word per object
|
|
||||||
const seen = new Set();
|
|
||||||
objRes.rows.forEach(r => {
|
|
||||||
if (!seen.has(r.id)) {
|
|
||||||
placeholderMap[r.id] = {
|
|
||||||
de: r.de, en: r.en, sv: r.sv,
|
|
||||||
type: 'object',
|
|
||||||
selections: r.selections || [],
|
|
||||||
};
|
|
||||||
seen.add(r.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Fetch statement→word links (positive + negative)
|
// Build global placeholder map: id → { type, selections? }
|
||||||
const statWordMap = {}; // statementId → { positive: [], negative: [] }
|
// Labels are embedded in the sentence text — no lookup needed.
|
||||||
|
const placeholderMap = {};
|
||||||
|
for (const list of allPlaceholderLists) {
|
||||||
|
for (const p of list) {
|
||||||
|
if (!placeholderMap[p.id]) {
|
||||||
|
placeholderMap[p.id] = p.type === 'object'
|
||||||
|
? { type: 'object', selections: selectionsMap[p.id] || [] }
|
||||||
|
: { type: 'word' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Fetch positive + negative words for PairWordCard answer options
|
||||||
|
const statWordMap = {};
|
||||||
if (statementIds.length) {
|
if (statementIds.length) {
|
||||||
const posRes = await query(
|
const posRes = await query(
|
||||||
`SELECT spw.statement_id, w.id, w.titel_de AS de, w.titel_en AS en, w.titel_sv AS sv
|
`SELECT spw.statement_id, w.id, w.titel_de AS de, w.titel_en AS en, w.titel_sv AS sv
|
||||||
@@ -148,23 +140,37 @@ router.get('/', requireJwt, async (req, res, next) => {
|
|||||||
negRes.rows.forEach(r => { statWordMap[r.statement_id]?.negative.push({ id: r.id, de: r.de, en: r.en, sv: r.sv }); });
|
negRes.rows.forEach(r => { statWordMap[r.statement_id]?.negative.push({ id: r.id, de: r.de, en: r.en, sv: r.sv }); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Find pictures via object_pairs (direct pair → object link)
|
// 8. Fetch pictures via object_pairs → object_pictures → pictures (one per pair)
|
||||||
const pairIds = pairs.map(p => p.id);
|
const pictureMap = {}; // pairId → { url, blurhash }
|
||||||
const pairPictureMap = {}; // pairId → { url, blurhash }
|
|
||||||
if (pairIds.length) {
|
if (pairIds.length) {
|
||||||
const picRes = await query(
|
const picRes = await query(
|
||||||
`SELECT DISTINCT ON (op2.pair_id)
|
`SELECT DISTINCT ON (op.pair_id)
|
||||||
op2.pair_id, p.picture_link AS url, p.blurhash
|
op.pair_id, p.picture_link AS url, p.blurhash
|
||||||
FROM object_pairs op2
|
FROM object_pairs op
|
||||||
JOIN object_pictures op ON op.object_id = op2.object_id
|
JOIN object_pictures pic ON pic.object_id = op.object_id
|
||||||
JOIN pictures p ON p.id = op.picture_id
|
JOIN pictures p ON p.id = pic.picture_id
|
||||||
WHERE op2.pair_id = ANY($1)
|
WHERE op.pair_id = ANY($1)
|
||||||
ORDER BY op2.pair_id, p.created_at`,
|
ORDER BY op.pair_id, p.created_at`,
|
||||||
[pairIds]
|
[pairIds]
|
||||||
);
|
);
|
||||||
picRes.rows.forEach(r => {
|
picRes.rows.forEach(r => { pictureMap[r.pair_id] = { url: r.url, blurhash: r.blurhash }; });
|
||||||
pairPictureMap[r.pair_id] = { url: r.url, blurhash: r.blurhash };
|
}
|
||||||
});
|
|
||||||
|
// 9. Build per-pair placeholder subset
|
||||||
|
function getPairPlaceholders(p) {
|
||||||
|
const sentences = [
|
||||||
|
questionsMap[p.question_id]?.sentence_de,
|
||||||
|
questionsMap[p.question_id]?.sentence_en,
|
||||||
|
questionsMap[p.question_id]?.sentence_sv,
|
||||||
|
statementsMap[p.positive_statement_id]?.positive_sentence_de,
|
||||||
|
statementsMap[p.positive_statement_id]?.positive_sentence_en,
|
||||||
|
statementsMap[p.positive_statement_id]?.positive_sentence_sv,
|
||||||
|
statementsMap[p.negative_statement_id]?.negative_sentence_de,
|
||||||
|
statementsMap[p.negative_statement_id]?.negative_sentence_en,
|
||||||
|
statementsMap[p.negative_statement_id]?.negative_sentence_sv,
|
||||||
|
];
|
||||||
|
const ids = new Set(sentences.flatMap(s => extractPlaceholders(s).map(ph => ph.id)));
|
||||||
|
return Object.fromEntries([...ids].map(id => [id, placeholderMap[id] || null]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStatement(id) {
|
function buildStatement(id) {
|
||||||
@@ -172,13 +178,13 @@ router.get('/', requireJwt, async (req, res, next) => {
|
|||||||
const s = statementsMap[id];
|
const s = statementsMap[id];
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sentence_de: s.positive_sentence_de,
|
sentence_de: s.positive_sentence_de,
|
||||||
sentence_en: s.positive_sentence_en,
|
sentence_en: s.positive_sentence_en,
|
||||||
sentence_sv: s.positive_sentence_sv,
|
sentence_sv: s.positive_sentence_sv,
|
||||||
negative_sentence_de: s.negative_sentence_de,
|
negative_sentence_de: s.negative_sentence_de,
|
||||||
negative_sentence_en: s.negative_sentence_en,
|
negative_sentence_en: s.negative_sentence_en,
|
||||||
negative_sentence_sv: s.negative_sentence_sv,
|
negative_sentence_sv: s.negative_sentence_sv,
|
||||||
answer: s.answer,
|
answer: s.answer,
|
||||||
positive_words: statWordMap[id]?.positive || [],
|
positive_words: statWordMap[id]?.positive || [],
|
||||||
negative_words: statWordMap[id]?.negative || [],
|
negative_words: statWordMap[id]?.negative || [],
|
||||||
};
|
};
|
||||||
@@ -190,29 +196,19 @@ router.get('/', requireJwt, async (req, res, next) => {
|
|||||||
return { id, sentence_de: q.sentence_de, sentence_en: q.sentence_en, sentence_sv: q.sentence_sv };
|
return { id, sentence_de: q.sentence_de, sentence_en: q.sentence_en, sentence_sv: q.sentence_sv };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Assemble
|
// 10. Assemble
|
||||||
const result = pairs.map(p => {
|
const result = pairs.map(p => ({
|
||||||
const allText = [
|
id: p.id,
|
||||||
questionsMap[p.question_id]?.sentence_de,
|
answer_type: p.answer_type,
|
||||||
questionsMap[p.question_id]?.sentence_sv,
|
status: p.status,
|
||||||
statementsMap[p.positive_statement_id]?.positive_sentence_de,
|
difficulty_level: p.difficulty_level,
|
||||||
statementsMap[p.positive_statement_id]?.positive_sentence_sv,
|
lang,
|
||||||
].join(' ');
|
question: buildQuestion(p.question_id),
|
||||||
const pairUuids = extractUuids(allText);
|
positive_statement: buildStatement(p.positive_statement_id),
|
||||||
|
negative_statement: buildStatement(p.negative_statement_id),
|
||||||
return {
|
picture: pictureMap[p.id] || null,
|
||||||
id: p.id,
|
placeholders: getPairPlaceholders(p),
|
||||||
answer_type: p.answer_type,
|
}));
|
||||||
status: p.status,
|
|
||||||
difficulty_level: p.difficulty_level,
|
|
||||||
lang,
|
|
||||||
question: buildQuestion(p.question_id),
|
|
||||||
positive_statement: buildStatement(p.positive_statement_id),
|
|
||||||
negative_statement: buildStatement(p.negative_statement_id),
|
|
||||||
picture: pairPictureMap[p.id] || null,
|
|
||||||
placeholders: Object.fromEntries(pairUuids.map(u => [u, placeholderMap[u] || null])),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|||||||
Reference in New Issue
Block a user