backend-plus 2.7.3 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -247,6 +247,10 @@ client-setup | front-end options
247
247
  .menu | whether it uses the integrated menus
248
248
  .title | screen title
249
249
  .lang | frontend language (and locale), for now "es" or "en"
250
+ .tab-plus | "only": the `.tab` files in `install` are read with [tab-plus](https://npmjs.org/package/tab-plus) and the `.txt "\|"` option of the grid export uses tab-plus (the first line of a `.tab` can be pasted to keep its sparse columns)
251
+ .tab-plus-table-options | per table, options for the `.tab` exported by the grid with `tab-plus: only`: a string is the first line of a `.tab` (it is shown in the "options" box, where it can be edited); an object `{fields, columnDefs}` is used if the box is left empty. At startup it is completed with the first line of the `.tab` of each table not defined there
252
+ .downloads.method | how the grid export saves the file: `SaveFilePicker` (default) uses the "save as" dialog if the browser has `showSaveFilePicker` (otherwise "confirm" and the "download" link); `legacy` always uses "confirm" and the "download" link
253
+ .downloads.close-retries | with `SaveFilePicker`, how many times it retries (0 to 5, default 0) if replacing the file fails when closing it, waiting 200 ms and doubling on each retry
250
254
  db | database options
251
255
  .motor | for now only 'postgresql'
252
256
  .database |
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- /*global myOwn, my, xlsxNowBrowser, Pikaday */
3
+ /*global myOwn, my, xlsxNow, xlsxNowBrowser, Pikaday */
4
4
  /*global miniMenuPromise, dialogPromise, alertPromise, confirmPromise, promptPromise, simpleFormPromise */
5
5
  /*global Blob, document, CustomEvent, URL */
6
6
 
@@ -50,6 +50,7 @@ myOwn.i18n.messages.en=changing(myOwn.i18n.messages.en, {
50
50
  allTWillDelete: "(ALL {$t} records will be deleted)",
51
51
  anotherUserChangedTheRow: "The data was changed in the database",
52
52
  confirmDeleteAll: "Do you want to delete these records?",
53
+ confirmExport: "confirm",
53
54
  deleteAllRecords: "delete all records",
54
55
  deleteRecord: "delete record",
55
56
  details: "details",
@@ -94,6 +95,8 @@ myOwn.i18n.messages.en=changing(myOwn.i18n.messages.en, {
94
95
  orientationToggle: "toggle orientation (vertical vs horizontal)",
95
96
  prepare: "prepare",
96
97
  preparingForExport: "preparing for export",
98
+ saveToDiskError: "The file could not be saved",
99
+ saveToDiskEmptied: "The file could not be saved and it was left empty (the browser empties it when it is chosen)",
97
100
  recordsReimaining: "{$r} records remains in the table",
98
101
  refresh: "refresh - retrive data from database",
99
102
  replaceNewLineWithSpace: 'replace new lines with spaces',
@@ -104,6 +107,9 @@ myOwn.i18n.messages.en=changing(myOwn.i18n.messages.en, {
104
107
  skippedColumns:'skipped columns',
105
108
  skipUnknownFieldsAtImport:'skip unknown fields at import',
106
109
  table: "table",
110
+ tabPlusHeader: "first line of a .tab to keep its column order and its sparse columns (optional)",
111
+ tabPlusOptions: "options",
112
+ tabPlusHeaderUnknownFields: "Nothing was exported. These columns of the pasted line are not among the exported ones: ",
107
113
  thereAreNot: "no",
108
114
  uploadFile: "upload file $1",
109
115
  verticalEdit: "vertical edit",
@@ -120,6 +126,7 @@ myOwn.i18n.messages.es=changing(myOwn.i18n.messages.es, {
120
126
  allTWillDelete: "(se borrarán todos los registros: {$t} registros)",
121
127
  anotherUserChangedTheRow: "Hubo un cambio en la base de datos para este registro",
122
128
  confirmDeleteAll: "¿Desea borrar estos registros?",
129
+ confirmExport: "confirmar",
123
130
  deleteAllRecords: "borrar todos los registros",
124
131
  deleteRecord: "borrar este registro",
125
132
  details: "detalles",
@@ -164,6 +171,8 @@ myOwn.i18n.messages.es=changing(myOwn.i18n.messages.es, {
164
171
  orientationToggle: "cambiar la orientación de la tabla (por fila o por columna)",
165
172
  prepare: "preparar",
166
173
  preparingForExport: "preparando para exportar",
174
+ saveToDiskError: "No se pudo guardar el archivo",
175
+ saveToDiskEmptied: "No se pudo guardar el archivo y quedó vacío (el navegador lo vacía al elegirlo)",
167
176
  recordsReimaining: "quedan {$r} registros en la tabla",
168
177
  refresh: "refrescar la grilla desde la base de datos",
169
178
  replaceNewLineWithSpace: 'replazar saltos de línea por espacios',
@@ -174,6 +183,9 @@ myOwn.i18n.messages.es=changing(myOwn.i18n.messages.es, {
174
183
  skippedColumns:'Columnas salteadas',
175
184
  skipUnknownFieldsAtImport:'saltear columnas que no existan',
176
185
  table: "tabla",
186
+ tabPlusHeader: "primera línea de un .tab para respetar su orden de columnas y sus columnas esparsas (opcional)",
187
+ tabPlusOptions: "opciones",
188
+ tabPlusHeaderUnknownFields: "No se exportó nada. Estas columnas de la línea pegada no están entre las exportadas: ",
177
189
  thereAreNot: "no hay",
178
190
  uploadFile: "subir el archivo $1",
179
191
  verticalEdit: "edición en forma de ficha",
@@ -1550,8 +1562,17 @@ myOwn.INCLUDE_LOOKUP_COLUMNS_IN_TXT_EXPORT=true;
1550
1562
 
1551
1563
  myOwn.dialogDownload = function dialogDownload(grid){
1552
1564
  return dialogPromise(function(dialogWindow, closeWindow){
1553
- var prepareDownloadElement=html.button({class:'export-a'},my.messages.prepare).create();
1565
+ var downloadsConfig = my.config.config?.downloads || {};
1566
+ var canSaveToDisk = downloadsConfig.method != 'legacy' && !!window.showSaveFilePicker;
1567
+ var actionElement=html.button({class:'export-a'},canSaveToDisk ? my.messages.download : my.messages.confirmExport).create();
1554
1568
  var downloadElement=html.a({class:'export-a'},my.messages.download).create();
1569
+ var useTabPlus = my.config.config?.['tab-plus'] == 'only';
1570
+ var tabPlusTableOptions = (my.config.config?.['tab-plus-table-options'] || {})[grid.def.name];
1571
+ var tabPlusHeader=html.textarea({rows:3, style:'width:100%; box-sizing:border-box'}).create();
1572
+ if(typeof tabPlusTableOptions == 'string'){
1573
+ tabPlusHeader.value = tabPlusTableOptions;
1574
+ }
1575
+ var tabPlusHeaderDiv=html.div([html.div({title:my.messages.tabPlusHeader}, my.messages.tabPlusOptions), tabPlusHeader]).create();
1555
1576
  var input={
1556
1577
  xlsx : html.input({class:'export-radio',type:'radio', value:'xlsx', name:'format', checked:true}).create(),
1557
1578
  txt : html.input({class:'export-radio',type:'radio', value:'txt' , name:'format'}).create(),
@@ -1572,7 +1593,8 @@ myOwn.dialogDownload = function dialogDownload(grid){
1572
1593
  html.div([html.label([input.readOnly , html.span(my.messages.readOnly)])]),
1573
1594
  html.div([html.label([input.hiddens , html.span(my.messages.hiddens)])]),
1574
1595
  html.br(),
1575
- // html.div([prepareDownloadElement]),
1596
+ tabPlusHeaderDiv,
1597
+ html.div([actionElement]),
1576
1598
  ]),
1577
1599
  html.div({class:'state-preparing'}, [
1578
1600
  html.div(my.messages.preparingForExport),
@@ -1587,55 +1609,193 @@ myOwn.dialogDownload = function dialogDownload(grid){
1587
1609
  html.div('.'),
1588
1610
  ]).create();
1589
1611
  dialogWindow.appendChild(mainDiv);
1590
- var fieldsDef2Export=[];
1591
- var extraColumns={}
1592
1612
  var lastColumnExported=-1;
1593
- var separator=';';
1594
- var replacer=function(x){ return x};
1595
- var dotExtension='.txt';
1596
- var prepare=function(){
1597
- mainDiv.setAttribute("current-state", "preparing");
1598
- setTimeout(function(){
1599
- fieldsDef2Export=grid.def.fields.filter(function(fieldDef){
1600
- return (fieldDef.inTable!==false || input.fromOtherTables.checked)
1601
- && fieldDef.visible
1602
- && (fieldDef.allow.update || input.readOnly.checked || fieldDef.isPk)
1603
- && (!grid.view.hiddenColumns.includes(fieldDef.name) || input.hiddens.checked);
1613
+ var showTabPlusHeader=function(){
1614
+ tabPlusHeaderDiv.style.display = useTabPlus && input.txt.checked ? '' : 'none';
1615
+ }
1616
+ var fileUrl=null;
1617
+ var exporting=false;
1618
+ // cuenta los cambios de opciones: lo que se terminó de armar con opciones viejas no se ofrece
1619
+ var optionsVersion=0;
1620
+ // Al cambiar una opción, lo que estaba listo para descargar ya no corresponde: se vuelve a "confirmar"
1621
+ var optionsChanged=function(){
1622
+ optionsVersion++;
1623
+ showTabPlusHeader();
1624
+ if(fileUrl!=null){
1625
+ URL.revokeObjectURL(fileUrl);
1626
+ fileUrl=null;
1627
+ }
1628
+ downloadElement.removeAttribute("href");
1629
+ downloadElement.removeAttribute("download");
1630
+ if(!exporting){
1631
+ mainDiv.setAttribute("current-state", "chossing");
1632
+ }
1633
+ }
1634
+ likeAr(input).forEach(function(element){ element.onchange=optionsChanged; });
1635
+ tabPlusHeader.oninput=optionsChanged;
1636
+ showTabPlusHeader();
1637
+ // El texto se arma entero. En dos tiempos: al llamarla valida las opciones (instantáneo, lanza el error si
1638
+ // no sirven) y devuelve {dotExtension, generate}; generate() arma el texto.
1639
+ var textExport=function(fieldsDef2Export){
1640
+ if(input.csv.checked){
1641
+ return {dotExtension:'.csv', generate:function(){
1642
+ return txtExport(fieldsDef2Export, ',', function(txt){
1643
+ return /[\n,"]/.test(txt)?'"'+txt.replace(/"/g,'""')+'"':txt
1644
+ });
1645
+ }};
1646
+ }else if(useTabPlus){
1647
+ var header=tabPlusExportHeader(fieldsDef2Export);
1648
+ return {dotExtension:'.tab', generate:function(){
1649
+ return tabPlusExport(fieldsDef2Export, header);
1650
+ }};
1651
+ }else{
1652
+ var trans={
1653
+ '|':'\\x7C',
1654
+ '\\':'\\\\',
1655
+ '\r':'\\r',
1656
+ '\n':'\\n',
1657
+ }
1658
+ return {dotExtension:'.tab', generate:function(){
1659
+ return txtExport(fieldsDef2Export, '|', function(txt){
1660
+ return txt.replace(/[|\\\r\n]/g,function(char){
1661
+ return trans[char];
1662
+ })
1663
+ });
1664
+ }};
1665
+ }
1666
+ }
1667
+ var saveToDisk=function(fieldsDef2Export){
1668
+ var step='showSaveFilePicker';
1669
+ var setStep=function(newStep){ step=newStep; };
1670
+ var file;
1671
+ if(input.xlsx.checked){
1672
+ var options=excelOptions(fieldsDef2Export);
1673
+ file={dotExtension:'.xlsx', write:function(writable){
1674
+ var currentOptions=options||excelOptions(fieldsDef2Export);
1675
+ options=null;
1676
+ setStep('write');
1677
+ return xlsxNow.createXlsxStream(currentOptions).pipeTo(writable, {preventClose:true, preventAbort:true});
1678
+ }};
1679
+ }else{
1680
+ var textFile=textExport(fieldsDef2Export);
1681
+ var text=null;
1682
+ file={dotExtension:textFile.dotExtension, write:function(writable){
1683
+ if(text==null){
1684
+ setStep('generate');
1685
+ text=textFile.generate();
1686
+ }
1687
+ setStep('write');
1688
+ return writable.write(text);
1689
+ }};
1690
+ }
1691
+ var writeOnce=function(handle){
1692
+ setStep('createWritable');
1693
+ return handle.createWritable().then(function(writable){
1694
+ return Promise.resolve().then(function(){
1695
+ return file.write(writable);
1696
+ }).then(function(){
1697
+ setStep('close');
1698
+ return writable.close();
1699
+ }, function(err){
1700
+ return writable.abort().then(function(){
1701
+ throw err;
1702
+ }, function(abortErr){
1703
+ console.error(abortErr);
1704
+ throw err;
1705
+ });
1706
+ });
1604
1707
  });
1605
- if(input.xlsx.checked){
1606
- excelExport();
1607
- }else{
1608
- if(input.csv.checked){
1609
- separator=',';
1610
- replacer=function(txt){
1611
- return /[\n,"]/.test(txt)?'"'+txt.replace(/"/g,'""')+'"':txt
1612
- }
1613
- dotExtension='.csv';
1614
- }else{
1615
- separator='|';
1616
- var trans={
1617
- '|':'\\x7C',
1618
- '\\':'\\\\',
1619
- '\r':'\\r',
1620
- '\n':'\\n',
1621
- }
1622
- replacer=function(txt){
1623
- return txt.replace(/[|\\\r\n]/g,function(char){
1624
- return trans[char];
1625
- })
1626
- }
1627
- dotExtension='.tab';
1708
+ }
1709
+ var closeRetries=downloadsConfig['close-retries'] || 0;
1710
+ var writeWithRetries=function(handle, attempt){
1711
+ return writeOnce(handle).catch(function(err){
1712
+ if(step==='close' && err.name==='InvalidStateError' && attempt<closeRetries){
1713
+ console.error('saveToDisk retry', attempt+1, err);
1714
+ return new Promise(function(resolve){
1715
+ setTimeout(resolve, 200 * Math.pow(2, attempt));
1716
+ }).then(function(){
1717
+ return writeWithRetries(handle, attempt+1);
1718
+ });
1628
1719
  }
1629
- txtExport();
1720
+ throw err;
1721
+ });
1722
+ }
1723
+ var mimeTypes={'.xlsx':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.csv':'text/csv', '.tab':'text/plain'};
1724
+ var accept={};
1725
+ accept[mimeTypes[file.dotExtension]]=[file.dotExtension];
1726
+ return window.showSaveFilePicker({
1727
+ suggestedName: grid.def.name+file.dotExtension,
1728
+ types: [{description: file.dotExtension, accept: accept}]
1729
+ }).then(function(handle){
1730
+ return writeWithRetries(handle, 0).then(function(){
1731
+ return 'saved';
1732
+ });
1733
+ }, function(err){
1734
+ if(err.name==='AbortError'){
1735
+ return 'cancelled';
1630
1736
  }
1631
- },100)
1737
+ throw err;
1738
+ }).catch(function(err){
1739
+ console.error('saveToDisk', step, err);
1740
+ // showSaveFilePicker vacía el archivo elegido antes de devolverlo: si falla después, quedó vacío
1741
+ var message=step==='showSaveFilePicker' ? my.messages.saveToDiskError : my.messages.saveToDiskEmptied;
1742
+ throw new Error(message+' ('+step+') '+err.name+': '+err.message);
1743
+ });
1632
1744
  }
1633
- prepareDownloadElement.onclick=prepare;
1634
- if("auto prepare"){
1635
- likeAr(input).forEach(function(input){ input.onclick=prepare; })
1636
- setTimeout(prepare,200);
1745
+ var prepareDownload=function(fieldsDef2Export){
1746
+ var version=optionsVersion;
1747
+ if(fileUrl!=null){
1748
+ URL.revokeObjectURL(fileUrl);
1749
+ fileUrl=null;
1750
+ }
1751
+ return Promise.resolve().then(function(){
1752
+ if(input.xlsx.checked){
1753
+ return xlsxNowBrowser.createXlsxBlob(excelOptions(fieldsDef2Export)).then(function(blob){
1754
+ return {blob:blob, dotExtension:'.xlsx'};
1755
+ });
1756
+ }
1757
+ var textFile=textExport(fieldsDef2Export);
1758
+ return {blob:new Blob([textFile.generate()], {type: 'text/plain'}), dotExtension:textFile.dotExtension};
1759
+ }).then(function(file){
1760
+ if(version!=optionsVersion){
1761
+ return 'chossing';
1762
+ }
1763
+ fileUrl = URL.createObjectURL(file.blob);
1764
+ downloadElement.href=fileUrl;
1765
+ downloadElement.setAttribute("download", grid.def.name+file.dotExtension);
1766
+ return 'ready';
1767
+ });
1637
1768
  }
1638
- var txtExport = function(){
1769
+ actionElement.onclick=function(){
1770
+ if(exporting){
1771
+ return;
1772
+ }
1773
+ var fieldsDef2Export=grid.def.fields.filter(function(fieldDef){
1774
+ return (fieldDef.inTable!==false || input.fromOtherTables.checked)
1775
+ && fieldDef.visible
1776
+ && (fieldDef.allow.update || input.readOnly.checked || fieldDef.isPk)
1777
+ && (!grid.view.hiddenColumns.includes(fieldDef.name) || input.hiddens.checked);
1778
+ });
1779
+ var exportPromise;
1780
+ try{
1781
+ exportPromise = canSaveToDisk ? saveToDisk(fieldsDef2Export).then(function(){ return 'chossing'; })
1782
+ : prepareDownload(fieldsDef2Export);
1783
+ }catch(err){
1784
+ alertPromise(err.message);
1785
+ return;
1786
+ }
1787
+ exporting=true;
1788
+ mainDiv.setAttribute("current-state", "preparing");
1789
+ exportPromise.then(function(state){
1790
+ exporting=false;
1791
+ mainDiv.setAttribute("current-state", state);
1792
+ }, function(err){
1793
+ exporting=false;
1794
+ mainDiv.setAttribute("current-state", "chossing");
1795
+ alertPromise(err.message);
1796
+ });
1797
+ }
1798
+ var txtExport = function(fieldsDef2Export, separator, replacer){
1639
1799
  var data=[];
1640
1800
  var titles = fieldsDef2Export.map(function(fieldDef){
1641
1801
  if(fieldDef.inTable===false && !input.fromOtherTables.checked) return '';
@@ -1650,11 +1810,66 @@ myOwn.dialogDownload = function dialogDownload(grid){
1650
1810
  return value!=null?typeStore.typerFrom(fieldDef).toPlainString(value):'';
1651
1811
  }));
1652
1812
  });
1653
- var blob = new Blob([data.map(function(line){return line.map(replacer).join(separator)}).join('\r\n')], {type: 'text/plain'});
1654
- var url = URL.createObjectURL(blob);
1655
- downloadElement.href=url;
1656
- downloadElement.setAttribute("download", grid.def.name+dotExtension);
1657
- mainDiv.setAttribute("current-state", "ready");
1813
+ return data.map(function(line){return line.map(replacer).join(separator)}).join('\r\n');
1814
+ }
1815
+ var tabPlusExportHeader = function(fieldsDef2Export){
1816
+ var tabPlus = require('tab-plus');
1817
+ var exportedNames = fieldsDef2Export.map(function(fieldDef){ return fieldDef.name; });
1818
+ // si se pegó un archivo entero solo interesa su primera línea
1819
+ var headerLine = tabPlusHeader.value.split(/\r?\n/).find(function(line){ return line.trim()!=''; });
1820
+ var header = null;
1821
+ if(headerLine!=null){
1822
+ header = tabPlus.parseTab(headerLine, {columnDefs: myOwn.tabPlusColumnDefs(fieldsDef2Export)});
1823
+ }else if(tabPlusTableOptions!=null && typeof tabPlusTableOptions == 'object'){
1824
+ header = tabPlusTableOptions;
1825
+ }
1826
+ if(header!=null){
1827
+ var unknownNames = header.fields.filter(function(name){ return !exportedNames.includes(name); });
1828
+ if(unknownNames.length){
1829
+ throw new Error(my.messages.tabPlusHeaderUnknownFields + unknownNames.join(', '));
1830
+ }
1831
+ }
1832
+ return header;
1833
+ }
1834
+ var tabPlusExport = function(fieldsDef2Export, header){
1835
+ var tabPlus = require('tab-plus');
1836
+ var autoTabPlus = require('auto-tab-plus');
1837
+ var exportedNames = fieldsDef2Export.map(function(fieldDef){ return fieldDef.name; });
1838
+ var rows = grid.depotsToDisplay.map(function(depot){
1839
+ var row = {};
1840
+ fieldsDef2Export.forEach(function(fieldDef){
1841
+ var value=depot.row[fieldDef.name];
1842
+ row[fieldDef.name] = value!=null?typeStore.typerFrom(fieldDef).toPlainString(value):null;
1843
+ });
1844
+ return row;
1845
+ });
1846
+ var tab;
1847
+ if(header==null){
1848
+ tab = {fields:exportedNames, rows:rows};
1849
+ }else{
1850
+ var newNames = exportedNames.filter(function(name){ return !header.fields.includes(name); });
1851
+ var fields = header.fields.concat(newNames);
1852
+ var decided = autoTabPlus.decideSparseColumns(
1853
+ fields,
1854
+ rows.map(function(row){ return fields.map(function(name){ return row[name]; }); }),
1855
+ header.columnDefs,
1856
+ header.columnDefs ? {under: autoTabPlus.defaultThreshold, sparse: newNames}
1857
+ : {under: autoTabPlus.defaultThreshold, fixed: header.fields, auto: newNames}
1858
+ );
1859
+ tab = {fields:decided.fields, columnDefs:decided.columnDefs, rows:rows};
1860
+ }
1861
+ var fieldDefByName = {};
1862
+ fieldsDef2Export.forEach(function(fieldDef){ fieldDefByName[fieldDef.name] = fieldDef; });
1863
+ var columnDefs = {};
1864
+ tab.fields.forEach(function(name){
1865
+ var sparse = tab.columnDefs?.[name]?.sparseDefault !== undefined;
1866
+ var emptyField = myOwn.tabPlusGenerateEmptyField(fieldDefByName[name], sparse);
1867
+ columnDefs[name] = {emptyField: emptyField};
1868
+ if(tab.columnDefs){
1869
+ tab.columnDefs[name] = {...tab.columnDefs[name], emptyField: emptyField};
1870
+ }
1871
+ });
1872
+ return tabPlus.generateTab(tab, {eol:'\r\n', columnDefs: columnDefs});
1658
1873
  }
1659
1874
  var XLSX_STYLES={
1660
1875
  header : {bold:true},
@@ -1668,7 +1883,7 @@ myOwn.dialogDownload = function dialogDownload(grid){
1668
1883
  // Las columnas que salen de exportJsonFieldAsColumns aparecen recién al
1669
1884
  // recorrer las filas, pero el encabezado se escribe antes que ellas: con
1670
1885
  // escritura secuencial hay que conocerlas de entrada, y esta pasada las junta.
1671
- var collectExtraColumns = function collectExtraColumns(depots, fieldDefs, firstColumn){
1886
+ var collectExtraColumns = function collectExtraColumns(depots, fieldDefs, firstColumn, extraColumns){
1672
1887
  var lastColumn=firstColumn-1;
1673
1888
  if(grid.def.exportJsonFieldAsColumns){
1674
1889
  depots.forEach(function(depot){
@@ -1700,9 +1915,11 @@ myOwn.dialogDownload = function dialogDownload(grid){
1700
1915
  // Las filas de una tabla, como las toma xlsx-now: la primera es el
1701
1916
  // encabezado y cada una de las demás es un array donde la posición es la
1702
1917
  // columna. `leftColumn` corre la tabla entera hacia la derecha.
1703
- var tableRowsXLS = function tableRowsXLS(depots, fieldDefs, leftColumn){
1918
+ // Es un generador para que xlsx-now pida cada fila recién cuando la va a escribir.
1919
+ var tableRowsXLS = function* tableRowsXLS(depots, fieldDefs, leftColumn){
1704
1920
  leftColumn=leftColumn||0;
1705
- var lastColumn=collectExtraColumns(depots, fieldDefs, fieldDefs.length);
1921
+ var extraColumns={};
1922
+ var lastColumn=collectExtraColumns(depots, fieldDefs, fieldDefs.length, extraColumns);
1706
1923
  lastColumnExported = Math.max(lastColumnExported, lastColumn);
1707
1924
  var pad=new Array(leftColumn);
1708
1925
  var headerRow=pad.concat(fieldDefs.map(function(field){
@@ -1711,8 +1928,8 @@ myOwn.dialogDownload = function dialogDownload(grid){
1711
1928
  likeAr(extraColumns).forEach(function(pos, name){
1712
1929
  headerRow[pos+leftColumn]={v:name, s:'extraHeader'};
1713
1930
  });
1714
- var rows=[headerRow];
1715
- depots.forEach(function(depot){
1931
+ yield headerRow;
1932
+ for(var depot of depots){
1716
1933
  var row=pad.concat([]);
1717
1934
  var addCell = function addCell(value, fieldDef, iColumn){
1718
1935
  if(value!=null){
@@ -1733,11 +1950,10 @@ myOwn.dialogDownload = function dialogDownload(grid){
1733
1950
  addCell(value, fieldDef, iColumn);
1734
1951
  }
1735
1952
  });
1736
- rows.push(row);
1737
- });
1738
- return rows;
1953
+ yield row;
1954
+ }
1739
1955
  }
1740
- var excelExport = function(){
1956
+ var excelOptions = function(fieldsDef2Export){
1741
1957
  var sheet1name=grid.def.name.length>27?grid.def.name.slice(0,27)+'...':grid.def.name;
1742
1958
  var sheet2name=grid.def.name!=="metadata"?"metadata":"meta-data";
1743
1959
  var dataSheet = {
@@ -1745,11 +1961,15 @@ myOwn.dialogDownload = function dialogDownload(grid){
1745
1961
  freezeRows: my.config.config?.['unfreeze-excel-rows'] ? 0 : 1,
1746
1962
  freezeColumns: my.config.config?.['unfreeze-excel-columns'] ? 0 : (grid.def.primaryKey?.length ?? 0),
1747
1963
  autoWidthMax: 40,
1964
+ autoWidthMin: 5,
1965
+ autoWidthRows: 16384,
1748
1966
  rows: tableRowsXLS(grid.depotsToDisplay, fieldsDef2Export)
1749
1967
  };
1750
1968
  var metadataSheet = {
1751
1969
  name:sheet2name,
1752
1970
  autoWidthMax: 40,
1971
+ autoWidthMin: 5,
1972
+ autoWidthRows: 16384,
1753
1973
  rows: [
1754
1974
  [],
1755
1975
  [{v:'table',s:'header'}, grid.def.name],
@@ -1767,22 +1987,13 @@ myOwn.dialogDownload = function dialogDownload(grid){
1767
1987
  }).map(function(fieldDef){
1768
1988
  return {row:fieldDef};
1769
1989
  });
1770
- extraColumns={};
1771
1990
  metadataSheet.rows.push(...(tableRowsXLS(fieldPropertiesDepot,fieldPropertiesDefs,1)));
1772
1991
  }
1773
1992
  }
1774
- xlsxNowBrowser.createXlsxBlob({
1993
+ return {
1775
1994
  sheets: [dataSheet, metadataSheet],
1776
1995
  styles:XLSX_STYLES
1777
- }).then(function(blob){
1778
- mainDiv.setAttribute("current-state", "ready");
1779
- var url = URL.createObjectURL(blob);
1780
- downloadElement.href=url;
1781
- downloadElement.setAttribute("download", grid.def.name+".xlsx");
1782
- }).catch(function(err){
1783
- mainDiv.setAttribute("current-state", "chossing");
1784
- alertPromise(err.message);
1785
- });
1996
+ };
1786
1997
  };
1787
1998
  });
1788
1999
  };
@@ -919,6 +919,31 @@ myOwn.registerPostInput = function registerPostInput(postInputName, postInputFun
919
919
  TypeStore.type.text.postInputs[postInputName]=postInputFunction;
920
920
  }
921
921
 
922
+ // Cómo se lee en un .tab (tab-plus) un campo sin contenido: vale '' si el campo acepta '' y no acepta null,
923
+ // y null en cualquier otro caso. La usan quienes leen un .tab (el instalador y el diálogo de exportar al
924
+ // leer la línea de opciones), por eso no se escribe en el archivo.
925
+ myOwn.tabPlusEmptyField = function tabPlusEmptyField(fieldDef){
926
+ return fieldDef.allowEmptyText && ('nullable' in fieldDef) && !fieldDef.nullable ? 'string' : 'null';
927
+ }
928
+
929
+ var tabPlusSparseEmptyField = Symbol('tab-plus sparse empty field');
930
+
931
+ myOwn.tabPlusGenerateEmptyField = function tabPlusGenerateEmptyField(fieldDef, sparse){
932
+ if(sparse){
933
+ return tabPlusSparseEmptyField;
934
+ }
935
+ return fieldDef.typeName=='text' && fieldDef.nullable!==false && fieldDef.allowEmptyText ? 'null' : 'both';
936
+ }
937
+
938
+ // el emptyField de cada campo, en el formato de options.columnDefs de tab-plus
939
+ myOwn.tabPlusColumnDefs = function tabPlusColumnDefs(fieldDefs){
940
+ var columnDefs = {};
941
+ fieldDefs.forEach(function(fieldDef){
942
+ columnDefs[fieldDef.name] = {emptyField: myOwn.tabPlusEmptyField(fieldDef)};
943
+ });
944
+ return columnDefs;
945
+ }
946
+
922
947
  return myOwn;
923
948
 
924
949
  });
@@ -511,6 +511,12 @@ export interface AppConfigClientSetup // front-end config
511
511
  "user-scalable"?:string
512
512
  "unfreeze-excel-rows"?:boolean
513
513
  "unfreeze-excel-columns"?:boolean
514
+ "tab-plus"?:"only"|"compare" // "only": the installer reads the .tab with tab-plus and the grid exports "|" with tab-plus
515
+ downloads?:{
516
+ method?:"legacy"|"SaveFilePicker"
517
+ "close-retries"?:0|1|2|3|4|5
518
+ }
519
+ "tab-plus-table-options"?:Record<string, string|{fields:string[], columnDefs?:Record<string, {position:number, sparseDefault?:string|null}>}>
514
520
  }
515
521
  export interface AppConfig {
516
522
  package: {
@@ -30,6 +30,7 @@ var stackTrace = require('stack-trace');
30
30
  var locatePath = require('@upgraded/locate-path');
31
31
  var jsYaml = require('js-yaml');
32
32
  var tabPlus = require('tab-plus');
33
+ var readline = require('readline');
33
34
  var nodemailer = require('nodemailer');
34
35
  var os = require('os');
35
36
  const cors = require('cors');
@@ -822,6 +823,15 @@ AppBackend.prototype.start = function start(opts){
822
823
  be.config = config;
823
824
  be.clientSetup = {config: be.config["client-setup"]};
824
825
  be.clientSetup.config.prefix = be.clientSetup.config.prefix || be.config.server["base-url"].substr(1)+':';
826
+ var downloadsConfig = be.config["client-setup"].downloads;
827
+ if(downloadsConfig != null){
828
+ if(downloadsConfig.method != null && !['legacy','SaveFilePicker'].includes(downloadsConfig.method)){
829
+ throw new Error('client-setup.downloads.method must be legacy or SaveFilePicker');
830
+ }
831
+ if(downloadsConfig['close-retries'] != null && ![0,1,2,3,4,5].includes(downloadsConfig['close-retries'])){
832
+ throw new Error('client-setup.downloads.close-retries must be an integer from 0 to 5');
833
+ }
834
+ }
825
835
  if(config.nodb){
826
836
  console.log("server without database");
827
837
  }else{
@@ -1385,6 +1395,10 @@ AppBackend.prototype.start = function start(opts){
1385
1395
  });
1386
1396
  }).then(function(){
1387
1397
  return be.postConfig();
1398
+ }).then(function(){
1399
+ if(be.config["client-setup"]["tab-plus"] == "only"){
1400
+ return be.collectTabPlusTableOptions();
1401
+ }
1388
1402
  }).then(function(){
1389
1403
  if(be.config["client-setup"].menu===true){
1390
1404
  var contextForDump = be.getContextForDump();
@@ -2341,6 +2355,8 @@ AppBackend.prototype.clientIncludes = function clientIncludes(req, opts) {
2341
2355
  { type: 'js', module: 'xlsx-now', modPath: '../../umd', file: 'xlsx-now.umd.js' },
2342
2356
  { type: 'js', module: 'xlsx-now', modPath: '../../umd', file: 'xlsx-now-browser.umd.js' },
2343
2357
  { type: 'js', module: 'require-bro' },
2358
+ { type: 'js', module: 'tab-plus' },
2359
+ { type: 'js', module: 'tab-plus', file: 'auto-tab-plus.js' },
2344
2360
  { type: 'js', module: 'js-yaml', modPath: 'browser', file: 'js-yaml.umd.min.js' },
2345
2361
  { type: 'js', module: 'cast-error' },
2346
2362
  { type: 'js', module: 'lazy-some' },
@@ -3018,6 +3034,61 @@ AppBackend.prototype.dumpDbTableFields = function dumpDbTableFields(tableDef, op
3018
3034
  return fields
3019
3035
  }
3020
3036
 
3037
+ AppBackend.prototype.tableDataFileCandidates = function tableDataFileCandidates(tableName){
3038
+ var be = this;
3039
+ var buscarEn=[Path.join('local-install',tableName+'.tab')].concat(
3040
+ be.appStack.map(function(stackNode){
3041
+ return Path.join(stackNode.path,'../install',tableName+'.tab').replace(regexpDistReplacer,'$1$2');
3042
+ })
3043
+ );
3044
+ if(be.config.install['table-data-dir'] instanceof Array){
3045
+ buscarEn=be.config.install['table-data-dir'].map(path=>Path.join(path,tableName+'.tab')).concat(buscarEn);
3046
+ }else if(be.config.install['table-data-dir']){
3047
+ buscarEn=[].concat(Path.join(be.config.install['table-data-dir'],tableName+'.tab')).concat(buscarEn);
3048
+ }
3049
+ return buscarEn;
3050
+ }
3051
+
3052
+ async function readTabHeaderLine(fileName){
3053
+ var stream = fs.createReadStream(fileName, {encoding:'utf8'});
3054
+ var lines = readline.createInterface({input:stream, crlfDelay:Infinity});
3055
+ try{
3056
+ var first = true;
3057
+ for await (var line of lines){
3058
+ if(first){
3059
+ if(/[-[]/.test(line[0])) return null;
3060
+ line = line.replace(/^/,'');
3061
+ first = false;
3062
+ }
3063
+ if(!(/^[-| ]*$/.test(line))) return line;
3064
+ }
3065
+ return null;
3066
+ }finally{
3067
+ lines.close();
3068
+ // readline no cierra su input: si se deja de leer antes del final el archivo queda abierto
3069
+ stream.destroy();
3070
+ }
3071
+ }
3072
+
3073
+ // para el diálogo de exportar: completa client-setup.tab-plus-table-options con la primera línea del .tab de
3074
+ // cada tabla que no la tenga definida
3075
+ AppBackend.prototype.collectTabPlusTableOptions = async function collectTabPlusTableOptions(){
3076
+ var be = this;
3077
+ var clientConfig = be.config["client-setup"];
3078
+ var tableOptions = clientConfig["tab-plus-table-options"] || {};
3079
+ var collected = {};
3080
+ await Promise.all(likeAr(be.tableStructures).map(async function(_tableDef, tableName){
3081
+ if(Object.prototype.hasOwnProperty.call(tableOptions, tableName)) return;
3082
+ var theTableFileName = await locatePath(be.tableDataFileCandidates(tableName));
3083
+ if(theTableFileName==undefined) return;
3084
+ var headerLine = await readTabHeaderLine(theTableFileName);
3085
+ if(headerLine!=null){
3086
+ collected[tableName] = headerLine;
3087
+ }
3088
+ }).array());
3089
+ clientConfig["tab-plus-table-options"] = {...collected, ...tableOptions};
3090
+ }
3091
+
3021
3092
  AppBackend.prototype.dumpDbSchemaPartial = async function dumpDbSchemaPartial(partialTableStructures, opts = {}){
3022
3093
  var complete = opts.complete;
3023
3094
  var be = this;
@@ -3360,19 +3431,14 @@ $body$;
3360
3431
  '-- END DATA FROM DUMP'
3361
3432
  ]
3362
3433
  } else {
3434
+ if(be.config?.internals?.["tab-plus"] !== undefined){
3435
+ throw new Error('the config internals.tab-plus was moved to client-setup.tab-plus');
3436
+ }
3437
+ var tabPlusMode = be.config["client-setup"]["tab-plus"];
3363
3438
  await Promise.all(likeAr(partialTableStructures).map(function(tableDef, tableName, _, i){
3364
3439
  if(tableDef?.sql?.isTable === false) return;
3365
3440
  // TODO: buscar
3366
- var buscarEn=[Path.join('local-install',tableName+'.tab')].concat(
3367
- be.appStack.map(function(stackNode){
3368
- return Path.join(stackNode.path,'../install',tableName+'.tab').replace(regexpDistReplacer,'$1$2');
3369
- })
3370
- );
3371
- if(be.config.install['table-data-dir'] instanceof Array){
3372
- buscarEn=be.config.install['table-data-dir'].map(path=>Path.join(path,tableName+'.tab')).concat(buscarEn);
3373
- }else if(be.config.install['table-data-dir']){
3374
- buscarEn=[].concat(Path.join(be.config.install['table-data-dir'],tableName+'.tab')).concat(buscarEn);
3375
- }
3441
+ var buscarEn=be.tableDataFileCandidates(tableName);
3376
3442
  if(!i) console.log('buscar en', buscarEn)
3377
3443
  return locatePath(buscarEn).then(function(theTableFileName){
3378
3444
  if(theTableFileName==undefined){
@@ -3424,7 +3490,7 @@ $body$;
3424
3490
  .map(line => splitRawRowIntoRow(line))
3425
3491
  .filter(line => line.length>1 || line.length==1 && line[0].trim() );
3426
3492
  if(lines.length>1){
3427
- if(be.config?.internals?.["tab-plus"] != "only"){
3493
+ if(tabPlusMode != "only"){
3428
3494
  if(lines[0][0].startsWith('\ufeff')){
3429
3495
  lines[0][0]=lines[0][0].replace('\ufeff','')
3430
3496
  }
@@ -3459,9 +3525,8 @@ $body$;
3459
3525
  }).join(',\n')+';\n';
3460
3526
  }
3461
3527
  dataText.push(dataString);
3462
- } else if(be.config?.internals?.["tab-plus"]){
3463
- var tabPlusEmptySymbol = Symbol('empty');
3464
- var parsedTabPlus = tabPlus.parseTab(content, {emptyField: tabPlusEmptySymbol});
3528
+ } else if(tabPlusMode){
3529
+ var parsedTabPlus = tabPlus.parseTab(content, {columnDefs: myOwn.tabPlusColumnDefs(tableDef.fields)});
3465
3530
  var tabPlusFields = parsedTabPlus.fields;
3466
3531
  var tabPlusRows = parsedTabPlus.rows;
3467
3532
  /* a partir de acá: solo construcción de SQL a partir de {fields,rows}, sin volver a tocar el parseo */
@@ -3472,11 +3537,9 @@ $body$;
3472
3537
  filteredFieldDefTabPlus.map(db.quoteIdent).join(', ')+
3473
3538
  ') FROM stdin;\n'+
3474
3539
  tabPlusRows.map(function(line){
3475
- return line.filter(function(_,i){ return filterField(tabPlusFields[i]);}).map(function(value,i){
3476
- var def = tableDef.field[filteredFieldDefTabPlus[i]];
3477
- return value===tabPlusEmptySymbol ? (
3478
- def.allowEmptyText && ('nullable' in def) && !def.nullable ? '' : '\\N'
3479
- ): value.replace(/\\/g,'\\\\').replace(/\t/g,'\\t').replace(/\n/g,'\\n').replace(/\r/g,'\\r');
3540
+ return line.filter(function(_,i){ return filterField(tabPlusFields[i]);}).map(function(value){
3541
+ return value===null ? '\\N'
3542
+ : value.replace(/\\/g,'\\\\').replace(/\t/g,'\\t').replace(/\n/g,'\\n').replace(/\r/g,'\\r');
3480
3543
  }).join('\t')+'\n';
3481
3544
  }).join('')+'\\.\n';
3482
3545
  }else{
@@ -3489,13 +3552,11 @@ $body$;
3489
3552
  if(def == null) {
3490
3553
  throw Error("no se encuentra la columna "+filteredFieldDefTabPlus[i]+" en "+tableName);
3491
3554
  }
3492
- return value===tabPlusEmptySymbol ? (
3493
- def.allowEmptyText && ('nullable' in def) && !def.nullable ? "''" : 'null'
3494
- ) : db.quoteNullable(value);
3555
+ return db.quoteNullable(value);
3495
3556
  }).join(', ')+")";
3496
3557
  }).join(',\n')+';\n';
3497
3558
  }
3498
- if(be.config?.internals?.["tab-plus"] == "compare" && dataStringTabPlus !== dataString){
3559
+ if(tabPlusMode == "compare" && dataStringTabPlus !== dataString){
3499
3560
  var oldSqlPath = path.replace(/\.tab$/,'')+'-old-local.sql';
3500
3561
  var newSqlPath = path.replace(/\.tab$/,'')+'-new-local.sql';
3501
3562
  fs.writeFileSync(oldSqlPath, dataString, {encoding:'UTF8'});
@@ -3506,7 +3567,7 @@ $body$;
3506
3567
  console.log('TRY_TAB_PLUS: el parseo con tab-plus difiere del parseo anterior para '+tableName+' ('+path+'). Ver '+oldSqlPath+' y '+newSqlPath);
3507
3568
  }
3508
3569
  }
3509
- if (be.config?.internals?.["tab-plus"] == "only") {
3570
+ if (tabPlusMode == "only") {
3510
3571
  dataText.push(dataStringTabPlus);
3511
3572
  }
3512
3573
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backend-plus",
3
3
  "description": "Backend for the anti Pareto rule",
4
- "version": "2.7.3",
4
+ "version": "2.7.4",
5
5
  "author": "Codenautas <codenautas@googlegroups.com>",
6
6
  "license": "MIT",
7
7
  "repository": "codenautas/backend-plus",
@@ -72,11 +72,11 @@
72
72
  "simple-git": "^3.36.0",
73
73
  "sql-tools": "^0.1.7",
74
74
  "stack-trace": "^1.0.0",
75
- "tab-plus": "^0.1.8",
75
+ "tab-plus": "^0.2.0",
76
76
  "type-store": "^0.6.1",
77
77
  "typed-controls": "^0.12.7",
78
78
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz",
79
- "xlsx-now": "^0.2.1"
79
+ "xlsx-now": "^0.4.2"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@types/big.js": "^7.0.0",