├── .gitignore ├── LICENSE ├── README.md ├── data └── gendata.ts ├── index.html ├── package.json ├── rollup.config.mjs ├── src ├── main.css └── main.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | 4 | node_modules 5 | 6 | .rollup.cache 7 | .DS_Store 8 | .idea 9 | .vscode 10 | .npm-debug 11 | npm-debug.log 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Leon Sorokin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 𝌠 μTable 2 | 3 | A tiny, fast UI for viewing, sorting, and filtering CSVs _(MIT Licensed)_ 4 | 5 | --- 6 | ### Introduction 7 | 8 | μTable is a fast interface for viewing CSV files. 9 | It draws much inspiration from [thoughtspile/hippotable](https://github.com/thoughtspile/hippotable), but makes different tech choices with the goal of being smaller and faster. 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
μTableHippotable
JS FrameworkiviSolidJS
CSV ParsingμDSVArquero (d3-dsv)
Virtualizationown (< 0.5 KB)TanStack/table (~60 KB)
Sorting / filteringown + μExpr (< 4 KB)Arquero (~400 KB)
localStorage Persistencenoyes
Bundle size20 KB416 KB
54 | 55 | Both projects are very early, and the choices made by Hippotable are totally sensible, considering its plans to leverage much more of the Arquero library. 56 | I have, however, previously tested some Arquero functions (such as grouping) and found its performance to be lacking in multiple areas. 57 | 58 | uTable has similar goals as Hippotable, but [perhaps] with greater scrutiny on external dependencies, and will always roll its own solutions when there are significant performance and/or size benefits. 59 | 60 | --- 61 | ### Features 62 | 63 | --- 64 | ### How to use 65 | 66 | **Statically hosted:** 67 | 68 | 1. Open https://leeoniya.github.io/uTable 69 | 2. Drag/drop a CSV file into the UI 70 | 71 | **Locally or dev:** 72 | 73 | 1. Clone this repo 74 | 2. Install dependencies: `npm install` 75 | 3. Build bundle: `npm run build` 76 | 4. Run an http server in repo root that can serve static files, for example: 77 | 1. Install: `npm i -g http-server` 78 | 2. Run `http-server` in repo root 79 | 5. Open `http://localhost:8080/` 80 | 6. Drag/drop a CSV file into the UI 81 | 82 | --- 83 | ### Performance -------------------------------------------------------------------------------- /data/gendata.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const random = (max: number) => Math.round(Math.random() * 1000) % max; 4 | const A = ["pretty", "large", "big", "small", "tall", "short", "long", "handsome", "plain", "quaint", "clean", "elegant", "easy", "angry", "crazy", "helpful", "mushy", "odd", "unsightly", "adorable", "important", "inexpensive", "cheap", "expensive", "fancy"]; 5 | const C = ["red", "yellow", "blue", "green", "pink", "brown", "purple", "brown", "white", "black", "orange"]; 6 | const N = ["table", "chair", "house", "bbq", "desk", "car", "pony", "cookie", "sandwich", "burger", "pizza", "mouse", "keyboard"]; 7 | 8 | let nextId = 0; 9 | function buildData(count: number) { 10 | const data: string[] = [ 11 | 'id,adjective,color,noun' 12 | ]; 13 | 14 | for (let i = 0; i < count; i++) 15 | data.push(`${nextId++},${A[random(A.length)]},${C[random(C.length)]},${N[random(N.length)]}`); 16 | return data.join('\n'); 17 | } 18 | 19 | let recs = buildData(3e5); 20 | 21 | fs.writeFileSync('./table.csv', recs); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | uTable 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "utable", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "author": "Leon Sorokin", 6 | "license": "MIT", 7 | "homepage": "https://github.com/leeoniya/uTable", 8 | "repository": "https://github.com/leeoniya/uTable", 9 | "keywords": [], 10 | "scripts": { 11 | "build": "rollup -c" 12 | }, 13 | "dependencies": { 14 | "ivi": "4.0.1", 15 | "udsv": "0.7.2", 16 | "uexpr": "leeoniya/uExpr#404558e29b298d903a7b85a1de45e856f94de7c3" 17 | }, 18 | "devDependencies": { 19 | "@ivi/rollup-plugin": "4.0.0", 20 | "@rollup/plugin-node-resolve": "16.0.1", 21 | "@rollup/plugin-terser": "0.4.4", 22 | "@rollup/plugin-typescript": "12.1.2", 23 | "@swc/core": "^1.11.18", 24 | "rollup": "4.39.0", 25 | "rollup-plugin-swc3": "^0.12.1", 26 | "tslib": "2.8.1", 27 | "typescript": "5.8.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 2 | import typescript from "@rollup/plugin-typescript"; 3 | import { ivi } from "@ivi/rollup-plugin"; 4 | import { minify, defineRollupSwcMinifyOption } from 'rollup-plugin-swc3' 5 | import fs from 'fs'; 6 | 7 | const name = 'main'; 8 | 9 | const copyCss = () => { 10 | return { 11 | name: 'copyCss', 12 | closeBundle: () => { 13 | fs.copyFileSync(`./src/${name}.css`, './dist/styles.css'); 14 | }, 15 | } 16 | }; 17 | 18 | export default [ 19 | { 20 | input: `./src/${name}.ts`, 21 | output: { 22 | file: "./dist/bundle.min.js", 23 | format: "es", 24 | strict: true, 25 | sourcemap: true, 26 | }, 27 | watch: { 28 | clearScreen: false, 29 | }, 30 | plugins: [ 31 | nodeResolve(), 32 | typescript(), 33 | ivi({}), 34 | minify( 35 | defineRollupSwcMinifyOption({ 36 | compress: { 37 | inline: 0, 38 | keep_infinity: true, 39 | }, 40 | toplevel: true, 41 | module: true, 42 | sourceMap: true, 43 | }) 44 | ), 45 | copyCss() 46 | ], 47 | }, 48 | ]; -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | *:before, 7 | *:after { 8 | box-sizing: inherit; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | } 14 | 15 | .scroll-wrap { 16 | position: relative; 17 | 18 | border: 1px solid #dadee4; 19 | width: 100%; 20 | height: 100vh; 21 | overflow-y: auto; 22 | /* overflow-x: hidden; */ 23 | overflow-anchor: none; 24 | } 25 | 26 | table { 27 | width: 100%; 28 | border-collapse: collapse; 29 | 30 | font-family: monospace; 31 | font-size: 12px; 32 | color: #3b4351; 33 | } 34 | 35 | tr { 36 | height: 18px; 37 | } 38 | 39 | th, 40 | td { 41 | padding: 0 6px; 42 | text-overflow: ellipsis; 43 | overflow: hidden; 44 | max-width: 1000px; 45 | white-space: nowrap; 46 | } 47 | 48 | /* td:hover { 49 | overflow: visible; 50 | white-space: unset; 51 | } */ 52 | 53 | thead { 54 | text-align: left; 55 | background: #eee; 56 | position: sticky; 57 | top: 0px; 58 | border-bottom: 1px solid #dadee4; 59 | } 60 | 61 | tbody tr { 62 | border-bottom: 1px solid #dadee4; 63 | } 64 | 65 | tbody tr:hover { 66 | background: #bbdefb; 67 | } 68 | 69 | .col-names th { 70 | position: relative; 71 | cursor: pointer; 72 | padding: 6px; 73 | user-select: none; 74 | } 75 | 76 | .col-names th .col-resize { 77 | position: absolute; 78 | right: 0; 79 | top: 0; 80 | 81 | width: 5px; 82 | height: 100%; 83 | 84 | cursor: col-resize; 85 | } 86 | 87 | .col-filts th { 88 | padding: 0 6px 6px 6px; 89 | } 90 | 91 | .col-filts select { 92 | position: absolute; 93 | background: none; 94 | padding: 2px; 95 | } 96 | 97 | .col-filts input { 98 | padding-left: 40px; 99 | width: 100%; 100 | min-width: 100px; 101 | max-width: 100%; 102 | } 103 | 104 | .col-sort { 105 | font-size: 16px; 106 | line-height: 0; 107 | margin-left: 5px; 108 | } 109 | 110 | .col-sort sup { 111 | font-size: 10px; 112 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { html, update, component, getProps, createRoot, useState, invalidate, useEffect, List } from "ivi"; 2 | import { Schema, inferSchema, initParser, type SchemaColumn } from "udsv"; 3 | import { Op, Expr, compileFilter } from 'uexpr'; 4 | 5 | type HTMLElementEvent = Event & { 6 | target: T; 7 | } 8 | 9 | interface Table { 10 | schema: Schema; 11 | data: string[][]; 12 | 13 | // rows?: number[] | null; 14 | // cols?: 15 | 16 | // filters: (various matchers, uExpr) 17 | // sorters: 18 | // groupers: 19 | // faceters: (enum values) 20 | } 21 | 22 | function haltEvent(e: Event) { 23 | e.preventDefault(); 24 | e.stopPropagation(); 25 | e.stopImmediatePropagation(); 26 | } 27 | 28 | function onWinCap(type: string, fn: EventListener) { 29 | window.addEventListener(type, fn, {capture: true}); 30 | } 31 | 32 | function offWinCap(type: string, fn: EventListener) { 33 | window.removeEventListener(type, fn, {capture: true}); 34 | } 35 | 36 | interface CSVDropperProps { 37 | setData: (table: Table | null) => void; 38 | } 39 | type Sorter = [pos: number, colIdx: number, sortDir: number]; 40 | type TupleSortFn = (a: string[], b: string[]) => number; 41 | 42 | const cmp = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }).compare; 43 | 44 | const compileSorterTuples = (cols: SchemaColumn[], pos: number[], dir: number[], simple = false): TupleSortFn | null => { 45 | let sorts: Sorter[] = []; 46 | 47 | for (let ci = 0; ci < dir.length; ci++) { 48 | if (dir[ci] != 0) 49 | sorts.push([pos[ci], ci, dir[ci]]); 50 | } 51 | 52 | if (sorts.length == 0) 53 | return null; 54 | 55 | sorts.sort((a, b) => a[0] - b[0]); 56 | 57 | // todo: handle nulls? 58 | let body = sorts.map(s => { 59 | let col = cols[s[1]]; 60 | let a = `a[${s[1]}]`; 61 | let b = `b[${s[1]}]`; 62 | 63 | return ( 64 | col.type == 'n' ? `${s[2]} * (${a} - ${b})` : 65 | simple ? `${s[2]} * (${a} > ${b} ? 1 : ${a} < ${b} ? -1 : 0)` : 66 | `${s[2]} * cmp(${a}, ${b})` 67 | ); 68 | }).join(' || '); 69 | 70 | return new Function('cmp', ` 71 | return (a, b) => ${body}; 72 | `)(cmp); 73 | }; 74 | 75 | const compileMatcherStringTuples = (rules: Expr[]) => { 76 | let nonEmpty = rules.filter(r => r[2] != ''); 77 | 78 | if (nonEmpty.length == 0) 79 | return (data: string[][]) => data; 80 | 81 | // add null handling, maybe for strings uExpr should parse as '' and nums as 0? 82 | let rules2 = ['&&', 83 | ...nonEmpty.flatMap(r => [ 84 | ['!==', r[1], null], 85 | r, 86 | ]) 87 | ] as unknown as Expr; 88 | 89 | // add uFuzzy, case insensitivity? 90 | 91 | // todo: make empty value acceptable, (isNull, falsy, cmp against explicit empty, etc) 92 | return compileFilter(rules2); 93 | }; 94 | 95 | const CSVDropper = component((c) => { 96 | let onDrop = (e: DragEvent) => { 97 | e.preventDefault(); 98 | 99 | for (const item of e.dataTransfer!.items) { 100 | if (item.kind == "file") { 101 | let file = item.getAsFile()!; 102 | 103 | if (file.name.endsWith(".csv")) { 104 | file.text().then((text) => { 105 | console.time("parse"); 106 | 107 | let s = inferSchema(text, {}, 100); 108 | 109 | // we dont need to parse dates except during display? they can be sorted by timestamp? 110 | s.cols.forEach(c => { 111 | if (c.type === 'd') 112 | c.type = 's'; 113 | }); 114 | 115 | let p = initParser(s); 116 | // let d = p.stringArrs(text); 117 | let d = p.typedArrs(text); 118 | 119 | console.timeEnd("parse"); 120 | 121 | getProps(c).setData({schema: s, data: d}); 122 | }); 123 | } 124 | } 125 | } 126 | }; 127 | 128 | let onDragOver = (e: DragEvent) => { 129 | e.preventDefault(); 130 | }; 131 | 132 | return () => html` 133 |
141 | Drag/drop CSV here... 142 |
143 | `; 144 | }); 145 | 146 | // const HeaderCell = component((c) => { 147 | // return (props) => html` 148 | // 153 | // `; 154 | // }); 155 | 156 | const Table = component
0 ? `${colWids[i]}px` : 'auto'}> 149 | //
150 | // ${c.name} 151 | // ${sortDir[i] != 0 ? html`${sortDir[i] == 1 ? `▲` : '▼'}${sortPos[i]}` : null} 152 | //
((c) => { 157 | let dom: HTMLElement; 158 | const setDom = (el: HTMLElement) => { dom = el; }; 159 | 160 | let table = getProps(c); 161 | let cols = table.schema.cols; 162 | 163 | // this can come from url params? cookies? 164 | let filts: Expr[] = cols.map((c, ci) => ['*', `[${ci}]`, '']); 165 | let sortDir: number[] = Array(cols.length).fill(0); 166 | let sortPos: number[] = Array(cols.length).fill(0); 167 | 168 | let dataFilt = table.data; 169 | let dataSort = table.data; 170 | 171 | let onClickCol = (idx: number, shiftKey: boolean) => { 172 | let dir = sortDir[idx]; 173 | let pos = sortPos[idx]; 174 | 175 | if (dir == 1) 176 | dir = -1; 177 | else if (dir == 0) { 178 | if (!shiftKey) { 179 | // reset all sorts 180 | sortPos.fill(0); 181 | sortDir.fill(0); 182 | } 183 | 184 | dir = 1; 185 | pos = Math.max(...sortPos) + 1; 186 | } 187 | else { 188 | for (let i = 0; i < sortPos.length; i++) 189 | if (sortPos[i] > pos) 190 | sortPos[i]--; 191 | 192 | dir = 0; 193 | pos = 0; 194 | } 195 | 196 | sortDir[idx] = dir; 197 | sortPos[idx] = pos; 198 | 199 | reSort(); 200 | }; 201 | 202 | let reFilt = () => { 203 | dataFilt = compileMatcherStringTuples(filts)(table.data); 204 | reSort(); 205 | }; 206 | 207 | let reSort = () => { 208 | let sortFn = compileSorterTuples(cols, sortPos, sortDir); 209 | 210 | if (sortFn == null) 211 | dataSort = dataFilt; 212 | else 213 | dataSort = dataFilt.slice().sort(sortFn); 214 | 215 | // when to do this? 216 | dom.scrollTop = 0; 217 | 218 | invalidate(c); 219 | }; 220 | 221 | let onChangeFiltOp = (idx: number, op: Op) => { 222 | filts[idx][0] = op; 223 | reFilt(); 224 | }; 225 | 226 | let onChangeFiltVal = (idx: number, val: string) => { 227 | filts[idx][2] = val; 228 | reFilt(); 229 | }; 230 | 231 | // approx row hgt to estimate how many to render based on viewport size 232 | let rowHgt = 0; 233 | let viewRows = 0; 234 | // min chunk length and used to estimate row height 235 | let chunkLen = Math.min(100, table.data.length); 236 | let idx0 = 0; 237 | let colWids = Array(cols.length).fill(null); 238 | 239 | const incrRoundDn = (num: number, incr: number) => Math.floor(num / incr) * incr; 240 | 241 | let sync = () => { 242 | let rFull = dom.getBoundingClientRect(); 243 | let rThead = dom.querySelector('thead')!.getBoundingClientRect(); 244 | let viewHgt = rFull.height - rThead.height; 245 | 246 | // set once during init from probed/rendered chunk 247 | if (rowHgt == 0) { 248 | let tbody = dom.querySelector('tbody')!; 249 | let rTbody = tbody.getBoundingClientRect(); 250 | 251 | rowHgt = rTbody.height / chunkLen; 252 | 253 | let i = 0; 254 | for (let colEl of dom.querySelectorAll('.col-names th')) 255 | colWids[i++] = colEl.getBoundingClientRect().width; 256 | } 257 | 258 | viewRows = Math.floor(viewHgt / rowHgt); 259 | chunkLen = 2 * viewRows; 260 | 261 | // console.log(chunkLen); 262 | }; 263 | 264 | let setIdx0 = (force = false) => { 265 | let idx1 = incrRoundDn(dom.scrollTop / rowHgt, viewRows); 266 | 267 | if (force || idx0 != idx1) { 268 | idx0 = idx1; 269 | invalidate(c); 270 | } 271 | }; 272 | 273 | // useLayoutEffect(c, () => { 274 | // for (let colEl of dom.querySelectorAll('.col-names th')) 275 | // console.log(colEl.getBoundingClientRect().width); 276 | // })(); 277 | 278 | useEffect(c, () => { 279 | // TODO: ensure this is only for vt scroll and resize, and handle hz resize independently (adjust col widths? only when table width 100%?) 280 | dom.addEventListener('scroll', () => setIdx0()); 281 | 282 | let resizeObserver = new ResizeObserver(() => { 283 | sync(); 284 | setIdx0(true); 285 | }); 286 | resizeObserver.observe(dom); 287 | 288 | return () => { 289 | resizeObserver.unobserve(dom); 290 | resizeObserver.disconnect(); 291 | }; 292 | })(); 293 | 294 | let onClicks = cols.map((c, i) => (e: MouseEvent) => onClickCol(i, e.shiftKey)); 295 | let onChangeFiltOps = cols.map((c, i) => (e: HTMLElementEvent) => onChangeFiltOp(i, e.target.value as Op)); 296 | let onChangeFiltVals = cols.map((c, i) => (e: HTMLElementEvent) => onChangeFiltVal(i, e.target.value)); 297 | 298 | // todo: make configurable per column 299 | const min = 50; 300 | const max = 500; 301 | 302 | let onDowns = cols.map((col, i) => (e: MouseEventInit) => { 303 | if (e.button !== 0) 304 | return; 305 | 306 | let fromX = e.clientX!; 307 | let fromWid = colWids[i]; 308 | 309 | let onMove: EventListener = (e: MouseEventInit) => { 310 | let newWid = fromWid + (e.clientX! - fromX); 311 | 312 | // clamp 313 | if (newWid > max || newWid < min) 314 | return; 315 | 316 | // ensure non-zero here 317 | 318 | colWids[i] = newWid; 319 | 320 | // TODO: invaldate only header component, or just th component? 321 | invalidate(c); 322 | }; 323 | 324 | let onClick: EventListener = (e: MouseEventInit) => { 325 | offWinCap('mousemove', onMove); 326 | offWinCap('click', onClick); 327 | haltEvent(e as Event); 328 | }; 329 | 330 | onWinCap('mousemove', onMove); 331 | onWinCap('click', onClick); 332 | haltEvent(e as Event); 333 | }); 334 | 335 | const Row = component((c) => row => html`${row.map(Cell)}`, () => true); 336 | const Cell = component((c) => col => html`
`, () => true); 337 | 338 | // col resize/drag 339 | // let onMouseDowns = cols.map((c, i) => (e: MouseEvent) => onClickCol(i, e.shiftKey)); 340 | 341 | return () => { 342 | let chunk = dataSort.slice(idx0, idx0 + chunkLen); 343 | // TODO: this will only change with filters 344 | let totalHgt = dataSort.length * rowHgt; 345 | 346 | let padTop = rowHgt == 0 ? 0 : idx0 * rowHgt; 347 | let padBtm = rowHgt == 0 ? 0 : totalHgt - Math.min(totalHgt, rowHgt * (idx0 + chunkLen)); 348 | 349 | return html` 350 |
351 | 0 ? 'fixed' : 'auto'}> 352 | 353 | 354 | ${cols.map((c, i) => html` 355 | 360 | `)} 361 | 362 | 363 | ${cols.map((c, ci) => html` 364 | 373 | `)} 374 | 375 | 376 | 377 | 378 | ${List(chunk, row => row, Row)} 379 | 380 | 381 |
0 ? `${colWids[i]}px` : 'auto'}> 356 |
357 | ${c.name} 358 | ${sortDir[i] != 0 ? html`${sortDir[i] == 1 ? `▲` : '▼'}${sortPos[i]}` : null} 359 |
365 | 371 | 372 |
382 |
383 | `; 384 | } 385 | }); 386 | 387 | const App = component((c) => { 388 | let [getData, setData] = useState(c, null); 389 | 390 | return () => { 391 | let table = getData(); 392 | return table == null ? CSVDropper({ setData }) : Table(table); 393 | }; 394 | }); 395 | 396 | update(createRoot(document.body), App()); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "node16", 7 | "declaration": false, 8 | "sourceMap": true, 9 | "importHelpers": true, 10 | "noEmitHelpers": true, 11 | "removeComments": false, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "noImplicitReturns": true, 17 | "noUnusedLocals": true, 18 | "skipLibCheck": true 19 | }, 20 | "include": [ 21 | "src/**/*.ts" 22 | ], 23 | "exclude": [ 24 | "src/bak/*.ts" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------