const { promisify } = require('util') const Entry = require('../models/entry') const { getInstanceSetting } = require('../models/helpers') const Dictionary = require('../models/dictionary') const Comment = require('../models/comment') const { searchEntryIndex, searchConsultancyEntryIndex } = require('../models/search-engine') const { intoDbArray } = require('../models/helpers') const { prepareEntries, prepareAggregation, prepareSeachFilterData, prepareConsultancyEntries } = require('../models/helpers/search') const email = require('../models/email') const generateQuery = require('../models/helpers/search/generate-query') const { DEFAULT_HITS_PER_PAGE } = require('../config/settings') const SFDSuggestionImporter = require('./helpers/search-filter-data-suggestion-importer') const User = require('../models/user') const { capitalize } = require('../utils') // TODO Luka (note to self): Measure performance, consider caching. exports.index = async (req, res) => { const { language } = req const { allPrimaryDomains, sourceLanguages, targetLanguages, allDictionaryNames, portals } = await SFDSuggestionImporter.initialize(req.determinedLanguage) const latestDicts = await Dictionary.fetchLatest3DictsByPublishDate( req.determinedLanguage ) const portalName = await getInstanceSetting(`portal_name_${language}`) const portalDescription = await getInstanceSetting( `portal_description_${language}` ) const isRoot = true res.render('pages/index', { allPrimaryDomains, sourceLanguages, targetLanguages, allDictionaryNames, portals, latestDicts, portalName, portalDescription, isRoot }) } exports.search = async (req, res) => { const searchString = req.query.q?.trim() if (!searchString) return res.redirect(303, '/') const title = req.t('Iskanje') let { allPrimaryDomains, sourceLanguages, targetLanguages, allDictionaryNames, portals } = await SFDSuggestionImporter.initialize(req.determinedLanguage) const filters = { sourceLanguages: intoDbArray(req.query.sl, 'always'), targetLanguages: intoDbArray(req.query.tl, 'always'), primaryDomains: intoDbArray(req.query.pd, 'always'), dictionaries: intoDbArray(req.query.d, 'always'), sources: intoDbArray(req.query.s, 'always') } const hitsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE const page = +req.query.p > 0 ? +req.query.p : 1 const [hitsQuery, aggregateQuery] = await generateQuery.main( searchString, filters, hitsPerPage, page, true ) // Consultancy const hitsQueryConsultancy = generateQuery.consultancy( searchString, { status: 'published', primaryDomain: filters.primaryDomains[0] }, hitsPerPage, page ) const hitsConsultancy = await searchConsultancyEntryIndex( hitsQueryConsultancy ) const consultancyHits = hitsConsultancy.body.hits.total.value const consultancyURL = `/svetovanje/iskanje?q=${searchString}${ filters.primaryDomains.length > 0 ? '&pd=' + filters.primaryDomains[0] : '' // consultancy filtering only supports one domain }` // console.log(entries) // const [hits, aggregationRaw] = await Promise.all([ searchEntryIndex(hitsQuery), searchEntryIndex(aggregateQuery) ]) const numberOfAllHits = hits.body.hits.total.value const numberOfAllPages = Math.ceil(numberOfAllHits / hitsPerPage) // TODO If there's no hits, this is probably the place where suggestions/related queries and processing would happen. let entriesByCategory = prepareEntries(hits) // Similar to search query without any extra filters, this is required for modal to diplay ALL res. // const allAggregation = await indexAllResultsNoFiltering(req, res) const aggregation = await prepareAggregation( aggregationRaw, req.determinedLanguage ) const searchFilterData = prepareSeachFilterData(aggregation, filters) // TODO Luka (note to self): Consider reworking Miha's logic below. It's hacky and possibly error prone. // TODO copied code below, maybe refactor to a helper function? // check express/controllers/api/v1/search.js listMainEntries entriesByCategory = Object.entries(entriesByCategory).reduce( (acc, category, index) => { acc[category[0]] = category[1].map(entry => { if (entry.foreignEntries) { entry.foreignEntries = entry.foreignEntries.map(fe => { fe.terms = fe.terms ? fe.terms.filter(term => !!term) : [] fe.synonyms = fe.synonyms ? fe.synonyms.filter(synonym => !!synonym) : [] fe.nbspCount = fe.terms.length + fe.synonyms.length - 1 return fe }) } return entry }) return acc }, {} ) const count = Object.values(entriesByCategory).reduce((acc, cat, idx) => { return acc + cat.length }, 0) // filter to 5 resuts per search filter Object.entries(searchFilterData).forEach(([key, value]) => { searchFilterData[key] = value.filter((p, i) => { return i < 5 }) }) // todo implement ALL disabled in pug view if required const disabledSideMenuFilters = { sourceLanguages: searchString === '*', targetLanguages: false, primaryDomains: false, dictionaries: false, sources: false } // Keep selected items on refresh sourceLanguages = addSelectedIdentifierToFilters( sourceLanguages, filters.sourceLanguages ) targetLanguages = addSelectedIdentifierToFilters( targetLanguages, filters.targetLanguages ) allPrimaryDomains = addSelectedIdentifierToFilters( allPrimaryDomains, filters.primaryDomains ) allDictionaryNames = addSelectedIdentifierToFilters( allDictionaryNames, filters.dictionaries ) /// ////////////// portals = addSelectedIdentifierToPortals(portals, filters.sources) if (count < 1) { return res.render('pages/search/no-results', { allPrimaryDomains, sourceLanguages, targetLanguages, allDictionaryNames, portals, searchString, entriesByCategory, searchFilterData, disabledSideMenuFilters, consultancyHits, consultancyURL, // allAggregation, numberOfAllHits, numberOfAllPages, page }) } // duplicated code below for filtering foreignentries, optimize later // Below code filters target languages based on source and target filters for each category const categoriesLabels = Object.keys(entriesByCategory) for ( let categoryIndex = 0; categoryIndex < categoriesLabels.length; categoryIndex++ ) { entriesByCategory[categoriesLabels[categoryIndex]] = entriesByCategory[ categoriesLabels[categoryIndex] ].map(entry => { entry.foreignEntries = entry.foreignEntries?.filter(foreignEntry => { if (filters.targetLanguages.length > 0) { if (aggregation.sourceLanguages.length === 1) { filters.sourceLanguages = [aggregation.sourceLanguages[0].id] } return ( filters.targetLanguages.includes(`${foreignEntry.lang.id}`) || filters.sourceLanguages?.includes(`${foreignEntry.lang.id}`) ) } else { return true } }) return entry }) } // Disable target languages logic if ( filters.sourceLanguages.length > 1 || // more or equal than 2 source languages (filters.sourceLanguages.length < 1 && // No filters, but more than 1 source language filters returned aggregation.sourceLanguages.length > 1) || (aggregation.targetLanguages.length === 1 && aggregation.sourceLanguages.length === 1 && aggregation.targetLanguages[0].id === aggregation.sourceLanguages[0].id) // ) { // predicate 1 Disable if 2 or more filters in Source languages are selected // predicate 2 If none are selected, then check if displayed filters are more than 2 aggregation.targetLanguages = [] filters.targetLanguages = [] targetLanguages = [] searchFilterData.targetLanguages = [] disabledSideMenuFilters.targetLanguages = true } // remove source language from target language filters.sourceLanguages.forEach(entry => { if ( aggregation.targetLanguages.filter(toFilter => toFilter.id === entry) .length > 0 ) { aggregation.targetLanguages = aggregation.targetLanguages.filter( toFilter => toFilter.id !== entry ) searchFilterData.targetLanguages = searchFilterData.targetLanguages.filter( toFilter => toFilter.id !== entry ) } }) if (aggregation.targetLanguages.length < 0) { searchFilterData.targetLanguages = [] disabledSideMenuFilters.targetLanguages = true } // TODO Add a page title? res.render('pages/search/results', { allPrimaryDomains, sourceLanguages, targetLanguages, allDictionaryNames, portals, searchString, entriesByCategory, searchFilterData, disabledSideMenuFilters, consultancyHits, consultancyURL, // allAggregation, numberOfAllHits, numberOfAllPages, page, title }) } exports.entryDetails = async (req, res) => { const termId = req.params.entryId const title = req.t('Termin') /* const [entry, domainLabels] = await Promise.all([ Entry.fetchFullWithOrderedForeignLanguages(termId), Dictionary.fetchDomainLabelsFromEntryId(termId) ]) */ const entry = await Entry.fetchFullWithOrderedForeignLanguages(termId) /* const entryData = { entry // allDomainLabelsJoined: domainLabels.map(e => e.name).join(', ') } */ // If external url, just redirect if (entry.external_url) { return res.redirect(303, entry.external_url) } // unnecessary legacy assigment, refactor when time is available const entryData = entry const { allPrimaryDomains, sourceLanguages, targetLanguages, allDictionaryNames, portals } = await SFDSuggestionImporter.initialize(req.determinedLanguage) const [dictStruct, dictionaryData, selectedDomainLabelsForEntry] = await Promise.all([ Dictionary.fetchDictionaryWithEditStructure(entry.dictionary_id), Dictionary.fetchDictionaryBasicInfo( entry.dictionary_id, req.determinedLanguage ), Entry.fetchDomainLabels(termId) ]) /// const filters = { ctxType: 'entry_dict_ext', ctxId: termId } // TODO: integrate numberOfAllPages, commentCount with pug const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE const page = 1 // +req.query.p > 0 ? +req.query.p : 1 const { pages_total: numberOfAllPages, comments, comment_count: commentCount } = await Comment.list(filters, req.user, resultsPerPage, page) /// // check if it is a local dictionary if (!dictionaryData.portalnamesl && !dictionaryData.portalcode) { dictionaryData[0].portalname = await getInstanceSetting('portal_name_sl') dictionaryData[0].portalcode = await getInstanceSetting('portal_code') } const reducedData = dictionaryData.reduce( (acc, x) => { if (acc.isbegin) { x.languages = [x.languagesl] x.subDomains = [x.domainsecondarysl] return x } if (!acc.languages.includes(x.languagesl)) { acc.languages.push(x.languagesl) } if (!acc.subDomains.includes(x.domainsecondarysl)) { acc.subDomains.push(x.domainsecondarysl) } return acc }, { isbegin: true } ) const { structure } = dictStruct // Improved version of entropy, but some data duplications still exist // struct data contains important data and re-maps for unification (maybe refactor later) const structData = { termId: termId, prevWindowTitle: req.t('Iskanje'), prevHref: '/iskanje', portalCode: dictionaryData[0].portalcode, portalName: dictionaryData[0].portalname, dictName: structure[`name${capitalize(req.determinedLanguage)}`], dictHref: `/slovarji/${structure.id}/o-slovarju?sentFromEntryId=${termId}`, fullAuthorName: reducedData.author ? reducedData.author.join(', ') : '', areas: dictionaryData[0].domain_primary, subareas: reducedData.subDomains ? reducedData.subDomains.join(', ') : '', languages: reducedData.languages ? reducedData.languages.join(', ') : '' } // TODO I18n if (reducedData.author) { if (reducedData.author.length > 2) { structData.authorLabel = req.t('Avtorji') } else if (reducedData.author.length === 2) { structData.authorLabel = req.t('Avtorja') } else if (reducedData.author.length === 1) { structData.authorLabel = req.t('Avtor') } } const finalData = { ...reducedData, ...structData } let selectedDomainLabelsForEntryString = '' if (selectedDomainLabelsForEntry.length) { selectedDomainLabelsForEntryString = mergeDomains( selectedDomainLabelsForEntry, (acc, n) => { if (acc === '') return n.name else return acc + ', ' + n.name } ) } /* entryData.entry.foreign_entries.forEach((val, idx) => { entry.foreignEntries[idx] = await }) */ const langs = await Dictionary.fetchLanguages( entryData.dictionary_id, req.determinedLanguage ) entryData.foreign_entries.forEach((val, idx) => { try { entry.foreign_entries[idx].name = langs[idx].name } catch (e) {} }) res.render('pages/search/result-detail', { allPrimaryDomains, entryData, sourceLanguages, targetLanguages, allDictionaryNames, portals, termId, structure, finalData, selectedDomainLabelsForEntryString, numberOfAllPages, comments, commentCount, title }) } exports.myProfile = async (req, res) => { res.render('pages/profile/my-profile', { title: req.t('Osnovni podatki') }) } exports.deleteProfile = async (req, res) => { res.render('pages/profile/delete-profile', { title: req.t('Izbriši račun') }) } exports.changePassword = async (req, res) => { res.render('pages/profile/change-password', { title: req.t('Spremeni geslo') }) } exports.resetPassword = async (req, res) => { if (req.user) return res.redirect(303, '/spremeni-geslo') const { token } = req.query if (!token) return res.redirect(303, '/') const isTokenValid = await User.isResetPasswordTokenValid(token) res.render('pages/reset-password/reset-password', { title: 'Ponastavitev gesla', isTokenValid, token }) } exports.changeEmail = async (req, res) => { const { token } = req.query if (!token) return res.redirect(303, '/') const user = await User.changeEmailWithToken(token, req.t) const renderAsync = promisify(req.app.render.bind(req.app)) const emailHtml = await renderAsync( `email/user-change-email-success_${req.language}`, { username: user.username } ) await email.send({ to: user.email, subject: req.t('Sprememba elektronskega naslova - uspeh'), html: emailHtml }) req.flash('info', req.t('Uspešno ste spremenili svoj elektronski naslov.')) if (req.user) return res.redirect(303, '/moj-racun') res.redirect(303, '/') } exports.userSettings = async (req, res) => { const hitsPerPageArr = await User.fetchAllowedHitsPerPage() res.render('pages/profile/change-profile-settings', { title: req.t('Nastavitve računa'), hitsPerPageArr, hitsForUser: req.user?.hitsPerPage }) } exports.changeUserLanguage = async (req, res) => { const { languageCode } = req.params const validCodes = ['sl', 'en'] if (!validCodes.includes(languageCode)) { throw Error(`Invalid language: ${languageCode}`) } const { user } = req if (user) { await User.updateLanguage(user.id, languageCode) } else { req.session.language = languageCode } res.redirect(303, '/') } function mergeDomains( domainList, aggregationFn = (acc, n) => { if (acc === '') return n else return acc + ', ' + n } ) { return Array.from(domainList).reduce(aggregationFn, '') } function filterResults(entries) { let mode = 0 const termLst = [] const ftermLst = [] const otherLst = [] entries.forEach(entry => { switch (entry._title) { case 'term': mode = 0 break case 'foreignTerm': mode = 1 break case 'other': mode = 2 } switch (mode) { case 0: termLst.push(entry) break case 1: ftermLst.push(entry) break case 2: otherLst.push(entry) break } }) return [termLst, ftermLst, otherLst] } function addSelectedIdentifierToFilters(source, selectedIDs) { return source.map(entry => { if (selectedIDs.includes(`${entry.id}`)) { entry.selected = true } return entry }) } function addSelectedIdentifierToPortals(source, selectedIDs) { return source.map(entry => { if (selectedIDs.includes(`${entry.code}`)) { entry.selected = true } return entry }) }