├── src ├── commands │ ├── index.ts │ ├── commands.ts │ ├── pasteHtmlAsMarkdown.ts │ ├── extractRangeToNewNote.ts │ ├── sendRangeToExistingNote.ts │ └── extractRangeToNewNote.spec.ts ├── utils │ ├── index.ts │ ├── utils.ts │ ├── utils.spec.ts │ └── clipboardUtils.ts ├── features │ ├── index.ts │ ├── codeActionProvider.ts │ └── codeActionProvider.spec.ts ├── test │ ├── config │ │ └── jestSetup.ts │ ├── runTest.ts │ ├── testRunner.ts │ └── testUtils.ts ├── extension.ts ├── declarations.d.ts └── extension.spec.ts ├── media ├── markdown-kit.png └── demo │ ├── Pasting HTML as Markdown.gif │ ├── Send range to a new note.gif │ └── Extracting range to a new note.gif ├── .gitignore ├── README.md ├── commitlint.config.js ├── .editorconfig ├── .huskyrc.js ├── codecov.yml ├── .prettierrc ├── .vscodeignore ├── tsconfig.json ├── CHANGELOG.md ├── webpack.config.js ├── LICENSE ├── CONTRIBUTING.md ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml └── package.json /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './commands'; 2 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | export * from './clipboardUtils'; 3 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | export { default as codeActionProvider } from './codeActionProvider'; 2 | -------------------------------------------------------------------------------- /src/test/config/jestSetup.ts: -------------------------------------------------------------------------------- 1 | jest.mock('vscode', () => (global as any).vscode, { virtual: true }); 2 | -------------------------------------------------------------------------------- /media/markdown-kit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/vscode-markdown-kit/HEAD/media/markdown-kit.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | yarn-error.log 7 | VERSION 8 | coverage/ 9 | -------------------------------------------------------------------------------- /media/demo/Pasting HTML as Markdown.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/vscode-markdown-kit/HEAD/media/demo/Pasting HTML as Markdown.gif -------------------------------------------------------------------------------- /media/demo/Send range to a new note.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/vscode-markdown-kit/HEAD/media/demo/Send range to a new note.gif -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown Kit 2 | 3 | Functionality in this repository was merged into [Memo](https://github.com/svsool/vscode-memo) to ease maintanence. 4 | -------------------------------------------------------------------------------- /media/demo/Extracting range to a new note.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svsool/vscode-markdown-kit/HEAD/media/demo/Extracting range to a new note.gif -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'subject-case': [0, 'never', []], 5 | 'header-max-length': [2, 'always', 120], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | tab_width = 2 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | const disableLinters = Number(process.env.DISABLE_MD_KIT_HOOKS) === 1; 2 | 3 | module.exports = { 4 | hooks: { 5 | 'pre-commit': disableLinters ? undefined : 'lint-staged', 6 | 'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 70..90 3 | round: down 4 | precision: 2 5 | status: 6 | project: 7 | default: 8 | threshold: 10% 9 | patch: 10 | default: 11 | threshold: 20% 12 | only_pulls: true 13 | 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "overrides": [ 4 | { 5 | "files": ["*.ts", "*.js"], 6 | "options": { 7 | "singleQuote": true, 8 | "trailingComma": "all" 9 | } 10 | }, 11 | { 12 | "files": "*.json", 13 | "options": { 14 | "parser": "json" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | **/*.spec.js 5 | src/** 6 | .gitignore 7 | .editorconfig 8 | .eslintrc.json 9 | .huskyrc.js 10 | .prettierrc 11 | CONTRIBUTION.md 12 | vsc-extension-quickstart.md 13 | yarn.lock 14 | yarn-error.log 15 | **/tsconfig.json 16 | **/webpack.config.js 17 | **/.eslintrc.json 18 | **/*.map 19 | **/*.ts 20 | node_modules 21 | VERSION 22 | coverage/ 23 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | import commands from './commands'; 4 | import { codeActionProvider } from './features'; 5 | 6 | const mdLangSelector = { language: 'markdown', scheme: '*' }; 7 | 8 | export const activate = async (context: vscode.ExtensionContext) => { 9 | context.subscriptions.push( 10 | vscode.languages.registerCodeActionsProvider(mdLangSelector, codeActionProvider), 11 | ...commands, 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "esModuleInterop": true, 6 | "outDir": "out", 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "lib": ["es2019"], 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true 14 | }, 15 | "exclude": ["node_modules", ".vscode-test"], 16 | "include": ["src/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cross-path-sort' { 2 | type SortOptions = { 3 | pathKey?: string; 4 | shallowFirst?: boolean; 5 | deepFirst?: boolean; 6 | homePathsSupported?: boolean; 7 | posixOrder?: ('rel' | 'home' | 'abs')[]; 8 | windowsOrder?: ('rel' | 'home' | 'abs' | 'drel' | 'dabs' | 'unc' | 'nms')[]; 9 | segmentCompareFn?: (a: string, b: string) => number; 10 | }; 11 | 12 | export function sort(paths: T[], options?: SortOptions): T[]; 13 | } 14 | -------------------------------------------------------------------------------- /src/commands/commands.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import pasteHtmlAsMarkdown from './pasteHtmlAsMarkdown'; 4 | import extractRangeToNewNote from './extractRangeToNewNote'; 5 | import sendRangeToExistingNote from './sendRangeToExistingNote'; 6 | 7 | const commands = [ 8 | vscode.commands.registerCommand('markdown-kit.pasteHtmlAsMarkdown', pasteHtmlAsMarkdown), 9 | vscode.commands.registerCommand('markdown-kit.extractRangeToNewNote', extractRangeToNewNote), 10 | vscode.commands.registerCommand('markdown-kit.sendRangeToExistingNote', sendRangeToExistingNote), 11 | ]; 12 | 13 | export default commands; 14 | -------------------------------------------------------------------------------- /src/extension.spec.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | const MARKDOWN_KIT_EXTENSION_ID = 'svsool.markdown-kit'; 4 | 5 | describe('extension', () => { 6 | it('should find extension in extensions list', () => { 7 | expect( 8 | vscode.extensions.all.some((extension) => extension.id === MARKDOWN_KIT_EXTENSION_ID), 9 | ).toBe(true); 10 | }); 11 | 12 | it('should not find not existing extension', () => { 13 | expect( 14 | vscode.extensions.all.some((extension) => { 15 | return extension.id === 'svsool.any-extension'; 16 | }), 17 | ).toBe(false); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/features/codeActionProvider.ts: -------------------------------------------------------------------------------- 1 | import { CodeActionProvider } from 'vscode'; 2 | 3 | const codeActionProvider: CodeActionProvider = { 4 | provideCodeActions(document, range) { 5 | if (range.isEmpty) { 6 | return []; 7 | } 8 | 9 | return [ 10 | { 11 | title: 'Extract range to a new note', 12 | command: 'markdown-kit.extractRangeToNewNote', 13 | arguments: [document, range], 14 | }, 15 | { 16 | title: 'Send range to an existing note', 17 | command: 'markdown-kit.sendRangeToExistingNote', 18 | arguments: [document, range], 19 | }, 20 | ]; 21 | }, 22 | }; 23 | 24 | export default codeActionProvider; 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.2.0](https://github.com/svsool/vscode-markdown-kit/compare/v1.1.0...v1.2.0) (2020-08-21) 6 | 7 | 8 | ### Features 9 | 10 | * Add extract range to a new note command ([5721b2e](https://github.com/svsool/vscode-markdown-kit/commit/5721b2e9bb65af993f9b186a554f85bc82b647a3)) 11 | 12 | ## 1.1.0 (2020-08-15) 13 | 14 | 15 | ### Features 16 | 17 | * Add Paste HTML as Markdown command ([d169b9c](https://github.com/svsool/vscode-markdown-kit/commit/d169b9c47d29a119c4bfb58623e67bf50cbf20e1)) 18 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | 5 | export const getWorkspaceFolder = (): string | undefined => 6 | vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0].uri.fsPath; 7 | 8 | export const ensureDirectoryExists = (filePath: string) => { 9 | const dirname = path.dirname(filePath); 10 | if (!fs.existsSync(dirname)) { 11 | ensureDirectoryExists(dirname); 12 | fs.mkdirSync(dirname); 13 | } 14 | }; 15 | 16 | export function getConfigProperty(property: string, fallback: T): T { 17 | return vscode.workspace.getConfiguration().get(property, fallback); 18 | } 19 | 20 | export function getMarkdownKitConfigProperty(property: string, fallback: T): T { 21 | return getConfigProperty(`markdown-kit.${property}`, fallback); 22 | } 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const config = { 5 | target: 'node', 6 | entry: './src/extension.ts', 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: 'extension.js', 10 | libraryTarget: 'commonjs2', 11 | devtoolModuleFilenameTemplate: '../[resource-path]', 12 | }, 13 | devtool: 'source-map', 14 | externals: { 15 | vscode: 'commonjs vscode', 16 | }, 17 | resolve: { 18 | extensions: ['.ts', '.js'], 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.ts$/, 24 | exclude: /node_modules/, 25 | use: [ 26 | { 27 | loader: 'ts-loader', 28 | }, 29 | ], 30 | }, 31 | ], 32 | }, 33 | plugins: [new webpack.IgnorePlugin({ resourceRegExp: /canvas|bufferutil|utf-8-validate/ })], 34 | }; 35 | 36 | module.exports = config; 37 | -------------------------------------------------------------------------------- /src/commands/pasteHtmlAsMarkdown.ts: -------------------------------------------------------------------------------- 1 | import TurndownService from 'turndown'; 2 | import vscode from 'vscode'; 3 | 4 | import { readClipboard } from '../utils'; 5 | 6 | const tdSettings = { 7 | headingStyle: 'atx' as const, 8 | codeBlockStyle: 'fenced' as const, 9 | }; 10 | 11 | const pasteHtmlAsMarkdown = async () => { 12 | const tdService = new TurndownService(tdSettings); 13 | 14 | const clipboard = await readClipboard(); 15 | 16 | const markdown = tdService.turndown(clipboard); 17 | 18 | const editor = vscode.window.activeTextEditor; 19 | 20 | if (!editor) { 21 | return; 22 | } 23 | 24 | editor.edit((edit) => { 25 | const current = editor.selection; 26 | 27 | editor.selections.forEach((selection) => { 28 | if (selection.isEmpty) { 29 | edit.insert(selection.start, markdown); 30 | } else { 31 | edit.replace(current, markdown); 32 | } 33 | }); 34 | }); 35 | }; 36 | 37 | export default pasteHtmlAsMarkdown; 38 | -------------------------------------------------------------------------------- /src/features/codeActionProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | import codeActionProvider from './codeActionProvider'; 4 | import { rndName, createFile, openTextDocument } from '../test/testUtils'; 5 | 6 | describe('codeActionProvider', () => { 7 | it('should provide code actions', async () => { 8 | const name0 = rndName(); 9 | 10 | await createFile(`${name0}.md`, 'Hello world!'); 11 | 12 | const doc = await openTextDocument(`${name0}.md`); 13 | const range = new vscode.Range(0, 0, 0, 12); 14 | 15 | expect( 16 | codeActionProvider.provideCodeActions(doc, range, undefined as any, undefined as any), 17 | ).toEqual([ 18 | { 19 | title: 'Extract range to a new note', 20 | command: 'markdown-kit.extractRangeToNewNote', 21 | arguments: [doc, range], 22 | }, 23 | { 24 | title: 'Send range to an existing note', 25 | command: 'markdown-kit.sendRangeToExistingNote', 26 | arguments: [doc, range], 27 | }, 28 | ]); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import fs from 'fs'; 3 | import os from 'os'; 4 | import { runTests } from 'vscode-test'; 5 | 6 | process.env.FORCE_COLOR = '1'; 7 | 8 | async function main() { 9 | try { 10 | // The folder containing the Extension Manifest package.json 11 | // Passed to `--extensionDevelopmentPath` 12 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 13 | 14 | // The path to the extension test script 15 | // Passed to --extensionTestsPath 16 | const extensionTestsPath = path.resolve(__dirname, './testRunner'); 17 | 18 | const tmpWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'markdown-kit-')); 19 | 20 | // Download VS Code, unzip it and run the integration test 21 | await runTests({ 22 | extensionDevelopmentPath, 23 | extensionTestsPath, 24 | launchArgs: [tmpWorkspaceDir, '--disable-extensions'], 25 | }); 26 | } catch (err) { 27 | console.error('Failed to run tests'); 28 | process.exit(1); 29 | } 30 | } 31 | 32 | main(); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Svyatoslav Sobol 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 | -------------------------------------------------------------------------------- /src/utils/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { closeEditorsAndCleanWorkspace } from '../test/testUtils'; 5 | import { getWorkspaceFolder, ensureDirectoryExists, getMarkdownKitConfigProperty } from './utils'; 6 | 7 | describe('getWorkspaceFolder()', () => { 8 | it('should return workspace folder', () => { 9 | expect(getWorkspaceFolder()).not.toBeUndefined(); 10 | }); 11 | }); 12 | 13 | describe('ensureDirectoryExists()', () => { 14 | beforeEach(closeEditorsAndCleanWorkspace); 15 | 16 | afterEach(closeEditorsAndCleanWorkspace); 17 | 18 | it('should create all necessary directories', () => { 19 | const dirPath = path.join(getWorkspaceFolder()!, 'folder1', 'folder2'); 20 | expect(fs.existsSync(dirPath)).toBe(false); 21 | ensureDirectoryExists(path.join(dirPath, 'file.md')); 22 | expect(fs.existsSync(dirPath)).toBe(true); 23 | }); 24 | }); 25 | 26 | describe('getMarkdownKitConfigProperty()', () => { 27 | it('should return config property', () => { 28 | expect(getMarkdownKitConfigProperty('sendRangeToExistingNote.position', null)).toBe('start'); 29 | }); 30 | 31 | it('should return default property on getting unknown config property', () => { 32 | expect(getMarkdownKitConfigProperty('unknownProperty', 'default')).toBe('default'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/test/testRunner.ts: -------------------------------------------------------------------------------- 1 | import { runCLI } from '@jest/core'; 2 | import { AggregatedResult } from '@jest/test-result'; 3 | import path from 'path'; 4 | 5 | const getFailureMessages = (results: AggregatedResult): string[] | undefined => { 6 | const failures = results.testResults.reduce( 7 | (acc, { failureMessage }) => (failureMessage ? [...acc, failureMessage] : acc), 8 | [], 9 | ); 10 | 11 | return failures.length > 0 ? failures : undefined; 12 | }; 13 | 14 | const rootDir = path.resolve(__dirname, '../..'); 15 | 16 | export function run(): Promise { 17 | process.stdout.write = (buffer: string) => { 18 | console.log(buffer); 19 | return true; 20 | }; 21 | process.stderr.write = (buffer: string) => { 22 | console.error(buffer); 23 | return true; 24 | }; 25 | 26 | process.env.NODE_ENV = 'test'; 27 | process.env.DISABLE_FS_WATCHER = 'true'; 28 | 29 | return new Promise(async (resolve, reject) => { 30 | try { 31 | const { results } = await (runCLI as any)( 32 | { 33 | rootDir, 34 | roots: ['/src'], 35 | verbose: true, 36 | colors: true, 37 | transform: JSON.stringify({ '^.+\\.ts$': 'ts-jest' }), 38 | runInBand: true, 39 | testRegex: process.env.JEST_TEST_REGEX || '\\.(test|spec)\\.ts$', 40 | testEnvironment: 'vscode', 41 | setupFiles: ['/src/test/config/jestSetup.ts'], 42 | globals: JSON.stringify({ 43 | 'ts-jest': { 44 | tsConfig: path.resolve(rootDir, './tsconfig.json'), 45 | }, 46 | }), 47 | ci: process.env.JEST_CI === 'true', 48 | testTimeout: 30000, 49 | watch: process.env.JEST_WATCH === 'true', 50 | collectCoverage: process.env.JEST_COLLECT_COVERAGE === 'true', 51 | }, 52 | [rootDir], 53 | ); 54 | 55 | const failureMessages = getFailureMessages(results); 56 | 57 | if (failureMessages?.length) { 58 | return reject(`${failureMessages?.length} tests failed!`); 59 | } 60 | 61 | return resolve(); 62 | } catch (error) { 63 | return reject(error); 64 | } 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | ## Project structure 4 | 5 | ``` 6 | src 7 | ├── commands - contains internal / external commands, e.g. open today or random note commands 8 | ├── extension.ts - plugin entrypoint 9 | ├── features - contains features, usually feature accepts plugin context and implements certain functionality 10 | ├── test - contains test runner and common test utils 11 | └── utils - common utils 12 | ``` 13 | 14 | ## Committing changes 15 | 16 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines and [Why Use Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#why-use-conventional-commits). 17 | 18 | Guidelines enforced via commit hooks, so commits MUST be prefixed with a type. 19 | 20 | ## Contributing 21 | 22 | 1. Fork this repository 23 | 2. Create your feature branch: `git checkout -b my-new-feature` 24 | 3. Commit your changes: `git commit -am 'feat: Add some feature'` 25 | 4. Push to the branch: `git push origin my-new-feature` 26 | 5. Submit a pull request 27 | 28 | ## Development 29 | 30 | * `cd && yarn && yarn watch` 31 | * Open project in VSCode using `code ` or via `File -> Open...` and press `F5` to open a new window with the extension loaded. 32 | * After making modifications run `Developer: Restart Extension Host` command from the command palette to restart the extension and quickly pick up your changes. 33 | * Set breakpoints in your code inside `src/extension.ts` to debug the extension. 34 | * Find output from the extension in the debug console. 35 | 36 | ## Run tests 37 | 38 | ``` 39 | yarn test # runs all tests 40 | yarn test:watch # runs only changed tests, consider also using JEST_TEST_REGEX env var for running specific tests 41 | ``` 42 | 43 | *Note: Before running integration tests, please ensure that all VSCode instances are closed.* 44 | 45 | ## Releasing 46 | 47 | *You can skip this section if your contribution comes via PR from a forked repository.* 48 | 49 | 1. Run `yarn release` 50 | 1. Push to origin with `git push --follow-tags origin master` 51 | 1. After push CI will automatically: 52 | - create new release 53 | - attach release artifacts 54 | - publish extension to the marketplace 55 | -------------------------------------------------------------------------------- /src/test/testUtils.ts: -------------------------------------------------------------------------------- 1 | import del from 'del'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { workspace, Uri, commands, ConfigurationTarget } from 'vscode'; 5 | 6 | import * as utils from '../utils'; 7 | 8 | export const cleanWorkspace = () => { 9 | const workspaceFolder = utils.getWorkspaceFolder(); 10 | 11 | if (workspaceFolder) { 12 | del.sync(['**/!(.vscode)'], { 13 | force: true, 14 | cwd: workspaceFolder, 15 | }); 16 | } 17 | }; 18 | 19 | export const createFile = async ( 20 | filename: string, 21 | content: string = '', 22 | ): Promise => { 23 | const workspaceFolder = utils.getWorkspaceFolder(); 24 | 25 | if (!workspaceFolder) { 26 | return; 27 | } 28 | 29 | const filepath = path.join(workspaceFolder, ...filename.split('/')); 30 | const dirname = path.dirname(filepath); 31 | 32 | utils.ensureDirectoryExists(filepath); 33 | 34 | if (!fs.existsSync(dirname)) { 35 | throw new Error(`Directory ${dirname} does not exist`); 36 | } 37 | 38 | fs.writeFileSync(filepath, content); 39 | 40 | return Uri.file(path.join(workspaceFolder, ...filename.split('/'))); 41 | }; 42 | 43 | export const removeFile = (filename: string) => 44 | fs.unlinkSync(path.join(utils.getWorkspaceFolder()!, ...filename.split('/'))); 45 | 46 | export const rndName = (): string => { 47 | const name = Math.random() 48 | .toString(36) 49 | .replace(/[^a-z]+/g, '') 50 | .substr(0, 5); 51 | 52 | return name.length !== 5 ? rndName() : name; 53 | }; 54 | 55 | export const openTextDocument = (filename: string) => { 56 | const filePath = path.join(utils.getWorkspaceFolder()!, filename); 57 | 58 | if (!fs.existsSync(filePath)) { 59 | throw new Error(`File ${filePath} does not exist`); 60 | } 61 | 62 | return workspace.openTextDocument(filePath); 63 | }; 64 | 65 | export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 66 | 67 | export const closeAllEditors = async () => { 68 | await commands.executeCommand('workbench.action.closeAllEditors'); 69 | await delay(100); 70 | }; 71 | 72 | export const updateConfigProperty = async (property: string, value: unknown) => { 73 | await workspace.getConfiguration().update(property, value, ConfigurationTarget.Workspace); 74 | }; 75 | 76 | export const closeEditorsAndCleanWorkspace = async () => { 77 | await closeAllEditors(); 78 | cleanWorkspace(); 79 | }; 80 | 81 | export const toPlainObject = (value: unknown): R => 82 | value !== undefined ? JSON.parse(JSON.stringify(value)) : value; 83 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "settings": { 9 | "import/resolver": { 10 | "node": { 11 | "paths": ["src"], 12 | "extensions": [".ts"] 13 | } 14 | } 15 | }, 16 | "extends": [ 17 | "prettier", 18 | "prettier/@typescript-eslint", 19 | "plugin:@typescript-eslint/recommended", 20 | "plugin:import/errors", 21 | "plugin:import/warnings" 22 | ], 23 | "plugins": ["@typescript-eslint", "prettier"], 24 | "rules": { 25 | "eqeqeq": "warn", 26 | "no-throw-literal": "warn", 27 | "semi": "off", 28 | "prettier/prettier": "error", 29 | "camelcase": 0, 30 | "default-case": 0, 31 | "curly": ["error", "all"], 32 | "global-require": 0, 33 | "import/export": 0, 34 | "import/extensions": 0, 35 | "import/named": 0, 36 | "import/no-named-as-default-member": 0, 37 | "import/no-named-as-default": 0, 38 | "import/no-unresolved": 0, 39 | "import/prefer-default-export": 0, 40 | "import/order": [ 41 | "error", 42 | { 43 | "newlines-between": "always", 44 | "groups": [ 45 | ["external", "builtin"], 46 | ["internal", "index", "sibling", "parent"] 47 | ] 48 | } 49 | ], 50 | "max-classes-per-file": 0, 51 | "new-cap": ["error", { "capIsNew": false }], 52 | "no-restricted-globals": 0, 53 | "no-var-requires": 0, 54 | "prefer-object-spread": "error", 55 | "prefer-destructuring": 0, 56 | "strict": 0, 57 | "consistent-return": 0, 58 | "@typescript-eslint/no-unused-vars": ["warn", { "ignoreRestSiblings": true }], 59 | "@typescript-eslint/camelcase": 0, 60 | "@typescript-eslint/explicit-function-return-type": 0, 61 | "@typescript-eslint/prefer-interface": 0, 62 | "@typescript-eslint/no-var-requires": 0, 63 | "@typescript-eslint/no-explicit-any": 0, 64 | "@typescript-eslint/no-empty-function": 0, 65 | "@typescript-eslint/no-non-null-assertion": 0, 66 | "@typescript-eslint/ban-types": 0, 67 | "@typescript-eslint/no-inferrable-types": 0, 68 | "@typescript-eslint/no-empty-interface": 0, 69 | "@typescript-eslint/no-object-literal-type-assertion": 0, 70 | "@typescript-eslint/indent": 0, 71 | "@typescript-eslint/consistent-type-definitions": ["error", "type"], 72 | "@typescript-eslint/explicit-module-boundary-types": 0, 73 | "@typescript-eslint/naming-convention": "warn", 74 | "@typescript-eslint/semi": "warn" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/commands/extractRangeToNewNote.ts: -------------------------------------------------------------------------------- 1 | import vscode, { Uri, window } from 'vscode'; 2 | import fs from 'fs-extra'; 3 | import path from 'path'; 4 | 5 | const filename = 'New File.md'; 6 | 7 | const prompt = 'New location within workspace'; 8 | 9 | const createFile = async (uri: vscode.Uri, content: string) => { 10 | const workspaceEdit = new vscode.WorkspaceEdit(); 11 | workspaceEdit.createFile(uri); 12 | workspaceEdit.set(uri, [new vscode.TextEdit(new vscode.Range(0, 0, 0, 0), content)]); 13 | 14 | await vscode.workspace.applyEdit(workspaceEdit); 15 | }; 16 | 17 | const showFile = async (uri: vscode.Uri) => 18 | await window.showTextDocument(await vscode.workspace.openTextDocument(uri)); 19 | 20 | const deleteRange = async (document: vscode.TextDocument, range: vscode.Range) => { 21 | const editor = await window.showTextDocument(document); 22 | await editor.edit((edit) => edit.delete(range)); 23 | }; 24 | 25 | const extractRangeToNewNote = async ( 26 | documentParam?: vscode.TextDocument, 27 | rangeParam?: vscode.Range, 28 | ) => { 29 | const document = documentParam ? documentParam : window.activeTextEditor?.document; 30 | 31 | if (!document || (document && document.languageId !== 'markdown')) { 32 | return; 33 | } 34 | 35 | const range = rangeParam ? rangeParam : window.activeTextEditor?.selection; 36 | 37 | if (!range || (range && range.isEmpty)) { 38 | return; 39 | } 40 | 41 | const filepath = path.join(path.dirname(document.uri.fsPath), filename); 42 | const targetPath = await window.showInputBox({ 43 | prompt, 44 | value: filepath, 45 | valueSelection: [filepath.lastIndexOf(filename), filepath.lastIndexOf('.md')], 46 | }); 47 | 48 | const targetUri = Uri.file(targetPath || ''); 49 | 50 | const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); 51 | 52 | if (!targetPath) { 53 | return; 54 | } 55 | 56 | if (!vscode.workspace.getWorkspaceFolder(targetUri)) { 57 | throw new Error( 58 | `New location "${targetUri.fsPath}" should be within the current workspace.${ 59 | workspaceFolder ? ` Example: ${path.join(workspaceFolder.uri.fsPath, filename)}` : '' 60 | }`, 61 | ); 62 | } 63 | 64 | if (await fs.pathExists(targetUri.fsPath)) { 65 | throw new Error('Such file or directory already exists. Please use unique filename instead.'); 66 | } 67 | 68 | // Order matters 69 | await createFile(targetUri, document.getText(range).trim()); 70 | 71 | await deleteRange(document, range); 72 | 73 | await showFile(targetUri); 74 | }; 75 | 76 | export default extractRangeToNewNote; 77 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | 11 | jobs: 12 | check-types: 13 | name: Check Types 14 | runs-on: ubuntu-18.04 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Setup Node 18 | uses: actions/setup-node@v1 19 | - name: Install Dependencies 20 | run: yarn 21 | - name: Check Types 22 | run: npm run ts 23 | lint: 24 | name: Run Linter 25 | runs-on: ubuntu-18.04 26 | steps: 27 | - uses: actions/checkout@v1 28 | - name: Setup Node 29 | uses: actions/setup-node@v1 30 | - name: Install Dependencies 31 | run: yarn 32 | - name: Check Types 33 | run: npm run lint 34 | tests: 35 | name: Run Tests 36 | strategy: 37 | matrix: 38 | os: [macos-10.15, ubuntu-18.04, windows-2019] 39 | runs-on: ${{ matrix.os }} 40 | env: 41 | OS: ${{ matrix.os }} 42 | steps: 43 | - uses: actions/checkout@v1 44 | - name: Setup Node 45 | uses: actions/setup-node@v1 46 | - name: Install Dependencies 47 | run: yarn 48 | - name: Run Tests 49 | uses: GabrielBB/xvfb-action@v1.0 50 | with: 51 | run: yarn test:ci 52 | - name: Upload coverage to Codecov 53 | uses: codecov/codecov-action@v1 54 | with: 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | directory: ./coverage/ 57 | env_vars: OS 58 | fail_ci_if_error: true 59 | create_release: 60 | name: Create Release 61 | if: success() && startsWith(github.ref, 'refs/tags/v') 62 | runs-on: ubuntu-18.04 63 | needs: [check-types, lint, tests] 64 | steps: 65 | - uses: actions/checkout@v1 66 | - name: Setup Node 67 | uses: actions/setup-node@v1 68 | - name: Install Dependencies 69 | run: yarn 70 | - name: Package 71 | run: yarn package 72 | - uses: "marvinpinto/action-automatic-releases@latest" 73 | with: 74 | repo_token: ${{ secrets.GITHUB_TOKEN }} 75 | prerelease: false 76 | files: | 77 | markdown-kit-*.vsix 78 | publish: 79 | name: Publish Release 80 | runs-on: ubuntu-18.04 81 | needs: [create_release] 82 | steps: 83 | - uses: actions/checkout@v1 84 | - name: Setup Node 85 | uses: actions/setup-node@v1 86 | - name: Install Dependencies 87 | run: yarn 88 | - name: Publish 89 | env: 90 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 91 | run: yarn deploy 92 | -------------------------------------------------------------------------------- /src/commands/sendRangeToExistingNote.ts: -------------------------------------------------------------------------------- 1 | import vscode, { Uri, window, workspace } from 'vscode'; 2 | import path from 'path'; 3 | import { sort as sortPaths } from 'cross-path-sort'; 4 | 5 | import { getMarkdownKitConfigProperty } from '../utils'; 6 | 7 | const createExistingNoteUrisQuickPick = async () => { 8 | const uris = sortPaths(await workspace.findFiles('**/*.md'), { 9 | pathKey: 'path', 10 | shallowFirst: false, 11 | }); 12 | 13 | const quickPick = window.createQuickPick(); 14 | 15 | quickPick.matchOnDescription = true; 16 | quickPick.matchOnDetail = true; 17 | 18 | quickPick.items = uris.map((uri) => { 19 | return { 20 | label: path.relative(workspace.getWorkspaceFolder(uri)!.uri.fsPath, uri.fsPath), 21 | detail: uri.fsPath, 22 | }; 23 | }); 24 | 25 | return quickPick; 26 | }; 27 | 28 | const sendText = async (uri: vscode.Uri, text: string, shouldAppend: boolean) => { 29 | const doc = await workspace.openTextDocument(uri); 30 | const workspaceEdit = new vscode.WorkspaceEdit(); 31 | const position = new vscode.Position(shouldAppend || doc.lineCount === 0 ? 0 : doc.lineCount, 0); 32 | const hasOneEmptyLine = doc.lineCount === 1 && doc.lineAt(0).text.trim() === ''; 33 | const lastLineHasContent = doc.lineAt(doc.lineCount - 1).text.trim() !== ''; 34 | const maybeLeadingNewLine = !shouldAppend || (!shouldAppend && lastLineHasContent) ? '\n' : ''; 35 | const maybeTrailingNewLine = 36 | (shouldAppend && !hasOneEmptyLine) || doc.lineCount === 0 || !shouldAppend ? '\n' : ''; 37 | const maybePushLine = shouldAppend || (!shouldAppend && lastLineHasContent) ? '\n' : ''; 38 | const content = maybePushLine + maybeLeadingNewLine + text + maybeTrailingNewLine; 39 | workspaceEdit.set(uri, [vscode.TextEdit.insert(position, content)]); 40 | 41 | await vscode.workspace.applyEdit(workspaceEdit); 42 | 43 | const insertPos = 44 | shouldAppend || doc.lineCount === 0 45 | ? doc.positionAt(text.length) 46 | : doc.positionAt(doc.offsetAt(new vscode.Position(doc.lineCount, 0)) + ('\n' + text).length); 47 | 48 | await doc.save(); 49 | 50 | return insertPos; 51 | }; 52 | 53 | const updateSelection = (editor: vscode.TextEditor, pos: vscode.Position) => { 54 | editor.selection = new vscode.Selection(pos.line, pos.character, pos.line, pos.character); 55 | }; 56 | 57 | const showFile = async (uri: vscode.Uri) => 58 | await window.showTextDocument(await vscode.workspace.openTextDocument(uri)); 59 | 60 | const deleteRange = async (document: vscode.TextDocument, range: vscode.Range) => { 61 | const editor = await window.showTextDocument(document); 62 | await editor.edit((edit) => edit.delete(range)); 63 | }; 64 | 65 | const sendRangeToExistingNote = async ( 66 | documentParam?: vscode.TextDocument, 67 | rangeParam?: vscode.Range, 68 | ) => { 69 | const document = documentParam ? documentParam : window.activeTextEditor?.document; 70 | 71 | if (!document || (document && document.languageId !== 'markdown')) { 72 | return; 73 | } 74 | 75 | const range = rangeParam ? rangeParam : window.activeTextEditor?.selection; 76 | 77 | if (!range || (range && range.isEmpty)) { 78 | return; 79 | } 80 | 81 | const quickPick = await createExistingNoteUrisQuickPick(); 82 | 83 | quickPick.onDidChangeSelection(async (selection) => { 84 | const targetPath = selection[0].detail; 85 | 86 | const targetUri = Uri.file(targetPath || ''); 87 | 88 | if (!targetPath) { 89 | return; 90 | } 91 | 92 | const position = getMarkdownKitConfigProperty('sendRangeToExistingNote.position', 'start'); 93 | const removeRangeFromSource = getMarkdownKitConfigProperty( 94 | 'sendRangeToExistingNote.removeRangeFromSource', 95 | true, 96 | ); 97 | const showTarget = getMarkdownKitConfigProperty('sendRangeToExistingNote.showTarget', true); 98 | 99 | // Order matters 100 | const insertPos = await sendText( 101 | targetUri, 102 | document.getText(range).trim(), 103 | position === 'start', 104 | ); 105 | 106 | if (removeRangeFromSource) { 107 | await deleteRange(document, range); 108 | } 109 | 110 | if (showTarget) { 111 | const editor = await showFile(targetUri); 112 | 113 | updateSelection(editor, insertPos); 114 | } else { 115 | vscode.window.showInformationMessage( 116 | `Range successfully sent to "${path.basename(targetUri.fsPath)}".`, 117 | ); 118 | } 119 | 120 | quickPick.hide(); 121 | }); 122 | 123 | quickPick.onDidHide(() => quickPick.dispose()); 124 | 125 | quickPick.show(); 126 | }; 127 | 128 | export default sendRangeToExistingNote; 129 | -------------------------------------------------------------------------------- /src/commands/extractRangeToNewNote.spec.ts: -------------------------------------------------------------------------------- 1 | import vscode, { window } from 'vscode'; 2 | import path from 'path'; 3 | 4 | import extractRangeToNewNote from './extractRangeToNewNote'; 5 | import { getWorkspaceFolder } from '../utils'; 6 | import { 7 | closeEditorsAndCleanWorkspace, 8 | rndName, 9 | createFile, 10 | openTextDocument, 11 | } from '../test/testUtils'; 12 | 13 | describe('extractRangeToNewNote command', () => { 14 | beforeEach(closeEditorsAndCleanWorkspace); 15 | 16 | afterEach(closeEditorsAndCleanWorkspace); 17 | 18 | it('should extract range to a new note', async () => { 19 | const name0 = rndName(); 20 | const name1 = rndName(); 21 | 22 | await createFile(`${name0}.md`, 'Hello world.'); 23 | 24 | const doc = await openTextDocument(`${name0}.md`); 25 | 26 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 27 | 28 | targetPathInputBoxSpy.mockReturnValue( 29 | Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)), 30 | ); 31 | 32 | await extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12)); 33 | 34 | expect(await doc.getText()).toBe(''); 35 | 36 | const newDoc = await openTextDocument(`${name1}.md`); 37 | 38 | expect(await newDoc.getText()).toBe('Hello world.'); 39 | 40 | targetPathInputBoxSpy.mockRestore(); 41 | }); 42 | 43 | it('should extract a multiline range to a new note', async () => { 44 | const name0 = rndName(); 45 | const name1 = rndName(); 46 | 47 | await createFile( 48 | `${name0}.md`, 49 | `Multiline 50 | Hello world.`, 51 | ); 52 | 53 | const doc = await openTextDocument(`${name0}.md`); 54 | 55 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 56 | 57 | targetPathInputBoxSpy.mockReturnValue( 58 | Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)), 59 | ); 60 | 61 | await extractRangeToNewNote(doc, new vscode.Range(0, 0, 1, 16)); 62 | 63 | expect(await doc.getText()).toBe(''); 64 | 65 | const newDoc = await openTextDocument(`${name1}.md`); 66 | 67 | expect(await newDoc.getText()).toMatchInlineSnapshot(` 68 | "Multiline 69 | Hello world." 70 | `); 71 | 72 | targetPathInputBoxSpy.mockRestore(); 73 | }); 74 | 75 | it('should extract range from active markdown file', async () => { 76 | const name0 = rndName(); 77 | const name1 = rndName(); 78 | 79 | await createFile(`${name0}.md`, 'Hello world.'); 80 | 81 | const doc = await openTextDocument(`${name0}.md`); 82 | const editor = await window.showTextDocument(doc); 83 | 84 | editor.selection = new vscode.Selection(0, 0, 0, 12); 85 | 86 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 87 | 88 | targetPathInputBoxSpy.mockReturnValue( 89 | Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)), 90 | ); 91 | 92 | await extractRangeToNewNote(); 93 | 94 | expect(await doc.getText()).toBe(''); 95 | 96 | const newDoc = await openTextDocument(`${name1}.md`); 97 | 98 | expect(await newDoc.getText()).toBe('Hello world.'); 99 | 100 | targetPathInputBoxSpy.mockRestore(); 101 | }); 102 | 103 | it('should not extract anything from unknown file format', async () => { 104 | const name0 = rndName(); 105 | 106 | await createFile(`${name0}.txt`, 'Hello world.'); 107 | 108 | const doc = await openTextDocument(`${name0}.txt`); 109 | const editor = await window.showTextDocument(doc); 110 | 111 | editor.selection = new vscode.Selection(0, 0, 0, 12); 112 | 113 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 114 | 115 | await extractRangeToNewNote(); 116 | 117 | expect(await doc.getText()).toBe('Hello world.'); 118 | 119 | expect(targetPathInputBoxSpy).not.toBeCalled(); 120 | 121 | targetPathInputBoxSpy.mockRestore(); 122 | }); 123 | 124 | it('should fail when target path is outside of the workspace', async () => { 125 | const name0 = rndName(); 126 | 127 | await createFile(`${name0}.md`, 'Hello world.'); 128 | 129 | const doc = await openTextDocument(`${name0}.md`); 130 | 131 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 132 | 133 | targetPathInputBoxSpy.mockReturnValue(Promise.resolve('/random-path/file.md')); 134 | 135 | expect(extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12))).rejects.toThrowError( 136 | 'should be within the current workspace', 137 | ); 138 | 139 | targetPathInputBoxSpy.mockRestore(); 140 | }); 141 | 142 | it('should fail when entered file already exists', async () => { 143 | const name0 = rndName(); 144 | const name1 = rndName(); 145 | 146 | await createFile(`${name0}.md`, 'Hello world.'); 147 | await createFile(`${name1}.md`); 148 | 149 | const doc = await openTextDocument(`${name0}.md`); 150 | 151 | const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox'); 152 | 153 | targetPathInputBoxSpy.mockReturnValue( 154 | Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)), 155 | ); 156 | 157 | expect(extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12))).rejects.toThrowError( 158 | 'Such file or directory already exists. Please use unique filename instead.', 159 | ); 160 | 161 | targetPathInputBoxSpy.mockRestore(); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/utils/clipboardUtils.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import { inspect } from 'util'; 3 | import fs from 'fs-extra'; 4 | 5 | /* These utils borrowed from https://github.com/andrewdotn/2md */ 6 | 7 | export function run(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> { 8 | const proc = spawn(cmd, args, { 9 | stdio: ['ignore', 'pipe', 'pipe'], 10 | }); 11 | 12 | const output = { stdout: '', stderr: '' }; 13 | 14 | proc.stdio[1]!.on('data', (data) => (output.stdout += data)); 15 | proc.stdio[2]!.on('data', (data) => (output.stderr += data)); 16 | 17 | return new Promise((resolve, reject) => { 18 | proc.on('error', (e) => reject(e)); 19 | proc.on('exit', (code, signal) => { 20 | if (code === 1 && /\(-1700\)$/m.test(output.stderr)) { 21 | return reject(new Error('The clipboard does not currently contain HTML-formatted data.')); 22 | } 23 | 24 | if (code !== 0 || signal) { 25 | return reject( 26 | new Error(`${inspect(cmd)} returned [${code}, ${signal}]; output was ${inspect(output)}`), 27 | ); 28 | } 29 | 30 | return resolve(output); 31 | }); 32 | }); 33 | } 34 | 35 | async function readClipboardMac() { 36 | const osaOutput = await run('osascript', ['-e', 'the clipboard as «class HTML»']); 37 | const hexEncodedHtml = osaOutput.stdout; 38 | const match = /^«data HTML((?:[0-9A-F]{2})+)»$\n/m.exec(hexEncodedHtml); 39 | 40 | if (!match) { 41 | throw new Error('Could not parse osascript output'); 42 | } 43 | 44 | return Buffer.from(match[1], 'hex').toString(); 45 | } 46 | 47 | async function readClipboardUnix() { 48 | const output = await run('xclip', ['-o', '-selection', 'clipboard', '-t', 'text/html']); 49 | 50 | if ((output.stderr ?? '') !== '') { 51 | throw new Error(`xclip printed an error: ${output.stderr}`); 52 | } 53 | 54 | return output.stdout; 55 | } 56 | 57 | // This does match https://en.wikipedia.org/wiki/Windows-1252 but was computed 58 | // by making a UTF-8 webpage with all characters from U+0000 to U+00FE, copying 59 | // and pasting, and writing some comparison code to see what got mangled. 60 | const cp1252Inverse: { [unicode: number]: number } = { 61 | 0x20ac: 0x80, 62 | 0x201a: 0x82, 63 | 0x192: 0x83, 64 | 0x201e: 0x84, 65 | 0x2026: 0x85, 66 | 0x2020: 0x86, 67 | 0x2021: 0x87, 68 | 0x2c6: 0x88, 69 | 0x2030: 0x89, 70 | 0x160: 0x8a, 71 | 0x2039: 0x8b, 72 | 0x152: 0x8c, 73 | 0x17d: 0x8e, 74 | 0x2018: 0x91, 75 | 0x2019: 0x92, 76 | 0x201c: 0x93, 77 | 0x201d: 0x94, 78 | 0x2022: 0x95, 79 | 0x2013: 0x96, 80 | 0x2014: 0x97, 81 | 0x2dc: 0x98, 82 | 0x2122: 0x99, 83 | 0x161: 0x9a, 84 | 0x203a: 0x9b, 85 | 0x153: 0x9c, 86 | 0x17e: 0x9e, 87 | 0x178: 0x9f, 88 | }; 89 | 90 | /* Turn a UTF-8+cp1252+UTF-16LE+BOM-encoded mess into a UTF-8 string. */ 91 | export function unMojibake(s: Buffer) { 92 | if (s[0] !== 0xff || s[1] !== 0xfe) { 93 | throw new Error('No BOM in clipboard output'); 94 | } 95 | 96 | // Turn UTF-16LE pairs into integers, ignoring endianness for now 97 | const array = new Uint16Array(s.buffer, s.byteOffset + 2, s.length / 2 - 1); 98 | 99 | // The string was UTF-8 encoded before getting the UTF-16 treatment, so 100 | // anything that doesn't fit in 8 bits has been mangled through cp1252. 101 | for (let i = 0; i < array.length; i++) { 102 | if (array[i] > 0xff) { 103 | const v = cp1252Inverse[array[i]]; 104 | 105 | if (v === undefined) { 106 | throw new Error(`Unknown cp1252 code point at ${i}: 0x${array[i].toString(16)}`); 107 | } 108 | 109 | array[i] = v; 110 | } 111 | } 112 | 113 | const decoded = Buffer.from(array); 114 | 115 | return decoded.toString('utf-8'); 116 | } 117 | 118 | async function readClipboardWindows() { 119 | const output = await run('powershell.exe', [ 120 | '-c', 121 | // When printing to the console, the encoding gets even more messed up, so 122 | // we use a temporary file instead. 123 | ` 124 | $tmp = New-TemporaryFile 125 | Get-Clipboard -TextFormatType Html > $tmp 126 | $tmp.ToString() 127 | `, 128 | ]); 129 | const tmpFilename = output.stdout.trim(); 130 | 131 | if ((output.stderr ?? '') !== '') { 132 | if (await fs.pathExists(tmpFilename)) { 133 | fs.unlink(tmpFilename); 134 | } 135 | throw new Error(`Powershell returned an error: ${output.stderr}`); 136 | } 137 | 138 | const tmpfileContent = await fs.readFile(tmpFilename); 139 | 140 | fs.unlink(tmpFilename); 141 | 142 | if (tmpfileContent[0] !== 0xff || tmpfileContent[1] !== 0xfe) { 143 | throw new Error('No BOM in clipboard output'); 144 | } 145 | 146 | const clipboardFormatHtml = unMojibake(tmpfileContent); 147 | const match = /^Version:([0-9]+)\.([0-9]+)\r?\nStartHTML:([0-9]+)/.exec(clipboardFormatHtml); 148 | 149 | if (!match || match.index !== 0) { 150 | throw new Error('Get-Clipboard did not return CF_HTML output'); 151 | } 152 | 153 | const htmlStartIndex = parseInt(match[3]); 154 | 155 | return clipboardFormatHtml.slice(htmlStartIndex); 156 | } 157 | 158 | export async function readClipboard() { 159 | for (const c of [readClipboardMac, readClipboardUnix, readClipboardWindows]) { 160 | try { 161 | return await c(); 162 | } catch (e) { 163 | if (e.code === 'ENOENT') { 164 | continue; 165 | } 166 | throw e; 167 | } 168 | } 169 | throw new Error('Unable to find a clipboard-reading program, please try' + ' file input instead'); 170 | } 171 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-kit", 3 | "displayName": "Markdown Kit", 4 | "description": "Collection of commands and utilities for managing Markdown notes in VSCode", 5 | "publisher": "svsool", 6 | "version": "1.2.0", 7 | "author": "Svyatoslav Sobol ", 8 | "license": "MIT", 9 | "icon": "media/markdown-kit.png", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/svsool/vscode-markdown-kit" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/svsool/vscode-markdown-kit" 16 | }, 17 | "engines": { 18 | "vscode": "^1.47.0" 19 | }, 20 | "categories": [ 21 | "Other" 22 | ], 23 | "keywords": [ 24 | "markdown", 25 | "kit", 26 | "utils", 27 | "utilities", 28 | "refactoring", 29 | "copy", 30 | "paste", 31 | "html", 32 | "clipboard", 33 | "extract" 34 | ], 35 | "activationEvents": [ 36 | "onLanguage:markdown", 37 | "onCommand:markdown-kit.pasteHtmlAsMarkdown", 38 | "onCommand:markdown-kit.extractRangeToNewNote", 39 | "onCommand:markdown-kit.sendRangeToExistingNote" 40 | ], 41 | "main": "./dist/extension", 42 | "contributes": { 43 | "commands": [ 44 | { 45 | "command": "markdown-kit.pasteHtmlAsMarkdown", 46 | "title": "Paste HTML as Markdown", 47 | "category": "Markdown Kit" 48 | }, 49 | { 50 | "command": "markdown-kit.extractRangeToNewNote", 51 | "title": "Extract range to a new note", 52 | "category": "Markdown Kit" 53 | }, 54 | { 55 | "command": "markdown-kit.sendRangeToExistingNote", 56 | "title": "Send range to an existing note", 57 | "category": "Markdown Kit" 58 | } 59 | ], 60 | "configuration": { 61 | "type": "object", 62 | "title": "Markdown Kit", 63 | "properties": { 64 | "markdown-kit.sendRangeToExistingNote.position": { 65 | "scope": "resource", 66 | "type": "string", 67 | "description": "Specifies the position where to save the range in the existing note.", 68 | "enum": ["start", "end"], 69 | "default": "start" 70 | }, 71 | "markdown-kit.sendRangeToExistingNote.removeRangeFromSource": { 72 | "scope": "resource", 73 | "type": "boolean", 74 | "description": "Removes range from the source when checked otherwise keeps the source intact.", 75 | "default": true 76 | }, 77 | "markdown-kit.sendRangeToExistingNote.showTarget": { 78 | "scope": "resource", 79 | "type": "boolean", 80 | "description": "Shows the target note when checked otherwise stays in the current one.", 81 | "default": true 82 | } 83 | } 84 | }, 85 | "menus": { 86 | "commandPalette": [ 87 | { 88 | "command": "markdown-kit.extractRangeToNewNote", 89 | "when": "editorHasSelection && editorLangId == markdown" 90 | }, 91 | { 92 | "command": "markdown-kit.sendRangeToExistingNote", 93 | "when": "editorHasSelection && editorLangId == markdown" 94 | } 95 | ] 96 | } 97 | }, 98 | "lint-staged": { 99 | "*.ts": [ 100 | "eslint --fix" 101 | ] 102 | }, 103 | "scripts": { 104 | "vscode:prepublish": "webpack --mode production", 105 | "deploy": "vsce publish --yarn", 106 | "package": "vsce package --yarn", 107 | "clean": "del dist out", 108 | "lint": "eslint src --ext ts", 109 | "compile": "webpack --mode development", 110 | "compile:tests": "tsc -p ./ && yarn run webpack --mode development", 111 | "watch": "webpack --mode development -w", 112 | "pretest": "yarn compile:tests", 113 | "pretest:ci": "yarn compile:tests", 114 | "pretest:watch": "yarn compile:tests", 115 | "release": "standard-version", 116 | "ts": "tsc --noEmit", 117 | "test": "node ./out/test/runTest.js", 118 | "test:ci": "cross-env JEST_CI=true JEST_COLLECT_COVERAGE=true node ./out/test/runTest.js", 119 | "test:watch": "cross-env JEST_WATCH=true node ./out/test/runTest.js" 120 | }, 121 | "devDependencies": { 122 | "@commitlint/cli": "^9.1.1", 123 | "@commitlint/config-conventional": "^9.1.1", 124 | "@types/del": "^4.0.0", 125 | "@types/fs-extra": "^9.0.1", 126 | "@types/jest": "^26.0.9", 127 | "@types/lodash.debounce": "^4.0.6", 128 | "@types/lodash.groupby": "^4.6.6", 129 | "@types/lodash.range": "^3.2.6", 130 | "@types/markdown-it": "^10.0.2", 131 | "@types/moment": "^2.13.0", 132 | "@types/node": "^14.0.27", 133 | "@types/open": "^6.2.1", 134 | "@types/turndown": "^5.0.0", 135 | "@types/vscode": "^1.47.0", 136 | "@typescript-eslint/eslint-plugin": "^3.9.0", 137 | "@typescript-eslint/parser": "^3.9.0", 138 | "cross-env": "^7.0.2", 139 | "del": "^5.1.0", 140 | "del-cli": "^3.0.1", 141 | "eslint": "^7.6.0", 142 | "eslint-config-prettier": "^6.11.0", 143 | "eslint-plugin-import": "^2.22.0", 144 | "eslint-plugin-prettier": "^3.1.4", 145 | "husky": "^4.2.5", 146 | "jest": "^26.3.0", 147 | "jest-environment-vscode": "^1.0.0", 148 | "lint-staged": "^10.2.9", 149 | "prettier": "^2.0.5", 150 | "standard-version": "^8.0.2", 151 | "ts-jest": "^26.2.0", 152 | "ts-loader": "^8.0.2", 153 | "typescript": "^3.9.7", 154 | "vsce": "^1.78.0", 155 | "vscode-test": "^1.3.0", 156 | "wait-for-expect": "^3.0.2", 157 | "webpack": "^4.44.1", 158 | "webpack-cli": "^3.3.12" 159 | }, 160 | "dependencies": { 161 | "cross-path-sort": "^1.0.0", 162 | "fs-extra": "^9.0.1", 163 | "turndown": "^6.0.0" 164 | } 165 | } 166 | --------------------------------------------------------------------------------