@teleporthq/teleport-plugin-next-data-source 0.44.11 → 0.44.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/__tests__/browser-row-policy.test.ts +6 -6
  2. package/__tests__/fetchers.test.ts +1 -1
  3. package/__tests__/items-path.test.ts +3 -2
  4. package/__tests__/search-relevance.test.ts +407 -0
  5. package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
  6. package/dist/cjs/fetchers/csv-file.js +4 -3
  7. package/dist/cjs/fetchers/csv-file.js.map +1 -1
  8. package/dist/cjs/fetchers/google-sheets.d.ts.map +1 -1
  9. package/dist/cjs/fetchers/google-sheets.js +2 -1
  10. package/dist/cjs/fetchers/google-sheets.js.map +1 -1
  11. package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
  12. package/dist/cjs/fetchers/javascript.js +2 -1
  13. package/dist/cjs/fetchers/javascript.js.map +1 -1
  14. package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
  15. package/dist/cjs/fetchers/mariadb.js +4 -3
  16. package/dist/cjs/fetchers/mariadb.js.map +1 -1
  17. package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
  18. package/dist/cjs/fetchers/mysql.js +3 -2
  19. package/dist/cjs/fetchers/mysql.js.map +1 -1
  20. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  21. package/dist/cjs/fetchers/postgresql.js +4 -3
  22. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  23. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  24. package/dist/cjs/fetchers/redshift.js +3 -2
  25. package/dist/cjs/fetchers/redshift.js.map +1 -1
  26. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  27. package/dist/cjs/fetchers/rest-api.js +3 -2
  28. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  29. package/dist/cjs/fetchers/supabase.d.ts.map +1 -1
  30. package/dist/cjs/fetchers/supabase.js +3 -2
  31. package/dist/cjs/fetchers/supabase.js.map +1 -1
  32. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  33. package/dist/cjs/fetchers/teleport.js +5 -4
  34. package/dist/cjs/fetchers/teleport.js.map +1 -1
  35. package/dist/cjs/fetchers/turso.d.ts.map +1 -1
  36. package/dist/cjs/fetchers/turso.js +2 -1
  37. package/dist/cjs/fetchers/turso.js.map +1 -1
  38. package/dist/cjs/search-relevance.d.ts +81 -0
  39. package/dist/cjs/search-relevance.d.ts.map +1 -0
  40. package/dist/cjs/search-relevance.js +119 -0
  41. package/dist/cjs/search-relevance.js.map +1 -0
  42. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  43. package/dist/esm/fetchers/csv-file.d.ts.map +1 -1
  44. package/dist/esm/fetchers/csv-file.js +4 -3
  45. package/dist/esm/fetchers/csv-file.js.map +1 -1
  46. package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
  47. package/dist/esm/fetchers/google-sheets.js +2 -1
  48. package/dist/esm/fetchers/google-sheets.js.map +1 -1
  49. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  50. package/dist/esm/fetchers/javascript.js +2 -1
  51. package/dist/esm/fetchers/javascript.js.map +1 -1
  52. package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
  53. package/dist/esm/fetchers/mariadb.js +4 -3
  54. package/dist/esm/fetchers/mariadb.js.map +1 -1
  55. package/dist/esm/fetchers/mysql.d.ts.map +1 -1
  56. package/dist/esm/fetchers/mysql.js +3 -2
  57. package/dist/esm/fetchers/mysql.js.map +1 -1
  58. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  59. package/dist/esm/fetchers/postgresql.js +4 -3
  60. package/dist/esm/fetchers/postgresql.js.map +1 -1
  61. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  62. package/dist/esm/fetchers/redshift.js +3 -2
  63. package/dist/esm/fetchers/redshift.js.map +1 -1
  64. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  65. package/dist/esm/fetchers/rest-api.js +3 -2
  66. package/dist/esm/fetchers/rest-api.js.map +1 -1
  67. package/dist/esm/fetchers/supabase.d.ts.map +1 -1
  68. package/dist/esm/fetchers/supabase.js +3 -2
  69. package/dist/esm/fetchers/supabase.js.map +1 -1
  70. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  71. package/dist/esm/fetchers/teleport.js +5 -4
  72. package/dist/esm/fetchers/teleport.js.map +1 -1
  73. package/dist/esm/fetchers/turso.d.ts.map +1 -1
  74. package/dist/esm/fetchers/turso.js +2 -1
  75. package/dist/esm/fetchers/turso.js.map +1 -1
  76. package/dist/esm/search-relevance.d.ts +81 -0
  77. package/dist/esm/search-relevance.d.ts.map +1 -0
  78. package/dist/esm/search-relevance.js +113 -0
  79. package/dist/esm/search-relevance.js.map +1 -0
  80. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +2 -2
  82. package/src/fetchers/csv-file.ts +17 -11
  83. package/src/fetchers/google-sheets.ts +15 -12
  84. package/src/fetchers/javascript.ts +12 -12
  85. package/src/fetchers/mariadb.ts +21 -26
  86. package/src/fetchers/mysql.ts +21 -26
  87. package/src/fetchers/postgresql.ts +31 -26
  88. package/src/fetchers/redshift.ts +18 -17
  89. package/src/fetchers/rest-api.ts +11 -11
  90. package/src/fetchers/supabase.ts +27 -20
  91. package/src/fetchers/teleport.ts +31 -26
  92. package/src/fetchers/turso.ts +18 -17
  93. package/src/search-relevance.ts +230 -0
@@ -213,7 +213,7 @@ const internal = { 'x-internal-data-secret': SECRET }
213
213
  const SORT_BY_NAME = JSON.stringify([{ field: 'name', order: 'asc' }])
214
214
 
215
215
  const mainQuery = (result: RouteResult): string =>
216
- (result.queries.find((q) => /^\s*SELECT \* FROM/i.test(q.sql)) || { sql: '' }).sql
216
+ (result.queries.find((q) => /^\s*SELECT (\S+\.)?\* FROM/i.test(q.sql)) || { sql: '' }).sql
217
217
 
218
218
  const POSTS = [
219
219
  {
@@ -325,24 +325,24 @@ describe('blog posts: a visitor reads the published posts, never an author email
325
325
  expect((await posts.run({ handler: 'getCount', query })).status).toBe(200)
326
326
  }
327
327
  const searched = await posts.run({ query: { query: 'ana', queryColumns } })
328
- expect(mainQuery(searched)).toContain('"title"::text ILIKE')
328
+ expect(mainQuery(searched)).toContain('lower("title"::text)')
329
329
 
330
330
  const admin = await posts.run({
331
331
  query: { query: 'ana', queryColumns },
332
332
  session: { role: 'admin' },
333
333
  })
334
- expect(mainQuery(admin)).toContain('"author_email"::text ILIKE')
334
+ expect(mainQuery(admin)).toContain('lower("author_email"::text)')
335
335
  })
336
336
 
337
337
  it('searches a visitor only through the columns it is served', async () => {
338
338
  const guest = await posts.run({ query: { query: 'ana' } })
339
339
  expect(guest.status).toBe(200)
340
340
  const sql = mainQuery(guest)
341
- expect(sql).toContain('"title"::text ILIKE')
341
+ expect(sql).toContain('lower("title"::text)')
342
342
  expect(sql).not.toContain('"author_email"')
343
343
 
344
344
  const admin = await posts.run({ query: { query: 'ana' }, session: { role: 'admin' } })
345
- expect(mainQuery(admin)).toContain('"author_email"::text ILIKE')
345
+ expect(mainQuery(admin)).toContain('lower("author_email"::text)')
346
346
  })
347
347
 
348
348
  it('applies to the generic Postgres route too', async () => {
@@ -484,7 +484,7 @@ describe("members: a page carries a member's public profile, never their account
484
484
 
485
485
  await users.pageFetch({ query: 'bo', queryColumns: JSON.stringify(['name', 'email']) })
486
486
  const searched = mainQuery({ queries: users.queries } as RouteResult)
487
- expect(searched).toContain('"name"::text ILIKE')
487
+ expect(searched).toContain('lower("name"::text)')
488
488
  expect(searched).not.toContain('"email"')
489
489
  })
490
490
 
@@ -337,7 +337,7 @@ describe('generateDataSourceFetcher', () => {
337
337
 
338
338
  expect(code).toContain('import { Client, types as pgTypes } from')
339
339
  expect(code).toContain('export default async function handler')
340
- expect(code).toContain('SELECT * FROM "users"')
340
+ expect(code).toContain(`'"users".*' : '*'} FROM "users"`)
341
341
  expect(code).toContain('localhost')
342
342
  })
343
343
 
@@ -108,9 +108,10 @@ describe.each<[string, FetcherSetup]>([
108
108
  setup.fetchImpl
109
109
  )
110
110
 
111
- // "row" and "2" each match somewhere in the name: row 2, row 12, row 20–25.
111
+ // "row" and "2" each match somewhere in the name: row 2, row 12, row 20–25;
112
+ // "2" as a whole word first, then at the start of one (20–25), then inside one (12).
112
113
  const data = result.data as { templates: Array<{ id: number }> }
113
- expect(data.templates.map((r) => r.id)).toEqual([2, 12, 20, 21, 22, 23, 24, 25])
114
+ expect(data.templates.map((r) => r.id)).toEqual([2, 20, 21, 22, 23, 24, 25, 12])
114
115
  })
