UNPKG

alinea

Version:
570 lines (568 loc) 20.2 kB
import { any, array, object } from "../../chunks/chunk-WD7H5L2L.js"; import "../../chunks/chunk-NZLE2WMY.js"; // src/core/db/EntryResolver.ts import { EntryFields } from "alinea/core/EntryFields"; import { Field } from "alinea/core/Field"; import { querySource as queryEdge } from "alinea/core/Graph"; import { getExpr, hasExpr, hasField, hasRoot, hasWorkspace } from "alinea/core/Internal"; import { getScope } from "alinea/core/Scope"; import { hasExact } from "alinea/core/util/Checks"; import { entries, fromEntries } from "alinea/core/util/Objects"; import { unreachable } from "alinea/core/util/Types"; import { createRecord } from "../EntryRecord.js"; import { compareStrings } from "../source/Utils.js"; import { assert } from "../util/Assert.js"; import { combineConditions } from "./EntryIndex.js"; import { LinkResolver } from "./LinkResolver.js"; var orFilter = object({ or: array(any) }).and(hasExact(["or"])); var andFilter = object({ and: array(any) }).and(hasExact(["and"])); var EntryResolver = class { index; #scope; constructor(config, index) { this.#scope = getScope(config); this.index = index; } call(ctx, entry, internal) { switch (internal.method) { case "snippet": { if (!ctx.searchTerms) throw new Error("Snippet method requires search terms to be provided"); const start = this.expr(ctx, entry, internal.args[0]); const end = this.expr(ctx, entry, internal.args[1]); const cutOff = this.expr(ctx, entry, internal.args[2]); const limit = this.expr(ctx, entry, internal.args[3]); return snippet( entry.searchableText, ctx.searchTerms, start, end, cutOff, limit ); } default: throw new Error(`Unknown method: "${internal.method}"`); } } field(entry, field) { const name = this.#scope.nameOf(field); if (!name) throw new Error(`Expression has no name ${field}`); return entry.data[name]; } expr(ctx, entry, expr) { const internal = getExpr(expr); switch (internal.type) { case "field": { const result = this.field(entry, expr); if (result && typeof result === "object") { return structuredClone(result); } return result; } case "entryField": return entry[internal.name]; case "call": return this.call(ctx, entry, internal); case "value": return internal.value; default: unreachable(internal); } } projectTypes(types) { return entries(EntryFields).concat(types.flatMap(entries)); } projection(query) { return query.select ?? fromEntries( (query.type ? this.projectTypes( Array.isArray(query.type) ? query.type : [query.type] ) : []).concat(entries(EntryFields)).concat(query.include ? entries(query.include) : []) ); } sourceFilter(ctx, entry, query) { switch (query.edge) { case "parent": { return { nodes: entry.parentId ? [ctx.graph.byId(entry.parentId)] : [], language: (lang) => lang.locale === entry.locale }; } case "next": { const parent = entry.parentId ? ctx.graph.byId(entry.parentId) : void 0; const [next] = Array.from( ctx.graph.filter({ nodes: parent?.children() ?? [], node: (node) => node.index > entry.index, language: (lang) => lang.locale === entry.locale }) ).sort((a, b) => compareStrings(a.index, b.index)); const nodes = next ? [ctx.graph.byId(next.id)] : []; return { nodes }; } case "previous": { const parent = entry.parentId ? ctx.graph.byId(entry.parentId) : void 0; const [previous] = Array.from( ctx.graph.filter({ nodes: parent?.children() ?? [], node: (node) => node.index < entry.index, language: (lang) => lang.locale === entry.locale }) ).sort((a, b) => compareStrings(b.index, a.index)); const nodes = previous ? [ctx.graph.byId(previous.id)] : []; return { nodes }; } case "siblings": { const parent = entry.parentId ? ctx.graph.byId(entry.parentId) : void 0; return { nodes: parent?.children() ?? [], node: (node) => query.includeSelf || node.id !== entry.id, language: (lang) => lang.locale === entry.locale }; } case "translations": { const self = ctx.graph.byId(entry.id); assert(self); return { nodes: [self], language: (lang) => query.includeSelf || lang.locale !== entry.locale }; } case "children": { const depth = query?.depth ?? 1; return { entry: (e) => e.filePath.startsWith(entry.childrenDir), node: (node) => node.level > entry.level && node.level <= entry.level + depth }; } case "parents": { const depth = query?.depth ?? Number.POSITIVE_INFINITY; const ids = entry.parents.slice(-depth); const nodes = ids.map((id) => ctx.graph.byId(id)); return { nodes, language: (lang) => lang.locale === entry.locale }; } case "entryMultiple": { const fieldValue = this.field(entry, query.field); const ids = Array.isArray(fieldValue) ? fieldValue.map((item) => item._entry).filter(Boolean) : []; const nodes = ids.map((id) => ctx.graph.byId(id)).filter(Boolean); return { nodes }; } case "entrySingle": { const fieldValue = this.field(entry, query.field); const entryId = fieldValue?._entry; const node = ctx.graph.byId(entryId); return { nodes: node ? [node] : [] }; } default: return {}; } } selectProjection(ctx, entry, value) { if (value && hasExpr(value)) return this.expr(ctx, entry, value); const source = queryEdge(value); if (!source) return fromEntries( entries(value).map(([key, value2]) => { return [key, this.selectProjection(ctx, entry, value2)]; }) ); const related = value; return this.query( { ...ctx, locale: entry.locale }, related, this.sourceFilter(ctx, entry, related) ).getUnprocessed(); } select(ctx, entry, query) { if (!entry) return null; if (query.select && hasExpr(query.select)) return this.expr(ctx, entry, query.select); const fields = this.projection(query); return this.selectProjection(ctx, entry, fields); } condition(ctx, query) { const location = Array.isArray(query.location) ? query.location : query.location && this.#scope.locationOf(query.location); const checkStatus = statusChecker(ctx.status); const checkLocation = location && locationChecker(location); const locale = query.locale ?? ctx.locale; let checkLocale = locale !== void 0 && query.edge !== "translations" && localeChecker( typeof locale === "string" ? locale.toLowerCase() : null, false ); if (!checkLocale && query.preferredLocale) checkLocale = localeChecker(query.preferredLocale.toLowerCase(), true); const checkType = Boolean(query.type) && typeChecker( Array.isArray(query.type) ? query.type.map((type) => this.#scope.nameOf(type)) : this.#scope.nameOf(query.type) ); const source = queryEdge(query); const checkEntry = entryChecker(this.#scope, query); const checkFilter = query.filter && filterChecker(query.filter, (entry, name) => { if (name.startsWith("_")) return entry[name.slice(1)]; return entry.data[name]; }); const multipleIds = typeof query.id === "object" && query.id !== null && query.id.in; const ids = Array.isArray(multipleIds) ? multipleIds : typeof query.id === "string" ? [query.id] : void 0; return { ids, condition(entry) { if (!checkStatus(entry)) return false; if (checkLocation && !checkLocation(entry)) return false; if (checkType && !checkType(entry)) return false; const matchesLocale = checkLocale ? checkLocale(entry) : true; if (source !== "translations" && !matchesLocale) return false; if (checkEntry && !checkEntry(entry)) return false; if (checkFilter && !checkFilter(entry)) return false; return true; } }; } isSingleResult(query) { return Boolean( query.first || query.get || query.count || query.edge === "parent" || query.edge === "next" || query.edge === "previous" ); } query(ctx, query, preFilter) { const edge = query; const { skip, take, orderBy, groupBy, search, count } = query; const { ids, condition } = this.condition(ctx, edge); const filter = { search: Array.isArray(search) ? search.join(" ") : search, node: (node) => ids ? ids.includes(node.id) : true, entry: condition }; let entries2 = Array.from( ctx.graph.filter( preFilter ? combineConditions(filter, preFilter) : filter ) ); if (groupBy) { assert(!Array.isArray(groupBy), "groupBy must be a single field"); const groups = /* @__PURE__ */ new Map(); for (const entry of entries2) { const value = this.expr(ctx, entry, groupBy); if (!groups.has(value)) groups.set(value, entry); } entries2 = Array.from(groups.values()); } if (orderBy) { const orders = Array.isArray(orderBy) ? orderBy : [orderBy]; entries2.sort((a, b) => { for (const order of orders) { const expr = order.asc ?? order.desc; const valueA = this.expr(ctx, a, expr); const valueB = this.expr(ctx, b, expr); const strings = typeof valueA === "string" && typeof valueB === "string"; const numbers = typeof valueA === "number" && typeof valueB === "number"; if (strings) { const compare = order.caseSensitive ? compareStrings(valueA, valueB) : valueA.localeCompare(valueB, void 0, { numeric: true }); if (compare !== 0) return order.asc ? compare : -compare; } else if (numbers) { if (valueA !== valueB) return order.asc ? valueA - valueB : valueB - valueA; } } return 0; }); } else if (edge.edge === "parents") { entries2.sort((a, b) => a.level - b.level); } if (skip) entries2.splice(0, skip); if (take) entries2.splice(take); const isSingle = this.isSingleResult(query); const asEdge = query; const getSelected = () => entries2.map((entry) => this.select(ctx, entry, query)); const getUnprocessed = () => { if (count) return entries2.length; const results = getSelected(); if (isSingle) return results[0]; return results; }; const getProcessed = async () => { if (count) return entries2.length; const results = getSelected(); if (isSingle) { const entry = entries2[0]; if (results[0]) { const linkResolver = new LinkResolver(this, ctx, entry.locale); await this.postRow({ linkResolver }, results[0], asEdge); } return results[0]; } if (results.length > 0) { await Promise.all( results.map((result, index) => { if (!result) return; const linkResolver = new LinkResolver( this, ctx, entries2[index].locale ); return this.postRow({ linkResolver }, result, asEdge); }).filter(Boolean) ); } return results; }; return { entries: entries2, getUnprocessed, getProcessed }; } async postField(ctx, interim, field) { const shape = Field.shape(field); await shape.applyLinks(interim, ctx.linkResolver); } async postExpr(ctx, interim, expr) { if (hasField(expr)) await this.postField(ctx, interim, expr); } async postRow(ctx, interim, query) { if (!interim) return; const selected = this.projection(query); if (hasExpr(selected)) return this.postExpr(ctx, interim, selected); if (queryEdge(selected)) return this.post(ctx, interim, selected); await Promise.all( entries(selected).map(([key, value]) => { const source = queryEdge(value); if (source) return this.post(ctx, interim[key], value); return this.postExpr(ctx, interim[key], value); }) ); } async post(ctx, interim, input) { if (input.count === true) return; const isSingle = this.isSingleResult(input); if (isSingle) return this.postRow(ctx, interim, input); await Promise.all(interim.map((row) => this.postRow(ctx, row, input))); } async resolve(query) { const { preview } = query; const previewEntry = preview && "entry" in preview ? preview.entry : void 0; let graph = this.index.graph; if (previewEntry) graph = graph.withChanges({ fromSha: this.index.tree.sha, changes: [ { op: "add", path: previewEntry.filePath, sha: previewEntry.fileHash, contents: new TextEncoder().encode( JSON.stringify( createRecord(previewEntry, previewEntry.status), null, 2 ) ) } ] }); const ctx = { status: query.status ?? "published", locale: query.locale, graph, searchTerms: Array.isArray(query.search) ? query.search.join(" ") : query.search }; return this.query(ctx, query).getProcessed(); } }; function statusChecker(status) { switch (status) { case "published": return (entry) => entry.status === "published"; case "draft": return (entry) => entry.status === "draft"; case "archived": return (entry) => entry.status === "archived"; case "preferDraft": return (entry) => entry.active; case "preferPublished": return (entry) => entry.main; case "all": return (entry) => true; } } function isObject(input) { return input && typeof input === "object"; } function entryChecker(scope, query) { const root = isObject(query.root) && hasRoot(query.root) ? scope.nameOf(query.root) : query.root; const workspace = isObject(query.workspace) && hasWorkspace(query.workspace) ? scope.nameOf(query.workspace) : query.workspace; return filterChecker({ id: query.id, parentId: query.parentId, path: query.path, url: query.url, level: query.level, workspace, root }); } function typeChecker(type) { if (Array.isArray(type)) { return (entry) => type.includes(entry.type); } return (entry) => entry.type === type; } function locationChecker(location) { switch (location.length) { case 1: return (entry) => entry.workspace === location[0]; case 2: return (entry) => entry.workspace === location[0] && entry.root === location[1]; case 3: { return (entry) => { if (entry.workspace !== location[0]) return false; if (entry.root !== location[1]) return false; if (entry.level === 0) return false; if (entry.level === 1) return entry.parentDir.endsWith(`/${location[2]}`); const segment = entry.parentDir.split("/").at(-entry.level); return segment === location[2]; }; } default: return (entry) => true; } } function localeChecker(locale, preferred) { return (entry) => { if (preferred && entry.locale === null) return true; if (typeof entry.locale === "string") return entry.locale.toLowerCase() === locale; return entry.locale === locale; }; } function filterChecker(filter, getField = (input, name) => input[name]) { const isOrFilter = orFilter.check(filter); if (isOrFilter) { const or = filter.or.filter(Boolean).map((op) => filterChecker(op, getField)); return (input) => { for (const fn of or) if (fn(input)) return true; return false; }; } const isAndFilter = andFilter.check(filter); if (isAndFilter) { const and = filter.and.filter(Boolean).map((op) => filterChecker(op, getField)); return (input) => { for (const fn of and) if (!fn(input)) return false; return true; }; } if (typeof filter !== "object" || filter === null) { return (input) => input === filter; } const conditions = createConditions(filter, getField); return (input) => { for (const condition of conditions) if (!condition(input)) return false; return true; }; } function createConditions(ops, getField) { const conditions = Array(); for (const [name, op] of entries(ops)) { if (op === void 0) continue; if (typeof op !== "object" || op === null) { conditions.push((input) => getField(input, name) === op); continue; } const inner = op; if (inner.is !== void 0) conditions.push((input) => getField(input, name) === inner.is); if (inner.isNot !== void 0) conditions.push((input) => getField(input, name) !== inner.isNot); const inOp = inner.in; if (Array.isArray(inOp)) conditions.push((input) => input && inOp.includes(getField(input, name))); const notInOp = inner.notIn; if (Array.isArray(notInOp)) conditions.push( (input) => input && !notInOp.includes(getField(input, name)) ); if (inner.gt !== void 0) conditions.push((input) => getField(input, name) > inner.gt); if (inner.gte !== void 0) conditions.push((input) => getField(input, name) >= inner.gte); if (inner.lt !== void 0) conditions.push((input) => getField(input, name) < inner.lt); if (inner.lte !== void 0) conditions.push((input) => getField(input, name) <= inner.lte); if (inner.startsWith) conditions.push((input) => { const field = getField(input, name); return typeof field === "string" && field.startsWith(inner.startsWith); }); const orOp = inner.or; if (orOp) { const inner2 = Array.isArray(orOp) ? orOp.flatMap((op2) => createConditions(op2, getField)) : createConditions(orOp, getField); conditions.push((input) => { for (const condition of inner2) if (condition(input)) return true; return false; }); } if (inner.has) { const has = filterChecker(inner.has); conditions.push((input) => has(getField(input, name))); } if (inner.includes) { const includes = filterChecker(inner.includes); conditions.push((input) => { const field = getField(input, name); if (!Array.isArray(field)) return false; for (const item of field) if (includes(item)) return true; return false; }); } } return conditions; } function snippet(body, searchTerms, start, end, cutOff, limit) { if (limit <= 0 || limit > 64) throw new Error( "The 'limit' parameter must be greater than zero and less than or equal to 64." ); const terms = searchTerms.toLowerCase().split(/\s+/).filter((term) => term !== ""); const words = body.split(/\s+/); const highlightedWords = Array(); let firstMatchIndex = -1; for (let i = 0; i < words.length; i++) { const lowerCaseWord = words[i].toLowerCase(); const isMatch = terms.some((term) => lowerCaseWord.includes(term)); if (isMatch && firstMatchIndex === -1) firstMatchIndex = i; if (isMatch) highlightedWords.push(`${start}${words[i]}${end}`); else highlightedWords.push(words[i]); } if (firstMatchIndex === -1) return words.slice(0, Math.min(limit, words.length)).join(" "); const idealStartIndex = Math.max(0, firstMatchIndex - Math.floor(limit / 2)); const snippetResultWords = highlightedWords.slice( idealStartIndex, idealStartIndex + limit ); let snippetResult = snippetResultWords.join(" "); if (idealStartIndex > 0) snippetResult = `${cutOff}${snippetResult}`; if (idealStartIndex + limit < highlightedWords.length) snippetResult = `${snippetResult}${cutOff}`; return snippetResult; } export { EntryResolver, statusChecker };