@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.
- package/__tests__/browser-row-policy.test.ts +6 -6
- package/__tests__/fetchers.test.ts +1 -1
- package/__tests__/items-path.test.ts +3 -2
- package/__tests__/search-relevance.test.ts +407 -0
- package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
- package/dist/cjs/fetchers/csv-file.js +4 -3
- package/dist/cjs/fetchers/csv-file.js.map +1 -1
- package/dist/cjs/fetchers/google-sheets.d.ts.map +1 -1
- package/dist/cjs/fetchers/google-sheets.js +2 -1
- package/dist/cjs/fetchers/google-sheets.js.map +1 -1
- package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
- package/dist/cjs/fetchers/javascript.js +2 -1
- package/dist/cjs/fetchers/javascript.js.map +1 -1
- package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
- package/dist/cjs/fetchers/mariadb.js +4 -3
- package/dist/cjs/fetchers/mariadb.js.map +1 -1
- package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
- package/dist/cjs/fetchers/mysql.js +3 -2
- package/dist/cjs/fetchers/mysql.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +4 -3
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
- package/dist/cjs/fetchers/redshift.js +3 -2
- package/dist/cjs/fetchers/redshift.js.map +1 -1
- package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
- package/dist/cjs/fetchers/rest-api.js +3 -2
- package/dist/cjs/fetchers/rest-api.js.map +1 -1
- package/dist/cjs/fetchers/supabase.d.ts.map +1 -1
- package/dist/cjs/fetchers/supabase.js +3 -2
- package/dist/cjs/fetchers/supabase.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +5 -4
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/turso.d.ts.map +1 -1
- package/dist/cjs/fetchers/turso.js +2 -1
- package/dist/cjs/fetchers/turso.js.map +1 -1
- package/dist/cjs/search-relevance.d.ts +81 -0
- package/dist/cjs/search-relevance.d.ts.map +1 -0
- package/dist/cjs/search-relevance.js +119 -0
- package/dist/cjs/search-relevance.js.map +1 -0
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/fetchers/csv-file.d.ts.map +1 -1
- package/dist/esm/fetchers/csv-file.js +4 -3
- package/dist/esm/fetchers/csv-file.js.map +1 -1
- package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
- package/dist/esm/fetchers/google-sheets.js +2 -1
- package/dist/esm/fetchers/google-sheets.js.map +1 -1
- package/dist/esm/fetchers/javascript.d.ts.map +1 -1
- package/dist/esm/fetchers/javascript.js +2 -1
- package/dist/esm/fetchers/javascript.js.map +1 -1
- package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
- package/dist/esm/fetchers/mariadb.js +4 -3
- package/dist/esm/fetchers/mariadb.js.map +1 -1
- package/dist/esm/fetchers/mysql.d.ts.map +1 -1
- package/dist/esm/fetchers/mysql.js +3 -2
- package/dist/esm/fetchers/mysql.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +4 -3
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/redshift.d.ts.map +1 -1
- package/dist/esm/fetchers/redshift.js +3 -2
- package/dist/esm/fetchers/redshift.js.map +1 -1
- package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
- package/dist/esm/fetchers/rest-api.js +3 -2
- package/dist/esm/fetchers/rest-api.js.map +1 -1
- package/dist/esm/fetchers/supabase.d.ts.map +1 -1
- package/dist/esm/fetchers/supabase.js +3 -2
- package/dist/esm/fetchers/supabase.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +5 -4
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/turso.d.ts.map +1 -1
- package/dist/esm/fetchers/turso.js +2 -1
- package/dist/esm/fetchers/turso.js.map +1 -1
- package/dist/esm/search-relevance.d.ts +81 -0
- package/dist/esm/search-relevance.d.ts.map +1 -0
- package/dist/esm/search-relevance.js +113 -0
- package/dist/esm/search-relevance.js.map +1 -0
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/fetchers/csv-file.ts +17 -11
- package/src/fetchers/google-sheets.ts +15 -12
- package/src/fetchers/javascript.ts +12 -12
- package/src/fetchers/mariadb.ts +21 -26
- package/src/fetchers/mysql.ts +21 -26
- package/src/fetchers/postgresql.ts +31 -26
- package/src/fetchers/redshift.ts +18 -17
- package/src/fetchers/rest-api.ts +11 -11
- package/src/fetchers/supabase.ts +27 -20
- package/src/fetchers/teleport.ts +31 -26
- package/src/fetchers/turso.ts +18 -17
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
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,
|
|
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&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":"
|
|
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
|
|
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":";;;AAAA,kCAKiB;AACjB,6DAAsE;AAE/D,IAAM,iBAAiB,GAAG,UAC/B,MAA+B;IAE/B,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE;QACzC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,+BAA+B,EAAE,CAAA;KAClE;IAED,IAAI,CAAC,MAAM,CAAC,WAAW,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE;QAC7C,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,mDAAmD,EAAE,CAAA;KACtF;IAED,IAAI,MAAM,CAAC,WAAW,IAAI,OAAO,MAAM,CAAC,WAAW,KAAK,QAAQ,EAAE;QAChE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,+BAA+B,EAAE,CAAA;KAClE;IAED,IAAI,MAAM,CAAC,UAAU,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE;QAC1D,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,8BAA8B,EAAE,CAAA;KACjE;IAED,IAAI,MAAM,CAAC,OAAO,KAAK,SAAS,EAAE;QAChC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE;YAClC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,qCAAqC,EAAE,CAAA;SACxE;QAED,KAAqB,UAAc,EAAd,KAAA,MAAM,CAAC,OAAO,EAAd,cAAc,EAAd,IAAc,EAAE;YAAhC,IAAM,MAAM,SAAA;YACf,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,MAAM,CAAC,EAAE,KAAK,QAAQ,EAAE;gBACxF,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,kCAAkC,EAAE,CAAA;aACrE;SACF;KACF;IAED,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAC1B,CAAC,CAAA;AAhCY,QAAA,iBAAiB,qBAgC7B;AAUD,IAAM,mBAAmB,GAAG;IAC1B,OAAO,
|
|
1
|
+
{"version":3,"file":"csv-file.js","sourceRoot":"","sources":["../../../src/fetchers/csv-file.ts"],"names":[],"mappings":";;;AAAA,kCAKiB;AACjB,wDAAkE;AAClE,6DAAsE;AAE/D,IAAM,iBAAiB,GAAG,UAC/B,MAA+B;IAE/B,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE;QACzC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,+BAA+B,EAAE,CAAA;KAClE;IAED,IAAI,CAAC,MAAM,CAAC,WAAW,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE;QAC7C,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,mDAAmD,EAAE,CAAA;KACtF;IAED,IAAI,MAAM,CAAC,WAAW,IAAI,OAAO,MAAM,CAAC,WAAW,KAAK,QAAQ,EAAE;QAChE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,+BAA+B,EAAE,CAAA;KAClE;IAED,IAAI,MAAM,CAAC,UAAU,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE;QAC1D,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,8BAA8B,EAAE,CAAA;KACjE;IAED,IAAI,MAAM,CAAC,OAAO,KAAK,SAAS,EAAE;QAChC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE;YAClC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,qCAAqC,EAAE,CAAA;SACxE;QAED,KAAqB,UAAc,EAAd,KAAA,MAAM,CAAC,OAAO,EAAd,cAAc,EAAd,IAAc,EAAE;YAAhC,IAAM,MAAM,SAAA;YACf,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,MAAM,CAAC,EAAE,KAAK,QAAQ,EAAE;gBACxF,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,kCAAkC,EAAE,CAAA;aACrE;SACF;KACF;IAED,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAC1B,CAAC,CAAA;AAhCY,QAAA,iBAAiB,qBAgC7B;AAUD,IAAM,mBAAmB,GAAG;IAC1B,OAAO,ijKA+IR,CAAA;AACD,CAAC,CAAA;AAEM,IAAM,sBAAsB,GAAG,UAAC,MAA+B;IACpE,IAAM,SAAS,GAAG,MAAuB,CAAA;IAEzC,0EAA0E;IAC1E,IAAM,gBAAgB,GAAG,SAAS,CAAC,gBAAgB,KAAK,KAAK,CAAA;IAC7D,IAAM,gBAAgB,GAAG,SAAS,CAAC,gBAAgB,KAAK,KAAK,CAAA;IAC7D,IAAM,aAAa,GAAG,SAAS,CAAC,OAAO,IAAI,EAAE,CAAA;IAE7C,IAAI,SAAS,CAAC,WAAW,EAAE;QACzB,IAAM,WAAW,GAAG,SAAS,CAAC,WAAW,CAAA;QACzC,OAAO,6BAAsB,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,wCACjC,gBAAgB,wCAChB,gBAAgB,qCACnB,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,iBAEnD,IAAA,iCAAyB,GAAE,iBAE3B,IAAA,+CAA4B,GAAE,iBAE9B,IAAA,iCAAyB,GAAE,iBAE3B,IAAA,oCAA4B,GAAE,iBAE9B,IAAA,qCAA6B,GAAE,iBAE/B,IAAA,8CAA2B,GAAE,iiFA+FoB,mBAAmB,EAAE,UAEvE,CAAA;KACE;SAAM;QACL,OAAO,uBAAgB,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,UAAU,IAAI,EAAE,CAAC,+BACnD,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,iBAE7C,IAAA,iCAAyB,GAAE,iBAE3B,IAAA,+CAA4B,GAAE,iBAE9B,IAAA,iCAAyB,GAAE,iBAE3B,IAAA,oCAA4B,GAAE,iBAE9B,IAAA,qCAA6B,GAAE,kEAEkB,mBAAmB,EAAE,UAEvE,CAAA;KACE;AACH,CAAC,CAAA;AA7IY,QAAA,sBAAsB,0BA6IlC;AAED,yCAAyC;AAClC,IAAM,uBAAuB,GAAG,UAAC,OAAY;IAClD,OAAO,knCA6CR,CAAA;AACD,CAAC,CAAA;AA/CY,QAAA,uBAAuB,2BA+CnC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"google-sheets.d.ts","sourceRoot":"","sources":["../../../src/fetchers/google-sheets.ts"],"names":[],"mappings":"
|
|
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
|