115
116
 
116
117
  it('returns the payload untouched without the param', async () => {
@@ -0,0 +1,407 @@
1
+ /* tslint:disable:function-constructor max-classes-per-file */
2
+ import { parse } from '@babel/parser'
3
+ import { generateDataSourceFetcher } from '../src/data-source-fetchers'
4
+ import { generateTeleportCountFetcher } from '../src/fetchers/teleport'
5
+ import { generateCSVFileFetcher } from '../src/fetchers/csv-file'
6
+ import { generateSearchRankHelperCode, generateSearchTermsCode } from '../src/search-relevance'
7
+
8
+ /**
9
+ * A list's search: the typed text is split into terms at white space, the
10
+ * punctuation around them dropped; every term must appear in one of the
11
+ * searched columns. Postgres and the in-memory routes read the stored text
12
+ * like the typed text — tags, entities and punctuation as spaces — so markup
13
+ * never matches. The matches are ordered column by column: by how much of the
14
+ * typed text the title holds (a whole word 3, a word start 2, inside a word
15
+ * 1), then the summary, and so on, with the list's own sort among rows that
16
+ * score alike. The order is part of the query, so a page is cut from the
17
+ * ranked rows, never ranked after.
18
+ */
19
+
20
+ interface Query {
21
+ sql: string
22
+ params: unknown[]
23
+ }
24
+
25
+ const SEARCHED = JSON.stringify(['title', 'excerpt', 'content'])
26
+
27
+ const pgRoute = (type: 'teleport' | 'postgresql', withCount = false) => {
28
+ const config =
29
+ type === 'teleport'
30
+ ? {
31
+ host: 'db',
32
+ database: 'site',
33
+ selectedTables: { articles: { columns: [{ name: 'id' }] } },
34
+ }
35
+ : { host: 'h', port: 5432, user: 'u', password: 'p', database: 'd' }
36
+ const route = generateDataSourceFetcher({ type, config } as never, 'articles')
37
+ return withCount && type === 'teleport'
38
+ ? route + generateTeleportCountFetcher(config, 'articles')
39
+ : route
40
+ }
41
+
42
+ const runPgRoute = async (
43
+ code: string,
44
+ query: Record<string, string>,
45
+ entry = 'handler'
46
+ ): Promise<Query[]> => {
47
+ const queries: Query[] = []
48
+ class FakeClient {
49
+ public async connect() {
50
+ return
51
+ }
52
+ public async end() {
53
+ return
54
+ }
55
+ public async query(sql: string, params: unknown[] = []) {
56
+ queries.push({ sql, params })
57
+ return /^SELECT COUNT/i.test(sql) ? { rows: [{ count: '0' }] } : { rows: [] }
58
+ }
59
+ }
60
+ const body = code
61
+ .replace(/^import .*$/gm, '')
62
+ .replace(/^export default async function handler/m, 'async function handler')
63
+ const module = { exports: {} as Record<string, (req: unknown, res: unknown) => Promise<void>> }
64
+ new Function(
65
+ 'require',
66
+ 'module',
67
+ 'process',
68
+ 'console',
69
+ 'Client',
70
+ 'pgTypes',
71
+ `${body}\nmodule.exports = { handler, getCount: typeof getCount === 'function' ? getCount : null }`
72
+ )(
73
+ () => ({ getToken: async () => null }),
74
+ module,
75
+ { env: {} },
76
+ { error: () => undefined, warn: () => undefined, log: () => undefined },
77
+ FakeClient,
78
+ { setTypeParser: () => undefined }
79
+ )
80
+ const res = { status: () => res, json: () => res, setHeader: () => res }
81
+ await module.exports[entry]({ method: 'GET', query, headers: {} }, res)
82
+ return queries.filter((recorded) => / FROM "?articles"?/.test(recorded.sql))
83
+ }
84
+
85
+ const searchText = (position: number) => `"__tq_search"."__tq_search_${position}"`
86
+ const like = (position: number, placeholder: string, before: string, after: string) =>
87
+ `${searchText(position)} LIKE ('${before}' || ${placeholder}::text || '${after}') ESCAPE '|'`
88
+ const columnScore = (position: number, placeholders: string[]) =>
89
+ '(' +
90
+ placeholders
91
+ .map(
92
+ (ph) =>
93
+ `CASE WHEN ${like(position, ph, '% ', ' %')} THEN 3` +
94
+ ` WHEN ${like(position, ph, '% ', '%')} THEN 2` +
95
+ ` WHEN ${like(position, ph, '%', '%')} THEN 1 ELSE 0 END`
96
+ )
97
+ .join(' + ') +
98
+ ') DESC'
99
+
100
+ describe.each(['teleport', 'postgresql'] as const)('the %s list route', (type) => {
101
+ const route = pgRoute(type)
102
+ const table = type === 'teleport' ? 'articles' : '"articles"'
103
+
104
+ it('parses as valid JavaScript', () => {
105
+ expect(() => parse(route, { sourceType: 'module', plugins: ['jsx'] })).not.toThrow()
106
+ })
107
+
108
+ it('needs every typed term, each in any searched column, case and punctuation aside', async () => {
109
+ const [main] = await runPgRoute(route, { query: 'Create "form?"', queryColumns: SEARCHED })
110
+ expect(main.sql).toContain(
111
+ `WHERE ((${like(0, '$1', '%', '%')} OR ${like(1, '$1', '%', '%')} OR ${like(
112
+ 2,
113
+ '$1',
114
+ '%',
115
+ '%'
116
+ )})` +
117
+ ` AND (${like(0, '$2', '%', '%')} OR ${like(1, '$2', '%', '%')} OR ${like(
118
+ 2,
119
+ '$2',
120
+ '%',
121
+ '%'
122
+ )}))`
123
+ )
124
+ expect(main.params).toEqual(['create', 'form'])
125
+ })
126
+
127
+ it("reads each searched column once per row, as text without markup or punctuation, and serves only the table's columns", async () => {
128
+ const [main] = await runPgRoute(route, { query: 'form', queryColumns: SEARCHED })
129
+ expect(main.sql).toMatch(
130
+ new RegExp(
131
+ `^SELECT ${table.replace(/"/g, '"')}\\.\\* FROM ${table} CROSS JOIN LATERAL \\(SELECT `
132
+ )
133
+ )
134
+ expect(main.sql).toContain(
135
+ `(' ' || regexp_replace(lower("title"::text), '<[^>]*>|&[a-z0-9#]+;|[]`
136
+ )
137
+ expect(main.sql).toContain(`, ' ', 'g') || ' ') AS "__tq_search_0", `)
138
+ expect(main.sql).toContain(` AS "__tq_search_2" OFFSET 0) AS "__tq_search" WHERE `)
139
+ })
140
+
141
+ it('reads punctuation inside a term as a space, like the stored text', async () => {
142
+ const [main] = await runPgRoute(route, {
143
+ query: 'e-commerce what’s 50%',
144
+ queryColumns: SEARCHED,
145
+ })
146
+ expect(main.params).toEqual(['e commerce', 'what s', '50'])
147
+ })
148
+
149
+ it('orders by the title score, then the excerpt, then the content, before the sort and the page', async () => {
150
+ const [main] = await runPgRoute(route, {
151
+ query: 'create form',
152
+ queryColumns: SEARCHED,
153
+ sorts: JSON.stringify([{ field: 'sort_order', order: 'asc' }]),
154
+ filters: JSON.stringify([{ source: 'status', destination: 'published', operand: '=' }]),
155
+ perPage: '12',
156
+ page: '2',
157
+ })
158
+ const rank = [0, 1, 2].map((position) => columnScore(position, ['$1', '$2'])).join(', ')
159
+ expect(main.sql).toContain(` ORDER BY ${rank}, sort_order ASC NULLS LAST`)
160
+ expect(main.sql).toMatch(/ LIMIT 12 OFFSET 12$/)
161
+ expect(main.params).toEqual(['create', 'form', 'published'])
162
+ })
163
+
164
+ it('ranks the matches when the list has no sort of its own, even over one column', async () => {
165
+ const [main] = await runPgRoute(route, {
166
+ query: 'form',
167
+ queryColumns: JSON.stringify(['title']),
168
+ })
169
+ expect(main.sql).toMatch(
170
+ new RegExp(` ORDER BY \\(CASE WHEN .* THEN 3 .* ELSE 0 END\\) DESC(, "id" ASC)?$`)
171
+ )
172
+ })
173
+
174
+ it('leaves the query alone without a search', async () => {
175
+ const [plain] = await runPgRoute(route, {
176
+ sorts: JSON.stringify([{ field: 'sort_order', order: 'asc' }]),
177
+ })
178
+ expect(plain.sql).toMatch(new RegExp(`^SELECT \\* FROM ${table}`))
179
+ expect(plain.sql).not.toContain('LATERAL')
180
+ expect(plain.sql).not.toContain('CASE WHEN')
181
+ expect(plain.sql).toContain(' ORDER BY sort_order ASC NULLS LAST')
182
+ })
183
+
184
+ it('treats a search of only punctuation as no search', async () => {
185
+ const [main] = await runPgRoute(route, { query: ' ?! - ', queryColumns: SEARCHED })
186
+ expect(main.sql).not.toContain('LATERAL')
187
+ expect(main.params).toEqual([])
188
+ })
189
+ })
190
+
191
+ describe('the built-in database count route', () => {
192
+ it('counts the rows matching every term, through the same texts, without ranking them', async () => {
193
+ const [count] = await runPgRoute(
194
+ pgRoute('teleport', true),
195
+ { query: 'create form', queryColumns: SEARCHED },
196
+ 'getCount'
197
+ )
198
+ expect(count.sql).toMatch(
199
+ /^SELECT COUNT\(\*\) FROM articles CROSS JOIN LATERAL \(SELECT [\s\S]* OFFSET 0\) AS "__tq_search" WHERE \(\(.*\) AND \(.*\)\)$/
200
+ )
201
+ expect(count.sql).not.toContain('CASE')
202
+ expect(count.params).toEqual(['create', 'form'])
203
+ })
204
+ })
205
+
206
+ describe('the MySQL list route', () => {
207
+ const route = generateDataSourceFetcher(
208
+ { type: 'mysql', config: { host: 'h', user: 'u', password: 'p', database: 'd' } } as never,
209
+ 'articles'
210
+ )
211
+
212
+ it('binds the ranking terms after every WHERE parameter, as the ORDER BY follows the WHERE', async () => {
213
+ const queries: Query[] = []
214
+ const mysql = {
215
+ escapeId: (name: string) => '`' + name + '`',
216
+ createConnection: () => ({
217
+ query: async (sql: string, params: unknown[]) => {
218
+ queries.push({ sql, params })
219
+ return [[]]
220
+ },
221
+ end: async () => undefined,
222
+ }),
223
+ }
224
+ const body = route
225
+ .replace(/^import .*$/gm, '')
226
+ .replace(/^export default async function handler/m, 'async function handler')
227
+ const handler = new Function('mysql', 'console', `${body}\nreturn handler`)(mysql, {
228
+ error: () => undefined,
229
+ warn: () => undefined,
230
+ })
231
+ const res = { status: () => res, json: () => res }
232
+ await handler(
233
+ {
234
+ query: {
235
+ query: '"e-commerce" form?',
236
+ queryColumns: JSON.stringify(['title', 'content']),
237
+ filters: JSON.stringify([{ source: 'status', destination: 'published', operand: '=' }]),
238
+ sorts: JSON.stringify([{ field: 'sort_order', order: 'asc' }]),
239
+ },
240
+ },
241
+ res
242
+ )
243
+ const [main] = queries
244
+ const placeholders = (main.sql.match(/\?/g) || []).length
245
+ expect(placeholders).toBe(main.params.length)
246
+ const title = "CONCAT(' ', LOWER(CAST(`title` AS CHAR)), ' ')"
247
+ expect(main.sql).toContain(
248
+ `ORDER BY (CASE WHEN ${title} LIKE CONCAT('% ', ?, ' %') ESCAPE '|' THEN 3` +
249
+ ` WHEN ${title} LIKE CONCAT('% ', ?, '%') ESCAPE '|' THEN 2` +
250
+ ` WHEN ${title} LIKE CONCAT('%', ?, '%') ESCAPE '|' THEN 1 ELSE 0 END`
251
+ )
252
+ expect(main.sql).toContain('ELSE 0 END) DESC, `sort_order` ASC')
253
+ // The stored text is read raw here, so a term keeps the punctuation inside it.
254
+ const term = ['e-commerce', 'form']
255
+ expect(main.params).toEqual([
256
+ term[0],
257
+ term[0],
258
+ term[1],
259
+ term[1],
260
+ 'published',
261
+ ...[term[0], term[0], term[0], term[1], term[1], term[1]],
262
+ ...[term[0], term[0], term[0], term[1], term[1], term[1]],
263
+ ])
264
+ })
265
+ })
266
+
267
+ const evaluate = (code: string, exports: string) =>
268
+ new Function(`${code}\nreturn { ${exports} }`)() as Record<
269
+ string,
270
+ (...args: unknown[]) => unknown
271
+ >
272
+
273
+ describe('the typed terms', () => {
274
+ const normalised = evaluate(generateSearchTermsCode(true), 'searchTerms').searchTerms
275
+ const raw = evaluate(generateSearchTermsCode(false), 'searchTerms').searchTerms
276
+
277
+ it('drops case, repeats and the punctuation around a term', () => {
278
+ expect(normalised(' How do I create a FORM? "create" ')).toEqual([
279
+ 'how',
280
+ 'do',
281
+ 'i',
282
+ 'create',
283
+ 'a',
284
+ 'form',
285
+ ])
286
+ expect(raw('(reCAPTCHA), "form?"')).toEqual(['recaptcha', 'form'])
287
+ })
288
+
289
+ it('reads the punctuation inside a term as a space only where the stored text is read the same way', () => {
290
+ expect(normalised('e-commerce what’s Q&A')).toEqual(['e commerce', 'what s', 'q a'])
291
+ expect(raw('e-commerce what’s Q&A')).toEqual(['e-commerce', 'what’s', 'q&a'])
292
+ })
293
+
294
+ it('finds nothing to search in blank text or punctuation alone', () => {
295
+ expect(normalised('')).toEqual([])
296
+ expect(normalised(' - ? ')).toEqual([])
297
+ expect(normalised(undefined)).toEqual([])
298
+ expect(raw(' % _ ')).toEqual([])
299
+ })
300
+ })
301
+
302
+ describe('a list searched in memory', () => {
303
+ const helpers = evaluate(generateSearchRankHelperCode(), 'matchesSearch, rankBySearch')
304
+ const valueOf = (row: Record<string, unknown>, column: string) => row[column]
305
+
306
+ it("never matches a word that is only in a rich-text body's markup", () => {
307
+ const row = {
308
+ content:
309
+ '<p><strong>Tip:</strong> see <a href="https://x.io" rel="noopener">Q&amp;A</a></p><aside class="tq-callout" data-kind="note"><p>Done.</p></aside>',
310
+ }
311
+ const matches = (query: string) => helpers.matchesSearch(row, query, ['content'], valueOf)
312
+ expect(matches('strong')).toBe(false)
313
+ expect(matches('href')).toBe(false)
314
+ expect(matches('noopener')).toBe(false)
315
+ expect(matches('amp')).toBe(false)
316
+ expect(matches('callout')).toBe(false)
317
+ expect(matches('tip see')).toBe(true)
318
+ expect(matches('Q&A')).toBe(true)
319
+ expect(matches('done.')).toBe(true)
320
+ })
321
+
322
+ it('finds a typed apostrophe in either form', () => {
323
+ const row = { title: 'What’s new' }
324
+ expect(helpers.matchesSearch(row, "what's", ['title'], valueOf)).toBe(true)
325
+ expect(helpers.matchesSearch({ title: "What's new" }, 'what’s', ['title'], valueOf)).toBe(true)
326
+ })
327
+
328
+ it('puts whole words before word starts before parts of words, the title before the rest', () => {
329
+ const rows = [
330
+ { id: 1, title: 'Other platforms', content: 'create' },
331
+ { id: 2, title: 'Forms and more', content: 'create' },
332
+ { id: 3, title: 'Unrelated', content: 'create a form' },
333
+ { id: 4, title: 'How to create a form', content: '' },
334
+ { id: 5, title: 'Create pages', content: 'a form' },
335
+ ]
336
+ const ranked = helpers.rankBySearch(
337
+ rows,
338
+ 'create form',
339
+ ['title', 'content'],
340
+ valueOf
341
+ ) as Array<{
342
+ id: number
343
+ }>
344
+ expect(ranked.map((row) => row.id)).toEqual([4, 5, 2, 1, 3])
345
+ })
346
+ })
347
+
348
+ describe('a list searched in memory (CSV file)', () => {
349
+ const rows = [
350
+ {
351
+ id: 1,
352
+ title: 'Publishing your site',
353
+ excerpt: 'Go live',
354
+ content: '<p>Create a <strong>form</strong> first</p>',
355
+ },
356
+ { id: 2, title: 'Forms', excerpt: 'How to create a form', content: '' },
357
+ { id: 3, title: 'How to create a form', excerpt: '', content: '' },
358
+ { id: 4, title: 'Create pages', excerpt: 'A form on a page', content: '' },
359
+ { id: 5, title: 'Create a contact form', excerpt: '', content: '' },
360
+ { id: 6, title: 'Unrelated', excerpt: '', content: '<p><strong>Strong</strong> words</p>' },
361
+ ]
362
+ const code = generateCSVFileFetcher({
363
+ parsedData: rows,
364
+ columns: ['id', 'title', 'excerpt', 'content'].map((id) => ({ id, label: id })),
365
+ })
366
+
367
+ const run = async (query: Record<string, string>) => {
368
+ const handler = new Function(
369
+ `${code.replace(/^export default async function handler/m, 'async function handler')}
370
+ return handler`
371
+ )()
372
+ let payload: { data: Array<{ id: number }> } = { data: [] }
373
+ const res = {
374
+ status: () => res,
375
+ json: (data: typeof payload) => {
376
+ payload = data
377
+ return res
378
+ },
379
+ }
380
+ await handler({ query }, res)
381
+ return payload.data.map((row) => row.id)
382
+ }
383
+
384
+ it('ranks by the title, then the excerpt, then the content; the sort among rows that score alike', async () => {
385
+ const ids = await run({
386
+ query: 'create form?',
387
+ queryColumns: SEARCHED,
388
+ sorts: JSON.stringify([{ field: 'id', order: 'desc' }]),
389
+ })
390
+ expect(ids).toEqual([5, 3, 4, 2, 1])
391
+ })
392
+
393
+ it('cuts the page from the ranked rows', async () => {
394
+ const ids = await run({
395
+ query: 'create form',
396
+ queryColumns: SEARCHED,
397
+ sorts: JSON.stringify([{ field: 'id', order: 'desc' }]),
398
+ perPage: '2',
399
+ page: '2',
400
+ })
401
+ expect(ids).toEqual([4, 2])
402
+ })
403
+
404
+ it('matches the text of a rich-text column, not its tags', async () => {
405
+ expect(await run({ query: 'strong', queryColumns: SEARCHED })).toEqual([6])
406
+ })
407
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"csv-file.d.ts","sourceRoot":"","sources":["../../../src/fetchers/csv-file.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,iBAAiB,WACpB,OAAO,MAAM,EAAE,OAAO,CAAC,KAC9B;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CA8BpC,CAAA;AA4JD,eAAO,MAAM,sBAAsB,WAAY,OAAO,MAAM,EAAE,OAAO,CAAC,KAAG,MAyIxE,CAAA;AAGD,eAAO,MAAM,uBAAuB,YAAa,GAAG,KAAG,MA+CtD,CAAA"}
1
+ {"version":3,"file":"csv-file.d.ts","sourceRoot":"","sources":["../../../src/fetchers/csv-file.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,iBAAiB,WACpB,OAAO,MAAM,EAAE,OAAO,CAAC,KAC9B;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CA8BpC,CAAA;AA6JD,eAAO,MAAM,sBAAsB,WAAY,OAAO,MAAM,EAAE,OAAO,CAAC,KAAG,MA6IxE,CAAA;AAGD,eAAO,MAAM,uBAAuB,YAAa,GAAG,KAAG,MA+CtD,CAAA"}
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateCSVCountFetcher = exports.generateCSVFileFetcher = exports.validateCSVConfig = void 0;
4
4
  var utils_1 = require("../utils");
5
+ var search_relevance_1 = require("../search-relevance");
5
6
  var header_detection_1 = require("./utils/header-detection");
6
7
  var validateCSVConfig = function (config) {
7
8
  if (!config || typeof config !== 'object') {
@@ -31,7 +32,7 @@ var validateCSVConfig = function (config) {
31
32
  };
32
33
  exports.validateCSVConfig = validateCSVConfig;
33
34
  var generateHandlerBody = function () {
34
- return "\n try {\n const { query, queryColumns, limit, page, perPage, sortBy, sortOrder, filters, sorts, offset: offsetParam } = req.query\n \n const labelToIdMap = {}\n columns.forEach((col) => {\n if (col.label && col.id) {\n labelToIdMap[col.label] = col.id\n }\n })\n \n let filteredData = [...data]\n \n if (query) {\n const searchTerms = query.toLowerCase().split(' ').filter(Boolean)\n \n if (queryColumns) {\n const searchColumns = safeJSONParse(queryColumns)\n filteredData = filteredData.filter((item) => {\n return searchTerms.every((term) => searchColumns.some((col) => {\n const field = labelToIdMap[col] || col\n const value = getNestedValue(item, field)\n return value && String(value).toLowerCase().includes(term)\n }))\n })\n } else {\n filteredData = filteredData.filter((item) => {\n try {\n const stringified = JSON.stringify(item).toLowerCase()\n return searchTerms.every((term) => stringified.includes(term))\n } catch {\n return false\n }\n })\n }\n }\n \n if (filters) {\n const filterTree = normalizeFilterTree(safeJSONParse(filters))\n \n if (filterTree) {\n filteredData = filteredData.filter((item) =>\n evaluateFilterTree(filterTree, (condition) => {\n const field = labelToIdMap[condition.source] || condition.source\n const value = getNestedValue(item, field)\n const target = condition.destination\n const operand = condition.operand\n \n if (Array.isArray(target)) {\n if (operand === '!=') {\n return !target.includes(value)\n }\n return target.includes(value)\n }\n \n return compareValues(value, target, operand)\n })\n )\n }\n }\n \n if (sorts) {\n const parsedSorts = safeJSONParse(sorts)\n if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {\n filteredData.sort((a, b) => {\n for (const sort of parsedSorts) {\n if (!sort.field) continue\n const field = labelToIdMap[sort.field] || sort.field\n const aVal = getNestedValue(a, field)\n const bVal = getNestedValue(b, field)\n const sortOrderValue = (sort.order || '').toLowerCase().startsWith('desc') ? -1 : 1\n \n let comparison = 0\n if (aVal === null || aVal === undefined) {\n comparison = bVal === null || bVal === undefined ? 0 : -1\n } else if (bVal === null || bVal === undefined) {\n comparison = 1\n } else if (typeof aVal === 'number' && typeof bVal === 'number') {\n comparison = aVal - bVal\n } else if (aVal instanceof Date && bVal instanceof Date) {\n comparison = aVal.getTime() - bVal.getTime()\n } else {\n const aStr = String(aVal)\n const bStr = String(bVal)\n if (aStr < bStr) comparison = -1\n else if (aStr > bStr) comparison = 1\n }\n \n if (comparison !== 0) return comparison * sortOrderValue\n }\n return 0\n })\n }\n } else if (sortBy) {\n const field = labelToIdMap[sortBy] || sortBy\n filteredData.sort((a, b) => {\n const aVal = getNestedValue(a, field)\n const bVal = getNestedValue(b, field)\n const sortOrderValue = (sortOrder || '').toLowerCase().startsWith('desc') ? -1 : 1\n \n let comparison = 0\n if (aVal === null || aVal === undefined) {\n comparison = bVal === null || bVal === undefined ? 0 : -1\n } else if (bVal === null || bVal === undefined) {\n comparison = 1\n } else if (typeof aVal === 'number' && typeof bVal === 'number') {\n comparison = aVal - bVal\n } else if (aVal instanceof Date && bVal instanceof Date) {\n comparison = aVal.getTime() - bVal.getTime()\n } else {\n const aStr = String(aVal)\n const bStr = String(bVal)\n if (aStr < bStr) comparison = -1\n else if (aStr > bStr) comparison = 1\n }\n \n return comparison * sortOrderValue\n })\n }\n \n const limitValue = limit || perPage\n const offsetValue = offsetParam !== undefined ? parseInt(offsetParam) : (page && perPage ? (parseInt(page) - 1) * parseInt(perPage) : 0)\n \n if (limitValue) {\n filteredData = filteredData.slice(offsetValue, offsetValue + parseInt(limitValue))\n }\n \n const safeData = JSON.parse(JSON.stringify(filteredData, dateReplacer))\n \n return res.status(200).json({\n success: true,\n data: safeData,\n timestamp: Date.now()\n })\n } catch (error) {\n console.error('CSV fetch error:', error)\n return res.status(500).json({\n success: false,\n error: error.message || 'Failed to fetch data',\n timestamp: Date.now()\n })\n }\n";
35
+ return "\n try {\n const { query, queryColumns, limit, page, perPage, sortBy, sortOrder, filters, sorts, offset: offsetParam } = req.query\n \n const labelToIdMap = {}\n columns.forEach((col) => {\n if (col.label && col.id) {\n labelToIdMap[col.label] = col.id\n }\n })\n \n let filteredData = [...data]\n \n if (query) {\n if (queryColumns) {\n const searchColumns = safeJSONParse(queryColumns)\n filteredData = filteredData.filter((item) =>\n matchesSearch(item, query, searchColumns, (row, col) => getNestedValue(row, labelToIdMap[col] || col))\n )\n } else {\n const terms = searchTerms(query)\n filteredData = filteredData.filter((item) => {\n try {\n const text = searchText(item)\n return terms.every((term) => text.indexOf(term) !== -1)\n } catch {\n return false\n }\n })\n }\n }\n \n if (filters) {\n const filterTree = normalizeFilterTree(safeJSONParse(filters))\n \n if (filterTree) {\n filteredData = filteredData.filter((item) =>\n evaluateFilterTree(filterTree, (condition) => {\n const field = labelToIdMap[condition.source] || condition.source\n const value = getNestedValue(item, field)\n const target = condition.destination\n const operand = condition.operand\n \n if (Array.isArray(target)) {\n if (operand === '!=') {\n return !target.includes(value)\n }\n return target.includes(value)\n }\n \n return compareValues(value, target, operand)\n })\n )\n }\n }\n \n if (sorts) {\n const parsedSorts = safeJSONParse(sorts)\n if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {\n filteredData.sort((a, b) => {\n for (const sort of parsedSorts) {\n if (!sort.field) continue\n const field = labelToIdMap[sort.field] || sort.field\n const aVal = getNestedValue(a, field)\n const bVal = getNestedValue(b, field)\n const sortOrderValue = (sort.order || '').toLowerCase().startsWith('desc') ? -1 : 1\n \n let comparison = 0\n if (aVal === null || aVal === undefined) {\n comparison = bVal === null || bVal === undefined ? 0 : -1\n } else if (bVal === null || bVal === undefined) {\n comparison = 1\n } else if (typeof aVal === 'number' && typeof bVal === 'number') {\n comparison = aVal - bVal\n } else if (aVal instanceof Date && bVal instanceof Date) {\n comparison = aVal.getTime() - bVal.getTime()\n } else {\n const aStr = String(aVal)\n const bStr = String(bVal)\n if (aStr < bStr) comparison = -1\n else if (aStr > bStr) comparison = 1\n }\n \n if (comparison !== 0) return comparison * sortOrderValue\n }\n return 0\n })\n }\n } else if (sortBy) {\n const field = labelToIdMap[sortBy] || sortBy\n filteredData.sort((a, b) => {\n const aVal = getNestedValue(a, field)\n const bVal = getNestedValue(b, field)\n const sortOrderValue = (sortOrder || '').toLowerCase().startsWith('desc') ? -1 : 1\n \n let comparison = 0\n if (aVal === null || aVal === undefined) {\n comparison = bVal === null || bVal === undefined ? 0 : -1\n } else if (bVal === null || bVal === undefined) {\n comparison = 1\n } else if (typeof aVal === 'number' && typeof bVal === 'number') {\n comparison = aVal - bVal\n } else if (aVal instanceof Date && bVal instanceof Date) {\n comparison = aVal.getTime() - bVal.getTime()\n } else {\n const aStr = String(aVal)\n const bStr = String(bVal)\n if (aStr < bStr) comparison = -1\n else if (aStr > bStr) comparison = 1\n }\n \n return comparison * sortOrderValue\n })\n }\n \n if (query && queryColumns) {\n filteredData = rankBySearch(filteredData, query, safeJSONParse(queryColumns), (item, col) =>\n getNestedValue(item, labelToIdMap[col] || col)\n )\n }\n \n const limitValue = limit || perPage\n const offsetValue = offsetParam !== undefined ? parseInt(offsetParam) : (page && perPage ? (parseInt(page) - 1) * parseInt(perPage) : 0)\n \n if (limitValue) {\n filteredData = filteredData.slice(offsetValue, offsetValue + parseInt(limitValue))\n }\n \n const safeData = JSON.parse(JSON.stringify(filteredData, dateReplacer))\n \n return res.status(200).json({\n success: true,\n data: safeData,\n timestamp: Date.now()\n })\n } catch (error) {\n console.error('CSV fetch error:', error)\n return res.status(500).json({\n success: false,\n error: error.message || 'Failed to fetch data',\n timestamp: Date.now()\n })\n }\n";
35
36
  };
36
37
  var generateCSVFileFetcher = function (config) {
37
38
  var csvConfig = config;
@@ -41,10 +42,10 @@ var generateCSVFileFetcher = function (config) {
41
42
  var configColumns = csvConfig.columns || [];
42
43
  if (csvConfig.fileContent) {
43
44
  var fileContent = csvConfig.fileContent;
44
- return "const csvContent = ".concat(JSON.stringify(fileContent), "\nconst autoDetectHeader = ").concat(autoDetectHeader, "\nconst firstRowIsHeader = ").concat(firstRowIsHeader, "\nconst configColumns = ").concat(JSON.stringify(configColumns), "\n\n").concat((0, utils_1.generateSafeJSONParseCode)(), "\n\n").concat((0, utils_1.generateDateFormatterCode)(), "\n\n").concat((0, utils_1.generateSortFilterHelperCode)(), "\n\n").concat((0, utils_1.generateFilterTreeHelpersCode)(), "\n\n").concat((0, header_detection_1.generateHeaderDetectionCode)(), "\n\nfunction parseCSVLine(line) {\n const result = []\n let current = ''\n let inQuotes = false\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i]\n const nextChar = line[i + 1]\n\n if (char === '\"') {\n if (inQuotes && nextChar === '\"') {\n current += '\"'\n i++\n } else {\n inQuotes = !inQuotes\n }\n } else if (char === ',' && !inQuotes) {\n result.push(current)\n current = ''\n } else {\n current += char\n }\n }\n\n result.push(current)\n return result\n}\n\nfunction parseCSVContent(content, autoDetect, firstRowHeader, columns) {\n const lines = content.split('\\n').filter((line) => line.trim())\n \n if (lines.length === 0) {\n return { columns: [], rows: [] }\n }\n \n const allRowValues = lines.map((line) => parseCSVLine(line))\n const firstRowValues = allRowValues[0]\n \n if (firstRowValues.length === 0) {\n return { columns: [], rows: [] }\n }\n \n const shouldAutoDetect = autoDetect !== false\n let hasHeaderRow = false\n \n if (shouldAutoDetect) {\n hasHeaderRow = detectHeaderRow(firstRowValues, allRowValues)\n } else {\n hasHeaderRow = firstRowHeader !== false\n }\n \n const useConfigColumns = columns && columns.length > 0\n let parsedColumns\n let dataStartIndex\n \n if (hasHeaderRow && allRowValues.length > 1) {\n parsedColumns = firstRowValues.map((header, index) => {\n const configCol = useConfigColumns ? columns[index] : null\n return {\n id: configCol?.id || `col_${index}`,\n label: String(header || configCol?.label || `Column ${index + 1}`),\n type: configCol?.type || 'string'\n }\n })\n dataStartIndex = 1\n } else {\n parsedColumns = firstRowValues.map((_, index) => {\n const configCol = useConfigColumns ? columns[index] : null\n return {\n id: configCol?.id || `col_${index}`,\n label: configCol?.label || `Column ${index + 1}`,\n type: configCol?.type || 'string'\n }\n })\n dataStartIndex = 0\n }\n \n const rows = []\n for (let i = dataStartIndex; i < allRowValues.length; i++) {\n const values = allRowValues[i]\n const row = {}\n parsedColumns.forEach((col, index) => {\n const value = values[index]?.trim() || null\n row[col.id] = value\n })\n rows.push(row)\n }\n \n return { columns: parsedColumns, rows }\n}\n\nconst { columns, rows: data } = parseCSVContent(csvContent, autoDetectHeader, firstRowIsHeader, configColumns)\n\nexport default async function handler(req, res) {").concat(generateHandlerBody(), "\n}\n");
45
+ return "const csvContent = ".concat(JSON.stringify(fileContent), "\nconst autoDetectHeader = ").concat(autoDetectHeader, "\nconst firstRowIsHeader = ").concat(firstRowIsHeader, "\nconst configColumns = ").concat(JSON.stringify(configColumns), "\n\n").concat((0, utils_1.generateSafeJSONParseCode)(), "\n\n").concat((0, search_relevance_1.generateSearchRankHelperCode)(), "\n\n").concat((0, utils_1.generateDateFormatterCode)(), "\n\n").concat((0, utils_1.generateSortFilterHelperCode)(), "\n\n").concat((0, utils_1.generateFilterTreeHelpersCode)(), "\n\n").concat((0, header_detection_1.generateHeaderDetectionCode)(), "\n\nfunction parseCSVLine(line) {\n const result = []\n let current = ''\n let inQuotes = false\n\n for (let i = 0; i < line.length; i++) {\n const char = line[i]\n const nextChar = line[i + 1]\n\n if (char === '\"') {\n if (inQuotes && nextChar === '\"') {\n current += '\"'\n i++\n } else {\n inQuotes = !inQuotes\n }\n } else if (char === ',' && !inQuotes) {\n result.push(current)\n current = ''\n } else {\n current += char\n }\n }\n\n result.push(current)\n return result\n}\n\nfunction parseCSVContent(content, autoDetect, firstRowHeader, columns) {\n const lines = content.split('\\n').filter((line) => line.trim())\n \n if (lines.length === 0) {\n return { columns: [], rows: [] }\n }\n \n const allRowValues = lines.map((line) => parseCSVLine(line))\n const firstRowValues = allRowValues[0]\n \n if (firstRowValues.length === 0) {\n return { columns: [], rows: [] }\n }\n \n const shouldAutoDetect = autoDetect !== false\n let hasHeaderRow = false\n \n if (shouldAutoDetect) {\n hasHeaderRow = detectHeaderRow(firstRowValues, allRowValues)\n } else {\n hasHeaderRow = firstRowHeader !== false\n }\n \n const useConfigColumns = columns && columns.length > 0\n let parsedColumns\n let dataStartIndex\n \n if (hasHeaderRow && allRowValues.length > 1) {\n parsedColumns = firstRowValues.map((header, index) => {\n const configCol = useConfigColumns ? columns[index] : null\n return {\n id: configCol?.id || `col_${index}`,\n label: String(header || configCol?.label || `Column ${index + 1}`),\n type: configCol?.type || 'string'\n }\n })\n dataStartIndex = 1\n } else {\n parsedColumns = firstRowValues.map((_, index) => {\n const configCol = useConfigColumns ? columns[index] : null\n return {\n id: configCol?.id || `col_${index}`,\n label: configCol?.label || `Column ${index + 1}`,\n type: configCol?.type || 'string'\n }\n })\n dataStartIndex = 0\n }\n \n const rows = []\n for (let i = dataStartIndex; i < allRowValues.length; i++) {\n const values = allRowValues[i]\n const row = {}\n parsedColumns.forEach((col, index) => {\n const value = values[index]?.trim() || null\n row[col.id] = value\n })\n rows.push(row)\n }\n \n return { columns: parsedColumns, rows }\n}\n\nconst { columns, rows: data } = parseCSVContent(csvContent, autoDetectHeader, firstRowIsHeader, configColumns)\n\nexport default async function handler(req, res) {").concat(generateHandlerBody(), "\n}\n");
45
46
  }
46
47
  else {
47
- return "const data = ".concat(JSON.stringify(csvConfig.parsedData || []), "\nconst columns = ").concat(JSON.stringify(configColumns), "\n\n").concat((0, utils_1.generateSafeJSONParseCode)(), "\n\n").concat((0, utils_1.generateDateFormatterCode)(), "\n\n").concat((0, utils_1.generateSortFilterHelperCode)(), "\n\n").concat((0, utils_1.generateFilterTreeHelpersCode)(), "\n\nexport default async function handler(req, res) {").concat(generateHandlerBody(), "\n}\n");
48
+ return "const data = ".concat(JSON.stringify(csvConfig.parsedData || []), "\nconst columns = ").concat(JSON.stringify(configColumns), "\n\n").concat((0, utils_1.generateSafeJSONParseCode)(), "\n\n").concat((0, search_relevance_1.generateSearchRankHelperCode)(), "\n\n").concat((0, utils_1.generateDateFormatterCode)(), "\n\n").concat((0, utils_1.generateSortFilterHelperCode)(), "\n\n").concat((0, utils_1.generateFilterTreeHelpersCode)(), "\n\nexport default async function handler(req, res) {").concat(generateHandlerBody(), "\n}\n");
48
49
  }
49
50
  };
50
51
  exports.generateCSVFileFetcher = generateCSVFileFetcher;
@@ -1 +1 @@
1
- {"version":3,"file":"csv-file.js","sourceRoot":"","sources":["../../../src/fetchers/csv-file.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"csv-file.js","sourceRoot":"","sources":["../../../src/fetchers/csv-file.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"google-sheets.d.ts","sourceRoot":"","sources":["../../../src/fetchers/google-sheets.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,0BAA0B,WAC7B,OAAO,MAAM,EAAE,OAAO,CAAC,KAC9B;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAwBpC,CAAA;AAcD,eAAO,MAAM,2BAA2B,WAAY,OAAO,MAAM,EAAE,OAAO,CAAC,KAAG,MA8V7E,CAAA"}
1
+ {"version":3,"file":"google-sheets.d.ts","sourceRoot":"","sources":["../../../src/fetchers/google-sheets.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,0BAA0B,WAC7B,OAAO,MAAM,EAAE,OAAO,CAAC,KAC9B;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAwBpC,CAAA;AAcD,eAAO,MAAM,2BAA2B,WAAY,OAAO,MAAM,EAAE,OAAO,CAAC,KAAG,MAgW7E,CAAA"}
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateGoogleSheetsFetcher = exports.validateGoogleSheetsConfig = void 0;
4
4
  var utils_1 = require("../utils");
5
+ var search_relevance_1 = require("../search-relevance");
5
6
  var header_detection_1 = require("./utils/header-detection");
6
7
  var validateGoogleSheetsConfig = function (config) {
7
8
  if (!config || typeof config !== 'object') {
@@ -29,7 +30,7 @@ var generateGoogleSheetsFetcher = function (config) {
29
30
  var autoDetectHeader = sheetsConfig.autoDetectHeader;
30
31
  var firstRowIsHeader = sheetsConfig.firstRowIsHeader;
31
32
  var configColumns = sheetsConfig.columns || [];
32
- return "import fetch from 'node-fetch'\n\n".concat((0, utils_1.generateSafeJSONParseCode)(), "\n\n").concat((0, utils_1.generateSortFilterHelperCode)(), "\n\n").concat((0, utils_1.generateFilterTreeHelpersCode)(), "\n\n").concat((0, header_detection_1.generateHeaderDetectionCode)(), "\n\nexport default async function handler(req, res) {\n try {\n const sheetUrl = ").concat(JSON.stringify(sheetsConfig.sheetUrl), "\n let sheetId = ").concat(JSON.stringify(sheetsConfig.sheetId), "\n const range = ").concat(JSON.stringify(sheetsConfig.range || 'A1:Z1000'), "\n const maxRows = ").concat(sheetsConfig.maxRows || 0, "\n const autoDetectHeader = ").concat(autoDetectHeader !== undefined ? autoDetectHeader : true, "\n const firstRowIsHeader = ").concat(firstRowIsHeader !== undefined ? firstRowIsHeader : true, "\n \n if (!sheetId && sheetUrl) {\n const match = sheetUrl.match(/\\/d\\/([a-zA-Z0-9-_]+)/)\n sheetId = match ? match[1] : undefined\n }\n \n if (!sheetId) {\n return res.status(400).json({\n success: false,\n error: 'Invalid Google Sheets URL or Sheet ID',\n timestamp: Date.now()\n })\n }\n \n let gid = undefined\n if (sheetUrl) {\n const gidMatch = sheetUrl.match(/[#&]gid=([0-9]+)/)\n gid = gidMatch ? gidMatch[1] : undefined\n }\n \n let url = `https://docs.google.com/spreadsheets/d/${sheetId}/gviz/tq?tqx=out:json&range=${range}`\n \n if (gid) {\n url += `&gid=${gid}`\n }\n \n if (maxRows && maxRows > 0) {\n url += `&tq=limit ${maxRows}`\n }\n \n const response = await fetch(url)\n \n if (!response.ok) {\n return res.status(response.status).json({\n success: false,\n error: `HTTP ${response.status}: ${response.statusText}`,\n timestamp: Date.now()\n })\n }\n \n const text = await response.text()\n const jsonMatch = text.match(/google\\.visualization\\.Query\\.setResponse\\((.*)\\);/)\n \n if (!jsonMatch) {\n return res.status(500).json({\n success: false,\n error: 'Unable to parse Google Sheets response',\n timestamp: Date.now()\n })\n }\n \n const data = JSON.parse(jsonMatch[1])\n \n if (data.status === 'error') {\n return res.status(500).json({\n success: false,\n error: data.errors?.[0]?.detailed_message || 'Failed to fetch Google Sheets data',\n timestamp: Date.now()\n })\n }\n \n const formatDateValue = (date) => {\n const options = {\n year: 'numeric',\n month: 'short',\n day: 'numeric',\n }\n \n const timeOptions = {\n hour: '2-digit',\n minute: '2-digit',\n }\n \n const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0\n \n if (hasTime) {\n return date.toLocaleString('en-US', { ...options, ...timeOptions })\n }\n \n return date.toLocaleDateString('en-US', options)\n }\n \n const parseGoogleSheetsValue = (value) => {\n if (typeof value === 'string') {\n const dateMatch = value.match(/^Date\\((\\d+),(\\d+),(\\d+)(?:,(\\d+),(\\d+),(\\d+))?\\)$/)\n if (dateMatch) {\n const year = parseInt(dateMatch[1], 10)\n const month = parseInt(dateMatch[2], 10)\n const day = parseInt(dateMatch[3], 10)\n const hour = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0\n const minute = dateMatch[5] ? parseInt(dateMatch[5], 10) : 0\n const second = dateMatch[6] ? parseInt(dateMatch[6], 10) : 0\n const date = new Date(year, month, day, hour, minute, second)\n return formatDateValue(date)\n }\n }\n return value\n }\n \n const table = data.table\n const rawRows = table.rows || []\n \n if (rawRows.length === 0) {\n return res.status(200).json({\n success: true,\n data: [],\n timestamp: Date.now()\n })\n }\n \n const firstRow = rawRows[0]\n const firstRowValues = firstRow.c.map((cell) => cell?.v ?? cell?.f ?? null)\n \n const shouldAutoDetect = autoDetectHeader !== false\n let hasHeaderRow = false\n \n if (shouldAutoDetect) {\n hasHeaderRow = detectHeaderRow(firstRowValues, rawRows)\n } else {\n hasHeaderRow = firstRowIsHeader !== false\n }\n \n const configColumns = ").concat(JSON.stringify(configColumns), "\n const useConfigColumns = configColumns && configColumns.length > 0\n \n let columns\n let dataRows\n \n if (hasHeaderRow && rawRows.length > 1) {\n columns = table.cols.map((col, index) => {\n const configCol = useConfigColumns ? configColumns[index] : null\n return {\n id: configCol?.id || col.id || `col_${index}`,\n label: String(firstRowValues[index] || col.label || configCol?.label || `Column ${index + 1}`),\n type: configCol?.type || col.type || 'string'\n }\n })\n dataRows = rawRows.slice(1)\n } else {\n columns = table.cols.map((col, index) => {\n const configCol = useConfigColumns ? configColumns[index] : null\n return {\n id: configCol?.id || col.id || `col_${index}`,\n label: configCol?.label || col.label || `Column ${index + 1}`,\n type: configCol?.type || col.type || 'string'\n }\n })\n dataRows = rawRows\n }\n \n if (maxRows && dataRows.length > maxRows) {\n dataRows = dataRows.slice(0, maxRows)\n }\n \n const rows = dataRows.map((row) => {\n const rowData = {}\n row.c.forEach((cell, index) => {\n const columnId = columns[index]?.id || `col_${index}`\n const rawValue = cell?.v ?? cell?.f ?? null\n rowData[columnId] = parseGoogleSheetsValue(rawValue)\n })\n return rowData\n })\n \n const columnsWithData = columns.filter((col, index) => {\n const hasHeaderData = col.label && col.label !== `Column ${index + 1}`\n const hasDataInColumn = rows.some((row) => {\n const value = row[col.id]\n return value !== null && value !== undefined && value !== ''\n })\n return hasHeaderData || hasDataInColumn\n })\n \n const filteredRows = rows.map((row) => {\n const filteredRow = {}\n columnsWithData.forEach((col) => {\n filteredRow[col.id] = row[col.id]\n })\n return filteredRow\n })\n \n const { query, queryColumns, limit, page, perPage, sortBy, sortOrder, filters, sorts, offset: offsetParam } = req.query\n \n // Create label-to-ID mapping for filters and sorts\n const labelToIdMap = {}\n columnsWithData.forEach((col) => {\n labelToIdMap[col.label] = col.id\n })\n \n let filteredData = [...filteredRows]\n \n if (query) {\n const searchTerms = query.toLowerCase().split(' ').filter(Boolean)\n \n if (queryColumns) {\n const searchColumns = safeJSONParse(queryColumns)\n filteredData = filteredData.filter((item) => {\n return searchTerms.every((term) => searchColumns.some((col) => {\n // Map label to column ID\n const field = labelToIdMap[col] || col\n const value = getNestedValue(item, field)\n return value && String(value).toLowerCase().includes(term)\n }))\n })\n } else {\n filteredData = filteredData.filter((item) => {\n try {\n const stringified = JSON.stringify(item).toLowerCase()\n return searchTerms.every((term) => stringified.includes(term))\n } catch {\n return false\n }\n })\n }\n }\n \n if (filters) {\n const filterTree = normalizeFilterTree(safeJSONParse(filters))\n \n if (filterTree) {\n filteredData = filteredData.filter((item) =>\n evaluateFilterTree(filterTree, (condition) => {\n // Map label to column ID\n const field = labelToIdMap[condition.source] || condition.source\n const value = getNestedValue(item, field)\n const target = condition.destination\n const operand = condition.operand\n \n if (Array.isArray(target)) {\n if (operand === '!=') {\n return !target.includes(value)\n }\n return target.includes(value)\n }\n \n return compareValues(value, target, operand)\n })\n )\n }\n }\n \n if (sorts) {\n const parsedSorts = safeJSONParse(sorts)\n if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {\n filteredData.sort((a, b) => {\n for (const sort of parsedSorts) {\n if (!sort.field) continue\n // Map label to column ID\n const field = labelToIdMap[sort.field] || sort.field\n const aVal = getNestedValue(a, field)\n const bVal = getNestedValue(b, field)\n const sortOrderValue = (sort.order || '').toLowerCase().startsWith('desc') ? -1 : 1\n \n let comparison = 0\n if (aVal === null || aVal === undefined) {\n comparison = bVal === null || bVal === undefined ? 0 : -1\n } else if (bVal === null || bVal === undefined) {\n comparison = 1\n } else if (typeof aVal === 'number' && typeof bVal === 'number') {\n comparison = aVal - bVal\n } else if (aVal instanceof Date && bVal instanceof Date) {\n comparison = aVal.getTime() - bVal.getTime()\n } else {\n const aStr = String(aVal)\n const bStr = String(bVal)\n if (aStr < bStr) comparison = -1\n else if (aStr > bStr) comparison = 1\n }\n \n if (comparison !== 0) return comparison * sortOrderValue\n }\n return 0\n })\n }\n } else if (sortBy) {\n filteredData.sort((a, b) => {\n // Map label to column ID\n const field = labelToIdMap[sortBy] || sortBy\n const aVal = getNestedValue(a, field)\n const bVal = getNestedValue(b, field)\n const sortOrderValue = (sortOrder || '').toLowerCase().startsWith('desc') ? -1 : 1\n \n let comparison = 0\n if (aVal === null || aVal === undefined) {\n comparison = bVal === null || bVal === undefined ? 0 : -1\n } else if (bVal === null || bVal === undefined) {\n comparison = 1\n } else if (typeof aVal === 'number' && typeof bVal === 'number') {\n comparison = aVal - bVal\n } else if (aVal instanceof Date && bVal instanceof Date) {\n comparison = aVal.getTime() - bVal.getTime()\n } else {\n const aStr = String(aVal)\n const bStr = String(bVal)\n if (aStr < bStr) comparison = -1\n else if (aStr > bStr) comparison = 1\n }\n \n return comparison * sortOrderValue\n })\n }\n \n const limitValue = limit || perPage\n const offsetValue = offsetParam !== undefined ? parseInt(offsetParam) : (page && perPage ? (parseInt(page) - 1) * parseInt(perPage) : 0)\n \n if (limitValue) {\n filteredData = filteredData.slice(offsetValue, offsetValue + parseInt(limitValue))\n }\n \n const safeData = JSON.parse(JSON.stringify(filteredData))\n \n return res.status(200).json({\n success: true,\n data: safeData,\n timestamp: Date.now()\n })\n } catch (error) {\n console.error('Google Sheets fetch error:', error)\n return res.status(500).json({\n success: false,\n error: error.message || 'Failed to fetch data',\n timestamp: Date.now()\n })\n }\n}\n");
33
+ return "import fetch from 'node-fetch'\n\n".concat((0, utils_1.generateSafeJSONParseCode)(), "\n\n").concat((0, search_relevance_1.generateSearchRankHelperCode)(), "\n\n").concat((0, utils_1.generateSortFilterHelperCode)(), "\n\n").concat((0, utils_1.generateFilterTreeHelpersCode)(), "\n\n").concat((0, header_detection_1.generateHeaderDetectionCode)(), "\n\nexport default async function handler(req, res) {\n try {\n const sheetUrl = ").concat(JSON.stringify(sheetsConfig.sheetUrl), "\n let sheetId = ").concat(JSON.stringify(sheetsConfig.sheetId), "\n const range = ").concat(JSON.stringify(sheetsConfig.range || 'A1:Z1000'), "\n const maxRows = ").concat(sheetsConfig.maxRows || 0, "\n const autoDetectHeader = ").concat(autoDetectHeader !== undefined ? autoDetectHeader : true, "\n const firstRowIsHeader = ").concat(firstRowIsHeader !== undefined ? firstRowIsHeader : true, "\n \n if (!sheetId && sheetUrl) {\n const match = sheetUrl.match(/\\/d\\/([a-zA-Z0-9-_]+)/)\n sheetId = match ? match[1] : undefined\n }\n \n if (!sheetId) {\n return res.status(400).json({\n success: false,\n error: 'Invalid Google Sheets URL or Sheet ID',\n timestamp: Date.now()\n })\n }\n \n let gid = undefined\n if (sheetUrl) {\n const gidMatch = sheetUrl.match(/[#&]gid=([0-9]+)/)\n gid = gidMatch ? gidMatch[1] : undefined\n }\n \n let url = `https://docs.google.com/spreadsheets/d/${sheetId}/gviz/tq?tqx=out:json&range=${range}`\n \n if (gid) {\n url += `&gid=${gid}`\n }\n \n if (maxRows && maxRows > 0) {\n url += `&tq=limit ${maxRows}`\n }\n \n const response = await fetch(url)\n \n if (!response.ok) {\n return res.status(response.status).json({\n success: false,\n error: `HTTP ${response.status}: ${response.statusText}`,\n timestamp: Date.now()\n })\n }\n \n const text = await response.text()\n const jsonMatch = text.match(/google\\.visualization\\.Query\\.setResponse\\((.*)\\);/)\n \n if (!jsonMatch) {\n return res.status(500).json({\n success: false,\n error: 'Unable to parse Google Sheets response',\n timestamp: Date.now()\n })\n }\n \n const data = JSON.parse(jsonMatch[1])\n \n if (data.status === 'error') {\n return res.status(500).json({\n success: false,\n error: data.errors?.[0]?.detailed_message || 'Failed to fetch Google Sheets data',\n timestamp: Date.now()\n })\n }\n \n const formatDateValue = (date) => {\n const options = {\n year: 'numeric',\n month: 'short',\n day: 'numeric',\n }\n \n const timeOptions = {\n hour: '2-digit',\n minute: '2-digit',\n }\n \n const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0\n \n if (hasTime) {\n return date.toLocaleString('en-US', { ...options, ...timeOptions })\n }\n \n return date.toLocaleDateString('en-US', options)\n }\n \n const parseGoogleSheetsValue = (value) => {\n if (typeof value === 'string') {\n const dateMatch = value.match(/^Date\\((\\d+),(\\d+),(\\d+)(?:,(\\d+),(\\d+),(\\d+))?\\)$/)\n if (dateMatch) {\n const year = parseInt(dateMatch[1], 10)\n const month = parseInt(dateMatch[2], 10)\n const day = parseInt(dateMatch[3], 10)\n const hour = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0\n const minute = dateMatch[5] ? parseInt(dateMatch[5], 10) : 0\n const second = dateMatch[6] ? parseInt(dateMatch[6], 10) : 0\n const date = new Date(year, month, day, hour, minute, second)\n return formatDateValue(date)\n }\n }\n return value\n }\n \n const table = data.table\n const rawRows = table.rows || []\n \n if (rawRows.length === 0) {\n return res.status(200).json({\n success: true,\n data: [],\n timestamp: Date.now()\n })\n }\n \n const firstRow = rawRows[0]\n const firstRowValues = firstRow.c.map((cell) => cell?.v ?? cell?.f ?? null)\n \n const shouldAutoDetect = autoDetectHeader !== false\n let hasHeaderRow = false\n \n if (shouldAutoDetect) {\n hasHeaderRow = detectHeaderRow(firstRowValues, rawRows)\n } else {\n hasHeaderRow = firstRowIsHeader !== false\n }\n \n const configColumns = ").concat(JSON.stringify(configColumns), "\n const useConfigColumns = configColumns && configColumns.length > 0\n \n let columns\n let dataRows\n \n if (hasHeaderRow && rawRows.length > 1) {\n columns = table.cols.map((col, index) => {\n const configCol = useConfigColumns ? configColumns[index] : null\n return {\n id: configCol?.id || col.id || `col_${index}`,\n label: String(firstRowValues[index] || col.label || configCol?.label || `Column ${index + 1}`),\n type: configCol?.type || col.type || 'string'\n }\n })\n dataRows = rawRows.slice(1)\n } else {\n columns = table.cols.map((col, index) => {\n const configCol = useConfigColumns ? configColumns[index] : null\n return {\n id: configCol?.id || col.id || `col_${index}`,\n label: configCol?.label || col.label || `Column ${index + 1}`,\n type: configCol?.type || col.type || 'string'\n }\n })\n dataRows = rawRows\n }\n \n if (maxRows && dataRows.length > maxRows) {\n dataRows = dataRows.slice(0, maxRows)\n }\n \n const rows = dataRows.map((row) => {\n const rowData = {}\n row.c.forEach((cell, index) => {\n const columnId = columns[index]?.id || `col_${index}`\n const rawValue = cell?.v ?? cell?.f ?? null\n rowData[columnId] = parseGoogleSheetsValue(rawValue)\n })\n return rowData\n })\n \n const columnsWithData = columns.filter((col, index) => {\n const hasHeaderData = col.label && col.label !== `Column ${index + 1}`\n const hasDataInColumn = rows.some((row) => {\n const value = row[col.id]\n return value !== null && value !== undefined && value !== ''\n })\n return hasHeaderData || hasDataInColumn\n })\n \n const filteredRows = rows.map((row) => {\n const filteredRow = {}\n columnsWithData.forEach((col) => {\n filteredRow[col.id] = row[col.id]\n })\n return filteredRow\n })\n \n const { query, queryColumns, limit, page, perPage, sortBy, sortOrder, filters, sorts, offset: offsetParam } = req.query\n \n // Create label-to-ID mapping for filters and sorts\n const labelToIdMap = {}\n columnsWithData.forEach((col) => {\n labelToIdMap[col.label] = col.id\n })\n \n let filteredData = [...filteredRows]\n \n if (query) {\n if (queryColumns) {\n const searchColumns = safeJSONParse(queryColumns)\n filteredData = filteredData.filter((item) =>\n matchesSearch(item, query, searchColumns, (row, col) => getNestedValue(row, labelToIdMap[col] || col))\n )\n } else {\n const terms = searchTerms(query)\n filteredData = filteredData.filter((item) => {\n try {\n const text = searchText(item)\n return terms.every((term) => text.indexOf(term) !== -1)\n } catch {\n return false\n }\n })\n }\n }\n \n if (filters) {\n const filterTree = normalizeFilterTree(safeJSONParse(filters))\n \n if (filterTree) {\n filteredData = filteredData.filter((item) =>\n evaluateFilterTree(filterTree, (condition) => {\n // Map label to column ID\n const field = labelToIdMap[condition.source] || condition.source\n const value = getNestedValue(item, field)\n const target = condition.destination\n const operand = condition.operand\n \n if (Array.isArray(target)) {\n if (operand === '!=') {\n return !target.includes(value)\n }\n return target.includes(value)\n }\n \n return compareValues(value, target, operand)\n })\n )\n }\n }\n \n if (sorts) {\n const parsedSorts = safeJSONParse(sorts)\n if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {\n filteredData.sort((a, b) => {\n for (const sort of parsedSorts) {\n if (!sort.field) continue\n // Map label to column ID\n const field = labelToIdMap[sort.field] || sort.field\n const aVal = getNestedValue(a, field)\n const bVal = getNestedValue(b, field)\n const sortOrderValue = (sort.order || '').toLowerCase().startsWith('desc') ? -1 : 1\n \n let comparison = 0\n if (aVal === null || aVal === undefined) {\n comparison = bVal === null || bVal === undefined ? 0 : -1\n } else if (bVal === null || bVal === undefined) {\n comparison = 1\n } else if (typeof aVal === 'number' && typeof bVal === 'number') {\n comparison = aVal - bVal\n } else if (aVal instanceof Date && bVal instanceof Date) {\n comparison = aVal.getTime() - bVal.getTime()\n } else {\n const aStr = String(aVal)\n const bStr = String(bVal)\n if (aStr < bStr) comparison = -1\n else if (aStr > bStr) comparison = 1\n }\n \n if (comparison !== 0) return comparison * sortOrderValue\n }\n return 0\n })\n }\n } else if (sortBy) {\n filteredData.sort((a, b) => {\n // Map label to column ID\n const field = labelToIdMap[sortBy] || sortBy\n const aVal = getNestedValue(a, field)\n const bVal = getNestedValue(b, field)\n const sortOrderValue = (sortOrder || '').toLowerCase().startsWith('desc') ? -1 : 1\n \n let comparison = 0\n if (aVal === null || aVal === undefined) {\n comparison = bVal === null || bVal === undefined ? 0 : -1\n } else if (bVal === null || bVal === undefined) {\n comparison = 1\n } else if (typeof aVal === 'number' && typeof bVal === 'number') {\n comparison = aVal - bVal\n } else if (aVal instanceof Date && bVal instanceof Date) {\n comparison = aVal.getTime() - bVal.getTime()\n } else {\n const aStr = String(aVal)\n const bStr = String(bVal)\n if (aStr < bStr) comparison = -1\n else if (aStr > bStr) comparison = 1\n }\n \n return comparison * sortOrderValue\n })\n }\n \n if (query && queryColumns) {\n filteredData = rankBySearch(filteredData, query, safeJSONParse(queryColumns), (item, col) =>\n getNestedValue(item, labelToIdMap[col] || col)\n )\n }\n \n const limitValue = limit || perPage\n const offsetValue = offsetParam !== undefined ? parseInt(offsetParam) : (page && perPage ? (parseInt(page) - 1) * parseInt(perPage) : 0)\n \n if (limitValue) {\n filteredData = filteredData.slice(offsetValue, offsetValue + parseInt(limitValue))\n }\n \n const safeData = JSON.parse(JSON.stringify(filteredData))\n \n return res.status(200).json({\n success: true,\n data: safeData,\n timestamp: Date.now()\n })\n } catch (error) {\n console.error('Google Sheets fetch error:', error)\n return res.status(500).json({\n success: false,\n error: error.message || 'Failed to fetch data',\n timestamp: Date.now()\n })\n }\n}\n");
33
34
  };
34
35
  exports.generateGoogleSheetsFetcher = generateGoogleSheetsFetcher;
35
36
  //# sourceMappingURL=google-sheets.js.map