fix(filter): correctly replace quoted values

https://github.com/go-vikunja/vikunja/issues/743
This commit is contained in:
kolaente 2025-08-10 18:17:46 +02:00
parent 2c25e60761
commit 5651c0b818
4 changed files with 213 additions and 17 deletions

View File

@ -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>

View File

@ -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', () => {

View File

@ -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)) {

View File

@ -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">