diff --git a/controllers/contextController.js b/controllers/contextController.js index 9effb1a..f8f4473 100644 --- a/controllers/contextController.js +++ b/controllers/contextController.js @@ -1,4 +1,5 @@ const { Context, Vocab } = require('../models'); +const { Op } = require('sequelize'); /** * Context Controller - Workflow-based status management @@ -11,7 +12,7 @@ class ContextController { */ async createContext(req, res, next) { try { - const { title, desc, grade, type, type_image , reference_id } = req.body; + const { title, desc, grade, type, type_image, reference_id } = req.body; // Validate required fields if (!title || !desc || !grade) { @@ -58,18 +59,96 @@ class ContextController { async getContextsByStatus(req, res, next) { try { const { status } = req.params; - const { page = 1, limit = 50, type, grade } = req.query; + const { page = 1, limit = 50, type, grade, title, date, from, to, sort } = req.query; const offset = (page - 1) * limit; const where = { status: parseInt(status) }; if (type) where.type = type; if (grade) where.grade = parseInt(grade); + if (title) where.title = { [Op.like]: `%${title}%` }; + + // Hàm helper để chuẩn hóa và parse date cho dải thời gian + const parseDateRange = (dStr, isEndDate = false) => { + if (!dStr) return null; + let s = String(dStr).replace('_', 'T').replace(' ', 'T'); + if (s.includes(':') && !s.includes('Z') && !s.match(/[+-]\d{2}:?\d{2}$/)) { + s += 'Z'; + } + const d = new Date(s); + if (isNaN(d.getTime())) return null; + if (!s.includes(':') && isEndDate) { + d.setHours(23, 59, 59, 999); + } + return d; + }; + + if (from || to) { + where.updated_at = {}; + if (from) { + const startDate = parseDateRange(from); + if (startDate) where.updated_at[Op.gte] = startDate; + } + if (to) { + const endDate = parseDateRange(to, true); + if (endDate) where.updated_at[Op.lte] = endDate; + } + } else if (date) { + // Chuẩn hóa chuỗi ngày tháng: thay '_' hoặc khoảng cách bằng 'T' để dễ xử lý + let dateString = String(date).replace('_', 'T').replace(' ', 'T'); + + // Nếu chuỗi có giờ phút (có dấu :) nhưng chưa có múi giờ (Z hoặc +/-) + // Ta mặc định là giờ UTC để khớp chính xác với những gì người dùng thấy trong DB + if (dateString.includes(':') && !dateString.includes('Z') && !dateString.match(/[+-]\d{2}:?\d{2}$/)) { + dateString += 'Z'; + } + + const searchDate = new Date(dateString); + + if (!isNaN(searchDate.getTime())) { + // Kiểm tra xem có dấu ':' (có giờ phút) không + const hasTime = dateString.includes(':'); + + if (hasTime) { + // Kiểm tra xem có cung cấp đến giây không (vd: 09:08:18 -> 3 phần) + const isSecondsProvided = dateString.split(':').length === 3; + + if (isSecondsProvided) { + // Tìm chính xác giây đó (dải 1000ms) + const startTime = Math.floor(searchDate.getTime() / 1000) * 1000; + const startRange = new Date(startTime); + const endRange = new Date(startTime + 1000); + where.updated_at = { [Op.gte]: startRange, [Op.lt]: endRange }; + } else { + // Chỉ có giờ:phút, tìm trong cả phút đó + const startTime = Math.floor(searchDate.getTime() / 60000) * 60000; + const startRange = new Date(startTime); + const endRange = new Date(startTime + 60000); + where.updated_at = { [Op.gte]: startRange, [Op.lt]: endRange }; + } + } else { + // Nếu chỉ có ngày (vd: 2026-02-28), tìm cả ngày theo giờ server + const startOfDay = new Date(searchDate); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(searchDate); + endOfDay.setHours(23, 59, 59, 999); + where.updated_at = { [Op.gte]: startOfDay, [Op.lte]: endOfDay }; + } + } + } + + // Sort order + let order = [['title', 'ASC']]; // default alphabet + if (sort === 'created_at') { + order = [['created_at', 'DESC']]; + } else if (sort === 'updated_at') { + order = [['updated_at', 'DESC']]; + } const { count, rows } = await Context.findAndCountAll({ where, limit: parseInt(limit), offset: parseInt(offset), - order: [['created_at', 'DESC']] + order }); res.json({ @@ -308,8 +387,8 @@ class ContextController { const updatedImagesSquare = currentVocab.image_square || []; updatedImagesSquare.push(context.image); await currentVocab.update({ image_square: updatedImagesSquare }); - } else if (context.type_image === 'normal') { - const updatedImagesNormal = currentVocab.image_normal || []; + } else if (context.type_image === 'normal') { + const updatedImagesNormal = currentVocab.image_normal || []; updatedImagesNormal.push(context.image); await currentVocab.update({ image_normal: updatedImagesNormal }); } @@ -416,11 +495,11 @@ class ContextController { type, status, grade, + reference_id, page = 1, limit = 50 } = req.body; - const { Op } = require('sequelize'); const offset = (page - 1) * limit; const where = {}; @@ -430,7 +509,8 @@ class ContextController { } if (type) where.type = type; if (status !== undefined && status !== null) where.status = parseInt(status); - if (grade !== undefined && grade !== null) where.grade = parseInt(grade); + if (grade !== undefined && grade !== null) where.grade = parseInt(grade); + if (reference_id) where.reference_id = reference_id; // ── Text search ────────────────────────────────────────────────────── // `search` → title OR context (cả hai cùng lúc) @@ -440,7 +520,7 @@ class ContextController { if (search) { textConditions.push( - { title: { [Op.like]: `%${search}%` } }, + { title: { [Op.like]: `%${search}%` } }, { context: { [Op.like]: `%${search}%` } } ); } diff --git a/controllers/sentencesController.js b/controllers/sentencesController.js index 6338569..2b53235 100644 --- a/controllers/sentencesController.js +++ b/controllers/sentencesController.js @@ -127,6 +127,8 @@ exports.getAllSentences = async (req, res) => { topic, text, search, + status, + sort, is_active = true } = req.query; @@ -137,6 +139,11 @@ exports.getAllSentences = async (req, res) => { where.is_active = is_active === 'true' || is_active === true; } + // Status filter (0 = chưa duyệt, 1 = đã duyệt, undefined = all) + if (status !== undefined && status !== '' && status !== 'all') { + where.status = parseInt(status); + } + if (category) { where.category = category; } @@ -156,11 +163,19 @@ exports.getAllSentences = async (req, res) => { ]; } + // Sort order + let order = [['created_at', 'DESC']]; // default + if (sort === 'updated_at') { + order = [['updated_at', 'DESC']]; + } else if (sort === 'alphabet') { + order = [['text', 'ASC']]; + } + const { count, rows } = await Sentences.findAndCountAll({ where, limit: parseInt(limit), offset: parseInt(offset), - order: [['created_at', 'DESC']] + order }); res.json({ diff --git a/controllers/vocabController.js b/controllers/vocabController.js index 8feddc3..6310b1c 100644 --- a/controllers/vocabController.js +++ b/controllers/vocabController.js @@ -60,10 +60,10 @@ const { Op } = require('sequelize'); */ exports.createVocab = async (req, res) => { try { - const { + const { text, ipa, - base_word, + base_word, vi, category, topic, @@ -160,16 +160,18 @@ exports.createVocab = async (req, res) => { */ exports.getAllVocabs = async (req, res) => { try { - const { - page = 1, - limit = 20, - category, + const { + page = 1, + limit = 20, + category, topic, base_word, text, search, grade_start, grade_end, + status, + sort, is_active = true } = req.query; @@ -181,6 +183,11 @@ exports.getAllVocabs = async (req, res) => { where.is_active = is_active === 'true' || is_active === true; } + // Status filter (0 = chưa duyệt, 1 = đã duyệt, undefined = all) + if (status !== undefined && status !== '' && status !== 'all') { + where.status = parseInt(status); + } + if (category) { where.category = category; } @@ -221,11 +228,19 @@ exports.getAllVocabs = async (req, res) => { where.grade_number = { [Op.lte]: parseInt(grade_end) }; } + // Sort order + let order = [['created_at', 'DESC']]; // default + if (sort === 'updated_at') { + order = [['updated_at', 'DESC']]; + } else if (sort === 'alphabet') { + order = [['text', 'ASC']]; + } + const { count, rows } = await Vocab.findAndCountAll({ where, limit: parseInt(limit), offset: parseInt(offset), - order: [['created_at', 'DESC']] + order }); res.json({ @@ -276,9 +291,9 @@ exports.getVocabById = async (req, res) => { const { id } = req.params; const vocab = await Vocab.findOne({ - where: { + where: { vocab_id: id, - is_active: true + is_active: true } }); @@ -339,7 +354,7 @@ exports.getVocabsWithoutIpa = async (req, res) => { const offset = (page - 1) * limit; const { count, rows } = await Vocab.findAndCountAll({ - where: { + where: { is_active: true, [Op.or]: [ { ipa: null }, @@ -407,7 +422,7 @@ exports.getVocabsWithoutImages = async (req, res) => { const offset = (page - 1) * limit; const { count, rows } = await Vocab.findAndCountAll({ - where: { + where: { is_active: true, [Op.or]: [ { image_small: null }, @@ -798,7 +813,7 @@ exports.getVocabStats = async (req, res) => { */ exports.searchVocabs = async (req, res) => { try { - const { + const { topic, category, base_word, @@ -876,7 +891,7 @@ exports.searchVocabs = async (req, res) => { // Handle shuffle_pos: find replacement words based on syntax if (shuffle_pos && typeof shuffle_pos === 'object') { const syntaxConditions = []; - + if (shuffle_pos.is_subject === true) { syntaxConditions.push({ 'syntax.is_subject': true }); } @@ -911,7 +926,7 @@ exports.searchVocabs = async (req, res) => { true ); }); - + if (orConditions.length === 1) { where[Op.and] = where[Op.and] || []; where[Op.and].push(orConditions[0]); @@ -975,7 +990,7 @@ exports.searchVocabs = async (req, res) => { exports.getAllCategories = async (req, res) => { try { const categories = await Vocab.findAll({ - where: { + where: { is_active: true, category: { [Op.ne]: null } }, @@ -1026,7 +1041,7 @@ exports.getAllCategories = async (req, res) => { exports.getAllTopics = async (req, res) => { try { const topics = await Vocab.findAll({ - where: { + where: { is_active: true, topic: { [Op.ne]: null } }, diff --git a/import-context-to-sentences.js b/import-context-to-sentences.js index ecf7837..5094e50 100644 --- a/import-context-to-sentences.js +++ b/import-context-to-sentences.js @@ -1,59 +1,26 @@ -/** - * Import Context (status=5) → Sentences - * - * Logic: - * 1. Lấy tất cả Context có status = 5 - * 2. So sánh Context.title với Sentences.text - * - Nếu chưa có → tạo mới Sentence - * - Nếu đã có → cập nhật image vào đúng mảng - * 3. Xác định slot image dựa vào Context.image.split("_")[1]: - * - "square" → image_square - * - "small" → image_small - * - "normal" → image_normal - * 4. Chuyển Context.status → 6 - */ - const { sequelize } = require('./config/database'); -const Context = require('./models/Context'); -const Sentences = require('./models/Sentences'); - -// ─── helper: thêm URL vào mảng JSON (không trùng lặp) ─────────────────────── -function addToArray(existing, url) { - const arr = Array.isArray(existing) ? [...existing] : []; - if (!arr.includes(url)) arr.push(url); - return arr; -} +const { Context, Vocab, Sentences } = require('./models'); // ─── helper: xác định slot image từ tên file/URL ──────────────────────────── function resolveImageSlot(imageUrl) { if (!imageUrl) return null; - - // Lấy phần tên file (bỏ path/query) const filename = imageUrl.split('/').pop().split('?')[0]; const parts = filename.split('_'); - - // Duyệt tất cả phần tử, tìm keyword for (const part of parts) { const key = part.toLowerCase(); if (key === 'square') return 'image_square'; - if (key === 'small') return 'image_small'; + if (key === 'small') return 'image_small'; if (key === 'normal') return 'image_normal'; } - - return null; // không xác định được + return null; } -// ─── main ──────────────────────────────────────────────────────────────────── async function run() { try { await sequelize.authenticate(); console.log('✅ Database connected\n'); - // 1. Lấy tất cả Context có status = 5 - const contexts = await Context.findAll({ - where: { status: 5 } - }); - + const contexts = await Context.findAll({ where: { status: 5 } }); console.log(`📦 Tìm thấy ${contexts.length} Context(s) có status = 5\n`); if (contexts.length === 0) { @@ -61,76 +28,78 @@ async function run() { process.exit(0); } - let created = 0; - let updated = 0; + let updatedVocab = 0; + let updatedSentences = 0; let skipped = 0; for (const ctx of contexts) { - const text = ctx.context; // so sánh với Sentences.text - const imageUrl = ctx.image; - const imageSlot = resolveImageSlot(imageUrl); + const imageUrl = ctx.image; + const slot = resolveImageSlot(imageUrl); - if (!text) { - console.warn(` ⚠️ Context [${ctx.uuid}] không có context — bỏ qua`); + if (!imageUrl || !slot) { + console.warn(` ⚠️ [BỎ QUA] ID ${ctx.uuid}: Không xác định được ảnh/slot`); skipped++; continue; } - // 2. Tìm Sentence có text khớp - let sentence = await Sentences.findOne({ - where: { text } - }); - - if (!sentence) { - // ── Tạo mới ────────────────────────────────────────────────────────── - const newData = { - text, - is_active: true, - image_small: [], - image_square: [], - image_normal: [], - }; - - // Gán image vào đúng slot - if (imageUrl && imageSlot) { - newData[imageSlot] = [imageUrl]; - } else if (imageUrl) { - console.warn(` ⚠️ Không xác định slot image từ URL: "${imageUrl}" — bỏ qua image`); - } - - await Sentences.create(newData); - console.log(` ✅ [TẠO MỚI] "${text}"${imageSlot ? ` → ${imageSlot}` : ''}`); - created++; + let targetEntity = null; + let entityType = ''; + // Xử lý Vocab + if (ctx.type === 'vocabulary') { + // Ưu tiên tìm bản ghi đang active + targetEntity = await Vocab.findOne({ + where: { text: ctx.title, is_active: true } + }); + // Nếu không có bản ghi active, mới tìm bản ghi bất kỳ + if (!targetEntity) targetEntity = await Vocab.findOne({ where: { text: ctx.title } }); + entityType = 'Vocab'; } else { - // ── Cập nhật image ─────────────────────────────────────────────────── - if (imageUrl && imageSlot) { - const updatedArr = addToArray(sentence[imageSlot], imageUrl); - await sentence.update({ [imageSlot]: updatedArr }); - console.log(` 🔄 [CẬP NHẬT] "${text}" → ${imageSlot} (+1 ảnh)`); - updated++; - } else { - console.warn(` ⚠️ [BỎ QUA IMAGE] "${text}" — URL trống hoặc không xác định slot`); - skipped++; + // Xử lý Sentence (ưu tiên active) + targetEntity = await Sentences.findOne({ + where: { text: ctx.context, is_active: true } + }); + if (!targetEntity) targetEntity = await Sentences.findOne({ where: { text: ctx.context } }); + + if (!targetEntity) { + targetEntity = await Sentences.findOne({ + where: { text: ctx.title, is_active: true } + }); + if (!targetEntity) targetEntity = await Sentences.findOne({ where: { text: ctx.title } }); } + entityType = 'Sentence'; } - // 3. Chuyển Context.status → 6 - await ctx.update({ status: 6 }); + if (targetEntity) { + // 1. Cập nhật ảnh (Ghi đè - để mảng chỉ có 1 ảnh) + await targetEntity.update({ [slot]: [imageUrl] }); + + // 2. Kiểm tra và sửa reference_id nếu lệch + const correctId = targetEntity.vocab_id || targetEntity.id; + const updates = { status: 6 }; + if (ctx.reference_id !== correctId) { + updates.reference_id = correctId; + } + await ctx.update(updates); + + if (entityType === 'Vocab') updatedVocab++; else updatedSentences++; + console.log(` ✅ [${entityType}] "${ctx.title}" -> ${slot} (Đã ghi đè)`); + } else { + console.warn(` ❌ [KHÔNG THẤY] "${ctx.title}" (Type: ${ctx.type})`); + skipped++; + } } console.log('\n─────────────────────────────────────'); console.log(`📊 Kết quả:`); - console.log(` ✅ Tạo mới : ${created}`); - console.log(` 🔄 Cập nhật : ${updated}`); - console.log(` ⚠️ Bỏ qua : ${skipped}`); - console.log(` 📌 Tổng : ${contexts.length}`); + console.log(` ✅ Cập nhật Vocab : ${updatedVocab}`); + console.log(` ✅ Cập nhật Sentences: ${updatedSentences}`); + console.log(` ⚠️ Bỏ qua : ${skipped}`); console.log('─────────────────────────────────────'); process.exit(0); } catch (error) { console.error('❌ Lỗi:', error.message); - console.error(error.stack); process.exit(1); } } diff --git a/migration_scripts/add-status-etc-fields.js b/migration_scripts/add-status-etc-fields.js new file mode 100644 index 0000000..ec62af8 --- /dev/null +++ b/migration_scripts/add-status-etc-fields.js @@ -0,0 +1,104 @@ +const { sequelize } = require('../config/database'); + +/** + * Migration: Thêm trường `status` (INT, default 0) và `etc` (TEXT, default '') + * cho cả bảng `vocab` và `sentences`. + * + * - status: dùng để confirm lại vocab/sentence (0 = chưa confirm, etc.) + * - etc: thông tin bổ sung dạng chuỗi + */ +async function run() { + try { + await sequelize.authenticate(); + console.log('✅ Database connected\n'); + + const queryInterface = sequelize.getQueryInterface(); + + // ─── VOCAB ─────────────────────────────────────────── + console.log('📦 Đang migrate bảng VOCAB...'); + + // Kiểm tra & thêm status + try { + await queryInterface.addColumn('vocab', 'status', { + type: sequelize.Sequelize.DataTypes.INTEGER, + defaultValue: 0, + allowNull: false, + comment: 'Trạng thái confirm (0 = chưa confirm)' + }); + console.log(' ✅ Đã thêm cột "status" vào bảng vocab'); + } catch (e) { + if (e.message.includes('Duplicate') || e.message.includes('already exists') || e.original?.code === 'ER_DUP_FIELDNAME') { + console.log(' ⚠️ Cột "status" đã tồn tại trong bảng vocab, bỏ qua.'); + } else { + throw e; + } + } + + // Kiểm tra & thêm etc + try { + await queryInterface.addColumn('vocab', 'etc', { + type: sequelize.Sequelize.DataTypes.TEXT, + defaultValue: '', + allowNull: true, + comment: 'Thông tin bổ sung' + }); + console.log(' ✅ Đã thêm cột "etc" vào bảng vocab'); + } catch (e) { + if (e.message.includes('Duplicate') || e.message.includes('already exists') || e.original?.code === 'ER_DUP_FIELDNAME') { + console.log(' ⚠️ Cột "etc" đã tồn tại trong bảng vocab, bỏ qua.'); + } else { + throw e; + } + } + + // ─── SENTENCES ─────────────────────────────────────── + console.log('\n📦 Đang migrate bảng SENTENCES...'); + + // Kiểm tra & thêm status + try { + await queryInterface.addColumn('sentences', 'status', { + type: sequelize.Sequelize.DataTypes.INTEGER, + defaultValue: 0, + allowNull: false, + comment: 'Trạng thái confirm (0 = chưa confirm)' + }); + console.log(' ✅ Đã thêm cột "status" vào bảng sentences'); + } catch (e) { + if (e.message.includes('Duplicate') || e.message.includes('already exists') || e.original?.code === 'ER_DUP_FIELDNAME') { + console.log(' ⚠️ Cột "status" đã tồn tại trong bảng sentences, bỏ qua.'); + } else { + throw e; + } + } + + // Kiểm tra & thêm etc (sentences đã có etc trong model nhưng chưa chắc có trong DB) + try { + await queryInterface.addColumn('sentences', 'etc', { + type: sequelize.Sequelize.DataTypes.TEXT, + defaultValue: '', + allowNull: true, + comment: 'Thông tin bổ sung' + }); + console.log(' ✅ Đã thêm cột "etc" vào bảng sentences'); + } catch (e) { + if (e.message.includes('Duplicate') || e.message.includes('already exists') || e.original?.code === 'ER_DUP_FIELDNAME') { + console.log(' ⚠️ Cột "etc" đã tồn tại trong bảng sentences, bỏ qua.'); + } else { + throw e; + } + } + + console.log('\n─────────────────────────────────────'); + console.log('✅ Migration hoàn tất!'); + console.log(' - vocab: status (INT, default 0), etc (TEXT, default "")'); + console.log(' - sentences: status (INT, default 0), etc (TEXT, default "")'); + console.log('─────────────────────────────────────'); + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi:', error.message); + process.exit(1); + } +} + +run(); diff --git a/migration_scripts/check-null-ref-id.js b/migration_scripts/check-null-ref-id.js new file mode 100644 index 0000000..8c8fd47 --- /dev/null +++ b/migration_scripts/check-null-ref-id.js @@ -0,0 +1,53 @@ +const { Context } = require('../models'); +const { Op } = require('sequelize'); +const { sequelize } = require('../config/database'); + +async function checkNullRefId() { + try { + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công.'); + + // Kiểm tra tất cả các bản ghi có reference_id là null hoặc chuỗi rỗng + const nullRefCount = await Context.count({ + where: { + [Op.or]: [ + { reference_id: null }, + { reference_id: '' } + ] + } + }); + + const totalCount = await Context.count(); + + // Thêm thống kê theo status để dễ hình dung + const statsByStatus = await Context.findAll({ + attributes: ['status', [sequelize.fn('COUNT', sequelize.col('uuid')), 'count']], + where: { + [Op.or]: [ + { reference_id: null }, + { reference_id: '' } + ] + }, + group: ['status'] + }); + + console.log('\n--- KẾT QUẢ KIỂM TRA REFERENCE_ID ---'); + console.log(`📊 Tổng số bản ghi trong bảng: ${totalCount}`); + console.log(`❌ Số bản ghi có reference_id bị NULL/Trống: ${nullRefCount}`); + console.log(`📈 Tỷ lệ lỗi: ${((nullRefCount / totalCount) * 100).toFixed(2)}%`); + + if (statsByStatus.length > 0) { + console.log('\nPhân loại theo Status:'); + statsByStatus.forEach(stat => { + console.log(` - Status ${stat.get('status')}: ${stat.get('count')} bản ghi`); + }); + } + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi khi kiểm tra:', error); + process.exit(1); + } +} + +checkNullRefId(); diff --git a/migration_scripts/fix-reference-ids.js b/migration_scripts/fix-reference-ids.js new file mode 100644 index 0000000..51ed2cc --- /dev/null +++ b/migration_scripts/fix-reference-ids.js @@ -0,0 +1,57 @@ +const { Context, Vocab, Sentences } = require('../models'); +const { sequelize } = require('../config/database'); + +async function fixReferenceIds() { + try { + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công.'); + + // Chỉ kiểm tra các bản ghi status 5 (hoặc bạn có thể bỏ where để check tất cả) + const contexts = await Context.findAll({ + where: { status: 5 } + }); + + console.log(`🔍 Đang kiểm tra ${contexts.length} bản ghi Context để sửa Reference ID...`); + + let fixedCount = 0; + let verifiedCount = 0; + + for (const ctx of contexts) { + // 1. Thử tìm trong Vocab trước + const vocab = await Vocab.findOne({ where: { text: ctx.title } }); + if (vocab) { + if (ctx.reference_id !== vocab.vocab_id) { + await ctx.update({ reference_id: vocab.vocab_id }); + fixedCount++; + console.log(`✅ Fixed ID: '${ctx.title}' -> ${vocab.vocab_id} (Vocab)`); + } else { + verifiedCount++; + } + continue; + } + + // 2. Nếu không thấy trong Vocab, thử tìm trong Sentences bằng trường context + const sentence = await Sentences.findOne({ where: { text: ctx.context } }); + if (sentence) { + if (ctx.reference_id !== sentence.id) { + await ctx.update({ reference_id: sentence.id }); + fixedCount++; + console.log(`✅ Fixed ID: '${ctx.title}' -> ${sentence.id} (Sentence)`); + } else { + verifiedCount++; + } + } + } + + console.log('\n--- KẾT QUẢ SỬA LỖI ---'); + console.log(`✅ Đã sửa: ${fixedCount} bản ghi.`); + console.log(`✔️ Đã chuẩn sẵn: ${verifiedCount} bản ghi.`); + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi:', error); + process.exit(1); + } +} + +fixReferenceIds(); diff --git a/migration_scripts/migrate-status-6-to-5.js b/migration_scripts/migrate-status-6-to-5.js new file mode 100644 index 0000000..b5923d0 --- /dev/null +++ b/migration_scripts/migrate-status-6-to-5.js @@ -0,0 +1,34 @@ +const { Context } = require('../models'); +const { Op } = require('sequelize'); +const { sequelize } = require('../config/database'); + +async function migrate() { + try { + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công.'); + + const fromDate = new Date('2026-02-28T09:00:00Z'); + + console.log(`🔍 Tìm kiếm các context có status = 6 và update từ ${fromDate.toISOString()} trở đi...`); + + const [affectedCount] = await Context.update( + { status: 5 }, + { + where: { + status: 6, + updated_at: { + [Op.gte]: fromDate + } + } + } + ); + + console.log(`✅ Đã cập nhật thành công ${affectedCount} bản ghi từ status 6 sang status 5.`); + process.exit(0); + } catch (error) { + console.error('❌ Lỗi khi migration:', error); + process.exit(1); + } +} + +migrate(); diff --git a/migration_scripts/output.txt b/migration_scripts/output.txt new file mode 100644 index 0000000..f803df4 Binary files /dev/null and b/migration_scripts/output.txt differ diff --git a/migration_scripts/sync-images-to-all.js b/migration_scripts/sync-images-to-all.js new file mode 100644 index 0000000..86a1a11 --- /dev/null +++ b/migration_scripts/sync-images-to-all.js @@ -0,0 +1,108 @@ +const { Context, Vocab, Sentences } = require('../models'); +const { sequelize } = require('../config/database'); + +// --- helper: xác định slot image từ tên file/URL --- +function resolveImageSlot(imageUrl) { + if (!imageUrl) return null; + const filename = imageUrl.split('/').pop().split('?')[0]; + const parts = filename.split('_'); + for (const part of parts) { + const key = part.toLowerCase(); + if (key === 'square') return 'image_square'; + if (key === 'small') return 'image_small'; + if (key === 'normal') return 'image_normal'; + } + // Nếu không tìm thấy trong underscore, thử tìm trong toàn bộ URL + if (imageUrl.toLowerCase().includes('square')) return 'image_square'; + if (imageUrl.toLowerCase().includes('small')) return 'image_small'; + if (imageUrl.toLowerCase().includes('normal')) return 'image_normal'; + return null; +} + +async function syncImages() { + try { + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công.'); + + const contexts = await Context.findAll({ + where: { status: 5 } + }); + + console.log(`📦 Tìm thấy ${contexts.length} bản ghi Context ở status 5.`); + + let vocabUpdated = 0; + let sentencesUpdated = 0; + let skipped = 0; + + for (const ctx of contexts) { + const imageUrl = ctx.image; + const slot = resolveImageSlot(imageUrl); + + if (!imageUrl || !slot) { + console.warn(`⚠️ ID ${ctx.uuid}: Không xác định được ảnh hoặc slot (URL: ${imageUrl}). Bỏ qua.`); + skipped++; + continue; + } + + let updated = false; + + // Ưu tiên 1: Dùng reference_id + if (ctx.reference_id) { + // Thử tìm trong Vocab + const vocab = await Vocab.findByPk(ctx.reference_id); + if (vocab) { + await vocab.update({ [slot]: [imageUrl] }); + vocabUpdated++; + updated = true; + } else { + // Thử tìm trong Sentences + const sentence = await Sentences.findByPk(ctx.reference_id); + if (sentence) { + await sentence.update({ [slot]: [imageUrl] }); + sentencesUpdated++; + updated = true; + } + } + } + + // Ưu tiên 2: Nếu chưa update được (hoặc ko có ref_id), thử match theo text + if (!updated) { + // Thử khớp Vocab.text = Context.title + const vocabByText = await Vocab.findOne({ where: { text: ctx.title } }); + if (vocabByText) { + await vocabByText.update({ [slot]: [imageUrl] }); + vocabUpdated++; + updated = true; + } else if (ctx.context) { + // Thử khớp Sentences.text = Context.context + const sentenceByText = await Sentences.findOne({ where: { text: ctx.context } }); + if (sentenceByText) { + await sentenceByText.update({ [slot]: [imageUrl] }); + sentencesUpdated++; + updated = true; + } + } + } + + if (updated) { + await ctx.update({ status: 6 }); + } else { + console.warn(`❌ ID ${ctx.uuid}: Không tìm thấy entity đích để cập nhật ảnh (Title: ${ctx.title}).`); + skipped++; + } + } + + console.log('\n--- KẾT QUẢ ĐỒNG BỘ ẢNH ---'); + console.log(`✅ Cập nhật Vocab: ${vocabUpdated} bản ghi.`); + console.log(`✅ Cập nhật Sentences: ${sentencesUpdated} bản ghi.`); + console.log(`⚠️ Bỏ qua hoặc lỗi: ${skipped} bản ghi.`); + console.log(`📊 Tổng xử lý: ${contexts.length}`); + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi khi đồng bộ:', error); + process.exit(1); + } +} + +syncImages(); diff --git a/migration_scripts/sync-ref-id-from-sentences.js b/migration_scripts/sync-ref-id-from-sentences.js new file mode 100644 index 0000000..64e0f3e --- /dev/null +++ b/migration_scripts/sync-ref-id-from-sentences.js @@ -0,0 +1,67 @@ +const { Context, Sentences } = require('../models'); +const { Op } = require('sequelize'); +const { sequelize } = require('../config/database'); + +async function syncRefIdFromSentences() { + try { + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công.'); + + // 1. Tìm các bản ghi status = 5 và reference_id đang null hoặc trống + const contexts = await Context.findAll({ + where: { + status: 5, + [Op.or]: [ + { reference_id: null }, + { reference_id: '' } + ] + } + }); + + console.log(`📊 Tìm thấy ${contexts.length} bản ghi status = 5 thiếu reference_id.`); + + let updatedCount = 0; + let notFoundCount = 0; + + for (const context of contexts) { + // Tìm câu trong bảng Sentences có text khớp chính xác với title của Context + const sentence = await Sentences.findOne({ + where: { + text: context.title + } + }); + + if (sentence) { + await context.update({ reference_id: sentence.id }); + updatedCount++; + // console.log(`✅ ID ${context.uuid}: Đã khớp '${context.title}' -> Sentence ID ${sentence.id}`); + } else { + notFoundCount++; + // console.log(`⚠️ ID ${context.uuid}: Không tìm thấy câu '${context.title}' trong bảng Sentences.`); + } + } + + // 2. Kiểm tra lại số lượng còn sót sau khi update + const remainingCount = await Context.count({ + where: { + status: 5, + [Op.or]: [ + { reference_id: null }, + { reference_id: '' } + ] + } + }); + + console.log('\n--- KẾT QUẢ CẬP NHẬT REFERENCE_ID TỪ SENTENCES ---'); + console.log(`✅ Đã cập nhật thành công: ${updatedCount} bản ghi.`); + console.log(`❌ Không tìm thấy Sentence khớp: ${notFoundCount} bản ghi.`); + console.log(`📊 Tổng số bản ghi status 5 vẫn còn thiếu reference_id: ${remainingCount}`); + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi khi migration:', error); + process.exit(1); + } +} + +syncRefIdFromSentences(); diff --git a/migration_scripts/sync-ref-id-from-vocab.js b/migration_scripts/sync-ref-id-from-vocab.js new file mode 100644 index 0000000..84312d5 --- /dev/null +++ b/migration_scripts/sync-ref-id-from-vocab.js @@ -0,0 +1,67 @@ +const { Context, Vocab } = require('../models'); +const { Op } = require('sequelize'); +const { sequelize } = require('../config/database'); + +async function syncRefIdFromVocab() { + try { + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công.'); + + // 1. Tìm các bản ghi status = 5 và reference_id đang null hoặc trống + const contexts = await Context.findAll({ + where: { + status: 5, + [Op.or]: [ + { reference_id: null }, + { reference_id: '' } + ] + } + }); + + console.log(`📊 Tìm thấy ${contexts.length} bản ghi status = 5 thiếu reference_id.`); + + let updatedCount = 0; + let notFoundCount = 0; + + for (const context of contexts) { + // Tìm từ trong bảng Vocab có text khớp chính xác với title của Context + const vocab = await Vocab.findOne({ + where: { + text: context.title + } + }); + + if (vocab) { + await context.update({ reference_id: vocab.vocab_id }); + updatedCount++; + // console.log(`✅ ID ${context.uuid}: Đã khớp '${context.title}' -> Vocab ID ${vocab.vocab_id}`); + } else { + notFoundCount++; + console.log(`⚠️ ID ${context.uuid}: Không tìm thấy từ '${context.title}' trong bảng Vocab.`); + } + } + + // 2. Kiểm tra lại số lượng còn sót sau khi update + const remainingCount = await Context.count({ + where: { + status: 5, + [Op.or]: [ + { reference_id: null }, + { reference_id: '' } + ] + } + }); + + console.log('\n--- KẾT QUẢ CẬP NHẬT REFERENCE_ID ---'); + console.log(`✅ Đã cập nhật thành công: ${updatedCount} bản ghi.`); + console.log(`❌ Không tìm thấy Vocab khớp: ${notFoundCount} bản ghi.`); + console.log(`📊 Tổng số bản ghi status 5 vẫn còn thiếu reference_id: ${remainingCount}`); + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi khi migration:', error); + process.exit(1); + } +} + +syncRefIdFromVocab(); diff --git a/migration_scripts/update-type-image-from-url.js b/migration_scripts/update-type-image-from-url.js new file mode 100644 index 0000000..4508855 --- /dev/null +++ b/migration_scripts/update-type-image-from-url.js @@ -0,0 +1,70 @@ +const { Context } = require('../models'); +const { Op } = require('sequelize'); +const { sequelize } = require('../config/database'); + +async function updateTypeImage() { + try { + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công.'); + + // 1. Tìm các bản ghi status = 5 và type_image đang null + const contexts = await Context.findAll({ + where: { + status: 5, + [Op.or]: [ + { type_image: null }, + { type_image: '' } + ] + } + }); + + console.log(`📊 Tìm thấy ${contexts.length} bản ghi status = 5 thiếu type_image.`); + + let updatedCount = 0; + let skippedCount = 0; + + for (const context of contexts) { + if (!context.image) { + skippedCount++; + continue; + } + + // Logic trích xuất type_image từ URL: https://image.senaai.tech/type/filename.jpg + // Ví dụ: https://image.senaai.tech/square/cool_square_1772002797233.jpg -> type là 'square' + const match = context.image.match(/image\.senaai\.tech\/([^/]+)\//); + + if (match && match[1]) { + const extractedType = match[1]; + await context.update({ type_image: extractedType }); + updatedCount++; + console.log(`✅ ID ${context.id}: Cập nhật type_image = '${extractedType}' từ URL.`); + } else { + skippedCount++; + console.log(`⚠️ ID ${context.id}: Không thể trích xuất type từ URL: ${context.image}`); + } + } + + // 2. Kiểm tra xem còn sót cái nào không + const remainingCount = await Context.count({ + where: { + status: 5, + [Op.or]: [ + { type_image: null }, + { type_image: '' } + ] + } + }); + + console.log('\n--- KẾT QUẢ ---'); + console.log(`✅ Đã cập nhật: ${updatedCount} bản ghi.`); + console.log(`⏭️ Bỏ qua (không có ảnh hoặc URL không khớp): ${skippedCount} bản ghi.`); + console.log(`❌ Số lượng còn sót lại vẫn thiếu type_image: ${remainingCount} bản ghi.`); + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi khi cập nhật:', error); + process.exit(1); + } +} + +updateTypeImage(); diff --git a/models/Sentences.js b/models/Sentences.js index 2874fea..1d87479 100644 --- a/models/Sentences.js +++ b/models/Sentences.js @@ -2,102 +2,107 @@ const { DataTypes } = require('sequelize'); const { sequelize } = require('../config/database'); const Sentences = sequelize.define('Sentences', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - comment: 'Unique identifier for sentence entry' + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + comment: 'Unique identifier for sentence entry' + }, + // Từ thực tế (wash, washes, washing, ate, eaten...) + text: { + type: DataTypes.TEXT, + allowNull: false + }, + ipa: { + type: DataTypes.TEXT, + comment: 'International Phonetic Alphabet representation' + }, + // Nội dung dùng chung (có thể lưu JSON để dễ quản lý hoặc dùng chung cho cả group) + vi: { + type: DataTypes.TEXT, + defaultValue: '', + comment: 'Vietnamese meaning' + }, + grade: { + type: DataTypes.TEXT, + defaultValue: '0', + comment: 'Grade level (e.g., Grade 1, Grade 2)' + }, + category: { + type: DataTypes.TEXT, + comment: 'Category of the sentence (e.g., Action Verbs, Nouns)' + }, + topic: { + type: DataTypes.TEXT, + comment: 'Topic of the sentence (e.g., Food, Travel, Education)' + }, + image_small: { + type: DataTypes.JSON, + defaultValue: [], + comment: 'Array of image URLs' + }, + image_square: { + type: DataTypes.JSON, + defaultValue: [], + comment: 'Array of image URLs' + }, + image_normal: { + type: DataTypes.JSON, + defaultValue: [], + comment: 'Array of image URLs' + }, + audio: { + type: DataTypes.JSON, + comment: 'Array of audio URLs' + }, + tags: { + type: DataTypes.JSON, + defaultValue: [], + comment: 'Array of tags for categorization' + }, + usage_note: { + type: DataTypes.TEXT, + defaultValue: '', + comment: 'Lưu ý về ngữ cảnh sử dụng câu này' + }, + etc: { + type: DataTypes.TEXT, + defaultValue: '', + comment: 'Các thông tin khác liên quan đến câu này (ví dụ: level, grammar points, etc.)' + }, + status: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Trạng thái confirm (0 = chưa confirm)' + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Whether this sentence entry is active' + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + } +}, { + tableName: 'sentences', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + name: 'idx_sentences_text', + fields: [{ name: 'text', length: 191 }] }, - // Từ thực tế (wash, washes, washing, ate, eaten...) - text: { - type: DataTypes.TEXT, - allowNull: false - }, - ipa : { - type: DataTypes.TEXT, - comment: 'International Phonetic Alphabet representation' - }, - // Nội dung dùng chung (có thể lưu JSON để dễ quản lý hoặc dùng chung cho cả group) - vi: { - type: DataTypes.TEXT, - defaultValue: '', - comment: 'Vietnamese meaning' - }, - grade : { - type: DataTypes.TEXT, - defaultValue: '0', - comment: 'Grade level (e.g., Grade 1, Grade 2)' - }, - category: { - type: DataTypes.TEXT, - comment: 'Category of the sentence (e.g., Action Verbs, Nouns)' - }, - topic: { - type: DataTypes.TEXT, - comment: 'Topic of the sentence (e.g., Food, Travel, Education)' - }, - image_small: { - type: DataTypes.JSON, - defaultValue: [], - comment: 'Array of image URLs' - }, - image_square: { - type: DataTypes.JSON, - defaultValue: [], - comment: 'Array of image URLs' - }, - image_normal: { - type: DataTypes.JSON, - defaultValue: [], - comment: 'Array of image URLs' - }, - audio : { - type: DataTypes.JSON, - comment: 'Array of audio URLs' - }, - tags: { - type: DataTypes.JSON, - defaultValue: [], - comment: 'Array of tags for categorization' - }, - usage_note: { - type: DataTypes.TEXT, - defaultValue: '', - comment: 'Lưu ý về ngữ cảnh sử dụng câu này' - }, - etc : { - type: DataTypes.TEXT, - defaultValue: '', - comment: 'Các thông tin khác liên quan đến câu này (ví dụ: level, grammar points, etc.)' - }, - is_active: { - type: DataTypes.BOOLEAN, - defaultValue: true, - comment: 'Whether this sentence entry is active' - }, - created_at: { - type: DataTypes.DATE, - defaultValue: DataTypes.NOW - }, - updated_at: { - type: DataTypes.DATE, - defaultValue: DataTypes.NOW + { + name: 'idx_category', + fields: [{ name: 'category', length: 191 }] } - }, { - tableName: 'sentences', - timestamps: true, - createdAt: 'created_at', - updatedAt: 'updated_at', - indexes: [ - { - name: 'idx_sentences_text', - fields: [{ name: 'text', length: 191 }] - }, - { - name: 'idx_category', - fields: [{ name: 'category', length: 191 }] - } - ] - }); + ] +}); module.exports = Sentences; diff --git a/models/Vocab.js b/models/Vocab.js index 0f35a17..604b764 100644 --- a/models/Vocab.js +++ b/models/Vocab.js @@ -3,121 +3,131 @@ const { sequelize } = require('../config/database'); const Grade = require('./Grade'); const Vocab = sequelize.define('Vocab', { - vocab_id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - comment: 'Unique identifier for vocabulary entry' + vocab_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + comment: 'Unique identifier for vocabulary entry' + }, + // Từ thực tế (wash, washes, washing, ate, eaten...) + text: { + type: DataTypes.STRING(100), + allowNull: false, + index: true + }, + ipa: { + type: DataTypes.STRING(100), + comment: 'International Phonetic Alphabet representation' + }, + // Từ gốc để nhóm lại (wash, eat...) + base_word: { + type: DataTypes.STRING(100), + allowNull: false, + index: true + }, + // Loại biến thể (V1, V2, V3, V_ing, Noun_Form...) + form_key: { + type: DataTypes.TEXT, + defaultValue: 'base', + comment: 'Form key indicating the type of word form (e.g., base, V1, V2, V3, V_ing, Noun_Form)' + }, + // Nội dung dùng chung (có thể lưu JSON để dễ quản lý hoặc dùng chung cho cả group) + vi: { + type: DataTypes.TEXT, + defaultValue: '', + comment: 'Vietnamese meaning' + }, + Grade_number: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Numeric representation of grade unit lesson as GG UU LL for easier querying and sorting' + }, + category: { + type: DataTypes.STRING(100), + comment: 'Category of the word (e.g., Action Verbs, Nouns)' + }, + topic: { + type: DataTypes.STRING(100), + comment: 'Topic of the word (e.g., Food, Travel, Education)' + }, + image_small: { + type: DataTypes.JSON, + comment: 'Array of image URLs' + }, + image_square: { + type: DataTypes.JSON, + comment: 'Array of image URLs' + }, + image_normal: { + type: DataTypes.JSON, + comment: 'Array of image URLs' + }, + audio: { + type: DataTypes.JSON, + comment: 'Array of audio URLs' + }, + example_sentences: { + type: DataTypes.JSON, + comment: 'Array of example sentences' + }, + tags: { + type: DataTypes.JSON, + comment: 'Array of tags for categorization' + }, + syntax: { + type: DataTypes.JSON, + comment: 'Syntax roles for Grammar Engine (is_subject, is_verb, is_object, is_be, is_adj, is_adv, is_article, verb_type, article_type, adv_type, position, priority, etc.)' + }, + semantics: { + type: DataTypes.JSON, + comment: 'Semantic constraints (can_be_subject_type, can_take_object_type, can_modify, cannot_modify, word_type, is_countable, person_type, etc.)' + }, + constraints: { + type: DataTypes.JSON, + comment: 'Grammar constraints (followed_by, match_subject, match_with, phonetic_rules, etc.)' + }, + status: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Trạng thái confirm (0 = chưa confirm)' + }, + etc: { + type: DataTypes.TEXT, + defaultValue: '', + comment: 'Thông tin bổ sung' + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Whether this vocab entry is active' + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + } +}, { + tableName: 'vocab', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + name: 'idx_vocab_text', + fields: ['text'] }, - // Từ thực tế (wash, washes, washing, ate, eaten...) - text: { - type: DataTypes.STRING(100), - allowNull: false, - index: true + { + name: 'idx_base_word', + fields: ['base_word'] }, - ipa : { - type: DataTypes.STRING(100), - comment: 'International Phonetic Alphabet representation' - }, - // Từ gốc để nhóm lại (wash, eat...) - base_word: { - type: DataTypes.STRING(100), - allowNull: false, - index: true - }, - // Loại biến thể (V1, V2, V3, V_ing, Noun_Form...) - form_key: { - type: DataTypes.TEXT, - defaultValue: 'base', - comment: 'Form key indicating the type of word form (e.g., base, V1, V2, V3, V_ing, Noun_Form)' - }, - // Nội dung dùng chung (có thể lưu JSON để dễ quản lý hoặc dùng chung cho cả group) - vi: { - type: DataTypes.TEXT, - defaultValue: '', - comment: 'Vietnamese meaning' - }, - Grade_number: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: 'Numeric representation of grade unit lesson as GG UU LL for easier querying and sorting' - }, - category: { - type: DataTypes.STRING(100), - comment: 'Category of the word (e.g., Action Verbs, Nouns)' - }, - topic: { - type: DataTypes.STRING(100), - comment: 'Topic of the word (e.g., Food, Travel, Education)' - }, - image_small: { - type: DataTypes.JSON, - comment: 'Array of image URLs' - }, - image_square: { - type: DataTypes.JSON, - comment: 'Array of image URLs' - }, - image_normal: { - type: DataTypes.JSON, - comment: 'Array of image URLs' - }, - audio : { - type: DataTypes.JSON, - comment: 'Array of audio URLs' - }, - example_sentences: { - type: DataTypes.JSON, - comment: 'Array of example sentences' - }, - tags: { - type: DataTypes.JSON, - comment: 'Array of tags for categorization' - }, - syntax: { - type: DataTypes.JSON, - comment: 'Syntax roles for Grammar Engine (is_subject, is_verb, is_object, is_be, is_adj, is_adv, is_article, verb_type, article_type, adv_type, position, priority, etc.)' - }, - semantics: { - type: DataTypes.JSON, - comment: 'Semantic constraints (can_be_subject_type, can_take_object_type, can_modify, cannot_modify, word_type, is_countable, person_type, etc.)' - }, - constraints: { - type: DataTypes.JSON, - comment: 'Grammar constraints (followed_by, match_subject, match_with, phonetic_rules, etc.)' - }, - is_active: { - type: DataTypes.BOOLEAN, - defaultValue: true, - comment: 'Whether this vocab entry is active' - }, - created_at: { - type: DataTypes.DATE, - defaultValue: DataTypes.NOW - }, - updated_at: { - type: DataTypes.DATE, - defaultValue: DataTypes.NOW + { + name: 'idx_category', + fields: ['category'] } - }, { - tableName: 'vocab', - timestamps: true, - createdAt: 'created_at', - updatedAt: 'updated_at', - indexes: [ - { - name: 'idx_vocab_text', - fields: ['text'] - }, - { - name: 'idx_base_word', - fields: ['base_word'] - }, - { - name: 'idx_category', - fields: ['category'] - } - ] - }); + ] +}); module.exports = Vocab;