diff --git a/versions/1.1/app/BTAppNode.js b/versions/1.1/app/BTAppNode.js new file mode 100644 index 0000000..3090494 --- /dev/null +++ b/versions/1.1/app/BTAppNode.js @@ -0,0 +1,1123 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Centralizes all the node-related app logic of reading and writing to org, creating the ui etc + * + * + ***/ + +'use strict' + +class BTAppNode extends BTNode { + + /*** + * + * Basic node accessor functions w associated logic + * + ***/ + constructor(title, parentId, text, level, firstChild = false) { + super(title, parentId, firstChild); + this._text = text; + this._level = level; + this._folded = false; + this._keyword = null; + this._tabId = 0; + this._tabGroupId = 0; + this._windowId = 0; + this._opening = false; + + // Three attributes of org ndes to track + this.drawers = {}; + this.tags = []; // the org-mode tags for this org header (ie BT Topic or link) + this.planning = ""; + + AllNodes[this._id] = this; + } + + set text(txt) { + this._text = txt; + } + get text() { + return this._text; + } + + set level(l) { + this._level = l; + } + get level() { + return this._level; + } + set tabId(id) { + this._tabId = id; + } + get tabId() { + return this._tabId; + } + set tabIndex(index) { + this._tabIndex = index; + } + get tabIndex() { + return this._tabIndex; + } + set tabGroupId(id) { + this._tabGroupId = id; + if (!id) this.setTGColor(null); // clean up any color classes + } + get tabGroupId() { + return this._tabGroupId; + } + set windowId(id) { + this._windowId = id; + } + get windowId() { + return this._windowId; + } + set opening(val) { + this._opening = val; + } + get opening() { + return this._opening; + } + resetLevel(l) { + // after a ui drag/drop need to reset level under new parent + this.level = l; + this.childIds.forEach(childId => { + AllNodes[childId].resetLevel(l+1); + }); + } + get keyword() { + return this._keyword; + } + set keyword(kw) { + this._keyword = kw; + } + iterateKeyword() { + // TODO -> DONE -> '' + switch (this._keyword) { + case 'TODO': + this._keyword = "DONE"; + break; + case 'DONE': + this._keyword = null; + break; + case null: + this._keyword = "TODO"; + break; + } + } + + set folded(f) { + this._folded = f; + } + get folded() { + return this._folded; + } + + set pendingDeletion(f) { + // indicate pending deletion on open tab + this._pendingDeletion = f; + const displayNode = this.getDisplayNode(); + const btTitle = $(displayNode).find('.btTitleText'); + const opacity = f ? 0.4 : 1; + $(btTitle).css('opacity', opacity); + } + get pendingDeletion() { + return this._pendingDeletion; + } + + + hasOpenChildren() { + return this.childIds.filter(id => AllNodes[id].tabId).length; + } + hasOpenDescendants() { + return (this.tabId || this.childIds.some(id => AllNodes[id].hasOpenDescendants())); + } + hasUnopenDescendants() { + return ((this.URL && !this.tabId) || + this.childIds.some(id => AllNodes[id].hasUnopenDescendants())); + } + needsTab() { + return (this.URL && !this.tabId); + } + openWindowIds() { + // arrya of open window Ids + const open = this.childIds.filter(id => AllNodes[id].windowId); + return open.map(id => AllNodes[id].windowId); + } + findAnOpenNode() { + // return a childId w an open tabgroup + return this.childIds.find(id => AllNodes[id].windowId); + } + + /*** + * + * UI Management + * + ***/ + + HTML() { + // Generate HTML for this nodes table row + let outputHTML = ""; + outputHTML += `${this.displayTitle()}`; + outputHTML += `${this.displayText()}`; + return outputHTML; + } + + displayText() { + // escape any html entities and pass thru to static fn below + let text = BTAppNode._decodeHtmlEntities(this._text); + text = text.replace(/&/g, "&").replace(//g, ">"); + + return BTAppNode._orgTextToHTML(text); + } + + /* for use escaping unicode in displayTitle below */ + static _textAreaForConversion = document.createElement('textarea'); + static _decodeHtmlEntities(str) { + BTAppNode._textAreaForConversion.innerHTML = str; + return BTAppNode._textAreaForConversion.value; + } + displayTitle() { + // Node title as shown in tree, for url. + + // handle keywords + let keywordText = (this._keyword) ? `${this._keyword} ` : ""; // TODO etc + + // escape any html entities + let title = BTAppNode._decodeHtmlEntities(this.title); + title = title.replace(/&/g, "&").replace(//g, ">"); + + return BTAppNode._orgTextToHTML(title, keywordText); + } + + url() { + // Node title as seen when its a search result + const reg = new RegExp("\\[\\[(.*?)\\]\\[(.*?)\\]\\]"); // NB non greedy match [[url][title]] + const match = this.title.match(reg); + return match ? match[1] : ""; + } + + getDisplayNode() { + // return jquery table row for node, lazy eval and cache + this.displayNode = this.displayNode || $(`tr[data-tt-id='${this.id}']`)[0]; + return this.displayNode; + } + + getTTNode() { + // return treetable node (nb not jquery node) + return $("table.treetable").treetable("node", this.id); + } + + unfoldOne() { + // open this one node to show its kids but collapse kids + + // iterate thru children calling collapsenode + this.childIds.forEach(id => { + const node = AllNodes[id]; + if (node?.isTopic()) { + $("table.treetable").treetable("collapseNode", id); + } + }); + // then expand this one node + $("table.treetable").treetable("expandNode", this.id); + } + unfoldAll() { + // open this node and all children + $("table.treetable").treetable("expandNode", this.id); + this.childIds.forEach(id => { + const node = AllNodes[id]; + if (node?.isTopic()) node.unfoldAll(); + }); + } + + createDisplayNode() { + // call out to treetable w nodes html, really its create or return. + // atTop is special case handling in ttable for a new top level node + if (this.getTTNode()) return this.getTTNode(); + const atTop = (this.level == 1) ? true : false; + const displayParent = (this.parentId) ? AllNodes[this.parentId].createDisplayNode() : null; + $("table.treetable").treetable("loadBranch", displayParent, this.HTML(), atTop); + return this.getTTNode(); + } + + redisplay(show=false) { + // regenerate content + const dn = this.getDisplayNode(); + let keywordText = (this._keyword) ? `${this._keyword} ` : ""; // TODO etc + + $(dn).find("span.btTitleText").html(keywordText + this.displayTopic); + $(dn).find("span.btText").html(this.displayText()); + $(dn).find("span.btText").scrollTop(0); // might have scrolled down for search + $(dn).find(".left").scrollLeft(0); // might have scrolled right for search + $(dn).find(".left").css("text-overflow", "ellipsis"); // reset text overflow default + $(dn).find("a").each(function() { // reset link click intercept + this.onclick = handleLinkClick; + }); + if (this.isTopic() && this.childIds.length) $(dn).removeClass('emptyTopic'); + if (this.isTopic() && !this.childIds.length) $(dn).addClass('emptyTopic'); + show && this.showForSearch(); // reclose if needed + } + + setTGColor(color = null) { + // set color to sync w Tabgroup color + const displayNode = this.getDisplayNode(); + if (!displayNode) return; + this.tgColor = color; // remember color thru a refresh + const colorClass = color ? 'tg'+color : null; + const selector = this.isTopic() ? ".btTitle" : ".btTitle span.btTitleText"; + + // remove any prev color and add new color or no longer shown in tg -> remove class + $(displayNode).find(selector).removeClass( + ['tggrey', 'tgblue', 'tgred', 'tgyellow', 'tggreen', 'tgpink', + 'tgpurple', 'tgcyan', 'tgorange']); + if (color) + $(displayNode).find(selector).addClass(['tabgroup', colorClass]); + else + $(displayNode).find(selector).removeClass('tabgroup'); + + // iterate to contained nodes + this.childIds.forEach(id => { + const node = AllNodes[id]; + if (node.tabId) node.setTGColor(color); + }); + } + + storeFavicon() { + // store favicon in browser local storage + try { + const host = this.URL.split(/[?#]/)[0]; // strip off any query or hash + localFileManager.set(host, this.faviconUrl); + } + catch (e) { + console.warn(`Error storing favicon: ${e}`); + } + } + + async populateFavicon() { + // add favicon icon either from local storage or goog + if (this.isTopic() || !this.URL) return; + const host = this.URL.split(/[?#]/)[0]; + const favClass = (configManager.getProp('BTFavicons') == 'OFF') ? 'faviconOff' : 'faviconOn'; + const favUrl = + this.faviconUrl || + await localFileManager.get(host) || + `https://www.google.com/s2/favicons?domain=${host}`; + this.faviconUrl = favUrl; + const dn = this.getDisplayNode(); + $(dn).find(`.${favClass}`).remove(); // remove any previous set icon + const fav = $(`favicon`); + + fav.on('error', function() { + this.src = 'resources/help.png'; // if no favicon found, use ? from help icon + this.width = this.height = 16; + }); + $(dn).find('.btlink').prepend(fav); + } + static populateFavicons() { + // iterate thru tab nodes adding favicon icon either from local storage or goog + // use requestIdleCallback and small batches bacause this can be costly with many nodes + const nodes = AllNodes.filter(n => n && !n.isTopic() && n.URL); + let index = 0; + + function processNextBatch(deadline) { + let nodesProcessed = 0; + while (index < nodes.length && deadline.timeRemaining() > 0 && nodesProcessed < 25) { + nodes[index].populateFavicon(); + index++; nodesProcessed++; + } + + if (index < nodes.length) { + //console.log(index); + requestIdleCallback(processNextBatch); + } + } + + requestIdleCallback(processNextBatch); + } + + /*** + * + * Search support + * + ***/ + + showForSearch() { + // show this node in the tree cos its the search hit (might be folded) + // nb show/unshow are also called to show/unshow the active tab in the tree + const disp = this.getDisplayNode(); + if(disp && !$(disp).is(':visible')) { + if (this.parentId) AllNodes[this.parentId].showForSearch(); // btnode show + $(disp).show(); // jquery node show + this.shownForSearch = true; + } + } + + unshowForSearch() { + // if this node was shown as a search result, now unshow it to get tree back to where it was. + if (this.shownForSearch) { + const disp = this.getDisplayNode(); + if (this.parentId) AllNodes[this.parentId].unshowForSearch(); + this.redisplay(); // reset any search horiz scrolling + $(disp).hide(); + this.shownForSearch = false; + } + } + + search(sstr) { + // search node for regex of /sstr/ig. update its display to show a hit (title or text) + + const reg = new RegExp(escapeRegExp(sstr), 'ig'); + let match = false; + const node = this.getDisplayNode(); + let titleStr; + if (this.keyword && reg.test(this.keyword)) { + titleStr = `${this.keyword} ${this.displayTopic}`; + $(node).find("span.btTitleText").html(titleStr); + match = true; + } else if (reg.test(this.displayTopic)) { + titleStr = this.displayTopic.replaceAll(reg, `${sstr}`); + $(node).find("span.btTitleText").html(titleStr); + match = true; + } else if (reg.test(this.url())) { + const hurl = this.url().replaceAll(reg, `${sstr}`); + titleStr = "[" + hurl + "] " + this.displayTopic + ""; + $(node).find("span.btTitleText").html(titleStr); + match = true; + } + if (reg.test(this._text)) { + // show 125 chars before and after any match + const index = this._text.search(reg); + const start = Math.max(index - 125, 0); + const len = this._text.length; + const end = Math.min(index + 125, len); + let textStr = this._text.substring(start, end); + textStr = (start > 0 ? "..." : "") + textStr + (end < len ? "..." : ""); + textStr = textStr.replaceAll(reg, `${sstr}`); + $(node).find("span.btText").html(textStr); + displayNotesForSearch(); // match might be hidden if single column + match = true; + } + if (match) + $(node).find("td").addClass('search'); + + return match; + } + + static searchNodesToRedisplay = new Set(); + extendedSearch(sstr) { + // search node for regex of /sstr/ig. update its display to show a hit (title or text) + + const reg = new RegExp(escapeRegExp(sstr), 'ig'); + let lmatch, rmatch; + const node = this.getDisplayNode(); + if (!$(node).is(":visible")) return; // return if not displayed + + let titleStr; + // Look for match in title/topic, url and note + if (reg.test(this.displayTopic)) { + titleStr = this.displayTopic.replaceAll(reg, `${sstr}`); + // $(node).find("span.btTitle").html(titleStr); + $(node).find("span.btTitleText").html(titleStr); + lmatch = true; + } + if (!lmatch && reg.test(this.url())) { + // nb don't add span highlighting to url + lmatch = true; + } + if (reg.test(this.text)) { + let textStr = this.text; + textStr = textStr.replaceAll(reg, `${sstr}`); + $(node).find("span.btText").html(textStr); + rmatch = true; + } + + if (lmatch) + $(node).find("td.left").addClass('searchLite'); + if (rmatch) + $(node).find("td.right").addClass('searchLite'); + + // remember which nodes need to be redisplayed when seach ends + if (lmatch || rmatch) BTAppNode.searchNodesToRedisplay.add(this.id); + } + static redisplaySearchedNodes() { + // iterate thru nodes highlighted in search and redisplay + + BTAppNode.searchNodesToRedisplay.forEach((n) => AllNodes[n].redisplay()); + BTAppNode.searchNodesToRedisplay.clear(); + } + + static displayOrder = {}; + static setDisplayOrder() { + // iterate through #content.tr rows and create a hash mapping the node.id to a {prev:, next:} structure + // used by nextDisplayNode() to iterate through nodes in display order + BTAppNode.displayOrder = {}; + let prevNodeId = null; + $("#content tr").each((i, node) => { + const nodeId = $(node).attr('data-tt-id'); + BTAppNode.displayOrder[nodeId] = { + prev: prevNodeId, + next: null + }; + prevNodeId && (BTAppNode.displayOrder[prevNodeId].next = nodeId); + prevNodeId = nodeId; + }); + + // set prev of first node to last and next of last node to first to iterate around + const firstNodeId = Object.keys(BTAppNode.displayOrder)[0]; + BTAppNode.displayOrder[firstNodeId].prev = prevNodeId; + BTAppNode.displayOrder[prevNodeId].next = firstNodeId; + } + static resetDisplayOrder() { + // Clear out the display order cache + BTAppNode.displayOrder = {}; + } + nextDisplayNode(reverse = false) { + // displayOrder is the order of the nodes in the table, not in AllNodes. Used by search to know the next node to search in + const nodeId = reverse ? BTAppNode.displayOrder[this.id].prev : BTAppNode.displayOrder[this.id].next; + return AllNodes[nodeId]; + } + + /*** + * + * Extension outbound interactions - calls to have extension do stuff + * + ***/ + + showNode() { + // highlight this nodes associated tab or window + if (this.tabId) + window.postMessage( + {'function' : 'showNode', 'tabId': this.tabId}); + else if (this.tabGroupId) + window.postMessage( + {'function' : 'showNode', 'tabGroupId': this.tabGroupId}); + else if (this.windowId) + window.postMessage( + {'function' : 'showNode', 'windowId': this.windowId}); + } + + async openTopicTree() { + // this node points to a topic tree, have fileManager open and insert it + await loadOrgFile(this.URL); + + } + + openPage(newWin = false) { + // open this nodes url + if (!this.URL || this._opening) return; + + // record stats + gtag('event', 'openRow', {'event_category': 'TabOperation'}); + configManager.incrementStat('BTNumTabOperations'); + + // if this node is a link to a topic tree load it up + if (this.isTopicTree()) { + if (!this.childIds.length || confirm('Re-add this topic tree?')) + this.openTopicTree(); + return; + } + + // if already open, tell bg to show it + if (this.tabId) { + this.showNode(); + return; + } + this.opening = true; // avoid opening twice w double clicks. unset in tabNavigated + + const parent = this.parentId ? AllNodes[this.parentId] : null; + if (parent?.hasOpenChildren() && (GroupingMode == 'TABGROUP')) newWin = false; // only allow opening in new window if not already in an open TG, or not using TGs + + const oldWinId = parent ? parent.windowId : 0; + // tell extension to open, then take care of grouping etc + callBackground({'function': 'openTabs', 'newWin': newWin, 'defaultWinId': oldWinId, + 'tabs': [{'nodeId': this.id, 'url': this.URL}]}); + + this.showNode(); + return; + } + + openAll(newWin = false) { + // open this node and any children. NB order taken care of by tabOpened -> groupAndPosition + + // record stats + gtag('event', 'openAll', {'event_category': 'TabOperation'}); + configManager.incrementStat('BTNumTabOperations'); + + // if we don't care about grouping just open each tab + if (GroupingMode == 'NONE') { + const tabsToOpen = this.listOpenableTabs(); // [{nodeId, url}..} + window.postMessage({'function': 'openTabs', 'tabs': tabsToOpen, 'newWin': newWin}); + } + else { // need to open all urls in single (possibly new) window + const tabGroupsToOpen = this.listOpenableTabGroups(); // [{tg, [{id, url}]},..] + window.postMessage({'function': 'openTabGroups', 'tabGroups': tabGroupsToOpen, + 'newWin': newWin}); + } + } + + groupAndPosition() { + // Topic node fn to (re)group open tabs and put them in correct order + + if (!this.isTopic() || (GroupingMode != 'TABGROUP')) return; + let tabInfo = []; + const myWin = this.windowId; + const myTG = this.tabGroupId; + this.childIds.forEach(id => { + const node = AllNodes[id]; + if (!node.tabId) return; + this.tabGroupId = myTG || node.tabGroupId; // tab might be moved to new TG/win + this.windowId = myWin || node.windowId; + const index = node?.expectedTabIndex() || 0; + tabInfo.push({'nodeId': id, 'tabId': node.tabId, 'tabIndex': index}); + }); + window.postMessage({'function': 'groupAndPositionTabs', 'tabGroupId': this.tabGroupId, + 'windowId': this.windowId, 'tabInfo': tabInfo, + 'groupName': this.topicName(), 'topicId': this.id, + }); + } + + putInGroup() { + // wrap this one nodes tab in a group + if (!this.tabId || !this.windowId || (GroupingMode != 'TABGROUP')) return; + const groupName = this.isTopic() ? this.topicName() : AllNodes[this.parentId]?.topicName(); + const groupId = this.isTopic() ? this.id : AllNodes[this.parentId]?.id; + const tgId = this.tabGroupId || AllNodes[this.parentId]?.tabGroupId; + window.postMessage({'function': 'groupAndPositionTabs', 'tabGroupId': tgId, + 'windowId': this.windowId, 'tabInfo': [{'nodeId': this.id, 'tabId': this.tabId, 'tabIndex': this.tabIndex}], + 'groupName': groupName, 'topicId': groupId,}); + } + + closeTab() { + // Close tabs associated w this node + if (this.tabId) + window.postMessage({'function': 'closeTab', 'tabId': this.tabId}); + this.childIds.forEach(id => { + const node = AllNodes[id]; + node.closeTab(); + }); + } + + updateTabGroup() { + // set TG in browser to appropriate name/folded state + let rsp; + if (this.tabGroupId && this.isTopic()) + rsp = callBackground({'function': 'updateGroup', 'tabGroupId': this.tabGroupId, + 'collapsed': this.folded, 'title': this.topicName()}); + return rsp; + } + + static ungroupAll() { + // user has changed from TABGROUP to NONE, tell background to ungroup all BT tabs + const tabIds = AllNodes.flatMap(n => n.tabId ? [n.tabId] : []); + if (tabIds.length) + if (confirm('Also ungroup open tabs?')) + callBackground({'function': 'ungroup', 'tabIds': tabIds}); + } + + groupOpenChildren() { + // used by groupAll, below, and after an undoDelete for individual node + if (this.hasOpenChildren()) { + const openTabIds = this.childIds.flatMap( + c => AllNodes[c].tabId ? [AllNodes[c].tabId] : []); + window.postMessage({ + 'function': 'moveOpenTabsToTG', 'groupName': this.displayTopic, + 'tabIds': openTabIds, 'windowId': this.windowId + }); + } + } + + static groupAll() { + // user has changed from NONE to TABGROUP, tell background to group all BT tabs + AllNodes.forEach(n => n.groupOpenChildren()); + } + + /*** + * + * Org suppport + * + ***/ + + orgDrawers() { + // generate any required drawer text + let drawerText = ""; + if (this.drawers) { + const drawers = Object.keys(this.drawers); + const reg = /:([\w-]*):(.*)$/gm; // regex to grab prop and its value from each line + let hits, ptext; + for (const drawer of drawers) { + drawerText += " :" + drawer + ":\n"; + ptext = this.drawers[drawer]; // of the form ":prop: val\n + + while (hits = reg.exec(ptext)) { + // Iterate thru properties handling VISIBILITY + if ((drawer == "PROPERTIES") && (hits[1] == "VISIBILITY")) + { // only if needed + if (this.folded) drawerText += " :VISIBILITY: folded\n"; + } + else + drawerText += ` :${hits[1]}: ${hits[2]}\n`; + } + drawerText += " :END:\n"; + } + } + if (this.childIds.length && this.folded && (!this.drawers || !this.drawers.PROPERTIES)) + //need to add in the PROPERTIES drawer if we need to store the nodes folded state + drawerText += " :PROPERTIES:\n :VISIBILITY: folded\n :END:\n"; + // finally, check to see if props is empty, otherwise return + return (drawerText == ' :PROPERTIES:\n :END:\n') ? "" : drawerText; + } + + orgTags(current) { + // insert any tags padded right + if (this.tags.length == 0) return ""; + const width = 77; // default for right adjusted tags + let tags = ":"; + for (const tag of this.tags) { + tags += tag + ":"; + } + const padding = Math.max(width - current.length - tags.length, 1); + return " ".repeat(padding) + tags; + } + + + orgText() { + // Generate org text for this node + let outputOrg = ""; + outputOrg += "*".repeat(this._level) + " "; + outputOrg += this._keyword ? this._keyword+" " : ""; // TODO DONE etc + outputOrg += this.title; + outputOrg += this.orgTags(outputOrg) + "\n"; // add in any tags + outputOrg += this.planning; // add in any planning rows + outputOrg += this.orgDrawers(); // add in any drawer text + outputOrg += this._text ? (this._text + "\n") : ""; + + return outputOrg; + } + + orgTextwChildren() { + // Generate org text for this node and its descendents + let outputOrg = this.orgText(); + this.childIds.forEach(function(id) { + if (!AllNodes[id]) return; + let txt = AllNodes[id].orgTextwChildren(); + outputOrg += txt.length ? "\n" + txt : ""; // eg BTLinkNodes might not have text + }); + return outputOrg; + } + + static generateOrgFile() { + // iterate thru nodes to do the work + let orgText = configManager.metaPropertiesToString(); + + // find and order the top level nodes according to table position + const topNodes = AllNodes.filter(node => node && !node.parentId); + topNodes.sort(function(a,b) { + const eltA = $(`tr[data-tt-id='${a.id}']`)[0]; + const eltB = $(`tr[data-tt-id='${b.id}']`)[0]; + const posA = eltA ? eltA.rowIndex : Number.MAX_SAFE_INTEGER; + const posB = eltB ? eltB.rowIndex : Number.MAX_SAFE_INTEGER; + return (posA - posB); + }); + + // iterate on top level nodes, generate text and recurse + topNodes.forEach(function (node) { + orgText += node.orgTextwChildren() + "\n"; + }); + return orgText.slice(0, -1); // take off final \n + } + + /*** + * + * Utility functions + * + ***/ + + + static _orgTextToHTML(txt, keyword = "") { + // convert text of form "asdf [[url][label]] ..." to "asdf label ..." + + const regexStr = "\\[\\[(.*?)\\]\\[(.*?)\\]\\]"; // NB non greedy + const reg = new RegExp(regexStr, "mg"); + let outputStr = txt; + let hits = reg.exec(outputStr); + if (hits) { + const h2 = (hits[2]=="undefined") ? hits[1] : hits[2]; + if (hits[1].indexOf('id:') == 0) // internal org links get highlighted, but not as hrefs + outputStr = outputStr.substring(0, hits.index) + + "" + h2 + "" + + outputStr.substring(hits.index + hits[0].length); + else + outputStr = outputStr.substring(0, hits.index) + + "" + keyword + h2 + "" + + outputStr.substring(hits.index + hits[0].length); + } else { + outputStr = keyword + outputStr; + } + return outputStr; + } + + countOpenableTabs() { + // used to warn of opening too many tabs and show appropriate row action buttons + let childCounts = this.childIds.map(x => AllNodes[x].countOpenableTabs()); + + const me = (this.URL && !this.tabId) ? 1 : 0; + + let n = 0; + if (childCounts.length) + n = childCounts.reduce((accumulator, currentValue) => accumulator + currentValue); + + return n + me; + } + + countClosableTabs() { + // used to warn of opening too many tabs and show appropriate row action buttons + let childCounts = this.childIds.map(x => AllNodes[x].countClosableTabs()); + + const me = (this.tabId) ? 1 : 0; + + let n = 0; + if (childCounts.length) + n = childCounts.reduce((accumulator, currentValue) => accumulator + currentValue); + + return n + me; + } + + countOpenableWindows() { + // used to warn of opening too many windows + let childCounts = this.childIds.map(x => AllNodes[x].countOpenableWindows()); + + // I'm a window if I have URL containing children + const me = this.childIds.some(id => AllNodes[id].URL) ? 1 : 0; + + let n = 0; + if (childCounts.length) + n = childCounts.reduce((accumulator, currentValue) => accumulator + currentValue); + + return n + me; + } + + listOpenableTabs() { + // gather up {nodeId, url} pairs for opening + let me = this.needsTab() ? [{'nodeId': this.id, 'url': this.URL}] : []; + let childrenURLs = this.childIds.flatMap(id => AllNodes[id].listOpenableTabs()); + return [me, ...childrenURLs].flat(); + } + + listOpenTabs() { + // {nodeId, tabId} array for this nodes open pages + let tabs = this._tabId ? [{'nodeId': this.id, 'tabId': this._tabId}] : []; + this.childIds.forEach( id => { + if (AllNodes[id] && AllNodes[id].tabId) + tabs.push({'nodeId': id, 'tabId': AllNodes[id].tabId}); + }); + return tabs; + } + + listOpenableTabGroups() { + // walk containment tree, create [{tabGroupId, windowId, tabGroupTabs: [{nodeId, url}]}, {}] + // where tgid & winid might be null => create new + if (!this.isTopic()) return []; // => not tab group + let tabGroupTabs = this.needsTab() ? [{'nodeId': this.id, 'url': this.URL}] : []; + this.childIds.forEach((id) => { + const node = AllNodes[id]; + if (!node.isTopic() && node.needsTab()) + tabGroupTabs.push({'nodeId': id, 'url': node.URL}); + }); + const me = tabGroupTabs.length ? + {'tabGroupId': this.tabGroupId, 'windowId': this.windowId, 'groupName': this.topicName(), + 'tabGroupTabs': tabGroupTabs} : []; + const subtopics = this.childIds.flatMap(id => AllNodes[id].listOpenableTabGroups()); + return [me, ...subtopics].flat(); + } + + handleNodeMove(newP, index = -1, browserAction = false) { + // move node to parent at index. Parent might be existing just at new index. + // Could be called from drag/drop/keyboard move or from tabs in tabGroups in browser + const oldP = this.parentId; + + // update display class if needed, old Parent might now be empty, new parent is not + if (AllNodes[oldP]?.childIds?.length == 1) + $(`tr[data-tt-id='${oldP}']`).addClass('emptyTopic'); + $(`tr[data-tt-id='${newP}']`).removeClass('emptyTopic'); + + // move the node in parental child arrays + this.reparentNode(newP, index); + + // Update nesting level as needed (== org *** nesting) + const newLevel = newP ? AllNodes[newP].level + 1 : 1; + if (this.level != newLevel) + this.resetLevel(newLevel); + + // if node has open tab we might need to update its tab group/position + if (this.tabId) { + if (newP != oldP) this.tabGroupId = AllNodes[newP].tabGroupId; + if (!browserAction) + AllNodes[newP].tabGroupId ? AllNodes[newP].groupAndPosition() : this.putInGroup(); + // update old P's display node to remove open tg styling + if (!AllNodes[oldP]?.hasOpenChildren()) { + $("tr[data-tt-id='"+oldP+"']").removeClass("opened"); + AllNodes[oldP].setTGColor(null); + AllNodes[oldP].tabGroupId = null; + } + } + } + + indexInParent() { + // Used for tab ordering + if (!this.parentId) return 0; + const parent = AllNodes[this.parentId]; + const thisid = this.id; + let index = (parent.tabId) ? 1 : 0; // if parent has a tab it's at index 0 + parent.childIds.some(id => { + if (id == thisid) return true; // exit when we get to this node + let n = AllNodes[id]; + if (n && n.tabId && (n.windowId == this.windowId)) index++; + }); + return index; + } + + leftmostOpenTabIndex() { + // used for ordering w tabGroups, find min tabIndex + const leftIndex = this.childIds.reduce( + (a, b) => Math.min(a, ((AllNodes[b].windowId == this.windowId) && + (AllNodes[b].tabIndex !== undefined)) + ? AllNodes[b].tabIndex : 999), + 999); + return (leftIndex < 999) ? leftIndex : 0; + } + + expectedTabIndex() { + if (!this.parentId) return 0; + const parent = AllNodes[this.parentId]; + return parent.leftmostOpenTabIndex() + this.indexInParent(); + } + + static generateTopics() { + // Iterate thru nodes and generate array of topics and their nesting + + function topicsForNode(id) { + // recurse over children + if (!AllNodes[id]) return; + if (AllNodes[id].isTopic()) + Topics.push({'name' : AllNodes[id].topicPath, 'level' : AllNodes[id].level}); + for (const nid of AllNodes[id].childIds) + topicsForNode(nid); + } + + // first make sure each node has a unique topicPath + BTNode.generateUniqueTopicPaths(); + Topics = new Array(); + $("#content tr").each(function() { + const id = $(this).attr('data-tt-id'); + if (AllNodes[id]?.parentId == null) + topicsForNode(id); + }); + } + + static findFromTab(tabId) { + // Return node associated w display tab + return AllNodes.find(node => node && (node.tabId == tabId)); + } + + static findFromURLTGWin(url, tg, win) { + // find node from url/TG/Window combo. + // #1 is there a unique BT node w url + // #2 is there a matching url in same TG or window as new tab + const urlNodes = AllNodes.filter(node => node && BTNode.compareURLs(node.URL, url)); + if (urlNodes.length == 0) return null; + if (urlNodes.length == 1) return urlNodes[0]; + for (const node of urlNodes) { + let parentId = node.parentId; + if (parentId && AllNodes[parentId] && AllNodes[parentId].tabGroupId == tg) + return node; + } + for (const node of urlNodes) { + let parentId = node.parentId; + if (parentId && AllNodes[parentId] && AllNodes[parentId].windowId == win) + return node; + } + return urlNodes[0]; // else just use first + } + + static findFromWindow(winId) { + // find topic from win + return AllNodes.find(node => node && node.isTopic() && node.windowId == winId); + } + + static findFromGroup(groupId) { + // find topic from tab group + return AllNodes.find(node => node && node.isTopic() && node.tabGroupId == groupId); + } + + static findOrCreateFromTopicDN(topicDN) { + // Walk down tree of topics from top, finding or creating nodes & tt display nodes + let components = topicDN.match(/.*?:/g); + if (components) components = components.map(c => c.slice(0, -1)); // remove : + const topic = topicDN.match(/:/) ? topicDN.match(/.*:(.*?$)/)[1] : topicDN; + const topTopic = (components && components.length) ? components[0] : topic; + + // Find or create top node + let topNode = AllNodes.find(node => node && node.topicName() == topTopic); + if (!topNode) { + topNode = new BTAppNode(topTopic, null, "", 1); + topNode.createDisplayNode(); + } + + if (!components) return topNode; + + // Remove, now handled first elt, Walk down rest creating as needed + let currentNode = topNode; + components.shift(); + components.forEach((t) => { + let node = currentNode; + currentNode = currentNode.findChild(t); + if (!currentNode) { + currentNode = new BTAppNode(t, node.id, "", node.level + 1); + currentNode.createDisplayNode(); + } + }); + + // finally find or create the leaf node + if (currentNode.findChild(topic)) + return currentNode.findChild(topic); + let newLeaf = new BTAppNode(topic, currentNode.id, "", currentNode.level + 1); + newLeaf.createDisplayNode(); + topNode.redisplay(); // since new nodes created + return newLeaf; + } + +} + + +class BTLinkNode extends BTAppNode { + /*** + * + * Specific link type node for links embedded in para text, not as BT created headlines. + * they show as children in the tree but don't generate a new node when the org file is written out, + * unless they are edited and given descriptive text, + * in which case they are written out as nodes and will be promoted to BTNodes + * the next time the file is read. + * + ***/ + + + constructor(title, parent, text, level, protocol) { + super(title, parent, text, level); + this._protocol = protocol; + } + + set protocol(ptxt) { + this._protocol = ptxt; + } + get protocol() { + return this._protocol; + } + + get text() { + return this._text; + } + + set text(txt) { + // When text is added this link is promoted to a headline. To prevent a dup link + // on next read replace the [[url][ttl]] in parent text with [url][ttl] + // so that it no longer has link syntax. + const parent = AllNodes[this.parentId]; + const nonLink = this._title.slice(1, -1); + parent.text = parent.text.replace(this._title, nonLink); + this._text = txt; + } + + orgTextwChildren() { + // only generate org text for links with added descriptive text + if (this._text.length) + return super.orgTextwChildren(); // call function on super class to write out, + return ""; + } + + HTML() { + // was limited to http links, internal org links will not work but file links do + // if (this.protocol.match('http')) + return super.HTML(); + // return ""; + } + + isTopic() { + // Link nodes are never topics + return false; + } +} + +/*** + * + * Centralized Mappings from MessageType to handler. Array of handler functions + * + ***/ + +const Handlers = { + "launchApp": launchApp, // Kick the whole thing off + "loadBookmarks": loadBookmarks, + "tabActivated": tabActivated, // User nav to Existing tab + "tabJoinedTG" : tabJoinedTG, // a tab was dragged or moved into a TG + "tabLeftTG" : tabLeftTG, // a tab was dragged out of a TG + "tabNavigated": tabNavigated, // User navigated a tab to a new url + "tabOpened" : tabOpened, // New tab opened by bg on our behalf + "tabMoved" : tabMoved, // user moved a tab + "tabPositioned": tabPositioned, // tab moved by extension + "tabClosed" : tabClosed, // tab closed + "saveTabs": saveTabs, // popup save operation - page, tg, window or session + "tabGroupCreated": tabGroupCreated, + "tabGroupUpdated": tabGroupUpdated, + "noSuchNode": noSuchNode, // bg is letting us know we requested action on a non-existent tab or tg +}; + +// Set handler for extension messaging +window.addEventListener('message', event => { + if (event.source != window || event.functionType == 'AWAIT') // async handled below + return; + + //console.count(`BTAppNode received: [${JSON.stringify(event)}]`); + if (Handlers[event.data.function]) { + console.log("BTAppNode dispatching to ", Handlers[event.data.function].name); + Handlers[event.data.function](event.data); + } +}); + +// Function to send a message to the content script and await a response +function callBackground(message) { + return new Promise((resolve) => { + // Listen for the response from the content script + window.addEventListener("message", function handler(event) { + if (event.source !== window || event.type !== 'AWAIT') { + return; + } + + if (event.data && event.data.type === "AWAIT_RESPONSE") { + window.removeEventListener("message", handler); + resolve(event.data.response); + } + }); + + // Send the message to the content script with the page's origin as targetOrigin + message.type = "AWAIT"; + window.postMessage(message, window.origin); + }); +} diff --git a/versions/1.1/app/BTNode.js b/versions/1.1/app/BTNode.js new file mode 100644 index 0000000..4f77d93 --- /dev/null +++ b/versions/1.1/app/BTNode.js @@ -0,0 +1,380 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Base model for a BrainTool node. Keeps track of containment and relationships + * Tree creation functions + * + ***/ + +'use strict' + +const ReservedWords = {'TODO': 'Todo', 'DONE': 'Done'}; // can't name topics these org-mode reserved words + +class BTNode { + constructor(title, parentId = null, firstChild = false) { + + const _title = this.sanitizeTitle(title); + this._id = BTNode.topIndex++; + this._title = _title; + this._parentId = parentId; + this._URL = BTNode.URLFromTitle(_title); + this._displayTopic = BTNode.displayNameFromTitle(_title); + this._childIds = []; + this._topicPath = ''; + if (parentId && AllNodes[parentId]) { + AllNodes[parentId].addChild(this._id, false, firstChild); // add to parent, index not passed, firstChild => front or back + } + // Global instance store. Check existence so staticBase, below, works. + // NB Entries could be overwritten by derived class ctor: + if (typeof AllNodes !== 'undefined') AllNodes[this._id] = this; + } + + sanitizeTitle(title) { + if (ReservedWords[title]) { + alert(`${title} is a reserved word in org-more, using ${ReservedWords[title]} instead`); + return ReservedWords[title]; + } + return title; + } + + get id() { + return this._id; + } + + set title(ttl) { + const _ttl = this.sanitizeTitle(ttl); + this._title = _ttl; + const url = BTNode.URLFromTitle(_ttl); // regenerate URL when title is changed + if (this._URL && !url) throw "URL cannot be set to null from non-null!!!"; + this._URL = url; + this._displayTopic = BTNode.displayNameFromTitle(_ttl); + } + get title() { + return this._title; + } + get URL() { + return this._URL; + } + set URL(url) { + if (this._URL && !url) throw "URL cannot be set to null from non-null!!!"; + this._URL = url; + } + get displayTopic() { + return this._displayTopic; + } + get topicPath() { + return this._topicPath; + } + set parentId(i) { + this._parentId = i; + } + get parentId() { + return this._parentId; + } + + get childIds() { + return this._childIds; + } + addChild(id, index, firstChild = false) { + if (index !== false) + this._childIds.splice(index, 0, parseInt(id)); + else if (firstChild) + this._childIds.unshift(parseInt(id)); + else + this._childIds.push(parseInt(id)); + } + removeChild(id) { + let index = this._childIds.indexOf(parseInt(id)); + if (index > -1) + this._childIds.splice(index, 1); + } + + findChild(childTopic) { + // does this topic node have this sub topic + const childId = this.childIds.find(id => AllNodes[id].topicName() == childTopic); + return childId ? AllNodes[childId] : null; + } + + getDescendantIds() { + // return a list of all the descendant node ids + let descendants = []; + + function dfs(node) { + for (let childId of node._childIds) { + let childNode = AllNodes[childId]; + if (childNode) { + descendants.push(childNode._id); + dfs(childNode); + } + } + } + + dfs(this); + + return descendants; + } + + // only used in isTopic + _hasWebLinks() { + // Calculate on demand since it may change based on node creation/deletion + if (this.URL) return true; + return this.childIds.some(id => AllNodes[id]._hasWebLinks); + } + + isTopic() { + // Is this node used as a topic => has webLinked children + return (this.level == 1) || (!this.URL) || this.childIds.some(id => AllNodes[id]._hasWebLinks); + } + + topicName () { + // return the topic name for this node + if (this.isTopic()) + return (this.URL) ? BTNode.editableTopicFromTitle(this.title) : this.title; + return AllNodes[this.parentId].topicName(); + } + + isTopicTree() { + // Does this nodes url match a pointer to a web .org resource that can be loaded + // NB only use on bt urls for now. any kind oof page can end in .org. + const reg = /.*:\/\/.*braintool.*\/.*\.org/i; + return reg.exec(this._URL) ? true : false; + } + + reparentNode(newP, index = -1) { + // move node from existing parent to new one, optional positional order + function arrayMoveElt(ary, from, to) { + // utility to move element within array + const elt = ary[from]; + ary.splice(from, 1); + ary.splice(to, 0, elt); + } + // throw an exception if newP = oldP + if (newP == this._id) throw "reparentNode: setting self to parent!"; + + const oldP = this.parentId; + if (!oldP && !newP) return; // nothing to do + if (oldP === newP) { + // Special case: new parent is the same as the old parent + const parentNode = AllNodes[oldP]; + const oldIndex = parentNode.childIds.indexOf(this._id); + arrayMoveElt(parentNode.childIds, oldIndex, index); + } else { + // either old or newP might be null, ie at top level + oldP && AllNodes[oldP].removeChild(this.id); + this.parentId = newP; + newP && AllNodes[newP].addChild(this.id, index); + } + } + + static URLFromTitle(title) { + // pull url from title string (which is in org format: "asdf [[url][label]] ...") + // nb find http(s), file:/// and chrome: urls + + const regexStr = "\\[\\[(http.*?|chrome.*?|edge.*?|brave.*?|file.*?)\\]\\[(.*?)\\]\\]"; // NB non greedy + const reg = new RegExp(regexStr, "mg"); + const hits = reg.exec(title); + return hits ? hits[1] : ""; + } + + static displayNameFromTitle(title) { + // Visible title for this node. Pull displayed title out, use url if none + + // first escape any html entities + title = title.replace(/&/g, "&").replace(//g, ">"); + let outputStr = title.replace(/\[\[(.*?)\]\[(.*?)\]\]/gm, (match, $1, $2) => + {return $2 || $1;}); + return outputStr; + } + + static editableTopicFromTitle(title) { + // Just return the [[][title]] part of the title + let match = title.match(/\[\[.*\]\[(.*)\]\]/); + return match ? match[1] : ''; + } + + replaceURLandTitle(newURL, newTitle) { + // replace the [[url][title]] part of the title with newTitle preserving any other text before/after + let match = this.title.match(/\[\[(.*?)\]\[(.*?)\]\]/); + if (match) { + this.title = this.title.replace(match[1], newURL).replace(match[2], newTitle); + } + return this.title; + } + + + static compareURLs(first, second) { + // sometimes I get trailing /'s other times not, also treat http and https as the same, + // also google docs immediately redirect to the exact same url but w /u/1/d instead of /d + // also navigation within window via # anchors is ok, but note not #'s embedded earlier in the url (eg http://sdf/#asdf/adfasdf) + // also maybe ?var= arguments are ok? Not on many sites (eg hn) where there's a ?page=123. + // => .replace(/\?.*$/, "") + + // Define an array of transformations for cases where differnt URLs should be considered the same BTNode + // Each transformation is an array where the first element is the regular expression and the second element is the replacement. + const transformations = [ + [/https/g, "http"], // http and https are the same + [/\/u\/1\/d/g, "/d"], // google docs weirdness + [/\/www\./g, "/"], // www and non-www are the same + [/#(?!.*\/).*$/g, ""], // ignore # anchors that are not at the end of the url + [/\/$/g, ""] // ignore trailing / + ]; + + if (first.indexOf("mail.google.com/mail") >= 0) { + // if its a gmail url need to match exactly + return (first == second); + } else { + // Apply each transformation to the URLs. + for (const [regex, replacement] of transformations) { + first = first.replace(regex, replacement); + second = second.replace(regex, replacement); + } + return (first == second); + } + } + + static findFromTitle(title) { + return AllNodes.find(node => (node && (node.title == title))); + } + + static findFromURL(url) { + return AllNodes.find(node => + (node && + (BTNode.compareURLs(BTNode.URLFromTitle(node.title), url)))); + } + + static topIndex = 1; // track the index of the next node to create, static class variable. + + static processTopicString(topic) { + // Topic string passed from popup can be: topic, topic:TODO, parent:topic or parent:topic:TODO + // return array[topicpath, TODO] + + topic = topic.trim(); + if (ReservedWords[topic]) topic = ReservedWords[topic]; // can't name topics these org-mode reserved words + let match = topic.match(/(.*):TODO/); + if (match) // topicDN:TODO form + return [match[1], "TODO"]; + return[topic, ""]; + } + + static undoStack = []; + static deleteNode(nodeId) { + // Cleanly delete this node + BTNode.undoStack = []; // only one level of undo, so clear each time + + function _deleteNode(nodeId) { + const node = AllNodes[nodeId]; + if (!node) return; + + // recurse to delete children if any. NB copy array cos the remove below changes it + const childIds = node.childIds.slice(); + for (let cid of childIds) { + _deleteNode(cid); + }; + + // Remove from parent + const parent = AllNodes[node.parentId]; + if (parent) + parent.removeChild(nodeId); + + BTNode.undoStack.push(node); + console.log('deleteing id=', nodeId); + delete(AllNodes[nodeId]); + } + _deleteNode(nodeId); + } + + static undoDelete() { + // read back in the undo list + if (!BTNode.undoStack.length) return; + let node, topNode = BTNode.undoStack[BTNode.undoStack.length -1]; + while (BTNode.undoStack.length) { + node = BTNode.undoStack.pop(); + AllNodes[node.id] = node; + if (node.parentId && AllNodes[node.parentId]) + AllNodes[node.parentId].addChild(node.id); + } + return topNode; + } + + fullTopicPath() { + // distinguished name for this node + const myTopic = this.isTopic() ? this.topicName() : ''; + if (this.parentId && AllNodes[this.parentId]) + return AllNodes[this.parentId].fullTopicPath() + ':' + myTopic; + else + return myTopic; + } + + static generateUniqueTopicPaths() { + // same topic can be under multiple parents, generate a unique topic Path for each node + + // First create a map from topics to array of node ids w that topic name + let topics = {}; + let flat = true; + let level = 1; + AllNodes.forEach((n) => { + if (!n) return; + const topicName = n.topicName(); + if (n.isTopic()) { + if (topics[topicName]) { + topics[topicName].push(n.id); + flat = false; + } + else + topics[topicName] = Array(1).fill(n.id); + }}); + + // !flat => dup topic names (<99 to prevent infinite loop + while(!flat && level < 99) { + level++; flat = true; + Object.entries(topics).forEach(([topic, ids]) => { + if (ids.length > 1) { + // replace dups w DN of increasing levels until flat + delete topics[topic]; + ids.forEach(id => { + let tpath = AllNodes[id].topicName(); + let parent = AllNodes[id].parentId; + for (let i = 1; i < level; i++) { + if (parent && AllNodes[parent]) { + tpath = AllNodes[parent].topicName() + ":" + tpath; + parent = AllNodes[parent].parentId; + } + } + if (topics[tpath]) { + topics[tpath].push(id); + flat = false; + } + else + topics[tpath] = Array(1).fill(id); + }); + } + }); + } + + // Now walk thru map and assign unique DN to topic nodes + Object.entries(topics).forEach(([topic, id]) => { + AllNodes[id[0]]._topicPath = topic; + }); + + // Finally set topic for link nodes to parent + AllNodes.forEach(node => { + if (!node.isTopic()) { + if (node.parentId && AllNodes[node.parentId]) + node._topicPath = AllNodes[node.parentId].topicPath; + else + node._topicPath = BTNode.editableTopicFromTitle(node.title); // no parent but not topic, use [[][title part]] + } + }); + } + +} diff --git a/versions/1.1/app/BrainTool.org b/versions/1.1/app/BrainTool.org new file mode 100644 index 0000000..310ca82 --- /dev/null +++ b/versions/1.1/app/BrainTool.org @@ -0,0 +1,119 @@ +#+PROPERTY: BTVersion 1 +#+PROPERTY: BTGroupingMode TABGROUP +#+PROPERTY: BTFavicons ON + +* 👋 Welcome +Feel free to edit or delete these sample Topics +* Topic (eg Projects) +Keep all your project links and notes in one place. + +** Sub-Topic (eg New Kitchen) + :PROPERTIES: + :VISIBILITY: folded + :END: +Expand and collapse the tree from the hierarchy on the left. + +*** eg Contractor Search + :PROPERTIES: + :VISIBILITY: folded + :END: +Topics can be nested to any depth +**** [[https://northriverbuilders.com/][North River Builders]] +Add text notes to saved pages... +Just right! Nile was great to talk to. Really engaged with the project. Good project management software, great portfolio. + +** Knowledge Worker Sample Project + :PROPERTIES: + :VISIBILITY: folded + :END: +An example professional project +*** [[https://slack.com/][Where work happens | Slack]] +Slack channel + +*** [[https://www.atlassian.com/software/jira][Jira | Issue & Project Tracking Software]] +Jira board + +* Areas + :PROPERTIES: + :VISIBILITY: folded + :END: +Non-project areas of life to keep track of. + +** Personal Stuff + :PROPERTIES: + :VISIBILITY: folded + :END: +Maybe keep personal and work stuff separate. + +*** Finance + :PROPERTIES: + :VISIBILITY: folded + :END: +**** [[https://www.bankofamerica.com/][Bank of America - Login]] + BofA site + +**** [[https://login.northwesternmutual.com/login][Login | Northwestern Mutual]] + NMIS investments site + +**** [[https://docs.google.com/spreadsheets/d/1yvidpw2wwS5x2Z1NX8lJ3yVLrdVBW4M3UBlB8PCWl_0/edit#gid=0][Expense tracking]] + +*** Health and Wellness + :PROPERTIES: + :VISIBILITY: folded + :END: + +**** [[https://myhealth.atriushealth.org/Authentication/Login?][MyHealth Online Portal]] +health portal + +**** [[https://aspireap.com/][Aspire]] +Gym schedule + +*** Fun and Entertainment + :PROPERTIES: + :VISIBILITY: folded + :END: + +**** [[https://netflix.com][Netflix queue]] + +**** [[https://open.spotify.com/][Coding playlists]] + +** Work Stuff + :PROPERTIES: + :VISIBILITY: folded + :END: +Areas of responsibility at work. +*** Admin, HR, Budget + :PROPERTIES: + :VISIBILITY: folded + :END: + +**** [[https://www.workday.com/][Workday annual review stuff]] + +**** [[https://www.adp.com/][payroll]] + +*** Team Info + :PROPERTIES: + :VISIBILITY: folded + :END: + +**** [[https://wikipedia.org][team wiki]] +You get the idea... + +* Resources + :PROPERTIES: + :VISIBILITY: folded + :END: +Reference materials and other resources you want to organize and get back to. + +** [[https://braintool.org/topicTrees/][Public Topic Trees]] +BrainTool topics can be saved and shared. See how and some examples. + +** [[https://braintool.org/support/userGuide][BrainTool User Guide]] + +** [[https://braintool.org/posts.html][BrainTool Blog]] + +* 🗄 Archive +Stuff no longer in active use. Move completed projects here. + +* 📝 Scratch +Pages that you save without a Topic will be filed under Scratch diff --git a/versions/1.1/app/bt.css b/versions/1.1/app/bt.css new file mode 100644 index 0000000..941a78f --- /dev/null +++ b/versions/1.1/app/bt.css @@ -0,0 +1,1196 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + +/* First define the basic theme variables */ +:root { + --btRowHeight: 30px; + --btFont: Roboto; + --btControlsFont: Roboto; + --btLetterSpacing: 0.02em; + + --btTopicFontWeight: 900; + --btTopicFontSize: 12px; + --btTopicLineHeight: 14px; + --btIndenterTopOffset: -8px; + + --btPageFontWeight: bold; + --btPageFontSize: 10px; + --btPageLineHeight: 12px; + + --btNoteFontWeight: normal; + --btNoteFontSize: 10px; + --btNoteLineHeight: 12px; + --btNoteLineClamp: 2; /* # of notes lines shown */ + + --btRowButtonMarginTop: auto; + --btFaviconPadding: 5px; + --btTabgroupPadding: 5px 7px 5px 7px !important; + + --btTitleFontSize: 16px; + --btTitleFontWeight: 700; + --btTitleLineHeight: 19px; + + --btSubtitleFontSize: 14px; + --btSubtitleFontWeight: 700; + --btSubtitleLineHeight: 19px; + + --btFootnoteFontSize: 10px; + --btFootnoteFontWeight: 400; + --btFootnoteLineHeight: 12px; + + --btWenkBottom: 8px; + --btWenkPadding: 0.2rem 0.5rem 0.2rem 0.5rem; + --btIndentStepSize: 30px; + + --btSettingsSubtextFontSize: 10px; + --btSettingsSubtextFontWeight: 400; + --btSettingsSubtextLineHeight: 12px; + --btSettingsSubtextFontColor: rgba(255, 255, 255, 0.7); + + --btSlideTextColor: #0C4F6B; + --btSlideHeaderBackground: linear-gradient(#DFE3E2 0%, #C3CBCB 80.6%, #C8CFD0 100%); + + --btMessageBackground: #F598D4; + --btWarningBackground: #F31D1D; + --btTipBackground: #B9CDDF; + + /* Items below differ in the DARK theme */ + + --btColor: #3C4749; /* was #2a3f3e;*/ + --btNoteColor: #636363; /* was #808285; */ + --btTopicBackground: #bcc4c5; + --btPageBackground: #d4dadb; + + /* uncomment to set note background color + --btNoteBackground: #e6e7e8; + */ + + --btInputBackground: white; + --btInputForeground: #2d2d2d; + --btHintColor: #BCBEC0; + --btTooltipBackground: rgba(42, 63, 62, 0.85); + --btTooltipForeground: #fff; + + --btRowSelected: #e0ece0; /* linear-gradient(180deg, #bcc4c5 0%, #d4dadb 50%, #bcc4c5 100%);*/ + --btRowHovered: #AAB6B8; + + --btGeneralBorder: #3f673f; + --btGeneralBorderShadow: #3f673f90; + --btButtonBackground: #7bb07b; + --btButtonHighlight: #3f673f90; + + --btHeaderBackground: linear-gradient(180deg, #F1F1F1 0%, #C3CBCB 52.6%, #C8CFD0 100%); + --btFooterBackground: linear-gradient(#e6e6e7, #c3cbcb, #a8b3b4); + --btEmptyBackground: whitesmoke; + --btRowBorderColor: white; + --btHeaderBorder: #ddd; + + --btControlsBackground: #eeeeee; + --btOptionsBackground: #e5e5e5; + --btControlsHeaderPrimaryColor: #2a3f3e; + --btControlsHeaderSecondaryColor: #435554; + --btControlsDividerColor: #556463; + + --btLinkColor: #2a3f3e; + --btLinkOpenedColor: rgba(5, 122, 159, 0.8); + --btLinkSelectedOpenedColor: #057a9f; + --btSearchResult: var(--btTipBackground); + + --btDialogBackground: #eeeeee; + --btTextAreaBackground: #fff; + --btDrawAttention: var(--btColor); + + --btOpenInfo: #0097C6; +} + +[data-theme="DARK"] { + + --btColor: whitesmoke; /* was #aebabc #b6c3c3; */ + --btNoteColor: ghostwhite; /* #aab6b8 */ + --btTopicBackground: #3d4749; + --btPageBackground: #626A6C; + + --btInputBackground: #2d2d2d; + --btInputForeground: white; + --btHintColor: white; + + --btTooltipForeground: rgba(42, 63, 62, 0.85); + --btTooltipBackground: #fff; + + --btRowSelected: #202020; /* #e5e5e5; */ + --btRowHovered: #525a5c; + --btRowButtonBackground: whitesmoke; + --btButtonHighlight: white; + + --btGeneralBorder: #7bb07b; + --btGeneralBorderShadow: #7bb07b90; + --btButtonBackground: #7bb07b; + + --btHeaderBackground: linear-gradient(180deg, #6C7777 0%, #475354 100%); + --btFooterBackground: linear-gradient(#6c7777, #475354); + --btEmptyBackground: #2d2d2d; + --btRowBorderColor: #2d2d2d; + --btHeaderBorder: #2d2d2d; + + --btControlsBackground: #556463; + --btOptionsBackground: #394847; + --btControlsHeaderPrimaryColor: #ffffff; + --btControlsHeaderSecondaryColor: rgba(255, 255, 255, 0.7); + --btControlsDividerColor: rgba(255, 255, 255, 0.3); + + --btLinkColor: #e0e5e5; + --btLinkOpenedColor: #78c6de; + --btLinkSelectedOpenedColor: #5bc1e1; + --btSearchResult: #2A3F3E; + --btOpenInfo: #7ce0ff; + + --btDialogBackground: linear-gradient(180deg, #566564 0%, #394C4B 100%); + --btTextAreaBackground: #202020; + --btDrawAttention: var(--btButtonBackground); + + /* --btTipBackground: #CE88B5; */ +} + +[data-dense="DENSE"] { + --btNoteLineClamp: 1; + --btRowHeight: 20px; + --btRowButtonMarginTop: -5px; + --btIndenterTopOffset: -4px; + --btFaviconPadding: 1px; + --btTabgroupPadding: 3px 7px 3px 7px !important; + + --btWenkBottom: 3px; + --btWenkPadding: 0.1rem 0.5rem 0.1rem 0.5rem; + --btIndentStepSize: 20px; +} + +[data-size="LARGE"] { + --btTopicFontSize: 16px; + --btTopicLineHeight: 18px; + + --btPageFontSize: 14px; + --btPageLineHeight: 16px; + + --btNoteFontSize: 14px; + --btNoteLineHeight: 16px; + --btRowButtonMarginTop: -1px; + --btRowHeight: 35px; +} +[data-dense="DENSE"][data-size="LARGE"] { + --btRowHeight: 24px; + --btIndenterTopOffset: -4px; +} + +a { + color: var(--btLinkColor); +} +a:link { + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +html { + background: var(--btEmptyBackground); +} +body { + overflow-x: clip; + margin-left: 0px; + margin-right: 0px; +} + +/* + * Header Bar + */ + +#controlsHeader { + z-index: 3; +} + +.topBanner { + width: 100%; + height: 54px; + left: 0px; + top: 0px; + background: var(--btHeaderBackground); + color: var(--btColor); + z-index: 2; +} +#topBar { + position: fixed; +} + +#brain { + margin-left: 10px; + margin-top: 8px; +} +#topBarRight { + position: absolute; + width: 80px; + height: 46px; + right: 0px; + top: 0px; + font-size: 11px; +} +#topBarRight div { + cursor: pointer; + font-family: var(--btFont); +} +#topBarRight img { + position: relative; + top: 3px; + height: 14px; + width: 14px; +} +#settingsButton { + padding-right: 15px; + padding-top: 6px; + padding-bottom: 4px; + padding-left: 5px; +} +#settingsButton.open { + background: rgba(42, 63, 62, 0.9); + color: rgba(255, 255, 255, 0.7); +} +#actionsButton { + padding-right: 15px; + padding-left: 2px; + padding-bottom: 10px; + padding-left: 5px; +} +#actionsButton.open { + background: rgba(42, 63, 62, 0.9); + color: rgba(255, 255, 255, 0.7); +} +#footerHelp.open { + background: rgba(42, 63, 62, 0.9); + color: rgba(255, 255, 255, 0.7); +} + +/* Set version of icon based on theme, light icon for dark theme */ +#settingsIcon.DARK { + content:url("resources/settingsIconLight.png"); +} +#settingsIcon.LIGHT { + content:url("resources/settingsIcon.png"); +} +#actionsIcon.DARK { + content:url("resources/actionsIconLight.png"); +} +#actionsIcon.LIGHT { + content:url("resources/actionsIcon.png"); +} + +#search_entry { + position: absolute; + left: 75px; + top: 12px; + border: none; + width: calc((100% - 190px) * 0.9); + color: var(--btInputForeground); + background: var(--btInputBackground); + border-radius: 12px; + box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.5); + margin-left: 5px; + padding-left: 15px; + height: 25px; +} + +#searchCancelButton { + position: absolute; + cursor: pointer; + top: 17px; + left: 62px; +} +#searchUpBtn { + position: absolute; + cursor: pointer; + top: 12px; + left: calc((100% - 155px) * 0.9 + 72px); +} +#searchDownBtn { + position: absolute; + cursor: pointer; + top: 25px; + left: calc((100% - 155px) * 0.9 + 72px); +} +.searchButton { + display: none; +} + +.hint { + /* alex */ + font-family: var(--btFont); + font-style: var(--btNoteFontWeight); + font-weight: var(--btNoteFontWeight); + font-size: var(--btNoteFontSize); + line-height: var(--btPageLineHeight); + color: var(--btHintColor); + z-index: 3; +} +.searchHint { + position: absolute; + top: 18px; + left: 90px; +} +.hintText { + position: relative; + top: -4px; +} +#newTopLevelTopic { + position: fixed; + top: 54px; + width: 100%; + height: 28px; + text-align: center; + background: #2A3F3E; + color: #85E20E; + cursor: pointer; + font-family: var(--btFont); + font-size: var(--btTopicFontSize); + z-index: 2; +} +#newTopic { + position: relative; + top: 1px; +} +#newTopicNameHint { + position: absolute; + left: 10px; + top: 4px; +} +#nameHint { + position: relative; + top: 3px; + left: -20px; +} +#resizer { + cursor: col-resize; + position: absolute; + top: 15px; + left: calc(50% - 13px); /* Center accounting for font width*/ + font-size: 11px; + opacity: 50%; +} + +/* style when input is active */ +:focus-visible, button:not([disabled]):hover, input[type="text"]:not([disabled]):hover, textarea:not([disabled]):hover, .introNavButton:hover, .introButton:hover { + outline-width: 1px; + outline-color: var(--btButtonHighlight); + outline-style: groove; +} +button:not([disabled]):hover, textarea:not([disabled]):not(:focus):hover, input[type="text"]:not([disabled]):not(:focus):hover { + cursor: pointer; +} +textarea:focus, input[type="text"]:focus { + cursor:text; +} +.highlight { + background-color: #85E20E; +} +.extendedHighlight { + background-color: #85E20EAA; +} +.failed { + background-color: #E59A98 !important; +} + +#trialExpiredWarning { + position: fixed; + top: 79px; + border: solid; + border-width: 1px; + border-color: grey; + z-index: 1; + display: none; +} +#trialExpiredWarning .panelClose { + margin-top: 7px; + right: 10px; +} +#nagHeading { + background-color: #CA4141; + height: 40px; + color: white; + font-family: var(--btFont); + font-size: var(--btTopicFontSize); + text-align: center; + line-height: 40px; +} + +#nagContent { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + background-color: #E2E2E2; +} + +#BTTrialBuddy { + height: 50px; + margin-right: 20px; +} + +#nagText { + flex-grow: 1; + font-family: var(--btFont); + font-size: 12px; + padding-right: 10px; + color: #8E9EA0; +} +/* + * BT Settings + */ + +.settingsActions { + display: none; + position: fixed; + width: 100%; + top: 26px; + padding-top: 15px; + padding-bottom: 15px; + border-top: 1px solid black; + border-bottom: 2px solid black; + background: rgba(43, 63, 62, 0.85); /* rgba(42, 63, 62, 0.95); */ + font-family: var(--btFont); + font-size: 14px; + font-weight: 400; + color: #ffffff; + text-align: center; + z-index: 3; + max-height: 80%; + overflow-y: scroll; +} +#settings { + top: 26px; +} +#actions { + top: 52px; + height: 200px; +} +.settingsActions hr { + margin-top: 7px; + margin-bottom: 7px; + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.4); + margin-left: 25px; + margin-right: 25px; +} +.settingsTitle { + font-size: var(--btTitleFontSize); + font-weight: var(--btTitleFontWeight); + line-height: var(--btTitleLineHeight); + margin-bottom: 15px; +} +.settingsSubtitle { + font-size: var(--btSubtitleFontSize); + font-weight: var(--btSubtitleFontWeight); + line-height: var(--btSubtitleLineHeight); +} +.settingsFootnote { + font-size: var(--btFootnoteFontSize); + font-weight: var(--btFootnoteFontWeight); + line-height: var(--btFootnoteLineHeight); + color: rgba(255, 255, 255, 0.6); + margin-top: 8px; +} +.settingsFootnoteSecondLine { + margin-top: 2px; +} +#importKey { + color: #a0ec5f; + margin-top: 5px; + cursor: pointer; +} +.settingsSubText { + font-size: var(--btSettingsSubtextFontSize); + font-weight: var(--btSettingsSubtextFontWeight); + line-height: var(--btSettingsSubtextLineHeight); + color: var(--btSettingsSubtextFontColor); + margin-top: 15px; + margin-bottom: 15px; +} +#settingsBackups { + margin-top: 10px; + font-size: 12px; +} +#settingsBackups input { + position: relative; + top: 2px; +} +#youShallNotPass { + position: absolute; + width: 96%; + left: 2%; + height: 318px; + top: 200px; + z-index: 5; + background-color: #888; + opacity: 50%; + border: solid; + border-width: 2px; + border-color: #DDD; + border-radius: 5px; + display: none; +} +#warningBanner { + position: relative; + top: 5%; + font-size: 80px; + transform: rotate(-45deg); +} +#help { + position: fixed; + bottom: 26px; + top: auto; + max-height: 60%; + overflow-y: auto; +} +#help .settingsSubtitle { + font-size: 12px; + cursor: pointer; +} +#help .helpAction:hover { + color: #a0ec5f; +} + +#BTVersion { + font-size: 10px; + margin-top: -12px; + margin-bottom: -3px; +} +.settingsInput { + display: flex; + justify-content: space-evenly; + margin-top: 5px; + margin-bottom: 10px; +} +.settingsInput span { + cursor: default; +} +#settings input { + accent-color: #272F30; /* #3C4749; */ +} +#settings input[type="radio"]:not(:checked) { + cursor: pointer; +} +#settingsSubscription button { + height: 35px; + width: 24%; + background: linear-gradient(180deg, #A0EC5F 0%, #55BA00 100%); + border-radius: 5px; + font-size: var(--btSubtitleFontSize); + font-weight: var(--btTitleFontWeight); + color: white; + border: none; + cursor: pointer; +} +#settingsSubscription button.subButton { + background: linear-gradient(180deg, rgba(160, 236, 95, 0.8) 0%, rgba(85, 186, 0, 0.8) 100%) !important; +} +#settingsSubscription button.subButton:hover { + outline-width: 1px; + outline-color: white; + outline-style: groove; +} +#actionsSyncStatus button { + height: 30px; + width: 30%; + background: var(--btFooterBackground); + border-radius: 1px; + font-size: var(--btTopicFontSize); + font-weight: var(--btSubtitleFontWeight); + color: var(--btTooltipBackground); + border: none; +} +#settingsSubscription a { + color: #a0ec5f; +} +.dropdown_text { + color: var(--btTooltipBackground); +} + +/* + * Row Buttons + */ + +#buttonRow { + position: absolute; + padding-right: 0px; + border-radius: 0px; + cursor: pointer; + right: 2px; + height: calc(var(--btRowHeight) - 2px); + display: flex; + align-items: center; +} +.rowButton { + height: 28px; + width: 26px; + border: 1px solid var(--btTopicBackground); + padding: 0px; + margin-left: -2px; + margin-right: -2px; + /* margin-top: var(--btRowButtonMarginTop); */ + background-color: var(--btRowButtonBackground); +} + +#tools { + border-color: white; + filter: invert(1); +} +#tools.moreToolsOn { + border-color: var(--btTopicBackground); + filter: invert(0); +} + +/* + * Card editor + */ + +#editOverlay { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 5; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: #00000040; + backdrop-filter: blur(1px); +} + +#dialog{ + border-radius: 3px; + padding: 10px 30px 20px 30px; + margin-top: 0px; + position: fixed; + background: var(--btDialogBackground); + color: var(--btControlsHeaderPrimaryColor); + display: flex; + flex-direction: column; + gap: 8px; +} + +#grant { + color: var(--btControlsHeaderPrimaryColor); +} + +#dialog textarea { + resize: none; + width: 100%; + border: 1px solid #ddd; + border-radius: 2px; + background: var(--btTextAreaBackground); + color: var(--btColor); + font-size: 11px; + margin-top: 10px; +} +#textText { + flex-grow: 1; +} +#dialog input { + border: 1px solid #ddd; + width: 100%; + border-radius: 2px; + font-family: var(--btControlsFont); + background: var(--btTextAreaBackground); + color: var(--btColor); +} +#dialog #titleText,#topicName { + white-space: nowrap; +} +#titleUrl { + height: 2em; + font-size: 0.6em; + font-style: italic; +} +#distinguishedName { + font-family:var(--btControlsFont); + white-space: nowrap; + text-align: center; + overflow: scroll; + padding-bottom: 10px; +} +#topic { + position: relative; + top: 10px; +} +#dialog button { + border: none; + height: 30px; + width: 40%; + border-radius: 5px; + color: var(--btTooltipForeground); +} +#update { + background-color: #58BA00; + margin-right: -5px; +} +#update:disabled { + background-color: var(--btButtonBackground); +} +#cancel { + background-color: #ff5555c8; +} + +/* + * Intro slides + */ + +#intro { + border-radius: 8px; + border-style: solid; + border-color: var(--btSlideTextColor); + width: calc(100% - 16px); + max-width: 460px; + position: absolute; + top: 8px; + left: 50%; /* Center*/ + transform: translateX(-50%); /* Shift the element back by half its width */ + height: 555px; + background: white; + font-family: var(--btFont); +} +#slide { + width: 80%; + margin-left: 10%; + margin-top: 25px; + color: var(--btSlideTextColor); + text-align: center; + font-size: var(--btSubtitleFontSize); +} +#slide p { + margin-top: 7px; + margin-bottom: 7px; +} +#slideHeader { + background-color: var(--btSlideHeaderBackground); + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +#headerImage { + width: 100%; + height: 100%; + object-fit: contain; + position: relative; + left: -190px; +} +#introClose { + position: absolute; + top: 10px; + right: 10px; + cursor: pointer; +} +#introTitle { + text-align: center; + width: 100%; + position:relative; + top: -45px; + color: #0C4F6B; + font-weight: var(--btTitleFontWeight); + font-size: var(--btTitleFontSize); +} +#introSubtitle { + width: 100%; + text-align: center; + position: relative; + top: -38px; + font-size: 11px; + font-weight: 400; + color: #808285; +} +.introImage { + width: 100%; + height: 100%; + object-fit: contain; +} +.introNavButton { + position: absolute; + width: 35%; + height: 38px; + bottom: 20px; + background: linear-gradient(180deg, #C3CBCB 0%, #C3CBCB 73.44%, #C8CFD0 100%); + border-radius: 10px; + text-align: center; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + letter-spacing: 0.05em; +} + +#introNext { + right: 40px; +} +#introPrev { + left: 40px; +} +#introNext- { + /* caret >> indicator png */ + position: relative; + left: 45px; +} +#introPrev- { + position: relative; + left: -105px; +} + + +#introButtons { + display:flex; + justify-content: center; + align-items: center; + flex-direction: column; + margin-top: 10px; + display: none; +} +.introButton { + width: 235px; + height: 60px; + background: linear-gradient(180deg, #A0EC5F 0%, #79D22E 100%); + border-radius: 15px; + margin-top: 20px; + cursor: pointer; + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-size: 14px; + line-height: 18px; + display: flex; + align-items: center; + justify-content: center; + color: #486543; +} + +#slideFooter { + position: absolute; + cursor: pointer; + bottom: 0px; + left: 25%; + font-size: 15px; + color: #636363; + display: none; +} +#dontShow { + position: relative; + top: 5px; + display: none; +} + +/* + * Key commands under Help + */ + +#keyCommandsTable { + font-size: var(--btSettingsLineHeight); + margin-left: auto; + margin-right: auto; +} +#keyCommands .keyCol { + margin-left: auto; + margin-right: auto; + text-align: left; +} + +#keyCommands .verticalCol { + writing-mode: vertical-rl; + text-align: center; + padding-right: 10px; +} + +.row_divider { + border-bottom: solid; + border-width: 1px; + border-color: grey; +} + +.panelClose { + position: fixed; + cursor: pointer; + right: 15px; + filter: invert(1); +} +#youShallNotPass .panelClose { + top: 277px; +} + +/* split button for import/export */ +.button_text { + font-size: var(--btTopicFontSize); + font-weight: var(--btSubtitleFontWeight); + color: rgba(42, 63, 62, 0.85); +} +.split_image { + opacity: 0.8; + margin-top: 0px; + height: 20px; + width: 20px; +} + +#permissions { + background-color: var(--btNoteColor); + color: var(--btTooltipForeground); +} + +#actions button, #permissions button { + height: 30px; + width: 100%; + background: var(--btFooterBackground); + border-radius: 1px; + border: none; + padding: 5px 5px 5px 8px; +} +#actions button:hover, #permissions button:hover { + background-image: none; + background-color: #a0ec5f; +} +#actionsSyncStatus button { + width: 30%; +} +.dropdown_button { + border-radius: 1px 1px 0px 0px; +} +.dropdown_text { + float: left; + margin-top: 3px; +} +.button_image_wrapper { + float: right; +} + +.dropdown { + display: inline-block; + width: 30%; +} + +.dropdown_content { + display: none; + position: absolute; + text-align: left; + background-color: #eee; + border: solid 1px rgba(42, 63, 62, 0.9); + border-radius: 0px 1px 1px 1px; + z-index: 1; + width: 27%; + transform: translate(-1px, -1px); +} + +.dropdown_content a { + text-decoration: none; + float: left; +} +.dropdown_content label { + float: left; + cursor: pointer; +} +.dropdown_content div { + cursor: pointer; + float: left; + padding: 5px 0px 5px 5px; + width: calc(100% - 5px); +} +.dropdown_content div:hover {background-color: #a0ec5f;} +.dropdown:hover .dropdown_content { + display: block; +} +/* end split button*/ + +#content { + background-color: var(--btInputBackground); +} + +body.waiting * { + cursor: progress !important; +} + +#backgroundLogo { + position: fixed; + width: 100px; + height: 100px; + left: calc(50% - 100px/2); + top: calc(50% - 100px/2 + 0.5px); + z-index: -1; +} + +/* + * Tip Container + */ + +#messageContainer { + display: none; + position: fixed; + bottom: 30px; + width: 100%; + height: 40px; + background: var(--btTipBackground); + font-family: Roboto; + font-weight: 400; + font-size: 11px; + color: #59718C; + text-align: center; + padding-top: 5px; + padding-bottom: 5px; + border-top-width: 1px; + border-top-color: var(--btRowBorderColor); + border-top-style: solid; +} +#message { + padding: 0px 20px 0px 20px; +} +#messageClose { + position: absolute; + cursor: pointer; + top: 7px; + left: 8px; +} +#messageNext { + position: absolute; + cursor: pointer; + top: 7px; + right: 8px; +} +#messageContainer.warning { + background: var(--btWarningBackground); + color: #fff; +} +#messageContainer.message { + background: var(--btMessageBackground); + color: #954073; +} +#messageContainer.tip > img { + filter : hue-rotate(180deg); +} +#message .emoji { + font-size: 18px; + vertical-align: middle; +} +#message a { + color: initial; +} +/* + * Footer + */ + +#footer { + position: fixed; + bottom: 0px; + width: 100%; + height: 30px; + background-image: var(--btFooterBackground); + font-family: Roboto; + font-weight: 400; + line-height: 11px; + color: var(--btColor); +} +#footerSavedIcon { + position: absolute; + width: 16px; + height: 16px; + left: 16px; + top: 6px; +} +#footerSavedInfo { + position: absolute; + height: 11px; + left: 38px; + top: 9px; + font-size: 10px; +} +#footerHelp { + cursor: pointer; + position: absolute; + right: 0px; + font-size: 11px; + + padding-right: 15px; + padding-bottom: 6px; + padding-left: 5px; + margin-top: 2px; +} +#footerHelpIcon { + position: relative; + top: 4px; + left: 3px; +} +#footerHelpIcon.DARK { + content:url("resources/helpLight.png"); +} +#footerHelpIcon.LIGHT { + content:url("resources/help.png"); +} +#footerInfo { + position: absolute; + height: 12px; + left: calc(50% - 138px/2 + 39px); + top: 9px; + font-size: 10px; +} +#footerOpenInfo { + color: var(--btOpenInfo); +} + +.left > img { + height: 12px; + width: 12px; + top: 2px; +} + +img.faviconOn { + display: inline; +} +img.faviconOff { + display: none; +} + +.tabgroup { + padding: var(--btTabgroupPadding); + border-radius: 5px; +} +.tggrey { + background: grey; + color: white !important; +} +.tgblue { + background: rgb(24, 89, 226); + color: white !important; +} +.tgred { + background: rgb(206, 26, 29); + color: white !important; +} +.tgyellow { + background: rgb(247, 155, 6); + color: black !important; +} +.tggreen { + background: green; + color: white !important; +} +.tgpink { + background: rgb(194, 0, 113); + color: white !important; +} +.tgpurple { + background: rgb(142, 28, 240); + color: white !important; +} +.tgcyan { + background: rgb(14, 104, 111); + color: white !important; +} +.tgorange { + background: orange; + color: black !important; +} diff --git a/versions/1.1/app/bt.js b/versions/1.1/app/bt.js new file mode 100644 index 0000000..ed75e8e --- /dev/null +++ b/versions/1.1/app/bt.js @@ -0,0 +1,2469 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Manages the App window UI and associated logic. + * NB Runs in context of the BT side panel, not background BT extension or helper btContent script + * + ***/ + +'use strict' + +const OptionKey = (navigator.userAgentData.platform == "macOS") ? "Option" : "Alt"; + +var InitialInstall = false; +var UpgradeInstall = false; +var GroupingMode = 'TABGROUP'; // or 'NONE' +var MRUTopicPerWindow = {}; // map winId to mru topic +var BTTabId = null; // tabId of BT + +/*** + * + * Opening activities + * + ***/ + +async function launchApp(msg) { + // Launch app w data passed from extension local storage + + configManager.setConfigAndKeys(msg); + InitialInstall = msg.initial_install; + UpgradeInstall = msg.upgrade_install; // null or value of 'previousVersion' + BTTabId = msg.BTTab; // knowledge of self + + BTFileText = msg.BTFileText; + processBTFile(); // create table etc + + // scroll to top + $('html, body').animate({scrollTop: '0px'}, 300); + + // If a backing store file was previously established, re-set it up on this startup + handleStartupFileConnection(); + + // Get BT sub id => premium + // BTId in local store and from org data should be the same. local store is primary + if (msg?.Config?.BTId) { + BTId = msg.Config.BTId; + if (configManager.getProp('BTId') && (BTId != configManager.getProp('BTId'))) + alert(`Conflicting subscription id's found! This should not happen. I'm using the local value, if there are issues contact BrainTool support.\nLocal value:${BTId}\nOrg file value:${configManager.getProp('BTId')}`); + configManager.setProp('BTId', BTId); + } else { + // get from file if not in local storage and save locally (will allow for recovery if lost) + if (configManager.getProp('BTId')) { + BTId = configManager.getProp('BTId'); + } + } + + // check for license, and if sub that its still valid else check to see if we shoudl nag + if (BTId && await checkLicense()) updateLicenseSettings(); + if (!BTId) configManager.potentiallyNag(); + + // show Alt or Option appropriately in visible text (Mac v PC) + $(".alt_opt").text(OptionKey); + + handleInitialTabs(msg.all_tabs, msg.all_tgs); // handle currently open tabs + initializeNotesColumn(); // set up notes column width based on slider saved position + checkCompactMode(); // tweak display if window is narrow + updateStats(); // record numlaunches etc + + if (!configManager.getProp('BTDontShowIntro')) + messageManager.showIntro(); +} + +function updateLicenseSettings() { + // Update UI based on license status + + // valid subscription, toggle from sub buttons to portal link + $('#settingsSubscriptionAdd').hide(); + $('#settingsSubscriptionStatus').show(); + $('#youShallNotPass').hide(); + if (configManager.getProp('BTExpiry') == 8640000000000000) { + // permanant license + $('#otp').show(); + $('#sub').hide(); + $('#portalLink').hide(); + } else { + // time limited sub + $('#otp').hide(); + $('#sub').show(); + $('#renewDate').text(new Date(configManager.getProp('BTExpiry')).toLocaleDateString()); + } + $('.subId').text(BTId); +} + +function updateStats() { + // read and update various useful stats, only called at startup + // NB before gtag calls some stats as for the previous session (eg BTSessionStartTime) + + // Record this launch and software version. also update version shown in ui help. + const BTAppVersion = configManager.getProp('BTAppVersion'); + $("#BTVersion").html(`(Version: ${BTAppVersion})`); + + gtag('event', 'launch_'+BTAppVersion, {'event_category': 'General', 'event_label': BTAppVersion, + 'value': 1}); + if (InitialInstall) { + gtag('event', 'install', {'event_category': 'General', 'event_label': InitialInstall, + 'value': 1}); + configManager.setStat('BTInstallDate', Date.now()); + } + if (UpgradeInstall) + gtag('event', 'upgrade', {'event_category': 'General', 'event_label': UpgradeInstall, + 'value': 1}); + + // Calculate some other stat info (and do some one-time setup of installDate and numSaves) + let stats = configManager.getProp('BTStats'); + if (!stats['BTNumSaves']) configManager.setStat('BTNumSaves', 0); + if (!stats['BTInstallDate']) configManager.initializeInstallDate(); + configManager.incrementStat('BTNumLaunches'); // this launch counts + stats = configManager.getProp('BTStats'); + + const lastSessionMinutes = + parseInt((stats['BTLastActivityTime'] - stats['BTSessionStartTime']) / 60000); + const daysSinceInstall = + parseInt((Date.now() - stats['BTInstallDate']) / 60000 / 60 / 24); + const currentOps = stats['BTNumTabOperations'] || 0; + const currentSaves = stats['BTNumSaves'] || 0; + const lastSessionOperations = currentOps - (stats['BTSessionStartOps'] || 0); + const lastSessionSaves = currentSaves - (stats['BTSessionStartSaves'] || 0); + + // Record general usage summary stats, they don't apply on first install + if (!InitialInstall) { + gtag('event', 'total_launches', {'event_category': 'Usage', 'event_label': 'NumLaunches', + 'value': stats['BTNumLaunches']}); + gtag('event', 'total_saves', {'event_category': 'Usage', 'event_label': 'NumSaves', + 'value': stats['BTNumSaves']}); + gtag('event', 'total_tab_operations', {'event_category': 'Usage', 'event_label': 'NumTabOperations', + 'value': stats['BTNumTabOperations'] || 0}); + gtag('event', 'total_nodes', {'event_category': 'Usage', 'event_label': 'NumNodes', + 'value': AllNodes.length}); + gtag('event', 'num_session_minutes', {'event_category': 'Usage', 'event_label': 'LastSessionMinutes', + 'value': lastSessionMinutes}); + gtag('event', 'num_session_saves', {'event_category': 'Usage', 'event_label': 'LastSessionSaves', + 'value': lastSessionSaves}); + gtag('event', 'num_session_operations', {'event_category': 'Usage', 'event_label': 'LastSessionOperations', + 'value': lastSessionOperations}); + gtag('event', 'total_days_since_install', {'event_category': 'Usage', 'event_label': 'DaysSinceInstall', + 'value': daysSinceInstall}); + } + + // Overwrite data from previous session now that its recorded + configManager.setStat('BTSessionStartTime', Date.now()); + configManager.setStat('BTSessionStartSaves', currentSaves); + configManager.setStat('BTSessionStartOps', currentOps); + + // show message or tip. Reset counter on upgrade => new messages + if (InitialInstall || UpgradeInstall) configManager.setProp('BTLastShownMessageIndex', 0); + messageManager.setupMessages(); +} + +function handleFocus(e) { + // BTTab comes to top + + document.activeElement.blur(); // Links w focus interfere w BTs selection so remove + const deletions = handlePendingDeletions(); // handle any pending deletions + if (!deletions) warnBTFileVersion(e); // check file version, warn if stale, NB if deletions then save will overwrite +} + +async function warnBTFileVersion(e) { + // warn in ui if there's a backing file and its newer than local data or if GDrive auth has expired + + if (!syncEnabled()) return; + messageManager.removeWarning(); + + if (GDriveConnected) { + const lastModifiedTime = await gDriveFileManager.getBTModifiedTime(); + if (!lastModifiedTime) { + const cb = (async e => { gDriveFileManager.renewToken(); messageManager.removeWarning(); }); + messageManager.showWarning("GDrive authorization has expired.
Click here to refresh now, otherwise I'll try when there's something to save.", cb); + return; + } + } + + const warnNewer = await checkBTFileVersion(); + if (warnNewer) { + const cb = (async e => { refreshTable(true); messageManager.removeWarning(); }); + messageManager.showWarning("The synced version of your BrainTool file has newer data.
Click here to refresh or disregard and it will be overwritten on the next save.", cb); + } +} + +function handlePendingDeletions() { + // handle any pending deletions from tab drag oparations (eg tabLeftTG without associated tabJoinedTG) + + const pending = AllNodes.filter(n => n.pendingDeletion); + pending.forEach(n => { + deleteNode(n.id); + }); + return pending.length; +} + +function handleInitialTabs(tabs, tgs) { + // array of {url, id, groupid, windId} passed from ext. mark any we care about as open + + tabs.forEach((tab) => { + const node = BTNode.findFromURL(tab.url); + if (!node) return; + + setNodeOpen(node); // set and propogate open in display + node.tabId = tab.id; + node.windowId = tab.windowId; + node.tabIndex = tab.tabIndex; + MRUTopicPerWindow[node.windowId] = node.topicPath; + if (tab.groupId > 0) { + node.tabGroupId = tab.groupId; + const tg = tgs.find(tg => tg.id == tab.groupId); + if (tg) node.setTGColor(tg.color); + } else { + node.putInGroup(); // not grouped currently, handle creating/assigning as needed on startup + node.groupAndPosition(); + } + if (node.parentId && AllNodes[node.parentId]) { + AllNodes[node.parentId].windowId = node.windowId; + AllNodes[node.parentId].tabGroupId = node.tabGroupId; + } + }); + if (tgs) + tgs.forEach((tg) => { + tabGroupUpdated({'tabGroupId': tg.id, 'tabGroupColor': tg.color, 'tabGroupName': tg.title, + 'tabGroupCollapsed': tg.collapsed, 'tabGroupWindowId': tg.windowId}); + const node = BTAppNode.findFromGroup(tg.id); + if (node) node.groupAndPosition(); + }); + // remember topic per window for suggestions in popup + window.postMessage({'function': 'localStore', 'data': {'mruTopics': MRUTopicPerWindow}}); + updateStatsRow(); +} + + +function brainZoom(iteration = 0) { + // iterate thru icons to swell the brain + + const iterationArray = ['01','02', '03','04','05','06','07','08','09','10','05','04', '03','02','01']; + const path = '../extension/images/BrainZoom'+iterationArray[iteration]+'.png'; + + if (iteration == iterationArray.length) { + $("#brain").attr("src", "../extension/images/BrainTool128.png"); + return; + } + $("#brain").attr("src", path); + const interval = (iteration <= 4 ? 150 : 50); + setTimeout(function() {brainZoom(++iteration);}, interval); + +} + +/*** + * + * Table handling + * + ***/ + +var ButtonRowHTML; +var Topics = new Array(); // track topics for future tab assignment +var BTFileText = ""; // Global container for file text +var OpenedNodes = []; // attempt to preserve opened state across refresh + + +async function refreshTable(fromStore = false) { + // Clear current state and redraw table. Used after an import or on manual GDrive refresh request + // Needed to regenerate the tabletree structures + + // First check to make sure we're not clobbering a pending write, see fileManager. + if (savePendingP()) { + alert('A save is currently in process, please wait a few seconds and try again'); + return; + } + $('body').addClass('waiting'); + + // Remember window opened state to repopulate later + OpenedNodes = AllNodes.filter(n => (n.tabId || n.tabGroupId)); + + BTNode.topIndex = 1; + AllNodes = []; + + // Either get BTFileText from file or use local copy. If file then await its return + try { + if (fromStore) + await getBTFile(); + processBTFile(); + } + catch (e) { + console.warn('error in refreshTable: ', e.toString()); + throw(e); + } +} + + +function generateTable() { + // Generate table from BT Nodes + var outputHTML = ""; + AllNodes.forEach(function(node) { + if (!node) return; + outputHTML += node.HTML(); + }); + outputHTML += "
"; + return outputHTML; +} + + +function processBTFile(fileText = BTFileText) { + // turn the org-mode text into an html table, extract Topics + + // First clean up from any previous state + BTNode.topIndex = 1; + AllNodes = []; + + try { + parseBTFile(fileText); + } + catch(e) { + alert('Could not process BT file. Please check it for errors and restart'); + $('body').removeClass('waiting'); + throw(e); + } + + var table = generateTable(); + /* for some reason w big files jquery was creating
content so using pure js + var container = $("#content"); + container.html(table); + */ + var container = document.querySelector('#content'); + container.innerHTML = table; + + $(container).treetable({ expandable: true, initialState: 'expanded', indent: 30, + animationTime: 250, onNodeCollapse: nodeCollapse, + onNodeExpand: nodeExpand}, true); + + BTAppNode.generateTopics(); + + // Let extension know about model + window.postMessage({'function': 'localStore', 'data': {'topics': Topics}}); + window.postMessage({'function': 'localStore', 'data': {'BTFileText': BTFileText}}); + + // initialize ui from any pre-refresh opened state + OpenedNodes.forEach(oldNode => { + const node = BTNode.findFromTitle(oldNode?.title); + if (!node) return; + $("tr[data-tt-id='"+node.id+"']").addClass("opened"); + node.tabId = oldNode.tabId; + node.windowId = oldNode.windowId; + node.tabGroupId = oldNode.tabGroupId; + node.tabIndex = oldNode.tabIndex; + if (oldNode.tgColor) node.setTGColor(oldNode.tgColor); + if (node.parentId && AllNodes[node.parentId] && node.windowId) { + AllNodes[node.parentId].windowId = node.windowId; + //AllNodes[node.parentId].tabGroupId = node.tabGroupId; + } + }); + + initializeUI(); + // Give events from init time to process + setTimeout(function () { + AllNodes.forEach(function(node) { + if (node?.folded) + $(container).treetable("collapseNodeImmediate", node.id); + }); + }, 200); + BTAppNode.populateFavicons(); // filled in async + + configManager.updatePrefs(); + $('body').removeClass('waiting'); +} + + +// initialize column resizer on startupfunction +function initializeNotesColumn() { + // when window is too small drop the notes column, also used when set in settings + const notesPref = configManager.getProp('BTNotes'); + const percent = parseInt(notesPref); + $("#resizer").css('left', `calc(${percent}% - 13px`); + handleResizer(); +} +let Resizing = false; // set while resizing in progress to avoid processing other events +function handleResizer() { + // Resizer has been dragged, or during set up + const left = $("#resizer").position().left + 13; + const fullWidth = $(window).width(); + const percent = left / fullWidth * 100; + if (percent < 95) { + $("td.left").css("width", percent + "%"); + $("td.right").css("width", (100 - percent) + "%"); + $("td.right").css("display", "table-cell"); + } else { + $("td.right").css("display", "none"); + } +} +$("#resizer").draggable({ + containment: "#newTopLevelTopic", // Restrict dragging within the parent div + axis: "x", + drag: function(e, ui) { + Resizing = true; + handleResizer(); + }, + stop: () => setTimeout(() => { + const left = $("#resizer").position().left + 13; + const fullWidth = $(window).width(); + const percent = left / fullWidth * 100; + configManager.setProp('BTNotes', percent); // save the new width, BTNotes = NOTES, NONOTES or % width + handleResizer(); + Resizing = false; + }, 250), // give time for resize to be processed +}); +// add on entry and on exit actions to highlight the resizer +$("#newTopLevelTopic").on('mouseenter', () => $("#resizer").css("opacity", 1)); +$("#newTopLevelTopic").on('mouseleave', () => $("#resizer").css("opacity", 0.5)); +function displayNotesForSearch() { + // when searching the hit might be in the hidden notes column. check for td.right and show if needed + if ($("td.right").css("display") == "none") { + $("td.right").css("display", "table-cell"); + $("td.left").css("width", "50%"); + $("td.right").css("width", "50%"); + } +} + +function initializeUI() { + //DRY'ing up common event stuff needed whenever the tree is modified + console.log('Initializing UI'); + + $("table.treetable tr").off('mouseenter'); // remove any previous handlers + $("table.treetable tr").off('mouseleave'); + $("table.treetable tr").on('mouseenter', null, buttonShow); + $("table.treetable tr").on('mouseleave', null, buttonHide); + // intercept link clicks on bt links + $("a.btlink").each(function() { + this.onclick = handleLinkClick; + }); + + // double click - show associated window + $("table.treetable tr").off("dblclick"); // remove any previous handler + $("table.treetable tr").on("dblclick", function () { + const nodeId = this.getAttribute("data-tt-id"); + AllNodes[nodeId].showNode(); + }); + + // single click - select row + $("table.treetable tr").off("click"); // remove any previous handler + $("table.treetable tr").on("click", function (e) { + // first check this is not openclose button, can't stop propagation + if (e?.originalEvent?.target?.classList?.contains('openClose')) return; + + // select the new row + $("tr.selected").removeClass('selected'); + $(this).addClass("selected"); + configManager.closeConfigDisplays(); // clicking also closes any open panel + }); + $(document).click(function(event) { + if (event.target.nodeName === 'HTML') { + configManager.closeConfigDisplays(); // clicking background also closes any open panel + } + }); + + makeRowsDraggable(); // abstracted out below + + // Hide loading notice and show sync/refresh buttons as appropriate + $("#loading").hide(); + updateSyncSettings(syncEnabled()); + + // Copy buttonRow's html for potential later recreation (see below) + if ($("#buttonRow")[0]) + ButtonRowHTML = $("#buttonRow")[0].outerHTML; + + updateStatsRow(configManager.getProp('BTTimestamp')); // show updated stats w last save time +} + +function reCreateButtonRow() { + // For some unknown reason very occasionally the buttonRow div gets lost/deleted + console.log("RECREATING BUTTONROW!!"); + const $ButtonRowHTML = $(ButtonRowHTML); + $ButtonRowHTML.appendTo($("#dialog")) +} + +function makeRowsDraggable(recalc = false) { + // make rows draggable. refreshPositions is expensive so we on;y turn it on when the unfold timeout hits + if (!makeRowsDraggable.recalcSet) { + makeRowsDraggable.recalcSet = false; + } + if (makeRowsDraggable.recalcSet && recalc) return; // recalc already on (ie refreshPosition = true) + makeRowsDraggable.recalcSet = recalc; + $("table.treetable tr").draggable({ + helper: function() { + buttonHide(); + const clone = $(this).clone(); + $(clone).find('.btTitle').html('HELKP!'); // empty clone of contents, for some reason + $(clone).css('background-color', '#7bb07b'); + + $("table.treetable tr").off('mouseenter'); // turn off hover behavior during drag + $("table.treetable tr").off('mouseleave'); + return clone; + }, + start: dragStart, // call fn below on start + axis: "y", + scrollSpeed: 5, + scroll: true, + scrollSensitivity: 100, + cursor: "move", + opacity: .75, + refreshPositions: recalc, // needed when topics are unfolded during DnD, set in unfold timeout + stop: function( event, ui ) { + // turn hover bahavior back on and remove classes iused to track drag + $("table.treetable tr").on('mouseenter', null, buttonShow); + $("table.treetable tr").on('mouseleave', null, buttonHide); + $("table.treetable tr").droppable("enable"); + $("tr").removeClass("hovered"); + $("td").removeClass("dropOver"); + $("td").removeClass("dropOver-pulse"); + $("tr").removeClass("dragTarget"); + $("tr").removeClass("ui-droppable-disabled"); + }, + revert: "invalid" // revert when drag ends but not over droppable + }); +} + +function dragStart(event, ui) { + // Called when drag operation is initiated. Set dragged row to be full sized + console.log("dragStart"); + const w = $(this).css('width'); + const h = $(this).css('height'); + ui.helper.css('width', w).css('height', h); + const nodeId = $(this).attr('data-tt-id'); + const node = AllNodes[nodeId]; + + $(this).addClass("dragTarget"); + makeRowsDroppable(node); +} +function makeRowsDroppable(node) { + // make rows droppable + console.log("into makeRowsDroppable"); + + $("table.treetable tr").droppable({ + drop: function(event, ui) { + // Remove unfold timeout + const timeout = $(this).data('unfoldTimeout'); + if (timeout) { + clearTimeout(timeout); + $(this).removeData('unfoldTimeout'); + } + dropNode(event, ui); + }, + over: function(event, ui) { + // highlight node a drop would drop into and underline the potential position, could be at top + $(this).children('td').first().addClass("dropOver"); + + // Add timeout to unfold node if hovered for 1 second + const dropNodeId = $(this).attr('data-tt-id'); + const dropNode = AllNodes[dropNodeId]; + if (dropNode && dropNode.folded) { + $(this).children('td').first().addClass("dropOver-pulse"); // to indicate unfold is coming + const timeout = setTimeout(() => { + dropNode.unfoldOne(); + setTimeout(makeRowsDraggable(true), 1); // refresh draggable positions after unfold + }, 2500); + $(this).data('unfoldTimeout', timeout); + } + }, + out: function(event, ui) { + // undo the above + $(this).children('td').first().removeClass(["dropOver", "dropOver-pulse"]); + + // Remove timeout if hover wasn't long enough for it to fire + const timeout = $(this).data('unfoldTimeout'); + if (timeout) { + clearTimeout(timeout); + $(this).removeData('unfoldTimeout'); + } + } + }); + + // disable droppable for self and all descendants, can't drop into self! + let ids = node.getDescendantIds(); + ids.push(node.id); + ids.forEach(id => { + $(`tr[data-tt-id='${id}']`).droppable("disable"); + }); +} + +function dropNode(event, ui) { + // Drop node w class=dragTarget below node w class=dropOver + // NB if dropOver is expanded target becomes first child, if collapsed next sibling + + const dragTarget = $(".dragTarget")[0]; + if (!dragTarget) return; // no target, no drop + const dragNodeId = $(dragTarget).attr('data-tt-id'); + const dragNode = AllNodes[dragNodeId]; + const dropNode = $($(".dropOver")[0]).parent(); + const dropNodeId = $(dropNode).attr('data-tt-id'); + const dropBTNode = AllNodes[dropNodeId]; + const oldParentId = dragNode.parentId; + + if (dropNodeId && dropBTNode) { + // move node and any associated tab/tgs + moveNode(dragNode, dropBTNode, oldParentId); + } +} + +function moveNode(dragNode, dropNode, oldParentId, browserAction = false) { + // perform move for DnD and keyboard move - drop Drag over Drop + // browserAction => user dragged tab in browser window, not in topic tree + + const treeTable = $("#content"); + let newParent, dragTr; + if (dropNode.isTopic() && !dropNode.folded ) { + // drop into dropNode as first child + dragNode.handleNodeMove(dropNode.id, 0, browserAction); + newParent = dropNode; + treeTable.treetable("move", dragNode.id, dropNode.id); + dragTr = $(`tr[data-tt-id='${dragNode.id}']`)[0]; + $(dragTr).attr('data-tt-parent-id', dropNode.id); + } else { + // drop below dropNode w same parent + const parentId = dropNode.parentId; + if (dragNode.id == parentId) { + console.log ("trying to drop onto self"); + return; + } + const parent = parentId ? AllNodes[parentId] : null; + newParent = parent; + const dropNodeIndex = parent ? parent.childIds.indexOf(parseInt(dropNode.id)) : -1; + let newIndex; + if (oldParentId != parentId) { + newIndex = dropNodeIndex + 1; + } else { + // same parent, a bit tricky. Index dropNode +1 if dragging up, but if dragging down index will shift anyway when we remove it from its current position. + const dragNodeIndex = parent ? parent.childIds.indexOf(parseInt(dragNode.id)) : -1; + newIndex = (dragNodeIndex > dropNodeIndex) ? dropNodeIndex + 1 : dropNodeIndex; + } + + dragNode.handleNodeMove(parentId, parent ? newIndex : -1, browserAction); + if (parentId) { + dragTr = $(`tr[data-tt-id='${dragNode.id}']`)[0]; + const dropTr = $(`tr[data-tt-id='${dropNode.id}']`)[0]; + treeTable.treetable("move", dragNode.id, parentId); + positionNode(dragTr, parentId, dropTr); // sort into position + } else { + treeTable.treetable("insertAtTop", dragNode.id, dropNode.id); + } + } + + // update tree row if oldParent is now childless + if (oldParentId && (AllNodes[oldParentId].childIds.length == 0)) { + const ttNode = $("#content").treetable("node", oldParentId); + $("#content").treetable("unloadBranch", ttNode); + } + + // update the rest of the app, backing store + saveBT(); + BTAppNode.generateTopics(); + window.postMessage({'function': 'localStore', 'data': {'topics': Topics }}); +} + +function positionNode(dragNode, dropParentId, dropBelow) { + // Position dragged node below the dropbelow element under the parent + // NB treetable does not support this so we need to use this sort method + const newPos = $("tr").index(dropBelow); + const treeTable = $("#content"); + const treeParent = treeTable.treetable("node", dropParentId); + const dropNode = dropBelow[0]; + $(dragNode).attr('data-tt-parent-id', dropParentId); + function compare(a,b) { + if (a { + saveBT(true, false); // passing in saveLocal=true to just remember fold locally + rememberFold.fastWriteTimer = null + }, 1000); + + if (!rememberFold.writeTimer) + rememberFold.writeTimer = + setTimeout(() => { + saveBT(false, false); + rememberFold.writeTimer = null + }, 1*60*1000); +} + +function nodeExpand() { + const node = AllNodes[this.id]; + const update = node.folded; + node.folded = false; + + // set highlighting based on open child links + if (!node.hasOpenDescendants()) + $(this.row).removeClass('opened'); + + // Update File and browser + if (update) { + rememberFold(); + node.updateTabGroup(); + } + +} + +function nodeCollapse() { + const node = AllNodes[this.id]; + const update = !node.folded; + node.folded = true; + + // if any highlighted descendants highlight node on collapse + if (node.hasOpenDescendants()) + $(this.row).addClass('opened'); + + // Update File and browser, if collapse is not a result of a drag start + if (update) { + rememberFold(); + node.updateTabGroup(); + } +} + +function handleLinkClick(e) { + if (!$(this).hasClass('btlink')) return; // not a bt link + const nodeId = $(this).closest("tr").attr('data-tt-id'); + AllNodes[nodeId].openPage(); + e.preventDefault(); +} + + + +/*** + * + * Handle relayed messages from Content script. Notifications that user has done something, + * or background has done something on our behalf. + * + ***/ + +function tabOpened(data, highlight = false) { + // handle tab open message + + const nodeId = data.nodeId; + const node = AllNodes[nodeId]; + const tabId = data.tabId; + const tabGroupId = data.tabGroupId; + const tabIndex = data.tabIndex; + const windowId = data.windowId; + const parentId = AllNodes[nodeId]?.parentId || nodeId; + const currentParentWin = AllNodes[parentId].windowId; + + node.tabId = tabId; + node.windowId = windowId; + node.tabIndex = tabIndex; + node.opening = false; + AllNodes[parentId].windowId = windowId; + if (tabGroupId) { + AllNodes[parentId].tabGroupId = tabGroupId; + node.tabGroupId = tabGroupId; + } + updateTabIndices(data.indices) // make sure indicies are up to date after change + setNodeOpen(node); + initializeUI(); + tabActivated(data); // also perform activation stuff + + if (highlight) { + const row = $("tr[data-tt-id='"+nodeId+"']"); + row.addClass("hovered", + {duration: 1000, + complete: function() { + row.removeClass("hovered", 1000); + }}); + } + + // Cos of async nature can't guarantee correct position on creation, reorder if we care + if (GroupingMode == 'NONE') return; + if (windowId == currentParentWin) + // we never automatically move tabs between windows + AllNodes[parentId].groupAndPosition(); + else + node.tabGroupId || node.putInGroup(); // don't group w others, just wrap in TG if not already + return; +} + +function tabClosed(data) { + // handle tab closed message, also used by tabNavigated when BT tab is navigated away + + function propogateClosed(parentId) { + // node not open and recurse to parent + if (!parentId) return; // terminate recursion + + const parent = AllNodes[parentId]; + // might have both last child closing but also have grandchildren + if (!parent.hasOpenChildren()) { + parent.windowId = 0; + parent.tabGroupId = 0; + } + + if (parent.hasOpenDescendants()) return; // terminate recursion + const parentElt = $("tr[data-tt-id='"+parentId+"']"); + parentElt.removeClass("opened"); + parentElt.addClass("hovered", + {duration: 1000, + complete: function() { + parentElt.removeClass("hovered", 1000); + }}); + // propogate up tree to dehighlight ancestors as appropriate + propogateClosed(parent.parentId); + }; + + data.indices && updateTabIndices(data.indices) // make sure indicies are up to date after change + const tabId = data.tabId; + const node = BTAppNode.findFromTab(tabId); + if (!node) return; + node.tabId = 0; + node.tabGroupId = 0; + node.tabIndex = 0; + node.windowId = 0; + node.opening = false; + node.navigated = false; + data.tabId && tabActivated(data); + + // update ui and animate parent to indicate change + $("tr[data-tt-id='"+node.id+"']").removeClass("opened", 1000); + propogateClosed(node.parentId); + updateStatsRow(); + + // set parent window and tgids. handle case where tabs r open in multiple windows + if (node.parentId && AllNodes[node.parentId]) { + const parent = AllNodes[node.parentId]; + if (!parent.hasOpenChildren()) + AllNodes[node.parentId].tabGroupId = 0; + else if (parent.openWindowIds().indexOf(parent.windowId) < 0) { + const openNode = parent.findAnOpenNode(); + parent.tabGroupId = AllNodes[openNode].tabGroupId; + parent.windowId = AllNodes[openNode].windowId; + } + } + +} + +function saveTabs(data) { + // iterate thru array of tabsData and save each to BT + // data is of the form: {'function': 'saveTabs', 'saveType':Tab|TG|Window|Session, 'tabs': [], 'note': msg.note, 'close': msg.close} + // tabs: [{'tabId': t.id, 'groupId': t.groupId, 'windowId': t.windowId, 'url': t.url, 'topic': topic, 'title': msg.title, favIconUrl: t.favIconUrl}] + // topic is potentially topic-dn:window##:TGName:TODO + console.log('saveTabs: ', data); + if (data.from == "btwindow") return; // ignore our own messages + const note = data.note; + const close = data.close; + + // Iterate tabs and create btappNodes as needed + const changedTopicNodes = new Set(); + data.tabs.forEach(tab=> { + + // Handle existing node case: update and return + const existingNode = BTAppNode.findFromTab(tab.tabId); + if (existingNode && !existingNode.navigated) { + if (note) { + existingNode.text = note; + existingNode.redisplay(); + } + if (close) existingNode.closeTab(); + return; // already saved, ignore other than making any note update + } + + // Find or create topic, use existingNodes topic if it exists + let topicNode, keyword, topicDN; + if (existingNode) { + tabClosed({"tabId": tab.tabId}); // tab navigated away, clean up + topicNode = AllNodes[existingNode.parentId]; + topicDN = topicNode.topicPath; + } else { + [topicDN, keyword] = BTNode.processTopicString(tab.topic || "📝 Scratch"); + topicNode = BTAppNode.findOrCreateFromTopicDN(topicDN); + } + changedTopicNodes.add(topicNode); + + // Create and populate node + const title = cleanTitle(tab.title); // get rid of unprintable characters etc + const node = new BTAppNode(`[[${tab.url}][${title}]]`, topicNode.id, note || "", topicNode.level + 1); + node.tabId = tab.tabId; node.windowId = tab.windowId; + topicNode.windowId = tab.windowId; + if (tab.groupId > 0) { // groupid = -1 if not set + node.tabGroupId = tab.groupId; + topicNode.tabGroupId = tab.groupId; + } + node.faviconUrl = tab.favIconUrl; node.tabIndex = tab.tabIndex; + if (keyword) node.keyword = keyword; + + // handle display aspects of single node + $("table.treetable").treetable("loadBranch", topicNode.getTTNode(), node.HTML()); + if (close) node.closeTab(); else setNodeOpen(node); // save and close popup operation + node.storeFavicon(); node.populateFavicon(); + MRUTopicPerWindow[node.windowId] = topicDN; // track mru topic per window for popup population + }); + + // update subtree of each changed topic node + changedTopicNodes.forEach(node => { + node.redisplay(); + if (!close) node.groupAndPosition(); + }); + + // update topic list, sync extension, reset ui and save changes. + BTAppNode.generateTopics(); + let lastTopicNode = Array.from(changedTopicNodes).pop(); + window.postMessage({'function': 'localStore', + 'data': { 'topics': Topics, 'mruTopics': MRUTopicPerWindow, 'currentTopic': lastTopicNode?.topicName() || '', 'currentText': note}}); + window.postMessage({'function' : 'brainZoom', 'tabId' : data.tabs[0].tabId}); + + initializeUI(); + saveBT(); +} + + +function tabPositioned(data, highlight = false) { + // handle tab move, currently as a result of an earlier groupAndPosition - see StoreTabs and tabOpened + + const nodeId = data.nodeId; + const node = AllNodes[nodeId]; + const tabId = data.tabId; + const tabGroupId = data.tabGroupId; + const tabIndex = data.tabIndex; + const windowId = data.windowId; + const parentId = AllNodes[nodeId]?.parentId || nodeId; + + node.tabId = tabId; + node.windowId = windowId; + node.tabIndex = tabIndex; + node.opening = false; + AllNodes[parentId].windowId = windowId; + if (tabGroupId) { + AllNodes[parentId].tabGroupId = tabGroupId; + node.tabGroupId = tabGroupId; + } +} + +function tabNavigated(data) { + // tab updated event, could be nav away or to a BT node or even between two btnodes + // if tabs are sticky, tab sticks w original BTnode, as long as: + // 1) it was a result of a link click or server redirect (ie not a new use of the tab like typing in the address bar) or + // 2) its not a nav to a different btnode whose topic is open in the same window + + function stickyTab() { + // Should the tab stay associated with the BT node + //if (configManager.getProp('BTStickyTabs') == 'NOTSTICKY') return false; + if (!transitionData) return true; // single page app or nav within page + if (transitionQualifiers.includes('from_address_bar')) + return false; // implies explicit user nav, nb order of tests important + if (transitionTypes.some(type => ['link', 'reload', 'form_submit'].includes(type))) return true; + if (transitionQualifiers.includes('server_redirect')) return true; + return false; + } + function closeAndUngroup() { + data['nodeId'] = tabNode.id; + tabClosed(data); + callBackground({'function' : 'ungroup', 'tabIds' : [tabId]}); + } + + const tabId = data.tabId; + const tabUrl = data.tabURL; + const groupId = data.groupId; + const windowId = data.windowId; + const tabNode = BTAppNode.findFromTab(tabId); + const urlNode = BTAppNode.findFromURLTGWin(tabUrl, groupId, windowId); + const parentsWindow = urlNode?.parentId ? AllNodes[urlNode.parentId]?.windowId : null; + const transitionData = data.transitionData; + const transitionTypes = transitionData?.transitionTypes || []; + const transitionQualifiers = transitionData?.transitionQualifiers || []; + const sticky = stickyTab(); + + if (tabNode && urlNode && (tabNode == urlNode)) return; // nothing to see here, carry on + + if (tabNode) { + // activity was on managed active tab + windowId && (tabNode.windowId = windowId); + if (!BTNode.compareURLs(tabNode.URL, tabUrl)) { + // if the url on load complete != initial => redirect or nav away + if (tabNode.opening) { + // tab gets created (see tabOpened) then a status complete event gets us here + console.log(`redirect from ${tabNode.URL} to ${tabUrl}`); + tabNode.URL = tabUrl; + } + else { + // Might be nav away from BT tab or maybe the tab sticks with the BT node + if (sticky) { + tabNode.navigated = true; + tabActivated(data); // handles updating localstorage/popup with current topic etc + } + else closeAndUngroup(); + } + } + tabNode.opening = false; + } + + if (urlNode && (parentsWindow == windowId)) { + // nav into a bt node from an open tab, ignore if parent/TG open elsewhere else handle like tab open + if (tabNode && sticky) closeAndUngroup(); // if sticky we won't have closed above but if urlnode is in same window we should + data['nodeId'] = urlNode.id; + tabOpened(data, true); + return; + } + if (urlNode && !parentsWindow && (!tabNode || !sticky)) { + // nav into a bt node from an open tab, set open if not open elsewhere and url has not stuck to stick tabnode + data['nodeId'] = urlNode.id; + tabOpened(data, true); + return; + } + + // Otherwise just a new tab. Take out of BT TG if its in one owned by BT + const tgParent = BTAppNode.findFromGroup(data.groupId); + if (tgParent && !tabNode) + callBackground({'function' : 'ungroup', 'tabIds' : [tabId]}); +} + +function tabActivated(data) { + // user switched to a new tab or win, fill in storage for popup's use and select in ui + + const tabId = data['tabId']; + + if (tabId == BTTabId) { + handleFocus({'reason': 'BTTab activated'}); // special case when the tab is us! + return; + } + + const winId = data['windowId']; + const groupId = data['groupId']; + const node = BTAppNode.findFromTab(tabId); + const winNode = BTAppNode.findFromWindow(winId); + const groupNode = BTAppNode.findFromGroup(groupId); + let m1, m2 = {'windowTopic': winNode ? winNode.topicPath : '', + 'groupTopic': groupNode ? groupNode.topicPath : '', 'currentTabId' : tabId}; + if (node) { + node.topicPath || BTNode.generateUniqueTopicPaths(); + changeSelected(node); // select in tree + m1 = {'currentTopic': node.topicPath, 'currentText': node.text, 'currentTitle': node.displayTopic, 'tabNavigated': node.navigated}; + } + else { + m1 = {'currentTopic': '', 'currentText': '', 'currentTitle': '', 'tabNavigated': false}; + clearSelected(); + } + window.postMessage({'function': 'localStore', 'data': {...m1, ...m2}}); +} + + +function tabGroupCreated(data) { + // TG created update associated topic color as appropriate + + const tgId = data.tabGroupId; + const color = data.tabGroupColor; + const topicId = data.topicId; + const node = BTAppNode.findFromGroup(tgId) || AllNodes[topicId]; + node?.setTGColor(color); +} + +function tabGroupUpdated(data){ + // TG updated update associated topic as appropriate + + const tgId = data.tabGroupId; + const windowId = data.tabGroupWindowId; + const color = data.tabGroupColor; + const name = data.tabGroupName; + const collapsed = data.tabGroupCollapsed; + const node = BTAppNode.findFromGroup(tgId); + const displayNode = node?.getDisplayNode(); + if (!node || !displayNode) return; + node.windowId = windowId; + + if (color) + node.setTGColor(color); + + if (name && (name != node.title)) { + node.title = name; + $(displayNode).find(".btTitle").html(name); + } + if (collapsed === undefined) return; + if (collapsed) $("table.treetable").treetable("collapseNode", node.id); + if (!collapsed) $("table.treetable").treetable("expandNode", node.id); +} + +function tabJoinedTG(data) { + // tab joined TG, update tab and topic nodes as appropriate + // NB Get here when an existing page is opened in its TG as well as page moving between TGs and tabgrouping being turned on from settings. + // known TG but unknown node => unmanaged tab dropped into managed TG => save it to the topic + + if (GroupingMode != 'TABGROUP') return; // don't care + const tabId = data.tabId; + const tgId = data.groupId; + const winId = data.windowId; + const tab = data.tab; + const index = data.tabIndex; + const indices = data.indices; + + let tabNode = BTAppNode.findFromTab(tabId); + const topicNode = BTAppNode.findFromGroup(tgId); + if (!topicNode && !tabNode) return; // don't care + + if (tabNode && !topicNode) { + // settings toggle => update parent w tg info + const tgParent = AllNodes[tabNode.parentId]; + tabNode.windowId = winId; + tabNode.tabGroupId = tgId; + tabNode.pendingDeletion = false; + tgParent.tabGroupId = tgId; + tgParent.windowId = winId; + return; + } + + if (!tabNode) { + // tab dropped into managed TG => save it to the topic + tabNode = new BTAppNode(`[[${tab.url}][${tab.title}]]`, topicNode.id, + "", topicNode.level + 1); + tabNode.tabId = tabId; + tabNode.tabGroupId = tgId; + tabNode.faviconUrl = tab.favIconUrl; + $("table.treetable").treetable("loadBranch", topicNode.getTTNode(), tabNode.HTML()); + tabNode.populateFavicon(); + initializeUI(); + tabActivated(data); // handles setting topic etc into local storage for popup + changeSelected(tabNode); + setNodeOpen(tabNode); + positionInTopic(topicNode, tabNode, index, indices, winId); + return; + } + + // remaining option - tab moved within or between TGs + tabNode.pendingDeletion = false; // no longer pending deletion + tabNode.tabGroupId = tgId; + // if topicNode has multiple open children then redo positioning + if (topicNode.hasOpenChildren() > 1) + positionInTopic(topicNode, tabNode, index, indices, winId); +} + +function tabLeftTG(data) { + // user moved tab out of TG => no longer managed => mark for deletion + // NB don't delete cos might be moving to another TG or its TG might be moving between windows + // Also NB this may arrive after tab has already been moved to another TG, in which case don't mark for deletion + + if (GroupingMode != 'TABGROUP') return; + const tabId = data.tabId; + const tabNode = BTAppNode.findFromTab(tabId); + const groupId = data.groupId; + if (!tabNode || (tabNode.groupId && (tabNode.groupId != groupId))) return; + tabNode.pendingDeletion = true; +} + +function tabMoved(data) { + // tab's position changed, ie tab index in window, or new window. + // NB data.indices maps tabId to index. need to reset globally since moves change other tabs + + const tabId = data.tabId; + const tgId = data.groupId; + let tabNode = BTAppNode.findFromTab(tabId); + const topicNode = BTAppNode.findFromGroup(tgId); + const index = data.tabIndex; + const winId = data.windowId; + const indices = data.indices; + const tab = data.tab; + if (!tabNode && !topicNode) return; // don't care + + if (!tabNode) { + // known TG but unknown node => unmanaged tab dropped into managed TG => save it to the topic + tabNode = new BTAppNode(`[[${tab.url}][${tab.title}]]`, topicNode.id, + "", topicNode.level + 1); + tabNode.tabId = tabId; + tabNode.tabGroupId = tgId; + tabNode.faviconUrl = tab.favIconUrl; + $("table.treetable").treetable("loadBranch", topicNode.getTTNode(), tabNode.HTML()); + tabNode.populateFavicon(); + initializeUI(); + changeSelected(tabNode); + } + + // Now position the node within its topic. + if (topicNode) positionInTopic(topicNode, tabNode, index, indices, winId); +} + +function noSuchNode(data) { + // we requested action on a tab or tg that doesn't exist, clean up + // NB Ideally this would trigger a re-sync of the BT tree but that's a job for later + if (data.type == 'tab') + tabClosed({'tabId': data.id}); + if (data.type == 'tabGroup') + console.log(`No such tab group ${data.id}, should handle this case`); +} + + +// Utility functions for the above + +function positionInTopic(topicNode, tabNode, index, indices, winId) { + // Position tab node under topic node as per tab ordering in browser + + // first update indices and find where tabNode should go under topicNode. + for (let [tabId, tabData] of Object.entries(indices)) { + let n = BTAppNode.findFromTab(tabId); + if (n) n.tabIndex = tabData.index; + } + let dropUnderNode = topicNode; + const leftIndex = topicNode.leftmostOpenTabIndex(); + if (index > leftIndex) { + for (let [tabId, tabData] of Object.entries(indices)) { + if ((tabData.windowId == winId) && (tabData.index == (index - 1))) + dropUnderNode = BTAppNode.findFromTab(tabId); + } + } + if (dropUnderNode?.tabGroupId != tabNode.tabGroupId) return; + + const dispNode = tabNode.getDisplayNode(); + const underDisplayNode = dropUnderNode.getDisplayNode(); + if ($(dispNode).prev()[0] != underDisplayNode) + moveNode(tabNode, dropUnderNode, tabNode.parentId, true); + tabNode.setTGColor(dropUnderNode.tgColor); + tabNode.windowId = winId; + updateTabIndices(indices); +} + +function cleanTitle(text) { + if (!text) return ""; + // NOTE: Regex is from https://stackoverflow.com/a/11598864 + const clean_non_printable_chars_re = /[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u037F-\u0383\u038B\u038D\u03A2\u0528-\u0530\u0557\u0558\u0560\u0588\u058B-\u058E\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08A1\u08AD-\u08E3\u08FF\u0978\u0980\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0C00\u0C04\u0C0D\u0C11\u0C29\u0C34\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5A-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C81\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D01\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5F\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F5-\u13FF\u169D-\u169F\u16F1-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191D-\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7-\u1CFF\u1DE7-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BB-\u20CF\u20F1-\u20FF\u218A-\u218F\u23F4-\u23FF\u2427-\u243F\u244B-\u245F\u2700\u2B4D-\u2B4F\u2B5A-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E3C-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FCD-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA698-\uA69E\uA6F8-\uA6FF\uA78F\uA794-\uA79F\uA7AB-\uA7F7\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FC-\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9E0-\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAA7C-\uAA7F\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F-\uABBF\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE27-\uFE2F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF]/g; + // clean page title text of things that can screw up BT. Currently [] and non printable chars + return text.replace("[", '').replace("]", '').replace(clean_non_printable_chars_re, ''); +} + +function setNodeOpen(node) { + // utility - show as open in browser, propagate upwards as needed above any collapsed nodes + + function propogateOpened(parentId) { + // recursively pass upwards adding opened class if appropriate + if (!parentId) return; // terminate recursion + if ($("tr[data-tt-id='"+parentId+"']").hasClass("collapsed")) + $("tr[data-tt-id='"+parentId+"']").addClass("opened"); + propogateOpened(AllNodes[parentId].parentId); + }; + + const parentId = node.parentId; + $("tr[data-tt-id='"+node.id+"']").addClass("opened"); + AllNodes[parentId] && node.setTGColor(AllNodes[parentId].tgColor); + $("tr[data-tt-id='"+parentId+"']").addClass("opened"); + propogateOpened(parentId); +} + +function clearSelected() { + // utility - unselect tt node if any + const currentSelection = $("tr.selected")[0]; + if (currentSelection) { + $("tr.selected").removeClass('selected'); + const node = $(currentSelection).attr("data-tt-id"); + AllNodes[node]?.unshowForSearch(); + } +} + +function changeSelected(node) { + // utility - make node visible and selected, unselected previous selection + + // Unselect current selection + const currentSelection = $("tr.selected")[0]; + clearSelected(); + if (!node) return; // nothing to select, we're done + + const tableNode = node.getDisplayNode(); + if (!tableNode) return; + if(!$(tableNode).is(':visible')) + node.showForSearch(); // unfold tree etc as needed + currentSelection && $(currentSelection).removeClass('selected'); + $(tableNode).addClass('selected'); + + // Make sure row is visible + const topOfRow = $(node.getDisplayNode()).position().top; + const displayTop = $(document).scrollTop(); + const height = $(window).height(); + if ((topOfRow < displayTop) || (topOfRow > (displayTop + height - 100))) + tableNode.scrollIntoView({block: 'center'}); + $("#search_entry").val(""); // clear search box on nav +} + +function updateTabIndices(indices) { + // hash of tabId:{tabIndex, windowId} sent from background after tabMoved + if (!indices) return; + let tab; + for (let [tabId, tabData] of Object.entries(indices)) { + tab = BTAppNode.findFromTab(tabId); + if (tab) { + tab.tabIndex = tabData.index; + tab.windowId = tabData.windowId; + } + } +} + +/*** + * + * Row Operations + * buttonShow/Hide, Edit Dialog control, Open Tab/Topic, Close, Delete, ToDo + * NB same fns for key and mouse events. + * getActiveNode finds the correct node in either case from event + * + ***/ + +function buttonShow(e) { + // Show buttons to perform row operations, triggered on hover + $(this).addClass("hovered"); + const td = $(this).find(".left"); + + if ($("#buttonRow").index() < 0) { + // Can't figure out how but sometimes after a Drag/drop the buttonRow is deleted + reCreateButtonRow(); + } + + // detach and center vertically on new td + $("#buttonRow").detach().appendTo($(td)); + const offset = $(this).offset().top; + const rowtop = offset + ($(this).hasClass('branch') ? 3 : 2); + + // figure out if tooltips are on and would go off bottom + const tooltips = configManager.getProp('BTTooltips') == 'ON'; + const scrollTop = $(document).scrollTop(); + const top = rowtop - scrollTop; + const windowHeight = $(window).height(); + const bottomGap = windowHeight - top; + if (tooltips && bottomGap < 130) + $("#buttonRow span").removeClass("wenk--left").addClass("wenk--right"); + else if (tooltips) + $("#buttonRow span").removeClass("wenk--right").addClass("wenk--left"); + + // Open/close buttons + const node = getActiveNode(e);open + const topic = node.isTopic() ? node : AllNodes[node.parentId]; + $("#openTab").hide(); + $("#openWindow").hide(); + $("#closeRow").hide(); + if (node && node.countOpenableTabs()){ + $("#openTab").show(); + if (!topic?.hasOpenChildren() || (GroupingMode != 'TABGROUP')) $("#openWindow").show(); // only allow opening in new window if not already in a TG, or not using TGs + } + if (node && node.countClosableTabs()) { + $("#closeRow").show(); + } + + // show expand/collapse if some kids of branch are not open/closed + if ($(this).hasClass("branch")) { + const id = this.getAttribute("data-tt-id"); + const notOpenKids = $("tr[data-tt-parent-id='"+id+"']").not(".opened"); + if (notOpenKids?.length) + $("#expand").show(); + const openKids = $("tr[data-tt-parent-id='"+id+"']").hasClass("opened"); + if (openKids) + $("#closeRow").show(); + } + + // allow adding children on branches or unpopulated branches (ie no links) + if ($(this).hasClass("branch") || !$(this).find('a').length) + $("#addChild").show(); + else + $("#addChild").hide(); + + // only show outdent on non-top level items. don't show it on links (!isTopic) where promoting would put to top level + if ((this.getAttribute("data-tt-parent-id")) && !((node.level == 2) && !node.isTopic())) + $("#outdent").show(); + else + $("#outdent").hide(); + + $("#buttonRow").offset({top: rowtop}); + $("#buttonRow").css("z-index", "0"); + $("#buttonRow").show(); +} + +function buttonHide() { + // hide button to perform row operations, triggered on exit + $(this).removeClass("hovered"); + $("#buttonRow").hide(); + $("#buttonRow").detach().appendTo($("#dialog")); +} + +function toggleMoreButtons(e) { + // show/hide non essential buttons + $("#otherButtons").toggle(100, 'easeInCirc', () => { + $("#tools").toggleClass('moreToolsOn'); + let moreToolsOn = $("#tools").hasClass('moreToolsOn'); + let hint = moreToolsOn ? "Fewer Tools" : "More Tools"; + $("#moreToolsSpan").attr('data-wenk', hint); + configManager.setProp('BTMoreToolsOn', moreToolsOn ? 'ON' : 'OFF'); + }); + if (e) { + e.preventDefault(); // prevent default browser behavior + e.stopPropagation(); // stop event from bubbling up + } + return false; +} + +function editRow(e) { + // position and populate the dialog and open it + const node = getActiveNode(e); + if (!node) return; + const duration = e.duration || 400; + const row = $(`tr[data-tt-id='${node.id}']`)[0]; + const top = $(row).position().top - $(document).scrollTop(); + const bottom = top + $(row).height(); + const dialog = $("#dialog")[0]; + + // populate dialog + const dn = node.fullTopicPath(); + if (dn == node.displayTopic) + $("#distinguishedName").hide(); + else { + $("#distinguishedName").show(); + const upto = dn.lastIndexOf(':'); + const displayStr = dn.substr(0, upto); + $("#distinguishedName").text(displayStr); + // if too long scroll to right side + setTimeout(() => { + const overflow = $("#distinguishedName")[0].scrollWidth - $("#distinguishedName")[0].offsetWidth; + if (overflow > 0) $("#distinguishedName").animate({ scrollLeft: '+='+overflow}, 500); + }, 500); + } + if (node.isTopic()) { + $("#titleUrl").hide(); + $("#titleText").hide(); + $("#topic").show(); + $("#topicName").val($("
").html(node.displayTopic).text()); + node.displayTopic && $("#newTopicNameHint").hide(); + } else { + $("#titleUrl").show(); + $("#titleText").show(); + $("#titleText").val(BTAppNode.editableTopicFromTitle(node.title)); + $("#topic").hide(); + $("#titleUrl").val(node.URL); + } + $("#textText").val(node.text); + $("#update").prop("disabled", true); + + // overlay grays everything out, dialog animates open on top. + $("#editOverlay").css("display", "block"); + const fullWidth = $($("#editOverlay")[0]).width(); + const dialogWidth = Math.min(fullWidth - 66, 600); // 63 = padding + 2xborder == visible width + const height = dialogWidth / 1.618; // golden! + /* + const otherRows = node.isTopic() ? 100 : 120; // non-text area room needed + $("#textText").height(height - otherRows); // notes field fits but as big as possible +*/ + if ((top + height + 140) < $(window).height()) + $(dialog).css("top", bottom+80); + else + // position above row to avoid going off bottom of screen (or the top) + $(dialog).css("top", Math.max(10, top - height + 30)); + + // Animate opening w calculated size + $(dialog).css({display: 'flex', opacity: 0.0, height: 0, width:0}) + .animate({width: dialogWidth, height: height, opacity: 1.0}, + duration, 'easeInCirc', + function () { + $("#textText")[0].setSelectionRange(node.text.length, node.text.length); + e.newTopic ? $("#topicName").focus() : $("#textText").focus(); + }); +} + +$(".editNode").on('input', function() { + // enable update button if one of the texts is edited. Avoid creating new nodes with empty titles + if ($("#topicName").is(":visible") && (!$("#topicName").val())) return; + $("#update").prop('disabled', false); +}); + +$("#editOverlay").on('mousedown', function(e) { + // click on the backdrop closes the dialog + if (e.target.id == 'editOverlay') + { + closeDialog(cancelEdit); + $("#buttonRow").show(100); + } +}); + +function checkCompactMode() { + // Display changes when window is narrow + if ($(window).width() < 400) { + $("#content").addClass('compactMode'); + $("#search").css('left', 'calc((100% - 175px) / 2)'); + $("#searchHint .hintText").css('display', 'none'); + } else { + $("#content").removeClass('compactMode'); + $("#search").css('left', 'calc((100% - 300px) / 2)'); + $("#searchHint .hintText").css('display', 'inline'); } + updateStatsRow(); + initializeNotesColumn(); +} + + +$(window).resize(() => checkCompactMode()); + +function closeDialog(cb = null, duration = 250) { + // animate dialog close and potentially callback when done + const dialog = $("#dialog")[0]; + const height = $(dialog).height(); + $(dialog).css({'margin-left':'auto'}); // see above, resetting to collapse back to center + $(dialog).animate({width: 0, height: 0}, duration, function () { + $("#editOverlay").css("display", "none"); + $(dialog).css({width: '88%', height: height}); // reset for next open + dialog.close(); + if (cb) cb(); + }); +} + +function getActiveNode(e) { + // Return the active node for the event, either hovered (button click) or selected (keyboard) + const tr = (['click', 'mouseenter'].includes(e.type)) ? + $(e.target).closest('tr')[0] : $("tr.selected")[0]; + if (!tr) return null; + const nodeId = $(tr).attr('data-tt-id') || 0; + return AllNodes[nodeId]; +} + +function openRow(e, newWin = false) { + // Open all links under this row in windows per topic + + // First find all AppNodes involved - selected plus children + const appNode = getActiveNode(e); + if (!appNode) return; + + // Warn if opening lots of stuff + const numTabs = appNode.countOpenableTabs(); + if (numTabs > 10) + if (!confirm(`Open ${numTabs} tabs?`)) + return; + + if (appNode.isTopic()) { + $("table.treetable").treetable("expandNode", appNode.id); // unfold + AllNodes[appNode.id].folded = false; + setTimeout(() => appNode.openAll(newWin), 50); + } else + appNode.openPage(newWin); + + $("#openWindow").hide(); + $("#openTab").hide(); + $("#closeRow").show(); +} + +function closeRow(e) { + // close this node's tab or window + const appNode = getActiveNode(e); + if (!appNode) return; + + $("#openWindow").show(); + $("#openTab").show(); + $("#closeRow").hide(); + appNode.closeTab(); + + gtag('event', 'close_row', {'event_category': 'TabOperation'}); + configManager.incrementStat('BTNumTabOperations'); +} + +function escapeRegExp(string) { + // stolen from https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +function deleteRow(e) { + // Delete selected node/row. + const appNode = getActiveNode(e); + if (!appNode) return false; + const kids = appNode.childIds.length && appNode.isTopic(); // Topic determines non link kids + buttonHide(); + + // If children nodes ask for confirmation + if (!kids || confirm('Delete whole subtree?')) { + // Remove from UI and treetable + deleteNode(appNode.id); + } +} + +function deleteNode(id) { + //delete node and clean up + id = parseInt(id); // could be string value + const node = AllNodes[id]; + if (!node) return; + const wasTopic = node.isTopic(); + const openTabs = node.listOpenTabs(); + + function propogateClosed(parentId) { + // update display of all ancestor nodes as needed + if (!parentId) return; + const parent = AllNodes[parentId]; + const openKids = parent.hasOpenChildren(); + const openDescendants = parent.hasOpenDescendants(); + if (!openKids) { + parent.tabGroupId = 0; + parent.windowId = 0; + parent.setTGColor(null) + } + if (!openDescendants) $("tr[data-tt-id='"+parent.id+"']").removeClass("opened"); + // update tree row if now is childless + if (parent.childIds.length == 0) { + const ttNode = $("#content").treetable("node", parent.id); + $("#content").treetable("unloadBranch", ttNode); + $(parent.getDisplayNode()).addClass("emptyTopic"); + } + propogateClosed(parent.parentId); // recurse + } + + // Ungroup and highlight the tab if it's open and the Topic Manager is in side panel, otherwise we leave the TMgr tab + // (good user experience and side effect is to update the tabs badge info + const BTHome = configManager.getProp('BTManagerHome'); + if (node.tabId && (BTHome == 'TAB')) + node.showNode(); + if (openTabs.length) { + const tabIds = openTabs.map(t => t.tabId); + callBackground({'function': 'ungroup', 'tabIds': tabIds}); + } + + $("table.treetable").treetable("removeNode", id); // Remove from UI and treetable + BTNode.deleteNode(id); // delete from model. NB handles recusion to children + + // Update parent display + propogateClosed(node.parentId); + + // if wasTopic remove from Topics and update extension + if (wasTopic) { + BTAppNode.generateTopics(); + window.postMessage({'function': 'localStore', 'data': {'topics': Topics }}); + } + + // Update File + saveBT(); +} + +function updateRow() { + // Update this node/row after edit. + const tr = $("tr.selected")[0] || $("tr.hovered")[0]; + if (!tr) return null; + const nodeId = $(tr).attr('data-tt-id'); + if (!nodeId) return null; + const node = AllNodes[nodeId]; + + // Update Model + const url = $("#titleUrl").val(); + const title = $("#titleText").val(); + const topic = $("#topicName").val(); + if (node.isTopic()) { + const changed = (node.title != topic); + node.title = topic; + if (changed) node.updateTabGroup(); // update browser (if needed) + } else + node.replaceURLandTitle(url, title); + node.text = $("#textText").val(); + + // Update ui + $(tr).find("span.btTitle").html(node.displayTitle()); + $(tr).find("span.btText").html(node.displayText()); + if (node.tgColor) node.setTGColor(node.tgColor); + node.populateFavicon(); // async, will just do its thing + + // Update File + saveBT(); + + // Update extension + BTAppNode.generateTopics(); + window.postMessage({'function': 'localStore', 'data': {'topics': Topics }}); + console.count('BT-OUT: Topics updated to local store'); + + // reset ui + closeDialog(); + initializeUI(); +} + +function toDo(e) { + // iterate todo state of selected node/row (TODO -> DONE -> ''). + const appNode = getActiveNode(e); + if (!appNode) return false; + + appNode.iterateKeyword(); // ask node to update internals + + // Update ui and file + const tr = $(`tr[data-tt-id='${appNode.id}']`); + $(tr).find("span.btTitle").html(appNode.displayTitle()); + if (appNode.tgColor) appNode.setTGColor(appNode.tgColor); + appNode.populateFavicon(); // async, will just do its thing + + // Stop the event from selecting the row and line up a save + e.stopPropagation(); + initializeUI(); + saveBT(); +} + +function promote(e) { + // move node up a level in tree hierarchy + + const node = getActiveNode(e); + if (!node || !node.parentId) return; // can't promote + + // collapse open subtree if any + if (node.childIds.length) + $("#content").treetable("collapseNode", node.id); + + // Do the move + const newParentId = AllNodes[node.parentId].parentId; + node.handleNodeMove(newParentId); + $("table.treetable").treetable("promote", node.id); + + // save to file, update Topics etc + saveBT(); + BTAppNode.generateTopics(); + window.postMessage({'function': 'localStore', 'data': {'topics': Topics }}); +} + +function _displayForEdit(newNode) { + // common from addNew and addChild below + + newNode.createDisplayNode(); + // highlight for editing + const tr = $(`tr[data-tt-id='${newNode.id}']`); + $("tr.selected").removeClass('selected'); + $(tr).addClass("selected"); + + // scrolled into view + const displayNode = tr[0]; + displayNode.scrollIntoView({block: 'center'}); + + // position & open card editor. Set hint text appropriately + const clientY = displayNode.getBoundingClientRect().top + 25; + const dummyEvent = {'clientY': clientY, 'target': displayNode, 'newTopic': true}; + $("#newTopicNameHint").show(); + $("#topicName").off('keyup'); + $("#topicName").on('keyup', () => $("#newTopicNameHint").hide()); + editRow(dummyEvent); +} + +function addNewTopLevelTopic() { + // create new top level item and open edit card + if (Resizing) return; // ignore during column resize + const newNode = new BTAppNode('', null, "", 1); + _displayForEdit(newNode); +} + +function addChild(e) { + // add new child to selected node + + // create child element + const node = getActiveNode(e); + if (!node) return; + const newNode = new BTAppNode('', node.id, "", node.level + 1, true); // true => add to front of parent's children + _displayForEdit(newNode); + + $(node.getDisplayNode()).removeClass("emptyTopic"); + + // Stop the event from selecting the row + e.stopPropagation(); + initializeUI(); +} + +function cancelEdit() { + // delete node if edit cancelled w empty name + + const tr = $("tr.selected")[0]; + if (!tr) return null; + const nodeId = $(tr).attr('data-tt-id') || 0; + const name = AllNodes[nodeId]?.title; + if (!nodeId || name != '') return; + + deleteNode(nodeId); +} + +/*** + * + * Option Processing + * Imports of Bookmarks, org file, tabsOutliner json. Grouping option updates + * + ***/ + +async function processImport(nodeId) { + // an import (bkmark, org, tabsOutliner) has happened => save and refresh + + configManager.closeConfigDisplays(); // close panel + await saveBT(); // save w imported data + refreshTable(); // re-gen treetable display + animateNewImport(nodeId); // indicate success +} + +function importBookmarks() { + // Send msg to result in subsequent loadBookmarks, set waiting status and close options pane + $('body').addClass('waiting'); + window.postMessage({'function': 'getBookmarks'}); +} + +function loadBookmarks(msg) { + // handler for bookmarks_imported received when Chrome bookmarks are push to local.storage + // nested {title: , url: , children: []} + + if (msg.result != 'success') { + alert('Bookmark permissions denied'); + $('body').removeClass('waiting'); + return; + } + + const dateString = getDateString().replace(':', '∷'); // 12:15 => :15 is a sub topic + const importName = "🔖 Bookmark Import (" + dateString + ")"; + const importNode = new BTAppNode(importName, null, "", 1); + + msg.data.bookmarks.children.forEach(node => { + loadBookmarkNode(node, importNode); + }); + gtag('event', 'BookmarkImport', {'event_category': 'Import'}); + + processImport(importNode.id); // see above +} + +function loadBookmarkNode(node, parent) { + // load a new node from bookmark export format as child of parent BTNode and recurse on children + + if (node?.url?.startsWith('javascript:')) return; // can't handle JS bookmarklets + + const title = node.url ? `[[${node.url}][${node.title}]]` : node.title; + const btNode = new BTAppNode(title, parent.id, "", parent.level + 1); + if (btNode.level > 3) // keep things tidy + btNode.folded = true; + + // handle link children, reverse cos new links go on top + node.children.reverse().forEach(n => { + let hasKids = n?.children?.length || 0; + let isJS = n?.url?.startsWith('javascript:') || false; // can't handle JS bookmarklets + if (hasKids || isJS) return; + + const title = n.url ? `[[${n.url}][${n.title}]]` : n.title; + new BTAppNode(title, btNode.id, "", btNode.level + 1); + }); + + // recurse on non-link nodes, nb above reverse was destructive, reverse again to preserve order + node.children.reverse().forEach(node => { + if (!node.children) return; + loadBookmarkNode(node, btNode); + }); +} + +function animateNewImport(id) { + // Helper for bookmark import, draw attention + const node = AllNodes[id]; + if (!node) return; + const element = $(`tr[data-tt-id='${node.id}']`)[0]; + element.scrollIntoView({block: 'center'}); + /* + $('html, body').animate({ + scrollTop: $(element).offset().top + }, 750); +*/ + $(element).addClass("attention", + {duration: 2000, + complete: function() { + $(element).removeClass("attention", 2000); + }}); +} + +function exportBookmarks() { + // generate minimal AllNodes for background to operate on + const nodeList = AllNodes.map(n => { + if (!n) return null; + return {'displayTopic': n.displayTopic, 'URL': n.URL, 'parentId': n.parentId, 'childIds': n.childIds.slice()}; + }); + const dateString = getDateString().replace(':', '∷'); // 12:15 => :15 is a sub topic + window.postMessage({'function': 'localStore', + 'data': {'AllNodes': nodeList, + title: 'BrainTool Export ' + dateString}}); + + // wait briefly to allow local storage too be written before background tries to access + setTimeout(() => window.postMessage({'function': 'exportBookmarks'}), 100); + gtag('event', 'BookmarkExport', {'event_category': 'Export'}); +} + +function groupingUpdate(from, to) { + // grouping has been changed, potentially update open tabs (WINDOW->NONE is ignored) + console.log(`Changing grouping options from ${from} to ${to}`); + if (from == 'TABGROUP' && to == 'NONE') + BTAppNode.ungroupAll(); + if ((from == 'NONE') && (to == 'TABGROUP')) + BTAppNode.groupAll(); +} + +function importSession() { + // Send msg to result in subsequent session save + window.postMessage({'function': 'saveTabs', 'type': 'Session', 'topic': '', 'from':'btwindow'}); +} + +/*** + * + * Search support + * + ***/ +let ReverseSearch = false; +let SearchOriginId = 0; +$("#search_entry").on("keyup", search); +$("#search_entry").on("keydown", searchOptionKey); +$("#search_entry").on("focus", enableSearch); +$("#search_entry").on("focusout", disableSearch); +$("#searchHint").on("click", enableSearch); +function enableSearch(e) { + // activate search mode + // ignore if tabbed into search box from card editor + const editing = ($($("#dialog")[0]).is(':visible')); + if (editing) return; + + $("#search_entry").select(); + $(".searchButton").show(); + $("#searchHint").hide(); + + // Start search from... + let row = (ReverseSearch) ? 'last' : 'first'; + let currentSelection = $("tr.selected")[0] || $('#content').find('tr:visible:'+row)[0]; + SearchOriginId = parseInt($(currentSelection).attr('data-tt-id')); + + // prevent other key actions til search is done + $(document).unbind('keyup'); + e.preventDefault(); + e.stopPropagation(); + + // Initialize cache of displayed order of nodes to search in display order + BTAppNode.setDisplayOrder(); +} + +function disableSearch(e = null) { + // turn off search mode + if (e && e.currentTarget == $("#search")[0]) return; // don't if still in search div + // special handling if tabbed into search box from card editor to allow edit card tabbing + const editing = ($($("#dialog")[0]).is(':visible')); + if (editing) { + e.code = "Tab"; + handleEditCardKeyup(e); + return; + } + + $("#search_entry").removeClass('failed'); + $("#search_entry").val(''); + $(".searchButton").hide(); + $("#searchHint").show(); + + // undo display of search hits + $("span.highlight").contents().unwrap(); + $("span.extendedHighlight").contents().unwrap(); + $("td").removeClass('search searchLite'); + + BTAppNode.redisplaySearchedNodes(); // fix searchLite'd nodes + AllNodes.forEach((n) => n.unshowForSearch()); // fold search-opened nodes back closed + + // redisplay selected node to remove any scrolling, url display etc + const selectedNodeId = $($("tr.selected")[0]).attr('data-tt-id'); + let node, displayNode; + if (selectedNodeId) { + node = AllNodes[selectedNodeId]; + displayNode = node.getDisplayNode(); + node.redisplay(true); + node.shownForSearch = false; + } else { + // reselect previous selection if search failed + node = AllNodes[SearchOriginId || 1]; + displayNode = node.getDisplayNode(); + $(displayNode).addClass('selected'); + displayNode.scrollIntoView({block: 'center'}); + } + + if (ExtendedSearchCB) // clear timeout if not executed + clearTimeout(ExtendedSearchCB); + + // turn back on other key actions. unbind first in cas still set + // reattach only after this keyup, if any, is done + $(document).unbind('keyup'); + setTimeout(()=>$(document).on("keyup", keyUpHandler), 500); + + // Clear cache of displayed order of nodes to search in display order + BTAppNode.resetDisplayOrder(); + + // reset compact mode (ie no notes) which might have changed while showing matching search results + initializeNotesColumn(); +} + +function searchButton(e, action) { + // called from next/prev search buttons. construct event and pass to search + + let event = { + altKey : true, + code : (action == "down") ? "KeyS" : "KeyR", + key : (action == "exit") ? "Enter" : "", + buttonNotKey: true + }; + search(event); + e.preventDefault(); + e.stopPropagation(); + if (action == "exit") // turn back on regular key actions + $(document).on("keyup", keyUpHandler); + + return false; +} +function searchOptionKey(event) { + // swallow keydown events for opt-s/r so they don't show in input. NB keyup is still + // triggered and caught by search below + + if ((event.altKey && (event.code == "KeyS" || event.code == "KeyR" || event.code == "Slash")) || + (event.code == "ArrowDown" || event.code == "ArrowUp")) { + event.stopPropagation(); + event.preventDefault(); + } +} + +let ExtendedSearchCB = null; // callback to perform searchlite +function search(keyevent) { + // called on keyup for search_entry, could be Search or Reverse-search, + // key is new letter or opt-s/r (search for next) or del + // set timeout to run a second pass extendedSearch after initial search hit is found. + + if (keyevent.code == "Escape") { + $("#search_entry").blur(); + return false; + } + + let sstr = $("#search_entry").val(); + let next = false; + if (ExtendedSearchCB) // clear timeout if not executed + clearTimeout(ExtendedSearchCB); + + // are we done? + if (keyevent.key == 'Enter' || keyevent.key == 'Tab') { + keyevent.buttonNotKey || keyevent.stopPropagation(); + keyevent.buttonNotKey || keyevent.preventDefault(); // stop keyHandler from getting it + $("#search_entry").blur(); // will call disableSearch + return false; + } + + // opt-s/r or slash : drop that char code and go to next match + if ((keyevent.altKey && (keyevent.code == "KeyS" || keyevent.code == "KeyR" || keyevent.code == "Slash")) || + (keyevent.code == "ArrowDown" || keyevent.code == "ArrowUp")) { + next = true; + ReverseSearch = (keyevent.code == "KeyR") || (keyevent.code == "ArrowUp"); + keyevent.buttonNotKey || keyevent.stopPropagation(); + keyevent.buttonNotKey || keyevent.preventDefault(); // stop opt key from displaying + } + + // undo effects of any previous hit + $("span.highlight").contents().unwrap(); + $("span.extendedHighlight").contents().unwrap(); + $("td").removeClass('search'); + $("td").removeClass('searchLite'); + + if (sstr.length < 1) return; // don't search for nothing! + + // Find where we're starting from (might be passed in from backspace key handling + let row = (ReverseSearch) ? 'last' : 'first'; + let currentSelection = $("tr.selected")[0] || $('#content').find('tr:visible:'+row)[0]; + let nodeId = keyevent.startId || parseInt($(currentSelection).attr('data-tt-id')); + + let prevNodeId = nodeId; + let node = AllNodes[nodeId]; + if (next || $("#search_entry").hasClass('failed')) { + node.redisplay(); + node = node.nextDisplayNode(ReverseSearch); // find next visible node, forward/reverse w looping + } + + // Do the search starting from node until we find a match or loop back around to where we started + while(node && !node.search(sstr)) { + node = node.nextDisplayNode(ReverseSearch); + if (node.id == prevNodeId) + node = null; + } + + if (node) { + const displayNode = node.getDisplayNode(); + if (prevNodeId != node.id) + AllNodes[prevNodeId].redisplay(); // remove search formating if moving on + $("tr.selected").removeClass('selected'); + $(displayNode).addClass('selected'); + node.showForSearch(); // unfold tree etc as needed + + scrollIntoViewIfNeeded(node.getDisplayNode()); + let highlight = $(displayNode).find("span.highlight")[0]; + if (highlight) { + // make sure hit is visible horizontally. NB scrollIntoView also scrolls vertically, need to reset that + const v = $(document).scrollTop(); + highlight.scrollIntoView({'inline' : 'center'}); + $(document).scrollTop(v); + $(displayNode).find(".left").css("text-overflow", "clip"); + } + + $("#search_entry").removeClass('failed'); + $("td").removeClass('searchLite'); + ExtendedSearchCB = setTimeout(() => extendedSearch(sstr, node), 200); + } else { + $("#search_entry").addClass('failed'); + $("tr.selected").removeClass('selected'); + } + + return (!next); // ret false to prevent entry +} + +function rowsInViewport() { + // Helper for extendedSearch to only search visible rows + function isInViewport(element) { + const rect = element.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + } + + return $("#content tr:visible").filter(function() { + return isInViewport(this); + }) + .map(function() { return $(this).attr("data-tt-id")}) + .get().map((e) => AllNodes[parseInt(e)]); +} + +function scrollIntoViewIfNeeded(element) { + // Helper function to make sure search or nav to item has its row visible but only scroll if needed + const height = $(window).height(); + const topOfRow = $(element).position().top; + const displayTop = $(document).scrollTop(); + if (topOfRow < displayTop) + element.scrollIntoView(true); + if (topOfRow > (displayTop + height - 200)) + element.scrollIntoView(false); +} + +function extendedSearch(sstr, currentMatch) { + // do extended search showing other hits on any visible nodes + + const nodesToSearch = rowsInViewport(); + + nodesToSearch.forEach((n) => { + if (!n || n == currentMatch) return; + n.extendedSearch(sstr); + }); +} + +/*** + * + * Keyboard event handlers + * + ***/ +// prevent default space/arrow key scrolling and element tabbing on table (not in card edit fields) +window.addEventListener("keydown", function(e) { + if ($($("#dialog")[0]).is(':visible')) { + // ignore keydown if card editing. keyup gets event + return; + } + if ($("#search_entry").is(":focus")) return; + if(["ArrowUp","ArrowDown","Space", "Tab", "Enter"].indexOf(e.code) > -1) { + e.preventDefault(); + } + + // up/down nav here to allow for auto repeat + const alt = e.altKey; + const code = e.code; + const navKeys = ["KeyN", "KeyP", "ArrowUp", "ArrowDown"]; + + // n or down arrow, p or up arrow for up/down (w/o alt) + let next, currentSelection = $("tr.selected")[0]; + if (!alt && navKeys.includes(code)) { + if (currentSelection) + next = (code == "KeyN" || code == "ArrowDown") ? + $(currentSelection).nextAll(":visible").first()[0] : // down or + $(currentSelection).prevAll(":visible").first()[0]; // up + else + // no selection => nav in from top or bottom + next = (code == "KeyN" || code == "ArrowDown") ? + $('#content').find('tr:visible:first')[0] : + $('#content').find('tr:visible:last')[0]; + + if (!next) return; + if (currentSelection) $(currentSelection).removeClass('selected'); + $(next).addClass('selected'); + scrollIntoViewIfNeeded(next); + + $("#search_entry").val(""); // clear search box on nav + e.preventDefault(); + e.stopPropagation(); + return; + } +}, false); + +$(document).on("keyup", keyUpHandler); +function keyUpHandler(e) { + // dispatch to appropriate command. NB key up event + + // ignore keys (except nav up/down) if edit dialog is open + const editing = ($($("#dialog")[0]).is(':visible')); + if (editing) { + handleEditCardKeyup(e); + return; + } + + // searchMode takes precidence and is detected on the search box input handler + if ($("#search_entry").is(":focus")) + return; + + const alt = e.altKey; + const code = e.code; + const key = e.key; + const navKeys = ["KeyN", "KeyP", "ArrowUp", "ArrowDown"]; + // This one doesn't need a row selected, alt-z for undo last delete + if (alt && code == "KeyZ") { + undo(); + } + + // escape closes any open config/help/setting panel + if (code === "Escape") configManager.closeConfigDisplays(); + + let next, currentSelection = $("tr.selected")[0]; + // Pageup/down move selection to top visible row, nb slight delay for scroll to finish + if (currentSelection && (code == "PageUp" || code == "PageDown")) { + setTimeout(() => { + let topRow = Array.from($("#content tr")).find(r => r.getBoundingClientRect().y > 60); + $(currentSelection).removeClass('selected'); + $(topRow).addClass('selected'); + }, 100); + } + + // s,r = Search, Reverse-search + if (code == "KeyS" || code == "KeyR" || key == "/") { + ReverseSearch = (code == "KeyR"); + enableSearch(e); + return; + } + + // h, ? = help + if (code == "KeyH" || key == "?") { + if ($('#help').is(':visible') && !$('#keyCommands').is(':visible')) { + configManager.toggleKeyCommands(); + } else { + $('#keyCommands').show(); + configManager.toggleHelpDisplay(); + } + e.preventDefault(); + } + + // digit 1-9, fold all at that level, expand to make those visible + const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; + if (digits.includes(key)) { + const lvl = digits.indexOf(key) + 1; // level requested + const tt = $("table.treetable"); + AllNodes.forEach(function(node) { + if (!tt.treetable("node", node.id)) return; // no such node + if (node?.level < lvl) + tt.treetable("expandNode", node.id); + if (node?.level >= lvl) + tt.treetable("collapseNode", node.id); + }); + rememberFold(); // save to storage + } + + if (!currentSelection) return; + const nodeId = $(currentSelection).attr('data-tt-id'); + const node = AllNodes[nodeId]; + if (!node) return; + + // up(38) and down(40) arrows move + if (alt && (code == "ArrowUp" || code == "ArrowDown")) { + if (node.childIds.length && !node.folded) { + $("#content").treetable("collapseNode", nodeId); + } + // its already below prev so we drop below prev.prev when moving up + const dropTr = (code == "ArrowUp") ? + $(currentSelection).prevAll(":visible").first().prevAll(":visible").first() : + $(currentSelection).nextAll(":visible").first(); + const dropId = $(dropTr).attr('data-tt-id'); + const dropNode = AllNodes[dropId]; + if (dropNode) moveNode(node, dropNode, node.parentId); + currentSelection.scrollIntoView({block: 'nearest'}); + e.preventDefault(); + return; + } + + // enter == open or close. + if (!alt && code == "Enter") { + if (node.childIds.length) { + if (node.hasUnopenDescendants()) + openRow(e); + else + closeRow(e); + } else { + if (node.URL && !node.tabId) + openRow(e); + if (node.tabId) + closeRow(e); + } + } + + // tab == cycle thru expand1, expandAll or collapse a topic node + if (code == "Tab") { + if (node.isTopic()) { + if (node.folded) { + node.unfoldOne(); // BTAppNode fn to unfold one level & remember for next tab + keyUpHandler.lastSelection = currentSelection; + } else { + if (currentSelection == keyUpHandler.lastSelection) { + node.unfoldAll(); // BTAppNode fn to unfold all levels, reset lastSelection so next tab will fold + keyUpHandler.lastSelection = null; + } else { + $("table.treetable").treetable("collapseNode", nodeId); + } + } + rememberFold(); // save to storage + } + e.preventDefault(); + return; + } + + // t = cycle TODO state + if (code == "KeyT") { + toDo(e); + } + + // e = edit + if (code == "KeyE") { + editRow(e); + e.preventDefault(); + } + + // delete || backspace = delete + if (code == "Backspace" || code == "Delete") { + // Find next (or prev if no next) row, delete, then select next + const next = $(currentSelection).nextAll(":visible").first()[0] || + $(currentSelection).prevAll(":visible").first()[0]; + deleteRow(e); + $(next).addClass('selected'); + next.scrollIntoView({block: 'nearest'}); + } + + // opt enter = new child + if (alt && code == "Enter" && node.isTopic()) { + addChild(e); + } + + // opt <- = promote + if (alt && code == "ArrowLeft") { + promote(e); + } + + // <- collapse open node, then nav up tree + if (!alt && code == "ArrowLeft") { + if (node.childIds.length && !node.folded) { + $("table.treetable").treetable("collapseNode", nodeId); + return; + } + if (!node.parentId) return; + next = $(`tr[data-tt-id=${node.parentId}]`)[0]; + $(currentSelection).removeClass('selected'); + $(next).addClass('selected'); + next.scrollIntoView({block: 'nearest'}); + } + + // -> open node, then nav down tree + if (code == "ArrowRight") { + if (node.folded) { + $("table.treetable").treetable("expandNode", nodeId); + return; + } + next = $(currentSelection).nextAll(":visible").first()[0]; + $(currentSelection).removeClass('selected'); + $(next).addClass('selected'); + } + + // space = open tab, w/alt-space => open in new window + if (code === "Space" || code === "KeyW") { + const newWin = alt || code === "KeyW"; + node.openPage(newWin); + e.preventDefault(); + } + +}; + +function handleEditCardKeyup(e) { + // subset of keyUpHandler applicible to card edit dialog, nb keyup event + + const code = e.code; + const alt = e.altKey; + if (code == "Tab") { + // restrain tabbing to within dialog. Button gets focus and then this handler is called. + // so we redirect focus iff the previous focused element was first/last + const focused = $(":focus")[0]; + const first = $($("#topicName")[0]).is(':visible') ? $("#topicName")[0] : $('#titleText')[0]; + if (!focused || !$(focused).hasClass('editNode')) { + // tabbed out of edit dialog, force back in + console.log("setting focus"); + if (!e.shiftKey) // tabbing forward + $(first).focus(); + else + $("#cancel").focus(); + } + e.preventDefault(); + e.stopPropagation(); + return; + } + if (code == "Enter") { + // on enter move focus to text entry box + $("#textText").focus(); + e.preventDefault(); + e.stopPropagation(); + } + if (alt && ["ArrowUp","ArrowDown"].includes(code)) { + // alt up/down iterates rows opening cards + const currentSelection = $("tr.selected")[0]; + const next = (code == "ArrowDown") ? + $(currentSelection).nextAll(":visible").first()[0] : // down + $(currentSelection).prevAll(":visible").first()[0]; // up + if (!next) return; + $(currentSelection).removeClass('selected'); + $(next).addClass('selected'); + next.scrollIntoView({block: 'nearest'}); + e.preventDefault(); + closeDialog(function () {editRow({type: 'internal', duration: 100});}, 100); + } + if (code === "Escape") closeDialog(cancelEdit); // escape out of edit then check need 4 cancel +}; + +function undo() { + // undo last delete + const node = BTNode.undoDelete(); + const parent = AllNodes[node.parentId]; + function updateTree(ttn, btn) { + // recurse as needed on tree update + + btn.displayNode = null; // remove cached html value + $("table.treetable").treetable("loadBranch", ttn || null, btn.HTML()); + if (btn.childIds.length) { + const n = $("table.treetable").treetable("node", btn.id); + btn.childIds.forEach( + (id) => updateTree(n, AllNodes[id])); + } + btn.populateFavicon(); + } + + // Update tree + let n = parent ? $("table.treetable").treetable("node", parent.id) : null; + updateTree(n, node); + $($(`tr[data-tt-id='${node.id}']`)[0]).addClass('selected'); + node.tgColor && node.setTGColor(node.tgColor); + // find nodes topic, either itself or its parent. if tabgrouping is on call topicnode.groupOpenChildren + const topicNode = node.isTopic() ? node : AllNodes[node.parentId]; + if (topicNode && (GroupingMode == 'TABGROUP')) { + topicNode.groupOpenChildren(); + } + + initializeUI(); + saveBT(); + BTAppNode.generateTopics(); + window.postMessage({'function': 'localStore', 'data': {'topics': Topics }}); + +} diff --git a/versions/1.1/app/configManager.js b/versions/1.1/app/configManager.js new file mode 100644 index 0000000..efbcfd7 --- /dev/null +++ b/versions/1.1/app/configManager.js @@ -0,0 +1,468 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Handles configuration/actions/help getting/setting and associated displays. + * Config can come from + * 1) config.js embedded in extension package and passed in msg + * 2) Config obj kept in local storage + * 3) metaProps - Org properties read from bt.org file and stored as properties on AllNodes[] + * 4) stats, listed, but stored in local storage under the BTStats property + * NB BTId is both in meta and local for recovery purposes + * + ***/ +'use strict'; + +const configManager = (() => { + + const Properties = { + 'keys': ['CLIENT_ID', 'API_KEY', 'FB_KEY', 'STRIPE_KEY'], + 'localStorageProps': [ 'BTId', 'BTTimestamp', 'BTFileID', 'BTAppVersion', 'BTGDriveConnected', 'BTStats', + 'BTLastShownMessageIndex', 'BTManagerHome', 'BTStickyTabs', 'BTTheme', 'BTFavicons', + 'BTNotes', 'BTDense', 'BTSize', 'BTTooltips', 'BTGroupingMode', 'BTDontShowIntro', + 'BTExpiry', 'BTBackupsOn', 'BTBackupsList', 'BTMoreToolsOn'], + 'orgProps': ['BTCohort', 'BTVersion', 'BTId'], + 'stats': [ 'BTNumTabOperations', 'BTNumSaves', 'BTNumLaunches', 'BTInstallDate', 'BTSessionStartTime', + 'BTLastActivityTime', 'BTSessionStartSaves', 'BTSessionStartOps', 'BTDaysOfUse'], + }; + let Config, Keys = {CLIENT_ID: '', API_KEY: '', FB_KEY: '', STRIPE_KEY: ''}; + + function setConfigAndKeys(msg) { + // takes message from background/Content script and pulls out settings + Config = msg.Config || {}; + if (!Config['BTStats']) Config['BTStats'] = {}; + if (msg.BTVersion) Config['BTAppVersion'] = msg.BTVersion; + Keys.CLIENT_ID = msg.client_id; + Keys.API_KEY = msg.api_key; + Keys.FB_KEY = msg.fb_key; + Keys.STRIPE_KEY = msg.stripe_key; + } + + function setProp(prop, value) { + // setter for property. extensionProps cannot be set + + if (Properties.localStorageProps.includes(prop)) { + Config[prop] = value; + window.postMessage({'function': 'localStore', 'data': {'Config': Config}}); + } + if (Properties.orgProps.includes(prop)) { + Config[prop] = value; + //setMetaProp(prop, value); // see parser.js + //saveBT(); + } + if (Properties.stats.includes(prop)) { + Config['BTStats'][prop] = value; + window.postMessage({'function': 'localStore', 'data': {'Config': Config}}); + } + }; + + function getProp(prop) { + // getter for sync props, fetch from appropriate place based on Config array above + + if (Properties.localStorageProps.includes(prop)) { + return Config[prop]; + } + if (Properties.orgProps.includes(prop)) { + return Config[prop]; + } + if (Properties.keys.includes(prop)) { + return Keys[prop]; + } + if (Properties.stats.includes(prop)) { + return Config['BTStats'][prop]; + } + return null; + }; + + function metaPropertiesToString() { + // return the string to be used to output meta properties to .org file + let str = ""; + Properties['orgProps'].forEach(function(prop) { + if (getProp(prop)) + str += `#+PROPERTY: ${prop} ${getProp(prop)}\n`; + }); + return str; + } + + function checkNewDayOfUse(prev, current) { + // last active timestamp same day as this timestamp? + const prevDate = new Date(prev).toLocaleDateString(); // eg 2/8/1966 + const currentDate = new Date(current).toLocaleDateString(); + if (prevDate != currentDate) { + const oldDaysOfUse = Config['BTStats']['BTDaysOfUse'] || 0; + Config['BTStats']['BTDaysOfUse'] = oldDaysOfUse + 1; + gtag('event', 'DayOfUse', {'event_category': 'Usage', + 'event_label': 'NumDaysOfUse', + 'value': Config['BTStats']['BTDaysOfUse']}); + } + } + + function incrementStat(statName) { + // numLaunches, numSaves, numTabOps, update lastactivity as side effect + const oldVal = Config['BTStats'][statName] || 0; + const date = Date.now(); + const previousActivityTime = Config['BTStats']['BTLastActivityTime'] || 0; + Config['BTStats'][statName] = oldVal + 1; + Config['BTStats']['BTLastActivityTime'] = date; + checkNewDayOfUse(previousActivityTime, date); // see above + window.postMessage({'function': 'localStore', 'data': {'Config': Config}}); + }; + + function setStat(statName, statValue) { + // just another prop eg sessionStartTime + setProp(statName, statValue); + }; + + function updatePrefs() { + // update preferences based on configuration + + let groupMode = configManager.getProp('BTGroupingMode'); + if (groupMode) { + const $radio = $('#tabGroupToggle :radio[name=grouping]'); + $radio.filter(`[value=${groupMode}]`).prop('checked', true); + GroupingMode = groupMode; + } + + // does the topic manager live in a tab or a window? + const managerHome = configManager.getProp('BTManagerHome'); + let $radio; + if (managerHome) { + $radio = $('#panelToggle :radio[name=location]'); + $radio.filter(`[value=${managerHome}]`).prop('checked', true); + window.postMessage({'function': 'localStore', 'data': {'ManagerHome': managerHome}}); + } + + // Fill in initial value for SettingsBackups checkbox + const backupsOn = configManager.getProp('BTBackupsOn'); + $('#backups').prop('checked', backupsOn); + + // do we load Favicons? Read value, set ui and re-save in case defaulted + const favSet = configManager.getProp('BTFavicons'); + const favicons = favSet || 'ON'; + $radio = $('#faviconToggle :radio[name=favicon]'); + $radio.filter(`[value=${favicons}]`).prop('checked', true); + if (!favSet) configManager.setProp('BTFavicons', favicons); + + // Sticky Tabs? + const sticky = configManager.getProp('BTStickyTabs') || 'STICKY'; + $radio = $('#stickyToggle :radio[name=sticky]'); + $radio.filter(`[value=${sticky}]`).prop('checked', true); + configManager.setProp('BTStickyTabs', sticky); + + // Dense? + const dense = configManager.getProp('BTDense') || 'NOTDENSE'; + $radio = $('#denseToggle :radio[name=dense]'); + $radio.filter(`[value=${dense}]`).prop('checked', true); + document.documentElement.setAttribute('data-dense', dense); + + // Large? + const large = configManager.getProp('BTSize') || 'NOTLARGE'; + $radio = $('#largeToggle :radio[name=large]'); + $radio.filter(`[value=${large}]`).prop('checked', true); + document.documentElement.setAttribute('data-size', large); + + // Tooltips? + const tooltips = configManager.getProp('BTTooltips') || 'ON'; + $radio = $('#tooltipsToggle :radio[name=tooltips]'); + $radio.filter(`[value=${tooltips}]`).prop('checked', true); + // do it + if (tooltips == 'ON') { + $("#buttonRow span").removeClass("wenk--off").addClass("wenk--left"); + $(".indenter a").removeClass("wenk--off").addClass("wenk--bottom"); + } else { + $("#buttonRow span").removeClass("wenk--left").removeClass("wenk--right").addClass("wenk--off"); + $(".indenter a").removeClass("wenk--bottom").addClass("wenk--off"); + } + + // More Tools? + (configManager.getProp('BTMoreToolsOn') == 'ON') && toggleMoreButtons(); + + // Theme saved or set from OS + const themeSet = configManager.getProp('BTTheme'); + const theme = themeSet || + (window?.matchMedia('(prefers-color-scheme: dark)').matches ? 'DARK' : 'LIGHT'); + $radio = $('#themeToggle :radio[name=theme]'); + $radio.filter(`[value=${theme}]`).prop('checked', true); + if (!themeSet) configManager.setProp('BTTheme', theme); + // Change theme by setting attr on document which overides a set of vars. see top of bt.css + document.documentElement.setAttribute('data-theme', theme); + $('#topBar img').removeClass(['LIGHT', 'DARK']).addClass(theme); // swap some icons + $('#footer img').removeClass(['LIGHT', 'DARK']).addClass(theme); + } + + // Register listener for radio button changes in Options, decide whether to nag + $(document).ready(function () { + $('#panelToggle :radio').change(function () { + const newHome = $(this).val(); + configManager.setProp('BTManagerHome', newHome); + // Let extension know + window.postMessage({'function': 'localStore', 'data': {'ManagerHome': newHome}}); + }); + + /* + $('#tabGroupToggle :radio').change(function () { + const oldVal = GroupingMode; + const newVal = $(this).val(); + GroupingMode = newVal; + configManager.setProp('BTGroupingMode', GroupingMode); + groupingUpdate(oldVal, newVal); + }); + */ + + $('#syncSetting :radio').change(async function () { + try { + const newVal = $(this).val(); + let success = false; + if (newVal == 'gdrive') + success = await authorizeGAPI(true); + else if (newVal == 'local') + success = await authorizeLocalFile(); + if (success) { + $("#settingsSync").hide(); + $("#settingsSyncStatus").show(); + $("#syncType").text((newVal == 'gdrive') ? "GDrive" : "Local File"); + $("#actionsSyncStatus").show(); + } else { + $("#settingsSyncNone").prop("checked", true); + } + return success; + } catch (err) { + console.warn(err); + $("#settingsSyncNone").prop("checked", true); + return false; + } + }); + + $('#settingsBackups :checkbox').change(async function () { + const newVal = $(this).prop('checked'); + configManager.setProp('BTBackupsOn', newVal); + configManager.setProp('BTBackupsList', {recent: [], daily: [], monthly: []}); + let success = await initiateBackups(newVal); // fileManager fn + if (!success) { + $(this).prop('checked', false); + configManager.setProp('BTBackupsOn', false); + } + }); + + /* + $('#stickyToggle :radio').change(function () { + const newN = $(this).val(); + configManager.setProp('BTStickyTabs', newN); + // No immediate action, take effect on next tabNavigated event + }); + */ + + $('#themeToggle :radio').change(function () { + const newTheme = $(this).val(); + configManager.setProp('BTTheme', newTheme); + document.documentElement.setAttribute('data-theme', newTheme); + $('#topBar img').removeClass(['DARK', 'LIGHT']).addClass(newTheme); + $('#footer img').removeClass(['DARK', 'LIGHT']).addClass(newTheme); + // Let extension know + window.postMessage({'function': 'localStore', 'data': {'Theme': newTheme}}); + }); + + $('#faviconToggle :radio').change(function () { + const favicons = $(this).val(); + const favClass = (favicons == 'ON') ? 'faviconOn' : 'faviconOff'; + configManager.setProp('BTFavicons', favicons); + // Turn on or off + $('#content img').removeClass('faviconOff faviconOn').addClass(favClass); + }); + + $('#denseToggle :radio').change(function () { + const newD = $(this).val(); + configManager.setProp('BTDense', newD); + // do it + document.documentElement.setAttribute('data-dense', newD); + }); + + $('#largeToggle :radio').change(function () { + const newL = $(this).val(); + configManager.setProp('BTSize', newL); + // do it + document.documentElement.setAttribute('data-size', newL); + }); + + $('#tooltipsToggle :radio').change(function () { + const newT = $(this).val(); + configManager.setProp('BTTooltips', newT); + // do it + if (newT == 'ON') { + $("#buttonRow span").removeClass("wenk--off").addClass("wenk--left"); + $(".indenter a").removeClass("wenk--off").addClass("wenk--bottom"); + } else { + $("#buttonRow span").removeClass("wenk--left").removeClass("wenk--right").addClass("wenk--off"); + $(".indenter a").removeClass("wenk--bottom").addClass("wenk--off"); + } + }); + }); + + function toggleSettingsDisplay() { + // open/close settings panel + + const iconColor = (getProp('BTTheme') == 'LIGHT') ? 'LIGHT' : 'DARK'; + const installDate = new Date(getProp('BTInstallDate')); + const today = new Date(); + const daysSinceInstall = Math.floor((today - installDate) / (24 * 60 * 60 * 1000)); + + if ($('#actions').is(':visible')) + toggleActionsDisplay(); // can't have both open + + if ($('#settings').is(':visible')) { + $('#settings').slideUp({duration: 250, 'easing': 'easeInCirc'}); + $("#content").fadeIn(250); + $("body").css("overflow", "auto"); + setTimeout(() => { + $('#settingsButton').removeClass('open'); + $('#topBar img').removeClass(['DARK', 'LIGHT']).addClass(iconColor); + }, 250); + } else { + $('#settings').slideDown({duration: 250, 'easing': 'easeInCirc'}); + $('#settingsButton').addClass('open'); + $('#topBar img').removeClass(['DARK', 'LIGHT']).addClass('DARK'); + $("#content").fadeOut(250); + $("body").css("overflow", "hidden"); // don't allow table to be scrolled + + // fade in and maybe out the overlay to shut off non-supporter features if not supporter + if (BTId) return; + // No BTId but might be still in trial period. Fade in overlay + setTimeout(() => { + $("#youShallNotPass").fadeIn(); + }, 1000); + // fade out overlay if trial still on ie < 30 days since install + if (daysSinceInstall <= 30) + setTimeout(() => {$("#youShallNotPass").fadeOut(); scrollToPurchaseButtons()}, 10000); + } + } + + function closeConfigDisplays() { + // close if open + if ($('#actions').is(':visible')) toggleActionsDisplay(); + if ($('#settings').is(':visible')) toggleSettingsDisplay(); + if ($('#help').is(':visible')) toggleHelpDisplay(); + } + + function toggleActionsDisplay() { + // open/close actions panel + + const iconColor = (getProp('BTTheme') == 'LIGHT') ? 'LIGHT' : 'DARK'; + + if ($('#actions').is(':visible')) { + $('#actions').slideUp({duration: 250, 'easing': 'easeInCirc'}); + $("#content").fadeIn(250); + $("body").css("overflow", "auto"); + setTimeout(() => { + $('#actionsButton').removeClass('open'); + $('#topBar img').removeClass(['DARK', 'LIGHT']).addClass(iconColor); + }, 250); + } else { + $('#actions').slideDown({duration: 250, 'easing': 'easeInCirc'}); + $('#actionsButton').addClass('open'); + $('#topBar img').removeClass(['LIGHT', 'DARK']).addClass('DARK'); + $("#content").fadeOut(250); + $("body").css("overflow", "hidden"); // don't allow table to be scrolled + } + } + + function toggleHelpDisplay(panel) { + // open/close help panel + + const iconColor = (getProp('BTTheme') == 'LIGHT') ? 'LIGHT' : 'DARK'; + if ($('#help').is(':visible')) { + // now visible => action is close + $('#help').slideUp({duration: 250, 'easing': 'easeInCirc'}); + $("#content").fadeIn(250); + $("body").css("overflow", "auto"); + setTimeout(() => { + $('#footerHelp').removeClass('open'); + $('#footer img').removeClass(['LIGHT', 'DARK']).addClass(iconColor); + }, 250); + } else { + // now visible => action is open + $('#help').slideDown({duration: 250, 'easing': 'easeInCirc'}); + $('#footerHelp').addClass('open'); + $('#footer img').removeClass(['LIGHT', 'DARK']).addClass('DARK'); + $("#content").fadeOut(250); + $("body").css("overflow", "hidden"); // don't allow table to be scrolled + } + } + + function toggleKeyCommands() { + // open/close key command table inside help + if ($('#keyCommands').is(':visible')) + setTimeout(()=>$('#keyCommands').slideUp({duration: 250, 'easing': 'easeInCirc'}),10); + else + setTimeout(()=>$('#keyCommands').slideDown({duration: 250, 'easing': 'easeInCirc'}), 10); + } + + function initializeInstallDate() { + // best guess at install date cos wasn't previously set + // Use bookmark import data if set. Otherwise assume 2x saves/day up to 1 year + if (Config['BTStats']['BTInstallDate']) return; + const saveDays = Math.min(Config['BTStats']['BTNumSaves'] / 2, 365); + const guessedInstallDate = Date.now() - (saveDays * 24 * 60 * 60000); + setProp('BTInstallDate', guessedInstallDate); + } + + function potentiallyNag() { + // Nagging check, called on startup + if (BTId) return; + const installDate = new Date(getProp('BTInstallDate')); + const today = new Date(); + const daysSinceInstall = Math.floor((today - installDate) / (24 * 60 * 60 * 1000)); + if (daysSinceInstall > 30) { + openTrialExpiredWarning(); + $('#settingsBackups :checkbox').prop('checked', false); + configManager.setProp('BTBackupsOn', false); + } + } + + function openTrialExpiredWarning() { + // show trial expired warning section and call to arms, slide tree down to accomodate + $("#trialExpiredWarning").show(); + $("#content").css("margin-top", "220px"); + } + function closeTrialExpiredWarning() { + // user closed warning - close it, reposition tree, open settings and scroll to subscribe section + $("#trialExpiredWarning").hide(); + $("#content").css("margin-top", "79px"); + toggleSettingsDisplay(); + scrollToPurchaseButtons(); + } + + function scrollToPurchaseButtons() { + // scroll to the purchase buttons in the settings panel + // Delay to allow any previous animation to complete + const settingsDiv = $('#settings'); + setTimeout(()=>settingsDiv.animate({ scrollTop: settingsDiv.prop('scrollHeight') }, 800, 'swing'), 800); + } + return { + setConfigAndKeys: setConfigAndKeys, + setProp: setProp, + getProp: getProp, + metaPropertiesToString: metaPropertiesToString, + setStat: setStat, + incrementStat: incrementStat, + updatePrefs: updatePrefs, + toggleSettingsDisplay: toggleSettingsDisplay, + toggleHelpDisplay: toggleHelpDisplay, + toggleActionsDisplay: toggleActionsDisplay, + closeConfigDisplays: closeConfigDisplays, + toggleKeyCommands: toggleKeyCommands, + initializeInstallDate: initializeInstallDate, + closeTrialExpiredWarning: closeTrialExpiredWarning, + potentiallyNag: potentiallyNag + }; +})(); + + diff --git a/versions/1.1/app/fileManager.js b/versions/1.1/app/fileManager.js new file mode 100644 index 0000000..57ad32f --- /dev/null +++ b/versions/1.1/app/fileManager.js @@ -0,0 +1,387 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Handles local and gdrive file storage interactions. Mostly just a facade on + * localFileManager and gDriveFileManager + * + ***/ +var GDriveConnected = false; +var LocalFileConnected = false; + +function syncEnabled() { + // Is there a backing store file, local or gdrive + return GDriveConnected || LocalFileConnected; +} + +async function handleStartupFileConnection() { + // If there's a backing store file, local or GDrive, handle reconnection etc + + let launchType = 'unsynced_launch'; + + // Handle GDrive connection + if (configManager.getProp('BTGDriveConnected')) { + await authorizeGAPI(false); + launchType = 'gdrive_launch'; + } + + // Handle Local connection/permissions + const local = await localFileManager.reestablishLocalFilePermissions(); + if (local) { + launchType = 'local_file_launch'; + updateSyncSettings(true, await localFileManager.getFileLastModifiedTime()); + } + + // fire off tracking event + gtag('event', launchType, {'event_category': 'General'}); +} + +async function saveBT(localOnly = false, newContent = true) { + // Save org version of BT Tree to local storage and potentially gdrive. + // localOnly => don't save to GDrive backing and don't send gtag stat. Used when folding/unfolding + // newContent => true if we're saving new content, false if just saving folded state + // Don't force GDrive re-auth if we're just folding/unfolding + + console.log(`Writing BT to ${localOnly ? 'local only' : 'local + any remote'} Storage`); + + // BTVersion is incremented on each content change, optionally backups are made + let currentBTVersion = parseInt(configManager.getProp('BTVersion')) || 1; + const backsOn = configManager.getProp('BTBackupsOn'); + if (newContent) { + configManager.setProp('BTVersion', currentBTVersion + 1); + if (backsOn) await performBackups(currentBTVersion); + } + + BTFileText = BTAppNode.generateOrgFile(); + if (window.LOCALTEST) return; + + window.postMessage({'function': 'localStore', 'data': {'BTFileText': BTFileText}}); + if (localOnly) return; // return if we're just remember folded state + + setTimeout(brainZoom, 1000); // swell the brain + console.log("Recording save event and writing to any backing store"); + if (InitialInstall) { + gtag('event', 'first_save', {'event_category': 'General'}); + InitialInstall = false; + } + + // also save to GDrive or local file if connected and drop an event + let event = "local_storage_save"; + if (GDriveConnected) { + await gDriveFileManager.saveBT(BTFileText, newContent); // if !newContent, don't force re-auth + event = "gdrive_save"; + } else if (LocalFileConnected) { + await localFileManager.saveBT(BTFileText); + event = "local_file_save"; + } + updateStatsRow(); // update num cards etc + messageManager.removeWarning(); // remove stale warning if any + gtag('event', event, {'event_category': 'Save', 'event_label': 'NumNodes', 'value': AllNodes.length}); + configManager.incrementStat('BTNumSaves'); +} + +async function authorizeLocalFile() { + // Called from user action button to allow filesystem access and choose BT folder + const success = await localFileManager.authorizeLocalFile(); + if (!success) return false; + + configManager.setProp('BTGDriveConnected', false); + configManager.setProp('BTTimestamp', await localFileManager.getFileLastModifiedTime()); + updateSyncSettings(true); + alert('Local file sync established. See Actions to disable.'); + return true; +} + +function authorizeGAPI(userInitiated) { + // load apis and pass thru to gdrive file manager + if (!window.gapi) { + let gapiscript = document.createElement('script'); + gapiscript.src = 'https://apis.google.com/js/api.js'; // URL of the Google API script + gapiscript.onload = gapiLoadOkay; + gapiscript.onerror = gapiLoadFail; + let gisscript = document.createElement('script'); + gisscript.src = 'https://accounts.google.com/gsi/client'; // URL of the Google GIS script + gisscript.onload = gisLoadOkay; + gisscript.onerror = gisLoadFail; + document.head.appendChild(gapiscript); + document.head.appendChild(gisscript); + } + gDriveFileManager.authorizeGapi(userInitiated); +} + +function savePendingP() { + // pass to correct file manager + if (GDriveConnected) + return gDriveFileManager.savePendingP(); + if (LocalFileConnected) + return localFileManager.savePendingP(); + return false; +} + +async function checkBTFileVersion() { + // pass to correct file manager + if (GDriveConnected) + return await gDriveFileManager.checkBTFileVersion(); + if (LocalFileConnected) + return await localFileManager.checkBTFileVersion(); + return false; +} + +async function getBTFile() { + // pass on + if (GDriveConnected) + return await gDriveFileManager.getBTFile(); + if (LocalFileConnected) + return await localFileManager.getBTFile(); + alert("No file connected"); + return ""; +} + +function stopSyncing() { + // BTN CB, stop syncing wherever. + LocalFileConnected = false; + GDriveConnected = false; + configManager.setProp('BTGDriveConnected', false); + configManager.setProp('BTBackupsOn', false); + localFileManager.reset(); + updateSyncSettings(); + alert('Sync has been disabled. See Settings to re-enable.'); +} + + + +/*** + * + * Manage backups + * + ***/ + +async function initiateBackups(setOn = true) { + // Call out to local or gdrive file managers to create or find BT-Backups folder + console.log('creating backups'); + if (!LocalFileConnected) { + alert('Local File syncing is required to create backups'); + return false; + } + if (setOn) { + await localFileManager.initiateBackups(setOn); + saveBT(); // Save version to show a backup file and avoid confusion + } else + alert('Backups are off'); + return true; +} +async function performBackups(BtFileVersion) { + // Pull info from ConfigManager for BTBackupsList on recent, daily and last monthly backups + // format: {mostRecentSaveTime, recent: [{handle:, timestamp}, {}, {}], daily: [{}, {}, {}], monthly: [{handle:, onlyMostRecentTimestamp}]} + // Figure out if new daily or monthly backup is required, most recent backup is always created + // Call out to local or gdrive managers to create new named files as appropriate + // Call out to delete older backups if needed + + const backups = configManager.getProp('BTBackupsList'); + const mostRecentSaveTime = backups.mostRecentSaveTime || 0; + const mrs = new Date(mostRecentSaveTime); + const previousDaily = backups.daily[0]?.timestamp || 0; + const previousMonthly = backups.monthly[0]?.timestamp || 0; + + // Turn most recent into backup + const recentName = "BrainTool-Backup-v" + BtFileVersion + ".org"; + const rbkup = await createBackup(recentName); + // push to front of recent list + backups.recent.unshift({handle: rbkup, timestamp: mostRecentSaveTime}); + // delete third recent backup if it exists + if (backups.recent.length > 3) { + await deleteBackup(backups.recent[3]); + backups.recent.pop(); + } + + // Check if we need to create a new daily backup + if (mostRecentSaveTime - previousDaily > 24*60*60*1000) { + const dailyName = "BrainTool-Backup-d" + mrs.toISOString().slice(5, 10) + ".org"; + const dbkup = await createBackup(dailyName); + // push to front of daily list + backups.daily.unshift({handle: dbkup, timestamp: mostRecentSaveTime}); + // delete third daily backup if it exists + if (backups.daily.length > 3) { + await deleteBackup(backups.daily[3]); + backups.daily.pop(); + } + } + // Check if we need a new monthly backup + if (mostRecentSaveTime - previousMonthly > 30*24*60*60*1000) { + const monthlyName = "BrainTool-Backup-m" + mrs.toISOString().slice(0, 10) + ".org"; + const mbkup = await createBackup(monthlyName); + // don't delete monthly files, just remember most recent + backups.monthly[0] = {handle: mbkup, timestamp: mostRecentSaveTime}; + } + + // update config + backups.mostRecentSaveTime = new Date().getTime(); + configManager.setProp('BTBackupsList', backups); +} + +async function createBackup(name) { + // Call correct file manager to create new backup file + console.log(`Creating backup ${name}`); + if (LocalFileConnected) + return await localFileManager.createBackup(name); + return false; +} +async function deleteBackup(deets) { + // pass to correct file manager. deets = {timestamp, handle} + console.log(`Deleting backup ${deets.handle}`); + if (LocalFileConnected) + return await localFileManager.deleteBackup(deets.handle); + return false; +} + +/*** + * + * UI updates. Stats, save time, icon etc + * + ***/ + + +function updateStatsRow(modifiedTime = null) { + // update #topics, urls, saves + const numTopics = AllNodes.filter(n => n?.isTopic()).length; + const numLinks = AllNodes.filter(n => n?.URL).length; + const numOpenLinks = AllNodes.filter(n => n?.URL && n?.tabId).length; + + modifiedTime = modifiedTime || configManager.getProp('BTTimestamp'); + const saveTime = getDateString(modifiedTime); + + // update Footer. take account of single column mode + const saveInfo = $("#content").hasClass('compactMode') ? `${saveTime}` : `Last saved ${saveTime}`; + const openInfo = $("#content").hasClass('compactMode') ? '' : `(${numOpenLinks} open)`; + $("#footerSavedInfo").html(saveInfo); + $("#footerItemInfo").text(`${numTopics} Topics, ${numLinks} pages`); + $("#footerOpenInfo").html(openInfo); + + if (GDriveConnected) { // set save icon to GDrive, not fileSave + $("#footerSavedIcon").attr("src", "resources/drive_icon.png"); + } + if (LocalFileConnected) { + $("#footerSavedIcon").attr("src", "resources/localSaveIcon.svg"); + } +} + +async function updateSyncSettings(connected = false, time = null) { + // Update the display to show/hide based on connectivity + + if (connected) { + $("#settingsSync").hide(); + $("#settingsSyncStatus").show(); + $("#actionsSyncStatus").show(); + const filetype = GDriveConnected ? 'GDrive' : 'Local File'; + $("#autoSaveLabel").text(`${filetype} sync is on.`); + if (GDriveConnected) { + $("#fileLocation").html(`File: ${"https://drive.google.com/file/d/" + configManager.getProp('BTFileID')}`); + configManager.getProp('BTFileID') && $("#fileLocation").show(); + } else { + let handle = await localFileManager.getLocalDirectoryHandle(); + $("#fileLocation").html(`Folder: ${handle.name}`); + $("#fileLocation").show(); + } + $("#syncType").text(filetype); + updateStatsRow(time); // last saved time etc + } else { + // remote sync turned off + $("#settingsSync").show(); + $("#settingsSyncStatus").hide(); + $("#actionsSyncStatus").hide(); + $("#settingsSyncNone").prop('checked', true); + // Needed? Screws up launchApp flow when connection has not been established: + // configManager.setProp('BTTimestamp', null); + } +} + +/*** + * + * Import/export file functions + * + ***/ + +function importOrgFile() { + // Only way I could find to get wait cursor to show was to introduce the timeout + $('body').addClass('waiting'); + setTimeout(() => _importOrgFile(), 100); +} +function _importOrgFile() { + // Import org file text from user chosen file + const fr = new FileReader(); + const uploader = $("#org_upload")[0]; + if (!uploader.files.length) { + $('body').removeClass('waiting'); + return; + } + const file = uploader.files[0]; + fr.onload = function(){ + insertOrgFile(file.name, fr.result); // call parser to insert + gtag('event', 'OrgImport', {'event_category': 'Import'}); + }; + fr.readAsText(file); + this.value = null; // needed to re-trigger if same file selected again +} + +async function loadOrgFile(url) { + // load topic tree from web resource + + $('body').addClass('waiting'); + let response = await fetch(url); + if (response.ok) { + const btdata = await response.text(); + insertOrgFile("Import", btdata); + } else { + $('body').removeClass('waiting'); + alert('Error loading Topic file'); + } + return; +} + + +function importTabsOutliner() { + // Only way I could find to get wait cursor to show was to introduce the timeout + $('body').addClass('waiting'); + setTimeout(() => _importTabsOutliner(), 100); +} +function _importTabsOutliner() { + // Import TabsOutliner json from user chosen file + const fr=new FileReader(); + const uploader = $("#to_upload")[0]; + if (!uploader.files.length) { + $('body').removeClass('waiting'); + return; + } + const file = uploader.files[0]; + fr.onload=function(){ + try { + const orgForTabsO = tabsToBT(fr.result); + insertOrgFile(file.name, orgForTabsO); + gtag('event', 'TOImport', {'event_category': 'Import'}); + } + catch(e) { + console.log("Error converting TabsOutliner file"); + $('body').removeClass('waiting'); + return; + } + }; + fr.readAsText(file); + this.value = null; // needed to re-trigger if same file selected again +} + +function exportOrgFile(event) { + // Import an org file from file + let filetext = BTAppNode.generateOrgFile(); + filetext = 'data:text/plain;charset=utf-8,' + encodeURIComponent(filetext); + $("#org_export").attr('href', filetext); + gtag('event', 'OrgExport', {'event_category': 'Export'}); +} diff --git a/versions/1.1/app/gDriveFileManager.js b/versions/1.1/app/gDriveFileManager.js new file mode 100644 index 0000000..1a682c1 --- /dev/null +++ b/versions/1.1/app/gDriveFileManager.js @@ -0,0 +1,559 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Handles gdrive file storage interactions. + * + ***/ +'use strict'; +var gapiLoadOkay, gapiLoadFail, gisLoadOkay, gisLoadFail + +const gDriveFileManager = (() => { + const gapiLoadPromise = new Promise((resolve, reject) => { + // See fileManager where apis.google.com etc are loaded, gapiLoadOkay is called from there this ensuring the lib is loaded + // when the promise is resolved. + gapiLoadOkay = resolve; + gapiLoadFail = reject; + }); + const gisLoadPromise = new Promise((resolve, reject) => { + gisLoadOkay = resolve; + gisLoadFail = reject; + }); + + // URL of the api we need to load + var DiscoveryDocs = ["https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"]; + + // Authorization scopes required by the API; + // Need to be able to create/read/write BTFile + var Scopes = 'https://www.googleapis.com/auth/drive.file'; + var tokenClient = null; + + async function initClient(userInitiated = false) { + + console.log("Initializing GDrive client app"); + let timeout = setTimeout(checkLoginReturned, 60000); + try { + // First, load and initialize the gapi.client + await gapiLoadPromise; + await new Promise((resolve, reject) => { + gapi.load('client', {callback: resolve, onerror: reject}); + }); + await gapi.client.init({ + apiKey: configManager.getProp('API_KEY'), + discoveryDocs: DiscoveryDocs + }); + await gapi.client.load(DiscoveryDocs[0]); // Load the Drive API + + // Now load the GIS client. tokenClient will be used to obtain the access token + await gisLoadPromise; + await new Promise((resolve, reject) => { + try { + tokenClient = google.accounts.oauth2.initTokenClient({ + client_id: configManager.getProp('CLIENT_ID'), + scope: Scopes, + prompt: 'consent', // Need to ask on initial connection cos token expires + callback: '', // defined at request time in await/promise scope. + }); + resolve(); + } catch (err) { + reject(err); + } + }); + await findOrCreateBTFile(userInitiated); + } catch (e){ + console.warn("Error in initClient:", e.toString()); + return false; + } + } + + const shouldUseGoogleDriveApi = () => { + // or alternative fetch via url + // return false; + return gapi.client.drive !== undefined; + } + + /** + * A wrapper function for connecting gapi using fetch. + * @param url + * @returns {Promise} + * @param {boolean} jsonOrText - true if we want response.json(), other response.text() will be returned. + */ + const fetchWrapper = async (url, jsonOrText = true) => { + + await getAccessToken(); + // Even if token exists, it might still be expired, so the wrapper function is needed + return getTokenAndRetryFunctionAfterAuthError(fetch, [url,{ + headers: { + Authorization: `Bearer ${getAccessToken()}` + } + }]).then(resp => handleFetchResponse(resp, jsonOrText)) + } + const handleFetchResponse = (response, jsonOrText=true) => { + if (response.ok) { + return jsonOrText ? response.json() : response.text(); // or response.text() for plain text + } else { + throw new Error(`HTTP error! Status: ${response.status}`); + } + } + + async function getAccessToken(forceAuth = true) { + // Get token, optional re-auth if expired + if (gapi.client.getToken()?.access_token) return gapi.client.getToken()?.access_token; + if (!forceAuth) return false; + + // else token has expired or there's some kind of issue. retry and reset signinstatus + console.warn("BT - Error Google Access Token not available. Trying to reAuth..."); + const token = await renewToken(); + if (BTFileID) updateSigninStatus(gapi.client.getToken()?.access_token !== undefined); // don't update if BTFile has not yet been read + return token; + } + + async function renewToken() { + // The access token is missing, invalid, or expired, or not yet existed, prompt for user consent to obtain one. + await new Promise((resolve, reject) => { + try { + // Settle this promise in the response callback for requestAccessToken() + tokenClient.callback = (resp) => { + if (resp.error !== undefined) reject(resp); + + // GIS has automatically updated gapi.client with the newly issued access token. + console.log('gapi.client access token: ' + JSON.stringify(gapi.client.getToken())); + resolve(resp); + }; + tokenClient.error_callback = (err) => { + console.error("Error requesting access token in renewToken: ", JSON.stringify(err)); + updateSigninStatus(false, err); + reject(err); + } + let rsp = tokenClient.requestAccessToken({'prompt': ''}); // ideally don't prompt user again + console.log("Requesting token: " + JSON.stringify(rsp)); + } catch (err) { + updateSigninStatus(false); + reject(err); + console.log("Error renewing token: " + JSON.stringify(err)); + } + }); + } + + async function renewTokenAndRetry(cb) { + // The access token is missing, invalid, or expired, or not yet existed, prompt for user consent to obtain one. + // might be a callback to call after token is renewed + if (confirm("BT - Security token expired. Renew the token and try again?")) { + try { + await renewToken(); + cb && cb(); + } catch (error) { + // Clean up, aisle five!!! + console.error("Failed to renew token: ", error); + updateSigninStatus(false, error); + } + } else { + updateSigninStatus(false); + } + } + + /** + * @param func - a function or a function generator + * @param {any[]} args - parameters of the function + * @returns {Promise} + * @param {boolean} isFunctionGenerated - true if this is a higher-order function that generates the main function. + * This handles the case where the function is method of Gapi that may change after a token is granted. + * Called from connnectAndFindFiles and the fetch wrapper + */ + async function getTokenAndRetryFunctionAfterAuthError(func, args, isFunctionGenerated = false) { + let func_ = func; + try { + await getAccessToken() + if (isFunctionGenerated) func_ = func() + return func_(...args) + } catch (err) { + console.error("Error in getTokenAndRetryFunctionAfterAuthError: ", JSON.stringify(err)); + } + } + + function revokeToken() { + // Not actually ever needed, but here for completeness and testing + let cred = gapi.client.getToken(); + if (cred !== null) { + google.accounts.oauth2.revoke(cred.access_token, () => {console.log('Revoked: ' + cred.access_token)}); + gapi.client.setToken(''); + updateSigninStatus(false); + } + } + + async function saveBT(fileText, forceAuth = false) { + BTFileText = fileText; + try { + // Save org version of BT Tree to gdrive. + const authorized = await getAccessToken(forceAuth); + if (authorized) writeBTFile (BTFileText); + } catch(err) { + alert(`Changes saved locally. GDrive connection failed. Google says:\n${JSON.stringify(err)}`); + updateSigninStatus(false); + console.log("Error in saveBT:", err); + } + } + async function authorizeGapi(userInitiated = false) { + // called from initial launch or Connect button (=> userInitiated) + // gapi needed to access gdrive not yet loaded => this script needs to wait + + console.log('Loading Google API...'); + gtag('event', 'auth_initiated', {'event_category': 'GDrive'}); + if (userInitiated) { + // implies from button click + gtag('event', 'auth_initiated_by_user', {'event_category': 'GDrive'}); + alert("- Passing you to Google to grant permissions. \n- Allow cookies and popups from both braintool.org and accounts.google.com in browser settings.\n- Complete all steps to allow file access, see braintool.org/support for details."); + } + // Init client will async flow will ensure that gapi is loaded + await initClient(userInitiated); + } + + function checkLoginReturned() { + // gapi.auth also sometimes doesn't return, most noteably cos of Privacy Badger + $('body').removeClass('waiting'); + if (GDriveConnected) return; + alert("Google Authentication should have completed by now.\nIt may have failed due to extensions such as Privacy Badger or if 3rd party cookies are disallowed. Exempt braintool.org and accounts.google.com from blockers and allow cookies and popups from those urls. If problems continues see \nbraintool.org/support"); + } + + /** + * Find or initialize BT file at gdrive + */ + var BTFileID; + async function findOrCreateBTFile(userInitiated) { + // on launch or explicit user 'connect to Gdrive' action (=> userInitiated) + + const files = await connectAndFindFiles(); + if (!files?.length) { + console.log('BrainTool.org file not found, creating..'); + await createStartingBT(); + return; + } + + // One or more BrainTool.org files found, get the one that matches our BTFileID, or just the first + const savedFileId = configManager.getProp('BTFileID'); + const file = files.find((f) => f.id == (savedFileId || 0)) || files[0]; + BTFileID = file.id; + const driveTimestamp = Date.parse(file.modifiedTime); + const newer = driveTimestamp > (configManager.getProp('BTTimestamp') || 0); + if (userInitiated || newer) { + // if user just initiated connection but file exists ask to import + // or if we have a recorded version thats older than disk, ask to import + const driveDate = new Date(driveTimestamp).toLocaleString(); + const msg = userInitiated ? + `A BrainTool.org file already exists. It was last modified ${driveDate}. 'OK' to use its contents, 'Cancel' to overwrite with local data.` : + "Synced BrainTool.org file on GDrive is newer than browser data. \nHit Cancel to ignore or OK to load newer. \nUse newer?" + if (confirm(msg)) { + try { + await refreshTable(true); + configManager.setProp('BTTimestamp', driveTimestamp); + messageManager.removeWarning(); // warning may have been set, safe to remove + + // later in flow property save was overwriting w old data on upgrade, + // so resave here to get disk version written to memory etc. + if (BTFileText) await gDriveFileManager.saveBT(BTFileText); + } + catch (err) { + alert("Error parsing BrainTool.org file from GDrive:\n" + JSON.stringify(err)); + throw(err); + } + } + } + // Update and Save FileID and save timestamp + updateSigninStatus(true); + updateStatsRow(driveTimestamp); + configManager.setProp('BTFileID', BTFileID); + configManager.getProp('BTTimestamp') || configManager.setProp('BTTimestamp', driveTimestamp); + } + + async function connectAndFindFiles() { + // Connect to Drive api and search for and return potential BT files + let response, files; + try { + if (shouldUseGoogleDriveApi()) { + response = await getTokenAndRetryFunctionAfterAuthError( gapi.client.drive?.files.list, [{ + 'pageSize': 1, + 'fields': "files(id, name, modifiedTime)", + 'q': "name='BrainTool.org' and not trashed" + }]); + files = response?.result?.files; + } + else { + // Connect to GAPI using fetch as a backup method + const url = "https://www.googleapis.com/drive/v3/files?pageSize=1&fields=files(id,name,modifiedTime)&q=name='BrainTool.org' and not trashed"; + files = await fetchWrapper(url).then(resp => resp.files); + } + } + catch (err) { + let msg = "BT - error reading file list from GDrive. Check permissions and retry"; + if (err?.result?.error?.message) + msg += "\nGoogle says:" + err.result.error.message; + alert(msg); + console.log("Error in findOrCreateBTFile: ", JSON.stringify(err)); + updateSigninStatus(false); + revokeToken(); + return; + } + GDriveConnected = true; + return files; + } + + async function createStartingBT() { + // Upload current BTFileText to newly created BrainTool.org file on GDrive + + // get accessToken, pass retry cb for if not available + const accessToken = await getAccessToken(); + + var metadata = { + 'name': 'BrainTool.org', // Filename at Google Drive + 'mimeType': 'text/plain' // mimeType at Google Drive + }; + + try { + // write BTFileText to GDrive + var form = new FormData(); + form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); + form.append('file', BTFileText); + + let response = await fetch( + 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,version,modifiedTime' + , { + method: 'POST', + headers: new Headers({ 'Authorization': 'Bearer ' + accessToken }), + body: form, + } + ); + let responseValue = await response.json(); + + console.log("Created ", responseValue); + BTFileID = responseValue.id; + configManager.setProp('BTFileID', BTFileID); + const timestamp = Date.parse(responseValue.modifiedTime); + configManager.setProp('BTTimestamp', timestamp); + updateStatsRow(timestamp); + updateSigninStatus(true); + } + catch(err) { + alert(`Error creating BT file on GDrive: [${JSON.stringify(err)}]`); + } + } + + async function getBTFile() { + console.log('Retrieving BT file'); + if (!BTFileID) { + alert("Something went wrong. BTFileID not set. Try restarting BrainTool"); + return; + } + try { + await getAccessToken(); + if (shouldUseGoogleDriveApi()) { + BTFileText = await gapi.client.drive.files.get({ + fileId: BTFileID, + alt: 'media' + }).then(resp => resp.body); + } else { + const url = `https://www.googleapis.com/drive/v3/files/${BTFileID}?alt=media` + BTFileText = await fetchWrapper(url, false) + } + const remoteVersion = await getBTModifiedTime(); + configManager.setProp('BTTimestamp', remoteVersion); + } + catch(error) { + console.error(`Could not read BT file. Google says: [${JSON.stringify(error, undefined, 2)}].`); + } + } + + window.LOCALTEST = false; // overwritten in test harness + var UnwrittenChangesTimer = null; + var SaveUnderway = false; + function savePendingP() { + // Are we in the middle of saving, or just finished and bundling any subsequent changes + return SaveUnderway || UnwrittenChangesTimer; + } + + async function writeBTFile() { + // Notification of change to save. Don't write more than once every 15 secs. + // If timer is already set then we're waiting for 15 secs so just return. + // If a save is not underway and its been 15 secs call _write to save + // Else set a timer if not already set + + if (UnwrittenChangesTimer) { + console.log("writeBTFile: change already outstanding, just exiting"); + return; + } + if (!SaveUnderway && new Date().getTime() > (15000 + (configManager.getProp('BTTimestamp') || 0))) { + try { + return await _writeBTFile(); + } + catch(err) { + console.log("Error in writeBTFile: ", JSON.stringify(err)); + throw(err); + } + } else { + // else set a timer, if one hasn't already been set + if (!UnwrittenChangesTimer) { + UnwrittenChangesTimer = setTimeout(_writeBTFile, 15000); + console.log("Holding BT file write"); + } + } + } + + async function _writeBTFile() { + // Write file contents into BT.org file on GDrive + // NB Have to be careful to keep SaveUnderway up to date on all exit paths + console.log("Writing BT file to gdrive"); + UnwrittenChangesTimer = null; + + BTFileID = BTFileID || configManager.getProp('BTFileID'); + if (!BTFileID) { + alert("BTFileID not set, not saving to GDrive"); + return; + } + + try { + const accessToken = await getAccessToken(); + if (!accessToken) throw new Error("Access token is not available"); + + // check we're not overwriting remote file + const warn = await checkBTFileVersion(); + if (warn && !confirm("There's a newer BrainTool.org file on GDrive. Overwrite it?\nNB changes have been made locally either way.")) + return; + + // go about saving the file + SaveUnderway = true; + const metadata = { + 'name': 'BrainTool.org', // Filename at Google Drive + 'mimeType': 'text/plain' // mimeType at Google Drive + }; + let form = new FormData(); + console.log("writing BT file. accessToken = ", accessToken); + form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); + form.append('file', new Blob([BTFileText], {type: 'text/plain'})); + + await fetch('https://www.googleapis.com/upload/drive/v3/files/' + + encodeURIComponent(BTFileID) + + '?uploadType=multipart&fields=id,version,modifiedTime', + { + method: 'PATCH', + headers: new Headers({ 'Authorization': 'Bearer ' + accessToken }), + body: form + }).then((res) => { + SaveUnderway = false; + if (!res.ok) { + console.error("BT - error writing to GDrive"); + console.log("GAPI response:\n", JSON.stringify(res)); + return; + } + return res.json(); + }).then(function(val) { + console.log(val); + if (!val) + throw new Error('access token exists, but upload failed. Probably need to renew token and retry'); + const mt = Date.parse(val.modifiedTime); + configManager.setProp('BTTimestamp', mt); + updateStatsRow(mt); // update stats when we know successful save + }).catch(function(err) { + SaveUnderway = false; + console.log("Error in writeBTFile: ", JSON.stringify(err)); + renewTokenAndRetry(_writeBTFile); + return; + }); + } + catch(err) { + SaveUnderway = false; + alert("BT - Error saving to GDrive."); + console.log("Error in _writeBTFile: ", JSON.stringify(err)); + return; + } + } + + + async function getBTModifiedTime() { + // query Drive for last modified time + try { + if (!BTFileID || !GDriveConnected) throw new Error("BTFileID not set or GDrive not connected"); + const token = await getAccessToken(false); + if (!token) { + console.log("GDrive token expired or not available, returning 0 as modified time."); + return 0; + } + let response; + if (shouldUseGoogleDriveApi()) { + response = await gapi.client.drive.files.get({ + fileId: BTFileID, + fields: 'version,modifiedTime' + }).then(resp => resp.result); + } else { + const url = `https://www.googleapis.com/drive/v3/files/${BTFileID}?fields=version,modifiedTime` + response = await fetchWrapper(url) + } + return Date.parse(response.modifiedTime); + } catch (e) { + const msg = e.message || e.result.error.message; + console.error('Error reading BT file version from GDrive:', msg); + return 0; + } + } + + async function checkBTFileVersion() { + // is there a newer version of the btfile on Drive? + + const localVersion = configManager.getProp('BTTimestamp'); + const remoteVersion = await getBTModifiedTime(); + console.log(`Checking timestamps. local: ${localVersion}, remote: ${remoteVersion}`); + return (remoteVersion > localVersion); + } + + async function updateSigninStatus(signedIn, error=false, userInitiated = false) { + // CallBack on GDrive signin state change + let alertText; + if (error) { + alertText = "Error Authenticating with Google. Google says:\n'"; + alertText += (error.message) ? error.message : JSON.stringify(error); + alertText += "'\n1) Restart \n2) Turn GDrive sync back on. \nOr if this is a cookie issue be aware that Google uses cookies for authentication.\n"; + alertText += "Go to 'chrome://settings/cookies' and make sure third-party cookies and popups are allowed for accounts.google.com and braintool.org. If it continues see \nbraintool.org/support"; + } else { + if (signedIn) { + gtag('event', 'auth_complete', {'event_category': 'GDrive'}); + if (userInitiated) { + saveBT(); // also save if newly authorized + alertText = 'GDrive connection established. See Actions to disable.'; + } + } else { + alertText = "GDrive connection lost"; + } + } + alertText && alert(alertText); + + updateSyncSettings(signedIn); // common fileManager fn to show connectivity info + GDriveConnected = signedIn; + configManager.setProp('BTGDriveConnected', signedIn); + } + + function haveAuth() { + // return true if we have a token + return gapi.client.getToken()?.access_token !== undefined; + } + + return { + saveBT: saveBT, + authorizeGapi: authorizeGapi, + checkBTFileVersion: checkBTFileVersion, + savePendingP: savePendingP, + getBTFile: getBTFile, + revokeToken: revokeToken, + renewToken: renewToken, + haveAuth: haveAuth, + getAccessToken: getAccessToken, + getBTModifiedTime: getBTModifiedTime, + BTFileID: BTFileID + }; +})(); diff --git a/versions/1.1/app/gdrivetest.html b/versions/1.1/app/gdrivetest.html new file mode 100644 index 0000000..14f9118 --- /dev/null +++ b/versions/1.1/app/gdrivetest.html @@ -0,0 +1,151 @@ + + + + BrainTool Chrome Extension + + + + + + + + + +
+

+ Testing Loading Google APIs

+

Sign in Status: Unknown

+
+ +
+ + API Key_________:
+ Client ID_______:
+ +
+ +
+ + + + +
+
+ + + + + + diff --git a/versions/1.1/app/index.html b/versions/1.1/app/index.html new file mode 100644 index 0000000..c80e619 --- /dev/null +++ b/versions/1.1/app/index.html @@ -0,0 +1,631 @@ + + + + + + + + + BrainTool Topic Manager + + + + + + + + + + + + + + + +
+
+
+ BrainTool + + + +
+ + Click here or 's' to search +
+ + + + + + + + + +
+
+ +
+ Settings + +
+
+ Actions + +
+ +
+ +
+
+ + ADD A NEW TOP LEVEL TOPIC + ←|→ + +
+
+ Close +
+ YOUR 30 DAY NAG-FREE TRIAL HAS EXPIRED +
+
+ trial Buddy +

Hello there!
It looks like your 30 trial has expired and you haven't signed up as a Supporter. Please consider doing so to support the creation and ongoing evolution of BrainTool.
Go to Settings for Supporter options.

+
+
+
+
+
+

Supporter
Zone

+
+ Close + +
BrainTool Settings
+
+
+
Topic Manager Location:
+
+ + + + + + + + +
+
* Applies next time the Topic Manager is opened.
+
+
+ + +
+
Sync To:
+
+ + + + + + + + + + + + +
+
* You will need to grant appropriate permissions
+
+
+ + + + + +
+
Dark Mode?
+
+ + + + + + + + +
+
+
+ +
+
Show Favicons?
+
+ + + + + + + + +
+
+
+ +
+
Compact?
+
+ + + + + + + + +
+
+
+ +
+
Large Font?
+
+ + + + + + + + +
+
+
+ +
+
Show Tooltips?
+
+ + + + + + + + +
+
+
+ +
+
Supporter Status:
+
+
You are using BrainTool for free.
Please consider supporting BrainTool with a purchase. Thank you!
+
+ + + +
+ Coupons applied at checkout +
+ Click here for current offers and all benefits information.
+
Click here to manually enter a license code. +
+
+
+ +
+
+
+ + Close + +
BrainTool Actions
+
+
+
Import/Export:
+ +
+ + + +
+
+ + + +
+ +
+ +
+ + Close +
Help and Support
+
BTVersion
+
+
+
User Guide and General Help
+
+
+
+
+ Keyboard Commands (or just press 'h' or '?') +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Navs,r,/ (↓↑,Option-s/r)Search/Reverse-search (next/prev match)
n,p,↑↓Navigate to Next, Previous row
← →Navigate up/down hierarchy
1-9Show tree nodes up to level N
TabSubtree cycle expand one level/all-levels/collapse
Browse Space (w)Show/open item (in a new window)
EnterOpen/Close in the browser
EditOption-zUndo last deletion
Option ↑↓Move selection up/down
OptionPromote selection up a level
t, et cycle TODO state, e Edit
DeleteDelete selection
Option-EnterCreate a new sub Topic
+ Option-b-bOpen BT SidePanel (this window)
+

NB Option is a modifier key like shift or control. It is also called Alt.

+ + + +
+
+
FAQs and How-tos
+
+
+
+
BrainTool Website
+
+ + + + +
+

+
Loading your BrainTool file
...

+
+ + + + + + +
+ +
+ +
Tip of the day:
+
+ +
+ + + +
+ +
+ +
+ + Name the Topic +
+ + + + +
+ + +
+
+ + + + + +
+ + + + + + + + + + + + + + + diff --git a/versions/1.1/app/jquery.treetable.css b/versions/1.1/app/jquery.treetable.css new file mode 100644 index 0000000..b34237f --- /dev/null +++ b/versions/1.1/app/jquery.treetable.css @@ -0,0 +1,166 @@ +/* Basically a complete fork at this point, many changes made! Combined what was jquerytreetable.css and jquerytreetabletheme.default.css */ + +table.treetable { + margin-top: 79px; + margin-bottom: 80px; + width: 100%; + border-spacing: 0px 1px; + font-family: var(--btFont); + color: var(--btColor); + letter-spacing: var(--btLetterSpacing); +} + +table.treetable tr { + height: var(--btRowHeight); + scroll-margin-top: 100px; /* prevents selection being scrolled off top */ + scroll-margin-bottom: 75px; +} + +/* equal width cols w tree on left not wrapping. not sure why max-width is needed?! */ +/* https://stackoverflow.com/questions/26292408/why-does-this-behave-the-way-it-does-with-max-width-0 */ +table.treetable td.right { + width: 50%; + max-width: 0px; + cursor: default; + padding: 3px 1px; + background-color: var(--btNoteBackground); + font-size: var(--btNoteFontSize); + font-weight: var(--btNoteFontWeight); + line-height: var(--btNoteLineHeight); + color: var(--btNoteColor); +} + +table.treetable tr.leaf { + background-color: var(--btPageBackground); + font-size: var(--btPageFontSize); + font-weight: var(--btPageFontWeight); +} + +table.treetable tr.branch { + background-color: var(--btTopicBackground); + font-size: var(--btTopicFontSize); + font-weight: var(--btTopicFontWeight); +} + +table.treetable td.searchLite { + background-color: var(--btSearchResult); +} + +table.treetable td.search { + text-overflow: initial; + white-space: normal; +} + +tr.opened td.left a{ + color: var(--btLinkOpenedColor); +} +tr.opened.selected td.left a{ + color: var(--btLinkSelectedOpenedColor); +} + +tr.opened td.left{ + color: var(--btLinkOpenedColor); + font-weight: bold; +} +tr.opened.selected td.left{ + color: var(--btLinkSelectedOpenedColor); +} + +table.treetable span.btText { + padding-left: 2px; + white-space: pre-line; + display: block; + overflow-y: auto; + max-height: calc(var(--btRowHeight) - 4px); +} + +table.treetable span.keyword { + color: #e03030; +} + +table.treetable tr.hovered { + background-color: var(--btRowHovered); +} + +table.treetable tr.selected { + background-color: var(--btButtonBackground); +} + +table.treetable tr.attention { + background-color: var(--btDrawAttention); +} + +table.treetable td.dropOver { + border-bottom-style: solid; + border-bottom-color: var(--btDrawAttention); + border-bottom-width: 8px; +} + +@keyframes pulse-bottom { + 0% { border-bottom-color: var(--btDrawAttention); } + 50% { border-bottom-color: transparent; } + 100% { border-bottom-color: var(--btDrawAttention); } +} +table.treetable td.dropOver-pulse { + border-bottom: 8px solid var(--btDrawAttention); + animation: pulse-bottom 0.75s 3; + animation-delay: 1.0s; +} + + +table.treetable td.left { + width: 50%; + max-width: 0px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + border-right: solid; + border-right-width: 1px; + border-right-color: var(--btRowBorderColor); +} + +table.treetable span.indenter a { + display: inline-flex; + align-items: center; + height: var(--btRowHeight); + width: var(--btRowHeight); + background-image: url(resources/collapsed.png); + background-size: contain; + background-repeat: no-repeat; +} +table.treetable tr.expanded span.indenter a { + background-image: url(resources/expanded.png); +} +table.treetable tr.emptyTopic.collapsed span.indenter a { + background-image: url(resources/emptyTopicCollapsed.png); + opacity: 75%; +} +table.treetable tr.emptyTopic.expanded span.indenter a { + background-image: url(resources/emptyTopicExpanded.png); + opacity: 75%; +} + +table.treetable span.btTitle img.faviconOn { + display: inline; + vertical-align: middle; + width: 16px; + height: 16px; + padding: var(--btFaviconPadding); + margin-right: 5px; + border: solid; + border-width: 1px; + border-color: var(--btButtonBackground); + background-color: #ced4d5; +} + +/* When title is for a topic, or just text leave some room, but undo that if we're showing the favicon */ +table.treetable span.btTitle { + margin-left: 15px; +} +table.treetable span.btTitle img { + margin-left: -15px; +} + +/* Original image for fold/unfold +background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAAAHFJREFUeNpi/P//PwMlgImBQsA44C6gvhfa29v3MzAwOODRc6CystIRbxi0t7fjDJjKykpGYrwwi1hxnLHQ3t7+jIGBQRJJ6HllZaUUKYEYRYBPOB0gBShKwKGA////48VtbW3/8clTnBIH3gCKkzJgAGvBX0dDm0sCAAAAAElFTkSuQmCC); +*/ \ No newline at end of file diff --git a/versions/1.1/app/jquery.treetable.js b/versions/1.1/app/jquery.treetable.js new file mode 100644 index 0000000..ec77a00 --- /dev/null +++ b/versions/1.1/app/jquery.treetable.js @@ -0,0 +1,814 @@ +/* + * jQuery treetable Plugin 3.2.0 + * http://ludo.cubicphuse.nl/jquery-treetable + * + * Copyright 2013, Ludo van den Boom + * Dual licensed under the MIT or GPL Version 2 licenses. + */ +(function($) { + "use strict"; + + var Node, Tree, methods; + + Node = (function() { + function Node(row, tree, settings) { + var parentId; + + this.row = row; + this.tree = tree; + this.settings = settings; + + // TODO Ensure id/parentId is always a string (not int) + this.id = this.row.data(this.settings.nodeIdAttr); + + // TODO Move this to a setParentId function? + parentId = this.row.data(this.settings.parentIdAttr); + if (parentId != null && parentId !== "") { + this.parentId = parentId; + } + + this.treeCell = $(this.row.children(this.settings.columnElType)[this.settings.column]); + this.expander = $(this.settings.expanderTemplate); + this.indenter = $(this.settings.indenterTemplate); + this.cell = $(this.settings.cellTemplate); + this.children = []; + this.initialized = false; + this.treeCell.prepend(this.indenter); + this.treeCell.wrapInner(this.cell); + } + + Node.prototype.addChild = function(child) { + return this.children.push(child); + }; + + Node.prototype.ancestors = function() { + var ancestors, node; + node = this; + ancestors = []; + while (node = node.parentNode()) { + ancestors.push(node); + } + return ancestors; + }; + + Node.prototype.collapse = function() { + if (this.collapsed()) { + return this; + } + + this.row.removeClass("expanded").addClass("collapsed"); + + this._hideChildren(); + this.expander.attr("title", this.settings.stringExpand); + + if (this.initialized && this.settings.onNodeCollapse != null) { + this.settings.onNodeCollapse.apply(this); + } + + return this; + }; + + /* Tony add. Set of functions like original treetable w/o delay for use in initial setup => no animation + Issue is that when running thru all nodes to re-setup collapsed state the delays mean children + that shoudl be hidden under a collapse are shown */ + Node.prototype.collapseImmediate = function() { + if (this.collapsed()) { + return this; + } + + this.row.removeClass("expanded").addClass("collapsed"); + + this._hideChildrenImmediate(); + this.expander.attr("title", this.settings.stringExpand); + + if (this.initialized && this.settings.onNodeCollapse != null) { + this.settings.onNodeCollapse.apply(this); + } + + return this; + }; + Node.prototype._hideChildrenImmediate = function() { + var child, _i, _len, _ref, _results; + _ref = this.children; + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + child = _ref[_i]; + _results.push(child.hideImmediate()); + } + return _results; + }; + + Node.prototype.hideImmediate = function() { + this._hideChildrenImmediate(); + this.row.hide(); + return this; + }; + /* End Special immediate set of functions */ + + + Node.prototype.collapsed = function() { + return this.row.hasClass("collapsed"); + }; + + // TODO destroy: remove event handlers, expander, indenter, etc. + + Node.prototype.expand = function() { + if (this.expanded()) { + return this; + } + + this.row.removeClass("collapsed").addClass("expanded"); + + if (this.initialized && this.settings.onNodeExpand != null) { + this.settings.onNodeExpand.apply(this); + } + + // Tony - was + // if ($(this.row).is(":visible")) { + // see https://github.com/ludo/jquery-treetable/issues/144 + if (!this.row[0].hidden) { + this._showChildren(); + } + + this.expander.attr("title", this.settings.stringCollapse); + + return this; + }; + + Node.prototype.expanded = function() { + return this.row.hasClass("expanded"); + }; + + Node.prototype.hide = function(delay) { + /// Tony - replacing plain hide with an attempt to animate the transition + const mydelay = this._hideChildren() || delay; // last if children, otherwise use delay + const therow = this.row; + setTimeout(() => therow.hide(), mydelay); + return this; + }; + + Node.prototype.show = function(delay) { + /// Tony - replacing show with an attempt to animate the transition + if (!this.initialized) { + this._initialize(); + } + const therow = this.row; + setTimeout(() => therow.show(), delay); + if (this.expanded()) { + this._showChildren(); + } + return this; + }; + + Node.prototype.isBranchNode = function() { + if(this.children.length > 0 || this.row.data(this.settings.branchAttr) === true) { + return true; + } else { + return false; + } + }; + + Node.prototype.updateBranchLeafClass = function(){ + this.row.removeClass('branch'); + this.row.removeClass('leaf'); + this.row.addClass(this.isBranchNode() ? 'branch' : 'leaf'); + }; + + Node.prototype.level = function() { + return this.ancestors().length; + }; + + Node.prototype.parentNode = function() { + if (this.parentId != null) { + return this.tree[this.parentId]; + } else { + return null; + } + }; + + Node.prototype.removeChild = function(child) { + var i = $.inArray(child, this.children); + return this.children.splice(i, 1) + }; + + /* Original + Node.prototype.render = function() { + var handler, + settings = this.settings, + target; + + if (settings.expandable === true && this.isBranchNode()) { + handler = function(e) { + $(this).parents("table").treetable("node", $(this).parents("tr").data(settings.nodeIdAttr)).toggle(); + return e.preventDefault(); + }; + + this.indenter.html(this.expander); + target = settings.clickableNodeNames === true ? this.treeCell : this.expander; + + target.off("click.treetable").on("click.treetable", handler); + target.off("keydown.treetable").on("keydown.treetable", function(e) { + if (e.keyCode == 13) { + handler.apply(this, [e]); + } + }); + } + + this.indenter[0].style.paddingLeft = "" + (this.level() * settings.indent) + "px"; + + return this; + }; + */ + + // Tony Edit to align nodes equally whether parents or not + Node.prototype.render = function() { + var handler, + settings = this.settings, + target; + + if (settings.expandable === true && this.isBranchNode()) { + handler = function(e) { + $(this).parents("table").treetable("node", $(this).parents("tr").data(settings.nodeIdAttr)).toggle(); + return e.preventDefault(); + }; + + this.indenter.html(this.expander); + target = settings.clickableNodeNames === true ? this.treeCell : this.expander; + + target.off("click.treetable").on("click.treetable", handler); + /* don't want treetable interfering w BT's key commands + target.off("keydown.treetable").on("keydown.treetable", function(e) { + if (e.keyCode == 13) { + handler.apply(this, [e]); + } + }); + */ + } + + // Tony Edit: shift siblings of parents left so all a parents children are equal, + // also, remove indenter if no longer applicible + //if (this.isBranchNode()) + //this.indenter[0].style.paddingLeft = "" + (this.level() * settings.indent) + "px"; + this.indenter[0].style.paddingLeft = "calc(var(--btIndentStepSize) * " + this.level() + ")"; + //else { + // this.indenter[0].style.paddingLeft = "" + ((this.level() - 1) * settings.indent) + "px"; + //$(this.indenter[0]).empty(); + //} + + return this; + }; + + Node.prototype.reveal = function() { + if (this.parentId != null) { + this.parentNode().reveal(); + } + return this.expand(); + }; + + Node.prototype.setParent = function(node) { + if (this.parentId != null) { + this.tree[this.parentId].removeChild(this); + } + this.parentId = node.id; + this.row.data(this.settings.parentIdAttr, node.id); + return node.addChild(this); + }; + + Node.prototype.toggle = function() { + if (this.expanded()) { + this.collapse(); + } else { + this.expand(); + } + return this; + }; + + Node.prototype._initialize = function() { + var settings = this.settings; + + this.render(); + + if (settings.expandable === true && settings.initialState === "collapsed") { + this.collapse(); + } else { + this.expand(); + } + + if (settings.onNodeInitialized != null) { + settings.onNodeInitialized.apply(this); + } + + return this.initialized = true; + }; + + Node.prototype._hideChildren = function() { + // Tony edited to show animation + var child, _i, _len, _ref, _results; + const animationTime = this.settings.animationTime || 500; + _ref = this.children; + _results = []; + const incr = Math.min(animationTime / _ref.length, 25); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + child = _ref[_i]; + _results.push(child.hide(incr * _i)); + } + return (incr * _i); // time to last animation + }; + + + Node.prototype._showChildren = function() { + // Tony edited to show animation + var child, _i, _len, _ref, _results; + const animationTime = this.settings.animationTime || 500; + _ref = this.children; + _results = []; + const incr = Math.min(animationTime / _ref.length, 25); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + child = _ref[_i]; + _results.push(child.show(incr * _i)); + } + return (incr * _i); + }; + + return Node; + })(); + + Tree = (function() { + function Tree(table, settings) { + this.table = table; + this.settings = settings; + this.tree = {}; + + // Cache the nodes and roots in simple arrays for quick access/iteration + this.nodes = []; + this.roots = []; + } + + Tree.prototype.collapseAll = function() { + var node, _i, _len, _ref, _results; + _ref = this.nodes; + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + node = _ref[_i]; + _results.push(node.collapse()); + } + return _results; + }; + + Tree.prototype.expandAll = function() { + var node, _i, _len, _ref, _results; + _ref = this.nodes; + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + node = _ref[_i]; + _results.push(node.expand()); + } + return _results; + }; + + Tree.prototype.findLastNode = function (node) { + if (node.children.length > 0) { + return this.findLastNode(node.children[node.children.length - 1]); + } else { + return node; + } + }; + + Tree.prototype.loadRows = function(rows) { + var node, row, i; + + if (rows != null) { + for (i = 0; i < rows.length; i++) { + row = $(rows[i]); + + if (row.data(this.settings.nodeIdAttr) != null) { + node = new Node(row, this.tree, this.settings); + this.nodes.push(node); + this.tree[node.id] = node; + + if (node.parentId != null && this.tree[node.parentId]) { + this.tree[node.parentId].addChild(node); + } else { + this.roots.push(node); + } + } + } + } + + for (i = 0; i < this.nodes.length; i++) { + node = this.nodes[i].updateBranchLeafClass(); + } + + return this; + }; + + Tree.prototype.move = function(node, destination) { + // Tony update - removed condition #2 thus allowing a node to be dropped + // to be its parent first child + + // Conditions: + // 1: +node+ should not be inserted as a child of +node+ itself. + // 2: +destination+ should not be the same as +node+'s current parent (this + // prevents +node+ from being moved to the same location where it already + // is). + // 3: +node+ should not be inserted in a location in a branch if this would + // result in +node+ being an ancestor of itself. + var nodeParent = node.parentNode(); + + // if (node !== destination && destination.id !== node.parentId && $.inArray(node, destination.ancestors()) === -1) { + + if (node !== destination && $.inArray(node, destination.ancestors()) === -1) { + node.setParent(destination); + this._moveRows(node, destination); + + // Re-render parentNode if this is its first child node, and therefore + // doesn't have the expander yet. + if (node.parentNode().children.length === 1) { + node.parentNode().render(); + } + } + + if(nodeParent){ + nodeParent.updateBranchLeafClass(); + } + if(node.parentNode()){ + node.parentNode().updateBranchLeafClass(); + } + node.updateBranchLeafClass(); + return this; + }; + + // Tony Addition + Tree.prototype.promote = function(node) { + // Move node up the hierarchy and position it before its current parent + var nodeParent = node.parentNode(); + if (!nodeParent) return; + var nodeGrandparent = nodeParent.parentNode(); + if (nodeGrandparent) { + node.setParent(nodeGrandparent); + } else { + nodeParent.removeChild(node); + node.parentId = null; + node.row.removeData(node.settings.parentIdAttr); + // TODO figure out why above line doesn't work so I don't need this one + node.row.removeAttr('data-tt-parent-id'); + } + + node.row.insertBefore(nodeParent.row); + node.render(); + + // Loop backwards through children to order correctly in UI + var children = node.children, i; + for (i = children.length - 1; i >= 0; i--) { + this._moveRows(children[i], node); + } + nodeParent.updateBranchLeafClass(); + return this; + }; + + // Tony Addition + Tree.prototype.insertAtTop = function(node, beforeNode) { + // insert node after beforeNode in tree at top level. Needed to insert at top level + + node.parentId = null; + node.row.removeData(node.settings.parentIdAttr); + // TODO figure out why above line doesn't work so I don't need this one + node.row.removeAttr('data-tt-parent-id'); + + node.row.insertAfter(beforeNode.row); + node.render(); + + // Loop backwards through children to order correctly in UI + var children = node.children, i; + for (i = children.length - 1; i >= 0; i--) { + this._moveRows(children[i], node); + } + children = beforeNode.children; + for (i = children.length - 1; i >= 0; i--) { + this._moveRows(children[i], beforeNode); + } + + return this; + } + + Tree.prototype.removeNode = function(node) { + // Recursively remove all descendants of +node+ + this.unloadBranch(node); + + // Remove node from DOM () + node.row.remove(); + + // Remove node from parent children list + if (node.parentId != null) { + node.parentNode().removeChild(node); + } + + // Clean up Tree object (so Node objects are GC-ed) + delete this.tree[node.id]; + this.nodes.splice($.inArray(node, this.nodes), 1); + + return this; + }; + + Tree.prototype.render = function() { + var root, _i, _len, _ref; + _ref = this.roots; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + root = _ref[_i]; + + // Naming is confusing (show/render). I do not call render on node from + // here. + root.show(); + } + return this; + }; + + Tree.prototype.sortBranch = function(node, sortFun) { + // First sort internal array of children + node.children.sort(sortFun); + + // Next render rows in correct order on page + this._sortChildRows(node); + + return this; + }; + + Tree.prototype.unloadBranch = function(node) { + // Use a copy of the children array to not have other functions interfere + // with this function if they manipulate the children array + // (eg removeNode). + var children = node.children.slice(0), + i; + + for (i = 0; i < children.length; i++) { + this.removeNode(children[i]); + } + + // Reset node's collection of children + node.children = []; + + node.updateBranchLeafClass(); + // Tony Add - re render since will now not need an expander + node.render(); + + return this; + }; + + Tree.prototype._moveRows = function(node, destination) { + var children = node.children, i; + + node.row.insertAfter(destination.row); + node.render(); + + // Loop backwards through children to have them end up on UI in correct + // order (see #112) + for (i = children.length - 1; i >= 0; i--) { + this._moveRows(children[i], node); + } + }; + + // Special _moveRows case, move children to itself to force sorting + Tree.prototype._sortChildRows = function(parentNode) { + return this._moveRows(parentNode, parentNode); + }; + + return Tree; + })(); + + // jQuery Plugin + methods = { + init: function(options, force) { + var settings; + + settings = $.extend({ + branchAttr: "ttBranch", + clickableNodeNames: false, + column: 0, + columnElType: "td", // i.e. 'td', 'th' or 'td,th' + expandable: false, + expanderTemplate: " ", + indent: 19, + indenterTemplate: "", + cellTemplate: '', + initialState: "collapsed", + nodeIdAttr: "ttId", // maps to data-tt-id + parentIdAttr: "ttParentId", // maps to data-tt-parent-id + /* replaced with Wenk tooltips. See wenk.css + stringExpand: "Expand", + stringCollapse: "Collapse", + */ + // Events + onInitialized: null, + onNodeCollapse: null, + onNodeExpand: null, + onNodeInitialized: null + }, options); + + return this.each(function() { + var el = $(this), tree; + + if (force || el.data("treetable") === undefined) { + tree = new Tree(this, settings); + tree.loadRows(this.rows).render(); + + el.addClass("treetable").data("treetable", tree); + + if (settings.onInitialized != null) { + settings.onInitialized.apply(tree); + } + } + + return el; + }); + }, + + destroy: function() { + return this.each(function() { + return $(this).removeData("treetable").removeClass("treetable"); + }); + }, + + collapseAll: function() { + this.data("treetable").collapseAll(); + return this; + }, + + collapseNode: function(id) { + var node = this.data("treetable").tree[id]; + + if (node) { + node.collapse(); + } else { + throw new Error("Unknown node '" + id + "'"); + } + + return this; + }, + + collapseNodeImmediate: function(id) { + var node = this.data("treetable").tree[id]; + + if (node) { + node.collapseImmediate(); + } else { + throw new Error("Unknown node '" + id + "'"); + } + + return this; + }, + + expandAll: function() { + this.data("treetable").expandAll(); + return this; + }, + + expandNode: function(id) { + var node = this.data("treetable").tree[id]; + + if (node) { + if (!node.initialized) { + node._initialize(); + } + + node.expand(); + } else { + throw new Error("Unknown node '" + id + "'"); + } + + return this; + }, + + loadBranch: function(node, rows, atTop) { + var settings = this.data("treetable").settings, + tree = this.data("treetable").tree; + + // TODO Switch to $.parseHTML + rows = $(rows); + + if (node == null) { // potentially inserting new root nodes at top + atTop ? this.prepend(rows) : this.append(rows); + } else { + var lastNode = this.data("treetable").findLastNode(node); + rows.insertAfter(lastNode.row); + } + + this.data("treetable").loadRows(rows); + + // Make sure nodes are properly initialized + rows.filter("tr").each(function() { + tree[$(this).data(settings.nodeIdAttr)].show(); + }); + + if (node != null) { + // Re-render parent to ensure expander icon is shown (#79) + node.render().expand(); + } + + return this; + }, + + move: function(nodeId, destinationId) { + var destination, node; + + node = this.data("treetable").tree[nodeId]; + destination = this.data("treetable").tree[destinationId]; + this.data("treetable").move(node, destination); + + return this; + }, + + // Tony Additions + promote: function(nodeId) { + var node; + node = this.data("treetable").tree[nodeId]; + this.data("treetable").promote(node); + return this; + }, + insertAtTop: function(nodeId, beforeId) { + var node = this.data("treetable").tree[nodeId]; + var before = this.data("treetable").tree[beforeId]; + return this.data("treetable").insertAtTop(node, before); + }, + + + node: function(id) { + return this.data("treetable").tree[id]; + }, + + removeNode: function(id) { + var node = this.data("treetable").tree[id]; + + if (node) { + this.data("treetable").removeNode(node); + } else { + throw new Error("Unknown node '" + id + "'"); + } + + return this; + }, + + reveal: function(id) { + var node = this.data("treetable").tree[id]; + + if (node) { + node.reveal(); + } else { + throw new Error("Unknown node '" + id + "'"); + } + + return this; + }, + + sortBranch: function(node, columnOrFunction) { + var settings = this.data("treetable").settings, + prepValue, + sortFun; + + columnOrFunction = columnOrFunction || settings.column; + sortFun = columnOrFunction; + + if ($.isNumeric(columnOrFunction)) { + sortFun = function(a, b) { + var extractValue, valA, valB; + + extractValue = function(node) { + var val = node.row.find("td:eq(" + columnOrFunction + ")").text(); + // Ignore trailing/leading whitespace and use uppercase values for + // case insensitive ordering + return $.trim(val).toUpperCase(); + } + + valA = extractValue(a); + valB = extractValue(b); + + if (valA < valB) return -1; + if (valA > valB) return 1; + return 0; + }; + } + + this.data("treetable").sortBranch(node, sortFun); + return this; + }, + + unloadBranch: function(node) { + this.data("treetable").unloadBranch(node); + return this; + } + }; + + $.fn.treetable = function(method) { + if (methods[method]) { + return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); + } else if (typeof method === 'object' || !method) { + return methods.init.apply(this, arguments); + } else { + return $.error("Method " + method + " does not exist on jQuery.treetable"); + } + }; + + // Expose classes to world + window.TreeTable || (window.TreeTable = {}); + window.TreeTable.Node = Node; + window.TreeTable.Tree = Tree; +})(jQuery); diff --git a/versions/1.1/app/localFileManager.js b/versions/1.1/app/localFileManager.js new file mode 100644 index 0000000..3c9177f --- /dev/null +++ b/versions/1.1/app/localFileManager.js @@ -0,0 +1,308 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Handles local file storage interactions. + * + ***/ +'use strict'; + +const localFileManager = (() => { + + // The following is pulled from https://github.com/jakearchibald/idb-keyval + // Seems its the only way to persist the filehandle across sessions (needs deep clone) + // See https://stackoverflow.com/questions/65928613 + // ------------------------------------------------ + + function promisifyRequest(request) { + return new Promise((resolve, reject) => { + // @ts-ignore - file size hacks + request.oncomplete = request.onsuccess = () => resolve(request.result); + // @ts-ignore - file size hacks + request.onabort = request.onerror = () => reject(request.error); + }); + } + function createStore(dbName, storeName) { + const request = indexedDB.open(dbName); + request.onupgradeneeded = () => request.result.createObjectStore(storeName); + const dbp = promisifyRequest(request); + return (txMode, callback) => dbp.then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName))); + } + let defaultGetStoreFunc; + function defaultGetStore() { + if (!defaultGetStoreFunc) { + defaultGetStoreFunc = createStore('keyval-store', 'keyval'); + } + return defaultGetStoreFunc; + } + /** + * Get a value by its key. + * + * @param key + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ + function get(key, customStore = defaultGetStore()) { + return customStore('readonly', (store) => promisifyRequest(store.get(key))); + } + /** + * Set a value with a key. + * + * @param key + * @param value + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ + function set(key, value, customStore = defaultGetStore()) { + return customStore('readwrite', (store) => { + store.put(value, key); + return promisifyRequest(store.transaction); + }); + } + /** + * Delete a particular key from the store. + * + * @param key + * @param customStore Method to get a custom store. Use with caution (see the docs). + */ + function del(key, customStore = defaultGetStore()) { + return customStore('readwrite', (store) => { + store.delete(key); + return promisifyRequest(store.transaction); + }); + } + function clear(customStore = defaultGetStore()) { + return customStore('readwrite', (store) => { + store.clear(); + return promisifyRequest(store.transaction); + }); + } + // ------------------------------------------------ + + let LocalDirectoryHandle, LocalFileHandle, BackupDirectoryHandle; + let savePending = false; + async function saveBT(BTFileText) { + // Save BT file to local file for which permission was granted + console.log('writing to local file'); + savePending = true; + if (!LocalFileHandle) + LocalFileHandle = await authorizeLocalFile(); + + // Create a FileSystemWritableFileStream to write to. + const writable = await LocalFileHandle.createWritable(); + // Write the contents of the file to the stream. + await writable.write(BTFileText); + // Close the file and write the contents to disk. + await writable.close(); + savePending = false; + configManager.setProp('BTTimestamp', Date.now()); + } + + function savePendingP() { + return savePending; + } + + async function authorizeLocalFile() { + // Called from user action button to allow filesystem access and choose BT folder + if (typeof window.showSaveFilePicker !== "function") { + alert("Sorry, local file saving is not supported on your browser (NB Brave has a config setting to enable.)"); + return null; + } + alert("Choose where you want to store your BrainTool file"); + const options = {startIn: 'documents', create: true}; + LocalDirectoryHandle = await window.showDirectoryPicker(options); + if (!LocalDirectoryHandle) { + alert('Cancelling Local File sync'); + return null; + } + + let fileExists = false; + try { + for await (const entry of LocalDirectoryHandle.values()) { + console.log(entry.kind, entry.name); + if (entry.name == 'BrainTool.org') { + LocalFileHandle = entry; + fileExists = true; + break; + } + } + if (!LocalFileHandle) + LocalFileHandle = + await LocalDirectoryHandle.getFileHandle('BrainTool.org', { create: true }); + } catch (err) { + console.log(err); + alert('Error accessing local file, cancelling sync'); + return null; + } + + LocalFileConnected = true; // used in fileManager facade + if (fileExists && + confirm("BrainTool.org file already exists. Click OK to use its contents")) { + await refreshTable(true); + } else { + // else do a save to sync everything up + const content = BTAppNode.generateOrgFile(); + saveBT(content); + } + + set('localFileHandle', LocalFileHandle); // store for subsequent sessions + set('localDirectoryHandle', LocalDirectoryHandle); // store for subsequent sessions + return LocalFileHandle; + } + + async function reestablishLocalFilePermissions() { + // Called at startup. if there's a file handle (=> local storage option) set up perms + + LocalFileHandle = await get('localFileHandle'); + if (!LocalFileHandle) return false; + + if ((await LocalFileHandle.queryPermission({mode: 'readwrite'})) !== 'granted') { + // Request permission if needed + // Need to show prompt and wait for response before asking for perms + + // show request re-using edit dialog overlay + $("#dialog").hide(); + $("#permissions").show(); + $("#editOverlay").css("display", "block"); + + // wait for click on grant button + let p = new Promise(function (resolve, reject) { + var listener = async () => { + $("#editOverlay").off('click', listener); + await LocalFileHandle.requestPermission({mode: 'readwrite'}); + resolve(event); + }; + $("#grant").on('click', listener); + $("#editOverlay").on('click', listener); + }); + await p; + + // hide request overlay + $("#dialog").show(); + $("#permissions").hide(); + $("#editOverlay").css("display", "none"); + } + + // check if newer version on disk + LocalFileConnected = true; + const newerOnDisk = await checkBTFileVersion(); + if (newerOnDisk && confirm("Synced BrainTool.org file on disk is newer than browser data. \nHit Cancel to ignore or OK to load newer. \nUse newer?")) { + try { + await refreshTable(true); + } + catch (err) { + alert("Error parsing BrainTool.org file from local file:\n" + JSON.stringify(err)); + throw(err); + } + } + return true; + } + + async function getBTFile() { + // read file data + const file = await LocalFileHandle.getFile(); + const contents = await file.text(); + + configManager.setProp('BTTimestamp', file.lastModified); + BTFileText = contents; + } + + async function getFileLastModifiedTime() { + // get the file instance and extract its lastmodified timestamp + if ((await LocalFileHandle.queryPermission({mode: 'readwrite'})) !== 'granted') + return 0; + const file = await LocalFileHandle.getFile(); + return file.lastModified; + } + + async function checkBTFileVersion() { + // is there a newer version of the btfile on Drive? + + const remoteVersion = await getFileLastModifiedTime() || 0; + const localVersion = configManager.getProp('BTTimestamp') || 0; + console.log(`Checking timestamps. local: ${localVersion}, remote: ${remoteVersion}`); + return (remoteVersion > localVersion); + } + + async function getLocalFileHandle() { + return LocalFileHandle || await get('localFileHandle'); + } + async function getLocalDirectoryHandle() { + return LocalDirectoryHandle || await get('localDirectoryHandle'); + } + + async function getBackupDirectoryHandle() { + return BackupDirectoryHandle || await get('backupDirectoryHandle'); + } + + function reset() { + // utility to clear out memory of localFileHandle. Called when sync turned off. + clear(); + LocalFileHandle = null; + LocalDirectoryHandle = null; + //del('localFileHandle'); + //del('localDirectoryHandle'); + } + + async function initiateBackups() { + // find or create the BT-Backups folder + try { + LocalDirectoryHandle = LocalDirectoryHandle || await getLocalDirectoryHandle(); + const backupDirectoryHandle = await LocalDirectoryHandle.getDirectoryHandle('BT-Backups', {create: true}); + if (!backupDirectoryHandle) { + alert('Error creating BT-Backups folder'); + return; + } + BackupDirectoryHandle = backupDirectoryHandle; + set('backupDirectoryHandle', BackupDirectoryHandle); + alert(`Backups will be stored in "${BackupDirectoryHandle.name}" under "${LocalDirectoryHandle.name}"`); + } catch (err) { + alert('Error accessing BT-Backups folder'); + throw err; + } + } + + async function createBackup(name) { + // Copy current live BrainTool.org file into the backups folder and name it 'name' + BackupDirectoryHandle = BackupDirectoryHandle || await getBackupDirectoryHandle(); + const file = await LocalFileHandle.getFile(); + const stream = await file.stream(); + const backupFileHandle = await BackupDirectoryHandle.getFileHandle(name, {create: true}); + const writable = await backupFileHandle.createWritable(); + await stream.pipeTo(writable); + return name; + } + + async function deleteBackup(name) { + // find the named file in the backups directory and delete it + BackupDirectoryHandle = BackupDirectoryHandle || await getBackupDirectoryHandle(); + await BackupDirectoryHandle.removeEntry(name); + } + + return { + set: set, + get: get, + saveBT: saveBT, + authorizeLocalFile: authorizeLocalFile, + checkBTFileVersion: checkBTFileVersion, + reestablishLocalFilePermissions: reestablishLocalFilePermissions, + savePendingP: savePendingP, + getBTFile: getBTFile, + getLocalFileHandle: getLocalFileHandle, + getLocalDirectoryHandle: getLocalDirectoryHandle, + getFileLastModifiedTime: getFileLastModifiedTime, + reset: reset, + initiateBackups: initiateBackups, + createBackup: createBackup, + deleteBackup: deleteBackup + }; +})(); + + diff --git a/versions/1.1/app/messageManager.js b/versions/1.1/app/messageManager.js new file mode 100644 index 0000000..576546d --- /dev/null +++ b/versions/1.1/app/messageManager.js @@ -0,0 +1,214 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Handles posting messages to the tip panel + * Tips, Messages, Warnings + * + * + ***/ +'use strict'; + +const messageManager = (() => { + const tipsArray = [ + "Add ':' at the end of a topic in the Bookmarker to create a new subtopic.", + "Double click on a table row to go to it's tab or tabgroup, if it's open.", + "Type ':TODO' after a topic in the Bookmarker to make the item a TODO in the BT tree.", + "Create topics like ToRead or ToWatch to keep track of pages you want to come back to.", + "You'll need to Refresh if you've been editing the BrainTool.org file directly.", + `${OptionKey}-b is the BrainTool accelerator key. You can change that in the browsers extension settings`, + "Save LinkedIn pages under specific topics to keep track of your contacts in context.", + "Use the TODO (star) button on a row to toggle between TODO, DONE and none.", + "See BrainTool.org for the BrainTool blog and other info.", + "Follow @ABrainTool on X and other socials!", + "Check out the Bookmark import/export functions under Actions", + "You can click on the topics shown in the Bookmarker instead of typing out the name.", + "Use the forward (>>) button on the right to cycle through tips", + `Double tap ${OptionKey}-b, or double click the toolbar icon, to surface the Topic Manager.`, + `When you have an Edit card open, the ${OptionKey}-up/down arrows will open the next/previous card.`, + "Click on a row to select it then use keyboard commands. 'h' for a list of them.", + "You can also store local files and folders in BrainTool.
Enter something like 'file:///users/tconfrey/Documents/' in the browser address bar.", + "Try hitting '1','2','3' etc to collapse the tree to that level.", + "Import public topic trees and useful links from braintool.org/topicTrees.", + "Try the DARK theme. It's under Settings.", + "Tab cycles a selected topic from collapsed, to showing children, to showing all descendants.", + "😀 You can use emojis to 🌞 brighten up your topic names. 👏 🛠" + ]; + const messageArray = [ + "Welcome to the BrainTool 1.1!
See the release notes for a list of changes.", + "Local file backups are now available. See Settings.
NB GDrive syncing must be off (see Actions)." + ]; + const introSlidesArray = [ + `

This window is the Topic Manager.

It allows you to open and close tabs, tab groups, and browser windows, organize them into nested Topics and find them again when you need them.

`, + `

The BrainTool Bookmarker tool lives in the browser bar.

It allows you to save the current tab, tab group, window or session under a named Topic, along with an optional note.

Pin it for easy access.

`, + `

Use BrainTool to organize all the tabs you want to save and come back to. Hover over a row for tools to open and close groups of tabs, add notes and todo's or edit the topic hierarchy.

`, + `

Everything is kept in plain text in a private local file that you own and can edit, or under your personal Google Drive account for cloud access.

`, + `

See Search, Settings and Actions in the Header and Help below. Watch for Messages, Warnings and Tips on startup.

`, + `

Those are the basics. See the Help section and linked manuals and tutorial videos for more.

Now lets get started organizing your online life!

We've set you up with a sample Topic hierarchy. You might want to also pull in your bookmarks or save your current session (either one can be done later).

` + ]; + + let Warning = false, Message = false, lastShownMessageIndex = 0, lastShownSlideIndex = 0; + + + function showTip() { + // add random entry from the tipsArray + + // First make sure ui is set correctly (prev might have been warning) + if (Message) removeMessage(); + $("#messageTitle").html('Tip:'); + $("#messageNext").show(); + $("#messageClose").show(); + $("#messageContainer").css("cursor", "auto"); + $("#messageContainer").addClass('tip'); + $("table.treetable").css("margin-bottom", "80px"); + + // Then show random tip + const index = Math.floor(Math.random() * tipsArray.length); + $("#message").html(tipsArray[index]); + $("#messageContainer").show(); + } + + function hideMessage() { + // remove message window, readjust table accordingly + $("table.treetable").css("margin-bottom", "30px"); + $('#messageContainer').hide(); + } + + function showWarning(message, clickHandler) { + // change message container to show warning message (eg stale file from warnBTFileVersion) and do something on click + if (Message) removeMessage(); + $("#messageTitle").html('Warning!'); + $("#message").html(`${message}`); + $("#messageNext").hide(); + $("#messageClose").hide(); + $("#messageContainer").css("cursor", "pointer"); + $("#messageContainer").addClass('warning'); + $("#messageContainer").on('click', clickHandler); + $("#messageContainer").show(); + Warning = true; + } + + function removeWarning () { + if (!Warning) return; // nothing to remove + $("#messageContainer").hide(); + $("#messageContainer").removeClass('warning'); + $("#messageContainer").off('click'); // remove this handler + Warning = false; + }; + + function showMessage() { + // change message container to show informational message (eg new feature available) + if (lastShownMessageIndex >= messageArray.length) { + showTip(); + return; + } + const message = messageArray[lastShownMessageIndex]; + $("#messageTitle").html('Message'); + $("#message").html(message); + $("#messageContainer").addClass('message'); + $("#messageContainer").show(); + configManager.setProp('BTLastShownMessageIndex', ++lastShownMessageIndex); + Message = true; + } + function removeMessage() { + if (!Message) return; + $("#messageContainer").hide(); + $("#messageContainer").removeClass('message'); + Message = false; + }; + + + // show message/tip on startup + function setupMessages() { + lastShownMessageIndex = configManager.getProp('BTLastShownMessageIndex') || 0; + if (lastShownMessageIndex < messageArray.length) { + showMessage(); + } else + showTip(); + }; + + + function showIntro() { + // Show intro slides + showSlide(); + $("#editOverlay").css("display", "block"); + $("#dialog").css("display", "none"); + $("#intro").css("display", "block"); + } + + function hideIntro() { + // Hide intro slides + $("#editOverlay").css("display", "none"); + $("#intro").css("display", "none"); + } + + function showSlide() { + // Inject html for current slide index + if (lastShownSlideIndex == 0) $("#introPrev").hide(); + else $("#introPrev").show(); + $("#slide").html(introSlidesArray[lastShownSlideIndex]); + if (lastShownSlideIndex == (introSlidesArray.length - 1)) { + $("#introNext").hide(); + $("#introButtons").css("display", "flex"); + } else { + $("#introNext").show(); + // Show 'don't show again' footer after 2nd slide or after initial install + if (!InitialInstall || (lastShownSlideIndex >= 2)) $("#slideFooter").show(); + $("#introButtons").css("display", "none"); + } + $("#slideNum").text(lastShownSlideIndex+1); + } + + function nextSlide() { + lastShownSlideIndex += 1; + showSlide(); + } + + function prevSlide() { + lastShownSlideIndex -= 1; + showSlide(); + } + + function dontShowIntro() { + // Set a flag to not show intro again, indicate checked + configManager.setProp('BTDontShowIntro', true); + $("#dontShow").show(); + $("#slideFooter").css("color", "lightgrey"); + } + function bookmarksIntro() { + // Close slides and Import bookmarks + hideIntro(); + importBookmarks(); + } + function sessionIntro() { + // Close slides and Import bookmarks + hideIntro(); + importSession(); + } + + return { + setupMessages: setupMessages, + showMessage: showMessage, + hideMessage: hideMessage, + showWarning: showWarning, + removeWarning: removeWarning, + showIntro: showIntro, + hideIntro: hideIntro, + nextSlide: nextSlide, + prevSlide: prevSlide, + dontShowIntro: dontShowIntro, + bookmarksIntro: bookmarksIntro, + sessionIntro: sessionIntro + }; +})(); + + diff --git a/versions/1.1/app/orga-bundlev2.js b/versions/1.1/app/orga-bundlev2.js new file mode 100644 index 0000000..ca51bd9 --- /dev/null +++ b/versions/1.1/app/orga-bundlev2.js @@ -0,0 +1,5737 @@ +(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i= 0 ? over : 1000 + over; + return asUTC - asTS; +} + +function fixOffset(date, offset, timezoneString) { + var localTS = date.getTime(); // Our UTC time is just a guess because our offset is just a guess + + var utcGuess = localTS - offset; // Test whether the zone matches the offset for this ts + + var o2 = calcOffset(new Date(utcGuess), timezoneString); // If so, offset didn't change and we're done + + if (offset === o2) { + return offset; + } // If not, change the ts by the difference in the offset + + + utcGuess -= o2 - offset; // If that gives us the local time we want, we're done + + var o3 = calcOffset(new Date(utcGuess), timezoneString); + + if (o2 === o3) { + return o2; + } // If it's different, we're in a hole time. The offset has changed, but the we don't adjust the time + + + return Math.max(o2, o3); +} + +function validateTimezone(hours, minutes) { + if (minutes != null && (minutes < 0 || minutes > 59)) { + return false; + } + + return true; +} + +module.exports = exports.default; +},{"../tzTokenizeDate/index.js":3}],3:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = tzTokenizeDate; + +/** + * Returns the [year, month, day, hour, minute, seconds] tokens of the provided + * `date` as it will be rendered in the `timeZone`. + */ +function tzTokenizeDate(date, timeZone) { + var dtf = getDateTimeFormat(timeZone); + return dtf.formatToParts ? partsOffset(dtf, date) : hackyOffset(dtf, date); +} + +var typeToPos = { + year: 0, + month: 1, + day: 2, + hour: 3, + minute: 4, + second: 5 +}; + +function partsOffset(dtf, date) { + var formatted = dtf.formatToParts(date); + var filled = []; + + for (var i = 0; i < formatted.length; i++) { + var pos = typeToPos[formatted[i].type]; + + if (pos >= 0) { + filled[pos] = parseInt(formatted[i].value, 10); + } + } + + return filled; +} + +function hackyOffset(dtf, date) { + var formatted = dtf.format(date).replace(/\u200E/g, ''); + var parsed = /(\d+)\/(\d+)\/(\d+),? (\d+):(\d+):(\d+)/.exec(formatted); // var [, fMonth, fDay, fYear, fHour, fMinute, fSecond] = parsed + // return [fYear, fMonth, fDay, fHour, fMinute, fSecond] + + return [parsed[3], parsed[1], parsed[2], parsed[4], parsed[5], parsed[6]]; +} // Get a cached Intl.DateTimeFormat instance for the IANA `timeZone`. This can be used +// to get deterministic local date/time output according to the `en-US` locale which +// can be used to extract local time parts as necessary. + + +var dtfCache = {}; + +function getDateTimeFormat(timeZone) { + if (!dtfCache[timeZone]) { + // New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` + var testDateFormatted = new Intl.DateTimeFormat('en-US', { + hour12: false, + timeZone: 'America/New_York', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).format(new Date('2014-06-25T04:00:00.123Z')); + var hourCycleSupported = testDateFormatted === '06/25/2014, 00:00:00' || testDateFormatted === '‎06‎/‎25‎/‎2014‎ ‎00‎:‎00‎:‎00'; + dtfCache[timeZone] = hourCycleSupported ? new Intl.DateTimeFormat('en-US', { + hour12: false, + timeZone: timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) : new Intl.DateTimeFormat('en-US', { + hourCycle: 'h23', + timeZone: timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + + return dtfCache[timeZone]; +} + +module.exports = exports.default; +},{}],4:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _tzIntlTimeZoneName = _interopRequireDefault(require("../../_lib/tzIntlTimeZoneName")); + +var _tzParseTimezone = _interopRequireDefault(require("../../_lib/tzParseTimezone")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var MILLISECONDS_IN_MINUTE = 60 * 1000; +var formatters = { + // Timezone (ISO-8601. If offset is 0, output is always `'Z'`) + X: function (date, token, localize, options) { + var originalDate = options._originalDate || date; + var timezoneOffset = options.timeZone ? (0, _tzParseTimezone.default)(options.timeZone, originalDate) / MILLISECONDS_IN_MINUTE : originalDate.getTimezoneOffset(); + + if (timezoneOffset === 0) { + return 'Z'; + } + + switch (token) { + // Hours and optional minutes + case 'X': + return formatTimezoneWithOptionalMinutes(timezoneOffset); + // Hours, minutes and optional seconds without `:` delimeter + // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets + // so this token always has the same output as `XX` + + case 'XXXX': + case 'XX': + // Hours and minutes without `:` delimeter + return formatTimezone(timezoneOffset); + // Hours, minutes and optional seconds with `:` delimeter + // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets + // so this token always has the same output as `XXX` + + case 'XXXXX': + case 'XXX': // Hours and minutes with `:` delimeter + + default: + return formatTimezone(timezoneOffset, ':'); + } + }, + // Timezone (ISO-8601. If offset is 0, output is `'+00:00'` or equivalent) + x: function (date, token, localize, options) { + var originalDate = options._originalDate || date; + var timezoneOffset = options.timeZone ? (0, _tzParseTimezone.default)(options.timeZone, originalDate) / MILLISECONDS_IN_MINUTE : originalDate.getTimezoneOffset(); + + switch (token) { + // Hours and optional minutes + case 'x': + return formatTimezoneWithOptionalMinutes(timezoneOffset); + // Hours, minutes and optional seconds without `:` delimeter + // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets + // so this token always has the same output as `xx` + + case 'xxxx': + case 'xx': + // Hours and minutes without `:` delimeter + return formatTimezone(timezoneOffset); + // Hours, minutes and optional seconds with `:` delimeter + // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets + // so this token always has the same output as `xxx` + + case 'xxxxx': + case 'xxx': // Hours and minutes with `:` delimeter + + default: + return formatTimezone(timezoneOffset, ':'); + } + }, + // Timezone (GMT) + O: function (date, token, localize, options) { + var originalDate = options._originalDate || date; + var timezoneOffset = options.timeZone ? (0, _tzParseTimezone.default)(options.timeZone, originalDate) / MILLISECONDS_IN_MINUTE : originalDate.getTimezoneOffset(); + + switch (token) { + // Short + case 'O': + case 'OO': + case 'OOO': + return 'GMT' + formatTimezoneShort(timezoneOffset, ':'); + // Long + + case 'OOOO': + default: + return 'GMT' + formatTimezone(timezoneOffset, ':'); + } + }, + // Timezone (specific non-location) + z: function (date, token, localize, options) { + var originalDate = options._originalDate || date; + + switch (token) { + // Short + case 'z': + case 'zz': + case 'zzz': + return (0, _tzIntlTimeZoneName.default)('short', originalDate, options); + // Long + + case 'zzzz': + default: + return (0, _tzIntlTimeZoneName.default)('long', originalDate, options); + } + } +}; + +function addLeadingZeros(number, targetLength) { + var sign = number < 0 ? '-' : ''; + var output = Math.abs(number).toString(); + + while (output.length < targetLength) { + output = '0' + output; + } + + return sign + output; +} + +function formatTimezone(offset, dirtyDelimeter) { + var delimeter = dirtyDelimeter || ''; + var sign = offset > 0 ? '-' : '+'; + var absOffset = Math.abs(offset); + var hours = addLeadingZeros(Math.floor(absOffset / 60), 2); + var minutes = addLeadingZeros(absOffset % 60, 2); + return sign + hours + delimeter + minutes; +} + +function formatTimezoneWithOptionalMinutes(offset, dirtyDelimeter) { + if (offset % 60 === 0) { + var sign = offset > 0 ? '-' : '+'; + return sign + addLeadingZeros(Math.abs(offset) / 60, 2); + } + + return formatTimezone(offset, dirtyDelimeter); +} + +function formatTimezoneShort(offset, dirtyDelimeter) { + var sign = offset > 0 ? '-' : '+'; + var absOffset = Math.abs(offset); + var hours = Math.floor(absOffset / 60); + var minutes = absOffset % 60; + + if (minutes === 0) { + return sign + String(hours); + } + + var delimeter = dirtyDelimeter || ''; + return sign + String(hours) + delimeter + addLeadingZeros(minutes, 2); +} + +var _default = formatters; +exports.default = _default; +module.exports = exports.default; +},{"../../_lib/tzIntlTimeZoneName":1,"../../_lib/tzParseTimezone":2}],5:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = format; + +var _format = _interopRequireDefault(require("date-fns/format")); + +var _formatters = _interopRequireDefault(require("./formatters")); + +var _toDate = _interopRequireDefault(require("../toDate")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var tzFormattingTokensRegExp = /([xXOz]+)|''|'(''|[^'])+('|$)/g; +/** + * @name format + * @category Common Helpers + * @summary Format the date. + * + * @description + * Return the formatted date string in the given format. The result may vary by locale. + * + * > ⚠️ Please note that the `format` tokens differ from Moment.js and other libraries. + * > See: https://git.io/fxCyr + * + * The characters wrapped between two single quotes characters (') are escaped. + * Two single quotes in a row, whether inside or outside a quoted sequence, represent a 'real' single quote. + * (see the last example) + * + * Format of the string is based on Unicode Technical Standard #35: + * https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table + * with a few additions (see note 7 below the table). + * + * Accepted patterns: + * | Unit | Pattern | Result examples | Notes | + * |---------------------------------|---------|-----------------------------------|-------| + * | Era | G..GGG | AD, BC | | + * | | GGGG | Anno Domini, Before Christ | 2 | + * | | GGGGG | A, B | | + * | Calendar year | y | 44, 1, 1900, 2017 | 5 | + * | | yo | 44th, 1st, 0th, 17th | 5,7 | + * | | yy | 44, 01, 00, 17 | 5 | + * | | yyy | 044, 001, 1900, 2017 | 5 | + * | | yyyy | 0044, 0001, 1900, 2017 | 5 | + * | | yyyyy | ... | 3,5 | + * | Local week-numbering year | Y | 44, 1, 1900, 2017 | 5 | + * | | Yo | 44th, 1st, 1900th, 2017th | 5,7 | + * | | YY | 44, 01, 00, 17 | 5,8 | + * | | YYY | 044, 001, 1900, 2017 | 5 | + * | | YYYY | 0044, 0001, 1900, 2017 | 5,8 | + * | | YYYYY | ... | 3,5 | + * | ISO week-numbering year | R | -43, 0, 1, 1900, 2017 | 5,7 | + * | | RR | -43, 00, 01, 1900, 2017 | 5,7 | + * | | RRR | -043, 000, 001, 1900, 2017 | 5,7 | + * | | RRRR | -0043, 0000, 0001, 1900, 2017 | 5,7 | + * | | RRRRR | ... | 3,5,7 | + * | Extended year | u | -43, 0, 1, 1900, 2017 | 5 | + * | | uu | -43, 01, 1900, 2017 | 5 | + * | | uuu | -043, 001, 1900, 2017 | 5 | + * | | uuuu | -0043, 0001, 1900, 2017 | 5 | + * | | uuuuu | ... | 3,5 | + * | Quarter (formatting) | Q | 1, 2, 3, 4 | | + * | | Qo | 1st, 2nd, 3rd, 4th | 7 | + * | | QQ | 01, 02, 03, 04 | | + * | | QQQ | Q1, Q2, Q3, Q4 | | + * | | QQQQ | 1st quarter, 2nd quarter, ... | 2 | + * | | QQQQQ | 1, 2, 3, 4 | 4 | + * | Quarter (stand-alone) | q | 1, 2, 3, 4 | | + * | | qo | 1st, 2nd, 3rd, 4th | 7 | + * | | qq | 01, 02, 03, 04 | | + * | | qqq | Q1, Q2, Q3, Q4 | | + * | | qqqq | 1st quarter, 2nd quarter, ... | 2 | + * | | qqqqq | 1, 2, 3, 4 | 4 | + * | Month (formatting) | M | 1, 2, ..., 12 | | + * | | Mo | 1st, 2nd, ..., 12th | 7 | + * | | MM | 01, 02, ..., 12 | | + * | | MMM | Jan, Feb, ..., Dec | | + * | | MMMM | January, February, ..., December | 2 | + * | | MMMMM | J, F, ..., D | | + * | Month (stand-alone) | L | 1, 2, ..., 12 | | + * | | Lo | 1st, 2nd, ..., 12th | 7 | + * | | LL | 01, 02, ..., 12 | | + * | | LLL | Jan, Feb, ..., Dec | | + * | | LLLL | January, February, ..., December | 2 | + * | | LLLLL | J, F, ..., D | | + * | Local week of year | w | 1, 2, ..., 53 | | + * | | wo | 1st, 2nd, ..., 53th | 7 | + * | | ww | 01, 02, ..., 53 | | + * | ISO week of year | I | 1, 2, ..., 53 | 7 | + * | | Io | 1st, 2nd, ..., 53th | 7 | + * | | II | 01, 02, ..., 53 | 7 | + * | Day of month | d | 1, 2, ..., 31 | | + * | | do | 1st, 2nd, ..., 31st | 7 | + * | | dd | 01, 02, ..., 31 | | + * | Day of year | D | 1, 2, ..., 365, 366 | 8 | + * | | Do | 1st, 2nd, ..., 365th, 366th | 7 | + * | | DD | 01, 02, ..., 365, 366 | 8 | + * | | DDD | 001, 002, ..., 365, 366 | | + * | | DDDD | ... | 3 | + * | Day of week (formatting) | E..EEE | Mon, Tue, Wed, ..., Su | | + * | | EEEE | Monday, Tuesday, ..., Sunday | 2 | + * | | EEEEE | M, T, W, T, F, S, S | | + * | | EEEEEE | Mo, Tu, We, Th, Fr, Su, Sa | | + * | ISO day of week (formatting) | i | 1, 2, 3, ..., 7 | 7 | + * | | io | 1st, 2nd, ..., 7th | 7 | + * | | ii | 01, 02, ..., 07 | 7 | + * | | iii | Mon, Tue, Wed, ..., Su | 7 | + * | | iiii | Monday, Tuesday, ..., Sunday | 2,7 | + * | | iiiii | M, T, W, T, F, S, S | 7 | + * | | iiiiii | Mo, Tu, We, Th, Fr, Su, Sa | 7 | + * | Local day of week (formatting) | e | 2, 3, 4, ..., 1 | | + * | | eo | 2nd, 3rd, ..., 1st | 7 | + * | | ee | 02, 03, ..., 01 | | + * | | eee | Mon, Tue, Wed, ..., Su | | + * | | eeee | Monday, Tuesday, ..., Sunday | 2 | + * | | eeeee | M, T, W, T, F, S, S | | + * | | eeeeee | Mo, Tu, We, Th, Fr, Su, Sa | | + * | Local day of week (stand-alone) | c | 2, 3, 4, ..., 1 | | + * | | co | 2nd, 3rd, ..., 1st | 7 | + * | | cc | 02, 03, ..., 01 | | + * | | ccc | Mon, Tue, Wed, ..., Su | | + * | | cccc | Monday, Tuesday, ..., Sunday | 2 | + * | | ccccc | M, T, W, T, F, S, S | | + * | | cccccc | Mo, Tu, We, Th, Fr, Su, Sa | | + * | AM, PM | a..aaa | AM, PM | | + * | | aaaa | a.m., p.m. | 2 | + * | | aaaaa | a, p | | + * | AM, PM, noon, midnight | b..bbb | AM, PM, noon, midnight | | + * | | bbbb | a.m., p.m., noon, midnight | 2 | + * | | bbbbb | a, p, n, mi | | + * | Flexible day period | B..BBB | at night, in the morning, ... | | + * | | BBBB | at night, in the morning, ... | 2 | + * | | BBBBB | at night, in the morning, ... | | + * | Hour [1-12] | h | 1, 2, ..., 11, 12 | | + * | | ho | 1st, 2nd, ..., 11th, 12th | 7 | + * | | hh | 01, 02, ..., 11, 12 | | + * | Hour [0-23] | H | 0, 1, 2, ..., 23 | | + * | | Ho | 0th, 1st, 2nd, ..., 23rd | 7 | + * | | HH | 00, 01, 02, ..., 23 | | + * | Hour [0-11] | K | 1, 2, ..., 11, 0 | | + * | | Ko | 1st, 2nd, ..., 11th, 0th | 7 | + * | | KK | 1, 2, ..., 11, 0 | | + * | Hour [1-24] | k | 24, 1, 2, ..., 23 | | + * | | ko | 24th, 1st, 2nd, ..., 23rd | 7 | + * | | kk | 24, 01, 02, ..., 23 | | + * | Minute | m | 0, 1, ..., 59 | | + * | | mo | 0th, 1st, ..., 59th | 7 | + * | | mm | 00, 01, ..., 59 | | + * | Second | s | 0, 1, ..., 59 | | + * | | so | 0th, 1st, ..., 59th | 7 | + * | | ss | 00, 01, ..., 59 | | + * | Fraction of second | S | 0, 1, ..., 9 | | + * | | SS | 00, 01, ..., 99 | | + * | | SSS | 000, 0001, ..., 999 | | + * | | SSSS | ... | 3 | + * | Timezone (ISO-8601 w/ Z) | X | -08, +0530, Z | | + * | | XX | -0800, +0530, Z | | + * | | XXX | -08:00, +05:30, Z | | + * | | XXXX | -0800, +0530, Z, +123456 | 2 | + * | | XXXXX | -08:00, +05:30, Z, +12:34:56 | | + * | Timezone (ISO-8601 w/o Z) | x | -08, +0530, +00 | | + * | | xx | -0800, +0530, +0000 | | + * | | xxx | -08:00, +05:30, +00:00 | 2 | + * | | xxxx | -0800, +0530, +0000, +123456 | | + * | | xxxxx | -08:00, +05:30, +00:00, +12:34:56 | | + * | Timezone (GMT) | O...OOO | GMT-8, GMT+5:30, GMT+0 | | + * | | OOOO | GMT-08:00, GMT+05:30, GMT+00:00 | 2 | + * | Timezone (specific non-locat.) | z...zzz | PDT, EST, CEST | 6 | + * | | zzzz | Pacific Daylight Time | 2,6 | + * | Seconds timestamp | t | 512969520 | 7 | + * | | tt | ... | 3,7 | + * | Milliseconds timestamp | T | 512969520900 | 7 | + * | | TT | ... | 3,7 | + * | Long localized date | P | 05/29/1453 | 7 | + * | | PP | May 29, 1453 | 7 | + * | | PPP | May 29th, 1453 | 7 | + * | | PPPP | Sunday, May 29th, 1453 | 2,7 | + * | Long localized time | p | 12:00 AM | 7 | + * | | pp | 12:00:00 AM | 7 | + * | | ppp | 12:00:00 AM GMT+2 | 7 | + * | | pppp | 12:00:00 AM GMT+02:00 | 2,7 | + * | Combination of date and time | Pp | 05/29/1453, 12:00 AM | 7 | + * | | PPpp | May 29, 1453, 12:00:00 AM | 7 | + * | | PPPppp | May 29th, 1453 at ... | 7 | + * | | PPPPpppp| Sunday, May 29th, 1453 at ... | 2,7 | + * Notes: + * 1. "Formatting" units (e.g. formatting quarter) in the default en-US locale + * are the same as "stand-alone" units, but are different in some languages. + * "Formatting" units are declined according to the rules of the language + * in the context of a date. "Stand-alone" units are always nominative singular: + * + * `format(new Date(2017, 10, 6), 'do LLLL', {locale: cs}) //=> '6. listopad'` + * + * `format(new Date(2017, 10, 6), 'do MMMM', {locale: cs}) //=> '6. listopadu'` + * + * 2. Any sequence of the identical letters is a pattern, unless it is escaped by + * the single quote characters (see below). + * If the sequence is longer than listed in table (e.g. `EEEEEEEEEEE`) + * the output will be the same as default pattern for this unit, usually + * the longest one (in case of ISO weekdays, `EEEE`). Default patterns for units + * are marked with "2" in the last column of the table. + * + * `format(new Date(2017, 10, 6), 'MMM') //=> 'Nov'` + * + * `format(new Date(2017, 10, 6), 'MMMM') //=> 'November'` + * + * `format(new Date(2017, 10, 6), 'MMMMM') //=> 'N'` + * + * `format(new Date(2017, 10, 6), 'MMMMMM') //=> 'November'` + * + * `format(new Date(2017, 10, 6), 'MMMMMMM') //=> 'November'` + * + * 3. Some patterns could be unlimited length (such as `yyyyyyyy`). + * The output will be padded with zeros to match the length of the pattern. + * + * `format(new Date(2017, 10, 6), 'yyyyyyyy') //=> '00002017'` + * + * 4. `QQQQQ` and `qqqqq` could be not strictly numerical in some locales. + * These tokens represent the shortest form of the quarter. + * + * 5. The main difference between `y` and `u` patterns are B.C. years: + * + * | Year | `y` | `u` | + * |------|-----|-----| + * | AC 1 | 1 | 1 | + * | BC 1 | 1 | 0 | + * | BC 2 | 2 | -1 | + * + * Also `yy` always returns the last two digits of a year, + * while `uu` pads single digit years to 2 characters and returns other years unchanged: + * + * | Year | `yy` | `uu` | + * |------|------|------| + * | 1 | 01 | 01 | + * | 14 | 14 | 14 | + * | 376 | 76 | 376 | + * | 1453 | 53 | 1453 | + * + * The same difference is true for local and ISO week-numbering years (`Y` and `R`), + * except local week-numbering years are dependent on `options.weekStartsOn` + * and `options.firstWeekContainsDate` (compare [getISOWeekYear]{@link https://date-fns.org/docs/getISOWeekYear} + * and [getWeekYear]{@link https://date-fns.org/docs/getWeekYear}). + * + * 6. Specific non-location timezones are created using the Intl browser API. The output is determined by the + * preferred standard of the current locale (en-US by default) which may not always give the expected result. + * For this reason it is recommended to supply a `locale` in the format options when formatting a time zone name. + * + * 7. These patterns are not in the Unicode Technical Standard #35: + * - `i`: ISO day of week + * - `I`: ISO week of year + * - `R`: ISO week-numbering year + * - `t`: seconds timestamp + * - `T`: milliseconds timestamp + * - `o`: ordinal number modifier + * - `P`: long localized date + * - `p`: long localized time + * + * 8. These tokens are often confused with others. See: https://git.io/fxCyr + * + * + * ### v2.0.0 breaking changes: + * + * - [Changes that are common for the whole + * library](https://github.com/date-fns/date-fns/blob/master/docs/upgradeGuide.md#Common-Changes). + * + * - The second argument is now required for the sake of explicitness. + * + * ```javascript + * // Before v2.0.0 + * format(new Date(2016, 0, 1)) + * + * // v2.0.0 onward + * format(new Date(2016, 0, 1), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx") + * ``` + * + * - New format string API for `format` function + * which is based on [Unicode Technical Standard + * #35](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table). See [this + * post](https://blog.date-fns.org/post/unicode-tokens-in-date-fns-v2-sreatyki91jg) for more details. + * + * - Characters are now escaped using single quote symbols (`'`) instead of square brackets. + * + * @param {Date|String|Number} date - the original date + * @param {String} format - the string of tokens + * @param {OptionsWithTZ} [options] - the object with options. See [Options]{@link https://date-fns.org/docs/Options} + * @param {0|1|2} [options.additionalDigits=2] - passed to `toDate`. See [toDate]{@link + * https://date-fns.org/docs/toDate} + * @param {0|1|2|3|4|5|6} [options.weekStartsOn=0] - the index of the first day of the week (0 - Sunday) + * @param {Number} [options.firstWeekContainsDate=1] - the day of January, which is + * @param {Locale} [options.locale=defaultLocale] - the locale object. See + * [Locale]{@link https://date-fns.org/docs/Locale} + * @param {Boolean} [options.awareOfUnicodeTokens=false] - if true, allows usage of Unicode tokens causes confusion: + * - Some of the day of year tokens (`D`, `DD`) that are confused with the day of month tokens (`d`, `dd`). + * - Some of the local week-numbering year tokens (`YY`, `YYYY`) that are confused with the calendar year tokens + * (`yy`, `yyyy`). See: https://git.io/fxCyr + * @param {String} [options.timeZone=''] - used to specify the IANA time zone offset of a date String. + * @returns {String} the formatted date string + * @throws {TypeError} 2 arguments required + * @throws {RangeError} `options.additionalDigits` must be 0, 1 or 2 + * @throws {RangeError} `options.locale` must contain `localize` property + * @throws {RangeError} `options.locale` must contain `formatLong` property + * @throws {RangeError} `options.weekStartsOn` must be between 0 and 6 + * @throws {RangeError} `options.firstWeekContainsDate` must be between 1 and 7 + * @throws {RangeError} `options.awareOfUnicodeTokens` must be set to `true` to use `XX` token; see: + * https://git.io/fxCyr + * + * @example + * // Represent 11 February 2014 in middle-endian format: + * var result = format(new Date(2014, 1, 11), 'MM/dd/yyyy') + * //=> '02/11/2014' + * + * @example + * // Represent 2 July 2014 in Esperanto: + * import { eoLocale } from 'date-fns/locale/eo' + * var result = format(new Date(2014, 6, 2), "do 'de' MMMM yyyy", { + * locale: eoLocale + * }) + * //=> '2-a de julio 2014' + * + * @example + * // Escape string by single quote characters: + * var result = format(new Date(2014, 6, 2, 15), "h 'o''clock'") + * //=> "3 o'clock" + */ + +function format(dirtyDate, dirtyFormatStr, dirtyOptions) { + var formatStr = String(dirtyFormatStr); + var options = dirtyOptions || {}; + var matches = formatStr.match(tzFormattingTokensRegExp); + + if (matches) { + var date = (0, _toDate.default)(dirtyDate, options); + formatStr = matches.reduce(function (result, token) { + return token[0] === "'" ? result : result.replace(token, "'" + _formatters.default[token[0]](date, token, null, options) + "'"); + }, formatStr); + } + + return (0, _format.default)(dirtyDate, formatStr, options); +} + +module.exports = exports.default; +},{"../toDate":8,"./formatters":4,"date-fns/format":31}],6:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = getTimezoneOffset; + +var _tzParseTimezone = _interopRequireDefault(require("../_lib/tzParseTimezone")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * @name getTimezoneOffset + * @category Time Zone Helpers + * @summary Gets the offset in milliseconds between the time zone and Universal Coordinated Time (UTC) + * + * @description + * Returns the time zone offset from UTC time in milliseconds for IANA time zones as well + * as other time zone offset string formats. + * + * For time zones where daylight savings time is applicable a `Date` should be passed on + * the second parameter to ensure the offset correctly accounts for DST at that time of + * year. When omitted, the current date is used. + * + * @param {String} timeZone - the time zone of this local time, can be an offset or IANA time zone + * @param {Date|Number} [date] - the date with values representing the local time + * @returns {Number} the time zone offset in milliseconds + * + * @example + * const result = getTimezoneOffset('-07:00') + * //=> -18000000 (-7 * 60 * 60 * 1000) + * const result = getTimezoneOffset('Africa/Johannesburg') + * //=> 7200000 (2 * 60 * 60 * 1000) + * const result = getTimezoneOffset('America/New_York', new Date(2016, 0, 1)) + * //=> -18000000 (-5 * 60 * 60 * 1000) + * const result = getTimezoneOffset('America/New_York', new Date(2016, 6, 1)) + * //=> -14400000 (-4 * 60 * 60 * 1000) + */ +function getTimezoneOffset(timeZone, date) { + return -(0, _tzParseTimezone.default)(timeZone, date); +} + +module.exports = exports.default; +},{"../_lib/tzParseTimezone":2}],7:[function(require,module,exports){ +"use strict"; + +// This file is generated automatically by `scripts/build/indices.js`. Please, don't change it. +module.exports = { + format: require('./format/index.js'), + getTimezoneOffset: require('./getTimezoneOffset/index.js'), + toDate: require('./toDate/index.js'), + utcToZonedTime: require('./utcToZonedTime/index.js'), + zonedTimeToUtc: require('./zonedTimeToUtc/index.js') +}; +},{"./format/index.js":5,"./getTimezoneOffset/index.js":6,"./toDate/index.js":8,"./utcToZonedTime/index.js":9,"./zonedTimeToUtc/index.js":10}],8:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = toDate; + +var _index = _interopRequireDefault(require("date-fns/_lib/toInteger/index.js")); + +var _index2 = _interopRequireDefault(require("date-fns/_lib/getTimezoneOffsetInMilliseconds/index.js")); + +var _tzParseTimezone = _interopRequireDefault(require("../_lib/tzParseTimezone")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var MILLISECONDS_IN_HOUR = 3600000; +var MILLISECONDS_IN_MINUTE = 60000; +var DEFAULT_ADDITIONAL_DIGITS = 2; +var patterns = { + dateTimeDelimeter: /[T ]/, + plainTime: /:/, + timeZoneDelimeter: /[Z ]/i, + // year tokens + YY: /^(\d{2})$/, + YYY: [/^([+-]\d{2})$/, // 0 additional digits + /^([+-]\d{3})$/, // 1 additional digit + /^([+-]\d{4})$/ // 2 additional digits + ], + YYYY: /^(\d{4})/, + YYYYY: [/^([+-]\d{4})/, // 0 additional digits + /^([+-]\d{5})/, // 1 additional digit + /^([+-]\d{6})/ // 2 additional digits + ], + // date tokens + MM: /^-(\d{2})$/, + DDD: /^-?(\d{3})$/, + MMDD: /^-?(\d{2})-?(\d{2})$/, + Www: /^-?W(\d{2})$/, + WwwD: /^-?W(\d{2})-?(\d{1})$/, + HH: /^(\d{2}([.,]\d*)?)$/, + HHMM: /^(\d{2}):?(\d{2}([.,]\d*)?)$/, + HHMMSS: /^(\d{2}):?(\d{2}):?(\d{2}([.,]\d*)?)$/, + // timezone tokens (to identify the presence of a tz) + timezone: /([Z+-].*| UTC|(?:[a-zA-Z]+\/[a-zA-Z_]+(?:\/[a-zA-Z_]+)?))$/ +}; +/** + * @name toDate + * @category Common Helpers + * @summary Convert the given argument to an instance of Date. + * + * @description + * Convert the given argument to an instance of Date. + * + * If the argument is an instance of Date, the function returns its clone. + * + * If the argument is a number, it is treated as a timestamp. + * + * If an argument is a string, the function tries to parse it. + * Function accepts complete ISO 8601 formats as well as partial implementations. + * ISO 8601: http://en.wikipedia.org/wiki/ISO_8601 + * If the function cannot parse the string or the values are invalid, it returns Invalid Date. + * + * If the argument is none of the above, the function returns Invalid Date. + * + * **Note**: *all* Date arguments passed to any *date-fns* function is processed by `toDate`. + * All *date-fns* functions will throw `RangeError` if `options.additionalDigits` is not 0, 1, 2 or undefined. + * + * @param {Date|String|Number} argument - the value to convert + * @param {OptionsWithTZ} [options] - the object with options. See [Options]{@link https://date-fns.org/docs/Options} + * @param {0|1|2} [options.additionalDigits=2] - the additional number of digits in the extended year format + * @param {String} [options.timeZone=''] - used to specify the IANA time zone offset of a date String. + * @returns {Date} the parsed date in the local time zone + * @throws {TypeError} 1 argument required + * @throws {RangeError} `options.additionalDigits` must be 0, 1 or 2 + * + * @example + * // Convert string '2014-02-11T11:30:30' to date: + * var result = toDate('2014-02-11T11:30:30') + * //=> Tue Feb 11 2014 11:30:30 + * + * @example + * // Convert string '+02014101' to date, + * // if the additional number of digits in the extended year format is 1: + * var result = toDate('+02014101', {additionalDigits: 1}) + * //=> Fri Apr 11 2014 00:00:00 + */ + +function toDate(argument, dirtyOptions) { + if (arguments.length < 1) { + throw new TypeError('1 argument required, but only ' + arguments.length + ' present'); + } + + if (argument === null) { + return new Date(NaN); + } + + var options = dirtyOptions || {}; + var additionalDigits = options.additionalDigits == null ? DEFAULT_ADDITIONAL_DIGITS : (0, _index.default)(options.additionalDigits); + + if (additionalDigits !== 2 && additionalDigits !== 1 && additionalDigits !== 0) { + throw new RangeError('additionalDigits must be 0, 1 or 2'); + } // Clone the date + + + if (argument instanceof Date || typeof argument === 'object' && Object.prototype.toString.call(argument) === '[object Date]') { + // Prevent the date to lose the milliseconds when passed to new Date() in IE10 + return new Date(argument.getTime()); + } else if (typeof argument === 'number' || Object.prototype.toString.call(argument) === '[object Number]') { + return new Date(argument); + } else if (!(typeof argument === 'string' || Object.prototype.toString.call(argument) === '[object String]')) { + return new Date(NaN); + } + + var dateStrings = splitDateString(argument); + var parseYearResult = parseYear(dateStrings.date, additionalDigits); + var year = parseYearResult.year; + var restDateString = parseYearResult.restDateString; + var date = parseDate(restDateString, year); + + if (isNaN(date)) { + return new Date(NaN); + } + + if (date) { + var timestamp = date.getTime(); + var time = 0; + var offset; + + if (dateStrings.time) { + time = parseTime(dateStrings.time); + + if (isNaN(time)) { + return new Date(NaN); + } + } + + if (dateStrings.timezone || options.timeZone) { + offset = (0, _tzParseTimezone.default)(dateStrings.timezone || options.timeZone, new Date(timestamp + time)); + + if (isNaN(offset)) { + return new Date(NaN); + } + } else { + // get offset accurate to hour in timezones that change offset + offset = (0, _index2.default)(new Date(timestamp + time)); + offset = (0, _index2.default)(new Date(timestamp + time + offset)); + } + + return new Date(timestamp + time + offset); + } else { + return new Date(NaN); + } +} + +function splitDateString(dateString) { + var dateStrings = {}; + var array = dateString.split(patterns.dateTimeDelimeter); + var timeString; + + if (patterns.plainTime.test(array[0])) { + dateStrings.date = null; + timeString = array[0]; + } else { + dateStrings.date = array[0]; + timeString = array[1]; + dateStrings.timezone = array[2]; + + if (patterns.timeZoneDelimeter.test(dateStrings.date)) { + dateStrings.date = dateString.split(patterns.timeZoneDelimeter)[0]; + timeString = dateString.substr(dateStrings.date.length, dateString.length); + } + } + + if (timeString) { + var token = patterns.timezone.exec(timeString); + + if (token) { + dateStrings.time = timeString.replace(token[1], ''); + dateStrings.timezone = token[1]; + } else { + dateStrings.time = timeString; + } + } + + return dateStrings; +} + +function parseYear(dateString, additionalDigits) { + var patternYYY = patterns.YYY[additionalDigits]; + var patternYYYYY = patterns.YYYYY[additionalDigits]; + var token; // YYYY or ±YYYYY + + token = patterns.YYYY.exec(dateString) || patternYYYYY.exec(dateString); + + if (token) { + var yearString = token[1]; + return { + year: parseInt(yearString, 10), + restDateString: dateString.slice(yearString.length) + }; + } // YY or ±YYY + + + token = patterns.YY.exec(dateString) || patternYYY.exec(dateString); + + if (token) { + var centuryString = token[1]; + return { + year: parseInt(centuryString, 10) * 100, + restDateString: dateString.slice(centuryString.length) + }; + } // Invalid ISO-formatted year + + + return { + year: null + }; +} + +function parseDate(dateString, year) { + // Invalid ISO-formatted year + if (year === null) { + return null; + } + + var token; + var date; + var month; + var week; // YYYY + + if (dateString.length === 0) { + date = new Date(0); + date.setUTCFullYear(year); + return date; + } // YYYY-MM + + + token = patterns.MM.exec(dateString); + + if (token) { + date = new Date(0); + month = parseInt(token[1], 10) - 1; + + if (!validateDate(year, month)) { + return new Date(NaN); + } + + date.setUTCFullYear(year, month); + return date; + } // YYYY-DDD or YYYYDDD + + + token = patterns.DDD.exec(dateString); + + if (token) { + date = new Date(0); + var dayOfYear = parseInt(token[1], 10); + + if (!validateDayOfYearDate(year, dayOfYear)) { + return new Date(NaN); + } + + date.setUTCFullYear(year, 0, dayOfYear); + return date; + } // yyyy-MM-dd or YYYYMMDD + + + token = patterns.MMDD.exec(dateString); + + if (token) { + date = new Date(0); + month = parseInt(token[1], 10) - 1; + var day = parseInt(token[2], 10); + + if (!validateDate(year, month, day)) { + return new Date(NaN); + } + + date.setUTCFullYear(year, month, day); + return date; + } // YYYY-Www or YYYYWww + + + token = patterns.Www.exec(dateString); + + if (token) { + week = parseInt(token[1], 10) - 1; + + if (!validateWeekDate(year, week)) { + return new Date(NaN); + } + + return dayOfISOWeekYear(year, week); + } // YYYY-Www-D or YYYYWwwD + + + token = patterns.WwwD.exec(dateString); + + if (token) { + week = parseInt(token[1], 10) - 1; + var dayOfWeek = parseInt(token[2], 10) - 1; + + if (!validateWeekDate(year, week, dayOfWeek)) { + return new Date(NaN); + } + + return dayOfISOWeekYear(year, week, dayOfWeek); + } // Invalid ISO-formatted date + + + return null; +} + +function parseTime(timeString) { + var token; + var hours; + var minutes; // hh + + token = patterns.HH.exec(timeString); + + if (token) { + hours = parseFloat(token[1].replace(',', '.')); + + if (!validateTime(hours)) { + return NaN; + } + + return hours % 24 * MILLISECONDS_IN_HOUR; + } // hh:mm or hhmm + + + token = patterns.HHMM.exec(timeString); + + if (token) { + hours = parseInt(token[1], 10); + minutes = parseFloat(token[2].replace(',', '.')); + + if (!validateTime(hours, minutes)) { + return NaN; + } + + return hours % 24 * MILLISECONDS_IN_HOUR + minutes * MILLISECONDS_IN_MINUTE; + } // hh:mm:ss or hhmmss + + + token = patterns.HHMMSS.exec(timeString); + + if (token) { + hours = parseInt(token[1], 10); + minutes = parseInt(token[2], 10); + var seconds = parseFloat(token[3].replace(',', '.')); + + if (!validateTime(hours, minutes, seconds)) { + return NaN; + } + + return hours % 24 * MILLISECONDS_IN_HOUR + minutes * MILLISECONDS_IN_MINUTE + seconds * 1000; + } // Invalid ISO-formatted time + + + return null; +} + +function dayOfISOWeekYear(isoWeekYear, week, day) { + week = week || 0; + day = day || 0; + var date = new Date(0); + date.setUTCFullYear(isoWeekYear, 0, 4); + var fourthOfJanuaryDay = date.getUTCDay() || 7; + var diff = week * 7 + day + 1 - fourthOfJanuaryDay; + date.setUTCDate(date.getUTCDate() + diff); + return date; +} // Validation functions + + +var DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +var DAYS_IN_MONTH_LEAP_YEAR = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +function isLeapYearIndex(year) { + return year % 400 === 0 || year % 4 === 0 && year % 100 !== 0; +} + +function validateDate(year, month, date) { + if (month < 0 || month > 11) { + return false; + } + + if (date != null) { + if (date < 1) { + return false; + } + + var isLeapYear = isLeapYearIndex(year); + + if (isLeapYear && date > DAYS_IN_MONTH_LEAP_YEAR[month]) { + return false; + } + + if (!isLeapYear && date > DAYS_IN_MONTH[month]) { + return false; + } + } + + return true; +} + +function validateDayOfYearDate(year, dayOfYear) { + if (dayOfYear < 1) { + return false; + } + + var isLeapYear = isLeapYearIndex(year); + + if (isLeapYear && dayOfYear > 366) { + return false; + } + + if (!isLeapYear && dayOfYear > 365) { + return false; + } + + return true; +} + +function validateWeekDate(year, week, day) { + if (week < 0 || week > 52) { + return false; + } + + if (day != null && (day < 0 || day > 6)) { + return false; + } + + return true; +} + +function validateTime(hours, minutes, seconds) { + if (hours != null && (hours < 0 || hours >= 25)) { + return false; + } + + if (minutes != null && (minutes < 0 || minutes >= 60)) { + return false; + } + + if (seconds != null && (seconds < 0 || seconds >= 60)) { + return false; + } + + return true; +} + +module.exports = exports.default; +},{"../_lib/tzParseTimezone":2,"date-fns/_lib/getTimezoneOffsetInMilliseconds/index.js":17,"date-fns/_lib/toInteger/index.js":29}],9:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = utcToZonedTime; + +var _tzParseTimezone = _interopRequireDefault(require("../_lib/tzParseTimezone")); + +var _toDate = _interopRequireDefault(require("../toDate")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * @name utcToZonedTime + * @category Time Zone Helpers + * @summary Get a date/time representing local time in a given time zone from the UTC date + * + * @description + * Returns a date instance with values representing the local time in the time zone + * specified of the UTC time from the date provided. In other words, when the new date + * is formatted it will show the equivalent hours in the target time zone regardless + * of the current system time zone. + * + * @param {Date|String|Number} date - the date with the relevant UTC time + * @param {String} timeZone - the time zone to get local time for, can be an offset or IANA time zone + * @param {OptionsWithTZ} [options] - the object with options. See [Options]{@link https://date-fns.org/docs/Options} + * @param {0|1|2} [options.additionalDigits=2] - passed to `toDate`. See [toDate]{@link https://date-fns.org/docs/toDate} + * @returns {Date} the new date with the equivalent time in the time zone + * @throws {TypeError} 2 arguments required + * @throws {RangeError} `options.additionalDigits` must be 0, 1 or 2 + * + * @example + * // In June 10am UTC is 6am in New York (-04:00) + * const result = utcToZonedTime('2014-06-25T10:00:00.000Z', 'America/New_York') + * //=> Jun 25 2014 06:00:00 + */ +function utcToZonedTime(dirtyDate, timeZone, options) { + var date = (0, _toDate.default)(dirtyDate, options); + var offsetMilliseconds = (0, _tzParseTimezone.default)(timeZone, date, true) || 0; + var d = new Date(date.getTime() - offsetMilliseconds); + var zonedTime = new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()); + return zonedTime; +} + +module.exports = exports.default; +},{"../_lib/tzParseTimezone":2,"../toDate":8}],10:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = zonedTimeToUtc; + +var _cloneObject = _interopRequireDefault(require("date-fns/_lib/cloneObject")); + +var _format = _interopRequireDefault(require("date-fns/format")); + +var _toDate = _interopRequireDefault(require("../toDate")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * @name zonedTimeToUtc + * @category Time Zone Helpers + * @summary Get the UTC date/time from a date representing local time in a given time zone + * + * @description + * Returns a date instance with the UTC time of the provided date of which the values + * represented the local time in the time zone specified. In other words, if the input + * date represented local time in time time zone, the timestamp of the output date will + * give the equivalent UTC of that local time regardless of the current system time zone. + * + * @param {Date|String|Number} date - the date with values representing the local time + * @param {String} timeZone - the time zone of this local time, can be an offset or IANA time zone + * @param {OptionsWithTZ} [options] - the object with options. See [Options]{@link https://date-fns.org/docs/Options} + * @param {0|1|2} [options.additionalDigits=2] - passed to `toDate`. See [toDate]{@link https://date-fns.org/docs/toDate} + * @returns {Date} the new date with the equivalent time in the time zone + * @throws {TypeError} 2 arguments required + * @throws {RangeError} `options.additionalDigits` must be 0, 1 or 2 + * + * @example + * // In June 10am in Los Angeles is 5pm UTC + * const result = zonedTimeToUtc(new Date(2014, 5, 25, 10, 0, 0), 'America/Los_Angeles') + * //=> 2014-06-25T17:00:00.000Z + */ +function zonedTimeToUtc(date, timeZone, options) { + if (date instanceof Date) { + date = (0, _format.default)(date, "yyyy-MM-dd'T'HH:mm:ss.SSS"); + } + + var extendedOptions = (0, _cloneObject.default)(options); + extendedOptions.timeZone = timeZone; + return (0, _toDate.default)(date, extendedOptions); +} + +module.exports = exports.default; +},{"../toDate":8,"date-fns/_lib/cloneObject":13,"date-fns/format":31}],11:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = addLeadingZeros; + +function addLeadingZeros(number, targetLength) { + var sign = number < 0 ? '-' : ''; + var output = Math.abs(number).toString(); + + while (output.length < targetLength) { + output = '0' + output; + } + + return sign + output; +} + +module.exports = exports.default; +},{}],12:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = assign; + +function assign(target, dirtyObject) { + if (target == null) { + throw new TypeError('assign requires that input parameter not be null or undefined'); + } + + dirtyObject = dirtyObject || {}; + + for (var property in dirtyObject) { + if (dirtyObject.hasOwnProperty(property)) { + target[property] = dirtyObject[property]; + } + } + + return target; +} + +module.exports = exports.default; +},{}],13:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = cloneObject; + +var _index = _interopRequireDefault(require("../assign/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function cloneObject(dirtyObject) { + return (0, _index.default)({}, dirtyObject); +} + +module.exports = exports.default; +},{"../assign/index.js":12}],14:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _index = _interopRequireDefault(require("../lightFormatters/index.js")); + +var _index2 = _interopRequireDefault(require("../../../_lib/getUTCDayOfYear/index.js")); + +var _index3 = _interopRequireDefault(require("../../../_lib/getUTCISOWeek/index.js")); + +var _index4 = _interopRequireDefault(require("../../../_lib/getUTCISOWeekYear/index.js")); + +var _index5 = _interopRequireDefault(require("../../../_lib/getUTCWeek/index.js")); + +var _index6 = _interopRequireDefault(require("../../../_lib/getUTCWeekYear/index.js")); + +var _index7 = _interopRequireDefault(require("../../addLeadingZeros/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var dayPeriodEnum = { + am: 'am', + pm: 'pm', + midnight: 'midnight', + noon: 'noon', + morning: 'morning', + afternoon: 'afternoon', + evening: 'evening', + night: 'night' + /* + * | | Unit | | Unit | + * |-----|--------------------------------|-----|--------------------------------| + * | a | AM, PM | A* | Milliseconds in day | + * | b | AM, PM, noon, midnight | B | Flexible day period | + * | c | Stand-alone local day of week | C* | Localized hour w/ day period | + * | d | Day of month | D | Day of year | + * | e | Local day of week | E | Day of week | + * | f | | F* | Day of week in month | + * | g* | Modified Julian day | G | Era | + * | h | Hour [1-12] | H | Hour [0-23] | + * | i! | ISO day of week | I! | ISO week of year | + * | j* | Localized hour w/ day period | J* | Localized hour w/o day period | + * | k | Hour [1-24] | K | Hour [0-11] | + * | l* | (deprecated) | L | Stand-alone month | + * | m | Minute | M | Month | + * | n | | N | | + * | o! | Ordinal number modifier | O | Timezone (GMT) | + * | p! | Long localized time | P! | Long localized date | + * | q | Stand-alone quarter | Q | Quarter | + * | r* | Related Gregorian year | R! | ISO week-numbering year | + * | s | Second | S | Fraction of second | + * | t! | Seconds timestamp | T! | Milliseconds timestamp | + * | u | Extended year | U* | Cyclic year | + * | v* | Timezone (generic non-locat.) | V* | Timezone (location) | + * | w | Local week of year | W* | Week of month | + * | x | Timezone (ISO-8601 w/o Z) | X | Timezone (ISO-8601) | + * | y | Year (abs) | Y | Local week-numbering year | + * | z | Timezone (specific non-locat.) | Z* | Timezone (aliases) | + * + * Letters marked by * are not implemented but reserved by Unicode standard. + * + * Letters marked by ! are non-standard, but implemented by date-fns: + * - `o` modifies the previous token to turn it into an ordinal (see `format` docs) + * - `i` is ISO day of week. For `i` and `ii` is returns numeric ISO week days, + * i.e. 7 for Sunday, 1 for Monday, etc. + * - `I` is ISO week of year, as opposed to `w` which is local week of year. + * - `R` is ISO week-numbering year, as opposed to `Y` which is local week-numbering year. + * `R` is supposed to be used in conjunction with `I` and `i` + * for universal ISO week-numbering date, whereas + * `Y` is supposed to be used in conjunction with `w` and `e` + * for week-numbering date specific to the locale. + * - `P` is long localized date format + * - `p` is long localized time format + */ + +}; +var formatters = { + // Era + G: function (date, token, localize) { + var era = date.getUTCFullYear() > 0 ? 1 : 0; + + switch (token) { + // AD, BC + case 'G': + case 'GG': + case 'GGG': + return localize.era(era, { + width: 'abbreviated' + }); + // A, B + + case 'GGGGG': + return localize.era(era, { + width: 'narrow' + }); + // Anno Domini, Before Christ + + case 'GGGG': + default: + return localize.era(era, { + width: 'wide' + }); + } + }, + // Year + y: function (date, token, localize) { + // Ordinal number + if (token === 'yo') { + var signedYear = date.getUTCFullYear(); // Returns 1 for 1 BC (which is year 0 in JavaScript) + + var year = signedYear > 0 ? signedYear : 1 - signedYear; + return localize.ordinalNumber(year, { + unit: 'year' + }); + } + + return _index.default.y(date, token); + }, + // Local week-numbering year + Y: function (date, token, localize, options) { + var signedWeekYear = (0, _index6.default)(date, options); // Returns 1 for 1 BC (which is year 0 in JavaScript) + + var weekYear = signedWeekYear > 0 ? signedWeekYear : 1 - signedWeekYear; // Two digit year + + if (token === 'YY') { + var twoDigitYear = weekYear % 100; + return (0, _index7.default)(twoDigitYear, 2); + } // Ordinal number + + + if (token === 'Yo') { + return localize.ordinalNumber(weekYear, { + unit: 'year' + }); + } // Padding + + + return (0, _index7.default)(weekYear, token.length); + }, + // ISO week-numbering year + R: function (date, token) { + var isoWeekYear = (0, _index4.default)(date); // Padding + + return (0, _index7.default)(isoWeekYear, token.length); + }, + // Extended year. This is a single number designating the year of this calendar system. + // The main difference between `y` and `u` localizers are B.C. years: + // | Year | `y` | `u` | + // |------|-----|-----| + // | AC 1 | 1 | 1 | + // | BC 1 | 1 | 0 | + // | BC 2 | 2 | -1 | + // Also `yy` always returns the last two digits of a year, + // while `uu` pads single digit years to 2 characters and returns other years unchanged. + u: function (date, token) { + var year = date.getUTCFullYear(); + return (0, _index7.default)(year, token.length); + }, + // Quarter + Q: function (date, token, localize) { + var quarter = Math.ceil((date.getUTCMonth() + 1) / 3); + + switch (token) { + // 1, 2, 3, 4 + case 'Q': + return String(quarter); + // 01, 02, 03, 04 + + case 'QQ': + return (0, _index7.default)(quarter, 2); + // 1st, 2nd, 3rd, 4th + + case 'Qo': + return localize.ordinalNumber(quarter, { + unit: 'quarter' + }); + // Q1, Q2, Q3, Q4 + + case 'QQQ': + return localize.quarter(quarter, { + width: 'abbreviated', + context: 'formatting' + }); + // 1, 2, 3, 4 (narrow quarter; could be not numerical) + + case 'QQQQQ': + return localize.quarter(quarter, { + width: 'narrow', + context: 'formatting' + }); + // 1st quarter, 2nd quarter, ... + + case 'QQQQ': + default: + return localize.quarter(quarter, { + width: 'wide', + context: 'formatting' + }); + } + }, + // Stand-alone quarter + q: function (date, token, localize) { + var quarter = Math.ceil((date.getUTCMonth() + 1) / 3); + + switch (token) { + // 1, 2, 3, 4 + case 'q': + return String(quarter); + // 01, 02, 03, 04 + + case 'qq': + return (0, _index7.default)(quarter, 2); + // 1st, 2nd, 3rd, 4th + + case 'qo': + return localize.ordinalNumber(quarter, { + unit: 'quarter' + }); + // Q1, Q2, Q3, Q4 + + case 'qqq': + return localize.quarter(quarter, { + width: 'abbreviated', + context: 'standalone' + }); + // 1, 2, 3, 4 (narrow quarter; could be not numerical) + + case 'qqqqq': + return localize.quarter(quarter, { + width: 'narrow', + context: 'standalone' + }); + // 1st quarter, 2nd quarter, ... + + case 'qqqq': + default: + return localize.quarter(quarter, { + width: 'wide', + context: 'standalone' + }); + } + }, + // Month + M: function (date, token, localize) { + var month = date.getUTCMonth(); + + switch (token) { + case 'M': + case 'MM': + return _index.default.M(date, token); + // 1st, 2nd, ..., 12th + + case 'Mo': + return localize.ordinalNumber(month + 1, { + unit: 'month' + }); + // Jan, Feb, ..., Dec + + case 'MMM': + return localize.month(month, { + width: 'abbreviated', + context: 'formatting' + }); + // J, F, ..., D + + case 'MMMMM': + return localize.month(month, { + width: 'narrow', + context: 'formatting' + }); + // January, February, ..., December + + case 'MMMM': + default: + return localize.month(month, { + width: 'wide', + context: 'formatting' + }); + } + }, + // Stand-alone month + L: function (date, token, localize) { + var month = date.getUTCMonth(); + + switch (token) { + // 1, 2, ..., 12 + case 'L': + return String(month + 1); + // 01, 02, ..., 12 + + case 'LL': + return (0, _index7.default)(month + 1, 2); + // 1st, 2nd, ..., 12th + + case 'Lo': + return localize.ordinalNumber(month + 1, { + unit: 'month' + }); + // Jan, Feb, ..., Dec + + case 'LLL': + return localize.month(month, { + width: 'abbreviated', + context: 'standalone' + }); + // J, F, ..., D + + case 'LLLLL': + return localize.month(month, { + width: 'narrow', + context: 'standalone' + }); + // January, February, ..., December + + case 'LLLL': + default: + return localize.month(month, { + width: 'wide', + context: 'standalone' + }); + } + }, + // Local week of year + w: function (date, token, localize, options) { + var week = (0, _index5.default)(date, options); + + if (token === 'wo') { + return localize.ordinalNumber(week, { + unit: 'week' + }); + } + + return (0, _index7.default)(week, token.length); + }, + // ISO week of year + I: function (date, token, localize) { + var isoWeek = (0, _index3.default)(date); + + if (token === 'Io') { + return localize.ordinalNumber(isoWeek, { + unit: 'week' + }); + } + + return (0, _index7.default)(isoWeek, token.length); + }, + // Day of the month + d: function (date, token, localize) { + if (token === 'do') { + return localize.ordinalNumber(date.getUTCDate(), { + unit: 'date' + }); + } + + return _index.default.d(date, token); + }, + // Day of year + D: function (date, token, localize) { + var dayOfYear = (0, _index2.default)(date); + + if (token === 'Do') { + return localize.ordinalNumber(dayOfYear, { + unit: 'dayOfYear' + }); + } + + return (0, _index7.default)(dayOfYear, token.length); + }, + // Day of week + E: function (date, token, localize) { + var dayOfWeek = date.getUTCDay(); + + switch (token) { + // Tue + case 'E': + case 'EE': + case 'EEE': + return localize.day(dayOfWeek, { + width: 'abbreviated', + context: 'formatting' + }); + // T + + case 'EEEEE': + return localize.day(dayOfWeek, { + width: 'narrow', + context: 'formatting' + }); + // Tu + + case 'EEEEEE': + return localize.day(dayOfWeek, { + width: 'short', + context: 'formatting' + }); + // Tuesday + + case 'EEEE': + default: + return localize.day(dayOfWeek, { + width: 'wide', + context: 'formatting' + }); + } + }, + // Local day of week + e: function (date, token, localize, options) { + var dayOfWeek = date.getUTCDay(); + var localDayOfWeek = (dayOfWeek - options.weekStartsOn + 8) % 7 || 7; + + switch (token) { + // Numerical value (Nth day of week with current locale or weekStartsOn) + case 'e': + return String(localDayOfWeek); + // Padded numerical value + + case 'ee': + return (0, _index7.default)(localDayOfWeek, 2); + // 1st, 2nd, ..., 7th + + case 'eo': + return localize.ordinalNumber(localDayOfWeek, { + unit: 'day' + }); + + case 'eee': + return localize.day(dayOfWeek, { + width: 'abbreviated', + context: 'formatting' + }); + // T + + case 'eeeee': + return localize.day(dayOfWeek, { + width: 'narrow', + context: 'formatting' + }); + // Tu + + case 'eeeeee': + return localize.day(dayOfWeek, { + width: 'short', + context: 'formatting' + }); + // Tuesday + + case 'eeee': + default: + return localize.day(dayOfWeek, { + width: 'wide', + context: 'formatting' + }); + } + }, + // Stand-alone local day of week + c: function (date, token, localize, options) { + var dayOfWeek = date.getUTCDay(); + var localDayOfWeek = (dayOfWeek - options.weekStartsOn + 8) % 7 || 7; + + switch (token) { + // Numerical value (same as in `e`) + case 'c': + return String(localDayOfWeek); + // Padded numerical value + + case 'cc': + return (0, _index7.default)(localDayOfWeek, token.length); + // 1st, 2nd, ..., 7th + + case 'co': + return localize.ordinalNumber(localDayOfWeek, { + unit: 'day' + }); + + case 'ccc': + return localize.day(dayOfWeek, { + width: 'abbreviated', + context: 'standalone' + }); + // T + + case 'ccccc': + return localize.day(dayOfWeek, { + width: 'narrow', + context: 'standalone' + }); + // Tu + + case 'cccccc': + return localize.day(dayOfWeek, { + width: 'short', + context: 'standalone' + }); + // Tuesday + + case 'cccc': + default: + return localize.day(dayOfWeek, { + width: 'wide', + context: 'standalone' + }); + } + }, + // ISO day of week + i: function (date, token, localize) { + var dayOfWeek = date.getUTCDay(); + var isoDayOfWeek = dayOfWeek === 0 ? 7 : dayOfWeek; + + switch (token) { + // 2 + case 'i': + return String(isoDayOfWeek); + // 02 + + case 'ii': + return (0, _index7.default)(isoDayOfWeek, token.length); + // 2nd + + case 'io': + return localize.ordinalNumber(isoDayOfWeek, { + unit: 'day' + }); + // Tue + + case 'iii': + return localize.day(dayOfWeek, { + width: 'abbreviated', + context: 'formatting' + }); + // T + + case 'iiiii': + return localize.day(dayOfWeek, { + width: 'narrow', + context: 'formatting' + }); + // Tu + + case 'iiiiii': + return localize.day(dayOfWeek, { + width: 'short', + context: 'formatting' + }); + // Tuesday + + case 'iiii': + default: + return localize.day(dayOfWeek, { + width: 'wide', + context: 'formatting' + }); + } + }, + // AM or PM + a: function (date, token, localize) { + var hours = date.getUTCHours(); + var dayPeriodEnumValue = hours / 12 >= 1 ? 'pm' : 'am'; + + switch (token) { + case 'a': + case 'aa': + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'abbreviated', + context: 'formatting' + }); + + case 'aaa': + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'abbreviated', + context: 'formatting' + }).toLowerCase(); + + case 'aaaaa': + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'narrow', + context: 'formatting' + }); + + case 'aaaa': + default: + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'wide', + context: 'formatting' + }); + } + }, + // AM, PM, midnight, noon + b: function (date, token, localize) { + var hours = date.getUTCHours(); + var dayPeriodEnumValue; + + if (hours === 12) { + dayPeriodEnumValue = dayPeriodEnum.noon; + } else if (hours === 0) { + dayPeriodEnumValue = dayPeriodEnum.midnight; + } else { + dayPeriodEnumValue = hours / 12 >= 1 ? 'pm' : 'am'; + } + + switch (token) { + case 'b': + case 'bb': + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'abbreviated', + context: 'formatting' + }); + + case 'bbb': + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'abbreviated', + context: 'formatting' + }).toLowerCase(); + + case 'bbbbb': + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'narrow', + context: 'formatting' + }); + + case 'bbbb': + default: + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'wide', + context: 'formatting' + }); + } + }, + // in the morning, in the afternoon, in the evening, at night + B: function (date, token, localize) { + var hours = date.getUTCHours(); + var dayPeriodEnumValue; + + if (hours >= 17) { + dayPeriodEnumValue = dayPeriodEnum.evening; + } else if (hours >= 12) { + dayPeriodEnumValue = dayPeriodEnum.afternoon; + } else if (hours >= 4) { + dayPeriodEnumValue = dayPeriodEnum.morning; + } else { + dayPeriodEnumValue = dayPeriodEnum.night; + } + + switch (token) { + case 'B': + case 'BB': + case 'BBB': + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'abbreviated', + context: 'formatting' + }); + + case 'BBBBB': + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'narrow', + context: 'formatting' + }); + + case 'BBBB': + default: + return localize.dayPeriod(dayPeriodEnumValue, { + width: 'wide', + context: 'formatting' + }); + } + }, + // Hour [1-12] + h: function (date, token, localize) { + if (token === 'ho') { + var hours = date.getUTCHours() % 12; + if (hours === 0) hours = 12; + return localize.ordinalNumber(hours, { + unit: 'hour' + }); + } + + return _index.default.h(date, token); + }, + // Hour [0-23] + H: function (date, token, localize) { + if (token === 'Ho') { + return localize.ordinalNumber(date.getUTCHours(), { + unit: 'hour' + }); + } + + return _index.default.H(date, token); + }, + // Hour [0-11] + K: function (date, token, localize) { + var hours = date.getUTCHours() % 12; + + if (token === 'Ko') { + return localize.ordinalNumber(hours, { + unit: 'hour' + }); + } + + return (0, _index7.default)(hours, token.length); + }, + // Hour [1-24] + k: function (date, token, localize) { + var hours = date.getUTCHours(); + if (hours === 0) hours = 24; + + if (token === 'ko') { + return localize.ordinalNumber(hours, { + unit: 'hour' + }); + } + + return (0, _index7.default)(hours, token.length); + }, + // Minute + m: function (date, token, localize) { + if (token === 'mo') { + return localize.ordinalNumber(date.getUTCMinutes(), { + unit: 'minute' + }); + } + + return _index.default.m(date, token); + }, + // Second + s: function (date, token, localize) { + if (token === 'so') { + return localize.ordinalNumber(date.getUTCSeconds(), { + unit: 'second' + }); + } + + return _index.default.s(date, token); + }, + // Fraction of second + S: function (date, token) { + return _index.default.S(date, token); + }, + // Timezone (ISO-8601. If offset is 0, output is always `'Z'`) + X: function (date, token, _localize, options) { + var originalDate = options._originalDate || date; + var timezoneOffset = originalDate.getTimezoneOffset(); + + if (timezoneOffset === 0) { + return 'Z'; + } + + switch (token) { + // Hours and optional minutes + case 'X': + return formatTimezoneWithOptionalMinutes(timezoneOffset); + // Hours, minutes and optional seconds without `:` delimiter + // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets + // so this token always has the same output as `XX` + + case 'XXXX': + case 'XX': + // Hours and minutes without `:` delimiter + return formatTimezone(timezoneOffset); + // Hours, minutes and optional seconds with `:` delimiter + // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets + // so this token always has the same output as `XXX` + + case 'XXXXX': + case 'XXX': // Hours and minutes with `:` delimiter + + default: + return formatTimezone(timezoneOffset, ':'); + } + }, + // Timezone (ISO-8601. If offset is 0, output is `'+00:00'` or equivalent) + x: function (date, token, _localize, options) { + var originalDate = options._originalDate || date; + var timezoneOffset = originalDate.getTimezoneOffset(); + + switch (token) { + // Hours and optional minutes + case 'x': + return formatTimezoneWithOptionalMinutes(timezoneOffset); + // Hours, minutes and optional seconds without `:` delimiter + // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets + // so this token always has the same output as `xx` + + case 'xxxx': + case 'xx': + // Hours and minutes without `:` delimiter + return formatTimezone(timezoneOffset); + // Hours, minutes and optional seconds with `:` delimiter + // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets + // so this token always has the same output as `xxx` + + case 'xxxxx': + case 'xxx': // Hours and minutes with `:` delimiter + + default: + return formatTimezone(timezoneOffset, ':'); + } + }, + // Timezone (GMT) + O: function (date, token, _localize, options) { + var originalDate = options._originalDate || date; + var timezoneOffset = originalDate.getTimezoneOffset(); + + switch (token) { + // Short + case 'O': + case 'OO': + case 'OOO': + return 'GMT' + formatTimezoneShort(timezoneOffset, ':'); + // Long + + case 'OOOO': + default: + return 'GMT' + formatTimezone(timezoneOffset, ':'); + } + }, + // Timezone (specific non-location) + z: function (date, token, _localize, options) { + var originalDate = options._originalDate || date; + var timezoneOffset = originalDate.getTimezoneOffset(); + + switch (token) { + // Short + case 'z': + case 'zz': + case 'zzz': + return 'GMT' + formatTimezoneShort(timezoneOffset, ':'); + // Long + + case 'zzzz': + default: + return 'GMT' + formatTimezone(timezoneOffset, ':'); + } + }, + // Seconds timestamp + t: function (date, token, _localize, options) { + var originalDate = options._originalDate || date; + var timestamp = Math.floor(originalDate.getTime() / 1000); + return (0, _index7.default)(timestamp, token.length); + }, + // Milliseconds timestamp + T: function (date, token, _localize, options) { + var originalDate = options._originalDate || date; + var timestamp = originalDate.getTime(); + return (0, _index7.default)(timestamp, token.length); + } +}; + +function formatTimezoneShort(offset, dirtyDelimiter) { + var sign = offset > 0 ? '-' : '+'; + var absOffset = Math.abs(offset); + var hours = Math.floor(absOffset / 60); + var minutes = absOffset % 60; + + if (minutes === 0) { + return sign + String(hours); + } + + var delimiter = dirtyDelimiter || ''; + return sign + String(hours) + delimiter + (0, _index7.default)(minutes, 2); +} + +function formatTimezoneWithOptionalMinutes(offset, dirtyDelimiter) { + if (offset % 60 === 0) { + var sign = offset > 0 ? '-' : '+'; + return sign + (0, _index7.default)(Math.abs(offset) / 60, 2); + } + + return formatTimezone(offset, dirtyDelimiter); +} + +function formatTimezone(offset, dirtyDelimiter) { + var delimiter = dirtyDelimiter || ''; + var sign = offset > 0 ? '-' : '+'; + var absOffset = Math.abs(offset); + var hours = (0, _index7.default)(Math.floor(absOffset / 60), 2); + var minutes = (0, _index7.default)(absOffset % 60, 2); + return sign + hours + delimiter + minutes; +} + +var _default = formatters; +exports.default = _default; +module.exports = exports.default; +},{"../../../_lib/getUTCDayOfYear/index.js":18,"../../../_lib/getUTCISOWeek/index.js":19,"../../../_lib/getUTCISOWeekYear/index.js":20,"../../../_lib/getUTCWeek/index.js":21,"../../../_lib/getUTCWeekYear/index.js":22,"../../addLeadingZeros/index.js":11,"../lightFormatters/index.js":15}],15:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _index = _interopRequireDefault(require("../../addLeadingZeros/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/* + * | | Unit | | Unit | + * |-----|--------------------------------|-----|--------------------------------| + * | a | AM, PM | A* | | + * | d | Day of month | D | | + * | h | Hour [1-12] | H | Hour [0-23] | + * | m | Minute | M | Month | + * | s | Second | S | Fraction of second | + * | y | Year (abs) | Y | | + * + * Letters marked by * are not implemented but reserved by Unicode standard. + */ +var formatters = { + // Year + y: function (date, token) { + // From http://www.unicode.org/reports/tr35/tr35-31/tr35-dates.html#Date_Format_tokens + // | Year | y | yy | yyy | yyyy | yyyyy | + // |----------|-------|----|-------|-------|-------| + // | AD 1 | 1 | 01 | 001 | 0001 | 00001 | + // | AD 12 | 12 | 12 | 012 | 0012 | 00012 | + // | AD 123 | 123 | 23 | 123 | 0123 | 00123 | + // | AD 1234 | 1234 | 34 | 1234 | 1234 | 01234 | + // | AD 12345 | 12345 | 45 | 12345 | 12345 | 12345 | + var signedYear = date.getUTCFullYear(); // Returns 1 for 1 BC (which is year 0 in JavaScript) + + var year = signedYear > 0 ? signedYear : 1 - signedYear; + return (0, _index.default)(token === 'yy' ? year % 100 : year, token.length); + }, + // Month + M: function (date, token) { + var month = date.getUTCMonth(); + return token === 'M' ? String(month + 1) : (0, _index.default)(month + 1, 2); + }, + // Day of the month + d: function (date, token) { + return (0, _index.default)(date.getUTCDate(), token.length); + }, + // AM or PM + a: function (date, token) { + var dayPeriodEnumValue = date.getUTCHours() / 12 >= 1 ? 'pm' : 'am'; + + switch (token) { + case 'a': + case 'aa': + return dayPeriodEnumValue.toUpperCase(); + + case 'aaa': + return dayPeriodEnumValue; + + case 'aaaaa': + return dayPeriodEnumValue[0]; + + case 'aaaa': + default: + return dayPeriodEnumValue === 'am' ? 'a.m.' : 'p.m.'; + } + }, + // Hour [1-12] + h: function (date, token) { + return (0, _index.default)(date.getUTCHours() % 12 || 12, token.length); + }, + // Hour [0-23] + H: function (date, token) { + return (0, _index.default)(date.getUTCHours(), token.length); + }, + // Minute + m: function (date, token) { + return (0, _index.default)(date.getUTCMinutes(), token.length); + }, + // Second + s: function (date, token) { + return (0, _index.default)(date.getUTCSeconds(), token.length); + }, + // Fraction of second + S: function (date, token) { + var numberOfDigits = token.length; + var milliseconds = date.getUTCMilliseconds(); + var fractionalSeconds = Math.floor(milliseconds * Math.pow(10, numberOfDigits - 3)); + return (0, _index.default)(fractionalSeconds, token.length); + } +}; +var _default = formatters; +exports.default = _default; +module.exports = exports.default; +},{"../../addLeadingZeros/index.js":11}],16:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +function dateLongFormatter(pattern, formatLong) { + switch (pattern) { + case 'P': + return formatLong.date({ + width: 'short' + }); + + case 'PP': + return formatLong.date({ + width: 'medium' + }); + + case 'PPP': + return formatLong.date({ + width: 'long' + }); + + case 'PPPP': + default: + return formatLong.date({ + width: 'full' + }); + } +} + +function timeLongFormatter(pattern, formatLong) { + switch (pattern) { + case 'p': + return formatLong.time({ + width: 'short' + }); + + case 'pp': + return formatLong.time({ + width: 'medium' + }); + + case 'ppp': + return formatLong.time({ + width: 'long' + }); + + case 'pppp': + default: + return formatLong.time({ + width: 'full' + }); + } +} + +function dateTimeLongFormatter(pattern, formatLong) { + var matchResult = pattern.match(/(P+)(p+)?/); + var datePattern = matchResult[1]; + var timePattern = matchResult[2]; + + if (!timePattern) { + return dateLongFormatter(pattern, formatLong); + } + + var dateTimeFormat; + + switch (datePattern) { + case 'P': + dateTimeFormat = formatLong.dateTime({ + width: 'short' + }); + break; + + case 'PP': + dateTimeFormat = formatLong.dateTime({ + width: 'medium' + }); + break; + + case 'PPP': + dateTimeFormat = formatLong.dateTime({ + width: 'long' + }); + break; + + case 'PPPP': + default: + dateTimeFormat = formatLong.dateTime({ + width: 'full' + }); + break; + } + + return dateTimeFormat.replace('{{date}}', dateLongFormatter(datePattern, formatLong)).replace('{{time}}', timeLongFormatter(timePattern, formatLong)); +} + +var longFormatters = { + p: timeLongFormatter, + P: dateTimeLongFormatter +}; +var _default = longFormatters; +exports.default = _default; +module.exports = exports.default; +},{}],17:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = getTimezoneOffsetInMilliseconds; + +/** + * Google Chrome as of 67.0.3396.87 introduced timezones with offset that includes seconds. + * They usually appear for dates that denote time before the timezones were introduced + * (e.g. for 'Europe/Prague' timezone the offset is GMT+00:57:44 before 1 October 1891 + * and GMT+01:00:00 after that date) + * + * Date#getTimezoneOffset returns the offset in minutes and would return 57 for the example above, + * which would lead to incorrect calculations. + * + * This function returns the timezone offset in milliseconds that takes seconds in account. + */ +function getTimezoneOffsetInMilliseconds(date) { + var utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds())); + utcDate.setUTCFullYear(date.getFullYear()); + return date.getTime() - utcDate.getTime(); +} + +module.exports = exports.default; +},{}],18:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = getUTCDayOfYear; + +var _index = _interopRequireDefault(require("../../toDate/index.js")); + +var _index2 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var MILLISECONDS_IN_DAY = 86400000; // This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 + +function getUTCDayOfYear(dirtyDate) { + (0, _index2.default)(1, arguments); + var date = (0, _index.default)(dirtyDate); + var timestamp = date.getTime(); + date.setUTCMonth(0, 1); + date.setUTCHours(0, 0, 0, 0); + var startOfYearTimestamp = date.getTime(); + var difference = timestamp - startOfYearTimestamp; + return Math.floor(difference / MILLISECONDS_IN_DAY) + 1; +} + +module.exports = exports.default; +},{"../../toDate/index.js":44,"../requiredArgs/index.js":24}],19:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = getUTCISOWeek; + +var _index = _interopRequireDefault(require("../../toDate/index.js")); + +var _index2 = _interopRequireDefault(require("../startOfUTCISOWeek/index.js")); + +var _index3 = _interopRequireDefault(require("../startOfUTCISOWeekYear/index.js")); + +var _index4 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var MILLISECONDS_IN_WEEK = 604800000; // This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 + +function getUTCISOWeek(dirtyDate) { + (0, _index4.default)(1, arguments); + var date = (0, _index.default)(dirtyDate); + var diff = (0, _index2.default)(date).getTime() - (0, _index3.default)(date).getTime(); // Round the number of days to the nearest integer + // because the number of milliseconds in a week is not constant + // (e.g. it's different in the week of the daylight saving time clock shift) + + return Math.round(diff / MILLISECONDS_IN_WEEK) + 1; +} + +module.exports = exports.default; +},{"../../toDate/index.js":44,"../requiredArgs/index.js":24,"../startOfUTCISOWeek/index.js":25,"../startOfUTCISOWeekYear/index.js":26}],20:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = getUTCISOWeekYear; + +var _index = _interopRequireDefault(require("../../toDate/index.js")); + +var _index2 = _interopRequireDefault(require("../startOfUTCISOWeek/index.js")); + +var _index3 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 +function getUTCISOWeekYear(dirtyDate) { + (0, _index3.default)(1, arguments); + var date = (0, _index.default)(dirtyDate); + var year = date.getUTCFullYear(); + var fourthOfJanuaryOfNextYear = new Date(0); + fourthOfJanuaryOfNextYear.setUTCFullYear(year + 1, 0, 4); + fourthOfJanuaryOfNextYear.setUTCHours(0, 0, 0, 0); + var startOfNextYear = (0, _index2.default)(fourthOfJanuaryOfNextYear); + var fourthOfJanuaryOfThisYear = new Date(0); + fourthOfJanuaryOfThisYear.setUTCFullYear(year, 0, 4); + fourthOfJanuaryOfThisYear.setUTCHours(0, 0, 0, 0); + var startOfThisYear = (0, _index2.default)(fourthOfJanuaryOfThisYear); + + if (date.getTime() >= startOfNextYear.getTime()) { + return year + 1; + } else if (date.getTime() >= startOfThisYear.getTime()) { + return year; + } else { + return year - 1; + } +} + +module.exports = exports.default; +},{"../../toDate/index.js":44,"../requiredArgs/index.js":24,"../startOfUTCISOWeek/index.js":25}],21:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = getUTCWeek; + +var _index = _interopRequireDefault(require("../../toDate/index.js")); + +var _index2 = _interopRequireDefault(require("../startOfUTCWeek/index.js")); + +var _index3 = _interopRequireDefault(require("../startOfUTCWeekYear/index.js")); + +var _index4 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var MILLISECONDS_IN_WEEK = 604800000; // This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 + +function getUTCWeek(dirtyDate, options) { + (0, _index4.default)(1, arguments); + var date = (0, _index.default)(dirtyDate); + var diff = (0, _index2.default)(date, options).getTime() - (0, _index3.default)(date, options).getTime(); // Round the number of days to the nearest integer + // because the number of milliseconds in a week is not constant + // (e.g. it's different in the week of the daylight saving time clock shift) + + return Math.round(diff / MILLISECONDS_IN_WEEK) + 1; +} + +module.exports = exports.default; +},{"../../toDate/index.js":44,"../requiredArgs/index.js":24,"../startOfUTCWeek/index.js":27,"../startOfUTCWeekYear/index.js":28}],22:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = getUTCWeekYear; + +var _index = _interopRequireDefault(require("../toInteger/index.js")); + +var _index2 = _interopRequireDefault(require("../../toDate/index.js")); + +var _index3 = _interopRequireDefault(require("../startOfUTCWeek/index.js")); + +var _index4 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 +function getUTCWeekYear(dirtyDate, dirtyOptions) { + (0, _index4.default)(1, arguments); + var date = (0, _index2.default)(dirtyDate, dirtyOptions); + var year = date.getUTCFullYear(); + var options = dirtyOptions || {}; + var locale = options.locale; + var localeFirstWeekContainsDate = locale && locale.options && locale.options.firstWeekContainsDate; + var defaultFirstWeekContainsDate = localeFirstWeekContainsDate == null ? 1 : (0, _index.default)(localeFirstWeekContainsDate); + var firstWeekContainsDate = options.firstWeekContainsDate == null ? defaultFirstWeekContainsDate : (0, _index.default)(options.firstWeekContainsDate); // Test if weekStartsOn is between 1 and 7 _and_ is not NaN + + if (!(firstWeekContainsDate >= 1 && firstWeekContainsDate <= 7)) { + throw new RangeError('firstWeekContainsDate must be between 1 and 7 inclusively'); + } + + var firstWeekOfNextYear = new Date(0); + firstWeekOfNextYear.setUTCFullYear(year + 1, 0, firstWeekContainsDate); + firstWeekOfNextYear.setUTCHours(0, 0, 0, 0); + var startOfNextYear = (0, _index3.default)(firstWeekOfNextYear, dirtyOptions); + var firstWeekOfThisYear = new Date(0); + firstWeekOfThisYear.setUTCFullYear(year, 0, firstWeekContainsDate); + firstWeekOfThisYear.setUTCHours(0, 0, 0, 0); + var startOfThisYear = (0, _index3.default)(firstWeekOfThisYear, dirtyOptions); + + if (date.getTime() >= startOfNextYear.getTime()) { + return year + 1; + } else if (date.getTime() >= startOfThisYear.getTime()) { + return year; + } else { + return year - 1; + } +} + +module.exports = exports.default; +},{"../../toDate/index.js":44,"../requiredArgs/index.js":24,"../startOfUTCWeek/index.js":27,"../toInteger/index.js":29}],23:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.isProtectedDayOfYearToken = isProtectedDayOfYearToken; +exports.isProtectedWeekYearToken = isProtectedWeekYearToken; +exports.throwProtectedError = throwProtectedError; +var protectedDayOfYearTokens = ['D', 'DD']; +var protectedWeekYearTokens = ['YY', 'YYYY']; + +function isProtectedDayOfYearToken(token) { + return protectedDayOfYearTokens.indexOf(token) !== -1; +} + +function isProtectedWeekYearToken(token) { + return protectedWeekYearTokens.indexOf(token) !== -1; +} + +function throwProtectedError(token, format, input) { + if (token === 'YYYY') { + throw new RangeError("Use `yyyy` instead of `YYYY` (in `".concat(format, "`) for formatting years to the input `").concat(input, "`; see: https://git.io/fxCyr")); + } else if (token === 'YY') { + throw new RangeError("Use `yy` instead of `YY` (in `".concat(format, "`) for formatting years to the input `").concat(input, "`; see: https://git.io/fxCyr")); + } else if (token === 'D') { + throw new RangeError("Use `d` instead of `D` (in `".concat(format, "`) for formatting days of the month to the input `").concat(input, "`; see: https://git.io/fxCyr")); + } else if (token === 'DD') { + throw new RangeError("Use `dd` instead of `DD` (in `".concat(format, "`) for formatting days of the month to the input `").concat(input, "`; see: https://git.io/fxCyr")); + } +} +},{}],24:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = requiredArgs; + +function requiredArgs(required, args) { + if (args.length < required) { + throw new TypeError(required + ' argument' + (required > 1 ? 's' : '') + ' required, but only ' + args.length + ' present'); + } +} + +module.exports = exports.default; +},{}],25:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = startOfUTCISOWeek; + +var _index = _interopRequireDefault(require("../../toDate/index.js")); + +var _index2 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 +function startOfUTCISOWeek(dirtyDate) { + (0, _index2.default)(1, arguments); + var weekStartsOn = 1; + var date = (0, _index.default)(dirtyDate); + var day = date.getUTCDay(); + var diff = (day < weekStartsOn ? 7 : 0) + day - weekStartsOn; + date.setUTCDate(date.getUTCDate() - diff); + date.setUTCHours(0, 0, 0, 0); + return date; +} + +module.exports = exports.default; +},{"../../toDate/index.js":44,"../requiredArgs/index.js":24}],26:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = startOfUTCISOWeekYear; + +var _index = _interopRequireDefault(require("../getUTCISOWeekYear/index.js")); + +var _index2 = _interopRequireDefault(require("../startOfUTCISOWeek/index.js")); + +var _index3 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 +function startOfUTCISOWeekYear(dirtyDate) { + (0, _index3.default)(1, arguments); + var year = (0, _index.default)(dirtyDate); + var fourthOfJanuary = new Date(0); + fourthOfJanuary.setUTCFullYear(year, 0, 4); + fourthOfJanuary.setUTCHours(0, 0, 0, 0); + var date = (0, _index2.default)(fourthOfJanuary); + return date; +} + +module.exports = exports.default; +},{"../getUTCISOWeekYear/index.js":20,"../requiredArgs/index.js":24,"../startOfUTCISOWeek/index.js":25}],27:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = startOfUTCWeek; + +var _index = _interopRequireDefault(require("../toInteger/index.js")); + +var _index2 = _interopRequireDefault(require("../../toDate/index.js")); + +var _index3 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 +function startOfUTCWeek(dirtyDate, dirtyOptions) { + (0, _index3.default)(1, arguments); + var options = dirtyOptions || {}; + var locale = options.locale; + var localeWeekStartsOn = locale && locale.options && locale.options.weekStartsOn; + var defaultWeekStartsOn = localeWeekStartsOn == null ? 0 : (0, _index.default)(localeWeekStartsOn); + var weekStartsOn = options.weekStartsOn == null ? defaultWeekStartsOn : (0, _index.default)(options.weekStartsOn); // Test if weekStartsOn is between 0 and 6 _and_ is not NaN + + if (!(weekStartsOn >= 0 && weekStartsOn <= 6)) { + throw new RangeError('weekStartsOn must be between 0 and 6 inclusively'); + } + + var date = (0, _index2.default)(dirtyDate); + var day = date.getUTCDay(); + var diff = (day < weekStartsOn ? 7 : 0) + day - weekStartsOn; + date.setUTCDate(date.getUTCDate() - diff); + date.setUTCHours(0, 0, 0, 0); + return date; +} + +module.exports = exports.default; +},{"../../toDate/index.js":44,"../requiredArgs/index.js":24,"../toInteger/index.js":29}],28:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = startOfUTCWeekYear; + +var _index = _interopRequireDefault(require("../toInteger/index.js")); + +var _index2 = _interopRequireDefault(require("../getUTCWeekYear/index.js")); + +var _index3 = _interopRequireDefault(require("../startOfUTCWeek/index.js")); + +var _index4 = _interopRequireDefault(require("../requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// This function will be a part of public API when UTC function will be implemented. +// See issue: https://github.com/date-fns/date-fns/issues/376 +function startOfUTCWeekYear(dirtyDate, dirtyOptions) { + (0, _index4.default)(1, arguments); + var options = dirtyOptions || {}; + var locale = options.locale; + var localeFirstWeekContainsDate = locale && locale.options && locale.options.firstWeekContainsDate; + var defaultFirstWeekContainsDate = localeFirstWeekContainsDate == null ? 1 : (0, _index.default)(localeFirstWeekContainsDate); + var firstWeekContainsDate = options.firstWeekContainsDate == null ? defaultFirstWeekContainsDate : (0, _index.default)(options.firstWeekContainsDate); + var year = (0, _index2.default)(dirtyDate, dirtyOptions); + var firstWeek = new Date(0); + firstWeek.setUTCFullYear(year, 0, firstWeekContainsDate); + firstWeek.setUTCHours(0, 0, 0, 0); + var date = (0, _index3.default)(firstWeek, dirtyOptions); + return date; +} + +module.exports = exports.default; +},{"../getUTCWeekYear/index.js":22,"../requiredArgs/index.js":24,"../startOfUTCWeek/index.js":27,"../toInteger/index.js":29}],29:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = toInteger; + +function toInteger(dirtyNumber) { + if (dirtyNumber === null || dirtyNumber === true || dirtyNumber === false) { + return NaN; + } + + var number = Number(dirtyNumber); + + if (isNaN(number)) { + return number; + } + + return number < 0 ? Math.ceil(number) : Math.floor(number); +} + +module.exports = exports.default; +},{}],30:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = addMilliseconds; + +var _index = _interopRequireDefault(require("../_lib/toInteger/index.js")); + +var _index2 = _interopRequireDefault(require("../toDate/index.js")); + +var _index3 = _interopRequireDefault(require("../_lib/requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * @name addMilliseconds + * @category Millisecond Helpers + * @summary Add the specified number of milliseconds to the given date. + * + * @description + * Add the specified number of milliseconds to the given date. + * + * ### v2.0.0 breaking changes: + * + * - [Changes that are common for the whole library](https://github.com/date-fns/date-fns/blob/master/docs/upgradeGuide.md#Common-Changes). + * + * @param {Date|Number} date - the date to be changed + * @param {Number} amount - the amount of milliseconds to be added. Positive decimals will be rounded using `Math.floor`, decimals less than zero will be rounded using `Math.ceil`. + * @returns {Date} the new date with the milliseconds added + * @throws {TypeError} 2 arguments required + * + * @example + * // Add 750 milliseconds to 10 July 2014 12:45:30.000: + * const result = addMilliseconds(new Date(2014, 6, 10, 12, 45, 30, 0), 750) + * //=> Thu Jul 10 2014 12:45:30.750 + */ +function addMilliseconds(dirtyDate, dirtyAmount) { + (0, _index3.default)(2, arguments); + var timestamp = (0, _index2.default)(dirtyDate).getTime(); + var amount = (0, _index.default)(dirtyAmount); + return new Date(timestamp + amount); +} + +module.exports = exports.default; +},{"../_lib/requiredArgs/index.js":24,"../_lib/toInteger/index.js":29,"../toDate/index.js":44}],31:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = format; + +var _index = _interopRequireDefault(require("../isValid/index.js")); + +var _index2 = _interopRequireDefault(require("../locale/en-US/index.js")); + +var _index3 = _interopRequireDefault(require("../subMilliseconds/index.js")); + +var _index4 = _interopRequireDefault(require("../toDate/index.js")); + +var _index5 = _interopRequireDefault(require("../_lib/format/formatters/index.js")); + +var _index6 = _interopRequireDefault(require("../_lib/format/longFormatters/index.js")); + +var _index7 = _interopRequireDefault(require("../_lib/getTimezoneOffsetInMilliseconds/index.js")); + +var _index8 = require("../_lib/protectedTokens/index.js"); + +var _index9 = _interopRequireDefault(require("../_lib/toInteger/index.js")); + +var _index10 = _interopRequireDefault(require("../_lib/requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +// This RegExp consists of three parts separated by `|`: +// - [yYQqMLwIdDecihHKkms]o matches any available ordinal number token +// (one of the certain letters followed by `o`) +// - (\w)\1* matches any sequences of the same letter +// - '' matches two quote characters in a row +// - '(''|[^'])+('|$) matches anything surrounded by two quote characters ('), +// except a single quote symbol, which ends the sequence. +// Two quote characters do not end the sequence. +// If there is no matching single quote +// then the sequence will continue until the end of the string. +// - . matches any single character unmatched by previous parts of the RegExps +var formattingTokensRegExp = /[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g; // This RegExp catches symbols escaped by quotes, and also +// sequences of symbols P, p, and the combinations like `PPPPPPPppppp` + +var longFormattingTokensRegExp = /P+p+|P+|p+|''|'(''|[^'])+('|$)|./g; +var escapedStringRegExp = /^'([^]*?)'?$/; +var doubleQuoteRegExp = /''/g; +var unescapedLatinCharacterRegExp = /[a-zA-Z]/; +/** + * @name format + * @category Common Helpers + * @summary Format the date. + * + * @description + * Return the formatted date string in the given format. The result may vary by locale. + * + * > ⚠️ Please note that the `format` tokens differ from Moment.js and other libraries. + * > See: https://git.io/fxCyr + * + * The characters wrapped between two single quotes characters (') are escaped. + * Two single quotes in a row, whether inside or outside a quoted sequence, represent a 'real' single quote. + * (see the last example) + * + * Format of the string is based on Unicode Technical Standard #35: + * https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table + * with a few additions (see note 7 below the table). + * + * Accepted patterns: + * | Unit | Pattern | Result examples | Notes | + * |---------------------------------|---------|-----------------------------------|-------| + * | Era | G..GGG | AD, BC | | + * | | GGGG | Anno Domini, Before Christ | 2 | + * | | GGGGG | A, B | | + * | Calendar year | y | 44, 1, 1900, 2017 | 5 | + * | | yo | 44th, 1st, 0th, 17th | 5,7 | + * | | yy | 44, 01, 00, 17 | 5 | + * | | yyy | 044, 001, 1900, 2017 | 5 | + * | | yyyy | 0044, 0001, 1900, 2017 | 5 | + * | | yyyyy | ... | 3,5 | + * | Local week-numbering year | Y | 44, 1, 1900, 2017 | 5 | + * | | Yo | 44th, 1st, 1900th, 2017th | 5,7 | + * | | YY | 44, 01, 00, 17 | 5,8 | + * | | YYY | 044, 001, 1900, 2017 | 5 | + * | | YYYY | 0044, 0001, 1900, 2017 | 5,8 | + * | | YYYYY | ... | 3,5 | + * | ISO week-numbering year | R | -43, 0, 1, 1900, 2017 | 5,7 | + * | | RR | -43, 00, 01, 1900, 2017 | 5,7 | + * | | RRR | -043, 000, 001, 1900, 2017 | 5,7 | + * | | RRRR | -0043, 0000, 0001, 1900, 2017 | 5,7 | + * | | RRRRR | ... | 3,5,7 | + * | Extended year | u | -43, 0, 1, 1900, 2017 | 5 | + * | | uu | -43, 01, 1900, 2017 | 5 | + * | | uuu | -043, 001, 1900, 2017 | 5 | + * | | uuuu | -0043, 0001, 1900, 2017 | 5 | + * | | uuuuu | ... | 3,5 | + * | Quarter (formatting) | Q | 1, 2, 3, 4 | | + * | | Qo | 1st, 2nd, 3rd, 4th | 7 | + * | | QQ | 01, 02, 03, 04 | | + * | | QQQ | Q1, Q2, Q3, Q4 | | + * | | QQQQ | 1st quarter, 2nd quarter, ... | 2 | + * | | QQQQQ | 1, 2, 3, 4 | 4 | + * | Quarter (stand-alone) | q | 1, 2, 3, 4 | | + * | | qo | 1st, 2nd, 3rd, 4th | 7 | + * | | qq | 01, 02, 03, 04 | | + * | | qqq | Q1, Q2, Q3, Q4 | | + * | | qqqq | 1st quarter, 2nd quarter, ... | 2 | + * | | qqqqq | 1, 2, 3, 4 | 4 | + * | Month (formatting) | M | 1, 2, ..., 12 | | + * | | Mo | 1st, 2nd, ..., 12th | 7 | + * | | MM | 01, 02, ..., 12 | | + * | | MMM | Jan, Feb, ..., Dec | | + * | | MMMM | January, February, ..., December | 2 | + * | | MMMMM | J, F, ..., D | | + * | Month (stand-alone) | L | 1, 2, ..., 12 | | + * | | Lo | 1st, 2nd, ..., 12th | 7 | + * | | LL | 01, 02, ..., 12 | | + * | | LLL | Jan, Feb, ..., Dec | | + * | | LLLL | January, February, ..., December | 2 | + * | | LLLLL | J, F, ..., D | | + * | Local week of year | w | 1, 2, ..., 53 | | + * | | wo | 1st, 2nd, ..., 53th | 7 | + * | | ww | 01, 02, ..., 53 | | + * | ISO week of year | I | 1, 2, ..., 53 | 7 | + * | | Io | 1st, 2nd, ..., 53th | 7 | + * | | II | 01, 02, ..., 53 | 7 | + * | Day of month | d | 1, 2, ..., 31 | | + * | | do | 1st, 2nd, ..., 31st | 7 | + * | | dd | 01, 02, ..., 31 | | + * | Day of year | D | 1, 2, ..., 365, 366 | 9 | + * | | Do | 1st, 2nd, ..., 365th, 366th | 7 | + * | | DD | 01, 02, ..., 365, 366 | 9 | + * | | DDD | 001, 002, ..., 365, 366 | | + * | | DDDD | ... | 3 | + * | Day of week (formatting) | E..EEE | Mon, Tue, Wed, ..., Sun | | + * | | EEEE | Monday, Tuesday, ..., Sunday | 2 | + * | | EEEEE | M, T, W, T, F, S, S | | + * | | EEEEEE | Mo, Tu, We, Th, Fr, Su, Sa | | + * | ISO day of week (formatting) | i | 1, 2, 3, ..., 7 | 7 | + * | | io | 1st, 2nd, ..., 7th | 7 | + * | | ii | 01, 02, ..., 07 | 7 | + * | | iii | Mon, Tue, Wed, ..., Sun | 7 | + * | | iiii | Monday, Tuesday, ..., Sunday | 2,7 | + * | | iiiii | M, T, W, T, F, S, S | 7 | + * | | iiiiii | Mo, Tu, We, Th, Fr, Su, Sa | 7 | + * | Local day of week (formatting) | e | 2, 3, 4, ..., 1 | | + * | | eo | 2nd, 3rd, ..., 1st | 7 | + * | | ee | 02, 03, ..., 01 | | + * | | eee | Mon, Tue, Wed, ..., Sun | | + * | | eeee | Monday, Tuesday, ..., Sunday | 2 | + * | | eeeee | M, T, W, T, F, S, S | | + * | | eeeeee | Mo, Tu, We, Th, Fr, Su, Sa | | + * | Local day of week (stand-alone) | c | 2, 3, 4, ..., 1 | | + * | | co | 2nd, 3rd, ..., 1st | 7 | + * | | cc | 02, 03, ..., 01 | | + * | | ccc | Mon, Tue, Wed, ..., Sun | | + * | | cccc | Monday, Tuesday, ..., Sunday | 2 | + * | | ccccc | M, T, W, T, F, S, S | | + * | | cccccc | Mo, Tu, We, Th, Fr, Su, Sa | | + * | AM, PM | a..aa | AM, PM | | + * | | aaa | am, pm | | + * | | aaaa | a.m., p.m. | 2 | + * | | aaaaa | a, p | | + * | AM, PM, noon, midnight | b..bb | AM, PM, noon, midnight | | + * | | bbb | am, pm, noon, midnight | | + * | | bbbb | a.m., p.m., noon, midnight | 2 | + * | | bbbbb | a, p, n, mi | | + * | Flexible day period | B..BBB | at night, in the morning, ... | | + * | | BBBB | at night, in the morning, ... | 2 | + * | | BBBBB | at night, in the morning, ... | | + * | Hour [1-12] | h | 1, 2, ..., 11, 12 | | + * | | ho | 1st, 2nd, ..., 11th, 12th | 7 | + * | | hh | 01, 02, ..., 11, 12 | | + * | Hour [0-23] | H | 0, 1, 2, ..., 23 | | + * | | Ho | 0th, 1st, 2nd, ..., 23rd | 7 | + * | | HH | 00, 01, 02, ..., 23 | | + * | Hour [0-11] | K | 1, 2, ..., 11, 0 | | + * | | Ko | 1st, 2nd, ..., 11th, 0th | 7 | + * | | KK | 01, 02, ..., 11, 00 | | + * | Hour [1-24] | k | 24, 1, 2, ..., 23 | | + * | | ko | 24th, 1st, 2nd, ..., 23rd | 7 | + * | | kk | 24, 01, 02, ..., 23 | | + * | Minute | m | 0, 1, ..., 59 | | + * | | mo | 0th, 1st, ..., 59th | 7 | + * | | mm | 00, 01, ..., 59 | | + * | Second | s | 0, 1, ..., 59 | | + * | | so | 0th, 1st, ..., 59th | 7 | + * | | ss | 00, 01, ..., 59 | | + * | Fraction of second | S | 0, 1, ..., 9 | | + * | | SS | 00, 01, ..., 99 | | + * | | SSS | 000, 001, ..., 999 | | + * | | SSSS | ... | 3 | + * | Timezone (ISO-8601 w/ Z) | X | -08, +0530, Z | | + * | | XX | -0800, +0530, Z | | + * | | XXX | -08:00, +05:30, Z | | + * | | XXXX | -0800, +0530, Z, +123456 | 2 | + * | | XXXXX | -08:00, +05:30, Z, +12:34:56 | | + * | Timezone (ISO-8601 w/o Z) | x | -08, +0530, +00 | | + * | | xx | -0800, +0530, +0000 | | + * | | xxx | -08:00, +05:30, +00:00 | 2 | + * | | xxxx | -0800, +0530, +0000, +123456 | | + * | | xxxxx | -08:00, +05:30, +00:00, +12:34:56 | | + * | Timezone (GMT) | O...OOO | GMT-8, GMT+5:30, GMT+0 | | + * | | OOOO | GMT-08:00, GMT+05:30, GMT+00:00 | 2 | + * | Timezone (specific non-locat.) | z...zzz | GMT-8, GMT+5:30, GMT+0 | 6 | + * | | zzzz | GMT-08:00, GMT+05:30, GMT+00:00 | 2,6 | + * | Seconds timestamp | t | 512969520 | 7 | + * | | tt | ... | 3,7 | + * | Milliseconds timestamp | T | 512969520900 | 7 | + * | | TT | ... | 3,7 | + * | Long localized date | P | 04/29/1453 | 7 | + * | | PP | Apr 29, 1453 | 7 | + * | | PPP | April 29th, 1453 | 7 | + * | | PPPP | Friday, April 29th, 1453 | 2,7 | + * | Long localized time | p | 12:00 AM | 7 | + * | | pp | 12:00:00 AM | 7 | + * | | ppp | 12:00:00 AM GMT+2 | 7 | + * | | pppp | 12:00:00 AM GMT+02:00 | 2,7 | + * | Combination of date and time | Pp | 04/29/1453, 12:00 AM | 7 | + * | | PPpp | Apr 29, 1453, 12:00:00 AM | 7 | + * | | PPPppp | April 29th, 1453 at ... | 7 | + * | | PPPPpppp| Friday, April 29th, 1453 at ... | 2,7 | + * Notes: + * 1. "Formatting" units (e.g. formatting quarter) in the default en-US locale + * are the same as "stand-alone" units, but are different in some languages. + * "Formatting" units are declined according to the rules of the language + * in the context of a date. "Stand-alone" units are always nominative singular: + * + * `format(new Date(2017, 10, 6), 'do LLLL', {locale: cs}) //=> '6. listopad'` + * + * `format(new Date(2017, 10, 6), 'do MMMM', {locale: cs}) //=> '6. listopadu'` + * + * 2. Any sequence of the identical letters is a pattern, unless it is escaped by + * the single quote characters (see below). + * If the sequence is longer than listed in table (e.g. `EEEEEEEEEEE`) + * the output will be the same as default pattern for this unit, usually + * the longest one (in case of ISO weekdays, `EEEE`). Default patterns for units + * are marked with "2" in the last column of the table. + * + * `format(new Date(2017, 10, 6), 'MMM') //=> 'Nov'` + * + * `format(new Date(2017, 10, 6), 'MMMM') //=> 'November'` + * + * `format(new Date(2017, 10, 6), 'MMMMM') //=> 'N'` + * + * `format(new Date(2017, 10, 6), 'MMMMMM') //=> 'November'` + * + * `format(new Date(2017, 10, 6), 'MMMMMMM') //=> 'November'` + * + * 3. Some patterns could be unlimited length (such as `yyyyyyyy`). + * The output will be padded with zeros to match the length of the pattern. + * + * `format(new Date(2017, 10, 6), 'yyyyyyyy') //=> '00002017'` + * + * 4. `QQQQQ` and `qqqqq` could be not strictly numerical in some locales. + * These tokens represent the shortest form of the quarter. + * + * 5. The main difference between `y` and `u` patterns are B.C. years: + * + * | Year | `y` | `u` | + * |------|-----|-----| + * | AC 1 | 1 | 1 | + * | BC 1 | 1 | 0 | + * | BC 2 | 2 | -1 | + * + * Also `yy` always returns the last two digits of a year, + * while `uu` pads single digit years to 2 characters and returns other years unchanged: + * + * | Year | `yy` | `uu` | + * |------|------|------| + * | 1 | 01 | 01 | + * | 14 | 14 | 14 | + * | 376 | 76 | 376 | + * | 1453 | 53 | 1453 | + * + * The same difference is true for local and ISO week-numbering years (`Y` and `R`), + * except local week-numbering years are dependent on `options.weekStartsOn` + * and `options.firstWeekContainsDate` (compare [getISOWeekYear]{@link https://date-fns.org/docs/getISOWeekYear} + * and [getWeekYear]{@link https://date-fns.org/docs/getWeekYear}). + * + * 6. Specific non-location timezones are currently unavailable in `date-fns`, + * so right now these tokens fall back to GMT timezones. + * + * 7. These patterns are not in the Unicode Technical Standard #35: + * - `i`: ISO day of week + * - `I`: ISO week of year + * - `R`: ISO week-numbering year + * - `t`: seconds timestamp + * - `T`: milliseconds timestamp + * - `o`: ordinal number modifier + * - `P`: long localized date + * - `p`: long localized time + * + * 8. `YY` and `YYYY` tokens represent week-numbering years but they are often confused with years. + * You should enable `options.useAdditionalWeekYearTokens` to use them. See: https://git.io/fxCyr + * + * 9. `D` and `DD` tokens represent days of the year but they are ofthen confused with days of the month. + * You should enable `options.useAdditionalDayOfYearTokens` to use them. See: https://git.io/fxCyr + * + * ### v2.0.0 breaking changes: + * + * - [Changes that are common for the whole library](https://github.com/date-fns/date-fns/blob/master/docs/upgradeGuide.md#Common-Changes). + * + * - The second argument is now required for the sake of explicitness. + * + * ```javascript + * // Before v2.0.0 + * format(new Date(2016, 0, 1)) + * + * // v2.0.0 onward + * format(new Date(2016, 0, 1), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx") + * ``` + * + * - New format string API for `format` function + * which is based on [Unicode Technical Standard #35](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table). + * See [this post](https://blog.date-fns.org/post/unicode-tokens-in-date-fns-v2-sreatyki91jg) for more details. + * + * - Characters are now escaped using single quote symbols (`'`) instead of square brackets. + * + * @param {Date|Number} date - the original date + * @param {String} format - the string of tokens + * @param {Object} [options] - an object with options. + * @param {Locale} [options.locale=defaultLocale] - the locale object. See [Locale]{@link https://date-fns.org/docs/Locale} + * @param {0|1|2|3|4|5|6} [options.weekStartsOn=0] - the index of the first day of the week (0 - Sunday) + * @param {Number} [options.firstWeekContainsDate=1] - the day of January, which is + * @param {Boolean} [options.useAdditionalWeekYearTokens=false] - if true, allows usage of the week-numbering year tokens `YY` and `YYYY`; + * see: https://git.io/fxCyr + * @param {Boolean} [options.useAdditionalDayOfYearTokens=false] - if true, allows usage of the day of year tokens `D` and `DD`; + * see: https://git.io/fxCyr + * @returns {String} the formatted date string + * @throws {TypeError} 2 arguments required + * @throws {RangeError} `date` must not be Invalid Date + * @throws {RangeError} `options.locale` must contain `localize` property + * @throws {RangeError} `options.locale` must contain `formatLong` property + * @throws {RangeError} `options.weekStartsOn` must be between 0 and 6 + * @throws {RangeError} `options.firstWeekContainsDate` must be between 1 and 7 + * @throws {RangeError} use `yyyy` instead of `YYYY` for formatting years using [format provided] to the input [input provided]; see: https://git.io/fxCyr + * @throws {RangeError} use `yy` instead of `YY` for formatting years using [format provided] to the input [input provided]; see: https://git.io/fxCyr + * @throws {RangeError} use `d` instead of `D` for formatting days of the month using [format provided] to the input [input provided]; see: https://git.io/fxCyr + * @throws {RangeError} use `dd` instead of `DD` for formatting days of the month using [format provided] to the input [input provided]; see: https://git.io/fxCyr + * @throws {RangeError} format string contains an unescaped latin alphabet character + * + * @example + * // Represent 11 February 2014 in middle-endian format: + * var result = format(new Date(2014, 1, 11), 'MM/dd/yyyy') + * //=> '02/11/2014' + * + * @example + * // Represent 2 July 2014 in Esperanto: + * import { eoLocale } from 'date-fns/locale/eo' + * var result = format(new Date(2014, 6, 2), "do 'de' MMMM yyyy", { + * locale: eoLocale + * }) + * //=> '2-a de julio 2014' + * + * @example + * // Escape string by single quote characters: + * var result = format(new Date(2014, 6, 2, 15), "h 'o''clock'") + * //=> "3 o'clock" + */ + +function format(dirtyDate, dirtyFormatStr, dirtyOptions) { + (0, _index10.default)(2, arguments); + var formatStr = String(dirtyFormatStr); + var options = dirtyOptions || {}; + var locale = options.locale || _index2.default; + var localeFirstWeekContainsDate = locale.options && locale.options.firstWeekContainsDate; + var defaultFirstWeekContainsDate = localeFirstWeekContainsDate == null ? 1 : (0, _index9.default)(localeFirstWeekContainsDate); + var firstWeekContainsDate = options.firstWeekContainsDate == null ? defaultFirstWeekContainsDate : (0, _index9.default)(options.firstWeekContainsDate); // Test if weekStartsOn is between 1 and 7 _and_ is not NaN + + if (!(firstWeekContainsDate >= 1 && firstWeekContainsDate <= 7)) { + throw new RangeError('firstWeekContainsDate must be between 1 and 7 inclusively'); + } + + var localeWeekStartsOn = locale.options && locale.options.weekStartsOn; + var defaultWeekStartsOn = localeWeekStartsOn == null ? 0 : (0, _index9.default)(localeWeekStartsOn); + var weekStartsOn = options.weekStartsOn == null ? defaultWeekStartsOn : (0, _index9.default)(options.weekStartsOn); // Test if weekStartsOn is between 0 and 6 _and_ is not NaN + + if (!(weekStartsOn >= 0 && weekStartsOn <= 6)) { + throw new RangeError('weekStartsOn must be between 0 and 6 inclusively'); + } + + if (!locale.localize) { + throw new RangeError('locale must contain localize property'); + } + + if (!locale.formatLong) { + throw new RangeError('locale must contain formatLong property'); + } + + var originalDate = (0, _index4.default)(dirtyDate); + + if (!(0, _index.default)(originalDate)) { + throw new RangeError('Invalid time value'); + } // Convert the date in system timezone to the same date in UTC+00:00 timezone. + // This ensures that when UTC functions will be implemented, locales will be compatible with them. + // See an issue about UTC functions: https://github.com/date-fns/date-fns/issues/376 + + + var timezoneOffset = (0, _index7.default)(originalDate); + var utcDate = (0, _index3.default)(originalDate, timezoneOffset); + var formatterOptions = { + firstWeekContainsDate: firstWeekContainsDate, + weekStartsOn: weekStartsOn, + locale: locale, + _originalDate: originalDate + }; + var result = formatStr.match(longFormattingTokensRegExp).map(function (substring) { + var firstCharacter = substring[0]; + + if (firstCharacter === 'p' || firstCharacter === 'P') { + var longFormatter = _index6.default[firstCharacter]; + return longFormatter(substring, locale.formatLong, formatterOptions); + } + + return substring; + }).join('').match(formattingTokensRegExp).map(function (substring) { + // Replace two single quote characters with one single quote character + if (substring === "''") { + return "'"; + } + + var firstCharacter = substring[0]; + + if (firstCharacter === "'") { + return cleanEscapedString(substring); + } + + var formatter = _index5.default[firstCharacter]; + + if (formatter) { + if (!options.useAdditionalWeekYearTokens && (0, _index8.isProtectedWeekYearToken)(substring)) { + (0, _index8.throwProtectedError)(substring, dirtyFormatStr, dirtyDate); + } + + if (!options.useAdditionalDayOfYearTokens && (0, _index8.isProtectedDayOfYearToken)(substring)) { + (0, _index8.throwProtectedError)(substring, dirtyFormatStr, dirtyDate); + } + + return formatter(utcDate, substring, locale.localize, formatterOptions); + } + + if (firstCharacter.match(unescapedLatinCharacterRegExp)) { + throw new RangeError('Format string contains an unescaped latin alphabet character `' + firstCharacter + '`'); + } + + return substring; + }).join(''); + return result; +} + +function cleanEscapedString(input) { + return input.match(escapedStringRegExp)[1].replace(doubleQuoteRegExp, "'"); +} + +module.exports = exports.default; +},{"../_lib/format/formatters/index.js":14,"../_lib/format/longFormatters/index.js":16,"../_lib/getTimezoneOffsetInMilliseconds/index.js":17,"../_lib/protectedTokens/index.js":23,"../_lib/requiredArgs/index.js":24,"../_lib/toInteger/index.js":29,"../isValid/index.js":32,"../locale/en-US/index.js":42,"../subMilliseconds/index.js":43,"../toDate/index.js":44}],32:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = isValid; + +var _index = _interopRequireDefault(require("../toDate/index.js")); + +var _index2 = _interopRequireDefault(require("../_lib/requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * @name isValid + * @category Common Helpers + * @summary Is the given date valid? + * + * @description + * Returns false if argument is Invalid Date and true otherwise. + * Argument is converted to Date using `toDate`. See [toDate]{@link https://date-fns.org/docs/toDate} + * Invalid Date is a Date, whose time value is NaN. + * + * Time value of Date: http://es5.github.io/#x15.9.1.1 + * + * ### v2.0.0 breaking changes: + * + * - [Changes that are common for the whole library](https://github.com/date-fns/date-fns/blob/master/docs/upgradeGuide.md#Common-Changes). + * + * - Now `isValid` doesn't throw an exception + * if the first argument is not an instance of Date. + * Instead, argument is converted beforehand using `toDate`. + * + * Examples: + * + * | `isValid` argument | Before v2.0.0 | v2.0.0 onward | + * |---------------------------|---------------|---------------| + * | `new Date()` | `true` | `true` | + * | `new Date('2016-01-01')` | `true` | `true` | + * | `new Date('')` | `false` | `false` | + * | `new Date(1488370835081)` | `true` | `true` | + * | `new Date(NaN)` | `false` | `false` | + * | `'2016-01-01'` | `TypeError` | `false` | + * | `''` | `TypeError` | `false` | + * | `1488370835081` | `TypeError` | `true` | + * | `NaN` | `TypeError` | `false` | + * + * We introduce this change to make *date-fns* consistent with ECMAScript behavior + * that try to coerce arguments to the expected type + * (which is also the case with other *date-fns* functions). + * + * @param {*} date - the date to check + * @returns {Boolean} the date is valid + * @throws {TypeError} 1 argument required + * + * @example + * // For the valid date: + * var result = isValid(new Date(2014, 1, 31)) + * //=> true + * + * @example + * // For the value, convertable into a date: + * var result = isValid(1393804800000) + * //=> true + * + * @example + * // For the invalid date: + * var result = isValid(new Date('')) + * //=> false + */ +function isValid(dirtyDate) { + (0, _index2.default)(1, arguments); + var date = (0, _index.default)(dirtyDate); + return !isNaN(date); +} + +module.exports = exports.default; +},{"../_lib/requiredArgs/index.js":24,"../toDate/index.js":44}],33:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = buildFormatLongFn; + +function buildFormatLongFn(args) { + return function (dirtyOptions) { + var options = dirtyOptions || {}; + var width = options.width ? String(options.width) : args.defaultWidth; + var format = args.formats[width] || args.formats[args.defaultWidth]; + return format; + }; +} + +module.exports = exports.default; +},{}],34:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = buildLocalizeFn; + +function buildLocalizeFn(args) { + return function (dirtyIndex, dirtyOptions) { + var options = dirtyOptions || {}; + var context = options.context ? String(options.context) : 'standalone'; + var valuesArray; + + if (context === 'formatting' && args.formattingValues) { + var defaultWidth = args.defaultFormattingWidth || args.defaultWidth; + var width = options.width ? String(options.width) : defaultWidth; + valuesArray = args.formattingValues[width] || args.formattingValues[defaultWidth]; + } else { + var _defaultWidth = args.defaultWidth; + + var _width = options.width ? String(options.width) : args.defaultWidth; + + valuesArray = args.values[_width] || args.values[_defaultWidth]; + } + + var index = args.argumentCallback ? args.argumentCallback(dirtyIndex) : dirtyIndex; + return valuesArray[index]; + }; +} + +module.exports = exports.default; +},{}],35:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = buildMatchFn; + +function buildMatchFn(args) { + return function (dirtyString, dirtyOptions) { + var string = String(dirtyString); + var options = dirtyOptions || {}; + var width = options.width; + var matchPattern = width && args.matchPatterns[width] || args.matchPatterns[args.defaultMatchWidth]; + var matchResult = string.match(matchPattern); + + if (!matchResult) { + return null; + } + + var matchedString = matchResult[0]; + var parsePatterns = width && args.parsePatterns[width] || args.parsePatterns[args.defaultParseWidth]; + var value; + + if (Object.prototype.toString.call(parsePatterns) === '[object Array]') { + value = findIndex(parsePatterns, function (pattern) { + return pattern.test(matchedString); + }); + } else { + value = findKey(parsePatterns, function (pattern) { + return pattern.test(matchedString); + }); + } + + value = args.valueCallback ? args.valueCallback(value) : value; + value = options.valueCallback ? options.valueCallback(value) : value; + return { + value: value, + rest: string.slice(matchedString.length) + }; + }; +} + +function findKey(object, predicate) { + for (var key in object) { + if (object.hasOwnProperty(key) && predicate(object[key])) { + return key; + } + } +} + +function findIndex(array, predicate) { + for (var key = 0; key < array.length; key++) { + if (predicate(array[key])) { + return key; + } + } +} + +module.exports = exports.default; +},{}],36:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = buildMatchPatternFn; + +function buildMatchPatternFn(args) { + return function (dirtyString, dirtyOptions) { + var string = String(dirtyString); + var options = dirtyOptions || {}; + var matchResult = string.match(args.matchPattern); + + if (!matchResult) { + return null; + } + + var matchedString = matchResult[0]; + var parseResult = string.match(args.parsePattern); + + if (!parseResult) { + return null; + } + + var value = args.valueCallback ? args.valueCallback(parseResult[0]) : parseResult[0]; + value = options.valueCallback ? options.valueCallback(value) : value; + return { + value: value, + rest: string.slice(matchedString.length) + }; + }; +} + +module.exports = exports.default; +},{}],37:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = formatDistance; +var formatDistanceLocale = { + lessThanXSeconds: { + one: 'less than a second', + other: 'less than {{count}} seconds' + }, + xSeconds: { + one: '1 second', + other: '{{count}} seconds' + }, + halfAMinute: 'half a minute', + lessThanXMinutes: { + one: 'less than a minute', + other: 'less than {{count}} minutes' + }, + xMinutes: { + one: '1 minute', + other: '{{count}} minutes' + }, + aboutXHours: { + one: 'about 1 hour', + other: 'about {{count}} hours' + }, + xHours: { + one: '1 hour', + other: '{{count}} hours' + }, + xDays: { + one: '1 day', + other: '{{count}} days' + }, + aboutXWeeks: { + one: 'about 1 week', + other: 'about {{count}} weeks' + }, + xWeeks: { + one: '1 week', + other: '{{count}} weeks' + }, + aboutXMonths: { + one: 'about 1 month', + other: 'about {{count}} months' + }, + xMonths: { + one: '1 month', + other: '{{count}} months' + }, + aboutXYears: { + one: 'about 1 year', + other: 'about {{count}} years' + }, + xYears: { + one: '1 year', + other: '{{count}} years' + }, + overXYears: { + one: 'over 1 year', + other: 'over {{count}} years' + }, + almostXYears: { + one: 'almost 1 year', + other: 'almost {{count}} years' + } +}; + +function formatDistance(token, count, options) { + options = options || {}; + var result; + + if (typeof formatDistanceLocale[token] === 'string') { + result = formatDistanceLocale[token]; + } else if (count === 1) { + result = formatDistanceLocale[token].one; + } else { + result = formatDistanceLocale[token].other.replace('{{count}}', count); + } + + if (options.addSuffix) { + if (options.comparison > 0) { + return 'in ' + result; + } else { + return result + ' ago'; + } + } + + return result; +} + +module.exports = exports.default; +},{}],38:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _index = _interopRequireDefault(require("../../../_lib/buildFormatLongFn/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var dateFormats = { + full: 'EEEE, MMMM do, y', + long: 'MMMM do, y', + medium: 'MMM d, y', + short: 'MM/dd/yyyy' +}; +var timeFormats = { + full: 'h:mm:ss a zzzz', + long: 'h:mm:ss a z', + medium: 'h:mm:ss a', + short: 'h:mm a' +}; +var dateTimeFormats = { + full: "{{date}} 'at' {{time}}", + long: "{{date}} 'at' {{time}}", + medium: '{{date}}, {{time}}', + short: '{{date}}, {{time}}' +}; +var formatLong = { + date: (0, _index.default)({ + formats: dateFormats, + defaultWidth: 'full' + }), + time: (0, _index.default)({ + formats: timeFormats, + defaultWidth: 'full' + }), + dateTime: (0, _index.default)({ + formats: dateTimeFormats, + defaultWidth: 'full' + }) +}; +var _default = formatLong; +exports.default = _default; +module.exports = exports.default; +},{"../../../_lib/buildFormatLongFn/index.js":33}],39:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = formatRelative; +var formatRelativeLocale = { + lastWeek: "'last' eeee 'at' p", + yesterday: "'yesterday at' p", + today: "'today at' p", + tomorrow: "'tomorrow at' p", + nextWeek: "eeee 'at' p", + other: 'P' +}; + +function formatRelative(token, _date, _baseDate, _options) { + return formatRelativeLocale[token]; +} + +module.exports = exports.default; +},{}],40:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _index = _interopRequireDefault(require("../../../_lib/buildLocalizeFn/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var eraValues = { + narrow: ['B', 'A'], + abbreviated: ['BC', 'AD'], + wide: ['Before Christ', 'Anno Domini'] +}; +var quarterValues = { + narrow: ['1', '2', '3', '4'], + abbreviated: ['Q1', 'Q2', 'Q3', 'Q4'], + wide: ['1st quarter', '2nd quarter', '3rd quarter', '4th quarter'] // Note: in English, the names of days of the week and months are capitalized. + // If you are making a new locale based on this one, check if the same is true for the language you're working on. + // Generally, formatted dates should look like they are in the middle of a sentence, + // e.g. in Spanish language the weekdays and months should be in the lowercase. + +}; +var monthValues = { + narrow: ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'], + abbreviated: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + wide: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] +}; +var dayValues = { + narrow: ['S', 'M', 'T', 'W', 'T', 'F', 'S'], + short: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], + abbreviated: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + wide: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] +}; +var dayPeriodValues = { + narrow: { + am: 'a', + pm: 'p', + midnight: 'mi', + noon: 'n', + morning: 'morning', + afternoon: 'afternoon', + evening: 'evening', + night: 'night' + }, + abbreviated: { + am: 'AM', + pm: 'PM', + midnight: 'midnight', + noon: 'noon', + morning: 'morning', + afternoon: 'afternoon', + evening: 'evening', + night: 'night' + }, + wide: { + am: 'a.m.', + pm: 'p.m.', + midnight: 'midnight', + noon: 'noon', + morning: 'morning', + afternoon: 'afternoon', + evening: 'evening', + night: 'night' + } +}; +var formattingDayPeriodValues = { + narrow: { + am: 'a', + pm: 'p', + midnight: 'mi', + noon: 'n', + morning: 'in the morning', + afternoon: 'in the afternoon', + evening: 'in the evening', + night: 'at night' + }, + abbreviated: { + am: 'AM', + pm: 'PM', + midnight: 'midnight', + noon: 'noon', + morning: 'in the morning', + afternoon: 'in the afternoon', + evening: 'in the evening', + night: 'at night' + }, + wide: { + am: 'a.m.', + pm: 'p.m.', + midnight: 'midnight', + noon: 'noon', + morning: 'in the morning', + afternoon: 'in the afternoon', + evening: 'in the evening', + night: 'at night' + } +}; + +function ordinalNumber(dirtyNumber, _dirtyOptions) { + var number = Number(dirtyNumber); // If ordinal numbers depend on context, for example, + // if they are different for different grammatical genders, + // use `options.unit`: + // + // var options = dirtyOptions || {} + // var unit = String(options.unit) + // + // where `unit` can be 'year', 'quarter', 'month', 'week', 'date', 'dayOfYear', + // 'day', 'hour', 'minute', 'second' + + var rem100 = number % 100; + + if (rem100 > 20 || rem100 < 10) { + switch (rem100 % 10) { + case 1: + return number + 'st'; + + case 2: + return number + 'nd'; + + case 3: + return number + 'rd'; + } + } + + return number + 'th'; +} + +var localize = { + ordinalNumber: ordinalNumber, + era: (0, _index.default)({ + values: eraValues, + defaultWidth: 'wide' + }), + quarter: (0, _index.default)({ + values: quarterValues, + defaultWidth: 'wide', + argumentCallback: function (quarter) { + return Number(quarter) - 1; + } + }), + month: (0, _index.default)({ + values: monthValues, + defaultWidth: 'wide' + }), + day: (0, _index.default)({ + values: dayValues, + defaultWidth: 'wide' + }), + dayPeriod: (0, _index.default)({ + values: dayPeriodValues, + defaultWidth: 'wide', + formattingValues: formattingDayPeriodValues, + defaultFormattingWidth: 'wide' + }) +}; +var _default = localize; +exports.default = _default; +module.exports = exports.default; +},{"../../../_lib/buildLocalizeFn/index.js":34}],41:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _index = _interopRequireDefault(require("../../../_lib/buildMatchPatternFn/index.js")); + +var _index2 = _interopRequireDefault(require("../../../_lib/buildMatchFn/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var matchOrdinalNumberPattern = /^(\d+)(th|st|nd|rd)?/i; +var parseOrdinalNumberPattern = /\d+/i; +var matchEraPatterns = { + narrow: /^(b|a)/i, + abbreviated: /^(b\.?\s?c\.?|b\.?\s?c\.?\s?e\.?|a\.?\s?d\.?|c\.?\s?e\.?)/i, + wide: /^(before christ|before common era|anno domini|common era)/i +}; +var parseEraPatterns = { + any: [/^b/i, /^(a|c)/i] +}; +var matchQuarterPatterns = { + narrow: /^[1234]/i, + abbreviated: /^q[1234]/i, + wide: /^[1234](th|st|nd|rd)? quarter/i +}; +var parseQuarterPatterns = { + any: [/1/i, /2/i, /3/i, /4/i] +}; +var matchMonthPatterns = { + narrow: /^[jfmasond]/i, + abbreviated: /^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i, + wide: /^(january|february|march|april|may|june|july|august|september|october|november|december)/i +}; +var parseMonthPatterns = { + narrow: [/^j/i, /^f/i, /^m/i, /^a/i, /^m/i, /^j/i, /^j/i, /^a/i, /^s/i, /^o/i, /^n/i, /^d/i], + any: [/^ja/i, /^f/i, /^mar/i, /^ap/i, /^may/i, /^jun/i, /^jul/i, /^au/i, /^s/i, /^o/i, /^n/i, /^d/i] +}; +var matchDayPatterns = { + narrow: /^[smtwf]/i, + short: /^(su|mo|tu|we|th|fr|sa)/i, + abbreviated: /^(sun|mon|tue|wed|thu|fri|sat)/i, + wide: /^(sunday|monday|tuesday|wednesday|thursday|friday|saturday)/i +}; +var parseDayPatterns = { + narrow: [/^s/i, /^m/i, /^t/i, /^w/i, /^t/i, /^f/i, /^s/i], + any: [/^su/i, /^m/i, /^tu/i, /^w/i, /^th/i, /^f/i, /^sa/i] +}; +var matchDayPeriodPatterns = { + narrow: /^(a|p|mi|n|(in the|at) (morning|afternoon|evening|night))/i, + any: /^([ap]\.?\s?m\.?|midnight|noon|(in the|at) (morning|afternoon|evening|night))/i +}; +var parseDayPeriodPatterns = { + any: { + am: /^a/i, + pm: /^p/i, + midnight: /^mi/i, + noon: /^no/i, + morning: /morning/i, + afternoon: /afternoon/i, + evening: /evening/i, + night: /night/i + } +}; +var match = { + ordinalNumber: (0, _index.default)({ + matchPattern: matchOrdinalNumberPattern, + parsePattern: parseOrdinalNumberPattern, + valueCallback: function (value) { + return parseInt(value, 10); + } + }), + era: (0, _index2.default)({ + matchPatterns: matchEraPatterns, + defaultMatchWidth: 'wide', + parsePatterns: parseEraPatterns, + defaultParseWidth: 'any' + }), + quarter: (0, _index2.default)({ + matchPatterns: matchQuarterPatterns, + defaultMatchWidth: 'wide', + parsePatterns: parseQuarterPatterns, + defaultParseWidth: 'any', + valueCallback: function (index) { + return index + 1; + } + }), + month: (0, _index2.default)({ + matchPatterns: matchMonthPatterns, + defaultMatchWidth: 'wide', + parsePatterns: parseMonthPatterns, + defaultParseWidth: 'any' + }), + day: (0, _index2.default)({ + matchPatterns: matchDayPatterns, + defaultMatchWidth: 'wide', + parsePatterns: parseDayPatterns, + defaultParseWidth: 'any' + }), + dayPeriod: (0, _index2.default)({ + matchPatterns: matchDayPeriodPatterns, + defaultMatchWidth: 'any', + parsePatterns: parseDayPeriodPatterns, + defaultParseWidth: 'any' + }) +}; +var _default = match; +exports.default = _default; +module.exports = exports.default; +},{"../../../_lib/buildMatchFn/index.js":35,"../../../_lib/buildMatchPatternFn/index.js":36}],42:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _index = _interopRequireDefault(require("./_lib/formatDistance/index.js")); + +var _index2 = _interopRequireDefault(require("./_lib/formatLong/index.js")); + +var _index3 = _interopRequireDefault(require("./_lib/formatRelative/index.js")); + +var _index4 = _interopRequireDefault(require("./_lib/localize/index.js")); + +var _index5 = _interopRequireDefault(require("./_lib/match/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * @type {Locale} + * @category Locales + * @summary English locale (United States). + * @language English + * @iso-639-2 eng + * @author Sasha Koss [@kossnocorp]{@link https://github.com/kossnocorp} + * @author Lesha Koss [@leshakoss]{@link https://github.com/leshakoss} + */ +var locale = { + code: 'en-US', + formatDistance: _index.default, + formatLong: _index2.default, + formatRelative: _index3.default, + localize: _index4.default, + match: _index5.default, + options: { + weekStartsOn: 0 + /* Sunday */ + , + firstWeekContainsDate: 1 + } +}; +var _default = locale; +exports.default = _default; +module.exports = exports.default; +},{"./_lib/formatDistance/index.js":37,"./_lib/formatLong/index.js":38,"./_lib/formatRelative/index.js":39,"./_lib/localize/index.js":40,"./_lib/match/index.js":41}],43:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = subMilliseconds; + +var _index = _interopRequireDefault(require("../_lib/toInteger/index.js")); + +var _index2 = _interopRequireDefault(require("../addMilliseconds/index.js")); + +var _index3 = _interopRequireDefault(require("../_lib/requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * @name subMilliseconds + * @category Millisecond Helpers + * @summary Subtract the specified number of milliseconds from the given date. + * + * @description + * Subtract the specified number of milliseconds from the given date. + * + * ### v2.0.0 breaking changes: + * + * - [Changes that are common for the whole library](https://github.com/date-fns/date-fns/blob/master/docs/upgradeGuide.md#Common-Changes). + * + * @param {Date|Number} date - the date to be changed + * @param {Number} amount - the amount of milliseconds to be subtracted. Positive decimals will be rounded using `Math.floor`, decimals less than zero will be rounded using `Math.ceil`. + * @returns {Date} the new date with the milliseconds subtracted + * @throws {TypeError} 2 arguments required + * + * @example + * // Subtract 750 milliseconds from 10 July 2014 12:45:30.000: + * const result = subMilliseconds(new Date(2014, 6, 10, 12, 45, 30, 0), 750) + * //=> Thu Jul 10 2014 12:45:29.250 + */ +function subMilliseconds(dirtyDate, dirtyAmount) { + (0, _index3.default)(2, arguments); + var amount = (0, _index.default)(dirtyAmount); + return (0, _index2.default)(dirtyDate, -amount); +} + +module.exports = exports.default; +},{"../_lib/requiredArgs/index.js":24,"../_lib/toInteger/index.js":29,"../addMilliseconds/index.js":30}],44:[function(require,module,exports){ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = toDate; + +var _index = _interopRequireDefault(require("../_lib/requiredArgs/index.js")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** + * @name toDate + * @category Common Helpers + * @summary Convert the given argument to an instance of Date. + * + * @description + * Convert the given argument to an instance of Date. + * + * If the argument is an instance of Date, the function returns its clone. + * + * If the argument is a number, it is treated as a timestamp. + * + * If the argument is none of the above, the function returns Invalid Date. + * + * **Note**: *all* Date arguments passed to any *date-fns* function is processed by `toDate`. + * + * @param {Date|Number} argument - the value to convert + * @returns {Date} the parsed date in the local time zone + * @throws {TypeError} 1 argument required + * + * @example + * // Clone the date: + * const result = toDate(new Date(2014, 1, 11, 11, 30, 30)) + * //=> Tue Feb 11 2014 11:30:30 + * + * @example + * // Convert the timestamp to date: + * const result = toDate(1392098430000) + * //=> Tue Feb 11 2014 11:30:30 + */ +function toDate(argument) { + (0, _index.default)(1, arguments); + var argStr = Object.prototype.toString.call(argument); // Clone the date + + if (argument instanceof Date || typeof argument === 'object' && argStr === '[object Date]') { + // Prevent the date to lose the milliseconds when passed to new Date() in IE10 + return new Date(argument.getTime()); + } else if (typeof argument === 'number' || argStr === '[object Number]') { + return new Date(argument); + } else { + if ((typeof argument === 'string' || argStr === '[object String]') && typeof console !== 'undefined') { + // eslint-disable-next-line no-console + console.warn("Starting with v2.0.0-beta.1 date-fns doesn't accept strings as date arguments. Please use `parseISO` to parse strings. See: https://git.io/fjule"); // eslint-disable-next-line no-console + + console.warn(new Error().stack); + } + + return new Date(NaN); + } +} + +module.exports = exports.default; +},{"../_lib/requiredArgs/index.js":24}],45:[function(require,module,exports){ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.parseTimestamp = exports.parse = void 0; +const options_1 = __importDefault(require("./options")); +const parse_1 = require("./parse"); +const timestamp_1 = require("./timestamp"); +Object.defineProperty(exports, "parseTimestamp", { enumerable: true, get: function () { return timestamp_1.parse; } }); +const tokenize_1 = require("./tokenize"); +__exportStar(require("./types"), exports); +const parse = (text, options = {}) => { + return parse_1.parse(tokenize_1.tokenize(text, Object.assign(Object.assign({}, options_1.default), options))); +}; +exports.parse = parse; + +},{"./options":47,"./parse":53,"./timestamp":62,"./tokenize":68,"./types":73}],46:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.level = exports.map = exports.push = void 0; +const position_1 = require("./position"); +const clone = ({ start, end }) => ({ + start: Object.assign({}, start), + end: Object.assign({}, end), +}); +const adjustPosition = (parent) => (child) => { + let dirty = false; + if (!child.position) + return; + if (parent.position) { + const belowLowerBound = position_1.before(parent.position.start); + const aboveUpperBound = position_1.after(parent.position.end); + if (position_1.isEmpty(parent.position)) { + parent.position = clone(child.position); + dirty = true; + } + else if (belowLowerBound(child.position.start)) { + parent.position.start = Object.assign({}, child.position.start); + dirty = true; + } + else if (aboveUpperBound(child.position.end)) { + parent.position.end = Object.assign({}, child.position.end); + dirty = true; + } + } + else { + parent.position = clone(child.position); + dirty = true; + } + if (!!parent.parent && dirty) { + adjustPosition(parent.parent)(parent); + } +}; +const push = (p) => (n) => { + if (!n) + return p; + adjustPosition(p)(n); + const node = n; + if (node) { + node.parent = p; + } + p.children.push(n); + return p; +}; +exports.push = push; +const map = (transform) => (node) => { + const result = Object.assign({ type: node.type }, transform(node)); + if (node.children) { + result.children = node.children.map(exports.map(transform)); + } + return result; +}; +exports.map = map; +const level = (node) => { + let count = 0; + let parent = node.parent; + while (parent) { + count += 1; + parent = parent.parent; + } + return count; +}; +exports.level = level; + +},{"./position":60}],47:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = { + todos: ['TODO | DONE'], + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, +}; + +},{}],48:[function(require,module,exports){ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const _primitive_1 = __importDefault(require("./_primitive")); +exports.default = (text) => { + let t = text; + const result = {}; + while (t.length > 0) { + const m = t.match(/^:\w+/); + if (!m) + break; + const key = m[0].substring(1); + t = t.slice(m[0].length); + const end = t.match(/\s(:\w+)/); + const index = end ? end.index + 1 : t.length; + const value = t.substring(0, index).trim(); + t = t.slice(index); + result[key] = _primitive_1.default(value); + } + return result; +}; + +},{"./_primitive":49}],49:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = (value) => { + const num = Number(value); + if (!Number.isNaN(num)) + return num; + if (value.toLowerCase() === 'true') + return true; + if (value.toLowerCase() === 'false') + return false; + return value; +}; + +},{}],50:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = (lexer) => { + const { peek, eat, substring } = lexer; + const begin = peek(); + if (!begin || begin.type !== 'block.begin') + return undefined; + const block = { + type: 'block', + name: begin.name, + params: begin.params, + position: begin.position, + value: '', + attributes: {}, + }; + eat(); + let contentStart = begin.position.end; + const nl = eat('newline'); + if (nl) { + contentStart = nl.position.end; + } + eat('newline'); + const range = { + start: contentStart, + end: begin.position.end, + }; + const align = (content) => { + let indent = -1; + return content.trimRight().split('\n').map(line => { + const _indent = line.search(/\S/); + if (indent === -1) { + indent = _indent; + } + if (indent === -1) + return ''; + return line.substring(Math.min(_indent, indent)); + }).join('\n'); + }; + const parse = () => { + const n = peek(); + if (!n || n.type === 'stars') + return undefined; + eat(); + if (n.type === 'block.end' && n.name.toLowerCase() === begin.name.toLowerCase()) { + range.end = n.position.start; + eat('newline'); + block.value = align(substring(range)); + block.position.end = n.position.end; + return block; + } + return parse(); + }; + return parse(); +}; + +},{}],51:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = (lexer) => { + const { peek, eat, substring } = lexer; + const begin = peek(); + if (!begin || begin.type !== 'drawer.begin') + return undefined; + const drawer = { + type: 'drawer', + name: begin.name, + position: begin.position, + value: '' + }; + eat(); + const contentPosition = peek().position; + const parse = () => { + const n = peek(); + if (!n || n.type === 'stars') + return undefined; + eat(); + if (n.type === 'drawer.end') { + contentPosition.end = n.position.start; + eat('newline'); + drawer.value = substring(contentPosition).trim(); + drawer.position.end = n.position.end; + return drawer; + } + else { + return parse(); + } + }; + return parse(); +}; + +},{}],52:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const node_1 = require("../node"); +exports.default = (lexer) => { + const { peek, eat } = lexer; + const parse = (headline) => { + const a = node_1.push(headline); + const token = peek(); + if (!token) + return headline; + if (token.type === 'newline') { + node_1.push(headline)(token); + eat(); + return headline; + } + if (token.type === 'stars') { + headline.level = token.level; + } + if (token.type === 'todo') { + headline.keyword = token.keyword; + headline.actionable = token.actionable; + } + if (token.value) { + headline.content += token.value; + } + if (token.type === 'tags') { + headline.tags = token.tags; + } + a(token); + eat(); + return parse(headline); + }; + return parse({ + type: 'headline', + actionable: false, + content: '', + children: [], level: -1 + }); +}; + +},{"../node":46}],53:[function(require,module,exports){ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.parse = void 0; +const section_1 = __importDefault(require("./section")); +const parse = (lexer) => { + return section_1.default(lexer)({ + type: 'document', + properties: {}, + children: [] + }); +}; +exports.parse = parse; + +},{"./section":57}],54:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const node_1 = require("../node"); +exports.default = (lexer) => { + const { peek, eat } = lexer; + const token = peek(); + if (!token || token.type !== 'list.item.bullet') + return undefined; + let eolCount = 0; + const newList = (token) => ({ + type: 'list', + indent: token.indent, + ordered: token.ordered, + children: [], + attributes: {}, + }); + const parseListItem = (listItem) => { + const token = peek(); + if (!token || token.type === 'newline') + return listItem; + if (token.type === 'list.item.tag') { + listItem.tag = token.value; + } + else { + node_1.push(listItem)(token); + } + eat(); + return parseListItem(listItem); + }; + const parse = (list) => { + const token = peek(); + if (!token || token.type === 'stars' || eolCount > 1) + return list; + if (token.type === 'newline') { + eat(); + eolCount += 1; + return parse(list); + } + eolCount = 0; + if (token.type !== 'list.item.bullet' || list.indent > token.indent) { + return list; + } + if (list.indent < token.indent) { + node_1.push(list)(parse(newList(token))); + } + else { + const li = parseListItem({ + type: 'list.item', + indent: token.indent, + children: [] + }); + node_1.push(list)(li); + } + return parse(list); + }; + return parse(newList(token)); +}; + +},{"../node":46}],55:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const node_1 = require("../node"); +const utils_1 = require("../utils"); +const isWhitespaces = node => { + return node.type === 'text.plain' && node.value.trim().length === 0; +}; +exports.default = ({ peek, eat }) => { + let eolCount = 0; + const createParagraph = () => ({ + type: 'paragraph', + children: [], + attributes: {}, + }); + const build = (p = undefined) => { + const token = peek(); + if (!token || eolCount >= 2) { + return p; + } + if (token.type === 'newline') { + eat(); + eolCount += 1; + p = p || createParagraph(); + node_1.push(p)({ type: 'text.plain', value: ' ', position: token.position }); + return build(p); + } + if (utils_1.isPhrasingContent(token)) { + p = p || createParagraph(); + node_1.push(p)(token); + eat(); + eolCount = 0; + return build(p); + } + return p; + }; + const p = build(); + if (!p) + return undefined; + while (p.children.length > 0) { + if (isWhitespaces(p.children[p.children.length - 1])) { + p.children.pop(); + continue; + } + if (isWhitespaces(p.children[0])) { + p.children.slice(1); + continue; + } + break; + } + if (p.children.length === 0) { + return undefined; + } + return p; +}; + +},{"../node":46,"../utils":75}],56:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = (lexer) => { + const { peek, eat } = lexer; + const all = []; + const parse = () => { + const keyword = peek(); + const timestamp = peek(1); + if (!keyword || keyword.type !== 'planning.keyword') + return; + if (!timestamp || timestamp.type !== 'planning.timestamp') + return; + const planning = { + type: 'planning', + keyword: keyword.value, + timestamp: timestamp.value, + position: { + start: keyword.position.start, + end: timestamp.position.end, + } + }; + eat(); + eat(); + all.push(planning); + parse(); + }; + parse(); + const nl = peek(); + if (nl && nl.type === 'newline') + eat(); + return all; +}; + +},{}],57:[function(require,module,exports){ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_1 = require("../node"); +const block_1 = __importDefault(require("./block")); +const drawer_1 = __importDefault(require("./drawer")); +const headline_1 = __importDefault(require("./headline")); +const list_1 = __importDefault(require("./list")); +const paragraph_1 = __importDefault(require("./paragraph")); +const planning_1 = __importDefault(require("./planning")); +const table_1 = __importDefault(require("./table")); +const utils_1 = __importDefault(require("./utils")); +const _parseSymbols_1 = __importDefault(require("./_parseSymbols")); +const _primitive_1 = __importDefault(require("./_primitive")); +const AFFILIATED_KEYWORDS = ['caption', 'header', 'name', 'plot', 'results']; +const attach = (attributes) => (node) => { + if (Object.keys(attributes).length === 0) + return; + node.attributes = Object.assign(Object.assign({}, node.attributes), attributes); +}; +exports.default = (lexer) => (root) => { + const { peek, eat, eatAll } = lexer; + const { tryTo } = utils_1.default(lexer); + const newSection = (props = {}) => { + const headline = headline_1.default(lexer); + const section = { + type: 'section', + level: headline.level, + properties: Object.assign({}, props), + children: [], + }; + node_1.push(section)(headline); + const plannings = planning_1.default(lexer); + plannings.forEach(node_1.push(section)); + while (tryTo(drawer_1.default)(drawer => { + if (drawer.name.toLowerCase() === 'properties') { + section.properties = drawer.value.split('\n').reduce((accu, current) => { + const m = current.match(/\s*:(.+?):\s*(.+)\s*$/); + if (m) { + return Object.assign(Object.assign({}, accu), { [m[1].toLowerCase()]: m[2] }); + } + return accu; + }, section.properties); + } + node_1.push(section)(drawer); + })) + continue; + return section; + }; + const parse = (section, attributes = {}) => { + if (eatAll('newline') > 1) { + attributes = {}; + } + const token = peek(); + if (!token) + return section; + if (token.type === 'stars') { + if (section.type === 'document' || + (section.type === 'section' && token.level > section.level)) { + const ns = newSection(section.properties); + node_1.push(section)(parse(ns)); + return parse(section); + } + return section; + } + if (token.type === 'keyword') { + const key = token.key.toLowerCase(); + const { value } = token; + if (AFFILIATED_KEYWORDS.includes(key)) { + attributes[key] = _primitive_1.default(value); + } + else if (key.startsWith('attr_')) { + attributes[key] = Object.assign(Object.assign({}, attributes[key]), _parseSymbols_1.default(value)); + } + else if (key === 'todo') { + lexer.addInBufferTodoKeywords(value); + } + else if (key === 'html') { + node_1.push(section)({ type: 'html', value }); + } + else if (section.type === 'document') { + section.properties[key] = value; + } + eat(); + return parse(section, attributes); + } + if (tryTo(list_1.default)(attach(attributes), node_1.push(section))) { + return parse(section); + } + if (tryTo(table_1.default)(attach(attributes), node_1.push(section))) { + return parse(section); + } + if (tryTo(block_1.default)(attach(attributes), node_1.push(section))) { + return parse(section); + } + if (token.type === 'hr') { + node_1.push(section)(token); + } + if (tryTo(paragraph_1.default)(attach(attributes), node_1.push(section))) { + return parse(section); + } + if (token.type === 'footnote.label') { + if (section.type !== 'document') + return section; + const footnote = { + type: 'footnote', + label: token.label, + children: [], + }; + eat(); + node_1.push(section)(parse(footnote)); + return parse(section); + } + eat(); + return parse(section); + }; + parse(root); + return root; +}; + +},{"../node":46,"./_parseSymbols":48,"./_primitive":49,"./block":50,"./drawer":51,"./headline":52,"./list":54,"./paragraph":55,"./planning":56,"./table":58,"./utils":59}],58:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const node_1 = require("../node"); +exports.default = (lexer) => { + const { peek, eat } = lexer; + const token = peek(); + if (!token || !token.type.startsWith('table.')) + return undefined; + const getCell = (cell = undefined) => { + const t = peek(); + if (!t || t.type === 'newline' || t.type === 'table.columnSeparator') { + return cell; + } + const c = cell || { type: 'table.cell', children: [] }; + node_1.push(c)(t); + eat(); + return getCell(c); + }; + const getRow = (row = undefined) => { + const t = peek(); + if (!t) { + return row; + } + if (t.type === 'table.hr' && row === undefined) { + eat(); + return t; + } + if (t.type === 'table.columnSeparator') { + eat('table.columnSeparator'); + const _row = row || { type: 'table.row', children: [] }; + node_1.push(_row)(getCell()); + return getRow(_row); + } + return row; + }; + const parse = (table = undefined) => { + const row = getRow(); + if (!row) { + return table; + } + const _table = table || { type: 'table', children: [], attributes: {} }; + node_1.push(_table)(row); + eat('newline'); + return parse(_table); + }; + return parse(); +}; + +},{"../node":46}],59:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const node_1 = require("../node"); +exports.default = (lexer) => { + const { peek, eat, save, restore } = lexer; + const collect = (stop) => (container) => { + const token = peek(); + if (!token || stop(token)) + return container; + eat(); + node_1.push(container)(token); + return collect(stop)(container); + }; + const skip = (predicate) => { + const token = peek(); + if (token && predicate(token)) { + eat(); + skip(predicate); + return; + } + return; + }; + const tryTo = (parse) => (...actions) => { + const savePoint = save(); + const node = parse(lexer); + if (!node) { + restore(savePoint); + return false; + } + actions.forEach(action => action(node)); + return true; + }; + return { + collect, + skip, + tryTo, + }; +}; + +},{"../node":46}],60:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isEmpty = exports.before = exports.after = exports.isEqual = void 0; +const isEqual = (p1, p2) => { + return p1.line === p2.line && p1.column === p2.column; +}; +exports.isEqual = isEqual; +const compare = (p1, p2) => { + if (p1.line > p2.line) + return true; + if (p1.line === p2.line && p1.column > p2.column) + return true; + return false; +}; +const after = (p1) => (p2) => { + return compare(p2, p1); +}; +exports.after = after; +const before = (p1) => (p2) => { + return compare(p1, p2); +}; +exports.before = before; +const isEmpty = (position) => { + return !position || exports.isEqual(position.start, position.end); +}; +exports.isEmpty = isEmpty; + +},{}],61:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.read = void 0; +const text_kit_1 = require("text-kit"); +const read = (text) => { + const { shift, substring, linePosition, toIndex, match, location, } = text_kit_1.read(text); + let cursor = { line: 1, column: 1 }; + const isStartOfLine = () => cursor.column === 1; + const getChar = (p = 0) => { + const { pos, offset } = typeof p === 'number' ? + { pos: cursor, offset: p } : + { pos: p, offset: 0 }; + return text.charAt(toIndex(pos) + offset); + }; + const endOfLine = (ln) => { + return location(toIndex(linePosition(ln).end)); + }; + const now = () => cursor; + const eat = (param = 'char') => { + const start = now(); + if (param === 'char') { + cursor = shift(start, 1); + } + else if (param === 'line') { + const lp = linePosition(cursor.line); + cursor = lp.end; + } + else if (param === 'whitespaces') { + return eat(/^\s+/); + } + else if (typeof param === 'number') { + cursor = shift(start, param); + } + else { + const m = param.exec(substring({ start: cursor })); + if (m) { + cursor = location(toIndex(cursor) + m.index + m[0].length); + } + } + const position = { + start, + end: cursor, + }; + return { + value: substring(position), + position, + }; + }; + const eol = () => endOfLine(cursor.line); + const EOF = () => { + return toIndex(now()) >= text.length - 1; + }; + const distance = ({ start, end }) => { + return toIndex(end) - toIndex(start); + }; + const jump = (point) => { + cursor = point; + }; + const reader = { + isStartOfLine, + getChar, + getLine: () => substring({ start: cursor }), + substring, + now, + distance, + EOF, + eat, + eol, + jump, + match: (pattern, position = { start: now(), end: eol() }) => match(pattern, position), + }; + return reader; +}; +exports.read = read; + +},{"text-kit":76}],62:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.parse = void 0; +const date_fns_tz_1 = require("date-fns-tz"); +const reader_1 = require("./reader"); +const parse = (input, { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone } = {}) => { + const { match, eat, getChar, jump } = reader_1.read(input); + eat('whitespaces'); + const timestamp = () => { + const { value: opening } = eat(/[<[]/g); + if (opening.length === 0) + return; + const active = opening === '<'; + const { value: _date } = eat(/\d{4}-\d{2}-\d{2}/); + let date = _date; + eat('whitespaces'); + let end; + const { value: _day } = eat(/[a-zA-Z]+/); + eat('whitespaces'); + const time = match(/(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?/); + if (time) { + date = `${_date} ${time.captures[1]}`; + if (time.captures[2]) { + end = `${_date} ${time.captures[2]}`; + } + jump(time.position.end); + } + const closing = getChar(); + if ((opening === '[' && closing === ']') || + (opening === '<' && closing === '>')) { + eat('char'); + return { + date: date_fns_tz_1.zonedTimeToUtc(date, timezone), + end: end ? date_fns_tz_1.zonedTimeToUtc(end, timezone) : undefined, + }; + } + }; + const ts = timestamp(); + if (!ts) + return; + if (!ts.end) { + const { value: doubleDash } = eat(/--/); + if (doubleDash.length > 0) { + const end = timestamp(); + if (!!end) { + ts.end = end.date; + } + } + } + return ts; +}; +exports.parse = parse; + +},{"./reader":61,"date-fns-tz":7}],63:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = (text) => { + const actionables = text.split(' ').map(p => p.trim()).filter(String); + const pipe = actionables.indexOf('|'); + const done = actionables.splice(pipe).filter(t => t !== '|'); + return { + actionables, + done, + get keywords() { + return actionables.concat(done); + } + }; +}; + +},{}],64:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = ({ reader }) => { + const { match, eat } = reader; + let m = match(/^\s*#\+begin_([^\s]+)\s*(.*)$/i); + if (m) { + eat('line'); + const params = m.captures[2].split(' ').map(p => p.trim()).filter(String); + return [{ + type: 'block.begin', + name: m.captures[1], + params, + position: m.position, + }]; + } + m = match(/^\s*#\+end_([^\s]+)\s*$/i); + if (m) { + eat('line'); + return [{ + type: 'block.end', + position: m.position, + name: m.captures[1], + }]; + } + return []; +}; + +},{}],65:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = ({ reader }) => { + const { match, eat } = reader; + const m = match(/^:(\w+):(?=\s*$)/); + if (m) { + eat('line'); + const name = m.captures[1]; + if (name.toLowerCase() === 'end') { + return [{ + type: 'drawer.end', + position: m.position, + }]; + } + else { + return [{ + type: 'drawer.begin', + name, + position: m.position, + }]; + } + } + return []; +}; + +},{}],66:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const inline_1 = require("./inline"); +exports.default = ({ reader }) => { + const { match, jump, eat } = reader; + let tokens = []; + const m = match(/^\[fn:([^\]]+)\](?=\s)/); + if (!m) + return []; + tokens.push({ + type: 'footnote.label', + label: m.captures[1], + position: m.position, + }); + jump(m.position.end); + eat('whitespaces'); + tokens = tokens.concat(inline_1.tokenize({ reader })); + return tokens; +}; + +},{"./inline":69}],67:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const position_1 = require("../position"); +const inline_1 = require("./inline"); +exports.default = ({ reader, todoKeywordSets }) => { + const { match, now, eol, eat, jump, substring, } = reader; + const todos = todoKeywordSets.flatMap(s => s.keywords); + const isActionable = (keyword) => { + return !!todoKeywordSets.find(s => s.actionables.includes(keyword)); + }; + let buffer = []; + const stars = eat(/^\*+(?=\s)/); + if (position_1.isEmpty(stars.position)) + throw Error('not gonna happen'); + buffer.push({ + type: 'stars', + level: stars.value.length, + position: stars.position, + }); + eat('whitespaces'); + const keyword = eat(RegExp(`^${todos.map(escape).join('|^')}(?=\\s)`)); + if (!position_1.isEmpty(keyword.position)) { + buffer.push({ + type: 'todo', + keyword: keyword.value, + actionable: isActionable(keyword.value), + position: keyword.position, + }); + } + eat('whitespaces'); + const priority = eat(/^\[#(A|B|C)\](?=\s)/); + if (!position_1.isEmpty(priority.position)) { + buffer.push(Object.assign({ type: 'priority' }, priority)); + } + eat('whitespaces'); + const tags = match(/\s+(:(?:[\w@_#%-]+:)+)[ \t]*$/gm); + let contentEnd = eol(); + if (tags) { + contentEnd = tags.position.start; + } + const tokens = inline_1.tokenize({ reader, end: contentEnd }); + buffer = buffer.concat(tokens); + if (tags) { + eat('whitespaces'); + const tagsPosition = { start: now(), end: tags.position.end }; + const s = substring(tagsPosition); + buffer.push({ + type: 'tags', + tags: s.split(':').map(t => t.trim()).filter(Boolean), + position: { start: now(), end: tags.position.end }, + }); + jump(tags.position.end); + } + eat('line'); + return buffer; +}; + +},{"../position":60,"./inline":69}],68:[function(require,module,exports){ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.tokenize = void 0; +const options_1 = __importDefault(require("../options")); +const position_1 = require("../position"); +const reader_1 = require("../reader"); +const todo_keyword_set_1 = __importDefault(require("../todo-keyword-set")); +const block_1 = __importDefault(require("./block")); +const drawer_1 = __importDefault(require("./drawer")); +const footnote_1 = __importDefault(require("./footnote")); +const headline_1 = __importDefault(require("./headline")); +const inline_1 = require("./inline"); +const list_1 = __importDefault(require("./list")); +const planning_1 = __importDefault(require("./planning")); +const table_1 = __importDefault(require("./table")); +const PLANNING_KEYWORDS = ['DEADLINE', 'SCHEDULED', 'CLOSED']; +const tokenize = (text, options = {}) => { + const { timezone, todos } = Object.assign(Object.assign({}, options_1.default), options); + const reader = reader_1.read(text); + const { isStartOfLine, eat, getLine, getChar, now, match, EOF, substring, } = reader; + const globalTodoKeywordSets = todos.map(todo_keyword_set_1.default); + const inBufferTodoKeywordSets = []; + const todoKeywordSets = () => { + return inBufferTodoKeywordSets.length === 0 + ? globalTodoKeywordSets : inBufferTodoKeywordSets; + }; + let tokens = []; + let cursor = 0; + const tok = () => { + eat('whitespaces'); + if (EOF()) + return []; + if (getChar() === '\n') { + return [{ + type: 'newline', + position: eat('char').position, + }]; + } + if (isStartOfLine() && match(/^\*+\s+/)) { + return headline_1.default({ + reader, + todoKeywordSets: todoKeywordSets(), + }); + } + const l = getLine(); + if (PLANNING_KEYWORDS.some((k) => l.startsWith(k))) { + return planning_1.default({ + reader, + keywords: PLANNING_KEYWORDS, + timezone + }); + } + if (l.startsWith('#+')) { + const keyword = match(/^\s*#\+(\w+):\s*(.*)$/); + if (keyword) { + eat('line'); + return [{ + type: 'keyword', + key: keyword.captures[1], + value: keyword.captures[2], + position: keyword.position, + }]; + } + const block = block_1.default({ reader }); + if (block.length > 0) + return block; + } + const list = list_1.default({ reader }); + if (list.length > 0) + return list; + if (l.startsWith('# ')) { + const comment = match(/^#\s+(.*)$/); + if (comment) { + eat('line'); + return [{ + type: 'comment', + position: comment.position, + value: comment.captures[1], + }]; + } + } + const drawer = drawer_1.default({ reader }); + if (drawer.length > 0) + return drawer; + const table = table_1.default({ reader }); + if (table.length > 0) + return table; + const hr = eat(/^\s*-{5,}\s*$/).position; + if (!position_1.isEmpty(hr)) { + return [{ + type: 'hr', + position: hr, + }]; + } + if (now().column === 1) { + const footnote = footnote_1.default({ reader }); + if (footnote.length > 0) + return footnote; + } + return inline_1.tokenize({ reader }); + }; + const peek = (offset = 0) => { + const pos = cursor + offset; + if (pos >= tokens.length) { + tokens = tokens.concat(tok()); + } + return tokens[pos]; + }; + const _eat = (type = undefined) => { + const t = peek(); + if (!t) + return undefined; + if (!type || type === t.type) { + cursor += 1; + return t; + } + return undefined; + }; + return { + peek, + eat: _eat, + eatAll: (type) => { + let count = 0; + while (_eat(type)) { + count += 1; + } + return count; + }, + match: (cond, offset = 0) => { + const token = peek(); + if (!token) + return false; + if (typeof cond === 'string') { + return token.type === cond; + } + return cond.test(token.type); + }, + all: (max = undefined) => { + let _all = []; + let tokens = tok(); + while (tokens.length > 0) { + _all = _all.concat(tokens); + tokens = tok(); + } + return _all; + }, + save: () => cursor, + restore: (point) => { cursor = point; }, + addInBufferTodoKeywords: (text) => { + inBufferTodoKeywordSets.push(todo_keyword_set_1.default(text)); + }, + substring, + }; +}; +exports.tokenize = tokenize; + +},{"../options":47,"../position":60,"../reader":61,"../todo-keyword-set":63,"./block":64,"./drawer":65,"./footnote":66,"./headline":67,"./inline":69,"./list":70,"./planning":71,"./table":72}],69:[function(require,module,exports){ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.tokenize = void 0; +const position_1 = require("../position"); +const uri_1 = __importDefault(require("../uri")); +const utils_1 = require("../utils"); +const POST = `[\\s-\\.,:!?'\\)}]|$`; +const BORDER = `[^,'"\\s]`; +const MARKERS = { + '*': 'text.bold', + '=': 'text.verbatim', + '/': 'text.italic', + '+': 'text.strikeThrough', + '_': 'text.underline', + '~': 'text.code', +}; +const tokenize = ({ reader, start, end }) => { + const { now, eat, eol, match, jump, substring, getChar } = reader; + start = start || Object.assign({}, now()); + end = end || Object.assign({}, eol()); + jump(start); + let cursor = Object.assign({}, start); + const _tokens = []; + const tokLink = () => { + const m = match(/^\[\[([^\]]*)\](?:\[([^\]]*)\])?\]/m); + if (!m) + return undefined; + const linkInfo = uri_1.default(m.captures[1]); + return Object.assign(Object.assign({ type: 'link', description: m.captures[2] }, linkInfo), { position: m.position }); + }; + const tokFootnote = () => { + const m = match(/^\[fn:(\w+)\]/); + if (!m) + return undefined; + return { + type: 'footnote.reference', + label: m.captures[1], + position: m.position, + }; + }; + const tokStyledText = (marker) => () => { + const m = match(RegExp(`^${utils_1.escape(marker)}(${BORDER}(?:.*?(?:${BORDER}))??)${utils_1.escape(marker)}(?=(${POST}.*))`, 'm')); + if (!m) + return undefined; + return { + type: MARKERS[marker], + value: m.captures[1], + position: m.position, + }; + }; + const tryTo = (tok) => { + const token = tok(); + if (!token) + return false; + cleanup(); + _tokens.push(token); + jump(token.position.end); + cursor = Object.assign({}, now()); + return true; + }; + const cleanup = () => { + if (position_1.isEqual(cursor, now())) + return; + const position = { start: Object.assign({}, cursor), end: Object.assign({}, now()) }; + const value = substring(position); + _tokens.push({ + type: 'text.plain', + value, + position, + }); + }; + const tokNewline = () => { + const m = match(/^\n/); + if (!m) + return undefined; + return { + type: 'newline', + position: m.position, + }; + }; + const tok = () => { + if (position_1.isEqual(now(), end)) { + return; + } + const char = getChar(); + if (char === '[') { + if (tryTo(tokLink)) + return tok(); + if (tryTo(tokFootnote)) + return tok(); + } + if (MARKERS[char]) { + const pre = getChar(-1); + if (now().column === 1 || /[\s({'"]/.test(pre)) { + if (tryTo(tokStyledText(char))) + return tok(); + } + } + if (tryTo(tokNewline)) + return tok(); + eat(); + tok(); + }; + tok(); + cleanup(); + return _tokens; +}; +exports.tokenize = tokenize; + +},{"../position":60,"../uri":74,"../utils":75}],70:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const inline_1 = require("./inline"); +exports.default = ({ reader }) => { + const { now, match, eat, jump, substring } = reader; + let tokens = []; + const indent = now().column - 1; + const bullet = match(/^([-+]|\d+[.)])(?=\s)/); + if (!bullet) + return []; + tokens.push({ + type: 'list.item.bullet', + indent, + ordered: /^\d/.test(bullet.captures[1]), + position: bullet.position, + }); + jump(bullet.position.end); + eat('whitespaces'); + const checkbox = match(/^\[(x|X|-| )\](?=\s)/); + if (checkbox) { + tokens.push({ + type: 'list.item.checkbox', + checked: checkbox.captures[1] !== ' ', + position: checkbox.position, + }); + jump(checkbox.position.end); + } + eat('whitespaces'); + const tagMark = match(/\s+::\s+/); + if (tagMark) { + const pos = { start: now(), end: tagMark.position.start }; + tokens.push({ + type: 'list.item.tag', + value: substring(pos), + position: pos, + }); + jump(tagMark.position.end); + } + tokens = tokens.concat(inline_1.tokenize({ reader })); + eat('line'); + return tokens; +}; + +},{"./inline":69}],71:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const timestamp_1 = require("../timestamp"); +exports.default = ({ reader, keywords, timezone }) => { + const { eat, substring, now, getLine } = reader; + const p = RegExp(`(${keywords.join('|')}):`, 'g'); + const currentLine = getLine(); + const { line, column } = now(); + const getLocation = (offset) => ({ + line, column: column + offset, + }); + const all = []; + const parseLastTimestamp = (end) => { + if (all.length === 0) + return; + const { type, position } = all[all.length - 1]; + if (!position) + throw Error(`position is ${position}`); + if (type !== 'planning.keyword') + return; + const endLocation = getLocation(end); + const timestampPosition = { start: position.end, end: endLocation }; + const value = substring(timestampPosition); + all.push({ + type: 'planning.timestamp', + value: timestamp_1.parse(value, { timezone }), + position: timestampPosition, + }); + }; + let m; + while ((m = p.exec(currentLine)) !== null) { + parseLastTimestamp(m.index); + all.push({ + type: 'planning.keyword', + value: m[1], + position: { + start: getLocation(m.index), + end: getLocation(p.lastIndex), + }, + }); + } + parseLastTimestamp(currentLine.length); + eat('line'); + return all; +}; + +},{"../timestamp":62}],72:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const inline_1 = require("./inline"); +exports.default = ({ reader }) => { + const { match, eat, getChar, jump } = reader; + if (getChar() !== '|') + return []; + if (getChar(1) === '-') { + return [{ type: 'table.hr', position: eat('line').position }]; + } + let tokens = [{ + type: 'table.columnSeparator', + position: eat('char').position, + }]; + const tokCells = () => { + const m = match(/\|/); + const end = m && m.position.start; + tokens = tokens.concat(inline_1.tokenize({ reader, end })); + if (!m) + return; + tokens.push({ + type: 'table.columnSeparator', + position: m.position, + }); + jump(m.position.end); + tokCells(); + }; + tokCells(); + return tokens; +}; + +},{"./inline":69}],73:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); + +},{}],74:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +const URL_PATTERN = /(?:([a-z][a-z0-9+.-]*):)?(.*)/i; +const isFilePath = (str) => { + return str && /^\.{0,2}\//.test(str); +}; +exports.default = (link) => { + const m = URL_PATTERN.exec(link); + if (!m) + return undefined; + const protocol = (m[1] || (isFilePath(m[2]) ? `file` : `internal`)).toLowerCase(); + let value = m[2]; + if (/https?/.test(protocol)) { + value = `${protocol}:${value}`; + } + let search; + if (protocol === 'file') { + const m = /(.*?)::(.*)/.exec(value); + if (m && m[1] && m[2]) { + value = m[1]; + search = parseInt(m[2]); + search = Number.isInteger(search) ? search : m[2]; + } + } + return { protocol, value, search }; +}; + +},{}],75:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isPhrasingContent = exports.escape = void 0; +const matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g; +const escape = (str) => { + return str.replace(matchOperatorsRe, '\\$&'); +}; +exports.escape = escape; +const isPhrasingContent = (token) => { + return token.type.startsWith('text.') + || token.type === 'footnote.reference' + || token.type === 'link'; +}; +exports.isPhrasingContent = isPhrasingContent; + +},{}],76:[function(require,module,exports){ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.read = void 0; +const read_1 = __importDefault(require("./read")); +exports.read = read_1.default; + +},{"./read":77}],77:[function(require,module,exports){ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.default = (text) => { + let cursor = 0; + const lines = []; + do { + lines.push(cursor); + const nl = text.indexOf('\n', cursor); + cursor = nl + 1; + } while (cursor > 0 && cursor < text.length); + const toIndex = ({ line, column }) => { + if (line < 1) + return 0; + if (line > lines.length) + return text.length; + const index = lines[line - 1] + column - 1; + return Math.max(0, Math.min(index, text.length)); + }; + const middle = (start, end) => { + return start + Math.floor((end - start) / 2); + }; + const findLine = (index, start, end) => { + if (index < 0) + return 1; + if (index >= text.length) + return lines.length; + const mid = middle(start, end); + if (lines[mid - 1] > index) + return findLine(index, start, mid); + if (lines[mid] <= index) + return findLine(index, mid, end); + return mid; + }; + const location = (index) => { + const line = findLine(index, 1, lines.length + 1); + const column = Math.min(index, text.length) - toIndex({ line, column: 1 }) + 1; + return { + line, + column, + }; + }; + const match = (pattern, position) => { + const content = substring(position); + if (!content) + return undefined; + const match = pattern.exec(content); + if (!match) + return undefined; + const offset = toIndex(position.start); + const captures = match.map(m => m); + return { + captures, + position: { + start: location(offset + match.index), + end: location(offset + match.index + match[0].length), + } + }; + }; + const shift = (point, offset) => { + return location(toIndex(point) + offset); + }; + const linePosition = (ln) => { + if (ln < 1 || ln > lines.length) + return undefined; + const nextLine = lines[ln]; + const endIndex = nextLine ? nextLine - 1 : text.length; + return { + start: { line: ln, column: 1 }, + end: location(endIndex), + }; + }; + const substring = ({ start, end = 'EOL' }) => { + let endIndex; + if (end === 'EOL') { + const lp = linePosition(start.line); + if (!lp) { + console.log({ start }); + } + endIndex = toIndex(linePosition(start.line).end); + } + else if (end === 'EOF') { + endIndex = text.length; + } + else { + endIndex = toIndex(end); + } + return text.substring(toIndex(start), endIndex); + }; + return { + get numberOfLines() { + return lines.length; + }, + substring, + linePosition, + location, + match, + toIndex, + shift, + }; +}; + +},{}],78:[function(require,module,exports){ + +var { parse } = require('orga'); +console.log(parse("* TODO remember the milk :shopping:")); + +window.orgaparse = function(foo){return parse(foo);}; +},{"orga":45}]},{},[78]); diff --git a/versions/1.1/app/parser.js b/versions/1.1/app/parser.js new file mode 100644 index 0000000..ad8bb05 --- /dev/null +++ b/versions/1.1/app/parser.js @@ -0,0 +1,191 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Thin wrapper on top of orga. See orga-bundle.js + * + ***/ +'use strict' + +var AllNodes = []; +var Lines= []; + +function parseBTFile(fileText) { + // create and recursively walk orga parse tree to create bt model + + let parseTree; + try { + parseTree = orgaparse(fileText); + } + catch(e) { + alert('Error in parsing BrainTool.org file:\n', JSON.stringify(e)); + throw(e); + } + Lines = generateLinesAndColumns(fileText); // used to pull out text content + for (const orgaNode of parseTree.children) { + if (orgaNode.type == "section") + orgaSection(orgaNode, null); + } + + // Save top level properties if any, NB parser doesn't handle correctly + // See [[https://github.com/orgapp/orgajs/issues/82]] + const filePropertyRegex = /(^#\+PROPERTY: .*$\n)+/m; // multi-line match prop statements + const match = filePropertyRegex.exec(fileText); + if (!match) return; + const propValRegex = /PROPERTY: (.*?) (.*)/g; + let m; + while ((m = propValRegex.exec(match[0])) !== null) { + configManager.setProp(m[1], m[2]); + } +} + +function findOrCreateParentTopic(fileName, fileText) { + // pull any BTParentTopic out of file props and get/create, otherrwise create at top level + + const filePropertyRegex = /(^#\+PROPERTY: .*$\n)+/m; // multi-line match prop statements + const match = filePropertyRegex.exec(fileText); + + const propValRegex = /PROPERTY: (.*?) (.*)/g; + let m; + while (match && (m = propValRegex.exec(match[0])) !== null) { + if (m[1] == "BTParentTopic") + return BTAppNode.findOrCreateFromTopicDN(m[2]); + } + + return new BTAppNode(fileName, null, `Imported ${getDateString()}`, 1); +} + +function insertOrgFile(fileName, fileText) { + // Insert contents of this org filetext under the provided parent + + const parentNode = findOrCreateParentTopic(fileName, fileText); + const parseTree = orgaparse(fileText); + Lines = generateLinesAndColumns(fileText); + for (const orgaNode of parseTree.children) { + if (orgaNode.type == "section") + orgaSection(orgaNode, parentNode); + } + processImport(parentNode.id); // bt.js fn to write and refresh +} + +function orgaSection(section, parentAppNode) { + // Section is a Headlines, Paragraphs and contained Sections. + // Generate BTNode per Headline from Orga nodes. + const appNode = new BTAppNode("", parentAppNode ? parentAppNode.id : null, "", 0); + let allText = ""; + let index = 0; + for (const orgaChild of section.children) { + orgaChild.indexInParent = index++; // remember order to help + switch(orgaChild.type) { + case "headline": + appNode.level = parentAppNode ? parentAppNode.level + 1 : orgaChild.level; + appNode.title = orgaText(orgaChild, appNode); + if (orgaChild.keyword) appNode.keyword = orgaChild.keyword; + if (orgaChild.tags) appNode.tags = orgaChild.tags; + break; + case "section": + orgaSection(orgaChild, appNode); + break; + case "planning": + appNode.planning = orgaNodeRawText(orgaChild) + "\n"; + break; + case "drawer": + appNode.drawers[orgaChild.name] = orgaChild.value; + if (orgaChild.name == "PROPERTIES") + appNode.folded = orgaChild.value.match(/:VISIBILITY:\s*folded/g) ? true : false; + break; + case "paragraph": + allText += allText.length ? "\n" : ""; // add newlines between para's + allText += orgaText(orgaChild, appNode); // returns text but also updates appNode for contained links + break; + default: + allText += allText.length ? "\n" : ""; // elements are newline seperated + allText += orgaNodeRawText(orgaChild); + } + } + appNode.text = allText; + return appNode; +} + +function orgaLinkOrgText(node) { + // work around - orga.js includes protocol on http(s) links but not file, chrome-extension etc + const valIncludesProtocol = node.value.search('://'); + let url = node.value; + if (valIncludesProtocol > 0) + // peel off any leading 'http(s):' NB node.value contains any leading // + url = node.value.substring(valIncludesProtocol + 1); + url = node.protocol + ':' + url; + return "[[" + url + "][" + node.description + "]]"; +} + +function orgaText(organode, containingNode) { + // Return text from orga headline or para node. Both can contain texts and links + // NB also pulling out links inside paragraphs + let linkTitle, node, lnkNode, btString = ""; + for (const orgaChild of organode.children) { + if (orgaChild.type == "priority") { + btString += orgaNodeRawText(orgaChild) + ' '; + } + if (orgaChild.type.startsWith("text.")) { + if (orgaChild.value.startsWith('*')) btString += ' '; // workaround. orga strips leading spaces + btString += orgaNodeRawText(orgaChild); + } + if (orgaChild.type == "link") { + linkTitle = orgaLinkOrgText(orgaChild); + btString += linkTitle; + + if (organode.type == "paragraph") { + // This is a link inside text, not a tag'd link. So special handling w BTLinkNode. + lnkNode = new BTLinkNode(linkTitle, containingNode.id, "", containingNode.level+1, orgaChild.protocol); + } + } + } + return btString; +} + +function orgaNodeRawText(organode) { + // return raw text for this node + + // orga uses 1-based indicies + const startLine = organode.position.start.line - 1; + let startCol = organode.position.start.column - 1; + const endLine = organode.position.end.line - 1; + let endCol = organode.position.end.column - 1; + + if (organode.type == 'table') { // weird orga behavior for table + startCol--; endCol++; + } + let string = ""; + if (startLine == endLine) + return Lines[startLine].substr(startCol, (endCol - startCol)); + for (let i = startLine; i <= endLine; i++) { + if (i == startLine) + string += Lines[i].substr(startCol); + else if (i == endLine) { + string += Lines[i].substr(0, endCol); + break; // done, skip adding another \n + } + else + string += Lines[i]; + string += "\n"; + } + return string; +} + +function generateLinesAndColumns(filetext) { + // return an array of the original lines and columns for use in regnerating orga + + let lines = []; + filetext.split(/\r?\n/).forEach(line => lines.push(line)); + return lines; + +} diff --git a/versions/1.1/app/resources/BTTrialBuddy.png b/versions/1.1/app/resources/BTTrialBuddy.png new file mode 100644 index 0000000..36982a5 Binary files /dev/null and b/versions/1.1/app/resources/BTTrialBuddy.png differ diff --git a/versions/1.1/app/resources/actionsIcon.png b/versions/1.1/app/resources/actionsIcon.png new file mode 100644 index 0000000..9d16bc3 Binary files /dev/null and b/versions/1.1/app/resources/actionsIcon.png differ diff --git a/versions/1.1/app/resources/actionsIconLight.png b/versions/1.1/app/resources/actionsIconLight.png new file mode 100644 index 0000000..f0749ce Binary files /dev/null and b/versions/1.1/app/resources/actionsIconLight.png differ diff --git a/versions/1.1/app/resources/addSubtopic.svg b/versions/1.1/app/resources/addSubtopic.svg new file mode 100644 index 0000000..cfffc8a --- /dev/null +++ b/versions/1.1/app/resources/addSubtopic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/versions/1.1/app/resources/arrowDown.png b/versions/1.1/app/resources/arrowDown.png new file mode 100644 index 0000000..010d144 Binary files /dev/null and b/versions/1.1/app/resources/arrowDown.png differ diff --git a/versions/1.1/app/resources/arrowUp.png b/versions/1.1/app/resources/arrowUp.png new file mode 100644 index 0000000..49f2151 Binary files /dev/null and b/versions/1.1/app/resources/arrowUp.png differ diff --git a/versions/1.1/app/resources/backgroundLogo.svg b/versions/1.1/app/resources/backgroundLogo.svg new file mode 100644 index 0000000..a43ec45 --- /dev/null +++ b/versions/1.1/app/resources/backgroundLogo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/versions/1.1/app/resources/close.png b/versions/1.1/app/resources/close.png new file mode 100644 index 0000000..785f145 Binary files /dev/null and b/versions/1.1/app/resources/close.png differ diff --git a/versions/1.1/app/resources/closeItem.svg b/versions/1.1/app/resources/closeItem.svg new file mode 100644 index 0000000..f67d6fd --- /dev/null +++ b/versions/1.1/app/resources/closeItem.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/versions/1.1/app/resources/closeSearch.png b/versions/1.1/app/resources/closeSearch.png new file mode 100644 index 0000000..b710140 Binary files /dev/null and b/versions/1.1/app/resources/closeSearch.png differ diff --git a/versions/1.1/app/resources/closeTip.png b/versions/1.1/app/resources/closeTip.png new file mode 100644 index 0000000..48712c2 Binary files /dev/null and b/versions/1.1/app/resources/closeTip.png differ diff --git a/versions/1.1/app/resources/collapse.png b/versions/1.1/app/resources/collapse.png new file mode 100644 index 0000000..bae0013 Binary files /dev/null and b/versions/1.1/app/resources/collapse.png differ diff --git a/versions/1.1/app/resources/collapsed.png b/versions/1.1/app/resources/collapsed.png new file mode 100644 index 0000000..a71e21c Binary files /dev/null and b/versions/1.1/app/resources/collapsed.png differ diff --git a/versions/1.1/app/resources/delete.svg b/versions/1.1/app/resources/delete.svg new file mode 100644 index 0000000..06681f2 --- /dev/null +++ b/versions/1.1/app/resources/delete.svg @@ -0,0 +1,4 @@ + + + + diff --git a/versions/1.1/app/resources/drag.svg b/versions/1.1/app/resources/drag.svg new file mode 100644 index 0000000..75d5a43 --- /dev/null +++ b/versions/1.1/app/resources/drag.svg @@ -0,0 +1,4 @@ + + + + diff --git a/versions/1.1/app/resources/drive_icon.png b/versions/1.1/app/resources/drive_icon.png new file mode 100644 index 0000000..c5ca484 Binary files /dev/null and b/versions/1.1/app/resources/drive_icon.png differ diff --git a/versions/1.1/app/resources/edit.svg b/versions/1.1/app/resources/edit.svg new file mode 100644 index 0000000..525269c --- /dev/null +++ b/versions/1.1/app/resources/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/versions/1.1/app/resources/emptyTopicCollapsed.png b/versions/1.1/app/resources/emptyTopicCollapsed.png new file mode 100644 index 0000000..47dbcc9 Binary files /dev/null and b/versions/1.1/app/resources/emptyTopicCollapsed.png differ diff --git a/versions/1.1/app/resources/emptyTopicExpanded.png b/versions/1.1/app/resources/emptyTopicExpanded.png new file mode 100644 index 0000000..088501e Binary files /dev/null and b/versions/1.1/app/resources/emptyTopicExpanded.png differ diff --git a/versions/1.1/app/resources/expand.png b/versions/1.1/app/resources/expand.png new file mode 100644 index 0000000..c80182c Binary files /dev/null and b/versions/1.1/app/resources/expand.png differ diff --git a/versions/1.1/app/resources/expanded.png b/versions/1.1/app/resources/expanded.png new file mode 100644 index 0000000..9450a0d Binary files /dev/null and b/versions/1.1/app/resources/expanded.png differ diff --git a/versions/1.1/app/resources/fileSave.png b/versions/1.1/app/resources/fileSave.png new file mode 100644 index 0000000..55cfd4a Binary files /dev/null and b/versions/1.1/app/resources/fileSave.png differ diff --git a/versions/1.1/app/resources/gridBackground.png b/versions/1.1/app/resources/gridBackground.png new file mode 100644 index 0000000..fc39d4b Binary files /dev/null and b/versions/1.1/app/resources/gridBackground.png differ diff --git a/versions/1.1/app/resources/gridBackgroundDark.png b/versions/1.1/app/resources/gridBackgroundDark.png new file mode 100644 index 0000000..a4c23a1 Binary files /dev/null and b/versions/1.1/app/resources/gridBackgroundDark.png differ diff --git a/versions/1.1/app/resources/headerImage.png b/versions/1.1/app/resources/headerImage.png new file mode 100644 index 0000000..bde472c Binary files /dev/null and b/versions/1.1/app/resources/headerImage.png differ diff --git a/versions/1.1/app/resources/help.png b/versions/1.1/app/resources/help.png new file mode 100644 index 0000000..f82c667 Binary files /dev/null and b/versions/1.1/app/resources/help.png differ diff --git a/versions/1.1/app/resources/helpLight.png b/versions/1.1/app/resources/helpLight.png new file mode 100644 index 0000000..c2f0186 Binary files /dev/null and b/versions/1.1/app/resources/helpLight.png differ diff --git a/versions/1.1/app/resources/link.png b/versions/1.1/app/resources/link.png new file mode 100644 index 0000000..b0532f7 Binary files /dev/null and b/versions/1.1/app/resources/link.png differ diff --git a/versions/1.1/app/resources/localSaveIcon.svg b/versions/1.1/app/resources/localSaveIcon.svg new file mode 100644 index 0000000..57eec0e --- /dev/null +++ b/versions/1.1/app/resources/localSaveIcon.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/versions/1.1/app/resources/logo.png b/versions/1.1/app/resources/logo.png new file mode 100644 index 0000000..26dbbf7 Binary files /dev/null and b/versions/1.1/app/resources/logo.png differ diff --git a/versions/1.1/app/resources/marquee.jpg b/versions/1.1/app/resources/marquee.jpg new file mode 100644 index 0000000..3b00139 Binary files /dev/null and b/versions/1.1/app/resources/marquee.jpg differ diff --git a/versions/1.1/app/resources/marquee.png b/versions/1.1/app/resources/marquee.png new file mode 100644 index 0000000..f7c5b22 Binary files /dev/null and b/versions/1.1/app/resources/marquee.png differ diff --git a/versions/1.1/app/resources/nextSlide.png b/versions/1.1/app/resources/nextSlide.png new file mode 100644 index 0000000..7deaf2d Binary files /dev/null and b/versions/1.1/app/resources/nextSlide.png differ diff --git a/versions/1.1/app/resources/nextTip.png b/versions/1.1/app/resources/nextTip.png new file mode 100644 index 0000000..ec77a14 Binary files /dev/null and b/versions/1.1/app/resources/nextTip.png differ diff --git a/versions/1.1/app/resources/noun_Arrow_461652.png b/versions/1.1/app/resources/noun_Arrow_461652.png new file mode 100644 index 0000000..137de83 Binary files /dev/null and b/versions/1.1/app/resources/noun_Arrow_461652.png differ diff --git a/versions/1.1/app/resources/noun_Close_811316.png b/versions/1.1/app/resources/noun_Close_811316.png new file mode 100644 index 0000000..8874481 Binary files /dev/null and b/versions/1.1/app/resources/noun_Close_811316.png differ diff --git a/versions/1.1/app/resources/noun_Delete_3039050.png b/versions/1.1/app/resources/noun_Delete_3039050.png new file mode 100644 index 0000000..555fe46 Binary files /dev/null and b/versions/1.1/app/resources/noun_Delete_3039050.png differ diff --git a/versions/1.1/app/resources/noun_File_3743054.png b/versions/1.1/app/resources/noun_File_3743054.png new file mode 100644 index 0000000..55cfd4a Binary files /dev/null and b/versions/1.1/app/resources/noun_File_3743054.png differ diff --git a/versions/1.1/app/resources/noun_Outdent_3549516.png b/versions/1.1/app/resources/noun_Outdent_3549516.png new file mode 100644 index 0000000..74b2c76 Binary files /dev/null and b/versions/1.1/app/resources/noun_Outdent_3549516.png differ diff --git a/versions/1.1/app/resources/noun_collapse all_3039233.png b/versions/1.1/app/resources/noun_collapse all_3039233.png new file mode 100644 index 0000000..f112c84 Binary files /dev/null and b/versions/1.1/app/resources/noun_collapse all_3039233.png differ diff --git a/versions/1.1/app/resources/noun_done_3483450.png b/versions/1.1/app/resources/noun_done_3483450.png new file mode 100644 index 0000000..7b9af54 Binary files /dev/null and b/versions/1.1/app/resources/noun_done_3483450.png differ diff --git a/versions/1.1/app/resources/noun_drag_2259306.png b/versions/1.1/app/resources/noun_drag_2259306.png new file mode 100644 index 0000000..1c33b91 Binary files /dev/null and b/versions/1.1/app/resources/noun_drag_2259306.png differ diff --git a/versions/1.1/app/resources/noun_edit_1513603.png b/versions/1.1/app/resources/noun_edit_1513603.png new file mode 100644 index 0000000..659b511 Binary files /dev/null and b/versions/1.1/app/resources/noun_edit_1513603.png differ diff --git a/versions/1.1/app/resources/noun_expand_all_3039287.png b/versions/1.1/app/resources/noun_expand_all_3039287.png new file mode 100644 index 0000000..2449f45 Binary files /dev/null and b/versions/1.1/app/resources/noun_expand_all_3039287.png differ diff --git a/versions/1.1/app/resources/noun_insert column left_3644551.png b/versions/1.1/app/resources/noun_insert column left_3644551.png new file mode 100644 index 0000000..2e19e7d Binary files /dev/null and b/versions/1.1/app/resources/noun_insert column left_3644551.png differ diff --git a/versions/1.1/app/resources/noun_link_965819.png b/versions/1.1/app/resources/noun_link_965819.png new file mode 100644 index 0000000..b0532f7 Binary files /dev/null and b/versions/1.1/app/resources/noun_link_965819.png differ diff --git a/versions/1.1/app/resources/noun_triangle_796670.png b/versions/1.1/app/resources/noun_triangle_796670.png new file mode 100644 index 0000000..be5942e Binary files /dev/null and b/versions/1.1/app/resources/noun_triangle_796670.png differ diff --git a/versions/1.1/app/resources/open1.png b/versions/1.1/app/resources/open1.png new file mode 100644 index 0000000..694d145 Binary files /dev/null and b/versions/1.1/app/resources/open1.png differ diff --git a/versions/1.1/app/resources/openTab.svg b/versions/1.1/app/resources/openTab.svg new file mode 100644 index 0000000..b1b3e72 --- /dev/null +++ b/versions/1.1/app/resources/openTab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/versions/1.1/app/resources/openWindow.svg b/versions/1.1/app/resources/openWindow.svg new file mode 100644 index 0000000..b81afc8 --- /dev/null +++ b/versions/1.1/app/resources/openWindow.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/versions/1.1/app/resources/outdent.svg b/versions/1.1/app/resources/outdent.svg new file mode 100644 index 0000000..29b4e4a --- /dev/null +++ b/versions/1.1/app/resources/outdent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/versions/1.1/app/resources/prevSlide.png b/versions/1.1/app/resources/prevSlide.png new file mode 100644 index 0000000..096a9fa Binary files /dev/null and b/versions/1.1/app/resources/prevSlide.png differ diff --git a/versions/1.1/app/resources/prevTip.png b/versions/1.1/app/resources/prevTip.png new file mode 100644 index 0000000..cf19e92 Binary files /dev/null and b/versions/1.1/app/resources/prevTip.png differ diff --git a/versions/1.1/app/resources/searchGlass.svg b/versions/1.1/app/resources/searchGlass.svg new file mode 100644 index 0000000..5625569 --- /dev/null +++ b/versions/1.1/app/resources/searchGlass.svg @@ -0,0 +1,3 @@ + + + diff --git a/versions/1.1/app/resources/settingsIcon.png b/versions/1.1/app/resources/settingsIcon.png new file mode 100644 index 0000000..5cb30c7 Binary files /dev/null and b/versions/1.1/app/resources/settingsIcon.png differ diff --git a/versions/1.1/app/resources/settingsIconLight.png b/versions/1.1/app/resources/settingsIconLight.png new file mode 100644 index 0000000..1cda8f7 Binary files /dev/null and b/versions/1.1/app/resources/settingsIconLight.png differ diff --git a/versions/1.1/app/resources/slide1.png b/versions/1.1/app/resources/slide1.png new file mode 100644 index 0000000..6c54869 Binary files /dev/null and b/versions/1.1/app/resources/slide1.png differ diff --git a/versions/1.1/app/resources/slide2.png b/versions/1.1/app/resources/slide2.png new file mode 100644 index 0000000..fa5bf16 Binary files /dev/null and b/versions/1.1/app/resources/slide2.png differ diff --git a/versions/1.1/app/resources/slide3.png b/versions/1.1/app/resources/slide3.png new file mode 100644 index 0000000..65ec999 Binary files /dev/null and b/versions/1.1/app/resources/slide3.png differ diff --git a/versions/1.1/app/resources/slide4.png b/versions/1.1/app/resources/slide4.png new file mode 100644 index 0000000..7090cb4 Binary files /dev/null and b/versions/1.1/app/resources/slide4.png differ diff --git a/versions/1.1/app/resources/slide5.png b/versions/1.1/app/resources/slide5.png new file mode 100644 index 0000000..3ecad9d Binary files /dev/null and b/versions/1.1/app/resources/slide5.png differ diff --git a/versions/1.1/app/resources/star.svg b/versions/1.1/app/resources/star.svg new file mode 100644 index 0000000..6a8b2b6 --- /dev/null +++ b/versions/1.1/app/resources/star.svg @@ -0,0 +1,4 @@ + + + + diff --git a/versions/1.1/app/resources/tools.svg b/versions/1.1/app/resources/tools.svg new file mode 100644 index 0000000..65b70d1 --- /dev/null +++ b/versions/1.1/app/resources/tools.svg @@ -0,0 +1,4 @@ + + + + diff --git a/versions/1.1/app/resources/toolsOpen.svg b/versions/1.1/app/resources/toolsOpen.svg new file mode 100644 index 0000000..c1f20f2 --- /dev/null +++ b/versions/1.1/app/resources/toolsOpen.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/versions/1.1/app/subscriptionManager.js b/versions/1.1/app/subscriptionManager.js new file mode 100644 index 0000000..0369fef --- /dev/null +++ b/versions/1.1/app/subscriptionManager.js @@ -0,0 +1,446 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Handles interacting w Stripe and Firebase for subscription and purchase management. + * + * initializeFirebase, signIn to get or create a new anonymous fb account, + * getSub to get the users sub from fb account. subscribe() w product key + * and manage via the url from getStripePortalURL. + * + ***/ + +var BTId; + +async function handlePurchase(product) { + // handle monthly or annual subscribe. Load Stripe code, load and init FB, check for existing sub or purchase, then pass to Stripe to complete txn + + // First Check if Stripe script is already loaded + try { + if (!window.Stripe) { + // Load Stripe script + const script = document.createElement('script'); + script.src = 'https://js.stripe.com/v3/'; + script.async = true; + + const loadPromise = new Promise((resolve, reject) => { + script.onload = resolve; + script.onerror = reject; + }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Script load timed out')), 10000); // 10 second timeout + }); + + document.head.appendChild(script); + + // Wait for the script to load or timeout + await Promise.race([loadPromise, timeoutPromise]); + } + FBDB || await initializeFirebase(); + if (!FBDB) { + console.error("Problem initializing Firebase"); + alert("Sorry Firebase initialization failed."); + return; + } + } catch(error) { + console.error("Error initializing Stripe/Firebase: ", error); + alert("Sorry Stripe/Firebase initialization failed."); + return; + } + + if (!confirm("You will now be forwarded to Stripe to confirm payment details to Data Foundries LLC (BrainTool's incorporated name).\n\nAfter that BT will reload with your supporter status in place.\n\nNB coupons can be applied at purchase.\nForwarding might take several seconds.")) + return; + $('body').addClass('waiting'); + + // Create user id, store in localStore and in BTFile text + try { + BTId = await signIn(); + if (!BTId) { + $('body').removeClass('waiting'); + console.error("Error signing in to FB"); + alert("Sorry Firebase user creation failed."); + return; + } + } catch(error) { + $('body').removeClass('waiting'); + console.error("Error signing in FB user:", error); + alert("Sorry Firebase user creation failed."); + return; + } + // Save sub id as BTId in local storage and org file property + configManager.setProp('BTId', BTId); + await saveBT(); + + if (product != OTP) { + // handle subscriptions different than one time purchase + let subscription = await getPurchase('subscriptions'); + if (subscription) { + $('body').removeClass('waiting'); + alert("Seems like you already have a subscription associated with this browser. Close and restart BrainTool."); + console.log("Subscription exists for this user:", JSON.stringify(subscription)); + return; + } + // Create sub - redirects to Stripe, so execution will end here. + // on reload the BTId value set above will indicate a premium subscription + subscribe(product); + } else { + // handle one time purchase + let myproduct = await getPurchase('payments'); + if (myproduct) { + $('body').removeClass('waiting'); + alert("Seems like you already have a license associated with this browser. Close and restart BrainTool."); + console.log("License exists for this user:", JSON.stringify(myproduct)); + return; + } + // redirects to Stripe, so execution will end here. + // on reload the BTId value set above will indicate a premium subscription + purchase(product); + } +} + +async function openStripePortal() { + // open page to manage subscription + if (!BTId) { + alert('BrainTool Id not set!'); + return; + } + alert("Opening Stripe portal to manage your subscription. This may take a few seconds."); + const url = await getStripePortalURL(); + window.open(url, '_blank'); +} + +// https://dashboard.stripe.com/tax-rates +const taxRates = []; + +// https://dashboard.stripe.com/apikeys +// For Firebase JS SDK v7.20.0 and later, measurementId is optional +// Config values generated by FB app console +const firebaseConfig = { + authDomain: "mybraintool-42.firebaseapp.com", + projectId: "mybraintool-42", + storageBucket: "mybraintool-42.appspot.com", + messagingSenderId: "177084785905", + appId: "1:177084785905:web:305c20b6239b97b3243550" +}; + +const Annual = "price_1P4TJrJfoHixgzDGQX5G7EYQ"; // test: "price_1P4PTlJfoHixgzDGOkTBFq4s"; +const Monthly = "price_1P4TJvJfoHixgzDGGuY6orEO"; // test: "price_1P4PRkJfoHixgzDGh1N8UMHA"; +const OTP = "price_1P4TK1JfoHixgzDGvFvSCdu9"; // test 20.99: "price_1P3kqcJfoHixgzDGJojQ5R3v"; + +const FunctionLocation = 'us-east1'; +let FBDB = null; + +async function initializeFirebase() { + // First load scripts (lazy cos not needed until there's a license check or purchase) + const firebaseScripts = [ + 'https://www.gstatic.com/firebasejs/8.6.5/firebase-app.js', // must be first + 'https://www.gstatic.com/firebasejs/8.6.5/firebase-functions.js', + 'https://www.gstatic.com/firebasejs/8.6.5/firebase-firestore.js', + 'https://www.gstatic.com/firebasejs/8.6.5/firebase-auth.js', + // Add other Firebase scripts here... + ]; + + // Load each script + for (const src of firebaseScripts) { + // Check if script is already loaded + if (!document.querySelector(`script[src="${src}"]`)) { + // Create new script element + const script = document.createElement('script'); + script.src = src; + script.async = true; + document.head.appendChild(script); + + // Wait for the script to load + await new Promise((resolve) => { + script.onload = resolve; + }); + } + } + + // Initialize Firebase w config + key + const fbKey = configManager.getProp('FB_KEY'); + if (!fbKey) { + console.error("Firebase api key not set!"); + return; + } + firebaseConfig.apiKey = fbKey; + + try { + const firebaseApp = firebase.initializeApp(firebaseConfig); + FBDB = firebaseApp.firestore(); + } + catch(error) { + var errorCode = error.code; + var errorMessage = error.message; + console.log("ERROR in initializeFirebase:"); + console.log(errorCode, errorMessage); + } +} + +async function signIn() { + // return current user if signed in, otherwise return a promise that resolves when + // a new anonymous user is created + + FBDB || await initializeFirebase(); + let uid = firebase.auth()?.currentUser?.uid; + if (uid) return uid; + + return new Promise(function (resolve) { + firebase.auth().signInAnonymously().then(() => { + firebase.auth().onAuthStateChanged((firebaseUser) => { + if (firebaseUser) resolve(firebaseUser.uid); + }); + }).catch((error) => { + var errorCode = error.code; + var errorMessage = error.message; + console.log(errorCode, errorMessage); + resolve(null); + }); + }); +} +// NB Signout : firebase.auth().signOut(); // need to sign out to create new user during testing + +async function checkLicense() { + // Startup checks for license exists, expired etc + + if(!BTId) { + console.log("No BTId => no license"); + return false; + } + const licenseExpiry = configManager.getProp('BTExpiry'); + if (BTId && licenseExpiry && (Date.now() < licenseExpiry)) { + console.log("Active license"); + return true; + } + + // Handle case where user just went thru Stripe flow, it passes back ?purchase=['product', 'subscription' or 'cancelled'] + const urlParams = new URLSearchParams(window.location.search); + const purchase = urlParams.get('purchase'); + if (purchase == 'cancelled') { + // need to reset BTId cos a user account was created prior to purchase + alert('Your purchase was cancelled. No changes were made.'); + BTId = null; configManager.setProp('BTId', null); + saveBT(); + return false; + } + + // Either new purchase or expired license or manually copied in from elsewhere => Need to load fb code to check + // await load fb codebase + const product = await getPurchase('payments'); + if (product) { + if (purchase == 'product') alert('You now have a permanent license. Thank you for supporting BrainTool!'); + configManager.setProp('BTExpiry', 8640000000000000); // max date + return true; + } + const subscription = await getPurchase('subscriptions'); + if (subscription && purchase == 'subscription') { + alert('Your subscription is now active. Thank you for supporting BrainTool!'); + configManager.setProp('BTExpiry', subscription.current_period_end.seconds * 1000); // convert to ms for Date + return true; + } + + if(!subscription && !product) { + console.log("BTID set but no purchase or subscription found"); + return false; + } + + configManager.setProp('BTExpiry', ((subscription?.current_period_end.seconds * 1000) || licenseExpiry)); // Sub exists but maybe expired + if (Date.now() < configManager.getProp('BTExpiry')) { + console.log("License renewed"); + return true; + } + console.log("License expired"); + alert("Looks like your subscription has expired. Create a new subscription or continue using the free version."); + return false; +} + + +async function getPurchase(collection = 'subscriptions') { + // Get subscription or one time payment record ('payments') for current user + // NB subs also create payment records on each renewal, so we need to filter for payments with non null item data + try { + FBDB || await initializeFirebase(); + } catch(e) { + console.error("Error initializing Firebase in getPurchase"); + console.log(JSON.stringify(e)); + return null; + } + return new Promise((resolve, reject) => { + try { + FBDB.collection('customers') + .doc(BTId) + .collection(collection) + .where('status', 'in', ['trialing', 'active', 'succeeded']) + .onSnapshot((snapshot) => { + if (snapshot.empty) { + console.log(`No active ${collection}!`); + resolve(null); + } else { + let result = snapshot.docs[0].data(); + if (collection === 'payments') { + result = snapshot.docs.find(doc => doc.data().items != null)?.data() || null; + } + console.log(`Sub: ${JSON.stringify(result)}`); + resolve(result); + } + }); + } catch(e) { + console.error("Error in getPurchase"); + console.log(JSON.stringify(e)); + reject(e); + } + }); +} + +// Checkout handler +async function subscribe(productPrice) { + const selectedPrice = { + price: productPrice, + quantity: 1, + }; + const baseURL = window.location.href.split('?')[0]; // drop any preexisting '?purchase=xyz' arg + const checkoutSession = { + collect_shipping_address: false, + billing_address_collection: 'auto', + tax_rates: taxRates, + allow_promotion_codes: true, + line_items: [selectedPrice], + success_url: baseURL + '?purchase=' + encodeURIComponent('subscription'), + cancel_url: baseURL + '?purchase=' + encodeURIComponent('cancelled'), + description: "BrainTool Supporter Subscription ID: " + BTId, + }; + try { + const docRef = await FBDB + .collection('customers') + .doc(BTId) + .collection('checkout_sessions') + .add(checkoutSession); + + // Wait for the CheckoutSession to get attached by the fb extension + docRef.onSnapshot((snap) => { + const { error, sessionId } = snap.data(); + if (error) { + $('body').removeClass('waiting'); + alert(`An error occured: ${error.message}`); + } + if (sessionId) { + // We have a session, let's redirect to Checkout + // Init Stripe + const stripeKey = configManager.getProp('STRIPE_KEY'); + const stripe = Stripe(stripeKey); + stripe.redirectToCheckout({ sessionId }); + } + }); + } catch(e) { + $('body').removeClass('waiting'); + console.error("Error in subscribe with ", productPrice, " Firebase says:"); + console.log(JSON.stringify(e)); + } +} + +async function purchase(productPrice) { + // similar to above but One-Time-purchase + const baseURL = window.location.href.split('?')[0]; // drop any preexisting '?purchase=xyz' arg + const checkoutSession = { + mode: "payment", + price: OTP, // One-time price created in Stripe + allow_promotion_codes: true, + success_url: baseURL+ '?purchase=' + encodeURIComponent('product'), + cancel_url: baseURL + '?purchase=' + encodeURIComponent('cancelled'), + description: "BrainTool Supporter License ID: " + BTId, + }; + try { + const docRef = await FBDB.collection("customers").doc(BTId).collection("checkout_sessions").add(checkoutSession); + // Wait for the CheckoutSession to get attached by the fb extension + docRef.onSnapshot((snap) => { + const { error, sessionId } = snap.data(); + if (error) { + $('body').removeClass('waiting'); + alert(`An error occured: ${error.message}`); + } + if (sessionId) { + // We have a session, let's redirect to Checkout + // Init Stripe + const stripeKey = configManager.getProp('STRIPE_KEY'); + const stripe = Stripe(stripeKey); + stripe.redirectToCheckout({ sessionId }); + } + }); + } catch(e) { + $('body').removeClass('waiting'); + console.error("Error in purchase with ", productPrice, " Firebase says:"); + console.log(JSON.stringify(e)); + } +} + +/* +async function RCPurchase(sessionId) { + // Skip Stripe processing to allow free license for RC. Stripe won't charge 0 for a product or give 100% discount + + const fakePayment = { + mode: "rc_stripe_bypass", + amount_total: 0, + status: "succeeded", + price: OTP, + object: "no_payment_intent", + } + const docRef = await FBDB.collection("customers").doc(BTId).collection("payments").add(fakePayment); + // Wait for the fakePayment to get attached by the fb extension + docRef.onSnapshot((snap) => { + const { error, sessionId } = snap.data(); + if (error) { + $('body').removeClass('waiting'); + alert(`An error occured in RCPurchase: ${error.message}`); + return; + } + $('body').removeClass('waiting'); + alert("Your (free) purchase was successful. You now have a permanent license. Thank you for supporting BrainTool!"); + configManager.setProp('BTExpiry', 8640000000000000); // max date + updateLicenseSettings(); + }); +} +*/ + +async function getStripePortalURL() { + // Billing portal handler + let rsp; + FBDB || await initializeFirebase(); + try { + const functionRef = firebase + .app() + .functions(FunctionLocation) + .httpsCallable('createSimplePortalLink'); + rsp = await functionRef( + { returnUrl: "https://braintool.org", 'BTId': BTId }); + } catch(e) { + const err = JSON.stringify(e); + console.error("Error in getPortal:", err); + alert("Error accessing Stripe portal:\n", err); + return ("https://braintool.org/support"); + } + return rsp.data.url; +} + +async function importKey() { + const key = prompt('Please enter your license key:'); + if (key) { + BTId = key; + if (await checkLicense()) { + configManager.setProp('BTId', key); + saveBT(); + alert('License key accepted. Thank you for supporting BrainTool!'); + updateLicenseSettings(); + } + } +} + diff --git a/versions/1.1/app/wenk.css b/versions/1.1/app/wenk.css new file mode 100644 index 0000000..f308462 --- /dev/null +++ b/versions/1.1/app/wenk.css @@ -0,0 +1,163 @@ +/** + * wenk - Lightweight tooltip for the greater good + * @version v1.0.6 + * (c) 2018 Tiaan du Plessis @tiaanduplessis | + * @link https://tiaanduplessis.github.io/wenk/ + * @license MIT + */ +[data-wenk] { + position: relative; +} + +[data-wenk]:after { + position: absolute; + font: var(--btFont); + font-size: var(--btTopicFontSize); + border-radius: .2rem; + content: attr(data-wenk); + padding: var(--btWenkPadding); + background-color: var(--btTooltipBackground); + color: var(--btTooltipForeground); + line-height: 1.25rem; + text-align: left; + pointer-events: none; + display: block; + opacity: 0; + z-index: 1; + visibility: hidden; + -webkit-transition: all .25s; + transition: all .25s; + bottom: 160%; + left: -125%; + -webkit-transform: translate(-25%, 3px); + transform: translate(-25%, 3px); + white-space: pre; + width: auto; + border: none; +} + +[data-wenk]:after { + opacity: 0; +} + +[data-wenk]:hover { + overflow: visible +} + +[data-wenk]:hover:after { + display: block; + opacity: 1; + visibility: visible; + -webkit-transform: translate(-25%, -5px); + transform: translate(-25%, -5px); +} + +[data-wenk].wenk--left:after, [data-wenk][data-wenk-pos="left"]:after { + bottom: auto; + left: auto; + top: 75%; + right: 50%; + -webkit-transform: translate(5px, -25%); + transform: translate(5px, -25%); +} + +[data-wenk].wenk--left:hover:after, [data-wenk][data-wenk-pos="left"]:hover:after { + -webkit-transform: translate(-5px, -25%); + transform: translate(-5px, -25%); +} + +[data-wenk].wenk--right:after, [data-wenk][data-wenk-pos="left"]:after { + bottom: 75%; + left: auto; + top: auto; + right: 50%; + -webkit-transform: translate(5px, -25%); + transform: translate(5px, -25%); +} + +[data-wenk].wenk--right:hover:after, [data-wenk][data-wenk-pos="left"]:hover:after { + -webkit-transform: translate(-5px, -25%); + transform: translate(-5px, -25%); +} + +/* used for the ttree expand/collapse buttons */ +[data-wenk].wenk--bottom:after { + bottom: var(--btWenkBottom); + left: 100%; + top: auto; + right: auto; + line-height: 1.0rem; + font-size: var(--btPageFontSize); +} + +tr.collapsed [data-wenk].wenk--bottom:hover:after { + -webkit-transform: translate(3px, 3px); + transform: translate(3px, 3px); +} + +tr.expanded [data-wenk].wenk--bottom:hover:after { + -webkit-transform: translate(3px, 3px); + transform: translate(3px, 3px); +} +/* +[data-wenk].wenk--bottom:after, [data-wenk][data-wenk-pos="bottom"]:after { + bottom: auto; + top: 100%; + left: -100%; + font-size: 12px; + -webkit-transform: translate(-25%, -5px); + transform: translate(-25%, -5px); +} + +[data-wenk].wenk--bottom:hover:after, [data-wenk][data-wenk-pos="bottom"]:hover:after { + -webkit-transform: translate(-25%, 5px); + transform: translate(-25%, 5px); +} + +[data-wenk].wenk--right:after, [data-wenk][data-wenk-pos="right"]:after { + bottom: auto; + top: 50%; + left: 100%; + -webkit-transform: translate(-5px, -25%); + transform: translate(-5px, -25%); +} + +[data-wenk].wenk--right:hover:after, [data-wenk][data-wenk-pos="right"]:hover:after { + -webkit-transform: translate(5px, -25%); + transform: translate(5px, -25%); +} + +[data-wenk][data-wenk-length="small"]:after, [data-wenk].wenk-length--small:after { + white-space: normal; + width: 80px; +} + +[data-wenk][data-wenk-length="medium"]:after, [data-wenk].wenk-length--medium:after { + white-space: normal; + width: 150px; +} + +[data-wenk][data-wenk-length="large"]:after, [data-wenk].wenk-length--large:after { + white-space: normal; + width: 260px; +} + +[data-wenk][data-wenk-length="fit"]:after, [data-wenk].wenk-length--fit:after { + white-space: normal; + width: 100%; +} + +[data-wenk][data-wenk-align="right"]:after, [data-wenk].wenk-align--right:after { + text-align: right; +} + +[data-wenk][data-wenk-align="center"]:after, [data-wenk].wenk-align--center:after { + text-align: center; +} +*/ +[data-wenk=""]:after { + visibility: hidden !important; +} +[data-wenk].wenk--off:after { + visibility: hidden !important; +} diff --git a/versions/1.1/extension/_locales/de/messages.json b/versions/1.1/extension/_locales/de/messages.json new file mode 100644 index 0000000..0b39a20 --- /dev/null +++ b/versions/1.1/extension/_locales/de/messages.json @@ -0,0 +1,10 @@ +{ + "appName": { + "message": "BrainTool - Go Beyond Bookmarks", + "description": "The title of the application, displayed in the web store." + }, + "appDesc": { + "message": "BrainTool is a Topic Manager for your online life.", + "description": "The description of the application, displayed in the web store." + } +} diff --git a/versions/1.1/extension/_locales/en/messages.json b/versions/1.1/extension/_locales/en/messages.json new file mode 100644 index 0000000..30d28fa --- /dev/null +++ b/versions/1.1/extension/_locales/en/messages.json @@ -0,0 +1,10 @@ +{ + "appName": { + "message": "BrainTool - Go Beyond Bookmarks", + "description": "The title of the application, displayed in the web store." + }, + "appDesc": { + "message": "BrainTool is the best Bookmark and Tabs Manager for your online life.", + "description": "The description of the application, displayed in the web store." + } +} diff --git a/versions/1.1/extension/_locales/es/messages.json b/versions/1.1/extension/_locales/es/messages.json new file mode 100644 index 0000000..0b39a20 --- /dev/null +++ b/versions/1.1/extension/_locales/es/messages.json @@ -0,0 +1,10 @@ +{ + "appName": { + "message": "BrainTool - Go Beyond Bookmarks", + "description": "The title of the application, displayed in the web store." + }, + "appDesc": { + "message": "BrainTool is a Topic Manager for your online life.", + "description": "The description of the application, displayed in the web store." + } +} diff --git a/versions/1.1/extension/_locales/fr/messages.json b/versions/1.1/extension/_locales/fr/messages.json new file mode 100644 index 0000000..b43b70b --- /dev/null +++ b/versions/1.1/extension/_locales/fr/messages.json @@ -0,0 +1,10 @@ +{ + "appName": { + "message": "BrainTool - Mieux que les Signets", + "description": "The title of the application, displayed in the web store." + }, + "appDesc": { + "message": "BrainTool est un Gestionnaire de Sujet pour votre vie en ligne", + "description": "The description of the application, displayed in the web store." + } +} diff --git a/versions/1.1/extension/_locales/ko/messages.json b/versions/1.1/extension/_locales/ko/messages.json new file mode 100644 index 0000000..e40bf98 --- /dev/null +++ b/versions/1.1/extension/_locales/ko/messages.json @@ -0,0 +1,10 @@ +{ + "appName": { + "message": "BrainTool - 북마크 넘어", + "description": "웹스토어에 표시되는 응용 프로그램의 제목입니다." + }, + "appDesc": { + "message": "BrainTool은 온라인 라이프를 위한 토픽 매니져입니다.", + "description": "웹스토어에 표시되는 응용 프로그램에 대한 설명입니다." + } +} diff --git a/versions/1.1/extension/awesomplete.css b/versions/1.1/extension/awesomplete.css new file mode 100755 index 0000000..9b57209 --- /dev/null +++ b/versions/1.1/extension/awesomplete.css @@ -0,0 +1,102 @@ +.awesomplete [hidden] { + display: none; +} + +.awesomplete .visually-hidden { + position: absolute; + clip: rect(0, 0, 0, 0); +} + +.awesomplete { + display: inline-block; + position: relative; +} + +.awesomplete > input { + display: block; +} + +.awesomplete > ul { + position: absolute; + left: 0; + z-index: 1; + min-width: 100%; + box-sizing: border-box; + list-style: none; + padding: 0; + margin: 0; + background: var(--btInputBackground); +} + +.awesomplete > ul:empty { + display: none; +} + +.awesomplete > ul { + border-radius: .3em; + margin: .2em 0 0; + background: var(--awesompleteBackground); + border: 1px solid rgba(0,0,0,.3); + box-shadow: .05em .2em .6em rgba(0,0,0,.2); + text-shadow: none; +} + +@supports (transform: scale(0)) { + .awesomplete > ul { + transition: .3s cubic-bezier(.4,.2,.5,1.4); + transform-origin: 1.43em -.43em; + } + + .awesomplete > ul[hidden], + .awesomplete > ul:empty { + opacity: 0; + transform: scale(0); + display: block; + transition-timing-function: ease; + } +} + + /* Pointer */ + .awesomplete > ul:before { + content: ""; + position: absolute; + top: -.43em; + left: 1em; + width: 0; height: 0; + padding: .4em; + background: var(--btInputBackground); + border: inherit; + border-right: 0; + border-bottom: 0; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + } + + .awesomplete > ul > li { + position: relative; + padding: .2em .5em; + cursor: pointer; + } + + .awesomplete > ul > li:hover { + background: hsl(200, 40%, 80%); + color: black; + } + + .awesomplete > ul > li[aria-selected="true"] { + background: #7bb07b; + } + + .awesomplete mark { + background: var(--btHighlightColor); + } + + .awesomplete li:hover mark { + background: hsl(68, 100%, 41%); + } + + .awesomplete li[aria-selected="true"] mark { + background: hsl(86, 100%, 21%); + color: var(--btInputBackground); + } +/*# sourceMappingURL=awesomplete.css.map */ diff --git a/versions/1.1/extension/awesomplete.js b/versions/1.1/extension/awesomplete.js new file mode 100755 index 0000000..396fb71 --- /dev/null +++ b/versions/1.1/extension/awesomplete.js @@ -0,0 +1,552 @@ +/** + * Simple, lightweight, usable local autocomplete library for modern browsers + * Because there weren’t enough autocomplete scripts in the world? Because I’m completely insane and have NIH syndrome? Probably both. :P + * @author Lea Verou http://leaverou.github.io/awesomplete + * MIT license + */ + +(function () { + +var _ = function (input, o) { + var me = this; + + // Keep track of number of instances for unique IDs + _.count = (_.count || 0) + 1; + this.count = _.count; + + // Setup + + this.isOpened = false; + + this.input = $(input); + this.input.setAttribute("autocomplete", "off"); + this.input.setAttribute("aria-expanded", "false"); + this.input.setAttribute("aria-owns", "awesomplete_list_" + this.count); + this.input.setAttribute("role", "combobox"); + + // store constructor options in case we need to distinguish + // between default and customized behavior later on + this.options = o = o || {}; + + configure(this, { + minChars: 2, + maxItems: 10, + autoFirst: false, + data: _.DATA, + filter: _.FILTER_CONTAINS, + sort: o.sort === false ? false : _.SORT_BYLENGTH, + container: _.CONTAINER, + item: _.ITEM, + replace: _.REPLACE, + tabSelect: false + }, o); + + this.index = -1; + + // Create necessary elements + + this.container = this.container(input); + + this.ul = $.create("ul", { + hidden: "hidden", + role: "listbox", + id: "awesomplete_list_" + this.count, + inside: this.container + }); + + this.status = $.create("span", { + className: "visually-hidden", + role: "status", + "aria-live": "assertive", + "aria-atomic": true, + inside: this.container, + textContent: this.minChars != 0 ? ("Type " + this.minChars + " or more characters for results.") : "Begin typing for results." + }); + + // Bind events + + this._events = { + input: { + "input": this.evaluate.bind(this), + "blur": this.close.bind(this, { reason: "blur" }), + "keydown": function(evt) { + var c = evt.keyCode; + + // If the dropdown `ul` is in view, then act on keydown for the following keys: + // Enter / Esc / Up / Down + if(me.opened) { + if (c === 13 && me.selected) { // Enter + evt.preventDefault(); + me.select(undefined, undefined, evt); + } + else if (c === 9 && me.selected && me.tabSelect) { + me.select(undefined, undefined, evt); + } + else if (c === 27) { // Esc + me.close({ reason: "esc" }); + } + else if (c === 38 || c === 40) { // Down/Up arrow + evt.preventDefault(); + me[c === 38? "previous" : "next"](); + } + } + } + }, + form: { + "submit": this.close.bind(this, { reason: "submit" }) + }, + ul: { + // Prevent the default mousedowm, which ensures the input is not blurred. + // The actual selection will happen on click. This also ensures dragging the + // cursor away from the list item will cancel the selection + "mousedown": function(evt) { + evt.preventDefault(); + }, + // The click event is fired even if the corresponding mousedown event has called preventDefault + "click": function(evt) { + var li = evt.target; + + if (li !== this) { + + while (li && !/li/i.test(li.nodeName)) { + li = li.parentNode; + } + + if (li && evt.button === 0) { // Only select on left click + evt.preventDefault(); + me.select(li, evt.target, evt); + } + } + } + } + }; + + $.bind(this.input, this._events.input); + $.bind(this.input.form, this._events.form); + $.bind(this.ul, this._events.ul); + + if (this.input.hasAttribute("list")) { + this.list = "#" + this.input.getAttribute("list"); + this.input.removeAttribute("list"); + } + else { + this.list = this.input.getAttribute("data-list") || o.list || []; + } + + _.all.push(this); +}; + +_.prototype = { + set list(list) { + if (Array.isArray(list)) { + this._list = list; + } + else if (typeof list === "string" && list.indexOf(",") > -1) { + this._list = list.split(/\s*,\s*/); + } + else { // Element or CSS selector + list = $(list); + + if (list && list.children) { + var items = []; + slice.apply(list.children).forEach(function (el) { + if (!el.disabled) { + var text = el.textContent.trim(); + var value = el.value || text; + var label = el.label || text; + if (value !== "") { + items.push({ label: label, value: value }); + } + } + }); + this._list = items; + } + } + + if (document.activeElement === this.input) { + this.evaluate(); + } + }, + + get selected() { + return this.index > -1; + }, + + get opened() { + return this.isOpened; + }, + + close: function (o) { + if (!this.opened) { + return; + } + + this.input.setAttribute("aria-expanded", "false"); + this.ul.setAttribute("hidden", ""); + this.isOpened = false; + this.index = -1; + + this.status.setAttribute("hidden", ""); + + $.fire(this.input, "awesomplete-close", o || {}); + }, + + open: function () { + this.input.setAttribute("aria-expanded", "true"); + this.ul.removeAttribute("hidden"); + this.isOpened = true; + + this.status.removeAttribute("hidden"); + + if (this.autoFirst && this.index === -1) { + this.goto(0); + } + + $.fire(this.input, "awesomplete-open"); + }, + + destroy: function() { + //remove events from the input and its form + $.unbind(this.input, this._events.input); + $.unbind(this.input.form, this._events.form); + + // cleanup container if it was created by Awesomplete but leave it alone otherwise + if (!this.options.container) { + //move the input out of the awesomplete container and remove the container and its children + var parentNode = this.container.parentNode; + + parentNode.insertBefore(this.input, this.container); + parentNode.removeChild(this.container); + } + + //remove autocomplete and aria-autocomplete attributes + this.input.removeAttribute("autocomplete"); + this.input.removeAttribute("aria-autocomplete"); + + //remove this awesomeplete instance from the global array of instances + var indexOfAwesomplete = _.all.indexOf(this); + + if (indexOfAwesomplete !== -1) { + _.all.splice(indexOfAwesomplete, 1); + } + }, + + next: function () { + var count = this.ul.children.length; + this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) ); + }, + + previous: function () { + var count = this.ul.children.length; + var pos = this.index - 1; + + this.goto(this.selected && pos !== -1 ? pos : count - 1); + }, + + // Should not be used, highlights specific item without any checks! + goto: function (i) { + var lis = this.ul.children; + + if (this.selected) { + lis[this.index].setAttribute("aria-selected", "false"); + } + + this.index = i; + + if (i > -1 && lis.length > 0) { + lis[i].setAttribute("aria-selected", "true"); + + this.status.textContent = lis[i].textContent + ", list item " + (i + 1) + " of " + lis.length; + + this.input.setAttribute("aria-activedescendant", this.ul.id + "_item_" + this.index); + + // scroll to highlighted element in case parent's height is fixed + this.ul.scrollTop = lis[i].offsetTop - this.ul.clientHeight + lis[i].clientHeight; + + $.fire(this.input, "awesomplete-highlight", { + text: this.suggestions[this.index] + }); + } + }, + + select: function (selected, origin, originalEvent) { + if (selected) { + this.index = $.siblingIndex(selected); + } else { + selected = this.ul.children[this.index]; + } + + if (selected) { + var suggestion = this.suggestions[this.index]; + + var allowed = $.fire(this.input, "awesomplete-select", { + text: suggestion, + origin: origin || selected, + originalEvent: originalEvent + }); + + if (allowed) { + this.replace(suggestion); + this.close({ reason: "select" }); + $.fire(this.input, "awesomplete-selectcomplete", { + text: suggestion, + originalEvent: originalEvent + }); + } + } + }, + + evaluate: function() { + var me = this; + var value = this.input.value; + + if (value.length >= this.minChars && this._list && this._list.length > 0) { + this.index = -1; + // Populate list with options that match + this.ul.innerHTML = ""; + + this.suggestions = this._list + .map(function(item) { + return new Suggestion(me.data(item, value)); + }) + .filter(function(item) { + return me.filter(item, value); + }); + + if (this.sort !== false) { + this.suggestions = this.suggestions.sort(this.sort); + } + + this.suggestions = this.suggestions.slice(0, this.maxItems); + + this.suggestions.forEach(function(text, index) { + me.ul.appendChild(me.item(text, value, index)); + }); + + if (this.ul.children.length === 0) { + + this.status.textContent = "No results found"; + + this.close({ reason: "nomatches" }); + + } else { + this.open(); + + this.status.textContent = this.ul.children.length + " results found"; + } + } + else { + this.close({ reason: "nomatches" }); + + this.status.textContent = "No results found"; + } + } +}; + +// Static methods/properties + +_.all = []; + +_.FILTER_CONTAINS = function (text, input) { + return RegExp($.regExpEscape(input.trim()), "i").test(text); +}; + +_.FILTER_STARTSWITH = function (text, input) { + return RegExp("^" + $.regExpEscape(input.trim()), "i").test(text); +}; + +_.SORT_BYLENGTH = function (a, b) { + if (a.length !== b.length) { + return a.length - b.length; + } + + return a < b? -1 : 1; +}; + +_.CONTAINER = function (input) { + return $.create("div", { + className: "awesomplete", + around: input + }); +} + +_.ITEM = function (text, input, item_id) { + var html = input.trim() === "" ? text : text.replace(RegExp($.regExpEscape(input.trim()), "gi"), "$&"); + return $.create("li", { + innerHTML: html, + "role": "option", + "aria-selected": "false", + "id": "awesomplete_list_" + this.count + "_item_" + item_id + }); +}; + +_.REPLACE = function (text) { + this.input.value = text.value; +}; + +_.DATA = function (item/*, input*/) { return item; }; + +// Private functions + +function Suggestion(data) { + var o = Array.isArray(data) + ? { label: data[0], value: data[1] } + : typeof data === "object" && "label" in data && "value" in data ? data : { label: data, value: data }; + + this.label = o.label || o.value; + this.value = o.value; +} +Object.defineProperty(Suggestion.prototype = Object.create(String.prototype), "length", { + get: function() { return this.label.length; } +}); +Suggestion.prototype.toString = Suggestion.prototype.valueOf = function () { + return "" + this.label; +}; + +function configure(instance, properties, o) { + for (var i in properties) { + var initial = properties[i], + attrValue = instance.input.getAttribute("data-" + i.toLowerCase()); + + if (typeof initial === "number") { + instance[i] = parseInt(attrValue); + } + else if (initial === false) { // Boolean options must be false by default anyway + instance[i] = attrValue !== null; + } + else if (initial instanceof Function) { + instance[i] = null; + } + else { + instance[i] = attrValue; + } + + if (!instance[i] && instance[i] !== 0) { + instance[i] = (i in o)? o[i] : initial; + } + } +} + +// Helpers + +var slice = Array.prototype.slice; + +function $(expr, con) { + return typeof expr === "string"? (con || document).querySelector(expr) : expr || null; +} + +function $$(expr, con) { + return slice.call((con || document).querySelectorAll(expr)); +} + +$.create = function(tag, o) { + var element = document.createElement(tag); + + for (var i in o) { + var val = o[i]; + + if (i === "inside") { + $(val).appendChild(element); + } + else if (i === "around") { + var ref = $(val); + ref.parentNode.insertBefore(element, ref); + element.appendChild(ref); + + if (ref.getAttribute("autofocus") != null) { + ref.focus(); + } + } + else if (i in element) { + element[i] = val; + } + else { + element.setAttribute(i, val); + } + } + + return element; +}; + +$.bind = function(element, o) { + if (element) { + for (var event in o) { + var callback = o[event]; + + event.split(/\s+/).forEach(function (event) { + element.addEventListener(event, callback); + }); + } + } +}; + +$.unbind = function(element, o) { + if (element) { + for (var event in o) { + var callback = o[event]; + + event.split(/\s+/).forEach(function(event) { + element.removeEventListener(event, callback); + }); + } + } +}; + +$.fire = function(target, type, properties) { + var evt = document.createEvent("HTMLEvents"); + + evt.initEvent(type, true, true ); + + for (var j in properties) { + evt[j] = properties[j]; + } + + return target.dispatchEvent(evt); +}; + +$.regExpEscape = function (s) { + return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); +}; + +$.siblingIndex = function (el) { + /* eslint-disable no-cond-assign */ + for (var i = 0; el = el.previousElementSibling; i++); + return i; +}; + +// Initialization + +function init() { + $$("input.awesomplete").forEach(function (input) { + new _(input); + }); +} + +// Make sure to export Awesomplete on self when in a browser +if (typeof self !== "undefined") { + self.Awesomplete = _; +} + +// Are we in a browser? Check for Document constructor +if (typeof Document !== "undefined") { + // DOM already loaded? + if (document.readyState !== "loading") { + init(); + } + else { + // Wait for it + document.addEventListener("DOMContentLoaded", init); + } +} + +_.$ = $; +_.$$ = $$; + +// Expose Awesomplete as a CJS module +if (typeof module === "object" && module.exports) { + module.exports = _; +} + +return _; + +}()); diff --git a/versions/1.1/extension/background.js b/versions/1.1/extension/background.js new file mode 100644 index 0000000..295835c --- /dev/null +++ b/versions/1.1/extension/background.js @@ -0,0 +1,894 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** +* +* Main entry point for all window and tab manipulation. Listens for messages from app +* (relayed by content script) and dispatches to handler. Also listens for updates from +* browser (tabs opened etc) and relays back to app for processing. +* +***/ + +'use strict'; + +var Keys; +try { + importScripts('config.js'); +} catch (e) { + console.log(e); + Keys = {CLIENT_ID: '', API_KEY: '', FB_KEY: '', STRIPE_KEY: ''}; +} + +var LocalTest = false; // control code path during unit testing +var InitialInstall = false; // should we serve up the welcome page +var UpdateInstall = false; // or the release notes page + +async function btSendMessage(msg) { + // send message to BT window/tab. Wrapper to facilitate debugging messaging + + const [BTTab, BTWin] = await getBTTabWin(); + if (!BTTab) { + console.log('BTTab not set, message not sent:', msg); + return; + } + console.log(`Sending to BT: ${JSON.stringify(msg)}`); + try { + await chrome.tabs.sendMessage(BTTab, msg); + check('btSendMEssage says:'); + } catch (error) { + console.warn('Error sending to BT:', error); + } +} + +async function getBTTabWin(reset = false) { + // read from local storage then cached. reset => topic mgr exit + if (reset) { + getBTTabWin.cachedValue = null; + return; + } + if (getBTTabWin.cachedValue) { + return getBTTabWin.cachedValue; + } + let p = await chrome.storage.local.get(['BTTab', 'BTWin']); + if (p.BTTab && p.BTWin) getBTTabWin.cachedValue = [p.BTTab, p.BTWin]; + return getBTTabWin.cachedValue || [0, 0]; +} + +function check(msg='') { + // check for error + if (chrome.runtime.lastError) { + console.log(msg + "!!Whoops, runtime error.. " + chrome.runtime.lastError.message); + } +} + +/* Document data kept in storage.local */ +const storageKeys = ["BTFileText", // golden source of BT .org text data + "TabAction", // remember popup default action + "currentTabId", + "currentTopic", // for setting badge text + "currentText", + "mruTopics", // mru items used to default mru topic in popup + "newInstall", // true/false, for popup display choice + "newVersion", // used for popup to indicate an update to user + "permissions", // perms granted + "ManagerHome", // open in Panel or Tab + "ManagerLocation", // {top, left, width, height} of panel + "topics"]; // used for popup display + +chrome.runtime.onUpdateAvailable.addListener(deets => { + // Handle update. Store version so popup can inform and then upgrade + chrome.storage.local.set({'newVersion' : deets.version}); +}); +chrome.runtime.onInstalled.addListener(deets => { + // special handling for first install or new version + if (deets.reason == 'install') { + InitialInstall = chrome.runtime.getManifest().version; // let app know version + chrome.storage.local.set({'newInstall' : true}); + chrome.storage.local.set({'newVersion' : InitialInstall}); + chrome.tabs.create({'url': "https://braintool.org/support/welcome"}); + } + if (deets.reason == 'update') { + // also clean up local storage - get all keys in use and validate against those now needed + chrome.storage.local.get(null, (items) => { + Object.keys(items).forEach((key) => { + if (!storageKeys.includes(key)) + chrome.storage.local.remove(key); + }); + }); + UpdateInstall = deets.previousVersion; + } +}); + +// Set survey pointer on uninstall +chrome.runtime.setUninstallURL('https://forms.gle/QPP8ZREnpDgXxdav9', () => console.log('uninstall url set to https://forms.gle/QPP8ZREnpDgXxdav9')); + +/*** +* +* Message handling. Handlers dispatched based on msg.function +* NB need explicit mapping, evaluating from string is blocked for security reasons +* +***/ +const Handlers = { + "initializeExtension": initializeExtension, + "openTabs": openTabs, + "openTabGroups": openTabGroups, + "groupAndPositionTabs": groupAndPositionTabs, + "showNode": showNode, + "brainZoom": brainZoom, + "closeTab": closeTab, + "moveTab": moveTab, + "ungroup": ungroup, + "moveOpenTabsToTG": moveOpenTabsToTG, + "updateGroup": updateGroup, + "saveTabs": saveTabs, +}; + +var Awaiting = false; +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if ((msg.from != 'btwindow' && msg.from != 'popup') || (msg.type == 'AWAIT_RESPONSE')) + return; + + async function handleMessage() { + try { + const result = await Handlers[msg.function](msg, sender); + sendResponse({ status: "success", message: result }); + } catch (error) { + console.error("Error during async operation:", error); + sendResponse({ status: "error", message: error.message }); + } + } + + if (msg.type == "AWAIT" && Handlers[msg.function]) { + Awaiting = true; + console.log("Background AWAITing ", msg.function, JSON.stringify(msg)); + handleMessage(); + setTimeout(() => Awaiting = false, 500); + return true; + } + + // NB workaround for bug in Chrome, see https://stackoverflow.com/questions/71520198/manifestv3-new-promise-error-the-message-port-closed-before-a-response-was-rece/71520415#71520415 + sendResponse(); + + console.log(`Background received: [${msg.function}]: ${JSON.stringify(msg)}`); + if (Handlers[msg.function]) { + console.log("Background dispatching to ", msg.function); + Handlers[msg.function](msg, sender); + return; + } + if (msg.function == 'getBookmarks' || msg.function == 'exportBookmarks') { + // request bookmark permission prior to bookmark operations + // NB not using the dispatch cos that loses that its user triggered and Chrome prevents + + if (LocalTest) { + getBookmarks(); return; + } + chrome.permissions.request( + {permissions: ['bookmarks']}, granted => { + if (granted) { + (msg.function == 'getBookmarks') ? getBookmarks() : exportBookmarks(); + } else { + // send back denial + btSendMessage({'function': 'loadBookmarks', 'result': 'denied'}); + } + }); + return; + } + if (msg.type == 'LOCALTEST') { + // Running under test so there is no external BT top level window + chrome.tabs.query({'url' : '*://localhost/test*'}, tabs => { + check(); + LocalTest = true; + }); + return; + } + console.warn("Background received unhandled message!!!!: ", msg); +}); + + +/*** +* +* Event handling for browser events of interest +* +***/ +function logEventWrapper(eventName, originalFunction) { + // wrap event handlers below to log event and args + return function(...args) { + console.log(`EVENT---: ${eventName}`, args); + return originalFunction.apply(this, args); + } +} + +/* -- Tab Events -- */ + +chrome.tabs.onMoved.addListener(logEventWrapper("tabs.onMoved", async (tabId, otherInfo) => { + // listen for tabs being moved and let BT know + if (Awaiting) return; // ignore events while we're awaiting our commands to take effect + const tab = await chrome.tabs.get(tabId); check(); + if (!tab || tab.status == 'loading') return; + const indices = await tabIndices(); + //console.log('moved event:', otherInfo, tab); + btSendMessage({ 'function': 'tabMoved', 'tabId': tabId, 'groupId': tab.groupId, + 'tabIndex': tab.index, 'windowId': tab.windowId, 'indices': indices, 'tab': tab}); + setTimeout(function() {setBadge(tabId);}, 200); +})); + +chrome.tabs.onRemoved.addListener(logEventWrapper("tabs.onRemoved", async (tabId, otherInfo) => { + // listen for tabs being closed and let BT know + const [BTTab, BTWin] = await getBTTabWin(); + if (!tabId || !BTTab) return; // closed? + if (tabId == BTTab) { + setTimeout(() => suspendExtension(), 100); + console.log('BTTab closed, suspending extension'); + return; + } + const indices = await tabIndices(); + btSendMessage({'function': 'tabClosed', 'tabId': tabId, 'indices': indices}); +})); + +chrome.tabs.onUpdated.addListener(logEventWrapper("tabs.onUpdated", async (tabId, changeInfo, tab) => { + // listen for tabs navigating to and from BT URLs or being moved to/from TGs + if (Awaiting) return; // ignore events while we're awaiting 'synchronous' commands to take effect + + const [BTTab, BTWin] = await getBTTabWin(); + if (!tabId || !BTTab || (tabId == BTTab)) return; // not set up yet or don't care + + const indices = await tabIndices(); // keep indicies in sync + if (changeInfo.status == 'complete') { + // tab navigated to/from url, add in transition info from Web Nav event, below + const transitionData = tabTransitionData[tabId] || null; // set in webNavigation.onCommitted event above + setTimeout (() => delete tabTransitionData[tabId], 1000); // clear out for next event + btSendMessage({ 'function': 'tabNavigated', 'tabId': tabId, 'groupId': tab.groupId, 'tabIndex': tab.index, + 'tabURL': tab.url, 'windowId': tab.windowId, 'indices': indices, 'transitionData': transitionData,}); + setTimeout(function() {setBadge(tabId);}, 200); + return; + } + if (changeInfo.groupId && (tab.status == 'complete') && tab.url) { + // tab moved to/from TG, wait til loaded so url etc is filled in + const message = { + 'function': (tab.groupId > 0) ? 'tabJoinedTG' : 'tabLeftTG', + 'tabId': tabId, + 'groupId': tab.groupId, + 'tabIndex': tab.index, + 'windowId': tab.windowId, + 'indices': indices, + 'tab': tab + }; + + // Adding a delay on Left to allow potential tab closed event to be processed first, otherwise tabLeftTG deletes BT Node + if (tab.groupId > 0) + btSendMessage(message); + else + setTimeout(async () => { btSendMessage(message); }, 250); + + setTimeout(function() {setBadge(tabId);}, 200); + } +})); + +chrome.tabs.onActivated.addListener(logEventWrapper("tabs.onActivated", async (info) => { + // Let app know there's a new top tab + if (!info.tabId) return; + chrome.tabs.get(info.tabId, tab => { + check(); + if (!tab) return; + btSendMessage({ 'function': 'tabActivated', 'tabId': info.tabId, + 'windowId': tab.windowId, 'groupId': tab.groupId}); + setTimeout(function() {setBadge(info.tabId);}, 250); + }); +})); + +// Listen for webNav events to know if the user was clicking a link or typing in the URL bar etc. +// Seems like some sites (g Reddit) trigger the history instead of Committed event. Don't know why + +const tabTransitionData = {}; // map of tabId: {transitionTypes: [""..], transitionQualifiers: [""..]} +chrome.webNavigation.onCommitted.addListener(logEventWrapper("webNavigation.onCommitted", async (details) => { + if (details?.frameId !== 0) return; + //console.log('webNavigation.onCommitted fired:', JSON.stringify(details)); + if (!tabTransitionData[details.tabId]) { + tabTransitionData[details.tabId] = { transitionTypes: [], transitionQualifiers: [] }; + } + tabTransitionData[details.tabId].transitionTypes.push(details.transitionType); + tabTransitionData[details.tabId].transitionQualifiers.push(...details.transitionQualifiers); +})); + +chrome.webNavigation.onHistoryStateUpdated.addListener(logEventWrapper("webNavigation.onHistoryStateUpdated", async (details) => { + if (details?.frameId !== 0) return; + //console.log('webNavigation.onHistoryStateUpdated fired:', JSON.stringify(details)); + if (!tabTransitionData[details.tabId]) { + tabTransitionData[details.tabId] = { transitionTypes: [], transitionQualifiers: [] }; + } + tabTransitionData[details.tabId].transitionTypes.push(details.transitionType); + tabTransitionData[details.tabId].transitionQualifiers.push(...details.transitionQualifiers); +})); + +/* -- TabGroup Events -- */ + +chrome.tabGroups.onCreated.addListener(logEventWrapper("tabGroups.onCreated", async (tg) => { + // listen for TG creation and let app know color etc + btSendMessage({'function': 'tabGroupCreated', 'tabGroupId': tg.id, 'tabGroupColor': tg.color}); +})); + +chrome.tabGroups.onUpdated.addListener(logEventWrapper("tabGroups.onUpdated", async (tg) => { + // listen for TG updates and let app know color etc + if (Awaiting) return; // ignore TG events while we're awaiting our commands to take effect + btSendMessage({ 'function': 'tabGroupUpdated', 'tabGroupId': tg.id, 'tabGroupColor': tg.color, + 'tabGroupName': tg.title, 'tabGroupCollapsed': tg.collapsed, 'tabGroupWindowId': tg.windowId}); +})); + +chrome.tabGroups.onRemoved.addListener(logEventWrapper("tabGroups.onRemoved", async (tg) => { + // listen for TG deletion + btSendMessage({'function': 'tabGroupRemoved', 'tabGroupId': tg.id}); +})); + + +/* -- Window Events -- */ + +chrome.windows.onFocusChanged.addListener(logEventWrapper("windows.onFocusChanged", async (windowId) => { + // Let app know there's a new top tab + + // don't care about special windows like dev tools + check(); + if (windowId <= 0) return; + chrome.tabs.query({'active': true, 'windowId': windowId},tabs => { + check(); + if (!tabs?.length) return; + btSendMessage({'function': 'tabActivated', 'tabId': tabs[0].id}); + setTimeout(function() {setBadge(tabs[0].id);}, 200); + }); +})); + +chrome.windows.onBoundsChanged.addListener(async (window) => { + // remember position of topic manager window + const [BTTab, BTWin] = await getBTTabWin(); + if (BTWin != window.id) return; + const location = {top: window.top, left: window.left, width: window.width, height: window.height}; + chrome.storage.local.set({'ManagerLocation': location}); +}); + +// listen for connect and immediate disconnect => open BT panel +chrome.runtime.onConnect.addListener(logEventWrapper("runtime.onConnect", async (port) => { + + const [BTTab, BTWin] = await getBTTabWin(); + const connectTime = Date.now(); + port.onDisconnect.addListener(() => { + const disconnectTime = Date.now(); + if (!BTWin) return; // might have been closed + if ((disconnectTime - connectTime) < 500) + chrome.windows.update(BTWin, {'focused': true}, () => { + check(); + chrome.tabs.update(BTTab, {'active': true}); + }); + }); +})); + +// utility to return tabId: {tabIndex windowId} hash +async function tabIndices() { + const tabs = await chrome.tabs.query({}); + const indices = {}; + tabs.forEach(t => indices[t.id] = {'index': t.index, 'windowId': t.windowId}); + return indices; +} + +/*** + * + * Functions that do the Apps bidding + * + ***/ + +// breaking out single tab opened handling, might not be in tg +async function tabOpened(winId, tabId, nodeId, index, tgId = 0) { + const indices = await tabIndices(); + btSendMessage({'function': 'tabOpened', 'nodeId': nodeId, 'tabIndex': index, + 'tabId': tabId, 'windowId': winId, 'tabGroupId': tgId, 'indices': indices}); + setTimeout(function() {setBadge(tabId);}, 250); +} + +function getOpenTabs() { + // return an array of [{winId:, tabId:, groupId:, url:}..] via promise + + return new Promise(resolve => { + let allTabs = []; + chrome.tabs.query({}, (tabs) => { + tabs.forEach((tab) => + allTabs.push({'id': tab.id, + 'groupId': tab.groupId, + 'windowId': tab.windowId, + 'tabIndex' : tab.index, + 'title': tab.title, + 'pinned': tab.pinned, + 'faviconUrl': tab.favIconUrl, + 'url': tab.url})); + resolve(allTabs); + }); + }); +} + +function getOpenTabGroups() { + // return array of [{windId, color, title, collapsed, id}] + return new Promise(resolve => { + chrome.tabGroups.query({}, (tgs) => { + resolve(tgs); + }); + }); +} + +async function initializeExtension(msg, sender) { + // sender is the BTContent script. We pull out its identifiers + const BTTab = sender.tab.id; + const BTWin = sender.tab.windowId; + const BTVersion = chrome.runtime.getManifest().version; + chrome.storage.local.set({'BTTab': BTTab, 'BTWin': BTWin}); + getBTTabWin(true); // clear cache + + let allTabs = await getOpenTabs(); + let allTGs = await getOpenTabGroups(); + + // send over gdrive app info + btSendMessage( + {'function': 'launchApp', 'client_id': Keys.CLIENT_ID, + 'api_key': Keys.API_KEY, 'fb_key': Keys.FB_KEY, + 'stripe_key': Keys.STRIPE_KEY, 'BTTab': BTTab, + 'initial_install': InitialInstall, 'upgrade_install': UpdateInstall, 'BTVersion': BTVersion, + 'all_tabs': allTabs, 'all_tgs': allTGs}); + + // check to see if a welcome is called for. repeat popup setting on bt win for safety. + if (InitialInstall || UpdateInstall) { + const welcomePage = InitialInstall ? + 'https://braintool.org/support/welcome' : + 'https://braintool.org/support/releaseNotes'; + chrome.tabs.create({'url': welcomePage}, + () => { + chrome.windows.update(BTWin, + {'focused' : true}, + () => check()); + }); + InitialInstall = null; UpdateInstall = null; + } + updateBTIcon('', 'BrainTool', '#59718C'); // was #5E954E + chrome.action.setIcon({'path': 'images/BrainTool128.png'}); +} + +function suspendExtension() { + // called when the BTWin/BTTab is detected to have been closed + + chrome.storage.local.set({'BTTab': 0, 'BTWin': 0}); + getBTTabWin(true); // clear cache + updateBTIcon('', 'BrainTool is not running.\nClick to start', '#e57f21'); + chrome.action.setIcon({'path': 'images/BrainToolGray.png'}); + + chrome.tabs.query({'currentWindow': true, 'active': true}, (tabs) => { + if (!tabs.length || !tabs[0].id) return; // sometimes theres no active tab + const tabId = tabs[0].id; + setTimeout(() => { + // wait for updateBTIcon to finish then show 'OFF' on top tab for 3 secs + chrome.action.setBadgeText({'text' : 'OFF', 'tabId': tabId}); + setTimeout(() => chrome.action.setBadgeText({'text' : '', 'tabId': tabId}), 3000); + }, 500); + }); +} + +function updateBTIcon(text, title, color) { + // utility fn called when BT is opened or closed to update icon appropriately + + // set for each tab to override previous tab-specific setting + chrome.tabs.query({}, (tabs) => + { + tabs.forEach((tab) => { + chrome.action.setBadgeText( + {'text' : text, 'tabId': tab.id}, () => check()); + chrome.action.setTitle( + {'title' : title, 'tabId': tab.id}); + chrome.action.setBadgeBackgroundColor( + {'color' : color, 'tabId': tab.id}); + }); + }); + + // set across all tabs + chrome.action.setBadgeText( + {'text' : text}, () => check()); + chrome.action.setTitle( + {'title' : title}); + chrome.action.setBadgeBackgroundColor( + {'color' : color}); +} + +function openTabs(msg, sender) { + // open list of {nodeId, url} pairs, potentially in new window + + function openTabsInWin(tabInfo, winId = null) { + // open [{url, nodeId}]s in tab in given window + tabInfo.forEach((tabData) => { + const args = winId ? {'windowId': winId, 'url': tabData.url} : {'url': tabData.url}; + chrome.tabs.create(args, tab => { + chrome.windows.update(tab.windowId, {'focused' : true}); + chrome.tabs.highlight({'windowId': tab.windowId, 'tabs': tab.index}); + tabOpened(tab.windowId, tab.id, tabData.nodeId, tab.index); + }); + }); + } + + const newWin = msg.newWin; + const defaultWinId = msg.defaultWinId; // 0 or winId of siblings + const [first, ...rest] = msg.tabs; + + if (newWin) + // Create new win w first url, then iterate on rest + chrome.windows.create({'url': first.url}, win => { + tabOpened(win.id, win.tabs[0].id, first.nodeId, win.tabs[0].index); + openTabsInWin(rest, win.id); + }); + else if (!defaultWinId) openTabsInWin(msg.tabs); // open in current win + else + // else check window exists & iterate on all adding to current window + chrome.windows.get(defaultWinId, (w) => { + if (!w) { + // in rare error case win may no longer exist => set to null + console.warn(`Error in openTabs. ${chrome.runtime.lastError?.message}`); + openTabsInWin(msg.tabs); + } else { + openTabsInWin(msg.tabs, defaultWinId); + } + }); +} + +function openTabGroups(msg, sender) { + // open tabs in specified or new tab group, potentially in new window + + const tabGroups = msg.tabGroups; // [{tg, win, tgname[{id, url}]},..] + const newWinNeeded = msg.newWin; + + function openTabsInTg(winId, tgid, tabInfo) { + // open [{url, nodeId}, ..] in window and group + // NB since a TG can't be set on creation need to iterate on creating tabs and then grouping + tabInfo.forEach(info => { + chrome.tabs.create({'url': info.url, 'windowId': winId}, tab => { + check(); if (!tab) return; + chrome.tabs.group({'tabIds': tab.id, 'groupId': tgid}, tgid => { + chrome.windows.update(tab.windowId, {'focused' : true}); + chrome.tabs.highlight({'windowId': tab.windowId, 'tabs': tab.index}); + tabOpened(winId, tab.id, info.nodeId, tab.index, tgid); + }); + }); + }); + } + + tabGroups.forEach(tg => { + // handle a {windowId, tabGroupId, groupName, 'tabGroupTabs': [{nodeId, url}]} instance + const[first, ...rest] = tg.tabGroupTabs; + const groupName = tg.groupName || ''; + + // create in existing win/tg if tg is open (even if newWin sent) + if (tg.tabGroupId) + { + openTabsInTg(tg.windowId, tg.tabGroupId, tg.tabGroupTabs); + return; + } + if (newWinNeeded) + // need to create window for first tab + chrome.windows.create({'url': first.url}, win => { + const newTabId = win.tabs[0].id; + chrome.tabs.group({'tabIds': newTabId, 'createProperties': {'windowId': win.id}}, + tgid => { + check(); if (!tgid) return; + tabOpened(win.id, win.tabs[0].id, first.nodeId, + win.tabs[0].index, tgid); + chrome.tabGroups.update(tgid, {'title' : groupName}); + openTabsInTg(win.id, tgid, rest); + }); + }); + else + // create tg in current window + chrome.tabs.create({'url': first.url}, tab => { + check(); if (!tab) return; + chrome.tabs.group({'tabIds': tab.id, + 'createProperties': {'windowId': tab.windowId}}, + tgid => { + check(); if (!tgid) return; + tabOpened(tab.windowId, tab.id, first.nodeId, + tab.index, tgid); + chrome.tabGroups.update(tgid, {'title' : groupName}); + openTabsInTg(tab.windowId, tgid, rest); + }); + }); + }); +} + +async function groupAndPositionTabs(msg, sender) { + // array of {nodeId, tabId, tabIndex} to group in tabGroupId and order + + const tabGroupId = msg.tabGroupId; + const windowId = msg.windowId; + const tabInfo = msg.tabInfo; + const topicId = msg.topicId; + const groupName = msg.groupName; + + // Sort left to right before moving + tabInfo.sort((a,b) => a.tabindex < b.tabindex); + const tabIds = tabInfo.map(t => t.tabId); + const groupArgs = tabGroupId ? + {'tabIds': tabIds, 'groupId': tabGroupId} : windowId ? + {'tabIds': tabIds, 'createProperties': {'windowId': windowId}} : {'tabIds': tabIds}; + console.log(`groupAndposition.groupArgs: ${JSON.stringify(groupArgs)}`); + if (!tabIds.length) return; // shouldn't happen, but safe + + const firstTab = await chrome.tabs.get(tabIds[0]); check(); + const tabIndex = tabInfo[0].tabIndex || firstTab?.index || 0; + chrome.tabs.move(tabIds, {'index': tabIndex}, tabs => { + // first move tabs into place + check('groupAndPositionTabs-move'); + if (!tabs) return; // error, eg tg still being moved by user + chrome.tabs.group(groupArgs, async (groupId) => { + // then group appropriately. NB this order cos move drops the tabgroup + check('groupAndPositionTabs-group'); + if (!groupId) console.log('Error: groupId not returned from tabs.group call.'); + else await chrome.tabGroups.update(groupId, {'title' : groupName}); + if (!tabGroupId) { + // new group => send tabGroupCreated msg to link to topic + const tg = await chrome.tabGroups.get(groupId); + btSendMessage({'function': 'tabGroupCreated', 'tabGroupId': groupId, 'topicId': topicId, 'tabGroupColor': tg.color}); + } + const theTabs = Array.isArray(tabs) ? tabs : [tabs]; // single tab? + theTabs.forEach(t => { + const nodeInfo = tabInfo.find(ti => ti.tabId == t.id); + btSendMessage({ 'function': 'tabPositioned', 'tabId': t.id, + 'nodeId': nodeInfo.nodeId, 'tabGroupId': groupId, + 'windowId': t.windowId, 'tabIndex': t.index}); + }); + }); + }); +} + +async function ungroup(msg, sender) { + // node deleted, navigated or we're not using tabgroups any more, so ungroup + await chrome.tabs.ungroup(msg.tabIds); + check('ungroup '); +} + +function moveOpenTabsToTG(msg, sender) { + // add tabs to new group, cos pref's changed + // send back tabJoinedTG msg per tab + + chrome.tabs.group({'createProperties': {'windowId': msg.windowId}, 'tabIds': msg.tabIds}, async (tgId) => { + check(); if (!tgId) return; + chrome.tabGroups.update(tgId, {'title' : msg.groupName}, tg => { + console.log('tabgroup updated:', tg); + }); + const indices = await tabIndices(); + msg.tabIds.forEach(tid => { + chrome.tabs.get(tid, tab => { + check(); if (!tab) return; + btSendMessage( + {'function': 'tabJoinedTG', 'tabId': tid, 'groupId': tgId, + 'tabIndex': tab.index, 'windowId': tab.windowId, 'indices': indices, + 'tab': tab}); + // was {'function': 'tabGrouped', 'tgId': tgId, 'tabId': tid, 'tabIndex': tab.index}); + }); + }); + }); +} + +async function updateGroup(msg, sender) { + // expand/collapse or name change on topic in topic manager, reflect in browser + + await chrome.tabGroups.update(msg.tabGroupId, {'collapsed': msg.collapsed, 'title': msg.title}); + check('UpdateGroup:'); +} + +function showNode(msg, sender) { + // Surface the window/tab associated with this node + + function signalError(type, id) { + // send back message so TM can fix display + btSendMessage({'function': 'noSuchNode', 'type': type, 'id': id}); + } + + if (msg.tabId) { + chrome.tabs.get(msg.tabId, function(tab) { + check(); + if (!tab) { signalError('tab', msg.tabId); return;} + chrome.windows.update(tab.windowId, {'focused' : true}, () => check()); + chrome.tabs.highlight({'windowId' : tab.windowId, 'tabs': tab.index}, + () => check()); + }); + } + else if (msg.tabGroupId) { + chrome.tabs.query({groupId: msg.tabGroupId}, function(tabs) { + check(); + if (!tabs) { signalError('tabGroup', msg.tabGroupId); return;} + if (tabs.length > 0) { + let firstTab = tabs[0]; + chrome.windows.update(firstTab.windowId, {'focused' : true}, () => check()); + chrome.tabs.highlight({'windowId' : firstTab.windowId, 'tabs': firstTab.index}, + () => check()); + } + }); + } + else if (msg.windowId) { + chrome.windows.update(msg.windowId, {'focused' : true}, () => check()); + } +} + +function closeTab(msg, sender) { + // Close a tab, NB tab listener will catch close and alert app + + const tabId = msg.tabId; + chrome.tabs.remove(tabId, ()=> check()); // ignore error +} + +async function moveTab(msg, sender) { + // move tab to window.index + try { + await chrome.tabs.move(msg.tabId, {'windowId': msg.windowId, 'index': msg.index}); + if (msg.tabGroupId) + await chrome.tabs.group({'groupId': msg.tabGroupId, 'tabIds': msg.tabId}); + console.log('Success moving tab.'); + } catch (error) { + if (error == 'Error: Tabs cannot be edited right now (user may be dragging a tab).') { + setTimeout(() => moveTab(msg, sender), 50); + } else { + console.error(error); + } + } +} + +var MarqueeEvent; // ptr to timeout event to allow cancellation + +function setBadge(tabId) { + // tab/window activated, set badge appropriately + + function marquee(badgeText, index) { + if (badgeText.length < 6 || index >= badgeText.length - 2) { + chrome.action.setBadgeText({'text' : badgeText, 'tabId': tabId}, () => check('marquee')); + } else { + chrome.action.setBadgeText({'text' : badgeText.slice(index) + " ", + 'tabId': tabId}, () => check('marquee')); + MarqueeEvent = setTimeout(function() {marquee(badgeText, ++index);}, 150); + } + } + if (MarqueeEvent) clearTimeout(MarqueeEvent); + chrome.storage.local.get(['currentTopic', 'currentText'], function(data) { + if (!data.currentTopic) { + chrome.action.setBadgeText({'text' : "", 'tabId' : tabId}, + () => check('Resetting badge text:')); + chrome.action.setTitle({'title' : 'BrainTool'}); + } else { + let title = data.currentTopic.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + marquee(title, 0); + chrome.action.setTitle({'title' : data.currentText || 'BrainTool'}); + chrome.action.setBadgeBackgroundColor({'color' : '#59718C'}); + } + }); +} + +function brainZoom(msg, sender, iteration = 0) { + // iterate thru icons to swell the brain + const iterationArray = ['01','02', '03','04','05','06','07','08','09','10','05','04', '03','02','01']; + const path = 'images/BrainZoom'+iterationArray[iteration]+'.png'; + const default_icon = { + "16": "images/BrainTool16.png", + "32": "images/BrainTool32.png", + "48": "images/BrainTool48.png", + "128": "images/BrainTool128.png" + }; + + if (iteration == iterationArray.length) { + chrome.action.setIcon({'path': default_icon, 'tabId': msg.tabId}); + setTimeout(function() {setBadge(msg.tabId);}, 150); + return; + } + chrome.action.setBadgeText({'text': '', 'tabId': msg.tabId}); + chrome.action.setIcon({'path': path, 'tabId': msg.tabId}, () => { + // if action was Close tab might be closed by now + if (chrome.runtime.lastError) + console.log("!!Whoops, tab closed before Zoom.. " + chrome.runtime.lastError.message); + else + setTimeout(function() {brainZoom(msg, sender, ++iteration);}, 150); + }); +} + +function getBookmarks() { + // User has requested bookmark import from browser + + chrome.bookmarks.getTree(async function(itemTree){ + itemTree[0].title = "Imported Bookmarks"; + chrome.storage.local.set({'bookmarks': itemTree[0]}, function() { + btSendMessage({'function': 'loadBookmarks', 'result': 'success'}); + }); + }); +} + +function exportBookmarks() { + // Top level bookmark exporter + let AllNodes; + + function exportNodeAsBookmark(btNode, parentBookmarkId) { + // export this node and recurse thru its children + chrome.bookmarks.create( + {title: btNode.displayTopic, url: btNode.URL, parentId: parentBookmarkId}, + (bmNode) => { + btNode.childIds.forEach(i => {exportNodeAsBookmark(AllNodes[i], bmNode.id); }); + }); + } + + chrome.storage.local.get(['title', 'AllNodes'], data => { + AllNodes = data.AllNodes; + chrome.bookmarks.create({title: data.title}, bmNode => { + // Iterate thru top level nodes exporting them + AllNodes.forEach(n => { + if (n && !n.parentId) + exportNodeAsBookmark(n, bmNode.id); + }); + chrome.windows.create({'url': 'chrome://bookmarks/?id='+bmNode.id}); + }); + }); +} + +function createSessionName() { + // return a name for the current session, 'session-Mar12 + const d = new Date(); + const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"]; + const monthName = monthNames[d.getMonth()]; + const day = String(d.getDate()).padStart(2, '0'); + return 'Session-' + monthName + day + ":"; +} + +async function saveTabs(msg, sender) { + // handle save for popup. msg.type Could be Tab, TG, Window or Session. + // msg: {'close','topic', 'note', 'title', 'currentWindowId' } + // Create array of appropriate tab data and send to BT window + + const currentTabs = msg.currentWindowId ? await chrome.tabs.query({'active': true, 'windowId': msg.currentWindowId}) : []; + const currentTab = currentTabs[0]; + const saveType = msg.type; + const [BTTab, BTWin] = await getBTTabWin(); + const allTabs = await getOpenTabs(); // array of tabs + const allTGs = await getOpenTabGroups(); // array of tgs + + // Create a hash of TGIds to TG names + const tgNames = {}; + allTGs.forEach(tg => tgNames[tg.id] = tg.title); + // ditto for windowIds + const winNames = {}; + let numWins = 1; + allTabs.forEach(t => winNames[t.windowId] = 'Window-'+numWins++); + + // Loop thru tabs, decide based on msg.type if it should be saved and if so add to array to send to BTTab + const tabsToSave = []; + const sessionName = createSessionName(); + allTabs.forEach(t => { + if (t.id == BTTab || t.pinned) return; + const tab = {'tabId': t.id, 'groupId': t.groupId, 'windowId': t.windowId, 'url': t.url, + 'favIconUrl': t.faviconUrl, 'tabIndex': t.tabIndex, 'title': t.title}; + const tgName = tgNames[t.groupId] || ''; // might want tabgroup name as topic + const winName = winNames[t.windowId] || ''; // might want window name as topic + const [topic, todo] = msg.topic.split(/:(TODO|DONE)$/, 2); // entered topic might have trailing :TODO or :DONE. split it off + if (saveType == 'Tab' && t.id == currentTab.id) { + tab['topic'] = topic+(todo ? ':'+todo : ''); + tab['title'] = msg.title; // original or popup-edited tab title + tabsToSave.push(tab); + } + if (saveType == 'TG' && t.groupId == currentTab.groupId) { + tab['topic'] = (topic||"📝 Scratch")+':'+tgName+(todo ? ':'+todo : ''); + tabsToSave.push(tab); + } + if (saveType == 'Window' && t.windowId == currentTab.windowId) { + tab['topic'] = (tgName ? topic+':'+tgName : topic)+(todo ? ':'+todo : ''); + tabsToSave.push(tab); + } + if (saveType == 'Session') { + tab['topic'] = (topic ? topic+":" : "📝 Scratch:") + sessionName + (tgName ? tgName : winName) + (todo ? ':'+todo : ''); + tabsToSave.push(tab); + } + }); + // Send save msg to BT. + if (tabsToSave.length) btSendMessage({'function': 'saveTabs', 'saveType':saveType, 'tabs': tabsToSave, 'note': msg.note, 'close': msg.close}); + currentTab && btSendMessage({'function': 'tabActivated', 'tabId': currentTab.id }); // ensure BT selects the current tab, if there is one + +} diff --git a/versions/1.1/extension/btContentScript.js b/versions/1.1/extension/btContentScript.js new file mode 100644 index 0000000..4efcbfa --- /dev/null +++ b/versions/1.1/extension/btContentScript.js @@ -0,0 +1,159 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** +* +* This script is basically just a relay for messages between the app window and the extension. +* In general message are passed thru, sometimes we need to pull from local storage +* +***/ + + +function getFromLocalStorage(key) { + // Promisification of storage.local.get + return new Promise(resolve => { + chrome.storage.local.get(key, function(item) { + resolve(item[key]); + }); + }); +} + + +function setToLocalStorage(obj) { + // Promisification of storage.local.set + return new Promise(resolve => { + chrome.storage.local.set(obj, function() { + if (chrome.runtime.lastError) + alert(`Error saving to browser storage:\n${chrome.runtime.lastError.message}\nContact BrainTool support`); + resolve(); + }); + }); +} + +// Listen for messages from the App +window.addEventListener('message', async function(event) { + // Handle message from Window, NB ignore msgs relayed from this script in listener below + if (event.source != window || event.data.from == "btextension") + return; + console.log(`Content-IN ${event.data.function || event.data.type} from TopicManager:`, event.data); + if (event.data.function == 'localStore') { + // stores topics, preferences, current tabs topic/note info etc for popup/extensions use + try { + await setToLocalStorage(event.data.data); + } + catch (e) { + const err = chrome.runtime.lastError.message || e; + console.warn("Error saving to storage:", err, "\nContact BrainTool support"); + } + return; + } + + /* 'Synchronous' calls */ + if (event.data.type == 'AWAIT') { + try { + event.data["from"] = "btwindow"; + const response = await callToBackground(event.data); + // Send the response back to the web page + window.postMessage({type: "AWAIT_RESPONSE", response: response}, event.origin); + } catch (error) { + console.error("Error sending message:", JSON.stringify(error)); + } + } + + else { + // handle all other default type messages + event.data["from"] = "btwindow"; + chrome.runtime.sendMessage(event.data); + } +}); + +// Function to send a message to the service worker and await a response +async function callToBackground(message) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (response) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(response); + } + }); + }); +} + +// Listen for messages from the extension +chrome.runtime.onMessage.addListener((msg, sender, response) => { + // Handle messages from extension + + // NB workaround for bug in Chrome, see https://stackoverflow.com/questions/71520198/manifestv3-new-promise-error-the-message-port-closed-before-a-response-was-rece/71520415#71520415 + response(); + + console.log(`Content-IN ${msg.function} from Extension:`, msg); + switch (msg.function) { + case 'loadBookmarks': + chrome.storage.local.get('bookmarks', data => { + msg.data = data; + msg["from"] = "btextension"; + window.postMessage(msg); + /* !!!!!!!!!!!! Release Candidate debugging change. Undo in 1.0 release !!!!!!!!!!!!! + chrome.storage.local.remove('bookmarks'); // clean up space + */ + }); + break; + case 'launchApp': // set up btfiletext before passing on to app, see below + launchApp(msg); + break; + default: + // handle all other default type messages + msg["from"] = "btextension"; + window.postMessage(msg); + } +}); + + +// Let extension know bt window is ready. Should only run once +var NotLoaded = true; +if (!window.LOCALTEST && NotLoaded) { + chrome.runtime.sendMessage({'from': 'btwindow', 'function': 'initializeExtension' }); + NotLoaded = false; + //setTimeout(waitForKeys, 5000); + console.count('Content-OUT:initializeExtension'); +} + + +async function launchApp(msg) { + // Launchapp msg comes from extension code w GDrive app IDs + // inject btfile data into msg either from local storage or the initial .org on server + // and then just pass on to app + + if (window.LOCALTEST) return; // running inside test harness + + let btdata = await getFromLocalStorage('BTFileText'); + if (!btdata) { + let response = await fetch('/app/BrainTool.org'); + if (response.ok) { + btdata = await response.text(); + chrome.storage.local.set({'BTFileText': btdata}); + } else { + alert('Error getting initial BT file'); + return; + } + } + + // also pull out subscription id if exists (=> premium) + let BTId = await getFromLocalStorage('BTId'); + let Config = await getFromLocalStorage('Config'); + if (BTId) msg["bt_id"] = BTId; + if (Config) msg["Config"] = Config; + + msg["from"] = "btextension"; + msg["BTFileText"] = btdata; + window.postMessage(msg); +} diff --git a/versions/1.1/extension/btContentScript.test.js b/versions/1.1/extension/btContentScript.test.js new file mode 100644 index 0000000..f9446d0 --- /dev/null +++ b/versions/1.1/extension/btContentScript.test.js @@ -0,0 +1,212 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** +* +* A version of the content script to drive a set of extensions<->app message handling tests +***/ + + +function getFromLocalStorage(key) { + // Promisification of storage.local.get + return new Promise(resolve => { + chrome.storage.local.get(key, function(item) { + resolve(item[key]); + }); + }); +} + + +function setToLocalStorage(obj) { + // Promisification of storage.local.set + return new Promise(resolve => { + chrome.storage.local.set(obj, function() { + if (chrome.runtime.lastError) + alert(`Error saving to browser storage:\n${chrome.runtime.lastError.message}\nContact BrainTool support`); + resolve(); + }); + }); +} + +// Listen for messages from the App +window.addEventListener('message', async function(event) { + // Handle message from Window, NB ignore msgs relayed from this script in listener below + if (event.source != window || event.data.from == "btextension") + return; + console.log(`Content-IN ${event.data.function} from TopicManager:`, event.data); + if (event.data.function == 'localStore') { + // stores topics, preferences, current tabs topic/note info etc for popup/extensions use + try { + await setToLocalStorage(event.data.data); + } + catch (e) { + const err = chrome.runtime.lastError.message || e; + console.warn("Error saving to storage:", err, "\nContact BrainTool support"); + } + } + else if (event.data.function == 'sendTestMessages') { + // call sendMessages with name of message set + sendTestMessages(event.data.messageSet); + } + else { + // handle all other default type messages + event.data["from"] = "btwindow"; + chrome.runtime.sendMessage(event.data); + } +}); + +// Listen for messages from the extension +chrome.runtime.onMessage.addListener((msg, sender, response) => { + // Handle messages from extension + + // NB workaround for bug in Chrome, see https://stackoverflow.com/questions/71520198/manifestv3-new-promise-error-the-message-port-closed-before-a-response-was-rece/71520415#71520415 + response(); + + console.log(`Content-IN ${msg.function} from Extension:`, msg); + switch (msg.function) { + case 'loadBookmarks': + chrome.storage.local.get('bookmarks', data => { + msg.data = data; + msg["from"] = "btextension"; + window.postMessage(msg); + chrome.storage.local.remove('bookmarks'); // clean up space + }); + break; + case 'launchApp': // set up btfiletext before passing on to app, see below + launchAppTests(msg); + break; + default: + // handle all other default type messages + msg["from"] = "btextension"; + window.postMessage(msg); + } +}); + + +// Let extension know bt window is ready to open gdrive app. Should only run once +var NotLoaded = true; +if (!window.LOCALTEST && NotLoaded) { + chrome.runtime.sendMessage({'from': 'btwindow', 'function': 'initializeExtension' }); + NotLoaded = false; + //setTimeout(waitForKeys, 5000); + console.count('Content-OUT:initializeExtension'); +} + +// Code for testing functions below + +const BTFileText = +`* BrainTool +** TG1 +*** [[https://braintool.org/overview.html][Overview Page]] +*** [[https://braintool.org/posts][posts Page]] +*** [[https://braintool.org/support.html][Support Page]] +*** [[https://braintool.org/support/releaseNotes.html][Release Notes]] +** TG2 +*** [[https://reddit.com][Reddit]] +*** [[https://news.ycombinator.com][Hacker News]] +*** [[https://slashdot.org][Slashdot]] +** TG3 +*** [[https://www.bbc.com/news][BBC News]] +*** [[https://www.theregister.com][The Register]] +*** [[https://www.theguardian.com/us][The Guardian]] +`; +const messageSets = +{'openTab' : [ + {"function":"tabOpened","nodeId":3,"tabIndex":3,"tabId":1,"windowId":1,"tabGroupId":0,"from":"btextension"}, + {"function":"tabActivated","tabId":1,"windowId":1,"groupId":-1,"from":"btextension"}, + {"function":"tabNavigated","tabId":1,"groupId":-1,"tabURL":"https://braintool.org/overview.html","windowId":1,"from":"btextension"}, + {"function":"tabJoinedTG","tgId":1,"tabId":1,"tabIndex":3,"from":"btextension"}, + {"function":"tabGroupCreated","tabGroupId":1,"tabGroupColor":"blue","from":"btextension"}, + {"function":"tabGroupUpdated","tabGroupId":1,"tabGroupColor":"blue","tabGroupName":"TG1","tabGroupCollapsed":false,"from":"btextension"}, +], +'openTG' : [ + {"function":"tabOpened","nodeId":4,"tabIndex":4,"tabId":2,"windowId":1,"tabGroupId":1,"from":"btextension"}, + {"function":"tabOpened","nodeId":5,"tabIndex":5,"tabId":3,"windowId":1,"tabGroupId":1,"from":"btextension"}, + {"function":"tabActivated","tabId":2,"windowId":1,"groupId":1,"from":"btextension"}, + {"function":"tabActivated","tabId":3,"windowId":1,"groupId":1,"from":"btextension"}, + {"function":"tabNavigated","tabId":2,"groupId":1,"tabURL":"https://braintool.org/posts","windowId":1,"from":"btextension"}, + {"function":"tabNavigated","tabId":3,"groupId":1,"tabURL":"https://braintool.org/support.html","windowId":1,"from":"btextension"}, + {"function":"tabPositioned","tabId":2,"nodeId":3,"tabGroupId":1,"windowId":1,"tabIndex":3,"from":"btextension"}, + {"function":"tabPositioned","tabId":2,"nodeId":4,"tabGroupId":1,"windowId":1,"tabIndex":4,"from":"btextension"}, + {"function":"tabPositioned","tabId":3,"nodeId":5,"tabGroupId":1,"windowId":1,"tabIndex":5,"from":"btextension"}, + {"function":"tabGroupUpdated","tabGroupId":1,"tabGroupColor":"blue","tabGroupName":"TG1","tabGroupCollapsed":false,"from":"btextension"}, +], +'dragTabIntoTG' : [ + {"function":"tabMoved","tabId":4,"groupId":1,"tabIndex":4,"windowId":1,"tabIndices":{"1465379101":{"index":0,"windowId":1},"1465379250":{"index":0,"windowId":1},"1":{"index":1,"windowId":1},"2":{"index":2,"windowId":1},"3":{"index":3,"windowId":1},"4":{"index":4,"windowId":1}},"tab":{"active":true,"audible":false,"autoDiscardable":true,"discarded":false,"favIconUrl":"https://braintool.org/favicon.ico?","groupId":1,"height":563,"highlighted":true,"id":4,"incognito":false,"index":4,"mutedInfo":{"muted":false},"openerTabId":1465379253,"pinned":false,"selected":true,"status":"complete","title":"BrainTool User Guide | BrainTool - Beyond Bookmarks","url":"https://braintool.org/support/userGuide.html","width":914,"windowId":1},"from":"btextension"}, + {"function":"tabMoved","tabId":4,"groupId":1,"tabIndex":3,"windowId":1,"tabIndices":{"1465379101":{"index":0,"windowId":1},"1465379250":{"index":0,"windowId":1},"1":{"index":1,"windowId":1},"2":{"index":2,"windowId":1},"3":{"index":4,"windowId":1},"4":{"index":3,"windowId":1}},"tab":{"active":true,"audible":false,"autoDiscardable":true,"discarded":false,"favIconUrl":"https://braintool.org/favicon.ico?","groupId":1,"height":563,"highlighted":true,"id":4,"incognito":false,"index":3,"mutedInfo":{"muted":false},"openerTabId":1465379253,"pinned":false,"selected":true,"status":"complete","title":"BrainTool User Guide | BrainTool - Beyond Bookmarks","url":"https://braintool.org/support/userGuide.html","width":914,"windowId":1},"from":"btextension"} +], +'navigateTabIntoTG' : [ + {"function":"tabNavigated","tabId":5,"groupId":-1,"tabURL":"https://braintool.org/support/releaseNotes.html","windowId":1,"from":"btextension"}, + {"function":"tabPositioned","tabId":2,"nodeId":3,"tabGroupId":1,"windowId":1,"tabIndex":1,"from":"btextension"}, + {"function":"tabPositioned","tabId":3,"nodeId":4,"tabGroupId":1,"windowId":1,"tabIndex":2,"from":"btextension"}, + {"function":"tabPositioned","tabId":5,"nodeId":6,"tabGroupId":1,"windowId":1,"tabIndex":3,"from":"btextension"}, + {"function":"tabGroupUpdated","tabGroupId":1,"tabGroupColor":"grey","tabGroupName":"TG1","tabGroupCollapsed":false,"from":"btextension"}, +], +'storeTab' : [ + {"function":"saveTabs","topic":"new topic","note":"","close":"GROUP","type":"Tab","tabs": + [{"url":"https://logseq.com/","windowId":1,"title":"Logseq: A privacy-first, open-source knowledge base","tabId":10,"tabIndex":1,"faviconUrl":"https://asset.logseq.com/static/img/logo.png"}],"from":"btextension"}, +], +'storeTabs' : [ + {"function":"saveTabs","topic":"3 tabs","note":"","type":"TG","tabs": + [{"url":"chrome://extensions/","windowId":1,"title":"Extensions","tabId":1,"tabIndex":0,"faviconUrl":""}, + {"url":"https://logseq.com/downloads","windowId":1,"title":"Logseq: A privacy-first, open-source knowledge base","tabId":2,"tabIndex":1,"faviconUrl":"https://asset.logseq.com/static/img/logo.png"}, + {"url":"https://blog.logseq.com/","windowId":1,"title":"Logseq Blog","tabId":3,"tabIndex":2,"faviconUrl":"https://blog.logseq.com/content/images/size/w256h256/2022/04/logseq-favicon.png"}],"from":"btextension"}, +], +'storeWindow' : [ + {"function":"saveTabs","topic":"BTWindow","note":"","type":"Window","windowId":1,"tabs": + [{"url":"https://braintool.org/support/releaseNotes","title":"BrainTool Release Notes | BrainTool - Beyond Bookmarks","tabId":14,"tabIndex":0,"faviconUrl":"https://braintool.org/favicon.ico?"}, + {"url":"https://braintool.org/support/userGuide.html","title":"BrainTool User Guide | BrainTool - Beyond Bookmarks","tabId":15,"tabIndex":1,"faviconUrl":"https://braintool.org/favicon.ico?"}, + {"url":"https://braintool.org/","title":"Go beyond Bookmarks with BrainTool, the online Topic Manager | BrainTool - Beyond Bookmarks","tabId":16,"tabIndex":2,"faviconUrl":"https://braintool.org/favicon.ico?"}], + "from":"btextension"}, +], +'storeSession' : [ + // data is of the form: {'function': 'saveTabs', 'saveType':Tab|TG|Window|Session, 'tabs': [], 'note': msg.note, 'close': msg.close} + // tabs: [{'tabId': t.id, 'groupId': t.groupId, 'windowId': t.windowId, 'url': t.url, 'topic': topic, 'title': msg.title, favIconUrl: t.favIconUrl}] + {"function":"saveTabs","note":"","type":"Session","tabs": + [{"tabId":1465380078,"groupId":1197213122,"windowId":3,"tabIndex":0,"topic":"Session1:Window1","title":"Three-body problem - Wikipedia","pinned":false,"faviconUrl":"https://en.wikipedia.org/static/favicon/wikipedia.ico","url":"https://en.wikipedia.org/wiki/Three-body_problem"}, + {"tabId":1465380084,"groupId":1197213122,"windowId":3,"tabIndex":1,"topic":"Session1:Window1","title":"Coriolis force - Wikipedia","pinned":false,"faviconUrl":"https://en.wikipedia.org/static/favicon/wikipedia.ico","url":"https://en.wikipedia.org/wiki/Coriolis_force"}, + {"tabId":1465380083,"groupId":1197213122,"windowId":3,"tabIndex":2,"topic":"Session1:Window1","title":"Lagrange point - Wikipedia","pinned":false,"faviconUrl":"https://en.wikipedia.org/static/favicon/wikipedia.ico","url":"https://en.wikipedia.org/wiki/Lagrange_point"}, + {"tabId":1465380086,"groupId":-1,"windowId":4,"tabIndex":0,"topic":"Session1:Window2","title":"amazon.com curved display - Google Search","pinned":false,"faviconUrl":"https://www.google.com/favicon.ico","url":"https://www.google.com/search?q=amazon.com+curved+display&oq=amazon.com+curved+display&aqs=chrome..69i57.9182j0j1&sourceid=chrome&ie=UTF-8"}, + {"tabId":1465380087,"groupId":-1,"windowId":4,"tabIndex":1,"topic":"Session1:Window2","title":"Amazon.com: Dell Curved Gaming, ","pinned":false,"faviconUrl":"https://www.amazon.com/favicon.ico", "url":"https://www.amazon.com/Dell-Curved-Monitor-Refresh-Display/dp/B095X7RV77/ref=asc_df_B095X7RV77"}, + {"tabId":1465380088,"groupId":-1,"windowId":4,"tabIndex":2,"topic":"Session1:Window2","title":"Our 5 Best Curved Monitor For Developers ","pinned":false, "faviconUrl":"https://images.top5-usa.com/image/fetch/c_scale,f_auto/https%3A%2F%2Fd1ttb1lnpo2lvz.cloudfront.net%2F10599b72%2Ffavicon.ico", "url":"https://www.top5-usa.com/curved-monitor-for-developers"}, + {"tabId":1465380089,"groupId":-1,"windowId":4,"tabIndex":3,"topic":"Session1:Window2","title":"Amazon.com: Viotek SUW49C 49-Inch Super Ultrawide ","pinned":false,"faviconUrl":"https://www.amazon.com/favicon.ico","url":"https://www.amazon.com/dp/B07L44N45F?tag=top5-usa-20&linkCode=osi&th=1"}] +}] +}; + +async function launchAppTests(msg) { + // Launchapp msg comes from extension code w GDrive app IDs + // inject test btfile data and then just pass on to app + // also pull out subscription id if exists (=> premium) + let BTId = await getFromLocalStorage('BTId'); + let Config = await getFromLocalStorage('Config'); + if (BTId) + msg["bt_id"] = BTId; + if (Config) + msg["Config"] = Config; + + msg["from"] = "btextension"; + msg["BTFileText"] = BTFileText; + window.postMessage(msg); +} + +async function sendTestMessages(messageSet) { + // Iterate through messages and send to content script + // Called manually from dev tools on client console with name of message set from above + if (!messageSets[messageSet]) { + console.error(`Message set ${messageSet} not found`); + return; + } + for (const msg of messageSets[messageSet]) { + console.log(`Content-OUT ${msg.function} to Extension:`, msg); + window.postMessage(msg); + await new Promise(resolve => setTimeout(resolve, 500)); + } +} diff --git a/versions/1.1/extension/images/BrainTool128.png b/versions/1.1/extension/images/BrainTool128.png new file mode 100644 index 0000000..5c613c1 Binary files /dev/null and b/versions/1.1/extension/images/BrainTool128.png differ diff --git a/versions/1.1/extension/images/BrainTool16.png b/versions/1.1/extension/images/BrainTool16.png new file mode 100644 index 0000000..454a199 Binary files /dev/null and b/versions/1.1/extension/images/BrainTool16.png differ diff --git a/versions/1.1/extension/images/BrainTool32.png b/versions/1.1/extension/images/BrainTool32.png new file mode 100644 index 0000000..a771ce3 Binary files /dev/null and b/versions/1.1/extension/images/BrainTool32.png differ diff --git a/versions/1.1/extension/images/BrainTool48.png b/versions/1.1/extension/images/BrainTool48.png new file mode 100644 index 0000000..1399798 Binary files /dev/null and b/versions/1.1/extension/images/BrainTool48.png differ diff --git a/versions/1.1/extension/images/BrainToolGray.png b/versions/1.1/extension/images/BrainToolGray.png new file mode 100644 index 0000000..99d68cd Binary files /dev/null and b/versions/1.1/extension/images/BrainToolGray.png differ diff --git a/versions/1.1/extension/images/BrainTool_Logo_Icon_760_cropped.png b/versions/1.1/extension/images/BrainTool_Logo_Icon_760_cropped.png new file mode 100644 index 0000000..d90d138 Binary files /dev/null and b/versions/1.1/extension/images/BrainTool_Logo_Icon_760_cropped.png differ diff --git a/versions/1.1/extension/images/BrainTool_Logo_Icon_800x800_transparent.png b/versions/1.1/extension/images/BrainTool_Logo_Icon_800x800_transparent.png new file mode 100644 index 0000000..c31df3b Binary files /dev/null and b/versions/1.1/extension/images/BrainTool_Logo_Icon_800x800_transparent.png differ diff --git a/versions/1.1/extension/images/BrainZoom01.png b/versions/1.1/extension/images/BrainZoom01.png new file mode 100644 index 0000000..f48dd7b Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom01.png differ diff --git a/versions/1.1/extension/images/BrainZoom02.png b/versions/1.1/extension/images/BrainZoom02.png new file mode 100644 index 0000000..1b5078a Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom02.png differ diff --git a/versions/1.1/extension/images/BrainZoom03.png b/versions/1.1/extension/images/BrainZoom03.png new file mode 100644 index 0000000..d9217c0 Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom03.png differ diff --git a/versions/1.1/extension/images/BrainZoom04.png b/versions/1.1/extension/images/BrainZoom04.png new file mode 100644 index 0000000..fb2dec7 Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom04.png differ diff --git a/versions/1.1/extension/images/BrainZoom05.png b/versions/1.1/extension/images/BrainZoom05.png new file mode 100644 index 0000000..8afbb09 Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom05.png differ diff --git a/versions/1.1/extension/images/BrainZoom06.png b/versions/1.1/extension/images/BrainZoom06.png new file mode 100644 index 0000000..d055c7a Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom06.png differ diff --git a/versions/1.1/extension/images/BrainZoom07.png b/versions/1.1/extension/images/BrainZoom07.png new file mode 100644 index 0000000..26f0356 Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom07.png differ diff --git a/versions/1.1/extension/images/BrainZoom08.png b/versions/1.1/extension/images/BrainZoom08.png new file mode 100644 index 0000000..a7636db Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom08.png differ diff --git a/versions/1.1/extension/images/BrainZoom09.png b/versions/1.1/extension/images/BrainZoom09.png new file mode 100644 index 0000000..b120c1e Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom09.png differ diff --git a/versions/1.1/extension/images/BrainZoom10.png b/versions/1.1/extension/images/BrainZoom10.png new file mode 100644 index 0000000..b3b3fe5 Binary files /dev/null and b/versions/1.1/extension/images/BrainZoom10.png differ diff --git a/versions/1.1/extension/manifest-edge.json b/versions/1.1/extension/manifest-edge.json new file mode 100644 index 0000000..598143c --- /dev/null +++ b/versions/1.1/extension/manifest-edge.json @@ -0,0 +1,44 @@ +{ + "manifest_version": 2, + "name": "__MSG_appName__", + "description": "__MSG_appDesc__", + "default_locale": "en", + "version": "0.9.5", + "permissions": ["tabs", "storage", "bookmarks"], + //"optional_permissions": ["bookmarks"], + "background": { + "scripts": ["background.js", "config.js"], + "persistent": true + }, + "content_scripts": [ + { + "matches": ["https://BrainTool.org/app/*"], + //"matches": ["http://localhost/app/*"], + "run_at" : "document_idle", + "js": ["btContentScript.js"] + } + ], + "browser_action": { + "default_icon": { + "16": "images/BrainTool16.png", + "32": "images/BrainTool32.png", + "48": "images/BrainTool48.png", + "128": "images/BrainTool128.png" + }, + "default_title" : "BrainTool!", + "default_popup" : "popup.html" + }, + "icons": { + "16": "images/BrainTool16.png", + "32": "images/BrainTool32.png", + "48": "images/BrainTool48.png", + "128": "images/BrainTool128.png" + }, + "commands": { + "_execute_browser_action": { + "suggested_key": { + "default": "Alt+B" + } + } + } +} diff --git a/versions/1.1/extension/manifest.json b/versions/1.1/extension/manifest.json new file mode 100644 index 0000000..b54d6f1 --- /dev/null +++ b/versions/1.1/extension/manifest.json @@ -0,0 +1,41 @@ +{ + "manifest_version": 3, + "name": "__MSG_appName__", + "description": "__MSG_appDesc__", + "default_locale": "en", + "version": "1.1", + "permissions": ["tabs", "storage", "tabGroups", "webNavigation", "bookmarks"], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": ["https://BrainTool.org/versions/*"], + "run_at" : "document_idle", + "js": ["btContentScript.js"] + } + ], + "action": { + "default_icon": { + "16": "images/BrainTool16.png", + "32": "images/BrainTool32.png", + "48": "images/BrainTool48.png", + "128": "images/BrainTool128.png" + }, + "default_title" : "BrainTool!", + "default_popup" : "popup.html" + }, + "icons": { + "16": "images/BrainTool16.png", + "32": "images/BrainTool32.png", + "48": "images/BrainTool48.png", + "128": "images/BrainTool128.png" + }, + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Alt+B" + } + } + } +} diff --git a/versions/1.1/extension/popup.css b/versions/1.1/extension/popup.css new file mode 100644 index 0000000..7206a66 --- /dev/null +++ b/versions/1.1/extension/popup.css @@ -0,0 +1,276 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + +/* First define the basic theme variables */ +:root { + --btFont: Roboto; + --btBackgroundHovered: #3f673f; + --btNoteFontWeight: normal; + --btNoteFontSize: 10px; + --btHintColor: #BCBEC0; + --btPageLineHeight: 12px; + + --btHeaderBackground: linear-gradient(180deg, #F1F1F1 0%, #C3CBCB 52.6%, #C8CFD0 100%); + --btBodyBackground: linear-gradient(180deg, rgba(196, 196, 196, 0.28) 0%, rgba(196, 196, 196, 0.3) 14.58%, rgba(196, 196, 196, 0.1) 100%); + --btTextColor: #0C4F6B; + --btBackground: #bed8be; + + --btHighlightColor: #cec; + --btSelected: #7bb07b; + + --btInputBackground: white; + --btInputForeground: #2d2d2d; + --awesompleteBackground: linear-gradient(to bottom right, white, hsla(0,0%,100%,.8)); + --btSlideTextColor: #0C4F6B; + --btSlideHeaderBackground: linear-gradient(#DFE3E2 0%, #C3CBCB 80.6%, #C8CFD0 100%); + --btTitleFontSize: 16px; + --btTitleFontWeight: 700; +} + +[data-theme="DARK"] { + + --btHeaderBackground:linear-gradient(180deg, #6C7777 0%, #475354 100%); + --btBodyBackground: linear-gradient(180deg, #566564 0%, #394C4B 100%); + --btTextColor: #E0F0F8; + + --btHighlightColor: #aab6b8; + + --btInputBackground: #2d2d2d; + --btInputForeground: white; + --awesompleteBackground: #566564; /*linear-gradient(to bottom, black, hsla(0,0%,0%,.6));*/ + + --btBackground: #bed8be; + +} + + +body { + width: 440px; + min-height:240px; + max-height: 600px; + padding-bottom: 30px; + overflow: hidden; + margin: 0px; + background: white; + + font-family: var(--btFont); + font-style: normal; + text-align: center; + color: var(--btTextColor); +} + +#welcome p { + text-align: left; + margin-left: 15px; + font-size: 13px; + line-height: 20px; + margin-top: 20px; +} + +#openingImage { + width: 150px; + float: right; +} +.allPages { + display: none; +} + +#header { + width: 100%; + height: 40px; + margin: 0px; + background: var(--btHeaderBackground); +} +h1 { + font-weight: 700; + font-size: 16px; + line-height: 19px; + padding-top: 10px; + margin: 0px; +} +h2 { + font-weight: 700; + font-size: 12px; + line-height: 14px; +} +h3 { + font-weight: 400; + font-size: 12px; + line-height: 14px; + margin-bottom: 6px; +} +.tgPages { + display: none; +} + +hr { + border-top: 1px solid #BCC4C5; + width: 380px; + margin-bottom: 4px; +} + +label { + position: relative; + top: -3px; +} +#saveSpan { + float:left; +} +#saveSession { + float: right; +} +#saveAs { + color: #58BA00; +} + +#topicSelector, #saveCheckboxes, #editCard, #buttonDiv { + width: 360px; + margin-left: 40px; +} +input[type=text], textarea { + width: 356px; + border: solid 1px #AEBABC; + border-radius: 2px; + background: var(--btInputBackground); + color: var(--btInputforeground); +} + +#currentTopics { + overflow-y: scroll; + height: 200px; + border: solid 1px #AEBABC; + border-radius: 2px; + background: var(--btInputBackground); +} +#currentTopics p { + margin-top: 0.1rem; + margin-bottom: 0.1rem; + text-overflow: ellipsis; + overflow-x: hidden; + white-space: nowrap; + text-align: left; + width: 100%; +} +#note { + height: 45px; +} + +.hint { + font-family: var(--btFont); + font-style: var(--btNoteFontWeight); + font-weight: var(--btNoteFontWeight); + font-size: var(--btNoteFontSize); + line-height: var(--btPageLineHeight); + color: var(--btHintColor); + z-index: 3; + position: absolute; +} +.hintText { + position: relative; +} +#newNoteHint { + top: 20px; + left: 20px; +} +#newTopicHint { + top: 3px; + left: 20px; +} +#saveHint { + text-align: right; + margin-bottom: -5px; + margin-right: 15px; + font-style: italic; + color: var(--btTextColor); + display: none; +} +.awesomplete { + font-family: var(--btFont); + font-size: 0.9rem; + width: 100%; +} +span.topic { + cursor: pointer; +} +p.highlight { + background: var(--btHighlightColor); +} +p.selected { + background: var(--btSelected); +} + +span.caret { + background-position: left center; + background-repeat: no-repeat; + display: inline-block; + text-decoration: none; + width: 17px; + cursor: pointer; +} + +button { + cursor: pointer; + width: 150px; + height: 30px; + background: #58BA00; + opacity: 0.75; + border-radius: 5px; + border: none; + color: white; + font: var(--btFont); + font-size: 14px; +} +button.activeButton { + opacity: 1.0; +} +#buttonDiv { + margin-top: 12px; + height: 30px; +} +#saveAndClose { + float:left; +} +#saveAndGroup { + float:right; +} + +/* Copied from TopicManager intro slides styling */ +#slideHeader { + background: linear-gradient(#DFE3E2 0%, #C3CBCB 80.6%, #C8CFD0 100%); + height: 70px; +} +#headerImage { + object-fit: contain; + position: relative; + left: -150px; +} +#introTitle { + text-align: center; + width: 100%; + position:relative; + top: -40px; + color: #0C4F6B; + font-weight: var(--btTitleFontWeight); + font-size: var(--btTitleFontSize); +} +#introImage { + object-fit: contain; + margin-right: 20px; +} + +/* open == expanded */ +span.caret.open { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAAAHFJREFUeNpi/P//PwMlgImBQsA44C6gvhfa29v3MzAwOODRc6CystIRbxi0t7fjDJjKykpGYrwwi1hxnLHQ3t7+jIGBQRJJ6HllZaUUKYEYRYBPOB0gBShKwKGA////48VtbW3/8clTnBIH3gCKkzJgAGvBX0dDm0sCAAAAAElFTkSuQmCC); +} + +/* closed */ +span.caret.closed { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAAAHlJREFUeNrcU1sNgDAQ6wgmcAM2MICGGlg1gJnNzWQcvwQGy1j4oUl/7tH0mpwzM7SgQyO+EZAUWh2MkkzSWhJwuRAlHYsJwEwyvs1gABDuzqoJcTw5qxaIJN0bgQRgIjnlmn1heSO5PE6Y2YXe+5Cr5+h++gs12AcAS6FS+7YOsj4AAAAASUVORK5CYII=); +} diff --git a/versions/1.1/extension/popup.html b/versions/1.1/extension/popup.html new file mode 100644 index 0000000..471f8b8 --- /dev/null +++ b/versions/1.1/extension/popup.html @@ -0,0 +1,107 @@ + + + + + + + + + + + + + +
+
+ +
  Welcome to BrainTool 
+
+

+ To get you started I'm going to:
+ - Open the Topic Manager on the left of this window.
+ - Nudge this window right to make sure the Topic Manager is visible.
+ - Open a tab to the Welcome page with new user information.
+

+

+ Opening the BrainTool Topic Manager at its previously saved location... +

+

+ Upgrading to BrainTool .
Restart is required.
Release Notes will open with details after restart.
+

+ + + +
+ + + + + + + + + + diff --git a/versions/1.1/extension/popup.js b/versions/1.1/extension/popup.js new file mode 100644 index 0000000..2b0830a --- /dev/null +++ b/versions/1.1/extension/popup.js @@ -0,0 +1,372 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * This code communicates an install, new version or need to open the Topic Manager + * and otherwise runs the Bookmarker which controls the topic entry for adding a page to BT. + * Trying to keep it minimal. No jQuery etc. + * + ***/ +'use strict'; + +// Utilities to show/hide array of elements +function showElements(elementIds) { + elementIds.forEach(elementId => { + document.getElementById(elementId).style.display = 'block'; + }); +} +function hideElements(elementIds) { + elementIds.forEach(elementId => { + document.getElementById(elementId).style.display = 'none'; + }); +} + +// Popup is launched from scratch on each invocation, So we need to figure out the situation and populate the display appropriately. +// 1. New install => show welcome page +// 2. New version => show upgrade page +// 3. Launch of Topic Manager => show launch splash and open in tab or side panel +// 4. Normal Bookmarker opening => populate based on: +// a. existing BT item => only show note update +// b. new tab => show topic selector and note entry +// c. tab in tg => select to greate new topic or use tg name + +const contextVariables = ['newInstall', 'newVersion', 'Theme', 'BTTab', 'ManagerHome', 'ManagerLocation']; +chrome.storage.local.get(contextVariables, async val => { + console.log(`local storage: ${JSON.stringify(val)}`); + const introTitle = document.getElementById('introTitle'); + + if (val['Theme']) { + // Change theme by setting attr on document which overide a set of vars. see top of .css + document.documentElement.setAttribute('data-theme', val['Theme']); + } + + if (val['newInstall']) { + // This is a new install, show the welcome page + introTitle.textContent = introTitle.textContent + val['newVersion']; + hideElements(['openingMessage', 'upgradeMessage', 'openingImage']); + showElements(['welcomeMessage', 'introImage', 'welcome']); + chrome.storage.local.remove(['newInstall', 'newVersion']); + document.getElementById("okButton").addEventListener('click', e => openTopicManager()); + return; + } + + if (val['newVersion']) { + // Background has received updateAvailable, so inform user and upgrade + document.getElementById('upgradeVersion').textContent = val['newVersion']; + introTitle.textContent = introTitle.textContent + val['newVersion']; + hideElements(['openingMessage', 'welcomeMessage', 'introImage']); + showElements(['upgradeMessage', 'openingImage', 'welcome']); + document.getElementById("okButton").addEventListener('click', e => reloadExtension()); + return; + } + + // Else launching Topic Mgr if its not open, or just normal bookmarker + let topicManagerTab = null; + if (val['BTTab']) { + try { + topicManagerTab = await chrome.tabs.get(val['BTTab']); + } catch (e) { + // tab no longer exists, clear it + chrome.storage.local.remove('BTTab'); + } + } + if (topicManagerTab) { + populateBookmarker(); + return; + } + + // Last case - need to re-open Topic Manager with home and location values + const home = val['ManagerHome'] || 'PANEL'; + const location = val['ManagerLocation']; + + // Show the splash notice for two seconds and open the topic mgr + const version = chrome.runtime.getManifest().version; + introTitle.textContent = introTitle.textContent + version; + hideElements(['welcomeMessage', 'introImage', 'upgradeMessage', 'okButton']); + showElements(['welcome', 'openingMessage']); + setTimeout(() => openTopicManager(home, location), 2000); + return; +}); + +function reloadExtension() { + chrome.tabs.query({title: "BrainTool Topic Manager"}, + (tabs => { + if (tabs.length) chrome.tabs.remove(tabs.map(tab => tab.id)); + chrome.storage.local.remove('newVersion'); + chrome.runtime.reload(); + }) + ); +} + +function openTopicManager(home = 'PANEL', location) { + // Create the BT Topic Manager + // home == tab => create manager in a tab, PANEL => in a side panel, default + // location {top, left, width, height} filled in by bg whenever Topic Manager is resized + + // First check for existing BT Tab eg error condition or after an Extension restart. + // Either way best thing is to kill it and start fresh. + chrome.tabs.query( + {title: "BrainTool Topic Manager"}, + (tabs => {if (tabs.length) chrome.tabs.remove(tabs.map(tab => tab.id));}) + ); + + // const url = "https://BrainTool.org/app/"; + // const url = "http://localhost:8000/app/"; // versions/"+version+"/app/"; + const url = "https://BrainTool.org/versions/"+version+'/app/'; + console.log('loading from ', url); + + // Default open in side panel + if (home != "TAB") { + chrome.windows.getCurrent(async mainwin => { + // create topic manager window where last placed or aligned w current window left/top + const wargs = location ? { + 'url' : url, + 'type' : "panel", + 'state' : "normal", + 'focused' : true, + 'top' : location.top, 'left' : location.left, + 'width' : location.width, 'height' : location.height + } : { + 'url' : url, + 'type' : "panel", + 'state' : "normal", + 'focused' : true, + 'top' : mainwin.top, 'left' : mainwin.left, + 'width' : 500, 'height' : mainwin.height + }; + // shift current win left to accomodate side-panel before creating TM. nb only shift normal windows, its wierd if the window is maximized + if ((!location) && (mainwin.state == 'normal')) + await chrome.windows.update(mainwin.id, {focused: false, left: (mainwin.left + 150)}); + createTopicManagerWindow(wargs); + }); + } else { + // open in tab + console.log('opening in tab'); + chrome.tabs.create({'url': url}); + } +} + +function createTopicManagerWindow(wargs) { + // Open Topic Manager, handle bounds error that happens if Mgr moved off visible screen + chrome.windows.create(wargs, async function(window) { + if (window) { + // for some reason position is not always set correctly, so update it explicitly + await chrome.windows.update(window.id, {'left': wargs.left, 'top': wargs.top, 'width': wargs.width, 'height' : wargs.height, + 'focused': true, 'drawAttention': true}); + console.log('Updated window:', window); + } else { + console.warn('error creating Topic Manager:', chrome.runtime.lastError?.message); + wargs.top = 50; wargs.left = 0; + wargs.width = Math.min(screen.width, wargs.width); + wargs.height = Math.min((screen.height - 50), wargs.height); + console.warn('Adjusting window bounds and re-creating Topic Manager:', wargs); + chrome.windows.create(wargs, async function(window2) { + if (!window2) { + alert('Error creating the TopicManager window. Chrome says:' + chrome.runtime.lastError?.message + '\n Using a tab instead...'); + chrome.tabs.create({'url': wargs.url}); + } + }); + } + }); +} + +// 4. Bookmarker Management from here on + +async function populateBookmarker() { + // Find tab info and open bookmarker + + chrome.runtime.connect(); // tell background popup is open + chrome.tabs.query({currentWindow: true}, list => { + const activeTab = list.find(t => t.active); + openBookmarker(activeTab); + }); +} + + +let Guess, Topics, OldTopic, CurrentTab; + +// Set up button cbs +const SaveAndGroupBtn = document.getElementById("saveAndGroup"); +const SaveAndCloseBtn = document.getElementById("saveAndClose"); +SaveAndGroupBtn.addEventListener('click', () => saveCB(false)); +SaveAndCloseBtn.addEventListener('click', () => saveCB(true)); + +// Logic for saveTab/saveAllTabs toggle +const SaveTab = document.getElementById("saveTab"); +const SaveWindow = document.getElementById("saveWindow"); +const SaveTG = document.getElementById("saveTG"); +const SaveSession = document.getElementById("saveAllSession"); + +SaveTab.addEventListener('change', e => { + if (SaveTab.checked) {SaveWindow.checked = false; SaveSession.checked = false; SaveTG.checked = false;} + else SaveWindow.checked = true; + updateForSelection(); +}); +SaveWindow.addEventListener('change', e => { + if (SaveWindow.checked) {SaveTab.checked = false; SaveSession.checked = false;} + else SaveTab.checked = true; + updateForSelection(); +}); +SaveTG.addEventListener('change', e => { + if (SaveTG.checked) {SaveTab.checked = false; SaveSession.checked = false;} + else SaveTab.checked = true; + updateForSelection(); +}); +SaveSession.addEventListener('change', e => { + if (SaveSession.checked) {SaveTab.checked = false; SaveWindow.checked = false; SaveTG.checked = false;} + else SaveTab.checked = true; + updateForSelection(); +}); +function updateForSelection() { + // handle AllPages toggle + const onePageElements = document.getElementsByClassName("onePage"); + const allPageElements = document.getElementsByClassName("allPages"); + const tgElements = document.getElementsByClassName("tgPages"); + if (SaveWindow.checked || SaveSession.checked) { + Array.from(onePageElements).forEach(e => e.style.display = "none"); + Array.from(tgElements).forEach(e => e.style.display = "none"); + Array.from(allPageElements).forEach(e => e.style.display = "block"); + TopicSelector.clearGuess(); + } + if (SaveTG.checked) { + Array.from(onePageElements).forEach(e => e.style.display = "none"); + Array.from(allPageElements).forEach(e => e.style.display = "none"); + Array.from(tgElements).forEach(e => e.style.display = "block"); + TopicSelector.clearGuess(); + } + if (SaveTab.checked) { + Array.from(tgElements).forEach(e => e.style.display = "none"); + Array.from(allPageElements).forEach(e => e.style.display = "none"); + Array.from(onePageElements).forEach(e => e.style.display = "block"); + TopicSelector.setGuess(Guess); + } +} + +/* for use escaping unicode in for topic name below */ +const _textAreaForConversion = document.createElement('textarea'); +function _decodeHtmlEntities(str) { + _textAreaForConversion.innerHTML = str; + return _textAreaForConversion.value; +} +async function openBookmarker(tab) { + // Get data from storage and launch popup w card editor, either existing node or new, or existing but navigated + CurrentTab = tab; + const tg = (tab.groupId > 0) ? await chrome.tabGroups.get(tab.groupId) : null; + const saverDiv = document.getElementById("saver"); + const titleH2 = document.getElementById('title'); + const saveTGSpan = document.getElementById('saveTGSpan'); + const saveTG = document.getElementById('saveTG'); + const saveTab = document.getElementById('saveTab'); + const saveWindow = document.getElementById('saveWindowSpan'); + const saveAs = document.getElementById('saveAs'); + + document.getElementById('welcome').style.display = 'none'; + saverDiv.style.display = 'block'; + saveAs.style.display = 'none'; + if (tg) { + // tab is part of a TG => set the saveTg checkbox to be checked + document.getElementById('tgName').textContent = tg.title; + saveWindow.style.display = 'none'; + saveTG.checked = true; + saveTab.checked = false; + } else { + saveTGSpan.style.display = 'none'; + } + + // Pull data from local storage, prepopulate and open Bookmarker + chrome.storage.local.get( + ['topics', 'currentTabId', 'currentTopic', 'currentText', 'tabNavigated', + 'currentTitle', 'mruTopics', 'saveAndClose'], + data => { + let title = (tab.title.length < 150) ? tab.title : + tab.title.substr(0, 150) + "..."; + titleH2.textContent = title; + + if (!data.saveAndClose) { + // set up save and group as default + SaveAndGroupBtn.classList.add("activeButton"); + SaveAndCloseBtn.classList.remove("activeButton"); + } + + // BT Page => just open card + if (data.currentTopic && data.currentTabId && (data.currentTabId == tab.id)) { + OldTopic = data.currentTopic; + document.getElementById('topicSelector').style.display = 'none'; + document.getElementById('saveCheckboxes').style.display = 'none'; + TopicCard.setupExisting(tab, data.currentText, + data.currentTitle, data.tabNavigated, saveCB); + return; + } + + // New page. create topic list (handling unicode), guess at topic and open card + Topics = data.topics.map(topic => ({ + ...topic, + name: _decodeHtmlEntities(topic.name) + })); + if (data.mruTopics) { + // pre-fill to mru topic for window + const windowId = tab.windowId; + Guess = data.mruTopics[windowId] || ''; + } + TopicSelector.setup(Guess, Topics, topicSelected); + TopicCard.setupNew(tab.title, tab, cardCompleted); + updateForSelection(); + }); +} + +function topicSelected() { + // CB from topic selector, highlight note text + document.querySelector('#note').focus(); +} +function cardCompleted() { + // CB from enter in notes field of card + const close = (SaveAndCloseBtn.classList.contains('activeButton')) ? true : false; + saveCB(close); +} + +async function saveCB(close) { + // Call out to background to do the save + const title = TopicCard.title(); + const note = TopicCard.note(); + const newTopic = OldTopic || TopicSelector.topic(); + const saverDiv = document.getElementById("saveCheckboxes"); + let saveType; + + // Is the savetype selector is hidden we're editng a single tab. Otherwise use the selector + if (window.getComputedStyle(saverDiv).display == 'none') saveType = 'Tab'; + else saveType = SaveTab.checked ? 'Tab' : (SaveTG.checked ? 'TG' : (SaveWindow.checked ? 'Window' : 'Session')); + + if ((saveType == 'Tab') && (CurrentTab.pinned)) { + // We don't handle pinned tabs, so alert and return + alert('BrainTool does not handle pinned tabs. Unpin the tab and try again.'); + } else { + await chrome.runtime.sendMessage({'from': 'popup', 'function': 'saveTabs', 'type': saveType, 'currentWindowId': CurrentTab.windowId, + 'close': close, 'topic': newTopic, 'note': note, 'title': title}); + if (!close) // if tab isn't closing animate the brain + await chrome.runtime.sendMessage({'from': 'popup', 'function': 'brainZoom', 'tabId': CurrentTab.id}); + await chrome.storage.local.set({'saveAndClose': close}); + } + window.close(); +} + +// Listen for messages from other components. Currently just to know to close BT popup. +chrome.runtime.onMessage.addListener((msg, sender) => { + switch (msg.from) { + case 'btwindow': + if (msg.function == 'initializeExtension') { + console.log("BT window is ready"); + window.close(); + } + break; + } + console.count("IN:"+msg.type); +}); diff --git a/versions/1.1/extension/resources/close.png b/versions/1.1/extension/resources/close.png new file mode 100644 index 0000000..785f145 Binary files /dev/null and b/versions/1.1/extension/resources/close.png differ diff --git a/versions/1.1/extension/resources/headerImage.png b/versions/1.1/extension/resources/headerImage.png new file mode 100644 index 0000000..bde472c Binary files /dev/null and b/versions/1.1/extension/resources/headerImage.png differ diff --git a/versions/1.1/extension/resources/intro.png b/versions/1.1/extension/resources/intro.png new file mode 100644 index 0000000..04e20af Binary files /dev/null and b/versions/1.1/extension/resources/intro.png differ diff --git a/versions/1.1/extension/resources/opening.png b/versions/1.1/extension/resources/opening.png new file mode 100644 index 0000000..8abc7c4 Binary files /dev/null and b/versions/1.1/extension/resources/opening.png differ diff --git a/versions/1.1/extension/topicCard.js b/versions/1.1/extension/topicCard.js new file mode 100644 index 0000000..e335a2f --- /dev/null +++ b/versions/1.1/extension/topicCard.js @@ -0,0 +1,86 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * This code runs under the popup and controls the topic card + * Trying to keep it minimal. No jQuery etc. + * + ***/ +'use strict'; + +const TopicCard = (() => { + const CardElt = document.getElementById("editCard"); + const NoteElt = document.querySelector('#note'); + const TitleElt = document.querySelector('#titleInput'); + const NoteHint = document.getElementById("newNoteHint"); + const SaveAs = document.getElementById('saveAs'); + let SaveCB; + let ExistingCard = false; + + function setupExisting(tab, note, title, tabNavigated, saveCB) { + // entry point when existing page is selected. + + TitleElt.value = tab.title; //title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); // value, cos its a text input + if (tabNavigated) { + NoteElt.value = ""; + SaveAs.style.display = "block"; + NoteHint.style.display = "block"; // show hint + } else { + if (note) NoteElt.value = note; + SaveAs.style.display = "none"; + } + ExistingCard = true; + + SaveCB = saveCB; + NoteHint.style.display = "none"; // hide hint + + NoteElt.classList.remove("inactive"); + NoteElt.focus(); + NoteElt.setSelectionRange(NoteElt.value.length, NoteElt.value.length); + + } + + function setupNew(title, tab, saveCB) { + // entry point for new page + + TitleElt.value = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); // value, cos its a text input + SaveCB = saveCB; + NoteHint.addEventListener('click', (e) => NoteElt.focus()); + SaveAs.style.display = "none"; + } + + NoteElt.onkeydown = function(e) { + // check for any key to clear + if (e.key == 'Tab') return; + NoteHint.style.display = "none"; // hide hint + }; + NoteElt.onkeyup = function(e) { + // key up, enter == save + if (e.key == "Enter" && !ExistingCard) { + done(); + } + }; + + function done() { + // one of the close buttons + SaveCB(); + } + function title() { return TitleElt.value;} + function note() {return NoteElt.value.replace(/\s+$/g, '');} // remove trailing newlines + + return { + title: title, + note: note, + setupExisting: setupExisting, + setupNew: setupNew + }; +})(); diff --git a/versions/1.1/extension/topicSelector.js b/versions/1.1/extension/topicSelector.js new file mode 100644 index 0000000..f54808c --- /dev/null +++ b/versions/1.1/extension/topicSelector.js @@ -0,0 +1,236 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * This code runs under the popup and controls the topic seclection for adding a page to BT. + * Trying to keep it minimal. No jQuery etc. + * + ***/ +'use strict'; + +const TopicSelector = (() => { + const TopicsElt = document.getElementById('currentTopics'); + const TopicElt = document.getElementById('topic'); + const SelectorElt = document.getElementById("topicSelector"); + const CardElt = document.getElementById("topicCard"); + const ButtonDiv = SelectorElt.querySelector('#buttonDiv'); + const TopicHint = document.getElementById("newTopicHint"); + let AwesomeWidget; + let Guessed = false; // capture whether the topic was guessed + let KeyCount = 0; + let SelectionCB; + + function setGuess(guess) { + // set the guess for the topic + AwesomeWidget.open(); + TopicElt.value = guess || ""; + Guessed = true; + TopicElt.select(); + TopicHint.style.display = "none"; // hide hint + } + function clearGuess() { + // clear the guess for the topic + TopicElt.value = ""; + Guessed = false; + TopicHint.style.display = "block"; // show hint + AwesomeWidget.close(); + } + + function setup (guess, topicsMap, selectionCB) { + // configure topic selector display. main entry point to component + TopicsElt.innerHTML = generateTopicMap(topicsMap); + const topics = topicsMap.map(t => t.name); + AwesomeWidget = new Awesomplete(TopicElt, { + list: topics, autoFirst: true, tabSelect: true, sort: false + }); + TopicElt.addEventListener('awesomplete-highlight', updateSelection); + TopicElt.addEventListener('awesomplete-close', widgetClose); + TopicElt.addEventListener('keydown', handleTopicKeyDown); + TopicElt.addEventListener('keyup', handleTopicKeyUp); + TopicHint.addEventListener('click', (e) => TopicElt.focus()); + SelectionCB = selectionCB; // save for later + guess ? setGuess(guess) : clearGuess(); + + const topicElts = document.querySelectorAll('.topic'); + topicElts.forEach(elt => elt.addEventListener('click', e => selectTopic(e))); + + const caretElts = document.querySelectorAll('.caret.closed'); + caretElts.forEach(elt => elt.addEventListener('click', e => toggleOpen(e))); + + SelectorElt.style.display = 'block'; + TopicElt.focus(); + } + + function generateTopicMap(topicsArray) { + // given array of {name:"topic", level:2} generate the display string (name "topic1:topic2" allowed) + const openCaret = ` `; + const closedCaret = ` `; + const noCaret = `     `; + let str = ""; + let index = 1; + let fullPath = []; // keep track of parentage + topicsArray.forEach(topic => { + const level = topic.level; + const name = topic.name; + const visible = (level > 2) ? "display:none;" : ""; + const bg = (level == 1) ? "lightgrey" : ""; + const nextTopic = topicsArray[index++]; + const nextTopicLevel = nextTopic?.level || 0; + fullPath[level] = name; + const dn = fullPath.slice(1, level+1).join(':'); + let caret; + // top level always opencaret, >2 no caret, ==2 no caret if no kids + if (level > 2) + caret = noCaret; + else if (level == 1) + caret = openCaret; + else if (nextTopicLevel <= level) + caret = noCaret; + else + caret = closedCaret; + str += `

${caret}${name}

`; + }); + return str; + } + + function updateSelection(h) { + // called on highlight event, shows corresponding item in table as selected + // nb see keyUp event below for highlighting of all matches + const selection = h?.text?.value || ""; + document.querySelectorAll("p").forEach(function(elt) { + if (elt.textContent.trim() == selection) + elt.classList.add('selected'); + else + elt.classList.remove('selected'); + }); + }; + function widgetClose(e) { + // widget closed maybe cos no matches + const nomatch = (e?.reason == 'nomatches'); + if (nomatch) + document.querySelectorAll("p").forEach(function(elt) { + elt.classList.remove('selected'); + }); + } + + function selectTopic(e) { + // select this row based on click. copy into widget and trigger appropriate events + const topic = e.target; + const text = topic.textContent; + TopicElt && (TopicElt.value = text); + AwesomeWidget.evaluate(); + // might be >1 item matching, find right one. + let index = 0; + while (AwesomeWidget.suggestions[index] && (AwesomeWidget.suggestions[index]?.value != text)) index++; + AwesomeWidget.goto(index); + AwesomeWidget.select(); + TopicHint.style.display = "none"; // hide hint + SelectionCB(); // done + } + + function toggleOpen(e) { + + // click event on caret to open/close subtree. NB only for level 2 topics + const caret = e.target; + const topicSpan = caret.nextSibling; + const open = caret.classList.contains('open'); + const pRow = topicSpan.parentElement; + const level = pRow.getAttribute("level"); + + let nRow = pRow; + while ((nRow = nRow.nextSibling) && (parseInt(nRow.getAttribute("level")) > level)) + nRow.style.display = open ? "none" : "block"; + + if (open) { + caret.classList.remove('open'); + caret.classList.add('closed'); + } else { + caret.classList.remove('closed'); + caret.classList.add('open'); + } + } + + function handleTopicKeyDown(e) { + // special key handling and resetting that selection not yet made + if (e.key == ":") { + AwesomeWidget.select(); + return; + } + if (e.key == "Enter") { + AwesomeWidget.select(); + return; + } + } + + function handleTopicKeyUp(e) { + // update table as user types to show option and its parents, or be done + + if (e.key == "Enter") { + SelectionCB(); + return; + } + + TopicHint.style.display = "none"; // hide hint + const exposeLevel = 2; + document.querySelectorAll("p").forEach(function(elt) { + if (elt.classList.contains("highlight")) + elt.classList.remove("highlight"); + if (elt.getAttribute("level") && parseInt(elt.getAttribute("level")) > exposeLevel) + elt.style.display = "none"; + else + elt.style.display = "block"; + }); + + if (!AwesomeWidget.isOpened) return; + + // We previously set a default if window already has a topic. Make it easy to delete. + // NB 2 keys cos if popup is opened via keypress it counts, opened via click does not! + if (Guessed && (KeyCount < 2) && (e.key == "Backspace")) { + TopicElt.value = ""; + AwesomeWidget.evaluate(); + return; + } + KeyCount++; + + // highlight suggestions in table + const suggestions = AwesomeWidget.suggestions || []; + suggestions.forEach(function(sug) { + const elt = document.getElementById(sug.value); + if (!elt) return; + elt.classList.add("highlight"); + elt.style.display = "block"; + if (parseInt(elt.getAttribute("level")) > exposeLevel) + showParent(elt); + }); + } + + function showParent(elt) { + // show parent Topic to elts, recurse until top ie level = 1 + const level = parseInt(elt.getAttribute("level")); + let prev = elt; + // walk up tree to elt w lower level (=> parent) + while (prev && parseInt(prev.getAttribute("level")) >= level) + prev = prev.previousSibling; + prev.style.display = "block"; + // Keep going until up to level 2 + if (parseInt(prev.getAttribute("level")) > 1) + showParent(prev); + } + + function topic() {return TopicElt.value;} + return { + setup: setup, + topic: topic, + setGuess: setGuess, + clearGuess: clearGuess + } +})(); diff --git a/versions/1.1/utilities/converters.js b/versions/1.1/utilities/converters.js new file mode 100644 index 0000000..8c5026c --- /dev/null +++ b/versions/1.1/utilities/converters.js @@ -0,0 +1,93 @@ +/*** + * + * Copyright (c) 2019-2024 Tony Confrey, DataFoundries LLC + * + * This file is part of the BrainTool browser manager extension, open source licensed under the GNU AGPL license. + * See the LICENSE file contained with this project. + * + ***/ + + + +/*** + * + * Conversion utilities. Currently just from TabsOutliner json format + * Used by BT import or in conjunction with associated converters.md file + * + ***/ + + +function getDateString(googleTimestamp = null) { + // return minimal date representation to append to bookmark tag, optionally work on TS from google + const d = googleTimestamp ? new Date(googleTimestamp) : new Date(); + const mins = d.getMinutes() < 10 ? "0"+d.getMinutes() : d.getMinutes(); + return (`${d.getMonth()+1}/${d.getDate()}/${d.getYear()-100} ${d.getHours()}:${mins}`); +} + + +function tabsToBT(tabsStr) { + // take a TO export str and output a BT orgmode equivalent + + let tabsJson; + try { + tabsJson = JSON.parse(tabsStr); + } + catch (e) { + alert("Error parsing TabsOutliner malformed json"); + throw(e); + } + const lastIndex = tabsJson.length - 1; + let node, title, numwin = 1, numgroup = 1, numnote = 1, numsep = 1; + // Don't need the extra layer of hierarchy since the fle name will be used as the top node: + //let BTText = "* TabsOutliner Import - " + getDateString().replace(':', '∷') + "\n"; + let BTText = ""; + tabsJson.forEach((elt, ind) => { + // ignore first and last elements, TO seems to use them for some special purpose + if (!ind || ind == lastIndex) return; + const info = elt[1]; + const nesting = elt[2]; + // Handle window/container type elements + if (info.type && (info.type == 'win' || info.type == 'savedwin')) { + node = '*'.repeat(nesting.length); + title = (info.marks && info.marks.customTitle) ? + info.marks.customTitle : 'Window'+numwin++; + node += ` ${title}\n`; + BTText += node; + } + // Handle tab/link type elements + if (info.data && info.data.url) { + // Create org header row + node = '*'.repeat(nesting.length); + title = info.data.title || 'Title'; + node += ` [[${info.data.url}][${title}]]\n`; + // Add note if any - its stored in marks.customTitle + if (info?.marks?.customTitle) node+= `${info.marks.customTitle}\n`; + BTText += node; + } + // Handle group type elements + if (info.type && info.type == 'group') { + node = '*'.repeat(nesting.length); + title = (info.marks && info.marks.customTitle) ? + info.marks.customTitle : 'Group'+numgroup++; + node += ` ${title}\n`; + BTText += node; + } + // Handle notes type elements + if (info.type && info.type == 'textnote') { + node = '*'.repeat(nesting.length); + title = (info.data && info.data.note) ? + info.data.note : 'Note'+numnote++; + node += ` ${title}\n`; + BTText += node; + } + // Handle seperator type elements + if (info.type && info.type == 'separatorline') { + node = '*'.repeat(nesting.length); + title = 'Separator'+numsep++; + node += ` ${title}\n--------------------------------\n`; + BTText += node; + } + }); + return BTText; +} + diff --git a/versions/1.1/utilities/converters.md b/versions/1.1/utilities/converters.md new file mode 100644 index 0000000..1043b3e --- /dev/null +++ b/versions/1.1/utilities/converters.md @@ -0,0 +1,32 @@ +--- +title: BrainTool Converter Utilities +description: Convery other formats to BrainTool org-mode markup +layout: default +audience: nonuser +--- + +# Tabs Outliner to [OrgMode](https://orgmode.org) conversion +Tabs Outliner import is now supported directly from inside BrainTool, see Import under Options. This tool takes a Tabs Outliner export and converts it to org-mode format (as used by BrainTool and many other productivity apps) for use elsewhere. + +Export your data from Tabs Outliner and save the file somewhere locally. Then select it using the file chooser below. The resulting org-mode formatted data can then be copied into any text file. + + + +
+ + + + + diff --git a/versions/1.1/utilities/keyTester.html b/versions/1.1/utilities/keyTester.html new file mode 100644 index 0000000..0d6f0c7 --- /dev/null +++ b/versions/1.1/utilities/keyTester.html @@ -0,0 +1,28 @@ + + + + + + + + + + +
KeyDownKeyPressKeyUp
+ + +