'use strict';
/**
* Model for review subjects, including metadata such as URLs, author names,
* business hours, etc.
*
* @namespace Thing
*/
const thinky = require('../db');
const r = thinky.r;
const type = thinky.type;
const urlUtils = require('../util/url-utils');
const debug = require('../util/debug');
const mlString = require('./helpers/ml-string');
const revision = require('./helpers/revision');
const slugName = require('./helpers/slug-name');
const getSetIDHandler = require('./helpers/set-id');
const File = require('./file');
const ThingSlug = require('./thing-slug');
const isValidLanguage = require('../locales/languages').isValid;
const ReportedError = require('../util/reported-error');
const adapters = require('../adapters/adapters');
const search = require('../search');
let thingSchema = {
id: type.string(),
// First element is primary URL associated with this thing
urls: [type.string().validator(_isValidURL)],
label: mlString.getSchema({
maxLength: 256
}),
aliases: mlString.getSchema({
maxLength: 256,
array: true
}),
description: mlString.getSchema({
maxLength: 512
}),
// Many creative works have a subtitle that we don't typically include as part
// of the label.
subtitle: mlString.getSchema({
maxlength: 256
}),
// For creative works like books, magazine articles. Author names can be
// transliterated, hence also a multilingual field. However, it is advisable
// to treat it as monolingual in presentation (i.e. avoid indicating language),
// since cross-language differences are the exception and not the norm.
authors: [mlString.getSchema({
maxLength: 256
})],
// Data for fields can be pulled from external sources. For fields that
// support this, we record whether a sync is currently active (in which
// case the field is not editable), when the last sync took place,
// and from where.
//
// Note we don't initialize the "active" property -- if (and only if) it is
// undefined, it may be switched on by the /adapters/sync scripts.
sync: {
description: {
active: type.boolean(),
updated: type.date(),
source: type.string().enum(['wikidata'])
}
},
originalLanguage: type
.string()
.max(4)
.validator(isValidLanguage),
canonicalSlugName: type.string(),
urlID: type.virtual().default(function() {
return this.canonicalSlugName ? encodeURIComponent(this.canonicalSlugName) : this.id;
}),
// Track original authorship across revisions
createdOn: type.date().required(true),
createdBy: type.string().uuid(4).required(true),
// These can only be populated from the outside using a user object
userCanDelete: type.virtual().default(false),
userCanEdit: type.virtual().default(false),
userCanUpload: type.virtual().default(false),
userIsCreator: type.virtual().default(false),
// Populated asynchronously using the populateReviewMetrics method
numberOfReviews: type.virtual().default(0),
averageStarRating: type.virtual().default(0)
};
// Add versioning related fields
Object.assign(thingSchema, revision.getSchema());
let Thing = thinky.createModel("things", thingSchema);
Thing.hasAndBelongsToMany(File, "files", "id", "id", {
type: 'media_usage'
});
File.hasAndBelongsToMany(Thing, "things", "id", "id", {
type: 'media_usage'
});
Thing.hasOne(ThingSlug, "slug", "id", "thingID");
ThingSlug.belongsTo(Thing, "thing", "thingID", "thing");
// NOTE: STATIC METHODS --------------------------------------------------------
// Standard handlers -----------------------------------------------------------
Thing.getNotStaleOrDeleted = revision.getNotStaleOrDeletedGetHandler(Thing);
Thing.filterNotStaleOrDeleted = revision.getNotStaleOrDeletedFilterHandler(Thing);
// Custom methods --------------------------------------------------------------
/**
* Find a Thing object using a URL. May return multiple matches (but ordinarily
* should not).
*
* @param {String} url
* the URL to look up
* @param {String} [userID]
* include any review(s) of this thing by the given user
* @returns {Query}
* query for current revisions that contain this URL
*/
Thing.lookupByURL = function(url, userID) {
let query = Thing
.filter(thing => thing('urls').contains(url))
.filter({ _oldRevOf: false }, { default: true })
.filter({ _revDeleted: false }, { default: true });
if (typeof userID == 'string')
query = query.getJoin({
reviews: {
_apply: seq => seq
.filter({ createdBy: userID })
.filter({ _oldRevOf: false }, { default: true })
.filter({ _revDeleted: false }, { default: true })
}
});
return query;
};
/**
* Get a Thing object by ID, plus some of the data linked to it.
*
* @async
* @param {String} id
* the unique ID of the Thing object
* @param {Object} [options]
* which data to include
* @param {Boolean} options.withFiles=true
* include metadata about file upload via join
* @param {Boolean} options.withReviewMetrics=true
* obtain review metrics (e.g., average rating); requires additional table
* lookup
* @returns {Thing}
* the Thing object
*/
Thing.getWithData = async function(id, {
// First-level joins
withFiles = true,
withReviewMetrics = true
} = {}) {
let join;
if (withFiles)
join = {
files: {
uploader: true,
_apply: seq => seq
.filter({ completed: true }) // We don't show unfinished uploads
.filter({ _revDeleted: false }, { default: true })
.filter({ _oldRevOf: false }, { default: true })
}
};
const thing = await Thing.getNotStaleOrDeleted(id, join);
if (thing._revDeleted)
throw revision.deletedError;
if (thing._oldRevOf)
throw revision.staleError;
if (withReviewMetrics)
await thing.populateReviewMetrics();
return thing;
};
/**
* Get label for a given thing in the provided language, or fall back to a
* prettified URL.
*
* @param {Thing} thing
* the thing object to get a label for
* @param {String} language
* the language code of the preferred language
* @returns {String}
* the best available label
*/
Thing.getLabel = function(thing, language) {
if (!thing || !thing.id)
return undefined;
let str;
if (thing.label)
str = mlString.resolve(language, thing.label).str;
if (str)
return str;
// If we have no proper label, we can at least show the URL
if (thing.urls && thing.urls.length)
return urlUtils.prettify(thing.urls[0]);
return undefined;
};
// INSTANCE METHODS ------------------------------------------------------------
// Standard handlers -----------------------------------------------------------
// See helpers/revision.js
Thing.define("newRevision", revision.getNewRevisionHandler(Thing));
Thing.define("deleteAllRevisions", revision.getDeleteAllRevisionsHandler(Thing));
// See helpers/slug-name.js
Thing.define("updateSlug", slugName.getUpdateSlugHandler({
SlugModel: ThingSlug,
slugForeignKey: 'thingID',
slugSourceField: 'label'
}));
// See helpers/set-id.js
Thing.define("setID", getSetIDHandler());
// Custom methods --------------------------------------------------------------
Thing.define("initializeFieldsFromAdapter", initializeFieldsFromAdapter);
Thing.define("populateUserInfo", populateUserInfo);
Thing.define("populateReviewMetrics", populateReviewMetrics);
Thing.define("setURLs", setURLs);
Thing.define("updateActiveSyncs", updateActiveSyncs);
Thing.define("getSourceIDsOfActiveSyncs", getSourceIDsOfActiveSyncs);
Thing.define("getReviewsByUser", getReviewsByUser);
Thing.define("getAverageStarRating", getAverageStarRating);
Thing.define("getReviewCount", getReviewCount);
Thing.define("addFile", addFile);
Thing.define("addFilesByIDsAndSave", addFilesByIDsAndSave);
/**
* Initialize field values from the lookup result of an adapter. Each adapter
* has a whitelist of supported fields which we check against before assigning
* values to the thing instance.
*
* This function does not save and can therefore run synchronously.
* It resets all sync settings (whether a sync for a given field is active or
* not), so it should only be invoked on new Thing objects.
*
* Silently ignores empty results.
*
* @param {Object} adapterResult
* the result from any backend adapter
* @param {Object} adapterResult.data
* data for this result
* @param {String} adapterResult.sourceID
* canonical source identifier
* @throws
* on malformed adapterResult object
* @instance
* @memberof Thing
*/
function initializeFieldsFromAdapter(adapterResult) {
if (typeof adapterResult != 'object')
return;
let responsibleAdapter = adapters.getAdapterForSource(adapterResult.sourceID);
let supportedFields = responsibleAdapter.getSupportedFields();
for (let field in adapterResult.data) {
if (supportedFields.includes(field)) {
this[field] = adapterResult.data[field];
if (typeof this.sync != 'object')
this.sync = {};
this.sync[field] = {
active: true,
source: adapterResult.sourceID,
updated: new Date()
};
}
}
}
/**
* Populate virtual permission fields in a Thing object with the rights of a
* given user.
*
* @param {User} user
* the user whose permissions to check
* @memberof Thing
* @instance
*/
function populateUserInfo(user) {
if (!user)
return; // Permissions will be at their default value (false)
// For now, we don't let users delete things they've created,
// since things are collaborative in nature
this.userCanDelete = user.isSuperUser || user.isSiteModerator || false;
this.userCanEdit = user.isSuperUser || user.isTrusted || user.id === this.createdBy;
this.userCanUpload = user.isSuperUser || user.isTrusted;
this.userIsCreator = user.id === this.createdBy;
}
/**
* Set this Thing object's virtual data fields for review metrics (performs
* table lookups, hence asynchronous). Does not save.
*
* @returns {Thing}
* the modified thing object
* @memberof Thing
* @instance
*/
async function populateReviewMetrics() {
const [averageStarRating, numberOfReviews] = await Promise.all([
this.getAverageStarRating(),
this.getReviewCount()
]);
this.averageStarRating = averageStarRating;
this.numberOfReviews = numberOfReviews;
return this;
}
/**
* Update URLs and reset a Thing object's synchronization settings, based on
* which adapters report that they can retrieve external metadata for a given
* URL. Does not save.
*
* @param {String[]} urls
* the *complete* array of URLs to assign (previously assigned URLs will be
* overwritten)
* @instance
* @memberof Thing
*/
function setURLs(urls) {
// Turn off all synchronization
if (typeof this.sync == 'object') {
for (let field in this.sync) {
this.sync[field].active = false;
}
}
// Turn on synchronization for supported fields. The first adapter
// from the getAll() array to claim a supported field will be responsible
// for it. The order of the URLs also matters -- adapters handling earlier
// URLs can claim fields before adapters handling later URLs get to them.
// We could change the order to enforce precedence, but for now, we leave
// it up to the editor.
urls.forEach(url => {
adapters.getAll().forEach(adapter => {
if (adapter.ask(url)) {
adapter.supportedFields.forEach(field => {
// Another adapter is already handling this field
if (this.sync && this.sync[field] && this.sync[field].active)
return;
if (!this.sync)
this.sync = {};
this.sync[field] = {
active: true,
source: adapter.sourceID
};
});
}
});
});
this.urls = urls;
}
/**
* Fetch new external data for all fields set to be synchronized. Saves.
* - Does not create a new revision, so if you need one, create it first.
* - Performs search index update.
* - Resolves with updated thing object.
* - May result in a slug update, so if initiated by a user, should be passed the
* user ID. Otherwise, any slug changes will be without attribution
*
* @param {String} userID
* the user to associate with any slug changes
* @returns {Thing}
* the updated thing
* @memberof Thing
* @instance
*/
async function updateActiveSyncs(userID) {
const thing = this; // For readability
// No URLs? Just give the thing back.
if (!thing.urls || !thing.urls.length)
return thing;
// No known syncs? We'll still fall through to the save/index operation at
// the end.
if (typeof thing.sync !== 'object')
thing.sync = {};
// Determine which external sources we need to contact. While one source
// may give us updates for many fields, we obviously only want to contact
// it once.
let sources = [];
for (let field in thing.sync) {
if (thing.sync[field].active && thing.sync[field].source &&
!sources.includes(thing.sync[field].source))
sources.push(thing.sync[field].source);
}
// Build array of lookup promises from currently used sources
let p = [];
// Keep track of all URLs we're contacting for convenience
let allURLs = [];
sources.forEach(source => {
let adapter = adapters.getAdapterForSource(source);
// Find all matching URLs in array (we might have, e.g., a URL for
// a work and one for an edition, and need to contact both).
let relevantURLs = thing.urls.filter(url => adapter.ask(url));
allURLs = allURLs.concat(relevantURLs);
// Add relevant lookups as individual elements to array, log errors
p.push(...relevantURLs.map(url =>
adapter
.lookup(url)
.catch(error => {
debug.error(`Problem contacting adapter "${adapter.getSourceID()}" for URL ${url}.`);
debug.error({ error });
})
));
});
if (allURLs.length)
debug.app(`Retrieving item metadata for ${thing.id} from the following URL(s):\n` +
allURLs.join(', '));
// Perform all lookups, then update thing from the results, which will be
// returned in the same order as the sources array.
const results = await Promise.all(p);
// If the label has changed, we need to update the short identifier
// (slug). This means a possible URL change! Redirects are put in
// place automatically.
let needSlugUpdate;
// Get obj w/ key = source ID, value = reverse order array of
// result.data objects
let dataBySource = _organizeDataBySource(results);
sources.forEach((source) => {
for (let field in thing.sync) {
// Only update if everything looks good: sync is active, source
// ID matches, we may have new data
if (thing.sync[field].active && thing.sync[field].source === source &&
Array.isArray(dataBySource[source])) {
// Earlier results get priority, i.e. need to be assigned last
for (let d of dataBySource[source]) {
if (d[field] !== undefined) {
thing.sync[field].updated = new Date();
this[field] = d[field];
if (field == 'label')
needSlugUpdate = true;
}
}
}
}
});
if (needSlugUpdate)
await thing.updateSlug(userID, thing.originalLanguage || 'en');
await thing.save();
// Index update can keep running after we resolve this promise, hence no "await"
search.indexThing(thing);
return thing;
/**
* Put valid data from results array into an object with sourceID as
* the key and data as a reverse-order array. There may be multiple
* URLs from one source, assigning value to the same field. URLs earlier
* in the original thing.urls array take priority, so we have to ensure
* they come last.
*
* @param {Object[]} results
* results from multiple adapters
* @returns {Object}
* key = source, value = array of data objects
* @memberof Thing
* @inner
* @protected
*/
function _organizeDataBySource(results) {
let rv = {};
results.forEach(result => {
// Correctly formatted result object with all relevant information
if (typeof result == 'object' && typeof result.sourceID == 'string' &&
typeof result.data == 'object') {
// Initialize array if needed
if (!Array.isArray(rv[result.sourceID]))
rv[result.sourceID] = [];
// See above on why this array is in reverse order
rv[result.sourceID].unshift(result.data);
}
});
return rv;
}
}
/**
* Get the identifiers of all sources for which relevant information is being
* fetched for this review subject.
*
* @returns {String[]}
* array of the source IDs
*/
function getSourceIDsOfActiveSyncs() {
const sync = this.sync;
const rv = [];
for (let key in sync) {
if (sync[key] && sync[key].active && sync[key].source && !rv.includes(sync[key].source))
rv.push(sync[key].source);
}
return rv;
}
/**
* Get all reviews by the given user for this thing. The number is typically
* 1, but there may be edge cases or bugs where a user will have multiple
* reviews for the same thing.
*
* @param {User} user
* the user whose reviews we're looking up for this thing
* @returns {Array}
* array of the reviews
* @instance
* @memberof Thing
*/
async function getReviewsByUser(user) {
let Review = require('./review');
if (!user)
return [];
const reviews = await Review
.filter({
thingID: this.id,
createdBy: user.id
})
.filter(r.row('_revDeleted').eq(false), { // Exclude deleted rows
default: true
})
.filter(r.row('_oldRevOf').eq(false), { // Exclude old revisions
default: true
})
.getJoin({
creator: {
_apply: seq => seq.without('password')
},
teams: true
});
reviews.forEach(review => review.populateUserInfo(user));
return reviews;
}
/**
* Calculate the average review rating for this Thing object
*
* @returns {Number}
* average rating, not rounded
* @memberof Thing
* @instance
*/
async function getAverageStarRating() {
try {
return await r.table('reviews')
.filter({ thingID: this.id })
.filter({ _oldRevOf: false }, { default: true })
.filter({ _revDeleted: false }, { default: true })
.avg('starRating');
} catch (error) {
// Throws if the stream is empty. We consider a subject with 0 reviews
// to have an average rating of 0.
if (error.name == 'ReqlRuntimeError')
return 0;
else
throw error;
}
}
/**
* Count the number of reviews associated with this Thing object (discounting
* old/deleted revisions).
*
* @returns {Number}
* the number of reviews
* @memberof Thing
* @instance
*/
async function getReviewCount() {
return await r.table('reviews')
.filter({ thingID: this.id })
.filter({ _oldRevOf: false }, { default: true })
.filter({ _revDeleted: false }, { default: true })
.count();
}
/**
* Simple helper method to initialize files array for a Thing object if it does
* not exist already, and then add a file.
*
* @param {File} file
* File object to add
* @memberof Thing
* @instance
*/
function addFile(file) {
if (this.files === undefined)
this.files = [];
this.files.push(file);
}
/**
* Obtain file objects for an array of file IDs, and associate them with this
* thing. Saves.
*
* @param {String[]} files
* IDs of the files to add
* @param {String} [userID]
* if given, uploader must match this user ID. Mismatches are silently ignored.
* @memberof Thing
* @instance
*/
async function addFilesByIDsAndSave(files, userID) {
const fileRevs = await File
.getAll(...files)
.filter({ _revDeleted: false }, { default: true })
.filter({ _oldRevOf: false }, { default: true });
fileRevs.forEach(fileRev => {
if (!userID || fileRev.uploadedBy === userID)
this.addFile(fileRev);
});
await this.saveAll({ files: true });
}
// Internal helper functions
/**
* @param {String} url
* URL to check
* @returns {Boolean}
* true if valid
* @throws {ReportedError}
* if invalid
* @memberof Thing
* @protected
*/
function _isValidURL(url) {
if (urlUtils.validate(url))
return true;
else
throw new ReportedError({
message: 'Thing URL %s is not a valid URL.',
messageParams: [url],
userMessage: 'invalid url',
userMessageParams: []
});
}
module.exports = Thing;