diff --git a/src/routes/feed.js b/src/routes/feed.js index ec40336..6c2ca6a 100644 --- a/src/routes/feed.js +++ b/src/routes/feed.js @@ -1,6 +1,7 @@ // GET /auth/feed?lang=sv&limit=20 -// Returns hydrated pairs for the learning feed (end-user JWT allowed). -// {{uuid}} placeholders in sentences are resolved to word/object labels in all languages. +// Returns hydrated pairs for the learning feed. +// Placeholders in sentences: {{label.w:wordId}} or {{label.o:objectId}} +// Pictures come from object_pairs → object_pictures → pictures. const router = require('express').Router(); const jwt = require('jsonwebtoken'); @@ -18,11 +19,22 @@ function requireJwt(req, res, next) { } } -// Extract all {{uuid}} placeholder values from a string -function extractUuids(text) { +// Parse {{label.w:uuid}} or {{label.o:uuid}} from a sentence string +function extractPlaceholders(text) { if (!text) return []; - const matches = text.matchAll(/\{\{([0-9a-f-]{36})\}\}/g); - return [...matches].map(m => m[1]); + return [...text.matchAll(/\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}/g)] + .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) => { @@ -40,8 +52,8 @@ router.get('/', requireJwt, async (req, res, next) => { [limit] ); 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 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 = {}; if (questionIds.length) { const r = await query( - `SELECT id, sentence_de, sentence_en, sentence_sv - FROM questions WHERE id = ANY($1)`, + `SELECT id, sentence_de, sentence_en, sentence_sv FROM questions WHERE id = ANY($1)`, [questionIds] ); 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; }); } - // 5. Collect all {{uuid}} references from all sentences - const allUuids = new Set(); + // 5. Parse all placeholders → find object IDs that need selections const allSentences = [ ...Object.values(questionsMap).flatMap(q => [q.sentence_de, q.sentence_en, q.sentence_sv]), ...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, ]), ]; - 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. Resolve UUIDs → words (direct) or via objects→words - const placeholderMap = {}; // uuid → { de, en, sv } - if (uuidArr.length) { - // Direct word lookup - 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] + // 6. Fetch selections for object placeholders + const selectionsMap = {}; // objectId → selections[] + if (objectIds.length) { + const r = await query( + `SELECT id, selections FROM objects WHERE id = ANY($1)`, + [objectIds] ); - wordRes.rows.forEach(w => { - 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); - } - }); - } - + r.rows.forEach(o => { selectionsMap[o.id] = o.selections || []; }); } - // 7. Fetch statement→word links (positive + negative) - const statWordMap = {}; // statementId → { positive: [], negative: [] } + // Build global placeholder map: id → { type, selections? } + // 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) { 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 @@ -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 }); }); } - // 8. Find pictures via object_pairs (direct pair → object link) - const pairIds = pairs.map(p => p.id); - const pairPictureMap = {}; // pairId → { url, blurhash } + // 8. Fetch pictures via object_pairs → object_pictures → pictures (one per pair) + const pictureMap = {}; // pairId → { url, blurhash } if (pairIds.length) { const picRes = await query( - `SELECT DISTINCT ON (op2.pair_id) - op2.pair_id, p.picture_link AS url, p.blurhash - FROM object_pairs op2 - JOIN object_pictures op ON op.object_id = op2.object_id - JOIN pictures p ON p.id = op.picture_id - WHERE op2.pair_id = ANY($1) - ORDER BY op2.pair_id, p.created_at`, + `SELECT DISTINCT ON (op.pair_id) + op.pair_id, p.picture_link AS url, p.blurhash + FROM object_pairs op + JOIN object_pictures pic ON pic.object_id = op.object_id + JOIN pictures p ON p.id = pic.picture_id + WHERE op.pair_id = ANY($1) + ORDER BY op.pair_id, p.created_at`, [pairIds] ); - picRes.rows.forEach(r => { - pairPictureMap[r.pair_id] = { url: r.url, blurhash: r.blurhash }; - }); + picRes.rows.forEach(r => { pictureMap[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) { @@ -172,13 +178,13 @@ router.get('/', requireJwt, async (req, res, next) => { const s = statementsMap[id]; return { id, - sentence_de: s.positive_sentence_de, - sentence_en: s.positive_sentence_en, - sentence_sv: s.positive_sentence_sv, + sentence_de: s.positive_sentence_de, + sentence_en: s.positive_sentence_en, + sentence_sv: s.positive_sentence_sv, negative_sentence_de: s.negative_sentence_de, negative_sentence_en: s.negative_sentence_en, negative_sentence_sv: s.negative_sentence_sv, - answer: s.answer, + answer: s.answer, positive_words: statWordMap[id]?.positive || [], 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 }; } - // 9. Assemble - const result = pairs.map(p => { - const allText = [ - questionsMap[p.question_id]?.sentence_de, - questionsMap[p.question_id]?.sentence_sv, - statementsMap[p.positive_statement_id]?.positive_sentence_de, - statementsMap[p.positive_statement_id]?.positive_sentence_sv, - ].join(' '); - const pairUuids = extractUuids(allText); - - return { - id: p.id, - 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])), - }; - }); + // 10. Assemble + const result = pairs.map(p => ({ + id: p.id, + 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: pictureMap[p.id] || null, + placeholders: getPairPlaceholders(p), + })); res.json(result); } catch (err) { next(err); }