@rokkit/data 1.0.0-next.100

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/src/list.js ADDED
@@ -0,0 +1,232 @@
1
+ import { writable, get } from 'svelte/store'
2
+ import { ascending } from 'd3-array'
3
+ import { nest } from 'd3-collection'
4
+ import { v4 as uuid } from '@lukeed/uuid'
5
+
6
+ /**
7
+ * Compares two items for sorting. When grouped, uses group comparison before item comparison
8
+ *
9
+ * @param {*} a first item
10
+ * @param {*} b second item
11
+ * @param {*} data
12
+ * @returns
13
+ */
14
+ export function compare(a, b, data) {
15
+ let result = 0
16
+
17
+ if (data.groupKey) {
18
+ result = ascending(data.lookup[a[data.groupKey]], data.lookup[b[data.groupKey]])
19
+ }
20
+ if (result === 0) {
21
+ result = ascending(a[data.sortKey], b[data.sortKey])
22
+ }
23
+ return result
24
+ }
25
+
26
+ /**
27
+ * Search the data for existence of string in the data
28
+ *
29
+ * @param {List} data
30
+ * @param {string} value
31
+ * @returns
32
+ */
33
+ export function quickSearch(data, value) {
34
+ return data.data.filter((d) => d[data.filterKey].toLowerCase().includes(value.toLowerCase()))
35
+ }
36
+
37
+ /**
38
+ * Implements a list containing data and functionality for sorting, filtering, and grouping.
39
+ */
40
+ export class List {
41
+ /**
42
+ * Creates a List instance with initial data and default settings for keys and functions.
43
+ * @param {Array<object>} data - The initial array of objects to populate the list.
44
+ */
45
+ constructor(data) {
46
+ this.data = data
47
+
48
+ this.primaryKey = 'id'
49
+ this.filterKey = undefined
50
+ this.sortKey = undefined
51
+ this.groupKey = undefined
52
+ this.lookup = {}
53
+ this.searchText = ''
54
+
55
+ this.filterUsing = undefined
56
+ this.sortUsing = undefined
57
+ this.filtered = writable(data)
58
+ }
59
+
60
+ /**
61
+ * Sets a primary key attribute for list items and assigns a unique identifier if not present.
62
+ *
63
+ * @param {string} value - The property name to be set as the primary key.
64
+ * @returns {List} This list instance for method chaining.
65
+ */
66
+ key(value) {
67
+ this.primaryKey = value
68
+ this.data = this.data.map((item) => (value in item ? item : { ...item, [value]: uuid() }))
69
+
70
+ return this
71
+ }
72
+
73
+ /**
74
+ * Configures sorting by a given key and sorting function.
75
+ *
76
+ * @param {string} key - The property name to sort by.
77
+ * @param {Function} [using=compare] - The function used to sort the list items.
78
+ * @returns {List} This list instance for method chaining.
79
+ */
80
+ sortBy(key, using = compare) {
81
+ this.sortKey = key
82
+ this.sortUsing = using
83
+
84
+ return this.sort().refresh()
85
+ }
86
+
87
+ /**
88
+ * Groups list items by a given key and maps group keys for display using an optional lookup object.
89
+ *
90
+ * @param {string} key - The property name to group by.
91
+ * @param {object|null} [lookup=null] - An object mapping group keys to display names.
92
+ * @returns {List} This list instance for method chaining.
93
+ */
94
+ groupBy(key, lookup = null) {
95
+ this.groupKey = key
96
+
97
+ if (lookup) this.lookup = lookup
98
+ else
99
+ this.lookup = this.data
100
+ .map((item) => item[key])
101
+ .reduce((obj, value) => ({ ...obj, [value]: value }), {})
102
+
103
+ return this.sort().refresh()
104
+ }
105
+
106
+ /**
107
+ * Sets the key and function for filtering list items.
108
+ *
109
+ * @param {string} key - The property name to filter by.
110
+ * @param {Function} [using=quickSearch] - The function used to filter the list items.
111
+ * @returns {List} This list instance for method chaining.
112
+ */
113
+ filterBy(key, using = quickSearch) {
114
+ this.filterKey = key
115
+ this.filterUsing = using
116
+ return this
117
+ }
118
+
119
+ /**
120
+ * Sorts the list based on the configured sorting function.
121
+ *
122
+ * @returns {List} This list instance for method chaining.
123
+ */
124
+ sort() {
125
+ if (this.sortUsing) this.data.sort((a, b) => this.sortUsing(a, b, this))
126
+ return this
127
+ }
128
+
129
+ /**
130
+ * Adds a new item to the list, assigning a primary key if necessary, and then sorts and refreshes the list.
131
+ *
132
+ * @param {object} item - The item to add to the list.
133
+ * @returns {List} This list instance for method chaining.
134
+ */
135
+ add(item) {
136
+ const index = this.data.findIndex((d) => d[this.primaryKey] === item[this.primaryKey])
137
+ if (index === -1) {
138
+ if (!(this.primaryKey in item)) {
139
+ item[this.primaryKey] = uuid()
140
+ }
141
+ this.data.push(item)
142
+ this.sort().refresh()
143
+ }
144
+ return this
145
+ }
146
+
147
+ /**
148
+ * Removes an item from the list by primary key and refreshes the list.
149
+ *
150
+ * @param {object} item - The item to remove from the list.
151
+ * @returns {List} This list instance for method chaining.
152
+ */
153
+ remove(item) {
154
+ const index = this.data.findIndex((d) => d[this.primaryKey] === item[this.primaryKey])
155
+ if (index > -1) {
156
+ this.data = [...this.data.slice(0, index), ...this.data.slice(index + 1)]
157
+ this.refresh()
158
+ }
159
+ return this
160
+ }
161
+
162
+ /**
163
+ * Modifies an existing item in the list, identified by primary key, and then sorts and refreshes the list.
164
+ *
165
+ * @param {object} item - The item to modify in the list.
166
+ * @returns {List} This list instance for method chaining.
167
+ */
168
+ modify(item) {
169
+ const index = this.data.findIndex((d) => d[this.primaryKey] === item[this.primaryKey])
170
+ if (index > -1) {
171
+ this.data[index] = { ...item }
172
+ this.sort().refresh()
173
+ }
174
+ return this
175
+ }
176
+
177
+ /**
178
+ * Sets the text to search for in the list's items and then refreshes the list.
179
+ *
180
+ * @param {string} text - The text to search for.
181
+ * @returns {List} This list instance for method chaining.
182
+ */
183
+ search(text) {
184
+ this.searchText = text
185
+ return this.refresh()
186
+ }
187
+
188
+ /**
189
+ * Refreshes the list based on current sorting, grouping, and filtering configuration.
190
+ *
191
+ * @returns {List} This list instance for method chaining.
192
+ */
193
+ refresh() {
194
+ let data = [...this.data]
195
+
196
+ if (this.filterKey && this.searchText) {
197
+ data = this.filterUsing(this, this.searchText)
198
+ }
199
+
200
+ if (this.groupKey) {
201
+ data = nest()
202
+ .key((d) => d[this.groupKey])
203
+ .entries(data)
204
+ .map(({ key, values }) => ({
205
+ key,
206
+ name: this.lookup[key],
207
+ data: values
208
+ }))
209
+ }
210
+
211
+ this.filtered.set(data)
212
+ return this
213
+ }
214
+ /**
215
+ * Gets the current state of the filtered list data.
216
+ *
217
+ * @returns {Array<object>} The current filtered data of the list.
218
+ */
219
+ current() {
220
+ return get(this.filtered)
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Create a new list
226
+ *
227
+ * @param {Array<object>} data
228
+ * @returns
229
+ */
230
+ export function list(data) {
231
+ return new List(data)
232
+ }
@@ -0,0 +1,106 @@
1
+ import { defaultPathOptions } from './constants'
2
+ import { getDeepScanSample } from './infer'
3
+ import { typeOf } from './utils'
4
+
5
+ /**
6
+ * Merges currency-related attributes into the corresponding base columns.
7
+ *
8
+ * @param {Array<Object>} input - An array of column definitions.
9
+ * @param {string} currencySuffix - The suffix used to identify currency columns.
10
+ * @returns {Array<Object>} A new array of merged column definitions.
11
+ */
12
+ function mergeCurrencyAttributes(input, currencySuffix) {
13
+ let currencyColumns = input.filter((col) => col.name.endsWith(currencySuffix))
14
+ const columns = input.filter((col) => !col.name.endsWith(currencySuffix))
15
+
16
+ currencyColumns = currencyColumns
17
+ .map((col) => {
18
+ const matched = columns.find((column) => column.name === col.name.replace(currencySuffix, ''))
19
+ if (matched) {
20
+ matched.fields.currency = col.name
21
+ matched.type = 'currency'
22
+ matched.digits = 2
23
+ return null
24
+ }
25
+ return col
26
+ })
27
+ .filter((col) => col !== null)
28
+
29
+ return [...columns, ...currencyColumns]
30
+ }
31
+
32
+ /**
33
+ * Adds a path modifier to column definitions that match a specific path.
34
+ *
35
+ * @param {Array<Object>} columns - The original array of column definitions.
36
+ * @param {string|null} path - The path used to identify columns that require path modifiers.
37
+ * @param {string} separator - The separator used in conjunction with the path.
38
+ * @returns {Array<Object>} The array of column definitions with path modifiers added as needed.
39
+ */
40
+ export function addPathModifier(columns, path, separator) {
41
+ if (!path) return columns
42
+
43
+ const index = columns.findIndex((col) => col.name === path)
44
+ if (index > -1) {
45
+ return [
46
+ { ...columns[index], path: true, separator },
47
+ ...columns.slice(0, index),
48
+ ...columns.slice(index + 1)
49
+ ]
50
+ }
51
+ return columns
52
+ }
53
+
54
+ /**
55
+ * Derives column properties for a given sample and options.
56
+ *
57
+ * @param {Object} sample - The sample object used to derive column properties.
58
+ * @param {Object} options - The options used to modify column properties.
59
+ * It can specify path, separator, and currencySuffix for column customization.
60
+ * @returns {Array<Object>} An array of derived column definitions.
61
+ */
62
+ export function deriveColumnProperties(sample, options) {
63
+ const { path, separator, currencySuffix } = { ...defaultPathOptions, ...options }
64
+
65
+ let columns = Object.keys(sample).map((key) => ({
66
+ name: key,
67
+ type: typeOf(sample[key]),
68
+ sortable: true,
69
+ filterable: true,
70
+ sorted: 'none',
71
+ fields: { text: key }
72
+ }))
73
+
74
+ columns = addPathModifier(columns, path, separator)
75
+ return mergeCurrencyAttributes(columns, currencySuffix)
76
+ }
77
+
78
+ /**
79
+ * Derives the column metadata from the provided data and options.
80
+ *
81
+ * @param {Array} data - The data to derive the column metadata from.
82
+ * @param {Object} [options] - Optional parameters to control the metadata derivation.
83
+ * @param {Array} [options.metadata] - The metadata to use instead of deriving it from the data.
84
+ * @param {boolean} [options.deepScan=false] - Determines if the metadata derivation should perform a deep scan of the data.
85
+ *
86
+ * @returns {import('./types').Metadata} The derived column metadata.
87
+ */
88
+ export function deriveColumnMetadata(data, options = {}) {
89
+ const { metadata, deepScan = false } = options
90
+ if (Array.isArray(metadata) && metadata.length > 0) return metadata
91
+ if (data.length === 0) return []
92
+ const sample = deepScan ? getDeepScanSample(data) : data[0]
93
+ return Object.entries(sample).map(([name, value]) => ({ name, type: typeOf(value) }))
94
+ }
95
+
96
+ /**
97
+ * Derive column index from metadata.
98
+ * @param {Array} metadata - The metadata array.
99
+ * @returns {Object} - The column index object.
100
+ */
101
+ // export function deriveColumnIndex(metadata) {
102
+ // return metadata.reduce((acc, col, index) => {
103
+ // acc[col.name] = index
104
+ // return acc
105
+ // }, {})
106
+ // }
package/src/model.js ADDED
@@ -0,0 +1,100 @@
1
+ import { identity, clone as copy } from 'ramda'
2
+ import { getDeepScanSample } from './infer'
3
+ import { typeOf } from './utils'
4
+
5
+ /**
6
+ * A model is a representation of a dataset
7
+ * @returns an object with methods to manipulate the model
8
+ */
9
+ export function model() {
10
+ let data = []
11
+
12
+ const actions = {
13
+ get: () => data,
14
+ /*
15
+ * Creates a clone from another model
16
+ * @param {Object} other - the other model object to clone from
17
+ */
18
+ clone: (other) => {
19
+ data = copy(other.get())
20
+ return actions
21
+ },
22
+ /**
23
+ * Renames the reference names using a renamer
24
+ * @param {Function} rename - the renamer function
25
+ */
26
+ renameUsing: (rename = identity) => {
27
+ if (rename !== identity) {
28
+ data = data.map((x) => ({ ...x, name: rename(x.name) }))
29
+ }
30
+ return actions
31
+ },
32
+ /**
33
+ * Analyzes the input data and derives a model from it
34
+ *
35
+ * @param {Array|Object} value - the data to derive the model from
36
+ * @param {boolean} sparseData - indicates that rows may have missing attributes
37
+ * @returns {Object} this
38
+ */
39
+ from: (value, sparseData) => {
40
+ data = deriveModel(value, sparseData)
41
+ return actions
42
+ },
43
+ /**
44
+ * Merges the model with another model
45
+ *
46
+ * @param {Array} other - the model to merge with
47
+ * @param {Boolean} override - whether to override the type of the first model
48
+ * @returns {Object} this
49
+ */
50
+ merge: (other, override = false) => {
51
+ data = mergeModels(data, other.get(), override)
52
+ return actions
53
+ }
54
+ }
55
+ return actions
56
+ }
57
+
58
+ /**
59
+ * Derives a model from the input data
60
+ *
61
+ * @param {Array|Object} data - the data to derive the model from
62
+ * @param {Boolean} sparse - whether the data set contains sparse data
63
+ * @returns {Array} - the derived model
64
+ */
65
+ function deriveModel(data, sparse = false) {
66
+ const result = []
67
+ let item = data
68
+
69
+ if (Array.isArray(data)) item = sparse ? getDeepScanSample(data) : data[0]
70
+
71
+ const kv = Object.entries(item)
72
+ kv.forEach(([key, value]) => {
73
+ result.push({
74
+ name: key,
75
+ type: typeOf(value)
76
+ })
77
+ })
78
+ return result
79
+ }
80
+
81
+ /**
82
+ * Merges two models, returning a new model
83
+ *
84
+ * @param {Array} first - the first model
85
+ * @param {Array} second - the second model
86
+ * @param {Boolean} override - whether to override the type of the first model
87
+ * @returns {Array} - the merged model
88
+ */
89
+ function mergeModels(first, second, override = false) {
90
+ second.forEach(({ name, type }) => {
91
+ const existing = first.find((x) => x.name === name)
92
+ if (existing) {
93
+ if (existing.type !== type) existing.mixedTypes = true
94
+ if (override) existing.type = type
95
+ } else {
96
+ first.push({ name, type })
97
+ }
98
+ })
99
+ return first
100
+ }
package/src/parser.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Constructs a regular expression pattern for matching search filter strings.
3
+ * The pattern captures "column", "operator", and "value" groups for filter expressions.
4
+ * Supported operators include common comparison and wildcard operators.
5
+ *
6
+ * Examples of valid expressions this regex can match:
7
+ * - "username:john_doe"
8
+ * - "age>30"
9
+ * - "status!=active"
10
+ * - "title~\"Product Manager\""
11
+ * - "completed!~*yes"
12
+ *
13
+ * The "column" group matches one or more word characters.
14
+ * The "operator" group matches one among the specified comparison or wildcard operators:
15
+ * :, >, <, >=, <=, =<, =>, =, !=, ~, ~*, !~, !~*.
16
+ * The "value" group matches either a double-quoted string or a single unquoted word
17
+ * that doesn't include whitespace or any of the operator characters.
18
+ *
19
+ * @returns {RegExp} - The regular expression pattern to identify search filter elements.
20
+ */
21
+ export function getRegex() {
22
+ const column = '[\\w]+'
23
+ const operator = ':|>|<|>=|<=|=<|=>|=|!=|~|~\\*|!~|!~\\*'
24
+ const value = '("[^"]+"|[^\\s=:<>!~*]+)'
25
+
26
+ const pattern = `(?<group>((?<column>${column})\\s?(?<operator>${operator})\\s?)(?<value>${value}))`
27
+
28
+ return new RegExp(pattern, 'gm')
29
+ }
30
+
31
+ /**
32
+ * Parses a search string and extracts filters based on a predefined regular expression pattern.
33
+ * Each filter captures column, operator, and value components. Any remaining text that
34
+ * does not fit the pattern is considered a general search term.
35
+ *
36
+ * @param {string} string - The search string to parse for filters.
37
+ * @returns {Object[]} - An array of filter objects each containing the column, operator, and value,
38
+ * plus an additional object for general search if there is remaining text.
39
+ */
40
+ export function parseFilters(string) {
41
+ const results = []
42
+ const regex = getRegex()
43
+
44
+ // Split the string into an array of tokens
45
+ const tokens = string.matchAll(regex)
46
+ let search = string
47
+
48
+ // Iterate over the tokens
49
+ for (const token of tokens) {
50
+ // Extract the named groups from the token
51
+ const { group, column } = token.groups
52
+ let { operator, value } = token.groups
53
+ search = search.replace(group, '').trim()
54
+
55
+ operator = replaceOperators(operator)
56
+ value = processValue(value, operator)
57
+
58
+ if (column && value) results.push({ column, operator, value })
59
+ }
60
+
61
+ if (search.length > 0) {
62
+ results.push({
63
+ operator: '~*',
64
+ value: new RegExp(removeQuotes(search), 'i')
65
+ })
66
+ }
67
+
68
+ return results
69
+ }
70
+
71
+ /**
72
+ * Replaces various shorthand operators with their standardized equivalent for filtering.
73
+ *
74
+ * @param {string} operator - The operator to be replaced.
75
+ * @returns {string} - The replaced operator string.
76
+ */
77
+ function replaceOperators(operator) {
78
+ return operator.replace(':', '~*').replace('=>', '>=').replace('=<', '<=')
79
+ }
80
+
81
+ /**
82
+ * Processes the filter value provided, converting it into a format suitable for filtering.
83
+ * If the operator includes a tilde (~), the value is converted to a RegExp.
84
+ *
85
+ * @param {string|number} value - The value to be processed. It could be a string needing quote removal or a number that needs parsing.
86
+ * @param {string} operator - The operator that determines the type of processing to be applied to the value.
87
+ * @returns {string|number|RegExp} - The processed value, which may be a RegExp if the operator involves pattern matching.
88
+ */
89
+ function processValue(value, operator) {
90
+ // If the value can be parsed as an integer, do so. Otherwise, strip quotes if present.
91
+
92
+ value = !isNaN(parseInt(value)) ? parseInt(value) : removeQuotes(value)
93
+ // If the operator includes a tilde (~), treat the value as a regular expression,
94
+ // case-insensitive if '*' is present, sensitive otherwise.
95
+ if (operator.includes('~')) {
96
+ value = operator.includes('*') ? new RegExp(value, 'i') : new RegExp(value)
97
+ }
98
+
99
+ return value
100
+ }
101
+
102
+ /**
103
+ * Removes double quotes from the start and end of a string.
104
+ *
105
+ * @param {string} str - The input string from which the surrounding quotes should be removed.
106
+ * @returns {string} - The unquoted string.
107
+ */
108
+ function removeQuotes(str) {
109
+ const quoteMatch = str.match(/^"([^"]+)"$/)
110
+ return quoteMatch ? quoteMatch[1] : str
111
+ }
package/src/renamer.js ADDED
@@ -0,0 +1,98 @@
1
+ import { omit, identity } from 'ramda'
2
+
3
+ /**
4
+ *
5
+ *
6
+ * @typedef {Object} OptionsToRenameKeys
7
+ * @property {string} [prefix] - The prefix to add to the key.
8
+ * @property {string} [suffix] - The suffix to add to the key.
9
+ * @property {string} [separator='_'] - The separator to use between the key and the prefix or suffix.
10
+ * @property {Array} [keys] - The keys to rename.
11
+ */
12
+
13
+ /**
14
+ * A generator function that produces a function to rename keys.
15
+ *
16
+ * @param {OptionsToRenameKeys} options - Options to rename keys
17
+ * @returns {Object} - A renamer object.
18
+ *
19
+ * Example:
20
+ *
21
+ * renamer({ prefix: 'a', keys: ['a', 'b'] }).get()
22
+ * renamer({ prefix: 'a', separator: '-' }).get()
23
+ * renamer().setPrefix('a')
24
+ * .setSeparator('-')
25
+ * .setKeys(['a', 'b'])
26
+ * .get()
27
+ * renamer().setPrefix('a').get()
28
+ * renamer().setSuffix('b').get()
29
+ * renamer().setKeys(['a', 'b']).get()
30
+ */
31
+ export function renamer(options = {}) {
32
+ const { prefix, suffix, separator = '_', keys = [] } = options
33
+ const rename = getRenamer(prefix, suffix, separator)
34
+ const renameObject = getObjectRenamer(rename, keys)
35
+
36
+ return {
37
+ get: () => ({ rename, renameObject }),
38
+ setPrefix: (value) => renamer({ ...omit(['suffix'], options), prefix: value }),
39
+ setSuffix: (value) => renamer({ ...omit(['prefix'], options), suffix: value }),
40
+ setKeys: (value) => renamer({ ...options, keys: value }),
41
+ setSeparator: (value) => renamer({ ...options, separator: value })
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Generates a renamer function which adds a prefix or suffix to a string.
47
+ *
48
+ * @param {string} prefix - The prefix to add to the key.
49
+ * @param {string} suffix - The suffix to add to the key.
50
+ * @param {string} separator - The separator to use between the key and the prefix or suffix.
51
+ * @returns {Function} - A function that takes a string and adds a prefix or suffix.
52
+ */
53
+ function getRenamer(prefix, suffix, separator) {
54
+ let rename = identity
55
+
56
+ if (prefix) {
57
+ rename = (x) => [prefix, x].join(separator)
58
+ } else if (suffix) {
59
+ rename = (x) => [x, suffix].join(separator)
60
+ }
61
+ return rename
62
+ }
63
+
64
+ /**
65
+ * Returns a function that renames the keys of an object.
66
+ *
67
+ * @param {Function} rename - The function to rename the keys.
68
+ * @param {Array} keys - The keys to rename.
69
+ * @returns {Function} - A function that takes an object and renames the keys.
70
+ */
71
+ function getObjectRenamer(rename, keys) {
72
+ if (rename === identity) return identity
73
+ if (keys.length > 0) {
74
+ const lookup = keys.reduce((acc, key) => ({ ...acc, [key]: rename(key) }), {})
75
+ return usingLookup(lookup)
76
+ }
77
+ return forObject(rename)
78
+ }
79
+
80
+ /**
81
+ * Renames the keys of an object using a lookup table.
82
+ *
83
+ * @param {Object} lookup - The lookup table.
84
+ * @returns {Function} - A function that takes an object and renames the keys using the lookup table.
85
+ */
86
+ function usingLookup(lookup) {
87
+ return (row) => Object.keys(row).reduce((acc, key) => ({ ...acc, [lookup[key]]: row[key] }), {})
88
+ }
89
+
90
+ /**
91
+ * Renames the keys of an object using a function.
92
+ *
93
+ * @param {Function} rename - The function to rename the keys.
94
+ * @returns {Function} - A function that takes an object and renames the keys using the function.
95
+ */
96
+ function forObject(rename) {
97
+ return (row) => Object.keys(row).reduce((acc, key) => ({ ...acc, [rename(key)]: row[key] }), {})
98
+ }