Commit 00d7b43d authored by Robert Knight's avatar Robert Knight

Support filtering annotations by CFI

Support `cfi:{start}-{end}` queries which filter annotations based on whether
they have an associated EPUB CFI which lies in `[start, end)`.

This query can be entered manually by the user in the search box, which is
useful for testing, but the intent is that this filter would be used in contexts
where the query is generated automatically (eg. as a result of a user making a
selection from the book's table of contents).
parent 1c638bd1
......@@ -115,6 +115,13 @@ export function compareCFIs(a: string, b: string): number {
return compareArrays(parseCFI(a), parseCFI(b));
}
/**
* Return true if the CFI `cfi` lies in the range [start, end).
*/
export function cfiInRange(cfi: string, start: string, end: string): boolean {
return compareCFIs(cfi, start) >= 0 && compareCFIs(cfi, end) < 0;
}
/**
* Return a slice of `cfi` up to the first step indirection [1], with assertions
* removed.
......
import { compareCFIs, documentCFI, stripCFIAssertions } from '../cfi';
import {
cfiInRange,
compareCFIs,
documentCFI,
stripCFIAssertions,
} from '../cfi';
describe('sidebar/util/cfi', () => {
describe('stripCFIAssertions', () => {
......@@ -119,4 +124,48 @@ describe('sidebar/util/cfi', () => {
);
});
});
describe('cfiInRange', () => {
[
// CFI before start of range
{
cfi: '/2',
start: '/4',
end: '/6',
expected: false,
},
// CFI at start of range
{
cfi: '/2',
start: '/2',
end: '/3',
expected: true,
},
// CFI in middle of range
{
cfi: '/4',
start: '/2',
end: '/6',
expected: true,
},
// CFI at start and end of empty range
{
cfi: '/2',
start: '/2',
end: '/2',
expected: false,
},
// CFI after end of range
{
cfi: '/6',
start: '/2',
end: '/4',
expected: false,
},
].forEach(({ cfi, start, end, expected }) => {
it('should return true if the cfi is in the range [start, end)', () => {
assert.equal(cfiInRange(cfi, start, end), expected);
});
});
});
});
import type {
APIAnnotationData,
Annotation,
EPUBContentSelector,
PageSelector,
SavedAnnotation,
TextQuoteSelector,
......@@ -342,6 +343,20 @@ export function quote(annotation: APIAnnotationData): string | null {
return quoteSel ? quoteSel.exact : null;
}
/**
* Return the EPUB Canonical Fragment Identifier for the table of contents entry
* associated with the part of the book / document that an annotation was made
* on.
*
* See {@link EPUBContentSelector}.
*/
export function cfi(annotation: APIAnnotationData): string | undefined {
const epubSel = annotation.target[0]?.selector?.find(
s => s.type === 'EPUBContentSelector',
) as EPUBContentSelector | undefined;
return epubSel?.cfi;
}
/**
* Return the label of the page that an annotation comes from.
*
......
import * as fixtures from '../../test/annotation-fixtures';
import * as annotationMetadata from '../annotation-metadata';
import {
cfi,
documentMetadata,
domainAndTitle,
isSaved,
......@@ -609,6 +610,27 @@ describe('sidebar/helpers/annotation-metadata', () => {
});
});
describe('cfi', () => {
it('returns CFI for annotation', () => {
const ann = {
target: [
{
source: 'https://publisher.org/article.pdf',
selector: [{ type: 'EPUBContentSelector', cfi: '/2/4' }],
},
],
};
assert.equal(cfi(ann), '/2/4');
});
it('returns undefined if annotation has no `EPUBContentSelector` selector', () => {
const anns = [fixtures.newPageNote(), fixtures.newAnnotation()];
for (const ann of anns) {
assert.isUndefined(pageLabel(ann));
}
});
});
describe('hasBeenEdited', () => {
it('should return false if created and updated timestamps are equal', () => {
const annotation = fakeAnnotation({
......
......@@ -267,6 +267,49 @@ describe('sidebar/helpers/view-filter', () => {
});
});
describe('"cfi" field', () => {
const annotation = {
id: 1,
target: [
{
selector: [
{
type: 'EPUBContentSelector',
cfi: '/2/4',
},
],
},
],
};
[
'/2/2-/2/6',
// CFI containing assertions in square brackets. Hyphens inside assertions
// should be ignored.
'/2/2[-/2/2]-/2/6',
].forEach(range => {
it('matches if annotation is in range', () => {
const filters = { cfi: { terms: [range], operator: 'or' } };
const result = filterAnnotations([annotation], filters);
assert.deepEqual(result, [1]);
});
});
it('does not match if annotation is outside of range', () => {
const filters = { cfi: { terms: ['/2/6-/2/8'], operator: 'or' } };
const result = filterAnnotations([annotation], filters);
assert.deepEqual(result, []);
});
['/2/2', '/2/2[-/2/6]'].forEach(range => {
it('does not match if term is not a range', () => {
const filters = { cfi: { terms: [range], operator: 'or' } };
const result = filterAnnotations([annotation], filters);
assert.deepEqual(result, []);
});
});
});
it('ignores filters with no terms in the query', () => {
const annotation = {
id: 1,
......
import { cfiInRange, stripCFIAssertions } from '../../shared/cfi';
import type { Annotation } from '../../types/api';
import { pageLabelInRange } from '../util/page-range';
import type { Facet } from '../util/search-filter';
import * as unicodeUtils from '../util/unicode';
import { quote, pageLabel } from './annotation-metadata';
import { cfi as getCFI, quote, pageLabel } from './annotation-metadata';
type Filter = {
matches: (ann: Annotation) => boolean;
......@@ -103,6 +104,25 @@ function stringFieldMatcher(
*/
const fieldMatchers: Record<string, Matcher | Matcher<number>> = {
quote: stringFieldMatcher(ann => [quote(ann) ?? '']),
cfi: {
fieldValues: ann => [getCFI(ann)?.trim() ?? ''],
matches: (cfi: string, cfiTerm: string) => {
// Here we use "-" as a separator between the start and end part of the
// range, as it is easy to parse.
//
// If we wanted to use a more standard CFI range representation,
// we could follow https://idpf.org/epub/linking/cfi/#sec-ranges.
if (cfiTerm.includes('-')) {
const [start, end] = cfiTerm.split('-');
return cfiInRange(cfi, start, end);
} else {
return false;
}
},
normalize: (val: string) => stripCFIAssertions(val.trim()),
},
page: {
fieldValues: ann => [pageLabel(ann)?.trim() ?? ''],
matches: (pageLabel: string, pageTerm: string) =>
......
......@@ -6,6 +6,18 @@
* filter annotations displayed to the user or fetched from the API.
*/
const filterFields = [
'cfi',
'group',
'quote',
'page',
'since',
'tag',
'text',
'uri',
'user',
];
/**
* Splits a search term into filter and data.
*
......@@ -19,11 +31,7 @@ function splitTerm(term: string): [null | string, string] {
return [null, term];
}
if (
['group', 'quote', 'page', 'since', 'tag', 'text', 'uri', 'user'].includes(
filter,
)
) {
if (filterFields.includes(filter)) {
const data = term.slice(filter.length + 1);
return [filter, data];
} else {
......@@ -130,6 +138,7 @@ export function generateFacetedFilter(
focusFilters: FocusFilter = {},
): Record<string, Facet> {
const any = [];
const cfi = [];
const page = [];
const quote = [];
const since = [];
......@@ -145,6 +154,9 @@ export function generateFacetedFilter(
const fieldValue = term.slice(filter.length + 1);
switch (filter) {
case 'cfi':
cfi.push(fieldValue);
break;
case 'quote':
quote.push(fieldValue);
break;
......@@ -191,11 +203,19 @@ export function generateFacetedFilter(
}
}
// Filter terms use an "AND" operator if it is possible for an annotation to
// match more than one term (eg. an annotation can have multiple tags) or "OR"
// otherwise (eg. an annotation cannot match two distinct user terms).
return {
any: {
terms: any,
operator: 'and',
},
cfi: {
terms: cfi,
operator: 'or',
},
quote: {
terms: quote,
operator: 'and',
......
......@@ -171,6 +171,15 @@ describe('sidebar/util/search-filter', () => {
},
},
},
{
query: 'cfi:/2-/4',
expectedFilter: {
cfi: {
operator: 'or',
terms: ['/2-/4'],
},
},
},
].forEach(({ query, expectedFilter }) => {
it('parses a search query', () => {
const filter = searchFilter.generateFacetedFilter(query);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment