import { firstBy } from 'thenby' import BigNumber from 'bignumber.js' import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes' import { escapeRegExp, isSecureURL } from '@baserow/modules/core/utils/string' import { SearchModes } from '@baserow/modules/database/utils/search' import { convertStringToMatchBackendTsvectorData } from '@baserow/modules/database/search/regexes' export const DEFAULT_VIEW_ID_COOKIE_NAME = 'defaultViewId' /** * Generates a sort function based on the provided sortings. */ export function getRowSortFunction($registry, sortings, fields, groupBys = []) { let sortFunction = firstBy() const combined = [...groupBys, ...sortings] combined.forEach((sort) => { // Find the field that is related to the sort. const field = fields.find((f) => f.id === sort.field) if (field !== undefined) { const fieldName = `field_${field.id}` const fieldType = $registry.get('field', field.type) const fieldSortFunction = fieldType.getSort(fieldName, sort.order, field) sortFunction = sortFunction.thenBy(fieldSortFunction) } }) sortFunction = sortFunction.thenBy((a, b) => new BigNumber(a.order).minus(new BigNumber(b.order)) ) sortFunction = sortFunction.thenBy((a, b) => a.id - b.id) return sortFunction } /** * Generates a sort function for fields based on order and id. */ export function sortFieldsByOrderAndIdFunction( fieldOptions, primaryAlwaysFirst = false ) { return (a, b) => { if (primaryAlwaysFirst) { // If primary must always be first, then first by primary. if (a.primary > b.primary) { return -1 } else if (a.primary < b.primary) { return 1 } } const orderA = fieldOptions[a.id] ? fieldOptions[a.id].order : maxPossibleOrderValue const orderB = fieldOptions[b.id] ? fieldOptions[b.id].order : maxPossibleOrderValue // First by order. if (orderA > orderB) { return 1 } else if (orderA < orderB) { return -1 } // Then by id. return a.id - b.id } } /** * Returns only fields that are visible (not hidden). */ export function filterVisibleFieldsFunction(fieldOptions) { return (field) => { const exists = Object.prototype.hasOwnProperty.call(fieldOptions, field.id) return !exists || !fieldOptions[field.id].hidden } } /** * Returns only fields that are visible (not hidden). */ export function filterHiddenFieldsFunction(fieldOptions) { return (field) => { const exists = Object.prototype.hasOwnProperty.call(fieldOptions, field.id) return exists && fieldOptions[field.id].hidden } } /** * Represents a node in a tree structure used for grouped filters. * A group node is made of a filterType (AND or OR), a list of filters and a parent. * If the parent is null it means that it is the root node. If the parent is not * null it means that it is a child of the parent node, and the constructor will take care to * add itself to the children of the parent node, so that we can later traverse the tree * from the root node and check if a row matches the filters. */ export const TreeGroupNode = class { /** * Constructs a new TreeGroupNode. * * @param {string} filterType - The type of filter (e.g., 'AND' or 'OR'). * @param {TreeGroupNode} [parent=null] - The parent node of this node. Null for the root node. * @param {number} [groupId=null] - The ID of the group this node represents. Null for the root node. */ constructor(filterType, parent = null, groupId = null) { this.filterType = filterType this.groupId = groupId this.parent = parent this.filters = [] this.children = [] if (parent) { parent.children.push(this) } } /** * Finds the group node with the provided ID in the tree rooted at this node. * * @param {number} groupId - The ID of the group to find. * @returns {TreeGroupNode|null} - The group node with the provided ID or null if it is not found. */ findNodeByGroupId(groupId) { if (this.groupId === groupId) { return this } for (const groupNode of this.children) { const found = groupNode.findNodeByGroupId(groupId) if (found) { return found } } return null } /** * Checks if this node or any of its descendants has filters. * * @returns {boolean} - True if there are filters, false otherwise. */ hasFilters() { return this.filters.length > 0 || this.children.some((c) => c.hasFilters()) } /** * Adds a filter object to this node list of filters. * * @param {object} filter - The filter to add. */ addFilter(filter) { this.filters.push(filter) } /** * Serializes the filter tree rooted at this node. The serialized version will be in the form: * { * filter_type: 'AND' | 'OR', * filters: [ * { * type: 'contains' | 'does_not_contain' | 'is' | 'is_not' | 'is_empty' | 'is_not_empty' | etc., * field: 1, * value: 'some value' * }, * ... * ], * groups: [ * { * filter_type: 'AND' | 'OR', * filters: [ * type: 'contains' | 'does_not_contain' | 'is' | 'is_not' | 'is_empty' | 'is_not_empty' | etc., * field: 2, * value: 'some other value' * }, * ... * ], * groups: [...] * }, * ..., * ], * } * * @returns {object} - The serialized tree. */ getFiltersTreeSerialized() { const serialized = { filter_type: this.filterType, filters: [], groups: [], } for (const filter of this.filters) { serialized.filters.push({ type: filter.type, field: filter.field, value: filter.value, }) } for (const groupNode of this.children) { serialized.groups.push(groupNode.getFiltersTreeSerialized()) } return serialized } /** * Determines if a given row matches the conditions of this node and its descendants. * This function will recursively check if the row matches the filters of this node * and its descendants. If the filter type of this node is 'AND' then it will return * true if the row matches all the filters. If the filter type of this node is 'OR' * then it will return true if the row matches at least one of the filters. * * @param {object} $registry - The registry containing field and filter type information. * @param {Array} fields - The list of fields. * @param {object} rowValues - The values of the row being checked. * @returns {boolean} - True if the row matches, false otherwise. */ matches($registry, fields, rowValues) { for (const child of this.children) { const matches = child.matches($registry, fields, rowValues) if (this.filterType === 'AND' && !matches) { return false } else if (this.filterType === 'OR' && matches) { return true } } const filterType = this.filterType for (const filter of this.filters) { const filterValue = filter.value const field = fields.find((f) => f.id === filter.field) const fieldType = $registry.get('field', field.type) const viewFilterType = $registry.get('viewFilter', filter.type) const rowValue = rowValues[`field_${field.id}`] const matches = viewFilterType.matches( rowValue, filterValue, field, fieldType ) if (filterType === 'AND' && !matches) { // With an `AND` filter type, the row must match all the filters, so if // one of the filters doesn't match we can mark it as invalid. return false } else if (filterType === 'OR' && matches) { // With an 'OR' filter type, the row only has to match one of the filters, // that is the case here so we can mark it as valid. return true } } if (filterType === 'AND') { // At this point with an `AND` condition the filter type matched all the // filters and therefore we can mark it as valid. return true } else if (filterType === 'OR') { // At this point with an `OR` condition none of the filters matched and // therefore we can mark it as invalid. return false } } } /** * Creates a tree structure from given filters and filter groups. Groups are * first sorted by ID because parent groups have smaller IDs since they were * created before their children. In this way, we ensure that when a child node * is added to the tree, its parent will already be present. * Once the tree has been created, it adds all the filters to the respective * groups. * * @param {string} filterType - The root filter type. * @param {Array} filters - The list of filters. * @param {Array} filterGroups - The list of filter groups. * @returns {TreeGroupNode} - The root of the filter tree. */ export const createFiltersTree = (filterType, filters, filterGroups) => { const rootGroup = new TreeGroupNode(filterType) const filterGroupsById = { '': rootGroup } const filterGroupsOrderedById = filterGroups ? [...filterGroups].sort((a, b) => a.id - b.id) : [] for (const filterGroup of filterGroupsOrderedById) { const parent = filterGroupsById[filterGroup.parent_group || ''] filterGroupsById[filterGroup.id] = new TreeGroupNode( filterGroup.filter_type, parent, filterGroup.id ) } for (const filter of filters) { const filterGroupId = filter.group || '' const filterGroup = filterGroupsById[filterGroupId] filterGroup.addFilter(filter) } return rootGroup } /** * A helper function that checks if the provided row values match the provided view * filters. Returning false indicates that the row should not be visible for that * view. */ export const matchSearchFilters = ( $registry, filterType, filters, filterGroups, fields, values ) => { // If there aren't any filters then it is not possible to check if the row // matches any of the filters, so we can mark it as valid. if (filters.length === 0) { return true } const filterTree = createFiltersTree(filterType, filters, filterGroups) return filterTree.matches($registry, fields, values) } function _fullTextSearch(registry, field, value, activeSearchTerm) { const searchableString = registry .get('field', field.type) .toSearchableString(field, value) const fixedValue = convertStringToMatchBackendTsvectorData(searchableString) const fixedTerm = convertStringToMatchBackendTsvectorData(activeSearchTerm) if (fixedTerm.length === 0) { return false } else { const regexMatchingWordsThatStartWithTerm = '(^|\\s+)' + escapeRegExp(fixedTerm) return !!fixedValue.match( new RegExp(regexMatchingWordsThatStartWithTerm, 'gu') ) } } function _compatSearchMode(registry, field, value, activeSearchTerm) { return registry .get('field', field.type) .containsFilter(value, activeSearchTerm, field) } export function valueMatchesActiveSearchTerm( searchMode, registry, field, value, activeSearchTerm ) { if (searchMode === SearchModes.MODE_FT_WITH_COUNT) { return _fullTextSearch(registry, field, value, activeSearchTerm) } else { return _compatSearchMode(registry, field, value, activeSearchTerm) } } function _findFieldsInRowMatchingSearch( row, activeSearchTerm, fields, registry, overrides, searchMode ) { const fieldSearchMatches = new Set() // If the row is loading then a temporary UUID is put in its id. We don't want to // accidentally match against that UUID as it will be shortly replaced with its // real id. if ( !row._.loading && row.id?.toString() === (activeSearchTerm || '').trim() ) { fieldSearchMatches.add('row_id') } for (const field of fields) { const fieldName = `field_${field.id}` const rowValue = fieldName in overrides ? overrides[fieldName] : row[fieldName] if (rowValue !== undefined && rowValue !== null) { const doesMatch = valueMatchesActiveSearchTerm( searchMode, registry, field, rowValue, activeSearchTerm ) if (doesMatch) { fieldSearchMatches.add(field.id.toString()) } } } return fieldSearchMatches } /** * Helper function which calculates if a given row and which of it's fields matches a * given search term. The rows values can be overridden by providing an overrides * object containing a mapping of the field name to override to a value that will be * used to check for matches instead of the rows real one. The rows values will not be * changed. */ export function calculateSingleRowSearchMatches( row, activeSearchTerm, hideRowsNotMatchingSearch, fields, registry, searchMode, overrides = {} ) { const searchIsBlank = activeSearchTerm === '' const fieldSearchMatches = searchIsBlank ? new Set() : _findFieldsInRowMatchingSearch( row, activeSearchTerm, fields, registry, overrides, searchMode ) const matchSearch = !hideRowsNotMatchingSearch || searchIsBlank || fieldSearchMatches.size > 0 return { row, matchSearch, fieldSearchMatches } } /** * Returns true is the empty value of the provided field matches the active search term. */ export function newFieldMatchesActiveSearchTerm( searchMode, registry, newField, activeSearchTerm ) { if (newField && activeSearchTerm !== '') { const fieldType = registry.get('field', newField.type) const emptyValue = fieldType.getEmptyValue(newField) return valueMatchesActiveSearchTerm( searchMode, registry, newField, emptyValue, activeSearchTerm ) } return false } export function getGroupBy(rootGetters, viewId) { if (rootGetters['page/view/public/getIsPublic']) { const view = rootGetters['view/get'](viewId) return view.group_bys .map((groupBy) => { return `${groupBy.order === 'DESC' ? '-' : ''}field_${groupBy.field}` }) .join(',') } else { return '' } } export function isAdhocSorting(app, workspace, view, publicView) { return ( publicView || (app.$hasPermission('database.table.view.list_sort', view, workspace.id) && !app.$hasPermission( 'database.table.view.create_sort', view, workspace.id )) ) } export function getOrderBy(view, adhocSorting) { if (adhocSorting) { return view.sortings .map((sort) => { return `${sort.order === 'DESC' ? '-' : ''}field_${sort.field}` }) .join(',') } else { return null } } export function isAdhocFiltering(app, workspace, view, publicView) { return ( publicView || (app.$hasPermission( 'database.table.view.list_filter', view, workspace.id ) && !app.$hasPermission( 'database.table.view.create_filter', view, workspace.id )) ) } export function getFilters(view, adhocFiltering) { const payload = {} if (adhocFiltering && !view.filters_disabled) { const { filter_type: filterType, filter_groups: filterGroups, filters, } = view const filterTree = createFiltersTree(filterType, filters, filterGroups) const serializedTree = filterTree.getFiltersTreeSerialized() payload.filters = [JSON.stringify(serializedTree)] } return payload } /** * Calculates the size of a UTF-8 encoded string in bytes - computes the size * of a string in UTF-8 encoding and utilizes the TextEncoder API if available. * * Using TextEncoder is preferred in Modern Browsers and Node.js Supported * environments because it provides a more efficient and accurate way to encode * strings into UTF-8 bytes and directly calculate the byte size of the encoded * string. * * In some older web browsers or environments where TextEncoder may not be available * (such as SSR where certain browser APIs are absent), it falls back to a less * accurate method and simply returns the length of the string. */ export function utf8ByteSize(str) { // Use TextEncoder if available (modern browsers and Node.js) if (typeof TextEncoder !== 'undefined') { const encoder = new TextEncoder() const data = encoder.encode(str) return data.length } else { // Fallback for older browsers (may not be as accurate) return str.length } } /** * Return the view that has been visited most recently or the first * available one that is capable of displaying the provided row data if required. * If no view is available that can display the row data, return undefined. */ export function getDefaultView(app, store, workspaceId, showRowModal) { // Put the most recently visited one first in the list. const defaultView = store.getters['view/default'] const allViews = store.getters['view/getAllOrdered'] const views = defaultView ? [defaultView, ...allViews] : allViews return views.find((view) => { const viewType = app.$registry.get('view', view.type) if (viewType.isDeactivated(workspaceId)) { return false } // Ensure that the view can display the row data if required. return showRowModal ? viewType.canShowRowModal() : true }) } /* * Extracts the metadata from the provided data to populate the row. */ export function extractRowMetadata(data, rowId) { const metadata = data.row_metadata || {} return metadata[rowId] || {} } /** * Limit the size of a cookie's value by removing elements from an array * until it fits within the maximum allowed cookie size. The array is * assumed to be ordered by least important to most important, so the first * elements are removed first. * * @param {Array} arrayOfValues - The array of values to encode. * @param {Function} encodingFunc - The function to use to encode the array. * @param {Number} maxLength - The maximum allowed length of the encoded value string. * @returns {String} - The serialized value to save in the cookie with the * max number of elements that fit in, or an empty string if none fit. */ export function fitInCookieEncoded( arrayOfValues, encodingFunc, maxLength = 2048 ) { for (let i = 0, l = arrayOfValues.length; i < l; i++) { const encoded = encodingFunc(arrayOfValues.slice(i)) // The encoded URI will be serialized when saved in the cookie, so we // need to encode it first to get the correct byte size. const serialized = encodeURIComponent(encoded) if (utf8ByteSize(serialized) < maxLength) { return encoded } } return '' } export function decodeDefaultViewIdPerTable(value) { // backward compatibility, we used to store the array of default views // with a slightly different format if (Array.isArray(value)) { return value.map((item) => ({ tableId: item.table_id, viewId: item.id, })) } const data = [] for (const item of value.split(',')) { const [tableId, viewId] = item.split(':') if (tableId !== undefined && viewId !== undefined) { data.push({ tableId: parseInt(tableId), viewId: parseInt(viewId) }) } } return data } export function encodeDefaultViewIdPerTable(data) { return data.map(({ tableId, viewId }) => `${tableId}:${viewId}`).join(',') } /** * Reads the default view for table from cookies. * * @param {Object} cookies - The cookies object. * @param {Number} tableId - The id of the table. * @param {String} cookieName - The name of the cookie. * @returns {Number|null} - The id of the default view for the table, or null if there * is no default view for the table. */ export function readDefaultViewIdFromCookie( cookies, tableId, cookieName = DEFAULT_VIEW_ID_COOKIE_NAME ) { try { const cookieValue = cookies.get(cookieName) || '' const defaultViews = decodeDefaultViewIdPerTable(cookieValue) const defaultView = defaultViews.find((view) => view.tableId === tableId) return defaultView ? defaultView.viewId : null } catch (error) { return null } } /** * Updates the default view for table in cookies (if it exists) or creates a new one if * it doesn't. The entry will be placed at the end of the list as the most recently * visited view. If the entire list does not fit in the cookie, the oldest entries (the * first ones) will be removed. * * @param {Object} cookies - The cookies object. * @param {Object} view - The view object. * @param {Object} config - The config object. * @param {String} cookieName - The name of the cookie. */ export function saveDefaultViewIdInCookie( cookies, view, config, cookieName = DEFAULT_VIEW_ID_COOKIE_NAME ) { const cookieValue = cookies.get(cookieName) || '' let defaultViews = decodeDefaultViewIdPerTable(cookieValue) function createEntry(view) { return { tableId: view.table_id, viewId: view.id } } try { const index = defaultViews.findIndex((obj) => obj.tableId === view.table_id) if (index !== -1) { const existingView = defaultViews.splice(index, 1)[0] existingView.viewId = view.id defaultViews.push(existingView) } else if (view.id !== view.slug) { defaultViews.push(createEntry(view)) } } catch (error) { defaultViews = [createEntry(view)] } finally { const fittedListEncoded = fitInCookieEncoded( defaultViews, encodeDefaultViewIdPerTable ) const secure = isSecureURL(config.PUBLIC_WEB_FRONTEND_URL) cookies.set(cookieName, fittedListEncoded, { path: '/', maxAge: 60 * 60 * 24 * 365, // 1 year sameSite: config.BASEROW_FRONTEND_SAME_SITE_COOKIE, secure, }) } }