'use strict'
const uuid = require('an-uuid')
const { exists, getRidComponents } = require('sesatheque-client/dist/server/sesatheques')
const { hasProp, isArrayNotEmpty, stringify } = require('sesajstools')
const htmlToText = require('html-to-text')
const tools = require('../lib/tools')
const { basicArrayIndexer, getNormalizedName } = require('../lib/normalize')
const { getRidEnfants } = require('../lib/ressource')
const Ressource = require('../../constructors/Ressource')
const config = require('../config')
// idem config.component.ressource, mais le require permet une meilleure autocompletion
const configRessource = require('./config')
const schema = require('./EntityRessource.schema')
const myBaseId = config.application.baseId
// on remplace aussi les qq <sup> par ^ (on verra plus tard pour mettre du latex entre $ ou $$)
const cleanHtml = (text) => htmlToText.fromString(text.replace(/<sup> *([0-9]+) *<\/sup>/g, '^$1'), { preserveNewlines: true, wordwrap: false })
module.exports = function (component) {
component.entity('EntityRessource', function () {
const EntityRessource = this
/**
* Notre entité ressource, cf [Entity](lassi/Entity.html)
* @entity EntityRessource
* @param {Object} values Un objet ayant des propriétés d'une ressource
* @extends Entity
* @extends Ressource
*/
EntityRessource.construct(function (values) {
// on initialise avec les propriétés d'un objet Ressource correctement typé et initialisé
Object.assign(this, new Ressource(values, myBaseId))
// mais après on ne peut plus ajouter de propriété dans afterStore, pas trouvé pourquoi…
// => TypeError: Cannot assign to read only property 'rid' of object '#<Entity>'
if (!hasProp(this, 'rid')) {
Object.defineProperty(this, 'rid', {
writable: true,
enumerable: true,
configurable: false
})
}
// on cast les dates avec notre tools.toDate() qui gère mieux les fuseaux,
// plus pratique ici que dans le constructeur qui ne peut pas faire de require
if (values) {
if (values.dateCreation && !(values.dateCreation instanceof Date)) values.dateCreation = tools.toDate(values.dateCreation)
if (values.dateMiseAJour && !(values.dateMiseAJour instanceof Date)) values.dateMiseAJour = tools.toDate(values.dateMiseAJour)
}
// la langue par défaut
if (this.langue) {
// on rectifie fre en fra
if (this.langue === 'fre') this.langue = 'fra'
} else {
this.langue = configRessource.langueDefaut
}
})
EntityRessource.validateJsonSchema(schema)
EntityRessource
// rid est obligatoire, mais on l'a pas encore à la création… => sparse
.defineIndex('rid', { unique: true, sparse: true })
// pour loadByCle
.defineIndex('cle', { unique: true, sparse: true })
.defineIndex('aliasOf')
.defineIndex('origine')
.defineIndex('idOrigine')
.defineIndex('type')
.defineIndex('titre')
.defineIndex('niveaux')
// par défaut, la valeur de l'index est la valeur du champ, mais on peut fournir une callback qui la remplace
// on retourne un tableau qui ne contient que les oid des éléments liés sans la nature de la relation
// c'est une string car ça peut être 'alias/xxx' où xxx est l'oid de l'alias et pas l'oid d'une ressource
// (pour gérer les relations avec des oid externes)
// @todo renommer l'index en relationRid
.defineIndex('relations', function () {
if (!this.relations || !this.relations.length) return null
// on retourne pour chaque relation l'item lié, tant pis pour la nature de la relation
return this.relations.map(relation => relation[1])
})
// pour les arbres, on indexe tous les enfants, c'est lourd en écriture d'index
// mais indispensable si on veut retrouver tous les arbres qui contiennent un item donné
// (pour mettre à jour titre & résumé par ex).
.defineIndex('enfants', function () {
if (!this.enfants || !this.enfants.length) return null
const rids = getRidEnfants(this)
if (rids.length) return null // arrive si l'arbre ne contient que des dossiers sans ressources dedans
return rids
})
.defineIndex('langue')
.defineIndex('publie', 'boolean')
.defineIndex('indexable', 'boolean')
.defineIndex('restriction', 'integer')
.defineIndex('dateCreation', 'date')
.defineIndex('dateMiseAJour', 'date')
// les array standard
// faut retourner une vraie fonction, qui sera appelée via fn.call(entity)
const getIndexer = (prop) => function () {
return basicArrayIndexer(this[prop])
}
const defineArrayStdIndex = (prop) => {
EntityRessource.defineIndex(prop, getIndexer(prop))
}
;[
'categories',
'typePedagogiques',
'typeDocumentaires',
'auteurs',
'auteursParents',
'contributeurs'
].forEach(defineArrayStdIndex)
// idem mais avec normalizer
// les groupes chez qui la ressource est publiée
EntityRessource.defineIndex('groupes', { normalizer: getNormalizedName }, getIndexer('groupes'))
// les groupes qui ont un droit d'écriture sur la ressource
EntityRessource.defineIndex('groupesAuteurs', { normalizer: getNormalizedName }, getIndexer('groupesAuteurs'))
// un index qui combine les champs contenant des pid
EntityRessource.defineIndex('iPids', function () {
return basicArrayIndexer([].concat(this.auteurs, this.auteursParents, this.contributeurs))
})
// les champs à indexer pour le fulltext
EntityRessource.defineTextSearchFields([
['titre', 5],
['resume', 2],
// poid de 1 par défaut pour le reste
'commentaires',
'description',
'groupes',
'groupesAuteurs'
])
// beforeStore était dans $ressourceRepository, pour des questions de cycle d'injection de dépendances
// on le ramène ici pour vérifier l'intégrité "interne" de l'entity, en laissant là-bas un beforeSave
// pour vérifier l'intégrité des relations et voir s'il faut archiver
EntityRessource.beforeStore(function (next) {
const logAndNext = (errorMessage) => {
log.dataError(errorMessage)
const error = Error(errorMessage)
error.noLog = true
next(error)
}
// un identifiant pour les messages d'erreur
const id = this.oid ||
this.rid ||
(this.origine && this.idOrigine && `${this.origine}/${this.idOrigine}`) ||
this.titre
// type et titre obligatoire
if (!this.type) return logAndNext(`Ressource sans type, impossible à sauvegarder (${id})`)
if (!this.titre) return logAndNext(`Ressource sans titre, impossible à sauvegarder (${id})`)
try {
// on peut écraser une ressource en fournissant son rid (sans son oid),
// on commence par vérifier ça
if (this.rid) {
const [baseId, oid] = getRidComponents(this.rid)
if (baseId !== myBaseId) return logAndNext(`Ressource avec rid ${this.rid} qui correspond à une autre Sésathèque (${baseId})`)
if (this.oid) {
if (this.oid !== oid) return logAndNext(`Ressource avec rid ${this.rid} incohérent avec son oid ${this.oid}`)
} else {
this.oid = oid
}
} else if (this.oid) {
this.rid = `${myBaseId}/${this.oid}`
} // else rid sera fixé en afterStore d'après l'oid créé
// check origine et idOrigine, qui peuvent être tous deux vides à ce stade
if (this.origine === myBaseId) {
// ressource créée ici, on vérifie la cohérence oid = idOrigine dans ce cas
if (this.idOrigine && this.oid) {
if (this.oid !== this.idOrigine) {
return logAndNext(Error(`Ressource incohérente (oid ${this.oid} avec origine ${this.origine} et idOrigine ${this.idOrigine})`))
} // sinon c'est cohérent et tout va bien
} else if (this.oid) {
// oid sans idOrigine, on fixe
this.idOrigine = this.oid
} else if (this.idOrigine) {
// idOrigine sans oid (ni rid qui aurait fixé oid plus haut)
// => c'est une ressource qui existe déjà dans la base,
// pas normal de n'avoir donné ni rid ni oid => on jette
return logAndNext(`ressource ${this.origine}/${this.idOrigine} existante fournie sans rid ni oid`)
}
} else if (this.origine) {
// on vérifie qu'on essaie pas d'enregistrer ici une ressource d'une autre sésathèque
// (à priori un pb de dump, ou un post vers l'api de la mauvaise sesathèque)
if (exists(this.origine)) {
return logAndNext(`Cette ressource ${this.origine}/${this.idOrigine}) devrait être enregistrée sur ${this.origine}`)
} else if (!this.idOrigine) {
return logAndNext(`origine sans idOrigine (${id})`)
}
} else if (this.type === 'error') {
// le seul cas où on vérifie pas origine/idOrigine,
// on note quand même dans le log qu'on enregistre une erreur
log.dataError('store d’une ressource de type error', this)
} else {
// pas d'origine on pourrait mettre d'office une origine myBaseId ici, mais c'est le boulot
// du contrôleur, le faire ici pourrait masquer une vraie erreur d'aiguillage
return logAndNext(`propriété origine obligatoire (${id})`)
}
// check aliasOf
if (this.aliasOf) {
// on vérifie que ça pointe vers une base connue
try {
getRidComponents(this.aliasOf)
} catch (error) {
return logAndNext(`aliasOf ${this.aliasOf} invalide (ressource ${id})`)
}
}
// on génère la clé de lecture si elle manque, on la vire si elle n'est plus nécessaire
if (this.publie && !this.restriction) {
// public
if (hasProp(this, 'cle')) delete this.cle
} else {
// prive
if (!this.cle) this.cle = uuid()
}
// les arbres ont une propriété enfants obligatoire
if (this.type === 'arbre') {
if (!Array.isArray(this.enfants)) return logAndNext(`arbre sans propriété enfants (${id})`)
}
// dans résumé/commentaires/description :
// - on vire tous les tags html sauf <br>
// - on remplace les <br /> par des \n
;['commentaires', 'resume', 'description'].forEach(p => {
if (this[p]) this[p] = cleanHtml(this[p])
})
// pas le meme pid en auteursParents s'il est déjà dans auteurs
if (isArrayNotEmpty(this.auteurs)) {
const isNotAuteur = pid => !this.auteurs.includes(pid)
if (isArrayNotEmpty(this.auteursParents)) {
this.auteursParents = this.auteursParents.filter(isNotAuteur)
}
if (isArrayNotEmpty(this.contributeurs)) {
this.contributeurs = this.contributeurs.filter(isNotAuteur)
}
}
// date de création
if (!this.dateCreation) this.dateCreation = new Date()
// date de mise à jour, sauf en cas de réindexation
// (si c'est pas reindex c'est un autre batch qui indique ça)
// @todo gérer dateMiseAJour dans le contrôleur
if (!this.$byPassDuplicate) this.dateMiseAJour = new Date()
// cohérence de la restriction
if (this.restriction === configRessource.constantes.restriction.groupe && (!this.groupes || !this.groupes.length)) {
log.dataError(`Ressource ${id} restreinte à ses groupes sans préciser lesquels, on la passe privée`)
this.restriction = configRessource.constantes.restriction.prive
}
// version & inc
if (!this.version) this.version = 0
if (!this.inc) this.inc = 0
// check des relations dans $ressourceRepository.beforeSave() mais pas ici
// (pour permettre des batch qui sauvent des paires liées par ex,
// quand la 1re a pas encore sa relation en base)
next(null, this)
} catch (error) {
next(error)
}
})
// met en idOrigine l'oid de la ressource si origine locale et que ça n'y était pas encore
// idem pour rid
EntityRessource.afterStore(function (next) {
let needToStore = false
// on ajoute notre id en idOrigine si on est l'origine
if (this.origine === myBaseId && !this.idOrigine) {
this.idOrigine = this.oid
needToStore = true
}
// on complète rid si besoin
if (!this.rid) {
this.rid = myBaseId + '/' + this.oid
needToStore = true
}
if (needToStore) {
this.store(next)
} else {
next()
}
})
// attention, c'est aussi déclenché sur un create avec oid
EntityRessource.onLoad(function () {
// on stocke la ressource telle qu'elle était au chargement, sérialisée pour ne pas avoir de shallow copy
if (this.oid) this.$original = stringify(this)
})
})
}