UNPKG

@discoveryjs/discovery

Version:

Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards

338 lines (337 loc) 10.1 kB
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; } }