├── .gitignore ├── assets ├── logo.png ├── demo-r-tibble.gif └── demo-py-polars.gif ├── .vscodeignore ├── jsconfig.json ├── .vscode-test.mjs ├── .github ├── dependabot.yml └── workflows │ ├── publish-extensions.yml │ └── ci.yml ├── test ├── README.md ├── quoted-values.test.js ├── paste-modules.test.js ├── extension.test.js └── edge-cases.test.js ├── LICENSE.md ├── extension.js ├── src ├── paste-default.js ├── paste-julia.js ├── utils.js ├── paste-python.js ├── paste-js.js ├── paste-markdown.js ├── paste-r.js ├── parse-table.js └── paste-sql.js ├── CHANGELOG.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode-test/ 3 | *.vsix 4 | prepros.config -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atsyplenkov/pastum/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/demo-r-tibble.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atsyplenkov/pastum/HEAD/assets/demo-r-tibble.gif -------------------------------------------------------------------------------- /assets/demo-py-polars.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atsyplenkov/pastum/HEAD/assets/demo-py-polars.gif -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | test/** 4 | .gitignore 5 | .yarnrc 6 | vsc-extension-quickstart.md 7 | **/jsconfig.json 8 | **/*.map 9 | **/.eslintrc.json 10 | **/.vscode-test.* 11 | .github/** -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2022", 5 | "checkJs": false, /* Typecheck .js files. */ 6 | "lib": [ 7 | "ES2022" 8 | ] 9 | }, 10 | "exclude": [ 11 | "node_modules" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode-test.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@vscode/test-cli'; 2 | 3 | export default defineConfig({ 4 | files: 'test/**/*.test.js', 5 | launchArgs: [ 6 | '--disable-gpu', 7 | '--disable-software-rasterizer', 8 | '--disable-dev-shm-usage', 9 | '--no-sandbox', 10 | '--disable-setuid-sandbox', 11 | '--disable-web-security', 12 | '--disable-features=VizDisplayCompositor' 13 | ], 14 | extensionDevelopmentPath: '.', 15 | extensionTestsPath: './test' 16 | }); 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/publish-extensions.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "*" 5 | 6 | name: Deploy Extension 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 22 15 | - run: npm ci 16 | - name: Publish to Open VSX Registry 17 | uses: HaaLeo/publish-vscode-extension@v2 18 | with: 19 | pat: ${{ secrets.OPEN_VSX_TOKEN }} 20 | - name: Publish to Visual Studio Marketplace 21 | uses: HaaLeo/publish-vscode-extension@v2 22 | with: 23 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 24 | registryUrl: https://marketplace.visualstudio.com -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Running Tests 2 | 3 | The tests are designed to work with the VS Code Test Framework. To run them: 4 | 5 | ```bash 6 | npm test 7 | ``` 8 | 9 | Or using the VS Code Test CLI: 10 | ```bash 11 | npx @vscode/test-cli --extensionDevelopmentPath=. --extensionTestsPath=./test 12 | ``` 13 | 14 | # Test Data Examples 15 | 16 | The tests use various table formats to ensure comprehensive coverage: 17 | 18 | ## Simple Table 19 | ``` 20 | Name Age Score 21 | Alice 25 95.5 22 | Bob 30 87.2 23 | ``` 24 | 25 | ## Large Dataset Test 26 | ``` 27 | Generated tables with 1000+ rows and 50+ columns for performance testing 28 | ``` 29 | 30 | ## Special Characters in Headers 31 | ``` 32 | Column Name! @Price$ %Change 33 | Value1 100 5.5 34 | ``` 35 | 36 | # Contributing 37 | 38 | When adding new features: 39 | 1. Add corresponding tests in the appropriate test file 40 | 2. Focus on unit tests for core functionality 41 | 3. Test edge cases and error conditions 42 | 4. Verify module structure and exports 43 | 5. Avoid complex async mocking -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 pastum authors 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 | -------------------------------------------------------------------------------- /extension.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | const r = require("./src/paste-r.js"); 3 | const py = require("./src/paste-python.js"); 4 | const jl = require("./src/paste-julia.js"); 5 | const js = require("./src/paste-js.js"); 6 | const md = require("./src/paste-markdown.js"); 7 | const sql = require("./src/paste-sql.js"); 8 | const def = require("./src/paste-default.js"); 9 | 10 | function activate(context) { 11 | context.subscriptions.push( 12 | vscode.commands.registerCommand( 13 | "pastum.Rdataframe", 14 | r.clipboardToRDataFrame 15 | ), 16 | vscode.commands.registerCommand( 17 | "pastum.Pydataframe", 18 | py.clipboardToPyDataFrame 19 | ), 20 | vscode.commands.registerCommand( 21 | "pastum.Jldataframe", 22 | jl.clipboardToJuliaDataFrame 23 | ), 24 | vscode.commands.registerCommand( 25 | "pastum.JSdataframe", 26 | js.clipboardToJSDataFrame 27 | ), 28 | vscode.commands.registerCommand( 29 | "pastum.Markdown", 30 | md.clipboardToMarkdown 31 | ), 32 | vscode.commands.registerCommand( 33 | "pastum.Sql", 34 | sql.clipboardToSql 35 | ), 36 | vscode.commands.registerCommand("pastum.Defaultdataframe", def.pasteDefault) 37 | ); 38 | } 39 | 40 | function deactivate() {} 41 | 42 | module.exports = { 43 | activate, 44 | deactivate, 45 | }; 46 | -------------------------------------------------------------------------------- /src/paste-default.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | const r = require("./paste-r.js"); 3 | const py = require("./paste-python.js"); 4 | const jl = require("./paste-julia.js"); 5 | const js = require("./paste-js.js"); 6 | const md = require("./paste-markdown.js"); 7 | const sql = require("./paste-sql.js"); 8 | 9 | function pasteDefault() { 10 | // Get the default dataframe framework 11 | const config = vscode.workspace.getConfiguration("pastum"); 12 | const frameR = config.get("defaultDataframeR"); 13 | const framePy = config.get("defaultDataframePython"); 14 | const frameJS = config.get("defaultDataframeJavascript"); 15 | const frameMD = config.get("defaultAligmentMarkdown"); 16 | const frameSql = config.get("defaultSqlStatement"); 17 | 18 | // Get the active editor language 19 | const editor = vscode.window.activeTextEditor; 20 | if (!editor) { 21 | vscode.window.showErrorMessage("No active editor found."); 22 | return; 23 | } 24 | 25 | // Switch to the appropriate framework based on the editor language 26 | switch (editor.document.languageId) { 27 | case "r": 28 | r.clipboardToRDataFrame(frameR); 29 | break; 30 | case "python": 31 | py.clipboardToPyDataFrame(framePy); 32 | break; 33 | case "julia": 34 | jl.clipboardToJuliaDataFrame(); 35 | break; 36 | case "javascript": 37 | js.clipboardToJSDataFrame(frameJS); 38 | break; 39 | case "markdown": 40 | md.clipboardToMarkdown(frameMD); 41 | break; 42 | case "sql": 43 | sql.clipboardToSql(frameSql); 44 | break; 45 | default: 46 | vscode.window.showErrorMessage("No default framework selected"); 47 | } 48 | } 49 | 50 | module.exports = { 51 | pasteDefault, 52 | }; 53 | -------------------------------------------------------------------------------- /test/quoted-values.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const vscode = require('vscode'); 3 | const parseTable = require('../src/parse-table'); 4 | 5 | suite('Quoted Values Parsing Test Suite', () => { 6 | // Mock configuration 7 | const originalGetConfiguration = vscode.workspace.getConfiguration; 8 | 9 | setup(() => { 10 | vscode.workspace.getConfiguration = () => ({ 11 | get: (key) => { 12 | switch (key) { 13 | case 'decimalPoint': return '10,000.00'; 14 | case 'defaultConvention': return 'PascalCase'; 15 | default: return null; 16 | } 17 | } 18 | }); 19 | }); 20 | 21 | teardown(() => { 22 | vscode.workspace.getConfiguration = originalGetConfiguration; 23 | }); 24 | 25 | test('should strip surrounding quotes from numeric values', () => { 26 | const input = 'Col1\n"1"'; 27 | const result = parseTable.parseClipboard(input); 28 | 29 | // Should catch the issue: currently "1" is parsed as string "1" (with quotes likely kept or treated as string) 30 | // We expect it to be parsed as a number 1 31 | assert.ok(result); 32 | assert.strictEqual(result.data[0][0], 1); 33 | // If it fails, it probably returns "1" (string) or key remains string type 34 | }); 35 | 36 | test('should strip surrounding quotes from string values', () => { 37 | const input = 'Col1\n"foo"'; 38 | const result = parseTable.parseClipboard(input); 39 | 40 | assert.ok(result); 41 | assert.strictEqual(result.data[0][0], 'foo'); 42 | // If it fails, it probably returns "\"foo\"" 43 | }); 44 | 45 | test('complex row with mixed quoted types', () => { 46 | // From issue description: x Apple Pear Lemon "1" 47 | const input = 'x\tApple\tPear\tLemon\n"1"\t"fruit"\t"fruit"\t"fruit"'; 48 | const result = parseTable.parseClipboard(input); 49 | 50 | assert.ok(result); 51 | assert.strictEqual(result.headers[0], 'X'); 52 | assert.strictEqual(result.data[0][0], 1); // "1" -> 1 53 | assert.strictEqual(result.data[0][1], 'fruit'); // "fruit" -> fruit 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master, main, develop ] 6 | pull_request: 7 | branches: [ master, main ] 8 | 9 | jobs: 10 | test: 11 | name: Test Extension 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: '22' 22 | cache: 'npm' 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Install xvfb and setup display 28 | run: | 29 | sudo apt-get update 30 | sudo apt-get install -y xvfb 31 | export DISPLAY=:99 32 | Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 33 | sleep 5 34 | echo "Display setup complete" 35 | 36 | - name: Verify display is working 37 | run: | 38 | export DISPLAY=:99 39 | xdpyinfo > /dev/null 2>&1 && echo "Display is working" || echo "Display not working" 40 | 41 | - name: Run tests 42 | run: npm test 43 | env: 44 | DISPLAY: ':99' 45 | 46 | - name: Check for linting errors 47 | run: npm run lint || echo "No lint script found, skipping lint check" 48 | 49 | build: 50 | name: Build Extension 51 | runs-on: ubuntu-latest 52 | needs: test 53 | 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | 58 | - name: Setup Node.js 59 | uses: actions/setup-node@v4 60 | with: 61 | node-version: '22' 62 | cache: 'npm' 63 | 64 | - name: Install dependencies 65 | run: npm ci 66 | 67 | - name: Build extension 68 | run: | 69 | npm install -g @vscode/vsce 70 | vsce package --out pastum-extension.vsix 71 | 72 | - name: Upload extension artifact 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: pastum-extension 76 | path: pastum-extension.vsix 77 | retention-days: 30 78 | -------------------------------------------------------------------------------- /src/paste-julia.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | const { parseClipboard } = require("./parse-table"); 3 | const { addTrailingZeroes, normalizeBool } = require("./utils"); 4 | 5 | /** 6 | * Parses the clipboard content into a structured table. 7 | */ 8 | async function clipboardToJuliaDataFrame() { 9 | try { 10 | // 1: Read the clipboard content 11 | const clipboardContent = await vscode.env.clipboard.readText(); 12 | 13 | if (!clipboardContent) { 14 | vscode.window.showErrorMessage( 15 | "Clipboard is empty or contains unsupported content." 16 | ); 17 | return; 18 | } 19 | 20 | // 2: Try to extract the table from clipboard content 21 | let formattedData = null; 22 | formattedData = parseClipboard(clipboardContent); 23 | 24 | // 3: Generate the Julia code for DataFrames.jl 25 | const jlCode = createJuliaDataFrame(formattedData); 26 | 27 | if (!jlCode) { 28 | vscode.window.showErrorMessage("Failed to generate Julia code."); 29 | return; 30 | } 31 | 32 | // 4: Insert the generated code into the active editor 33 | const editor = vscode.window.activeTextEditor; 34 | if (editor) { 35 | editor.edit((editBuilder) => { 36 | editBuilder.insert(editor.selection.active, jlCode); 37 | }); 38 | } 39 | } catch (error) { 40 | vscode.window.showErrorMessage(`Error: ${error.message}`); 41 | } 42 | } 43 | 44 | /** 45 | * Generates Julia code for DataFrames.jl 46 | * Creates a DataFrame using column-based construction syntax. 47 | * 48 | * Modified from: https://web-apps.thecoatlessprofessor.com/data/html-table-to-dataframe-tool.html 49 | * 50 | */ 51 | function createJuliaDataFrame(tableData) { 52 | function formatValue(value, colIndex) { 53 | if (value === "") { 54 | return "missing"; 55 | } else if (columnTypes[colIndex] === "string") { 56 | return `"${value}"`; 57 | } else if (columnTypes[colIndex] === "numeric") { 58 | return addTrailingZeroes(value); 59 | } else if (columnTypes[colIndex] === "boolean") { 60 | return normalizeBool(value, "julia"); 61 | } else if (columnTypes[colIndex] === "integer") { 62 | return value; 63 | } else { 64 | return `"${value}"`; 65 | } 66 | } 67 | 68 | const { headers, data, columnTypes } = tableData; 69 | const config = vscode.workspace.getConfiguration("pastum"); 70 | const libraryDeclaration = config.get("libraryDeclaration"); 71 | let code = libraryDeclaration ? `using DataFrames\n\n` : ""; 72 | 73 | code += `DataFrame(\n`; 74 | headers.forEach((header, i) => { 75 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 76 | code += ` :${header} => [${values}]${i < headers.length - 1 ? ",\n" : "\n" 77 | }`; 78 | }); 79 | code += `)`; 80 | 81 | return code; 82 | } 83 | 84 | module.exports = { 85 | clipboardToJuliaDataFrame, 86 | }; 87 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | ## [0.3.2] - 2025-12-22 6 | 7 | Patch release that fixes some vulnerabilities in dependencies. 8 | 9 | ### Fixed 10 | - quoted values are now parsed correctly (#42) 11 | 12 | ## [0.3.1] - 2025-09-12 13 | 14 | ### Added 15 | - Support to generate SQL statements for querying and modifying database tables (in #41, thanks to @juarezr) 16 | 17 | ## [0.3.0] - 2025-09-12 18 | 19 | ### Added 20 | 21 | - Markdown table support (thanks to @juarezr) 22 | - TSV and CSV table support (thanks to @juarezr) 23 | - `pastum.libraryDeclaration` configuration option, which allows the user to add library declaration to the pasted dataframe. (#18) 24 | - `pastum.airFormat` configuration option, which allows the user to add comment to skip air formatting in R. (#16) 25 | 26 | ## [0.2.1] - 2024-11-02 27 | 28 | ### Added 29 | 30 | - JavaScript support (#15) 31 | - Experimental boolean support 32 | 33 | ### Fixed 34 | 35 | - Correct indentation when pasting `tibble::tribble()` 36 | 37 | ## [0.2.0] - 2024-11-01 38 | 39 | ### Added 40 | 41 | - Introducing `Pastum: paste as default dataframe` command — it is now sensitive to the active editor language (#13). That is, if you are writing in a file with a `.py` extension, then VS Code understands that the language you are writing in is Python. In this case, `pastum` will paste the dataframe as a python code according to the configured default dataframe framework (i.e., `pastum.defaultDataframePython` and `pastum.defaultDataframeR` settings). However, full control is still available and unaffected through the command palette. 42 | - You can now control the decimal separator _(e.g., '.' in `12.45`)_ and the digit group separator _(i.e., in numbers over 999)_ through the `pastum.decimalPoint` config (#10). By default, it is set up for a dot (.) as the decimal separator and a comma (,) as the group separator. 43 | 44 | ## [0.1.0] - 2024-10-29 45 | 46 | ### Added 47 | 48 | - Website with main features descriptions — [https://pastum.anatolii.nz](https://pastum.anatolii.nz) (#5) 49 | - `tibble::tribble()` support (#11) 50 | - Paste as `tibble::tribble()` is the default option for `pastum.defaultDataframe` (i.e., context menu) 51 | 52 | ### Fixed 53 | 54 | - Minor grammar and spelling edits 55 | - Fixed distinction between string and numeric column types. If at least one value in the column is a string, the whole column is treated as a string. 56 | - Cyrillic letters support in header rows (#9) 57 | - Removed trailing zeroes when the table is copied from the web (#12) 58 | 59 | ## [0.0.3] - 2024-10-27 60 | 61 | - Added "Paste Default Dataframe" command, which can be set in settings. Allows the user to select the default language and framework. 62 | - Added "Paste Default Dataframe" command to the context menu. 63 | - Ditched the `jsdom` dependency in favor of speed and package size 64 | 65 | ## [0.0.2] - 2024-10-26 66 | 67 | - Added distinction between Integer/Float values 68 | - `Missing`, `NA`, or `None` values are inserted by default if the value is empty 69 | - Added `pastum.defaultConvention` configuration option, which allows the user to choose the column name renaming convention 70 | 71 | ## [0.0.1] - 2024-10-24 72 | 73 | - Initial release 74 | - R dataframes support 75 | - Python dataframes support 76 | - Julia dataframes support 77 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | 3 | function normalizeValue(value, decimalPoints = null) { 4 | const config = vscode.workspace.getConfiguration("pastum"); 5 | decimalPoints = config.get("decimalPoint"); 6 | 7 | if (decimalPoints === "10,000.00") { 8 | return value.replace(/,/g, ""); 9 | } else if (decimalPoints === "10 000.00") { 10 | return value.replace(/ /g, ""); 11 | } else if (decimalPoints === "10 000,00") { 12 | return value.replace(/ /g, "").replace(/,/g, "."); 13 | } else if (decimalPoints === "10.000,00") { 14 | return value.replace(/\./g, "").replace(/,/g, "."); 15 | } else if (decimalPoints === null) { 16 | // return error 17 | vscode.window.showErrorMessage("No default decimalPoint selected"); 18 | } 19 | } 20 | 21 | function isInt(value) { 22 | // Normalize the string by removing thousands separators 23 | const normalized = normalizeValue(value); 24 | // Parse value as a float 25 | const float = parseFloat(normalized); 26 | // Calculate the residual after removing the decimal point 27 | let residual = Math.abs(float % 1); 28 | 29 | return ( 30 | !isNaN(float) && 31 | !residual > 0 && 32 | normalized.trim() !== "" && 33 | !/^0\d/.test(normalized) 34 | ); 35 | } 36 | 37 | function isNumeric(value) { 38 | // Normalize the string by removing thousands separators 39 | const normalized = normalizeValue(value); 40 | 41 | return ( 42 | !isNaN(normalized) && 43 | !isNaN(parseFloat(normalized)) && 44 | normalized.trim() !== "" && 45 | !/^0\d/.test(normalized) 46 | ); // Reject numbers with leading zeros 47 | } 48 | 49 | function isBool(value) { 50 | const normalized = value.toLowerCase().trim(); 51 | let boolCheck = normalized === "true" || normalized === "false"; 52 | return boolCheck && normalized !== "" && !isNaN(normalized); 53 | } 54 | 55 | function normalizeBool(value, language) { 56 | const normalized = value.toLowerCase().trim(); 57 | switch (language) { 58 | case "python": 59 | if (normalized === "true") { 60 | return "True"; 61 | } else if (normalized === "false") { 62 | return "False"; 63 | } 64 | break; 65 | case "r": 66 | if (normalized === "true") { 67 | return "TRUE"; 68 | } else if (normalized === "false") { 69 | return "FALSE"; 70 | } 71 | break; 72 | case "julia": 73 | if (normalized === "true") { 74 | return "true"; 75 | } else if (normalized === "false") { 76 | return "false"; 77 | } 78 | break; 79 | case "javascript": 80 | if (normalized === "true") { 81 | return "true"; 82 | } else if (normalized === "false") { 83 | return "false"; 84 | } 85 | break; 86 | default: 87 | return value; 88 | } 89 | } 90 | 91 | function cleanDataValue(value) { 92 | return value.trim().replace(/\u00A0/g, " "); 93 | } 94 | 95 | function convertValue(value) { 96 | if (isNumeric(value)) { 97 | let float = parseFloat(normalizeValue(value)); 98 | return float; 99 | } else if (isInt(value)) { 100 | return parseInt(normalizeValue(value)); 101 | } 102 | return value; 103 | } 104 | 105 | function isRowEmpty(row) { 106 | return row.every((cell) => cell === "" || cell.trim() === ""); 107 | } 108 | 109 | function addTrailingZeroes(value) { 110 | return value.toString().indexOf(".") === -1 ? value + ".0" : value; 111 | } 112 | 113 | module.exports = { 114 | isInt, 115 | cleanDataValue, 116 | convertValue, 117 | isNumeric, 118 | isRowEmpty, 119 | addTrailingZeroes, 120 | normalizeValue, 121 | normalizeBool, 122 | isBool, 123 | }; 124 | -------------------------------------------------------------------------------- /test/paste-modules.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const vscode = require('vscode'); 3 | 4 | const pasteR = require('../src/paste-r'); 5 | const pastePython = require('../src/paste-python'); 6 | const pasteJulia = require('../src/paste-julia'); 7 | const pasteJS = require('../src/paste-js'); 8 | const pasteMarkdown = require('../src/paste-markdown'); 9 | const pasteSql = require('../src/paste-sql'); 10 | const pasteDefault = require('../src/paste-default'); 11 | 12 | suite('Paste Modules Test Suite', () => { 13 | let mockEditor; 14 | let mockClipboard; 15 | let originalClipboard; 16 | let originalActiveTextEditor; 17 | 18 | setup(() => { 19 | mockEditor = { 20 | selection: { active: { line: 0, character: 0 } }, 21 | edit: (callback) => { 22 | const editBuilder = { 23 | insert: (position, text) => { 24 | mockEditor.insertedText = text; 25 | } 26 | }; 27 | callback(editBuilder); 28 | return Promise.resolve(true); 29 | }, 30 | document: { languageId: 'python' } 31 | }; 32 | 33 | mockClipboard = { 34 | readText: () => Promise.resolve('Name\tAge\tScore\nAlice\t25\t95.5\nBob\t30\t87') 35 | }; 36 | 37 | originalClipboard = vscode.env.clipboard; 38 | originalActiveTextEditor = vscode.window.activeTextEditor; 39 | 40 | vscode.env.clipboard = mockClipboard; 41 | vscode.window.activeTextEditor = mockEditor; 42 | 43 | vscode.workspace.getConfiguration = () => ({ 44 | get: (key) => { 45 | switch (key) { 46 | case 'decimalPoint': return '10,000.00'; 47 | case 'defaultConvention': return 'PascalCase'; 48 | case 'defaultDataframeR': return 'tibble ✨'; 49 | case 'defaultDataframePython': return 'pandas 🐼'; 50 | case 'defaultDataframeJavascript': return 'polars 🐻'; 51 | case 'defaultAligmentMarkdown': return 'columnar ↔️'; 52 | case 'defaultSqlStatement': return 'INSERT INTO VALUES'; 53 | default: return null; 54 | } 55 | } 56 | }); 57 | 58 | vscode.window.showQuickPick = (items) => Promise.resolve(items[0]); 59 | vscode.window.showErrorMessage = () => { }; 60 | }); 61 | 62 | teardown(() => { 63 | vscode.env.clipboard = originalClipboard; 64 | vscode.window.activeTextEditor = originalActiveTextEditor; 65 | }); 66 | 67 | suite('Python Paste Module Tests', () => { 68 | test('clipboardToPyDataFrame - function exists', () => { 69 | assert.strictEqual(typeof pastePython.clipboardToPyDataFrame, 'function'); 70 | }); 71 | }); 72 | 73 | suite('R Paste Module Tests', () => { 74 | test('clipboardToRDataFrame - function exists', () => { 75 | assert.strictEqual(typeof pasteR.clipboardToRDataFrame, 'function'); 76 | }); 77 | }); 78 | 79 | suite('Julia Paste Module Tests', () => { 80 | test('clipboardToJuliaDataFrame - function exists', () => { 81 | assert.strictEqual(typeof pasteJulia.clipboardToJuliaDataFrame, 'function'); 82 | }); 83 | }); 84 | 85 | suite('JavaScript Paste Module Tests', () => { 86 | test('clipboardToJSDataFrame - function exists', () => { 87 | assert.strictEqual(typeof pasteJS.clipboardToJSDataFrame, 'function'); 88 | }); 89 | }); 90 | 91 | suite('Markdown Paste Module Tests', () => { 92 | test('clipboardToMarkdown - function exists', () => { 93 | assert.strictEqual(typeof pasteMarkdown.clipboardToMarkdown, 'function'); 94 | }); 95 | }); 96 | 97 | suite('Sql Paste Module Tests', () => { 98 | test('clipboardToSql - function exists', () => { 99 | assert.strictEqual(typeof pasteSql.clipboardToSql, 'function'); 100 | }); 101 | }); 102 | 103 | suite('Default Paste Module Tests', () => { 104 | test('pasteDefault - function exists', () => { 105 | assert.strictEqual(typeof pasteDefault.pasteDefault, 'function'); 106 | }); 107 | 108 | test('pasteDefault - shows error when no editor is active', async () => { 109 | vscode.window.activeTextEditor = null; 110 | 111 | const originalShowError = vscode.window.showErrorMessage; 112 | let errorShown = false; 113 | 114 | vscode.window.showErrorMessage = (msg) => { 115 | errorShown = true; 116 | assert.ok(msg.includes('No active editor found')); 117 | }; 118 | 119 | await pasteDefault.pasteDefault(); 120 | assert.ok(errorShown); 121 | 122 | vscode.window.showErrorMessage = originalShowError; 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/paste-python.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | const { parseClipboard } = require("./parse-table"); 3 | const { addTrailingZeroes, normalizeBool } = require("./utils"); 4 | 5 | async function clipboardToPyDataFrame(framework = null) { 6 | try { 7 | // 1: Read the clipboard content 8 | const clipboardContent = await vscode.env.clipboard.readText(); 9 | 10 | if (!clipboardContent) { 11 | vscode.window.showErrorMessage( 12 | "Clipboard is empty or contains unsupported content." 13 | ); 14 | return; 15 | } 16 | 17 | // 2: Try to extract the table from clipboard content 18 | let formattedData = null; 19 | formattedData = parseClipboard(clipboardContent); 20 | 21 | // 3: Ask the user which framework they want to use 22 | if (framework === null) { 23 | framework = await vscode.window.showQuickPick( 24 | ["pandas 🐼", "polars 🐻", "datatable 🎩"], 25 | { 26 | placeHolder: "Select the Python framework for creating the dataframe", 27 | } 28 | ); 29 | } 30 | framework = framework.split(" ")[0]; 31 | 32 | if (!framework) { 33 | vscode.window.showErrorMessage("No framework selected."); 34 | return; 35 | } 36 | 37 | // 4: Generate the Python code using the selected framework 38 | const pyCode = createPyDataFrame(formattedData, framework); 39 | 40 | if (!pyCode) { 41 | vscode.window.showErrorMessage("Failed to generate Python code."); 42 | return; 43 | } 44 | 45 | // 5: Insert the generated code into the active editor 46 | const editor = vscode.window.activeTextEditor; 47 | if (editor) { 48 | editor.edit((editBuilder) => { 49 | editBuilder.insert(editor.selection.active, pyCode); 50 | }); 51 | } 52 | } catch (error) { 53 | vscode.window.showErrorMessage(`Error: ${error.message}`); 54 | } 55 | } 56 | 57 | /** 58 | * Generates Python dataframe objects. 59 | * Supports pandas, polars, and datatable frameworks. 60 | * 61 | * Modified from: https://web-apps.thecoatlessprofessor.com/data/html-table-to-dataframe-tool.html 62 | * 63 | */ 64 | function createPyDataFrame(tableData, framework) { 65 | const { headers, data, columnTypes } = tableData; 66 | const config = vscode.workspace.getConfiguration("pastum"); 67 | const libraryDeclaration = config.get("libraryDeclaration"); 68 | let code = ""; 69 | 70 | /** 71 | * Formats a value according to its column type for R syntax 72 | * @param {any} value - The value to format 73 | * @param {number} colIndex - Column index for type lookup 74 | * @returns {string} Formatted value 75 | */ 76 | function formatValue(value, colIndex) { 77 | if (value === "") { 78 | return "None"; 79 | } else if (columnTypes[colIndex] === "string") { 80 | return `"${value}"`; 81 | } else if (columnTypes[colIndex] === "numeric") { 82 | return addTrailingZeroes(value); 83 | } else if (columnTypes[colIndex] === "boolean") { 84 | return normalizeBool(value, "python"); 85 | } else if (columnTypes[colIndex] === "integer") { 86 | return value; 87 | } else { 88 | return `"${value}"`; 89 | } 90 | } 91 | 92 | // pandas 93 | if (framework === "pandas") { 94 | code = libraryDeclaration ? `import pandas as pd\n\n` : ""; 95 | code += `pd.DataFrame({\n`; 96 | headers.forEach((header, i) => { 97 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 98 | code += ` "${header}": [${values}]${i < headers.length - 1 ? ",\n" : "\n" 99 | }`; 100 | }); 101 | code += `})`; 102 | } else if (framework === "datatable") { 103 | code = libraryDeclaration ? `import datatable as dt\n\n` : ""; 104 | code += `dt.Frame({\n`; 105 | headers.forEach((header, i) => { 106 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 107 | code += ` "${header}": [${values}]${i < headers.length - 1 ? ",\n" : "\n" 108 | }`; 109 | }); 110 | code += `})`; 111 | } else if (framework === "polars") { 112 | code = libraryDeclaration ? `import polars as pl\n\n` : ""; 113 | code += `pl.DataFrame({\n`; 114 | headers.forEach((header, i) => { 115 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 116 | code += ` "${header}": [${values}]${i < headers.length - 1 ? ",\n" : "\n" 117 | }`; 118 | }); 119 | code += `})`; 120 | } 121 | 122 | return code; 123 | } 124 | 125 | module.exports = { 126 | clipboardToPyDataFrame, 127 | }; 128 | -------------------------------------------------------------------------------- /src/paste-js.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | const { parseClipboard } = require("./parse-table"); 3 | const { addTrailingZeroes, normalizeBool } = require("./utils"); 4 | 5 | async function clipboardToJSDataFrame(framework = null) { 6 | try { 7 | // 1: Read the clipboard content 8 | const clipboardContent = await vscode.env.clipboard.readText(); 9 | 10 | if (!clipboardContent) { 11 | vscode.window.showErrorMessage( 12 | "Clipboard is empty or contains unsupported content." 13 | ); 14 | return; 15 | } 16 | 17 | // 2: Try to extract the table from clipboard content 18 | let formattedData = null; 19 | formattedData = parseClipboard(clipboardContent); 20 | 21 | // 3: Ask the user which framework they want to use 22 | if (framework === null) { 23 | framework = await vscode.window.showQuickPick( 24 | ["base", "polars 🐻", "arquero 🏹", "danfo 🐝"], 25 | { 26 | placeHolder: 27 | "Select the JavaScript framework for creating the dataframe", 28 | } 29 | ); 30 | } 31 | framework = framework.split(" ")[0]; 32 | 33 | if (!framework) { 34 | vscode.window.showErrorMessage("No framework selected."); 35 | return; 36 | } 37 | 38 | // 4: Generate the JS code using the selected framework 39 | const jsCode = createJSDataFrame(formattedData, framework); 40 | 41 | if (!jsCode) { 42 | vscode.window.showErrorMessage("Failed to generate JavaScript code."); 43 | return; 44 | } 45 | 46 | // 5: Insert the generated code into the active editor 47 | const editor = vscode.window.activeTextEditor; 48 | if (editor) { 49 | editor.edit((editBuilder) => { 50 | editBuilder.insert(editor.selection.active, jsCode); 51 | }); 52 | } 53 | } catch (error) { 54 | vscode.window.showErrorMessage(`Error: ${error.message}`); 55 | } 56 | } 57 | 58 | /** 59 | * Generates JS dataframe objects. 60 | * Supports base, polars frameworks. 61 | * 62 | */ 63 | function createJSDataFrame(tableData, framework) { 64 | const { headers, data, columnTypes } = tableData; 65 | const config = vscode.workspace.getConfiguration("pastum"); 66 | const libraryDeclaration = config.get("libraryDeclaration"); 67 | let code = ""; 68 | 69 | /** 70 | * Formats a value according to its column type for R syntax 71 | * @param {any} value - The value to format 72 | * @param {number} colIndex - Column index for type lookup 73 | * @returns {string} Formatted value 74 | */ 75 | function formatValue(value, colIndex) { 76 | if (value === "") { 77 | return "null"; 78 | } else if (columnTypes[colIndex] === "string") { 79 | return `"${value}"`; 80 | } else if (columnTypes[colIndex] === "numeric") { 81 | return addTrailingZeroes(value); 82 | } else if (columnTypes[colIndex] === "boolean") { 83 | return normalizeBool(value, "javascript"); 84 | } else if (columnTypes[colIndex] === "integer") { 85 | return value; 86 | // FIXME: 87 | // add BigInt? 88 | // return `BigInt(${value})`; 89 | } else { 90 | return `"${value}"`; 91 | } 92 | } 93 | 94 | // base 95 | if (framework === "base") { 96 | code = `const df = {\n`; 97 | headers.forEach((header, i) => { 98 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 99 | code += ` "${header}": [${values}]${ 100 | i < headers.length - 1 ? ",\n" : "\n" 101 | }`; 102 | }); 103 | code += `};`; 104 | } else if (framework === "polars") { 105 | code = libraryDeclaration ? `import pl from "nodejs-polars";\n\n` : ""; 106 | code += `const df = pl.DataFrame({\n`; 107 | headers.forEach((header, i) => { 108 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 109 | code += ` "${header}": [${values}]${ 110 | i < headers.length - 1 ? ",\n" : "\n" 111 | }`; 112 | }); 113 | code += `});`; 114 | } else if (framework === "arquero") { 115 | code = libraryDeclaration ? `import {table} from "arquero";\n\n` : ""; 116 | code += `const df = table({\n`; 117 | headers.forEach((header, i) => { 118 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 119 | code += ` "${header}": [${values}]${ 120 | i < headers.length - 1 ? ",\n" : "\n" 121 | }`; 122 | }); 123 | code += `});`; 124 | } else if (framework === "danfo") { 125 | code = libraryDeclaration ? `import * as dfd from "danfojs-node";\n\n` : ""; 126 | code += `obj_data = {\n`; 127 | headers.forEach((header, i) => { 128 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 129 | code += ` "${header}": [${values}]${ 130 | i < headers.length - 1 ? ",\n" : "\n" 131 | }`; 132 | }); 133 | code += `};\n\n`; 134 | code += `df = new dfd.DataFrame(obj_data);\n\n`; 135 | code += `df.print();`; 136 | } 137 | 138 | return code; 139 | } 140 | 141 | module.exports = { 142 | clipboardToJSDataFrame, 143 | }; 144 | -------------------------------------------------------------------------------- /src/paste-markdown.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | const { parseClipboard } = require("./parse-table"); 3 | const { addTrailingZeroes, normalizeBool } = require("./utils"); 4 | 5 | async function clipboardToMarkdown(alignment = null) { 6 | try { 7 | // 1: Read the clipboard content 8 | const clipboardContent = await vscode.env.clipboard.readText(); 9 | 10 | if (!clipboardContent) { 11 | vscode.window.showErrorMessage( 12 | "Clipboard is empty or contains unsupported content." 13 | ); 14 | return; 15 | } 16 | 17 | // 2: Try to extract the table from clipboard content 18 | let formattedData = null; 19 | formattedData = parseClipboard(clipboardContent); 20 | 21 | // 3: Ask the user which alignment they want to use 22 | if (alignment === null) { 23 | alignment = await vscode.window.showQuickPick( 24 | ["columnar ↔️", "compact ↩️"], 25 | { 26 | placeHolder: "Select the alignment for creating the Markdown table", 27 | } 28 | ); 29 | } 30 | alignment = alignment.split(" ")[0]; 31 | 32 | if (!alignment) { 33 | vscode.window.showErrorMessage("No alignment selected."); 34 | return; 35 | } 36 | 37 | // 4: Generate the Markdown code using the selected alignment 38 | const pyCode = createMarkdown(formattedData, alignment); 39 | 40 | if (!pyCode) { 41 | vscode.window.showErrorMessage("Failed to generate Markdown code."); 42 | return; 43 | } 44 | 45 | // 5: Insert the generated code into the active editor 46 | const editor = vscode.window.activeTextEditor; 47 | if (editor) { 48 | editor.edit((editBuilder) => { 49 | editBuilder.insert(editor.selection.active, pyCode); 50 | }); 51 | } 52 | } catch (error) { 53 | vscode.window.showErrorMessage(`Error: ${error.message}`); 54 | } 55 | } 56 | 57 | /** 58 | * Generates a markdown table. 59 | * Supports columnar and compact alignments. 60 | */ 61 | function createMarkdown(tableData, alignment) { 62 | const { headers, data, columnTypes } = tableData; 63 | let code = "\n"; 64 | 65 | /** 66 | * Formats a value according to its column type for R syntax 67 | * @param {any} value - The value to format 68 | * @param {number} colIndex - Column index for type lookup 69 | * @returns {string} Formatted value 70 | */ 71 | function formatValue(value, colIndex) { 72 | if (value === "") { 73 | return ""; 74 | } else if (columnTypes[colIndex] === "string") { 75 | return `${value}`; 76 | } else if (columnTypes[colIndex] === "numeric") { 77 | return addTrailingZeroes(value); 78 | } else if (columnTypes[colIndex] === "boolean") { 79 | return normalizeBool(value, "javascript"); 80 | } else if (columnTypes[colIndex] === "integer") { 81 | return value; 82 | } else { 83 | return `${value}`; 84 | } 85 | } 86 | 87 | // Calculate column widths based on header and data lengths 88 | function calculateColumnWidths(columns, rows) { 89 | return columns.map((header, colIndex) => { 90 | const headerWidth = header.length; 91 | const maxDataWidth = Math.max( 92 | ...rows.map( 93 | (row) => formatValue(row[colIndex], colIndex).toString().length 94 | ) 95 | ); 96 | return Math.max(headerWidth, maxDataWidth); 97 | }); 98 | } 99 | 100 | function getAlign(colIndex) { 101 | let colt = columnTypes[colIndex]; 102 | return (colt === "numeric") || (colt === "integer"); 103 | } 104 | 105 | 106 | // Pads a value to the target width 107 | function padToWidth(value, width, padding, before) { 108 | let wide = width - value.toString().length; 109 | let fill = padding.repeat(wide); 110 | return before ? fill + value : value + fill; 111 | } 112 | 113 | if (alignment === "compact") { 114 | // Column headers without padding 115 | let vals = headers.map((header, i) => header).join(" | "); 116 | code = `| ${vals} |\n`; 117 | // Column headers/rows separators without padding 118 | vals = headers.map((header, i) => "-").join("-|-"); 119 | code += `|-${vals}-|\n`; 120 | 121 | // Data rows without padding 122 | data.forEach((row) => { 123 | const rowValues = row 124 | .map((value, i) => ` ${formatValue(value, i)} `).join(" | "); 125 | code += `| ${rowValues} |\n`; 126 | }); 127 | 128 | } else if (alignment === "columnar") { 129 | // Calculate column widths based on header and data lengths 130 | const colWidths = calculateColumnWidths(headers, data); 131 | // Column headers with padding 132 | let vals = headers 133 | .map((header, i) => padToWidth(header, colWidths[i], " ", false)) 134 | .join(" | "); 135 | code = `| ${vals} |\n`; 136 | // Column headers/rows separators with padding 137 | vals = headers 138 | .map((header, i) => padToWidth("-", colWidths[i], "-", false)) 139 | .join("-|-"); 140 | code += `|-${vals}-|\n`; 141 | 142 | // Data rows with padding 143 | data.forEach((row) => { 144 | const cells = row 145 | .map((value, i) => padToWidth( 146 | formatValue(value, i), 147 | colWidths[i], " ", getAlign(i) 148 | )).join(" | "); 149 | code += `| ${cells} |\n`; 150 | }); 151 | } 152 | 153 | return code + "\n"; 154 | } 155 | 156 | module.exports = { 157 | clipboardToMarkdown, 158 | }; 159 | -------------------------------------------------------------------------------- /test/extension.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const vscode = require('vscode'); 3 | const path = require('path'); 4 | 5 | const utils = require('../src/utils'); 6 | const parseTable = require('../src/parse-table'); 7 | const extension = require('../extension'); 8 | const pasteDefault = require('../src/paste-default'); 9 | 10 | suite('Pastum Extension Test Suite', () => { 11 | vscode.window.showInformationMessage('Running Pastum tests...'); 12 | 13 | suite('Utils Module Tests', () => { 14 | const originalGetConfiguration = vscode.workspace.getConfiguration; 15 | 16 | setup(() => { 17 | vscode.workspace.getConfiguration = () => ({ 18 | get: (key) => { 19 | if (key === 'decimalPoint') return '10,000.00'; 20 | return null; 21 | } 22 | }); 23 | }); 24 | 25 | teardown(() => { 26 | vscode.workspace.getConfiguration = originalGetConfiguration; 27 | }); 28 | 29 | test('normalizeValue - removes commas for 10,000.00 format', () => { 30 | const result = utils.normalizeValue('12,345.67'); 31 | assert.strictEqual(result, '12345.67'); 32 | }); 33 | 34 | test('normalizeValue - handles spaces for 10 000.00 format', () => { 35 | vscode.workspace.getConfiguration = () => ({ 36 | get: () => '10 000.00' 37 | }); 38 | const result = utils.normalizeValue('12 345.67'); 39 | assert.strictEqual(result, '12345.67'); 40 | }); 41 | 42 | test('normalizeValue - handles European format 10 000,00', () => { 43 | vscode.workspace.getConfiguration = () => ({ 44 | get: () => '10 000,00' 45 | }); 46 | const result = utils.normalizeValue('12 345,67'); 47 | assert.strictEqual(result, '12345.67'); 48 | }); 49 | 50 | test('normalizeValue - handles German format 10.000,00', () => { 51 | vscode.workspace.getConfiguration = () => ({ 52 | get: () => '10.000,00' 53 | }); 54 | const result = utils.normalizeValue('12.345,67'); 55 | assert.strictEqual(result, '12345.67'); 56 | }); 57 | 58 | 59 | test('isNumeric - identifies numeric values correctly', () => { 60 | assert.strictEqual(utils.isNumeric('123'), true); 61 | assert.strictEqual(utils.isNumeric('123.45'), true); 62 | assert.strictEqual(utils.isNumeric('abc'), false); 63 | assert.strictEqual(utils.isNumeric(''), false); 64 | assert.strictEqual(utils.isNumeric('01'), false); 65 | }); 66 | 67 | 68 | test('normalizeBool - converts boolean values for different languages', () => { 69 | assert.strictEqual(utils.normalizeBool('true', 'python'), 'True'); 70 | assert.strictEqual(utils.normalizeBool('false', 'python'), 'False'); 71 | assert.strictEqual(utils.normalizeBool('true', 'r'), 'TRUE'); 72 | assert.strictEqual(utils.normalizeBool('false', 'r'), 'FALSE'); 73 | assert.strictEqual(utils.normalizeBool('true', 'julia'), 'true'); 74 | assert.strictEqual(utils.normalizeBool('false', 'julia'), 'false'); 75 | assert.strictEqual(utils.normalizeBool('true', 'javascript'), 'true'); 76 | assert.strictEqual(utils.normalizeBool('false', 'javascript'), 'false'); 77 | }); 78 | 79 | test('cleanDataValue - removes non-breaking spaces', () => { 80 | const input = ' test\u00A0value '; 81 | const result = utils.cleanDataValue(input); 82 | assert.strictEqual(result, 'test value'); 83 | }); 84 | 85 | test('convertValue - converts numeric strings to numbers', () => { 86 | assert.strictEqual(utils.convertValue('123'), 123); 87 | assert.strictEqual(utils.convertValue('123.45'), 123.45); 88 | assert.strictEqual(utils.convertValue('abc'), 'abc'); 89 | }); 90 | 91 | test('isRowEmpty - detects empty rows', () => { 92 | assert.strictEqual(utils.isRowEmpty(['', '', '']), true); 93 | assert.strictEqual(utils.isRowEmpty([' ', ' ', ' ']), true); 94 | assert.strictEqual(utils.isRowEmpty(['a', '', '']), false); 95 | }); 96 | 97 | test('addTrailingZeroes - adds decimal point to integers', () => { 98 | assert.strictEqual(utils.addTrailingZeroes(123), '123.0'); 99 | assert.strictEqual(utils.addTrailingZeroes('123.45'), '123.45'); 100 | }); 101 | }); 102 | 103 | suite('Parse Table Module Tests', () => { 104 | const originalGetConfiguration = vscode.workspace.getConfiguration; 105 | 106 | setup(() => { 107 | vscode.workspace.getConfiguration = () => ({ 108 | get: (key) => { 109 | if (key === 'decimalPoint') return '10,000.00'; 110 | if (key === 'defaultConvention') return 'PascalCase'; 111 | return null; 112 | } 113 | }); 114 | }); 115 | 116 | teardown(() => { 117 | vscode.workspace.getConfiguration = originalGetConfiguration; 118 | }); 119 | 120 | test('parseClipboard - parses simple table correctly', () => { 121 | const input = 'Name\tAge\tScore\nAlice\t25\t95.5\nBob\t30\t87.2'; 122 | const result = parseTable.parseClipboard(input); 123 | 124 | assert.strictEqual(result.headers.length, 3); 125 | assert.strictEqual(result.headers[0], 'Name'); 126 | assert.strictEqual(result.headers[1], 'Age'); 127 | assert.strictEqual(result.headers[2], 'Score'); 128 | 129 | assert.strictEqual(result.data.length, 2); 130 | assert.strictEqual(result.data[0][0], 'Alice'); 131 | assert.strictEqual(result.data[0][1], 25); 132 | assert.strictEqual(result.data[0][2], 95.5); 133 | }); 134 | 135 | 136 | }); 137 | 138 | suite('Extension Activation Tests', () => { 139 | test('activate - registers all commands', () => { 140 | const mockContext = { 141 | subscriptions: [] 142 | }; 143 | 144 | const originalRegisterCommand = vscode.commands.registerCommand; 145 | const registeredCommands = []; 146 | 147 | vscode.commands.registerCommand = (command, handler) => { 148 | registeredCommands.push(command); 149 | return { dispose: () => { } }; 150 | }; 151 | 152 | extension.activate(mockContext); 153 | 154 | const expectedCommands = [ 155 | 'pastum.Rdataframe', 156 | 'pastum.Pydataframe', 157 | 'pastum.Jldataframe', 158 | 'pastum.JSdataframe', 159 | 'pastum.Markdown', 160 | 'pastum.Sql', 161 | 'pastum.Defaultdataframe' 162 | ]; 163 | 164 | expectedCommands.forEach(cmd => { 165 | assert.ok(registeredCommands.includes(cmd), `Command ${cmd} should be registered`); 166 | }); 167 | 168 | vscode.commands.registerCommand = originalRegisterCommand; 169 | }); 170 | 171 | test('deactivate - function exists', () => { 172 | assert.strictEqual(typeof extension.deactivate, 'function'); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/paste-r.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | const { parseClipboard } = require("./parse-table"); 3 | const { addTrailingZeroes, normalizeBool } = require("./utils"); 4 | 5 | async function clipboardToRDataFrame(framework = null) { 6 | try { 7 | // 1: Read the clipboard content 8 | const clipboardContent = await vscode.env.clipboard.readText(); 9 | 10 | if (!clipboardContent) { 11 | vscode.window.showErrorMessage( 12 | "Clipboard is empty or contains unsupported content." 13 | ); 14 | return; 15 | } 16 | 17 | // 2: Try to extract the table from clipboard content 18 | let formattedData = null; 19 | formattedData = parseClipboard(clipboardContent); 20 | 21 | // 3: Ask the user which framework they want to use 22 | if (framework === null) { 23 | framework = await vscode.window.showQuickPick( 24 | ["base", "tribble 🔢", "tibble ✨", "data.table 🎩", "polars 🐻"], 25 | { placeHolder: "Select the R framework for creating the dataframe" } 26 | ); 27 | } 28 | framework = framework.split(" ")[0]; 29 | 30 | if (!framework) { 31 | vscode.window.showErrorMessage("No framework selected."); 32 | return; 33 | } 34 | 35 | // 4: Generate the R code using the selected framework 36 | const rCode = createRDataFrame(formattedData, framework); 37 | 38 | if (!rCode) { 39 | vscode.window.showErrorMessage("Failed to generate R code."); 40 | return; 41 | } 42 | 43 | // 5: Insert the generated code into the active editor 44 | const editor = vscode.window.activeTextEditor; 45 | if (editor) { 46 | editor.edit((editBuilder) => { 47 | editBuilder.insert(editor.selection.active, rCode); 48 | }); 49 | } 50 | } catch (error) { 51 | vscode.window.showErrorMessage(`Error: ${error.message}`); 52 | } 53 | } 54 | 55 | /** 56 | * Generates R dataframe objects. 57 | * Supports base R, tibble, data.table, and R polars frameworks. 58 | * 59 | * Modified from: https://web-apps.thecoatlessprofessor.com/data/html-table-to-dataframe-tool.html 60 | * 61 | * Framework-specific details: 62 | * - base R: Uses data.frame() constructor, no package dependencies 63 | * - tibble: Uses tibble() constructor, requires tibble package 64 | * - data.table: Uses data.table() constructor, requires data.table package 65 | * - polars: Uses pl$DataFrame() constructor, requires polars package 66 | * 67 | * @param {Object} tableData - Processed table data 68 | * @param {Array} tableData.headers - Column names 69 | * @param {Array>} tableData.data - Table values 70 | * @param {Array} tableData.columnTypes - Column types ('numeric' or 'string') 71 | * @param {string} framework - R framework to use ('base', 'tibble', 'data.table', 'polars') 72 | * @returns {string} Generated R code 73 | * 74 | */ 75 | function createRDataFrame(tableData, framework) { 76 | const { headers, data, columnTypes } = tableData; 77 | const config = vscode.workspace.getConfiguration("pastum"); 78 | const airFormat = config.get("airFormat"); 79 | 80 | let code = airFormat ? "# fmt:skip\n" : ""; 81 | // let code = ""; 82 | 83 | /** 84 | * Formats a value according to its column type for R syntax 85 | * @param {any} value - The value to format 86 | * @param {number} colIndex - Column index for type lookup 87 | * @returns {string} Formatted value 88 | */ 89 | function formatValue(value, colIndex) { 90 | if (value === "") { 91 | return "NA"; 92 | } else if (columnTypes[colIndex] === "string") { 93 | return `"${value}"`; 94 | } else if (columnTypes[colIndex] === "numeric") { 95 | return addTrailingZeroes(value); 96 | } else if (columnTypes[colIndex] === "boolean") { 97 | return normalizeBool(value, "r"); 98 | } else if (columnTypes[colIndex] === "integer") { 99 | return value + "L"; 100 | } else { 101 | return `"${value}"`; 102 | } 103 | } 104 | 105 | // Calculate column widths based on header and data lengths 106 | function calculateColumnWidths() { 107 | return headers.map((header, colIndex) => { 108 | const headerWidth = header.length + 1; // +1 for `~` in tribble 109 | const maxDataWidth = Math.max( 110 | ...data.map( 111 | (row) => formatValue(row[colIndex], colIndex).toString().length 112 | ) 113 | ); 114 | return Math.max(headerWidth, maxDataWidth); 115 | }); 116 | } 117 | 118 | // Pads a value to the target width 119 | function padToWidth(value, width) { 120 | return value + " ".repeat(width - value.toString().length); 121 | } 122 | 123 | // Generate code based on selected framework 124 | if (framework === "base") { 125 | code += `data.frame(\n`; 126 | headers.forEach((header, i) => { 127 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 128 | code += ` ${header} = c(${values})${i < headers.length - 1 ? ",\n" : "\n" 129 | }`; 130 | }); 131 | code += `)`; 132 | } else if (framework === "tibble") { 133 | code += `tibble::tibble(\n`; 134 | headers.forEach((header, i) => { 135 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 136 | code += ` ${header} = c(${values})${i < headers.length - 1 ? ",\n" : "\n" 137 | }`; 138 | }); 139 | code += `)`; 140 | } else if (framework === "tribble") { 141 | const colWidths = calculateColumnWidths(); 142 | code += `tibble::tribble(\n`; 143 | 144 | // Column headers with padding 145 | code += 146 | " " + 147 | headers 148 | // Increment by 1 to account for `,` 149 | .map((header, i) => padToWidth(`~${header},`, colWidths[i] + 1)) 150 | .join(" ") + 151 | "\n"; 152 | 153 | // Data rows with padding 154 | data.forEach((row) => { 155 | const rowValues = row 156 | .map((value, i) => 157 | // Increment by 1 to account for `,` 158 | padToWidth(`${formatValue(value, i)},`, colWidths[i] + 1) 159 | ) 160 | .join(" "); 161 | code += ` ${rowValues}\n`; 162 | }); 163 | 164 | // Remove trailing comma and close parentheses 165 | code = code.trimEnd().slice(0, -1) + `\n)`; 166 | } else if (framework === "data.table") { 167 | code += `data.table::data.table(\n`; 168 | headers.forEach((header, i) => { 169 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 170 | code += ` ${header} = c(${values})${i < headers.length - 1 ? ",\n" : "\n" 171 | }`; 172 | }); 173 | code += `)`; 174 | } else if (framework === "polars") { 175 | code += `polars::pl$DataFrame(\n`; 176 | headers.forEach((header, i) => { 177 | const values = data.map((row) => formatValue(row[i], i)).join(", "); 178 | code += ` ${header} = c(${values})${i < headers.length - 1 ? ",\n" : "\n" 179 | }`; 180 | }); 181 | code += `)`; 182 | } 183 | 184 | return code; 185 | } 186 | 187 | module.exports = { 188 | clipboardToRDataFrame, 189 | }; 190 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pastum", 3 | "displayName": "Pastum", 4 | "description": "Convert table from clipboard to R, Python, Julia, or JS dataframes and also to SQL or Markdown", 5 | "version": "0.3.2", 6 | "publisher": "atsyplenkov", 7 | "license": "MIT", 8 | "pricing": "Free", 9 | "icon": "assets/logo.png", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/atsyplenkov/pastum" 13 | }, 14 | "homepage": "https://github.com/atsyplenkov/pastum", 15 | "bugs": { 16 | "url": "https://github.com/atsyplenkov/pastum/issues", 17 | "email": "atsyplenkov@fastmail.com" 18 | }, 19 | "engines": { 20 | "vscode": "^1.104.0" 21 | }, 22 | "categories": [ 23 | "Other", 24 | "Snippets" 25 | ], 26 | "keywords": [ 27 | "paste", 28 | "table", 29 | "markdown", 30 | "paste table", 31 | "keybindings" 32 | ], 33 | "activationEvents": [], 34 | "main": "./extension.js", 35 | "contributes": { 36 | "commands": [ 37 | { 38 | "command": "pastum.Rdataframe", 39 | "title": "Table ➔ R Dataframe", 40 | "category": "Pastum" 41 | }, 42 | { 43 | "command": "pastum.Pydataframe", 44 | "title": "Table ➔ Python Dataframe", 45 | "category": "Pastum" 46 | }, 47 | { 48 | "command": "pastum.Jldataframe", 49 | "title": "Table ➔ Julia Dataframe", 50 | "category": "Pastum" 51 | }, 52 | { 53 | "command": "pastum.JSdataframe", 54 | "title": "Table ➔ JavaScript Dataframe", 55 | "category": "Pastum" 56 | }, 57 | { 58 | "command": "pastum.Sql", 59 | "title": "Table ➔ SQL", 60 | "category": "Pastum" 61 | }, 62 | { 63 | "command": "pastum.Markdown", 64 | "title": "Table ➔ Markdown", 65 | "category": "Pastum" 66 | }, 67 | { 68 | "command": "pastum.Defaultdataframe", 69 | "title": "Pastum: paste as default dataframe" 70 | } 71 | ], 72 | "menus": { 73 | "editor/context": [ 74 | { 75 | "when": "config.pastum.showContextMenu && (editorLangId == 'r' || editorLangId == 'python' || editorLangId == 'julia'|| editorLangId == 'javascript')", 76 | "command": "pastum.Defaultdataframe", 77 | "group": "navigation@1" 78 | } 79 | ] 80 | }, 81 | "configuration": { 82 | "type": "object", 83 | "title": "Pastum Configuration", 84 | "properties": { 85 | "pastum.decimalPoint": { 86 | "type": "string", 87 | "enum": [ 88 | "10,000.00", 89 | "10 000.00", 90 | "10 000,00", 91 | "10.000,00" 92 | ], 93 | "default": "10,000.00", 94 | "markdownDescription": "Select default decimal separator *(e.g., '.' in `12.45`)* and digit group separator *(i.e. in numbers over 999)*. For example, `12,345.67` will be converted to `12345.67`. To learn more about decimal point and digit group separator, see [Wikipedia article](https://en.m.wikipedia.org/wiki/Decimal_separator)." 95 | }, 96 | "pastum.defaultConvention": { 97 | "type": "string", 98 | "enum": [ 99 | "PascalCase", 100 | "camelCase", 101 | "snake_case" 102 | ], 103 | "default": "PascalCase", 104 | "markdownDescription": "Select naming convention for column names preprocessing. To learn more about naming conventions in programming, see [freecodecamp post](https://www.freecodecamp.org/news/snake-case-vs-camel-case-vs-pascal-case-vs-kebab-case-whats-the-difference/#kebab-case). For example, `Hello World!` will be converted to \n\n - PascalCase: `HelloWorld` \n\n - camelCase: `helloWorld` \n\n - snake_case: `hello_world`" 105 | }, 106 | "pastum.showContextMenu": { 107 | "type": "boolean", 108 | "default": true, 109 | "markdownDescription": "Show the `Pastum: paste as default dataframe` command in the editor context menu *(i.e., right-click menu)*. It will only appear in R, Python, and Julia editors. The dataframe will be pasted according to the specified `pastum.defaultDataframeR` and `pastum.defaultDataframePy`." 110 | }, 111 | "pastum.libraryDeclaration": { 112 | "type": "boolean", 113 | "default": true, 114 | "markdownDescription": "Add library declaration to the pasted dataframe. (i.e., `import pandas as pd`) before the pasted dataframe." 115 | }, 116 | "pastum.airFormat": { 117 | "type": "boolean", 118 | "default": true, 119 | "markdownDescription": "Add comment to skip air formatting in R. (i.e., `# fmt:skip`) before the pasted dataframe." 120 | }, 121 | "pastum.defaultDataframeR": { 122 | "type": "string", 123 | "enum": [ 124 | "base", 125 | "tribble 🔢", 126 | "tibble ✨", 127 | "data.table 🎩" 128 | ], 129 | "default": "tribble 🔢", 130 | "markdownDescription": "Select the default framework for R dataframes to be pasted using the `pastum.Defaultdataframe` command." 131 | }, 132 | "pastum.defaultDataframePython": { 133 | "type": "string", 134 | "enum": [ 135 | "pandas 🐼", 136 | "polars 🐻", 137 | "datatable 🎩" 138 | ], 139 | "default": "pandas 🐼", 140 | "markdownDescription": "Select the default framework for Python dataframes to be pasted using the `pastum.Defaultdataframe` command." 141 | }, 142 | "pastum.defaultDataframeJavascript": { 143 | "type": "string", 144 | "enum": [ 145 | "base", 146 | "polars 🐻", 147 | "arquero 🏹", 148 | "danfo 🐝" 149 | ], 150 | "default": "polars 🐻", 151 | "markdownDescription": "Select the default framework for JavaScript dataframes to be pasted using the `pastum.Defaultdataframe` command." 152 | }, 153 | "pastum.defaultAligmentMarkdown": { 154 | "type": "string", 155 | "enum": [ 156 | "columnar ↔️", 157 | "compact ↩️" 158 | ], 159 | "default": "columnar ↔️", 160 | "markdownDescription": "Select the default aligment for Markdown tables to be pasted using the `pastum.Defaultdataframe` command." 161 | }, 162 | "pastum.defaultSqlStatement": { 163 | "type": "string", 164 | "enum": [ 165 | "SELECT FROM VALUES", 166 | "SELECT UNION ALL", 167 | "INSERT INTO VALUES", 168 | "INSERT INTO SELECT VALUES", 169 | "INSERT INTO", 170 | "DELETE WHERE", 171 | "UPDATE WHERE", 172 | "MERGE INTO", 173 | "CREATE TABLE" 174 | ], 175 | "default": "INSERT INTO VALUES", 176 | "markdownDescription": "Select the default SQL statement to be pasted using the `pastum.Defaultdataframe` command." 177 | } 178 | } 179 | } 180 | }, 181 | "scripts": { 182 | "test": "vscode-test" 183 | }, 184 | "devDependencies": { 185 | "@types/node": "24.x", 186 | "@types/vscode": "^1.104.0", 187 | "@vscode/test-cli": "^0.0.12", 188 | "@vscode/test-electron": "^2.5.2", 189 | "typescript": "^5.9.3" 190 | } 191 | } -------------------------------------------------------------------------------- /test/edge-cases.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const vscode = require('vscode'); 3 | 4 | const utils = require('../src/utils'); 5 | const parseTable = require('../src/parse-table'); 6 | 7 | suite('Edge Cases and Error Handling Test Suite', () => { 8 | const originalGetConfiguration = vscode.workspace.getConfiguration; 9 | 10 | setup(() => { 11 | vscode.workspace.getConfiguration = () => ({ 12 | get: (key) => { 13 | switch (key) { 14 | case 'decimalPoint': return '10,000.00'; 15 | case 'defaultConvention': return 'PascalCase'; 16 | default: return null; 17 | } 18 | } 19 | }); 20 | }); 21 | 22 | teardown(() => { 23 | vscode.workspace.getConfiguration = originalGetConfiguration; 24 | }); 25 | 26 | suite('Utils Edge Cases', () => { 27 | test('normalizeValue - handles null decimalPoint configuration', () => { 28 | vscode.workspace.getConfiguration = () => ({ 29 | get: () => null 30 | }); 31 | 32 | const originalShowError = vscode.window.showErrorMessage; 33 | let errorShown = false; 34 | 35 | vscode.window.showErrorMessage = (msg) => { 36 | errorShown = true; 37 | assert.ok(msg.includes('No default decimalPoint selected')); 38 | }; 39 | 40 | const result = utils.normalizeValue('12,345.67', null); 41 | assert.ok(errorShown); 42 | 43 | vscode.window.showErrorMessage = originalShowError; 44 | }); 45 | 46 | test('isInt - handles edge cases with leading zeros', () => { 47 | assert.strictEqual(utils.isInt('007'), false); 48 | assert.strictEqual(utils.isInt('0123'), false); 49 | assert.strictEqual(utils.isInt('0'), true); 50 | }); 51 | 52 | test('isNumeric - handles scientific notation', () => { 53 | assert.strictEqual(utils.isNumeric('1e5'), true); 54 | assert.strictEqual(utils.isNumeric('1E-3'), true); 55 | assert.strictEqual(utils.isNumeric('1.23e+4'), true); 56 | }); 57 | 58 | 59 | test('normalizeBool - handles unknown language', () => { 60 | const result = utils.normalizeBool('true', 'unknown'); 61 | assert.strictEqual(result, 'true'); 62 | }); 63 | 64 | test('convertValue - handles complex numeric formats', () => { 65 | vscode.workspace.getConfiguration = () => ({ 66 | get: () => '10.000,00' 67 | }); 68 | 69 | const result = utils.convertValue('1.234.567,89'); 70 | assert.strictEqual(result, 1234567.89); 71 | }); 72 | 73 | test('isRowEmpty - handles rows with only whitespace characters', () => { 74 | assert.strictEqual(utils.isRowEmpty(['\t', '\n', '\r']), true); 75 | assert.strictEqual(utils.isRowEmpty(['\u00A0', ' ', '']), true); 76 | }); 77 | }); 78 | 79 | suite('Parse Table Edge Cases', () => { 80 | test('parseClipboard - handles malformed table data', () => { 81 | const input = 'Header1\nIncompleteRow'; 82 | 83 | try { 84 | const result = parseTable.parseClipboard(input); 85 | assert.ok(result); 86 | assert.strictEqual(result.headers.length, 1); 87 | assert.strictEqual(result.data.length, 1); 88 | } catch (error) { 89 | assert.ok(error.message.includes('Invalid table format')); 90 | } 91 | }); 92 | 93 | test('parseClipboard - handles table with only headers', () => { 94 | const input = 'Header1\tHeader2\tHeader3'; 95 | 96 | try { 97 | const result = parseTable.parseClipboard(input); 98 | assert.fail('Should have thrown an error for table with no data rows'); 99 | } catch (error) { 100 | assert.ok(error.message.includes('No data rows found')); 101 | } 102 | }); 103 | 104 | test('parseClipboard - handles empty input', () => { 105 | const input = ''; 106 | 107 | try { 108 | const result = parseTable.parseClipboard(input); 109 | assert.fail('Should have thrown an error for empty input'); 110 | } catch (error) { 111 | assert.ok(error.message.includes('Invalid table format')); 112 | } 113 | }); 114 | 115 | 116 | test('parseClipboard - handles table with special characters in headers', () => { 117 | const input = 'Column Name!\t@Price$\t%Change\nValue1\t100\t5.5'; 118 | const result = parseTable.parseClipboard(input); 119 | 120 | assert.ok(result); 121 | assert.strictEqual(result.headers[0], 'ColumnName'); 122 | assert.strictEqual(result.headers[1], 'Price'); 123 | assert.strictEqual(result.headers[2], 'Change'); 124 | }); 125 | 126 | test('parseClipboard - handles table with numeric-like strings', () => { 127 | const input = 'ID\tCode\n001\t002\n003\t004'; 128 | const result = parseTable.parseClipboard(input); 129 | 130 | assert.ok(result); 131 | assert.deepStrictEqual(result.columnTypes, ['string', 'string']); 132 | }); 133 | 134 | 135 | test('parseClipboard - handles mixed boolean and string values', () => { 136 | const input = 'Status\nTrue\nFalse\nMaybe'; 137 | const result = parseTable.parseClipboard(input); 138 | 139 | assert.ok(result); 140 | assert.strictEqual(result.columnTypes[0], 'string'); 141 | }); 142 | 143 | test('parseClipboard - handles very large numbers', () => { 144 | const input = 'BigNumber\n999999999999999\n1000000000000000'; 145 | const result = parseTable.parseClipboard(input); 146 | 147 | assert.ok(result); 148 | assert.ok(result.columnTypes[0] === 'integer' || result.columnTypes[0] === 'numeric'); 149 | }); 150 | 151 | test('parseClipboard - handles negative numbers', () => { 152 | const input = 'Value\n-123\n-456.78\n-0.001'; 153 | const result = parseTable.parseClipboard(input); 154 | 155 | assert.ok(result); 156 | assert.strictEqual(result.columnTypes[0], 'numeric'); 157 | }); 158 | 159 | test('parseClipboard - handles different naming conventions', () => { 160 | vscode.workspace.getConfiguration = () => ({ 161 | get: (key) => { 162 | if (key === 'decimalPoint') return '10,000.00'; 163 | if (key === 'defaultConvention') return 'snake_case'; 164 | return null; 165 | } 166 | }); 167 | 168 | const input = 'Long Column Name\tAnother Header\nValue1\tValue2'; 169 | const result = parseTable.parseClipboard(input); 170 | 171 | assert.ok(result); 172 | assert.strictEqual(result.headers[0], 'long_column_name'); 173 | assert.strictEqual(result.headers[1], 'another_header'); 174 | }); 175 | 176 | test('parseClipboard - handles camelCase convention', () => { 177 | vscode.workspace.getConfiguration = () => ({ 178 | get: (key) => { 179 | if (key === 'decimalPoint') return '10,000.00'; 180 | if (key === 'defaultConvention') return 'camelCase'; 181 | return null; 182 | } 183 | }); 184 | 185 | const input = 'First Column\tSecond Column\nValue1\tValue2'; 186 | const result = parseTable.parseClipboard(input); 187 | 188 | assert.ok(result); 189 | assert.strictEqual(result.headers[0], 'firstColumn'); 190 | assert.strictEqual(result.headers[1], 'secondColumn'); 191 | }); 192 | 193 | test('parseClipboard - handles headers starting with numbers', () => { 194 | const input = '1stColumn\t2ndColumn\nValue1\tValue2'; 195 | const result = parseTable.parseClipboard(input); 196 | 197 | assert.ok(result); 198 | assert.ok(result.headers[0].startsWith('x') || result.headers[0].startsWith('_')); 199 | assert.ok(result.headers[1].startsWith('x') || result.headers[1].startsWith('_')); 200 | }); 201 | 202 | test('parseClipboard - handles Cyrillic characters in headers', () => { 203 | const input = 'Имя\tВозраст\nАлиса\t25\nБоб\t30'; 204 | const result = parseTable.parseClipboard(input); 205 | 206 | assert.ok(result); 207 | assert.ok(result.headers[0].includes('Имя') || result.headers[0] === 'Имя'); 208 | assert.ok(result.headers[1].includes('Возраст') || result.headers[1] === 'Возраст'); 209 | }); 210 | }); 211 | 212 | suite('Memory and Performance Edge Cases', () => { 213 | test('parseClipboard - handles large table data', () => { 214 | const rows = ['ID\tName\tValue']; 215 | for (let i = 1; i <= 1000; i++) { 216 | rows.push(`${i}\tName${i}\t${i * 1.5}`); 217 | } 218 | const input = rows.join('\n'); 219 | 220 | const result = parseTable.parseClipboard(input); 221 | 222 | assert.ok(result); 223 | assert.strictEqual(result.headers.length, 3); 224 | assert.strictEqual(result.data.length, 1000); 225 | }); 226 | 227 | test('parseClipboard - handles table with many columns', () => { 228 | const headers = []; 229 | const values = []; 230 | for (let i = 1; i <= 50; i++) { 231 | headers.push(`Col${i}`); 232 | values.push(`Value${i}`); 233 | } 234 | const input = headers.join('\t') + '\n' + values.join('\t'); 235 | 236 | const result = parseTable.parseClipboard(input); 237 | 238 | assert.ok(result); 239 | assert.strictEqual(result.headers.length, 50); 240 | assert.strictEqual(result.data.length, 1); 241 | }); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pastum: paste as ... dataframe 2 | 3 |

4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 |

19 | 20 | `pastum` allows you to quickly transform any text/HTML/CSV table from your clipboard into a dataframe object in your favorite language — R, Python, Julia, JavaScript or Markdown. Almost all popular frameworks are supported; if something is missing, don't hesitate to raise an [issue](https://github.com/atsyplenkov/pastum/issues). 21 | 22 | # Example usage 23 | 24 | ### Text table to polars (Python) 25 | 26 | Using the command palette, insert the copied text table as a Python, R, or Julia object. Select the framework on the go. Just press `Ctrl/Cmd+Shift+P`, type `pastum`, and select the preferred option: 27 | 28 | ![](https://github.com/atsyplenkov/pastum/raw/master/assets/demo-py-polars.gif) 29 | 30 | ### Text table to tibble (R) 31 | 32 | Or you can specify the `pastum.defaultDataframeR`/`pastum.defaultDataframePython` parameter in the VS Code settings and insert the table using the right-click context menu by selecting `Pastum: paste as default dataframe`. The inserted language-framework pair will depend on the editor language *(i.e., you cannot paste a pandas dataframe into an R file using this command)*: 33 | 34 | ![](https://github.com/atsyplenkov/pastum/raw/master/assets/demo-r-tibble.gif) 35 | 36 | # Supported languages and frameworks 37 | 38 | - R: `base`, `tribble 🔢`, `tibble ✨`, `data.table 🎩` 39 | - Python: `pandas 🐼`, `polars 🐻`, `datatable 🎩` 40 | - Julia: `DataFrames.jl` 41 | - JavaScript: `base`, `polars 🐻`, `arquero 🏹`, `danfo 🐝` 42 | - Markdown: `columnar ↔️`, `compact ↩️` 43 | - SQL: many options to generate SELECT, INSERT, UPDATE, MERGE, AND CREATE TABLE statements. 44 | 45 | `pastum` recognises tables in the following formats: text, HTML, CSV, TSV. 46 | 47 | # Try it Yourself 48 | 49 | In the table below, the most unfortunate and complex situation is presented. It is a mixture of empty cells, strings, integer and float values. Select, copy and try to paste it into the IDE. The `{pastum}` will recognize all types correctly and fill empty cells with corresponding `NA`/`missing`/`None`/`null` values. 50 | 51 | | Integer ID | Strings with missing values | Only float | Int and Float | 52 | |------------|-----------------------------|------------|---------------| 53 | | 1 | Javascript | 1.43 | 1 | 54 | | 2 | Rust | 123,456.78 | 2 | 55 | | 3 | | -45 | 3 | 56 | | 4 | Clojure | 123456.78 | 4 | 57 | | 5 | Basic | -45.65 | 5.5 | 58 | 59 | ```r 60 | # paste it as a tribble object in R 61 | tibble::tribble( 62 | ~IntegerID, ~StringsWithMissingValues, ~OnlyFloat, ~IntAndFloat, 63 | 1L, "Javascript", 1.43, 1.0, 64 | 2L, "Rust", 123456.78, 2.0, 65 | 3L, NA, -45.0, 3.0, 66 | 4L, "Clojure", 123456.78, 4.0, 67 | 5L, "Basic", -45.65, 5.5 68 | ) 69 | ``` 70 | 71 | # Installation 72 | ### Release version 73 | 74 | The extension is published on both the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=atsyplenkov.pastum) and the [Open VSX Registry](https://open-vsx.org/extension/atsyplenkov/pastum): just click `Install` there or manually install it with: 75 | 76 | 1) Start VS Code (or any other Code OSS-based IDE, such as [Positron](https://github.com/posit-dev/positron), Cursor, etc.). 77 | 78 | 2) Inside VS Code, go to the extensions view either by executing the `View: Show Extensions` command (click `View -> Command Palette`) or by clicking on the extension icon on the left side of the VS Code window. 79 | 80 | 3) In the extensions view, simply search for the term `pastum` in the marketplace search box, then select the extension named `Pastum` and click the install button. 81 | 82 | Alternatively, you can install the latest version from the [Releases](https://github.com/atsyplenkov/pastum/releases/) page. Download the latest `.vsix` file and install it as described [here](https://code.visualstudio.com/docs/editor/extension-marketplace#_install-from-a-vsix). 83 | 84 | ### Development version 85 | The bleeding-edge development version is available as [CI artefacts](https://github.com/atsyplenkov/pastum/actions/workflows/ci.yml) in a `.vsix` format. 86 | 87 | # Features 88 | 89 | - For a complete list of features and example usage, see — [pastum.anatolii.nz](https://pastum.anatolii.nz) 90 | 91 | - You can use the extension through the command palette (`Ctrl/Cmd+Shift+P`) or via the right-click context menu. If you are a conservative person who doesn't switch frameworks often, you can specify your favorite one in the settings and always use the `Pastum: paste as default dataframe` command. 92 | 93 | - The extension mimics the behavior of the [`{datapasta}`](https://github.com/milesmcbain/datapasta/) R package and is capable of detecting the main types: `strings` (or `character` vectors in R), `integer`, and `float` values. A numeric column is considered to be `float` if at least one of the values is `float`; otherwise, the entire column will be treated as `integer`. By default, trailing zeroes are added to all `float` values to comply with `polars` rules (i.e., numeric values `c(1, 2, 3, 4.5)` are transformed to `c(1.0, 2.0, 3.0, 4.5)`). 94 | 95 | - Empty table cells will be replaced with `NA`, `None`, or `missing` values depending on the preferred programming language. 96 | 97 | - By default, the column names are renamed following the PascalCase [convention](https://www.freecodecamp.org/news/snake-case-vs-camel-case-vs-pascal-case-vs-kebab-case-whats-the-difference/#kebab-case) _(i.e., non-machine friendly column names like 'Long & Ugly column💥' will be transformed to 'LongUglyColumn')_. However, the user can specify the preferred naming convention in the settings — `pastum.defaultConvention`. 98 | 99 | - Since `v0.2.0`, users can control the [decimal separator](https://en.m.wikipedia.org/wiki/Decimal_separator) _(e.g., '.' in `12.45`)_ and the digit group separator _(i.e., in numbers over 999)_ through the `pastum.decimalPoint` config. By default, it is set up for a dot (`.`) as the decimal separator and a comma (`,`) as the group separator. 100 | 101 | - Since `v0.3.0`, users can control the library declaration pasted with the dataframe (e.g., `import pandas as pd` in Python or `using DataFrames` in Julia) through the `pastum.libraryDeclaration` config. 102 | 103 | # IDE support 104 | The extension has almost zero dependencies and is expected to work with any Code OSS-based IDE starting from `v1.104.0`. So, if you are using VS Code, go to the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=atsyplenkov.pastum); otherwise, visit the [Open VSX Registry](https://open-vsx.org/extension/atsyplenkov/pastum). 105 | 106 | # Questions and Feature Requests 107 | There's a lot going on with the development of new features in Pastum. If you have any questions or something is not working, feel free to [open an issue](https://github.com/atsyplenkov/pastum/issues) or start a conversation on [BlueSky](https://bsky.app/profile/anatolii.nz). 108 | 109 | # Contributions 110 | Contributions are welcome! If you'd like to contribute, please, fork, submit a PR and I'll merge it. 111 | 112 | # Acknowledgements 113 | This extension was inspired by the [`{datapasta}`](https://github.com/milesmcbain/datapasta/) R package created by [@MilesMcBain](https://github.com/MilesMcBain) and contributors. However, the implementation in the Code OSS environment was influenced by [@coatless](https://github.com/coatless) and his [web app](https://web-apps.thecoatlessprofessor.com/data/html-table-to-dataframe-tool.html). 114 | -------------------------------------------------------------------------------- /src/parse-table.js: -------------------------------------------------------------------------------- 1 | // Code here was inspired by: 2 | // https://web-apps.thecoatlessprofessor.com/data/html-table-to-dataframe-tool.html 3 | const vscode = require("vscode"); 4 | const utils = require("./utils.js"); 5 | 6 | /** 7 | * Parses the clipboard content into a structured table. 8 | */ 9 | function parseClipboard(clipboardContent) { 10 | let parsedTableData = null; 11 | 12 | parsedTableData = parseTable(clipboardContent); 13 | 14 | // Format headers according to R language conventions 15 | const formattedHeaders = parsedTableData.headers.map((header) => 16 | formatVariableName(header, null) 17 | ); 18 | 19 | // Prepare formatted data for code generation 20 | const formattedData = { 21 | headers: formattedHeaders, 22 | data: parsedTableData.data, 23 | columnTypes: parsedTableData.columnTypes, 24 | }; 25 | 26 | if (!parsedTableData) { 27 | vscode.window.showErrorMessage("No valid table found in the clipboard."); 28 | return; 29 | } 30 | 31 | return formattedData; 32 | } 33 | 34 | /** 35 | * Formats column names to be valid variable names in different programming languages. 36 | * Handles specific naming conventions and restrictions for R. 37 | */ 38 | function formatVariableName(name, convention = null) { 39 | // Retrieve the setting if convention is not provided 40 | if (!convention) { 41 | const config = vscode.workspace.getConfiguration("pastum"); 42 | convention = config.get("defaultConvention"); 43 | } 44 | 45 | // Normalize and clean the input string 46 | let formatted = name 47 | .trim() 48 | .replace(/\u00A0/g, " ") // Replace non-breaking spaces with regular spaces 49 | .replace(/[^a-zA-Z0-9_\s\u0400-\u04FF]/g, "") // Remove all special characters except spaces and cyrillic letters 50 | .replace(/\s+/g, "_") // Convert spaces to underscores 51 | .replace(/^(\d)/, "_$1"); // Prefix numbers at start with underscore 52 | 53 | switch (convention) { 54 | case "snake_case": 55 | const split = formatted.split("_"); 56 | formatted = split 57 | .filter((word) => word !== "") 58 | .map((word) => word.charAt(0).toLowerCase() + word.slice(1)) 59 | .join("_"); 60 | break; 61 | case "PascalCase": 62 | formatted = formatted 63 | .split("_") 64 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 65 | .join(""); 66 | break; 67 | case "camelCase": 68 | formatted = formatted 69 | .split("_") 70 | .map((word) => word.charAt(0).toLowerCase() + word.slice(1)) 71 | .map((word, index) => 72 | index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1) 73 | ) 74 | .join(""); 75 | break; 76 | } 77 | 78 | if (!/^[a-zA-Z_\u0400-\u04FF]/.test(formatted)) { 79 | formatted = "x" + formatted; 80 | } 81 | 82 | return formatted; 83 | } 84 | 85 | /** 86 | * FIXME: This function is not used yet. 87 | * Expands a plain text table into a normalized matrix. 88 | * This function ensures each row has consistent columns and removes any empty rows. 89 | */ 90 | function expandTextTable(table) { 91 | // Initialize matrix 92 | const matrix = []; 93 | 94 | // Determine the maximum number of columns in the table 95 | let maxCols = table.reduce((max, row) => Math.max(max, row.length), 0); 96 | 97 | // Normalize each row 98 | table.forEach((row, rowIndex) => { 99 | // Ensure consistent column count by adding empty cells to shorter rows 100 | while (row.length < maxCols) { 101 | row.push(""); 102 | } 103 | 104 | // Filter out empty rows, but preserve the header row (first row) 105 | if (rowIndex === 0 || !utils.isRowEmpty(row)) { 106 | matrix.push(row); 107 | } 108 | }); 109 | 110 | return matrix; 111 | } 112 | 113 | /** 114 | * Parses text containing a table into structured data. 115 | * Handles the complete table processing pipeline from text to formatted data. 116 | */ 117 | function parseTable(inputString) { 118 | try { 119 | let expandedMatrix; 120 | 121 | // Handle plain text table (assuming tab separated values) 122 | expandedMatrix = parseTextTable(inputString); 123 | 124 | // Step 3: Extract and validate headers 125 | const headers = expandedMatrix[0]; 126 | if (headers.length === 0) { 127 | throw new Error("No headers found in the table"); 128 | } 129 | 130 | // Step 4: Extract and validate data rows 131 | const data = expandedMatrix.slice(1); 132 | if (data.length === 0) { 133 | throw new Error("No data rows found in the table"); 134 | } 135 | // Step 5: Determine column types through data analysis 136 | const columnTypes = new Array(headers.length).fill("numeric"); 137 | const columnCounts = new Array(headers.length) 138 | .fill(0) 139 | .map(() => ({ numeric: 0, nonNumeric: 0 })); 140 | // Count numeric and non-numeric values in 141 | // each column 142 | data.forEach((row) => { 143 | row.forEach((value, colIndex) => { 144 | if (value === null || value === undefined || value === "") { 145 | // Ignore empty values 146 | return; 147 | } 148 | if (utils.isNumeric(value)) { 149 | columnCounts[colIndex].numeric++; 150 | } else { 151 | columnCounts[colIndex].nonNumeric++; 152 | } 153 | }); 154 | }); 155 | // If the majority of values in a column are non-numeric, 156 | // assume whole column is string 157 | columnCounts.forEach((counts, colIndex) => { 158 | if (counts.nonNumeric > 0) { 159 | columnTypes[colIndex] = "string"; 160 | } 161 | }); 162 | 163 | // Check if all values in a numeric column are integer 164 | columnTypes.forEach((type, colIndex) => { 165 | if (type === "numeric") { 166 | const values = data.map((row) => row[colIndex]).filter(value => value !== ""); 167 | const allIntegers = values.every((value) => utils.isInt(value)); 168 | if (allIntegers) { 169 | columnTypes[colIndex] = "integer"; 170 | } 171 | } 172 | }); 173 | 174 | // Check if all values in a string column are boolean 175 | columnTypes.forEach((type, colIndex) => { 176 | if (type === "string") { 177 | const values = data.map((row) => row[colIndex]).filter(value => value !== ""); 178 | const allBool = values.every((value) => utils.isBool(value)); 179 | if (allBool) { 180 | columnTypes[colIndex] = "boolean"; 181 | } 182 | } 183 | }); 184 | 185 | // Step 6: Convert data to appropriate types 186 | const convertedData = data.map((row) => 187 | row.map((value, colIndex) => 188 | columnTypes[colIndex] !== "string" && 189 | columnTypes[colIndex] !== "boolean" 190 | ? utils.convertValue(value) 191 | : value 192 | ) 193 | ); 194 | 195 | return { headers, data: convertedData, columnTypes }; 196 | } catch (error) { 197 | throw new Error("Invalid table format: " + error.message); 198 | } 199 | } 200 | 201 | /** 202 | * Parses a plain text table (assumes comma, tab, or space delimited values) 203 | * into a structured matrix. 204 | */ 205 | function parseTextTable(textString) { 206 | // Split the input by line breaks for rows 207 | const rows = textString.trim().split(/\r?\n/); 208 | const rlen = rows.length; 209 | const len2 = rlen - 2; 210 | 211 | // Delimiters: TAB (spreadsheets, IDEs), comma (CSV), semicolon (CSV), pipe (TSV) 212 | // or by spaces (fixed width) 213 | const patterns = ['\\t|\\s\\t', ',', ';', '\\|', '\\s+']; 214 | let results = []; 215 | let columns = []; 216 | // Finds the best pattern to split the table 217 | for (let i = 0; i < patterns.length; i++) { 218 | let pattern = patterns[i]; 219 | let regex = new RegExp(pattern, 'gm'); 220 | let matrix = rows.map((row) => row.split(regex)); 221 | let cols = getNumSplitRows(matrix, i); 222 | 223 | results[i] = matrix; 224 | columns[i] = cols; 225 | } 226 | // Choose the pattern that best splits the table 227 | const sorted = columns.sort(sortByBestRowSplit); 228 | const best = sorted[0][2]; 229 | const res = results[best]; 230 | // Append empty cells to make the table rectangular and avoid errors while converting 231 | const maxCols = getMaxCols(res);; 232 | const normalized = res.map((row) => { 233 | if (row.length < maxCols) { 234 | const diff = maxCols - row.length; 235 | return row.concat(new Array(diff).fill("")); 236 | } 237 | return row; 238 | }).map(row => row.map(cell => { 239 | // Strip surrounding quotes if present 240 | const trimmed = cell.trim(); 241 | if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { 242 | return trimmed.slice(1, -1); 243 | } 244 | return cell; 245 | })); 246 | return normalized; 247 | } 248 | 249 | function sortByBestRowSplit(a, b) { 250 | let res = b[0] - a[0]; // More rows with same number of columns (DESC) 251 | if (res == 0) { 252 | res = b[1] - a[1]; // More rows with columns split by the pattern (DESC) 253 | if (res == 0) { 254 | res = a[2] - b[2]; // Pattern order (ASC) gives TAB 255 | } 256 | } 257 | return res 258 | } 259 | 260 | function getMaxCols(matrix) { 261 | let maxCols = 0; 262 | let numRows = matrix.length; 263 | for (let i = 0; i < numRows; i++) { 264 | let cols = matrix[i].length; 265 | if (cols > maxCols) { 266 | maxCols = cols; 267 | } 268 | } 269 | return maxCols; 270 | } 271 | 272 | function getNumSplitRows(matrix, index) { 273 | let numRowSplit = 0; 274 | let numColsEqual = 0; 275 | let numCols = -1; 276 | let numRows = matrix.length; 277 | for (let i = 0; i < numRows; i++) { 278 | let cols = matrix[i].length; 279 | if (cols > 1) { 280 | numRowSplit += 1; 281 | if (numCols <= 0) { 282 | numCols = cols; 283 | numColsEqual = 1; // First row establishes the column count 284 | } else if (cols == numCols) { 285 | numColsEqual += 1; 286 | } 287 | } 288 | } 289 | return [numColsEqual, numRowSplit, index]; 290 | } 291 | 292 | module.exports = { 293 | parseClipboard, 294 | }; 295 | -------------------------------------------------------------------------------- /src/paste-sql.js: -------------------------------------------------------------------------------- 1 | const vscode = require("vscode"); 2 | const { parseClipboard } = require("./parse-table"); 3 | const { addTrailingZeroes, normalizeBool } = require("./utils"); 4 | 5 | async function clipboardToSql(statement = null) { 6 | 7 | function abortOnError(message) { 8 | vscode.window.showErrorMessage(message); 9 | } 10 | 11 | try { 12 | // 1: Read the clipboard content 13 | const clipboardContent = await vscode.env.clipboard.readText(); 14 | 15 | if (!clipboardContent) { 16 | return abortOnError("Clipboard is empty or contains unsupported content."); 17 | } 18 | 19 | // 2: Try to extract the table from clipboard content 20 | let tableData = parseClipboard(clipboardContent); 21 | 22 | // 3: Ask the user which statement they want to use 23 | if (statement === null) { 24 | const stlist = [ 25 | "SELECT FROM VALUES", 26 | "SELECT UNION ALL", 27 | "INSERT INTO VALUES", 28 | "INSERT INTO SELECT VALUES", 29 | "INSERT INTO", 30 | "DELETE WHERE", 31 | "UPDATE WHERE", 32 | "MERGE INTO", 33 | "CREATE TABLE" 34 | ]; 35 | statement = await vscode.window.showQuickPick( 36 | stlist, 37 | { 38 | placeHolder: "Select the statement for creating the Sql table", 39 | } 40 | ); 41 | } 42 | 43 | if (!statement) { 44 | return abortOnError("No SQL statement selected."); 45 | } 46 | 47 | let keyColumns = []; 48 | if (statement === "UPDATE WHERE" || statement === "MERGE INTO") { 49 | keyColumns = await vscode.window.showQuickPick( 50 | tableData.headers, 51 | { 52 | placeHolder: "Select the key columns for matching rows", 53 | canPickMany: true 54 | } 55 | ); 56 | if (!keyColumns || keyColumns.length === 0) { 57 | return abortOnError("No key columns for matching rows selected."); 58 | } 59 | } 60 | 61 | // 4: Generate the Sql code using the selected statement 62 | const sqlCode = createSql(tableData, statement, keyColumns); 63 | 64 | if (!sqlCode) { 65 | return abortOnError("Failed to generate Sql code."); 66 | } 67 | 68 | // 5: Insert the generated code into the active editor 69 | const editor = vscode.window.activeTextEditor; 70 | if (editor) { 71 | editor.edit((editBuilder) => { 72 | editBuilder.insert(editor.selection.active, sqlCode); 73 | }); 74 | } 75 | } catch (error) { 76 | abortOnError(`Error: ${error.message}`); 77 | } 78 | } 79 | 80 | /** 81 | * Generates a Sql script based on the provided table data. 82 | */ 83 | function createSql(tableData, statement, keyColumns) { 84 | 85 | /** 86 | * Formats a value according to its column type for SQL syntax 87 | * @param {any} value - The value to format 88 | * @param {number} colIndex - Column index for type lookup 89 | * @returns {string} Formatted value 90 | */ 91 | function formatValue(value, colIndex) { 92 | if (value === "") { 93 | return "NULL"; 94 | } else if (columnTypes[colIndex] === "string") { 95 | return `'${value}'`; 96 | } else if (columnTypes[colIndex] === "numeric") { 97 | return addTrailingZeroes(value); 98 | } else if (columnTypes[colIndex] === "boolean") { 99 | return normalizeBool(value, "javascript"); 100 | } else if (columnTypes[colIndex] === "integer") { 101 | return value; 102 | } else { 103 | return `'${value}'`; 104 | } 105 | } 106 | 107 | function getSqlTypeFor(colIndex) { 108 | let colt = columnTypes[colIndex]; 109 | if (colt === "string") { 110 | return "VARCHAR(100)"; 111 | } else if (colt === "numeric") { 112 | return "NUMERIC(9,6)"; 113 | } else if (colt === "boolean") { 114 | return "BOOLEAN"; 115 | } else if (colt === "integer") { 116 | return "INTEGER"; 117 | } else { 118 | return "VARCHAR(50)"; 119 | } 120 | } 121 | 122 | function getRowsAs(rows, cols, template, colstart, colsep, colend, rowsep) { 123 | let lines = []; 124 | rows.forEach((row) => { 125 | const vals = row 126 | .map(function (value, i) { 127 | let nam1 = cols[i]; 128 | let val2 = formatValue(value, i); 129 | let res1 = template.replace("{1}", nam1); 130 | let res2 = res1.replace("{2}", val2); 131 | return res2; 132 | }).join(colsep); 133 | lines = lines.concat(colstart + vals + colend); 134 | }); 135 | if (lines.length > 0) { 136 | return lines.join(rowsep); 137 | } 138 | return ""; 139 | } 140 | 141 | function getRowsAs2Columns( 142 | rows, cols, keys, 143 | template1, col1start, col1sep, col1end, 144 | template2, col2start, col2sep, col2end) { 145 | 146 | const lines = rows.map(function (row, j) { 147 | const vals = row.map(function (value, i) { 148 | let nam1 = cols[i]; 149 | let val2 = formatValue(value, i); 150 | let pos = keys.indexOf(nam1) < 0 ? 1 : 2; 151 | let tpl = pos === 1 ? template1 : template2; 152 | let res = tpl.replace("{1}", nam1).replace("{2}", val2); 153 | return [res, pos]; 154 | }); 155 | let val1 = vals.filter(v => v[1] === 1).map(v => v[0]); 156 | let val2 = vals.filter(v => v[1] === 2).map(v => v[0]); 157 | let text1 = col1start + val1.join(col1sep) + col1end; 158 | let text2 = col2start + val2.join(col2sep) + col2end; 159 | return [text1, text2]; 160 | }); 161 | return lines; 162 | } 163 | 164 | // Pads a value to the target width 165 | function padToWidth(value, width, padding) { 166 | let wide = width - value.toString().length; 167 | return value + padding.repeat(wide); 168 | } 169 | 170 | function getRowsAsTuple(rows, cols) { 171 | return getRowsAs(rows, cols, "{2}", " (", ", ", ")", ",\n"); 172 | } 173 | 174 | function getRowsAsUnionAll(rows, cols) { 175 | return getRowsAs(rows, cols, "{2} AS {1}", " SELECT ", ", ", "", " UNION ALL\n"); 176 | } 177 | 178 | function getColumnsAsTuple(cols) { 179 | let cols2 = cols ? cols.join(", ") : ""; 180 | return `(${cols2})`; 181 | } 182 | 183 | function getSqlAsCreateTable(rows, cols) { 184 | let width = cols.reduce((prev, col) => prev > col.length ? prev : col.length, 0); 185 | let names = cols.map(function (value, i) { 186 | let colname = padToWidth(value, width, " "); 187 | let coltype = getSqlTypeFor(i); 188 | return ` ${colname} ${coltype}`; 189 | }); 190 | let fields = names.join(",\n"); 191 | let drop = "-- DROP TABLE IF EXISTS mytable;"; 192 | let sql = `${drop}\n\nCREATE TABLE IF NOT EXISTS mytable (\n${fields}\n);\n\n`; 193 | return sql; 194 | } 195 | 196 | function getSqlAsSelectFromValues(rows, cols) { 197 | let names = getColumnsAsTuple(cols); 198 | let lines = getRowsAsTuple(rows, cols); 199 | let sql = `SELECT * FROM (VALUES\n${lines}\n) AS t${names};\n`; 200 | return sql; 201 | } 202 | 203 | function getSqlAsSelectUnionAll(rows, cols) { 204 | let lines = getRowsAsUnionAll(rows, cols); 205 | let sql = `WITH mytable AS (\n${lines}\n)\nSELECT m.* FROM mytable AS m;\n`; 206 | return sql; 207 | } 208 | 209 | function getSqlAsMergeInto(rows, cols, keys) { 210 | let unionall = getRowsAsUnionAll(rows, cols); 211 | let onkeys = keys.map(k => `t2.${k} = s1.${k}`).join("\n AND "); 212 | let nonkeys = cols.filter(k => keys.indexOf(k) < 0); 213 | let upset = nonkeys.map(k => ` ${k} = s1.${k}`).join(",\n"); 214 | let c1 = cols.join(", "); 215 | let c2 = cols.join(", s1."); 216 | let into = ` INSERT (${c1})\n VALUES (s1.${c2})`; 217 | 218 | let sql = `MERGE INTO mytable AS t2 USING(\n${unionall}\n` 219 | + ` ) AS s1\n ON ${onkeys}\n` 220 | + `WHEN MATCHED THEN UPDATE SET\n${upset}\n` 221 | + `WHEN NOT MATCHED THEN\n${into}\n` 222 | + `WHEN NOT MATCHED BY SOURCE THEN\n DELETE;\n`; 223 | return sql; 224 | } 225 | 226 | function getSqlAsInsertFromSelectValues(rows, cols) { 227 | let sql = getSqlAsSelectFromValues(rows, cols); 228 | return `INSERT INTO mytable\n${sql}`; 229 | } 230 | 231 | function getSqlAsInsertIntoValues(rows, cols) { 232 | let names = getColumnsAsTuple(cols); 233 | let lines = getRowsAsTuple(rows, cols); 234 | let sql = `INSERT INTO mytable\n ${names}\nVALUES\n${lines};\n`; 235 | return sql; 236 | } 237 | 238 | function getSqlAsInsertIntoMultiple(rows, cols) { 239 | let names = getColumnsAsTuple(cols); 240 | let pre = `INSERT INTO mytable ${names} VALUES (`; 241 | let sql = getRowsAs(rows, cols, "{2}", pre, ", ", ");", "\n"); 242 | return sql + "\n"; 243 | } 244 | 245 | function getSqlAsDeleteWhere(rows, cols) { 246 | let names = getColumnsAsTuple(cols); 247 | let pre = `DELETE FROM mytable WHERE `; 248 | let sql = getRowsAs(rows, cols, "{1} = {2}", pre, " AND ", "", ";\n"); 249 | let res = sql.replaceAll("= NULL", "IS NULL") + ";\n\n"; 250 | return res; 251 | } 252 | 253 | function getSqlAsUpdateWhere(rows, cols, keys) { 254 | const vals = getRowsAs2Columns( 255 | rows, cols, keys, "{1} = {2}", "", ", ", "", "{1} = {2}", "", " AND ", "" 256 | ); 257 | let stmt = `UPDATE mytable SET {1} WHERE {2};`; 258 | let sql = vals.map(v => stmt.replace("{1}", v[0]).replace("{2}", v[1].replaceAll("= NULL", "IS NULL"))); 259 | return sql.join("\n") + "\n\n"; 260 | } 261 | 262 | const { headers, data, columnTypes } = tableData; 263 | switch (statement) { 264 | case "SELECT FROM VALUES": 265 | return getSqlAsSelectFromValues(data, headers); 266 | case "SELECT UNION ALL": 267 | return getSqlAsSelectUnionAll(data, headers); 268 | case "INSERT INTO VALUES": 269 | return getSqlAsInsertIntoValues(data, headers); 270 | case "INSERT INTO SELECT VALUES": 271 | return getSqlAsInsertFromSelectValues(data, headers); 272 | case "INSERT INTO": 273 | return getSqlAsInsertIntoMultiple(data, headers); 274 | case "DELETE WHERE": 275 | return getSqlAsDeleteWhere(data, headers); 276 | case "UPDATE WHERE": 277 | return getSqlAsUpdateWhere(data, headers, keyColumns); 278 | case "MERGE INTO": 279 | return getSqlAsMergeInto(data, headers, keyColumns); 280 | case "CREATE TABLE": 281 | return getSqlAsCreateTable(data, headers); 282 | } 283 | return ""; 284 | } 285 | 286 | module.exports = { 287 | clipboardToSql, 288 | }; 289 | --------------------------------------------------------------------------------