├── .vscode └── settings.json ├── styles.css ├── jest.config.js ├── .gitignore ├── versions.json ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── .bumpversion.cfg ├── manifest.json ├── tsconfig.json ├── src ├── types.ts ├── render.ts ├── main.ts ├── csv_table.ts ├── util.ts ├── csv_table.test.ts └── util.test.ts ├── rollup.config.js ├── package.json ├── LICENSE └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .csv-table { 2 | width: 100%; 3 | } 4 | .csv-error { 5 | font-weight: 700; 6 | padding: 10em; 7 | border: 1px solid #f00; 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | moduleNameMapper: { 5 | 'src/(.*)': '/src/$1' 6 | } 7 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | 13 | # obsidian 14 | data.json 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.2.0": "0.11.10", 3 | "1.1.1": "0.11.10", 4 | "1.1.0": "0.11.10", 5 | "1.0.3": "0.11.10", 6 | "1.0.2": "0.11.10", 7 | "1.0.1": "0.11.10", 8 | "1.0.0": "0.11.10", 9 | "0.1.0": "0.11.10" 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Install modules 9 | run: npm i 10 | - name: Run tests 11 | run: npm test 12 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.2.0 3 | commit = False 4 | tag = False 5 | 6 | [bumpversion:file:package.json] 7 | search = "version": "{current_version}" 8 | replace = "version": "{new_version}" 9 | 10 | [bumpversion:file:manifest.json] 11 | search = "version": "{current_version}" 12 | replace = "version": "{new_version}" 13 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-csv-table", 3 | "name": "CSV Table", 4 | "version": "1.2.0", 5 | "minAppVersion": "0.11.10", 6 | "description": "Render CSV data as a table within your notes.", 7 | "author": "Adam Coddington ", 8 | "authorUrl": "https://coddingtonbear.net/", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "lib": [ 15 | "dom", 16 | "es5", 17 | "scripthost", 18 | "es2015" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'csv-parse' 2 | 3 | export interface CsvTableData { 4 | columns: string[], 5 | rows: Record[] 6 | } 7 | 8 | export interface NamedColumn { 9 | name: string 10 | expression: string 11 | } 12 | 13 | export interface ExtendedSortExpression { 14 | expression: string 15 | reversed: boolean 16 | } 17 | 18 | export interface CsvTableSpec { 19 | source: string 20 | csvOptions?: Options 21 | columns?: (NamedColumn | string)[] 22 | columnVariables?: Record 23 | filter?: string[] | string 24 | maxRows?: number 25 | sortBy?: (string | ExtendedSortExpression)[] | string | ExtendedSortExpression 26 | } 27 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | 5 | const isProd = process.env.BUILD === "production"; 6 | 7 | const banner = `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ROLLUP 9 | if you want to view the source visit the plugins github repository 10 | */ 11 | `; 12 | 13 | export default { 14 | input: "src/main.ts", 15 | output: { 16 | dir: ".", 17 | sourcemap: "inline", 18 | sourcemapExcludeSources: isProd, 19 | format: "cjs", 20 | exports: "default", 21 | banner, 22 | }, 23 | external: ["obsidian"], 24 | plugins: [typescript(), nodeResolve({ browser: true }), commonjs()], 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-csv-table", 3 | "version": "1.2.0", 4 | "description": "Render CSV data as a table within your notes.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js --environment BUILD:production", 9 | "test": "jest" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@rollup/plugin-commonjs": "^18.0.0", 16 | "@rollup/plugin-json": "^4.1.0", 17 | "@rollup/plugin-node-resolve": "^11.2.1", 18 | "@rollup/plugin-typescript": "^8.2.1", 19 | "@types/jest": "^26.0.23", 20 | "@types/node": "^14.14.37", 21 | "jest": "^27.0.1", 22 | "obsidian": "^0.12.0", 23 | "prettier": "^2.3.0", 24 | "rollup": "^2.32.1", 25 | "ts-jest": "^27.0.0", 26 | "tslib": "^2.2.0", 27 | "typescript": "^4.2.4" 28 | }, 29 | "dependencies": { 30 | "csv-parse": "^4.15.4", 31 | "filtrex": "^2.1.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021, Adam Coddington 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/render.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownRenderChild } from 'obsidian'; 2 | 3 | import { getCellDisplay, getColumnInfo } from './util' 4 | 5 | export class TableRenderer extends MarkdownRenderChild { 6 | constructor(public columns: string[], public rows: any[], public container: HTMLElement) { 7 | super(container) 8 | } 9 | 10 | async onload() { 11 | await this.render() 12 | } 13 | 14 | async render() { 15 | const tableEl = this.container.createEl('table') 16 | 17 | const theadEl = tableEl.createEl('thead') 18 | const headerEl = theadEl.createEl('tr') 19 | const tbodyEl = tableEl.createEl('tbody') 20 | 21 | const columnNames: string[] = [] 22 | 23 | for (const column of this.columns) { 24 | const columnInfo = getColumnInfo(column) 25 | 26 | headerEl.createEl('th', { text: columnInfo.name }) 27 | columnNames.push(columnInfo.name) 28 | } 29 | 30 | for (const row of this.rows) { 31 | const trEl = tbodyEl.createEl('tr') 32 | 33 | for (const columnName of columnNames) { 34 | trEl.createEl('td', { text: getCellDisplay(row, columnName) }) 35 | } 36 | } 37 | } 38 | } 39 | 40 | export function renderErrorPre(container: HTMLElement, error: string): HTMLElement { 41 | let pre = container.createEl('pre', { cls: ["csv-table", "csv-error"] }); 42 | pre.appendText(error); 43 | return pre; 44 | } 45 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, parseYaml, TFile } from "obsidian"; 2 | 3 | import { getFilteredCsvData } from "./csv_table"; 4 | import { TableRenderer, renderErrorPre } from "./render"; 5 | import { CsvTableSpec } from "./types"; 6 | 7 | export default class CsvTablePlugin extends Plugin { 8 | async onload() { 9 | this.registerMarkdownCodeBlockProcessor( 10 | "csvtable", 11 | async (csvSpecString: string, el, ctx) => { 12 | try { 13 | let tableSpec: CsvTableSpec = { 14 | source: "", // Assert that this has a proper value below 15 | }; 16 | try { 17 | tableSpec = parseYaml(csvSpecString); 18 | } catch (e) { 19 | throw new Error(`Could not parse CSV table spec: ${e.message}`); 20 | } 21 | 22 | if (!tableSpec.source) { 23 | throw new Error("Parameter 'source' is required."); 24 | } 25 | 26 | const file = this.app.vault.getAbstractFileByPath(tableSpec.source); 27 | if (!(file instanceof TFile)) { 28 | throw new Error( 29 | `CSV file '${tableSpec.source}' could not be found.` 30 | ); 31 | } 32 | const csvData = await this.app.vault.cachedRead(file); 33 | 34 | const filteredCsvData = getFilteredCsvData(tableSpec, csvData); 35 | ctx.addChild( 36 | new TableRenderer(filteredCsvData.columns, filteredCsvData.rows, el) 37 | ); 38 | } catch (e) { 39 | renderErrorPre(el, e.message); 40 | return; 41 | } 42 | } 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/csv_table.ts: -------------------------------------------------------------------------------- 1 | import parseCsv from 'csv-parse/lib/sync' 2 | import { compileExpression } from 'filtrex' 3 | import { CsvTableSpec, CsvTableData, ExtendedSortExpression } from './types' 4 | 5 | import { applyRowFilters, getColumnInfo, evaluateExpression, sortRows, getArrayForArrayOrObject, getSortExpression } from './util' 6 | 7 | 8 | export function getFilteredCsvData( 9 | csvSpec: CsvTableSpec, 10 | csvData: string 11 | ): CsvTableData { 12 | const { 13 | cast = true, 14 | cast_date = true, 15 | trim = true, 16 | columns = true, 17 | skip_empty_lines = true, 18 | ...extraOptions 19 | } = (csvSpec.csvOptions ?? {}) 20 | const csvOptions = { 21 | cast, trim, columns, skip_empty_lines, ...extraOptions 22 | } 23 | const parsedCsvData = parseCsv(csvData, csvOptions) 24 | const columnNames: string[] = [] 25 | const rowColumns: string[] = Object.keys(parsedCsvData[0]) 26 | 27 | try { 28 | for (const column of csvSpec.columns ?? rowColumns) { 29 | const columnInfo = getColumnInfo(column) 30 | 31 | // Do not attempt to compile/set the expression value 32 | // if it already exists in our known row columns 33 | if (rowColumns.indexOf(columnInfo.name) === -1) { 34 | const expression = compileExpression(columnInfo.expression) 35 | for (const row of parsedCsvData) { 36 | row[columnInfo.name] = evaluateExpression(row, expression, csvSpec.columnVariables) 37 | } 38 | } 39 | 40 | columnNames.push(columnInfo.name) 41 | } 42 | } catch (e) { 43 | throw new Error(`Error evaluating column expressions: ${e.message}.`) 44 | } 45 | 46 | let filteredSortedCsvData: Record[] = [] 47 | try { 48 | filteredSortedCsvData = sortRows( 49 | getArrayForArrayOrObject(csvSpec.sortBy).map(getSortExpression), 50 | applyRowFilters( 51 | getArrayForArrayOrObject(csvSpec.filter), 52 | csvSpec.maxRows ?? Infinity, 53 | parsedCsvData, 54 | csvSpec.columnVariables 55 | ), 56 | csvSpec.columnVariables 57 | ) 58 | } catch (e) { 59 | throw new Error(`Error evaluating filter expressions: ${e.message}.`) 60 | } 61 | 62 | return { 63 | columns: columnNames, 64 | rows: filteredSortedCsvData 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | # Sequence of patterns matched against refs/tags 5 | tags: 6 | - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 14 | - name: Use Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: '14.x' # You might need to adjust this value to your own version 18 | # Get the version number and put it in a variable 19 | - name: Get Version 20 | id: version 21 | run: | 22 | echo "::set-output name=tag::$(git describe --abbrev=0)" 23 | # Build the plugin 24 | - name: Build 25 | id: build 26 | run: | 27 | npm install 28 | npm run build --if-present 29 | # Package the required files into a zip 30 | - name: Package 31 | run: | 32 | mkdir ${{ github.event.repository.name }} 33 | cp main.js manifest.json styles.css README.md ${{ github.event.repository.name }} 34 | zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} 35 | # Create the release on github 36 | - name: Create Release 37 | id: create_release 38 | uses: actions/create-release@v1 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | VERSION: ${{ github.ref }} 42 | with: 43 | tag_name: ${{ github.ref }} 44 | release_name: ${{ github.ref }} 45 | draft: false 46 | prerelease: false 47 | # Upload the packaged release file 48 | - name: Upload zip file 49 | id: upload-zip 50 | uses: actions/upload-release-asset@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | upload_url: ${{ steps.create_release.outputs.upload_url }} 55 | asset_path: ./${{ github.event.repository.name }}.zip 56 | asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip 57 | asset_content_type: application/zip 58 | # Upload the main.js 59 | - name: Upload main.js 60 | id: upload-main 61 | uses: actions/upload-release-asset@v1 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | with: 65 | upload_url: ${{ steps.create_release.outputs.upload_url }} 66 | asset_path: ./main.js 67 | asset_name: main.js 68 | asset_content_type: text/javascript 69 | # Upload the manifest.json 70 | - name: Upload manifest.json 71 | id: upload-manifest 72 | uses: actions/upload-release-asset@v1 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | with: 76 | upload_url: ${{ steps.create_release.outputs.upload_url }} 77 | asset_path: ./manifest.json 78 | asset_name: manifest.json 79 | asset_content_type: application/json 80 | # Upload the style.css 81 | - name: Upload styles.css 82 | id: upload-css 83 | uses: actions/upload-release-asset@v1 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | with: 87 | upload_url: ${{ steps.create_release.outputs.upload_url }} 88 | asset_path: ./styles.css 89 | asset_name: styles.css 90 | asset_content_type: text/css 91 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { compileExpression } from 'filtrex' 2 | import { ExtendedSortExpression } from './types' 3 | 4 | type ExpressionFn = (item: Record) => any 5 | 6 | 7 | export function applyRowFilters( 8 | filters: string[], 9 | maxRows: number = Infinity, 10 | rows: Record[], 11 | columnVariables?: Record 12 | ): Record[] { 13 | const filteredRows: Record[] = [] 14 | const expressions: ExpressionFn[] = [] 15 | 16 | for (const expression of filters) { 17 | expressions.push(compileExpression(expression)) 18 | } 19 | 20 | let rowIndex = 1; 21 | for (const row of rows) { 22 | let passesTests = true 23 | 24 | if (rowIndex > maxRows) { 25 | break 26 | } 27 | 28 | for (const expression of expressions) { 29 | if (!evaluateExpression(row, expression, columnVariables)) { 30 | passesTests = false 31 | break 32 | } 33 | } 34 | if (passesTests) { 35 | filteredRows.push(row) 36 | } 37 | rowIndex += 1 38 | } 39 | return filteredRows 40 | } 41 | 42 | export function sortRows( 43 | sortExpressions: ExtendedSortExpression[], 44 | rows: Record[], 45 | columnVariables?: Record 46 | ): Record[] { 47 | const sortedRows: Record[] = [...rows] 48 | const expressions: ExpressionFn[] = [] 49 | 50 | for (const expression of sortExpressions) { 51 | expressions.push(compileExpression(expression.expression)) 52 | } 53 | 54 | for (const expression of sortExpressions.reverse()) { 55 | const sortExpression = compileExpression(expression.expression) 56 | 57 | sortedRows.sort((a, b) => { 58 | const aResult = evaluateExpression(a, sortExpression, columnVariables) 59 | const bResult = evaluateExpression(b, sortExpression, columnVariables) 60 | 61 | if (aResult < bResult) { 62 | return expression.reversed ? 1 : -1 63 | } else if (aResult > bResult) { 64 | return expression.reversed ? -1 : 1 65 | } else { 66 | return 0 67 | } 68 | }) 69 | } 70 | return sortedRows 71 | } 72 | 73 | export function evaluateExpression(row: Record, expression: ExpressionFn, columnVariables?: Record): any { 74 | const extendedRow: Record = { ...row } 75 | 76 | for (const columnVariable in columnVariables ?? {}) { 77 | extendedRow[columnVariable] = row[columnVariables[columnVariable]] 78 | } 79 | 80 | return expression(extendedRow) 81 | } 82 | 83 | export function getCellDisplay(row: Record, expression: string): any { 84 | if (typeof row[expression] === 'string') { 85 | return row[expression] 86 | } else { 87 | return JSON.stringify(row[expression]) 88 | } 89 | } 90 | 91 | export interface ColumnInfo { 92 | name: string 93 | expression: string 94 | } 95 | 96 | export function getColumnInfo(column: string | ColumnInfo): ColumnInfo { 97 | if (typeof column === 'string') { 98 | return { 99 | name: column, 100 | expression: column 101 | } 102 | } else { 103 | return column 104 | } 105 | } 106 | 107 | export function getSortExpression(expression: string | ExtendedSortExpression): ExtendedSortExpression { 108 | if (typeof expression === 'string') { 109 | return { 110 | expression: expression, 111 | reversed: false 112 | } 113 | } 114 | return expression 115 | } 116 | 117 | export function getArrayForArrayOrObject(value?: T[] | T | null): T[] { 118 | if (value === null || value === undefined) { 119 | return [] 120 | } 121 | 122 | if (Array.isArray(value)) { 123 | return value 124 | } 125 | 126 | return [value] 127 | } 128 | -------------------------------------------------------------------------------- /src/csv_table.test.ts: -------------------------------------------------------------------------------- 1 | import { getFilteredCsvData } from "./csv_table" 2 | import { CsvTableSpec, CsvTableData } from "./types"; 3 | 4 | const SAMPLE_CSV_DATA: string[][] = [ 5 | ["country", "capitol", "population"], 6 | ["United States of America", "\"Washington, DC\"", "328200000"], 7 | ["Colombia", "Bogota", "50340000"], 8 | ["Russia", "Moscow", "144400000"], 9 | ] 10 | 11 | function getCsvString(data: string[][]): string { 12 | const rows: string[] = []; 13 | 14 | for (const row of data) { 15 | rows.push(row.join(',')) 16 | } 17 | 18 | return rows.join('\n') 19 | } 20 | 21 | describe('csv_table/getCodeBlockData', () => { 22 | test('basic', () => { 23 | const csvData = getCsvString(SAMPLE_CSV_DATA) 24 | const tableSpec: CsvTableSpec = { 25 | source: 'arbitrary.csv' 26 | } 27 | 28 | const actual = getFilteredCsvData(tableSpec, csvData) 29 | const expected: CsvTableData = { 30 | columns: [ 31 | "country", 32 | "capitol", 33 | "population", 34 | ], 35 | rows: [ 36 | { 37 | "country": "United States of America", 38 | "capitol": "Washington, DC", 39 | "population": 328200000, 40 | }, 41 | { 42 | "country": "Colombia", 43 | "capitol": "Bogota", 44 | "population": 50340000, 45 | }, 46 | { 47 | "country": "Russia", 48 | "capitol": "Moscow", 49 | "population": 144400000, 50 | } 51 | ], 52 | } 53 | 54 | expect(actual).toEqual(expected) 55 | }) 56 | 57 | test('with simple column expressions', () => { 58 | const csvData = getCsvString(SAMPLE_CSV_DATA) 59 | const tableSpec: CsvTableSpec = { 60 | source: 'arbitrary.csv', 61 | columns: [ 62 | "country", 63 | "population / 1000000", 64 | ] 65 | } 66 | 67 | const actual = getFilteredCsvData(tableSpec, csvData) 68 | const expected: CsvTableData = { 69 | columns: [ 70 | "country", 71 | "population / 1000000", 72 | ], 73 | rows: [ 74 | { 75 | "country": "United States of America", 76 | "capitol": "Washington, DC", 77 | "population": 328200000, 78 | "population / 1000000": 328.2, 79 | }, 80 | { 81 | "country": "Colombia", 82 | "capitol": "Bogota", 83 | "population": 50340000, 84 | "population / 1000000": 50.34, 85 | }, 86 | { 87 | "country": "Russia", 88 | "capitol": "Moscow", 89 | "population": 144400000, 90 | "population / 1000000": 144.4, 91 | } 92 | ], 93 | } 94 | 95 | expect(actual).toEqual(expected) 96 | }) 97 | 98 | test('with named column expressions', () => { 99 | const csvData = getCsvString(SAMPLE_CSV_DATA) 100 | const tableSpec: CsvTableSpec = { 101 | source: 'arbitrary.csv', 102 | columns: [ 103 | "country", 104 | { 105 | "name": "millions", 106 | "expression": "population / 1000000" 107 | } 108 | ] 109 | } 110 | 111 | const actual = getFilteredCsvData(tableSpec, csvData) 112 | const expected: CsvTableData = { 113 | columns: [ 114 | "country", 115 | "millions" 116 | ], 117 | rows: [ 118 | { 119 | "country": "United States of America", 120 | "capitol": "Washington, DC", 121 | "population": 328200000, 122 | "millions": 328.2, 123 | }, 124 | { 125 | "country": "Colombia", 126 | "capitol": "Bogota", 127 | "population": 50340000, 128 | "millions": 50.34, 129 | }, 130 | { 131 | "country": "Russia", 132 | "capitol": "Moscow", 133 | "population": 144400000, 134 | "millions": 144.4, 135 | } 136 | ], 137 | } 138 | 139 | expect(actual).toEqual(expected) 140 | }) 141 | 142 | test('with single filter', () => { 143 | const csvData = getCsvString(SAMPLE_CSV_DATA) 144 | const tableSpec: CsvTableSpec = { 145 | source: 'arbitrary.csv', 146 | filter: "population > 300000000", 147 | } 148 | 149 | const actual = getFilteredCsvData(tableSpec, csvData) 150 | const expected: CsvTableData = { 151 | columns: [ 152 | "country", 153 | "capitol", 154 | "population", 155 | ], 156 | rows: [ 157 | { 158 | "country": "United States of America", 159 | "capitol": "Washington, DC", 160 | "population": 328200000, 161 | }, 162 | ], 163 | } 164 | 165 | expect(actual).toEqual(expected) 166 | }) 167 | 168 | test('with multiple filters', () => { 169 | const csvData = getCsvString(SAMPLE_CSV_DATA) 170 | const tableSpec: CsvTableSpec = { 171 | source: 'arbitrary.csv', 172 | filter: [ 173 | "population < 300000000", 174 | "capitol == \"Bogota\"" 175 | ] 176 | } 177 | 178 | const actual = getFilteredCsvData(tableSpec, csvData) 179 | const expected: CsvTableData = { 180 | columns: [ 181 | "country", 182 | "capitol", 183 | "population", 184 | ], 185 | rows: [ 186 | { 187 | "country": "Colombia", 188 | "capitol": "Bogota", 189 | "population": 50340000, 190 | }, 191 | ], 192 | } 193 | 194 | expect(actual).toEqual(expected) 195 | }) 196 | 197 | test('with non-variable-safe column names', () => { 198 | const csvData = getCsvString([ 199 | ['1st', '2nd'], 200 | ['alpha', '1'], 201 | ['beta', '2'], 202 | ]) 203 | const tableSpec: CsvTableSpec = { 204 | source: 'arbitrary.csv', 205 | columns: [ 206 | "1st", 207 | "2nd", 208 | ] 209 | } 210 | 211 | const actual = getFilteredCsvData(tableSpec, csvData) 212 | const expected: CsvTableData = { 213 | columns: [ 214 | "1st", 215 | "2nd", 216 | ], 217 | rows: [ 218 | { 219 | "1st": "alpha", 220 | "2nd": 1, 221 | }, 222 | { 223 | "1st": "beta", 224 | "2nd": 2, 225 | }, 226 | ], 227 | } 228 | 229 | expect(actual).toEqual(expected) 230 | }) 231 | }) 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Obsidian CSV Table 2 | 3 | Have data in a CSV file that you'd like to render as a table in Obsidian? Now you can. 4 | 5 | ## Quickstart 6 | 7 | Imagine you have the following CSV file named `countries.csv`: 8 | 9 | ``` 10 | name,capitol,population 11 | United States of America,"Washington, DC",328200000 12 | Colombia,Bogota,50340000 13 | Russia,Moscow,144400000 14 | ``` 15 | 16 | The following code block: 17 | 18 | ~~~ 19 | ```csvtable 20 | source: countries.csv 21 | ``` 22 | ~~~ 23 | 24 | will render a table like: 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 |
namecapitolpopulation
United States of AmericaWashington, DC328200000
ColombiaBogota50340000
RussiaMoscow144400000
52 | 53 | ## Options 54 | 55 | - `source`: (Required) Path (relative to your vault's root) to the csv file to render within your notes. 56 | - `csvOptions`: Options to use for decoding the referenced CSV file; see https://csv.js.org/parse/options/ for available options. 57 | - `columns`: A list of columns to render. Each item may be either the name of a field to display or an expression (see "Expressions" below), and can be re-named. If unspecified, all columns in the referenced CSV will be rendered. See "Selecting particular columns" below for details. 58 | - `filter`: A list of filter expressions (see "Expressions" below) or a single filter expression to use for limiting which rows of the referenced CSV will be displayed. If unspecified, all rows of the referenced CSV will be rendered taking into account the value specified for `maxRows` below. See "Filtering displayed rows" for details. 59 | - `sortBy`: A list of sort expressions (see "Expressions" below) or a single sort expression to use for sorting the displayed rows. If unspecified, rows will be displayed in the order they appear in the referenced CSV. See "Sorting Rows" for details. 60 | - `columnVariables`: A mapping of variable name to column name allowing you to set a name for use in `filter` or `columns` above to reference the value of a field that is not a valid variable name. 61 | - `maxRows`: The maximum number of rows to display. If unspecified, all unfiltered rows of the referenced CSV will be displayed. 62 | 63 | ### Expressions 64 | 65 | This library uses `filtrex` for expression evaluation; see their documentation to see more information about the expression syntax and what functions are available: https://github.com/m93a/filtrex#expressions. 66 | 67 | See "Filtering displayed rows" for an example of a filter expression in action, but realistically they work exactly as you'd probably expect. 68 | 69 | ### Selecting particular columns 70 | 71 | You can use the `columns` field to control which columns of your CSV file to render, e.g: 72 | 73 | ~~~ 74 | ```csvtable 75 | columns: 76 | - name 77 | - population 78 | source: my_csv_file.csv 79 | ``` 80 | ~~~ 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
namepopulation
United States of America328200000
Colombia50340000
Russia144400000
104 | 105 | It's also possible for you to set better names for your columns or use expressions: 106 | 107 | ~~~ 108 | ```csvtable 109 | columns: 110 | - expression: name 111 | name: Country Name 112 | - expression: population / 1000000 113 | name: Population (Millions) 114 | source: my_csv_file.csv 115 | ``` 116 | ~~~ 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |
Country NamePopulation (Millions)
United States of America328.2
Colombia50.34
Russia144.4
140 | 141 | ### Filtering displayed rows 142 | 143 | Maybe you would like to display only a subset of the rows of your CSV? If so, you can provide a `filter` expression to limit which rows are shown: 144 | 145 | ~~~ 146 | ```csvtable 147 | source: my_csv_file.csv 148 | filter: population < 100000000 149 | ``` 150 | ~~~ 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 |
namepopulation
Colombia50340000
166 | 167 | By default, the parser will attempt to cast the values of each field to an integer, boolean, or date object where appropriate for use in your filter expressions. Also, note that your filter expression can also be provided as a list; those expressions will be and-ed together, e.g.: 168 | 169 | ~~~ 170 | ```csvtable 171 | source: my_csv_file.csv 172 | filter: 173 | - population < 100000000 174 | - name == "Colombia" 175 | ``` 176 | ~~~ 177 | 178 | Note that the filtering language requires that you use double-quoted strings in comparisons -- if you had entered `name == 'Colombia'` above, the filter would not have returned results. 179 | 180 | ### Sorting Rows 181 | 182 | If you would like to sort the rows of your displayed CSV, you can provide a sort expression: 183 | 184 | ~~~ 185 | ```csvtable 186 | source: my_csv_file.csv 187 | sortBy: name 188 | ``` 189 | ~~~ 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 |
namepopulation
Colombia50340000
Russia144400000
United States of America328200000
213 | 214 | Additionally, you can specify your `sortBy` expression as a list; the document will be sorted by all specified fields in rank order: 215 | 216 | ~~~ 217 | ```csvtable 218 | source: my_csv_file.csv 219 | sortBy: 220 | - columnOne 221 | - columnTwo 222 | ``` 223 | ~~~ 224 | 225 | It's also possible for you to sort your displayed data in reverse order if you specify your `sortBy` expression using an extended format allowing you to specify both the expression and direction of sort: 226 | 227 | ~~~ 228 | ```csvtable 229 | source: my_csv_file.csv 230 | sortBy: 231 | - expression: name 232 | reversed: true 233 | ``` 234 | ~~~ 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 |
namepopulation
United States of America328200000
Russia144400000
Colombia50340000
258 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { compileExpression } from 'filtrex' 2 | 3 | import { applyRowFilters, evaluateExpression, getColumnInfo, getCellDisplay, ColumnInfo, sortRows, getSortExpression, getArrayForArrayOrObject } from './util' 4 | 5 | const EXAMPLE_ROWS: Record[] = [ 6 | { 7 | name: "United States of America", 8 | capitol: "Washington, DC", 9 | population: 328200000, 10 | }, 11 | { 12 | name: "Colombia", 13 | capitol: "Bogota", 14 | population: 50340000, 15 | }, 16 | { 17 | name: "Russia", 18 | capitol: "Moscow", 19 | population: 144400000, 20 | } 21 | ] 22 | 23 | describe('util/applyRowFilters', () => { 24 | test('If no filters, all rows are returned', () => { 25 | const finalRows = applyRowFilters( 26 | [], 27 | Infinity, 28 | EXAMPLE_ROWS, 29 | {} 30 | ) 31 | 32 | expect(finalRows).toEqual(EXAMPLE_ROWS) 33 | }) 34 | 35 | test('Limits count of rows to specified limit', () => { 36 | const finalRows = applyRowFilters( 37 | [], 38 | 1, 39 | EXAMPLE_ROWS, 40 | {} 41 | ) 42 | 43 | // The first one should be the only result 44 | expect(finalRows).toEqual([EXAMPLE_ROWS[0]]) 45 | }) 46 | 47 | test('Applies single row filter as expression', () => { 48 | const finalRows = applyRowFilters( 49 | [ 50 | "population < 100000000" 51 | ], 52 | Infinity, 53 | EXAMPLE_ROWS, 54 | {} 55 | ) 56 | 57 | // Colombia should be the only result 58 | expect(finalRows).toEqual([EXAMPLE_ROWS[1]]) 59 | }) 60 | 61 | test('Applies multiple row filters as anded expression', () => { 62 | const finalRows = applyRowFilters( 63 | [ 64 | "population < 300000000", 65 | "capitol == \"Moscow\"", 66 | ], 67 | Infinity, 68 | EXAMPLE_ROWS, 69 | {} 70 | ) 71 | 72 | // Russia should be the only result 73 | expect(finalRows).toEqual([EXAMPLE_ROWS[2]]) 74 | }) 75 | 76 | test('Applies row filters using columnVariables as expressions', () => { 77 | const finalRows = applyRowFilters( 78 | [ 79 | "poblacion < 100000000" 80 | ], 81 | Infinity, 82 | EXAMPLE_ROWS, 83 | { 84 | poblacion: "population" 85 | } 86 | ) 87 | 88 | // Colombia should be the only result 89 | expect(finalRows).toEqual([EXAMPLE_ROWS[1]]) 90 | }) 91 | }) 92 | 93 | describe("util/sortRows", () => { 94 | test("Sorts rows using column name", () => { 95 | const result = sortRows( 96 | [ 97 | { 98 | expression: 'population', 99 | reversed: false, 100 | } 101 | ], 102 | EXAMPLE_ROWS 103 | ) 104 | 105 | expect(result.map((row) => row.name)).toEqual([ 106 | "Colombia", 107 | "Russia", 108 | "United States of America", 109 | ]) 110 | }) 111 | 112 | test("Sorts rows using column name, reversed", () => { 113 | const result = sortRows( 114 | [ 115 | { 116 | expression: 'population', 117 | reversed: true, 118 | } 119 | ], 120 | EXAMPLE_ROWS 121 | ) 122 | 123 | expect(result.map((row) => row.name)).toEqual([ 124 | "United States of America", 125 | "Russia", 126 | "Colombia", 127 | ]) 128 | }) 129 | 130 | test("Sorts rows when using an expression", () => { 131 | const result = sortRows( 132 | [ 133 | { 134 | expression: 'population * 10', 135 | reversed: false, 136 | } 137 | ], 138 | EXAMPLE_ROWS 139 | ) 140 | 141 | expect(result.map((row) => row.name)).toEqual([ 142 | "Colombia", 143 | "Russia", 144 | "United States of America", 145 | ]) 146 | }) 147 | 148 | test("Sorts upon multiple expressions", () => { 149 | const result = sortRows( 150 | [ 151 | { 152 | expression: 'numeric', 153 | reversed: false, 154 | }, 155 | { 156 | expression: 'alpha', 157 | reversed: false, 158 | } 159 | ], 160 | [ 161 | { 162 | alpha: 'b', 163 | numeric: 4 164 | }, 165 | { 166 | alpha: 'a', 167 | numeric: 10 168 | }, 169 | { 170 | alpha: 'a', 171 | numeric: 4 172 | }, 173 | ] 174 | ) 175 | 176 | expect(result).toEqual([ 177 | { 178 | alpha: 'a', 179 | numeric: 4 180 | }, 181 | { 182 | alpha: 'b', 183 | numeric: 4 184 | }, 185 | { 186 | alpha: 'a', 187 | numeric: 10 188 | }, 189 | ]) 190 | }) 191 | }) 192 | 193 | describe('util/evaluateExpression', () => { 194 | test('Evaluates simple expression', () => { 195 | const expression = compileExpression("value * 100") 196 | 197 | const row = { 198 | value: 100 199 | } 200 | const value = evaluateExpression(row, expression, {}) 201 | 202 | expect(value).toEqual(row.value * 100) 203 | }) 204 | 205 | test('Performs columnVariable transformations', () => { 206 | const expression = compileExpression("myvar") 207 | 208 | const row = { 209 | 'Some string field name': 100 210 | } 211 | const columnVariables = { 212 | myvar: 'Some string field name' 213 | } 214 | const value = evaluateExpression(row, expression, columnVariables) 215 | 216 | expect(value).toEqual(row['Some string field name']) 217 | }) 218 | }) 219 | 220 | describe('util/getCellDisplay', () => { 221 | test('Fetches string cell display', () => { 222 | const row = { 223 | value: 'Some value' 224 | } 225 | const display = getCellDisplay(row, 'value') 226 | 227 | expect(display).toEqual(row.value) 228 | }) 229 | 230 | test('JSON-encodes non-string cell display', () => { 231 | const row = { 232 | value: { arbitrary: 100 } 233 | } 234 | const display = getCellDisplay(row, 'value') 235 | 236 | expect(display).toEqual(JSON.stringify(row.value)) 237 | }) 238 | }) 239 | 240 | describe('util/getColumnInfo', () => { 241 | test("Parses column having string name", () => { 242 | const arbitraryColumnName = "my_column" 243 | const columnInfo = getColumnInfo(arbitraryColumnName) 244 | 245 | expect(columnInfo).toEqual({ 246 | name: arbitraryColumnName, 247 | expression: arbitraryColumnName 248 | }) 249 | }) 250 | 251 | test("Parses column having defined name", () => { 252 | const providedColumnInfo: ColumnInfo = { 253 | name: "Something", 254 | expression: "my_column" 255 | } 256 | const columnInfo = getColumnInfo(providedColumnInfo) 257 | 258 | expect(columnInfo).toEqual(providedColumnInfo) 259 | }) 260 | }) 261 | 262 | describe('util/getSortExpression', () => { 263 | test("Parses string sort expression", () => { 264 | const expression = 'beep' 265 | const parsed = getSortExpression(expression) 266 | 267 | expect(parsed).toEqual({ 268 | expression, 269 | reversed: false 270 | }) 271 | }) 272 | 273 | test("Parses extended sort expression", () => { 274 | const expression = { 275 | expression: 'beep', 276 | reversed: true, 277 | } 278 | const parsed = getSortExpression(expression) 279 | 280 | expect(parsed).toEqual(expression) 281 | }) 282 | }) 283 | 284 | describe('util/getArrayForArrayOrObject', () => { 285 | test('Returns empty array for null', () => { 286 | const value: null = null 287 | const actual = getArrayForArrayOrObject(value) 288 | 289 | expect(actual).toEqual([]) 290 | }) 291 | 292 | test('Returns empty array for undefined', () => { 293 | const value: undefined = undefined 294 | const actual = getArrayForArrayOrObject(value) 295 | 296 | expect(actual).toEqual([]) 297 | }) 298 | 299 | test('Returns original array for array', () => { 300 | const value: string[] = ['beep'] 301 | const actual = getArrayForArrayOrObject(value) 302 | 303 | expect(actual).toEqual(value) 304 | }) 305 | 306 | test('Returns array of single item if single item', () => { 307 | const value: string = 'beep' 308 | const actual = getArrayForArrayOrObject(value) 309 | 310 | expect(actual).toEqual([value]) 311 | }) 312 | }) 313 | --------------------------------------------------------------------------------