Initial commit

This commit is contained in:
Luka Romih
2022-12-07 04:43:36 +01:00
commit 869e8139fb
496 changed files with 55373 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
README.md
+1
View File
@@ -0,0 +1 @@
old_express
+18
View File
@@ -0,0 +1,18 @@
root: true
env:
es2021: true
extends:
- standard
- prettier
overrides:
- files:
- "*.js"
- "express/bin/www"
excludedFiles: "express/public"
env:
node: true
- files: "express/public/javascripts/*.js"
env:
browser: true
rules:
no-console: error
+262
View File
@@ -0,0 +1,262 @@
# Common settings that generally should always be used with your language specific settings
# Auto detect text files and perform LF normalization
# https://www.davidlaing.com/2012/09/19/customise-your-gitattributes-to-become-a-git-ninja/
* text=auto
#
# The above will handle all files NOT found below
#
# Documents
*.bibtex text diff=bibtex
*.doc diff=astextplain
*.DOC diff=astextplain
*.docx diff=astextplain
*.DOCX diff=astextplain
*.dot diff=astextplain
*.DOT diff=astextplain
*.pdf diff=astextplain
*.PDF diff=astextplain
*.rtf diff=astextplain
*.RTF diff=astextplain
*.md text
*.tex text diff=tex
*.adoc text
*.textile text
*.mustache text
*.csv text
*.tab text
*.tsv text
*.sql text
# Graphics
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.tif binary
*.tiff binary
*.ico binary
# SVG treated as an asset (binary) by default.
*.svg text
# If you want to treat it as binary,
# use the following line instead.
# *.svg binary
*.eps binary
#
# Exclude files from exporting
#
.gitattributes export-ignore
.gitignore export-ignore
## GITATTRIBUTES FOR WEB PROJECTS
#
# These settings are for any web project.
#
# Details per file setting:
# text These files should be normalized (i.e. convert CRLF to LF).
# binary These files are binary and should be left untouched.
#
# Note that binary is a macro for -text -diff.
######################################################################
# Auto detect
## Handle line endings automatically for files detected as
## text and leave all files detected as binary untouched.
## This will handle all files NOT defined below.
# * text=auto
# Source code
*.bash text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.coffee text
*.css text
*.htm text diff=html
*.html text diff=html
*.inc text
*.ini text
*.js text
*.json text
*.jsx text
*.less text
*.ls text
*.map text -diff
*.od text
*.onlydata text
*.php text diff=php
*.pl text
*.ps1 text eol=crlf
*.py text diff=python
*.rb text diff=ruby
*.sass text
*.scm text
*.scss text diff=css
*.sh text eol=lf
*.sql text
*.styl text
*.tag text
*.ts text
*.tsx text
*.xml text
*.xhtml text diff=html
# Docker
*.dockerignore text
Dockerfile text
# Documentation
*.ipynb text
*.markdown text
*.md text
*.mdwn text
*.mdown text
*.mkd text
*.mkdn text
*.mdtxt text
*.mdtext text
*.txt text
AUTHORS text
CHANGELOG text
CHANGES text
CONTRIBUTING text
COPYING text
copyright text
*COPYRIGHT* text
INSTALL text
license text
LICENSE text
NEWS text
readme text
*README* text
TODO text
# Templates
*.dot text
*.ejs text
*.haml text
*.handlebars text
*.hbs text
*.hbt text
*.jade text
*.latte text
*.mustache text
*.njk text
*.phtml text
*.tmpl text
*.tpl text
*.twig text
*.vue text
# Linters
.csslintrc text
.eslintrc text
.htmlhintrc text
.jscsrc text
.jshintrc text
.jshintignore text
.stylelintrc text
# Configs
*.bowerrc text
*.cnf text
*.conf text
*.config text
.babelrc text
.browserslistrc text
.editorconfig text
.env text
.gitattributes text
.gitconfig text
.htaccess text
*.lock text -diff
package-lock.json text -diff
*.npmignore text
*.yaml text
*.yml text
browserslist text
Makefile text
makefile text
# Heroku
Procfile text
.slugignore text
# Graphics
*.ai binary
*.bmp binary
*.eps binary
*.gif binary
*.gifv binary
*.ico binary
*.jng binary
*.jp2 binary
*.jpg binary
*.jpeg binary
*.jpx binary
*.jxr binary
*.pdf binary
*.png binary
*.psb binary
*.psd binary
# SVG treated as an asset (binary) by default.
*.svg text
# If you want to treat it as binary,
# use the following line instead.
# *.svg binary
*.svgz binary
*.tif binary
*.tiff binary
*.wbmp binary
*.webp binary
# Audio
*.kar binary
*.m4a binary
*.mid binary
*.midi binary
*.mp3 binary
*.ogg binary
*.ra binary
# Video
*.3gpp binary
*.3gp binary
*.as binary
*.asf binary
*.asx binary
*.fla binary
*.flv binary
*.m4v binary
*.mng binary
*.mov binary
*.mp4 binary
*.mpeg binary
*.mpg binary
*.ogv binary
*.swc binary
*.swf binary
*.webm binary
# Archives
*.7z binary
*.gz binary
*.jar binary
*.rar binary
*.tar binary
*.zip binary
# Fonts
*.ttf binary
*.eot binary
*.otf binary
*.woff binary
*.woff2 binary
# Executables
*.exe binary
*.pyc binary
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.vscode
/.env
.idea
express/public/stylesheets
+3
View File
@@ -0,0 +1,3 @@
old_express
express/public/stylesheets
help-pug-demo.pug
+19
View File
@@ -0,0 +1,19 @@
overrides:
- files:
- "*.js"
- "*.pug"
- "express/bin/www"
options:
semi: false
singleQuote: true
trailingComma: none
arrowParens: avoid
- files: "*.pug"
options:
pugAttributeSeparator: as-needed
pugSingleQuote: false
# Disabled and solved in package.json
# Reason: bug https://github.com/prettier/prettier/issues/8924
# - files: "express/bin/www"
# # options:
# # parser: babel
+5
View File
@@ -0,0 +1,5 @@
# Terminološki portal
## Navodila za namesitev
Navodila so zaradi dinamičnih sprememb v integriranih zunanjih komponentah še v pripravi in bodo pripravljena, ko bo celota stabilnejša.
+6
View File
@@ -0,0 +1,6 @@
FROM alpine:3.16 AS builder
WORKDIR /sloleks
RUN wget -qO- "https://www.clarin.si/repository/xmlui/bitstream/handle/11356/1230/Sloleks2.0.LMF.zip?sequence=3&isAllowed=y" | unzip -
FROM ghcr.io/clarinsi/rsdo-concordancer-api:v1.0.0
COPY --from=builder /sloleks /sloleks
+26
View File
@@ -0,0 +1,26 @@
# Initial values are default vaules.
# Change if needed.
EXPRESS_LISTEN_PORT=3000
EXPRESS_IS_BEHIND_PROXY=false
EXPRESS_SECRET="weak_secret"
POSTGRES_ADMIN_PASSWORD="weak_admin_password" # user: postgres
POSTGRES_EXPRESS_PASSWORD="weak_express_password" # user: express
POSTGRES_CONCORDANCER_PASSWORD="weak_concordancer_password" # user: concordancer
POSTGRES_LISTEN_PORT=5432
SMTP_HOST="maildev"
SMTP_PORT=1025
SMTP_TLS_REJECT_UNAUTHORIZED=true
SMTP_FROM="Sender name <sender@server.com>"
OS_DASHBOARDS_LISTEN_PORT=3002
CONCORDANCER_LISTEN_PORT=3003
# Development only settings
MAILDEV_WEB_GUI_PORT=3001
# Production only settings
URL_ORIGIN="https://mywebportal.com"
+25
View File
@@ -0,0 +1,25 @@
_Tale dokument se bo dopolnjeval in bo v končni obliki v angleščini_
# Navodila za zagon aplikacije v razvojnem okolju
**Na sistemu potrebuješ imeti naložen Docker.**
1. Z Git repozitorija https://git.amebis.si/TerminoloskiPortal.git preneseš (najnovejšo) kodo (git clone, git pull, ...).
2. `/dev/.env` skopiraš en nivo višje, v koren projekta, in po želji prilagodiš vrednosti.
3. Za zagon celotne aplikacije v terminalu v korenu projekta poženeš `docker-compose up`
4. Za ustavitev zgolj zapreš proces.
**Dodatno**
- Za vsak nadaljni zagon uporabljaš `docker-compose up`.
- Ob spremembi _node modulov_ je najprej treba pognati `docker-compose down`, nato pa `docker-compose up --build`.
- Če kadarkoli želiš sprostiti disk oz. ponastaviti aplikacijo, uporabiš `docker-compose down`. Podatki iz baze se ohranijo. Za izbris tudi njih, poženi raje `docker-compose down --volumes`.
- Ob razvoju upoštevaj razvojne konvencije, navedene v `dev/razvojne_konvencije.md`
- Odjemalec elektronske pošte je privzeto dosegljiv na portu 3001 (nastavljivo z variablo MAILDEV_WEB_GUI_PORT)
+51
View File
@@ -0,0 +1,51 @@
# Razvojne konvencije Terminološkega portala
_Tale dokument se bo dopolnjeval in ga v končnem repozitoriju ne bo ali pa bo v končni obliki v angleščini._
## Priporočen editor, nastavitve in razširitve
- Visual Studio Code ima za razvoj Node.js aplikacij ogromno uporabnih funkcionalnosti že vgrajenih
- Nastavitve in razširitve niso vključene v Git repozitorij, vendar jih Luka z veseljem priporoči/deli. Še posebej je uporabna launch konfiguracija za debugiranje kode v kontejnerju v realnem času.
- V VS Codu se da ta dokument brati v formatirani obliki z zagonom (_Ctrl + Shift + P_) ukaza `Markdown: Open Preview`
## Git workflow
- Commit messagi naj spoštujejo [to konvencijo](https://chris.beams.io/posts/git-commit/#seven-rules) in naj bodo v angleščini.
- Za začetek ne commitajmo direktno v _master_ vejo. Vsakršno delo naj bo opravljeno na ločeni veji, ki pa jo bo Luka po potrebi dopolnil/prilagodil in dodal v _master_. Tipičen tok naj torej sledi naslednjemu vzorcu:
- Posodobiš _master_ na najnovejšo verzijo: `git pull origin master`
- Narediš novo vejo za dopolnitve: `git checkout -b <ime veje>`
- Ime veje naj bo formata _\<ime>/\<dopolnitev>_
- Primer imena veje: _jure/admin-console_
- Narediš potrebne popravke v kodi in jih poljubnokrat, toda vsaj enkrat, commitaš: `git commit -m "<Commit message>"`
- Vejo porineš na remote repositorij: `git push origin <ime veje>`
- Luku sporočiš, da je veja končana in pripravljena za integracijo v _master_
- Ko Luka sporoči, da je bila veja integrirana, jo izbrišeš lokalno in na remote repozitoriju
- Greš na _master_: `git checkout master`
- Lokalni izbris: `git branch -D <ime veje>`
- Remote izbris: `git push origin --delete <ime veje>`
## Sintaksa
### Splošno
- Za večino znanih tipov datotek za enotno formatiranje skrbi kombinacija ESLint-a in Prettier-ja. Kljub temu je spodaj navedenih nekaj splošnih smernic, ki jih upoštevajmo na splošno, v vseh datotekah.
- Za integracijo zgoraj navedenih orodij v VS Code in poenastavitev workflowa vam lahko pomaga Luka
- Če ne uporabljate integracije teh orodij v text editor, pred vsakim git commitom poženite `npm run format`
- Če se bo v git repozitorij začela objavljati neformatirana koda, bomo morali uporabiti git hooke; najraje na klientovi strani, sicer na strežniški
#### Splošne smernice
- Zamik enega nivoja naj bo povsod 2 presledka (JS, CSS, Pug, ...)
- Datoteke naj se končajo z newlinom.
- Komentarji se povedi. Začnejo se z veliko začetnico in končajo z ločilom.
### JS
- smernice in oblikovanje temeljijo na [JavaScript Standard Stilu](https://standardjs.com) z dodatnimi dopolnitvami
- uporaba console metod (log, warn, error, ...) v _končni_ kodi ni dovoljena
- ker je končnemu uporabniku (na klientovi strani) neuporabna in lahko kvečjemu razkrije neželene detajle
- ker je sinhrona in zato neprimerna za strežniško stran (Node)
- za debugiranje lahko uporabljamo debug modul ali log pointe debuggerskega orodja. V skrajnem primeru lahko uporabite console metode, a jih pred git commitom odstranite
+150
View File
@@ -0,0 +1,150 @@
version: "3.4"
services:
express:
build:
context: express
dockerfile: Dockerfile.prod
depends_on:
- postgres
- redis
- opensearch
restart: always
environment:
NODE_ENV: production
IS_BEHIND_PROXY: "${EXPRESS_IS_BEHIND_PROXY:?}"
SECRET: "${EXPRESS_SECRET:?}"
COOKIES_SECURE: "true"
PGHOST: postgres
PGUSER: express
PGPASSWORD: "${POSTGRES_EXPRESS_PASSWORD:?}"
PGDATABASE: term_portal
SMTP_HOST: "${SMTP_HOST:?}"
SMTP_PORT: "${SMTP_PORT:?}"
SMTP_TLS_REJECT_UNAUTHORIZED: "${SMTP_TLS_REJECT_UNAUTHORIZED:?}"
SMTP_FROM: "${SMTP_FROM:?}"
ORIGIN: "${URL_ORIGIN:?}"
volumes:
- express-data:/usr/src/app/data_files
ports:
- "127.0.0.1:${EXPRESS_LISTEN_PORT:?}:3000"
postgres:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_PASSWORD: "${POSTGRES_ADMIN_PASSWORD:?}"
POSTGRES_DB: term_portal
POSTGRES_EXPRESS_PASSWORD: "${POSTGRES_EXPRESS_PASSWORD:?}"
POSTGRES_CONCORDANCER_PASSWORD: "${POSTGRES_CONCORDANCER_PASSWORD:?}"
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=sl-SI"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d
ports:
- "127.0.0.1:${POSTGRES_LISTEN_PORT:?}:5432"
redis:
image: redis:6-alpine
restart: always
volumes:
- redis-data:/data
- ./redis/conf:/usr/local/etc/redis
command: redis-server /usr/local/etc/redis/redis.conf
opensearch:
build: opensearch
restart: always
environment:
- cluster.name=term-portal
- node.name=node-1
# - bootstrap.memory_lock=true # along with the memlock settings below, disables swapping
# - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" # minimum and maximum Java heap size, recommend setting both to 50% of system RAM
- "DISABLE_INSTALL_DEMO_CONFIG=true" # disables execution of install_demo_configuration.sh bundled with security plugin, which installs demo certificates and security configurations to OpenSearch
- "DISABLE_SECURITY_PLUGIN=true" # disables security plugin entirely in OpenSearch by setting plugins.security.disabled: true in opensearch.yml
- "discovery.type=single-node" # disables bootstrap checks that are enabled when network.host is set to a non-loopback address
# ulimits:
# memlock:
# soft: -1
# hard: -1
# nofile:
# soft: 65536 # maximum number of open files for the OpenSearch user, set to at least 65536 on modern systems
# hard: 65536
volumes:
- opensearch-data:/usr/share/opensearch/data
# Disabled by default. Enable if needed.
opensearch-dashboards:
image: opensearchproject/opensearch-dashboards:2.3.0
profiles:
- opensearch-dashboards
depends_on:
- opensearch
restart: always
environment:
- 'OPENSEARCH_HOSTS=["http://opensearch:9200"]'
- "DISABLE_SECURITY_DASHBOARDS_PLUGIN=true" # disables security dashboards plugin in OpenSearch Dashboards
ports:
- "127.0.0.1:${OS_DASHBOARDS_LISTEN_PORT:?}:5601"
concordancer:
# image: ghcr.io/clarinsi/rsdo-concordancer-api:v1.0.0
build: concordancer
depends_on:
- postgres
- opensearch
restart: always
environment:
- "RSDO:Database:MasterConnectionString=Host=postgres;Username=concordancer;Password=${POSTGRES_CONCORDANCER_PASSWORD:?};Database=Concordancer"
- "RSDO:Elastic:ConnectionString=http://opensearch:9200"
- "RSDO:Tokenizer:ClasslaUrl=https://orodja.cjvt.si/oznacevalnik/ajax_api/v1/slv/process"
ports:
- "127.0.0.1:${CONCORDANCER_LISTEN_PORT:?}:5000"
volumes:
- express-data:/data
- sloleks:/sloleks
# Disabled by default. Enable if needed.
concordancer-manager:
image: ghcr.io/clarinsi/rsdo-concordancer-systemmanager:v1.0.0
profiles:
- concordancer-manager
depends_on:
- postgres
- opensearch
restart: always
environment:
- "RSDO:Database:MasterConnectionString=Host=postgres;Username=concordancer;Password=${POSTGRES_CONCORDANCER_PASSWORD:?};Database=Concordancer"
- "RSDO:Elastic:ConnectionString=http://opensearch:9200"
volumes:
- sloleks:/sloleks
cron:
build:
context: express
dockerfile: Dockerfile.prod
init: true
depends_on:
- postgres
- redis
- opensearch
restart: always
environment:
NODE_ENV: production
PGHOST: postgres
PGUSER: express
PGPASSWORD: "${POSTGRES_EXPRESS_PASSWORD:?}"
PGDATABASE: term_portal
SMTP_HOST: "${SMTP_HOST:?}"
SMTP_PORT: "${SMTP_PORT:?}"
SMTP_TLS_REJECT_UNAUTHORIZED: "${SMTP_TLS_REJECT_UNAUTHORIZED:?}"
SMTP_FROM: "${SMTP_FROM:?}"
entrypoint: ["scheduled/entrypoint.sh"]
command: ["crond", "-f", "-l", "2"]
volumes:
express-data:
postgres-data:
redis-data:
opensearch-data:
sloleks:
+173
View File
@@ -0,0 +1,173 @@
version: "3.4"
services:
express:
build: express
depends_on:
- postgres
- redis
- opensearch
- maildev
environment:
NODE_ENV: development
DEBUG: termPortal:*
IS_BEHIND_PROXY: "${EXPRESS_IS_BEHIND_PROXY:?}"
SECRET: "${EXPRESS_SECRET:?}"
COOKIES_SECURE: "false"
PGHOST: postgres
PGUSER: express
PGPASSWORD: "${POSTGRES_EXPRESS_PASSWORD:?}"
PGDATABASE: term_portal
SMTP_HOST: "${SMTP_HOST:?}"
SMTP_PORT: "${SMTP_PORT:?}"
SMTP_TLS_REJECT_UNAUTHORIZED: "${SMTP_TLS_REJECT_UNAUTHORIZED:?}"
SMTP_FROM: "${SMTP_FROM:?}"
ORIGIN: http://localhost:${EXPRESS_LISTEN_PORT:?}
volumes:
# When developing/debugging concordancer, disable express bind mount and node_modules volume and enable express-data.
# - express-data:/usr/src/app/data_files
- ./express:/usr/src/app
- /usr/src/app/node_modules
ports:
- "127.0.0.1:${EXPRESS_LISTEN_PORT:?}:3000"
- "127.0.0.1:9229:9229"
# Enabling TTY makes the npm module "debug" output display properly in container logs
tty: true
postgres:
image: postgres:15-alpine
# Use any of the two custom Dockerfiles below for debugging PL/pgSQL functions.
# Disable docker-entrypoint-initdb.d bind mount when doing so.
# build:
# context: postgres
# dockerfile: Dockerfile.debug.alpine
# # dockerfile: Dockerfile.debug.debian
environment:
POSTGRES_PASSWORD: "${POSTGRES_ADMIN_PASSWORD:?}"
POSTGRES_DB: term_portal
POSTGRES_EXPRESS_PASSWORD: "${POSTGRES_EXPRESS_PASSWORD:?}"
POSTGRES_CONCORDANCER_PASSWORD: "${POSTGRES_CONCORDANCER_PASSWORD:?}"
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=sl-SI"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d
ports:
- "127.0.0.1:${POSTGRES_LISTEN_PORT:?}:5432"
redis:
image: redis:6-alpine
volumes:
- redis-data:/data
- ./redis/conf:/usr/local/etc/redis
command: redis-server /usr/local/etc/redis/redis.conf
opensearch:
build: opensearch
environment:
- cluster.name=term-portal
- node.name=node-1
# - bootstrap.memory_lock=true # along with the memlock settings below, disables swapping
# - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" # minimum and maximum Java heap size, recommend setting both to 50% of system RAM
- "DISABLE_INSTALL_DEMO_CONFIG=true" # disables execution of install_demo_configuration.sh bundled with security plugin, which installs demo certificates and security configurations to OpenSearch
- "DISABLE_SECURITY_PLUGIN=true" # disables security plugin entirely in OpenSearch by setting plugins.security.disabled: true in opensearch.yml
- "discovery.type=single-node" # disables bootstrap checks that are enabled when network.host is set to a non-loopback address
# ulimits:
# memlock:
# soft: -1
# hard: -1
# nofile:
# soft: 65536 # maximum number of open files for the OpenSearch user, set to at least 65536 on modern systems
# hard: 65536
volumes:
- opensearch-data:/usr/share/opensearch/data
# Disabled by default. Enable if needed.
opensearch-dashboards:
image: opensearchproject/opensearch-dashboards:2.3.0
profiles:
- debug-opensearch
depends_on:
- opensearch
environment:
- 'OPENSEARCH_HOSTS=["http://opensearch:9200"]'
- "DISABLE_SECURITY_DASHBOARDS_PLUGIN=true" # disables security dashboards plugin in OpenSearch Dashboards
ports:
- "127.0.0.1:${OS_DASHBOARDS_LISTEN_PORT:?}:5601"
concordancer:
# image: ghcr.io/clarinsi/rsdo-concordancer-api:v1.0.0
build: concordancer
profiles:
- develop-concordancer
depends_on:
- postgres
- opensearch
restart: always
environment:
- "RSDO:Database:MasterConnectionString=Host=postgres;Username=concordancer;Password=${POSTGRES_CONCORDANCER_PASSWORD:?};Database=Concordancer"
- "RSDO:Elastic:ConnectionString=http://opensearch:9200"
- "RSDO:Tokenizer:ClasslaUrl=https://orodja.cjvt.si/oznacevalnik/ajax_api/v1/slv/process"
ports:
- "127.0.0.1:${CONCORDANCER_LISTEN_PORT:?}:5000"
volumes:
- express-data:/data
- sloleks:/sloleks
concordancer-manager:
image: ghcr.io/clarinsi/rsdo-concordancer-systemmanager:v1.0.0
profiles:
- develop-concordancer
depends_on:
- postgres
- opensearch
restart: always
environment:
- "RSDO:Database:MasterConnectionString=Host=postgres;Username=concordancer;Password=${POSTGRES_CONCORDANCER_PASSWORD:?};Database=Concordancer"
- "RSDO:Elastic:ConnectionString=http://opensearch:9200"
volumes:
- sloleks:/sloleks
# Disabled by default. Enable for scheduled task testing.
# Manual testing can be done by running tasks inside express container directly.
# cron:
# build: express
# init: true
# depends_on:
# - postgres
# - redis
# - opensearch
# - maildev
# environment:
# NODE_ENV: development
# DEBUG: termPortal:*
# PGHOST: postgres
# PGUSER: express
# PGPASSWORD: "${POSTGRES_EXPRESS_PASSWORD:?}"
# PGDATABASE: term_portal
# SMTP_HOST: "${SMTP_HOST:?}"
# SMTP_PORT: "${SMTP_PORT:?}"
# SMTP_TLS_REJECT_UNAUTHORIZED: "${SMTP_TLS_REJECT_UNAUTHORIZED:?}"
# SMTP_FROM: "${SMTP_FROM:?}"
# volumes:
# - /usr/src/app/node_modules
# - /usr/src/app/scheduled
# - ./express:/usr/src/app
# # Enabling TTY makes the npm module "debug" output display properly in container logs
# tty: true
# entrypoint: ["scheduled/entrypoint.sh"]
# command: ["crond", "-f", "-l", "2"]
maildev:
image: maildev/maildev:2.0.0-beta3
volumes:
- maildev-data:/var/lib/maildev
ports:
- "127.0.0.1:${MAILDEV_WEB_GUI_PORT:?}:1080"
volumes:
express-data:
postgres-data:
redis-data:
opensearch-data:
maildev-data:
sloleks:
+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

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