From c400936fb1325b4ecc9536a88c824219235f905e Mon Sep 17 00:00:00 2001 From: tangly1024 Date: Fri, 5 Jun 2026 04:08:42 +0800 Subject: [PATCH] fix: filter embedded Notion collection views Respect Notion collection view filters for embedded collection views, including query2 filters, localized status groups, and sibling filter inheritance for alternate embedded views. (cherry picked from commit 5a1017a73c34e200d8a35b9a108b02db6e1a6cc5) --- .../lib/db/notion/convertInnerUrl.test.js | 8 + __tests__/lib/notion-data-format.test.js | 426 ++++++++++++++++++ components/ExternalPlugins.js | 2 +- lib/db/SiteDataApi.js | 3 + lib/db/notion/filterCollectionViewData.js | 353 +++++++++++++++ 5 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 lib/db/notion/filterCollectionViewData.js diff --git a/__tests__/lib/db/notion/convertInnerUrl.test.js b/__tests__/lib/db/notion/convertInnerUrl.test.js index 46e591de4c9..913862a5f00 100644 --- a/__tests__/lib/db/notion/convertInnerUrl.test.js +++ b/__tests__/lib/db/notion/convertInnerUrl.test.js @@ -1,3 +1,11 @@ +jest.mock('notion-utils', () => ({ + idToUuid: id => + `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice( + 16, + 20 + )}-${id.slice(20)}` +})) + import { convertInnerUrl } from '@/lib/db/notion/convertInnerUrl' describe('convertInnerUrl', () => { diff --git a/__tests__/lib/notion-data-format.test.js b/__tests__/lib/notion-data-format.test.js index e4c3ce981a1..5e3d2cc87a8 100644 --- a/__tests__/lib/notion-data-format.test.js +++ b/__tests__/lib/notion-data-format.test.js @@ -1,4 +1,5 @@ import getAllPageIds from '@/lib/db/notion/getAllPageIds' +import { filterCollectionViewData } from '@/lib/db/notion/filterCollectionViewData' import { adapterNotionBlockMap, normalizeNotionBlockType @@ -170,6 +171,431 @@ describe('Notion data format compatibility', () => { expect(pageIds).toEqual(['page_1', 'page_2']) }) + it('filters embedded collection query results by selected view filters', () => { + const blockMap = { + block: { + published_page: { + value: { + id: 'published_page', + type: 'page', + properties: { + type: [['Post']], + status: [['Published']] + } + } + }, + draft_page: { + value: { + id: 'draft_page', + type: 'page', + properties: { + type: [['Post']], + status: [['Draft']] + } + } + }, + invisible_page: { + value: { + id: 'invisible_page', + type: 'page', + properties: { + type: [['Post']], + status: [['Invisible']] + } + } + } + }, + collection: { + collection_1: { + value: { + schema: { + type: { name: 'type', type: 'select' }, + status: { name: 'status', type: 'select' } + } + } + } + }, + collection_view: { + view_1: { + value: { + value: { + id: 'view_1', + page_sort: ['published_page', 'draft_page', 'invisible_page'], + format: { + collection_pointer: { id: 'collection_1' }, + property_filters: [ + { + filter: { + property: 'type', + filter: { + operator: 'enum_is', + value: { type: 'exact', value: 'Post' } + } + } + }, + { + filter: { + property: 'status', + filter: { + operator: 'enum_is', + value: { type: 'exact', value: 'Published' } + } + } + } + ] + } + } + } + } + }, + collection_query: { + collection_1: { + view_1: { + collection_group_results: { + blockIds: ['published_page', 'draft_page', 'invisible_page'] + } + } + } + } + } + + filterCollectionViewData(blockMap) + + expect( + blockMap.collection_query.collection_1.view_1.collection_group_results + .blockIds + ).toEqual(['published_page']) + expect(blockMap.collection_view.view_1.value.value.page_sort).toEqual([ + 'published_page' + ]) + }) + + it('matches localized Notion status values through status groups', () => { + const blockMap = { + block: { + progress_page: { + value: { + id: 'progress_page', + type: 'page', + properties: { + status: [['进行中']], + title: [['照片标题2']] + } + } + }, + todo_page: { + value: { + id: 'todo_page', + type: 'page', + properties: { + title: [['照片标题1']] + } + } + } + }, + collection: { + collection_1: { + value: { + schema: { + status: { + name: '状态', + type: 'status', + groups: [ + { + name: 'In progress', + optionIds: ['option_progress'] + } + ], + options: [ + { + id: 'option_progress', + value: '进行中' + } + ] + } + } + } + } + }, + collection_view: { + view_1: { + value: { + value: { + id: 'view_1', + page_sort: ['progress_page', 'todo_page'], + format: { + collection_pointer: { id: 'collection_1' }, + property_filters: [ + { + filter: { + property: 'status', + filter: { + operator: 'status_is', + value: { type: 'is_group', value: 'In progress' } + } + } + } + ] + } + } + } + } + }, + collection_query: { + collection_1: { + view_1: { + collection_group_results: { + blockIds: ['progress_page', 'todo_page'] + } + } + } + } + } + + filterCollectionViewData(blockMap) + + expect( + blockMap.collection_query.collection_1.view_1.collection_group_results + .blockIds + ).toEqual(['progress_page']) + expect(blockMap.collection_view.view_1.value.value.page_sort).toEqual([ + 'progress_page' + ]) + }) + + it('filters embedded collection results from query2 compound filters', () => { + const blockMap = { + block: { + selected_page: { + value: { + id: 'selected_page', + type: 'page', + properties: { + title: [['Alpha release']], + priority: [['5']] + } + } + }, + low_priority_page: { + value: { + id: 'low_priority_page', + type: 'page', + properties: { + title: [['Alpha draft']], + priority: [['1']] + } + } + }, + wrong_title_page: { + value: { + id: 'wrong_title_page', + type: 'page', + properties: { + title: [['Beta release']], + priority: [['5']] + } + } + } + }, + collection: { + collection_1: { + value: { + schema: { + title: { name: 'title', type: 'title' }, + priority: { name: 'priority', type: 'number' } + } + } + } + }, + collection_view: { + view_1: { + value: { + value: { + id: 'view_1', + format: { + collection_pointer: { id: 'collection_1' } + }, + query2: { + filter: { + operator: 'and', + filters: [ + { + property: 'title', + filter: { + operator: 'string_contains', + value: { type: 'exact', value: 'Alpha' } + } + }, + { + property: 'priority', + filter: { + operator: 'number_greater_than', + value: { type: 'exact', value: 3 } + } + } + ] + } + } + } + } + } + }, + collection_query: { + collection_1: { + view_1: { + collection_group_results: { + blockIds: [ + 'selected_page', + 'low_priority_page', + 'wrong_title_page' + ] + } + } + } + } + } + + filterCollectionViewData(blockMap) + + expect( + blockMap.collection_query.collection_1.view_1.collection_group_results + .blockIds + ).toEqual(['selected_page']) + }) + + it('inherits sibling filters for embedded collection views without filters', () => { + const blockMap = { + block: { + collection_block: { + value: { + id: 'collection_block', + type: 'collection_view', + view_ids: ['gallery_view', 'board_view', 'list_view'] + } + }, + progress_page: { + value: { + id: 'progress_page', + type: 'page', + properties: { + status: [['进行中']], + title: [['照片标题2']] + } + } + }, + todo_page: { + value: { + id: 'todo_page', + type: 'page', + properties: { + title: [['照片标题1']] + } + } + } + }, + collection: { + collection_1: { + value: { + schema: { + status: { + name: '状态', + type: 'status', + groups: [ + { + name: 'In progress', + optionIds: ['option_progress'] + } + ], + options: [ + { + id: 'option_progress', + value: '进行中' + } + ] + } + } + } + } + }, + collection_view: { + gallery_view: { + value: { + value: { + id: 'gallery_view', + type: 'gallery', + format: { + collection_pointer: { id: 'collection_1' } + } + } + } + }, + board_view: { + value: { + value: { + id: 'board_view', + type: 'board', + format: { + collection_pointer: { id: 'collection_1' }, + property_filters: [ + { + filter: { + property: 'status', + filter: { + operator: 'status_is', + value: { type: 'is_group', value: 'In progress' } + } + } + } + ] + } + } + } + }, + list_view: { + value: { + value: { + id: 'list_view', + type: 'list', + format: { + collection_pointer: { id: 'collection_1' } + } + } + } + } + }, + collection_query: { + collection_1: { + gallery_view: { + collection_group_results: { + blockIds: ['progress_page', 'todo_page'] + } + }, + board_view: { + collection_group_results: { + blockIds: ['progress_page', 'todo_page'] + } + }, + list_view: { + collection_group_results: { + blockIds: ['progress_page', 'todo_page'] + } + } + } + } + } + + filterCollectionViewData(blockMap) + + expect( + blockMap.collection_query.collection_1.gallery_view + .collection_group_results.blockIds + ).toEqual(['progress_page']) + expect( + blockMap.collection_query.collection_1.list_view.collection_group_results + .blockIds + ).toEqual(['progress_page']) + }) + it('normalizes nested blocks and strips crdt fields before rendering', () => { const formatted = formatNotionBlock({ page_1: { diff --git a/components/ExternalPlugins.js b/components/ExternalPlugins.js index ceb48e892a2..271e4a7321c 100644 --- a/components/ExternalPlugins.js +++ b/components/ExternalPlugins.js @@ -154,7 +154,7 @@ const ExternalPlugin = props => { const taskId = window.requestIdleCallback(callback) return () => window.cancelIdleCallback(taskId) } - const timeoutId = window.setTimeout(callback, 0) + const timeoutId = window.setTimeout(() => callback(), 0) return () => window.clearTimeout(timeoutId) } diff --git a/lib/db/SiteDataApi.js b/lib/db/SiteDataApi.js index 04a09c8253a..ed6bbcd88c2 100644 --- a/lib/db/SiteDataApi.js +++ b/lib/db/SiteDataApi.js @@ -23,6 +23,7 @@ import { normalizeSchema, normalizePageBlock } from './notion/normalizeUtil' +import { filterCollectionViewData } from './notion/filterCollectionViewData' import { fetchPageFromNotion } from './notion/getNotionPost' import { processPostData } from '../utils/post' import { adapterNotionBlockMap } from '../utils/notion.util' @@ -199,6 +200,7 @@ function cleanPostForClient(post) { const cleanedPost = cleanBlock(post) delete cleanedPost.content cleanRecordMapMetadata(cleanedPost.blockMap) + filterCollectionViewData(cleanedPost.blockMap) pruneUnusedCollectionRecords(cleanedPost.blockMap) return cleanedPost } @@ -208,6 +210,7 @@ function cleanNoticeForClient(notice) { const cleanedNotice = cleanBlock(notice) pruneBlockMapToRootPage(cleanedNotice.blockMap, cleanedNotice.id) cleanRecordMapMetadata(cleanedNotice.blockMap) + filterCollectionViewData(cleanedNotice.blockMap) pruneUnusedCollectionRecords(cleanedNotice.blockMap) return cleanedNotice } diff --git a/lib/db/notion/filterCollectionViewData.js b/lib/db/notion/filterCollectionViewData.js new file mode 100644 index 00000000000..f670d62ac00 --- /dev/null +++ b/lib/db/notion/filterCollectionViewData.js @@ -0,0 +1,353 @@ +function filterCollectionViewData(blockMap) { + if (!blockMap?.collection_view || !blockMap?.collection_query) return + + const inheritedFilters = getInheritedCollectionViewFilters(blockMap) + + Object.values(blockMap.collection_view).forEach(entry => { + const view = entry?.value?.value || entry?.value || entry + if (!view?.id) return + + const collectionId = view?.format?.collection_pointer?.id + const filter = getCollectionViewFilter(view) || inheritedFilters[view.id] + const collection = getRecordById(blockMap.collection, collectionId) + const schema = collection?.value?.schema || collection?.schema || {} + const collectionQuery = getRecordById(blockMap.collection_query, collectionId) + const viewQuery = getRecordById(collectionQuery, view.id) + + if (!collectionId || !filter || !viewQuery) return + + filterBlockIdsInPlace(viewQuery, blockId => { + const block = blockMap.block?.[blockId]?.value + return matchesCollectionFilter(block, filter, schema) + }) + + if (Array.isArray(view.page_sort)) { + view.page_sort = view.page_sort.filter(blockId => { + const block = blockMap.block?.[blockId]?.value + return matchesCollectionFilter(block, filter, schema) + }) + } + }) +} + +function getInheritedCollectionViewFilters(blockMap) { + const inheritedFilters = {} + + Object.values(blockMap.block || {}).forEach(entry => { + const block = entry?.value || entry + if (block?.type !== 'collection_view' || !Array.isArray(block.view_ids)) { + return + } + + const firstSiblingFilter = block.view_ids + .map(viewId => { + const view = getRecordById(blockMap.collection_view, viewId) + const viewValue = view?.value?.value || view?.value || view + return getCollectionViewFilter(viewValue) + }) + .find(Boolean) + + if (!firstSiblingFilter) return + + block.view_ids.forEach(viewId => { + const view = getRecordById(blockMap.collection_view, viewId) + const viewValue = view?.value?.value || view?.value || view + if (!getCollectionViewFilter(viewValue)) { + inheritedFilters[viewValue?.id || viewId] = firstSiblingFilter + } + }) + }) + + return inheritedFilters +} + +function getCollectionViewFilter(view) { + const filters = [] + + if (Array.isArray(view?.format?.property_filters)) { + filters.push( + ...view.format.property_filters + .map(filterItem => normalizePropertyFilter(filterItem)) + .filter(Boolean) + ) + } + + if (view?.query2?.filter) { + const queryFilter = normalizeFilter(view.query2.filter) + if (queryFilter) filters.push(queryFilter) + } + + if (filters.length === 0) return null + if (filters.length === 1) return filters[0] + + return { + operator: view?.filter_operator || 'and', + filters + } +} + +function normalizeFilter(filter) { + if (!filter || typeof filter !== 'object') return null + + if (Array.isArray(filter.filters)) { + return { + operator: filter.operator || 'and', + filters: filter.filters + .map(child => normalizeFilter(child)) + .filter(Boolean) + } + } + + return normalizePropertyFilter(filter) +} + +function normalizePropertyFilter(filterItem) { + const property = filterItem?.property || filterItem?.filter?.property + const filter = filterItem?.filter?.filter || filterItem?.filter + + if (!property || !filter) return null + + return { + property, + filter + } +} + +function filterBlockIdsInPlace(value, predicate) { + if (!value || typeof value !== 'object') return + + if (Array.isArray(value.blockIds)) { + value.blockIds = value.blockIds.filter(predicate) + } + + Object.values(value).forEach(child => filterBlockIdsInPlace(child, predicate)) +} + +function matchesCollectionFilter(block, filter, schema = {}) { + if (!block?.properties) return false + + if (Array.isArray(filter?.filters)) { + const matcher = child => matchesCollectionFilter(block, child, schema) + return filter.operator === 'or' + ? filter.filters.some(matcher) + : filter.filters.every(matcher) + } + + const propertyId = filter?.property + const propertyFilter = filter?.filter + if (!propertyId || !propertyFilter) return true + + const values = getPropertyValues(block.properties[propertyId]) + return matchesFilter(values, propertyFilter, schema[propertyId]) +} + +function matchesPropertyFilters(block, filters, schema = {}) { + return matchesCollectionFilter( + block, + { operator: 'and', filters: filters.map(normalizePropertyFilter) }, + schema + ) +} + +function matchesFilter(values, filter, propertySchema) { + const expectedValues = getExpectedValues(filter.value, propertySchema) + const actualValues = expandPropertyValues(values, propertySchema) + const actualText = actualValues.join(' ') + const expectedText = expectedValues.join(' ') + + switch (filter.operator) { + case 'enum_is': + case 'status_is': + return expectedValues.some(value => actualValues.includes(value)) + case 'enum_is_not': + case 'status_is_not': + return expectedValues.every(value => !actualValues.includes(value)) + case 'enum_contains': + case 'multi_select_contains': + return expectedValues.some(value => actualValues.includes(value)) + case 'enum_does_not_contain': + case 'multi_select_does_not_contain': + return expectedValues.every(value => !actualValues.includes(value)) + case 'string_contains': + return expectedValues.some(value => + actualValues.some(current => current.includes(value)) + ) + case 'string_does_not_contain': + return expectedValues.every(value => + actualValues.every(current => !current.includes(value)) + ) + case 'string_is': + return expectedValues.some(value => actualValues.includes(value)) + case 'string_is_not': + return expectedValues.every(value => !actualValues.includes(value)) + case 'string_starts_with': + return expectedValues.some(value => actualText.startsWith(value)) + case 'string_ends_with': + return expectedValues.some(value => actualText.endsWith(value)) + case 'checkbox_is': + return toBoolean(actualValues[0]) === toBoolean(expectedValues[0]) + case 'checkbox_is_not': + return toBoolean(actualValues[0]) !== toBoolean(expectedValues[0]) + case 'number_equals': + return toNumber(actualValues[0]) === toNumber(expectedValues[0]) + case 'number_does_not_equal': + return toNumber(actualValues[0]) !== toNumber(expectedValues[0]) + case 'number_greater_than': + return toNumber(actualValues[0]) > toNumber(expectedValues[0]) + case 'number_less_than': + return toNumber(actualValues[0]) < toNumber(expectedValues[0]) + case 'number_greater_than_or_equal_to': + return toNumber(actualValues[0]) >= toNumber(expectedValues[0]) + case 'number_less_than_or_equal_to': + return toNumber(actualValues[0]) <= toNumber(expectedValues[0]) + case 'date_is': + return normalizeDate(actualValues[0]) === normalizeDate(expectedValues[0]) + case 'date_is_before': + return normalizeDate(actualValues[0]) < normalizeDate(expectedValues[0]) + case 'date_is_after': + return normalizeDate(actualValues[0]) > normalizeDate(expectedValues[0]) + case 'date_is_on_or_before': + return normalizeDate(actualValues[0]) <= normalizeDate(expectedValues[0]) + case 'date_is_on_or_after': + return normalizeDate(actualValues[0]) >= normalizeDate(expectedValues[0]) + case 'relation_contains': + case 'person_contains': + return expectedValues.some(value => actualValues.includes(value)) + case 'relation_does_not_contain': + case 'person_does_not_contain': + return expectedValues.every(value => !actualValues.includes(value)) + case 'is_empty': + return actualValues.length === 0 + case 'is_not_empty': + return actualValues.length > 0 + default: + return true + } +} + +function getPropertyValues(property) { + if (!Array.isArray(property)) return [] + + const values = [] + + property.forEach(item => { + const plainValue = item?.[0] + if (plainValue !== undefined && plainValue !== null && plainValue !== '') { + values.push(String(plainValue)) + } + + item?.[1]?.forEach(decoration => { + const metadata = decoration?.[1] + ;[ + metadata?.id, + metadata?.page_id, + metadata?.user_id, + metadata?.value, + metadata?.start_date + ].forEach(value => { + if (value !== undefined && value !== null && value !== '') { + values.push(String(value)) + } + }) + }) + }) + + return [...new Set(values)] +} + +function getExpectedValues(value, propertySchema) { + if (Array.isArray(value)) { + return value.flatMap(item => getExpectedValues(item, propertySchema)) + } + + if (value?.value !== undefined && value?.value !== null) { + return expandStatusGroupValue(String(value.value), propertySchema) + } + if (value?.start_date) return [value.start_date] + if (value?.id) return [value.id] + if (value !== undefined && value !== null) return [String(value)] + + return [] +} + +function expandPropertyValues(values, propertySchema) { + if (propertySchema?.type !== 'status') return values + + const groupValues = propertySchema.groups + ?.filter(group => + propertySchema.options?.some( + option => values.includes(option.value) && group.optionIds?.includes(option.id) + ) + ) + .map(group => group.name) + + return [...new Set([...values, ...(groupValues || [])])] +} + +function expandStatusGroupValue(value, propertySchema) { + if (propertySchema?.type !== 'status') return [value] + + const group = propertySchema.groups?.find(group => group.name === value) + if (!group) return [value] + + const optionValues = propertySchema.options + ?.filter(option => group.optionIds?.includes(option.id)) + .map(option => option.value) + .filter(optionValue => typeof optionValue === 'string') + + return [value, ...(optionValues || [])] +} + +function toBoolean(value) { + return value === true || value === 'true' || value === 'Yes' || value === '1' +} + +function toNumber(value) { + const number = Number(value) + return Number.isNaN(number) ? null : number +} + +function normalizeDate(value) { + if (!value) return '' + return String(value).slice(0, 10) +} + +function getRecordById(record, id) { + if (!record || !id) return null + + for (const candidate of getIdCandidates(id)) { + const value = record[candidate] + if (value) return value + } + + return null +} + +function getIdCandidates(id) { + const candidates = new Set([id]) + + if (typeof id === 'string') { + const compactId = id.replace(/-/g, '') + candidates.add(compactId) + + if (/^[0-9a-fA-F]{32}$/.test(compactId)) { + candidates.add( + [ + compactId.slice(0, 8), + compactId.slice(8, 12), + compactId.slice(12, 16), + compactId.slice(16, 20), + compactId.slice(20) + ].join('-') + ) + } + } + + return [...candidates] +} + +export { + filterCollectionViewData, + matchesCollectionFilter, + matchesPropertyFilters +}