Initial commit
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
old_express
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.vscode
|
||||
/.env
|
||||
.idea
|
||||
express/public/stylesheets
|
||||
@@ -0,0 +1,3 @@
|
||||
old_express
|
||||
express/public/stylesheets
|
||||
help-pug-demo.pug
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}'`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
const { getInstanceSetting } = require('../models/helpers')
|
||||
|
||||
exports.enhanceLocals = async (req, res, next) => {
|
||||
res.locals.portalCode = await getInstanceSetting('portal_code')
|
||||
next()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.2 KiB |