diff --git a/dist/appsscript.json b/dist/appsscript.json new file mode 100644 index 0000000..c78cfb0 --- /dev/null +++ b/dist/appsscript.json @@ -0,0 +1,11 @@ +{ + "timeZone": "America/Los_Angeles", + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "dependencies": {}, + "oauthScopes": [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/script.container.ui", + "https://www.googleapis.com/auth/spreadsheets.currentonly" + ] +} diff --git a/dist/code.gs b/dist/code.gs new file mode 100644 index 0000000..ff9060f --- /dev/null +++ b/dist/code.gs @@ -0,0 +1,866 @@ +/* code.gs generated by concat-gs-files workflow: gh/pffy/code-gs */ + +/* addon.gs */ + +// name : addon.gs +// git : https://github.com/pffy/markdown-table +// author : The Pffy Authors https://pffy.dev +// license : https://unlicense.org/ + +function onOpen() { + var ui = SpreadsheetApp.getUi(); + ui.createAddonMenu() + .addItem('Export range to Markdown table ...', + 'exportActiveRange') + .addItem('Export all selected ranges to Markdown tables ...', + 'exportAllActiveRanges') + .addSeparator() + .addItem('Export entire sheet to Markdown table ...', + 'exportEntireSheet') + .addItem('Export all sheets to Markdown tables ...', + 'exportAllSheets') + .addSeparator() + .addItem('Export selected named ranges to Markdown tables ...', + 'openSelectNamedRanges') + .addItem('Export all named ranges to Markdown tables ...', + 'exportAllNamedRanges') + .addToUi(); +} + +function onInstall() { + onOpen(); +} + + +/*----------------------------------------------------------------------------*/ +/* fn.gs */ + +// name : fn.gs +// git : https://github.com/pffy/markdown-table +// author : The Pffy Authors https://pffy.dev +// license : https://unlicense.org/ + +function convertActiveRange() { + + const sht = SpreadsheetApp.getActiveSheet(); + const activeRange = sht.getActiveRange(); + + const output = cotton(activeRange); + opts.output = output; + + return output; +} + +function convertEntireSheet() { + + const sht = SpreadsheetApp.getActiveSheet(); + const dataRange = sht.getDataRange(); + + const output = cotton(dataRange); + opts.output = output; + + return output; +} + +function convertAllRanges() { + + const crlf = '\r\n'; + + const arr = []; + const sht = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); + + const rngs = sht.getActiveRangeList().getRanges(); + let i = 1; + rngs.forEach(function(rng) { + arr.push(crlf); + arr.push('range ' + i); + arr.push(cotton(rng)); + i++; + }); + + const output = arr.join(crlf + crlf); + opts.output = output; + + return output; +} + +function convertAllSheets() { + + const crlf = '\r\n'; + + const arr = []; + const shts = SpreadsheetApp.getActiveSpreadsheet().getSheets(); + + shts.forEach(function(sht) { + const dataRange = sht.getDataRange(); + arr.push(crlf); + arr.push(sht.getSheetName()); + arr.push(cotton(dataRange)); + }); + + const output = arr.join(crlf + crlf); + opts.output = output; + + return output; +} + +function convertAllNamedRanges() { + + const crlf = '\r\n'; + const prefix = '### '; // for named range header above the table + + const arr = []; + const ss = SpreadsheetApp.getActiveSpreadsheet(); + + const rngs = ss.getNamedRanges().sort(orderNamedRangeByAlpha); + + rngs.forEach(function(rng) { + arr.push(crlf); + arr.push(prefix + rng.getName()); + arr.push(cotton(rng.getRange())); + }); + + const output = arr.join(crlf + crlf); + opts.output = output; + + return output; +} + +function convertSelectedNamedRanges(str) { + + const crlf = '\r\n'; + const prefix = '### '; // for named range header above the table + + const arr = []; + + // named ranges from user selection + const nr = str.split(','); + + const ss = SpreadsheetApp.getActiveSpreadsheet(); + + // filter to only process selected named ranges + const rngs = ss.getNamedRanges().filter(function(rng){ + return nr.indexOf(rng.getName()) > -1; + }).forEach(function(rng) { + arr.push(crlf); + arr.push(prefix + rng.getName()); + arr.push(cotton(rng.getRange())); + }); + + const output = arr.join(crlf + crlf); + opts.output = output; + + return output; +} + +function orderNamedRangeByAlpha(a,b) { + + // based on solution found here: + // https://stackoverflow.com/a/30840201 + + // alphabetical order + if(a.getName() < b.getName()) return -1; + if(a.getName() > b.getName()) return 1; + return 0; +} + +// returns Markdown syntax for column alignments +function getAlignSyntax(str) { + + switch(str.toLowerCase()) { + case 'left': + case 'general-left': + return ':--'; + case 'center': + case 'general-center': + return ':--:'; + case 'right': + case 'general-right': + return '--:'; + default: + // center will be the default for now + return ':--:'; + } +} + +// font functions + +// returns true if font is monospace; false, otherwise +function isFontMonospace(str) { + + // monospace Google fonts: + // https://www.google.com/fonts + const fonts = `anonymous pro +azeret mono +b612 mono +courier +courier new +courier prime +cousine +cutive mono +dm mono +droid sans mono +fira code +fira mono +ibm plex mono +inconsolata +jetbrains mono +major mono display +monospace +nanum gothic coding +noto sans mono +nova mono +overpass mono +oxygen mono +pt mono +red hat mono +roboto mono +share tech mono +space mono +source code pro +spline sans mono +syne mono +ubuntu mono +vt323 +xanh mono +`.trim().split(/\n/); + + return (fonts.indexOf('' + str.toLowerCase()) > -1); +} + +// adds Markdown text formatting syntax to string +function addTextSyntax(str, chr) { + return Utilities.formatString(chr + '%s' + chr, str); +} + +// more general functions + +// saves file to Google drive, then returns URL +function saveToDrive(str) { + + // GCP implementation: + // Drive API must be enabled in console/gcloud + + const filename = opts.filename || 'cotton-table.markdown'; + + const label = opts.folder || 'Cotton Markdown Tables'; + const folder = DriveApp.getRootFolder(); + + if(folder.getFoldersByName(label).hasNext()) { + newFolder = folder.getFoldersByName(label).next(); + } else { + newFolder = folder.createFolder(label); + } + + const file = newFolder.createFile(filename, + str, MimeType.PLAIN_TEXT); + + opts.url = file.getUrl(); + opts.durl = file.getDownloadUrl(); +} + +// returns true if an object is empty; otherwise, false. +function isObjectEmpty(obj) { + return obj && (Object.keys(obj).length < 1 && obj.constructor === Object); +} + +function include(filename) { + return HtmlService.createHtmlOutputFromFile(filename).getContent(); +} + +function openSidebar(filename) { + const ui = HtmlService.createTemplateFromFile(filename) + .evaluate().setTitle(opts.title).setWidth(300); + SpreadsheetApp.getUi().showSidebar(ui); +} + +function finish() { + + // using solution found here: + // https://stackoverflow.com/a/19316873 + Object.keys(opts).forEach(key => delete opts[key]); +} + +function say(str , title) { + title = title || 'Hi' + SpreadsheetApp.getActiveSpreadsheet() + .toast(str, title, -1); +} + +// returns true if spreadsheet has named ranges; otherwise, false. +function hasNamedRanges() { + return !!SpreadsheetApp.getActiveSpreadsheet().getNamedRanges().length; +} + +function addNamedRangeSelectHtml() { + + const ss = SpreadsheetApp.getActiveSpreadsheet(); + + const arr = []; + arr.push(''); + + return arr.join('\n'); +} + +// cleans Markdown in each table cell +function cleanText(str) { + + // prevents "new column" + // all must go + str = str.replace(/\|/g, '|' ); + + // prevents list item + // first must go + str = str.replace(/^\*/, '*' ); + str = str.replace(/^\-/, '+' ); + str = str.replace(/^\+/, '−' ); + + // prevents headers + // first must go + str = str.replace(/^#/, '#' ); // hashtag + + // prevents quote block + // first must go + str = str.replace(/^>/, '>' ); + + return str; +} + +// returns true if any checkboxes detected; otherwise, false +function isCheckbox(validation) { + return validation.getCriteriaType().toJSON() === 'CHECKBOX'; +} + +// surrounds string with matching HTML tags +function addHtmlTagToSyntax(str, tag) { + return `<${tag}>${str}`; +} + +// adds hyperlink to Markdown table syntax +function addHyperlinkToSyntax(obj) { + obj.title = obj.title.replace(/\"/g, '\\"'); + return `[${obj.label}](${obj.url} "${obj.title}")`; +} + +// generates YouTube thumbnail syntax +function addYoutubeSyntax(obj) { + obj.title = obj.label.replace(/\"/g, '"'); + obj.image = `https://img.youtube.com/vi/${obj.id}/mqdefault.jpg`; + return `[![${obj.label}](${obj.image} "${obj.title}")](${obj.url})`; +} + +// generates IMG html element +function addImageHtml(obj) { + obj.label = obj.label.replace(/\"/g, """); + return ``; +} + + +/*----------------------------------------------------------------------------*/ +/* obj.gs */ + +// name : obj.gs +// git : https://github.com/pffy/markdown-table +// author : The Pffy Authors https://pffy.dev +// license : https://unlicense.org/ + +function cotton (range) { + + if(range === undefined || isObjectEmpty(range)) { + const emptytable = ' \n:--:'; + return emptytable; + } + + const rng = range; + const sht = rng.getSheet(); + + // count rows and columns + const rows = rng.getNumRows(); + const cols = rng.getNumColumns(); + + // todo: add cell limit in later versions + + // delimiter for Markdown columns + const pipe = ' | '; + + // column alignments + const aligns = rng.getHorizontalAlignments()[0] + .map((a) => getAlignSyntax(a)).filter(function(e,i){ + return !isTableColumnHidden(i+1); + }); + + // syntax for column alignments (row two) + const rowtwo = pipe + aligns.join(pipe) + pipe; + + // font typeface + const fontFamilies = rng.getFontFamilies(); + + // formulas + const formulas = rng.getFormulas(); + + // any formulas?? + const hasFormulas = !formulas.every(function(r){ // rows + return true === r.every(function(c){ // columns + return c === ''; + }); + }); + + // rich text values + const richTexts = rng.getRichTextValues(); + + // text styles + const textStyles = rng.getTextStyles(); + + // data validation for cell input + const valids = rng.getDataValidations(); + + // any data validation, including checkboxes + const hasValids = !valids.every(function(r){ // rows + return true === r.every(function(c){ // columns + return c === null; + }); + }); + + // notes on cell input + const notes = rng.getNotes(); + + let noteCount = 0; + let noteItem = ''; + + const footnotes = []; + + // any notes? + const hasNotes = !notes.every(function(r){ // rows + return true === r.every(function(c){ // columns + return c === ""; + }); + }); + + // cell values + // never used + // const values = rng.getValues(); + + // locale and user-formatted strings + const displayValues = rng.getDisplayValues(); + + // blank test function + const isBlank = (ev) => (ev === ''); + + // position in range + let x = 0; + let y = 0; + + let val = ''; + let cell = {}; + + let arr = []; + let table = []; + + let therow = ''; + + // hyperlink depot + const re_hyperlink = /^(\=HYPERLINK\()/i; + const hyperlink = {}; + + let hasHyperlink = false; + let hasHypertext = false; + + // youtube depot + const re_yt_watch = /\<\\>/i; + const yt = {}; + + // image depot + const re_img_url = /^(\=IMAGE\(\"(.*)\"\))/i; + const re_img_ref = /^(\=IMAGE\((\w+\d+)\))/i; + const img = {}; + + // now begins the warp and weft of the exporter + + for(let i = 1; i < rows + 1; i++) { + + x = i - 1; + + if(!hasNotes && !hasFormulas && displayValues[x].every(isBlank)) { + + arr = new Array(cols).fill(' '); + therow = pipe + arr.join(pipe) + pipe; + table.push(therow.trim()); + + if(i < 2) { + table.push(rowtwo.trim()); + } + + continue; + + } else { + arr = []; + } + + for(let j = 1; j < cols + 1; j++) { + + y = j - 1; + + cell = rng.getCell(i, j); + + if(isTableColumnHidden(cell.getColumn())) { + continue; + } + + if(isTableRowHidden(cell.getRow())) { + continue; + } + + // pre-value processor for =IMAGE(url) + if(hasFormulas && re_img_url.test(formulas[x][y])) { + + img.url = formulas[x][y].match(re_img_url)[2]; + img.label = 'Image'; + + if(hasNotes && notes[x][y]) { + img.label = notes[x][y]; + } + + img.h = sht.getRowHeight(cell.getRow()); + img.w = sht.getColumnWidth(cell.getColumn()); + + val = addImageHtml(img); + + footnotes.push(`IMAGE: ${notes[x][y]}
${img.url}
`); + + // done with the column + arr.push(val); + continue; + } + + // pre-value processor for =IMAGE(A1) + if(hasFormulas && re_img_ref.test(formulas[x][y])) { + + img.ref = formulas[x][y].match(re_img_ref)[2]; + img.url = sht.getRange(img.ref).getDisplayValue(); + img.label = 'Image'; + + if(hasNotes && notes[x][y]) { + img.label = notes[x][y]; + } + + img.h = sht.getRowHeight(cell.getRow()); + img.w = sht.getColumnWidth(cell.getColumn()); + + val = addImageHtml(img); + + footnotes.push(`IMAGE: ${notes[x][y]}
${img.url}
`); + + // done with the column + arr.push(val); + continue; + } + + val = displayValues[x][y].trim(); + + if(!val) { + arr.push(' '); + continue; + } + + // post-value processor for YouTube thumbnail generator + if(re_yt_watch.test(val)) { + + yt.id = val.match(re_yt_watch)[1]; + yt.url = `https://www.youtube.com/watch?=${yt.id}`; + + yt.label = 'YouTube Video'; + + if(hasNotes && notes[x][y]) { + yt.label = notes[x][y]; + } + + val = addYoutubeSyntax(yt); + + footnotes.push(`VIDEO: ${notes[x][y]}
${yt.url}
`); + + // done with the column + arr.push(val); + continue; + } + + + + // sanitize Markdown table text + val = cleanText(val); + + // process notes first + if(hasNotes && notes[x][y]) { + noteCount++; + noteItem = `${noteCount}`; + footnotes.push(`${noteItem} ${notes[x][y]}
`); + } + + // simply prints text check box for this cell + if(hasValids && valids[x][y] && isCheckbox(valids[x][y])) { + if(cell.isChecked()) { + val = '`[X]`'; + arr.push(); + } else { + val = '`[ ]`'; + } + + // postfix the reference to the value + if(hasNotes && notes[x][y]) { + val = val + noteItem; + } + + // checkbox cell complete; adds column to row + arr.push(val); + continue; + } + + if(isFontMonospace(fontFamilies[x][y])) { + val = val.replace(/\n/g, '`
`'); // pre-processing + val = addTextSyntax(val, '`'); + } + + // detect and convert bold + if(textStyles[x][y].isBold()) { + val = addTextSyntax(val, '**'); + } + + // detect and convert italic + if(textStyles[x][y].isItalic()) { + val = addTextSyntax(val, '*'); + } + + // detect and convert strike-through + if(textStyles[x][y].isStrikethrough()) { + val = addTextSyntax(val, '~~'); + } + + // detect and convert underline + if(textStyles[x][y].isUnderline() && !richTexts[x][y].getLinkUrl()) { + say('hi underline!'); + val = addHtmlTagToSyntax(val, 'ins'); + } + + // converts new line into line break HTML tag + // NOTE: should be after font styling + val = val.replace(/\n/g, '
'); + + // checks for =HYPERLINK() + hasHyperlink = hasFormulas && formulas[x][y] && re_hyperlink.test(formulas[x][y]); + + // checks for hypertext formatting + hasHypertext = richTexts[x][y].getLinkUrl(); + + // if hyperlink detected, val becomes a label + if(hasHyperlink || hasHypertext) { + + hyperlink.url = richTexts[x][y].getLinkUrl(); + hyperlink.title = richTexts[x][y].getText(); + hyperlink.label = val; + + val = addHyperlinkToSyntax(hyperlink); + } + + // postfix the reference to the cell value + if(hasNotes && notes[x][y]) { + val = val + noteItem; + } + + // adds new column to current row + arr.push(val); + } + + // adds new row to table + therow = pipe + arr.join(pipe) + pipe; + table.push(therow.trim()); + + // adds alignments row to Markdown table + if(i < 2) { + table.push(rowtwo.trim()); + } + } + + // add footnotes below markdown table + if(!!footnotes.length) { + table.push('\n\n### Notes\n' + footnotes.join('\n') + '\n'); + } + + // some inner functions are bleow + + // returns true if row is hidden; otherwise, false. + function isTableRowHidden(num) { + + if(sht.isRowHiddenByFilter(num)) { + return true; + } + + if(sht.isRowHiddenByUser(num)) { + return true; + } + + return false; + } + + // returns true if column is hidden; otherwise, false. + function isTableColumnHidden(num) { + + if(sht.isColumnHiddenByUser(num)){ + return true; + } + + return false; + } + + // returns Markdown table + return table.join('\n'); +} + + +/*----------------------------------------------------------------------------*/ +/* opts.gs */ + +// name : opts.gs +// git : https://github.com/pffy/markdown-table +// author : The Pffy Authors https://pffy.dev +// license : https://unlicense.org/ + +const opts = {}; + + +/*----------------------------------------------------------------------------*/ +/* ui.gs */ + +// name : ui.gs +// git : https://github.com/pffy/markdown-table +// author : The Pffy Authors https://pffy.dev +// license : https://unlicense.org/ + +// exports current selectino as a table +function exportActiveRange() { + + opts.mode = 'markdown table'; + opts.abbr = 'a human-readable table rendered by platforms'; + opts.details = 'The one table you selected is in this document.'; + opts.title = 'Cotton Markdown Tables'; + opts.folder = opts.title; + opts.filename = 'cotton-table.markdown'; + + saveToDrive(convertActiveRange()); + openSidebar('done'); + finish(); +} + +// exports the multi-selection as tables +function exportAllActiveRanges() { + + opts.mode = 'markdown table group'; + opts.abbr = 'many tables in one document'; + opts.details = 'Each table represents a selection.'; + opts.title = 'Cotton Markdown Tables'; + opts.folder = opts.title; + opts.filename = 'cotton-table.markdown'; + + saveToDrive(convertAllRanges()); + openSidebar('done'); + finish(); +} + +// exports selected named ranges as tables +function exportSelectedNamedRanges(str){ + + if(!hasNamedRanges()) { + sayNoNamedRanges() + return false; + } + + // NOTE: not accessed from menu, accessed from sidebar + + opts.mode = 'markdown table group'; + opts.abbr = 'many tables in one document'; + opts.details = 'Each table represents a named range.'; + opts.title = 'Cotton Markdown Tables'; + opts.folder = opts.title; + opts.filename = 'cotton-table.markdown'; + + saveToDrive(convertSelectedNamedRanges(str)); + openSidebar('done'); + finish(); +} + + +// exports all the named ranges as tables +function exportAllNamedRanges() { + + if(!hasNamedRanges()) { + sayNoNamedRanges() + return false; + } + + opts.mode = 'markdown table group'; + opts.abbr = 'many tables in one document'; + opts.details = 'Each table represents a named range.'; + opts.title = 'Cotton Markdown Tables'; + opts.folder = opts.title; + opts.filename = 'cotton-table.markdown'; + + saveToDrive(convertAllNamedRanges()); + openSidebar('done'); + finish(); +} + +// exports the active sheet in the spreadsheet +function exportEntireSheet() { + + opts.mode = 'markdown table'; + opts.abbr = 'a human-readable table rendered by platforms'; + opts.details = 'The entire sheet is the table in this document.'; + opts.title = 'Cotton Markdown Tables'; + opts.folder = opts.title; + opts.filename = 'cotton-table.markdown'; + + saveToDrive(convertEntireSheet()); + openSidebar('done'); + finish(); +} + +// exports all sheets in a spreadsheet +function exportAllSheets() { + + opts.mode = 'markdown table group'; + opts.abbr = 'many tables in one document'; + opts.details = 'Each table represents an entire sheet.'; + opts.title = 'Cotton Markdown Tables'; + opts.folder = opts.title; + opts.filename = 'cotton-table.markdown'; + + saveToDrive(convertAllSheets()); + openSidebar('done'); + finish(); +} + +// opens sidebar to select named ranges +function openSelectNamedRanges() { + + if(!hasNamedRanges()) { + sayNoNamedRanges() + return false; + } + + opts.title = 'Cotton Markdown Tables'; + openSidebar('select'); +} + +// uses toast to say something +function sayNoNamedRanges() { + say('You have no named ranges in this spreadsheet. To create named ranges, try the menu item Data > "Named ranges" ...', 'Oops!'); +} diff --git a/dist/done.html b/dist/done.html new file mode 100644 index 0000000..5ec6b98 --- /dev/null +++ b/dist/done.html @@ -0,0 +1,43 @@ + + + + + + + + + + +

Success!

+ +

You created a document. +

+ + + + + + + + + + + + + + + + + + diff --git a/dist/footer.html b/dist/footer.html new file mode 100644 index 0000000..af1a716 --- /dev/null +++ b/dist/footer.html @@ -0,0 +1,6 @@ + diff --git a/dist/javascript.html b/dist/javascript.html new file mode 100644 index 0000000..c91083b --- /dev/null +++ b/dist/javascript.html @@ -0,0 +1,64 @@ + + + diff --git a/dist/select.html b/dist/select.html new file mode 100644 index 0000000..742dc63 --- /dev/null +++ b/dist/select.html @@ -0,0 +1,42 @@ + + + + + <?= opts.title; ?> + + + + + + +

Named Ranges

+ + + Select one or more: + + + + +
+ + +
+ + + + + + + + + + + + diff --git a/dist/stylesheet.html b/dist/stylesheet.html new file mode 100644 index 0000000..08488d2 --- /dev/null +++ b/dist/stylesheet.html @@ -0,0 +1,57 @@ + + +