diff --git a/app/assets/javascripts/blob/xlsx/components/table.vue b/app/assets/javascripts/blob/xlsx/components/table.vue new file mode 100644 index 0000000000000000000000000000000000000000..81227fefaea8071f193d92338da4b0786771f72d --- /dev/null +++ b/app/assets/javascripts/blob/xlsx/components/table.vue @@ -0,0 +1,88 @@ + + + diff --git a/app/assets/javascripts/blob/xlsx/components/tabs.vue b/app/assets/javascripts/blob/xlsx/components/tabs.vue new file mode 100644 index 0000000000000000000000000000000000000000..88375615e1715de788525d767e6234991d4d70ed --- /dev/null +++ b/app/assets/javascripts/blob/xlsx/components/tabs.vue @@ -0,0 +1,40 @@ + + + diff --git a/app/assets/javascripts/blob/xlsx/eventhub.js b/app/assets/javascripts/blob/xlsx/eventhub.js new file mode 100644 index 0000000000000000000000000000000000000000..0948c2e53524a736a55c060600868ce89ee7687a --- /dev/null +++ b/app/assets/javascripts/blob/xlsx/eventhub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/blob/xlsx/index.vue b/app/assets/javascripts/blob/xlsx/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..d841066cc87ffd92b94447d21299c4b79874f47a --- /dev/null +++ b/app/assets/javascripts/blob/xlsx/index.vue @@ -0,0 +1,83 @@ + + + diff --git a/app/assets/javascripts/blob/xlsx/service.js b/app/assets/javascripts/blob/xlsx/service.js new file mode 100644 index 0000000000000000000000000000000000000000..9135e8edc58aa346a01bdc944779379bb3ca0a00 --- /dev/null +++ b/app/assets/javascripts/blob/xlsx/service.js @@ -0,0 +1,93 @@ +/* eslint-disable class-methods-use-this */ +import xlsx from 'xlsx'; + +export default class XlsxService { + constructor(endpoint) { + this.endpoint = endpoint; + } + + getData() { + return this.loadFile() + .then(workbook => this.processWorkbook(workbook)); + } + + loadFile() { + return new Promise((resolve) => { + const request = new XMLHttpRequest(); + request.open('GET', this.endpoint, true); + request.responseType = 'arraybuffer'; + + request.onload = () => { + const arraybuffer = request.response; + const data = new Uint8Array(arraybuffer); + const arr = []; + data.forEach((d) => { + arr.push(String.fromCharCode(d)); + }); + const bstr = arr.join(''); + const workbook = xlsx.read(bstr, { + type: 'binary', + }); + + resolve(workbook); + }; + + request.send(); + }); + } + + processWorkbook(workbook) { + return new Promise((resolve) => { + const sheets = workbook.Sheets; + const data = {}; + + workbook.SheetNames.forEach((sheetName) => { + const sheet = sheets[sheetName]; + const columns = this.getColumns(sheet); + const rows = xlsx.utils.sheet_to_json(sheet, { + raw: true, + }).map((row) => { + const arr = []; + + columns.forEach((col) => { + const val = row[col]; + + if (typeof val !== 'undefined') { + arr.push(val); + } else { + arr.push(''); + } + }); + + return arr; + }); + + data[sheetName] = { + columns, + rows, + }; + }); + + resolve(data); + }); + } + + getColumns(sheet) { + if (!sheet['!ref']) return []; + + const range = xlsx.utils.decode_range(sheet['!ref']); + const columnHeaders = []; + for (let c = range.s.c; c <= range.e.c; c += 1) { + const val = sheet[xlsx.utils.encode_cell({ + c, + r: range.s.r, + })]; + + if (val) { + columnHeaders.push(val.v); + } + } + + return columnHeaders; + } +} diff --git a/app/assets/javascripts/blob/xlsx_viewer.js b/app/assets/javascripts/blob/xlsx_viewer.js new file mode 100644 index 0000000000000000000000000000000000000000..511510c0c4f97ab9d0d58a273ec2802d988f074b --- /dev/null +++ b/app/assets/javascripts/blob/xlsx_viewer.js @@ -0,0 +1,24 @@ +/* eslint-disable no-new */ +import Vue from 'vue'; +import xlsxTable from './xlsx/index.vue'; + +document.addEventListener('DOMContentLoaded', () => { + new Vue({ + el: document.getElementById('js-xlsx-viewer'), + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + }; + }, + components: { + xlsxTable, + }, + render(createElement) { + return createElement('xlsx-table', { + props: { + endpoint: this.endpoint, + }, + }); + }, + }); +}); diff --git a/app/assets/stylesheets/pages/xlsx.scss b/app/assets/stylesheets/pages/xlsx.scss new file mode 100644 index 0000000000000000000000000000000000000000..d34a001e3f06eebac2f3b2085783c3ad1945baa0 --- /dev/null +++ b/app/assets/stylesheets/pages/xlsx.scss @@ -0,0 +1,30 @@ +$hll-bg: #f8eec7; + +.hll { + > td { + background-color: $hll-bg; + } +} + +.xlsx-table { + border: 0; + + > thead > tr > th { + &:first-of-type { + border-left: 0; + } + + &:last-of-type { + border-right: 0; + } + } + + > tbody > tr > td { + &:first-of-type { + background-color: $gray-light; + border-top: 0; + border-bottom: 0; + vertical-align: middle; + } + } +} diff --git a/app/models/blob.rb b/app/models/blob.rb index 55872acef5101bfffa65d4fd890aa985ea9f409f..51aaf978192af62c59f55ad8468979388d4596e7 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -54,6 +54,10 @@ def pdf? extension == 'pdf' end + def xlsx? + binary? && name && File.extname(name) == '.xlsx' + end + def ipython_notebook? text? && language&.name == 'Jupyter Notebook' end @@ -109,6 +113,8 @@ def to_partial_path(project) else 'text' end + elsif xlsx? + 'xlsx' else 'download' end diff --git a/app/views/projects/blob/_xlsx.html.haml b/app/views/projects/blob/_xlsx.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..d0b3234d056139c3525ccf1b01aaaab7b6b9f0c0 --- /dev/null +++ b/app/views/projects/blob/_xlsx.html.haml @@ -0,0 +1,5 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('xlsx_viewer') + +.file-content#js-xlsx-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } diff --git a/changelogs/unreleased/xlsx-renderer.yml b/changelogs/unreleased/xlsx-renderer.yml new file mode 100644 index 0000000000000000000000000000000000000000..21966ba43922cf54b79399064fab643e6fa4b56b --- /dev/null +++ b/changelogs/unreleased/xlsx-renderer.yml @@ -0,0 +1,4 @@ +--- +title: XLSX files render in the browser +merge_request: +author: diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 59c7050a14de5dfb4f26b5946a268a9aa5a41131..a21463f4679fbdfbc017a7ad11aef44fb1c114b6 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -398,3 +398,15 @@ :why: https://github.com/remy/undefsafe/blob/master/LICENSE :versions: [] :when: 2017-04-10 06:30:00.002555000 Z +- - :approve + - printj + - :who: Phil Hughes + :why: https://github.com/SheetJS/printj/blob/master/LICENSE + :versions: [] + :when: 2017-04-11 15:04:29.316170000 Z +- - :approve + - voc + - :who: Phil Hughes + :why: https://github.com/SheetJS/voc/blob/master/LICENSE + :versions: [] + :when: 2017-04-11 15:05:17.527393000 Z diff --git a/config/webpack.config.js b/config/webpack.config.js index 0fea3f8222b83cd1634e27d399c36ade718c8c0f..9731c000d635b91e1f5c496d3c8df40c08164025 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -40,6 +40,7 @@ var config = { notebook_viewer: './blob/notebook_viewer.js', sketch_viewer: './blob/sketch_viewer.js', pdf_viewer: './blob/pdf_viewer.js', + xlsx_viewer: './blob/xlsx_viewer.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', @@ -123,6 +124,7 @@ var config = { 'merge_conflicts', 'notebook_viewer', 'pdf_viewer', + 'xlsx_viewer', 'vue_pipelines', ], minChunks: function(module, count) { diff --git a/package.json b/package.json index a17399ddb8f614b897e3d2af9a2e97849ccb97b3..dc42b22dd3c5d4a74429acea05bb2f924efb06dc 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "vue-resource": "^0.9.3", "vue-template-compiler": "^2.2.6", "webpack": "^2.3.3", - "webpack-bundle-analyzer": "^2.3.0" + "webpack-bundle-analyzer": "^2.3.0", + "xlsx": "^0.9.9" }, "devDependencies": { "babel-plugin-istanbul": "^4.0.0", diff --git a/spec/javascripts/blob/xlsx/components/table_spec.js b/spec/javascripts/blob/xlsx/components/table_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ccef4b9c22d758ebe8b398b984cd250d7110ceae --- /dev/null +++ b/spec/javascripts/blob/xlsx/components/table_spec.js @@ -0,0 +1,108 @@ +import Vue from 'vue'; +import table from '~/blob/xlsx/components/table.vue'; + +describe('XLSX table', () => { + let vm; + + beforeEach((done) => { + const TableComponent = Vue.extend(table); + + vm = new TableComponent({ + propsData: { + sheet: { + columns: ['test', 'test 2'], + rows: [ + ['test 3', 'test 4'], + ['test 6', 'test 5'], + ], + }, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + location.hash = ''; + }); + + describe('linePath', () => { + it('returns linePath with just the number when hash is empty', () => { + expect( + vm.linePath(0), + ).toBe('#L1'); + }); + + it('returns linePath with just the number when hash has a value', () => { + location.hash = 'test'; + + expect( + vm.linePath(0), + ).toBe('#test-L1'); + }); + }); + + describe('getCurrentLineNumberFromUrl', () => { + it('gets line number', () => { + location.hash = 'L1'; + vm.getCurrentLineNumberFromUrl(); + + expect( + vm.currentLineNumber, + ).toBe(1); + }); + + it('gets line number when hash has sheet name', () => { + location.hash = 'test-L1'; + vm.getCurrentLineNumberFromUrl(); + + expect( + vm.currentLineNumber, + ).toBe(1); + }); + }); + + it('renders column names', () => { + expect( + vm.$el.querySelector('th:nth-child(2)').textContent.trim(), + ).toBe('test'); + + expect( + vm.$el.querySelector('th:nth-child(3)').textContent.trim(), + ).toBe('test 2'); + }); + + describe('row rendering', () => { + it('renders row numbers', () => { + expect( + vm.$el.querySelector('td:first-child').textContent.trim(), + ).toBe('1'); + }); + + it('updates hash when clicking line number', (done) => { + vm.$el.querySelector('td:first-child a').click(); + + Vue.nextTick(() => { + expect( + location.hash, + ).toBe('#L1'); + + done(); + }); + }); + + it('calls updateCurrentLineNumber when clicking line number', (done) => { + spyOn(vm, 'updateCurrentLineNumber'); + + vm.$el.querySelector('td:first-child a').click(); + + Vue.nextTick(() => { + expect( + vm.updateCurrentLineNumber, + ).toHaveBeenCalledWith(0); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/blob/xlsx/components/tabs_spec.js b/spec/javascripts/blob/xlsx/components/tabs_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..031c662471bb2eebe8ac0d890c3b237b0922e4fa --- /dev/null +++ b/spec/javascripts/blob/xlsx/components/tabs_spec.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import tabs from '~/blob/xlsx/components/tabs.vue'; +import eventHub from '~/blob/xlsx/eventhub'; + +describe('XLSX tabs', () => { + let vm; + + beforeEach((done) => { + const TabsComponent = Vue.extend(tabs); + + vm = new TabsComponent({ + propsData: { + currentSheetName: 'test 1', + sheetNames: ['test 1', 'test 2'], + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('changes hash to sheet name', (done) => { + eventHub.$on('update-sheet', (name) => { + expect( + name, + ).toBe('test 2'); + + done(); + }); + + vm.changeSheet('test 2'); + }); + + it('selects current sheet name', () => { + expect( + vm.$el.querySelector('li:first-child'), + ).toHaveClass('active'); + + expect( + vm.$el.querySelector('li:nth-child(2)'), + ).not.toHaveClass('active'); + }); + + it('getTabPath returns encoded path', () => { + expect( + vm.getTabPath('test 2'), + ).toBe(`#${encodeURIComponent('test 2')}`); + }); +}); diff --git a/spec/javascripts/blob/xlsx/index_spec.js b/spec/javascripts/blob/xlsx/index_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..68f0d58f449b0d85ac065aee908b170bee2f0eb0 --- /dev/null +++ b/spec/javascripts/blob/xlsx/index_spec.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import Service from '~/blob/xlsx/service'; +import component from '~/blob/xlsx/index.vue'; +import eventHub from '~/blob/xlsx/eventhub'; + +describe('XLSX Renderer', () => { + let vm; + + beforeEach((done) => { + const RendererComponent = Vue.extend(component); + + spyOn(Service.prototype, 'getData').and.callFake(() => new Promise((resolve) => { + resolve({ + test: { + columns: 1, + }, + 'test 1': { + columns: 2, + }, + }); + + setTimeout(done, 0); + })); + + spyOn(eventHub, '$off'); + + vm = new RendererComponent({ + propsData: { + endpoint: '/', + }, + }).$mount(); + }); + + afterEach(() => { + location.hash = ''; + }); + + it('sheetNames returns array of sheet names', () => { + expect( + vm.sheetNames, + ).toEqual(['test', 'test 1']); + }); + + it('sheet returns currently selected sheet', () => { + expect( + vm.sheet, + ).toEqual({ + columns: 1, + }); + }); + + describe('getInitialSheet', () => { + it('defaults to first sheet', () => { + expect( + vm.currentSheetName, + ).toBe('test'); + }); + + it('uses hash for currentSheetName', () => { + location.hash = 'test 1'; + + expect( + vm.getInitialSheet(), + ).toBe('test 1'); + }); + + it('defaults to first sheet if hash is not found in sheetNames', () => { + location.hash = 'test 2'; + + expect( + vm.getInitialSheet(), + ).toBe('test'); + }); + }); + + it('removes eventHub listener on destroy', () => { + vm.$destroy(); + + expect( + eventHub.$off, + ).toHaveBeenCalledWith('update-sheet', vm.updateSheetName); + }); +}); diff --git a/yarn.lock b/yarn.lock index e16cd9c36730167a9ef8534fd0e2a393be13c11d..8c95b49196a9a0a7507bd9a16b5b79a2e12d5983 100644 --- a/yarn.lock +++ b/yarn.lock @@ -37,6 +37,14 @@ acorn@^4.0.11, acorn@^4.0.3, acorn@^4.0.4: version "4.0.11" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.11.tgz#edcda3bd937e7556410d42ed5860f67399c794c0" +adler-32@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/adler-32/-/adler-32-1.0.0.tgz#28728a71756f629666dd1653cd80793a9df18651" + dependencies: + concat-stream "" + exit-on-epipe "" + printj "" + after@0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" @@ -1068,6 +1076,12 @@ center-align@^0.1.1: align-text "^0.1.3" lazy-cache "^1.0.3" +cfb@~0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/cfb/-/cfb-0.11.1.tgz#a96db8f272a6c3fb99dbbb23ef41223f48be1ea7" + dependencies: + commander "" + chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -1153,6 +1167,15 @@ code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" +codepage@~1.8.0: + version "1.8.1" + resolved "https://registry.yarnpkg.com/codepage/-/codepage-1.8.1.tgz#f1a009d5261dc2754628bacb6fbbf0e6e2abffaa" + dependencies: + commander "" + concat-stream "" + exit-on-epipe "" + voc "" + color-convert@^1.3.0: version "1.9.0" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" @@ -1185,6 +1208,10 @@ colormin@^1.0.5: css-color-names "0.0.4" has "^1.0.1" +colors@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" + colors@^1.1.0, colors@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" @@ -1201,7 +1228,7 @@ combined-stream@^1.0.5, combined-stream@~1.0.5: dependencies: delayed-stream "~1.0.0" -commander@^2.8.1, commander@^2.9.0: +commander@, commander@^2.8.1, commander@^2.9.0, commander@~2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" dependencies: @@ -1257,6 +1284,14 @@ concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" +concat-stream@, concat-stream@^1.4.6: + version "1.6.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7" + dependencies: + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + concat-stream@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.0.tgz#53f7d43c51c5e43f81c8fdd03321c631be68d611" @@ -1265,14 +1300,6 @@ concat-stream@1.5.0: readable-stream "~2.0.0" typedarray "~0.0.5" -concat-stream@^1.4.6: - version "1.6.0" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7" - dependencies: - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - config-chain@~1.1.5: version "1.1.11" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.11.tgz#aba09747dfbe4c3e70e766a6e41586e1859fc6f2" @@ -1373,6 +1400,14 @@ cosmiconfig@^2.1.0, cosmiconfig@^2.1.1: parse-json "^2.2.0" require-from-string "^1.1.0" +crc-32@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.0.1.tgz#2efbddb7ccbb7beb4d181803b10e33a29c9fd214" + dependencies: + concat-stream "" + exit-on-epipe "" + printj "" + create-ecdh@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d" @@ -2116,6 +2151,10 @@ exit-hook@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" +exit-on-epipe@, exit-on-epipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/exit-on-epipe/-/exit-on-epipe-1.0.0.tgz#f6e0579c8214d33a08109fd6e2e5c1dbc70463fc" + expand-braces@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/expand-braces/-/expand-braces-0.1.2.tgz#488b1d1d2451cb3d3a6b192cfc030f44c5855fea" @@ -2362,6 +2401,10 @@ forwarded@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" +frac@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/frac/-/frac-0.3.1.tgz#577677b7fdcbe6faf7c461f1801d34137cda4354" + fresh@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.3.0.tgz#651f838e22424e7566de161d8358caa199f83d4f" @@ -4411,6 +4454,10 @@ preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" +printj@: + version "1.0.0" + resolved "https://registry.yarnpkg.com/printj/-/printj-1.0.0.tgz#5c37de6c5772a3fed8468399c2063b5b22528867" + private@^0.1.6: version "0.1.7" resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1" @@ -5137,6 +5184,14 @@ sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" +ssf@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/ssf/-/ssf-0.9.0.tgz#eab445edc8c85bc1b637eaa62c3cf0ace92b7148" + dependencies: + colors "0.6.2" + frac "0.3.1" + voc "" + sshpk@^1.7.0: version "1.10.2" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.2.tgz#d5a804ce22695515638e798dbe23273de070a5fa" @@ -5585,6 +5640,10 @@ vm-browserify@0.0.4: dependencies: indexof "0.0.1" +voc@: + version "0.5.0" + resolved "https://registry.yarnpkg.com/voc/-/voc-0.5.0.tgz#be6ca7c76e4a57d930cc80f6b31fbd80ca86045c" + void-elements@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" @@ -5823,6 +5882,18 @@ xdg-basedir@^2.0.0: dependencies: os-homedir "^1.0.0" +xlsx@^0.9.9: + version "0.9.10" + resolved "https://registry.yarnpkg.com/xlsx/-/xlsx-0.9.10.tgz#86434dd92fc743d8fced728c3e1e373b22ca2820" + dependencies: + adler-32 "~1.0.0" + cfb "~0.11.1" + codepage "~1.8.0" + commander "~2.9.0" + crc-32 "~1.0.0" + exit-on-epipe "~1.0.0" + ssf "~0.9.0" + xmlhttprequest-ssl@1.5.3: version "1.5.3" resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d"