fix(filter): correctly replace quoted values
https://github.com/go-vikunja/vikunja/issues/743
This commit is contained in:
parent
2c25e60761
commit
5651c0b818
|
|
@ -8,12 +8,12 @@
|
|||
ref="filterInputRef"
|
||||
v-model="filterQuery"
|
||||
:project-id="projectId"
|
||||
class="mbe-2"
|
||||
@update:modelValue="() => change('modelValue')"
|
||||
/>
|
||||
<pre>{{ filterQuery }}</pre>
|
||||
<div
|
||||
v-if="filterFromView"
|
||||
class="tw-text-sm tw-mbe-2"
|
||||
class="tw-text-sm mbe-2"
|
||||
>
|
||||
{{ $t('filters.fromView') }}
|
||||
<code>{{ filterFromView }}</code><br>
|
||||
|
|
|
|||
|
|
@ -189,6 +189,182 @@ describe('Filter Transformation', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Special Characters', () => {
|
||||
const apostropheResolver = (title: string) => {
|
||||
switch (title.toLowerCase()) {
|
||||
case "john's task":
|
||||
return 1
|
||||
case "mary's project":
|
||||
return 2
|
||||
case "user's label":
|
||||
return 3
|
||||
case "it's working":
|
||||
return 4
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const apostropheIdResolver = (id: number) => {
|
||||
switch (id) {
|
||||
case 1:
|
||||
return "John's Task"
|
||||
case 2:
|
||||
return "Mary's Project"
|
||||
case 3:
|
||||
return "User's Label"
|
||||
case 4:
|
||||
return "It's Working"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
describe('Apostrophes in quoted values', () => {
|
||||
it('should handle double-quoted labels with apostrophes', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels = "John\'s Task"',
|
||||
apostropheResolver,
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = 1')
|
||||
})
|
||||
|
||||
it('should handle single-quoted labels with apostrophes', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
"labels = 'Mary\\'s Project'",
|
||||
apostropheResolver,
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = 2')
|
||||
})
|
||||
|
||||
it('should handle projects with apostrophes in double quotes', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'project = "User\'s Label"',
|
||||
nullTitleToIdResolver,
|
||||
apostropheResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = 3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apostrophes in unquoted values', () => {
|
||||
it('should handle unquoted labels with apostrophes', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels = John\'s',
|
||||
(title: string) => title === 'John\'s' ? 1 : null,
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = 1')
|
||||
})
|
||||
|
||||
it('should handle unquoted projects with apostrophes', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'project = Mary\'s',
|
||||
nullTitleToIdResolver,
|
||||
(title: string) => title === 'Mary\'s' ? 2 : null,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple values with apostrophes', () => {
|
||||
it('should handle multiple labels with apostrophes using in operator', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels in "John\'s Task", "Mary\'s Project"',
|
||||
apostropheResolver,
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels in 1, 2')
|
||||
})
|
||||
|
||||
it('should handle multiple labels with apostrophes using not in operator', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels not in "User\'s Label", "It\'s Working"',
|
||||
apostropheResolver,
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels not in 3, 4')
|
||||
})
|
||||
|
||||
it('should handle mixed quoted and unquoted values with apostrophes', () => {
|
||||
const mixedResolver = (title: string) => {
|
||||
if (title === "John's Task") return 1
|
||||
if (title === "Mary's") return 2
|
||||
return null
|
||||
}
|
||||
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels in "John\'s Task", Mary\'s',
|
||||
mixedResolver,
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels in 1, 2')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle apostrophes in complex filter queries', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels = "John\'s Task" && project = "Mary\'s Project" || priority = 1',
|
||||
apostropheResolver,
|
||||
apostropheResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = 1 && project = 2 || priority = 1')
|
||||
})
|
||||
|
||||
describe('Reverse transformation with apostrophes', () => {
|
||||
it('should transform labels with apostrophes from API to frontend', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'labels = 1',
|
||||
apostropheIdResolver,
|
||||
nullIdToTitleResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = John\'s Task')
|
||||
})
|
||||
|
||||
it('should transform projects with apostrophes from API to frontend', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'project = 2',
|
||||
nullIdToTitleResolver,
|
||||
apostropheIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = Mary\'s Project')
|
||||
})
|
||||
|
||||
it('should handle multiple values with apostrophes in reverse transformation', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'labels in 1, 2',
|
||||
apostropheIdResolver,
|
||||
nullIdToTitleResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels in John\'s Task, Mary\'s Project')
|
||||
})
|
||||
|
||||
it('should handle complex queries with apostrophes in reverse transformation', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'labels = 1 && project = 2 || priority = 1',
|
||||
apostropheIdResolver,
|
||||
apostropheIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = John\'s Task && project = Mary\'s Project || priority = 1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('To API', () => {
|
||||
for (const c in fieldCases) {
|
||||
it('should transform all filter params for ' + c + ' to snake_case', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import {snakeCase} from 'change-case'
|
||||
|
||||
function trimQuotes(str: string): string {
|
||||
|
||||
str = str.trim()
|
||||
|
||||
if ((str.startsWith('"') && str.endsWith('"')) ||
|
||||
(str.startsWith('\'') && str.endsWith('\''))) {
|
||||
return str.slice(1, -1)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export const DATE_FIELDS = [
|
||||
'dueDate',
|
||||
'startDate',
|
||||
|
|
@ -70,7 +81,7 @@ export function hasFilterQuery(filter: string): boolean {
|
|||
}
|
||||
|
||||
export function getFilterFieldRegexPattern(field: string): RegExp {
|
||||
return new RegExp('\\b(' + field + ')\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"&|()<]+?)(?=\\s*(?:&&|\\|\\||$))', 'ig')
|
||||
return new RegExp('\\b(' + field + ')\\s*' + FILTER_OPERATORS_REGEX + '\\s*(?:(["\'])((?:\\\\.|(?!\\3)[^\\\\])*?)\\3|([^&|()<]+?))(?=\\s*(?:&&|\\||$))', 'g')
|
||||
}
|
||||
|
||||
export function transformFilterStringForApi(
|
||||
|
|
@ -86,7 +97,8 @@ export function transformFilterStringForApi(
|
|||
}
|
||||
|
||||
AVAILABLE_FILTER_FIELDS.forEach(f => {
|
||||
filter = filter.replace(new RegExp(f, 'ig'), f)
|
||||
const fieldPattern = new RegExp('\\b(' + f + ')\\b(?=\\s*' + FILTER_OPERATORS_REGEX + ')', 'gi')
|
||||
filter = filter.replace(fieldPattern, f)
|
||||
})
|
||||
|
||||
// Transform labels and projects to ids
|
||||
|
|
@ -102,8 +114,8 @@ export function transformFilterStringForApi(
|
|||
const replacements: { start: number, length: number, replacement: string }[] = []
|
||||
|
||||
while ((match = pattern.exec(filter)) !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [matched, fieldName, operator, quotes, keyword] = match
|
||||
const [matched, fieldName, operator, quotes, quotedContent, unquotedContent] = match
|
||||
const keyword = quotedContent || unquotedContent
|
||||
if (!keyword) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -115,11 +127,18 @@ export function transformFilterStringForApi(
|
|||
|
||||
let replaced = keyword
|
||||
|
||||
const transformedKeywords: string[] = []
|
||||
keywords.forEach(k => {
|
||||
const id = resolver(k)
|
||||
if (id !== null) {
|
||||
replaced = replaced.replace(k, String(id))
|
||||
let id = resolver(k)
|
||||
if (id === null && k.includes('\\')) {
|
||||
id = resolver(k.replaceAll('\\', ''))
|
||||
}
|
||||
if (id === null) {
|
||||
transformedKeywords.push(k)
|
||||
return
|
||||
}
|
||||
|
||||
transformedKeywords.push(String(id))
|
||||
})
|
||||
|
||||
// Join the transformed keywords back together
|
||||
|
|
@ -143,11 +162,10 @@ export function transformFilterStringForApi(
|
|||
continue
|
||||
}
|
||||
|
||||
const actualKeywordStart = (match?.index || 0) + matched.length - keyword.length
|
||||
replacements.push({
|
||||
start: actualKeywordStart,
|
||||
length: keyword.length,
|
||||
replacement: replaced,
|
||||
start: match.index!,
|
||||
length: matched.length,
|
||||
replacement: reconstructedMatch,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -170,9 +188,10 @@ export function transformFilterStringForApi(
|
|||
// Transform projects to ids
|
||||
filter = transformFieldToIds(PROJECT_FIELDS, projectResolver, filter)
|
||||
|
||||
// Transform all attributes to snake case
|
||||
// Transform all field names (not values) to snake case
|
||||
AVAILABLE_FILTER_FIELDS.forEach(f => {
|
||||
filter = filter.replaceAll(f, snakeCase(f))
|
||||
const fieldPattern = new RegExp('\\b' + f + '\\b(?=\\s*' + FILTER_OPERATORS_REGEX + ')', 'gi')
|
||||
filter = filter.replace(fieldPattern, snakeCase(f))
|
||||
})
|
||||
|
||||
return filter
|
||||
|
|
@ -204,7 +223,8 @@ export function transformFilterStringFromApi(
|
|||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(filter)) !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [matched, fieldName, operator, quotes, keyword] = match
|
||||
const [matched, fieldName, operator, quotes, quotedContent, unquotedContent] = match
|
||||
const keyword = quotedContent || unquotedContent
|
||||
if (keyword) {
|
||||
let keywords = [keyword.trim()]
|
||||
if (isMultiValueOperator(operator)) {
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@
|
|||
</template>
|
||||
|
||||
<template #text>
|
||||
<p class="tw-text-balance !tw-mbe-0">
|
||||
<p class="tw-text-balance">
|
||||
{{ $t('task.detail.delete.text1') }}
|
||||
</p>
|
||||
<p class="tw-text-balance">
|
||||
|
|
|
|||
Loading…
Reference in New Issue