@discoveryjs/discovery
Version:
Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards
338 lines (337 loc) • 10.1 kB
JavaScript
import jora from "jora";
import { normalizeEncodings } from "../core/encodings/utils.js";
import { loadDataFromEvent, loadDataFromFile, loadDataFromStream, loadDataFromUrl } from "../core/utils/load-data.js";
import { TextViewRenderer } from "../core/text-view.js";
import * as textViews from "../text-views/index.js";
import { Emitter } from "../core/emitter.js";
import { ActionManager } from "../core/action.js";
import { ObjectMarkerManager } from "../core/object-marker.js";
import { createExtensionApi, setupModel } from "./model-extension-api.js";
import { createLegacyExtensionApi } from "./model-legacy-extension-api.js";
import { querySuggestions } from "./query-suggestions.js";
import { Logger } from "../core/utils/logger.js";
import { equal } from "../core/utils/compare.js";
const noopQuery = () => void 0;
const logPrefix = "[Discovery]";
const isJoraIdentifier = (value) => /^[a-zA-Z_][a-zA-Z_$0-9]*$/.test(value) && !joraKeywords.includes(value);
const joraKeywords = [
"and",
"or",
"in",
"has",
"is",
"not",
"no",
"asc",
"ascN",
"ascA",
"ascNA",
"ascAN",
"desc",
"descN",
"descA",
"descNA",
"descAN"
];
const mixinEncodings = (host, options) => ({
...options,
encodings: Array.isArray(options?.encodings) ? [...options.encodings, ...host.encodings] : host.encodings
});
export class Model extends Emitter {
options;
info;
logger;
action;
objectMarkers;
linkResolvers;
encodings;
datasets;
data;
#context;
prepare;
#legacyPrepare;
#lastSetData;
textView;
constructor(options) {
super();
this.options = options || {};
const {
name,
version,
description,
icon,
logLevel = "warn",
logger = console,
extensions,
encodings,
context,
setup
} = options || {};
this.info = {
name: name || "Untitled model",
version: version || null,
description: description || null,
icon: icon || null
};
this.logger = new Logger(logPrefix, logLevel, logger);
this.action = new ActionManager();
this.objectMarkers = new ObjectMarkerManager(this.logger);
this.linkResolvers = [];
this.datasets = [];
this.encodings = normalizeEncodings(encodings);
this.data = void 0;
this.#context = context;
this.prepare = (data) => data;
this.#legacyPrepare = true;
this.textView = new TextViewRenderer(this);
this.apply(extensions);
if (typeof setup === "function") {
this.#legacyPrepare = false;
setupModel(this, setup);
} else {
setupModel(this, () => {
});
}
this.apply(textViews);
for (const { page, lookup } of this.objectMarkers.values) {
if (page) {
this.linkResolvers.push((value) => {
const marker = lookup(value);
return marker && {
type: page,
text: marker.title,
href: marker.href,
entity: marker.object
};
});
}
}
}
// extension
apply(extensions) {
if (Array.isArray(extensions)) {
extensions.forEach((extension) => this.apply(extension));
} else if (typeof extensions === "function") {
extensions.call(null, this);
} else if (extensions) {
this.apply(Object.values(extensions));
}
}
// logging
log() {
this.logger.error("Model#log() is deprecated, use Model#logger interface instead");
}
// ==========
// Data & context
//
get legacyPrepare() {
return this.#legacyPrepare;
}
setPrepare(fn) {
if (typeof fn !== "function") {
throw new Error("An argument should be a function");
}
this.prepare = fn;
}
get context() {
return this.#context;
}
set context(context) {
this.setContext(context, true);
}
setContext(context, replace = false) {
const prevContext = this.#context;
if (replace) {
this.#context = context;
} else {
const newContext = {
...this.#context,
...context
};
if (!equal(newContext, prevContext)) {
this.#context = newContext;
}
}
if (!Object.is(this.#context, prevContext)) {
this.emit("context", prevContext, this.#context);
}
}
getContext() {
return {
model: this.info,
actions: this.action.actionMap,
datasets: this.datasets,
data: this.data,
...this.context
};
}
setData(data, options) {
options = options || {};
const setDataMarker = Symbol();
this.#lastSetData = setDataMarker;
const startTime = Date.now();
const checkIsNotPrevented = () => {
if (this.#lastSetData !== setDataMarker) {
throw new Error("Prevented by another setData()");
}
};
const prepareApi = this.#legacyPrepare ? createLegacyExtensionApi(this, options) : createExtensionApi(this, options);
const setDataPromise = Promise.resolve().then(() => {
checkIsNotPrevented();
prepareApi.before?.(this);
return this.prepare.call(null, data, prepareApi.contextApi) || data;
}).then((data2) => {
checkIsNotPrevented();
this.datasets = [{ ...options.dataset, data: data2 }];
this.data = data2;
prepareApi.after?.(this);
this.emit("data");
this.logger.perf(`Data prepared in ${Date.now() - startTime}ms`);
});
return setDataPromise;
}
async trackLoadDataProgress(loadDataResult) {
const startTime = Date.now();
const dataset = await loadDataResult.dataset;
this.logger.perf(`Data loaded in ${Date.now() - startTime}ms`);
return this.setData(dataset.data, { dataset });
}
loadDataFromStream(stream, options) {
return this.trackLoadDataProgress(loadDataFromStream(stream, mixinEncodings(this, options)));
}
loadDataFromEvent(event, options) {
return this.trackLoadDataProgress(loadDataFromEvent(event, mixinEncodings(this, options)));
}
loadDataFromFile(file, options) {
return this.trackLoadDataProgress(loadDataFromFile(file, mixinEncodings(this, options)));
}
loadDataFromUrl(url, options) {
return this.trackLoadDataProgress(loadDataFromUrl(url, mixinEncodings(this, options)));
}
unloadData() {
if (!this.hasDatasets()) {
return;
}
this.datasets = [];
this.data = void 0;
this.emit("unloadData");
}
hasDatasets() {
return this.datasets.length !== 0;
}
// ======================
// Data query
//
getQueryEngineInfo() {
return {
name: "jora",
version: jora.version,
link: "https://discoveryjs.github.io/jora/#article:jora-syntax"
};
}
queryFnFromString() {
return noopQuery;
}
queryFn(query) {
switch (typeof query) {
case "function":
return query;
case "string":
return Object.assign(this.queryFnFromString(query), { query });
}
}
query(query, data, context) {
switch (typeof query) {
case "function":
return query(data, context);
case "string":
return this.queryFn(query)(data, context);
default:
return query;
}
}
queryBool(query, data, context) {
return jora.buildin.bool(this.query(query, data, context));
}
querySuggestions(query, offset, data, context) {
return querySuggestions(this, query, offset, data, context);
}
pathToQuery(path) {
let query = "";
const putPart = (part) => query += query === "" ? part[0] === "[" ? "$" + part : part : part[0] === "[" ? part : "." + part;
for (const part of path) {
if (part === "*") {
putPart("values()");
} else if (typeof part === "number") {
putPart(`[${part}]`);
} else if (isJoraIdentifier(part)) {
putPart(part);
} else {
putPart(`["${part}"]`);
}
}
return query;
}
// ======================
// Links
//
stripAnchorFromHash(hash) {
return typeof hash === "string" ? hash.replace(/(^|&)!anchor(=[^&]+|(?=&|$))/g, "") : hash;
}
encodePageHash(pageId = null, pageRef = null, pageParams, pageAnchor = null) {
let encodedParams = pageParams;
if (encodedParams) {
if (typeof encodedParams !== "string") {
if (!Array.isArray(encodedParams)) {
encodedParams = Object.entries(encodedParams);
}
encodedParams = encodedParams.filter(([name]) => name !== "!anchor").map((pair) => pair.map(encodeURIComponent).join("=")).join("&");
} else {
encodedParams = this.stripAnchorFromHash(encodedParams);
}
}
return `#${pageId ? encodeURIComponent(pageId) : ""}${typeof pageRef === "string" && pageRef || typeof pageRef === "number" ? ":" + encodeURIComponent(pageRef) : ""}${encodedParams ? "&" + encodedParams : ""}${pageAnchor ? "&!anchor=" + encodeURIComponent(pageAnchor) : ""}`;
}
decodePageHash(hash, getDecodeParams = () => Object.fromEntries) {
const delimIndex = (hash.indexOf("&") + 1 || hash.length + 1) - 1;
const [pageId, pageRef = null] = hash.substring(hash[0] === "#" ? 1 : 0, delimIndex).split(":").map(decodeURIComponent);
const paramsTuples = [];
let pageAnchor = null;
for (const pair of hash.slice(delimIndex + 1).split("&").filter(Boolean)) {
const eqIndex = pair.indexOf("=");
let name;
let value;
if (eqIndex !== -1) {
name = decodeURIComponent(pair.slice(0, eqIndex));
value = decodeURIComponent(pair.slice(eqIndex + 1));
} else {
name = decodeURIComponent(pair);
value = true;
}
if (name === "!anchor") {
pageAnchor = value && value !== true ? value : null;
} else {
paramsTuples.push([name, value]);
}
}
return {
pageId,
pageRef,
pageParams: getDecodeParams(pageId)(paramsTuples),
pageAnchor
};
}
resolveValueLinks(value) {
const result = [];
const type = typeof value;
if (value && (type === "object" || type === "string")) {
for (const resolver of this.linkResolvers) {
const link = resolver(value);
if (link) {
result.push(link);
}
}
}
return result.length ? result : null;
}
}