Initial commit

This commit is contained in:
Luka Romih
2022-12-07 04:43:36 +01:00
commit 257f3c354f
496 changed files with 55373 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
# Use the non-alpine base image for easier container debugging.
# FROM node:14
FROM node:16-alpine
# Curl is used in cron batch file.
RUN apk --no-cache add curl
WORKDIR /usr/src/app
COPY package*.json .
RUN npm install
COPY . .
COPY scheduled/crontab /var/spool/cron/crontabs/root
RUN which crond && \
rm -rf /etc/periodic && \
chmod 0744 scheduled/entrypoint.sh
EXPOSE 3000
# Use devstart-wait if you need code execution to pause until a debugger is attached.
# CMD ["npm", "run", "devstart-wait"]
CMD ["npm", "run", "devstart"]
+22
View File
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
FROM node:16-alpine
# Curl is used in cron batch file.
RUN apk --no-cache add curl
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --production --silent
COPY . .
COPY scheduled/crontab /var/spool/cron/crontabs/root
RUN which crond && \
rm -rf /etc/periodic && \
chmod 0744 scheduled/entrypoint.sh
EXPOSE 3000
CMD ["node", "bin/www"]
+105
View File
@@ -0,0 +1,105 @@
// Import 3rd party node modules.
const express = require('express')
const path = require('path')
const logger = require('morgan')
const helmet = require('helmet')
const favicon = require('serve-favicon')
const cookieParser = require('cookie-parser')
const createError = require('http-errors')
const debug = require('debug')('termPortal:app')
// Import own modules.
const { isBehindProxy, secret } = require('./config/keys')
const helmetConfig = require('./config/helmet')
const session = require('./middleware/session')
const passport = require('./middleware/auth')
const user = require('./middleware/user')
const settings = require('./middleware/settings')
const { enhanceLocals } = require('./middleware')
// Import Routers.
const apiRouter = require('./routes/api')
const adminRouter = require('./routes/admin')
const dictionariesRouter = require('./routes/dictionaries')
const extractionRouter = require('./routes/extraction')
const consultancyRouter = require('./routes/consultancy')
const indexRouter = require('./routes/index')
// Create express app.
const app = express()
// View engine setup.
const viewsPath = path.join(__dirname, 'views')
app.set('view engine', 'pug')
app.set('views', viewsPath)
app.locals.basedir = viewsPath
// Other settings.
const inDevEnv = app.get('env') === 'development'
if (isBehindProxy) app.set('trust proxy', 1) // Trust first proxy.
// Mount middleware.
app.use(logger('dev'))
app.use(helmet(helmetConfig))
app.use(favicon(path.join(__dirname, 'public', 'images', 'favicon.ico')))
app.use(express.static(path.join(__dirname, 'public')))
app.use(express.json({ type: ['application/json', 'application/csp-report'] }))
app.use(express.urlencoded({ extended: true }))
app.use(cookieParser(secret))
app.use(session)
app.use(passport.initialize())
app.use(passport.session())
app.use(passport.authenticate('remember-me'))
app.use(user.enhance)
app.use(settings.prepareRequiredSettings)
app.use(enhanceLocals)
// Apparently express-debug can't be run inside another middleware and must be run in this file.
// To help you debug, temporarily uncomment the next line, but comment the helmet line due to strict CSP.
// if (app.get('env') === 'development') require('express-debug')(app)
// Pretty printing pug output is strongly ill-advised and deprecated, since too often, it creates subtle bugs.
// But if you really need it to help you debug, you can temporarily enable the next line.
// app.locals.pretty = true
// Mount routers.
app.use('/api', apiRouter)
app.use('/admin', adminRouter)
app.use('/slovarji', dictionariesRouter)
app.use('/luscenje', extractionRouter)
app.use('/svetovanje', consultancyRouter)
app.use('/', indexRouter)
// Catch 404 and forward to error handler.
app.use((req, res, next) => next(createError(404)))
// Error handler.
app.use((err, req, res, next) => {
if (err.status !== 404) debug(err)
// Set error info to be displayed to user depending on environment.
let message, error
if (inDevEnv || err.displayInProd) {
message = err.message
error = err
} else {
message =
err.status === 404
? 'Stran ne obstaja'
: 'Prišlo je do strežniške napake. Poskusite kasneje.'
error = {}
}
res.status(err.status || 500)
// Send plain error message for ajax requests.
if (req.isAjax) {
return res.send(message)
}
// Render the error page.
res.render('error', { title: 'Napaka', message, error })
})
module.exports = app
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const db = require('../models/db')
const cache = require('../models/cache')
const searchEngine = require('../models/search-engine')
const email = require('../models/email')
const { seedDummyData } = require('../models/comment')
const { initDemoData } = require('../models/demo-paginacija')
const app = require('../app')
const debug = require('debug')('termPortal:server')
const http = require('http')
/**
* Get port from environment and store in Express.
*/
const port = normalizePort(process.env.PORT || '3000')
app.set('port', port)
/**
* Create HTTP server.
*/
const server = http.createServer(app)
/**
* Listen on provided port, on all network interfaces.
*/
// Wait for all dependancy connections before seeding data and starting to serve requests.
;(async () => {
await Promise.all([
db.waitForConnection(),
cache.waitForConnection(),
searchEngine.waitForConnection(),
email.waitForConnection()
])
await searchEngine.initEntryIndex()
await searchEngine.initConsultancyEntryIndex()
seedDummyData()
// initDemoData()
server.listen(port)
})()
server.on('error', onError)
server.on('listening', onListening)
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
const port = parseInt(val, 10)
if (isNaN(port)) {
// Named pipe.
return val
}
if (port >= 0) {
// Port number.
return port
}
return false
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error
}
const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port
// Handle specific listen errors with friendly messages.
switch (error.code) {
case 'EACCES':
// eslint-disable-next-line no-console
console.error(bind + ' requires elevated privileges')
process.exit(1)
case 'EADDRINUSE':
// eslint-disable-next-line no-console
console.error(bind + ' is already in use')
process.exit(1)
default:
throw error
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
const addr = server.address()
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
debug('Listening on ' + bind)
}
+41
View File
@@ -0,0 +1,41 @@
const crypto = require('crypto')
module.exports = {
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'none'"],
scriptSrc: [
generateCspNonce,
"'strict-dynamic'",
'https:',
"'unsafe-inline'"
],
connectSrc: ["'self'"],
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
fontSrc: ["'self'", 'https:'],
imgSrc: ["'self'", 'data:'],
// Disabled TT due to jQuery using sink functions and trying to sanitize
// produced HTML with DOMPurify breaks functionality of summernote.
// requireTrustedTypesFor: ["'script'"],
reportUri: ['/api/v1/system/csp-reports']
}
},
referrerPolicy: {
policy: ['no-referrer', 'strict-origin-when-cross-origin']
}
}
// TODO CSP nonce is generated for every request.
// It would only really be needed (CSP in general) for the ones which return HTML documents
// and not for all the other (static) resources (styles, scrips, images, ...)
// Consider: caching static resources, using hash based policy instead of nonce or other ...
function generateCspNonce(req, res) {
const cspNonce = crypto.randomBytes(16).toString('base64')
res.locals.cspNonce = cspNonce
return `'nonce-${cspNonce}'`
}
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
isBehindProxy: process.env.IS_BEHIND_PROXY === 'true',
secret: process.env.SECRET,
cookiesSecure: process.env.COOKIES_SECURE === 'true',
smtpHost: process.env.SMTP_HOST,
smtpPort: process.env.SMTP_PORT,
smtpTlsRejectUnauthorized:
process.env.SMTP_TLS_REJECT_UNAUTHORIZED === 'true',
smtpFrom: process.env.SMTP_FROM,
origin: process.env.ORIGIN
}
+22
View File
@@ -0,0 +1,22 @@
const { cookiesSecure } = require('../config/keys')
// Duration of validity of a remember me token. 1 year.
const REMEMBER_ME_DURATION_JS = 365 * 24 * 60 * 60 * 1000
exports.REMEMBER_ME_DURATION_SQL = '365 days'
exports.rememberMeCookieSettings = {
httpOnly: true,
maxAge: REMEMBER_ME_DURATION_JS,
secure: cookiesSecure,
signed: true,
sameSite: 'lax'
}
exports.DEFAULT_HITS_PER_PAGE = 10
exports.EDITOR_MAX_HITS = 10000
// If you change this one, don't forget to also update the volume mount in docker-compose.prod.yml.
exports.DATA_FILES_PATH = 'data_files'
exports.MAX_EXTRACTIONS_PER_USER = 5
+57
View File
@@ -0,0 +1,57 @@
const Comment = require('../../../models/comment')
const Entry = require('../../../models/entry')
const { DEFAULT_HITS_PER_PAGE } = require('../../../config/settings')
exports.listComments = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const page = +req.query.p > 0 ? +req.query.p : 1
const filters = { ctxType: req.query.ctx_type, ctxId: req.query.ctx_id }
if (filters.ctxId === 'null') {
filters.ctxId = null
}
const {
pages_total: numberOfAllPages,
comments,
comment_count: commentCount
} = await Comment.list(filters, req.user, resultsPerPage, page)
res.send({ page, numberOfAllPages, comments, commentCount })
}
exports.createComment = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const receivedComment = req.body
const { ctxType, ctxId } = receivedComment
await Comment.create(receivedComment, req.user?.id)
const filters = { ctxType, ctxId }
const { pages_total: pagesTotal, comments } = await Comment.list(
filters,
req.user,
resultsPerPage,
'last'
)
if (ctxType === 'entry_dict_int' || ctxType === 'entry_dict_ext') {
await Entry.indexIntoSearchEngine(ctxId)
}
res.send({ comments, pagesTotal })
}
exports.seedComments = async (req, res) => {
const { commentCount } = req.params
await Comment.seed(commentCount)
res.send(`${commentCount} new comments generated`)
}
exports.clearComments = async (req, res) => {
await Comment.clear()
res.send('All comments cleared')
}
exports.updateStatus = async (req, res) => {
const commentId = req.body.params.id
const commentStatus = req.body.params.status
await Comment.updateStatus(commentId, commentStatus)
res.send('Visibility changed')
}
+321
View File
@@ -0,0 +1,321 @@
const ConsultancyEntry = require('../../../models/consultancy-entry')
const User = require('../../../models/user')
const { promisify } = require('util')
const {
deleteConsultancyEntryFromIndex
} = require('../../../models/search-engine')
const email = require('../../../models/email')
const helper = require('../../../models/helpers')
const consultancy = {}
consultancy.listEntries = async (req, res) => {
const consultancyEntryList = await ConsultancyEntry.fetchAll()
const data = {}
data.consEntryList = consultancyEntryList
res.send(data)
}
consultancy.listNewEntries = async (req, res) => {
const consultancyNewEntryList = await ConsultancyEntry.fetchAllNew()
const data = {}
data.consultancyNewEntryList = consultancyNewEntryList
res.send(data)
}
consultancy.createQuestion = async (req, res) => {
const q = req.body
const consultancyEntry = {}
consultancyEntry.status = 'new'
consultancyEntry.authorId = req.user.id
consultancyEntry.institution = q.institution
const { description } = q
if (!description) {
return res.status(400).send('description is a required parameter!')
}
consultancyEntry.description = description
consultancyEntry.domainPrimaryIdInitial = q.domainPrimary
if (!consultancyEntry.domainPrimaryIdInitial || q.domainPrimary === '-1') {
consultancyEntry.domainPrimaryIdInitial = null // undefined
}
consultancyEntry.existingSolutions = q.existing_solutions
consultancyEntry.examplesOfUse = q.examples_of_use
Object.keys(consultancyEntry).forEach(key => {
consultancyEntry[key] = helper.removeHtmlTags(consultancyEntry[key]).trim()
})
const questionId = await ConsultancyEntry.createQuestion(consultancyEntry)
await ConsultancyEntry.indexIntoSearchEngine(questionId, true)
// TODO SEND EMAIL
// TODOOOOOOOOO
const emails = await ConsultancyEntry.fetchConsultancyAdminEmails()
const renderAsync = promisify(req.app.render.bind(req.app))
const emailHtml = await renderAsync('email/consultancy-creation-notify', {
propertyToPassGoesHere: 'test1234'
})
await email.send({
to: emails,
subject: 'Ustvarjeno novo vprašanje v svetovalnici',
html: emailHtml
})
/// /////////////////
res.status(201).send()
}
consultancy.updateDomain = async (req, res) => {
const { id, value } = req.body
if (!id) return res.status(400).send()
await User.updateConsultancyDomains(id, value)
res.status(204).send()
}
consultancy.insertConsultantAndHisDomainsByUsername = async (req, res) => {
const { username, domains } = req.body
if (!username) {
return res.status(400).send()
}
await User.insertNewConsultantWithDomainByUsername(username, domains)
res.status(204).send()
}
consultancy.removeConsultant = async (req, res) => {
const { id } = req.body
if (!id) return res.status(400).send()
await User.removeConsultant(id)
res.send()
}
consultancy.getSharedAuthors = async (req, res) => {
const { id } = req.query
if (!id) return res.status(400).send()
const authors = await ConsultancyEntry.getSharedAuthorsArray(id)
res.send(authors)
}
consultancy.insertNonModerator = async (req, res) => {
const userId = req.body.user_id
const entryId = req.body.entry_id
if (!userId || !entryId) return res.status(400).send()
await ConsultancyEntry.insertNonModerator(entryId, userId)
await ConsultancyEntry.indexIntoSearchEngine(entryId, true)
res.status(201).send()
}
consultancy.getSharedAuthorsBeforePublish = async (req, res) => {
const { id } = req.query
if (!id) return res.status(400).send()
const authors = await ConsultancyEntry.getSharedAuthorsArrayBeforePublish(id)
res.send(authors)
}
consultancy.updateSharedAuthors = async (req, res) => {
const { id, authors } = req.body
if (!id || !authors.length) return res.status(400).send()
await ConsultancyEntry.updateSharedAuthorsArray(id, authors)
await ConsultancyEntry.indexIntoSearchEngine(id, true)
res.send({})
}
/*
function assign - assigns from any state of the consultancy
question to work in-progress
inputs:
id -> id of the question
*/
consultancy.assign = async (req, res) => {
const questionId = req.body.question_id
let userId = req.body.user_id
if (!questionId) return res.status(400).send()
if (!userId) {
const user = await ConsultancyEntry.getModerator(questionId)
userId = user.id
if (!userId) return res.status(400).send({})
}
await ConsultancyEntry.assignWorkInProgress(questionId, userId)
await ConsultancyEntry.indexIntoSearchEngine(questionId, true)
// TODO SEND MAIL TO MODERATOR
/*
Skrbnik svetovalnice vam je v urejanje dodelil novo terminološko vprašanje.
*/
const emails = await ConsultancyEntry.fetchModeratorEmail(questionId)
const renderAsync = promisify(req.app.render.bind(req.app))
const emailHtml = await renderAsync('email/consultancy-assigned')
await email.send({
to: emails,
subject: 'Novo terminološko vprašanje',
html: emailHtml
})
res.send()
}
consultancy.reject = async (req, res) => {
const questionId = req.body.question_id
if (!questionId) return res.status(400).send()
await ConsultancyEntry.rejectEntry(questionId)
await ConsultancyEntry.indexIntoSearchEngine(questionId, true)
res.send()
}
consultancy.sendToReview = async (req, res) => {
const questionId = req.body.question_id
if (!questionId) return res.status(400).send()
await ConsultancyEntry.sendToReview(questionId)
await ConsultancyEntry.indexIntoSearchEngine(questionId, true)
/* SEND MAIL TO ALL CONSULTANCY ADMINS
Za potrditev objave ste prejeli novo terminološko vprašanje.
const allEmailsSet = new Set([...adminEmails, ...dictionariesAdminEmails])
const allEmails = []
for (const email of allEmailsSet) allEmails.push(email)
const type = 'delete'
const renderAsync = promisify(appRef.render.bind(appRef))
const emailHtml = await renderAsync('email/dictionary-status-change', {
type,
nameSl
})
await email.send({
to: allEmails,
subject: 'Obvestilo o številu gesel',
html: emailHtml
})
*/
const emails = await ConsultancyEntry.fetchConsultancyAdminEmails()
const renderAsync = promisify(req.app.render.bind(req.app))
const emailHtml = await renderAsync('email/consultancy-item-review')
await email.send({
to: emails,
subject: 'Potrditev objave',
html: emailHtml
})
res.send()
}
consultancy.publish = async (req, res) => {
const questionId = req.body.question_id
const answerAuthors = req.body.answer_authors
if (!questionId) return res.status(400).send()
const entry = await ConsultancyEntry.fetchById(questionId)
if (!entry.title) {
return res.status(400).send('Answer not completed')
}
await ConsultancyEntry.publish(questionId, answerAuthors)
await ConsultancyEntry.indexIntoSearchEngine(questionId, true)
res.send()
}
consultancy.updateQuestion = async (req, res) => {
const { id, questionTitle, domain: domainId, question, answer } = req.body
if (!id) return res.status(400).send({})
if (questionTitle === '' || question === '' || answer === '') {
return res.status(422).send('Polja naslov, vprašanje in mnenje so obvezna!')
}
const entry = await ConsultancyEntry.fetchById(id)
entry.domainPrimaryId = domainId > 0 ? domainId : null
entry.question = question
entry.answer = answer // helper.removeHtmlTags(answer).trim()
entry.title = questionTitle
// TODO Luka: Miha, update only fields that were updated.
await ConsultancyEntry.updateQuestion(entry)
await ConsultancyEntry.indexIntoSearchEngine(id, true)
res.send({})
}
consultancy.insertNonModerator = async (req, res) => {
const questionId = req.body.question_id
const userId = req.body.user_id
if (!questionId || !userId) return res.status(400).send()
await ConsultancyEntry.insertNonModerator(questionId, userId)
await ConsultancyEntry.indexIntoSearchEngine(questionId, true)
res.send({})
}
consultancy.deleteConsultantForEntry = async (req, res) => {
const questionId = req.body.question_id
const userId = req.body.user_id
if (!questionId || !userId) return res.status(400).send()
await ConsultancyEntry.removeConsultantForEntry(questionId, userId)
await ConsultancyEntry.indexIntoSearchEngine(questionId, true)
res.send({})
}
consultancy.deleteQuestion = async (req, res) => {
const { id } = req.body // question/entry id
if (!id) return res.status(400).send()
await ConsultancyEntry.removeEntry(id)
await deleteConsultancyEntryFromIndex(id, true)
res.send()
}
module.exports = consultancy
@@ -0,0 +1,15 @@
const DemoPaginacija = require('../../../models/demo-paginacija')
const { DEFAULT_HITS_PER_PAGE } = require('../../../config/settings')
exports.list = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const page = +req.query.p > 0 ? +req.query.p : 1
const { pages_total: numberOfAllPages, results } = await DemoPaginacija.fetch(
resultsPerPage,
page
)
res.send({ page, numberOfAllPages, results })
}
+189
View File
@@ -0,0 +1,189 @@
const Dictionary = require('../../../models/dictionary')
const Entry = require('../../../models/entry')
const {
searchEntryIndex,
deleteEntryFromIndex,
deleteDictionaryEntriesFromIndex
} = require('../../../models/search-engine')
const genEditorAllQuery = require('../../../models/helpers/search/generate-query/editor/all')
const { prepareEditorEntries } = require('../../../models/helpers/search')
const { DEFAULT_HITS_PER_PAGE } = require('../../../config/settings')
const { minEntriesRequirementCheckAndAct } = require('../../helpers/dictionary')
const dictionary = {}
dictionary.getEntry = async (req, res) => {
const entryId = req.query.id
const [entry, domainLabels] = await Promise.all([
Entry.fetchFull(entryId),
Dictionary.fetchDomainLabelsFromEntryId(entryId)
])
const data = {
entry,
allDomainLabels: domainLabels
}
res.send(data)
}
dictionary.createEntry = async (req, res) => {
const { body } = req
const { dictionaryId } = body
const entryId = await Entry.create(req.user.id, dictionaryId, body)
await Promise.all([
Dictionary.updateMetadataAfterModifyingEntries(dictionaryId),
Entry.indexIntoSearchEngine(entryId, true)
])
res.send({ entryId })
}
dictionary.updateEntry = async (req, res) => {
const { entryId } = req.body
const dictionaryId = await Entry.update(req.user.id, req.body)
await Promise.all([
Dictionary.updateMetadataAfterModifyingEntry(entryId),
Entry.indexIntoSearchEngine(entryId, true),
minEntriesRequirementCheckAndAct.onUpdate(dictionaryId)
])
res.end()
}
dictionary.fetchEntries = async (req, res) => {
const dictionaryId = req.query.id
const hitsQuery = genEditorAllQuery(dictionaryId)
const hits = await searchEntryIndex(hitsQuery)
const terms = prepareEditorEntries(hits)
res.send(terms)
}
// This function deletes selected entry.
dictionary.deleteEntry = async (req, res) => {
const { entryId } = req.query
const dictionaryId = await Entry.delete(entryId)
await Promise.all([
Dictionary.updateMetadataAfterModifyingEntries(dictionaryId),
deleteEntryFromIndex(entryId, true),
minEntriesRequirementCheckAndAct.onDelete(dictionaryId, req.app)
])
res.end()
}
// This function deletes all entries in seleceted dictionary.
dictionary.deleteAllEntries = async (req, res) => {
// TODO This method executes all delete operations before sending the response,
// TODO after which the client tells the user it might take a few minutes.
const dictionaryId = +req.params.dictionaryId
await Entry.deleteAll(dictionaryId)
await Promise.all([
Dictionary.updateMetadataAfterModifyingEntries(dictionaryId),
deleteDictionaryEntriesFromIndex(dictionaryId),
minEntriesRequirementCheckAndAct.onDelete(dictionaryId, req.app)
])
res.end()
}
dictionary.publishAllEntries = async (req, res) => {
// // TODO This method executes all operations before sending the response,
// // TODO after which the client tells the user it might take a few minutes.
// // TODO Also, the message is generic and the same as with deleting a dictionary or all of its entries.
const dictionaryId = +req.params.dictionaryId
await Entry.publishAllQualified(dictionaryId)
// TODO Consider reducing the following two index operations into a single one.
await Promise.all([
Dictionary.updateMetadataAfterModifyingEntries(dictionaryId),
deleteDictionaryEntriesFromIndex(dictionaryId)
])
await Dictionary.indexIntoSearchEngine(dictionaryId)
res.end()
}
dictionary.delete = async (req, res) => {
// TODO This method executes all operations before sending the response,
// TODO after which the client tells the user it might take a few minutes.
// TODO It also keeps the user on a page which shouldn't exist anymore.
// TODO In fact, any value is apparently valid as dictionaryId URL parameter (no validation yet).
const dictionaryId = +req.params.dictionaryId
await Dictionary.delete(dictionaryId)
await deleteDictionaryEntriesFromIndex(dictionaryId)
res.end()
}
dictionary.updateDomainLabels = async (req, res) => {
const { dictionaryId, payload } = req.body.params
await Dictionary.updateDomainLabel(dictionaryId, payload)
res.end()
}
dictionary.renovateSecondaryDomains = async (req, res) => {
const data = req.body.params.payload
await Dictionary.renovateSecondaryDomains(data)
res.end()
}
dictionary.getEntryVersionSnapshot = async (req, res) => {
const { entryId, version } = req.params
const historySnapshot = await Entry.fetchVersionSnapshot(entryId, version)
res.send(historySnapshot)
}
dictionary.listDictionaries = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const page = +req.query.p > 0 ? +req.query.p : 1
const { pages_total: numberOfAllPages, results } =
await Dictionary.fetchAllAdminDictionaries(resultsPerPage, page)
res.send({ page, numberOfAllPages, results })
}
dictionary.listDomainLabels = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { dictionaryId } = req.params
const page = +req.query.p > 0 ? +req.query.p : 1
const { pages_total: numberOfAllPages, results } =
await Dictionary.fetchPaginationDomainLabels(
dictionaryId,
resultsPerPage,
page
)
res.send({ page, numberOfAllPages, results })
}
dictionary.listSecondaryDomains = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const page = +req.query.p > 0 ? +req.query.p : 1
const { pages_total: numberOfAllPages, results } =
await Dictionary.fetchAllSecondaryDomains(resultsPerPage, page)
res.send({ page, numberOfAllPages, results })
}
dictionary.extractionImport = async (req, res) => {
// TODO Import logic (Luka's task)
// const { id: dictionaryId, extractionId } = req.params
// const { from, to } = req.query
// const fromIndex = +from > 1 ? Math.floor(from) - 1 : 0
// const toIndex = Number.isInteger(+to) ? Math.abs(to) : undefined
// console.log({ dictionaryId, extractionId, fromIndex, toIndex })
res.send('IMPORTING')
}
module.exports = dictionary
+328
View File
@@ -0,0 +1,328 @@
const { unlink, rm, mkdir } = require('fs/promises')
const { promisify } = require('util')
const { URLSearchParams } = require('url')
const multer = require('multer')
const validator = require('validator')
const axios = require('axios')
const {
getExtractionFilesPath,
getDocumentsPath,
getStopTermsPath,
getConllusPath
} = require('../../../models/helpers/extraction')
const { checkIfcanBegin } = require('../../helpers/extraction')
const Extraction = require('../../../models/extraction')
const Domain = require('../../../models/domain')
const email = require('../../../models/email')
const { intoDbArray } = require('../../../models/helpers')
const { origin } = require('../../../config/keys')
const { DEFAULT_HITS_PER_PAGE } = require('../../../config/settings')
const MAX_FILE_NAME_LENGTH = 100
const MAX_FILE_SIZE = 10 ** 9 // 1 GB
const VALID_DOCUMENT_EXTENSIONS = ['txt', 'doc', 'docx', 'pdf']
const VALID_STOP_TERMS_FILE_EXTENSION = 'txt'
const MAX_OSS_DOCUMENT_COUNT = 500
const extractionFileStorage = multer.diskStorage({
destination: (req, file, cb) => {
const {
params: { id: extractionId },
fileType
} = req
const destinationPath =
fileType === 'stopTerms'
? getStopTermsPath(extractionId)
: getDocumentsPath(extractionId)
cb(null, destinationPath)
},
filename: (req, file, cb) => cb(null, file.originalname)
})
const extractionFileBodyParser = multer({
storage: extractionFileStorage,
limits: {
fieldNameSize: 15,
fieldSize: 10,
fields: 1,
fileSize: MAX_FILE_SIZE,
headerPairs: 500
},
fileFilter: extractionFileFilter
}).single('extractionFile')
const parseExtractionFileBody = promisify(extractionFileBodyParser)
const extraction = {}
extraction.delete = async (req, res) => {
const extractionId = req.params.id
const corpusId = await Extraction.delete(extractionId)
if (corpusId) {
await axios.delete(`http://concordancer:5000/dashboard/corpus/${corpusId}`)
}
const extractionFilesPath = getExtractionFilesPath(extractionId)
await rm(extractionFilesPath, { recursive: true })
res.end()
}
extraction.docsList = async (req, res) => {
const extractionId = req.params.id
const documentsStats = await Extraction.fetchAllDocumentsStats(extractionId)
res.send(documentsStats)
}
extraction.docsUpdate = async (req, res) => {
try {
await parseExtractionFileBody(req, res)
} catch (error) {
if (
error instanceof multer.MulterError &&
error.code === 'LIMIT_FILE_SIZE'
) {
throw Error('File too large. Must not be over 1 GB.')
}
throw error
}
res.end()
}
extraction.docDelete = async (req, res) => {
const { id: extractionId, filename } = req.params
const documentsPath = getDocumentsPath(extractionId)
const filePath = `${documentsPath}/${filename}`
await unlink(filePath)
res.end()
}
extraction.stopTermsList = async (req, res) => {
const extractionId = req.params.id
const stopTermsFilesStats = await Extraction.fetchAllStopTermsFilesStats(
extractionId
)
res.send(stopTermsFilesStats)
}
extraction.stopTermsUpdate = async (req, res) => {
try {
await parseExtractionFileBody(req, res)
} catch (error) {
if (
error instanceof multer.MulterError &&
error.code === 'LIMIT_FILE_SIZE'
) {
throw Error('File too large. Must not be over 1 GB.')
}
throw error
}
res.end()
}
extraction.stopTermDelete = async (req, res) => {
const { id: extractionId, filename } = req.params
const stopTermsPath = getStopTermsPath(extractionId)
const filePath = `${stopTermsPath}/${filename}`
await unlink(filePath)
res.end()
}
extraction.ossSaveParams = [saveOssParams, (req, res) => res.end()]
extraction.ossSearch = [
saveOssParams,
async (req, res) => {
const { id: extractionId } = req.params
const { ossParams } = req
const searchParams = new URLSearchParams({
...(ossParams.year && { leta: ossParams.year }),
...(ossParams.documentType && { vrste: ossParams.documentType }),
...(ossParams.keywords && { kljucneBesede: ossParams.keywords }),
...(ossParams.domainUdk && { udk: ossParams.domainUdk })
})
const searchApiUrl = `http://rsdo.lhrs.feri.um.si:8080/oss/steviloBesedilPoIskanju?${searchParams}`
const { data: documentCount } = await axios.get(searchApiUrl)
const canSave = documentCount && documentCount <= MAX_OSS_DOCUMENT_COUNT
await Extraction.updateOssParams(extractionId, {
params: ossParams,
status: canSave ? 'valid' : 'invalid'
})
res.send({ documentCount, canSave })
}
]
extraction.ossConfirmParams = async (req, res) => {
const { id: extractionId } = req.params
const { ossParams } = await Extraction.fetch(extractionId)
if (ossParams.status !== 'valid') throw Error('OSS params not valid')
await Extraction.updateOssParams(extractionId, {
params: ossParams.params,
status: 'confirmed'
})
res.end()
}
extraction.begin = async (req, res) => {
const extractionId = req.params.id
const extraction = await Extraction.fetch(extractionId)
const canBegin = await checkIfcanBegin(extraction)
if (!canBegin) throw Error('Extraction does not qualify to be ran')
let timeStarted
const { ossParams, name: extractionName } = extraction
if (ossParams) {
timeStarted = await Extraction.beginOss(extractionId)
} else {
const conllusPath = getConllusPath(extractionId)
await mkdir(conllusPath, { recursive: true })
const documentsNames = await Extraction.fetchAllDocumentsNames(extractionId)
timeStarted = await Extraction.beginOwn(extractionId, documentsNames)
}
res.send(timeStarted)
if (ossParams) {
// TODO This next method is only a temporary solution.
// TODO It should be called before response and its execution delegated to a seperate process or at least a seperate thread.
await Extraction.processOss(extractionId, ossParams.params)
} else {
// TODO This next method is only a temporary solution.
// TODO It should be called before response and its execution delegated to a seperate process or at least a seperate thread.
await Extraction.processOwn(extractionId, extractionName)
}
const extractionLink = new URL('/luscenje', origin)
const renderAsync = promisify(req.app.render.bind(req.app))
const authorEmail = await Extraction.fetchAuthorEmail(extractionId)
const emailHtml = await renderAsync('email/extraction-done', {
extractionName,
extractionLink
})
await email.send({
to: authorEmail,
subject: 'Luščenje končano',
html: emailHtml
})
}
extraction.duplicate = async (req, res) => {
// TODO Validate, if can be duplicated: status = finished or failed.
// TODO Execute duplication.
res.send('DUPLICATING!')
}
extraction.termCandidatesExport = async (req, res) => {
// TODO CSV logic (Luka's task)
// const extractionId = req.params.id
// const { from, to } = req.query
// const fromIndex = +from > 1 ? Math.floor(from) - 1 : 0
// const toIndex = Number.isInteger(+to) ? Math.abs(to) : undefined
// console.log({ extractionId, fromIndex, toIndex })
res.download('public/images/help-amebis-logo-pug-demo.png')
}
extraction.listFinishedForUser = async (req, res) => {
const extractions = await Extraction.fetchFinishedForUser(req.user.id)
res.send(extractions)
}
extraction.listTermCandidates = async (req, res) => {
const extractionId = req.params.id
const termCandidatesJson = await Extraction.fetchTermCandidatesJson(
extractionId
)
const termCandidates = JSON.parse(termCandidatesJson).terminoloski_kandidati
const hitsPerPage = +req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const numberOfAllPages = Math.ceil(termCandidates.length / hitsPerPage)
res.send({ hitsPerPage, numberOfAllPages, termCandidates })
}
function extractionFileFilter(req, file, cb) {
let fileType
switch (req.path.split('/').at(-1)) {
case 'documents':
fileType = 'document'
break
case 'stop-terms':
fileType = 'stopTerms'
break
default:
return cb(Error('Invalid API endpoint'))
}
const filenamePartsArray = file.originalname.split('.')
const fileExtension = filenamePartsArray.pop()
if (
(fileType === 'document' &&
!VALID_DOCUMENT_EXTENSIONS.includes(fileExtension)) ||
(fileType === 'stopTerms' &&
fileExtension !== VALID_STOP_TERMS_FILE_EXTENSION)
) {
return cb(Error('Invalid file type'))
}
const fileName = filenamePartsArray.join('.')
if (!fileName || fileName.length > MAX_FILE_NAME_LENGTH) {
return cb(
Error(
`Filename must be between 1 and ${MAX_FILE_NAME_LENGTH} characters long.`
)
)
}
if (!validator.isAlphanumeric(fileName[0], 'sl-SI', { ignore: '_' })) {
return cb(
Error(
'Filename must begin with an alphanumeric character or an underscore.'
)
)
}
if (!validator.isAlphanumeric(fileName, 'sl-SI', { ignore: ' _-.' })) {
return cb(
Error(
'Filename can only contain alphanumeric characters, spaces, underscores, minuses and periods.'
)
)
}
req.fileType = fileType
cb(null, true)
}
async function saveOssParams(req, res, next) {
const { id: extractionId } = req.params
const { body } = req
const ossParams = {
...(body.domain && {
domainUdk: intoDbArray(
(await Domain.fetchById(body.domain)).udkCode,
'always'
)
}),
...(body.documentType && {
documentType: intoDbArray(body.documentType, 'always').map(type => +type)
}),
...(body.year && {
year: intoDbArray(body.year, 'always').map(year => +year)
}),
...(body.keywords && {
keywords: intoDbArray(body.keywords, 'always')
})
}
await Extraction.update(extractionId, body.name)
await Extraction.updateOssParams(extractionId, {
params: ossParams,
status: 'unvalidated'
})
req.ossParams = ossParams
next()
}
module.exports = extraction
@@ -0,0 +1,18 @@
const InterInstanceSync = require('../../../models/inter_instance_sync')
exports.listDictionaries = async (req, res) => {
const dictionaries = await InterInstanceSync.listDictionaries()
res.send(dictionaries)
}
exports.syncDictionary = async (req, res) => {
const did = req.params.dictionaryId
const since = req.query.lastSynced
? req.query.lastSynced
: '2000-01-01 00:00:00'
const entriesToSync = await InterInstanceSync.getUpdatedEntriesSince(
did,
since
)
res.send(entriesToSync)
}
+63
View File
@@ -0,0 +1,63 @@
const Portal = require('../../../models/portal')
const axios = require('axios')
const { DEFAULT_HITS_PER_PAGE } = require('../../../config/settings')
const portal = {}
portal.create = async (req, res) => {
const portal = await Portal.create(req.body)
res.send(portal)
}
portal.syncRemoteDictionaries = async (req, res) => {
const { linkedPortalId } = req.params
const { indexURL } = await Portal.fetchPortal(linkedPortalId)
const { data: dictionaries } = await axios.get(indexURL)
await Portal.syncRemoteDictionaries(linkedPortalId, dictionaries)
res.end()
}
portal.deleteLinkedDictionary = async (req, res) => {
const { linkedPortalId } = req.params
await Portal.deleteLinkedDictionary(linkedPortalId)
res.end()
}
portal.fetchDictionary = async (req, res) => {
const portalId = req.query.id
const dictionaries = await Portal.fetchDictionaries(portalId)
res.send(dictionaries)
}
portal.updatePortalStatus = async (req, res) => {
const portalId = req.body.params.id
const isEnabled = req.body.params.isEnabled
await Portal.updatePortalStatus(portalId, isEnabled)
res.end()
}
portal.listSelectedLinkedDicts = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { portalId } = req.params
const page = +req.query.p > 0 ? +req.query.p : 1
const { pages_total: numberOfAllPages, results } =
await Portal.fetchSelectedLinkedDictionaries(portalId, resultsPerPage, page)
res.send({ page, numberOfAllPages, results })
}
portal.listAllLinkedDicts = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const page = +req.query.p > 0 ? +req.query.p : 1
const { pages_total: numberOfAllPages, results } =
await Portal.fetchAllLinkedDictionaries(resultsPerPage, page)
res.send({ page, numberOfAllPages, results })
}
module.exports = portal
+230
View File
@@ -0,0 +1,230 @@
const Dictionary = require('../../../models/dictionary')
const { searchEntryIndex } = require('../../../models/search-engine')
const { intoDbArray } = require('../../../models/helpers')
const {
prepareEntries,
prepareEditorEntries,
prepareAggregation,
prepareSeachFilterData
} = require('../../../models/helpers/search')
const generateQuery = require('../../../models/helpers/search/generate-query')
const { getInstanceSetting } = require('../../../models/helpers')
const { DEFAULT_HITS_PER_PAGE } = require('../../../config/settings')
exports.listMainEntries = async (req, res) => {
const searchString = req.query.q?.trim()
if (!searchString) return res.status(400).end()
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 = await generateQuery.main(
searchString,
filters,
hitsPerPage,
page
)
const hits = await searchEntryIndex(hitsQuery)
const numberOfAllHits = hits.body.hits.total.value
const numberOfAllPages = Math.ceil(numberOfAllHits / hitsPerPage)
let entriesByCategory = prepareEntries(hits)
// 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/index.js search
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
},
{}
)
// res.send({ page, numberOfAllPages, entries })
res.append('page', page)
res.append('number-of-all-pages', numberOfAllPages)
res.render('utilities/response-pug-wrapper/entryLister', {
entriesByCategory
})
}
exports.listEditorEntries = async (req, res) => {
const { dictionaryId } = req.params
if (!dictionaryId) return res.status(400).end()
const qs = req.query
const searchField = qs.field
const searchString = qs.q?.trim() ?? ''
const filters = {
isValid: qs.isValid === undefined ? undefined : qs.isValid !== 'false',
isPublished:
qs.isPublished === undefined ? undefined : qs.isPublished !== 'false',
hasComments:
qs.hasComments === undefined ? undefined : qs.hasComments !== 'false',
isComplete:
qs.isComplete === undefined ? undefined : qs.isComplete !== 'false',
isTerminologyReviewed:
qs.isTerminologyReviewed === undefined
? undefined
: qs.isTerminologyReviewed !== 'false',
isLanguageReviewed:
qs.isLanguageReviewed === undefined
? undefined
: qs.isLanguageReviewed !== 'false'
}
const hitsQuery = generateQuery.editor(
dictionaryId,
searchField,
searchString,
filters
)
const hits = await searchEntryIndex(hitsQuery)
const entries = prepareEditorEntries(hits)
res.send(entries)
}
exports.listFilteredDictionaries = async (req, res) => {
let searchString = req.query.q?.trim()
if (!searchString) {
searchString = ''
}
const filters = {
// sourceLanguages: intoDbArray(req.query.sl, 'always'),
// targetLanguages: intoDbArray(req.query.tl, 'always'),
primaryDomains: intoDbArray(req.query.pd, 'always')
// sources: intoDbArray(req.query.s, 'always')
}
const orderType = req.query.orderType
const orderIndex = req.query.orderIndex === 'true'
// TODO: User another constant since this one is also user in the search results
const hitsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const page = +req.query.p > 0 ? +req.query.p : 1
// const defaultportalname = await getInstanceSetting('portal_name')
const defaultportalcode = await getInstanceSetting('portal_code')
let dictionaries
// mapping done here to not expose database info to public
let orderAttribute
if (orderType === 'domainName') {
orderAttribute = 'dp'
} else {
orderAttribute = 'd'
}
dictionaries = await Dictionary.fetchBasicInfoPerPageFilteredWithOrdering(
searchString,
filters.primaryDomains,
hitsPerPage,
page,
orderAttribute,
orderIndex
)
dictionaries = dictionaries.map(e => {
if (!e.portalcode) {
e.portalcode = defaultportalcode
// e.portalname = defaultportalname
}
return e
})
const numberOfAllHits = parseInt(
(await Dictionary.fetchFilteredCount(searchString, filters.primaryDomains))
.count
)
const numberOfAllPages = Math.ceil(numberOfAllHits / hitsPerPage)
// res.send({ page, numberOfAllPages, entries })
res.append('page', page)
res.append('number-of-all-pages', numberOfAllPages)
res.render('utilities/response-pug-wrapper/dictionaryLister', {
dictionaries
})
}
exports.showModalFilterResults = async (req, res) => {
const searchString = req.query.q?.trim()
if (!searchString) return res.status(400).end()
const selectedFilter = req.query.selectedFilter
// The selected
const filters = {
sourceLanguages:
selectedFilter === 'sourceLanguages'
? []
: intoDbArray(req.query.sl, 'always'),
targetLanguages:
selectedFilter === 'targetLanguages'
? []
: intoDbArray(req.query.tl, 'always'),
primaryDomains:
selectedFilter === 'primaryDomains'
? []
: intoDbArray(req.query.pd, 'always'),
dictionaries:
selectedFilter === 'dictionaries'
? []
: intoDbArray(req.query.d, 'always'),
sources:
selectedFilter === '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 [, aggregateQuery] = await generateQuery.main(
searchString,
filters,
hitsPerPage,
page,
true
)
const aggregationRaw = await searchEntryIndex(aggregateQuery)
const aggregation = await prepareAggregation(aggregationRaw)
res.send(prepareSeachFilterData(aggregation, filters))
}
+16
View File
@@ -0,0 +1,16 @@
const debug = require('debug')('termPortal:controllers/api/v1/system')
const Eurotermbank = require('../../../models/system/eurotermbank')
exports.handleCspReports = (req, res) => {
debug(req.body)
res.sendStatus(200)
}
exports.syncWithEurotermbank = async (req, res) => {
try {
await Eurotermbank.push()
res.send('Sync successful')
} catch (error) {
res.send('Sync failed')
}
}
+35
View File
@@ -0,0 +1,35 @@
const User = require('../../../models/user')
const { DEFAULT_HITS_PER_PAGE } = require('../../../config/settings')
const users = {}
users.listUsers = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const page = +req.query.p > 0 ? +req.query.p : 1
const { pages_total: numberOfAllPages, results } = await User.fetchAll(
resultsPerPage,
page
)
res.send({ page, numberOfAllPages, results })
}
users.updateHitsPerPage = async (req, res) => {
const hitAmount = req.body.hitAmount
await User.updateHitsPerPage(req.user.userName, hitAmount)
res.status(200).send()
}
users.updateFristNameAndSurname = async (req, res) => {
const firstname = req.body.name
const surname = req.body.surname
await User.updateFirstNameAndLastName(req.user.userName, firstname, surname)
res.status(200).send()
}
module.exports = users
+408
View File
@@ -0,0 +1,408 @@
const Dictionary = require('../models/dictionary')
const ConsultancyEntry = require('../models/consultancy-entry')
const Domain = require('../models/domain')
const User = require('../models/user')
const utils = require('../utils')
// const helpers = require('../models/helpers')
const { DEFAULT_HITS_PER_PAGE } = require('../config/settings')
const generateQuery = require('../models/helpers/search/generate-query')
const { searchConsultancyEntryIndex } = require('../models/search-engine')
const { prepareConsultancyEntries } = require('../models/helpers/search')
// const { minEntriesRequirementCheckAndAct } = require('./helpers/dictionary')
const consultancy = {}
const consultancyAdmin = {}
consultancy.index = async (req, res) => {
req.indexHitPageAmount = '5'
return await consultancyRequest(
req,
res,
'published',
'pages/consultancy/index'
)
}
consultancy.search = async (req, res) => {
return await consultancyRequest(
req,
res,
'published',
'pages/consultancy/search'
)
}
consultancy.specificQuestion = async (req, res) => {
const { id } = req.params
const entry = await ConsultancyEntry.fetchByIdWithFormattedTime(id)
// const author = await User.fetchUser(entry.authorId)
const allPrimaryDomains = await Dictionary.fetchAllPrimaryDomains()
entry.answerAuthors = entry.answerAuthors.filter(author => author !== '')
let authorString
if (entry.answerAuthors.length === 1) {
authorString = 'Avtor'
} else if (entry.answerAuthors.length === 2) {
authorString = 'Avtorja'
} else {
authorString = 'Avtorji'
}
entry.domain = allPrimaryDomains.filter(
filt => filt.id === entry.domainPrimaryId
)
if (entry.domain.length > 0) {
entry.domain = entry.domain[0].nameSl
} else {
entry.domain = false
}
res.render('pages/consultancy/item-details', {
allPrimaryDomains,
authorString,
entry
})
}
consultancy.new = async (req, res) => {
const allPrimaryDomains = await Dictionary.fetchAllPrimaryDomains()
res.render('pages/consultancy/ask', {
allPrimaryDomains
})
}
consultancyAdmin.new = async (req, res) => {
return await consultancyRequest(
req,
res,
'new',
'pages/consultancy/admin/index',
true,
false
)
}
consultancyAdmin.users = async (req, res) => {
const allPrimaryDomains = await Dictionary.fetchAllPrimaryDomains()
const users = await User.fetchConsultants()
res.render('pages/consultancy/admin/users', {
allPrimaryDomains,
users
})
}
consultancyAdmin.rejected = async (req, res) => {
return await consultancyRequest(
req,
res,
'rejected',
'pages/consultancy/admin/rejected',
true,
false
)
}
consultancyAdmin.published = async (req, res) => {
return await consultancyRequest(
req,
res,
'published',
'pages/consultancy/admin/published',
true,
false
)
}
consultancyAdmin.prepared = async (req, res) => {
return await consultancyRequest(
req,
res,
'review',
'pages/consultancy/admin/prepared',
true,
false
)
}
consultancyAdmin.inProgress = async (req, res) => {
// TODO Below is an example use of consultancy search implemented using search engine.
// TODO Adjust and use it everywhere it's needed and delete these comments.
// *************************************** EXAMPLE START ***************************************
return await consultancyRequest(
req,
res,
'in progress',
'pages/consultancy/admin/in-progress',
true,
false
)
}
consultancyAdmin.statistics = async (req, res) => {
const allPrimaryDomains = await Dictionary.fetchAllPrimaryDomains()
res.render('pages/consultancy/admin/statistics', {
allPrimaryDomains
})
}
consultancyAdmin.edit = async (req, res) => {
const { id } = req.params
const key = req.query.sentFrom
const sentFrom = {}
sentFrom[key] = true
const moderator = await ConsultancyEntry.getModerator(id)
const editors = await ConsultancyEntry.getEditors(id)
if (
req.user.hasRole('consultancy admin') ||
req.user.hasRole('portal admin')
) {
console.log('Editor guard omitted due to being administrator')
} else if (editors.filter(editors => editors.id === req.user.id) < 1) {
return res.send('You do not have permsisions to edit this answer')
}
const entry = await ConsultancyEntry.fetchByIdWithFormattedTime(id)
const allPrimaryDomains = await Dictionary.fetchAllPrimaryDomains()
const author = await User.fetchUser(entry.authorId)
const isPublished = entry.status === 'published'
res.render('pages/consultancy/admin/edit', {
id,
sentFrom: key,
entry: entry,
allPrimaryDomains,
moderator,
author,
isPublished,
// TODO Luka: I suspect this will not work as intended on staging or production environments. Test.
urlPrefix: req.protocol + '://' + req.get('host')
})
}
function dateMap(obj) {
if (!obj.formattedTimeCreated) {
obj.formattedTimeCreated = obj.timeCreated
}
return obj
}
async function mapDomainIdToDomainNameSlovene(obj) {
try {
const area = await Domain.fetchById(
obj.domainPrimaryId ? obj.domainPrimaryId : obj.domainPrimaryIdInitial
)
obj.area = area.nameSl
} catch {
obj.area = 'Ni področja'
}
return obj
}
function mapInitialValuesAsEmpty(obj) {
if (!obj.authors) {
obj.authors = []
}
if (!obj.title) {
obj.title = ''
}
if (!obj.numShared) {
obj.numShared = 0
}
return obj
}
async function mapEntryList(list) {
return await Promise.all(
list.map(entry => {
let entity = utils.compose(dateMap, mapInitialValuesAsEmpty)(entry)
// TODO Each mapDomainIdToDomainNameSlovene call leads to one DB query.
// TODO Test if and what scenarios can lead to too many calls and how it can be avoided.
entity = utils.composeAsync(mapDomainIdToDomainNameSlovene)(entry)
return entity
})
)
}
function summaryDisplay(str) {
if (str.split('\n').length > 3) {
return splitLine(str, 3)
} else {
return cropLongString(str)
}
}
function splitLine(str, countLines) {
if (!str) {
return str
}
if (countLines <= 0) {
return ''
}
let nlIndex = -1
let newLinesFound = 0
while (newLinesFound < countLines) {
const nextIndex = str.indexOf('\n', nlIndex + 1)
if (nextIndex === -1) {
return str
}
nlIndex = nextIndex
newLinesFound++
}
const nextIndex = str.indexOf('\n', nlIndex + 1)
return str.slice(0, nlIndex) + (nextIndex !== -1 ? '...' : '')
}
function cropLongString(str) {
if (str.length > 500) {
return str.slice(0, 500) + '...'
}
return str
}
async function consultancyRequest(
req,
res,
type,
url,
isAdminPage = false,
privilegeToSeAll = true // this method seperates consultancy main from admin, so all results get visible TO ALL REGISTERED USERS, not just admins
) {
const searchString = req.query.q?.trim() ?? ''
let assignedConsultant
if (
!privilegeToSeAll &&
req.user &&
!(req.user.hasRole('portal admin') || req.user.hasRole('consultancy admin'))
) {
assignedConsultant = req.user.id
} else {
assignedConsultant = undefined
}
const filters = {
status: type,
assignedConsultant,
primaryDomain: req.query.pd
}
if (!isAdminPage) {
filters.assignedConsultant = undefined
filters.status = 'published' // guard
}
let hitsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
if (req.indexHitPageAmount) {
hitsPerPage = req.indexHitPageAmount
}
const page = +req.query.p > 0 ? +req.query.p : 1
const hitsQuery = generateQuery.consultancy(
searchString,
filters,
hitsPerPage,
page
)
const hits = await searchConsultancyEntryIndex(hitsQuery)
const numberOfAllHits = hits.body.hits.total.value
const numberOfAllPages = Math.ceil(numberOfAllHits / hitsPerPage)
let entries = prepareConsultancyEntries(hits)
// console.log({ entries, numberOfAllHits, numberOfAllPages })
entries = entries.map(entry => {
entry.primaryDomain = entry.primaryDomain
? entry.primaryDomain.nameSl
: 'nedefinirano'
if (entry.assignedConsultants) {
entry.firstName = entry.assignedConsultants[0]?.firstName
entry.lastName = entry.assignedConsultants[0]?.lastName
}
if (entry.assignedConsultants && entry.assignedConsultants.length > 1) {
entry.sharedAuthors = []
for (let i = 1; i < entry.assignedConsultants.length; i++) {
// skip first element (moderator)
entry.sharedAuthors.push(
`${entry.assignedConsultants[i].firstName} ${entry.assignedConsultants[i].lastName}`
)
}
}
if (entry.timeCreated) {
const date = new Date(entry.timeCreated)
entry.formattedTimeCreated = `${date.getDate()}. ${
date.getMonth() + 1
}. ${date.getFullYear()}`
}
return entry
})
const allPrimaryDomains = await Dictionary.fetchAllPrimaryDomains()
// const entryList = await mapEntryList(inProgressEntryList)
const userList = await User.fetchConsultants()
if (!isAdminPage) {
entries.map(entry => {
const MAX_CHARACTER_LENGTH = 200
let appendAnswer = ''
let appendQuestion = ''
if (entry.answer && entry.answer.length > MAX_CHARACTER_LENGTH) {
appendAnswer = '...'
}
if (entry.question && entry.question.length > MAX_CHARACTER_LENGTH) {
appendQuestion = '...'
}
entry.answerSummary = `${entry.answer.slice(
0,
MAX_CHARACTER_LENGTH
)}${appendAnswer}`
entry.question = `${entry.question.slice(
0,
MAX_CHARACTER_LENGTH
)}${appendQuestion}`
return entry
})
}
res.render(url, {
allPrimaryDomains,
entries, // entryList,
userList,
numberOfAllPages,
queryCount: numberOfAllHits
})
}
module.exports = { consultancy, consultancyAdmin }
+17
View File
@@ -0,0 +1,17 @@
const DemoPaginacija = require('../models/demo-paginacija')
const { DEFAULT_HITS_PER_PAGE } = require('../config/settings')
const demoPaginacija = {}
demoPaginacija.izrišiStran = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { pages_total: numberOfAllPages, results } = await DemoPaginacija.fetch(
resultsPerPage,
1
)
res.render('pages/demo-paginacija', { numberOfAllPages, results })
}
module.exports = demoPaginacija
+699
View File
@@ -0,0 +1,699 @@
const { unlink } = require('fs/promises')
const { promisify } = require('util')
const multer = require('multer')
const debug = require('debug')('termPortal:controllers/dictionary')
const Dictionary = require('../models/dictionary')
const Entry = require('../models/entry')
const User = require('../models/user')
const Comment = require('../models/comment')
const genEditorAllQuery = require('../models/helpers/search/generate-query/editor/all')
const { searchEntryIndex } = require('../models/search-engine')
const { prepareEditorEntries } = require('../models/helpers/search')
const { getInstanceSetting } = require('../models/helpers')
const { DEFAULT_HITS_PER_PAGE, DATA_FILES_PATH } = require('../config/settings')
const {
statusChangeCheckAndAct,
determineNewStatus
} = require('./helpers/dictionary')
const SFDSuggestionImporter = require('./helpers/search-filter-data-suggestion-importer')
const Extraction = require('../models/extraction')
const importFileBodyParser = multer({
dest: `${DATA_FILES_PATH}/dict_import_temp`,
limits: {
fieldNameSize: 21,
fieldSize: 9,
fields: 3,
fileSize: 1000 * 1000 * 1000, // 1GB
headerPairs: 500
},
fileFilter: importFileFilter
}).single('dictionaryImportFile')
const parseImportFileBody = promisify(importFileBodyParser)
const dictionary = {}
dictionary.list = async (req, res) => {
let dictionaries
if (req.isAuthenticated()) {
dictionaries = await Dictionary.fetchAllByUser(req.user.id)
}
res.render('pages/dictionaries/list', {
title: 'Seznam slovarjev',
dictionaries
})
}
dictionary.new = async (req, res) => {
// TODO Once english language is implemented, gather selected language (sl/en) from request ~ (cookies?)
const language = 'name_sl'
const [allPrimaryDomains, allSecondaryDomains, allLanguages] =
await Promise.all([
Dictionary.fetchAllPrimaryDomains(),
Dictionary.fetchAllApprovedSecondaryDomains(),
Dictionary.fetchAllLanguages(language)
])
res.render('pages/dictionaries/new', {
title: 'Nov slovar',
allPrimaryDomains,
allSecondaryDomains,
allLanguages
})
}
dictionary.create = async (req, res) => {
await Dictionary.create(req.body, req.user.id)
res.redirect('/slovarji/moji')
}
dictionary.editDescription = async (req, res) => {
const { dictionaryId } = req.params
const [
allPrimaryDomains,
allSecondaryDomains,
dictionary,
associatedSecondaryDomains
] = await Promise.all([
Dictionary.fetchAllPrimaryDomains(),
Dictionary.fetchAllApprovedSecondaryDomains(),
Dictionary.fetchEditDescription(dictionaryId),
Dictionary.fetchSecondaryDomains(dictionaryId)
])
res.render('pages/dictionaries/description', {
title: 'Ime in opis',
allPrimaryDomains,
allSecondaryDomains,
dictionary,
associatedSecondaryDomains
})
}
dictionary.updateDescription = async (req, res) => {
const { dictionaryId } = req.params
const { body } = req
// TODO Consider using a transaction.
await Promise.all([
Dictionary.updateDescription(dictionaryId, body),
Dictionary.deleteSecondaryDomains(dictionaryId, body),
Dictionary.updateSecondaryDomains(dictionaryId, body)
])
res.redirect('back')
}
dictionary.editUsers = async (req, res) => {
const dictionaryId = req.params.dictionaryId
const [dictionary, userRights, entriesCount, minEntries, publishApproval] =
await Promise.all([
Dictionary.fetchEditUsers(dictionaryId),
User.fetchAllWithDictionaryRights(dictionaryId),
Dictionary.countPublishedEntries(dictionaryId),
getInstanceSetting('min_entries_per_dictionary'),
getInstanceSetting('dictionary_publish_approval')
])
let viewPath
switch (req.baseUrl) {
case '/slovarji':
viewPath = 'pages/dictionaries/users'
break
case '/admin':
viewPath = 'pages/admin/dictionary-users'
}
res.render(viewPath, {
title: 'Uporabniki',
dictionary,
userRights,
entriesCount,
minEntries,
publishApproval
})
}
dictionary.updateUsers = async (req, res) => {
// TODO Due to reindexing of all of dictionary entries on status change, this operation might take a while.
// TODO Stress test and consider either a notification to user or alteast a progress indicator while they wait.
const { dictionaryId } = req.params
const isPublished = req.body.isPublished === 'on'
const newDictStatus = await determineNewStatus(isPublished)
const { nameSl, status: oldDictStatus } = await Dictionary.fetchEditUsers(
dictionaryId
)
await Promise.all([
Dictionary.updateUsers(dictionaryId, req.body, newDictStatus),
User.updateUserRights(dictionaryId, req.body.rightsPerUser)
])
if (newDictStatus !== oldDictStatus) {
if (newDictStatus === 'published') {
await Dictionary.updateTimePublished(dictionaryId)
}
await Dictionary.indexIntoSearchEngine(dictionaryId)
}
await statusChangeCheckAndAct.updateUsers(
dictionaryId,
isPublished,
oldDictStatus,
nameSl,
req.app,
req.user
)
res.redirect('back')
}
dictionary.editStructure = async (req, res) => {
// TODO Once english language is implemented, gather selected language (sl/en) from request ~ (cookies?)
const language = 'name_sl'
const { dictionaryId } = req.params
const [dictionary, associatedLanguages, allLanguages] = await Promise.all([
Dictionary.fetchEditStructure(dictionaryId),
Dictionary.fetchLanguages(dictionaryId),
Dictionary.fetchAllLanguages(language)
])
let viewPath
switch (req.baseUrl) {
case '/slovarji':
viewPath = 'pages/dictionaries/structure'
break
case '/admin':
viewPath = 'pages/admin/dictionary-structure'
}
res.render(viewPath, {
title: 'Struktura slovarskega sestavka',
dictionary,
associatedLanguages,
allLanguages
})
}
dictionary.updateStructure = async (req, res) => {
const { body } = req
const { dictionaryId } = req.params
// TODO Consider using a transaction.
await Promise.all([
Dictionary.updateStructure(dictionaryId, body),
Dictionary.deleteLanguages(dictionaryId),
Dictionary.updateLanguages(dictionaryId, body)
])
res.redirect('back')
}
dictionary.editAdvanced = async (req, res) => {
const { dictionaryId } = req.params
const dictionaryName = await Dictionary.fetchName(dictionaryId)
let viewPath
switch (req.baseUrl) {
case '/slovarji':
viewPath = 'pages/dictionaries/advanced'
break
case '/admin':
viewPath = 'pages/admin/dictionary-advanced'
}
res.render(viewPath, {
title: 'Napredno',
dictionary: { id: req.params.dictionaryId },
dictionaryName
})
}
dictionary.comments = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
let viewPath
const { dictionaryId } = req.params
const type = 'dictionary'
const filters = { ctxType: type, ctxId: dictionaryId }
const [{ comments, pages_total: numberOfAllPages }, dictionaryName] =
await Promise.all([
Comment.list(filters, req.user, resultsPerPage, 1),
Dictionary.fetchName(dictionaryId)
])
switch (req.baseUrl) {
case '/slovarji':
viewPath = 'pages/dictionaries/dictionary-comments'
break
case '/admin':
viewPath = 'pages/admin/dictionary-comments'
}
res.render(viewPath, {
title: 'Komentarji',
numberOfAllPages,
dictionary: { id: req.params.dictionaryId },
comments,
dictionaryName
})
}
dictionary.showImportFromFileForm = async (req, res) => {
const { dictionaryId } = req.params
const [imports, dictionaryName] = await Promise.all([
Dictionary.fetchAllImports(dictionaryId),
Dictionary.fetchName(dictionaryId)
])
let viewPath
switch (req.baseUrl) {
case '/slovarji':
viewPath = 'pages/dictionaries/import'
break
case '/admin':
viewPath = 'pages/admin/dictionary-import'
}
res.render(viewPath, {
title: 'Uvoz iz datoteke',
dictionary: { id: dictionaryId },
imports,
dictionaryName
})
}
dictionary.listAdminDictionaries = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { pages_total: numberOfAllPages, results } =
await Dictionary.fetchAllAdminDictionaries(resultsPerPage, 1)
res.render('pages/admin/dictionaries-list', {
title: 'Struktura slovarjev',
numberOfAllPages,
results
})
}
dictionary.adminEditDescription = async (req, res) => {
const { dictionaryId } = req.params
const [
allPrimaryDomains,
allSecondaryDomains,
dictionary,
associatedSecondaryDomains,
status
] = await Promise.all([
Dictionary.fetchAllPrimaryDomains(),
Dictionary.fetchAllApprovedSecondaryDomains(),
Dictionary.fetchEditDescription(dictionaryId),
Dictionary.fetchSecondaryDomains(dictionaryId),
Dictionary.fetchStatus(dictionaryId)
])
res.render('pages/admin/dictionary-description', {
title: 'Podatki',
allPrimaryDomains,
allSecondaryDomains,
dictionary,
associatedSecondaryDomains,
status
})
}
dictionary.updateAdminDescription = async (req, res) => {
// TODO Due to reindexing of all of dictionary entries on status change, this operation might take a while.
// TODO Stress test and consider either a notification to user or alteast a progress indicator while they wait.
const { dictionaryId } = req.params
const { body } = req
const newDictStatus = body.status
const oldDictStatus = await Dictionary.fetchStatus(dictionaryId)
// TODO Consider using a transaction.
await Promise.all([
Dictionary.updateDescription(dictionaryId, body),
Dictionary.deleteSecondaryDomains(dictionaryId, body),
Dictionary.updateSecondaryDomains(dictionaryId, body),
Dictionary.updateStatus(dictionaryId, body)
])
if (newDictStatus !== oldDictStatus) {
if (newDictStatus === 'published') {
await Dictionary.updateTimePublished(dictionaryId)
}
await Dictionary.indexIntoSearchEngine(dictionaryId)
}
await statusChangeCheckAndAct.updateAdminDescription(
dictionaryId,
newDictStatus,
oldDictStatus,
req.app,
req.user
)
res.redirect('back')
}
dictionary.showImportFromExtractionForm = async (req, res) => {
const { dictionaryId } = req.params
const dictionaryName = await Dictionary.fetchName(dictionaryId)
const extractions = await Extraction.fetchFinishedForUser(req.user.id)
let viewPath, title
switch (req.baseUrl) {
case '/slovarji':
viewPath = 'pages/dictionaries/extraction-import'
title = 'Uvoz'
break
case '/admin':
viewPath = 'pages/admin/dictionary-extraction-import'
title = 'Uvoz luščenje'
}
res.render(viewPath, {
title,
dictionary: { id: dictionaryId },
dictionaryName,
extractions
})
}
dictionary.showExportToFileForm = async (req, res) => {
const { dictionaryId } = req.params
const dictionaryName = await Dictionary.fetchName(dictionaryId)
let viewPath
switch (req.baseUrl) {
case '/slovarji':
viewPath = 'pages/dictionaries/export'
break
case '/admin':
viewPath = 'pages/admin/dictionary-export'
}
res.render(viewPath, {
title: 'Izvoz',
dictionary: { id: req.params.dictionaryId },
dictionaryName
})
}
dictionary.editDomainLabels = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { dictionaryId } = req.params
const [{ pages_total: numberOfAllPages, results }, dictionaryName] =
await Promise.all([
Dictionary.fetchPaginationDomainLabels(dictionaryId, resultsPerPage, 1),
Dictionary.fetchName(dictionaryId)
])
let viewPath
switch (req.baseUrl) {
case '/slovarji':
viewPath = 'pages/dictionaries/domain-labels'
break
case '/admin':
viewPath = 'pages/admin/dictionary-domain-labels'
}
res.render(viewPath, {
title: 'Področne oznake',
dictionary: { id: dictionaryId },
numberOfAllPages,
results,
dictionaryName
})
}
dictionary.showContent = async (req, res) => {
const { dictionaryId } = req.params
const hitsQuery = genEditorAllQuery(dictionaryId)
const [
hits,
canPublishEntriesInEdit,
dictionaryName,
structure,
languages,
entryDomainLabels
] = await Promise.all([
searchEntryIndex(hitsQuery),
getInstanceSetting('can_publish_entries_in_edit'),
Dictionary.fetchName(dictionaryId),
Dictionary.fetchEditStructure(dictionaryId),
Dictionary.fetchLanguages(dictionaryId),
Dictionary.fetchDomainLabels(dictionaryId)
])
const terms = prepareEditorEntries(hits)
res.render('pages/dictionaries/content', {
title: 'Vsebina slovarja',
terms,
canPublishEntriesInEdit,
dictionaryName,
structure,
languages,
dictionaryId,
entryDomainLabels
})
}
dictionary.showSecondaryDomains = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { pages_total: numberOfAllPages, results } =
await Dictionary.fetchAllSecondaryDomains(resultsPerPage, 1)
res.render('pages/admin/areas', {
title: 'Podpodročja',
numberOfAllPages,
results
})
}
dictionary.dictionaryList = async (req, res) => {
/* const [allPrimaryDomains, allSecondaryDomains, allLanguages] =
await Promise.all([
Dictionary.fetchAllPrimaryDomains(),
Dictionary.fetchAllApprovedSecondaryDomains(),
Dictionary.fetchAllLanguages()
]) */
// const defaultportalname = await getInstanceSetting('portal_name')
const defaultportalcode = await getInstanceSetting('portal_code')
const hitsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const page = +req.query.p > 0 ? +req.query.p : 1
// initial mapping
// mapping done here to not expose database info to public
const orderIndex = false
const orderAttribute = 'd'
let dictionaries = await Dictionary.fetchBasicInfoPerPageFilteredWithOrdering(
'',
{},
hitsPerPage,
page,
orderAttribute,
orderIndex
)
const numberOfAllHits = parseInt(
(await Dictionary.fetchAllDictionariesCount()).count
)
const numberOfAllPages = Math.ceil(numberOfAllHits / hitsPerPage)
const allPrimaryDomains = await Dictionary.fetchAllPrimaryDomains()
dictionaries = dictionaries.map(e => {
if (!e.portalcode) {
e.portalcode = defaultportalcode
// e.portalname = defaultportalname
}
return e
})
const isDictionaryListPage = true
res.render('pages/dictionaries/dictlist', {
title: 'Seznam slovarjev',
dictionaries,
allPrimaryDomains,
numberOfAllPages,
isDictionaryListPage
/*
allSecondaryDomains,
allLanguages */
})
}
dictionary.dictionaryDetails = async (req, res) => {
const { absolutePrevPath, sentFromEntryId } = req.query
const dictId = req.params.dictionaryId
const {
allPrimaryDomains,
sourceLanguages,
targetLanguages,
allDictionaryNames,
portals
} = await SFDSuggestionImporter.initialize()
const dictionaryData = await Dictionary.fetchDictionaryBasicInfo(dictId)
const filters = { ctxType: 'dictionary', ctxId: dictId }
// 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.portalname && !dictionaryData.portalcode) {
dictionaryData[0].portalname = await getInstanceSetting('portal_name')
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 structData = {
prevWindowTitle: 'Nazaj',
dictName: dictionaryData[0].dictionarysl,
portalCode: dictionaryData[0].portalcode,
portalName: dictionaryData[0].portalname,
fullAuthorName: reducedData.author ? reducedData.author.join(', ') : '',
areas: dictionaryData[0].domain_primary,
subareas: reducedData.subDomains ? reducedData.subDomains.join(', ') : '',
languages: reducedData.languages ? reducedData.languages.join(', ') : ''
}
if (reducedData.author) {
if (reducedData.author.length > 2) {
structData.authorLabel = 'Avtorji'
} else if (reducedData.author.length === 2) {
structData.authorLabel = 'Avtorja'
} else if (reducedData.author.length === 1) {
structData.authorLabel = 'Avtor'
}
}
if (sentFromEntryId === 'dictsList') {
structData.prevHref = `/slovarji`
} else {
structData.prevHref = `/termin/${sentFromEntryId}`
}
const finalData = {
...reducedData,
...structData
}
res.render('pages/search/result-detail-dictionary', {
allPrimaryDomains,
sourceLanguages,
targetLanguages,
allDictionaryNames,
portals,
sentFromEntryId,
dictId,
absolutePrevPath,
dictionaryData,
finalData,
numberOfAllPages,
comments,
commentCount
}) // todo
}
dictionary.importFromFile = async (req, res) => {
try {
await parseImportFileBody(req, res)
// TODO return JSON error response rather then delegate to express.
// TODO Also return proper status codes.
// TODO File (or other form data) cound not be present. Add validation or fallback/errorhandling.
const { dictionaryId } = req.params
const importFilePath = req.file.path
const { deleteExistingEntries, entryStatus, importFileFormat } = req.body
await Dictionary.openImportFileJob(
dictionaryId,
deleteExistingEntries,
importFileFormat
)
if (deleteExistingEntries) {
await Entry.deleteAllFromIndex(dictionaryId)
await Entry.deleteAll(dictionaryId)
}
await Dictionary.importFromFile(
req.user.id,
dictionaryId,
importFilePath,
entryStatus
)
// TODO Rather then waiting for success/failure, return immediately and continue processing in the background.
// TODO Also add API endpoint for getting progress.
await Dictionary.indexIntoSearchEngine(dictionaryId)
// TODO Error --> import job status: (indexing) error --> User manually triggers reindex.
debug('IMPORT SUCCESSFUL')
const importProcessId = 'DUMMY ID' // TODO You'll get it from DB.
res.status(202).send(importProcessId)
} catch (error) {
// TODO Consider writing a property on req and add an extra error handler for API errors.
debug('IMPORT FAILED')
debug(error)
res.status(400).send(error)
} finally {
try {
await unlink(req.file.path)
} catch (error) {
debug('ERROR REMOVING TEMP IMPORT FILE:')
debug(error)
}
}
}
function importFileFilter(req, file, cb) {
if (file.mimetype !== 'text/xml') return cb(Error('Invalid file type'))
cb(null, true)
}
/*
function commaSeperationReducer(dictionaryData) {
return dictionaryData.reduce((acc, x) => {
if (acc === ':://') {
return x
} else {
return `${acc}, ${x}`
}
}, ':://')
} */
module.exports = dictionary
+150
View File
@@ -0,0 +1,150 @@
const { mkdir } = require('fs/promises')
const {
getDocumentsPath,
getStopTermsPath
} = require('../models/helpers/extraction')
const { checkIfcanBegin } = require('./helpers/extraction')
const { MAX_EXTRACTIONS_PER_USER } = require('../config/settings')
const Extraction = require('../models/extraction')
const Dictionary = require('../models/dictionary')
const Domain = require('../models/domain')
const { intoDbArray } = require('../models/helpers')
const { DEFAULT_HITS_PER_PAGE } = require('../config/settings')
const extraction = {}
extraction.list = async (req, res) => {
let extractions = await Extraction.fetchAllForUser(req.user.id)
extractions = await Promise.all(
extractions.map(async extraction => {
extraction.canBegin = await checkIfcanBegin(extraction)
if (extraction.status === 'finished') {
extraction.termCandidatesCount =
await Extraction.fetchTermCandidatesCount(extraction.id)
}
return extraction
})
)
res.render('extraction-poc/list', { extractions })
}
extraction.create = async (req, res) => {
const extractionCount = await Extraction.countAllForUser(req.user.id)
if (extractionCount >= MAX_EXTRACTIONS_PER_USER) {
// TODO Tukaj bo treba prikazati tudi obvestilo uporabniku skladno s trenutno metodologijo prikaza obvestil.
return res.redirect(303, 'back')
}
const extractionName = `Luščenje ${extractionCount + 1}`
const { extractionType } = req.body
let extractionId
if (extractionType === 'own') {
extractionId = await Extraction.createOwn(req.user.id, extractionName)
const documentsPath = getDocumentsPath(extractionId)
const stopTermsPath = getStopTermsPath(extractionId)
await Promise.all([
mkdir(documentsPath, { recursive: true }),
mkdir(stopTermsPath, { recursive: true })
])
} else {
extractionId = await Extraction.createOss(req.user.id, extractionName)
const stopTermsPath = getStopTermsPath(extractionId)
await mkdir(stopTermsPath, { recursive: true })
}
// Redirect to extraction edit page.
res.redirect(`poc/${extractionId}`)
}
extraction.edit = async (req, res) => {
const extractionId = req.params.id
const extraction = await Extraction.fetch(extractionId)
if (extraction.ossParams) {
const [allPrimaryDomains, stopTermsFiles] = await Promise.all([
Dictionary.fetchAllPrimaryDomains(),
Extraction.fetchAllStopTermsFilesStats(extractionId)
])
const { params } = extraction.ossParams
const domainUdk = params?.domainUdk?.[0]
if (domainUdk)
extraction.domainId = await Domain.fetchIdByUdkCode(domainUdk)
extraction.documentType = intoDbArray(params.documentType, 'always')
extraction.year = intoDbArray(params.year, 'always')
extraction.keywords = intoDbArray(params.keywords, 'always')
res.render('extraction-poc/edit-oss', {
id: extractionId,
extraction,
allPrimaryDomains,
stopTermsFiles
})
} else {
const [extractionDocuments, stopTermsFiles] = await Promise.all([
Extraction.fetchAllDocumentsStats(extractionId),
Extraction.fetchAllStopTermsFilesStats(extractionId)
])
res.render('extraction-poc/edit-own', {
id: extractionId,
extraction,
extractionDocuments,
stopTermsFiles
})
}
}
extraction.updateOwn = async (req, res) => {
const extractionId = req.params.id
await Extraction.update(extractionId, req.body.name)
// Reload page.
res.redirect(`./${extractionId}`)
}
extraction.docsEdit = async (req, res) => {
const extractionId = req.params.id
const extractionDocuments = await Extraction.fetchAllDocumentsStats(
extractionId
)
res.render('extraction-poc/docs-edit', {
id: extractionId,
extractionDocuments
})
}
extraction.stopTermsEdit = async (req, res) => {
const extractionId = req.params.id
const stopTermsFiles = await Extraction.fetchAllStopTermsFilesStats(
extractionId
)
res.render('extraction-poc/stop-terms-edit', {
id: extractionId,
stopTermsFiles
})
}
extraction.listTermCandidates = async (req, res) => {
const extractionId = req.params.id
const termCandidatesJson = await Extraction.fetchTermCandidatesJson(
extractionId
)
const termCandidates = JSON.parse(termCandidatesJson).terminoloski_kandidati
const hitsPerPage = +req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const numberOfAllPages = Math.ceil(termCandidates.length / hitsPerPage)
const firstPageOfTermCandidates = termCandidates.slice(0, hitsPerPage)
res.render('extraction-poc/term-candidates', {
extractionId,
termCandidatesJson,
firstPageOfTermCandidates,
hitsPerPage,
numberOfAllPages
})
}
module.exports = extraction
+156
View File
@@ -0,0 +1,156 @@
const { mkdir } = require('fs/promises')
const {
getDocumentsPath,
getStopTermsPath
} = require('../models/helpers/extraction')
const { checkIfcanBegin } = require('./helpers/extraction')
const { MAX_EXTRACTIONS_PER_USER } = require('../config/settings')
const Extraction = require('../models/extraction')
const Dictionary = require('../models/dictionary')
const Domain = require('../models/domain')
const { intoDbArray } = require('../models/helpers')
const { DEFAULT_HITS_PER_PAGE } = require('../config/settings')
const extraction = {}
extraction.list = async (req, res) => {
let extractions = []
if (req.user) {
extractions = await Extraction.fetchAllForUser(req.user.id)
extractions = await Promise.all(
extractions.map(async extraction => {
extraction.canBegin = await checkIfcanBegin(extraction)
if (extraction.status === 'finished') {
extraction.termCandidatesCount =
await Extraction.fetchTermCandidatesCount(extraction.id)
}
return extraction
})
)
}
res.render('pages/extraction/list', { title: 'Luščenje seznam', extractions })
}
extraction.create = async (req, res) => {
const extractionCount = await Extraction.countAllForUser(req.user.id)
if (extractionCount >= MAX_EXTRACTIONS_PER_USER) {
// TODO Tukaj bo treba prikazati tudi obvestilo uporabniku skladno s trenutno metodologijo prikaza obvestil.
return res.redirect(303, 'back')
}
const extractionName = `Luščenje ${extractionCount + 1}`
const { extractionType } = req.body
let extractionId
if (extractionType === 'own') {
extractionId = await Extraction.createOwn(req.user.id, extractionName)
const documentsPath = getDocumentsPath(extractionId)
const stopTermsPath = getStopTermsPath(extractionId)
await Promise.all([
mkdir(documentsPath, { recursive: true }),
mkdir(stopTermsPath, { recursive: true })
])
} else {
extractionId = await Extraction.createOss(req.user.id, extractionName)
const stopTermsPath = getStopTermsPath(extractionId)
await mkdir(stopTermsPath, { recursive: true })
}
// Redirect to extraction edit page.
res.redirect(`luscenje/${extractionId}`)
}
extraction.edit = async (req, res) => {
const extractionId = req.params.id
const extraction = await Extraction.fetch(extractionId)
if (extraction.ossParams) {
const [allPrimaryDomains, stopTermsFiles] = await Promise.all([
Dictionary.fetchAllPrimaryDomains(),
Extraction.fetchAllStopTermsFilesStats(extractionId)
])
const { params } = extraction.ossParams
const domainUdk = params?.domainUdk?.[0]
if (domainUdk)
extraction.domainId = await Domain.fetchIdByUdkCode(domainUdk)
extraction.documentType = intoDbArray(params.documentType, 'always')
extraction.year = intoDbArray(params.year, 'always')
extraction.keywords = intoDbArray(params.keywords, 'always')
res.render('pages/extraction/edit-oss', {
title: 'KAS + dokumenti',
id: extractionId,
extraction,
allPrimaryDomains,
stopTermsFiles
})
} else {
const [extractionDocuments, stopTermsFiles] = await Promise.all([
Extraction.fetchAllDocumentsStats(extractionId),
Extraction.fetchAllStopTermsFilesStats(extractionId)
])
res.render('pages/extraction/edit-own', {
title: 'Besedila',
id: extractionId,
extraction,
extractionDocuments,
stopTermsFiles
})
}
}
extraction.updateOwn = async (req, res) => {
const extractionId = req.params.id
await Extraction.update(extractionId, req.body.name)
// Reload page.
res.redirect(`./${extractionId}`)
}
extraction.docsEdit = async (req, res) => {
const extractionId = req.params.id
const extractionDocuments = await Extraction.fetchAllDocumentsStats(
extractionId
)
res.render('pages/extraction/docs-edit', {
id: extractionId,
extractionDocuments
})
}
extraction.stopTermsEdit = async (req, res) => {
const extractionId = req.params.id
const stopTermsFiles = await Extraction.fetchAllStopTermsFilesStats(
extractionId
)
res.render('pages/extraction/stop-terms-edit', {
id: extractionId,
stopTermsFiles
})
}
extraction.listTermCandidates = async (req, res) => {
const extractionId = req.params.id
const termCandidatesJson = await Extraction.fetchTermCandidatesJson(
extractionId
)
const termCandidates = JSON.parse(termCandidatesJson).terminoloski_kandidati
const hitsPerPage = +req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const numberOfAllPages = Math.ceil(termCandidates.length / hitsPerPage)
const firstPageOfTermCandidates = termCandidates.slice(0, hitsPerPage)
res.render('pages/extraction/term-candidates', {
extractionId,
termCandidatesJson,
firstPageOfTermCandidates,
hitsPerPage,
numberOfAllPages
})
}
module.exports = extraction
+211
View File
@@ -0,0 +1,211 @@
const { getInstanceSetting } = require('../../models/helpers')
const Dictionary = require('../../models/dictionary')
const cache = require('../../models/cache')
const { promisify } = require('util')
const email = require('../../models/email')
const MIN_ENTRIES_MAIL_NAMESPACE = 'below_min_entries_mail_sent'
// Helper object for minEntries below. Defines operations on anti-spam bookmarks.
const minEntriesEmailBookmark = {
// Checks whether the anti-spam bookmark (still) exists.
async exists(dictionaryId) {
const bookMarkName = `${MIN_ENTRIES_MAIL_NAMESPACE}:${dictionaryId}`
return !!(await cache.exists(bookMarkName))
},
// Sets the anti-spam bookmark for given dictionary.
async set(dictionaryId) {
const bookMarkName = `${MIN_ENTRIES_MAIL_NAMESPACE}:${dictionaryId}`
await cache.set(bookMarkName, true)
},
// Removes the anti-spam bookmark for given dictionary if conditions are met.
async update(dictionaryId) {
const doesBookmarkExist = await this.exists(dictionaryId)
if (!doesBookmarkExist) return
const minEntries = await getInstanceSetting('min_entries_per_dictionary')
const isBelowMinEntriesThreshold = await checkIfBelowMinEntriesThreshold(
dictionaryId,
minEntries
)
if (isBelowMinEntriesThreshold) return
const bookMarkName = `${MIN_ENTRIES_MAIL_NAMESPACE}:${dictionaryId}`
await cache.unlink(bookMarkName)
}
}
// Exports actions related to checking and acting on minimum entries per dictionary setting.
exports.minEntriesRequirementCheckAndAct = {
// Checks required criteria and sends notifications emails if required.
async onDelete(dictionaryId, appRef) {
const minEntries = await getInstanceSetting('min_entries_per_dictionary')
// Only proceed if a valid and positive minimum entries per dictionary setting is set.
if (!(+minEntries > 0)) return
const [isBelowMinEntriesThreshold, wasEmailAlreadySent] = await Promise.all(
[
checkIfBelowMinEntriesThreshold(dictionaryId, minEntries),
minEntriesEmailBookmark.exists(dictionaryId)
]
)
if (!isBelowMinEntriesThreshold || wasEmailAlreadySent) return
// Prepare and send notification emails.
const [nameSl, adminEmails, dictionariesAdminEmails] = await Promise.all([
Dictionary.fetchName(dictionaryId),
Dictionary.fetchAdminEmails(dictionaryId),
Dictionary.fetchDictionariesAdminEmails()
])
const allEmailsSet = new Set([...adminEmails, ...dictionariesAdminEmails])
const allEmails = []
for (const email of allEmailsSet) allEmails.push(email)
const type = 'delete'
const renderAsync = promisify(appRef.render.bind(appRef))
const emailHtml = await renderAsync('email/dictionary-status-change', {
type,
nameSl
})
await email.send({
to: allEmails,
subject: 'Obvestilo o številu gesel',
html: emailHtml
})
await minEntriesEmailBookmark.set(dictionaryId)
},
// Use this method after any entry updating operations to release potential anti-spam bookmarks.
onUpdate: minEntriesEmailBookmark.update.bind(minEntriesEmailBookmark)
}
// Helper function to check if a given (published) dictionary has less entries than provided minEntries parameter.
async function checkIfBelowMinEntriesThreshold(dictionaryId, minEntries) {
const [publishedEntries, dictionaryStatus] = await Promise.all([
Dictionary.countPublishedEntries(dictionaryId),
Dictionary.fetchStatus(dictionaryId)
])
const isBelowMinEntriesThreshold =
+publishedEntries < +minEntries && dictionaryStatus === 'published'
return isBelowMinEntriesThreshold
}
// Determine new dictionary status based on dictionary_publish_approval setting.
exports.determineNewStatus = async isPublished => {
const publishApproval = await getInstanceSetting(
'dictionary_publish_approval'
)
let newStatus
if (publishApproval === 'F') {
newStatus = isPublished ? 'published' : 'closed'
} else {
newStatus = isPublished ? 'reviewed' : 'closed'
}
return newStatus
}
// Exports actions related to checking and acting on dictionary status changes.
exports.statusChangeCheckAndAct = {
// Notify dictionaries admins by email on dictionary status changes.
async updateUsers(
dictionaryId,
isPublishedNew,
oldDictStatus,
nameSl,
appRef,
user
) {
const isPublishedOld = oldDictStatus === 'published'
// Published: on -> off.
if (!isPublishedNew && isPublishedOld) {
const dictionariesAdminEmails =
await Dictionary.fetchDictionariesAdminEmails()
const type = 'unpublish'
await renderAndSendStatusChangeEmails(
appRef,
type,
user.email,
nameSl,
dictionariesAdminEmails
)
// Published: off -> on.
} else if (isPublishedNew && !isPublishedOld) {
const [isApprovalRequired, dictionariesAdminEmails] = await Promise.all([
getInstanceSetting('dictionary_publish_approval'),
Dictionary.fetchDictionariesAdminEmails()
])
const type =
isApprovalRequired === 'T' ? 'publish-approval' : 'publish-no-approval'
await renderAndSendStatusChangeEmails(
appRef,
type,
user.email,
nameSl,
dictionariesAdminEmails
)
}
},
// Notify dictionary admins by email after dictionaries admins
// change dictionary status from reviewed to either closed or published.
async updateAdminDescription(
dictionaryId,
statusNew,
statusOld,
appRef,
user
) {
if (statusOld === 'reviewed' && statusNew !== 'reviewed') {
const [nameSl, adminEmails] = await Promise.all([
Dictionary.fetchName(dictionaryId),
Dictionary.fetchAdminEmails(dictionaryId)
])
const type = 'status'
await renderAndSendStatusChangeEmails(
appRef,
type,
user.email,
nameSl,
adminEmails
)
}
}
}
// Helper function used by statusChangeCheckAndAct methods.
async function renderAndSendStatusChangeEmails(
appRef,
type,
changerEmail,
nameSl,
targetEmails
) {
const renderAsync = promisify(appRef.render.bind(appRef))
const emailHtml = await renderAsync('email/dictionary-status-change', {
type,
changerEmail,
nameSl
})
await email.send({
to: targetEmails,
subject: 'Sprememba stanja slovarja',
html: emailHtml
})
}
+11
View File
@@ -0,0 +1,11 @@
const Extraction = require('../../models/extraction')
// Check if extraction process can be started.
exports.checkIfcanBegin = async extraction => {
if (extraction.status !== 'new') return false
if (extraction.ossParams) return extraction.ossParams.status === 'confirmed'
const documentsNames = await Extraction.fetchAllDocumentsNames(extraction.id)
return !!documentsNames.length
}
@@ -0,0 +1,34 @@
const Dictionary = require('../../models/dictionary')
const Portal = require('../../models/portal')
const { getInstanceSetting } = require('../../models/helpers')
const helper = {}
helper.initialize = async () => {
const initializers = {}
// TODO Once english language is implemented, gather selected language (sl/en) from request ~ (cookies?)
const language = 'name_sl'
// TODO Consider parallelizing following queries. Single vs pooled clients?
initializers.allPrimaryDomains = await Dictionary.fetchAllPrimaryDomains()
initializers.sourceLanguages = await Dictionary.fetchAllLanguages(language)
initializers.targetLanguages = initializers.sourceLanguages.filter(
// drop slovene language
l => l.id !== 32
)
initializers.allDictionaryNames = await Dictionary.fetchAll()
initializers.portals = []
initializers.portals.push({
name: await getInstanceSetting('portal_name'),
code: await getInstanceSetting('portal_code')
})
initializers.portals.concat(await Portal.fetchAll())
return initializers
}
module.exports = helper
+386
View File
@@ -0,0 +1,386 @@
const Entry = require('../models/entry')
const { getInstanceSetting } = require('../models/helpers')
const Dictionary = require('../models/dictionary')
const Comment = require('../models/comment')
const { searchEntryIndex } = require('../models/search-engine')
const { intoDbArray } = require('../models/helpers')
const {
prepareEntries,
prepareAggregation,
prepareSeachFilterData
} = require('../models/helpers/search')
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')
// TODO Luka (note to self): Measure performance, consider caching.
exports.index = async (req, res) => {
const {
allPrimaryDomains,
sourceLanguages,
targetLanguages,
allDictionaryNames,
portals
} = await SFDSuggestionImporter.initialize()
const englishLanguageEnabled = false // dummy variable for future edit
const latestDicts = await Dictionary.fetchLatest3DictsByPublishDate(
englishLanguageEnabled
)
const portalName = await getInstanceSetting('portal_name')
const portalDescription = await getInstanceSetting('portal_description')
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('/')
const {
allPrimaryDomains,
sourceLanguages,
targetLanguages,
allDictionaryNames,
portals
} = await SFDSuggestionImporter.initialize()
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
)
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)
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
}
if (count < 1) {
return res.render('pages/search/no-results', {
allPrimaryDomains,
sourceLanguages,
targetLanguages,
allDictionaryNames,
portals,
searchString,
entriesByCategory,
searchFilterData,
disabledSideMenuFilters,
// allAggregation,
numberOfAllHits,
numberOfAllPages,
page
})
}
// TODO Add a page title?
res.render('pages/search/results', {
allPrimaryDomains,
sourceLanguages,
targetLanguages,
allDictionaryNames,
portals,
searchString,
entriesByCategory,
searchFilterData,
disabledSideMenuFilters,
// allAggregation,
numberOfAllHits,
numberOfAllPages,
page
})
}
exports.entryDetails = async (req, res) => {
const termId = req.params.entryId
/* 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(', ')
} */
// unnecessary legacy assigment, refactor when time is available
const entryData = entry
const {
allPrimaryDomains,
sourceLanguages,
targetLanguages,
allDictionaryNames,
portals
} = await SFDSuggestionImporter.initialize()
const [dictStruct, dictionaryData, selectedDomainLabelsForEntry] =
await Promise.all([
Dictionary.fetchDictionaryWithEditStructure(entry.dictionary_id),
Dictionary.fetchDictionaryBasicInfo(entry.dictionary_id),
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.portalname && !dictionaryData.portalcode) {
dictionaryData[0].portalname = await getInstanceSetting('portal_name')
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: 'Iskanje',
prevHref: '/iskanje',
portalCode: dictionaryData[0].portalcode,
portalName: dictionaryData[0].portalname,
dictName: structure.nameSl,
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(', ') : ''
}
if (reducedData.author) {
if (reducedData.author.length > 2) {
structData.authorLabel = 'Avtorji'
} else if (reducedData.author.length === 2) {
structData.authorLabel = 'Avtorja'
} else if (reducedData.author.length === 1) {
structData.authorLabel = '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)
entryData.foreign_entries.forEach((val, idx) => {
try {
entry.foreign_entries[idx].name_sl = langs[idx].nameSl
entry.foreign_entries[idx].name_en = langs[idx].nameEn
} catch (e) {}
})
res.render('pages/search/result-detail', {
allPrimaryDomains,
entryData,
sourceLanguages,
targetLanguages,
allDictionaryNames,
portals,
termId,
structure,
finalData,
selectedDomainLabelsForEntryString,
numberOfAllPages,
comments,
commentCount
})
}
exports.myProfile = async (req, res) => {
res.render('pages/profile/my-profile', { title: 'Moj račun' })
}
exports.changePassword = async (req, res) => {
res.render('pages/profile/change-password', { title: 'Spremeni geslo' })
}
exports.userSettings = async (req, res) => {
const hitsPerPageArr = await User.fetchAllowedHitsPerPage()
res.render('pages/profile/change-profile-settings', {
title: 'Nastavitve računa',
hitsPerPageArr,
hitsForUser: req.user?.hitsPerPage
})
}
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]
}
+140
View File
@@ -0,0 +1,140 @@
const portal = {}
const Portal = require('../models/portal')
const Comment = require('../models/comment')
const { clearCachedInstanceSettings } = require('../models/helpers')
const { DEFAULT_HITS_PER_PAGE } = require('../config/settings')
portal.instanceSettings = async (req, res) => {
const portal = await Portal.fetchInstanceSettings()
res.render('pages/admin/portal', {
title: 'Nastavitve portala',
portal
})
}
portal.updateInstaceSettings = async (req, res) => {
const payload = req.body
await Portal.updateInstaceSettings(payload)
await clearCachedInstanceSettings()
res.redirect('/admin/nastavitve/portal')
}
portal.instanceDictSettings = async (req, res) => {
const dictionary = await Portal.fetchInstanceDictSettings()
res.render('pages/admin/settings-dictionaries', {
title: 'Nastavitve slovarjev',
dictionary
})
}
portal.updateInstanceDictSettings = async (req, res) => {
const payload = req.body
await Portal.updateInstaceDictSettings(payload)
await clearCachedInstanceSettings()
res.redirect('/admin/nastavitve/slovarji')
}
portal.instanceConsultancySettings = async (req, res) => {
const consultancy = await Portal.fetchInstanceConsultancySettings()
res.render('pages/admin/portal-consultancy-settings', {
title: 'Nastavitve svetovalnice',
consultancy
})
}
portal.updateInstanceConusltacySettings = async (req, res) => {
const payload = req.body
await Portal.updateInstaceConsultancySettings(payload)
await clearCachedInstanceSettings()
res.redirect('/admin/nastavitve/svetovalnica')
}
portal.new = async (req, res) => {
res.render('pages/admin/new-connection', {
title: 'Nova povezava'
})
}
portal.list = async (req, res) => {
const allLinkedPortals = await Portal.fetchAll()
res.render('pages/admin/connections-list', {
title: 'Seznam povezav',
allLinkedPortals
})
}
portal.fetchPortal = async (req, res) => {
const portalId = req.params.portalId
const portal = await Portal.fetchPortal(portalId)
res.render('pages/admin/portal-edit', { title: 'Uredi povezavo', portal })
}
portal.updatePortal = async (req, res) => {
const portalId = req.params.portalId
const payload = req.body
await Portal.update(portalId, payload)
res.redirect('/admin/povezave/seznam')
}
portal.fetchSelectedLinkedDictionaries = async (req, res) => {
const linkedId = req.params.portalId
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { pages_total: numberOfAllPages, results } =
await Portal.fetchSelectedLinkedDictionaries(linkedId, resultsPerPage, 1)
res.render('pages/admin/portal-list-dict', {
title: 'Slovarji portala',
linkedId,
numberOfAllPages,
results
})
}
portal.updateSelectedDictionaries = async (req, res) => {
const linkedId = req.params.portalId
await Portal.updateSelectedDictionaries(linkedId, req.body)
res.redirect('back')
}
portal.fetchAllLinkedDictionaries = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { pages_total: numberOfAllPages, results } =
await Portal.fetchAllLinkedDictionaries(resultsPerPage, 1)
res.render('pages/admin/portals-all-linked-dictionaries', {
title: 'Povezani',
numberOfAllPages,
results
})
}
portal.updateAllDictionaries = async (req, res) => {
await Portal.updateAllDictionaries(req.body)
res.redirect('back')
}
portal.comments = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const type = 'portal'
const filters = { ctxType: type }
const { comments, pages_total: numberOfAllPages } = await Comment.list(
filters,
req.user,
resultsPerPage,
1
)
res.render('pages/admin/comments', {
title: 'Komentarji',
numberOfAllPages,
dictionary: { id: req.params.dictionaryId },
comments
})
}
module.exports = portal
+142
View File
@@ -0,0 +1,142 @@
const { randomBytes } = require('crypto')
const { promisify } = require('util')
const passport = require('passport')
const User = require('../models/user')
const email = require('../models/email')
const { origin } = require('../config/keys')
const { rememberMeCookieSettings } = require('../config/settings')
const RandomBytesAsync = promisify(randomBytes)
const { DEFAULT_HITS_PER_PAGE } = require('../config/settings')
const user = {}
user.register = async (req, res) => {
// TODO Add validation.
const userId = await User.create(req.body)
const activationToken = (await RandomBytesAsync(32)).toString('hex')
await User.saveActivationToken(userId, activationToken)
const { email: userEmail, username } = req.body
let activationLink = new URL('/users/activate', origin)
activationLink.searchParams.set('token', activationToken)
activationLink = activationLink.href
const renderAsync = promisify(req.app.render.bind(req.app))
const emailHtml = await renderAsync('email/user-activation', {
username,
activationLink
})
await email.send({
to: userEmail,
subject: 'Aktivacija računa',
html: emailHtml
})
res.send('Registracija uspešna')
}
user.activateAccount = async (req, res) => {
// TODO Add validation. What if user is already logged in? What if account is already active? ...
const { token } = req.query
const user = await User.fetchByActivationToken(token)
await User.activateAccount(user)
const loginAsync = promisify(req.login.bind(req))
await loginAsync(user)
res.redirect('/')
}
user.login = async (req, res, next) => {
passport.authenticate(
'local',
{
badRequestMessage:
'Nepravilno uporabniško ime, elektronski naslov ali geslo.'
},
async (err, user, info) => {
if (err) return next(err)
if (!user) {
const err = Error(info.message)
err.status = 403
err.displayInProd = true
return next(err)
}
const loginAsync = promisify(req.login.bind(req))
await loginAsync(user)
if (req.body.rememberMe) {
// TODO Consider what happens if the user already has a remember me token.
const rememberMeToken = await User.generateRememberMeToken()
await User.saveRememberMeToken(user, rememberMeToken)
res.cookie('remember_me', rememberMeToken, rememberMeCookieSettings)
}
res.send('Prijava uspešna')
}
)(req, res, next)
}
user.logout = async (req, res) => {
const rememberMeToken = req.signedCookies.remember_me
if (rememberMeToken) {
res.clearCookie('remember_me')
await User.clearRememberMeToken(rememberMeToken)
}
req.logout()
// Manually clear session.passport due to bug in current passport version.
delete req.session.passport.user
res.redirect('/')
}
user.list = async (req, res) => {
const resultsPerPage = req.user?.hitsPerPage || DEFAULT_HITS_PER_PAGE
const { pages_total: numberOfAllPages, results } = await User.fetchAll(
resultsPerPage,
1
)
res.render('pages/admin/user-list', {
title: 'Seznam slovarjeva',
numberOfAllPages,
results
})
}
user.listAllWithPortalRoles = async (req, res) => {
const users = await User.fetchAllWithPortalRoles()
res.render('pages/admin/user-portals', { title: 'Seznam slovarjev', users })
}
user.findByUsernameOrEmail = async (req, res) => {
const userNameEmail = req.query.userNameEmail
const searchedUser = await User.findByUsernameOrEmail(userNameEmail)
res.send(searchedUser)
}
user.updateRoles = async (req, res) => {
await User.updatePortalRoles(req.body.rolesPerUser)
res.redirect('back')
}
user.adminEdit = async (req, res) => {
const userId = req.params.userId
const [userData, userRoles] = await Promise.all([
User.fetchUser(userId),
User.fetchUserRoles(userId)
])
res.render('pages/admin/user-edit', {
title: 'Urejanje uporabnikov',
userData,
userRoles
})
}
// TODO Aljaž: Luka, please adjust update function for updating user's password and email
// TODO Handle username unique constraint failure.
user.adminUpdate = async (req, res) => {
const { userId } = req.params
await User.updateUser(userId, req.body)
res.redirect('back')
}
module.exports = user
+113
View File
@@ -0,0 +1,113 @@
const passport = require('passport')
const LocalStrategy = require('passport-local').Strategy
const RememberMeStrategy = require('passport-remember-me-extended').Strategy
const bcrypt = require('bcrypt')
const db = require('../models/db')
const User = require('../models/user')
const {
rememberMeCookieSettings,
REMEMBER_ME_DURATION_SQL
} = require('../config/settings')
passport.serializeUser((user, done) => {
done(null, user.id)
})
passport.deserializeUser(async (id, done) => {
try {
// TODO Consider storing this data in cache.
// TODO In that case, make sure to invalidate it at every (relevant) data change.
const user = await User.fetchDeserializedDataById(id)
// TODO Consider what to do if no user was found?
done(null, user)
} catch (error) {
done(error)
}
})
passport.use(
new LocalStrategy(
{ usernameField: 'usernameOrEmail' },
async (usernameOrEmail, password, done) => {
try {
const { rows } = await db.query(
'SELECT id, status, bcrypt_hash FROM "user" WHERE username = $1 OR email = $1',
[usernameOrEmail]
)
const user = rows[0]
if (!user) {
return done(null, false, {
message: 'Nepravilno uporabniško ime, elektronski naslov ali geslo.'
})
}
if (user.status !== 'active') {
return done(null, false, {
message:
'Uporabniški račun še ni aktiviran. Kliknite aktivacijsko povezavo, katero smo vam poslali po elektronski pošti.'
})
}
const isCorrectPassword = await bcrypt.compare(
password,
user.bcrypt_hash
)
if (!isCorrectPassword) {
return done(null, false, {
message: 'Nepravilno uporabniško ime, elektronski naslov ali geslo.'
})
}
delete user.bcrypt_hash
done(null, user)
} catch (error) {
done(error)
}
}
)
)
passport.use(
new RememberMeStrategy(
{ cookie: rememberMeCookieSettings, signed: true },
async (token, done) => {
try {
const text = `
DELETE
FROM user_token_remember_me t
WHERE
t.token = $1
AND AGE(NOW(), t.time_created) < $2::INTERVAL
RETURNING (SELECT u.id FROM "user" u where u.id = t.user_id)
`
const values = [token, REMEMBER_ME_DURATION_SQL]
let {
rows: [user]
} = await db.query(text, values)
if (!user) return done(null, false)
user = await User.fetchDeserializedDataById(user.id)
done(null, user)
} catch (error) {
done(error)
}
},
async (user, done) => {
try {
const token = await User.generateRememberMeToken()
await User.saveRememberMeToken(user, token)
done(null, token)
} catch (error) {
done(error)
}
}
)
)
module.exports = passport
+6
View File
@@ -0,0 +1,6 @@
const { getInstanceSetting } = require('../models/helpers')
exports.enhanceLocals = async (req, res, next) => {
res.locals.portalCode = await getInstanceSetting('portal_code')
next()
}
+54
View File
@@ -0,0 +1,54 @@
const session = require('express-session')
const RedisStore = require('connect-redis')(session)
const { secret, cookiesSecure } = require('../config/keys')
const redisClient = require('../models/cache')
const SESSION_DURATION = 1000 * 60 * 60 * 2 // 2 hours.
const SESSION_ID_COOKIE_NAME = 'sid'
// const RETRY_PERIOD = 3000 // 3 seconds.
const options = {
cookie: { maxAge: SESSION_DURATION, sameSite: 'lax', secure: cookiesSecure },
secret,
name: SESSION_ID_COOKIE_NAME,
rolling: true,
resave: false,
saveUninitialized: false,
store: new RedisStore({ client: redisClient })
}
module.exports = session(options)
// Aditional checking logic below.
// Export it instead of the middleware above if it proves necessary.
// const sessionMiddleware = session(options)
// function verifiedSession(req, res, next) {
// let tries = 3
// let timeoutId
// function lookupSession(err) {
// clearTimeout(timeoutId)
// if (err) return next(err)
// if (req.session !== undefined) return next()
// tries -= 1
// if (tries < 0) {
// return next(Error('Session store unresponsive'))
// }
// sessionMiddleware(req, res, lookupSession)
// if (req.session === undefined) {
// timeoutId = setTimeout(lookupSession, RETRY_PERIOD)
// }
// }
// lookupSession()
// }
// module.exports = verifiedSession
+23
View File
@@ -0,0 +1,23 @@
const settings = {}
const { getInstanceSetting } = require('../models/helpers')
settings.prepareRequiredSettings = async (req, res, next) => {
const isExtractionEnabled =
(await getInstanceSetting('is_extraction_enabled')) === 'T'
const isDictionariesEnabled =
(await getInstanceSetting('is_dictionaries_enabled')) === 'T'
const isConsultancyEnabled =
(await getInstanceSetting('is_consultancy_enabled')) === 'T'
// req.extractionEnabled = isExtractionEnabled
// req.dictionariesEnabled = isDictionariesEnabled
// req.consultancyEnabled = isConsultancyEnabled
res.locals.extractionEnabled = isExtractionEnabled
res.locals.dictionariesEnabled = isDictionariesEnabled
res.locals.consultancyEnabled = isConsultancyEnabled
next()
}
module.exports = settings
+95
View File
@@ -0,0 +1,95 @@
const user = {}
user.enhance = (req, res, next) => {
// Make req.user available to view engine.
res.locals.user = req.user
// Extend the req.user object with 3 rolechecking methods.
if (!req.user) return next()
req.user.hasRole = hasRole
req.user.hasDictionaryRole = hasDictionaryRole
req.user.hasAnyDictionaryRole = hasAnyDictionaryRole
req.user.isEditorOfConsultancyEntry = isEditorOfConsultancyEntry
next()
}
user.isDictionaryAdmin = (req, res, next) => {
const { dictionaryId } = req.params
const isAdmin = req.user.hasDictionaryRole(dictionaryId, 'administration')
if (isAdmin) return next()
if (req.isAjax) return res.status(400).end()
res.redirect(req.baseUrl)
}
user.isDictionaryEditor = (req, res, next) => {
const { dictionaryId } = req.params
const isEditor = req.user.hasAnyDictionaryRole(dictionaryId)
if (isEditor) return next()
if (req.isAjax) return res.status(400).end()
res.redirect(req.baseUrl)
}
/**
* Checks if the user has a specific role.
*
* @param {'portal admin'|'dictionaries admin'|'consultancy admin'|'consultant'|'editor'} roleName
* @returns {boolean}
*/
function hasRole(roleName) {
return this.userRoles.some(role => role.roleName === roleName)
}
/**
* Checks if the user has a specific dictionary role.
*
* @param {number} dictionaryId
* @param {'administration'|'terminologyReview'|'languageReview'|'editing'} roleName
* @returns {boolean}
*/
function hasDictionaryRole(dictionaryId, roleName) {
return this.userRoles.some(role => {
const validDictionaryRoles = [
'administration',
'editing',
'terminologyReview',
'languageReview'
]
return (
role.dictionaryId === +dictionaryId &&
role.roleName === 'editor' &&
validDictionaryRoles.includes(roleName) &&
role[roleName]
)
})
}
/**
* Checks if the user has any dictionary role for the specified dictionary.
*
* @param {number} dictionaryId
* @returns {boolean}
*/
function hasAnyDictionaryRole(dictionaryId) {
return this.userRoles.some(role => {
return role.dictionaryId === +dictionaryId && role.roleName === 'editor'
})
}
/**
* Checks if the user is an editor of the speficied consultancy entry.
*
* @param {number} consultancyEntryId
* @returns {boolean}
*/
function isEditorOfConsultancyEntry(consultancyEntryId) {
return this.assignedConsultancyEntries.some(entry => {
return entry.id === +consultancyEntryId
})
}
module.exports = user
+119
View File
@@ -0,0 +1,119 @@
const debug = require('debug')('app:batch')
// const db = require('../models/db')
function update(dbc, report, status, done) {
const reps = JSON.stringify(report)
debug('update', status, reps.length)
if (status === 'D' || status === 'F') {
dbc.query(
'UPDATE batch SET report=$1, job_state=$2, ended=now(), changed=now() WHERE id=$3',
[reps, status, report.id],
err => {
done(err)
}
)
} else {
dbc.query(
'UPDATE batch SET report=$1, changed=now() WHERE id=$2',
[reps, report.id],
err => {
done(err)
}
)
}
}
module.exports = {
Report: function (jobName, threadId) {
this.id = 0
this.name = jobName
this.thread = threadId
this.warnings = 0
this.progress = {
total_percent: 0,
phase_name: '',
phase_percent: 0,
item_name: ''
}
this.trace = [] // here we push messages and errors
},
/**
* initializes batch tracker by adding a record to batch table
* @param job_name name of the job
* @param done (err, BatchId)
*/
init: function (dbc, report, done) {
debug('initializing', report)
dbc.query(
'INSERT INTO batch (job_name, job_state, thread, report) VALUES($1,$2,$3,$4) RETURNING id',
[report.name, 'R', report.thread, JSON.stringify(report)],
(err, result) => {
if (err) return done(err)
debug(result)
report.id = result.rows[0].id
done()
}
)
},
/**
* Close the batch by marking its status as completed and setting end_time.
* @param report object containing full batch report
* @param msg final message
* @param done
*/
finalize: function (dbc, report, msg, done) {
debug('finalizing', msg)
if (msg) report.trace.push(msg)
else report.trace.push('done')
update(dbc, report, 'D', done)
},
/**
* Report on phase. If phase name changes add new phase message to trace.
* @param report object containing full batch report
* @param phase name of phase
* @param percentJob percent (total for job)
* @param done
*/
reportPhase: function (dbc, report, phase, percentJob, done) {
debug('phase', phase, percentJob)
if (report.progress.phase_name !== phase) {
report.trace.push('starting phase ' + phase)
report.progress.phase_percent = 0
report.progress.item_name = ''
}
report.progress.phase_name = phase
report.progress.total_percent = percentJob
update(dbc, report, '', done)
},
/**
* Report on item processed. This is sublevel of phase
* @param report object containing full batch report
* @param item name of item
* @param percentPhase percent of current phase done
* @param done
*/
reportItem: function (dbc, report, item, percentPhase, done) {
debug('item', item, percentPhase)
report.progress.item_name = item
report.progress.phase_percent = percentPhase
update(dbc, report, '', done)
},
/**
* Report error during process and close batch with status Failed and setting end_time.
* @param report object containing full batch report
* @param err the error. If it contains stack it will be included in trace
* @param done
*/
fail: function (dbc, report, err, done) {
debug('fail', err)
if (err.stack) report.trace.push(err.stack)
else if (err.message) report.trace.push(err.message)
else report.trace.push(JSON.stringify(err))
update(dbc, report, 'F', done)
}
}
+18
View File
@@ -0,0 +1,18 @@
const Redis = require('ioredis')
const debug = require('debug')('termPortal:models/cache')
const client = new Redis({ host: 'redis' })
client.on('error', debug)
const isReadyPromise = new Promise(resolve => client.on('ready', resolve))
client.waitForConnection = async () => {
// eslint-disable-next-line no-console
console.log('Waiting for cache to be ready')
await isReadyPromise
// eslint-disable-next-line no-console
console.log('Cache is ready')
}
module.exports = client
+432
View File
@@ -0,0 +1,432 @@
const db = require('./db')
const debug = require('debug')('termPortal:models/comment')
const User = require('../models/user')
class Comment {
// Deserialize flat data into an organized comment object.
constructor({
id,
message,
author_first_name: authorFirstName,
author_last_name: authorLastName,
time_created: timeCreated,
status,
quote_message: quoteMessage,
quote_author_first_name: quoteAuthorFirstName,
quote_author_last_name: quoteAuthorLastName,
quote_time_created: quoteTimeCreated
}) {
this.id = id
this.message = message
this.author = { firstName: authorFirstName, lastName: authorLastName }
this.timeCreated = timeCreated
this.status = status
this.quote = quoteMessage
? {
message: quoteMessage,
author: {
firstName: quoteAuthorFirstName,
lastName: quoteAuthorLastName
},
timeCreated: quoteTimeCreated
}
: null
}
// Fetch comments from DB.
static async list(filters, user, resultsPerPage, page) {
const { ctxType, ctxId } = filters
if (!ctxType) throw Error('Missing context type')
const values = [resultsPerPage, ctxType]
let whereClause = 'WHERE c.context_type = $2'
if (ctxId) {
whereClause += ' AND c.context_id = $3'
values.push(ctxId)
}
const nonModeratedContexts = ['entry_dict_int', 'entry_consult_int']
// In internal/non-moderated contexts, fetch all context specific comments and never show visibility toggles.
// In external/moderated contexts, for users with elevated context related rights, show all comments and show visibility toggles,
// for everyone else, show only visible comments and don't show visibility toggles.
let displayVisibilityToggles = false
if (!nonModeratedContexts.includes(ctxType)) {
let isCommentModerator = false
if (user) {
switch (ctxType) {
case 'portal':
if (user.hasRole('portal admin')) isCommentModerator = true
break
case 'dictionary':
if (
user.hasRole('portal admin') ||
user.hasRole('dictionaries admin')
) {
isCommentModerator = true
}
break
case 'consultancy':
if (
user.hasRole('portal admin') ||
user.hasRole('consultancy admin')
) {
isCommentModerator = true
}
break
case 'entry_dict_ext':
if (
user.hasRole('portal admin') ||
user.hasRole('consultancy admin') ||
user.hasDictionaryRole(ctxId, 'administration')
) {
isCommentModerator = true
}
break
default:
throw Error('Invalid context type')
}
}
if (isCommentModerator) {
displayVisibilityToggles = true
} else {
whereClause += " AND c.status = 'visible'"
}
}
let offsetValue
if (page === 'last') {
offsetValue = `(SELECT (CEIL(COUNT(*) / $1::float) - 1) * $1 FROM comment c ${whereClause})`
} else {
offsetValue = resultsPerPage * (page - 1)
}
const text = `
SELECT jsonb_build_object(
'pages_total', (
SELECT CEIL(COUNT(*) / $1::float)
FROM comment c
${whereClause}
),
'comment_count', (
SELECT COUNT(*)
FROM comment c
${whereClause}
),
'comments', ARRAY(
SELECT jsonb_build_object(
'id', c.id,
'message', c.message,
'timeCreated', c.time_created,
'status', c.status,
'author', (
SELECT jsonb_build_object(
'firstName', cu.first_name,
'lastName', cu.last_name
)
),
'showEye', ${displayVisibilityToggles},
'quote', (
SELECT
CASE
WHEN c.quoted_comment_id IS NULL THEN NULL
ELSE jsonb_build_object(
'message', q.message,
'timeCreated', q.time_created,
'author', (
SELECT jsonb_build_object(
'firstName', qu.first_name,
'lastName', qu.last_name
)
)
)
END
)
)
FROM comment c
LEFT JOIN "user" cu
ON cu.id = c.author_id
LEFT JOIN comment q
ON q.id = c.quoted_comment_id
LEFT JOIN "user" qu
ON qu.id = q.author_id
${whereClause}
ORDER BY c.time_created
LIMIT $1
OFFSET ${offsetValue}
)
) results`
const { rows } = await db.query(text, values)
const { results } = rows[0]
return results
}
// Insert a new comment into DB.
static async create(comment, userId) {
const text =
'INSERT INTO comment (message, author_id, context_type, context_id, quoted_comment_id) VALUES ($1, $2, $3, $4, $5)'
const values = [
comment.message,
userId,
comment.ctxType,
comment.ctxId,
comment.quoteId
]
await db.query(text, values)
}
static async updateStatus(status, id) {
const values = [id, status]
const text = `
UPDATE comment
SET status = $1
WHERE id = $2`
await db.query(text, values)
}
// Insert a new demo comment into DB.
static async createDemo(comment) {
const text =
"INSERT INTO comment (message, author_id, context_type, quoted_comment_id) VALUES ($1, $2, 'portal', $3) RETURNING id"
const values = [comment.message, pickRandomMockUserId(), comment.quoteId]
const { rows } = await db.query(text, values)
const idOfInsertedComment = rows[0].id
const text2 = `${selectAllCommentsQueryString} WHERE c.id = ${idOfInsertedComment}`
const { rows: rows2 } = await db.query(text2)
const insertedComment = rows2[0]
const deserializedComment = new this(insertedComment)
return deserializedComment
}
// Seed DB with <commentCount> random comments.
static async seed(commentCount) {
const seedTasks = []
for (let i = 0; i < commentCount; i++) {
seedTasks.push(this.createDemo({ message: pickRandomMockMessage() }))
}
const seededComments = await Promise.all(seedTasks)
debug(`Successfully seeded ${commentCount} comments`)
debug('Comments:')
seededComments.forEach(comment => debug(comment))
}
// Clear all comments from DB.
static async clear() {
await db.query('TRUNCATE comment')
}
}
// Base SQL query string to fetch all comments.
// Can be extended with a WHEN filter clause.
const selectAllCommentsQueryString = `SELECT
c.id,
c.message,
cu.first_name author_first_name,
cu.last_name author_last_name,
c.time_created,
c.status,
q.message quote_message,
qu.first_name quote_author_first_name,
qu.last_name quote_author_last_name,
q.time_created quote_time_created
FROM comment c
LEFT JOIN "user" cu
ON cu.id = c.author_id
LEFT JOIN comment q
ON q.id = c.quoted_comment_id
LEFT JOIN "user" qu
ON qu.id = q.author_id`
// A list of messages of varying length for DB seeding.
const mockMessageVariations = [
'Lorem ipsum dolor sit amet.',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dolorem obcaecati ut reprehenderit explicabo, adipisci atque! Repudiandae eos facilis veniam modi.',
'Lorem ipsum dolor sit amet consectetur adipisicing elit. Ab dicta error architecto id soluta laborum pariatur saepe doloribus voluptatem voluptas totam placeat, inventore rem! Tempore illum deleniti esse nemo. Amet.',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur tristique at sem eu ultricies. Curabitur cursus efficitur ipsum, et iaculis ipsum egestas vel egestas vestibulum nec odio posuere, mollis diam et, bibendum velit. Proin non velit nec dui luctus dolor.',
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eveniet deleniti ad quasi, ea, recusandae esse autem expedita tempora molestiae ipsa labore magnam dolorem nostrum, corrupti sint obcaecati. Voluptatum molestiae, qui laudantium voluptatibus eius, ratione voluptate, eaque quae alias dicta pariatur?'
]
// A list of users for DB seeding and random asigning to new
// comments until authentication and session mechanism are in place.
const mockUsers = [
{ firstName: 'Primož', lastName: 'Roglič' },
{ firstName: 'Tadej', lastName: 'Pogačar' },
{ firstName: 'Krištof', lastName: 'Kolumb' },
{ firstName: 'Rudolf', lastName: 'Maister' },
{ firstName: 'Ricky', lastName: 'Rickardo' },
{ firstName: 'Freddy', lastName: 'Mercury' },
{ firstName: 'Roger', lastName: 'Moore' },
{ firstName: 'Michael', lastName: 'Jackson' },
{ firstName: 'John', lastName: 'Elton' },
{ firstName: 'Harry', lastName: 'Potter' }
]
function pickRandomMockMessage() {
return mockMessageVariations[
Math.floor(Math.random() * mockMessageVariations.length)
]
}
function pickRandomMockUserId() {
return Math.ceil(Math.random() * mockUsers.length)
}
async function seedMockUsersInDb() {
const { rows } = await db.query('SELECT COUNT(*) user_count FROM "user"')
const userCount = rows[0].user_count
if (+userCount) return 'Users already exist'
await Promise.all(
mockUsers.map(async user => {
const text =
'INSERT INTO "user"(username, first_name, last_name, email, bcrypt_hash) VALUES ($1, $2, $3, $4, \'dummyBcryptHash\') RETURNING *'
const values = [
`${user.firstName}_${user.lastName}`,
user.firstName,
user.lastName,
`${user.firstName}.${user.lastName}@rsdo.com`
]
const { rows } = await db.query(text, values)
const createdUser = rows[0]
debug(`Created user: ${JSON.stringify(createdUser)}`)
})
)
return 'Successfully seeded all users'
}
async function seedPortalAdmin() {
const MOCK_ADMIN_BASE = 'admin'
const {
rows: [mockAdmin]
} = await db.query('SELECT id FROM "user" WHERE username = $1', [
MOCK_ADMIN_BASE
])
if (mockAdmin) {
return `Portal admin already exists (username: ${MOCK_ADMIN_BASE}, password: ${MOCK_ADMIN_BASE})`
}
const adminUser = {
username: MOCK_ADMIN_BASE,
firstName: MOCK_ADMIN_BASE,
lastName: MOCK_ADMIN_BASE,
password: MOCK_ADMIN_BASE,
email: `${MOCK_ADMIN_BASE}@rsdo.com`
}
const userId = await User.create(adminUser)
const assignAdminRole = db.query(
`INSERT INTO user_role (user_id, role_name)
VALUES
($1, 'portal admin'),
($1, 'dictionaries admin'),
($1, 'consultancy admin'),
($1, 'consultant')`,
[userId]
)
const activateAdminUser = db.query(
`UPDATE "user" SET status = 'active', time_activated = time_registered WHERE username = $1`,
[adminUser.username]
)
await Promise.all([assignAdminRole, activateAdminUser])
return `Successfully seeded portal admin (username: ${MOCK_ADMIN_BASE}, password: ${MOCK_ADMIN_BASE})`
}
async function seedConsultants() {
const {
rows: [mockConsultancyAdmin]
} = await db.query('SELECT id FROM "user" WHERE username = $1', ['cadmin'])
if (mockConsultancyAdmin) {
return "Consultants already exist: 'cadmin', 'consultant1', 'consultant2', 'consultant3'"
}
const cadminUser = {
username: 'cadmin',
firstName: 'cadmin',
lastName: 'cadmin',
password: 'cadmin',
email: 'cadmin@rsdo.com'
}
const userId = await User.create(cadminUser)
const assignConsultancyAdminRole = db.query(
`INSERT INTO user_role (user_id, role_name)
VALUES
($1, 'consultancy admin')`,
[userId]
)
const activateConsultancyAdminUser = db.query(
`UPDATE "user" SET status = 'active', time_activated = time_registered WHERE username = $1`,
[cadminUser.username]
)
await Promise.all([assignConsultancyAdminRole, activateConsultancyAdminUser])
for (let i = 1; i <= 3; i++) {
const consultant = {
username: `consultant${i}`,
firstName: `consultant${i}`,
lastName: `consultant${i}`,
password: `consultant${i}`,
email: `consultant${i}@rsdo.com`
}
const userId = await User.create(consultant)
const assignConsultantRole = db.query(
`INSERT INTO user_role (user_id, role_name)
VALUES
($1, 'consultant')`,
[userId]
)
const activateConsultant = db.query(
`UPDATE "user" SET status = 'active', time_activated = time_registered WHERE username = $1`,
[consultant.username]
)
await Promise.all([assignConsultantRole, activateConsultant])
}
return `Successfully seeded consultants 'cadmin', 'consultant1', 'consultant2', 'consultant3'`
}
Comment.seedDummyData = () => {
// Seed DB with mock users on empty DB.
// seedMockUsersInDb()
// .then(debug)
// .catch(err => {
// debug('Users not seeded.')
// debug(err)
// })
// Seed DB with mock portal admin user on empty DB.
// TODO Replace with a more robust solution for production.
seedPortalAdmin()
.then(debug)
.catch(err => {
debug('Portal admin not seeded.')
debug(err)
})
// Seed DB with mock consultancy admin and consultant users on empty DB.
// seedConsultants()
// .then(debug)
// .catch(err => {
// debug('Consultants not seeded.')
// debug(err)
// })
}
module.exports = Comment
+754
View File
@@ -0,0 +1,754 @@
const db = require('./db')
const {
searchEngineClient,
deleteConsultancyEntriesFromIndex,
CONSULTANCY_ENTRY_INDEX
} = require('./search-engine')
const { removeHtmlTags } = require('./helpers')
class ConsultancyEntry {
constructor({
id,
id_external: idExternal,
time_created: timeCreated,
status,
author_id: authorId,
institution,
description,
domain_primary_id_initial: domainPrimaryIdInitial,
existing_solutions: existingSolutions,
examples_of_use: examplesOfUse,
time_published: timePublished,
title,
question,
answer,
path,
answer_authors: answerAuthors,
domain_primary_id: domainPrimaryId,
first_name: firstName,
last_name: lastName,
is_moderator: isModerator,
formatted_time_created: formattedTimeCreated,
formatted_time_published: formattedTimePublished
}) {
this.id = id
this.idExternal = idExternal
this.timeCreated = timeCreated
this.status = status
this.authorId = authorId
this.institution = institution
this.description = description
this.domainPrimaryIdInitial = domainPrimaryIdInitial
this.existingSolutions = existingSolutions
this.examplesOfUse = examplesOfUse
this.timePublished = timePublished
this.title = title
this.question = question
this.answer = answer
this.path = path
this.answerAuthors = answerAuthors
this.domainPrimaryId = domainPrimaryId
this.firstName = firstName
this.lastName = lastName
this.isModerator = isModerator
this.formattedTimeCreated = formattedTimeCreated
this.formattedTimePublished = formattedTimePublished
}
// Fetch all consultancy entries from DB.
static async fetchAll() {
// TODO Luka: Miha, define specific fields instead of using *.
const { rows: fetchedConsEntries } = await db.query(`
SELECT *
FROM consultancy_entry
ORDER BY time_created DESC`)
const deserializedConsEntries = fetchedConsEntries.map(
consEntry => new this(consEntry)
)
return deserializedConsEntries
}
// Fetch consultancy entry by ID
// to_char(time_created,'HH24:MI:SS DD/MM/YYYY')
static async fetchByIdWithFormattedTime(id) {
const { rows: fetchedConsEntry } = await db.query(
`
SELECT
id,
id_external,
to_char(time_created,'DD. MM. YYYY') time_created,
status,
author_id,
institution,
description,
domain_primary_id_initial,
existing_solutions,
examples_of_use,
to_char(time_published,'DD. MM. YYYY') time_published,
title,
question,
answer,
path,
answer_authors,
domain_primary_id
FROM consultancy_entry
WHERE id=$1`,
[id]
)
return new this(fetchedConsEntry[0])
}
static async fetchById(id) {
const { rows: fetchedConsEntry } = await db.query(
`
SELECT
id,
id_external,
to_char(time_created, 'DD. MM. YYYY') time_created,
status,
author_id,
institution,
description,
domain_primary_id_initial,
existing_solutions,
examples_of_use,
to_char(time_published,'DD. MM. YYYY') time_published,
title,
question,
answer,
path,
answer_authors,
domain_primary_id
FROM consultancy_entry
WHERE id=$1`,
[id]
)
return new this(fetchedConsEntry[0])
}
// Fetch dictionaries admin emails.
static async fetchConsultancyAdminEmails() {
const query = {
text: `
SELECT email FROM "user" WHERE id
IN(SELECT user_id FROM user_role
WHERE role_name = 'consultancy admin')`,
rowMode: 'array'
}
const { rows: users } = await db.query(query)
const emails = users.flat()
return emails
}
static async fetchModeratorEmail(entryId) {
const query = {
text: `
SELECT email FROM "user" WHERE id
IN (SELECT user_id FROM consultancy_entry_consultant cec
WHERE cec.entry_id=$1 AND cec.is_moderator=true)`,
rowMode: 'array'
}
const { rows: users } = await db.query(query, [entryId])
const emails = users.flat()
return emails
}
// Fetch all consultancy entries filtered by status from DB.
static async fetchAllByStatus(status) {
// TODO Luka: Miha, define specific fields instead of using *.
const sqlQuery = `
SELECT *, to_char(time_created, 'FMDD. FMMM. YYYY') formatted_time_created
FROM consultancy_entry
WHERE status=$1
ORDER BY time_created DESC`
const values = [status]
const { rows: fetchedConsEntries } = await db.query(sqlQuery, values)
const deserializedConsEntries = fetchedConsEntries.map(
consEntry => new this(consEntry)
)
return deserializedConsEntries
}
// Fetch all consultancy entries filtered by status from DB.
static async fetchAllByStatusCount(status) {
// TODO Luka: Miha, define specific fields instead of using *.
const sqlQuery = `
SELECT COUNT(id)
FROM consultancy_entry
WHERE status=$1`
const values = [status]
const { rows } = await db.query(sqlQuery, values)
return rows[0].count
}
static async fetchWithStatusByIdCount(status, id) {
const sqlQuery = `
SELECT COUNT(DISTINCT(ce.id))
FROM "consultancy_entry" ce
INNER JOIN "consultancy_entry_consultant" cec ON ce.id = cec.entry_id
INNER JOIN "user" u ON u.id = cec.user_id
WHERE ce.status=$1 and cec.user_id=$2`
const values = [status, id]
const { rows } = await db.query(sqlQuery, values)
return rows[0].count
}
static async fetchWithStatusById(status, id) {
const sqlQuery = `
SELECT ce.id,
to_char(time_created, 'FMDD. FMMM. YYYY') formatted_time_created,
author_id,
institution,
description,
domain_primary_id_initial,
to_char(time_published, 'FMDD. FMMM. YYYY') time_published,
title,
question,
answer,
path,
answer_authors,
domain_primary_id,
first_name,
last_name,
cec.is_moderator
FROM "consultancy_entry" ce
INNER JOIN "consultancy_entry_consultant" cec ON ce.id = cec.entry_id
INNER JOIN "user" u ON u.id = cec.user_id
WHERE ce.status=$1 and cec.user_id=$2
ORDER BY time_created DESC`
/* filter them in pug by displaying moderator, and showing authors
of non moderator */
/* AND cec.is_moderator='true';` */
const values = [status, id]
const { rows: fetchedConsEntries } = await db.query(sqlQuery, values)
const deserializedConsEntries = fetchedConsEntries.map(
consEntry => new this(consEntry)
)
return deserializedConsEntries
}
static async fetchInProgressById(id) {
return await this.fetchWithStatusById('in progress', id)
}
static async fetchPublishedById(id) {
return await this.fetchWithStatusById('published', id)
}
// Fetch entry count.
static async fetchAllByStatusWithAuthorDataCount(status) {
const sqlQuery = `
SELECT COUNT(DISTINCT(ce.id))
FROM "consultancy_entry" ce
INNER JOIN "consultancy_entry_consultant" cec ON ce.id = cec.entry_id
INNER JOIN "user" u ON u.id = cec.user_id
WHERE ce.status=$1`
/* filter them in pug by displaying moderator, and showing authors
of non moderator */
/* AND cec.is_moderator='true';` */
const values = [status]
const { rows } = await db.query(sqlQuery, values)
await db.query(sqlQuery, values)
return rows[0].count
}
// Fetch all consultancy entries filtered by status from DB.
static async fetchAllByStatusWithAuthorData(status) {
const sqlQuery = `
SELECT ce.id,
to_char(time_created, 'FMDD. FMMM. YYYY') formatted_time_created,
author_id,
institution,
description,
domain_primary_id_initial,
to_char(time_published, 'FMDD. FMMM. YYYY') time_published,
title,
question,
answer,
path,
answer_authors,
domain_primary_id,
first_name,
last_name,
cec.is_moderator
FROM "consultancy_entry" ce
INNER JOIN "consultancy_entry_consultant" cec ON ce.id = cec.entry_id
INNER JOIN "user" u ON u.id = cec.user_id
WHERE ce.status=$1
ORDER BY time_created DESC`
/* filter them in pug by displaying moderator, and showing authors
of non moderator */
/* AND cec.is_moderator='true';` */
const values = [status]
const { rows: fetchedConsEntries } = await db.query(sqlQuery, values)
const deserializedConsEntries = fetchedConsEntries.map(
consEntry => new this(consEntry)
)
return deserializedConsEntries
}
// Fetch all new consultancy entries from DB.
static async fetchAllNew() {
const newEntries = await this.fetchAllByStatus('new')
return newEntries
}
static async fetchAllRejected() {
const newEntries = await this.fetchAllByStatusWithAuthorData('rejected')
return newEntries
}
static async fetchAllInProgress() {
const newEntries = await this.fetchAllByStatusWithAuthorData('in progress')
return newEntries
}
static async fetchAllPrepared() {
const newEntries = await this.fetchAllByStatusWithAuthorData('review')
return newEntries
}
static async fetchAllPublished() {
const newEntries = await this.fetchAllByStatusWithAuthorData('published')
return newEntries
}
static async fetch5MostRecentPublished() {
const sqlQuery = `
SELECT ce.id,
to_char(time_created, 'FMDD. FMMM. YYYY') formatted_time_created,
author_id,
institution,
description,
domain_primary_id_initial,
to_char(time_published, 'FMDD. FMMM. YYYY') time_published,
title,
question,
answer,
path,
answer_authors,
domain_primary_id,
first_name,
last_name,
cec.is_moderator
FROM "consultancy_entry" ce
INNER JOIN "consultancy_entry_consultant" cec ON ce.id = cec.entry_id
INNER JOIN "user" u ON u.id = cec.user_id
WHERE ce.status=$1
ORDER BY time_published DESC
LIMIT 5`
/* filter them in pug by displaying moderator, and showing authors
of non moderator */
/* AND cec.is_moderator='true';` */
const values = ['published']
const { rows: fetchedConsEntries } = await db.query(sqlQuery, values)
const deserializedConsEntries = fetchedConsEntries.map(
consEntry => new this(consEntry)
)
return deserializedConsEntries
}
static async fetchPublishedCount() {
const sqlQuery = `
SELECT COUNT(DISTINCT(ce.id))
FROM "consultancy_entry" ce
INNER JOIN "consultancy_entry_consultant" cec ON ce.id = cec.entry_id
INNER JOIN "user" u ON u.id = cec.user_id
WHERE ce.status=$1`
/* filter them in pug by displaying moderator, and showing authors
of non moderator */
/* AND cec.is_moderator='true';` */
const values = ['published']
const { rows } = await db.query(sqlQuery, values)
return rows[0].count
}
/*
static async fetchAuthorsFirstAndLastName(entryId, userId) {
const sqlQuery = `
SELECT u.first_name u.last_name
FROM "user" u INNER JOIN "consultancy_entry_consultant" cec ON u.id = cec.user_id
WHERE cec.entry_id=$1 and cec.user_id=$2`
const values = [entryId, userId]
const { rows: fetchedConsEntries } = await db.query(sqlQuery, values)
return fetchedConsEntries
}
*/
/* Get shared authors in progress,
answer_authors only get published when the question is published */
static async getSharedAuthorsArrayBeforePublish(entryId) {
const sqlQuery = `SELECT cec.entry_id, cec.user_id,
u.first_name, u.last_name, u.username
FROM "consultancy_entry_consultant" cec INNER JOIN
"user" u ON u.id = cec.user_id
WHERE cec.entry_id=$1 AND cec.is_moderator='false';`
const values = [entryId]
const { rows } = await db.query(sqlQuery, values)
return rows
}
static async getModerator(entryId) {
const sqlQuery = `
SELECT u.id, u.first_name, u.last_name
FROM "consultancy_entry" ce
INNER JOIN "consultancy_entry_consultant" cec ON ce.id = cec.entry_id
INNER JOIN "user" u ON u.id = cec.user_id
WHERE ce.id=$1 AND cec.is_moderator=true;`
const values = [entryId]
const { rows } = await db.query(sqlQuery, values)
return rows[0]
}
static async getEditors(entryId) {
const sqlQuery = `
SELECT u.id, u.first_name, u.last_name
FROM "consultancy_entry" ce
INNER JOIN "consultancy_entry_consultant" cec ON ce.id = cec.entry_id
INNER JOIN "user" u ON u.id = cec.user_id
WHERE ce.id=$1`
const values = [entryId]
const { rows } = await db.query(sqlQuery, values)
return rows
}
static async getSharedAuthorsArray(entryId) {
const sqlQuery = `SELECT answer_authors FROM "consultancy_entry"
WHERE id=$1;`
const values = [entryId]
const { rows } = await db.query(sqlQuery, values)
return rows[0]
}
static async updateSharedAuthorsArray(entryId, authors) {
const sqlQuery = `UPDATE "consultancy_entry" SET answer_authors=$2
WHERE id=$1;`
const values = [entryId, authors]
await db.query(sqlQuery, values)
}
static async createQuestion(consultancyEntry) {
const sqlQuery = `INSERT INTO consultancy_entry (
status,
author_id,
institution,
description,
domain_primary_id_initial,
existing_solutions,
examples_of_use
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7
)
RETURNING id;`
const values = [
consultancyEntry.status,
consultancyEntry.authorId,
consultancyEntry.institution,
consultancyEntry.description,
consultancyEntry.domainPrimaryIdInitial || null,
consultancyEntry.existingSolutions,
consultancyEntry.examplesOfUse
]
const {
rows: [{ id }]
} = await db.query(sqlQuery, values)
return id
}
static async updateQuestion(consultancyEntry) {
await db.transaction(async dbClient => {
const sqlQuery = `UPDATE consultancy_entry SET
id_external=$2,
status=$3,
author_id=$4,
institution=$5,
description=$6,
domain_primary_id_initial=$7,
existing_solutions=$8,
examples_of_use=$9,
title=$10,
question=$11,
answer=$12,
path=$13,
answer_authors=$14,
domain_primary_id=$15
WHERE id=$1;`
const values = [
consultancyEntry.id,
consultancyEntry.idExternal,
consultancyEntry.status,
consultancyEntry.authorId,
consultancyEntry.institution,
consultancyEntry.description,
consultancyEntry.domainPrimaryIdInitial,
consultancyEntry.existingSolutions,
consultancyEntry.examplesOfUse,
consultancyEntry.title,
consultancyEntry.question,
consultancyEntry.answer,
consultancyEntry.path,
consultancyEntry.answerAuthors,
consultancyEntry.domainPrimaryId
]
await dbClient.query(sqlQuery, values)
await dbClient.query(
`UPDATE "consultancy_entry" SET status=$2
WHERE id=$1;`,
[consultancyEntry.id, 'in progress']
)
})
}
static async deleteQuestion(id) {
const sqlQuery = `DELETE FROM consultancy_entry
WHERE id=$1;`
const values = [id]
await db.query(sqlQuery, values)
}
/** refactor in case you need both moderator and non moderator
* insertions as a common function
*/
static async insertNonModerator(entryId, userId) {
const sqlQuery = `INSERT INTO "consultancy_entry_consultant" (entry_id, user_id, is_moderator) VALUES
($1, $2, 'false');
`
const values = [entryId, userId]
const { rows } = await db.query(sqlQuery, values)
return rows
}
static async assignWorkInProgress(entryId, userId) {
await db.transaction(async dbClient => {
let sqlQuery = `SELECT entry_id FROM consultancy_entry_consultant
WHERE entry_id=$1 AND is_moderator='true'`
let values = [entryId]
const { rows } = await dbClient.query(sqlQuery, values)
if (rows.length) {
await dbClient.query(
`DELETE FROM consultancy_entry_consultant
WHERE entry_id=$1 AND is_moderator='true'`,
values
)
}
sqlQuery = [
`UPDATE "consultancy_entry" SET status='in progress'
WHERE id=$1;`,
`INSERT INTO "consultancy_entry_consultant" (entry_id, user_id, is_moderator) VALUES
($1, $2, 'true');
`
]
values = [[entryId], [entryId, userId]]
for (let i = 0; i < sqlQuery.length; i++) {
await dbClient.query(sqlQuery[i], values[i])
}
})
}
static async updateConsultancyEntryStatus(entryId, status) {
const sqlQuery = `UPDATE "consultancy_entry" SET status=$2
WHERE id=$1;`
const values = [entryId, status]
await db.query(sqlQuery, values)
}
static async rejectEntry(entryId) {
await this.updateConsultancyEntryStatus(entryId, 'rejected')
}
static async sendToReview(entryId) {
await this.updateConsultancyEntryStatus(entryId, 'review')
}
static async publish(entryId, answerAuthors) {
await db.transaction(async dbClient => {
await dbClient.query(
`UPDATE "consultancy_entry" SET status=$2
WHERE id=$1;`,
[entryId, 'published']
)
if (answerAuthors) {
answerAuthors = answerAuthors.split(',').filter(author => author !== '')
await dbClient.query(
`UPDATE "consultancy_entry"
SET answer_authors=$2
WHERE id=$1;`,
[entryId, answerAuthors]
)
}
await dbClient.query(
`UPDATE "consultancy_entry"
SET time_published=CURRENT_TIMESTAMP
WHERE id=$1;`,
[entryId]
)
})
}
static async removeConsultantForEntry(entryId, userId) {
// TODO check for any other tables where entry id is foreign key
await db.query(
`DELETE FROM consultancy_entry_consultant
WHERE entry_id=$1 AND user_id=$2`,
[entryId, userId]
)
}
static async removeEntry(id) {
// TODO check for any other tables where entry id is foreign key
await db.transaction(async dbClient => {
await dbClient.query(
`DELETE FROM consultancy_entry_consultant
WHERE entry_id=$1`,
[id]
)
await dbClient.query(
`DELETE FROM consultancy_entry
WHERE id=$1`,
[id]
)
})
}
// (Re)index specific consultancy entry into consultancy search index.
static async indexIntoSearchEngine(entryId, shouldWait) {
const values = [entryId]
const text = `
SELECT
jsonb_strip_nulls(
jsonb_build_object(
'id', ce.id,
'timeCreated', ce.time_created,
'status', ce.status,
'description', ce.description,
'title', ce.title,
'question', ce.question,
'answer', ce.answer,
'answerAuthors', ce.answer_authors,
'primaryDomain', jsonb_strip_nulls(
jsonb_build_object(
'id', dp.id,
'nameSl', dp.name_sl,
'nameEn', dp.name_en
)
),
'assignedConsultants', ARRAY(
SELECT jsonb_strip_nulls(
jsonb_build_object(
'id', u.id,
'firstName', u.first_name,
'lastName', u.last_name,
'isModerator', cec.is_moderator
)
)
FROM consultancy_entry_consultant cec
LEFT JOIN "user" u on u.id = cec.user_id
WHERE cec.entry_id = ce.id
ORDER BY is_moderator DESC
)
)
) entry
FROM consultancy_entry ce
LEFT JOIN domain_primary dp on dp.id = ce.domain_primary_id
WHERE ce.id = $1`
let {
rows: [{ entry }]
} = await db.query(text, values)
if (!entry.answerAuthors?.length) delete entry.answerAuthors
if (!Object.keys(entry.primaryDomain).length) delete entry.primaryDomain
if (!entry.assignedConsultants.length) delete entry.assignedConsultants
entry = removeHtmlTags(JSON.stringify(entry))
await searchEngineClient.index({
id: entryId,
index: CONSULTANCY_ENTRY_INDEX,
body: entry,
refresh: shouldWait ? 'wait_for' : false
})
}
// Remove all consultancy entries from index and index them all again.
static async reindexAll() {
await deleteConsultancyEntriesFromIndex()
const { rows } = await db.query('SELECT id FROM consultancy_entry')
for (const row of rows) {
const { id: entryId } = row
await this.indexIntoSearchEngine(entryId)
}
}
}
module.exports = ConsultancyEntry
+65
View File
@@ -0,0 +1,65 @@
const { Pool, Client } = require('pg')
const debug = require('debug')('termPortal:models/db')
const pool = new Pool()
pool.on('error', (err, client) => {
debug('Error on DB pool idle client:')
debug({ err, client })
})
exports.query = (text, params) => pool.query(text, params)
exports.getClient = () => pool.connect()
// Returns a new client from outside the pool.
exports.getExtraClient = () => {
const client = new Client()
client.connect()
return client
}
exports.transaction = async queriesFn => {
const client = await pool.connect()
try {
await client.query('BEGIN')
await queriesFn(client)
await client.query('COMMIT')
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally {
client.release()
}
}
exports.genParamStr = paramArr => {
const paramPlaceholderString = paramArr.reduce((str, param, index) => {
if (index) str += ', '
str += `$${index + 1}`
return str
}, '')
return paramPlaceholderString
}
exports.waitForConnection = () => {
return new Promise(resolve => {
async function testConnection() {
try {
// eslint-disable-next-line no-console
console.log('Verifying connection to DB server')
await pool.query('SELECT 1')
// eslint-disable-next-line no-console
console.log('Connection to DB server verified')
resolve()
} catch {
// eslint-disable-next-line no-console
console.error('Could not connect to DB server')
setTimeout(testConnection, 1000)
}
}
testConnection()
})
}
+49
View File
@@ -0,0 +1,49 @@
const db = require('./db')
const DemoPaginacija = {}
// Metoda za generacijo demo podatkov.
DemoPaginacija.initDemoData = async () => {
await db.query(`
CREATE TABLE IF NOT EXISTS demo_paginacija (zanimivo TEXT, nezanimivo1 TEXT, nezanimivo2 TEXT);
DO $$
BEGIN
IF (SELECT COUNT(*) FROM demo_paginacija) = 0 THEN
FOR stevec IN 1..9993 LOOP
INSERT INTO demo_paginacija VALUES ('vrednost' || stevec, 'brezveze', 'tega res ne rabimo');
END LOOP;
END IF;
END $$`)
}
// Metoda za poizvedbo demo podatkov za določeno stran.
DemoPaginacija.fetch = async (resultsPerPage, page) => {
const {
rows: [{ result }]
} = await db.query(
`
SELECT jsonb_build_object(
'pages_total', (
SELECT CEIL(COUNT(*) / $1::float)
FROM demo_paginacija
),
'results', ARRAY(
SELECT jsonb_build_object(
'zanimivo', zanimivo,
'nezanimivo1', nezanimivo1,
'nezanimivo2', nezanimivo2
)
FROM demo_paginacija
LIMIT $1
OFFSET $2
)
) result
`,
[resultsPerPage, resultsPerPage * (page - 1)]
)
return result
}
module.exports = DemoPaginacija
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
// TODO Possible duplicate methods in Dictionary model.
// TODO Ambiguous model name. There are also secondary domains, domain labels, ...
const db = require('./db')
class Domain {
constructor({
id,
name_sl: nameSl,
name_en: nameEn,
udk_code: udkCode,
cerif_name: cerifName,
cerif_code: cerifCode,
eurovoc_name: eurovocName,
eurovoc_code: eurovocNode
}) {
this.id = id
this.nameSl = nameSl
this.nameEn = nameEn
this.udkCode = udkCode
this.cerifName = cerifName
this.cerifCode = cerifCode
this.eurovocName = eurovocName
this.eurovocNode = eurovocNode
}
// Fetch all consultancy entries from DB.
static async fetchAll() {
// TODO Luka: Miha, define specific fields instead of using *.
const { rows: fetchedConsEntries } = await db.query(`
SELECT *
FROM domain_primary`)
const domains = fetchedConsEntries.map(domain => new this(domain))
return domains
}
// Fetch domain by ID.
static async fetchById(id) {
// TODO Luka: Miha, define specific fields instead of using *.
const { rows: domainEntity } = await db.query(
`
SELECT *
FROM domain_primary
WHERE id=$1`,
[id]
)
const domain = new this(domainEntity[0])
return domain
}
// Fetch domain by name.
static async fetchByName(name) {
// TODO Luka: Miha, define specific fields instead of using *.
const { rows: domainEntity } = await db.query(
`
SELECT *
FROM domain_primary
WHERE name_sl=$1`,
[name]
)
const domain = new this(domainEntity[0])
return domain
}
// Fetch primary domain id by udk code.
static async fetchIdByUdkCode(udkCode) {
const {
rows: [{ id }]
} = await db.query('SELECT id FROM domain_primary WHERE udk_code = $1', [
udkCode
])
return id
}
}
module.exports = Domain
+40
View File
@@ -0,0 +1,40 @@
const nodemailer = require('nodemailer')
const htmlToText = require('nodemailer-html-to-text').htmlToText()
const {
smtpHost,
smtpPort,
smtpTlsRejectUnauthorized,
smtpFrom
} = require('../config/keys')
const options = {
host: smtpHost,
port: smtpPort,
tls: { rejectUnauthorized: smtpTlsRejectUnauthorized }
}
const defaults = { from: smtpFrom }
const transporter = nodemailer.createTransport(options, defaults)
transporter.use('compile', htmlToText)
exports.send = data => transporter.sendMail(data)
exports.waitForConnection = () => {
return new Promise(resolve => {
async function testConnection() {
try {
// eslint-disable-next-line no-console
console.log('Verifying connection to SMTP server')
await transporter.verify()
// eslint-disable-next-line no-console
console.log('Connection to SMTP server verified')
resolve()
} catch {
// eslint-disable-next-line no-console
console.error('Could not connect to SMTP server')
setTimeout(testConnection, 1000)
}
}
testConnection()
})
}
+650
View File
@@ -0,0 +1,650 @@
const db = require('./db')
const { searchEngineClient, ENTRY_INDEX } = require('./search-engine')
const { intoDbArray, getInstanceSetting, removeHtmlTags } = require('./helpers')
const { prepareEntryForIndexing } = require('./helpers/dictionary')
const Entry = {}
// Create a new dictionary entry in DB.
Entry.create = async (userId, dictionaryId, entry) => {
const pickedLinks = intoDbArray(entry.links, 'always')
const pickedType = intoDbArray(entry.type, 'always')
const links = pickedLinks.map((link, index) => ({
link,
type: pickedType[index]
}))
const foreign = intoDbArray(entry.foreign, 'always')
const foreignLanguageContent = foreign.reduce((agg, row) => {
if (row.term || row.definition || row.synonym) {
agg.push({
language: row.code,
terms: intoDbArray(row.term, 'undefined'),
definition: row.definition || null,
synonyms: intoDbArray(row.synonym, 'undefined')
})
}
return agg
}, [])
const isValid =
!!entry.term &&
(!!entry.definition || foreignLanguageContent.some(el => el.terms))
const values = [
dictionaryId,
isValid,
entry.status,
entry.term || null,
userId,
entry.homonymSort || null,
entry.wordforms || null,
entry.accent || null,
entry.pronunciation,
intoDbArray(entry.domainLabels, 'always'),
entry.label || null,
entry.definition || null,
intoDbArray(entry.synonyms),
links,
entry.other || null,
foreignLanguageContent,
intoDbArray(entry.image),
intoDbArray(entry.audio),
intoDbArray(entry.video)
]
const text = `SELECT entry_new (${db.genParamStr(values)})`
const {
rows: [{ entry_new: entryId }]
} = await db.query(text, values)
return entryId
}
// // Fetch all entry terms of a single dictionary from DB.
// Entry.fetchAll = async dictionaryId => {
// const text = `
// SELECT
// e.id,
// e.is_valid as valid,
// e.is_published as published,
// e.term as term,
// MAX(ef.term) as fterm,
// CASE
// WHEN NOW() - e.time_most_recent_comment < INTERVAL '1 week' THEN 'T'
// WHEN NOW() - e.time_most_recent_comment < INTERVAL '1 month' THEN 'M'
// WHEN NOW() - e.time_most_recent_comment < INTERVAL '1 year' THEN 'L'
// ELSE ''
// END comment_age
// FROM entry e
// LEFT JOIN entry_foreign ef ON e.id = ef.entry_id
// WHERE dictionary_id = $1
// GROUP BY id, is_valid, is_published, e.term, comment_age
// ORDER BY e.term`
// const value = [dictionaryId]
// const { rows: fetchedTerms } = await db.query(text, value)
// return fetchedTerms
// }
// Metoda za poizvedbo demo podatkov za določeno stran.
// Entry.fetchPaginated = async (resultsPerPage, page) => {
// const {
// rows: [{ result }]
// } = await db.query(
// `
// SELECT jsonb_build_object(
// 'pages_total', (
// SELECT CEIL(COUNT(*) / $1::float)
// FROM demo_paginacija
// ),
// 'results', ARRAY(
// SELECT jsonb_build_object(
// dictionary_id,
// term,
// is_published,
// is_terminology_reviewed,
// is_language_reviewed,
// status,
// label,
// definition,
// synonym,
// other,
// image,
// audio,
// video
// )
// FROM entry
// LIMIT $1
// OFFSET $2
// )
// ) result
// `,
// [resultsPerPage, resultsPerPage * (page - 1)]
// )
// return result
// }
// Fetch all data, related to single entry from DB.
Entry.fetchFull = async entryId => {
const text = `
SELECT
jsonb_strip_nulls(
jsonb_build_object(
'dictionary_id', e.dictionary_id,
'is_valid', e.is_valid,
'is_published', e.is_published,
'is_terminology_reviewed', e.is_terminology_reviewed,
'is_language_reviewed', e.is_language_reviewed,
'status', e.status,
'term', e.term,
'version', e.version,
'version_author', (
SELECT username
FROM "user" u
LEFT JOIN entry e ON e.version_author = u.id
WHERE e.id = $1
),
'homonym_sort', e.homonym_sort,
'label', e.label,
'definition', e.definition,
'synonyms', e.synonym,
'other', e.other,
'image', e.image,
'audio', e.audio,
'video', e.video,
'time_modified', e.time_modified,
'domain_labels', ARRAY(
SELECT name
FROM entry_domain_label edl
LEFT JOIN domain_label dl ON dl.id = edl.domain_label_id
WHERE entry_id = e.id
),
'links', ARRAY(
SELECT jsonb_build_object(
'type', type,
'link', link
)
FROM entry_link
WHERE entry_id = e.id
),
'foreign_entries', ARRAY(
SELECT jsonb_strip_nulls(
jsonb_build_object(
'language_id', language_id,
'term', term,
'definition', definition,
'synonym', synonym
)
)
FROM entry_foreign
WHERE entry_id = e.id
),
'versions', ARRAY(
SELECT jsonb_strip_nulls(
jsonb_build_object(
'version', version,
'version_time', version_time
)
)
FROM entry_version_history
WHERE entry_id = e.id
)
)
) entry
FROM entry e
WHERE e.id = $1`
const value = [entryId]
const {
rows: [{ entry }]
} = await db.query(text, value)
return entry
}
// Fetch all data, related to single entry from DB with ordered foreign languages.
Entry.fetchFullWithOrderedForeignLanguages = async entryId => {
const text = `
SELECT
jsonb_strip_nulls(
jsonb_build_object(
'dictionary_id', e.dictionary_id,
'is_valid', e.is_valid,
'is_published', e.is_published,
'is_terminology_reviewed', e.is_terminology_reviewed,
'is_language_reviewed', e.is_language_reviewed,
'status', e.status,
'term', e.term,
'synonym', e.synonym,
'version', e.version,
'version_author', (
SELECT username
FROM "user" u
LEFT JOIN entry e ON e.version_author = u.id
WHERE e.id = $1
),
'homonym_sort', e.homonym_sort,
'label', e.label,
'definition', e.definition,
'synonyms', e.synonym,
'other', e.other,
'image', e.image,
'audio', e.audio,
'video', e.video,
'time_modified', e.time_modified,
'domain_labels', ARRAY(
SELECT name
FROM entry_domain_label edl
LEFT JOIN domain_label dl ON dl.id = edl.domain_label_id
WHERE entry_id = e.id
),
'links', ARRAY(
SELECT jsonb_build_object(
'type', type,
'link', link
)
FROM entry_link
WHERE entry_id = e.id
),
'foreign_entries', ARRAY(
SELECT jsonb_strip_nulls(
jsonb_build_object(
'language_id', ef.language_id,
'term', ef.term,
'definition', ef.definition,
'synonym', ef.synonym
)
)
FROM entry e
LEFT JOIN entry_foreign ef on ef.entry_id = e.id
LEFT JOIN dictionary_language dl on dl.dictionary_id = e.dictionary_id AND ef.language_id = dl.language_id
LEFT JOIN language l on l.id = ef.language_id
WHERE entry_id = $1
ORDER BY dl.selection_order
),
'versions', ARRAY(
SELECT jsonb_strip_nulls(
jsonb_build_object(
'version', version,
'version_time', version_time
)
)
FROM entry_version_history
WHERE entry_id = e.id
)
)
) entry
FROM entry e
WHERE e.id = $1`
const value = [entryId]
const {
rows: [{ entry }]
} = await db.query(text, value)
return entry
}
// Fetch single entry data from DB.
Entry.fetch = async entryId => {
const text = `
SELECT
dictionary_id,
term,
is_published,
is_terminology_reviewed,
is_language_reviewed,
homonym_sort,
status,
label,
definition,
synonym,
other,
image,
audio,
video
FROM entry
WHERE id = $1`
const value = [entryId]
const { rows: fetchedEntryData } = await db.query(text, value)
return fetchedEntryData[0]
}
// Fetch domain labels associated with a single entry from DB.
Entry.fetchDomainLabels = async entryId => {
const text = `
SELECT
dl.id,
dl.dictionary_id,
dl.name
FROM entry e
INNER JOIN entry_domain_label edl ON e.id = edl.entry_id
INNER JOIN domain_label dl ON edl.domain_label_id = dl.id
WHERE e.id = $1`
const value = [entryId]
const { rows: fetchedDomainLabels } = await db.query(text, value)
return fetchedDomainLabels
}
// Fetch foreign content associated with a single entry from DB.
Entry.fetchForeign = async entryId => {
const text = `
SELECT term, language_id, definition, synonym
FROM entry_foreign
WHERE entry_id=$1`
const value = [entryId]
const { rows: fetchedEntries } = await db.query(text, value)
return fetchedEntries
}
// Redundant. Use getInstanceSetting function instead.
// Entry.fetchEditingPhase = async () => {
// const text = `
// SELECT
// name, value
// FROM
// instance_settings
// WHERE
// name = 'can_publish_entries_in_edit'
// `
// const { rows: fetchedPhase } = await db.query(text)
// const aggregatedSettings = aggregateSettings(fetchedPhase)
// const deserializedSettings = deserialize.dictSettings(aggregatedSettings)
// return deserializedSettings
// }
// Delete foreign content of a single entry from DB.
Entry.deleteForeign = async entryId => {
const text = 'DELETE FROM entry_foreign WHERE entry_id = $1'
const value = [entryId]
await db.query(text, value)
}
// Delete domain label associations with a single entry from DB.
Entry.deleteDomainLabels = async entryId => {
const text = 'DELETE FROM entry_domain_label WHERE entry_id = $1'
const value = [entryId]
await db.query(text, value)
}
// Delete links of a single entry from DB.
Entry.deleteLinks = async entryId => {
const text = 'DELETE FROM entry_link WHERE entry_id = $1'
const value = [entryId]
await db.query(text, value)
}
// Delete single entry from DB.
Entry.delete = async entryId => {
const text = 'DELETE FROM entry WHERE id = $1 RETURNING dictionary_id'
const value = [entryId]
const {
rows: [{ dictionary_id: dictionaryId }]
} = await db.query(text, value)
return dictionaryId
}
// Delete foreign content for selected dictionary from DB.
Entry.deleteAllForeign = async dictionaryId => {
const text = `
DELETE FROM entry_foreign
WHERE entry_id
IN (SELECT id
FROM entry
WHERE dictionary_id = $1)`
const value = [dictionaryId]
await db.query(text, value)
}
// Delete all domain label associations for selected dictionary from DB.
Entry.deleteAllDomainLabels = async dictionaryId => {
const text = `
DELETE FROM entry_domain_label
WHERE entry_id
IN (SELECT id
FROM entry
WHERE dictionary_id = $1)`
const value = [dictionaryId]
await db.query(text, value)
}
// Delete all links for selected dictionary.
Entry.deleteAllLinks = async dictionaryId => {
const text = `
DELETE FROM entry_link
WHERE entry_id
IN (SELECT id
FROM entry
WHERE dictionary_id = $1)`
const value = [dictionaryId]
await db.query(text, value)
}
// (Re)index specific entry into entry search index.
Entry.indexIntoSearchEngine = async (entryId, shouldWait) => {
const values = [entryId]
const text = `
SELECT
jsonb_strip_nulls(
jsonb_build_object(
'id', e.id,
'is_valid', e.is_valid,
'is_published', e.is_published,
'is_terminology_reviewed', e.is_terminology_reviewed,
'is_language_reviewed', e.is_language_reviewed,
'status', e.status,
'term', e.term,
'homonym_sort', e.homonym_sort,
'label', e.label,
'definition', e.definition,
'synonyms', e.synonym,
'other', e.other,
'time_most_recent_comment', e.time_most_recent_comment,
'domain_labels', ARRAY(
SELECT name
FROM entry_domain_label edl
LEFT JOIN domain_label dl ON dl.id = edl.domain_label_id
WHERE entry_id = e.id
),
'links', ARRAY(
SELECT link
FROM entry_link
WHERE entry_id = e.id
),
'foreign_entries', ARRAY(
SELECT jsonb_strip_nulls(
jsonb_build_object(
'lang', jsonb_build_object(
'id', l.id,
'code', l.code,
'nameSl', l.name_sl,
'nameEn', l.name_en
),
'terms', ef.term,
'definition', ef.definition,
'synonyms', ef.synonym
)
)
FROM entry_foreign ef
LEFT JOIN LANGUAGE l ON l.id = ef.language_id
WHERE entry_id = e.id
)
)
) entry,
jsonb_strip_nulls(
jsonb_build_object(
'id', d.id,
'nameSl', d.name_sl,
'nameSlShort', d.name_sl_short,
'nameEn', d.name_en,
'status', d.status
)
) "dictionary",
jsonb_build_object(
'id', dp.id,
'nameSl', dp.name_sl,
'nameEn', dp.name_en
) primary_domain
FROM entry e
JOIN dictionary d ON d.id = e.dictionary_id
JOIN domain_primary dp ON dp.id = d.domain_primary_id
WHERE e.id = $1`
const {
rows: [dataToIndex]
} = await db.query(text, values)
const { dictionary, primary_domain: primaryDomain } = dataToIndex
let { entry } = dataToIndex
// TODO Luka: I expect "source" needing a rework once linked portals and dictionaries start working.
const source = {
code: await getInstanceSetting('portal_code'),
name: await getInstanceSetting('portal_name')
}
entry = prepareEntryForIndexing(entry)
entry.primaryDomain = primaryDomain
entry.dictionary = dictionary
entry.source = source
entry = removeHtmlTags(JSON.stringify(entry))
await searchEngineClient.index({
id: entryId,
index: ENTRY_INDEX,
body: entry,
refresh: shouldWait ? 'wait_for' : false
})
}
// Delete all entries for selected dictionary from search index.
Entry.deleteAllFromIndex = async dictionaryId => {
await searchEngineClient.deleteByQuery({
index: ENTRY_INDEX,
body: { query: { match: { 'dictionary.id': dictionaryId } } }
})
}
// Delete all entries for selected dictionary from DB.
Entry.deleteAll = async dictionaryId => {
const text = 'DELETE FROM entry WHERE dictionary_id = $1'
const value = [dictionaryId]
await db.query(text, value)
}
// Publish all entries for selected dictionary from DB that match the criteria.
Entry.publishAllQualified = async dictionaryId => {
const text = `
UPDATE entry
SET is_published = TRUE
WHERE
dictionary_id = $1
AND is_valid = TRUE
AND status = ANY(
CASE (SELECT value FROM instance_settings WHERE name = 'can_publish_entries_in_edit')
WHEN 'T' THEN ARRAY ['complete', 'in_edit']::entry_status[]
ELSE ARRAY ['complete']::entry_status[]
END
)
`
const value = [dictionaryId]
await db.query(text, value)
}
// Update single entry in DB.
Entry.update = async (userId, entry) => {
// Copied from Entry.create and modified.
const pickedLinks = intoDbArray(entry.links, 'always')
const pickedType = intoDbArray(entry.type, 'always')
const links = pickedLinks.map((link, index) => ({
link,
type: pickedType[index]
}))
const foreign = intoDbArray(entry.foreign, 'always')
const foreignLanguageContent = foreign.reduce((agg, row) => {
if (row.term || row.definition || row.synonym) {
agg.push({
language: row.code,
terms: intoDbArray(row.term, 'undefined'),
definition: row.definition || null,
synonyms: intoDbArray(row.synonym, 'undefined')
})
}
return agg
}, [])
const isValid =
!!entry.term &&
(!!entry.definition || foreignLanguageContent.some(el => el.terms))
const values = [
entry.entryId,
isValid,
!!entry.isPublished,
!!entry.isTerminologyReviewed,
!!entry.isLanguageReviewed,
entry.status,
entry.term || null,
userId,
entry.homonymSort || null,
intoDbArray(entry.domainLabels, 'always'),
entry.label || null,
entry.definition || null,
intoDbArray(entry.synonyms),
links,
entry.other || null,
foreignLanguageContent,
intoDbArray(entry.image),
intoDbArray(entry.audio),
intoDbArray(entry.video)
]
const text = `SELECT entry_update (${db.genParamStr(values)})`
const {
rows: [{ entry_update: dictionaryId }]
} = await db.query(text, values)
return dictionaryId
}
// // Fetch all versions (with timestamps) of a single entry from DB.
// Entry.fetchVersions = async entryId => {
// const { rows: historyVersions } = await db.query(
// 'SELECT version, version_time FROM entry_version_history WHERE entry_id = $1',
// [entryId]
// )
// return historyVersions
// }
// Fetch a single version snapshot of a single entry from DB.
Entry.fetchVersionSnapshot = async (entryId, version) => {
const {
rows: [{ version_snapshot: historySnapshot }]
} = await db.query(
'SELECT version_snapshot FROM entry_version_history WHERE entry_id = $1 and version = $2',
[entryId, version]
)
return historySnapshot
}
/* Fetch by language and entry Id. Note that this version includes the language name */
Entry.fetchForeignEntryById = async entryId => {
const text = `
SELECT
entry_id,
language_id,
code,
name_sl,
name_en,
term,
definition,
synonym
FROM entry_foreign ef
INNER JOIN language lang ON ef.language_id = lang.id
WHERE ef.entry_id = $1`
const value = [entryId]
const { rows: fetchedDomainLabels } = await db.query(text, value)
return fetchedDomainLabels
}
module.exports = Entry
+501
View File
@@ -0,0 +1,501 @@
const FormData = require('form-data')
const { createReadStream } = require('fs')
const { writeFile, readFile } = require('fs/promises')
const axios = require('axios')
const db = require('./db')
const {
deserialize,
getDocumentsPath,
getStopTermsPath,
getConllusPath,
getTermCandidatesPath,
getFileNamesInFolder,
getFileStatsInFolder
} = require('./helpers/extraction')
const Extraction = {}
// Fetch all extractions for a specific user.
Extraction.fetchAllForUser = async userId => {
const { rows: fetchedExtractions } = await db.query(
'SELECT id, name, status, corpus_id, oss_params, time_started, time_finished FROM extraction WHERE user_id = $1 ORDER BY id',
[userId]
)
return fetchedExtractions.map(fetchedExtraction =>
deserialize.extraction(fetchedExtraction)
)
}
// Count all extractions for a specific user.
Extraction.countAllForUser = async userId => {
const {
rows: [{ count: extractionCount }]
} = await db.query('SELECT COUNT(*) FROM extraction WHERE user_id = $1', [
userId
])
return +extractionCount
}
// Create a new (own) extraction entry in DB.
Extraction.createOwn = async (userId, extractionName) => {
const {
rows: [{ id }]
} = await db.query(
'INSERT INTO extraction (user_id, name) VALUES ($1, $2) RETURNING id',
[userId, extractionName]
)
return id
}
// Create a new (oss) extraction entry in DB.
Extraction.createOss = async (userId, extractionName) => {
const {
rows: [{ id }]
} = await db.query(
'INSERT INTO extraction (user_id, name, oss_params) VALUES ($1, $2, $3) RETURNING id',
[userId, extractionName, { params: {}, status: 'new' }]
)
return id
}
// Fetch a specific extraction entry from DB.
Extraction.fetch = async id => {
const {
rows: [fetchedExtraction]
} = await db.query(
'SELECT id, name, status, corpus_id, oss_params, time_started, time_finished FROM extraction WHERE id = $1',
[id]
)
return deserialize.extraction(fetchedExtraction)
}
// Fetch author email of a specific extraction entry from DB.
Extraction.fetchAuthorEmail = async id => {
const {
rows: [{ email }]
} = await db.query(
`SELECT u.email
FROM extraction e
LEFT JOIN "user" u ON u.id = e.user_id
WHERE e.id = $1`,
[id]
)
return email
}
// Update extraction entry in DB.
Extraction.update = async (id, name) => {
await db.query('UPDATE extraction SET name = $2 WHERE id = $1', [id, name])
}
// Delete a specific extraction entry from DB.
Extraction.delete = async id => {
const {
rows: [{ corpus_id: corpusId }]
} = await db.query(
'DELETE FROM extraction WHERE id = $1 RETURNING corpus_id',
[id]
)
return corpusId
}
// Fetch all documents' names for a specific extraction.
Extraction.fetchAllDocumentsNames = async extractionId => {
const documentsPath = getDocumentsPath(extractionId)
const documentsNames = await getFileNamesInFolder(documentsPath)
return documentsNames
}
// Fetch all documents' metadata for a specific extraction.
Extraction.fetchAllDocumentsStats = async extractionId => {
const documentsPath = getDocumentsPath(extractionId)
const documentsStats = await getFileStatsInFolder(documentsPath)
return documentsStats
}
// Fetch all stop terms files' names for a specific extraction.
Extraction.fetchAllStopTermsFilesNames = async extractionId => {
const stopTermsFilesPath = getStopTermsPath(extractionId)
const stopTermsFilesNames = await getFileNamesInFolder(stopTermsFilesPath)
return stopTermsFilesNames
}
// Fetch all stop terms files' metadata for a specific extraction.
Extraction.fetchAllStopTermsFilesStats = async extractionId => {
const stopTermsFilesPath = getStopTermsPath(extractionId)
const stopTermsFilesStats = await getFileStatsInFolder(stopTermsFilesPath)
return stopTermsFilesStats
}
// Update OSS parameters for a specific extraction in DB.
Extraction.updateOssParams = async (extractionId, newOssParams) => {
await db.query('UPDATE extraction SET oss_params = $1 WHERE id = $2', [
newOssParams,
extractionId
])
}
// Fetch term candidates JSON for a specific extraction.
Extraction.fetchTermCandidatesJson = async extractionId => {
const termCandidatesPath = getTermCandidatesPath(extractionId)
const fileContent = await readFile(termCandidatesPath, 'utf8')
return fileContent
}
// Fetch the number of term candidates for a specific extraction.
Extraction.fetchTermCandidatesCount = async function (extractionId) {
const termCandidatesJson = await this.fetchTermCandidatesJson(extractionId)
const termCandidates = JSON.parse(termCandidatesJson).terminoloski_kandidati
return termCandidates.length
}
// Mark extraction from own documents as began.
Extraction.beginOwn = async (extractionId, documentsNames) => {
let timeStarted
await db.transaction(async dbClient => {
;[
{
rows: [{ time_started: timeStarted }]
}
] = await Promise.all([
dbClient.query(
"UPDATE extraction SET status = 'in progress', time_started = NOW() WHERE id = $1 RETURNING time_started",
[extractionId]
),
dbClient.query(
'INSERT INTO extraction_job (extraction_id, job_type, filename) VALUES ($1, $2, UNNEST($3::VARCHAR[]))',
[extractionId, 'doc to conllu', documentsNames]
),
dbClient.query(
'INSERT INTO extraction_job (extraction_id, job_type, filename) VALUES ($1, $2, $3)',
[extractionId, 'conllus to term candidates', '']
),
dbClient.query(
'INSERT INTO extraction_job (extraction_id, job_type, filename) VALUES ($1, $2, $3)',
[extractionId, 'concordancer', '']
)
])
})
return timeStarted
}
// Mark extraction from OSS as began.
Extraction.beginOss = async extractionId => {
let timeStarted
await db.transaction(async dbClient => {
const insertExtractionJob = dbClient.query(
'INSERT INTO extraction_job (extraction_id, job_type, filename) VALUES ($1, $2, $3)',
[extractionId, 'oss term candidates', '']
)
const updateExtraction = dbClient.query(
"UPDATE extraction SET status = 'in progress', time_started = NOW() WHERE id = $1 RETURNING time_started",
[extractionId]
)
;[
{
rows: [{ time_started: timeStarted }]
}
] = await Promise.all([updateExtraction, insertExtractionJob])
})
return timeStarted
}
// Fetch all finished extractions for a specific user.
Extraction.fetchFinishedForUser = async userId => {
const { rows: fetchedExtractions } = await db.query(
'SELECT id, name FROM extraction WHERE user_id = $1 AND status = $2 ORDER BY id',
[userId, 'finished']
)
return fetchedExtractions.map(fetchedExtraction =>
deserialize.extraction(fetchedExtraction)
)
}
// Supervise a specific extraction from own documents and bring it out of 'in progress' status.
// This is a temporary solution as explained in its execution context.
// It's also completely unmodular and a complete mess. Refactor at appropriate time.
Extraction.processOwn = async function (extractionId, extractionName) {
// Get documents folder path and all documets' names witin.
const documentsPath = getDocumentsPath(extractionId)
const documentNames = await this.fetchAllDocumentsNames(extractionId)
const conllusPath = getConllusPath(extractionId)
const conllusPaths = []
// Using remote API, transform each document into conllu format.
for (const documentName of documentNames) {
const filePath = `${documentsPath}/${documentName}`
const form = new FormData()
form.append('file', createReadStream(filePath), documentName)
try {
const { data: data1 } = await axios.post(
'http://rsdo.lhrs.feri.um.si:8080/datotekaVConlluAsync',
form,
{
headers: {
...form.getHeaders()
}
}
)
const remotejobId = +data1.check_job_url.split('/').at(-1)
await db.query(
"UPDATE extraction_job SET status = 'in progress', remote_job_id = $1 WHERE extraction_id = $2 AND job_type = $3 AND filename = $4",
[remotejobId, extractionId, 'doc to conllu', documentName]
)
// Kristjan said: I don't have to wait for one job to finish to begin the next. I could launch all at once, which is the whole purpose of async processing.
// Consider reworking it in such manner.
// Poll job until finished.
while (true) {
await sleep(5)
const { data: data2 } = await axios.get(
`http://rsdo.lhrs.feri.um.si:8080/job/${remotejobId}`
)
if (data2.finished_on) {
if (data2.job_status !== 'finished processing (OK)') throw Error()
// TODO Read the response as a stream and try to parse it's contents into a file (write stream)('stream-json' package?).
const fileSavePath = `${conllusPath}/${documentName}.conllu`
await writeFile(fileSavePath, data2.job_result)
await db.query(
"UPDATE extraction_job SET status = 'finished', time_finished = NOW() WHERE extraction_id = $1 AND job_type = $2 AND filename = $3",
[extractionId, 'doc to conllu', documentName]
)
conllusPaths.push(fileSavePath)
break
}
}
} catch {
await failTheJob(extractionId, 'doc to conllu', documentName)
}
}
// Conllu transformation for all documents finished. Start extracting term candidates.
// TODO This is a naive implementation which builds the whole payload in memory. Make it streamy.
const conllusArr = []
for (const conlluPath of conllusPaths) {
const fileContent = await readFile(conlluPath, 'utf8')
conllusArr.push(fileContent)
}
const stopTermsPath = getStopTermsPath(extractionId)
const stopTermsFilesNames = await this.fetchAllStopTermsFilesNames(
extractionId
)
const stopTermsSet = new Set()
const stopTermsSeperator = /\r?\n/
for (const stopTermsFileName of stopTermsFilesNames) {
const fileContent = await readFile(
`${stopTermsPath}/${stopTermsFileName}`,
'utf8'
)
const stopTerms = fileContent.split(stopTermsSeperator)
stopTerms.forEach(stopTerm => stopTermsSet.add(stopTerm.trim()))
}
stopTermsSet.delete('')
const termCandidatesPath = getTermCandidatesPath(extractionId)
try {
const { data: data3 } = await axios.post(
'http://rsdo.lhrs.feri.um.si:8080/izlusciAsync',
{
conllus: conllusArr,
prepovedaneBesede: Array.from(stopTermsSet)
}
)
const remotejobId = +data3.check_job_url.split('/').at(-1)
await db.query(
"UPDATE extraction_job SET status = 'in progress', remote_job_id = $1 WHERE extraction_id = $2 AND job_type = $3 AND filename = $4",
[remotejobId, extractionId, 'conllus to term candidates', '']
)
// Poll job until finished.
while (true) {
await sleep(5)
const { data: data4 } = await axios.get(
`http://rsdo.lhrs.feri.um.si:8080/job/${remotejobId}`
)
if (data4.finished_on) {
if (data4.job_status !== 'finished processing (OK)') throw Error()
// TODO Read the response as a stream and try to parse it's contents into a file (write stream)('stream-json' package?).
await writeFile(termCandidatesPath, JSON.stringify(data4.job_result))
// TODO Once returned JSON is properly formed, use the bottom line instead.
// await writeFile(termCandidatesPath, data4.job_result.terminoloski_kandidati)
await db.query(
"UPDATE extraction_job SET status = 'finished', time_finished = NOW() WHERE extraction_id = $1 AND job_type = $2 AND filename = $3",
[extractionId, 'conllus to term candidates', '']
)
break
}
}
} catch {
await failTheJob(extractionId, 'conllus to term candidates', '')
await failExtraction(extractionId)
return
}
// Now we have conllus and term_candidates.json.
// Start concondancer corpus processing.
try {
await db.query(
"UPDATE extraction_job SET status = 'in progress' WHERE extraction_id = $1 AND job_type = $2 AND filename = $3",
[extractionId, 'concordancer', '']
)
console.log('CREATING CORPUS')
const {
data: {
entityInfo: { id: corpusId }
}
} = await axios.post('http://concordancer:5000/dashboard/corpus', {
title: extractionName
})
console.log('CORPUS CREATED')
console.log('SLEEP FOR 10 SECS')
await sleep(10)
for (const conlluPath of conllusPaths) {
const textPathParts = conlluPath.split('/')
textPathParts[0] = '/data'
const textPath = textPathParts.join('/')
console.log('ADDING TEXT')
await axios.post(
`http://concordancer:5000/dashboard/corpus/${corpusId}/text`,
{ sourceFile: textPath }
)
console.log('TEXT ADDED')
console.log('SLEEP FOR 10 SECS')
await sleep(10)
}
const termListPathParts = termCandidatesPath.split('/')
termListPathParts[0] = '/data'
const termListPath = termListPathParts.join('/')
console.log('ADDING TERMS')
await axios.post(
`http://concordancer:5000/dashboard/corpus/${corpusId}/termList`,
{ sourceFile: termListPath }
)
console.log('TERMS ADDED')
await db.query(
"UPDATE extraction_job SET status = 'finished', time_finished = NOW() WHERE extraction_id = $1 AND job_type = $2 AND filename = $3",
[extractionId, 'concordancer', '']
)
await db.query(
"UPDATE extraction SET status = 'finished', time_finished = NOW(), corpus_id = $1 WHERE id = $2",
[corpusId, extractionId]
)
console.log('EXTRACTION SUCCESSFUL')
} catch (e) {
console.log('EXTRACTION ERROR')
console.log(e)
await failTheJob(extractionId, 'concordancer', '')
await failExtraction(extractionId)
}
}
// Supervise a specific extraction from OSS and bring it out of 'in progress' status.
// This is a temporary solution as explained in its execution context.
// It's also completely unmodular and a complete mess. Refactor at appropriate time.
Extraction.processOss = async function (extractionId, ossParams) {
// TODO Consider if it would make sense to make stop term file reading streaming.
// TODO Probably not, at least not while the the OSS enpoint is GET, due to limited length of URLs.
// TODO Also consider refactoring certain parts,
// TODO as some are identical or similar to Own variants or used earlier in the same pipeline.
const stopTermsPath = getStopTermsPath(extractionId)
const stopTermsFilesNames = await this.fetchAllStopTermsFilesNames(
extractionId
)
const stopTermsSet = new Set()
const stopTermsSeperator = /\r?\n/
for (const stopTermsFileName of stopTermsFilesNames) {
const fileContent = await readFile(
`${stopTermsPath}/${stopTermsFileName}`,
'utf8'
)
const stopTerms = fileContent.split(stopTermsSeperator)
stopTerms.forEach(stopTerm => stopTermsSet.add(stopTerm.trim()))
}
stopTermsSet.delete('')
const stopTerms = Array.from(stopTermsSet)
const searchParams = new URLSearchParams({
...(ossParams.year && { leta: ossParams.year }),
...(ossParams.documentType && { vrste: ossParams.documentType }),
...(ossParams.keywords && { kljucneBesede: ossParams.keywords }),
...(ossParams.domainUdk && { udk: ossParams.domainUdk }),
...(stopTerms.length && { prepovedaneBesede: stopTerms })
})
const extractApiUrl = `http://rsdo.lhrs.feri.um.si:8080/oss/izlusciPoIskanjuAsync?${searchParams}`
try {
const { data: data1 } = await axios.get(extractApiUrl)
const remotejobId = +data1.check_job_url.split('/').at(-1)
await db.query(
"UPDATE extraction_job SET status = 'in progress', remote_job_id = $1 WHERE extraction_id = $2 AND job_type = $3 AND filename = $4",
[remotejobId, extractionId, 'oss term candidates', '']
)
// Poll job until finished.
while (true) {
await sleep(5)
const { data: data2 } = await axios.get(
`http://rsdo.lhrs.feri.um.si:8080/job/${remotejobId}`
)
if (data2.finished_on) {
if (data2.job_status !== 'finished processing (OK)') throw Error()
// TODO Read the response as a stream and try to parse it's contents into a file (write stream)('stream-json' package?).
const termCandidatesPath = getTermCandidatesPath(extractionId)
await writeFile(termCandidatesPath, JSON.stringify(data2.job_result))
// TODO Once returned JSON is properly formed, use the bottom line instead.
// await writeFile(termCandidatesPath, data4.job_result.terminoloski_kandidati)
await db.query(
"UPDATE extraction_job SET status = 'finished', time_finished = NOW() WHERE extraction_id = $1 AND job_type = $2 AND filename = $3",
[extractionId, 'oss term candidates', '']
)
break
}
}
// Now we have term_candidates.json.
// Mark extraction as finished.
await db.query(
"UPDATE extraction SET status = 'finished', time_finished = NOW() WHERE id = $1",
[extractionId]
)
} catch {
await failTheJob(extractionId, 'oss term candidates', '')
await failExtraction(extractionId)
}
}
async function failTheJob(extractionId, jobType, documentName) {
await db.query(
"UPDATE extraction_job SET status = 'failed', time_finished = NOW() WHERE extraction_id = $1 AND job_type = $2 AND filename = $3",
[extractionId, jobType, documentName]
)
}
async function failExtraction(extractionId) {
await db.query(
"UPDATE extraction SET status = 'failed', time_finished = NOW() WHERE id = $1",
[extractionId]
)
}
function sleep(seconds) {
return new Promise(resolve => setTimeout(resolve, seconds * 1000))
}
module.exports = Extraction
@@ -0,0 +1,282 @@
const fs = require('fs')
const xmlFlow = require('xml-flow')
const xss = require('xss')
const debug = require('debug')('termPortal:models/helpers/dictionary')
const db = require('../../db')
const { intoDbArray } = require('..')
const JOBS_MAX = 50
const JOBS_MIN = 15
// Import given file into given dictionary.
exports.readFileIntoDb = (
userId,
dictionaryId,
importFilePath,
entryStatus,
dbClient
) => {
return new Promise((resolve, reject) => {
const progress = {
jobCount: 0,
totalCount: 0,
validCount: 0,
isWholeFileRead: false,
importError: null
}
entryStatus = entryStatus === 'complete' ? 'complete' : 'in_edit'
const importFileReader = fs.createReadStream(importFilePath)
const xmlStream = xmlFlow(importFileReader, {
strict: true,
trim: false,
preserveMarkup: xmlFlow.ALWAYS,
simplifyNodes: false,
useArrays: xmlFlow.ALWAYS
})
const handleError = handleImportError(xmlStream, progress, reject)
const handleEntry = handleEntryXml(
userId,
dictionaryId,
entryStatus,
progress,
dbClient,
resolve,
handleError
)
xmlStream
.on('error', () => {})
.once('error', handleError)
.on('end', () => (progress.isWholeFileRead = true))
.on('tag:entry', handleEntry)
})
}
function handleImportError(xmlStream, progress, reject) {
return function handleError(error) {
if (progress.importError) return
progress.importError = error
xmlStream.removeAllListeners('tag:entry')
reject(error)
}
}
function handleEntryXml(
userId,
dictionaryId,
entryStatus,
progress,
dbClient,
resolve,
handleError
) {
return async function handleEntry(entry) {
if (progress.importError) return
try {
if (++progress.jobCount > JOBS_MAX) this.pause()
const term = toMixedBasic(
entry.$markup.find(el => el.$name === 'term')?.$markup
)
const hwGrp = entry.$markup.find(el => el.$name === 'hwGrp')
const wordforms = hwGrp?.$attrs.wfs
const accent = hwGrp?.$attrs.acc
const pronunciation = hwGrp?.$attrs.pron
const domainLabels = entry.$markup
.find(el => el.$name === 'domainLabels')
?.$markup.filter(childEl => childEl.$name === 'domainLabel')
.map(domainLabel => toText(domainLabel.$markup))
const label = toMixedExtended(
entry.$markup.find(el => el.$name === 'label')?.$markup
)
const definition = toMixedExtended(
entry.$markup.find(el => el.$name === 'def')?.$markup
)
const synonyms = entry.$markup
.find(el => el.$name === 'syns')
?.$markup.filter(childEl => childEl.$name === 'syn')
.map(synonym => toMixedBasic(synonym.$markup))
const links = entry.$markup
.find(el => el.$name === 'links')
?.$markup.filter(childEl => childEl.$name === 'link')
.map(link => ({
link: toMixedBasic(link.$markup),
type: link.$attrs.type
}))
const other = toMixedOther(
entry.$markup.find(el => el.$name === 'other')?.$markup
)
let hasForeignTerms = false
const foreignLanguageContent = entry.$markup
.find(el => el.$name === 'fLangs')
?.$markup.filter(childEl => childEl.$name === 'fLang')
.map(fLang => ({
language: fLang.$attrs.lang,
terms: fLang.$markup
.find(el => el.$name === 'fTerms')
?.$markup.filter(childEl => childEl.$name === 'fTerm')
.map(term => {
const content = toMixedBasic(term.$markup)
if (content.length) hasForeignTerms = true
return content
}),
definition:
toMixedExtended(
fLang.$markup.find(el => el.$name === 'fDef')?.$markup
) || null,
synonyms: fLang.$markup
.find(el => el.$name === 'fSyns')
?.$markup.filter(childEl => childEl.$name === 'fSyn')
.map(synonym => toMixedBasic(synonym.$markup))
}))
const multimedia = entry.$markup.find(el => el.$name === 'mm')?.$markup
const images = []
const audio = []
const videos = []
multimedia?.forEach(mm => {
switch (mm.$name) {
case 'image':
images.push(toText(mm.$markup))
break
case 'audio':
audio.push(toText(mm.$markup))
break
case 'video':
videos.push(toText(mm.$markup))
}
})
const isValid = !!term && (!!definition || hasForeignTerms)
if (!entry.$markup.length) return
const values = [
dictionaryId,
isValid,
entryStatus,
term || null,
userId,
null,
wordforms,
accent,
pronunciation,
intoDbArray(domainLabels, 'always'),
label || null,
definition || null,
intoDbArray(synonyms),
intoDbArray(links, 'always'),
other || null,
intoDbArray(foreignLanguageContent, 'always'),
images.length ? images : null,
audio.length ? audio : null,
videos.length ? videos : null
]
const text = `SELECT entry_new (${db.genParamStr(values)})`
await dbClient.query(text, values)
progress.totalCount++
if (isValid) progress.validCount++
} catch (error) {
handleError(error)
} finally {
progress.jobCount--
if (progress.jobCount < JOBS_MIN) this.resume()
if (
!progress.jobCount &&
progress.isWholeFileRead &&
!progress.importError
) {
debug(`TOTAL ENTRIES: ${progress.totalCount}`)
debug(`VALID ENTRIES: ${progress.validCount}`)
resolve()
}
}
}
}
const markupFilter = {
noMixed: new xss.FilterXSS({
whiteList: {},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script', 'style']
}),
mixedBasic: new xss.FilterXSS({
whiteList: {
sup: [],
sub: []
},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script', 'style']
}),
mixedExtended: new xss.FilterXSS({
whiteList: {
sup: [],
sub: [],
b: [],
i: [],
a: ['href']
},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script', 'style'],
onTag: customTagHandler
}),
mixedOther: new xss.FilterXSS({
whiteList: {
sup: [],
sub: [],
b: [],
i: [],
a: ['href'],
br: []
},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script', 'style'],
onTag: customTagHandler
})
}
function customTagHandler(tag, html, { isWhite, isClosing }) {
// Special treatment only for whitelisted opening anchor tags.
if (tag !== 'a' || !isWhite || isClosing) return
const matchUrl = html.match(/href="?(?<url>https?:\/\/.*?)"?[\s>]/)
const url = matchUrl ? xss.escapeAttrValue(matchUrl.groups.url) : undefined
return `<a href${url ? `="${url}" target="_blank"` : ''}>`
}
function toText(markupObj) {
return markupFilter.noMixed
.process(xmlFlow.toXml(markupObj))
.replace(/\s+/g, ' ')
.trim()
}
function toMixedBasic(markupObj) {
return markupFilter.mixedBasic
.process(xmlFlow.toXml(markupObj))
.replace(/\s+/g, ' ')
.trim()
}
function toMixedExtended(markupObj) {
return markupFilter.mixedExtended
.process(xmlFlow.toXml(markupObj))
.replace(/\s+/g, ' ')
.trim()
}
function toMixedOther(markupObj) {
return markupFilter.mixedOther
.process(xmlFlow.toXml(markupObj))
.replace(/\s+/g, ' ')
.trim()
}
+196
View File
@@ -0,0 +1,196 @@
const { removeHtmlTags } = require('../../helpers')
const { searchEngineClient, ENTRY_INDEX } = require('../../search-engine')
exports.deserialize = {
primaryDomain(domain) {
const deserializedDomain = {
id: domain.id,
nameSl: domain.name_sl,
nameEn: domain.name_en
}
return deserializedDomain
},
secondaryDomain(domain) {
const deserializedDomain = {
id: domain.id,
isApproved: domain.approved,
nameSl: domain.name_sl,
nameEn: domain.name_en
}
return deserializedDomain
},
approvedSecondaryDomain(domain) {
const deserializedDomain = {
id: domain.id,
nameSl: domain.name_sl,
nameEn: domain.name_en
}
return deserializedDomain
},
language(language) {
const deserializedLanguage = {
id: language.id,
code: language.code,
nameSl: language.name_sl,
nameEn: language.name_en
}
return deserializedLanguage
},
editDescription(dictionary) {
const deserializedDictionary = {
id: dictionary.id,
nameSl: dictionary.name_sl,
nameEn: dictionary.name_en,
nameSlShort: dictionary.name_sl_short,
author: dictionary.author,
domainPrimary: dictionary.domain_primary_id,
description: dictionary.description,
issn: dictionary.issn
}
return deserializedDictionary
},
editUsers(dictionary) {
const deserializedDictionary = {
id: dictionary.id,
nameSl: dictionary.name_sl,
terminologyReviewFlag: dictionary.entries_have_terminology_review_flag,
languageReviewFlag: dictionary.entries_have_language_review_flag,
status: dictionary.status
}
return deserializedDictionary
},
editStructure(dictionary) {
const deserializedDictionary = {
id: dictionary.id,
nameSl: dictionary.name_sl,
hasDomainLabels: dictionary.entries_have_domain_labels,
hasLabel: dictionary.entries_have_label,
hasDefinition: dictionary.entries_have_definition,
hasSynonyms: dictionary.entries_have_synonyms,
hasLinks: dictionary.entries_have_links,
hasOther: dictionary.entries_have_other,
hasForeignLanguages: dictionary.entries_have_foreign_languages,
hasForeignDefinitions: dictionary.entries_have_foreign_definitions,
hasForeignSynonyms: dictionary.entries_have_foreign_synonyms,
hasImages: dictionary.entries_have_images,
hasAudio: dictionary.entries_have_audio,
hasVideo: dictionary.entries_have_videos
}
return deserializedDictionary
},
editDomainLabels(domainLabel) {
const deserializedDomainLabel = {
id: domainLabel.id,
name: domainLabel.name,
isVisible: domainLabel.is_visible
}
return deserializedDomainLabel
},
imports(oneImport) {
const deserializedImports = {
timeStarted: oneImport.time_started,
status: oneImport.status,
deleteExisting: oneImport.delete_existing_entries,
fileFormat: oneImport.file_format,
countValidEntries: oneImport.count_valid_entries
}
return deserializedImports
}
}
exports.bulkIndex = async (entries, primaryDomain, dictionary, source) => {
const entriesCount = entries.length
if (!entries.length) return
// Construct request body.
const bulkBody = new Array(entriesCount * 2)
for (let i = 0; i < entriesCount; i++) {
let entry = prepareEntryForIndexing(entries[i])
bulkBody[i * 2] = { index: { _index: ENTRY_INDEX, _id: entry.id } }
entry.primaryDomain = primaryDomain
entry.dictionary = dictionary
entry.source = source
entry = removeHtmlTags(JSON.stringify(entry))
bulkBody[i * 2 + 1] = entry
}
// Send the request.
const bulkResponse = await searchEngineClient.bulk({ body: bulkBody })
// Log possible errors.
if (bulkResponse.errors) {
const erroredDocuments = []
bulkResponse.items.forEach((action, i) => {
const operation = Object.keys(action)[0]
if (action[operation].error) {
erroredDocuments.push({
// If the status is 429 it means that you can retry the document,
// otherwise it's very likely a mapping error, and you should
// fix the document before to try it again.
status: action[operation].status,
error: action[operation].error,
operation: action[operation].body[i * 2],
document: action[operation].body[i * 2 + 1]
})
}
})
console.log('Errors indexing documents:') // eslint-disable-line no-console
console.log(erroredDocuments) // eslint-disable-line no-console
}
}
function prepareEntryForIndexing(entry) {
// Snake case property names into camel case.
entry.isValid = entry.is_valid
delete entry.is_valid
entry.isPublished = entry.is_published
delete entry.is_published
entry.isTerminologyReviewed = entry.is_terminology_reviewed
delete entry.is_terminology_reviewed
entry.isLanguageReviewed = entry.is_language_reviewed
delete entry.is_language_reviewed
entry.homonymSort = entry.homonym_sort
delete entry.homonym_sort
entry.timeMostRecentComment = entry.time_most_recent_comment
delete entry.time_most_recent_comment
entry.domainLabels = entry.domain_labels
delete entry.domain_labels
entry.foreignEntries = entry.foreign_entries
delete entry.foreign_entries
// Remove (top-level) properies with null or empty array values.
entry = Object.fromEntries(
Object.entries(entry).filter(
([_, v]) => v !== null && (!Array.isArray(v) || v.length)
)
)
return entry
}
exports.prepareEntryForIndexing = prepareEntryForIndexing
+62
View File
@@ -0,0 +1,62 @@
const { readdir, stat } = require('fs/promises')
const { partial } = require('filesize')
const { DATA_FILES_PATH } = require('../../config/settings')
const formatFileSize = partial({ separator: ',' })
exports.deserialize = {
extraction(extraction) {
const deserializedExtraction = {
id: extraction.id,
name: extraction.name,
status: extraction.status,
corpusId: extraction.corpus_id,
timeStarted: extraction.time_started,
timeFinished: extraction.time_finished,
ossParams: extraction.oss_params
}
return deserializedExtraction
}
}
exports.getExtractionFilesPath = getExtractionFilesPath
exports.getDocumentsPath = extractionId => {
return `${getExtractionFilesPath(extractionId)}/documents`
}
exports.getStopTermsPath = extractionId => {
return `${getExtractionFilesPath(extractionId)}/stop_terms`
}
exports.getConllusPath = extractionId => {
return `${getExtractionFilesPath(extractionId)}/conllu`
}
exports.getTermCandidatesPath = extractionId => {
return `${getExtractionFilesPath(extractionId)}/term_candidates.json`
}
exports.getFileNamesInFolder = async folderPath => {
const filenames = await readdir(folderPath)
return filenames
}
exports.getFileStatsInFolder = async folderPath => {
const filenames = await readdir(folderPath)
const fileStats = await Promise.all(
filenames.map(async filename => {
const filePath = `${folderPath}/${filename}`
const { mtimeMs: timeModified, size } = await stat(filePath)
const sizeHumanReadable = formatFileSize(size)
const fileStats = { filename, size: sizeHumanReadable, timeModified }
return fileStats
})
)
return fileStats
}
function getExtractionFilesPath(extractionId) {
return `${DATA_FILES_PATH}/extraction/${extractionId}`
}
+73
View File
@@ -0,0 +1,73 @@
const xss = require('xss')
const cache = require('../cache')
const Portal = require('../portal')
const INSTANCE_SETTING_NAMESPACE = 'instance-setting'
const INSTANCE_SETTING_CACHE_EXPIRE_TIME = 60 * 60 // 1 hour.
const SLOVENIAN_LANGUAGE_ID_CACHE_EXPIRE_TIME = 60 * 60 // 1 hour.
exports.intoDbArray = (inputData, mode) => {
if (Array.isArray(inputData)) {
return inputData
} else if (inputData === undefined || inputData === '') {
if (mode === 'always') return []
if (mode === 'undefined') return undefined
else return null
// return mode === 'always' ? [] : null
}
return [inputData]
}
exports.removeHtmlTags = text => {
return tagFilter.process(text)
}
// Returns setting value by its name from table instance_settings.
exports.getInstanceSetting = async settingName => {
const settingCacheName = `${INSTANCE_SETTING_NAMESPACE}:${settingName}`
let settingValue = await cache.get(settingCacheName)
if (!settingValue) {
settingValue = await Portal.getInstanceSettingValue(settingName)
await cache.set(
settingCacheName,
settingValue,
'EX',
INSTANCE_SETTING_CACHE_EXPIRE_TIME
)
}
return settingValue
}
// Deletes cached instance settings.
exports.clearCachedInstanceSettings = async () => {
const settingNames = await Portal.fetchAllInstanceSettingNames()
const settingCacheNames = settingNames.map(
settingName => `${INSTANCE_SETTING_NAMESPACE}:${settingName}`
)
await cache.unlink(...settingCacheNames)
}
// Returns slovenian language id and keeps it cached.
exports.getSlovenianLanguageId = async () => {
const settingCacheName = 'slovenian-language-id'
let id = await cache.get(settingCacheName)
if (!id) {
id = (await Portal.getSlovenianLanguageId()).toString()
await cache.set(
settingCacheName,
id,
'EX',
SLOVENIAN_LANGUAGE_ID_CACHE_EXPIRE_TIME
)
}
return id
}
const tagFilter = new xss.FilterXSS({
whiteList: {},
stripIgnoreTag: true
})
+67
View File
@@ -0,0 +1,67 @@
exports.aggregateSettings = settings => {
const deserializedSettings = settings.reduce((agg, setting) => {
agg[setting.name] = setting.value
return agg
}, {})
return deserializedSettings
}
exports.deserialize = {
settings(settings) {
const deserializedSettings = {
name: settings.portal_name,
description: settings.portal_description,
code: settings.portal_code,
isExtractionEnabled: settings.is_extraction_enabled,
isDictionariesEnabled: settings.is_dictionaries_enabled,
isConsultancyEnabled: settings.is_consultancy_enabled
}
return deserializedSettings
},
dictSettings(settings) {
const deserializedSettings = {
minEntriesPerDictionary: settings.min_entries_per_dictionary,
dictionaryPublishApproval: settings.dictionary_publish_approval,
// keepNumOfExportsPerDict: settings.keep_num_of_exports_per_dictionary,
// dictionaryAutoSaveFrequency: settings.dictionary_auto_save_frequency,
numOfHistoryEntriesPerEntry: settings.num_of_history_entires_per_entry,
canPublishEntriesInEdit: settings.can_publish_entries_in_edit
}
return deserializedSettings
},
consultSettings(settings) {
const deserializedSettings = {
consultancyType: settings.consultancy_type,
zrcEmail: settings.zrc_email,
zrcURL: settings.zrc_url
}
return deserializedSettings
},
connections(connection) {
const deserializedConnections = {
id: connection.id,
name: connection.name,
indexURL: connection.url_index,
code: connection.code,
isEnabled: connection.is_enabled,
synced: connection.time_last_synced,
isLinked: connection.is_linked,
URLupdate: connection.url_update
}
return deserializedConnections
},
dictionaries(dictionary) {
const deserializeDictionaries = {
id: dictionary.id,
name: dictionary.name,
code: dictionary.code,
isEnabled: dictionary.is_enabled
}
return deserializeDictionaries
}
}
@@ -0,0 +1,40 @@
// TODO Consider alternative paging when hits exceed 10000.
module.exports = function (filters, hitsPerPage, page) {
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
filter: []
}
},
sort: ['_score', 'timeCreated']
}
if (filters.status) {
queryDsl.query.bool.filter.push({
term: {
status: filters.status
}
})
}
if (filters.assignedConsultant) {
queryDsl.query.bool.filter.push({
term: {
'assignedConsultants.id': filters.assignedConsultant
}
})
}
if (filters.primaryDomain) {
queryDsl.query.bool.filter.push({
term: {
'primaryDomain.id': filters.primaryDomain
}
})
}
return queryDsl
}
@@ -0,0 +1,76 @@
// TODO Consider alternative paging when hits exceed 10000.
module.exports = function (searchString, filters, hitsPerPage, page) {
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
must: {
dis_max: {
queries: [
{
constant_score: {
filter: {
match_phrase: {
title: searchString
}
},
boost: 3
}
},
{
constant_score: {
filter: {
match_phrase: {
question: searchString
}
},
boost: 2
}
},
{
constant_score: {
filter: {
match_phrase: {
answer: searchString
}
},
boost: 1
}
}
]
}
},
filter: []
}
},
sort: ['_score', 'timeCreated']
}
if (filters.status) {
queryDsl.query.bool.filter.push({
term: {
status: filters.status
}
})
}
if (filters.assignedConsultant) {
queryDsl.query.bool.filter.push({
term: {
'assignedConsultants.id': filters.assignedConsultant
}
})
}
if (filters.primaryDomain) {
queryDsl.query.bool.filter.push({
term: {
'primaryDomain.id': filters.primaryDomain
}
})
}
return queryDsl
}
@@ -0,0 +1,70 @@
const { queryForField } = require('../..')
const whitespace = /\s+/
// TODO Consider alternative paging when hits exceed 10000.
module.exports = function (searchString, filters, hitsPerPage, page) {
const searchTokens = searchString.split(whitespace)
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
must: {
dis_max: {
queries: [
{
constant_score: {
filter: queryForField('title', searchTokens),
boost: 3
}
},
{
constant_score: {
filter: queryForField('question', searchTokens),
boost: 2
}
},
{
constant_score: {
filter: queryForField('answer', searchTokens),
boost: 1
}
}
]
}
},
filter: []
}
},
sort: ['_score', 'timeCreated']
}
if (filters.status) {
queryDsl.query.bool.filter.push({
term: {
status: filters.status
}
})
}
if (filters.assignedConsultant) {
queryDsl.query.bool.filter.push({
term: {
'assignedConsultants.id': filters.assignedConsultant
}
})
}
if (filters.primaryDomain) {
queryDsl.query.bool.filter.push({
term: {
'primaryDomain.id': filters.primaryDomain
}
})
}
return queryDsl
}
@@ -0,0 +1,85 @@
// TODO Consider alternative paging when hits exceed 10000.
module.exports = function (searchString, filters, hitsPerPage, page) {
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
must: {
dis_max: {
queries: [
{
constant_score: {
filter: {
match: {
title: {
query: searchString,
operator: 'and'
}
}
},
boost: 3
}
},
{
constant_score: {
filter: {
match: {
question: {
query: searchString,
operator: 'and'
}
}
},
boost: 2
}
},
{
constant_score: {
filter: {
match: {
answer: {
query: searchString,
operator: 'and'
}
}
},
boost: 1
}
}
]
}
},
filter: []
}
},
sort: ['_score', 'timeCreated']
}
if (filters.status) {
queryDsl.query.bool.filter.push({
term: {
status: filters.status
}
})
}
if (filters.assignedConsultant) {
queryDsl.query.bool.filter.push({
term: {
'assignedConsultants.id': filters.assignedConsultant
}
})
}
if (filters.primaryDomain) {
queryDsl.query.bool.filter.push({
term: {
'primaryDomain.id': filters.primaryDomain
}
})
}
return queryDsl
}
@@ -0,0 +1,129 @@
const { EDITOR_MAX_HITS } = require('../../../../../config/settings')
module.exports = function (dictionaryId, filters, searchFieldFilters) {
const queryDsl = {
_source: ['id', 'isValid', 'isPublished', 'term'],
fields: ['foreignEntries.terms'],
script_fields: {
commentActivityIndicator: {
script: {
source: `
if (!doc.containsKey('timeMostRecentComment') || doc['timeMostRecentComment'].empty) return "";
ZonedDateTime mostRecentCommentTime = doc['timeMostRecentComment'].value;
long nowMilli = params['now'];
Instant nowInstant = Instant.ofEpochMilli(nowMilli);
ZonedDateTime now = ZonedDateTime.ofInstant(nowInstant, ZoneId.of('Z'));
long ageInDays = ChronoUnit.DAYS.between(mostRecentCommentTime, now);
if (ageInDays < 7) {
return "T"
} else if (ageInDays < 30) {
return "M"
} else if (ageInDays < 365) {
return "L"
} else {
return ""
}
`,
params: {
now: Date.now()
}
}
}
},
size: EDITOR_MAX_HITS,
query: {
bool: {
filter: [
{
term: {
'dictionary.id': dictionaryId
}
}
]
}
},
sort: ['term.sort', 'homonymSort']
}
if (searchFieldFilters) queryDsl.query.bool.filter.push(searchFieldFilters)
if (filters) {
if (filters.isValid !== undefined) {
queryDsl.query.bool.filter.push({
term: {
isValid: filters.isValid
}
})
}
if (filters.isPublished !== undefined) {
queryDsl.query.bool.filter.push({
term: {
isPublished: filters.isPublished
}
})
}
if (filters.hasComments !== undefined) {
if (filters.hasComments === true) {
queryDsl.query.bool.filter.push({
exists: {
field: 'timeMostRecentComment'
}
})
} else {
queryDsl.query.bool.filter.push({
bool: {
must_not: {
exists: {
field: 'timeMostRecentComment'
}
}
}
})
}
}
if (filters.isComplete !== undefined) {
if (filters.isComplete === true) {
queryDsl.query.bool.filter.push({
term: {
status: 'complete'
}
})
} else {
queryDsl.query.bool.filter.push({
bool: {
must_not: {
term: {
status: 'complete'
}
}
}
})
}
}
if (filters.isTerminologyReviewed !== undefined) {
queryDsl.query.bool.filter.push({
term: {
isTerminologyReviewed: filters.isTerminologyReviewed
}
})
}
if (filters.isLanguageReviewed !== undefined) {
queryDsl.query.bool.filter.push({
term: {
isLanguageReviewed: filters.isLanguageReviewed
}
})
}
}
return queryDsl
}
@@ -0,0 +1,103 @@
const { fieldMap } = require('../..')
const generateQuery = require('./all')
module.exports = function (dictionaryId, searchField, filters) {
const searchFieldFilters = generateSearchFieldFilters(searchField)
const queryDsl = generateQuery(dictionaryId, filters, searchFieldFilters)
return queryDsl
}
function generateSearchFieldFilters(searchField) {
const mappedName = fieldMap[searchField]
if (mappedName) {
if (mappedName.startsWith('foreignEntries')) {
return {
nested: {
path: 'foreignEntries',
query: {
exists: {
field: mappedName
}
}
}
}
}
return {
exists: {
field: mappedName
}
}
}
return {
dis_max: {
queries: [
{
exists: {
field: 'term'
}
},
{
exists: {
field: 'synonyms'
}
},
{
exists: {
field: 'label'
}
},
{
exists: {
field: 'definition'
}
},
{
exists: {
field: 'other'
}
},
{
exists: {
field: 'domainLabels'
}
},
{
exists: {
field: 'links'
}
},
{
nested: {
path: 'foreignEntries',
query: {
dis_max: {
queries: [
{
exists: {
field: 'foreignEntries.terms'
}
},
{
exists: {
field: 'foreignEntries.synonyms'
}
},
{
exists: {
field: 'foreignEntries.definition'
}
}
]
}
}
}
}
]
}
}
}
@@ -0,0 +1,76 @@
const { fieldMap } = require('../..')
const generateQuery = require('./all')
module.exports = function (dictionaryId, searchField, searchString, filters) {
const searchFieldFilters = generateSearchFieldFilters(
searchField,
searchString
)
const queryDsl = generateQuery(dictionaryId, filters, searchFieldFilters)
return queryDsl
}
function generateSearchFieldFilters(searchField, searchString) {
const mappedName = fieldMap[searchField]
if (mappedName) {
if (mappedName.startsWith('foreignEntries')) {
return {
nested: {
path: 'foreignEntries',
query: {
match_phrase: {
[mappedName]: searchString
}
}
}
}
}
return {
match_phrase: {
[mappedName]: searchString
}
}
}
return {
dis_max: {
queries: [
{
multi_match: {
query: searchString,
type: 'phrase',
fields: [
'term',
'synonyms',
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
},
{
nested: {
path: 'foreignEntries',
query: {
multi_match: {
query: searchString,
type: 'phrase',
fields: [
'foreignEntries.terms',
'foreignEntries.synonyms',
'foreignEntries.definition'
]
}
}
}
}
]
}
}
}
@@ -0,0 +1,62 @@
const { fieldMap, queryForField } = require('../..')
const generateQuery = require('./all')
const whitespace = /\s+/
module.exports = function (dictionaryId, searchField, searchString, filters) {
const searchFieldFilters = generateSearchFieldFilters(
searchField,
searchString
)
const queryDsl = generateQuery(dictionaryId, filters, searchFieldFilters)
return queryDsl
}
function generateSearchFieldFilters(searchField, searchString) {
const searchTokens = searchString.split(whitespace)
const mappedName = fieldMap[searchField]
if (mappedName) {
if (mappedName.startsWith('foreignEntries')) {
return {
nested: {
path: 'foreignEntries',
query: queryForField(mappedName, searchTokens)
}
}
}
return queryForField(mappedName, searchTokens)
}
return {
dis_max: {
queries: [
queryForField('term', searchTokens),
queryForField('synonyms', searchTokens),
queryForField('label', searchTokens),
queryForField('definition', searchTokens),
queryForField('other', searchTokens),
queryForField('domainLabels', searchTokens),
queryForField('links', searchTokens),
{
nested: {
path: 'foreignEntries',
query: {
dis_max: {
queries: [
queryForField('foreignEntries.terms', searchTokens),
queryForField('foreignEntries.synonyms', searchTokens),
queryForField('foreignEntries.definition', searchTokens)
]
}
}
}
}
]
}
}
}
@@ -0,0 +1,82 @@
const { fieldMap } = require('../..')
const generateQuery = require('./all')
module.exports = function (dictionaryId, searchField, searchString, filters) {
const searchFieldFilters = generateSearchFieldFilters(
searchField,
searchString
)
const queryDsl = generateQuery(dictionaryId, filters, searchFieldFilters)
return queryDsl
}
function generateSearchFieldFilters(searchField, searchString) {
const mappedName = fieldMap[searchField]
if (mappedName) {
if (mappedName.startsWith('foreignEntries')) {
return {
nested: {
path: 'foreignEntries',
query: {
match: {
[mappedName]: {
query: searchString,
operator: 'and'
}
}
}
}
}
}
return {
match: {
[mappedName]: {
query: searchString,
operator: 'and'
}
}
}
}
return {
dis_max: {
queries: [
{
multi_match: {
query: searchString,
operator: 'and',
fields: [
'term',
'synonyms',
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
},
{
nested: {
path: 'foreignEntries',
query: {
multi_match: {
query: searchString,
operator: 'and',
fields: [
'foreignEntries.terms',
'foreignEntries.synonyms',
'foreignEntries.definition'
]
}
}
}
}
]
}
}
}
@@ -0,0 +1,183 @@
const debug = require('debug')('termPortal:generate-query')
const genAllHitQuery = require('./main/all')
const genAllAggQuery = require('./main/aggregate-all')
const genPhraseHitQuery = require('./main/phrase')
const genPhraseAggQuery = require('./main/aggregate-phrase')
const genWildcardHitQuery = require('./main/wildcard')
const genWildcardAggQuery = require('./main/aggregate-wildcard')
const genMultiWordHitQuery = require('./main/word-multi')
const genWordAggQuery = require('./main/aggregate-word')
const genSingleWordHitQuery = require('./main/word-single')
const genEditorAllQuery = require('./editor/all')
const genEditorExistsQuery = require('./editor/exists')
const genEditorPhraseQuery = require('./editor/phrase')
const genEditorWildcardQuery = require('./editor/wildcard')
const genEditorWordsQuery = require('./editor/words')
const genConsultancyAllQuery = require('./consultancy/all')
const genConsultancyPhraseQuery = require('./consultancy/phrase')
const genConsultancyWildcardQuery = require('./consultancy/wildcard')
const genConsultancyWordsQuery = require('./consultancy/words')
const notOnlyAsterisks = /[^*"\s]/
const hasWildcards = /[*?]/
const hasWhitespace = /\s/
// Determines query type, constructs and returns appropriate search engine query(es).
exports.main = async (
searchString,
filters,
hitsPerPage,
page,
withAggregation
) => {
let hitsQuery
let aggregateQuery
const firstChar = searchString[0]
const lastChar = searchString[searchString.length - 1]
// TODO Aditional sanitization/transformation?
if (!notOnlyAsterisks.test(searchString)) {
debug('query type: all')
hitsQuery = genAllHitQuery(filters, hitsPerPage, page)
if (withAggregation) {
aggregateQuery = genAllAggQuery(hitsQuery)
}
} else if (firstChar === '"' && lastChar === '"') {
debug('query type: phrase')
searchString = searchString.slice(1, -1)
hitsQuery = await genPhraseHitQuery(
searchString,
filters,
hitsPerPage,
page
)
if (withAggregation) {
aggregateQuery = genPhraseAggQuery(searchString, hitsQuery)
}
} else if (hasWildcards.test(searchString)) {
debug('query type: wildcard')
let slovenianFieldQueries, foreignFieldQueries
;[hitsQuery, slovenianFieldQueries, foreignFieldQueries] =
await genWildcardHitQuery(searchString, filters, hitsPerPage, page)
if (withAggregation) {
aggregateQuery = genWildcardAggQuery(
hitsQuery,
slovenianFieldQueries,
foreignFieldQueries
)
}
} else if (hasWhitespace.test(searchString)) {
debug('query type: multi word')
hitsQuery = await genMultiWordHitQuery(
searchString,
filters,
hitsPerPage,
page
)
if (withAggregation) {
aggregateQuery = genWordAggQuery(searchString, hitsQuery)
}
} else {
debug('query type: single word')
hitsQuery = await genSingleWordHitQuery(
searchString,
filters,
hitsPerPage,
page
)
if (withAggregation) {
aggregateQuery = genWordAggQuery(searchString, hitsQuery)
}
}
return withAggregation ? [hitsQuery, aggregateQuery] : hitsQuery
}
// Determines query type, constructs and returns appropriate search engine query for editor.
exports.editor = (dictionaryId, searchField, searchString, filters) => {
let hitsQuery
const firstChar = searchString[0]
const lastChar = searchString[searchString.length - 1]
// TODO Aditional sanitization/transformation?
if (!searchString) {
debug('query type: all')
hitsQuery = genEditorAllQuery(dictionaryId, filters)
} else if (!notOnlyAsterisks.test(searchString)) {
debug('query type: exists')
hitsQuery = genEditorExistsQuery(dictionaryId, searchField, filters)
} else if (firstChar === '"' && lastChar === '"') {
debug('query type: phrase')
searchString = searchString.slice(1, -1)
hitsQuery = genEditorPhraseQuery(
dictionaryId,
searchField,
searchString,
filters
)
} else if (hasWildcards.test(searchString)) {
debug('query type: wildcard')
hitsQuery = genEditorWildcardQuery(
dictionaryId,
searchField,
searchString,
filters
)
} else {
debug('query type: words')
hitsQuery = genEditorWordsQuery(
dictionaryId,
searchField,
searchString,
filters
)
}
return hitsQuery
}
// Determines query type, constructs and returns appropriate search engine query for consultancy.
exports.consultancy = (searchString, filters, hitsPerPage, page) => {
let hitsQuery
const firstChar = searchString[0]
const lastChar = searchString[searchString.length - 1]
// TODO Aditional sanitization/transformation?
if (!notOnlyAsterisks.test(searchString)) {
debug('query type: all')
hitsQuery = genConsultancyAllQuery(filters, hitsPerPage, page)
} else if (firstChar === '"' && lastChar === '"') {
debug('query type: phrase')
searchString = searchString.slice(1, -1)
hitsQuery = genConsultancyPhraseQuery(
searchString,
filters,
hitsPerPage,
page
)
} else if (hasWildcards.test(searchString)) {
debug('query type: wildcard')
hitsQuery = genConsultancyWildcardQuery(
searchString,
filters,
hitsPerPage,
page
)
} else {
debug('query type: words')
hitsQuery = genConsultancyWordsQuery(
searchString,
filters,
hitsPerPage,
page
)
}
return hitsQuery
}
@@ -0,0 +1,42 @@
module.exports = function (hitsQuery) {
// A shallow copy will prevent hitsQuery from being modified.
const queryDsl = { ...hitsQuery }
queryDsl.from = 0
queryDsl.size = 0
queryDsl.aggs = {
primaryDomains: {
terms: {
field: 'primaryDomain.id',
size: 100
}
},
dictionaries: {
terms: {
field: 'dictionary.id',
size: 200
}
},
sources: {
terms: {
field: 'source.code'
}
},
foreign: {
nested: {
path: 'foreignEntries'
},
aggs: {
targetLanguages: {
terms: {
field: 'foreignEntries.lang.id',
size: 50
}
}
}
}
}
return queryDsl
}
@@ -0,0 +1,80 @@
module.exports = function (searchString, hitsQuery) {
// A shallow copy will prevent hitsQuery from being modified.
const queryDsl = { ...hitsQuery }
queryDsl.from = 0
queryDsl.size = 0
queryDsl.aggs = {
primaryDomains: {
terms: {
field: 'primaryDomain.id',
size: 100
}
},
dictionaries: {
terms: {
field: 'dictionary.id',
size: 200
}
},
sources: {
terms: {
field: 'source.code'
}
},
slovenianHits: {
filter: {
multi_match: {
query: searchString,
type: 'phrase',
fields: [
'term',
'synonyms',
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
}
},
foreign: {
nested: {
path: 'foreignEntries'
},
aggs: {
targetLanguages: {
terms: {
field: 'foreignEntries.lang.id',
size: 50
}
},
foreignHits: {
filter: {
multi_match: {
query: searchString,
type: 'phrase',
fields: [
'foreignEntries.terms',
'foreignEntries.synonyms',
'foreignEntries.definition'
]
}
},
aggs: {
sourceLanguages: {
terms: {
field: 'foreignEntries.lang.id',
size: 50
}
}
}
}
}
}
}
return queryDsl
}
@@ -0,0 +1,68 @@
module.exports = function (
hitsQuery,
slovenianFieldQueries,
foreignFieldQueries
) {
// A shallow copy will prevent hitsQuery from being modified.
const queryDsl = { ...hitsQuery }
queryDsl.from = 0
queryDsl.size = 0
queryDsl.aggs = {
primaryDomains: {
terms: {
field: 'primaryDomain.id',
size: 100
}
},
dictionaries: {
terms: {
field: 'dictionary.id',
size: 200
}
},
sources: {
terms: {
field: 'source.code'
}
},
slovenianHits: {
filter: {
dis_max: {
queries: slovenianFieldQueries
}
}
},
foreign: {
nested: {
path: 'foreignEntries'
},
aggs: {
targetLanguages: {
terms: {
field: 'foreignEntries.lang.id',
size: 50
}
},
foreignHits: {
filter: {
dis_max: {
queries: foreignFieldQueries
}
},
aggs: {
sourceLanguages: {
terms: {
field: 'foreignEntries.lang.id',
size: 50
}
}
}
}
}
}
}
return queryDsl
}
@@ -0,0 +1,80 @@
module.exports = function (searchString, hitsQuery) {
// A shallow copy will prevent hitsQuery from being modified.
const queryDsl = { ...hitsQuery }
queryDsl.from = 0
queryDsl.size = 0
queryDsl.aggs = {
primaryDomains: {
terms: {
field: 'primaryDomain.id',
size: 100
}
},
dictionaries: {
terms: {
field: 'dictionary.id',
size: 200
}
},
sources: {
terms: {
field: 'source.code'
}
},
slovenianHits: {
filter: {
multi_match: {
query: searchString,
operator: 'and',
fields: [
'term',
'synonyms',
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
}
},
foreign: {
nested: {
path: 'foreignEntries'
},
aggs: {
targetLanguages: {
terms: {
field: 'foreignEntries.lang.id',
size: 50
}
},
foreignHits: {
filter: {
multi_match: {
query: searchString,
operator: 'and',
fields: [
'foreignEntries.terms',
'foreignEntries.synonyms',
'foreignEntries.definition'
]
}
},
aggs: {
sourceLanguages: {
terms: {
field: 'foreignEntries.lang.id',
size: 50
}
}
}
}
}
}
}
return queryDsl
}
@@ -0,0 +1,64 @@
// TODO Consider alternative paging when hits exceed 10000.
module.exports = function (filters, hitsPerPage, page) {
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
filter: [
{
term: {
isPublished: true
}
},
{
term: {
'dictionary.status': 'published'
}
}
]
}
},
sort: ['_score', 'term.sort', 'homonymSort']
}
if (filters.primaryDomains.length) {
queryDsl.query.bool.filter.push({
terms: {
'primaryDomain.id': filters.primaryDomains
}
})
}
if (filters.dictionaries.length) {
queryDsl.query.bool.filter.push({
terms: {
'dictionary.id': filters.dictionaries
}
})
}
if (filters.sources.length) {
queryDsl.query.bool.filter.push({
terms: {
'source.code': filters.sources
}
})
}
if (filters.targetLanguages.length) {
queryDsl.query.bool.filter.push({
nested: {
path: 'foreignEntries',
query: {
terms: {
'foreignEntries.lang.id': filters.targetLanguages
}
}
}
})
}
return queryDsl
}
@@ -0,0 +1,247 @@
const { separateSlovenianLanguage } = require('../..')
// TODO Consider alternative paging when hits exceed 10000.
module.exports = async function (searchString, filters, hitsPerPage, page) {
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
must: {
dis_max: {
queries: [
{
constant_score: {
filter: {
term: {
'term.keyword': {
value: searchString
}
}
},
boost: 6
}
},
{
constant_score: {
filter: {
match_phrase: {
term: searchString
}
},
boost: 5
}
},
{
constant_score: {
filter: {
nested: {
path: 'foreignEntries',
query: {
term: {
'foreignEntries.terms.keyword': {
value: searchString
}
}
}
}
},
boost: 4
}
},
{
constant_score: {
filter: {
nested: {
path: 'foreignEntries',
query: {
match_phrase: {
'foreignEntries.terms': searchString
}
}
}
},
boost: 3
}
},
{
constant_score: {
filter: {
dis_max: {
queries: [
{
match_phrase: {
synonyms: searchString
}
},
{
nested: {
path: 'foreignEntries',
query: {
match_phrase: {
'foreignEntries.synonyms': searchString
}
}
}
}
]
}
},
boost: 2
}
},
{
constant_score: {
filter: {
dis_max: {
queries: [
{
multi_match: {
query: searchString,
type: 'phrase',
fields: [
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
},
{
nested: {
path: 'foreignEntries',
query: {
match_phrase: {
'foreignEntries.definition': searchString
}
}
}
}
]
}
},
boost: 1
}
}
]
}
},
filter: [
{
term: {
isPublished: true
}
},
{
term: {
'dictionary.status': 'published'
}
}
]
}
},
sort: ['_score', 'term.sort', 'homonymSort']
}
if (filters.primaryDomains.length) {
queryDsl.query.bool.filter.push({
terms: {
'primaryDomain.id': filters.primaryDomains
}
})
}
if (filters.dictionaries.length) {
queryDsl.query.bool.filter.push({
terms: {
'dictionary.id': filters.dictionaries
}
})
}
if (filters.sources.length) {
queryDsl.query.bool.filter.push({
terms: {
'source.code': filters.sources
}
})
}
if (filters.targetLanguages.length) {
queryDsl.query.bool.filter.push({
nested: {
path: 'foreignEntries',
query: {
terms: {
'foreignEntries.lang.id': filters.targetLanguages
}
}
}
})
}
if (filters.sourceLanguages.length) {
const [foreignLanguages, isSlovenianSource] =
await separateSlovenianLanguage(filters.sourceLanguages)
const sourceLanguageFilter = {
dis_max: {
queries: []
}
}
if (isSlovenianSource) {
sourceLanguageFilter.dis_max.queries.push({
multi_match: {
query: searchString,
type: 'phrase',
fields: [
'term',
'synonyms',
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
})
}
if (foreignLanguages.length) {
sourceLanguageFilter.dis_max.queries.push({
nested: {
path: 'foreignEntries',
query: {
bool: {
must: [
{
terms: {
'foreignEntries.lang.id': foreignLanguages
}
},
{
multi_match: {
query: searchString,
type: 'phrase',
fields: [
'foreignEntries.terms',
'foreignEntries.synonyms',
'foreignEntries.definition'
]
}
}
]
}
}
}
})
}
queryDsl.query.bool.filter.push(sourceLanguageFilter)
}
return queryDsl
}
@@ -0,0 +1,207 @@
const { separateSlovenianLanguage, queryForField } = require('../..')
const whitespace = /\s+/
// TODO Consider alternative paging when hits exceed 10000.
module.exports = async function (searchString, filters, hitsPerPage, page) {
const searchTokens = searchString.split(whitespace)
const termQuery = queryForField('term', searchTokens)
const foreignTermQuery = queryForField('foreignEntries.terms', searchTokens)
const synonymsQuery = queryForField('synonyms', searchTokens)
const foreignSynonymsQuery = queryForField(
'foreignEntries.synonyms',
searchTokens
)
const labelQuery = queryForField('label', searchTokens)
const definitionQuery = queryForField('definition', searchTokens)
const otherQuery = queryForField('other', searchTokens)
const domainLabelsQuery = queryForField('domainLabels', searchTokens)
const linksQuery = queryForField('links', searchTokens)
const foreignDefinitionQuery = queryForField(
'foreignEntries.definition',
searchTokens
)
const slovenianFieldQueries = [
termQuery,
synonymsQuery,
labelQuery,
definitionQuery,
otherQuery,
domainLabelsQuery,
linksQuery
]
const foreignFieldQueries = [
foreignTermQuery,
foreignSynonymsQuery,
foreignDefinitionQuery
]
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
must: {
dis_max: {
queries: [
{
constant_score: {
filter: termQuery,
boost: 5
}
},
{
constant_score: {
filter: {
nested: {
path: 'foreignEntries',
query: foreignTermQuery
}
},
boost: 3
}
},
{
constant_score: {
filter: {
dis_max: {
queries: [
synonymsQuery,
{
nested: {
path: 'foreignEntries',
query: foreignSynonymsQuery
}
}
]
}
},
boost: 2
}
},
{
constant_score: {
filter: {
dis_max: {
queries: [
labelQuery,
definitionQuery,
otherQuery,
domainLabelsQuery,
linksQuery,
{
nested: {
path: 'foreignEntries',
query: foreignDefinitionQuery
}
}
]
}
},
boost: 1
}
}
]
}
},
filter: [
{
term: {
isPublished: true
}
},
{
term: {
'dictionary.status': 'published'
}
}
]
}
},
sort: ['_score', 'term.sort', 'homonymSort']
}
if (filters.primaryDomains.length) {
queryDsl.query.bool.filter.push({
terms: {
'primaryDomain.id': filters.primaryDomains
}
})
}
if (filters.dictionaries.length) {
queryDsl.query.bool.filter.push({
terms: {
'dictionary.id': filters.dictionaries
}
})
}
if (filters.sources.length) {
queryDsl.query.bool.filter.push({
terms: {
'source.code': filters.sources
}
})
}
if (filters.targetLanguages.length) {
queryDsl.query.bool.filter.push({
nested: {
path: 'foreignEntries',
query: {
terms: {
'foreignEntries.lang.id': filters.targetLanguages
}
}
}
})
}
if (filters.sourceLanguages.length) {
const [foreignLanguages, isSlovenianSource] =
await separateSlovenianLanguage(filters.sourceLanguages)
const sourceLanguageFilter = {
dis_max: {
queries: []
}
}
if (isSlovenianSource) {
sourceLanguageFilter.dis_max.queries.push(...slovenianFieldQueries)
}
if (foreignLanguages.length) {
sourceLanguageFilter.dis_max.queries.push({
nested: {
path: 'foreignEntries',
query: {
bool: {
must: [
{
terms: {
'foreignEntries.lang.id': foreignLanguages
}
},
{
dis_max: {
queries: foreignFieldQueries
}
}
]
}
}
}
})
}
queryDsl.query.bool.filter.push(sourceLanguageFilter)
}
return [queryDsl, slovenianFieldQueries, foreignFieldQueries]
}
@@ -0,0 +1,233 @@
const { separateSlovenianLanguage } = require('../..')
// TODO Consider alternative paging when hits exceed 10000.
module.exports = async function (searchString, filters, hitsPerPage, page) {
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
must: {
dis_max: {
queries: [
{
constant_score: {
filter: {
match: {
term: {
query: searchString,
operator: 'and'
}
}
},
boost: 5
}
},
{
constant_score: {
filter: {
nested: {
path: 'foreignEntries',
query: {
match: {
'foreignEntries.terms': {
query: searchString,
operator: 'and'
}
}
}
}
},
boost: 3
}
},
{
constant_score: {
filter: {
dis_max: {
queries: [
{
match: {
synonyms: {
query: searchString,
operator: 'and'
}
}
},
{
nested: {
path: 'foreignEntries',
query: {
match: {
'foreignEntries.synonyms': {
query: searchString,
operator: 'and'
}
}
}
}
}
]
}
},
boost: 2
}
},
{
constant_score: {
filter: {
dis_max: {
queries: [
{
multi_match: {
query: searchString,
operator: 'and',
fields: [
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
},
{
nested: {
path: 'foreignEntries',
query: {
match: {
'foreignEntries.definition': {
query: searchString,
operator: 'and'
}
}
}
}
}
]
}
},
boost: 1
}
}
]
}
},
filter: [
{
term: {
isPublished: true
}
},
{
term: {
'dictionary.status': 'published'
}
}
]
}
},
sort: ['_score', 'term.sort', 'homonymSort']
}
if (filters.primaryDomains.length) {
queryDsl.query.bool.filter.push({
terms: {
'primaryDomain.id': filters.primaryDomains
}
})
}
if (filters.dictionaries.length) {
queryDsl.query.bool.filter.push({
terms: {
'dictionary.id': filters.dictionaries
}
})
}
if (filters.sources.length) {
queryDsl.query.bool.filter.push({
terms: {
'source.code': filters.sources
}
})
}
if (filters.targetLanguages.length) {
queryDsl.query.bool.filter.push({
nested: {
path: 'foreignEntries',
query: {
terms: {
'foreignEntries.lang.id': filters.targetLanguages
}
}
}
})
}
if (filters.sourceLanguages.length) {
const [foreignLanguages, isSlovenianSource] =
await separateSlovenianLanguage(filters.sourceLanguages)
const sourceLanguageFilter = {
dis_max: {
queries: []
}
}
if (isSlovenianSource) {
sourceLanguageFilter.dis_max.queries.push({
multi_match: {
query: searchString,
operator: 'and',
fields: [
'term',
'synonyms',
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
})
}
if (foreignLanguages.length) {
sourceLanguageFilter.dis_max.queries.push({
nested: {
path: 'foreignEntries',
query: {
bool: {
must: [
{
terms: {
'foreignEntries.lang.id': foreignLanguages
}
},
{
multi_match: {
query: searchString,
operator: 'and',
fields: [
'foreignEntries.terms',
'foreignEntries.synonyms',
'foreignEntries.definition'
]
}
}
]
}
}
}
})
}
queryDsl.query.bool.filter.push(sourceLanguageFilter)
}
return queryDsl
}
@@ -0,0 +1,262 @@
const { separateSlovenianLanguage } = require('../..')
// TODO Consider alternative paging when hits exceed 10000.
module.exports = async function (searchString, filters, hitsPerPage, page) {
const queryDsl = {
from: hitsPerPage * (page - 1),
size: hitsPerPage,
query: {
bool: {
must: {
dis_max: {
queries: [
{
constant_score: {
filter: {
term: {
'term.keyword': {
value: searchString
}
}
},
boost: 6
}
},
{
constant_score: {
filter: {
match: {
term: {
query: searchString,
operator: 'and'
}
}
},
boost: 5
}
},
{
constant_score: {
filter: {
nested: {
path: 'foreignEntries',
query: {
term: {
'foreignEntries.terms.keyword': {
value: searchString
}
}
}
}
},
boost: 4
}
},
{
constant_score: {
filter: {
nested: {
path: 'foreignEntries',
query: {
match: {
'foreignEntries.terms': {
query: searchString,
operator: 'and'
}
}
}
}
},
boost: 3
}
},
{
constant_score: {
filter: {
dis_max: {
queries: [
{
match: {
synonyms: {
query: searchString,
operator: 'and'
}
}
},
{
nested: {
path: 'foreignEntries',
query: {
match: {
'foreignEntries.synonyms': {
query: searchString,
operator: 'and'
}
}
}
}
}
]
}
},
boost: 2
}
},
{
constant_score: {
filter: {
dis_max: {
queries: [
{
multi_match: {
query: searchString,
operator: 'and',
fields: [
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
},
{
nested: {
path: 'foreignEntries',
query: {
match: {
'foreignEntries.definition': {
query: searchString,
operator: 'and'
}
}
}
}
}
]
}
},
boost: 1
}
}
]
}
},
filter: [
{
term: {
isPublished: true
}
},
{
term: {
'dictionary.status': 'published'
}
}
]
}
},
sort: ['_score', 'term.sort', 'homonymSort']
}
if (filters.primaryDomains.length) {
queryDsl.query.bool.filter.push({
terms: {
'primaryDomain.id': filters.primaryDomains
}
})
}
if (filters.dictionaries.length) {
queryDsl.query.bool.filter.push({
terms: {
'dictionary.id': filters.dictionaries
}
})
}
if (filters.sources.length) {
queryDsl.query.bool.filter.push({
terms: {
'source.code': filters.sources
}
})
}
if (filters.targetLanguages.length) {
queryDsl.query.bool.filter.push({
nested: {
path: 'foreignEntries',
query: {
terms: {
'foreignEntries.lang.id': filters.targetLanguages
}
}
}
})
}
if (filters.sourceLanguages.length) {
const [foreignLanguages, isSlovenianSource] =
await separateSlovenianLanguage(filters.sourceLanguages)
const sourceLanguageFilter = {
dis_max: {
queries: []
}
}
if (isSlovenianSource) {
sourceLanguageFilter.dis_max.queries.push({
multi_match: {
query: searchString,
operator: 'and',
fields: [
'term',
'synonyms',
'label',
'definition',
'other',
'domainLabels',
'links'
]
}
})
}
if (foreignLanguages.length) {
sourceLanguageFilter.dis_max.queries.push({
nested: {
path: 'foreignEntries',
query: {
bool: {
must: [
{
terms: {
'foreignEntries.lang.id': foreignLanguages
}
},
{
multi_match: {
query: searchString,
operator: 'and',
fields: [
'foreignEntries.terms',
'foreignEntries.synonyms',
'foreignEntries.definition'
]
}
}
]
}
}
}
})
}
queryDsl.query.bool.filter.push(sourceLanguageFilter)
}
return queryDsl
}
+225
View File
@@ -0,0 +1,225 @@
const Portal = require('../../portal')
const { getSlovenianLanguageId } = require('..')
const slCollator = new Intl.Collator('sl', { sensitivity: 'base' })
// Return a new array, with slovenian language removed and isSlovenianSource set to true if present.
exports.separateSlovenianLanguage = async languages => {
const slovenianLanguageId = await getSlovenianLanguageId()
let isSlovenianSource = false
const foreignLanguages = languages.filter(language => {
if (language !== slovenianLanguageId) return true
isSlovenianSource = true
return false
})
return [foreignLanguages, isSlovenianSource]
}
// Simplify and enhance search engine's hits output into useful entries object.
exports.prepareEntries = hits => {
const entriesByCategory = hits.body.hits.hits.reduce(
(agg, hit) => {
const entry = hit._source
switch (hit._score) {
case 6:
case 5:
agg.byTerm.push(entry)
break
case 4:
case 3:
agg.byForeignTerm.push(entry)
break
case 2:
case 1:
agg.byOther.push(entry)
break
default:
agg.uncategorized.push(entry)
}
return agg
},
{ byTerm: [], byForeignTerm: [], byOther: [], uncategorized: [] }
)
return entriesByCategory
}
// Transform search engine's aggregation raw output into correct and friendly format.
exports.prepareAggregation = async aggregationRaw => {
const { aggregations, hits } = aggregationRaw.body
const hitsCount = hits.total.value
if (!hitsCount) return
const primaryDomainIds = aggregations.primaryDomains.buckets.map(
bucket => bucket.key
)
const dictionaryIds = aggregations.dictionaries.buckets.map(
bucket => bucket.key
)
const languageIdSet = new Set()
const refinedSourceLanguageBuckets = []
aggregations.foreign.targetLanguages.buckets.forEach(bucket =>
languageIdSet.add(bucket.key)
)
const slovenianHitCount = aggregations.slovenianHits?.doc_count
// "Match all" aggregation returns no slovenian hits and source languages.
if (slovenianHitCount) {
const slovenianLanguageId = await getSlovenianLanguageId()
const slovenianBucket = {
key: slovenianLanguageId,
doc_count: slovenianHitCount
}
let wasSlovenianBucketInserted = false
aggregations.foreign.foreignHits.sourceLanguages.buckets.forEach(bucket => {
if (!wasSlovenianBucketInserted && bucket.doc_count < slovenianHitCount) {
languageIdSet.add(slovenianLanguageId)
refinedSourceLanguageBuckets.push(slovenianBucket)
wasSlovenianBucketInserted = true
}
languageIdSet.add(bucket.key)
refinedSourceLanguageBuckets.push(bucket)
})
if (!wasSlovenianBucketInserted) {
languageIdSet.add(slovenianLanguageId)
refinedSourceLanguageBuckets.push(slovenianBucket)
}
} else if (slovenianHitCount === 0) {
aggregations.foreign.foreignHits.sourceLanguages.buckets.forEach(bucket => {
languageIdSet.add(bucket.key)
refinedSourceLanguageBuckets.push(bucket)
})
}
const languageIds = [...languageIdSet]
const names = await Portal.getSearchAggregateNames(
primaryDomainIds,
dictionaryIds,
languageIds
)
const aggregation = {
sourceLanguages: refinedSourceLanguageBuckets.map(
({ key: id, doc_count: hits }) => {
return { id, name: names.languages[id], hits }
}
),
targetLanguages: aggregations.foreign.targetLanguages.buckets.map(
({ key: id, doc_count: hits }) => {
return { id, name: names.languages[id], hits }
}
),
primaryDomains: aggregations.primaryDomains.buckets.map(
({ key: id, doc_count: hits }) => {
return { id, name: names.primaryDomains[id], hits }
}
),
dictionaries: aggregations.dictionaries.buckets.map(
({ key: id, doc_count: hits }) => {
return { id, name: names.dictionaries[id], hits }
}
),
sources: aggregations.sources.buckets.map(
({ key: id, doc_count: hits }) => {
return { id, name: id, hits }
}
)
}
sortAggregation(aggregation)
return aggregation
}
// Enhance aggregation for display in search filters.
exports.prepareSeachFilterData = (aggregation, filters) => {
const enhancedAggregation = {}
if (aggregation) {
for (const category of Object.keys(aggregation)) {
const enhancedCategoryMembers = aggregation[category].map(member => {
if (filters[category].includes(member.id)) member.checked = true
return member
})
enhancedAggregation[category] = enhancedCategoryMembers
}
}
return enhancedAggregation
}
// Maps editor search api field names into search engine ones.
exports.fieldMap = {
term: 'term',
synonyms: 'synonyms',
label: 'label',
definition: 'definition',
other: 'other',
domainLabels: 'domainLabels',
links: 'links',
foreignTerms: 'foreignEntries.terms',
foreignSynonyms: 'foreignEntries.synonyms',
foreignDefinition: 'foreignEntries.definition'
}
// Generate search engine DSL wildcard query fragment for a single field.
exports.queryForField = (field, searchTokens) => {
return {
bool: {
filter: searchTokens.map(token => {
return {
wildcard: {
[field]: {
value: token
}
}
}
})
}
}
}
// Simplify and enhance search engine's editor hits output into useful entries list.
exports.prepareEditorEntries = hits => {
const entries = hits.body.hits.hits.map(hit => {
const entry = hit._source
entry.foreignTerm = hit.fields['foreignEntries.terms']?.[0]
entry.commentActivityIndicator = hit.fields.commentActivityIndicator[0]
return entry
})
return entries
}
function sortAggregation(aggregation) {
for (const categoryBuckets of Object.values(aggregation)) {
categoryBuckets.sort(bucketCompareFn)
}
}
function bucketCompareFn(bucketA, bucketB) {
if (bucketA.hits !== bucketB.hits) return 0
return slCollator.compare(bucketA.name, bucketB.name)
}
// Simplify search engine's consultancy hits output into useful entries list.
exports.prepareConsultancyEntries = hits => {
const entries = hits.body.hits.hits.map(hit => hit._source)
return entries
}
+42
View File
@@ -0,0 +1,42 @@
exports.deserialize = {
userById(user) {
const deserializedUser = {
id: user.id,
userName: user.username,
firstName: user.first_name,
lastName: user.last_name,
email: user.email,
hitsPerPage: user.hits_per_page,
userRoles: user.user_roles,
assignedConsultancyEntries: user.assigned_consultancy_entries
}
return deserializedUser
},
user(userData) {
const deserializedData = {
id: userData.id,
userName: userData.username,
firstName: userData.first_name,
lastName: userData.last_name,
email: userData.email,
password: userData.password
}
return deserializedData
},
userRights(userData) {
const deserializedRights = {
id: userData.id,
userName: userData.username,
email: userData.email,
hasAdministration: userData.administration,
hasEditing: userData.editing,
hasTerminologyReview: userData.terminology_review,
hasLanguageReview: userData.language_review
}
return deserializedRights
}
}
+109
View File
@@ -0,0 +1,109 @@
const db = require('./db')
// const debug = require('debug')('termPortal:sync')
// Fetch all own published dictionaries from DB.
class InterInstanceSync {
static async listDictionaries() {
const indexSql = `
SELECT
d.id,
d.name_sl,
d.name_sl_short,
d.name_en,
d.author,
d.issn,
dp.name_sl domain_primary_name_sl,
ARRAY(
SELECT jsonb_build_object(
'name_sl', ds.name_sl,
'name_en', ds.name_en
)
FROM dictionary_domain_secondary dds
LEFT JOIN domain_secondary ds ON ds.id = dds.domain_secondary_id
WHERE dds.dictionary_id = d.id
) domain_secondary_names,
ARRAY(
SELECT l.code
FROM dictionary_language dl
LEFT JOIN language l ON l.id = dl.language_id
WHERE dl.dictionary_id = d.id
ORDER BY dl.selection_order
) language_codes,
d.time_created,
d.time_modified,
d.time_content_modified,
d.description,
d.entries_have_domain_labels,
d.entries_have_label,
d.entries_have_definition,
d.entries_have_synonyms,
d.entries_have_links,
d.entries_have_other,
d.entries_have_foreign_languages,
d.entries_have_foreign_definitions,
d.entries_have_foreign_synonyms,
d.entries_have_images,
d.entries_have_audio,
d.entries_have_videos,
d.entries_have_terminology_review_flag,
d.entries_have_language_review_flag
FROM dictionary d
LEFT JOIN domain_primary dp ON dp.id = d.domain_primary_id
LEFT JOIN linked_dictionary ld on ld.target_dictionary_id = d.id
WHERE ld.id IS NULL AND d.status = 'published'
ORDER BY id
`
const { rows: dictionaryList } = await db.query(indexSql)
return dictionaryList
}
/**
* Fetch all published (complete) entries for given dictionary that were modified after since date.
* Entries shall include list of translations.
* @param dictionaryId
* @param since
* @returns {Promise<*>}
*/
static async getUpdatedEntriesSince(dictionaryId, since) {
const sqlEntries =
'SELECT id, term, label, definition, synonym FROM entry' +
" WHERE dictionary_id= $1 AND time_modified > $2 AND status='complete' AND is_published IS TRUE"
const sqlTranslations =
'SELECT l.code AS language_code, ef.entry_id, ef.term, ef.definition, ef.synonym FROM entry_foreign AS ef' +
' INNER JOIN language AS l ON l.id = ef.language_id' +
' WHERE entry_id = ANY($1::int[]) ORDER BY ef.entry_id, l.code'
const { rows: entryList } = await db.query(sqlEntries, [
dictionaryId,
since
])
const entryIds = entryList.map(e => {
return e.id
})
const { rows: translationList } = await db.query(sqlTranslations, [
entryIds
])
let currentEntryId = 0
let lastEntryId = 0
translationList.forEach(t => {
currentEntryId = t.entry_id
const entry = entryList.find(e => {
return e.id === currentEntryId
})
if (!entry) return
if (lastEntryId === 0) {
entry.translations = []
lastEntryId = t.entry_id
} else if (currentEntryId !== lastEntryId) {
// new entry translations : save previous
entry.translations = []
lastEntryId = currentEntryId
currentEntryId = t.entry_id
}
entry.translations.push(t)
})
return entryList
}
}
module.exports = InterInstanceSync
+486
View File
@@ -0,0 +1,486 @@
const db = require('./db')
const { aggregateSettings, deserialize } = require('./helpers/portal')
const Portal = {}
Portal.fetchInstanceSettings = async () => {
const text = `
SELECT
name, value
FROM
instance_settings
WHERE
name
IN (
'portal_name',
'portal_code',
'portal_description',
'is_consultancy_enabled',
'is_dictionaries_enabled',
'is_extraction_enabled')`
const { rows: fetchedSettings } = await db.query(text)
const aggregatedSettings = aggregateSettings(fetchedSettings)
const deserializedSettings = deserialize.settings(aggregatedSettings)
return deserializedSettings
}
Portal.updateInstaceSettings = async payload => {
const isExtractionEnabled = payload.isExtractionEnabled ? 'T' : 'F'
const isDictionariesEnabled = payload.isDictionariesEnabled ? 'T' : 'F'
const isConsultancyEnabled = payload.isConsultancyEnabled ? 'T' : 'F'
const values = [
payload.portalName,
payload.portalCode,
payload.portalDescription,
isExtractionEnabled,
isDictionariesEnabled,
isConsultancyEnabled
]
const text = `
UPDATE
instance_settings
SET
value
= CASE name
WHEN
'portal_name' THEN $1
WHEN
'portal_code' THEN $2
WHEN
'portal_description' THEN $3
WHEN
'is_extraction_enabled' THEN $4
WHEN
'is_dictionaries_enabled' THEN $5
WHEN
'is_consultancy_enabled' THEN $6
ELSE value
END`
await db.query(text, values)
}
Portal.fetchInstanceDictSettings = async () => {
const text = `
SELECT
name, value
FROM
instance_settings
WHERE
name
IN (
'min_entries_per_dictionary',
'dictionary_publish_approval',
'num_of_history_entires_per_entry',
'can_publish_entries_in_edit')`
const { rows: fetchedSettings } = await db.query(text)
const aggregatedSettings = aggregateSettings(fetchedSettings)
const deserializedSettings = deserialize.dictSettings(aggregatedSettings)
return deserializedSettings
}
Portal.updateInstaceDictSettings = async payload => {
const dictionaryPublish = payload.dictionaryPublishApproval ? 'T' : 'F'
const canPublishEntriesInEdit = payload.canPublishEntriesInEdit ? 'T' : 'F'
const values = [
payload.minEntriesPerDictionary,
dictionaryPublish,
// payload.keepNumOfExportsPerDict,
// payload.dictionaryAutoSaveFrequency,
payload.numOfHistoryEntriesPerEntry,
canPublishEntriesInEdit
]
const text = `
UPDATE
instance_settings
SET
value
= CASE name
WHEN
'min_entries_per_dictionary' THEN $1
WHEN
'dictionary_publish_approval' THEN $2
WHEN
'num_of_history_entires_per_entry' THEN $3
WHEN
'can_publish_entries_in_edit' THEN $4
ELSE value
END`
await db.query(text, values)
}
Portal.fetchInstanceConsultancySettings = async () => {
const text = `
SELECT
name, value
FROM
instance_settings
WHERE
name
IN (
'consultancy_type',
'zrc_email',
'zrc_url')`
const { rows: fetchedSettings } = await db.query(text)
const aggregatedSettings = aggregateSettings(fetchedSettings)
const deserializedSettings = deserialize.consultSettings(aggregatedSettings)
return deserializedSettings
}
Portal.updateInstaceConsultancySettings = async payload => {
const values = [payload.consultancyType, payload.zrcEmail, payload.zrcURL]
const text = `
UPDATE
instance_settings
SET
value
= CASE name
WHEN
'consultancy_type' THEN $1
WHEN
'zrc_email' THEN $2
WHEN
'zrc_url' THEN $3
ELSE value
END`
await db.query(text, values)
}
Portal.create = async portal => {
const isEnabled = true
const values = [
portal.name,
portal.url_update,
portal.url_index,
portal.code,
isEnabled
]
const text = `
INSERT INTO linked_portal (
name,
url_update,
url_index,
code,
is_enabled
)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`
const { rows } = await db.query(text, values)
return rows
}
Portal.fetchAll = async () => {
const text = `
SELECT
id,
name,
url_index,
is_enabled,
code,
time_last_synced,
EXISTS (SELECT ld.name
FROM linked_dictionary as ld
WHERE lp.id = ld.linked_portal_id) is_linked
FROM linked_portal as lp
ORDER BY id`
const { rows: fetchedConnections } = await db.query(text)
const deserializedConnections = fetchedConnections.map(connection =>
deserialize.connections(connection)
)
return deserializedConnections
}
Portal.fetchPortal = async portalId => {
const value = [portalId]
const text = `
SELECT
name,
url_update,
url_index,
code
FROM
linked_portal
WHERE
id = $1`
const { rows } = await db.query(text, value)
const fetchedPortal = rows[0]
const deserializedPortal = deserialize.connections(fetchedPortal)
return deserializedPortal
}
Portal.update = async (portalId, payload) => {
const values = [
payload.name,
payload.code,
payload.url_update,
payload.url_index,
portalId
]
const text = `
UPDATE
linked_portal
SET
name = $1,
code = $2,
url_update = $3,
url_index = $4
WHERE id = $5`
await db.query(text, values)
}
Portal.syncRemoteDictionaries = async (linkedPortalId, dictionaries) => {
const text = 'SELECT sync_remote_dictionaries($1, $2)'
const values = [linkedPortalId, dictionaries]
await db.query(text, values)
}
Portal.deleteLinkedDictionary = async linkedPortalId => {
const text = `DELETE from linked_portal WHERE id = $1`
const value = [linkedPortalId]
await db.query(text, value)
}
Portal.fetchDictionaries = async portalId => {
const value = [portalId]
const text = `
SELECT
name,
target_dictionary_id
FROM linked_dictionary
WHERE linked_portal_id = $1`
const { rows: fetchedDictionaries } = await db.query(text, value)
return fetchedDictionaries
}
Portal.fetchSelectedLinkedDictionaries = async (id, resultsPerPage, page) => {
const {
rows: [{ result }]
} = await db.query(
`
SELECT jsonb_build_object(
'pages_total', (
SELECT CEIL(COUNT(*) / $2::float)
FROM linked_dictionary
WHERE linked_dictionary.linked_portal_id = $1
),
'results', ARRAY(
SELECT jsonb_build_object(
'id', linked_dictionary.id,
'name', linked_dictionary.name,
'isEnabled', linked_dictionary.is_enabled,
'code', linked_portal.code
)
FROM linked_dictionary
LEFT JOIN linked_portal
ON linked_dictionary.linked_portal_id = linked_portal.id
WHERE linked_dictionary.linked_portal_id = $1
ORDER BY linked_dictionary.id
LIMIT $2
OFFSET $3
)
) result`,
[id, resultsPerPage, resultsPerPage * (page - 1)]
)
return result
}
Portal.updateSelectedDictionaries = async (id, body) => {
const portalId = id
let enableArr = body.isEnabled ? Object.keys(body.isEnabled) : []
const dbClient = await db.getClient()
try {
await dbClient.query('BEGIN')
const setAllToFalse = `
UPDATE linked_dictionary
SET is_enabled = false
WHERE linked_portal_id = $1`
await dbClient.query(setAllToFalse, [portalId])
enableArr = enableArr.map(
linkedDictionaryId => +linkedDictionaryId.replaceAll("'", '')
)
const text = `
UPDATE linked_dictionary
SET is_enabled = true
WHERE id = ANY ($1)
AND linked_portal_id = $2`
const values = [enableArr, portalId]
await dbClient.query(text, values)
await dbClient.query('COMMIT')
} catch (error) {
await dbClient.query('ROLLBACK')
throw error
} finally {
dbClient.release()
}
}
Portal.fetchAllLinkedDictionaries = async (resultsPerPage, page) => {
const {
rows: [{ result }]
} = await db.query(
`
SELECT jsonb_build_object(
'pages_total', (
SELECT CEIL(COUNT(*) / $1::float)
FROM linked_dictionary
),
'results', ARRAY(
SELECT jsonb_build_object(
'id', linked_dictionary.id,
'name', linked_dictionary.name,
'isEnabled', linked_dictionary.is_enabled,
'code', linked_portal.code
)
FROM linked_dictionary
LEFT JOIN linked_portal
ON linked_dictionary.linked_portal_id = linked_portal.id
ORDER BY linked_portal.id
LIMIT $1
OFFSET $2
)
) result`,
[resultsPerPage, resultsPerPage * (page - 1)]
)
return result
}
Portal.updateAllDictionaries = async body => {
const enableArr = Object.keys(body.isEnabled)
const dbClient = await db.getClient()
try {
await dbClient.query('BEGIN')
const setAllToFalse = `
UPDATE linked_dictionary
SET is_enabled = false`
await dbClient.query(setAllToFalse)
const enableAllDictionary = enableArr.map(async userId => {
userId = +userId.replaceAll("'", '')
const text = `
UPDATE linked_dictionary
SET is_enabled = true
WHERE id = $1`
const values = [userId]
await dbClient.query(text, values)
})
await Promise.all(enableAllDictionary)
await dbClient.query('COMMIT')
} catch (error) {
await dbClient.query('ROLLBACK')
throw error
} finally {
dbClient.release()
}
}
Portal.updatePortalStatus = async (portalId, isEnabled) => {
const values = [isEnabled, portalId]
const text = `
UPDATE linked_portal
SET is_enabled = $1
WHERE id = $2`
await db.query(text, values)
}
Portal.getInstanceSettingValue = async settingName => {
const values = [settingName]
const text = `
SELECT value
FROM instance_settings
WHERE name = $1`
const { rows } = await db.query(text, values)
const settingValue = rows[0].value
return settingValue
}
Portal.fetchAllInstanceSettingNames = async () => {
const { rows } = await db.query('SELECT name FROM instance_settings')
const settingNames = rows.map(row => row.name)
return settingNames
}
Portal.getSlovenianLanguageId = async () => {
const { rows } = await db.query("SELECT id FROM language WHERE code = 'sl'")
const id = rows[0].id
return id
}
Portal.getSearchAggregateNames = async (
primaryDomainIds,
dictionaryIds,
languageIds
) => {
const text = `
SELECT jsonb_build_object(
'primaryDomains', jsonb_object(
ARRAY(
SELECT ARRAY [id, name_sl]::TEXT[]
FROM domain_primary
WHERE id = ANY ($1)
)
),
'dictionaries', jsonb_object(
ARRAY(
SELECT ARRAY [id, name_sl]::TEXT[]
FROM dictionary
WHERE id = ANY ($2)
)
),
'languages', jsonb_object(
ARRAY(
SELECT ARRAY [id, name_sl]::TEXT[]
FROM language
WHERE id = ANY ($3)
)
)
) "names"
`
const values = [primaryDomainIds, dictionaryIds, languageIds]
const {
rows: [{ names }]
} = await db.query(text, values)
return names
}
module.exports = Portal
+244
View File
@@ -0,0 +1,244 @@
const debug = require('debug')('termPortal:models/search-engine')
const { Client } = require('@opensearch-project/opensearch')
const ENTRY_INDEX = 'entry'
const CONSULTANCY_ENTRY_INDEX = 'consultancy_entry'
const client = new Client({ node: 'http://opensearch:9200' })
exports.initEntryIndex = async () => {
const { statusCode } = await client.indices.exists({ index: ENTRY_INDEX })
const doesIndexExist = statusCode === 200
if (doesIndexExist) {
debug('Entry search index already exists.')
return
}
await client.indices.create({
index: ENTRY_INDEX,
body: {
settings: {
number_of_replicas: 0
},
mappings: {
dynamic: 'strict',
properties: {
id: { type: 'keyword', index: false, doc_values: false },
isValid: { type: 'boolean', doc_values: false },
isPublished: { type: 'boolean', doc_values: false },
isTerminologyReviewed: { type: 'boolean', doc_values: false },
isLanguageReviewed: { type: 'boolean', doc_values: false },
status: { type: 'keyword', doc_values: false },
term: {
type: 'text',
doc_values: false,
fields: {
keyword: { type: 'keyword', doc_values: false },
sort: {
type: 'icu_collation_keyword',
index: false,
language: 'sl',
country: 'SI'
}
}
},
homonymSort: { type: 'keyword', index: false },
label: { type: 'text', doc_values: false },
definition: { type: 'text', doc_values: false },
synonyms: { type: 'text', doc_values: false },
other: { type: 'text', doc_values: false },
timeMostRecentComment: { type: 'date' },
domainLabels: { type: 'text', doc_values: false },
links: { type: 'text', doc_values: false },
foreignEntries: {
type: 'nested',
properties: {
lang: {
properties: {
id: { type: 'keyword' },
code: { type: 'keyword', index: false, doc_values: false },
nameSl: { type: 'keyword', index: false, doc_values: false },
nameEn: { type: 'keyword', index: false, doc_values: false }
}
},
terms: {
type: 'text',
doc_values: false,
fields: {
keyword: { type: 'keyword', doc_values: false }
}
},
definition: { type: 'text', doc_values: false },
synonyms: { type: 'text', doc_values: false }
}
},
primaryDomain: {
properties: {
id: { type: 'keyword' },
nameSl: { type: 'keyword', index: false, doc_values: false },
nameEn: { type: 'keyword', index: false, doc_values: false }
}
},
dictionary: {
properties: {
id: { type: 'keyword' },
nameSl: { type: 'keyword', index: false, doc_values: false },
nameSlShort: { type: 'keyword', index: false, doc_values: false },
nameEn: { type: 'keyword', index: false, doc_values: false },
status: { type: 'keyword', doc_values: false }
}
},
source: {
properties: {
code: { type: 'keyword' },
name: { type: 'keyword', index: false, doc_values: false }
}
}
}
}
}
})
debug('Entry search index created.')
}
exports.initConsultancyEntryIndex = async () => {
const { statusCode } = await client.indices.exists({
index: CONSULTANCY_ENTRY_INDEX
})
const doesIndexExist = statusCode === 200
if (doesIndexExist) {
debug('Consultancy entry search index already exists.')
return
}
await client.indices.create({
index: CONSULTANCY_ENTRY_INDEX,
body: {
settings: {
number_of_replicas: 0
},
mappings: {
dynamic: 'strict',
properties: {
id: { type: 'keyword', index: false, doc_values: false },
timeCreated: { type: 'date', index: false },
status: { type: 'keyword', doc_values: false },
description: { type: 'text', index: false, doc_values: false },
title: { type: 'text', doc_values: false },
question: { type: 'text', doc_values: false },
answer: { type: 'text', doc_values: false },
answerAuthors: { type: 'keyword', index: false, doc_values: false },
primaryDomain: {
properties: {
id: { type: 'keyword', doc_values: false },
nameSl: { type: 'keyword', index: false, doc_values: false },
nameEn: { type: 'keyword', index: false, doc_values: false }
}
},
assignedConsultants: {
properties: {
id: { type: 'keyword', doc_values: false },
firstName: { type: 'keyword', index: false, doc_values: false },
lastName: { type: 'keyword', index: false, doc_values: false },
isModerator: { type: 'boolean', index: false, doc_values: false }
}
}
}
}
}
})
debug('Consultancy entry search index created.')
}
exports.waitForConnection = () => {
return new Promise(resolve => {
async function testConnection() {
try {
// eslint-disable-next-line no-console
console.log('Verifying connection to search engine server')
await client.ping()
// eslint-disable-next-line no-console
console.log('Connection to search engine server verified')
resolve()
} catch {
// eslint-disable-next-line no-console
console.error('Could not connect to search engine server')
setTimeout(testConnection, 1000)
}
}
testConnection()
})
}
exports.searchEngineClient = client
exports.ENTRY_INDEX = ENTRY_INDEX
exports.CONSULTANCY_ENTRY_INDEX = CONSULTANCY_ENTRY_INDEX
// A wrapper, to simplify querying the entry index.
exports.searchEntryIndex = async query => {
const hits = await client.search({
index: ENTRY_INDEX,
filter_path: 'hits,aggregations',
body: query
})
return hits
}
// A wrapper, to simplify querying the consultancy entry index.
exports.searchConsultancyEntryIndex = async query => {
const hits = await client.search({
index: CONSULTANCY_ENTRY_INDEX,
filter_path: 'hits',
body: query
})
return hits
}
exports.deleteEntryFromIndex = async (entryId, shouldWait) => {
await client.delete({
index: ENTRY_INDEX,
id: entryId,
refresh: shouldWait ? 'wait_for' : false
})
}
exports.deleteConsultancyEntryFromIndex = async (entryId, shouldWait) => {
await client.delete({
index: CONSULTANCY_ENTRY_INDEX,
id: entryId,
refresh: shouldWait ? 'wait_for' : false
})
}
exports.deleteDictionaryEntriesFromIndex = async dictionaryId => {
await client.deleteByQuery({
index: ENTRY_INDEX,
body: {
query: {
term: {
'dictionary.id': {
value: dictionaryId
}
}
}
}
})
}
exports.deleteConsultancyEntriesFromIndex = async () => {
await client.deleteByQuery({
index: CONSULTANCY_ENTRY_INDEX,
body: {
query: {
match_all: {}
}
}
})
}
+183
View File
@@ -0,0 +1,183 @@
const db = require('../../models/db')
const Cursor = require('pg-cursor')
const axios = require('axios').default
const xmlFlow = require('xml-flow')
const { removeHtmlTags } = require('../helpers')
const Eurotermbank = {}
// Push all changes to Eurotermbank.
Eurotermbank.push = async () => {
const dbClient = await db.getClient()
try {
const { rows: dictrionariesToSync } = await dbClient.query(
'SELECT id, name_sl FROM dictionary ORDER BY id'
)
for (const { id, name_sl: name } of dictrionariesToSync) {
// Create/update collection metadata.
await centralSyncApi.put(id.toString(), {
name,
domainid: 2841
})
const text = `
SELECT
id,
term,
definition,
ARRAY(
SELECT name
FROM entry_domain_label edl
LEFT JOIN domain_label dl ON dl.id = edl.domain_label_id
WHERE entry_id = e.id
) domain_labels,
ARRAY(
SELECT jsonb_build_object(
'link', link,
'type', type)
FROM entry_link
WHERE entry_id = e.id
) links,
ARRAY(
SELECT jsonb_build_object(
'lang_code', l.code,
'terms', ef.term,
'definition', ef.definition,
'synonyms', ef.synonym)
FROM entry_foreign ef
LEFT JOIN LANGUAGE l ON l.id = ef.language_id
WHERE entry_id = e.id
) foreign_entries
FROM entry e
WHERE
e.dictionary_id = $1
AND is_valid`
const values = [id]
const cursor = dbClient.query(new Cursor(text, values))
let entries = []
do {
// Keep getting and sending entries in batches of 100.
entries = await cursor.read(100)
if (entries.length) {
const tbxPayload = generateTbxPayload(entries)
await centralSyncApi.post(`${id}/entries`, tbxPayload, {
headers: { 'content-type': 'application/xml' }
})
}
} while (entries.length === 100)
}
} finally {
dbClient.release()
}
}
const centralSyncApi = axios.create({
baseURL:
'https://test-fedterm.eurotermbank.com/api/termservice/sync/collection/external/',
auth: {
username: 'SlovenianNTP',
password: '3l063Ni=p0tr4l(t3rm'
}
})
function generateTbxPayload(entries) {
// Opening boilerplate.
let tbxPayload =
'<?xml version="1.0" encoding="utf-8"?><!DOCTYPE martif SYSTEM "https://eurotermbank.com/TBXcoreStructV02%20%281%29.dtd"><martif type="TBX" xml:lang="en"><martifHeader><fileDesc><sourceDesc><p>Sync collection sample</p></sourceDesc></fileDesc><encodingDesc><p type="XCSURI">https://eurotermbank.com/tbx-0.5.1.xcs</p></encodingDesc></martifHeader><text><body>'
// Entry content TBX.
tbxPayload += entries.reduce(
(payload, entry) => (payload += generateEntryTbx(entry)),
''
)
// Closing boilerplate.
tbxPayload += '</body></text></martif>'
return tbxPayload
}
function generateEntryTbx(entry) {
const entryObj = {
$name: 'termEntry',
$attrs: { id: entry.id },
$markup: [
{
$name: 'langSet',
$attrs: { 'xml:lang': 'sl' },
$markup: [
{
$name: 'ntig',
$markup: [
{
$name: 'termGrp',
$markup: [{ $name: 'term', $text: removeHtmlTags(entry.term) }]
}
]
}
]
}
]
}
if (entry.definition) {
const definitionObj = {
$name: 'descripGrp',
$markup: [
{
$name: 'descrip',
$attrs: { type: 'definition' },
$text: removeHtmlTags(entry.definition)
}
]
}
entryObj.$markup[0].$markup.push(definitionObj)
}
entry.foreign_entries.forEach(fEntry => {
const fEntryObj = {
$name: 'langSet',
$attrs: { 'xml:lang': fEntry.lang_code },
$markup: [
{
$name: 'ntig',
$markup: [
{
$name: 'termGrp',
$markup: [
{ $name: 'term', $text: removeHtmlTags(fEntry.terms[0]) }
]
}
]
}
]
}
if (fEntry.definition) {
const fDefinitionObj = {
$name: 'descripGrp',
$markup: [
{
$name: 'descrip',
$attrs: { type: 'definition' },
$text: removeHtmlTags(fEntry.definition)
}
]
}
fEntryObj.$markup.push(fDefinitionObj)
}
entryObj.$markup.push(fEntryObj)
})
return xmlFlow.toXml(entryObj)
}
module.exports = Eurotermbank
+470
View File
@@ -0,0 +1,470 @@
const db = require('./db')
const bcrypt = require('bcrypt')
const uid = require('uid-safe')
const { deserialize } = require('./helpers/user')
const User = {}
// Create new user in DB.
User.create = async user => {
const SALT_ROUNDS = 12
const bcryptHash = await bcrypt.hash(user.password, SALT_ROUNDS)
const values = [
user.username || null,
user.firstName || null,
user.lastName || null,
user.email || null,
bcryptHash || null
]
const text = `INSERT INTO "user" (
username,
first_name,
last_name,
email,
bcrypt_hash
)
VALUES (${db.genParamStr(values)})
RETURNING id`
const { rows } = await db.query(text, values)
const userId = rows[0].id
return userId
}
// Save an activation token for a single user in DB.
User.saveActivationToken = async (userId, activationToken) => {
await db.query(
'INSERT INTO user_token_activation (token, user_id) VALUES ($1, $2)',
[activationToken, userId]
)
}
// Fetch user from DB by (valid) activation token.
User.fetchByActivationToken = async activationToken => {
const TOKEN_VALID_PERIOD = '1 week'
const text = `
SELECT u.id
FROM user_token_activation t
INNER JOIN "user" u ON u.id = t.user_id
WHERE
t.token = $1
AND AGE(NOW(), t.time_created) < INTERVAL '${TOKEN_VALID_PERIOD}'
`
const values = [activationToken]
const { rows } = await db.query(text, values)
const user = rows[0]
// TODO Perhaps suggest to the user to request another one and make a shortcut.
if (!user) throw Error('Povezava je neveljavna ali pa je že potekla')
return user
}
// Activate user account.
User.activateAccount = async user => {
await db.query(`UPDATE "user" SET status = 'active' WHERE id = $1`, [user.id])
}
// Generate a user remember me token.
User.generateRememberMeToken = async () => {
const token = await uid(32)
return token
}
// Save a remember me token for a single user in DB.
User.saveRememberMeToken = async (user, rememberMeToken) => {
await db.query(
'INSERT INTO user_token_remember_me (token, user_id) VALUES ($1, $2)',
[rememberMeToken, user.id]
)
}
// Remove a specific remember me token from DB.
User.clearRememberMeToken = async rememberMeToken => {
await db.query('DELETE FROM user_token_remember_me WHERE token = $1', [
rememberMeToken
])
}
// Fetch user data that should be available on every request from DB by id.
User.fetchDeserializedDataById = async userId => {
const text = `
SELECT
u.id,
u.username,
u.first_name,
u.last_name,
u.email,
u.hits_per_page,
ARRAY(
SELECT jsonb_build_object(
'roleName', r.role_name,
'dictionaryId', r.dictionary_id,
'administration', r.administration,
'terminologyReview', r.terminology_review,
'languageReview', r.language_review,
'editing', r.editing)
FROM user_role r
WHERE r.user_id = u.id
) user_roles,
ARRAY(
SELECT jsonb_build_object(
'id', cr.entry_id,
'isModerator', cr.is_moderator)
FROM consultancy_entry_consultant cr
WHERE cr.user_id = u.id
) assigned_consultancy_entries
FROM "user" u
WHERE
u.id = $1`
const values = [userId]
const {
rows: [user]
} = await db.query(text, values)
const deserializedUser = deserialize.userById(user)
return deserializedUser
}
// Fetch all registered users on the portal
User.fetchAll = async (resultsPerPage, page) => {
const {
rows: [{ result }]
} = await db.query(
`
SELECT jsonb_build_object(
'pages_total', (
SELECT CEIL(COUNT(*) / $1::float)
FROM "user"
),
'results', ARRAY(
SELECT jsonb_build_object(
'id', id,
'userName', username,
'email', email,
'status', status
)
FROM "user"
ORDER BY username
LIMIT $1
OFFSET $2
)
) result
`,
[resultsPerPage, resultsPerPage * (page - 1)]
)
return result
}
// Fetch all users with at least one portal role along with all their portal roles.
User.fetchAllWithPortalRoles = async () => {
const text = `
SELECT u.id,
u.username,
u.email,
jsonb_build_object(
'isPortalAdmin', 'portal admin' = ANY (r.roles_array),
'isDictionariesAdmin', 'dictionaries admin' = ANY (r.roles_array),
'isConsultancyAdmin', 'consultancy admin' = ANY (r.roles_array)
) roles
FROM (
SELECT user_id, array_agg(role_name) roles_array
FROM user_role
WHERE role_name IN ('portal admin', 'dictionaries admin', 'consultancy admin')
GROUP BY user_id
) r
LEFT JOIN "user" u ON u.id = r.user_id
ORDER BY u.id
`
const { rows: users } = await db.query(text)
return users
}
// Find searched user with specific username or email
User.findByUsernameOrEmail = async userNameEmail => {
const text = `
SELECT
id,
username,
email
FROM "user"
WHERE username=$1
OR email=$1`
const value = [userNameEmail]
const { rows: user } = await db.query(text, value)
return user
}
// Delete relevant portal roles and set new ones.
User.updatePortalRoles = async rolesPerUser => {
const rolesPerUserArr = Object.entries(rolesPerUser)
const dbClient = await db.getClient()
try {
await dbClient.query('BEGIN')
const removeAllPortalRolesQuery = `
DELETE FROM user_role
WHERE role_name IN ('portal admin', 'dictionaries admin', 'consultancy admin')`
await dbClient.query(removeAllPortalRolesQuery)
const updateRoles = rolesPerUserArr.map(async ([userId, roles]) => {
userId = +userId.replaceAll("'", '')
const rolesArr = []
if (roles.isPortalAdmin) rolesArr.push('portal admin')
if (roles.isDictionariesAdmin) rolesArr.push('dictionaries admin')
if (roles.isConsultancyAdmin) {
rolesArr.push('consultancy admin')
rolesArr.push('consultant')
}
const updateRolesPerUser = rolesArr.map(async roleName => {
const text = `
INSERT INTO user_role (user_id, role_name)
VALUES ($1, $2)`
const values = [userId, roleName]
await dbClient.query(text, values)
})
await Promise.all(updateRolesPerUser)
})
await Promise.all(updateRoles)
const countAdminsQuery = `
SELECT COUNT(*) portal_admins_count
FROM user_role
WHERE role_name = 'portal admin'`
const { rows } = await dbClient.query(countAdminsQuery)
const portalAdminsCount = +rows[0].portal_admins_count
if (!portalAdminsCount) throw Error('Can not delete all portal admins')
await dbClient.query('COMMIT')
} catch (error) {
await dbClient.query('ROLLBACK')
throw error
} finally {
dbClient.release()
}
}
User.fetchUser = async userId => {
const text = `
SELECT id, username, first_name, last_name, email
FROM "user"
WHERE id=$1`
const value = [userId]
const { rows } = await db.query(text, value)
const fetchedUser = rows[0]
const deserializedUserData = deserialize.user(fetchedUser)
return deserializedUserData
}
User.updateUser = async (userId, payload) => {
const text = `
UPDATE "user"
SET
username = $2,
first_name = $3,
last_name = $4
WHERE id = $1`
const values = [userId, payload.username, payload.firstName, payload.lastName]
await db.query(text, values)
}
User.fetchUserRoles = async userId => {
const text = `
SELECT
jsonb_build_object(
'isPortalAdmin', 'portal admin' = ANY (r.roles_array),
'isDictionariesAdmin', 'dictionaries admin' = ANY (r.roles_array),
'isConsultancyAdmin', 'consultancy admin' = ANY (r.roles_array),
'isConsultant', 'consultant' = ANY (r.roles_array),
'isEditor', 'editor' = ANY (r.roles_array)
) roles
FROM (
SELECT user_id, array_agg(role_name) roles_array
FROM user_role
WHERE role_name IN ('portal admin', 'dictionaries admin', 'consultancy admin', 'consultant', 'editor')
GROUP BY user_id
) r
LEFT JOIN "user" u ON u.id = r.user_id
WHERE u.id=$1`
const value = [userId]
const { rows } = await db.query(text, value)
const fetchedUser = rows[0]
return fetchedUser
}
User.fetchAllWithDictionaryRights = async dictionaryId => {
const text = `
SELECT u.username,u.email, u.id, r.administration, r.editing, r.terminology_review, r.language_review
FROM user_role r
INNER JOIN "user" u ON u.id = r.user_id
WHERE dictionary_id = $1
ORDER BY u.username`
const value = [dictionaryId]
const { rows: fetchedUserRights } = await db.query(text, value)
const deserializedRights = fetchedUserRights.map(user =>
deserialize.userRights(user)
)
return deserializedRights
}
User.updateUserRights = async (dictionaryId, rightsPerUser) => {
const rightsPerUserArr = Object.entries(rightsPerUser)
const dbClient = await db.getClient()
try {
await dbClient.query('BEGIN')
const value = [dictionaryId]
const text = `
DELETE FROM user_role
WHERE dictionary_id = $1`
await dbClient.query(text, value)
const roleName = 'editor'
const updateRights = rightsPerUserArr.map(async ([userId, roles]) => {
userId = +userId.replaceAll("'", '')
const values = [
userId,
roleName,
dictionaryId,
!!roles.isAdministration,
!!roles.isEditing,
!!roles.isTerminologyReview,
!!roles.isLanguageReview
]
const text = `
INSERT INTO user_role (user_id, role_name, dictionary_id, administration, editing, terminology_review, language_review)
VALUES ($1, $2, $3, $4, $5, $6, $7)`
await dbClient.query(text, values)
})
await Promise.all(updateRights)
const textCount = `
SELECT COUNT(*) dictionary_admins_count
FROM user_role
WHERE administration = true
AND dictionary_id = $1`
const { rows } = await dbClient.query(textCount, value)
const portalAdminsCount = +rows[0].dictionary_admins_count
if (!portalAdminsCount) throw Error('Can not delete all dictionary admins')
await dbClient.query('COMMIT')
} catch (error) {
await dbClient.query('ROLLBACK')
throw error
} finally {
dbClient.release()
}
}
User.deleteUserDictionary = async dictionaryId => {
const text = 'DELETE FROM user_role WHERE dictionary_id = $1'
const value = [dictionaryId]
await db.query(text, value)
}
// Fetch user with role consultant or consultancy admin
User.fetchConsultants = async () => {
// fetch domains string for the user
const text = `
SELECT DISTINCT u.id, u.username, u.first_name, u.last_name, ur.domains
FROM user_role ur
INNER JOIN "user" u ON u.id = ur.user_id
ORDER BY u.id ASC
`
const { rows } = await db.query(text)
const users = rows
return users
}
// Insert consultancy role domains
User.updateConsultancyDomains = async (id, value) => {
await db.query('UPDATE user_role SET domains=$1 WHERE user_id = $2', [
value,
id
])
}
// Insert new consultant role with domain of
User.insertNewConsultantWithDomain = async (userId, domains) => {
const { rows } = await db.query(
"SELECT user_id FROM user_role WHERE user_id = $1 and role_name = 'consultant'",
[userId]
)
if (rows.length) return
await db.query(
"INSERT INTO user_role (user_id, domains, role_name) VALUES ($1, $2, 'consultant')",
[userId, domains]
)
}
// Insert new consultant role with domain of
User.insertNewConsultantWithDomainByUsername = async (username, domains) => {
const { rows } = await db.query(
'SELECT id FROM "user" WHERE username = $1 or email = $1',
[username]
)
await User.insertNewConsultantWithDomain(rows[0].id, domains)
}
// Remove consultant role
User.removeConsultant = async userId => {
await db.query(
`DELETE FROM user_role
WHERE user_id=$1 and role_name='consultant'`,
[userId]
)
}
User.fetchAllowedHitsPerPage = async () => {
return (
await db.query(`SELECT unnest(enum_range(NULL::user_hits_per_page))`)
).rows.map(e => e.unnest)
}
User.updateFirstNameAndLastName = async (username, firstName, lastName) => {
return await db.query(
`UPDATE "user"
SET first_name=$2, last_name=$3
WHERE username=$1;`,
[username, firstName, lastName]
)
}
User.updateHitsPerPage = async (username, hitsPerPageAmount) => {
return await db.query(
`UPDATE "user"
SET hits_per_page=$2::user_hits_per_page
WHERE username=$1;`,
[username, hitsPerPageAmount]
)
}
module.exports = User
+6681
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "terminoloski-portal",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www",
"devstart": "nodemon --ignore data_files/ --inspect=0.0.0.0:9229 ./bin/www",
"devstart-wait": "nodemon --ignore data_files/ --inspect-brk=0.0.0.0:9229 ./bin/www"
},
"dependencies": {
"@opensearch-project/opensearch": "^2.1.0",
"async": "^3.2.3",
"axios": "^0.26.1",
"bcrypt": "^5.0.1",
"connect-redis": "^6.0.0",
"cookie-parser": "^1.4.5",
"debug": "^4.3.2",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"express-session": "^1.17.2",
"filesize": "^10.0.5",
"form-data": "^4.0.0",
"helmet": "^5.0.2",
"http-errors": "^2.0.0",
"ioredis": "^4.27.8",
"morgan": "^1.10.0",
"multer": "^1.4.3",
"needle": "^3.0.0",
"nodemailer": "^6.7.2",
"nodemailer-html-to-text": "^3.2.0",
"passport": "^0.5.1",
"passport-local": "^1.0.0",
"passport-remember-me-extended": "^0.0.3",
"pg": "^8.6.0",
"pg-cursor": "^2.7.1",
"pug": "^3.0.2",
"serve-favicon": "^2.5.0",
"uid-safe": "^2.1.5",
"validator": "^13.7.0",
"xml-flow": "^1.0.4",
"xss": "^1.0.10"
},
"devDependencies": {
"express-debug": "^1.1.1",
"nodemon": "^2.0.12"
}
}
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15.5" cy="5.5" r="2.5" stroke="#F5F5F5" stroke-width="2"/>
<circle cx="15.5" cy="15.5" r="2.5" stroke="#F5F5F5" stroke-width="2"/>
<circle cx="15.5" cy="25.5" r="2.5" stroke="#F5F5F5" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,8 @@
<svg width="102" height="78" viewBox="0 0 102 78" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 6C0 2.68629 2.68629 0 6 0H96C99.3137 0 102 2.68629 102 6V72C102 75.3137 99.3137 78 96 78H6C2.68629 78 0 75.3137 0 72V6Z" fill="#E0E6EA"/>
<path d="M34.5234 62.3516H30.1172L29.1953 65H27.1406L31.4375 53.625H33.2109L37.5156 65H35.4531L34.5234 62.3516ZM30.6719 60.7578H33.9688L32.3203 56.0391L30.6719 60.7578ZM38.2734 60.7109C38.2734 59.4089 38.5755 58.3646 39.1797 57.5781C39.7839 56.7865 40.5938 56.3906 41.6094 56.3906C42.5052 56.3906 43.2292 56.7031 43.7812 57.3281V53H45.6797V65H43.9609L43.8672 64.125C43.2995 64.8125 42.5417 65.1562 41.5938 65.1562C40.6042 65.1562 39.8021 64.7578 39.1875 63.9609C38.5781 63.1641 38.2734 62.0807 38.2734 60.7109ZM40.1719 60.875C40.1719 61.7344 40.3359 62.4062 40.6641 62.8906C40.9974 63.3698 41.4688 63.6094 42.0781 63.6094C42.8542 63.6094 43.4219 63.263 43.7812 62.5703V58.9609C43.4323 58.2839 42.8698 57.9453 42.0938 57.9453C41.4792 57.9453 41.0052 58.1901 40.6719 58.6797C40.3385 59.1641 40.1719 59.8958 40.1719 60.875ZM49.4453 56.5469L49.5 57.4297C50.0938 56.737 50.9062 56.3906 51.9375 56.3906C53.0677 56.3906 53.8411 56.8229 54.2578 57.6875C54.8724 56.8229 55.737 56.3906 56.8516 56.3906C57.7839 56.3906 58.4766 56.6484 58.9297 57.1641C59.388 57.6797 59.6224 58.4401 59.6328 59.4453V65H57.7344V59.5C57.7344 58.9635 57.6172 58.5703 57.3828 58.3203C57.1484 58.0703 56.7604 57.9453 56.2188 57.9453C55.7865 57.9453 55.4323 58.0625 55.1562 58.2969C54.8854 58.526 54.6953 58.8281 54.5859 59.2031L54.5938 65H52.6953V59.4375C52.6693 58.4427 52.1615 57.9453 51.1719 57.9453C50.4115 57.9453 49.8724 58.2552 49.5547 58.875V65H47.6562V56.5469H49.4453ZM63.6016 65H61.7031V56.5469H63.6016V65ZM61.5859 54.3516C61.5859 54.0599 61.6771 53.8177 61.8594 53.625C62.0469 53.4323 62.3125 53.3359 62.6562 53.3359C63 53.3359 63.2656 53.4323 63.4531 53.625C63.6406 53.8177 63.7344 54.0599 63.7344 54.3516C63.7344 54.638 63.6406 54.8776 63.4531 55.0703C63.2656 55.2578 63 55.3516 62.6562 55.3516C62.3125 55.3516 62.0469 55.2578 61.8594 55.0703C61.6771 54.8776 61.5859 54.638 61.5859 54.3516ZM67.4375 56.5469L67.4922 57.5234C68.1172 56.7682 68.9375 56.3906 69.9531 56.3906C71.7135 56.3906 72.6094 57.3984 72.6406 59.4141V65H70.7422V59.5234C70.7422 58.987 70.625 58.5911 70.3906 58.3359C70.1615 58.0755 69.7839 57.9453 69.2578 57.9453C68.4922 57.9453 67.9219 58.2917 67.5469 58.9844V65H65.6484V56.5469H67.4375Z" fill="#46535B"/>
<path d="M61.4285 25.8209V18.2063C61.4285 17.6638 61.092 17.1781 60.584 16.9876L50.1713 13.0829C49.8766 12.9724 49.5519 12.9724 49.2573 13.0829L38.8446 16.9876C38.3366 17.1781 38 17.6638 38 18.2063V27.3174C38 31.8033 40.9699 35.3877 43.6508 37.7335C45.0186 38.9303 46.3803 39.8658 47.3975 40.5015C47.9074 40.8202 48.3342 41.0657 48.6365 41.2331L49.1314 41.4971C49.4978 41.6803 49.9299 41.6807 50.2964 41.4975M50.301 41.4951L50.2982 41.4965" stroke="#006CB7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="58.5396" cy="35.2574" r="7.46031" stroke="#006CB7" stroke-width="2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.8616 36.2976C55.3193 35.8399 55.9401 35.5828 56.5873 35.5828H60.4921C61.1393 35.5828 61.7601 35.8399 62.2178 36.2976C62.6754 36.7552 62.9326 37.376 62.9326 38.0232V38.9994C62.9326 39.269 62.714 39.4875 62.4445 39.4875C62.1749 39.4875 61.9564 39.269 61.9564 38.9994V38.0232C61.9564 37.6349 61.8021 37.2624 61.5275 36.9878C61.2529 36.7132 60.8804 36.559 60.4921 36.559H56.5873C56.199 36.559 55.8265 36.7132 55.5519 36.9878C55.2773 37.2624 55.123 37.6349 55.123 38.0232V38.9994C55.123 39.269 54.9045 39.4875 54.6349 39.4875C54.3654 39.4875 54.1469 39.269 54.1469 38.9994V38.0232C54.1469 37.376 54.404 36.7552 54.8616 36.2976Z" fill="#006CB7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5397 30.7018C57.731 30.7018 57.0754 31.3574 57.0754 32.1661C57.0754 32.9748 57.731 33.6303 58.5397 33.6303C59.3484 33.6303 60.004 32.9748 60.004 32.1661C60.004 31.3574 59.3484 30.7018 58.5397 30.7018ZM56.0992 32.1661C56.0992 30.8182 57.1919 29.7256 58.5397 29.7256C59.8876 29.7256 60.9802 30.8182 60.9802 32.1661C60.9802 33.5139 59.8876 34.6065 58.5397 34.6065C57.1919 34.6065 56.0992 33.5139 56.0992 32.1661Z" fill="#006CB7"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,7 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.8">
<path d="M48.9999 93.9166C24.0916 93.9166 4.08325 73.9082 4.08325 48.9999C4.08325 24.0916 24.0916 4.08325 48.9999 4.08325C73.9082 4.08325 93.9166 24.0916 93.9166 48.9999C93.9166 73.9082 73.9082 93.9166 48.9999 93.9166ZM48.9999 12.2499C28.5833 12.2499 12.2499 28.5833 12.2499 48.9999C12.2499 69.4166 28.5833 85.7499 48.9999 85.7499C69.4166 85.7499 85.7499 69.4166 85.7499 48.9999C85.7499 28.5833 69.4166 12.2499 48.9999 12.2499Z" fill="#AC7171"/>
<path d="M48.9999 53.0832C46.5499 53.0832 44.9166 51.4499 44.9166 48.9999V32.6666C44.9166 30.2166 46.5499 28.5833 48.9999 28.5833C51.4499 28.5833 53.0832 30.2166 53.0832 32.6666V48.9999C53.0832 51.4499 51.4499 53.0832 48.9999 53.0832Z" fill="#AC7171"/>
<path d="M48.9999 69.4166C47.7749 69.4166 46.9582 69.0082 46.1416 68.1916C45.3249 67.3749 44.9166 66.5582 44.9166 65.3332C44.9166 64.9249 44.9166 64.1082 45.3249 63.6999C45.7332 63.2916 45.7332 62.8832 46.1416 62.4749C47.3666 61.2499 48.9999 60.8416 50.6332 61.6582C51.0416 61.6582 51.0416 61.6582 51.4499 62.0666C51.4499 62.0666 51.8582 62.4749 52.2666 62.4749C52.6749 62.8832 53.0832 63.2916 53.0832 63.6999C53.0832 64.1082 53.0832 64.9249 53.0832 65.3332C53.0832 65.7416 53.0832 66.5582 52.6749 66.9666C52.2666 67.3749 52.2666 67.7832 51.8582 68.1916C51.0416 69.0082 50.2249 69.4166 48.9999 69.4166Z" fill="#AC7171"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 1C18.1 1 23 5.9 23 12C23 18.1 18.1 23 12 23C5.9 23 1 18.1 1 12C1 5.9 5.9 1 12 1ZM12 21C17 21 21 17 21 12C21 7 17 3 12 3C7 3 3 7 3 12C3 17 7 21 12 21Z" fill="#057ED1"/>
<path d="M12 11C12.6 11 13 11.4 13 12L13 16C13 16.6 12.6 17 12 17C11.4 17 11 16.6 11 16L11 12C11 11.4 11.4 11 12 11Z" fill="#057ED1"/>
<path d="M12 7C12.3 7 12.5 7.1 12.7 7.3C12.9 7.5 13 7.7 13 8C13 8.1 13 8.3 12.9 8.4C12.8 8.5 12.8 8.6 12.7 8.7C12.4 9 12 9.1 11.6 8.9C11.5 8.9 11.5 8.9 11.4 8.8C11.4 8.8 11.3 8.7 11.2 8.7C11.1 8.6 11 8.5 11 8.4C11 8.3 11 8.1 11 8C11 7.9 11 7.7 11.1 7.6C11.2 7.5 11.2 7.4 11.3 7.3C11.5 7.1 11.7 7 12 7Z" fill="#057ED1"/>
</svg>

After

Width:  |  Height:  |  Size: 739 B

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.49987 21.9999C2.99987 21.9999 2.49987 21.8999 1.99987 21.5999C0.599865 20.7999 0.099865 18.8999 0.899865 17.4999L9.39986 3.2999C9.69986 2.8999 9.99986 2.4999 10.3999 2.2999C11.0999 1.8999 11.8999 1.7999 12.6999 1.9999C13.4999 2.1999 14.0999 2.6999 14.5999 3.3999L22.9999 17.4999C23.2999 17.9999 23.3999 18.4999 23.3999 18.9999C23.3999 19.7999 23.0999 20.5999 22.4999 21.0999C21.9999 21.6999 21.2999 21.9999 20.4999 21.9999H3.49987ZM11.0999 4.3999L2.69987 18.4999C2.39987 18.9999 2.59986 19.5999 3.09986 19.8999C3.19986 19.9999 3.39987 19.9999 3.49987 19.9999H20.3999C20.6999 19.9999 20.8999 19.8999 21.0999 19.6999C21.2999 19.4999 21.3999 19.2999 21.3999 18.9999C21.3999 18.7999 21.3999 18.6999 21.2999 18.4999L12.8999 4.3999C12.5999 3.8999 11.9999 3.7999 11.4999 3.9999C11.2999 4.0999 11.1999 4.1999 11.0999 4.3999Z" fill="#D12525"/>
<path d="M11.9999 13.9999C11.3999 13.9999 10.9999 13.5999 10.9999 12.9999V8.9999C10.9999 8.3999 11.3999 7.9999 11.9999 7.9999C12.5999 7.9999 12.9999 8.3999 12.9999 8.9999V12.9999C12.9999 13.5999 12.5999 13.9999 11.9999 13.9999Z" fill="#D12525"/>
<path d="M11.9999 17.9999C11.6999 17.9999 11.4999 17.8999 11.2999 17.6999C11.0999 17.4999 10.9999 17.2999 10.9999 16.9999C10.9999 16.8999 10.9999 16.6999 11.0999 16.5999C11.1999 16.4999 11.1999 16.3999 11.2999 16.2999C11.3999 16.1999 11.4999 16.0999 11.5999 16.0999C11.7999 15.9999 11.9999 15.9999 12.1999 15.9999C12.2999 15.9999 12.2999 15.9999 12.3999 16.0999C12.4999 16.0999 12.4999 16.0999 12.5999 16.1999C12.5999 16.1999 12.6999 16.2999 12.7999 16.2999C12.8999 16.3999 12.9999 16.4999 12.9999 16.5999C12.9999 16.6999 13.0999 16.8999 13.0999 16.9999C13.0999 17.2999 12.9999 17.4999 12.7999 17.6999C12.4999 17.8999 12.2999 17.9999 11.9999 17.9999Z" fill="#D12525"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.49999 22C2.99999 22 2.49999 21.9 1.99999 21.6C0.599987 20.8 0.0999871 18.9 0.899987 17.5L9.39999 3.30002C9.69999 2.90002 9.99999 2.50002 10.4 2.30002C11.1 1.90002 11.9 1.80002 12.7 2.00002C13.5 2.20002 14.1 2.70002 14.6 3.40002L23 17.5C23.3 18 23.4 18.5 23.4 19C23.4 19.8 23.1 20.6 22.5 21.1C22 21.7 21.3 22 20.5 22H3.49999ZM11.1 4.40002L2.69999 18.5C2.39999 19 2.59999 19.6 3.09999 19.9C3.19999 20 3.39999 20 3.49999 20H20.4C20.7 20 20.9 19.9 21.1 19.7C21.3 19.5 21.4 19.3 21.4 19C21.4 18.8 21.4 18.7 21.3 18.5L12.9 4.40002C12.6 3.90002 12 3.80002 11.5 4.00002C11.3 4.10002 11.2 4.20002 11.1 4.40002Z" fill="#FB6F28"/>
<path d="M12 14C11.4 14 11 13.6 11 13V9.00002C11 8.40002 11.4 8.00002 12 8.00002C12.6 8.00002 13 8.40002 13 9.00002V13C13 13.6 12.6 14 12 14Z" fill="#FB6F28"/>
<path d="M12 18C11.7 18 11.5 17.9 11.3 17.7C11.1 17.5 11 17.3 11 17C11 16.9 11 16.7 11.1 16.6C11.2 16.5 11.2 16.4 11.3 16.3C11.4 16.2 11.5 16.1 11.6 16.1C11.8 16 12 16 12.2 16C12.3 16 12.3 16 12.4 16.1C12.5 16.1 12.5 16.1 12.6 16.2C12.6 16.2 12.7 16.3 12.8 16.3C12.9 16.4 13 16.5 13 16.6C13 16.7 13.1 16.9 13.1 17C13.1 17.3 13 17.5 12.8 17.7C12.5 17.9 12.3 18 12 18Z" fill="#FB6F28"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg width="20" height="15" viewBox="0 0 20 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3.00002H6C5.4 3.00002 5 2.60002 5 2.00002C5 1.40002 5.4 1.00002 6 1.00002H19C19.6 1.00002 20 1.40002 20 2.00002C20 2.60002 19.6 3.00002 19 3.00002Z" fill="#46535B"/>
<path d="M19 9.00002H6C5.4 9.00002 5 8.60002 5 8.00002C5 7.40002 5.4 7.00002 6 7.00002H19C19.6 7.00002 20 7.40002 20 8.00002C20 8.60002 19.6 9.00002 19 9.00002Z" fill="#46535B"/>
<path d="M19 15H6C5.4 15 5 14.6 5 14C5 13.4 5.4 13 6 13H19C19.6 13 20 13.4 20 14C20 14.6 19.6 15 19 15Z" fill="#46535B"/>
<path d="M1 3.00002C0.9 3.00002 0.9 3.00002 0.8 3.00002C0.7 3.00002 0.7 3.00002 0.6 2.90002C0.5 2.90002 0.5 2.80002 0.4 2.80002C0.3 2.80002 0.3 2.70002 0.3 2.70002C0.2 2.60002 0.0999999 2.50002 0.0999999 2.40002C-9.68575e-08 2.30002 0 2.10002 0 2.00002C0 1.90002 -9.68575e-08 1.70002 0.0999999 1.60002C0.2 1.50002 0.2 1.40002 0.3 1.30002C0.6 1.00002 1 0.900023 1.4 1.10002C1.5 1.20002 1.6 1.20002 1.7 1.30002C1.9 1.50002 2 1.70002 2 2.00002C2 2.30002 1.9 2.50002 1.7 2.70002C1.5 2.90002 1.3 3.00002 1 3.00002Z" fill="#46535B"/>
<path d="M1 9.00002C0.7 9.00002 0.5 8.90002 0.3 8.70002C0.0999999 8.50002 0 8.30002 0 8.00002C0 7.90002 0 7.90002 0 7.80002C0 7.70002 -9.68575e-08 7.70002 0.0999999 7.60002C0.0999999 7.50002 0.2 7.50002 0.2 7.40002C0.2 7.30002 0.3 7.30002 0.3 7.30002C0.4 7.20002 0.5 7.10002 0.6 7.10002C1 6.90002 1.4 7.00002 1.7 7.30002L1.8 7.40002C1.8 7.50002 1.9 7.50002 1.9 7.60002C1.9 7.70002 1.9 7.70002 2 7.80002C2 7.90002 2 7.90002 2 8.00002C2 8.30002 1.9 8.50002 1.7 8.70002C1.5 8.90002 1.3 9.00002 1 9.00002Z" fill="#46535B"/>
<path d="M1 15C0.7 15 0.5 14.9 0.3 14.7C0.0999999 14.5 0 14.3 0 14C0 13.9 -9.68575e-08 13.7 0.0999999 13.6C0.2 13.5 0.2 13.4 0.3 13.3C0.7 12.9 1.3 12.9 1.7 13.3C1.9 13.5 2 13.7 2 14C2 14.3 1.9 14.5 1.7 14.7C1.5 14.9 1.3 15 1 15Z" fill="#46535B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5148_3821)">
<path d="M7 10L12 15L17 10H7Z" fill="#323232"/>
</g>
<defs>
<clipPath id="clip0_5148_3821">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 297 B

+10
View File
@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5148_3822)">
<path d="M7 14L12 9L17 14H7Z" fill="#323232"/>
</g>
<defs>
<clipPath id="clip0_5148_3822">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 296 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 18C6.10218 18 5.72064 18.158 5.43934 18.4393C5.15804 18.7206 5 19.1022 5 19.5C5 20.0523 4.55228 20.5 4 20.5C3.44772 20.5 3 20.0523 3 19.5C3 18.5717 3.36875 17.6815 4.02513 17.0251C4.6815 16.3687 5.57174 16 6.5 16H20C20.5523 16 21 16.4477 21 17C21 17.5523 20.5523 18 20 18H6.5Z" fill="#006CB7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 3C6.10218 3 5.72064 3.15804 5.43934 3.43934C5.15804 3.72064 5 4.10218 5 4.5V19.5C5 19.8978 5.15804 20.2794 5.43934 20.5607C5.72064 20.842 6.10218 21 6.5 21H19V3H6.5ZM6.5 1H20C20.5523 1 21 1.44772 21 2V22C21 22.5523 20.5523 23 20 23H6.5C5.57174 23 4.6815 22.6313 4.02513 21.9749C3.36875 21.3185 3 20.4283 3 19.5V4.5C3 3.57174 3.36875 2.6815 4.02513 2.02513C4.6815 1.36875 5.57174 1 6.5 1Z" fill="#006CB7"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1 3C1 2.44772 1.44772 2 2 2H8C9.32608 2 10.5979 2.52678 11.5355 3.46447C12.4732 4.40215 13 5.67392 13 7V21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21C11 20.4696 10.7893 19.9609 10.4142 19.5858C10.0391 19.2107 9.53043 19 9 19H2C1.44772 19 1 18.5523 1 18V3ZM11 17.5359V7C11 6.20435 10.6839 5.44129 10.1213 4.87868C9.55871 4.31607 8.79565 4 8 4H3V17H9C9.70823 17 10.3971 17.1878 11 17.5359Z" fill="#006CB7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4645 3.46447C13.4021 2.52678 14.6739 2 16 2H22C22.5523 2 23 2.44772 23 3V18C23 18.5523 22.5523 19 22 19H15C14.4696 19 13.9609 19.2107 13.5858 19.5858C13.2107 19.9609 13 20.4696 13 21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21V7C11 5.67392 11.5268 4.40215 12.4645 3.46447ZM13 17.5359C13.6029 17.1878 14.2918 17 15 17H21V4H16C15.2044 4 14.4413 4.31607 13.8787 4.87868C13.3161 5.44129 13 6.20435 13 7V17.5359Z" fill="#006CB7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1 3C1 2.44772 1.44772 2 2 2H8C9.32608 2 10.5979 2.52678 11.5355 3.46447C12.4732 4.40215 13 5.67392 13 7V21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21C11 20.4696 10.7893 19.9609 10.4142 19.5858C10.0391 19.2107 9.53043 19 9 19H2C1.44772 19 1 18.5523 1 18V3ZM11 17.5359V7C11 6.20435 10.6839 5.44129 10.1213 4.87868C9.55871 4.31607 8.79565 4 8 4H3V17H9C9.70823 17 10.3971 17.1878 11 17.5359Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4645 3.46447C13.4021 2.52678 14.6739 2 16 2H22C22.5523 2 23 2.44772 23 3V18C23 18.5523 22.5523 19 22 19H15C14.4696 19 13.9609 19.2107 13.5858 19.5858C13.2107 19.9609 13 20.4696 13 21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21V7C11 5.67392 11.5268 4.40215 12.4645 3.46447ZM13 17.5359C13.6029 17.1878 14.2918 17 15 17H21V4H16C15.2044 4 14.4413 4.31607 13.8787 4.87868C13.3161 5.44129 13 6.20435 13 7V17.5359Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1 3C1 2.44772 1.44772 2 2 2H8C9.32608 2 10.5979 2.52678 11.5355 3.46447C12.4732 4.40215 13 5.67392 13 7V21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21C11 20.4696 10.7893 19.9609 10.4142 19.5858C10.0391 19.2107 9.53043 19 9 19H2C1.44772 19 1 18.5523 1 18V3ZM11 17.5359V7C11 6.20435 10.6839 5.44129 10.1213 4.87868C9.55871 4.31607 8.79565 4 8 4H3V17H9C9.70823 17 10.3971 17.1878 11 17.5359Z" fill="#46535B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4645 3.46447C13.4021 2.52678 14.6739 2 16 2H22C22.5523 2 23 2.44772 23 3V18C23 18.5523 22.5523 19 22 19H15C14.4696 19 13.9609 19.2107 13.5858 19.5858C13.2107 19.9609 13 20.4696 13 21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21V7C11 5.67392 11.5268 4.40215 12.4645 3.46447ZM13 17.5359C13.6029 17.1878 14.2918 17 15 17H21V4H16C15.2044 4 14.4413 4.31607 13.8787 4.87868C13.3161 5.44129 13 6.20435 13 7V17.5359Z" fill="#46535B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 18C6.10218 18 5.72064 18.158 5.43934 18.4393C5.15804 18.7206 5 19.1022 5 19.5C5 20.0523 4.55228 20.5 4 20.5C3.44772 20.5 3 20.0523 3 19.5C3 18.5717 3.36875 17.6815 4.02513 17.0251C4.6815 16.3687 5.57174 16 6.5 16H20C20.5523 16 21 16.4477 21 17C21 17.5523 20.5523 18 20 18H6.5Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 3C6.10218 3 5.72064 3.15804 5.43934 3.43934C5.15804 3.72064 5 4.10218 5 4.5V19.5C5 19.8978 5.15804 20.2794 5.43934 20.5607C5.72064 20.842 6.10218 21 6.5 21H19V3H6.5ZM6.5 1H20C20.5523 1 21 1.44772 21 2V22C21 22.5523 20.5523 23 20 23H6.5C5.57174 23 4.6815 22.6313 4.02513 21.9749C3.36875 21.3185 3 20.4283 3 19.5V4.5C3 3.57174 3.36875 2.6815 4.02513 2.02513C4.6815 1.36875 5.57174 1 6.5 1Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 908 B

Some files were not shown because too many files have changed in this diff Show More