Source: Spreadsheet.js

  1. /*
  2. * Copyright 2020-2022 Sleepdiary Developers <sleepdiary@pileofstuff.org>
  3. *
  4. * Permission is hereby granted, free of charge, to any person
  5. * obtaining a copy of this software and associated documentation
  6. * files (the "Software"), to deal in the Software without
  7. * restriction, including without limitation the rights to use, copy,
  8. * modify, merge, publish, distribute, sublicense, and/or sell copies
  9. * 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
  13. * included in all copies or substantial portions of the Software.
  14. *
  15. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  16. * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  17. * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  18. * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
  19. * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
  20. * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  21. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22. * SOFTWARE.
  23. */
  24. "use strict";
  25. /**
  26. * @typedef {{
  27. * member: string,
  28. * type: (string|undefined),
  29. * regexp: (RegExp|undefined)
  30. * }} SpreadsheetHighLevelCellRule
  31. *
  32. * <p>Define a high-level rule for serialising/parsing a spreadsheet cell.</p>
  33. *
  34. * <p><tt>key</tt> should be the name of a member of the relevant data
  35. * structure, <tt>type</tt> should be <tt>number</tt>, <tt>time</tt>,
  36. * <tt>duration</tt> or <tt>string</tt> (the default). If
  37. * <tt>regexp</tt> is passed, the value is expected to match that
  38. * pattern.</p>
  39. */
  40. let SpreadsheetHighLevelCellRule;
  41. /**
  42. * @typedef {{
  43. * members: Array<string>,
  44. * import: Function,
  45. * export: Function
  46. * }} SpreadsheetLowLevelCellRule
  47. *
  48. * <p>Define a low-level rule for serialising/parsing a series of cells.</p>
  49. *
  50. * <p><tt>keys</tt> should be a non-empty array of member names that
  51. * will be created. <tt>import</tt> and <tt>export</tt> are functions
  52. * that take three arguments (<tt>array_element</tt>, <tt>row</tt> and
  53. * <tt>offset</tt>) and return a value that indicates whether the
  54. * operation was a success. <tt>import</tt> is expected to copy
  55. * values from <tt>row</tt> to <tt>array_element</tt>, wheraes
  56. * <tt>export</tt> copies data back to <tt>row</tt>.
  57. *
  58. */
  59. let SpreadsheetLowLevelCellRule;
  60. /**
  61. * @typedef {{
  62. * sheet: string,
  63. * member: (string|undefined),
  64. * cells: Array<SpreadsheetHighLevelCellRule|SpreadsheetLowLevelCellRule>
  65. * }} SpreadsheetSheetRule
  66. *
  67. * <p>Define the conversion to/from a single sheet in the spreadsheet.</p>
  68. *
  69. * <p><tt>sheet</tt> should be the name of the sheet in the spreadsheet.
  70. * <tt>member</tt> should be the name of the associated member in the data
  71. * structure (if different from <tt>sheet</tt>).</p>
  72. */
  73. let SpreadsheetSheetRule;
  74. /**
  75. * @typedef Array<SpreadsheetSheetRule> SpreadsheetRules
  76. *
  77. * <p>Define the conversion to/from a spreadsheet.</p>
  78. *
  79. */
  80. let SpreadsheetRules;
  81. const SpreadsheetNumberFormats = {
  82. "number" : "General",
  83. "time" : "YYYY-MM-DD\" \"HH:MM",
  84. "duration": "[h]:mm",
  85. };
  86. /**
  87. * @class Interface to spreadsheet libraries
  88. *
  89. * <p>At the time of writing, this library uses ExcelJS to handle
  90. * spreadsheets. But we might replace this with a different library
  91. * in future, or we might add more libraries to handle other
  92. * spreadsheet formats.</p>
  93. *
  94. * <p>To reduce code complexity, the Spreadsheet class presents a
  95. * high-level abstraction of spreadsheet functionality, which should
  96. * remain unchanged no matter which underlying library we use.</p>
  97. *
  98. * <p>The most important part of a spreadsheet is the 2D array of
  99. * cells in each sheet. Each cell is an object with a <tt>value</tt>
  100. * and a <tt>style</tt>. The <tt>value</tt> is a number, string, date
  101. * or <tt>null</tt>. The style is a string containing the background
  102. * colour, a comma, and the foreground colour
  103. * (e.g. <TT>#FFFFFF00,#FF00FFFF</TT>). Each colour is one of:
  104. *
  105. * <ul>
  106. * <li> <TT>#AARRGGBB</TT> - alpha, red, green, blue; e.g. <TT>#FFFFFF00</TT> is yellow
  107. * <li> <tt>iNNN</tt> - numeric indexed value; e.g. <tt>i1</tt> is index 1
  108. * <li> <tt>tNNN</tt> - numeric theme; e.g. <tt>t1</tt> is theme 1
  109. * </ul>
  110. *
  111. * <p>Implementations are encouraged to get Unix timestamps with the
  112. * <tt>parse_timestamp()</tt> function in this class. That function
  113. * will attempt to decode various common time-like strings
  114. * (e.g. <tt>noon</tt>).</p>
  115. *
  116. * @example
  117. * console.log( spreadsheet["sheets"].length ); // number of sheets
  118. * @example
  119. * console.log( spreadsheet["sheets"][0]["name"] ); // name of the first sheet
  120. * @example
  121. * console.log( spreadsheet["sheets"][0]["cells"][0][1] ); // value of cell A2
  122. * -> {
  123. * "value": new Date(...),
  124. * "style": "#FFFFFF00,#FF000000FF",
  125. * }
  126. *
  127. * @unrestricted
  128. */
  129. class Spreadsheet {
  130. /**
  131. * @param {*=} value - contents of the cell
  132. * @param {string=} style - cell formatting
  133. */
  134. static create_cell(value,style) {
  135. return { "value": value, "style": style || "" };
  136. }
  137. /**
  138. * @param {Object} associated - object to synchronise the spreadsheet with
  139. * @param {SpreadsheetRules} rules - rules to convert between objects and spreadsheets
  140. * @param {boolean=} convert_times_to_dates - guess the dates associated with times
  141. *
  142. * <p>The spreadsheet rules can include a <tt>formats</tt> member,
  143. * specifying how numbers will be formatted in the associated
  144. * member. Each value can be <tt>null</tt> (no special formatting), an
  145. * Excel number format code, or <tt>number</tt>/<tt>time</tt>/<tt>duration</tt>
  146. * (aliases for common formats).</p>
  147. *
  148. * @example
  149. * let spreadsheet = new Spreadsheet(
  150. * associated,
  151. * [
  152. * {
  153. * sheet: "...", // name of a sheet in the spreadsheet (e.g. "records")
  154. * member: "...", // name of an array in the diary object (default: same as "sheet")
  155. * //type: "dictionary", // indicates the member is an object containing key/value pairs
  156. * cells: [ // cells in the associated sheet
  157. *
  158. * // most cells can be defined with the high-level format:
  159. * {
  160. * "member": "...", // required - name of the member (e.g. 'start')
  161. * "type": "text", // optional - can also be "number", "time" or "duration"
  162. * "regexp": /^[0-9]$/, // optional - values must match this pattern
  163. * //"optional": true, // allow this value to be missing
  164. * },
  165. *
  166. * // sometimes you might need to use the low-level format:
  167. * {
  168. * "members": [ "foo", "bar" ], // names of members that will be returned by the read
  169. * "formats": [ null , "duration" ], // how numbers are formatted in this member (see above)
  170. * "export": (array_element,row,offset) => { // append cells to the current row
  171. * row[offset ] = Spreadsheet.create_cell(array_element["foo"]);
  172. * row[offset+1] = Spreadsheet.create_cell(array_element["foo"]+array_element["bar"]);
  173. * return false; // indicates this value cannot be serialised
  174. * },
  175. * "import": (array_element,row,offset) => {
  176. * array_element["foo"] = row[offset]["value"];
  177. * array_element["bar"] = row[offset+1]["value"] - row[offset]["value"];
  178. * return false; // indicates this value cannot be parsed
  179. * },
  180. * },
  181. *
  182. * ...
  183. *
  184. * ]
  185. * },
  186. *
  187. * ...
  188. *
  189. * ]
  190. * );
  191. */
  192. constructor(associated,rules,convert_times_to_dates) {
  193. const debug = false;
  194. function exporter(cell) {
  195. const create_cell = Spreadsheet.create_cell;
  196. const member = cell["member"];
  197. let ret;
  198. switch ( cell["type"] ) {
  199. case "time" : ret = (elem,row,offset) =>
  200. row[offset] = Spreadsheet.create_cell( ( elem[member] === undefined ) ? undefined : new Date( elem[member] ));
  201. break;
  202. case "duration": ret = (elem,row,offset) =>
  203. row[offset] = Spreadsheet.create_cell( ( elem[member] === undefined ) ? undefined : elem[member] / (1000*60*60*24) );
  204. break;
  205. default : ret = (elem,row,offset) =>
  206. row[offset] = Spreadsheet.create_cell(elem[member]);
  207. }
  208. if ( cell["optional"] ) {
  209. const inner = ret;
  210. ret = (elem,row,offset) => ( elem[member] === undefined ) || inner(elem,row,offset);
  211. }
  212. if ( debug ) { // debugging use only
  213. const inner = ret;
  214. ret = (elem,row,offset) => {
  215. const result = inner(elem,row,offset);
  216. console.info(cell,row[offset],elem[member],result);
  217. return result;
  218. };
  219. }
  220. return ret;
  221. }
  222. function importer(cell,self) {
  223. const member = cell["member"];
  224. let ret;
  225. switch ( cell["type"] ) {
  226. case "time" :
  227. ret = (
  228. convert_times_to_dates
  229. ? (elem,row,offset) => !isNaN( elem[member] = row[offset]. dated_timestamp )
  230. : (elem,row,offset) => !isNaN( elem[member] = row[offset].parsed_timestamp )
  231. );
  232. break;
  233. case "duration": ret = (elem,row,offset) => !isNaN( elem[member] = row[offset]["value"].getTime() + self["epoch_offset"] ); break;
  234. case "number" : ret = (elem,row,offset) => !isNaN( elem[member] = parseFloat(row[offset]["value"]) ); break;
  235. case "boolean" : ret = (elem,row,offset) => !isNaN( elem[member] = !!row[offset]["value"] ); break;
  236. default: ret = (elem,row,offset) => { elem[member] = row[offset]["value"]; return true; };
  237. }
  238. if ( cell["regexp"] ) {
  239. const inner = ret;
  240. ret = (elem,row,offset) => cell["regexp"].test(row[offset]["value"]) && inner(elem,row,offset);
  241. }
  242. if ( cell["optional"] ) {
  243. const inner = ret;
  244. ret = (elem,row,offset) => (
  245. !row[offset] ||
  246. row[offset]["value"] == null ||
  247. inner(elem,row,offset) ||
  248. row[offset]["value"] == ""
  249. );
  250. }
  251. if ( debug ) {
  252. const inner = ret;
  253. ret = (elem,row,offset) => {
  254. const result = inner(elem,row,offset);
  255. if ( result ) {
  256. console.info(result,cell,elem[member]);
  257. } else {
  258. console.error(result,cell,elem[member],row[offset]?row[offset]["value"]:"(no cell at this offset)");
  259. }
  260. return result;
  261. };
  262. }
  263. return ret;
  264. }
  265. /**
  266. * Low-level spreadsheet library object.
  267. * @type (Object|null)
  268. * @private
  269. */
  270. this.raw = null;
  271. /**
  272. * Object associated with this spreadsheet
  273. */
  274. this.associated = associated;
  275. /**
  276. * Spreadsheet rules
  277. * @private
  278. */
  279. this.rules = rules.map( rule => ({
  280. "sheet" : rule["sheet"] || rule["member"],
  281. "member" : rule["member"] || rule["sheet"],
  282. "type" : rule["type"] || "list",
  283. "optional": !!rule["optional"],
  284. // convert high-level definitions to low-level definitions:
  285. "cells": rule["cells"].map( cell => {
  286. if ( cell["members"] ) {
  287. cell["formats"] = ( cell["formats"] || [] ).map(
  288. format => SpreadsheetNumberFormats[format] || format
  289. );
  290. while ( cell["formats"].length < cell["members"].length ) {
  291. cell["formats"].push("General");
  292. }
  293. return cell;
  294. } else {
  295. return {
  296. "members": [ cell["member"] ],
  297. "regexp": cell["regexp"] || new RegExp(""),
  298. "formats": [
  299. SpreadsheetNumberFormats[ cell["type"] ] || "General"
  300. ],
  301. "export": exporter(cell),
  302. "import": importer(cell,this),
  303. }
  304. }
  305. }),
  306. }));
  307. /*
  308. * Epoch used by dates created in this spreadsheet,
  309. * in milliseconds relative to the Unix epoch
  310. */
  311. this["epoch_offset"] = 0;
  312. /**
  313. * Array of sheets
  314. */
  315. this["sheets"] = [];
  316. }
  317. ["get_sheet"](name,headers,number_formats) {
  318. const expected = headers.join("\0");
  319. const sheets = this["sheets"].filter(
  320. sheet => sheet["cells"][0] && sheet["cells"][0].map( cell => cell["value"] ).join("\0") == expected
  321. );
  322. if ( sheets.length ) {
  323. return [ false, sheets.find( sheet => sheet["name"] == name ) || sheets[0] ];
  324. } else {
  325. const ret = {
  326. "name": name,
  327. "number_formats": number_formats.map( type => type ? SpreadsheetNumberFormats[ type ] || type : "General" ),
  328. "cells": [
  329. headers.map( header => Spreadsheet.create_cell(header,"#FFEEEEEE,#FFEEEEEE") )
  330. ],
  331. };
  332. return [ true, ret ];
  333. }
  334. }
  335. /**
  336. * Convert a cell to a Unix timestamp
  337. * @param {Object} value - cell or value to analyse
  338. * @param {Object=} raw_spreadsheet - raw spreadsheet object from which the value was taken
  339. * @return {number} Unix timestamp (if parseable)
  340. */
  341. static parse_timestamp(value,raw_spreadsheet) {
  342. return DiaryBase.parse_timestamp(
  343. (value||{}).hasOwnProperty("value") ? value["value"] : value,
  344. raw_spreadsheet
  345. ? (
  346. ( raw_spreadsheet["properties"] || {} ) ["date1904"]
  347. ? 2082844800000
  348. : 2209161600000
  349. )
  350. : 0
  351. );
  352. }
  353. /**
  354. * Calculate timestamps for all cells in a worksheet
  355. * @param {Array} sheet - sheet to process
  356. * @param {Object=} raw_spreadsheet - raw spreadsheet object from which the value was taken
  357. */
  358. static parse_all_timestamps(sheet,raw_spreadsheet) {
  359. let prev_date = 0;
  360. sheet.forEach( row => row.forEach( cell => {
  361. let timestamp = cell.parsed_timestamp = Spreadsheet.parse_timestamp(cell,raw_spreadsheet),
  362. dated_timestamp
  363. = cell.dated_timestamp
  364. = ( isNaN(timestamp) || timestamp < 0 || timestamp > 86400000 )
  365. ? timestamp
  366. : timestamp + prev_date - (prev_date%86400000)
  367. ;
  368. if ( dated_timestamp < prev_date ) {
  369. cell.dated_timestamp = dated_timestamp += 86400000;
  370. }
  371. prev_date = dated_timestamp;
  372. }));
  373. }
  374. /**
  375. * Read data from a buffer (e.g. a file input)
  376. */
  377. static buffer_to_spreadsheet(buffer) {
  378. function encode_style(style) {
  379. if ( style.hasOwnProperty("argb" ) ) return '#' + style["argb"];
  380. else if ( style.hasOwnProperty("indexed") ) return 'i' + style["indexed"];
  381. else if ( style.hasOwnProperty("theme" ) ) return 't' + style["theme"];
  382. return '';
  383. }
  384. let spreadsheet;
  385. try {
  386. spreadsheet = new self["ExcelJS"]["Workbook"]();
  387. } catch (e) {
  388. spreadsheet = new ( require("exceljs")["Workbook"] )();
  389. }
  390. return spreadsheet["xlsx"]["load"](buffer).then(
  391. () => {
  392. if ( spreadsheet["_worksheets"].length ) {
  393. let sheets = [];
  394. spreadsheet["eachSheet"]( (raw_worksheet, sheetId) => {
  395. let sheet = { "name": raw_worksheet["name"], "cells": [] };
  396. sheets.push(sheet);
  397. raw_worksheet["eachRow"]( { "includeEmpty": true }, (raw_row, row_number) => {
  398. let row = sheet["cells"][row_number-1] = [];
  399. raw_row["eachCell"]({ "includeEmpty": true }, function(cell, col_number) {
  400. let style = "";
  401. if ( cell["style"] && cell["style"]["fill"] ) {
  402. style = (
  403. encode_style(cell["style"]["fill"]["bgColor"]||{}) +
  404. ',' +
  405. encode_style(cell["style"]["fill"]["fgColor"]||{})
  406. );
  407. }
  408. if ( style.length == 1 ) style = "";
  409. // check for floating point errors:
  410. if ( (cell["value"]||{}).getTime ) {
  411. const time = cell["value"].getTime();
  412. if ( ( time % 1000 ) == 1 || ( time % 1000 ) == -999 ) {
  413. cell["value"] = new Date( time-1 );
  414. }
  415. if ( ( time % 1000 ) == -1 || ( time % 1000 ) == 999 ) {
  416. cell["value"] = new Date( time+1 );
  417. }
  418. }
  419. row[col_number-1] = Spreadsheet.create_cell(cell["value"],style);
  420. })
  421. });
  422. });
  423. sheets.forEach( sheet => Spreadsheet.parse_all_timestamps( sheet["cells"], spreadsheet ) );
  424. return {
  425. "file_format": "spreadsheet",
  426. "spreadsheet": spreadsheet,
  427. "sheets": sheets,
  428. }
  429. } else {
  430. throw Error( "empty spreadsheet" );
  431. }
  432. }
  433. );
  434. }
  435. /**
  436. * Copy data from the parameter into this.sheets and this.associated
  437. *
  438. * @param {string} contents - CSV file to load from
  439. * @return {Object|undefined} spreadsheet information
  440. */
  441. static parse_csv(contents) {
  442. const value = "(?:[^\",\\r\\n]*|\"(?:[^\"]|\"\")*\")",
  443. csv_re = new RegExp(`^${value}(?:,${value})*(?:\r\n|\r|\n)`);
  444. // Excel requires a byte order mark, which we ignore:
  445. if ( contents[0] == "\u{FEFF}" ) contents = contents.substr(1);
  446. // reduce the complexity of the regexps by guaranteeing exactly one trailing newline:
  447. contents = contents.replace(/[\r\n]*$/,'\n');
  448. // does this look like a valid CSV file?
  449. for ( let offset=0; offset!=contents.length; ) {
  450. const m = contents.substr(offset).match(csv_re);
  451. if ( !m ) return;
  452. offset += m[0].length;
  453. }
  454. let spreadsheet;
  455. try {
  456. spreadsheet = new self["ExcelJS"]["Workbook"]();
  457. } catch (e) {
  458. spreadsheet = new ( require("exceljs")["Workbook"] )();
  459. }
  460. let raw_records = spreadsheet["addWorksheet"]("Records"),
  461. raw_settings = spreadsheet["addWorksheet"]("Settings"),
  462. records = [],
  463. row_number=0,
  464. settings = [ [ Spreadsheet.create_cell("Setting"), Spreadsheet.create_cell("Value") ] ]
  465. ;
  466. // CSV can start with key=value lines...
  467. while ( !contents.search(/^([A-Za-z_][A-Za-z0-9_]*)=[^,]*\n/) ) {
  468. contents = contents.replace(
  469. /([^=]*?)=(.*)\n/,
  470. (_,key,value) => {
  471. settings.push([key,value].map( v => Spreadsheet.create_cell(v) ));
  472. raw_settings["addRow"]([ key, value ]);
  473. return '';
  474. }
  475. );
  476. }
  477. contents.replace(
  478. new RegExp(`${value}(?:,${value})*(?:\r\n|\r|\n)`, 'g'),
  479. line_str => {
  480. let raw_row = raw_records["getRow"](++row_number),
  481. row = [],
  482. n=0
  483. ;
  484. records.push(row);
  485. line_str
  486. .replace( new RegExp(value+'[,\r\n]','g'), value => {
  487. let raw_cell = raw_row["getCell"](++n);
  488. if ( value[0] == '"' ) {
  489. raw_cell["value"] = value.substr(1,value.length-3).replace( /""/g, '"' );
  490. } else {
  491. raw_cell["value"] = value.substr(0,value.length-1);
  492. }
  493. row.push(Spreadsheet.create_cell(raw_cell["value"]));
  494. });
  495. }
  496. );
  497. Spreadsheet.parse_all_timestamps(records);
  498. return {
  499. "spreadsheet": spreadsheet,
  500. "sheets": [
  501. { "name": "Records" , "cells": records },
  502. { "name": "Settings", "cells": settings }
  503. ],
  504. }
  505. }
  506. static escape_csv_component( value ) {
  507. return (
  508. ( value === undefined )
  509. ? ''
  510. : ( value["getTime"] )
  511. ? DiaryBase.date(value.getTime())["format"]("yyyy-MM-ddTHH:mm:ss.SSS")+'Z'
  512. : ( value.toString().search(/[",\n]/) == -1 )
  513. ? value
  514. : '"'+value.replace(/"/g, '""')+'"'
  515. );
  516. }
  517. /**
  518. * Copy data from the parameter into this.sheets and this.associated
  519. *
  520. * @param {Object} spreadsheet - spreadsheet to load from
  521. * @return {boolean} whether the operation was successful
  522. */
  523. ["load"](spreadsheet) {
  524. const debug = false;
  525. const sheets = spreadsheet["sheets"];
  526. if ( !sheets ) return false;
  527. const old_offset = this["epoch_offset"];
  528. this["epoch_offset"] = (
  529. ( spreadsheet["spreadsheet"]["properties"] || {} ) ["date1904"]
  530. ? 2082844800000
  531. : 2209161600000
  532. );
  533. if ( !this.rules.every(
  534. sheet_rule => sheets.some( sheet => {
  535. const cells = sheet["cells"];
  536. const is_dictionary = sheet_rule["type"] == "dictionary";
  537. if ( debug ) {
  538. console.info( "Checking whether sheet_rule describes sheet...", sheet_rule, sheet );
  539. }
  540. if (
  541. !cells.length ||
  542. ( is_dictionary && cells.length != 2 )
  543. ) {
  544. if ( debug ) {
  545. console.info( "sheet_rule describes a sheet with a different shape" );
  546. }
  547. return false;
  548. }
  549. // ensure that all headers are present:
  550. const header_row = cells[0].slice(0);
  551. if (
  552. !sheet_rule["cells"].every(
  553. cell => cell["members"].every( member => member == (header_row.shift()||{"value":NaN})["value"] )
  554. )
  555. ) {
  556. if ( debug ) {
  557. console.info( "sheet_rule describes a sheet with different headers" );
  558. }
  559. return false;
  560. }
  561. // calculate array and check the values actually match:
  562. let array = [];
  563. if ( !cells.slice(1).every( row => {
  564. if ( !row.length ) return true;
  565. let offset = 0;
  566. let array_element = {};
  567. const parse_sheet =
  568. cell => cell["import"](
  569. array_element,
  570. row,
  571. ( offset += cell["members"].length ) - cell["members"].length
  572. );
  573. array.push(array_element);
  574. return sheet_rule["cells"].every(
  575. debug
  576. ? function(cell,n) {
  577. if ( parse_sheet(cell) ) {
  578. return true;
  579. } else {
  580. console.info(
  581. "sheet_rule describes a sheet with a different body",
  582. n,
  583. cell,
  584. array_element,
  585. row
  586. );
  587. return false;
  588. }
  589. }
  590. : parse_sheet
  591. );
  592. }) ) {
  593. return false;
  594. }
  595. if ( debug ) {
  596. console.info( "sheet_rule matches sheet - loading" );
  597. }
  598. const member = sheet_rule["member"];
  599. if ( is_dictionary ) {
  600. this.associated[member] = Object.assign( array[0], this.associated[member]||{} );
  601. } else {
  602. if ( !this.associated[member] ) this.associated[member] = [];
  603. let data = this.associated[member];
  604. if ( data.length >= cells.length ) data.splice( 0, cells.length - 1 );
  605. array.forEach( (array_element,n) => data[n] = Object.assign( array_element, data[n] ) );
  606. }
  607. let number_formats = [];
  608. sheet_rule["cells"].forEach( cell => number_formats = number_formats.concat( cell["formats"] ) );
  609. sheet["number_formats"] = number_formats;
  610. if ( debug ) {
  611. console.info( "sheet_rule matches sheet - done" );
  612. }
  613. return true;
  614. })
  615. ) ) {
  616. this["epoch_offset"] = old_offset;
  617. return false;
  618. }
  619. this.raw = spreadsheet["spreadsheet"];
  620. this["sheets"] = sheets;
  621. return true;
  622. }
  623. /**
  624. * Copy data from this.associated to this.sheets
  625. * @return {boolean}
  626. */
  627. ["synchronise"]() {
  628. return this.rules.every( sheet_rule => {
  629. const is_dictionary = sheet_rule["type"] == "dictionary";
  630. let headers = [];
  631. let number_formats = [];
  632. sheet_rule["cells"].forEach( cell => {
  633. headers = headers.concat( cell["members"] )
  634. number_formats = number_formats.concat( cell["formats"] );
  635. });
  636. const added_sheet = this["get_sheet"](sheet_rule["sheet"],headers,number_formats);
  637. const added = added_sheet[0];
  638. const sheet = added_sheet[1];
  639. let cells = sheet["cells"] = [
  640. headers.map(header => Spreadsheet.create_cell(header,"#FFEEEEEE,#FFEEEEEE"))
  641. ];
  642. const associated = this.associated[sheet_rule["member"] || sheet_rule["sheet"]];
  643. function process_row(array_element) {
  644. let row = [], offset = 0;
  645. cells.push(row);
  646. return sheet_rule["cells"].every(
  647. cell => cell["export"]( array_element, row, ( offset += cell["members"].length ) - cell["members"].length )
  648. );
  649. }
  650. if ( is_dictionary ) {
  651. if ( !process_row(associated) ) return false;
  652. } else {
  653. if ( !associated.every(process_row) ) return false;
  654. }
  655. if ( added ) this["sheets"].push(sheet);
  656. return true;
  657. });
  658. }
  659. /**
  660. * Generate a spreadsheet based on this.sheets
  661. * @return {Promise}
  662. */
  663. ["serialise"]() {
  664. function decode_style(style) {
  665. switch ( style[0] ) {
  666. case '#': return { "argb" : style.slice(1) };
  667. case 'i': return { "indexed": parseInt(style.slice(1),10) };
  668. case 't': return { "theme" : parseInt(style.slice(1),10) };
  669. default : return {};
  670. }
  671. }
  672. const fix_timestamps = !this.raw;
  673. if ( !this.raw ) {
  674. try {
  675. this.raw = new self["ExcelJS"]["Workbook"]();
  676. } catch (e) {
  677. this.raw = new ( require("exceljs")["Workbook"] )();
  678. }
  679. this["epoch_offset"] = (
  680. ( this.raw["properties"] || {} ) ["date1904"]
  681. ? 2082844800000
  682. : 2209161600000
  683. );
  684. }
  685. const epoch_offset = this["epoch_offset"];
  686. // Remove deleted worksheets:
  687. let raw_sheets = {};
  688. this["sheets"].forEach( sheet => raw_sheets[sheet["name"]] = 0 );
  689. this.raw["eachSheet"]( (worksheet, sheetId) => {
  690. if ( raw_sheets.hasOwnProperty(worksheet["name"]) ) {
  691. raw_sheets[worksheet["name"]] = worksheet;
  692. } else {
  693. this.raw.removeWorksheet(sheetId);
  694. }
  695. });
  696. // Add/update worksheets:
  697. this["sheets"].forEach( sheet => {
  698. let raw_sheet = raw_sheets[sheet["name"]] || this.raw["addWorksheet"](sheet["name"],sheet["options"]);
  699. // Remove deleted cells:
  700. raw_sheet["eachRow"]( { "includeEmpty": true }, (raw_row, row_number) => {
  701. let row = sheet["cells"][row_number-1] || [];
  702. raw_row["eachCell"]({ "includeEmpty": true }, (raw_cell, col_number) => {
  703. if ( !row[col_number] ) {
  704. raw_cell["value"] = null;
  705. raw_cell["style"] = {};
  706. }
  707. })
  708. });
  709. // Add/update cells:
  710. sheet["cells"].forEach( (row,n) => {
  711. let raw_row = raw_sheet["getRow"](n+1);
  712. row.forEach( (cell,n) => {
  713. let raw_cell = raw_row["getCell"](n+1);
  714. if ( fix_timestamps && (cell["value"]||{}).getTime && cell["value"].getTime() >= 0 && cell["value"].getTime() < 24 * 60 * 60 * 1000 ) {
  715. // reset times relative to the new epoch
  716. raw_cell["value"] = new Date( cell["value"].getTime() - epoch_offset );
  717. } else {
  718. raw_cell["value"] = cell["value"];
  719. }
  720. let style = cell["style"].split(','),
  721. raw_style = raw_cell["style"] = raw_cell["style"] || {},
  722. column_font = ((sheet["styles"]||[])[n]||{})["font"]
  723. ;
  724. if ( style[0] || style[1] || style[2] ) {
  725. // Note: we remove any existing style, in case it interacts with the style we add:
  726. raw_style["fill"] = raw_style["fill"] || {};
  727. raw_style["fill"]["type"] = "pattern";
  728. raw_style["fill"]["pattern"] = "solid";
  729. if ( style[0] ) {
  730. raw_style["fill"]["bgColor"] = decode_style(style[0]);
  731. } else {
  732. delete raw_style["fill"]["bgColor"];
  733. }
  734. if ( style[1] ) {
  735. raw_style["fill"]["fgColor"] = decode_style(style[1]);
  736. } else {
  737. delete raw_style["fill"]["fgColor"];
  738. }
  739. if ( style[2] ) {
  740. raw_style["font"] = raw_style["font"] || {};
  741. raw_style["font"]["color"] = decode_style(style[2]);
  742. } else {
  743. delete raw_style["font"];
  744. }
  745. } else {
  746. if ( raw_style && raw_style["fill"] ) {
  747. delete raw_style["fill"]["bgColor"];
  748. delete raw_style["fill"]["fgColor"];
  749. }
  750. }
  751. /*
  752. * Cell styles override column styles, so we need to copy them in.
  753. * "font" is the only property we actually use right now.
  754. */
  755. if ( column_font ) {
  756. Object.assign( raw_style["font"] = raw_style["font"] || {}, column_font );
  757. }
  758. });
  759. raw_row["commit"]();
  760. });
  761. if ( sheet["widths"] ) {
  762. sheet["widths"].forEach( (w,n) => raw_sheet["columns"][n]["width"] = w );
  763. } else {
  764. raw_sheet["columns"].forEach( col => col["width"] = 18 );
  765. }
  766. if ( sheet["styles"] ) {
  767. sheet["styles"].forEach( (s,n) => raw_sheet["columns"][n]["style"] = s );
  768. }
  769. (sheet["number_formats"]||[]).forEach (
  770. (format,n) => raw_sheet["getColumn"](n+1)["numFmt"] = format
  771. );
  772. });
  773. return this.raw["xlsx"]["writeBuffer"]();
  774. }
  775. output_csv() {
  776. const sheet_rule = this.rules[0];
  777. return (
  778. // Settings:
  779. (this.associated["settings"]||[])
  780. .map( row => row["Setting"] + "=" + row["Value"] + "\n" )
  781. .concat(
  782. // header:
  783. [sheet_rule["cells"].map( cell => cell["members"].join(',') ).join(',') + "\n"],
  784. // Records:
  785. this.associated[sheet_rule["member"]].map(
  786. r => {
  787. let row = [], offset = 0;
  788. sheet_rule["cells"].forEach(
  789. cell => cell["export"]( r, row, ( offset += cell["members"].length ) - cell["members"].length )
  790. );
  791. return row.map( v => Spreadsheet.escape_csv_component( v["value"] )).join(',') + "\n"
  792. }
  793. )
  794. ).join("")
  795. );
  796. }
  797. ["file_format"]() { return "Spreadsheet"; }
  798. }