├── README.md └── src ├── LICENSE ├── background.js ├── content.js ├── css ├── app.css ├── bootstrap │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ ├── flags.css │ └── flags.png ├── fonts │ └── Inkfree.ttf └── open-iconic │ ├── FONT-LICENSE │ ├── ICON-LICENSE │ ├── README.md │ └── font │ ├── css │ └── open-iconic-bootstrap.min.css │ └── fonts │ ├── open-iconic.eot │ ├── open-iconic.otf │ ├── open-iconic.svg │ ├── open-iconic.ttf │ └── open-iconic.woff ├── fonts └── Inkfree.ttf ├── images ├── PinExtension.png ├── favicon.ico ├── ghpathelp.png ├── ghpathelp_videos.png ├── icon-128.png ├── icon-16.png ├── icon-32.png ├── icon-48.png ├── noprofilepic.png ├── possum800.png ├── upgrade-from-104.png └── upgrade-from-107.png ├── index.html ├── index.js ├── jswasm ├── sqlite3-opfs-async-proxy.js ├── sqlite3-worker1-promiser.js ├── sqlite3-worker1.js ├── sqlite3.js └── sqlite3.wasm ├── lib ├── apis │ └── mastodonlib.js ├── bootstrap-icons │ ├── bootstrap-icons.css │ └── fonts │ │ ├── bootstrap-icons.woff │ │ └── bootstrap-icons.woff2 ├── content │ ├── embvideo │ │ └── squiddy.js │ ├── nitter │ │ ├── nitterparsing.js │ │ ├── nittertweetparsing.js │ │ └── nittertweetsrecorder.js │ ├── recorderfactory.js │ ├── recordinglib.js │ └── twitter │ │ ├── twitterfollowparsing.js │ │ ├── twitterfollowrecorder.js │ │ ├── twitterparsing.js │ │ ├── twittertweetparsing.js │ │ └── twittertweetsrecorder.js ├── dbui │ ├── favoritingui.js │ ├── ghbackupui.js │ ├── ghconfigui.js │ ├── ghrestoreui.js │ ├── ghvideoupui.js │ ├── logui.js │ ├── mdonui.js │ ├── queryingui.js │ ├── queryworkui.js │ ├── renderbindui.js │ ├── renderlib.js │ ├── settingsui.js │ ├── tabsui.js │ ├── tagginglib.js │ └── topiclib.js ├── shared │ ├── appgraphs.js │ ├── appschema.js │ ├── constants.js │ ├── cryptolib.js │ ├── datatypes.js │ ├── emojilib.js │ ├── es6lib.js │ ├── githublib.js │ ├── imagelib.js │ ├── pagetypes.js │ ├── purify.min.js │ ├── pushmerge │ │ ├── networkfollowerspushmerger.js │ │ ├── networkfollowingspushmerger.js │ │ ├── postimgspushmerger.js │ │ ├── postspushmerger.js │ │ ├── posttopicratingspushmerger.js │ │ ├── profilefavoritespushmerger.js │ │ ├── profileimgspushmerger.js │ │ ├── profilespushmerger.js │ │ ├── pushmergehelper.js │ │ └── pushmergerfactory.js │ ├── queue.js │ ├── settingslib.js │ ├── strlib.js │ ├── syncflow.js │ └── urlparsing.js └── worker │ ├── connfetcher.js │ ├── dbormlib.js │ ├── dbsyncsaver.js │ ├── mastodonconnsavemapper.js │ ├── postfetcher.js │ ├── savemapperfactory.js │ ├── syncimport │ ├── importmapperfactory.js │ ├── networkimportmapper.js │ ├── postimgsimportmapper.js │ ├── postsimportmapper.js │ ├── posttopicratingsimportmapper.js │ ├── profilefavoritesimportmapper.js │ ├── profileimgsimportmapper.js │ └── profilesimportmapper.js │ ├── tweetsavemapper.js │ ├── twitterconnsavemapper.js │ └── twitterprofilesavemapper.js ├── manifest.json ├── popup.html ├── popup.js ├── welcome.html ├── whatsnew105.html ├── whatsnew108.html └── worker.js /README.md: -------------------------------------------------------------------------------- 1 | # Whosum Social Assistant + Chrome Extension 2 | 3 | This is a free open source application to help you better engage with your social network relationships. Think of it as a DVR for caching online social networks and experiences, starting with Twitter. 4 | 5 | Install it at the Chrome app store [from here](https://whosum.com/assistant) or use in developer mode [like this](https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/#load-unpacked). 6 | 7 | *** IMPORTANT *** The video [here](https://whosum.com/assistant) provides an important installation tip on how to pin the toolbar button. (Otherwise, nothing happens.) 8 | 9 | Development is led by [Positive Sum Networks](https://positivesum.net), a New York based non-profit. PSN, together with the open source community, is building a suite of apps and services under the name [Whosum](https://whosum.com). Per the Whosum launch video 10 | >"Who you are and who you know. We hold these WHOs to be self-evident as your property and your source of power." 11 | 12 | After installing, go to Chrome -> More Tools -> Extensions and click Details. Then turn it on. You should see an icon with '+Σ'. Click it to get started: 13 | - NEW: Record, search, tag, and rate Tweets! 14 | - Record lists of Twitter followers/following 15 | - Review and search within follow lists to find Mastodon profile links and other helpful data. 16 | 17 | Curious about scraping web data? [Here](cpomagazine.com/data-privacy/what-the-hiq-vs-linkedin-case-means-for-automated-web-scraping/) is a good article about the HIQ v. LinkedIn case. 18 | 19 | Twitter is just the beginning. Our roadmap calls for helping individuals to pull network data from the other major social sites. Data is stored within a private database right there in your browser (not on a server). How cool is that! (Optional sync features coming soon.) 20 | 21 | We encourage developers to submit pull requests. We also look forward to collaborating with for-profit companies who share our vision of creating "digital robot butler" apps that *privately* leverage personal data as described [here](https://scafaria.com/planning-the-human-centric-web-1bcd2b275a81). PSN aims to create the patterns and infrastructure that will allow [new economic models](https://prosocialcapitalism.com) to thrive, free from surveillance capitalism and manipulative business practices. 22 | 23 | We invite you to also check out the Whosum ["possum passport"](https://whosum.com/prove) feature for portable identity ("who you are"). Together, these tools will allow decentralized and prosocial networking alternatives that extend experiences like Mastodon (which we also recommend!). 24 | 25 | >>>>>>>>>>>>>> 26 | 27 | Developer notes for converting to Firefox (SQLite OPFS is not yet supported by FF). Manifest requires: 28 | 29 | "background": { 30 | "scripts": ["background.js"] 31 | }, 32 | "browser_specific_settings": { 33 | "gecko": { 34 | "id": "whosumsupport@positivesum.net", 35 | "strict_min_version": "109.0" 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Positive Sum Networks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | var _lastSetBadgeText; 2 | var _badgeSetCounter = 0; 3 | 4 | /**************************/ 5 | // ON-INSTALL 6 | /**************************/ 7 | // todo: consider using 'module' approach that allows background to include library scripts 8 | // stackoverflow.com/questions/2399389/detect-chrome-extension-first-run-update 9 | chrome.runtime.onInstalled.addListener((details) => { 10 | const currentVersion = chrome.runtime.getManifest().version; 11 | const previousVersion = details.previousVersion; 12 | const reason = details.reason; 13 | 14 | if (previousVersion != currentVersion) { 15 | console.log(`Previous Version: ${previousVersion }`); 16 | console.log(`Current Version: ${currentVersion }`); 17 | } 18 | 19 | switch (reason) { 20 | case 'install': 21 | console.log('New User installed the extension.'); 22 | chrome.tabs.create({ url: 'welcome.html' }); 23 | break; 24 | case 'update': 25 | if (previousVersion != currentVersion) { 26 | console.log('User has updated their extension.'); 27 | if (previousVersion === "1.0.4") { 28 | chrome.tabs.create({ url: 'whatsnew105.html' }); 29 | } 30 | else if (previousVersion === "1.0.7") { 31 | chrome.tabs.create({ url: 'whatsnew108.html' }); 32 | } 33 | } 34 | break; 35 | case 'chrome_update': 36 | case 'shared_module_update': 37 | default: 38 | console.log('Other install events within the browser'); 39 | break; 40 | } 41 | }); 42 | 43 | /**************************/ 44 | // LISTEN FOR MESSAGES 45 | /**************************/ 46 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 47 | // stackoverflow.com/a/73836810 48 | let returnsData = false; 49 | switch (request.actionType) { 50 | case 'save': 51 | returnsData = true; 52 | break; 53 | default: 54 | break; 55 | } 56 | 57 | (async () => { 58 | switch (request.actionType) { 59 | case 'save': 60 | const saveResponse = await processSave(request); 61 | sendResponse(saveResponse); 62 | return returnsData; 63 | case 'setBadge': 64 | fancySetBadge(request.badgeText); 65 | return returnsData; 66 | case 'logMe': 67 | console.log(request.data); 68 | return returnsData; 69 | case 'savedThreadToBg': 70 | await onSavedThread(request.threadUrlKeys); 71 | return returnsData; 72 | case 'foundPartialThread': 73 | await saveThreadExpansionUrlKey(request.threadUrlKey); 74 | return returnsData; 75 | case 'foundEmbeddedVideo': 76 | await saveEmbeddedVideoUrlKey(request.urlKey); 77 | return returnsData; 78 | default: 79 | return returnsData; 80 | } 81 | })(); 82 | return returnsData; 83 | }); 84 | 85 | /**************************/ 86 | // SAVING 87 | /**************************/ 88 | 89 | const processSave = async function(request) { 90 | const records = request.payload; 91 | await injectImageBase64s(records); 92 | 93 | await saveToTempStorage(records); 94 | return {saved: records, success: true}; 95 | } 96 | 97 | // TODO: Parallel tasks like one of these 98 | // bytelimes.com/batch-async-tasks-with-async-generators/ 99 | // stackoverflow.com/questions/35612428/call-async-await-functions-in-parallel 100 | const injectImageBase64s = async function(records) { 101 | for (let i = 0; i < records.length; i++) { 102 | let item = records[i]; 103 | if (!item.skipImg64) { 104 | // a) the first way we can inject is simplest (by convention, imgCdnUrl gets an img64Url companion property) 105 | if (item.imgCdnUrl) { 106 | item.img64Url = await getImageBase64(item.imgCdnUrl); 107 | } 108 | 109 | // b1) though I'd rather not have knowledge here of quoteTweet.postImgs (domain-specific), 110 | // background.js isn't using modules yet, so... 111 | let imgInfos = []; 112 | if (item.quoteTweet && item.quoteTweet.postImgs && item.quoteTweet.postImgs.length > 0) { 113 | imgInfos.push(...item.quoteTweet.postImgs); 114 | } 115 | // b2) the other way we can inject is setting the img64Url companion property from RECORDING.infuseImgCdns 116 | if (item.imgInfos && item.imgInfos.length > 0) { 117 | imgInfos.push(...item.imgInfos); 118 | } 119 | 120 | for (let j = 0; j < imgInfos.length; j++) { 121 | let imgInfo = imgInfos[j]; 122 | if (imgInfo.imgCdnUrl) { 123 | imgInfo.img64Url = await getImageBase64(imgInfo.imgCdnUrl); 124 | } 125 | } 126 | } 127 | } 128 | } 129 | 130 | // caches what we'll want to persist to the sqlitedb when we get the chance 131 | const saveToTempStorage = async function(records) { 132 | // the 'fordb-' prefix is how we find all such pending batches (see STORAGE_PREFIX.FOR_DB) 133 | const key = `fordb-${Date.now().toString()}`; 134 | await chrome.storage.local.set({ [key]: records }); 135 | } 136 | 137 | /**************************/ 138 | // BG IMAGE FETCH 139 | /**************************/ 140 | 141 | // stackoverflow.com/questions/57346889/how-to-return-base64-data-from-a-fetch-promise 142 | const getImageBase64 = async function(url) { 143 | try { 144 | const response = await fetch(url); 145 | const blob = await response.blob(); 146 | const reader = new FileReader(); 147 | await new Promise((resolve, reject) => { 148 | reader.onload = resolve; 149 | reader.onerror = reject; 150 | reader.readAsDataURL(blob); 151 | }); 152 | return reader.result.replace(/^data:.+;base64,/, '') 153 | } 154 | catch(err) { 155 | console.log(`img64err: ${err}`); 156 | return undefined; 157 | } 158 | } 159 | 160 | /**************************/ 161 | // EMBEDDED VIDEOS 162 | /**************************/ 163 | 164 | const saveEmbeddedVideoUrlKey = async function(urlKey) { 165 | const EMBEDDED_VIDEO_URLKEY = 'embVideo-'; 166 | if (urlKey) { 167 | const key = `${EMBEDDED_VIDEO_URLKEY}${urlKey}`; 168 | await chrome.storage.local.set({ [key]: urlKey }); 169 | } 170 | } 171 | 172 | /**************************/ 173 | // THREADS 174 | /**************************/ 175 | 176 | // a clone of SETTINGs.RECORDING.removeThreadExpansionUrlKey 177 | // until we use "modules" approach to background though, can't stay DRY 178 | const saveThreadExpansionUrlKey = async function(threadUrlKey) { 179 | const THREAD_EXPANSION_URLKEY = 'threadMore-'; 180 | if (threadUrlKey) { 181 | const key = `${THREAD_EXPANSION_URLKEY}${threadUrlKey}`; 182 | await chrome.storage.local.set({ [key]: threadUrlKey }); 183 | } 184 | } 185 | 186 | // a clone of SETTINGs.RECORDING.removeThreadExpansionUrlKey 187 | // until we use "modules" approach to background though, can't stay DRY 188 | const onSavedThread = async function(threadUrlKeys) { 189 | 190 | for (let i = 0; i < threadUrlKeys.length; i++) { 191 | let threadUrlKey = threadUrlKeys[i]; 192 | // this gives the user time to plunk around with the page 193 | // i.e. in case they're scrolling through it etc. while reviewing threads to expand 194 | // (and where not already recording manually) 195 | // this is part of the popup's threaa expansion flow 196 | setTimeout(async () => { 197 | const THREAD_EXPANSION_URLKEY = 'threadMore-'; 198 | const key = `${THREAD_EXPANSION_URLKEY}${threadUrlKey}`; 199 | await chrome.storage.local.remove(key); 200 | }, 10000); 201 | } 202 | } 203 | 204 | /**************************/ 205 | // MISC 206 | /**************************/ 207 | 208 | const fancySetBadge = function(text) { 209 | chrome.action.setBadgeText({text: text}); 210 | 211 | _badgeSetCounter++; 212 | const isEven = (_badgeSetCounter % 2) == 0; 213 | // toggle color 214 | if (isEven == true || text == 'DONE') { 215 | chrome.action.setBadgeBackgroundColor({color: "#1A73E8" }); 216 | } 217 | else { 218 | chrome.action.setBadgeBackgroundColor({color: "#216CB1" }); 219 | } 220 | _lastSetBadgeText = Date.now(); 221 | } 222 | 223 | const getStorageValue = async function(key) { 224 | const setting = await chrome.storage.local.get(key); 225 | if (!setting) { return null; } 226 | return setting[key]; 227 | } 228 | 229 | // ensure badge clears out 230 | const checkIfShouldAssumeDone = function() { 231 | 232 | chrome.action.getBadgeText({}, function(result) { 233 | if (result && result.startsWith('+') && _lastSetBadgeText && (Date.now() -_lastSetBadgeText) > 8000) { 234 | fancySetBadge('DONE'); 235 | } 236 | }); 237 | 238 | // a long enough time period that we can really believe it's done 239 | setTimeout(() => { 240 | checkIfShouldAssumeDone(); 241 | }, 10000); 242 | } 243 | checkIfShouldAssumeDone(); 244 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | // a note on scraping (legal standing): 2 | // cpomagazine.com/data-privacy/what-the-hiq-vs-linkedin-case-means-for-automated-web-scraping/ 3 | 4 | /* 5 | APPLICATION FLOW: 6 | 7 | On Startup 8 | tryRecordAsNeeded() if cached setting suggests we should 9 | 10 | On click 'record' or stop recording from the popup 11 | timer checks for updated recordingContext 12 | 13 | When we're recording, a MutationObserver looks for newly added nodes (plus when turned on, finds relevant ones that are already there) 14 | and converts them to the savable records. Every 5 seconds, these are saved to localStorage (using background.js) thanks to RECORDING.setSaveTimer. 15 | When the DB UI is opened, records are flushed from localStorage to the SQLite database. 16 | 17 | */ 18 | var _recorder = null; 19 | 20 | // adjust nitter settings (once) 21 | window.onload = function() { 22 | const href = document.location.href; 23 | const domain = STR.extractDomain(href); 24 | // https://nitter.one/settings?reason=clearAltUrls 25 | if (href.indexOf('https://nitter') > -1 && href.indexOf('/settings') > -1 && href.indexOf(REASON_CLEAR_ALT_URLS) > -1) { 26 | // remove the query string parm so the refresh excludes it 27 | window.history.pushState({}, "", href.split("?")[0]); 28 | // if the nitter instance is in an error state, we can't adjust settings 29 | const replaceTwitterElm = document.querySelector('input[name="replaceTwitter"]'); 30 | if (replaceTwitterElm) { 31 | replaceTwitterElm.value = ''; 32 | document.querySelector('input[name="replaceYouTube"]').value = ''; 33 | document.querySelector('input[name="replaceReddit"]').value = ''; 34 | document.querySelector('button[type="submit"]').click(); 35 | const key = `${SETTINGS.FIXED_SETTINGS_PREFIX}${domain}`; 36 | chrome.storage.local.set({ [key]: true }); 37 | } 38 | } 39 | }; 40 | 41 | // main scenario: recording 42 | const kickoffPollForRecording = async function() { 43 | _recorder = RECORDING.getRecorder(); 44 | if (_recorder) { 45 | await _recorder.pollForRecording(); 46 | } 47 | else { 48 | const href = document.location.href; 49 | if (href.indexOf('squidlr.com') > -1) { 50 | await SQUIDDY.pollForCaptureVideo(); 51 | } 52 | } 53 | } 54 | 55 | kickoffPollForRecording(); 56 | -------------------------------------------------------------------------------- /src/css/bootstrap/flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/css/bootstrap/flags.png -------------------------------------------------------------------------------- /src/css/fonts/Inkfree.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/css/fonts/Inkfree.ttf -------------------------------------------------------------------------------- /src/css/open-iconic/FONT-LICENSE: -------------------------------------------------------------------------------- 1 | SIL OPEN FONT LICENSE Version 1.1 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | PREAMBLE 6 | The goals of the Open Font License (OFL) are to stimulate worldwide 7 | development of collaborative font projects, to support the font creation 8 | efforts of academic and linguistic communities, and to provide a free and 9 | open framework in which fonts may be shared and improved in partnership 10 | with others. 11 | 12 | The OFL allows the licensed fonts to be used, studied, modified and 13 | redistributed freely as long as they are not sold by themselves. The 14 | fonts, including any derivative works, can be bundled, embedded, 15 | redistributed and/or sold with any software provided that any reserved 16 | names are not used by derivative works. The fonts and derivatives, 17 | however, cannot be released under any other type of license. The 18 | requirement for fonts to remain under this license does not apply 19 | to any document created using the fonts or their derivatives. 20 | 21 | DEFINITIONS 22 | "Font Software" refers to the set of files released by the Copyright 23 | Holder(s) under this license and clearly marked as such. This may 24 | include source files, build scripts and documentation. 25 | 26 | "Reserved Font Name" refers to any names specified as such after the 27 | copyright statement(s). 28 | 29 | "Original Version" refers to the collection of Font Software components as 30 | distributed by the Copyright Holder(s). 31 | 32 | "Modified Version" refers to any derivative made by adding to, deleting, 33 | or substituting -- in part or in whole -- any of the components of the 34 | Original Version, by changing formats or by porting the Font Software to a 35 | new environment. 36 | 37 | "Author" refers to any designer, engineer, programmer, technical 38 | writer or other person who contributed to the Font Software. 39 | 40 | PERMISSION & CONDITIONS 41 | Permission is hereby granted, free of charge, to any person obtaining 42 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 43 | redistribute, and sell modified and unmodified copies of the Font 44 | Software, subject to the following conditions: 45 | 46 | 1) Neither the Font Software nor any of its individual components, 47 | in Original or Modified Versions, may be sold by itself. 48 | 49 | 2) Original or Modified Versions of the Font Software may be bundled, 50 | redistributed and/or sold with any software, provided that each copy 51 | contains the above copyright notice and this license. These can be 52 | included either as stand-alone text files, human-readable headers or 53 | in the appropriate machine-readable metadata fields within text or 54 | binary files as long as those fields can be easily viewed by the user. 55 | 56 | 3) No Modified Version of the Font Software may use the Reserved Font 57 | Name(s) unless explicit written permission is granted by the corresponding 58 | Copyright Holder. This restriction only applies to the primary font name as 59 | presented to the users. 60 | 61 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 62 | Software shall not be used to promote, endorse or advertise any 63 | Modified Version, except to acknowledge the contribution(s) of the 64 | Copyright Holder(s) and the Author(s) or with their explicit written 65 | permission. 66 | 67 | 5) The Font Software, modified or unmodified, in part or in whole, 68 | must be distributed entirely under this license, and must not be 69 | distributed under any other license. The requirement for fonts to 70 | remain under this license does not apply to any document created 71 | using the Font Software. 72 | 73 | TERMINATION 74 | This license becomes null and void if any of the above conditions are 75 | not met. 76 | 77 | DISCLAIMER 78 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 79 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 80 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 81 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 82 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 83 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 84 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 85 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 86 | OTHER DEALINGS IN THE FONT SOFTWARE. 87 | -------------------------------------------------------------------------------- /src/css/open-iconic/ICON-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Waybury 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/css/open-iconic/README.md: -------------------------------------------------------------------------------- 1 | [Open Iconic v1.1.1](http://useiconic.com/open) 2 | =========== 3 | 4 | ### Open Iconic is the open source sibling of [Iconic](http://useiconic.com). It is a hyper-legible collection of 223 icons with a tiny footprint—ready to use with Bootstrap and Foundation. [View the collection](http://useiconic.com/open#icons) 5 | 6 | 7 | 8 | ## What's in Open Iconic? 9 | 10 | * 223 icons designed to be legible down to 8 pixels 11 | * Super-light SVG files - 61.8 for the entire set 12 | * SVG sprite—the modern replacement for icon fonts 13 | * Webfont (EOT, OTF, SVG, TTF, WOFF), PNG and WebP formats 14 | * Webfont stylesheets (including versions for Bootstrap and Foundation) in CSS, LESS, SCSS and Stylus formats 15 | * PNG and WebP raster images in 8px, 16px, 24px, 32px, 48px and 64px. 16 | 17 | 18 | ## Getting Started 19 | 20 | #### For code samples and everything else you need to get started with Open Iconic, check out our [Icons](http://useiconic.com/open#icons) and [Reference](http://useiconic.com/open#reference) sections. 21 | 22 | ### General Usage 23 | 24 | #### Using Open Iconic's SVGs 25 | 26 | We like SVGs and we think they're the way to display icons on the web. Since Open Iconic are just basic SVGs, we suggest you display them like you would any other image (don't forget the `alt` attribute). 27 | 28 | ``` 29 | icon name 30 | ``` 31 | 32 | #### Using Open Iconic's SVG Sprite 33 | 34 | Open Iconic also comes in a SVG sprite which allows you to display all the icons in the set with a single request. It's like an icon font, without being a hack. 35 | 36 | Adding an icon from an SVG sprite is a little different than what you're used to, but it's still a piece of cake. *Tip: To make your icons easily style able, we suggest adding a general class to the* `` *tag and a unique class name for each different icon in the* `` *tag.* 37 | 38 | ``` 39 | 40 | 41 | 42 | ``` 43 | 44 | Sizing icons only needs basic CSS. All the icons are in a square format, so just set the `` tag with equal width and height dimensions. 45 | 46 | ``` 47 | .icon { 48 | width: 16px; 49 | height: 16px; 50 | } 51 | ``` 52 | 53 | Coloring icons is even easier. All you need to do is set the `fill` rule on the `` tag. 54 | 55 | ``` 56 | .icon-account-login { 57 | fill: #f00; 58 | } 59 | ``` 60 | 61 | To learn more about SVG Sprites, read [Chris Coyier's guide](http://css-tricks.com/svg-sprites-use-better-icon-fonts/). 62 | 63 | #### Using Open Iconic's Icon Font... 64 | 65 | 66 | ##### …with Bootstrap 67 | 68 | You can find our Bootstrap stylesheets in `font/css/open-iconic-bootstrap.{css, less, scss, styl}` 69 | 70 | 71 | ``` 72 | 73 | ``` 74 | 75 | 76 | ``` 77 | 78 | ``` 79 | 80 | ##### …with Foundation 81 | 82 | You can find our Foundation stylesheets in `font/css/open-iconic-foundation.{css, less, scss, styl}` 83 | 84 | ``` 85 | 86 | ``` 87 | 88 | 89 | ``` 90 | 91 | ``` 92 | 93 | ##### …on its own 94 | 95 | You can find our default stylesheets in `font/css/open-iconic.{css, less, scss, styl}` 96 | 97 | ``` 98 | 99 | ``` 100 | 101 | ``` 102 | 103 | ``` 104 | 105 | 106 | ## License 107 | 108 | ### Icons 109 | 110 | All code (including SVG markup) is under the [MIT License](http://opensource.org/licenses/MIT). 111 | 112 | ### Fonts 113 | 114 | All fonts are under the [SIL Licensed](http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web). 115 | -------------------------------------------------------------------------------- /src/css/open-iconic/font/css/open-iconic-bootstrap.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Icons;src:url(../fonts/open-iconic.eot);src:url(../fonts/open-iconic.eot?#iconic-sm) format('embedded-opentype'),url(../fonts/open-iconic.woff) format('woff'),url(../fonts/open-iconic.ttf) format('truetype'),url(../fonts/open-iconic.otf) format('opentype'),url(../fonts/open-iconic.svg#iconic-sm) format('svg');font-weight:400;font-style:normal}.oi{position:relative;top:1px;display:inline-block;speak:none;font-family:Icons;font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.oi:empty:before{width:1em;text-align:center;box-sizing:content-box}.oi.oi-align-center:before{text-align:center}.oi.oi-align-left:before{text-align:left}.oi.oi-align-right:before{text-align:right}.oi.oi-flip-horizontal:before{-webkit-transform:scale(-1,1);-ms-transform:scale(-1,1);transform:scale(-1,1)}.oi.oi-flip-vertical:before{-webkit-transform:scale(1,-1);-ms-transform:scale(-1,1);transform:scale(1,-1)}.oi.oi-flip-horizontal-vertical:before{-webkit-transform:scale(-1,-1);-ms-transform:scale(-1,1);transform:scale(-1,-1)}.oi-account-login:before{content:'\e000'}.oi-account-logout:before{content:'\e001'}.oi-action-redo:before{content:'\e002'}.oi-action-undo:before{content:'\e003'}.oi-align-center:before{content:'\e004'}.oi-align-left:before{content:'\e005'}.oi-align-right:before{content:'\e006'}.oi-aperture:before{content:'\e007'}.oi-arrow-bottom:before{content:'\e008'}.oi-arrow-circle-bottom:before{content:'\e009'}.oi-arrow-circle-left:before{content:'\e00a'}.oi-arrow-circle-right:before{content:'\e00b'}.oi-arrow-circle-top:before{content:'\e00c'}.oi-arrow-left:before{content:'\e00d'}.oi-arrow-right:before{content:'\e00e'}.oi-arrow-thick-bottom:before{content:'\e00f'}.oi-arrow-thick-left:before{content:'\e010'}.oi-arrow-thick-right:before{content:'\e011'}.oi-arrow-thick-top:before{content:'\e012'}.oi-arrow-top:before{content:'\e013'}.oi-audio-spectrum:before{content:'\e014'}.oi-audio:before{content:'\e015'}.oi-badge:before{content:'\e016'}.oi-ban:before{content:'\e017'}.oi-bar-chart:before{content:'\e018'}.oi-basket:before{content:'\e019'}.oi-battery-empty:before{content:'\e01a'}.oi-battery-full:before{content:'\e01b'}.oi-beaker:before{content:'\e01c'}.oi-bell:before{content:'\e01d'}.oi-bluetooth:before{content:'\e01e'}.oi-bold:before{content:'\e01f'}.oi-bolt:before{content:'\e020'}.oi-book:before{content:'\e021'}.oi-bookmark:before{content:'\e022'}.oi-box:before{content:'\e023'}.oi-briefcase:before{content:'\e024'}.oi-british-pound:before{content:'\e025'}.oi-browser:before{content:'\e026'}.oi-brush:before{content:'\e027'}.oi-bug:before{content:'\e028'}.oi-bullhorn:before{content:'\e029'}.oi-calculator:before{content:'\e02a'}.oi-calendar:before{content:'\e02b'}.oi-camera-slr:before{content:'\e02c'}.oi-caret-bottom:before{content:'\e02d'}.oi-caret-left:before{content:'\e02e'}.oi-caret-right:before{content:'\e02f'}.oi-caret-top:before{content:'\e030'}.oi-cart:before{content:'\e031'}.oi-chat:before{content:'\e032'}.oi-check:before{content:'\e033'}.oi-chevron-bottom:before{content:'\e034'}.oi-chevron-left:before{content:'\e035'}.oi-chevron-right:before{content:'\e036'}.oi-chevron-top:before{content:'\e037'}.oi-circle-check:before{content:'\e038'}.oi-circle-x:before{content:'\e039'}.oi-clipboard:before{content:'\e03a'}.oi-clock:before{content:'\e03b'}.oi-cloud-download:before{content:'\e03c'}.oi-cloud-upload:before{content:'\e03d'}.oi-cloud:before{content:'\e03e'}.oi-cloudy:before{content:'\e03f'}.oi-code:before{content:'\e040'}.oi-cog:before{content:'\e041'}.oi-collapse-down:before{content:'\e042'}.oi-collapse-left:before{content:'\e043'}.oi-collapse-right:before{content:'\e044'}.oi-collapse-up:before{content:'\e045'}.oi-command:before{content:'\e046'}.oi-comment-square:before{content:'\e047'}.oi-compass:before{content:'\e048'}.oi-contrast:before{content:'\e049'}.oi-copywriting:before{content:'\e04a'}.oi-credit-card:before{content:'\e04b'}.oi-crop:before{content:'\e04c'}.oi-dashboard:before{content:'\e04d'}.oi-data-transfer-download:before{content:'\e04e'}.oi-data-transfer-upload:before{content:'\e04f'}.oi-delete:before{content:'\e050'}.oi-dial:before{content:'\e051'}.oi-document:before{content:'\e052'}.oi-dollar:before{content:'\e053'}.oi-double-quote-sans-left:before{content:'\e054'}.oi-double-quote-sans-right:before{content:'\e055'}.oi-double-quote-serif-left:before{content:'\e056'}.oi-double-quote-serif-right:before{content:'\e057'}.oi-droplet:before{content:'\e058'}.oi-eject:before{content:'\e059'}.oi-elevator:before{content:'\e05a'}.oi-ellipses:before{content:'\e05b'}.oi-envelope-closed:before{content:'\e05c'}.oi-envelope-open:before{content:'\e05d'}.oi-euro:before{content:'\e05e'}.oi-excerpt:before{content:'\e05f'}.oi-expand-down:before{content:'\e060'}.oi-expand-left:before{content:'\e061'}.oi-expand-right:before{content:'\e062'}.oi-expand-up:before{content:'\e063'}.oi-external-link:before{content:'\e064'}.oi-eye:before{content:'\e065'}.oi-eyedropper:before{content:'\e066'}.oi-file:before{content:'\e067'}.oi-fire:before{content:'\e068'}.oi-flag:before{content:'\e069'}.oi-flash:before{content:'\e06a'}.oi-folder:before{content:'\e06b'}.oi-fork:before{content:'\e06c'}.oi-fullscreen-enter:before{content:'\e06d'}.oi-fullscreen-exit:before{content:'\e06e'}.oi-globe:before{content:'\e06f'}.oi-graph:before{content:'\e070'}.oi-grid-four-up:before{content:'\e071'}.oi-grid-three-up:before{content:'\e072'}.oi-grid-two-up:before{content:'\e073'}.oi-hard-drive:before{content:'\e074'}.oi-header:before{content:'\e075'}.oi-headphones:before{content:'\e076'}.oi-heart:before{content:'\e077'}.oi-home:before{content:'\e078'}.oi-image:before{content:'\e079'}.oi-inbox:before{content:'\e07a'}.oi-infinity:before{content:'\e07b'}.oi-info:before{content:'\e07c'}.oi-italic:before{content:'\e07d'}.oi-justify-center:before{content:'\e07e'}.oi-justify-left:before{content:'\e07f'}.oi-justify-right:before{content:'\e080'}.oi-key:before{content:'\e081'}.oi-laptop:before{content:'\e082'}.oi-layers:before{content:'\e083'}.oi-lightbulb:before{content:'\e084'}.oi-link-broken:before{content:'\e085'}.oi-link-intact:before{content:'\e086'}.oi-list-rich:before{content:'\e087'}.oi-list:before{content:'\e088'}.oi-location:before{content:'\e089'}.oi-lock-locked:before{content:'\e08a'}.oi-lock-unlocked:before{content:'\e08b'}.oi-loop-circular:before{content:'\e08c'}.oi-loop-square:before{content:'\e08d'}.oi-loop:before{content:'\e08e'}.oi-magnifying-glass:before{content:'\e08f'}.oi-map-marker:before{content:'\e090'}.oi-map:before{content:'\e091'}.oi-media-pause:before{content:'\e092'}.oi-media-play:before{content:'\e093'}.oi-media-record:before{content:'\e094'}.oi-media-skip-backward:before{content:'\e095'}.oi-media-skip-forward:before{content:'\e096'}.oi-media-step-backward:before{content:'\e097'}.oi-media-step-forward:before{content:'\e098'}.oi-media-stop:before{content:'\e099'}.oi-medical-cross:before{content:'\e09a'}.oi-menu:before{content:'\e09b'}.oi-microphone:before{content:'\e09c'}.oi-minus:before{content:'\e09d'}.oi-monitor:before{content:'\e09e'}.oi-moon:before{content:'\e09f'}.oi-move:before{content:'\e0a0'}.oi-musical-note:before{content:'\e0a1'}.oi-paperclip:before{content:'\e0a2'}.oi-pencil:before{content:'\e0a3'}.oi-people:before{content:'\e0a4'}.oi-person:before{content:'\e0a5'}.oi-phone:before{content:'\e0a6'}.oi-pie-chart:before{content:'\e0a7'}.oi-pin:before{content:'\e0a8'}.oi-play-circle:before{content:'\e0a9'}.oi-plus:before{content:'\e0aa'}.oi-power-standby:before{content:'\e0ab'}.oi-print:before{content:'\e0ac'}.oi-project:before{content:'\e0ad'}.oi-pulse:before{content:'\e0ae'}.oi-puzzle-piece:before{content:'\e0af'}.oi-question-mark:before{content:'\e0b0'}.oi-rain:before{content:'\e0b1'}.oi-random:before{content:'\e0b2'}.oi-reload:before{content:'\e0b3'}.oi-resize-both:before{content:'\e0b4'}.oi-resize-height:before{content:'\e0b5'}.oi-resize-width:before{content:'\e0b6'}.oi-rss-alt:before{content:'\e0b7'}.oi-rss:before{content:'\e0b8'}.oi-script:before{content:'\e0b9'}.oi-share-boxed:before{content:'\e0ba'}.oi-share:before{content:'\e0bb'}.oi-shield:before{content:'\e0bc'}.oi-signal:before{content:'\e0bd'}.oi-signpost:before{content:'\e0be'}.oi-sort-ascending:before{content:'\e0bf'}.oi-sort-descending:before{content:'\e0c0'}.oi-spreadsheet:before{content:'\e0c1'}.oi-star:before{content:'\e0c2'}.oi-sun:before{content:'\e0c3'}.oi-tablet:before{content:'\e0c4'}.oi-tag:before{content:'\e0c5'}.oi-tags:before{content:'\e0c6'}.oi-target:before{content:'\e0c7'}.oi-task:before{content:'\e0c8'}.oi-terminal:before{content:'\e0c9'}.oi-text:before{content:'\e0ca'}.oi-thumb-down:before{content:'\e0cb'}.oi-thumb-up:before{content:'\e0cc'}.oi-timer:before{content:'\e0cd'}.oi-transfer:before{content:'\e0ce'}.oi-trash:before{content:'\e0cf'}.oi-underline:before{content:'\e0d0'}.oi-vertical-align-bottom:before{content:'\e0d1'}.oi-vertical-align-center:before{content:'\e0d2'}.oi-vertical-align-top:before{content:'\e0d3'}.oi-video:before{content:'\e0d4'}.oi-volume-high:before{content:'\e0d5'}.oi-volume-low:before{content:'\e0d6'}.oi-volume-off:before{content:'\e0d7'}.oi-warning:before{content:'\e0d8'}.oi-wifi:before{content:'\e0d9'}.oi-wrench:before{content:'\e0da'}.oi-x:before{content:'\e0db'}.oi-yen:before{content:'\e0dc'}.oi-zoom-in:before{content:'\e0dd'}.oi-zoom-out:before{content:'\e0de'} -------------------------------------------------------------------------------- /src/css/open-iconic/font/fonts/open-iconic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/css/open-iconic/font/fonts/open-iconic.eot -------------------------------------------------------------------------------- /src/css/open-iconic/font/fonts/open-iconic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/css/open-iconic/font/fonts/open-iconic.otf -------------------------------------------------------------------------------- /src/css/open-iconic/font/fonts/open-iconic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/css/open-iconic/font/fonts/open-iconic.ttf -------------------------------------------------------------------------------- /src/css/open-iconic/font/fonts/open-iconic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/css/open-iconic/font/fonts/open-iconic.woff -------------------------------------------------------------------------------- /src/fonts/Inkfree.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/fonts/Inkfree.ttf -------------------------------------------------------------------------------- /src/images/PinExtension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/PinExtension.png -------------------------------------------------------------------------------- /src/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/favicon.ico -------------------------------------------------------------------------------- /src/images/ghpathelp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/ghpathelp.png -------------------------------------------------------------------------------- /src/images/ghpathelp_videos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/ghpathelp_videos.png -------------------------------------------------------------------------------- /src/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/icon-128.png -------------------------------------------------------------------------------- /src/images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/icon-16.png -------------------------------------------------------------------------------- /src/images/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/icon-32.png -------------------------------------------------------------------------------- /src/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/icon-48.png -------------------------------------------------------------------------------- /src/images/noprofilepic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/noprofilepic.png -------------------------------------------------------------------------------- /src/images/possum800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/possum800.png -------------------------------------------------------------------------------- /src/images/upgrade-from-104.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/upgrade-from-104.png -------------------------------------------------------------------------------- /src/images/upgrade-from-107.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/images/upgrade-from-107.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // PUBLIC VARIABLES 2 | // avoid double-submit 3 | var _lastRenderedRequest = ''; 4 | var _docLocSearch = ''; 5 | // improves experience of deleting in autocomplete textbox 6 | var _deletingOwner = false; 7 | var _deletingMdonRemoteOwner = false; 8 | var _deletingTopicFilter = false; 9 | // so we can reduce how many times we ask for (expensive) total counts 10 | var _counterSet = new Set(); 11 | var _counters = []; 12 | 13 | // CHROME-LEVEL EVENTS 14 | 15 | // read out to initialize (using chrome.storage.local is more seure than localStorage) 16 | chrome.storage.local.get([MASTODON.OAUTH_CACHE_KEY.USER], function(result) { 17 | _mdonRememberedUser = result.mdonUser || {}; 18 | }); 19 | 20 | chrome.storage.local.get([MASTODON.OAUTH_CACHE_KEY.CLIENT_ID], function(result) { 21 | _mdonClientId = result.mdonClientId || ''; 22 | // console.log('clientid: ' + _mdonClientId); 23 | }); 24 | 25 | chrome.storage.local.get([MASTODON.OAUTH_CACHE_KEY.CLIENT_SECRET], function(result) { 26 | _mdonClientSecret = result.mdonClientSecret || ''; 27 | // console.log('secret: ' + _mdonClientSecret); 28 | }); 29 | 30 | chrome.storage.local.get([MASTODON.OAUTH_CACHE_KEY.ACCESS_TOKEN], function(result) { 31 | _mdonAccessToken = result.mdonAccessToken || ''; 32 | // console.log('access: ' + _mdonAccessToken); 33 | }); 34 | 35 | chrome.storage.local.get([MASTODON.OAUTH_CACHE_KEY.USER_AUTH_TOKEN], function(result) { 36 | _mdonUserAuthToken = result.mdonUserAuthToken || ''; 37 | // console.log('userauth: ' + _mdonUserAuthToken); 38 | }); 39 | 40 | // BINDINGS 41 | QUERYWORK_UI.bindWorker(); 42 | RENDERBIND_UI.bindEvents(); 43 | ES6.TRISTATE.initAll(); 44 | QUERYWORK_UI.bindElements(); 45 | SETTINGS_UI.bindElements(); 46 | GHBACKUP_UI.bindElements(); 47 | GHRESTORE_UI.bindElements(); 48 | TABS_UI.bindElements(); 49 | GHCONFIG_UI.bindElements(); 50 | MDON_UI.bindElements(); 51 | GHVIDEOUP_UI.bindElements(); -------------------------------------------------------------------------------- /src/jswasm/sqlite3-worker1-promiser.js: -------------------------------------------------------------------------------- 1 | /* 2 | 2022-08-24 3 | 4 | The author disclaims copyright to this source code. In place of a 5 | legal notice, here is a blessing: 6 | 7 | * May you do good and not evil. 8 | * May you find forgiveness for yourself and forgive others. 9 | * May you share freely, never taking more than you give. 10 | 11 | *********************************************************************** 12 | 13 | This file implements a Promise-based proxy for the sqlite3 Worker 14 | API #1. It is intended to be included either from the main thread or 15 | a Worker, but only if (A) the environment supports nested Workers 16 | and (B) it's _not_ a Worker which loads the sqlite3 WASM/JS 17 | module. This file's features will load that module and provide a 18 | slightly simpler client-side interface than the slightly-lower-level 19 | Worker API does. 20 | 21 | This script necessarily exposes one global symbol, but clients may 22 | freely `delete` that symbol after calling it. 23 | */ 24 | 'use strict'; 25 | 26 | globalThis.sqlite3Worker1Promiser = function callee(config = callee.defaultConfig){ 27 | 28 | if(1===arguments.length && 'function'===typeof arguments[0]){ 29 | const f = config; 30 | config = Object.assign(Object.create(null), callee.defaultConfig); 31 | config.onready = f; 32 | }else{ 33 | config = Object.assign(Object.create(null), callee.defaultConfig, config); 34 | } 35 | const handlerMap = Object.create(null); 36 | const noop = function(){}; 37 | const err = config.onerror 38 | || noop ; 39 | const debug = config.debug || noop; 40 | const idTypeMap = config.generateMessageId ? undefined : Object.create(null); 41 | const genMsgId = config.generateMessageId || function(msg){ 42 | return msg.type+'#'+(idTypeMap[msg.type] = (idTypeMap[msg.type]||0) + 1); 43 | }; 44 | const toss = (...args)=>{throw new Error(args.join(' '))}; 45 | if(!config.worker) config.worker = callee.defaultConfig.worker; 46 | if('function'===typeof config.worker) config.worker = config.worker(); 47 | let dbId; 48 | config.worker.onmessage = function(ev){ 49 | ev = ev.data; 50 | debug('worker1.onmessage',ev); 51 | let msgHandler = handlerMap[ev.messageId]; 52 | if(!msgHandler){ 53 | if(ev && 'sqlite3-api'===ev.type && 'worker1-ready'===ev.result) { 54 | 55 | if(config.onready) config.onready(); 56 | return; 57 | } 58 | msgHandler = handlerMap[ev.type] ; 59 | if(msgHandler && msgHandler.onrow){ 60 | msgHandler.onrow(ev); 61 | return; 62 | } 63 | if(config.onunhandled) config.onunhandled(arguments[0]); 64 | else err("sqlite3Worker1Promiser() unhandled worker message:",ev); 65 | return; 66 | } 67 | delete handlerMap[ev.messageId]; 68 | switch(ev.type){ 69 | case 'error': 70 | msgHandler.reject(ev); 71 | return; 72 | case 'open': 73 | if(!dbId) dbId = ev.dbId; 74 | break; 75 | case 'close': 76 | if(ev.dbId===dbId) dbId = undefined; 77 | break; 78 | default: 79 | break; 80 | } 81 | try {msgHandler.resolve(ev)} 82 | catch(e){msgHandler.reject(e)} 83 | }; 84 | return function(){ 85 | let msg; 86 | if(1===arguments.length){ 87 | msg = arguments[0]; 88 | }else if(2===arguments.length){ 89 | msg = Object.create(null); 90 | msg.type = arguments[0]; 91 | msg.args = arguments[1]; 92 | }else{ 93 | toss("Invalid arugments for sqlite3Worker1Promiser()-created factory."); 94 | } 95 | if(!msg.dbId) msg.dbId = dbId; 96 | msg.messageId = genMsgId(msg); 97 | msg.departureTime = performance.now(); 98 | const proxy = Object.create(null); 99 | proxy.message = msg; 100 | let rowCallbackId ; 101 | if('exec'===msg.type && msg.args){ 102 | if('function'===typeof msg.args.callback){ 103 | rowCallbackId = msg.messageId+':row'; 104 | proxy.onrow = msg.args.callback; 105 | msg.args.callback = rowCallbackId; 106 | handlerMap[rowCallbackId] = proxy; 107 | }else if('string' === typeof msg.args.callback){ 108 | toss("exec callback may not be a string when using the Promise interface."); 109 | 110 | } 111 | } 112 | 113 | let p = new Promise(function(resolve, reject){ 114 | proxy.resolve = resolve; 115 | proxy.reject = reject; 116 | handlerMap[msg.messageId] = proxy; 117 | debug("Posting",msg.type,"message to Worker dbId="+(dbId||'default')+':',msg); 118 | config.worker.postMessage(msg); 119 | }); 120 | if(rowCallbackId) p = p.finally(()=>delete handlerMap[rowCallbackId]); 121 | return p; 122 | }; 123 | }; 124 | globalThis.sqlite3Worker1Promiser.defaultConfig = { 125 | worker: function(){ 126 | let theJs = "sqlite3-worker1.js"; 127 | if(this.currentScript){ 128 | const src = this.currentScript.src.split('/'); 129 | src.pop(); 130 | theJs = src.join('/')+'/' + theJs; 131 | 132 | }else if(globalThis.location){ 133 | 134 | const urlParams = new URL(globalThis.location.href).searchParams; 135 | if(urlParams.has('sqlite3.dir')){ 136 | theJs = urlParams.get('sqlite3.dir') + '/' + theJs; 137 | } 138 | } 139 | return new Worker(theJs + globalThis.location.search); 140 | }.bind({ 141 | currentScript: globalThis?.document?.currentScript 142 | }), 143 | onerror: (...args)=>console.error('worker1 promiser error',...args) 144 | }; 145 | -------------------------------------------------------------------------------- /src/jswasm/sqlite3-worker1.js: -------------------------------------------------------------------------------- 1 | /* 2 | 2022-05-23 3 | 4 | The author disclaims copyright to this source code. In place of a 5 | legal notice, here is a blessing: 6 | 7 | * May you do good and not evil. 8 | * May you find forgiveness for yourself and forgive others. 9 | * May you share freely, never taking more than you give. 10 | 11 | *********************************************************************** 12 | 13 | This is a JS Worker file for the main sqlite3 api. It loads 14 | sqlite3.js, initializes the module, and postMessage()'s a message 15 | after the module is initialized: 16 | 17 | {type: 'sqlite3-api', result: 'worker1-ready'} 18 | 19 | This seemingly superfluous level of indirection is necessary when 20 | loading sqlite3.js via a Worker. Instantiating a worker with new 21 | Worker("sqlite.js") will not (cannot) call sqlite3InitModule() to 22 | initialize the module due to a timing/order-of-operations conflict 23 | (and that symbol is not exported in a way that a Worker loading it 24 | that way can see it). Thus JS code wanting to load the sqlite3 25 | Worker-specific API needs to pass _this_ file (or equivalent) to the 26 | Worker constructor and then listen for an event in the form shown 27 | above in order to know when the module has completed initialization. 28 | 29 | This file accepts a URL arguments to adjust how it loads sqlite3.js: 30 | 31 | - `sqlite3.dir`, if set, treats the given directory name as the 32 | directory from which `sqlite3.js` will be loaded. 33 | */ 34 | "use strict"; 35 | { 36 | const urlParams = globalThis.location 37 | ? new URL(self.location.href).searchParams 38 | : new URLSearchParams(); 39 | let theJs = 'sqlite3.js'; 40 | if(urlParams.has('sqlite3.dir')){ 41 | theJs = urlParams.get('sqlite3.dir') + '/' + theJs; 42 | } 43 | 44 | importScripts(theJs); 45 | } 46 | sqlite3InitModule().then(sqlite3 => sqlite3.initWorker1API()); 47 | -------------------------------------------------------------------------------- /src/jswasm/sqlite3.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/jswasm/sqlite3.wasm -------------------------------------------------------------------------------- /src/lib/bootstrap-icons/fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/lib/bootstrap-icons/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /src/lib/bootstrap-icons/fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PositiveSumNet/SocialAssistant/1a9258c74f3554f3377fa90d53e0c8af22398142/src/lib/bootstrap-icons/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /src/lib/content/embvideo/squiddy.js: -------------------------------------------------------------------------------- 1 | var SQUIDDY = { 2 | getVideoUrl: function(resolution) { 3 | const anchors = Array.from(document.querySelectorAll('a[href^="https://video.twimg.com"]')); 4 | if (anchors.length == 0) { return null; } 5 | let bestAnchor; 6 | switch (resolution) { 7 | case VIDEO_RES.MEDIUM: 8 | bestAnchor = anchors.find(function(a) { return a.innerText.indexOf('Medium resolution') > -1; }); 9 | break; 10 | case VIDEO_RES.LOWEST: 11 | bestAnchor = anchors.find(function(a) { return a.innerText.indexOf('Lowest resolution') > -1; }); 12 | break; 13 | case VIDEO_RES.HIGH: 14 | default: 15 | bestAnchor = anchors.find(function(a) { return a.innerText.indexOf('Best resolution') > -1; }); 16 | break; 17 | } 18 | 19 | return bestAnchor ? bestAnchor.href : anchors[0].href; 20 | }, 21 | 22 | getUrlKey: function() { 23 | // starting with: 24 | // https://www.squidlr.com/download?url=https://x.com/USERNAME/status/1604953808890294756#high 25 | // get to: USERNAME_status_1604953808890294756.mp4 26 | // and noting that _status_ will serve as delimiter 27 | let href = document.location.href; 28 | href = STR.stripUrlHashSuffix(href); 29 | let urlParm = URLPARSE.getQueryParm('url'); 30 | let urlKey = STR.tryGetUrlKey(urlParm, ['x.com']); 31 | return urlKey; 32 | }, 33 | 34 | captureVideo: function(cdnUrl, autoDownloadSansPopup) { 35 | const urlKey = SQUIDDY.getUrlKey(); 36 | // we want the urlKey to be discernable from the file name so that during Import we can link it up with the source post 37 | const fileName = STR.buildVideoFileNameFromUrlKey(urlKey); 38 | 39 | if (autoDownloadSansPopup == true) { 40 | ES6.downloadMediaFile(cdnUrl, fileName); 41 | } 42 | else { 43 | chrome.runtime.sendMessage({ 44 | actionType: MSGTYPE.TO_POPUP.DOWNLOAD_MEDIA, 45 | urlKey: urlKey, 46 | cdnUrl: cdnUrl, 47 | fileName: fileName 48 | }); 49 | } 50 | }, 51 | 52 | pollForCaptureVideo: async function() { 53 | let resolution = STR.getUrlHashSuffix(document.location.href); 54 | // note that we only auto-nav if the hashed resolution suffix is appended to the url 55 | // (so that users can also use Squidlr on its own without interference) 56 | // see STR.buildSquidlrUrl 57 | // also note that we conditionally append '_auto' for the auto-download without popup context 58 | if (!STR.hasLen(resolution)) { return; } 59 | const autoDownloadSansPopup = resolution.endsWith('_auto'); 60 | resolution = STR.stripSuffix(resolution, '_auto'); 61 | let cdnUrl; 62 | for (let i = 0; i < 5; i++) { 63 | // try a few times, waiting for the page to load 64 | cdnUrl = SQUIDDY.getVideoUrl(resolution); 65 | if (STR.hasLen(cdnUrl)) { 66 | break; 67 | } 68 | await ES6.sleep(1000); 69 | } 70 | 71 | if (!STR.hasLen(cdnUrl)) { 72 | console.log('unable to resolve video url'); 73 | return; 74 | } 75 | 76 | SQUIDDY.captureVideo(cdnUrl, autoDownloadSansPopup); 77 | } 78 | }; -------------------------------------------------------------------------------- /src/lib/content/nitter/nitterparsing.js: -------------------------------------------------------------------------------- 1 | var NPARSE = { 2 | isErrorPage: function() { 3 | const errorPanel = document.querySelector('.error-panel'); 4 | if (errorPanel && errorPanel.textContent == 'Tweet not found') { 5 | return true; 6 | } 7 | else { 8 | return false; 9 | } 10 | }, 11 | 12 | getMainColumn: function(warn) { 13 | // main timeline 14 | let elms = document.querySelectorAll('.timeline'); 15 | 16 | if (elms && elms.length === 1) { 17 | return elms[0]; 18 | } 19 | 20 | // detail page for a conversation thread 21 | elms = document.querySelectorAll('.conversation'); 22 | if (elms && elms.length === 1) { 23 | return elms[0]; 24 | } 25 | 26 | if (warn === true) { 27 | console.warn('Cannot find nitter main column; page structure may have changed.'); 28 | } 29 | } 30 | }; -------------------------------------------------------------------------------- /src/lib/content/nitter/nittertweetsrecorder.js: -------------------------------------------------------------------------------- 1 | // specific observers are held in _observers and also referenced by specific recorders 2 | var _neetObserver; 3 | 4 | var _nitterFullyLoaded = false; 5 | window.onload = function() { 6 | _nitterFullyLoaded = true; 7 | }; 8 | 9 | var NEETSREC = { 10 | 11 | // IRecorder 12 | pollForRecording: async function() { 13 | const mainColumn = NPARSE.getMainColumn(); 14 | let polledContextOk = undefined; // see ES6.tryCureInvalidatedContext 15 | if (mainColumn) { 16 | polledContextOk = await RECORDING.pollContext(); 17 | if (polledContextOk == true) { 18 | const shouldRecord = await RECORDING.shouldRecord(); 19 | if (shouldRecord == false && _recording == true) { 20 | // need to stop recording 21 | RECORDING.stopRecording(''); 22 | } 23 | else if (shouldRecord == true && _recording == false) { 24 | // need to start recording 25 | NEETSREC.kickoffRecording(); 26 | } 27 | } 28 | } 29 | else if (NPARSE.isErrorPage() == true) { 30 | const parsedUrl = RECORDING.getParsedUrl(); 31 | if (!_isDeadThread) { 32 | // not yet reported 33 | chrome.runtime.sendMessage({ 34 | actionType: MSGTYPE.TO_POPUP.DEAD_THREAD, 35 | parsedUrl: parsedUrl 36 | }); 37 | } 38 | _isDeadThread = true; 39 | } 40 | 41 | if (polledContextOk != false) { 42 | 43 | const timerTime = _nitterFullyLoaded ? 2000 : 1000; 44 | 45 | setTimeout(() => { 46 | NEETSREC.pollForRecording(); 47 | }, timerTime); 48 | } 49 | }, 50 | 51 | // IRecorder 52 | kickoffRecording: function() { 53 | 54 | const mainColumn = NPARSE.getMainColumn(); 55 | 56 | if (!mainColumn) { 57 | return; 58 | } 59 | 60 | // stackoverflow.com/questions/57468727/when-to-disconnect-mutationobserver-in-chrome-web-extension 61 | // www.smashingmagazine.com/2019/04/mutationobserver-api-guide/ 62 | _recording = true; 63 | _autoScroll = RECORDING.calcShouldAutoScroll(); 64 | _neetObserver = new MutationObserver(NEETSREC.tweetMutationCallback); 65 | _observers.push(_neetObserver); 66 | const observerSettings = { attributes: false, childList: true, subtree: true }; 67 | // process nodes that are already there 68 | const parsedUrl = RECORDING.getParsedUrl(); 69 | NEETSREC.processForNodeScope(mainColumn, parsedUrl); 70 | // record more nodes that get added 71 | // use of 'body' is because main column can change during non-reload page update 72 | // (happens for twitter, not so much for nitter, but choosing to be consistent in approach) 73 | _neetObserver.observe(document.body, observerSettings); 74 | 75 | // periodically check for collected items to save 76 | RECORDING.setSaveTimer(); 77 | // periodically scroll (if autoScroll is on) 78 | const avoidScrollIfHidden = NEETSREC.shouldAvoidScrollIfHidden(SITE.NITTER); 79 | RECORDING.scrollAsNeeded(avoidScrollIfHidden); 80 | }, 81 | 82 | getMaxEmptyScrolls: function() { 83 | return 10; 84 | }, 85 | 86 | ensureLastItemInView: function() { 87 | // no-op for now 88 | }, 89 | 90 | getNextPageNavAnchor: function(parsedUrl) { 91 | 92 | if (parsedUrl.threadDetailId) { 93 | // on a thread detail page 94 | // we only want to click 'next page' with regard to the "main" conversation. 95 | // (we're not trying to iterate through all comment pages) 96 | return Array.from(document.querySelectorAll('.conversation .main-thread .more-replies a.more-replies-text')).find(function(a) { 97 | // as opposed to the 'earlier replies' button at the top 98 | return a.innerText == 'more replies'; 99 | }); 100 | } 101 | else { 102 | // on the main timeline stream 103 | return Array.from(document.querySelectorAll('.timeline .show-more a')).find(function(a) { 104 | // as opposed to the 'Load newest' button at the top 105 | return a.innerText == 'Load more'; 106 | }); 107 | } 108 | }, 109 | 110 | // IRecorder 111 | shouldAvoidScrollIfHidden: function() { 112 | return false; 113 | }, 114 | 115 | // IRecorder 116 | isThrottled: function() { 117 | return false; 118 | }, 119 | 120 | tryUnthrottle: function() { 121 | // no-op 122 | }, 123 | 124 | processTweets: function(scopeElm, parsedUrl) { 125 | const tweetElms = NEETPARSE.getTweetElms(scopeElm); 126 | if (!tweetElms || tweetElms.length == 0) { return; } 127 | let tweets = []; 128 | for (let i = 0; i < tweetElms.length; i++) { 129 | let tweetElm = tweetElms[i]; 130 | let tweet = NEETPARSE.buildTweetFromElm(tweetElm, parsedUrl); 131 | if (tweet) { 132 | tweets.push(tweet); 133 | } 134 | } 135 | 136 | tweets = NEETSREC.REPLY_GUY_FILTER.filter(tweets); 137 | 138 | if (tweets.length > 0) { 139 | for (let i = 0; i < tweets.length; i++) { 140 | let item = tweets[i]; 141 | let key = `nitterTweet:${item.urlKey}`.toLowerCase(); 142 | if (RECORDING.pushSavable(item, key) == true) { 143 | // mark for follow-on processing 144 | RECORDING.storeThreadExpansionUrlKeyAsNeeded(item); 145 | } 146 | } 147 | } 148 | }, 149 | 150 | // IRecorder 151 | finalizeParsedUrl: function(parsedUrl) { 152 | // no-op 153 | }, 154 | 155 | processForNodeScope: function(node, parsedUrl) { 156 | if (!ES6.isElementNode(node)) { return; } 157 | NEETSREC.processTweets(node, parsedUrl); 158 | }, 159 | 160 | // ensures that mutation callback wasn't triggered after an ajax call changed the de-facto page type 161 | // caller should ensure that it's finalized 162 | isCompatibleUrl: function(parsedUrl) { 163 | if (!parsedUrl) { return false; } 164 | // see RECORDERFACTORY.getRecorder 165 | switch(parsedUrl.pageType) { 166 | case PAGETYPE.TWITTER.HOME: 167 | case PAGETYPE.TWITTER.SEARCH: 168 | case PAGETYPE.TWITTER.TWEETS: 169 | return true; 170 | default: 171 | return false; 172 | } 173 | }, 174 | 175 | // in case page type changes during processing, we don't want the page to stay "stuck" until next refresh 176 | tweetMutationCallback: function(mutations) { 177 | try { 178 | NEETSREC.mutationCallbackWorker(mutations); 179 | } 180 | catch (ex) { 181 | console.log('Nitter recorder error'); 182 | console.log(ex); 183 | console.trace(); 184 | } 185 | }, 186 | 187 | mutationCallbackWorker: function(mutations) { 188 | const parsedUrl = URLPARSE.parseUrl(document.location.href); 189 | if (NEETSREC.isCompatibleUrl(parsedUrl)) { 190 | const mainColumn = TPARSE.getMainColumn(); 191 | for (let mutation of mutations) { 192 | if (mutation.type === 'childList') { 193 | let nodes = mutation.addedNodes; 194 | for (let i = 0; i < nodes.length; i++) { 195 | let node = nodes[i]; 196 | if (ES6.isElmaWithinElmB(node, mainColumn) == true) { 197 | NEETSREC.processForNodeScope(node, parsedUrl); 198 | } 199 | } 200 | } 201 | } 202 | } 203 | }, 204 | 205 | onSaved: function(records) { 206 | RECORDING.onSavedPosts(records); 207 | }, 208 | 209 | REPLY_GUY_FILTER: { 210 | // note that twitter operates on dom elements, whereas this filters tweets 211 | filter: function(tweets) { 212 | if (tweets.length == 0) { return tweets; } 213 | const shouldFilter = RECORDING.calcShouldMinRecordedReplies(); 214 | if (!shouldFilter) { return tweets; } 215 | const keepers = []; 216 | // top-most tweet is used as basis for comparison (on author) with subsequent tweets 217 | const topTweet = tweets[0]; 218 | const topElmAuthor = STR.getAuthorFromUrlKey(topTweet.urlKey); 219 | 220 | const pageOwner = URLPARSE.getActivePageOwner(); 221 | for (let i = 0; i < tweets.length; i++) { 222 | let tweet = tweets[i]; 223 | let author = STR.getAuthorFromUrlKey(tweet.urlKey); 224 | if (STR.sameText(author, topElmAuthor) || STR.sameText(author, pageOwner)) { 225 | keepers.push(tweet); 226 | } 227 | } 228 | 229 | return keepers; 230 | } 231 | } 232 | }; -------------------------------------------------------------------------------- /src/lib/content/recorderfactory.js: -------------------------------------------------------------------------------- 1 | /* 2 | returns: 3 | 4 | IRecorder { 5 | void pollForRecording(); 6 | bool shouldAvoidScrollIfHidden(); 7 | void kickoffRecording(); 8 | bool isThrottled(); 9 | void tryUnthrottle(); 10 | getNextPageNavAnchor(parsedUrl); 11 | void ensureLastItemInView(); 12 | int getMaxEmptyScrolls(); 13 | void onSaved(entities); 14 | } 15 | 16 | */ 17 | 18 | var RECORDERFACTORY = { 19 | 20 | getRecorder: function(parsedUrl) { 21 | switch(parsedUrl.pageType) { 22 | case PAGETYPE.TWITTER.FOLLOWERS: 23 | case PAGETYPE.TWITTER.FOLLOWING: 24 | return TFOLLOWREC; 25 | case PAGETYPE.TWITTER.HOME: 26 | case PAGETYPE.TWITTER.SEARCH: 27 | case PAGETYPE.TWITTER.TWEETS: 28 | switch (parsedUrl.site) { 29 | case SITE.NITTER: 30 | return NEETSREC; 31 | default: 32 | return TWEETSREC; 33 | } 34 | default: 35 | return undefined; 36 | } 37 | } 38 | }; -------------------------------------------------------------------------------- /src/lib/content/twitter/twitterfollowparsing.js: -------------------------------------------------------------------------------- 1 | /******************************************************/ 2 | // parsing twitter follow lists 3 | // see connsaver.js for Person schema 4 | /******************************************************/ 5 | 6 | var TFOLLOWPARSE = { 7 | 8 | // The twitter profile photo points upward to anchor with href of '/myhandle' 9 | // then upward to div with data-testid of UserCell. 10 | // The UserCell has two anchor elements (other than the img anchor), the first for DisplayName and the next with Handle (myhandle). 11 | // So we can grab everything using this photo node. 12 | buildTwitterFollowFromPhoto: function(img, parsedUrl) { 13 | const imgSrc = img.getAttribute('src'); 14 | const imgAnchor = ES6.findUpTag(img, 'a', false); 15 | const profileUrl = imgAnchor.getAttribute('href'); 16 | const atHandle = TPARSE.twitterHandleFromProfileUrl(profileUrl); 17 | const userCell = TFOLLOWPARSE.findUpTwitterUserCell(img); 18 | // one is handle, one is description 19 | 20 | const textAnchors = Array.from(userCell.getElementsByTagName('a')).filter(function(a) { return a != imgAnchor; }); 21 | 22 | const displayNameAnchor = textAnchors.find(function(a) { 23 | return a.innerText && a.innerText.length > 0 && 24 | a.innerText.toLowerCase() != atHandle.toLowerCase(); 25 | }); 26 | 27 | const displayName = ES6.getUnfurledText(displayNameAnchor); 28 | const description = TFOLLOWPARSE.getTwitterProfileDescription(displayNameAnchor); 29 | 30 | // include the @ symbol 31 | // see PERSON_ATTR for Person schema 32 | const person = { 33 | handle: atHandle, 34 | displayName: displayName, 35 | description: description, 36 | pageType: parsedUrl.pageType, 37 | owner: STR.ensurePrefix(parsedUrl.owner, '@'), 38 | imgCdnUrl: imgSrc 39 | }; 40 | 41 | person.accounts = STR.extractAccounts([person.displayName, person.description]); 42 | return person; 43 | }, 44 | 45 | // from an element within a twitter profile list item, navigate up to the parent 'UserCell' 46 | findUpTwitterUserCell: function(el) { 47 | while (el.parentNode) { 48 | el = el.parentNode; 49 | if (ES6.isElementNode(el)) { 50 | let role = el.getAttribute('data-testid'); 51 | if (role === 'UserCell') { 52 | return el; 53 | } 54 | } 55 | } 56 | return null; 57 | }, 58 | 59 | findTwitterDescriptionWithinUserCell: function(cell) { 60 | const div = cell.lastElementChild.lastElementChild.lastElementChild; 61 | const dirAttr = div.getAttribute('dir'); 62 | 63 | if (dirAttr === 'auto') { 64 | return div; 65 | } 66 | else { 67 | return null; 68 | } 69 | }, 70 | 71 | getTwitterProfileDescription: function(displayNameAnchorElm) { 72 | if (!displayNameAnchorElm) { return null; } 73 | const parentCell = TFOLLOWPARSE.findUpTwitterUserCell(displayNameAnchorElm); 74 | if (!parentCell) { return null; } 75 | const descripElm = TFOLLOWPARSE.findTwitterDescriptionWithinUserCell(parentCell); 76 | if (!descripElm) { return null; } 77 | let text = ES6.getUnfurledText(descripElm); 78 | return text; 79 | } 80 | 81 | }; -------------------------------------------------------------------------------- /src/lib/content/twitter/twitterfollowrecorder.js: -------------------------------------------------------------------------------- 1 | // see IRecorder defined at recorderfactory 2 | // see state variables at recordinglib 3 | 4 | // specific observers are held in _observers and also referenced by specific recorders 5 | var _followObserver; 6 | 7 | var TFOLLOWREC = { 8 | 9 | // IRecorder 10 | pollForRecording: async function() { 11 | const mainColumn = TPARSE.getMainColumn(); 12 | let polledContextOk = undefined; // see ES6.tryCureInvalidatedContext 13 | if (mainColumn) { 14 | polledContextOk = await RECORDING.pollContext(); 15 | if (polledContextOk == true) { 16 | const shouldRecord = await RECORDING.shouldRecord(); 17 | 18 | if (shouldRecord == false && _recording == true) { 19 | // need to stop recording 20 | RECORDING.stopRecording(''); 21 | } 22 | else if (shouldRecord == true && _recording == false) { 23 | // need to start recording 24 | TFOLLOWREC.kickoffRecording(); 25 | } 26 | } 27 | } 28 | 29 | if (polledContextOk != false) { 30 | setTimeout(() => { 31 | TFOLLOWREC.pollForRecording(); 32 | }, 2000); 33 | } 34 | }, 35 | 36 | // IRecorder 37 | kickoffRecording: function() { 38 | 39 | const mainColumn = TPARSE.getMainColumn(); 40 | if (!mainColumn) { 41 | return; 42 | } 43 | 44 | // stackoverflow.com/questions/57468727/when-to-disconnect-mutationobserver-in-chrome-web-extension 45 | // www.smashingmagazine.com/2019/04/mutationobserver-api-guide/ 46 | _recording = true; 47 | _autoScroll = RECORDING.calcShouldAutoScroll(); 48 | _followObserver = new MutationObserver(TFOLLOWREC.twitterFollowMutationCallback); 49 | _observers.push(_followObserver); 50 | const observerSettings = { attributes: false, childList: true, subtree: true }; 51 | _followObserver.observe(document.body, observerSettings); 52 | TFOLLOWREC.processTwitterFollows(mainColumn); 53 | 54 | // periodically check for collected items to save 55 | RECORDING.setSaveTimer(); 56 | // periodically scroll (if autoScroll is on) 57 | const avoidScrollIfHidden = TFOLLOWREC.shouldAvoidScrollIfHidden(SITE.TWITTER); 58 | RECORDING.scrollAsNeeded(avoidScrollIfHidden); 59 | }, 60 | 61 | // IRecorder 62 | // n/a (this is infinite scroll) 63 | getNextPageNavAnchor: function(parsedUrl) { 64 | return null; 65 | }, 66 | 67 | // IRecorder 68 | shouldAvoidScrollIfHidden: function() { 69 | return true; 70 | }, 71 | 72 | // IRecorder 73 | isThrottled: function() { 74 | return TPARSE.isThrottled(); 75 | }, 76 | 77 | tryUnthrottle: function() { 78 | const retryBtn = TPARSE.getThrottledRetryElem(); 79 | if (retryBtn) { 80 | retryBtn.click(); 81 | } 82 | }, 83 | 84 | getMaxEmptyScrolls: function() { 85 | return 4; 86 | }, 87 | 88 | ensureLastItemInView: function() { 89 | // no-op for now 90 | }, 91 | 92 | processTwitterFollows: function(scopeElm) { 93 | const parsedUrl = RECORDING.getParsedUrl(); 94 | const photos = TPARSE.getTwitterProfilePhotos(scopeElm); 95 | const ppl = []; 96 | 97 | for (let i = 0; i < photos.length; i++) { 98 | let photo = photos[i]; 99 | let per = TFOLLOWPARSE.buildTwitterFollowFromPhoto(photo, parsedUrl); 100 | ppl.push(per); 101 | } 102 | 103 | if (ppl.length > 0) { 104 | for (let i = 0; i < ppl.length; i++) { 105 | let item = ppl[i]; 106 | let key = `${item.handle}-${item.owner}-${item.pageType}`.toLowerCase(); 107 | item.key = key; 108 | if (!_savableKeySet.has(key) && !_savedKeySet.has(key)) { 109 | _savables.push(item); 110 | _savableKeySet.add(key); 111 | _lastDiscoveryTime = Date.now(); 112 | } 113 | } 114 | } 115 | }, 116 | 117 | // IRecorder 118 | finalizeParsedUrl: function(parsedUrl) { 119 | // no-op 120 | }, 121 | 122 | // ensures that mutation callback wasn't triggered after an ajax call changed the de-facto page type 123 | // caller should ensure that it's finalized 124 | isCompatibleUrl: function(parsedUrl) { 125 | if (!parsedUrl) { return false; } 126 | // see RECORDERFACTORY.getRecorder 127 | switch(parsedUrl.pageType) { 128 | case PAGETYPE.TWITTER.FOLLOWERS: 129 | case PAGETYPE.TWITTER.FOLLOWING: 130 | return true; 131 | default: 132 | return false; 133 | } 134 | }, 135 | 136 | twitterFollowMutationCallback: function(mutations) { 137 | try { 138 | TFOLLOWREC.mutationCallbackWorker(mutations); 139 | } 140 | catch (ex) { 141 | console.log('Twitter follow recorder error'); 142 | console.log(ex); 143 | console.trace(); 144 | } 145 | }, 146 | 147 | 148 | mutationCallbackWorker: function(mutations) { 149 | const parsedUrl = RECORDING.getParsedUrl(); 150 | // if e.g. the page context changed to 'notifications' (and had been recording), we will end up with an undefined parsedUrl and should exit 151 | if (TFOLLOWREC.isCompatibleUrl(parsedUrl)) { 152 | const mainColumn = TPARSE.getMainColumn(); 153 | for (let mutation of mutations) { 154 | if (mutation.type === 'childList') { 155 | let nodes = mutation.addedNodes; 156 | for (let i = 0; i < nodes.length; i++) { 157 | let node = nodes[i]; 158 | 159 | if (ES6.isElmaWithinElmB(node, mainColumn) == true) { 160 | if (TPARSE.isTwitterProfilePhoto(node)) { 161 | TFOLLOWREC.processTwitterFollows(node); 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | }, 169 | 170 | onSaved: function(records) { 171 | // no-op 172 | } 173 | 174 | }; -------------------------------------------------------------------------------- /src/lib/content/twitter/twitterparsing.js: -------------------------------------------------------------------------------- 1 | /******************************************************/ 2 | // parsing twitter (general) - not specific to follows 3 | /******************************************************/ 4 | var TPARSE = { 5 | 6 | PROFILE_IMAGES_HINT: 'profile_images/', 7 | 8 | getHomePageOwnerHandle: function() { 9 | return ES6.SPECIAL.getTwitterHomePageOwnerHandle(); 10 | }, 11 | 12 | getMainColumn: function(warn) { 13 | const elms = document.querySelectorAll('div[data-testid="primaryColumn"]'); 14 | 15 | if (elms && elms.length === 1) { 16 | return elms[0]; 17 | } 18 | else { 19 | if (warn === true) { 20 | console.warn('Cannot find twitter main column; page structure may have changed.'); 21 | } 22 | } 23 | }, 24 | 25 | isDeadThread: function() { 26 | const telltale = Array.from(document.querySelectorAll('article a[href*=rules-and-policies]')) 27 | .find(function(elm) { 28 | return elm.textContent == 'Learn more'; 29 | }); 30 | 31 | if (telltale) { 32 | return Array.from(document.querySelectorAll('article')).length == 1; 33 | } 34 | else { 35 | return false; 36 | } 37 | }, 38 | 39 | isThrottled: function() { 40 | const elm = TPARSE.getThrottledRetryElem(); 41 | return elm != undefined && elm != null; 42 | }, 43 | 44 | // when throttled, a button labeled 'Retry' appears beneath the text 'Something went wrong. Try reloading.' 45 | getThrottledRetryElem: function() { 46 | const col = TPARSE.getMainColumn(); 47 | if (!col) { return undefined; } 48 | const cells = col.querySelectorAll('div[data-testid="cellInnerDiv"]'); 49 | if (!cells || cells.length === 0) { return undefined; } 50 | const lastCell = cells[cells.length-1]; 51 | if (lastCell.innerText && lastCell.innerText.startsWith('Something went wrong.')) { 52 | const btn = lastCell.querySelector('div[role="button"]'); 53 | if (btn && btn.innerText === 'Retry') { 54 | return btn; 55 | } 56 | else { 57 | return undefined; 58 | } 59 | } 60 | else { 61 | return undefined; 62 | } 63 | }, 64 | 65 | getTwitterProfilePhotos: function(scopeElem) { 66 | if (TPARSE.isTwitterProfilePhoto(scopeElem)) { 67 | return [scopeElem]; 68 | } 69 | else { 70 | // all img elms with src that starts with the tell-tale prefix 71 | return Array.from(scopeElem.querySelectorAll(`img[src*="${TPARSE.PROFILE_IMAGES_HINT}"]`)); 72 | } 73 | }, 74 | 75 | isTwitterProfilePhoto: function(elm) { 76 | const isPhoto = elm && STR.sameText(elm.tagName, 'img') && elm.getAttribute('src').includes(TPARSE.PROFILE_IMAGES_HINT); 77 | return isPhoto; 78 | }, 79 | 80 | twitterHandleFromProfileUrl: function(url) { 81 | let trimmed = url.startsWith('/') ? url.substring(1) : url; 82 | 83 | if (!trimmed.startsWith('@')) { 84 | trimmed = '@' + trimmed; 85 | } 86 | 87 | return trimmed; 88 | }, 89 | 90 | isTweetElm: function(elm) { 91 | const isTweet = elm && STR.sameText(elm.tagName, 'article') && STR.sameText(elm.getAttribute('data-testid'), 'tweet'); 92 | return isTweet; 93 | }, 94 | 95 | getTweetCardImgElms: function(scopeElem) { 96 | if (TPARSE.isTweetCardImgElm(scopeElem)) { 97 | return [scopeElem]; 98 | } 99 | else { 100 | return Array.from(scopeElem.querySelectorAll('div[data-testid="card.wrapper"] img')); 101 | } 102 | }, 103 | 104 | isTweetCardImgElm: function(elm) { 105 | const isImg = elm && STR.sameText(elm.tagName, 'img'); 106 | if (!isImg) { return false; } 107 | const parent = ES6.findUpByAttrValue(elm, 'data-testid', 'card.wrapper'); 108 | const isValid = (isImg == true && parent); 109 | return isValid; 110 | }, 111 | 112 | // when there's only a placeholder (ugly) svg instead of a real img 113 | getTweetCardSvgElms: function(scopeElem) { 114 | if (TPARSE.isTweetCardSvgElm(scopeElem)) { 115 | return [scopeElem]; 116 | } 117 | else { 118 | return Array.from(scopeElem.querySelectorAll('div[data-testid="card.wrapper"] svg')); 119 | } 120 | }, 121 | 122 | isTweetCardSvgElm: function(elm) { 123 | const isSvg = elm && STR.sameText(elm.tagName, 'svb'); 124 | if (!isSvg) { return false; } 125 | const parent = ES6.findUpByAttrValue(elm, 'data-testid', 'card.wrapper'); 126 | const isValid = (isImg == true && parent); 127 | return isValid; 128 | }, 129 | 130 | getTweetPostImgElms: function(scopeElem) { 131 | if (TPARSE.isTweetPostImgElm(scopeElem)) { 132 | return [scopeElem]; 133 | } 134 | else { 135 | return Array.from(scopeElem.querySelectorAll('div[data-testid="tweetPhoto"] img')).filter(function(img) { 136 | // handle video thumbnails separately 137 | return img.src.indexOf('video_thumb') < 0; 138 | }); 139 | } 140 | }, 141 | 142 | isTweetPostImgElm: function(elm) { 143 | const isImg = elm && STR.sameText(elm.tagName, 'img'); 144 | if (!isImg) { return false; } 145 | const parent = ES6.findUpByAttrValue(elm, 'data-testid', 'tweetPhoto'); 146 | const isValid = (isImg == true && parent && elm.src.indexOf('video_thumb') < 0); 147 | return isValid; 148 | }, 149 | 150 | getTweetPostEmbeddedVideoElms: function(scopeElem) { 151 | if (TPARSE.isTweetPostEmbeddedVideoElm(scopeElem)) { 152 | return [scopeElem]; 153 | } 154 | else { 155 | return Array.from(scopeElem.querySelectorAll('div[data-testid="tweetPhoto"] video')); 156 | } 157 | }, 158 | 159 | isTweetPostEmbeddedVideoElm: function(elm) { 160 | const isVideo = elm && STR.sameText(elm.tagName, 'video'); 161 | if (!isVideo) { return false; } 162 | const parent = ES6.findUpByAttrValue(elm, 'data-testid', 'tweetPhoto'); 163 | const isValid = (isVideo == true && parent); 164 | return isValid; 165 | }, 166 | 167 | getTweetAuthorImgElms: function(scopeElem) { 168 | if (TPARSE.isTweetAuthorImgElm(scopeElem)) { 169 | return [scopeElem]; 170 | } 171 | else { 172 | return Array.from(scopeElem.querySelectorAll('div[data-testid="Tweet-User-Avatar"] img')); 173 | } 174 | }, 175 | 176 | isTweetAuthorImgElm: function(elm) { 177 | const isImg = elm && STR.sameText(elm.tagName, 'img'); 178 | if (!isImg) { return false; } 179 | const avatarParent = ES6.findUpByAttrValue(elm, 'data-testid', 'Tweet-User-Avatar'); 180 | const isValid = (isImg == true && avatarParent); 181 | return isValid; 182 | } 183 | 184 | }; -------------------------------------------------------------------------------- /src/lib/dbui/favoritingui.js: -------------------------------------------------------------------------------- 1 | var FAVORITING_UI = { 2 | configureFavoriting: function(a) { 3 | a.onclick = function(event) { 4 | const pageType = QUERYING_UI.PAGE_TYPE.getPageTypeFromUi(); 5 | const site = PAGETYPE.getSite(pageType); 6 | 7 | const handle = a.getAttribute('data-testid'); 8 | const atHandle = STR.ensurePrefix(handle, '@'); 9 | const iconElm = a.querySelector('i'); 10 | 11 | const alreadyFavorited = iconElm.classList.contains(RENDER.CLS.STAR_ON_CLS); 12 | let removeFromFavorites; 13 | if (alreadyFavorited) { 14 | // toggle to not-favorite 15 | FAVORITING_UI.applyToAll(handle, false); 16 | removeFromFavorites = true; 17 | } 18 | else { 19 | // toggle to is-favorite 20 | FAVORITING_UI.applyToAll(handle, true); 21 | removeFromFavorites = false; 22 | } 23 | 24 | // tell the db (see DBORM.setListMember) 25 | const msg = { 26 | actionType: MSGTYPE.TODB.SET_LIST_MEMBER, 27 | list: LIST_FAVORITES, 28 | member: atHandle, 29 | site: site, 30 | removal: removeFromFavorites 31 | }; 32 | 33 | _worker.postMessage(msg); 34 | return false; 35 | }; 36 | }, 37 | 38 | // applies star status to not just this anchor, but all its brethren 39 | applyToAll: function(handle, starIt) { 40 | const anchors = Array.from(document.querySelectorAll('a.canstar')).filter(function(a) { 41 | return a.getAttribute('data-testid') == handle; 42 | }); 43 | 44 | for (let i = 0; i < anchors.length; i++) { 45 | let anchor = anchors[i]; 46 | let iconElm = anchor.querySelector('i'); 47 | if (starIt == true) { 48 | iconElm.classList.remove(RENDER.CLS.STAR_OFF_CLS); 49 | iconElm.classList.add(RENDER.CLS.STAR_ON_CLS); 50 | } 51 | else { 52 | iconElm.classList.remove(RENDER.CLS.STAR_ON_CLS) 53 | iconElm.classList.add(RENDER.CLS.STAR_OFF_CLS); 54 | } 55 | } 56 | } 57 | }; -------------------------------------------------------------------------------- /src/lib/dbui/ghbackupui.js: -------------------------------------------------------------------------------- 1 | var GHBACKUP_UI = { 2 | bindElements: function() { 3 | Array.from(document.querySelectorAll('#exportFilterSection input[type="checkbox"]')).forEach(function(input) { 4 | input.addEventListener('change', (event) => { 5 | GHBACKUP_UI.saveBackupSettingsFromUi(); 6 | }); 7 | }); 8 | 9 | Array.from(document.querySelectorAll('#exportFilterSection input[type="text"]')).forEach(function(input) { 10 | input.addEventListener('blur', (event) => { 11 | GHBACKUP_UI.saveBackupSettingsFromUi(); 12 | }); 13 | }); 14 | 15 | const btnDismissGhMfaTip = document.getElementById('btnDismissGhMfaTip'); 16 | btnDismissGhMfaTip.onclick = function(event) { 17 | document.getElementById('ghMfaSection').classList.add('d-none'); 18 | SETTINGS.GITHUB.setDismissedMfaNote(); 19 | return false; 20 | }; 21 | 22 | const btnSwitchToConfigVideos = document.getElementById('btnSwitchToConfigVideos'); 23 | btnSwitchToConfigVideos.onclick = async function(event) { 24 | GHCONFIG_UI.setGithubConfigRepoTypeTab(GITHUB.REPO_TYPE.VIDEOS); 25 | await TABS_UI.SYNC.activateGhConfigureTab(); 26 | return false; 27 | } 28 | 29 | const btnGhBkpStart = document.getElementById('btnGhBkpStart'); 30 | btnGhBkpStart.onclick = async function(event) { 31 | GHBACKUP_UI.saveBackupSettingsFromUi(); 32 | btnGhBkpPause.classList.remove('d-none'); 33 | btnGhBkpStart.classList.add('d-none'); 34 | btnGhBkpRestart.classList.add('d-none'); 35 | await SYNCFLOW.resumeSync(SYNCFLOW.DIRECTION.BACKUP); 36 | return false; 37 | }; 38 | 39 | const btnGhBkpPause = document.getElementById('btnGhBkpPause'); 40 | btnGhBkpPause.onclick = function(event) { 41 | btnGhBkpPause.classList.add('d-none'); 42 | btnGhBkpStart.classList.remove('d-none'); 43 | btnGhBkpRestart.classList.remove('d-none'); 44 | SYNCFLOW.pauseSync(SYNCFLOW.DIRECTION.BACKUP); 45 | return false; 46 | }; 47 | 48 | const btnGhBkpRestart = document.getElementById('btnGhBkpRestart'); 49 | btnGhBkpRestart.onclick = async function(event) { 50 | GHBACKUP_UI.saveBackupSettingsFromUi(); 51 | btnGhBkpPause.classList.remove('d-none'); 52 | btnGhBkpStart.classList.add('d-none'); 53 | btnGhBkpRestart.classList.add('d-none'); 54 | await SYNCFLOW.startOverSync(SYNCFLOW.DIRECTION.BACKUP); 55 | return false; 56 | }; 57 | }, 58 | 59 | renderSyncBackupStatus: async function(status) { 60 | const hideTip = SETTINGS.GITHUB.getDismissedMfaNote(); 61 | const tipElm = document.getElementById('ghMfaSection'); 62 | if (hideTip == true) { 63 | tipElm.classList.add('d-none'); 64 | } 65 | else { 66 | tipElm.classList.remove('d-none'); 67 | } 68 | 69 | status = status || SYNCFLOW.buildStatus(SYNCFLOW.DIRECTION.BACKUP); 70 | const statusElm = document.getElementById('ghBackupStatusMsg'); 71 | statusElm.textContent = status.msg; 72 | const checkElm = document.getElementById('ghBackupStatusCheck'); 73 | const exclamElm = document.getElementById('ghBackupStatusFail'); 74 | if (status.ok === true) { 75 | checkElm.classList.remove('d-none'); 76 | exclamElm.classList.add('d-none'); 77 | } 78 | else if (status.ok === false) { 79 | checkElm.classList.add('d-none'); 80 | exclamElm.classList.remove('d-none'); 81 | } 82 | else { 83 | checkElm.classList.add('d-none'); 84 | exclamElm.classList.add('d-none'); 85 | } 86 | 87 | const skipIdenticalElm = statusElm.parentNode.querySelector('.priorSkipped'); 88 | if (status.priorStepIdentical == true) { 89 | skipIdenticalElm.classList.remove('d-none'); 90 | } 91 | else { 92 | skipIdenticalElm.classList.add('d-none'); 93 | } 94 | 95 | // special handling of _backupStartedThisSession is in case running was calculated as true based on last step being very recent 96 | // but guarding against the case where the user hit F5 (and so it isn't actually running in this session) 97 | if (status.running === true && _backupStartedThisSession == true) { 98 | btnGhBkpPause.classList.remove('d-none'); 99 | btnGhBkpStart.classList.add('d-none'); 100 | btnGhBkpRestart.classList.add('d-none'); 101 | } 102 | else { 103 | GHBACKUP_UI.reflectBackupSettings(); 104 | btnGhBkpPause.classList.add('d-none'); 105 | btnGhBkpStart.classList.remove('d-none'); 106 | 107 | if (status.ok === true || status.msg == SYNCFLOW.START_MSG) { 108 | // we're at the beginning, so start and restart mean the same thing 109 | btnGhBkpRestart.classList.add('d-none'); 110 | } 111 | else { 112 | btnGhBkpRestart.classList.remove('d-none'); 113 | } 114 | } 115 | 116 | // Videos section 117 | const hasVideoConfig = await SETTINGS.GITHUB.hasSyncToken(GITHUB.REPO_TYPE.VIDEOS); 118 | GHBACKUP_UI.unveilUploaderAsNeeded(hasVideoConfig); 119 | }, 120 | 121 | unveilUploaderAsNeeded: function(hasVideoRepoConn) { 122 | const needVideoConnElm = document.getElementById('needVideoConn'); 123 | const uploaduiElm = document.getElementById('uploadui'); 124 | if (hasVideoRepoConn) { 125 | needVideoConnElm.classList.add('d-none'); 126 | uploaduiElm.classList.remove('d-none'); 127 | } 128 | else { 129 | needVideoConnElm.classList.remove('d-none'); 130 | uploaduiElm.classList.add('d-none'); 131 | } 132 | }, 133 | 134 | saveBackupSettingsFromUi: function() { 135 | const config = {}; 136 | const ns = SETTINGS.SYNCFLOW.CONFIG; 137 | config[ns.WITH_FAVORITES] = GHBACKUP_UI.getBackupSettingFromUi(ns.WITH_FAVORITES, 'optExportWithFavorites'); 138 | config[ns.WITH_PROFILES] = GHBACKUP_UI.getBackupSettingFromUi(ns.WITH_PROFILES, 'optExportWithProfiles'); 139 | config[ns.WITH_AVATARS] = GHBACKUP_UI.getBackupSettingFromUi(ns.WITH_AVATARS, 'optExportWithAvatars'); 140 | config[ns.WITH_NETWORKS] = GHBACKUP_UI.getBackupSettingFromUi(ns.WITH_NETWORKS, 'optExportWithNetworks'); 141 | config[ns.WITH_TOPICS] = GHBACKUP_UI.getBackupSettingFromUi(ns.WITH_TOPICS, 'optExportWithTopics'); 142 | config[ns.WITH_POSTS] = GHBACKUP_UI.getBackupSettingFromUi(ns.WITH_POSTS, 'optExportWithPosts'); 143 | config[ns.WITH_POST_IMAGES] = GHBACKUP_UI.getBackupSettingFromUi(ns.WITH_POST_IMAGES, 'optExportWithPostImages'); 144 | config[ns.DO_TWITTER] = GHBACKUP_UI.getBackupSettingFromUi(ns.DO_TWITTER, 'optExportTwitter'); 145 | config[ns.DO_MASTODON] = GHBACKUP_UI.getBackupSettingFromUi(ns.DO_MASTODON, 'optExportMastodon'); 146 | config[ns.AUTHOR_FILTER] = GHBACKUP_UI.getBackupSettingFromUi(ns.AUTHOR_FILTER, 'optExportForAuthor') || ''; 147 | config[ns.POSTED_FROM] = GHBACKUP_UI.getBackupSettingFromUi(ns.POSTED_FROM, 'optExportPostsFrom') || ''; 148 | config[ns.POSTED_UNTIL] = GHBACKUP_UI.getBackupSettingFromUi(ns.POSTED_UNTIL, 'optExportPostsUntil') || ''; 149 | // radio button 150 | config[ns.OVERWRITE] = GHBACKUP_UI.getBackupSettingFromUi(ns.OVERWRITE); 151 | SETTINGS.SYNCFLOW.BACKUP.saveExportConfig(config); 152 | }, 153 | 154 | reflectBackupSettings: function() { 155 | const config = SETTINGS.SYNCFLOW.BACKUP.getExportConfig(); 156 | const ns = SETTINGS.SYNCFLOW.CONFIG; 157 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.WITH_FAVORITES, 'optExportWithFavorites'); 158 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.WITH_PROFILES, 'optExportWithProfiles'); 159 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.WITH_AVATARS, 'optExportWithAvatars'); 160 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.WITH_NETWORKS, 'optExportWithNetworks'); 161 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.WITH_TOPICS, 'optExportWithTopics'); 162 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.WITH_POSTS, 'optExportWithPosts'); 163 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.WITH_POST_IMAGES, 'optExportWithPostImages'); 164 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.DO_TWITTER, 'optExportTwitter'); 165 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.DO_MASTODON, 'optExportMastodon'); 166 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.AUTHOR_FILTER, 'optExportForAuthor'); 167 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.POSTED_FROM, 'optExportPostsFrom'); 168 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.POSTED_UNTIL, 'optExportPostsUntil'); 169 | GHBACKUP_UI.reflectBackupSettingInUi(config, ns.OVERWRITE); 170 | }, 171 | 172 | getBackupSettingFromUi: function(setting, elmId) { 173 | // radio button handled separately 174 | if (setting == SETTINGS.SYNCFLOW.CONFIG.OVERWRITE) { 175 | return document.getElementById('optExportWithOverwrite').checked; 176 | } 177 | 178 | const elm = document.getElementById(elmId); 179 | if (elm.type == 'checkbox') { 180 | return elm.checked; 181 | } 182 | else if (setting == SETTINGS.SYNCFLOW.CONFIG.AUTHOR_FILTER) { 183 | return elm.value; 184 | } 185 | else if (setting == SETTINGS.SYNCFLOW.CONFIG.POSTED_FROM || setting == SETTINGS.SYNCFLOW.CONFIG.POSTED_UNTIL) { 186 | if (!STR.hasLen(elm.value)) { 187 | return null; 188 | } 189 | else { 190 | return STR.dateFromMmDdYyyy(elm.value, true); 191 | } 192 | } 193 | }, 194 | 195 | reflectBackupSettingInUi: function(config, setting, elmId) { 196 | if (setting == SETTINGS.SYNCFLOW.CONFIG.OVERWRITE) { 197 | const shouldOverwrite = STR.isTruthy(config[setting]); 198 | document.getElementById('optExportWithOverwrite').checked = shouldOverwrite; 199 | document.getElementById('optExportWithMerge').checked = !shouldOverwrite; 200 | return; 201 | } 202 | 203 | const elm = document.getElementById(elmId); 204 | if (elm.type == 'checkbox') { 205 | elm.checked = STR.isTruthy(config[setting]); 206 | } 207 | else if (setting == SETTINGS.SYNCFLOW.CONFIG.AUTHOR_FILTER) { 208 | elm.value = STR.hasLen(config[setting]) ? config[setting] : ''; 209 | } 210 | else if (setting == SETTINGS.SYNCFLOW.CONFIG.POSTED_FROM || setting == SETTINGS.SYNCFLOW.CONFIG.POSTED_UNTIL) { 211 | if (!STR.hasLen(config[setting])) { 212 | elm.value = ''; 213 | } 214 | else { 215 | const dt = new Date(config[setting]); 216 | if (isNaN(dt)) { 217 | elm.value = ''; 218 | } 219 | else { 220 | elm.value = `${dt.getMonth() + 1}/${dt.getDate()}/${dt.getFullYear()}`; 221 | } 222 | } 223 | } 224 | } 225 | }; -------------------------------------------------------------------------------- /src/lib/dbui/ghrestoreui.js: -------------------------------------------------------------------------------- 1 | var GHRESTORE_UI = { 2 | bindElements: function() { 3 | Array.from(document.querySelectorAll('#importFilterSection input[type="checkbox"]')).forEach(function(input) { 4 | input.addEventListener('change', (event) => { 5 | GHRESTORE_UI.saveRestoreSettingsFromUi(); 6 | }); 7 | }); 8 | 9 | Array.from(document.querySelectorAll('#importFilterSection input[type="text"]')).forEach(function(input) { 10 | input.addEventListener('blur', (event) => { 11 | GHRESTORE_UI.saveRestoreSettingsFromUi(); 12 | }); 13 | }); 14 | 15 | const btnGhRestoreStart = document.getElementById('btnGhRestoreStart'); 16 | btnGhRestoreStart.onclick = async function(event) { 17 | GHRESTORE_UI.saveRestoreSettingsFromUi(); 18 | btnGhRestorePause.classList.remove('d-none'); 19 | btnGhRestoreStart.classList.add('d-none'); 20 | btnGhRestoreRestart.classList.add('d-none'); 21 | await SYNCFLOW.resumeSync(SYNCFLOW.DIRECTION.RESTORE); 22 | return false; 23 | }; 24 | 25 | const btnGhRestorePause = document.getElementById('btnGhRestorePause'); 26 | btnGhRestorePause.onclick = function(event) { 27 | btnGhRestorePause.classList.add('d-none'); 28 | btnGhRestoreStart.classList.remove('d-none'); 29 | btnGhRestoreRestart.classList.remove('d-none'); 30 | SYNCFLOW.pauseSync(SYNCFLOW.DIRECTION.RESTORE); 31 | return false; 32 | }; 33 | 34 | const btnGhRestoreRestart = document.getElementById('btnGhRestoreRestart'); 35 | btnGhRestoreRestart.onclick = async function(event) { 36 | GHRESTORE_UI.saveRestoreSettingsFromUi(); 37 | btnGhRestorePause.classList.remove('d-none'); 38 | btnGhRestoreStart.classList.add('d-none'); 39 | btnGhRestoreRestart.classList.add('d-none'); 40 | await SYNCFLOW.startOverSync(SYNCFLOW.DIRECTION.RESTORE); 41 | return false; 42 | }; 43 | }, 44 | 45 | renderSyncRestoreStatus: async function(status) { 46 | status = status || SYNCFLOW.buildStatus(SYNCFLOW.DIRECTION.RESTORE); 47 | const statusElm = document.getElementById('ghRestoreStatusMsg'); 48 | statusElm.textContent = status.msg; 49 | const checkElm = document.getElementById('ghRestoreStatusCheck'); 50 | const exclamElm = document.getElementById('ghRestoreStatusFail'); 51 | if (status.ok === true) { 52 | checkElm.classList.remove('d-none'); 53 | exclamElm.classList.add('d-none'); 54 | } 55 | else if (status.ok === false) { 56 | checkElm.classList.add('d-none'); 57 | exclamElm.classList.remove('d-none'); 58 | } 59 | else { 60 | checkElm.classList.add('d-none'); 61 | exclamElm.classList.add('d-none'); 62 | } 63 | 64 | const skipIdenticalElm = statusElm.parentNode.querySelector('.priorSkipped'); 65 | if (status.priorStepIdentical == true) { 66 | skipIdenticalElm.classList.remove('d-none'); 67 | } 68 | else { 69 | skipIdenticalElm.classList.add('d-none'); 70 | } 71 | 72 | const btnGhRestoreStart = document.getElementById('btnGhRestoreStart'); 73 | const btnGhRestorePause = document.getElementById('btnGhRestorePause'); 74 | const btnGhRestoreRestart = document.getElementById('btnGhRestoreRestart'); 75 | // special handling of _restoreStartedThisSession is in case running was calculated as true based on last step being very recent 76 | // but guarding against the case where the user hit F5 (and so it isn't actually running in this session) 77 | if (status.running === true && _restoreStartedThisSession == true) { 78 | btnGhRestorePause.classList.remove('d-none'); 79 | btnGhRestoreStart.classList.add('d-none'); 80 | btnGhRestoreRestart.classList.add('d-none'); 81 | } 82 | else { 83 | GHRESTORE_UI.reflectRestoreSettings(); 84 | QUERYWORK_UI.hideDbProcessingMsg(); 85 | btnGhRestorePause.classList.add('d-none'); 86 | btnGhRestoreStart.classList.remove('d-none'); 87 | if (status.ok === true || status.msg == SYNCFLOW.START_MSG) { 88 | // we're at the beginning, so start and restart mean the same thing 89 | btnGhRestoreRestart.classList.add('d-none'); 90 | } 91 | else { 92 | btnGhRestoreRestart.classList.remove('d-none'); 93 | } 94 | } 95 | }, 96 | 97 | saveRestoreSettingsFromUi: function() { 98 | const config = {}; 99 | const ns = SETTINGS.SYNCFLOW.CONFIG; 100 | config[ns.WITH_FAVORITES] = GHRESTORE_UI.getRestoreSettingFromUi(ns.WITH_FAVORITES, 'optImportWithFavorites'); 101 | config[ns.WITH_PROFILES] = GHRESTORE_UI.getRestoreSettingFromUi(ns.WITH_PROFILES, 'optImportWithProfiles'); 102 | config[ns.WITH_AVATARS] = GHRESTORE_UI.getRestoreSettingFromUi(ns.WITH_AVATARS, 'optImportWithAvatars'); 103 | config[ns.WITH_NETWORKS] = GHRESTORE_UI.getRestoreSettingFromUi(ns.WITH_NETWORKS, 'optImportWithNetworks'); 104 | config[ns.WITH_TOPICS] = GHRESTORE_UI.getRestoreSettingFromUi(ns.WITH_TOPICS, 'optImportWithTopics'); 105 | config[ns.WITH_POSTS] = GHRESTORE_UI.getRestoreSettingFromUi(ns.WITH_POSTS, 'optImportWithPosts'); 106 | config[ns.WITH_POST_IMAGES] = GHRESTORE_UI.getRestoreSettingFromUi(ns.WITH_POST_IMAGES, 'optImportWithPostImages'); 107 | config[ns.DO_TWITTER] = GHRESTORE_UI.getRestoreSettingFromUi(ns.DO_TWITTER, 'optImportTwitter'); 108 | config[ns.DO_MASTODON] = GHRESTORE_UI.getRestoreSettingFromUi(ns.DO_MASTODON, 'optImportMastodon'); 109 | config[ns.AUTHOR_FILTER] = GHRESTORE_UI.getRestoreSettingFromUi(ns.AUTHOR_FILTER, 'optImportForAuthor') || ''; 110 | config[ns.POSTED_FROM] = GHRESTORE_UI.getRestoreSettingFromUi(ns.POSTED_FROM, 'optImportPostsFrom') || ''; 111 | config[ns.POSTED_UNTIL] = GHRESTORE_UI.getRestoreSettingFromUi(ns.POSTED_UNTIL, 'optImportPostsUntil') || ''; 112 | SETTINGS.SYNCFLOW.RESTORE.saveImportConfig(config); 113 | }, 114 | 115 | reflectRestoreSettings: function() { 116 | const config = SETTINGS.SYNCFLOW.RESTORE.getImportConfig(); 117 | const ns = SETTINGS.SYNCFLOW.CONFIG; 118 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.WITH_FAVORITES, 'optImportWithFavorites'); 119 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.WITH_PROFILES, 'optImportWithProfiles'); 120 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.WITH_AVATARS, 'optImportWithAvatars'); 121 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.WITH_NETWORKS, 'optImportWithNetworks'); 122 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.WITH_TOPICS, 'optImportWithTopics'); 123 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.WITH_POSTS, 'optImportWithPosts'); 124 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.WITH_POST_IMAGES, 'optImportWithPostImages'); 125 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.DO_TWITTER, 'optImportTwitter'); 126 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.DO_MASTODON, 'optImportMastodon'); 127 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.AUTHOR_FILTER, 'optImportForAuthor'); 128 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.POSTED_FROM, 'optImportPostsFrom'); 129 | GHRESTORE_UI.reflectRestoreSettingInUi(config, ns.POSTED_UNTIL, 'optImportPostsUntil'); 130 | }, 131 | 132 | getRestoreSettingFromUi: function(setting, elmId) { 133 | const elm = document.getElementById(elmId); 134 | if (elm.type == 'checkbox') { 135 | return elm.checked; 136 | } 137 | else if (setting == SETTINGS.SYNCFLOW.CONFIG.AUTHOR_FILTER) { 138 | return elm.value; 139 | } 140 | else if (setting == SETTINGS.SYNCFLOW.CONFIG.POSTED_FROM || setting == SETTINGS.SYNCFLOW.CONFIG.POSTED_UNTIL) { 141 | if (!STR.hasLen(elm.value)) { 142 | return null; 143 | } 144 | else { 145 | return STR.dateFromMmDdYyyy(elm.value, true); 146 | } 147 | } 148 | }, 149 | 150 | reflectRestoreSettingInUi: function(config, setting, elmId) { 151 | const elm = document.getElementById(elmId); 152 | if (elm.type == 'checkbox') { 153 | elm.checked = STR.isTruthy(config[setting]); 154 | } 155 | else if (setting == SETTINGS.SYNCFLOW.CONFIG.AUTHOR_FILTER) { 156 | elm.value = STR.hasLen(config[setting]) ? config[setting] : ''; 157 | } 158 | else if (setting == SETTINGS.SYNCFLOW.CONFIG.POSTED_FROM || setting == SETTINGS.SYNCFLOW.CONFIG.POSTED_UNTIL) { 159 | if (!STR.hasLen(config[setting])) { 160 | elm.value = ''; 161 | } 162 | else { 163 | const dt = new Date(config[setting]); 164 | if (isNaN(dt)) { 165 | elm.value = ''; 166 | } 167 | else { 168 | elm.value = `${dt.getMonth() + 1}/${dt.getDate()}/${dt.getFullYear()}`; 169 | } 170 | } 171 | } 172 | } 173 | }; -------------------------------------------------------------------------------- /src/lib/dbui/ghvideoupui.js: -------------------------------------------------------------------------------- 1 | /************************/ 2 | // Upload/Import (videos) 3 | // smashingmagazine.com/2018/01/drag-drop-file-uploader-vanilla-js/ 4 | /************************/ 5 | var GHVIDEOUP_UI = { 6 | bindElements: function() { 7 | const dropArea = document.getElementById("drop-area"); 8 | const fileElem = document.getElementById('fileElem'); 9 | 10 | // a full page refresh is in order (helps avoid disk log + redraws the full page) 11 | document.getElementById('uploadDone').onclick = function(event) { 12 | // a full page refresh is in order 13 | location.reload(); 14 | return false; 15 | }; 16 | 17 | ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { 18 | dropArea.addEventListener(eventName, ES6.preventDragBehaviors, false) 19 | document.body.addEventListener(eventName, ES6.preventDragBehaviors, false) 20 | }); 21 | 22 | // Highlight drop area when item is dragged over it 23 | ['dragenter', 'dragover'].forEach(eventName => { 24 | dropArea.addEventListener(eventName, function() { 25 | dropArea.classList.add('highlightDropArea'); 26 | }, false) 27 | }); 28 | 29 | ['dragleave', 'drop'].forEach(eventName => { 30 | dropArea.addEventListener(eventName, function() { 31 | dropArea.classList.remove('active'); 32 | }, false) 33 | }); 34 | 35 | // Handle dropped files 36 | dropArea.addEventListener('drop', function(e) { 37 | var dt = e.dataTransfer; 38 | var files = dt.files; 39 | 40 | GHVIDEOUP_UI.handleUploadFiles(files); 41 | }, false); 42 | 43 | fileElem.addEventListener('change', (event) => { 44 | GHVIDEOUP_UI.handleUploadFiles(event.target.files); 45 | }); 46 | }, 47 | 48 | handleUploadFiles: function(files) { 49 | document.getElementById('uploadPending').classList.remove('d-none'); 50 | document.getElementById('uploadSuccess').classList.add('d-none'); 51 | document.getElementById('uploadError').classList.add('d-none'); 52 | 53 | const fileNameSet = new Set(); 54 | for (let i = 0; i < files.length; i++) { 55 | let file = files[i]; 56 | let cleanName = STR.cleanDownloadedFileName(file.name); 57 | if (!fileNameSet.has(cleanName)) { 58 | GHVIDEOUP_UI.processVideoUpload(file); 59 | fileNameSet.add(cleanName); 60 | } 61 | } 62 | }, 63 | 64 | processVideoUpload: function(file) { 65 | const reader = new FileReader(); 66 | 67 | // set the event for when reading completes 68 | reader.onload = async function(e) { 69 | const uploadedCntElem = document.getElementById('uploadedCnt'); 70 | uploadedCntElem.innerText = parseInt(uploadedCntElem.innerText) + 1; 71 | // base64.guru/converter/encode/video 72 | let b64Data = e.target.result; 73 | 74 | await GITHUB.VIDEOS.uploadVideoWorker( 75 | b64Data, 76 | reader.fileName, 77 | GHVIDEOUP_UI.onUploadVideoSuccess, 78 | GHVIDEOUP_UI.onUploadVideoError); 79 | } 80 | 81 | // start reading 82 | // stackoverflow.com/questions/36280818/how-to-convert-file-to-base64-in-javascript 83 | reader.fileName = file.name; 84 | // bacancytechnology.com/qanda/javascript/javascript-using-the-filereader-api 85 | reader.readAsDataURL(file); 86 | }, 87 | 88 | onUploadVideoSuccess: async function(successMsg, repoConnInfo) { 89 | document.getElementById('uploadPending').classList.add('d-none'); 90 | document.getElementById('uploadSuccess').classList.remove('d-none'); 91 | document.querySelector('#uploadSuccess span').textContent = successMsg; 92 | 93 | const processedCntElem = document.getElementById('uploadProcessedCnt'); 94 | processedCntElem.innerText = parseInt(processedCntElem.innerText) + 1; 95 | 96 | const repoType = GITHUB.REPO_TYPE.VIDEOS; 97 | const rateLimit = await GITHUB.getRateLimit(repoConnInfo.token, repoType); 98 | GHCONFIG_UI.renderRateLimit(rateLimit); 99 | }, 100 | 101 | onUploadVideoError: function(errorMsg) { 102 | document.getElementById('uploadPending').classList.add('d-none'); 103 | document.getElementById('uploadError').classList.remove('d-none'); 104 | document.querySelector('#uploadError span').textContent = errorMsg; 105 | } 106 | }; -------------------------------------------------------------------------------- /src/lib/dbui/logui.js: -------------------------------------------------------------------------------- 1 | var LOG_UI = { 2 | // when sqlite pushes unhandled exception log messages, they log (ugly) as rendered divs 3 | logHtml: function(cssClass, ...args) { 4 | let elm = document.getElementById('logMsgSection') 5 | 6 | if (!elm) { 7 | elm = document.createElement('div') 8 | elm.append(document.createTextNode(args.join(' '))); 9 | document.body.append(elm); 10 | } 11 | else { 12 | elm.classList.remove('d-none'); 13 | elm.textContent = args.join('\n'); 14 | } 15 | 16 | if (cssClass) elm.classList.add(cssClass); 17 | }, 18 | 19 | logSqliteVersion: function(versionInfo) { 20 | document.getElementById('sqliteVersionLib').textContent = versionInfo.libVersion; 21 | document.getElementById('sqliteOpfsOk').textContent = versionInfo.opfsOk.toString(); 22 | //document.getElementById('sqliteSourceId').textContent = versionInfo.sourceId; 23 | }, 24 | 25 | logDbScriptVersion: function(versionInfo) { 26 | document.getElementById('dbScriptNumber').textContent = versionInfo.version.toString(); 27 | } 28 | }; -------------------------------------------------------------------------------- /src/lib/dbui/mdonui.js: -------------------------------------------------------------------------------- 1 | var MDON_UI = { 2 | bindElements: function() { 3 | document.getElementById('mdonLaunchAuthBtn').onclick = function(event) { 4 | MASTODON.launchAuth(); 5 | return false; 6 | }; 7 | 8 | document.getElementById('mdonAcceptAuthBtn').onclick = function(event) { 9 | MASTODON.userSubmittedAuthCode(); 10 | return false; 11 | }; 12 | 13 | document.getElementById('mdonDisconnect').onclick = function(event) { 14 | MASTODON.disconnect(); 15 | return false; 16 | }; 17 | document.getElementById('mdonDisconnect2').onclick = function(event) { 18 | MASTODON.disconnect(); 19 | return false; 20 | }; 21 | 22 | document.getElementById('mdonDownloadFollowingListBtn').onclick = function(event) { 23 | MASTODON.downloadConnections(CONN_DIRECTION.FOLLOWING); 24 | return false; 25 | }; 26 | 27 | document.getElementById('mdonDownloadFollowersListBtn').onclick = function(event) { 28 | MASTODON.downloadConnections(CONN_DIRECTION.FOLLOWERS); 29 | return false; 30 | }; 31 | 32 | document.getElementById('mdonStopDownloadBtn').onclick = function(event) { 33 | MASTODON.abortPaging(); 34 | return false; 35 | }; 36 | 37 | document.getElementById('btnFollowAllOnMastodon').onclick = function(event) { 38 | if (!_mdonRememberedUser || !_mdonRememberedUser.Handle) { 39 | TABS_UI.activateMastodonTab(); 40 | } 41 | else { 42 | MASTODON.followAllVisibleMastodonAccounts(); 43 | } 44 | 45 | return false; 46 | }; 47 | 48 | document.getElementById('txtMdonDownloadConnsFor').addEventListener('keydown', function(event) { 49 | if (event.key === "Backspace" || event.key === "Delete") { 50 | _deletingMdonRemoteOwner = true; 51 | } 52 | else { 53 | _deletingMdonRemoteOwner = false; 54 | } 55 | }); 56 | 57 | const mdonAccountRemoteSearch = ES6.debounce((event) => { 58 | const userInput = document.getElementById('txtMdonDownloadConnsFor').value || ''; 59 | 60 | // min 5 characters to search 61 | if (!userInput || userInput.length < 5) { 62 | MASTODON.initRemoteOwnerPivotPicker(true); 63 | } 64 | 65 | MASTODON.suggestRemoteAccountOwner(userInput); 66 | }, 250); 67 | // ... uses debounce 68 | document.getElementById('txtMdonDownloadConnsFor').addEventListener('input', mdonAccountRemoteSearch); 69 | 70 | // choose owner from typeahead results 71 | const mdonRemoteOwnerPivotPicker = document.getElementById('mdonRemoteOwnerPivotPicker'); 72 | mdonRemoteOwnerPivotPicker.onclick = function(event) { 73 | document.getElementById('txtMdonDownloadConnsFor').value = QUERYING_UI.OWNER.handleFromClickedOwner(event); 74 | MASTODON.onChooseRemoteOwner(); 75 | }; 76 | 77 | document.getElementById('clearMdonCacheBtn').onclick = function(event) { 78 | MASTODON.disconnect(true); 79 | return false; 80 | }; 81 | } 82 | }; -------------------------------------------------------------------------------- /src/lib/dbui/renderbindui.js: -------------------------------------------------------------------------------- 1 | // more tightly coupled to the rest of the UI than renderlib.js 2 | var RENDERBIND_UI = { 3 | bindEvents: function() { 4 | // companion to the above pushState so that back button works 5 | window.addEventListener("popstate", async function(event) { 6 | if (_docLocSearch != document.location.search) { 7 | await RENDERBIND_UI.initialRender(true); 8 | } 9 | }); 10 | }, 11 | 12 | initialRender: async function(leaveHistoryStackAlone) { 13 | // app version 14 | document.getElementById('manifestVersion').textContent = chrome.runtime.getManifest().version; 15 | 16 | // ensure _topicTags are in place 17 | RENDER.POST.TAGGING.initTopicTags(); 18 | 19 | const parms = URLPARSE.getQueryParms(); 20 | 21 | let owner = parms[URL_PARM.OWNER]; 22 | let pageType = parms[URL_PARM.PAGE_TYPE]; 23 | let topic = parms[URL_PARM.TOPIC]; 24 | let threadUrlKey = parms[URL_PARM.THREAD]; 25 | 26 | if (!pageType) { 27 | pageType = SETTINGS.getCachedPageType(); 28 | } 29 | 30 | let site; 31 | if (pageType) { 32 | site = PAGETYPE.getSite(pageType); 33 | SETTINGS.cacheSite(site); 34 | } 35 | else { 36 | site = SETTINGS.getCachedSite(); 37 | } 38 | 39 | QUERYING_UI.PAGE_TYPE.updateUiForCachedSite(); 40 | 41 | // pageType/direction 42 | pageType = pageType || SETTINGS.getCachedPageType() || PAGETYPE.TWITTER.FOLLOWING; 43 | 44 | switch (pageType) { 45 | case PAGETYPE.TWITTER.FOLLOWERS: 46 | case PAGETYPE.MASTODON.FOLLOWERS: 47 | document.getElementById('cmbType').value = CONN_DIRECTION.FOLLOWERS; 48 | break; 49 | case PAGETYPE.TWITTER.FOLLOWING: 50 | case PAGETYPE.MASTODON.FOLLOWING: 51 | document.getElementById('cmbType').value = CONN_DIRECTION.FOLLOWING; 52 | break; 53 | case PAGETYPE.TWITTER.TWEETS: 54 | document.getElementById('cmbType').value = POSTS; 55 | break; 56 | case PAGETYPE.MASTODON.TOOTS: 57 | // TEMPORARY - until mastodon toots are ready 58 | document.getElementById('cmbType').value = CONN_DIRECTION.FOLLOWING; // POSTS; 59 | break; 60 | case PAGETYPE.GITHUB.CONFIGURE: 61 | case PAGETYPE.GITHUB.BACKUP: 62 | case PAGETYPE.GITHUB.RESTORE: 63 | break; 64 | default: 65 | break; 66 | } 67 | 68 | // SEARCH 69 | document.getElementById('txtSearch').value = parms[URL_PARM.SEARCH] || ''; 70 | 71 | // PAGING 72 | let page = parms[URL_PARM.PAGE]; 73 | if (!page || isNaN(page) == true) { 74 | page = 1; 75 | } 76 | document.getElementById('txtPageNum').value = page; 77 | 78 | // post toggles 79 | // WITH_RETWEETS 80 | const optWithRetweets = document.getElementById('optWithRetweets'); 81 | QUERYING_UI.FILTERS.setOptToggleBtn(optWithRetweets, parms[URL_PARM.WITH_RETWEETS] != 'false'); // default to true 82 | 83 | // TOPIC 84 | const optGuessTopics = document.getElementById('optGuessTopics'); 85 | QUERYING_UI.FILTERS.setOptToggleBtn(optGuessTopics, parms[URL_PARM.GUESS_TOPICS] == 'true'); // default to false 86 | QUERYING_UI.FILTERS.TOPICS.setTopicFilterChoiceInUi(topic); 87 | // THREAD 88 | QUERYING_UI.THREAD.setOneThreadState(threadUrlKey); 89 | // post sort 90 | QUERYING_UI.ORDERING.setTopicSortInUi(); 91 | 92 | QUERYING_UI.FILTERS.setQueryOptionVisibility(); 93 | 94 | document.getElementById('txtOwnerHandle').value = STR.stripPrefix(owner, '@') || ''; 95 | 96 | switch (site) { 97 | case SITE.GITHUB: 98 | await TABS_UI.SYNC.activateGithubTab(pageType); 99 | _docLocSearch = document.location.search; // aids our popstate behavior 100 | break; 101 | default: 102 | QUERYWORK_UI.executeSearch(owner, leaveHistoryStackAlone, topic); 103 | break; 104 | } 105 | }, 106 | 107 | renderPostStream: function(payload) { 108 | QUERYING_UI.initMainListUiElms(); 109 | const plist = document.getElementById('paginated-list'); 110 | let html = ''; 111 | // rows 112 | const rows = payload.rows; 113 | for (let i = 0; i < rows.length; i++) { 114 | let row = rows[i]; 115 | // renderPost uses DOMPurify.sanitize 116 | html += RENDERBIND_UI.renderPost(row); 117 | } 118 | 119 | if (!STR.hasLen(html)) { 120 | plist.textContent = EMPTY_LIST_MSG; 121 | } 122 | else { 123 | plist.innerHTML = html; 124 | } 125 | 126 | QUERYING_UI.SEARCH.showSearchProgress(false); 127 | RENDERBIND_UI.onAddedRows(plist); 128 | 129 | _lastRenderedRequest = JSON.stringify(payload.request); 130 | }, 131 | 132 | renderPost: function(post) { 133 | const pageType = QUERYING_UI.PAGE_TYPE.getPageTypeFromUi(); 134 | const site = PAGETYPE.getSite(pageType); 135 | return RENDER.POST.renderPost(post, site); 136 | }, 137 | 138 | renderConnections: function(payload) { 139 | QUERYING_UI.initMainListUiElms(); 140 | const plist = document.getElementById('paginated-list'); 141 | let html = ''; 142 | 143 | // rows 144 | const rows = payload.rows; 145 | for (let i = 0; i < rows.length; i++) { 146 | let row = rows[i]; 147 | // renderPerson uses DOMPurify.sanitize 148 | html += RENDERBIND_UI.renderPerson(row, 'followResult'); 149 | } 150 | 151 | if (!STR.hasLen(html)) { 152 | plist.textContent = EMPTY_LIST_MSG; 153 | } 154 | else { 155 | plist.innerHTML = html; 156 | } 157 | 158 | IMAGE.resolveDeferredLoadImages(plist); 159 | if (SETTINGS_UI.canRenderMastodonFollowOneButtons() === true) { 160 | MASTODON.renderFollowOnMastodonButtons(plist); 161 | } 162 | 163 | QUERYING_UI.SEARCH.showSearchProgress(false); 164 | RENDERBIND_UI.onAddedRows(plist); 165 | QUERYWORK_UI.requestTotalCount(); 166 | 167 | _lastRenderedRequest = JSON.stringify(payload.request); 168 | }, 169 | 170 | renderPerson: function(person, context) { 171 | const renderAnchorsRule = RENDERBIND_UI.getPersonRenderAnchorsRule(); 172 | const filtered = QUERYING_UI.FILTERS.detailReflectsFilter(); 173 | return RENDER.PERSON.renderPerson(person, context, renderAnchorsRule, filtered); 174 | }, 175 | 176 | renderMatchedOwners: function(payload) { 177 | const owners = payload.owners; 178 | const listOwnerPivotPicker = document.getElementById('listOwnerPivotPicker'); 179 | const txtOwnerHandle= document.getElementById('txtOwnerHandle'); 180 | 181 | listOwnerPivotPicker.replaceChildren(); 182 | 183 | if (owners.length === 1 && !_deletingOwner) { 184 | // exact match; pick it! (after an extra check that the user isn't 185 | // trying to delete, in which case auto-complete would be annoying) 186 | txtOwnerHandle.value = STR.stripPrefix(owners[0].Handle, '@'); 187 | QUERYWORK_UI.onChooseOwner(); 188 | } 189 | else { 190 | for (i = 0; i < owners.length; i++) { 191 | // renderPerson uses DOMPurify.sanitize 192 | listOwnerPivotPicker.innerHTML += RENDERBIND_UI.renderPerson(owners[i], 'owner'); 193 | } 194 | 195 | IMAGE.resolveDeferredLoadImages(listOwnerPivotPicker); 196 | } 197 | }, 198 | 199 | onAddedRows: function(container) { 200 | // tag & rate 201 | Array.from(container.getElementsByClassName('postScoredTagger')).forEach(elm => RENDER.POST.TAGGING.configureTagAndRate(elm)); 202 | Array.from(container.getElementsByClassName('postAnotherTag')).forEach(elm => RENDER.POST.TAGGING.configureAddAnotherTag(elm)); 203 | Array.from(container.getElementsByClassName('postTagCancel')).forEach(elm => RENDER.POST.TAGGING.cancelTagEdits(elm)); 204 | // view thread 205 | Array.from(container.getElementsByClassName('btnViewThread')).forEach(elm => QUERYING_UI.THREAD.configureViewThread(elm)); 206 | // delete (and confirm) 207 | Array.from(container.getElementsByClassName('btnDelPost')).forEach(elm => QUERYING_UI.DELETION.configureDeletePost(elm)); 208 | Array.from(container.getElementsByClassName('btnDelPostConfirm')).forEach(elm => QUERYING_UI.DELETION.configureDeletePostConfirmed(elm)); 209 | Array.from(container.getElementsByClassName('btnDelPostCancel')).forEach(elm => QUERYING_UI.DELETION.configureDeletePostCanceled(elm)); 210 | // simple favoriting 211 | Array.from(container.getElementsByClassName("canstar")).forEach(a => FAVORITING_UI.configureFavoriting(a)); 212 | // video elements 213 | Array.from(container.querySelectorAll('.embedsVideo .videoHeader a')).forEach(a => RENDERBIND_UI.configureGetEmbeddedVideo(a)); 214 | }, 215 | 216 | renderSuggestedOwner: function(payload) { 217 | const owner = payload.owner; 218 | if (!owner || !owner.Handle || owner.Handle.length === 0) { 219 | return; 220 | } 221 | 222 | const value = document.getElementById('txtOwnerHandle').value; 223 | 224 | if (!value || value.length === 0) { 225 | document.getElementById('txtOwnerHandle').value = owner.Handle; 226 | // we're doing a page init and so far it's empty, so let's 227 | QUERYING_UI.PAGING.resetPage(); 228 | QUERYWORK_UI.executeSearch(); 229 | } 230 | }, 231 | 232 | // guides us as to which links to look for (e.g. so that if we're focused on mdon we don't distract the user with rendered email links) 233 | getPersonRenderAnchorsRule: function() { 234 | if (document.getElementById('optWithMdon').checked === true) { 235 | return RENDER_CONTEXT.ANCHORS.MDON_ONLY; 236 | } 237 | else if (document.getElementById('optWithEmail').checked === true) { 238 | return RENDER_CONTEXT.ANCHORS.EMAIL_ONLY; 239 | } 240 | else if (document.getElementById('optWithUrl').checked === true) { 241 | return RENDER_CONTEXT.ANCHORS.EXTURL_ONLY; 242 | } 243 | else { 244 | return RENDER_CONTEXT.ANCHORS.ALL; 245 | } 246 | }, 247 | 248 | configureGetEmbeddedVideo: function(a) { 249 | a.onclick = async function(event) { 250 | event.preventDefault(); 251 | await RENDERBIND_UI.onRequestEmbeddedVideo(a); 252 | return false; 253 | } 254 | }, 255 | 256 | onRequestEmbeddedVideo: async function(a) { 257 | const postUrlKey = RENDER.POST.getPostUrlKey(a); 258 | const b64DataUri = await GITHUB.VIDEOS.resolveB64DataUri(postUrlKey); 259 | 260 | if (STR.hasLen(b64DataUri)) { 261 | const imgContainer = ES6.findUpClass(a, 'postContentImg'); 262 | const mediaParent = imgContainer.querySelector('.mediaContainer'); 263 | const imgElm = mediaParent.querySelector('img'); 264 | const videoElm = ES6.swapImgForVideo(mediaParent, imgElm, b64DataUri); 265 | videoElm.loop = true; 266 | } 267 | else { 268 | // best we can do is launch it via squidlr 269 | const videoRes = SETTINGS.RECORDING.VIDEO_EXTRACTION.getPreferredVideoRes(); 270 | const squidlrUrl = STR.buildSquidlrUrl(postUrlKey, videoRes, true); 271 | window.open(squidlrUrl, '_blank'); 272 | a.querySelector('span').textContent = 'Video launched and downloaded in a separate tab. For inline viewing, try the Backups tab -> Upload Videos feature.'; 273 | a.classList.remove('fw-bold'); 274 | a.classList.add('small'); 275 | } 276 | } 277 | }; -------------------------------------------------------------------------------- /src/lib/dbui/settingsui.js: -------------------------------------------------------------------------------- 1 | var SETTINGS_UI = { 2 | bindElements: function() { 3 | 4 | document.getElementById('mdonGear').onclick = function(event) { 5 | const mdonServer = SETTINGS_UI.confirmMdonServer(); 6 | if (mdonServer != null) { 7 | // re-render 8 | const optWithMdon = document.getElementById('optWithMdon'); 9 | optWithMdon.checked = true; 10 | QUERYWORK_UI.executeSearch(); 11 | } 12 | return false; 13 | }; 14 | 15 | const btnClearCache = document.getElementById('btnClearCache'); 16 | btnClearCache.addEventListener('click', async () => { 17 | if (confirm('If unknown problems persist even after relaunching the browser, you may wish to clear the cache. \nContinue?')) { 18 | await chrome.storage.local.clear(); 19 | localStorage.clear(); 20 | } 21 | return true; 22 | }); 23 | 24 | }, 25 | 26 | canRenderMastodonFollowOneButtons: function() { 27 | const site = SETTINGS.getCachedSite(); 28 | const mdonMode = document.getElementById('optWithMdon').checked; 29 | return site === SITE.MASTODON || mdonMode === true; 30 | }, 31 | 32 | ensureAskedMdonServer: function() { 33 | const asked = SETTINGS.getAskedMdonServer(); 34 | 35 | if (!asked) { 36 | SETTINGS_UI.confirmMdonServer(); 37 | } 38 | }, 39 | 40 | confirmMdonServer: function() { 41 | const mdonServer = SETTINGS.getMdonServer() || ''; 42 | const input = prompt("First, please input the Mastodon server where you have an account (e.g. 'toad.social').", mdonServer); 43 | 44 | if (input != null) { 45 | SETTINGS.localSet(SETTINGS.MDON_SERVER, input); 46 | } 47 | 48 | // even if they cancelled, we'll avoid showing again (they can click the gear if desired) 49 | SETTINGS.localSet(SETTINGS.ASKED.MDON_SERVER, true); 50 | return input; 51 | } 52 | }; -------------------------------------------------------------------------------- /src/lib/dbui/tabsui.js: -------------------------------------------------------------------------------- 1 | var TABS_UI = { 2 | 3 | bindElements: function() { 4 | document.getElementById('ghConfigDataTab').onclick = async function(event) { 5 | GHCONFIG_UI.setGithubConfigRepoTypeTab(GITHUB.REPO_TYPE.DATA); 6 | await GHCONFIG_UI.reflectGithubTokenStatus(); 7 | return false; 8 | } 9 | document.getElementById('ghConfigVideosTab').onclick = async function(event) { 10 | GHCONFIG_UI.setGithubConfigRepoTypeTab(GITHUB.REPO_TYPE.VIDEOS); 11 | await GHCONFIG_UI.reflectGithubTokenStatus(); 12 | return false; 13 | } 14 | 15 | document.getElementById('btnCancelGithubToken').onclick = async function(event) { 16 | // the goal is to switch back to the tab of the token type that we do have 17 | const hasDataToken = await SETTINGS.GITHUB.hasSyncToken(GITHUB.REPO_TYPE.DATA); 18 | const hasVideoToken = await SETTINGS.GITHUB.hasSyncToken(GITHUB.REPO_TYPE.VIDEOS); 19 | if (hasDataToken == true) { 20 | GHCONFIG_UI.setGithubConfigRepoTypeTab(GITHUB.REPO_TYPE.DATA); 21 | } 22 | else if (hasVideoToken == true) { 23 | GHCONFIG_UI.setGithubConfigRepoTypeTab(GITHUB.REPO_TYPE.VIDEOS); 24 | } 25 | await GHCONFIG_UI.reflectGithubTokenStatus(); 26 | return false; 27 | } 28 | 29 | document.getElementById('ghConfigureTab').onclick = async function(event) { 30 | await TABS_UI.SYNC.activateGhConfigureTab(); 31 | return false; 32 | } 33 | document.getElementById('ghBackupTab').onclick = async function(event) { 34 | await TABS_UI.SYNC.activateGhBackupTab(); 35 | return false; 36 | } 37 | document.getElementById('ghRestoreTab').onclick = async function(event) { 38 | await TABS_UI.SYNC.activateGhRestoreTab(); 39 | return false; 40 | } 41 | 42 | document.getElementById('twitterLensBtn').onclick = function(event) { 43 | const site = SETTINGS.getCachedSite(); 44 | 45 | if (site != SITE.TWITTER) { 46 | SETTINGS.cacheSite(SITE.TWITTER); 47 | QUERYING_UI.PAGE_TYPE.updateUiForCachedSite(true); 48 | QUERYWORK_UI.executeSearch(); 49 | } 50 | 51 | return false; 52 | }; 53 | 54 | document.getElementById('mastodonLensBtn').onclick = function(event) { 55 | TABS_UI.activateMastodonTab(); 56 | return false; 57 | }; 58 | 59 | document.getElementById('githubLensBtn').onclick = async function(event) { 60 | await TABS_UI.SYNC.activateGithubTab(); 61 | return false; 62 | }; 63 | 64 | }, 65 | 66 | activateMastodonTab: function() { 67 | const site = SETTINGS.getCachedSite(); 68 | 69 | if (site != SITE.MASTODON) { 70 | SETTINGS.cacheSite(SITE.MASTODON); 71 | QUERYING_UI.PAGE_TYPE.updateUiForCachedSite(true); 72 | QUERYWORK_UI.executeSearch(); 73 | } 74 | }, 75 | 76 | SYNC: { 77 | activateGithubTab: async function(pageType) { 78 | pageType = pageType || SETTINGS.getCachedPageType(SITE.GITHUB); 79 | const site = SETTINGS.getCachedSite(); 80 | if (site != SITE.GITHUB) { 81 | SETTINGS.cacheSite(SITE.GITHUB); 82 | QUERYING_UI.PAGE_TYPE.updateUiForCachedSite(true); 83 | } 84 | 85 | await TABS_UI.SYNC.unveilGithubUi(pageType); 86 | }, 87 | 88 | unveilGithubUi: async function(pageType) { 89 | switch (pageType) { 90 | case PAGETYPE.GITHUB.BACKUP: 91 | await TABS_UI.SYNC.activateGhBackupTab(); 92 | break; 93 | case PAGETYPE.GITHUB.RESTORE: 94 | await TABS_UI.SYNC.activateGhRestoreTab(); 95 | break; 96 | case PAGETYPE.GITHUB.CONFIGURE: 97 | default: 98 | await TABS_UI.SYNC.activateGhConfigureTab(); 99 | break; 100 | } 101 | }, 102 | 103 | getActiveSyncTabPageTypeFromUi: function() { 104 | if (document.getElementById('ghRestoreTab').classList.contains('active')) { 105 | return PAGETYPE.GITHUB.RESTORE; 106 | } 107 | else if (document.getElementById('ghBackupTab').classList.contains('active')) { 108 | return PAGETYPE.GITHUB.BACKUP; 109 | } 110 | else { 111 | return PAGETYPE.GITHUB.CONFIGURE; 112 | } 113 | }, 114 | 115 | setActiveSyncTabPageType: async function(pageType) { 116 | const backupTab = document.getElementById('ghBackupTab'); 117 | const restoreTab = document.getElementById('ghRestoreTab'); 118 | const configureTab = document.getElementById('ghConfigureTab'); 119 | 120 | if (pageType == PAGETYPE.GITHUB.RESTORE) { 121 | restoreTab.classList.add('active'); 122 | 123 | if (backupTab.classList.contains('active')) { 124 | backupTab.classList.remove('active'); 125 | } 126 | if (configureTab.classList.contains('active')) { 127 | configureTab.classList.remove('active'); 128 | } 129 | 130 | restoreTab.setAttribute('aria-current', 'page'); 131 | backupTab.removeAttribute('aria-current'); 132 | configureTab.removeAttribute('aria-current'); 133 | } 134 | else if (pageType == PAGETYPE.GITHUB.BACKUP) { 135 | backupTab.classList.add('active'); 136 | 137 | if (restoreTab.classList.contains('active')) { 138 | restoreTab.classList.remove('active'); 139 | } 140 | if (configureTab.classList.contains('active')) { 141 | configureTab.classList.remove('active'); 142 | } 143 | 144 | backupTab.setAttribute('aria-current', 'page'); 145 | restoreTab.removeAttribute('aria-current'); 146 | configureTab.removeAttribute('aria-current'); 147 | } 148 | else { 149 | // configure tab 150 | configureTab.classList.add('active'); 151 | 152 | if (restoreTab.classList.contains('active')) { 153 | restoreTab.classList.remove('active'); 154 | } 155 | if (backupTab.classList.contains('active')) { 156 | backupTab.classList.remove('active'); 157 | } 158 | 159 | configureTab.setAttribute('aria-current', 'page'); 160 | restoreTab.removeAttribute('aria-current'); 161 | backupTab.removeAttribute('aria-current'); 162 | } 163 | 164 | QUERYING_UI.QUERY_STRING.conformAddressBarUrlQueryParmsToUi(false); 165 | const cacheKey = SETTINGS.pageTypeCacheKey(SITE.GITHUB); 166 | SETTINGS.localSet(cacheKey, pageType); 167 | }, 168 | 169 | activateGhConfigureTab: async function() { 170 | await GHCONFIG_UI.reflectGithubTokenStatus(); 171 | await TABS_UI.SYNC.setActiveSyncTabPageType(PAGETYPE.GITHUB.CONFIGURE); 172 | document.getElementById('configureSyncUi').style.display = 'block'; 173 | document.getElementById('backupUi').style.display = 'none'; 174 | document.getElementById('restoreUi').style.display = 'none'; 175 | }, 176 | 177 | activateGhBackupTab: async function() { 178 | await TABS_UI.SYNC.setActiveSyncTabPageType(PAGETYPE.GITHUB.BACKUP); 179 | document.getElementById('configureSyncUi').style.display = 'none'; 180 | document.getElementById('backupUi').style.display = 'block'; 181 | document.getElementById('restoreUi').style.display = 'none'; 182 | await GHBACKUP_UI.renderSyncBackupStatus(); 183 | }, 184 | 185 | activateGhRestoreTab: async function() { 186 | await TABS_UI.SYNC.setActiveSyncTabPageType(PAGETYPE.GITHUB.RESTORE); 187 | document.getElementById('configureSyncUi').style.display = 'none'; 188 | document.getElementById('backupUi').style.display = 'none'; 189 | document.getElementById('restoreUi').style.display = 'block'; 190 | await GHRESTORE_UI.renderSyncRestoreStatus(); 191 | } 192 | } 193 | }; -------------------------------------------------------------------------------- /src/lib/dbui/tagginglib.js: -------------------------------------------------------------------------------- 1 | var TAGGING = { 2 | CONSTANTS: { 3 | TAG_RATE: '-- Set topic & rate --', 4 | TAG_FILTER_BY: '-- Filter by topic --', 5 | UNTAG_THIS_POST: 'Untag this post', 6 | REQUEST_TOPIC: 'Add a topic', 7 | REQUEST_SUBTOPIC: 'Add a subtopic', 8 | }, 9 | POSTS: { 10 | onTaggingSuccess: function(saveTagMsg) { 11 | const postScoredTaggerElm = RENDER.getElmByEditingId(saveTagMsg.editingId); 12 | let hasTag = false; 13 | // tag name is via concatSubtopicRatingTag 14 | let concatSubtopic = ''; 15 | let rating; 16 | const sogs = APPSCHEMA.SAVING.getSogs(saveTagMsg.savableSet, APPSCHEMA.SocialPostSubtopicRating.Name); 17 | if (sogs.length == 1) { 18 | const tagName = sogs[0].o; 19 | const splt = STR.splitSubtopicRatingTag(tagName); 20 | if (splt) { 21 | concatSubtopic = splt.subtopic; 22 | rating = splt.rating; 23 | } 24 | 25 | postScoredTaggerElm.classList.remove('noneSelected'); 26 | } 27 | else { 28 | postScoredTaggerElm.classList.add('noneSelected'); 29 | } 30 | 31 | if (rating) { 32 | hasTag = true; 33 | } 34 | 35 | const clsNames = Array.from(postScoredTaggerElm.classList); 36 | for (let i = 0; i < clsNames.length; i++) { 37 | let clsName = clsNames[i]; 38 | if (clsName.startsWith('quintile-')) { 39 | postScoredTaggerElm.classList.remove(clsName); 40 | } 41 | } 42 | 43 | if (rating) { 44 | postScoredTaggerElm.classList.add(`quintile-${rating}`); 45 | } 46 | 47 | // also update the cls of the container (for + sign visibility) 48 | const container = ES6.findUpClass(postScoredTaggerElm, 'postScoredTaggers'); 49 | if (hasTag == true) { 50 | container.classList.add('hasTag'); 51 | } 52 | else { 53 | container.classList.remove('hasTag'); 54 | } 55 | 56 | if (STR.hasLen(concatSubtopic)) { 57 | postScoredTaggerElm.setAttribute('data-testid', concatSubtopic); 58 | } 59 | else { 60 | postScoredTaggerElm.removeAttribute('data-testid'); 61 | // if there's another tagging element in place, we don't need this one 62 | const familyElms = Array.from(container.querySelectorAll('.postScoredTagger')); 63 | if (familyElms.length > 1) { 64 | postScoredTaggerElm.remove(); 65 | return; 66 | } 67 | } 68 | 69 | // now visually update the tagger ui 70 | const displayTopic = STR.hasLen(concatSubtopic) ? concatSubtopic : TAGGING.CONSTANTS.TAG_RATE; 71 | const txtBox = postScoredTaggerElm.querySelector('.postTagText'); 72 | txtBox.value = displayTopic; 73 | txtBox.setAttribute('title', displayTopic); // in case it's too big to see the whole thing 74 | RENDER.POST.TAGGING.finishTagEdits(postScoredTaggerElm); 75 | }, 76 | 77 | buildSaveTagMsg: function(postScoredTaggerElm, newRatedSubtopic, oldRatedSubtopic, pageType) { 78 | if (!STR.hasLen(newRatedSubtopic) && !STR.hasLen(oldRatedSubtopic)) { return null; } 79 | 80 | const editingId = RENDER.setEditingId(postScoredTaggerElm); 81 | const urlKey = RENDER.POST.getPostUrlKey(postScoredTaggerElm); 82 | 83 | const entDefns = [APPSCHEMA.SocialPostSubtopicRating]; 84 | const savableSet = APPSCHEMA.SAVING.newSavableSet(entDefns, false); 85 | const deletableSet = APPSCHEMA.SAVING.newSavableSet(entDefns, false); 86 | const graph = APPGRAPHS.getGraphByPageType(pageType); 87 | 88 | if (STR.hasLen(newRatedSubtopic)) { 89 | const newTopic = { s: urlKey, o: newRatedSubtopic, g: graph }; 90 | APPSCHEMA.SAVING.getSubset(savableSet, APPSCHEMA.SocialPostSubtopicRating.Name).sogs.push(newTopic); 91 | } 92 | if (STR.hasLen(oldRatedSubtopic)) { 93 | const oldTopic = { s: urlKey, o: oldRatedSubtopic, g: graph }; 94 | APPSCHEMA.SAVING.getSubset(deletableSet, APPSCHEMA.SocialPostSubtopicRating.Name).sogs.push(oldTopic); 95 | } 96 | 97 | return { 98 | actionType: MSGTYPE.TODB.EXECUTE_SAVE_AND_DELETE, 99 | savableSet: savableSet, 100 | deletableSet: deletableSet, 101 | urlKey: urlKey, 102 | editingId: editingId, 103 | onSuccessType: MSGTYPE.FROMDB.ON_SUCCESS.SAVED_POST_TAG 104 | }; 105 | }, 106 | 107 | setRating: function(postScoredTaggerElm, rating, subtopicName) { 108 | const pageType = QUERYING_UI.PAGE_TYPE.getPageTypeFromUi(); 109 | const oldSubtopicName = postScoredTaggerElm.getAttribute('data-testid'); 110 | 111 | const oldRating = Array.from(postScoredTaggerElm.classList) 112 | .filter(function(cls) { 113 | return cls.startsWith('quintile-') 114 | }).map(function(cls) { 115 | return parseInt(cls.split('-')[1]); 116 | }); 117 | 118 | if (!rating && STR.hasLen(subtopicName)) { 119 | // no rating was provided; and yet we are trying to tag (non-null subtopic) 120 | // retain existing stars 121 | rating = oldRating; 122 | } 123 | 124 | const newTagName = STR.hasLen(subtopicName) ? STR.concatSubtopicRatingTag(subtopicName, rating) : null; 125 | 126 | let oldTagName; 127 | if (STR.hasLen(oldSubtopicName)) { 128 | oldTagName = STR.concatSubtopicRatingTag(oldSubtopicName, oldRating); 129 | } 130 | 131 | // the actual save 132 | const saveTagMsg = TAGGING.POSTS.buildSaveTagMsg(postScoredTaggerElm, newTagName, oldTagName, pageType); 133 | _worker.postMessage(saveTagMsg); 134 | } 135 | } 136 | }; -------------------------------------------------------------------------------- /src/lib/dbui/topiclib.js: -------------------------------------------------------------------------------- 1 | var _topicTags = []; 2 | var _ranTopicInit = false; 3 | 4 | var TOPICS = { 5 | // TopicName: SubtopicName 6 | TOPIC_SUBTOPIC_COLON: ': ', 7 | 8 | // for a sync file we use 9 | // topicname_sub_subtopicname 10 | TOPIC_SUBTOPIC_FOR_FILE: '_sub_', 11 | 12 | concatTopicFullName: function(topicName, subtopicName) { 13 | return `${topicName}${TOPICS.TOPIC_SUBTOPIC_COLON}${subtopicName}`; 14 | }, 15 | 16 | // saved to database; this is the callbacck 17 | onSavedTopicSubtopicPairs: function(payload) { 18 | const savedPairs = APPSCHEMA.SAVING.getSubset(payload.savableSet, APPSCHEMA.SocialTopicSubtopic.Name).sogs.map(function(sog) { 19 | return { 20 | topic: sog.s, 21 | subtopic: sog.o 22 | }; 23 | }); 24 | 25 | const topicNames = new Set(savedPairs.map(function(p) { return p.topic; })); 26 | 27 | // make sure the localStorage cache has them 28 | for (let i = 0; i < savedPairs.length; i++) { 29 | let savedPair = savedPairs[i]; 30 | let addable = { 31 | Name: savedPair.topic, 32 | Subtopics: [ 33 | { 34 | Name: savedPair.subtopic 35 | } 36 | ] 37 | }; 38 | // handles merging, so we don't have to worry about losing existing subtopics 39 | SETTINGS.TOPICS.addTopic(addable); 40 | } 41 | 42 | // make sure the UI has them 43 | const allTopics = SETTINGS.TOPICS.getLocalCacheTopics(); 44 | const relevantTopics = allTopics.filter(function(t) { return topicNames.has(t.Name); }); 45 | RENDER.POST.TAGGING.ensureRenderedTopicChoices(relevantTopics); 46 | }, 47 | 48 | // fairly low-stakes, so wrapped in try/catch 49 | ensureRemoteTopicSettings: function(rawContentCallback) { 50 | try { 51 | TOPICS.ensureRemoteTopicsWorker(rawContentCallback); 52 | } catch (error) { 53 | console.log('failed pulling topics'); 54 | console.log(error); 55 | } 56 | }, 57 | 58 | ensureRemoteTopicsWorker: function(rawContentCallback) { 59 | const shouldPull = TOPICS.shouldPullRemoteTopics(); 60 | if (!shouldPull) { return; } 61 | console.log('pulling remote server topics'); 62 | SETTINGS.localSet(SETTINGS.REMOTE.LAST_TOPICS_PULL_TRY, Date.now()); 63 | GITHUB.getRawContent(rawContentCallback, GITHUB.PUBLISHER_ORG, GITHUB.TOPICS_REPO, GITHUB.TOPICS_FILE); 64 | }, 65 | 66 | buildSavableTopicSet: function(topicName, subtopicName) { 67 | const entDefn = APPSCHEMA.SocialTopicSubtopic; 68 | const entDefns = [entDefn]; 69 | const savableSet = APPSCHEMA.SAVING.newSavableSet(entDefns, false); 70 | const record = {s: topicName.trim(), o: subtopicName.trim(), g: APPGRAPHS.METADATA}; 71 | APPSCHEMA.SAVING.getSubset(savableSet, entDefn.Name).sogs.push(record); 72 | return savableSet; 73 | }, 74 | 75 | // returns { savableSet, deletableSet } 76 | // We need a way for the remote settings to tell us locally to delete deprecated records 77 | // so that stale concepts are no longer used by auto-complete 78 | buildSets: function(topics) { 79 | const entDefns = [ 80 | APPSCHEMA.SocialTopicSubtopic, 81 | APPSCHEMA.SocialSubtopicKeyword 82 | ]; 83 | 84 | const savableSet = APPSCHEMA.SAVING.newSavableSet(entDefns, false); 85 | const deletableSet = APPSCHEMA.SAVING.newSavableSet(entDefns, false); 86 | const graph = APPGRAPHS.METADATA; 87 | 88 | for (let t = 0; t < topics.length; t++) { 89 | let topic = topics[t]; 90 | let topicName = topic.Name; 91 | let deprecated = false; 92 | if (topicName.endsWith(DEPRECATED_SUFFIX)) { 93 | topicName = STR.stripSuffix(topicName, DEPRECATED_SUFFIX); 94 | deprecated = true; 95 | } 96 | for (let s = 0; s < topic.Subtopics.length; s++) { 97 | let subtopic = topic.Subtopics[s]; 98 | let subtopicName = subtopic.Name; 99 | if (subtopicName.endsWith(DEPRECATED_SUFFIX)) { 100 | subtopicName = STR.stripSuffix(subtopicName, DEPRECATED_SUFFIX); 101 | deprecated = true; 102 | } 103 | 104 | const topicSubtopicRecord = {s: topicName, o: subtopicName, g: graph}; 105 | const topicSubtopicSet = deprecated == true ? deletableSet : savableSet; 106 | APPSCHEMA.SAVING.getSubset(topicSubtopicSet, APPSCHEMA.SocialTopicSubtopic.Name).sogs.push(topicSubtopicRecord); 107 | 108 | if (!subtopic.Keywords) { 109 | subtopic.Keywords = []; 110 | } 111 | for (let w = 0; w < subtopic.Keywords.length; w++) { 112 | let word = subtopic.Keywords[w]; 113 | let wordDep = deprecated; 114 | if (word.endsWith(DEPRECATED_SUFFIX)) { 115 | word = STR.stripSuffix(word, DEPRECATED_SUFFIX); 116 | wordDep = true; 117 | } 118 | 119 | const subtopicWordRecord = {s: subtopicName, o: word, g: graph }; 120 | const subtopicWordSet = wordDep == true ? deletableSet : savableSet; 121 | APPSCHEMA.SAVING.getSubset(subtopicWordSet, APPSCHEMA.SocialSubtopicKeyword.Name).sogs.push(subtopicWordRecord); 122 | } 123 | } 124 | } 125 | 126 | return { 127 | savableSet: savableSet, 128 | deletableSet: deletableSet 129 | } 130 | }, 131 | 132 | mergeTopics: function(serverTopics, localTopics, includeDeprecated) { 133 | const mergedTopics = []; 134 | mergedTopics.push(...localTopics); 135 | for (let i = 0; i < serverTopics.length; i++) { 136 | let serverTopic = serverTopics[i]; 137 | if (!TOPICS.isDeprecatedTopic(serverTopic)) { 138 | let localTopic = mergedTopics.find(function(t) { return STR.sameText(t.Name, serverTopic.Name); }); 139 | if (!localTopic) { 140 | mergedTopics.push(serverTopic); 141 | } 142 | else { 143 | // server and local both exist; make sure the local knows about all its subtopics 144 | for (let j = 0; j < serverTopic.Subtopics.length; j++) { 145 | let serverSubtopic = serverTopic.Subtopics[j]; 146 | if (!TOPICS.isDeprecatedSubtopic(serverSubtopic)) { 147 | let localSubtopic = localTopic.Subtopics.find(function(t) { return STR.sameText(t.Name, serverSubtopic.Name); }); 148 | if (!localSubtopic) { 149 | localTopic.Subtopics.push(serverSubtopic); 150 | } 151 | } 152 | else if (includeDeprecated == true) { 153 | localTopic.Subtopics.push(serverSubtopic); 154 | } 155 | } 156 | } 157 | } 158 | else if (includeDeprecated == true) { 159 | mergedTopics.push(serverTopic); 160 | } 161 | } 162 | 163 | return mergedTopics; 164 | }, 165 | 166 | getMatchedTopic: function(topics, topicName, subtopicName) { 167 | return topics.find(function(t) { 168 | if (STR.sameText(t.Name, topicName)) { 169 | let matchedSubtopic = t.Subtopics.find(function(s) { 170 | return STR.sameText(s.Name, subtopicName); 171 | }); 172 | if (matchedSubtopic) { 173 | return true; 174 | } 175 | else { 176 | return false; 177 | } 178 | } 179 | else { 180 | return false; 181 | } 182 | }); 183 | }, 184 | 185 | isDeprecatedTopic: function(topic) { 186 | return topic.Name.endsWith(DEPRECATED_SUFFIX); 187 | }, 188 | 189 | isDeprecatedSubtopic: function(subtopic) { 190 | return subtopic.Name.endsWith(DEPRECATED_SUFFIX); 191 | }, 192 | 193 | isDeprecatedWord: function(word) { 194 | return word.endsWith(DEPRECATED_SUFFIX); 195 | }, 196 | 197 | parseTopics: function(content) { 198 | const lines = content.split('\n'); 199 | const topics = []; 200 | let topic; 201 | let subtopic; 202 | 203 | for (let i = 0; i < lines.length; i++) { 204 | let line = lines[i]; 205 | 206 | if (line.startsWith('# ')) { 207 | // topic 208 | topic = { 209 | Name: STR.stripPrefix(line, '# ').trim(), 210 | Subtopics: [] 211 | }; 212 | topics.push(topic); 213 | } 214 | else if (line.startsWith('- ')) { 215 | // subtopic 216 | let subLine = STR.stripPrefix(line, '- ').trim(); 217 | let subParts = subLine.split(': '); 218 | let words = (subParts.length == 1) ? [] : subParts[1].split(', ').map(function(p) { return p.trim(); }); 219 | subtopic = { 220 | Name: subParts[0].trim(), 221 | Keywords: words 222 | }; 223 | topic.Subtopics.push(subtopic); 224 | } 225 | } 226 | 227 | return topics; 228 | }, 229 | 230 | shouldPullRemoteTopics: function() { 231 | const tryMsAgoCutoff = 5 * 60 * 1000; // 5 minutes 232 | const successMsAgoCutoff = 6 * 60 * 60 * 1000; // 6 hours 233 | 234 | return SETTINGS.shouldRetryNow( 235 | SETTINGS.REMOTE.LAST_TOPICS_PULL_TRY, 236 | SETTINGS.REMOTE.LAST_TOPICS_PULL_SUCCESS, 237 | tryMsAgoCutoff, 238 | successMsAgoCutoff); 239 | }, 240 | 241 | fromConcatNames: function(concatNames) { 242 | concatNames = concatNames.sort(); 243 | const topics = []; 244 | for (let i = 0; i < concatNames.length; i++) { 245 | let concatName = concatNames[i]; 246 | let splat = concatName.split(TOPICS.TOPIC_SUBTOPIC_COLON); 247 | if (splat.length == 2) { 248 | let topicName = splat[0]; 249 | let subtopicName = splat[1]; 250 | let topic = topics.find(function(t) { return STR.sameText(t.Name, topicName); }); 251 | if (!topic) { 252 | topic = { 253 | Name: topicName, 254 | Subtopics: [] 255 | }; 256 | topics.push(topic); 257 | } 258 | let subtopic = topic.Subtopics.find(function(s) { return STR.sameText(s.Name, subtopicName); }); 259 | if (!subtopic) { 260 | topic.Subtopics.push({Name: subtopicName}); 261 | } 262 | } 263 | } 264 | 265 | return topics; 266 | } 267 | } -------------------------------------------------------------------------------- /src/lib/shared/appgraphs.js: -------------------------------------------------------------------------------- 1 | // The RDF spec (w3.org/rdf) provides inspiration. 2 | // Data can be represented as quads (Subject, Predicate, Object, NamedGraph). 3 | // The entity/table name holds knowledge of the Predicate. 4 | // And NamedGraph can be used to represent provenance. 5 | // Note that within the DB, the URI prefix (e.g. https://whosum.com/graph/) can be omitted. 6 | // Until identity is proven (a requirement for sharing across users), the placeholder 'MYSELF' is used to refer to the local user. 7 | 8 | var APPGRAPHS = { 9 | MYSELF: 'MYSELF', 10 | METADATA: 'METADATA', 11 | 12 | PARM_NAME: { 13 | SITE: 'site', 14 | CONTRIBUTOR: 'contributor' 15 | }, 16 | 17 | // for including graphs sourced from multiple contributors (use with LIKE operator) 18 | getGraphsStartWithByPageType: function(pageType) { 19 | const site = PAGETYPE.getSite(pageType); 20 | return APPGRAPHS.getGraphsStartWithBySite(site); 21 | }, 22 | 23 | // for including graphs sourced from multiple contributors (use with LIKE operator) 24 | getGraphsStartWithBySite: function(site) { 25 | return `${APPGRAPHS.PARM_NAME.SITE}=${site}%`; 26 | }, 27 | 28 | getGraphByPageType: function(pageType, contributor = APPGRAPHS.MYSELF) { 29 | const site = PAGETYPE.getSite(pageType); 30 | return APPGRAPHS.getGraphBySite(site, contributor); 31 | }, 32 | 33 | getGraphBySite: function(site, contributor = APPGRAPHS.MYSELF) { 34 | return `${APPGRAPHS.PARM_NAME.SITE}=${site}&${APPGRAPHS.PARM_NAME.CONTRIBUTOR}=${contributor}`; 35 | }, 36 | 37 | getSiteFromGraph: function(graph) { 38 | if (!graph) { 39 | return undefined; 40 | } 41 | 42 | const kvps = graph.split('&'); 43 | for (let i = 0; i < kvps.length; i++) { 44 | let kvp = kvps[i]; 45 | let parts = kvp.split('='); 46 | if (parts.length === 2 && parts[0] == APPGRAPHS.PARM_NAME.SITE) { 47 | let site = parts[1]; 48 | return site; 49 | } 50 | } 51 | 52 | return undefined; 53 | } 54 | }; -------------------------------------------------------------------------------- /src/lib/shared/cryptolib.js: -------------------------------------------------------------------------------- 1 | var CRYPTO = { 2 | HASH_METHOD: { 3 | SHA1: 'SHA-1', 4 | SHA2: 'SHA-256' 5 | }, 6 | 7 | // stackoverflow.com/a/40031979/9014097 8 | buf2hex: function (buffer) { // buffer is an ArrayBuffer 9 | return Array.prototype.map.call(new Uint8Array(buffer), x => ('00' + x.toString(16)).slice(-2)).join(''); 10 | }, 11 | 12 | // stackoverflow.com/a/11562550/9014097 13 | buf2Base64: function(buffer) { 14 | return btoa([].reduce.call(new Uint8Array(buffer),function(p,c){return p+String.fromCharCode(c)},'')); 15 | }, 16 | 17 | utf8ByteLen: function(str) { 18 | if (!str || str.length == 0) { return 0; } 19 | const inputBytes = new TextEncoder().encode(str); 20 | return inputBytes.length; 21 | }, 22 | 23 | // stackoverflow.com/questions/63736585/why-does-crypto-subtle-digest-return-an-empty-object 24 | hash: async function(str, method) { 25 | if (!str || str.length == 0) { return null; } 26 | const inputBytes = new TextEncoder().encode(str); 27 | const hashBytes = await window.crypto.subtle.digest(method, inputBytes); 28 | const hashedStr = CRYPTO.buf2hex(hashBytes); 29 | return hashedStr; 30 | } 31 | }; -------------------------------------------------------------------------------- /src/lib/shared/datatypes.js: -------------------------------------------------------------------------------- 1 | var DATATYPES = { 2 | // standard 3 | TEXT: 'text', 4 | DATETIME: 'datetime', 5 | BOOLEAN: 'boolean', // reminder: provide 1 or 0 for boolean values 6 | INTEGER: 'integer', 7 | FLOAT: 'float', 8 | 9 | // special 10 | LIST_NAME: 'list', 11 | ACCOUNT_HANDLE: 'account-handle', 12 | EMAIL_ADDRESS: 'email-address', 13 | MASTODON_ACCOUNT_URL: 'mastodon-account-url', 14 | // (not mastodon) 15 | EXTERNAL_URL: 'url', 16 | // an image at its original location 17 | IMG_SOURCE_URL: 'image-source-url', 18 | // an image cached into binary data 19 | IMG_BINARY: 'image-binary', 20 | // the portion of a post url that serves as its key (e.g. username/status/123451234) 21 | POST_URL_KEY: 'post-url-key', 22 | // SubtopicName-5 23 | // where delimiter is '-' followed by a numeric rating 24 | SUBTOPIC_RATING: 'subtopic-rating' 25 | }; -------------------------------------------------------------------------------- /src/lib/shared/emojilib.js: -------------------------------------------------------------------------------- 1 | /********************************************************************/ 2 | // Most emojis can be saved as text and re-rendered. 3 | // But Microsoft won't render flags such as '🇺🇸' so we use a trick 4 | // with flags.css 5 | /********************************************************************/ 6 | 7 | const _emojiRegex = /\p{Emoji}/u; 8 | 9 | // unicode.org/reports/tr51/#EBNF_and_Regex 10 | // rendering is via flags.css 11 | const _flagRegexCapture = /(\p{RI}\p{RI})/ug; 12 | 13 | var EMOJI = { 14 | 15 | // emojiguide.org/thread 16 | THREAD: '🧵', 17 | 18 | // www.wisdomgeek.com/development/web-development/javascript/how-to-check-if-a-string-contains-emojis-in-javascript/ 19 | isEmoji: function(txt) { 20 | if (!txt || txt.length === 0) { 21 | return false; 22 | } 23 | 24 | return _emojiRegex.test(txt); 25 | }, 26 | 27 | // figured this out by analyzing a-z values from: 28 | // emojipedia.org/regional-indicator-symbol-letter-a/ 29 | // console.log('🇦'.charCodeAt(1)); 30 | // console.log('a'.charCodeAt(0)); 31 | // console.log('🇿'.charCodeAt(1)); 32 | // console.log('z'.charCodeAt(0)); 33 | unicodeRegionCharToAscii: function(u) { 34 | if (u.charCodeAt(0) != 55356) { 35 | return u; 36 | } 37 | 38 | let ucc = u.charCodeAt(1); 39 | if (!ucc) { return u; } 40 | if (ucc < 56806 || ucc > 56831) { return u; } 41 | let asciCode = ucc - 56709; // by analyzing regional 'a' vs ascii 'a' 42 | // convert it to char 43 | let chr = String.fromCharCode(asciCode); 44 | return chr; 45 | }, 46 | 47 | // stackoverflow.com/questions/24531751/how-can-i-split-a-string-containing-emoji-into-an-array 48 | emojiStringToArray: function(str) { 49 | const split = str.split(/([\uD800-\uDBFF][\uDC00-\uDFFF])/); 50 | const arr = []; 51 | for (let i=0; i < split.length; i++) { 52 | let char = split[i] 53 | if (char !== "") { 54 | arr.push(char); 55 | } 56 | } 57 | 58 | return arr; 59 | }, 60 | 61 | injectFlagEmojis: function(raw) { 62 | if (!raw) { return raw; } 63 | 64 | return raw.replace(_flagRegexCapture, function(matched) { 65 | const emojiArr = EMOJI.emojiStringToArray(matched); 66 | const asChars = emojiArr.map(function(u) { return EMOJI.unicodeRegionCharToAscii(u); }); 67 | const concat = asChars.join(''); 68 | return ``; 69 | }); 70 | } 71 | 72 | }; -------------------------------------------------------------------------------- /src/lib/shared/es6lib.js: -------------------------------------------------------------------------------- 1 | /*********************************************/ 2 | // general purpose utils for es6 dom & scraping 3 | /*********************************************/ 4 | 5 | var ES6 = { 6 | 7 | // make sure this stays small (eliminate when we can) 8 | // this is *only* here because popup needs parseUrl which needs getHomePageOwnerHandle() 9 | SPECIAL: { 10 | getTwitterHomePageOwnerHandle: function() { 11 | const profileAnchor = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]'); 12 | if (!profileAnchor) { return null; } 13 | const href = profileAnchor.getAttribute('href'); 14 | let handle = STR.stripPrefix(href, '/'); 15 | handle = STR.ensurePrefix(handle, '@'); 16 | return handle; 17 | } 18 | }, 19 | 20 | TRISTATE: { 21 | initAll: function() { 22 | // css-tricks.com/indeterminate-checkboxes/ 23 | Array.from(document.getElementsByClassName('chk-tristate')).forEach(function(chk) { 24 | chk.onclick = function(e) { 25 | const elm = e.target; 26 | if (elm.readOnly) { 27 | elm.checked = elm.readOnly=false; 28 | } 29 | else if (!elm.checked) { 30 | elm.readOnly = elm.indeterminate=true; 31 | } 32 | } 33 | }); 34 | }, 35 | 36 | setValue(chkElm, boolOrUndefined) { 37 | if (boolOrUndefined === true) { 38 | chkElm.checked = true; 39 | chkElm.indeterminate = false; 40 | } 41 | else if (boolOrUndefined === false) { 42 | chkElm.checked = false; 43 | chkElm.indeterminate = false; 44 | } 45 | else { 46 | // undefined 47 | chkElm.checked = false; 48 | chkElm.indeterminate = true; 49 | chkElm.readOnly = true; 50 | } 51 | }, 52 | 53 | getValue(chkElm) { 54 | const checked = chkElm.checked; 55 | const indeterminate = chkElm.indeterminate; 56 | 57 | if (checked === true) { 58 | return true; 59 | } 60 | else if (indeterminate === true) { 61 | return undefined; 62 | } 63 | else { 64 | return false; 65 | } 66 | } 67 | }, 68 | 69 | // stackoverflow.com/questions/41115702/hide-null-values-in-output-from-json-stringify 70 | asPrettyJson: function(data) { 71 | if (!data) { return null; } 72 | return JSON.stringify(data, ES6.jsonReplacer, 2); 73 | }, 74 | 75 | // stackoverflow.com/questions/41115702/hide-null-values-in-output-from-json-stringify 76 | jsonReplacer: function(key, value) { 77 | if (value === null) { 78 | return undefined; 79 | } 80 | return value; 81 | }, 82 | 83 | // builtin.com/software-engineering-perspectives/javascript-sleep 84 | // call using await 85 | sleep: function(delay) { 86 | return new Promise((resolve) => setTimeout(resolve, delay)); 87 | }, 88 | 89 | distinctify: function(arr) { 90 | const set = new Set(); 91 | for (let i = 0; i < arr.length; i++) { 92 | set.add(arr[i]); 93 | } 94 | return Array.from(set); 95 | }, 96 | 97 | // stackoverflow.com/questions/57618344/how-can-i-download-a-video-given-its-url-in-javascript 98 | downloadMediaFile: function(cdnUrl, fileName) { 99 | var xhr = new XMLHttpRequest(); 100 | xhr.open('GET', cdnUrl, true); 101 | xhr.responseType = 'blob'; 102 | xhr.onload = function() { 103 | var urlCreator = window.URL || window.webkitURL; 104 | var imageUrl = urlCreator.createObjectURL(this.response); 105 | var tag = document.createElement('a'); 106 | tag.href = imageUrl; 107 | tag.target = '_blank'; 108 | tag.download = fileName; 109 | document.body.appendChild(tag); 110 | tag.click(); 111 | document.body.removeChild(tag); 112 | }; 113 | xhr.onerror = err => { 114 | alert('Failed to download media'); 115 | }; 116 | xhr.send(); 117 | }, 118 | 119 | getDepthFirstTree: function(elem, elems = null) { 120 | elems = elems ?? []; 121 | 122 | if (elem) { 123 | elems.push(elem); 124 | 125 | if (elem.childNodes) { 126 | for (let i = 0; i < elem.childNodes.length; i++) { 127 | let child = elem.childNodes[i]; 128 | ES6.getDepthFirstTree(child, elems); 129 | } 130 | } 131 | } 132 | 133 | return elems; 134 | }, 135 | 136 | // videoDataUri is e.g. "data:video/mp4;base64,AAAAHGZ0eXBtcDQyAAA..."" 137 | swapImgForVideo: function(mediaParent, imgElm, videoDataUri) { 138 | const videoElm = document.createElement('video'); 139 | videoElm.setAttribute('autoplay', ''); 140 | videoElm.setAttribute('controls', ''); 141 | videoElm.src = videoDataUri; 142 | imgElm.remove(); 143 | mediaParent.appendChild(videoElm); 144 | return videoElm; 145 | }, 146 | 147 | preventDragBehaviors: function(e) { 148 | e.preventDefault(); 149 | e.stopPropagation(); 150 | }, 151 | 152 | // joshwcomeau.com/snippets/javascript/debounce/ 153 | debounce: function(callback, wait) { 154 | let timeoutId = null; 155 | return (...args) => { 156 | window.clearTimeout(timeoutId); 157 | timeoutId = window.setTimeout(() => { 158 | callback.apply(null, args); 159 | }, wait); 160 | }; 161 | }, 162 | 163 | isElmaWithinElmB: function(elmA, elmB, selfCheck = true) { 164 | if(selfCheck === true && elmA === elmB ) { return true; } 165 | while (elmA.parentNode) { 166 | elmA = elmA.parentNode; 167 | if (ES6.isElementNode(elmA) && elmA === elmB) { 168 | return true; 169 | } 170 | } 171 | return false; 172 | }, 173 | 174 | findUpClass: function(el, cls, selfCheck = true) { 175 | if(selfCheck === true && el && el.classList.contains(cls)) { return el; } 176 | while (el.parentNode) { 177 | el = el.parentNode; 178 | if (ES6.isElementNode(el) && el.classList.contains(cls)) { 179 | return el; 180 | } 181 | } 182 | return null; 183 | }, 184 | 185 | findUpByAttrValue: function(el, attrName, attrValue) { 186 | while (el.parentNode && el.parentNode.nodeType == 1 /* element */) { 187 | el = el.parentNode; 188 | if (ES6.isElementNode(el)) { 189 | let foundValue = el.getAttribute(attrName); 190 | if (foundValue && STR.sameText(foundValue, attrValue)) { 191 | return el; 192 | } 193 | } 194 | } 195 | 196 | return null; 197 | }, 198 | 199 | // stackoverflow.com/questions/7332179/how-to-recursively-search-all-parentnodes/7333885#7333885 200 | findUpTag: function(el, tag, selfCheck = true) { 201 | if (selfCheck === true && el && STR.sameText(el.tagName, tag)) { return el; } 202 | while (el.parentNode) { 203 | el = el.parentNode; 204 | if (ES6.isElementNode(el) && STR.sameText(el.tagName, tag)) { 205 | return el; 206 | } 207 | } 208 | return null; 209 | }, 210 | 211 | // stackoverflow.com/questions/21776389/javascript-object-grouping 212 | groupBy: function(arr, prop) { 213 | const map = new Map(Array.from(arr, obj => [obj[prop], []])); 214 | arr.forEach(obj => map.get(obj[prop]).push(obj)); 215 | return Array.from(map.values()); 216 | }, 217 | 218 | // stackoverflow.com/questions/1129216/sort-array-of-objects-by-string-property-value 219 | sortBy: function(arr, prop) { 220 | return arr.slice(0).sort(function(a, b) { 221 | if (!a[prop] && !b[prop]) { return 0; } 222 | if (a[prop] && !b[prop]) { return 1; } 223 | if (!a[prop] && b[prop]) { return -1; } 224 | return (a[prop] > b[prop]) ? 1 : (a[prop] < b[prop]) ? -1 : 0; 225 | }); 226 | }, 227 | 228 | sortByDesc: function(arr, prop) { 229 | return arr.slice(0).sort(function(a, b) { 230 | if (!a[prop] && !b[prop]) { return 0; } 231 | if (a[prop] && !b[prop]) { return -1; } 232 | if (!a[prop] && b[prop]) { return 1; } 233 | return (a[prop] > b[prop]) ? -1 : (a[prop] < b[prop]) ? 1 : 0; 234 | }); 235 | }, 236 | 237 | unfurlHtml: function(html) { 238 | 239 | if (!html || html.length === 0) { 240 | return ''; 241 | } 242 | 243 | const elm = document.createElement('div'); 244 | elm.innerHTML = DOMPurify.sanitize(html); 245 | const unfurled = ES6.getUnfurledText(elm); 246 | 247 | return unfurled; 248 | }, 249 | 250 | // accounts for emojis and newlines 251 | getUnfurledText: function(elem, expandAnchors) { 252 | let elems = ES6.getDepthFirstTree(elem); 253 | 254 | const elps = '…'; // twitter ellipses 255 | let concat = ''; 256 | 257 | for (let i = 0; i < elems.length; i++) { 258 | let e = elems[i]; 259 | let txt = ''; 260 | if (e.tagName && e.tagName.toLowerCase() === 'img') { 261 | let altAttr = e.getAttribute('alt'); // possible emoji 262 | if (EMOJI.isEmoji(altAttr)) { 263 | txt = altAttr; 264 | } 265 | } 266 | else if (e.nodeType == 3 && e.data && e.data != elps) { 267 | // text node 268 | txt = STR.cleanNewLineCharacters(e.data, '\n'); 269 | if (e.parentNode && ES6.isElementNode(e.parentNode) && e.parentNode.tagName.toLowerCase() == 'a') { 270 | // it's common for an url to be displayed that omits the http prefix, but we need it 271 | let href = e.parentNode.getAttribute('href'); 272 | if (STR.stripHttpWwwPrefix(txt) == STR.stripHttpWwwPrefix(href)) { 273 | // use the href instead 274 | txt = href; 275 | } 276 | else if (expandAnchors == true && txt.endsWith(elps)) { 277 | // this is abbreviated link text within an anchor; use the href instead 278 | txt = href; 279 | } 280 | } 281 | } 282 | else if (e.nodeName.toLowerCase() === 'br' || e.nodeName.toLowerCase() === 'p') { 283 | // newline 284 | txt = '\n'; 285 | } 286 | 287 | concat = `${concat}${txt}`; 288 | } 289 | 290 | return concat; 291 | }, 292 | 293 | getElementTextAsInt: function(elm) { 294 | if (!elm) { return undefined; } 295 | let val = elm.textContent; 296 | if (val && val.length > 0) { 297 | val = val.replace(',', ''); 298 | } 299 | 300 | return parseInt(val); 301 | }, 302 | 303 | addSeconds: function(date, seconds) { 304 | // Date.now() is in milliseconds 305 | return date + seconds * 1000; 306 | }, 307 | 308 | // this is for case where the chrome extension context has become invalidated (e.g. on an upgrade) 309 | // insertion of the query string parameter is so we don't get caught in a refresh loop 310 | tryCureInvalidatedContext: function() { 311 | const parser = new URL(window.location.href); 312 | let dnr = parser.searchParams.get('dnr'); 313 | if (dnr) { 314 | console.log('Page refresh is required, but we already tried! so this tab is simply disabled.'); 315 | return false; 316 | } 317 | else { 318 | parser.searchParams.append('dnr', true); 319 | window.location = parser.href; 320 | return true; 321 | } 322 | }, 323 | 324 | isElementNode: function(node) { 325 | return node && node.nodeType == 1; 326 | }, 327 | 328 | previousElementNodeSibling: function(node) { 329 | let prior = node; 330 | // 10 should be plenty 331 | for (let i = 0; i < 10; i++) { 332 | prior = prior.previousSibling; 333 | if (prior == null) { return null; } 334 | if (ES6.isElementNode(prior)) { return prior; } 335 | } 336 | 337 | return null; 338 | }, 339 | 340 | nextElementNodeSibling: function(node) { 341 | let next = node; 342 | // 10 should be plenty 343 | for (let i = 0; i < 10; i++) { 344 | next = next.nextSibling; 345 | if (next == null) { return null; } 346 | if (ES6.isElementNode(next)) { return next; } 347 | } 348 | 349 | return null; 350 | }, 351 | 352 | // get next item in array after hitting the marker value 353 | getNext: function(arr, marker, caseInsensitive = true) { 354 | let hit = false; 355 | for (let i = 0; i < arr.length; i++) { 356 | // the || is to also allow for non-string comparisons to be evaluated as intended 357 | if (arr[i] == marker || STR.sameText(arr[i], marker, caseInsensitive)) { 358 | hit = true; 359 | } 360 | else if (hit == true) { 361 | return arr[i]; 362 | } 363 | } 364 | 365 | return null; 366 | } 367 | }; -------------------------------------------------------------------------------- /src/lib/shared/imagelib.js: -------------------------------------------------------------------------------- 1 | var IMAGE = { 2 | 3 | TAG: { 4 | // An image tag that holds a base64 encoded image url, which can later be resolved (async) to a base64 image. 5 | // Reminder: Our chrome-ext CSP doesn't want to load remote images directly. 6 | DEFERRED_LOAD: 'deferred-load' 7 | }, 8 | 9 | PLACEHOLDER_CLUE: { 10 | MASTODON: 'missing.png' 11 | }, 12 | 13 | // this becomes img.src 14 | writeBase64Src: function(imgCdnUrl, img64Data) { 15 | const imgType = STR.inferImageFileExt(imgCdnUrl); 16 | return `data:image/${imgType};base64,${img64Data}`; 17 | }, 18 | 19 | shouldDeferLoadImages: function(site, imgCdnUrl) { 20 | switch(site) { 21 | case SITE.MASTODON: 22 | return imgCdnUrl.indexOf(IMAGE.PLACEHOLDER_CLUE.MASTODON) < 0; 23 | case SITE.TWITTER: 24 | default: 25 | return false; 26 | } 27 | }, 28 | 29 | writeDeferredLoadTag: function(url) { 30 | return `${IMAGE.TAG.DEFERRED_LOAD}='${STR.toBase64(url)}'`; 31 | }, 32 | 33 | resolveDeferredLoadImages: function(scopeElm) { 34 | Array.from(scopeElm.querySelectorAll(`img[${IMAGE.TAG.DEFERRED_LOAD}]`)).forEach(function(img) { 35 | let b64 = img.getAttribute(IMAGE.TAG.DEFERRED_LOAD); 36 | let url = STR.fromBase64(b64); 37 | const origSrc = STR.inferImageFileExt(img.getAttribute('src')); 38 | const img64Fn = IMAGE.getImageBase64(url); 39 | 40 | img64Fn.then(function(data64) { 41 | try { 42 | // successfully converted to base64 image that we can render! 43 | const newSrc = IMAGE.writeBase64Src(origSrc, data64); 44 | img.setAttribute('src', newSrc); 45 | } 46 | catch { 47 | // no image, no problem 48 | } 49 | 50 | img.removeAttribute(IMAGE.TAG.DEFERRED_LOAD); 51 | }); 52 | 53 | }); 54 | }, 55 | 56 | // fyi: if we ever need to, we could start using an image proxy like 57 | // https://corsproxy.io/?https://mastodon.scot/avatars/original/missing.png 58 | getImageBase64: async function(url) { 59 | const response = await fetch(url); 60 | const blob = await response.blob(); 61 | const reader = new FileReader(); 62 | await new Promise((resolve, reject) => { 63 | reader.onload = resolve; 64 | reader.onerror = reject; 65 | reader.readAsDataURL(blob); 66 | }); 67 | return reader.result.replace(/^data:.+;base64,/, ''); 68 | } 69 | 70 | }; -------------------------------------------------------------------------------- /src/lib/shared/pagetypes.js: -------------------------------------------------------------------------------- 1 | // the type of page the user is looking at 2 | var PAGETYPE = { 3 | // special pageType indicating entity, subject, object, graph, timestamp (no mapper required to save it) 4 | SOGE: 'soge', 5 | // nitter uses this pageType too 6 | TWITTER: { 7 | HOME: 'twitterHome', 8 | SEARCH: 'twitterSearch', 9 | FOLLOWERS: 'followersOnTwitter', 10 | FOLLOWING: 'followingOnTwitter', 11 | PROFILE: 'twitterProfile', 12 | TWEETS: 'tweets', // see SAVABLE_TWEET_ATTR 13 | TWEET_CARDS: 'tcards', // see TWEET_CARD_ATTR 14 | TWEET_POST_MEDIA: 'tpostmedia', // see TWEET_POST_IMG_ATTR 15 | TWEET_AUTHOR_IMG: 'tauthimgs' // { handle: ..., imgCdnUrl: ... } 16 | }, 17 | 18 | MASTODON: { 19 | FOLLOWERS: 'followersOnMastodon', 20 | FOLLOWING: 'followingOnMastodon', 21 | TOOTS: 'toots' 22 | }, 23 | 24 | GITHUB: { 25 | CONFIGURE: 'ghConfigure', 26 | BACKUP: 'ghBackup', 27 | RESTORE: 'ghRestore' 28 | }, 29 | 30 | getPageType: function(site, direction, forPosts) { 31 | switch (site) { 32 | case SITE.TWITTER: 33 | if (forPosts === true) { 34 | return PAGETYPE.TWITTER.TWEETS; 35 | } 36 | switch (direction) { 37 | case CONN_DIRECTION.FOLLOWING: 38 | return PAGETYPE.TWITTER.FOLLOWING; 39 | case CONN_DIRECTION.FOLLOWERS: 40 | return PAGETYPE.TWITTER.FOLLOWERS; 41 | default: 42 | return undefined; 43 | } 44 | case SITE.MASTODON: 45 | if (forPosts === true) { 46 | return PAGETYPE.MASTODON.TOOTS; 47 | } 48 | switch (direction) { 49 | case CONN_DIRECTION.FOLLOWING: 50 | return PAGETYPE.MASTODON.FOLLOWING; 51 | case CONN_DIRECTION.FOLLOWERS: 52 | return PAGETYPE.MASTODON.FOLLOWERS; 53 | default: 54 | return undefined; 55 | } 56 | default: 57 | return undefined; 58 | } 59 | }, 60 | 61 | getListMemberEntDefn: function(site) { 62 | switch (site) { 63 | case SITE.TWITTER: 64 | case SITE.NITTER: 65 | case SITE.MASTODON: 66 | return APPSCHEMA.SocialListMember; 67 | default: 68 | return undefined; 69 | } 70 | }, 71 | 72 | // note: linkedin or fb (symmetric) would use SocialConnection 73 | // this method will expand to include "Post" and other top-level concepts 74 | getRootEntDefn: function(pageType) { 75 | switch (pageType) { 76 | case PAGETYPE.TWITTER.FOLLOWERS: 77 | case PAGETYPE.MASTODON.FOLLOWERS: 78 | return APPSCHEMA.SocialConnHasFollower; 79 | case PAGETYPE.TWITTER.FOLLOWING: 80 | case PAGETYPE.MASTODON.FOLLOWING: 81 | return APPSCHEMA.SocialConnIsFollowing; 82 | default: 83 | return undefined; 84 | } 85 | }, 86 | 87 | // note: linkedin or fb (symmetric) would not have one (mutual follow is inherent) 88 | getReciprocalEntDefn: function(pageType) { 89 | switch (pageType) { 90 | case PAGETYPE.TWITTER.FOLLOWERS: 91 | case PAGETYPE.MASTODON.FOLLOWERS: 92 | return APPSCHEMA.SocialConnIsFollowing; 93 | case PAGETYPE.TWITTER.FOLLOWING: 94 | case PAGETYPE.MASTODON.FOLLOWING: 95 | return APPSCHEMA.SocialConnHasFollower; 96 | default: 97 | return undefined; 98 | } 99 | }, 100 | 101 | getSite: function(pageType) { 102 | switch (pageType) { 103 | case PAGETYPE.TWITTER.FOLLOWERS: 104 | case PAGETYPE.TWITTER.FOLLOWING: 105 | case PAGETYPE.TWITTER.PROFILE: 106 | case PAGETYPE.TWITTER.TWEETS: 107 | case PAGETYPE.TWITTER.TWEET_CARDS: 108 | case PAGETYPE.TWITTER.TWEET_POST_MEDIA: 109 | case PAGETYPE.TWITTER.TWEET_AUTHOR_IMG: 110 | return SITE.TWITTER; 111 | case PAGETYPE.MASTODON.FOLLOWERS: 112 | case PAGETYPE.MASTODON.FOLLOWING: 113 | case PAGETYPE.MASTODON.TOOTS: 114 | return SITE.MASTODON; 115 | case PAGETYPE.GITHUB.CONFIGURE: 116 | case PAGETYPE.GITHUB.BACKUP: 117 | case PAGETYPE.GITHUB.RESTORE: 118 | return SITE.GITHUB; 119 | default: 120 | return undefined; 121 | } 122 | } 123 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/networkfollowerspushmerger.js: -------------------------------------------------------------------------------- 1 | var NETWORK_FOLLOWERS_PUSH_MERGER = { 2 | 3 | mergeForPush: function(localJson, remoteJson) { 4 | return PUSH_MERGE_HELPER.mergeBySimpleDistinct( 5 | localJson, 6 | remoteJson, 7 | SYNC_COL.NETWORK.Connection, 8 | SYNC_COL.NETWORK.Connection, 9 | SYNCFLOW.STEP_TYPE.networkFollowers); 10 | } 11 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/networkfollowingspushmerger.js: -------------------------------------------------------------------------------- 1 | var NETWORK_FOLLOWINGS_PUSH_MERGER = { 2 | 3 | mergeForPush: function(localJson, remoteJson) { 4 | return PUSH_MERGE_HELPER.mergeBySimpleDistinct( 5 | localJson, 6 | remoteJson, 7 | SYNC_COL.NETWORK.Connection, 8 | SYNC_COL.NETWORK.Connection, 9 | SYNCFLOW.STEP_TYPE.networkFollowings); 10 | } 11 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/postimgspushmerger.js: -------------------------------------------------------------------------------- 1 | var POST_IMGS_PUSH_MERGER = { 2 | 3 | mergeForPush: function(localJson, remoteJson) { 4 | if (!STR.hasLen(localJson) || !STR.hasLen(remoteJson)) { return localJson; } 5 | const localRows = PUSH_MERGE_HELPER.getRows(localJson); 6 | const remoteRows = PUSH_MERGE_HELPER.getRows(remoteJson); 7 | 8 | const stepType = SYNCFLOW.STEP_TYPE.postImgs; 9 | 10 | let aggRows = []; 11 | const keys = new Set(); 12 | 13 | for (let i = 0; i < localRows.length; i++) { 14 | let localRow = localRows[i]; 15 | let key = POST_IMGS_PUSH_MERGER.buildKey(localRow); 16 | if (!keys.has(key)) { 17 | aggRows.push(localRow); 18 | } 19 | } 20 | 21 | for (let i = 0; i < remoteRows.length; i++) { 22 | let remoteRow = remoteRows[i]; 23 | let key = POST_IMGS_PUSH_MERGER.buildKey(remoteRow); 24 | if (!keys.has(key)) { 25 | aggRows.push(remoteRow); 26 | } 27 | } 28 | 29 | aggRows = ES6.sortBy(aggRows, SYNC_COL.POST_IMGS.PostUrlKey); 30 | const json = SYNCFLOW.PUSH_WRITER.asJson(aggRows, stepType); 31 | return json; 32 | }, 33 | 34 | // we don't want a massive dictionary by using the raw binary, so we hash it 35 | buildKey: function(row) { 36 | return CRYPTO.hash(`${row[SYNC_COL.POST_IMGS.PostUrlKey]}-${SYNC_COL.POST_IMGS.Img}`, CRYPTO.HASH_METHOD.SHA1); 37 | } 38 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/postspushmerger.js: -------------------------------------------------------------------------------- 1 | var POSTS_PUSH_MERGER = { 2 | 3 | mergeForPush: function(localJson, remoteJson) { 4 | return PUSH_MERGE_HELPER.mergeBySubject( 5 | localJson, 6 | remoteJson, 7 | POST_SEL.PostUrlKey, 8 | POSTS_PUSH_MERGER.mergeWorker, 9 | POST_SEL.PostTime, 10 | SYNCFLOW.STEP_TYPE.posts); 11 | }, 12 | 13 | mergeWorker: function(localRow, remoteRow) { 14 | const mergeUsesLocal = (localRow[SCHEMA_CONSTANTS.COLUMNS.Timestamp] > remoteRow[SCHEMA_CONSTANTS.COLUMNS.Timestamp]); 15 | const merged = (mergeUsesLocal) ? localRow : remoteRow; 16 | 17 | // our primary concern here is getting the thread right 18 | if (mergeUsesLocal) { 19 | POSTS_PUSH_MERGER.adoptThreadKeyAsNeeded(merged, remoteRow); 20 | } 21 | else { 22 | POSTS_PUSH_MERGER.adoptThreadKeyAsNeeded(merged, localRow); 23 | } 24 | 25 | return merged; 26 | }, 27 | 28 | // the thread key is "interesting" when it's not identical to the post key 29 | // and in a merge scenario we prefer the "earliest" post id number 30 | adoptThreadKeyAsNeeded: function(merged, adoptFrom) { 31 | const mThreadKey = merged[POST_SEL.ThreadUrlKey]; 32 | const aThreadKey = adoptFrom[POST_SEL.ThreadUrlKey]; 33 | 34 | const mPostKey = merged[POST_SEL.PostUrlKey]; 35 | const aPostKey = adoptFrom[POST_SEL.PostUrlKey]; 36 | 37 | if (!STR.hasLen(aThreadKey) || aThreadKey == aPostKey) { 38 | // these are scenarios where we don't have anything to gain from the other post info 39 | return; 40 | } 41 | 42 | if (!STR.hasLen(mThreadKey)) { 43 | merged[POST_SEL.ThreadUrlKey] = aThreadKey; 44 | } 45 | else if (mThreadKey == mPostKey) { 46 | merged[POST_SEL.ThreadUrlKey] = aThreadKey; 47 | } 48 | else { 49 | const mId = parseInt(STR.getTweetIdFromUrlKey(mThreadKey)); 50 | const aId = parseInt(STR.getTweetIdFromUrlKey(aThreadKey)); 51 | 52 | if (!isNaN(mId) && !isNaN(aId) && aId < mId) { 53 | merged[POST_SEL.ThreadUrlKey] = aThreadKey; 54 | } 55 | } 56 | } 57 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/posttopicratingspushmerger.js: -------------------------------------------------------------------------------- 1 | var POST_TOPIC_RATINGS_PUSH_MERGER = { 2 | 3 | mergeForPush: function(localJson, remoteJson) { 4 | return PUSH_MERGE_HELPER.mergeBySimpleDistinct( 5 | localJson, 6 | remoteJson, 7 | SYNC_COL.RATED_POST.PostUrlKey, 8 | SYNC_COL.RATED_POST.PostUrlKey, 9 | SYNCFLOW.STEP_TYPE.postTopicRatings); 10 | } 11 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/profilefavoritespushmerger.js: -------------------------------------------------------------------------------- 1 | var PROFILE_FAVORITES_PUSH_MERGER = { 2 | 3 | mergeForPush: function(localJson, remoteJson) { 4 | return PUSH_MERGE_HELPER.mergeBySimpleDistinct( 5 | localJson, 6 | remoteJson, 7 | SYNC_COL.FAVORITES.Handle, 8 | SYNC_COL.FAVORITES.Handle, 9 | SYNCFLOW.STEP_TYPE.profileFavorites); 10 | } 11 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/profileimgspushmerger.js: -------------------------------------------------------------------------------- 1 | var PROFILE_IMGS_PUSH_MERGER = { 2 | 3 | mergeForPush: function(localJson, remoteJson) { 4 | return PUSH_MERGE_HELPER.mergeBySubject( 5 | localJson, 6 | remoteJson, 7 | SYNC_COL.PROFILE_IMGS.Handle, 8 | PROFILE_IMGS_PUSH_MERGER.mergeWorker, 9 | SYNC_COL.PROFILE_IMGS.Handle, 10 | SYNCFLOW.STEP_TYPE.profileImgs); 11 | }, 12 | 13 | mergeWorker: function(localRow, remoteRow) { 14 | return (remoteRow[SCHEMA_CONSTANTS.COLUMNS.Timestamp] > localRow[SCHEMA_CONSTANTS.COLUMNS.Timestamp]) 15 | ? remoteRow 16 | : localRow; 17 | } 18 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/profilespushmerger.js: -------------------------------------------------------------------------------- 1 | var PROFILES_PUSH_MERGER = { 2 | 3 | mergeForPush: function(localJson, remoteJson) { 4 | return PUSH_MERGE_HELPER.mergeBySubject( 5 | localJson, 6 | remoteJson, 7 | SYNC_COL.PROFILES.Handle, 8 | PROFILES_PUSH_MERGER.mergeWorker, 9 | SYNC_COL.PROFILES.Handle, 10 | SYNCFLOW.STEP_TYPE.profiles); 11 | }, 12 | 13 | mergeWorker: function(localRow, remoteRow) { 14 | // starting with local (as opposed to starting with {}) is defensive in case we forget that new properties have been added 15 | const merged = localRow; 16 | if (remoteRow[SCHEMA_CONSTANTS.COLUMNS.Timestamp] > localRow[SCHEMA_CONSTANTS.COLUMNS.Timestamp]) { 17 | merged[SCHEMA_CONSTANTS.COLUMNS.Timestamp] = remoteRow[SCHEMA_CONSTANTS.COLUMNS.Timestamp]; 18 | merged[SYNC_COL.PROFILES.Display] = remoteRow[SYNC_COL.PROFILES.Display]; 19 | } 20 | 21 | if (!STR.hasLen(localRow[SYNC_COL.PROFILES.Detail]) || remoteRow[SYNC_COL.PROFILES.DetailTimestamp] > localRow[SYNC_COL.PROFILES.DetailTimestamp]) { 22 | merged[SYNC_COL.PROFILES.DetailTimestamp] = remoteRow[SYNC_COL.PROFILES.DetailTimestamp]; 23 | merged[SYNC_COL.PROFILES.Detail] = remoteRow[SYNC_COL.PROFILES.Detail]; 24 | } 25 | 26 | return merged; 27 | } 28 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/pushmergehelper.js: -------------------------------------------------------------------------------- 1 | var PUSH_MERGE_HELPER = { 2 | mergeBySimpleDistinct: function(localJson, remoteJson, distinctBy, sortBy, stepType) { 3 | if (!STR.hasLen(localJson) || !STR.hasLen(remoteJson)) { return localJson; } 4 | const localRows = PUSH_MERGE_HELPER.getRows(localJson); 5 | const remoteRows = PUSH_MERGE_HELPER.getRows(remoteJson); 6 | 7 | let aggRows = []; 8 | aggRows.push(...localRows); 9 | const keys = new Set(aggRows.map(function(r) { return r[distinctBy]; })); 10 | for (let i = 0; i < remoteRows.length; i++) { 11 | let remoteRow = remoteRows[i]; 12 | let key = remoteRow[distinctBy]; 13 | if (!keys.has(key)) { 14 | aggRows.push(remoteRow); 15 | keys.add(key); 16 | } 17 | } 18 | 19 | aggRows = ES6.sortBy(aggRows, sortBy); 20 | const json = SYNCFLOW.PUSH_WRITER.asJson(aggRows, stepType); 21 | return json; 22 | }, 23 | 24 | mergeBySubject: function(localJson, remoteJson, sProp, fnMergeWorker, sortBy, stepType) { 25 | if (!STR.hasLen(localJson) || !STR.hasLen(remoteJson)) { return localJson; } 26 | const localObj = JSON.parse(localJson); 27 | const remoteObj = JSON.parse(remoteJson); 28 | if (PUSH_MERGE_HELPER.isRemoteOldVersion(localObj, remoteObj)) { return localJson; } 29 | const localRows = localObj[SYNCFLOW.DATA_PART.data]; 30 | const remoteRows = remoteObj[SYNCFLOW.DATA_PART.data]; 31 | let aggRows = []; 32 | 33 | const localKeys = new Set(localRows.map(function(r) { return PUSH_MERGE_HELPER.getSubjectGraphKey(r, sProp); })); 34 | const remoteKeys = new Set(localRows.map(function(r) { return PUSH_MERGE_HELPER.getSubjectGraphKey(r, sProp); })); 35 | 36 | for (let i = 0; i < localRows.length; i++) { 37 | let localRow = localRows[i]; 38 | let key = PUSH_MERGE_HELPER.getSubjectGraphKey(localRow, sProp); 39 | if (!remoteKeys.has(key)) { 40 | // no-merge needed 41 | aggRows.push(localRow); 42 | } 43 | else { 44 | let remoteRow = remoteRows.find(function(r) { return PUSH_MERGE_HELPER.getSubjectGraphKey(r, sProp) == key; }); 45 | if (remoteRow) { 46 | let mergedRow = fnMergeWorker(localRow, remoteRow); 47 | aggRows.push(mergedRow); 48 | } 49 | else { 50 | // no corresponding remote row 51 | aggRows.push(localRow); 52 | } 53 | } 54 | } 55 | 56 | // remote rows that needed merge were handled; here only pick up those not contained in local set 57 | for (let i = 0; i < remoteRows.length; i++) { 58 | let remoteRow = remoteRows[i]; 59 | let key = PUSH_MERGE_HELPER.getSubjectGraphKey(remoteRow, sProp); 60 | if (!localKeys.has(key)) { 61 | aggRows.push(remoteRow); 62 | } 63 | } 64 | 65 | aggRows = ES6.sortBy(aggRows, sortBy); 66 | const json = SYNCFLOW.PUSH_WRITER.asJson(aggRows, stepType); 67 | return json; 68 | }, 69 | 70 | getSubjectGraphKey: function(row, sProp) { 71 | return `${row[sProp]}-${row[SCHEMA_CONSTANTS.COLUMNS.NamedGraph]}`.toLowerCase(); 72 | }, 73 | 74 | getRows: function(json) { 75 | const obj = JSON.parse(json); 76 | const rows = obj[SYNCFLOW.DATA_PART.data]; 77 | return rows; 78 | }, 79 | 80 | isRemoteOldVersion: function(localObj, remoteObj) { 81 | const localVersion = parseInt(localObj[SYNCFLOW.DATA_PART.version]); 82 | const remoteVersion = parseInt(remoteObj[SYNCFLOW.DATA_PART.version]); 83 | return !isNaN(remoteVersion) && !isNaN(localVersion) && remoteVersion < localVersion; 84 | } 85 | }; -------------------------------------------------------------------------------- /src/lib/shared/pushmerge/pushmergerfactory.js: -------------------------------------------------------------------------------- 1 | var PUSH_MERGER_FACTORY = { 2 | getPushMerger: function(stepType) { 3 | switch (stepType) { 4 | case SYNCFLOW.STEP_TYPE.profileFavorites: 5 | return PROFILE_FAVORITES_PUSH_MERGER; 6 | case SYNCFLOW.STEP_TYPE.profiles: 7 | return PROFILES_PUSH_MERGER; 8 | case SYNCFLOW.STEP_TYPE.profileImgs: 9 | return PROFILE_IMGS_PUSH_MERGER; 10 | case SYNCFLOW.STEP_TYPE.networkFollowings: 11 | return NETWORK_FOLLOWINGS_PUSH_MERGER; 12 | case SYNCFLOW.STEP_TYPE.networkFollowers: 13 | return NETWORK_FOLLOWERS_PUSH_MERGER; 14 | case SYNCFLOW.STEP_TYPE.postTopicRatings: 15 | return POST_TOPIC_RATINGS_PUSH_MERGER; 16 | case SYNCFLOW.STEP_TYPE.posts: 17 | return POSTS_PUSH_MERGER; 18 | case SYNCFLOW.STEP_TYPE.postImgs: 19 | return POST_IMGS_PUSH_MERGER; 20 | default: 21 | return null; 22 | } 23 | } 24 | }; -------------------------------------------------------------------------------- /src/lib/shared/queue.js: -------------------------------------------------------------------------------- 1 | // javascripttutorial.net/javascript-queue/ 2 | class Queue { 3 | 4 | constructor() { 5 | this.elements = {}; 6 | this.head = 0; 7 | this.tail = 0; 8 | } 9 | 10 | enqueue(element) { 11 | this.elements[this.tail] = element; 12 | this.tail++; 13 | } 14 | 15 | dequeue() { 16 | const item = this.elements[this.head]; 17 | delete this.elements[this.head]; 18 | this.head++; 19 | return item; 20 | } 21 | 22 | peek() { 23 | return this.elements[this.head]; 24 | } 25 | 26 | get length() { 27 | return this.tail - this.head; 28 | } 29 | 30 | get isEmpty() { 31 | return this.length === 0; 32 | } 33 | } -------------------------------------------------------------------------------- /src/lib/shared/urlparsing.js: -------------------------------------------------------------------------------- 1 | var URLPARSE = { 2 | 3 | getQueryParms: function() { 4 | const rawParams = new URLSearchParams(location.search); 5 | const parms = {}; 6 | for (const [key, value] of rawParams) { 7 | parms[key] = value; 8 | } 9 | return parms; 10 | }, 11 | 12 | getQueryParm: function(key) { 13 | const parms = URLPARSE.getQueryParms(); 14 | return parms[key]; 15 | }, 16 | 17 | equivalentUrl: function(url1, url2) { 18 | if (!url1 || !url2 || url1.length == 0 || url2.length == 0) { return false; } 19 | const parsedUrl1 = URLPARSE.parseUrl(url1); 20 | const parsedUrl2 = URLPARSE.parseUrl(url2); 21 | return URLPARSE.equivalentParsedUrl(parsedUrl1, parsedUrl2); 22 | }, 23 | 24 | equivalentParsedUrl: function(parsedUrl1, parsedUrl2, relaxedOwnerCheck) { 25 | if (!parsedUrl1 || !parsedUrl2) { return false; } 26 | const pageType1 = URLPARSE.finalizePageType(parsedUrl1.pageType); 27 | const pageType2 = URLPARSE.finalizePageType(parsedUrl2.pageType); 28 | const draft = (parsedUrl1.site == parsedUrl2.site && pageType1 == pageType2 && (relaxedOwnerCheck == true || STR.sameText(parsedUrl1.owner, parsedUrl2.owner))); 29 | 30 | if (parsedUrl1.withReplies === true && parsedUrl2.withReplies === false || parsedUrl1.withReplies === false && parsedUrl2.withReplies === true) { 31 | // when user clicks to resume auto-recording and we switch to that tab, 32 | // we'd rather launch a new window 'with_replies' (for a recording of tweets situation) than to reuse 33 | // a window that lacks 'with_replies' (since the default tweets list doesn't go back all the way in time) 34 | return false; 35 | } 36 | else { 37 | return draft; 38 | } 39 | }, 40 | 41 | buildUrl: function(parsedUrl, nitterDomain) { 42 | const site = parsedUrl.site || PAGETYPE.getSite(parsedUrl.pageType); 43 | const owner = parsedUrl.owner; 44 | let baseUrl = 'https://x.com'; 45 | 46 | switch (site) { 47 | case SITE.NITTER: 48 | baseUrl = `https://${nitterDomain}`; 49 | break; 50 | default: 51 | break; 52 | } 53 | 54 | // note that there is no nitter following/follwers 55 | switch (parsedUrl.pageType) { 56 | case PAGETYPE.TWITTER.FOLLOWING: 57 | return `${baseUrl}/${owner}/following`; 58 | 59 | case PAGETYPE.TWITTER.FOLLOWERS: 60 | return `${baseUrl}/${owner}/followers`; 61 | 62 | case PAGETYPE.TWITTER.PROFILE: 63 | case PAGETYPE.TWITTER.TWEETS: 64 | // unless we say 'with_replies' the infinite scroll doesn't go back all the way (empirically) 65 | return `${baseUrl}/${owner}/with_replies`; 66 | 67 | default: 68 | return undefined; 69 | } 70 | }, 71 | 72 | finalizePageType: function(pageType) { 73 | switch (pageType) { 74 | case PAGETYPE.TWITTER.HOME: 75 | case PAGETYPE.TWITTER.SEARCH: 76 | return PAGETYPE.TWITTER.TWEETS; 77 | default: 78 | return pageType; 79 | } 80 | }, 81 | 82 | getActivePageOwner: function() { 83 | const parsedUrl = URLPARSE.parseUrl(document.location.href); 84 | if (!parsedUrl) { return null; } 85 | return parsedUrl.owner; 86 | }, 87 | 88 | parseUrl: function(url) { 89 | let fullUrl = url; 90 | if (!url || url.length == 0) { return undefined; } 91 | 92 | let pageType; 93 | let site; 94 | let owner; 95 | let threadDetailId; 96 | let bookmarked; 97 | 98 | url = STR.stripUrlHashSuffix(url); 99 | url = STR.stripQueryString(url); 100 | url = STR.stripSuffix(url, '/'); 101 | url = STR.stripHttpWwwPrefix(url); 102 | url = url.replace('mobile.', ''); 103 | 104 | const parts = url.split('/'); 105 | const domain = parts[0]; 106 | if (STR.sameText(domain, 'twitter.com') == true || STR.sameText(domain, 'x.com') == true) { 107 | site = SITE.TWITTER; 108 | } 109 | else if (SETTINGS.NITTER.isNitterDomain(domain)) { 110 | site = SITE.NITTER; 111 | } 112 | else { 113 | // for now we only know how to parse twitter and nitter urls 114 | return undefined; 115 | } 116 | 117 | let withReplies = false; 118 | if (parts.length == 1 && site == SITE.TWITTER) { 119 | return { 120 | pageType: PAGETYPE.TWITTER.HOME, 121 | site: site 122 | }; 123 | } 124 | else if (parts.length == 2) { 125 | switch (parts[1].toLowerCase()) { 126 | case 'home': 127 | return { 128 | pageType: PAGETYPE.TWITTER.HOME, 129 | site: site 130 | }; 131 | case 'search': 132 | const searchOwner = URLPARSE.getTwitterSearchPageHandle(fullUrl); 133 | return { 134 | pageType: PAGETYPE.TWITTER.SEARCH, 135 | site: site, 136 | owner: searchOwner 137 | }; 138 | case 'explore': 139 | case 'notifications': 140 | case 'messages': 141 | case 'about': 142 | case 'settings': 143 | return null; 144 | default: 145 | // continue (presumably it's e.g. 'x.com/scafaria') 146 | pageType = PAGETYPE.TWITTER.TWEETS; 147 | owner = parts[1]; 148 | withReplies = false; 149 | break; 150 | } 151 | 152 | } 153 | else if (parts.length == 3) { 154 | owner = parts[1]; 155 | switch (parts[2].toLowerCase()) { 156 | case 'followers': 157 | case 'followers_you_follow': 158 | case 'verified_followers': 159 | pageType = PAGETYPE.TWITTER.FOLLOWERS; 160 | break; 161 | case 'following': 162 | pageType = PAGETYPE.TWITTER.FOLLOWING; 163 | break; 164 | case 'with_replies': 165 | pageType = PAGETYPE.TWITTER.TWEETS; 166 | withReplies = true; 167 | break; 168 | case 'bookmarks': 169 | bookmarked = true; 170 | case 'likes': 171 | case 'media': 172 | pageType = PAGETYPE.TWITTER.TWEETS; 173 | withReplies = false; 174 | break; 175 | default: 176 | // unknown 177 | return undefined; 178 | } 179 | } 180 | else if (parts.length == 4 && STR.sameText(parts[2], 'status')) { 181 | // x.com/username/status/12345 182 | owner = parts[1]; 183 | pageType = PAGETYPE.TWITTER.TWEETS; 184 | threadDetailId = parts[3]; 185 | } 186 | else if (parts.length == 4 && STR.sameText(parts[2], 'lists')) { 187 | // x.com/i/lists/0000000000000000123 188 | owner = parts[1]; 189 | pageType = PAGETYPE.TWITTER.TWEETS; 190 | } 191 | 192 | // special case happens with twitter bookmarks and one's own lists 193 | if (owner == 'i') { 194 | owner = ES6.SPECIAL.getTwitterHomePageOwnerHandle(); 195 | } 196 | 197 | return { 198 | pageType: pageType, 199 | site: site, 200 | owner: owner, 201 | withReplies: withReplies, 202 | threadDetailId: threadDetailId, 203 | bookmarked: bookmarked 204 | }; 205 | }, 206 | 207 | // https://twitter.com/search?q=from%3A%40positivesumnet%20until%3A2023-03-01&src=typed_query&f=live 208 | getTwitterSearchPageHandle: function(url) { 209 | url = url || document.location.href; 210 | const splat = url.split('?'); 211 | if (splat.length < 2) { return null; } 212 | const qParms = splat[1].split('&'); 213 | const qParm = qParms.find(function(q) { return q.startsWith('q'); }); 214 | const qSplat = qParm.split('='); 215 | if (qSplat.length < 2) { return null; } 216 | let qParmVal = qSplat[1]; 217 | if (!STR.hasLen(qParmVal)) { 218 | return null; 219 | } 220 | else { 221 | qParmVal = qParmVal.replaceAll('%20', ' ').replaceAll('+', ' ').replaceAll('%40', '@').replaceAll('from:', '').replaceAll('from%3A', ''); 222 | const parts = qParmVal.split(' '); 223 | const ownerPart = parts.find(function(p) { return p.startsWith('@'); }); 224 | if (STR.hasLen(ownerPart)) { 225 | return STR.stripPrefix(ownerPart, '@'); 226 | } 227 | else { 228 | return null; 229 | } 230 | } 231 | }, 232 | 233 | // twitter doesn't always surface the urlKey of the quote tweet 234 | buildVirtualQuoteUrlKey: function(tweetUrlKey) { 235 | return `${tweetUrlKey}${QUOTED_SUFFIX}`; 236 | }, 237 | 238 | isVirtualQuoteUrlKey: function(urlKey) { 239 | return urlKey && urlKey.endsWith(QUOTED_SUFFIX); 240 | } 241 | 242 | }; -------------------------------------------------------------------------------- /src/lib/worker/dbsyncsaver.js: -------------------------------------------------------------------------------- 1 | var DBSYNCSAVER = { 2 | // called by continueRestore 3 | // reminder, we're on the worker thread 4 | saveForRestore: function(request) { 5 | const step = request.step; 6 | const rateLimit = request.rateLimit; 7 | const fileMarker = request.fileMarker; 8 | const data = request.data; 9 | const rows = (data && data.data) ? data.data : []; // data node holds the records 10 | 11 | if (rows.length > 0) { 12 | const mapper = IMPORT_MAPPER_FACTORY.getMapper(step[SYNCFLOW.STEP.type]); 13 | const savableSet = mapper.mapSavableSet(data); 14 | DBORM.SAVING.execSaveSet(savableSet); 15 | } 16 | 17 | const result = SYNCFLOW.PULL_EXEC.buildPulledResult(step, rows, rateLimit, fileMarker); 18 | postMessage({ 19 | type: MSGTYPE.FROMDB.SAVED_FOR_RESTORE, 20 | result: result 21 | }); 22 | } 23 | }; -------------------------------------------------------------------------------- /src/lib/worker/mastodonconnsavemapper.js: -------------------------------------------------------------------------------- 1 | // saving followers or connections 2 | // where source is per PERSON_ATTR 3 | 4 | var MASTODON_CONN_SAVE_MAPPER = { 5 | 6 | // the entity definitions relevant for saving followers/connections 7 | getEntDefns: function(pageType) { 8 | 9 | const connEntDefn = PAGETYPE.getRootEntDefn(pageType); 10 | 11 | return [ 12 | connEntDefn, 13 | APPSCHEMA.SocialProfileDisplayName, 14 | APPSCHEMA.SocialProfileDescription, 15 | APPSCHEMA.SocialProfileImgSourceUrl, 16 | APPSCHEMA.SocialProfileLinkExternalUrl, 17 | APPSCHEMA.SocialProfileLinkEmailAddress, 18 | APPSCHEMA.SocialFollowerCount, 19 | APPSCHEMA.SocialFollowingCount 20 | ]; 21 | }, 22 | 23 | mapSavableSet: function(records, pageType, graph) { 24 | const connEntDefn = PAGETYPE.getRootEntDefn(pageType); 25 | const entDefns = MASTODON_CONN_SAVE_MAPPER.getEntDefns(pageType); 26 | // passing false for onlyIfNewer because this is a save context (definitely newer than whatever is in the db) 27 | // as opposed to a sync context (where we could be syncing in data that is older than our db) 28 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, false); 29 | 30 | // now map the records into each subset based on connection-specific mapping logic 31 | // subject | object | graph 32 | 33 | const connections = records.map(function(x) { 34 | return {s: x.OwnerHandle, o: x.Handle, g: graph}; 35 | }); 36 | 37 | const displayNames = records.map(function(x) { 38 | return {s: x.Handle, o: x.DisplayName, g: graph}; 39 | }); 40 | 41 | const descriptions = records.map(function(x) { 42 | return {s: x.Handle, o: x.Detail, g: graph}; 43 | }); 44 | 45 | const imgCdns = records.map(function(x) { 46 | return {s: x.Handle, o: x.ImgCdnUrl, g: graph}; 47 | }); 48 | 49 | const followerCounts = records.map(function(x) { 50 | return {s: x.Handle, o: x.FollowersCount, g: graph}; 51 | }); 52 | 53 | const followingCounts = records.map(function(x) { 54 | return {s: x.Handle, o: x.FollowingCount, g: graph}; 55 | }); 56 | 57 | const urls = []; 58 | const emails = []; 59 | for (let i = 0; i < records.length; i++) { 60 | let record = records[i]; 61 | let accounts = STR.extractAccounts([record.DisplayName, record.Detail], false); 62 | if (accounts.urls) { 63 | accounts.urls.forEach(x => urls.push({s: record.Handle, o: x, g: graph})); 64 | } 65 | if (accounts.emails) { 66 | accounts.emails.forEach(x => emails.push({s: record.Handle, o: x, g: graph})); 67 | } 68 | } 69 | 70 | APPSCHEMA.SAVING.getSubset(set, connEntDefn.Name).sogs = connections; 71 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileDisplayName.Name).sogs = displayNames; 72 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileDescription.Name).sogs = descriptions; 73 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileImgSourceUrl.Name).sogs = imgCdns; 74 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileLinkExternalUrl.Name).sogs = urls; 75 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileLinkEmailAddress.Name).sogs = emails; 76 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialFollowerCount.Name).sogs = followerCounts; 77 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialFollowingCount.Name).sogs = followingCounts; 78 | 79 | return set; 80 | } 81 | }; -------------------------------------------------------------------------------- /src/lib/worker/savemapperfactory.js: -------------------------------------------------------------------------------- 1 | /* 2 | returns: 3 | 4 | ISaveMapper { 5 | SavableSet mapSavableSet(records, pageType); 6 | } 7 | 8 | SavableSet is per APPSCHEMA.SAVING.newSavableSet 9 | 10 | */ 11 | var SAVEMAPPERFACTORY = { 12 | 13 | getSaveMapper: function(pageType) { 14 | switch(pageType) { 15 | case PAGETYPE.TWITTER.FOLLOWERS: 16 | case PAGETYPE.TWITTER.FOLLOWING: 17 | return TWITTER_CONN_SAVE_MAPPER; 18 | case PAGETYPE.TWITTER.PROFILE: 19 | return TWITTER_PROFILE_SAVE_MAPPER; 20 | case PAGETYPE.MASTODON.FOLLOWERS: 21 | case PAGETYPE.MASTODON.FOLLOWING: 22 | return MASTODON_CONN_SAVE_MAPPER; 23 | case PAGETYPE.TWITTER.TWEETS: 24 | return TWEET_SAVE_MAPPER; 25 | case PAGETYPE.TWITTER.TWEET_CARDS: 26 | return TCARD_SAVE_MAPPER; 27 | case PAGETYPE.TWITTER.TWEET_POST_MEDIA: 28 | return TPOST_MEDIA_SAVE_MAPPER; 29 | case PAGETYPE.TWITTER.TWEET_AUTHOR_IMG: 30 | return TWEET_AUTHOR_IMG_SAVE_MAPPER; 31 | default: 32 | return undefined; 33 | } 34 | } 35 | }; -------------------------------------------------------------------------------- /src/lib/worker/syncimport/importmapperfactory.js: -------------------------------------------------------------------------------- 1 | var IMPORT_MAPPER_FACTORY = { 2 | getMapper: function(stepType) { 3 | switch (stepType) { 4 | case SYNCFLOW.STEP_TYPE.profileFavorites: 5 | return PROFILE_FAVORITES_IMPORT_MAPPER; 6 | case SYNCFLOW.STEP_TYPE.profiles: 7 | return PROFILES_IMPORT_MAPPER; 8 | case SYNCFLOW.STEP_TYPE.profileImgs: 9 | return PROFILE_IMGS_IMPORT_MAPPER; 10 | case SYNCFLOW.STEP_TYPE.networkFollowings: 11 | case SYNCFLOW.STEP_TYPE.networkFollowers: 12 | return NETWORK_IMPORT_MAPPER; 13 | case SYNCFLOW.STEP_TYPE.postTopicRatings: 14 | return POST_TOPIC_RATINGS_IMPORT_MAPPER; 15 | case SYNCFLOW.STEP_TYPE.posts: 16 | return POSTS_IMPORT_MAPPER; 17 | case SYNCFLOW.STEP_TYPE.postImgs: 18 | return POST_IMGS_IMPORT_MAPPER; 19 | default: 20 | return null; 21 | } 22 | } 23 | }; -------------------------------------------------------------------------------- /src/lib/worker/syncimport/networkimportmapper.js: -------------------------------------------------------------------------------- 1 | var NETWORK_IMPORT_MAPPER = { 2 | mapSavableSet: function(data) { 3 | const connEntDefn = NETWORK_IMPORT_MAPPER.getConnEntity(data); 4 | const entDefns = [connEntDefn]; 5 | 6 | // passing true for onlyIfNewer because this is a sync context 7 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, true); 8 | const records = data.data; // data node holds the records 9 | 10 | // RDF - subject, object, graph, timestamp 11 | const connections = records.map(function(x) { 12 | return { 13 | s: x[SYNC_COL.NETWORK.Handle], 14 | o: x[SYNC_COL.NETWORK.Connection], 15 | g: x[SCHEMA_CONSTANTS.COLUMNS.NamedGraph], 16 | t: x[SCHEMA_CONSTANTS.COLUMNS.Timestamp] 17 | }; 18 | }); 19 | 20 | APPSCHEMA.SAVING.getSubset(set, connEntDefn.Name).sogs = connections; 21 | 22 | return set; 23 | }, 24 | 25 | getConnEntity: function(data) { 26 | const type = data.type; 27 | switch (type) { 28 | case SYNCFLOW.STEP_TYPE.networkFollowers: 29 | return APPSCHEMA.SocialConnHasFollower; 30 | case SYNCFLOW.STEP_TYPE.networkFollowings: 31 | return APPSCHEMA.SocialConnIsFollowing; 32 | default: 33 | throw('unexpected connection data type'); 34 | } 35 | } 36 | }; -------------------------------------------------------------------------------- /src/lib/worker/syncimport/postimgsimportmapper.js: -------------------------------------------------------------------------------- 1 | var POST_IMGS_IMPORT_MAPPER = { 2 | mapSavableSet: function(data) { 3 | 4 | // see getPostImageEntities 5 | const entDefns = [ 6 | APPSCHEMA.SocialPostCardImgBinary, 7 | APPSCHEMA.SocialPostCardImgSourceUrl, 8 | APPSCHEMA.SocialPostRegImgBinary, 9 | APPSCHEMA.SocialPostRegImgSourceUrl 10 | ]; 11 | 12 | // passing true for onlyIfNewer because this is a sync context 13 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, true); 14 | const records = data.data; // data node holds the records 15 | 16 | // RDF - subject, object, graph, timestamp 17 | for (let i = 0; i < records.length; i++) { 18 | let record = records[i]; 19 | // per SYNC.getPostImgs, ent defn name is the "type" property 20 | let type = record[SYNC_COL.POST_IMGS.Type]; 21 | let entDefn = entDefns.find(function(e) { return e.Name == type; }); 22 | let subset = APPSCHEMA.SAVING.getSubset(set, entDefn.Name); 23 | let mapped = POST_IMGS_IMPORT_MAPPER.mapRecord(record); 24 | subset.sogs.push(mapped); 25 | } 26 | 27 | return set; 28 | }, 29 | 30 | mapRecord: function(record) { 31 | return { 32 | s: record[SYNC_COL.POST_IMGS.PostUrlKey], 33 | o: record[SYNC_COL.POST_IMGS.Img], 34 | g: record[SCHEMA_CONSTANTS.COLUMNS.NamedGraph], 35 | t: record[SCHEMA_CONSTANTS.COLUMNS.Timestamp] 36 | }; 37 | } 38 | }; -------------------------------------------------------------------------------- /src/lib/worker/syncimport/postsimportmapper.js: -------------------------------------------------------------------------------- 1 | var POSTS_IMPORT_MAPPER = { 2 | mapSavableSet: function(data) { 3 | 4 | const entDefns = [ 5 | // author 6 | APPSCHEMA.SocialPostAuthorHandle, 7 | APPSCHEMA.SocialProfileDisplayName, 8 | // post 9 | APPSCHEMA.SocialPostTime, 10 | APPSCHEMA.SocialPostText, 11 | APPSCHEMA.SocialPostReplyToUrlKey, 12 | APPSCHEMA.SocialPostReposter, 13 | APPSCHEMA.SocialPostQuoteOf, 14 | APPSCHEMA.SocialPostThreadUrlKey, 15 | APPSCHEMA.SocialPostSearchBlob, 16 | APPSCHEMA.SocialPostEmbedsVideo, 17 | // card 18 | APPSCHEMA.SocialPostCardSearchBlob, 19 | APPSCHEMA.SocialPostCardText, 20 | APPSCHEMA.SocialPostCardShortUrl, 21 | APPSCHEMA.SocialPostCardFullUrl, 22 | // stats 23 | APPSCHEMA.SocialPostReplyCount, 24 | APPSCHEMA.SocialPostLikeCount, 25 | APPSCHEMA.SocialPostReshareCount 26 | ]; 27 | 28 | // passing true for onlyIfNewer because this is a sync context 29 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, true); 30 | const records = data.data; // data node holds the records 31 | 32 | const mapper = POSTS_IMPORT_MAPPER; // just to abbreviate 33 | // RDF - subject, object, graph, timestamp 34 | for (let i = 0; i < records.length; i++) { 35 | let record = records[i]; 36 | mapper.mapPostWorker(set, record); 37 | // quote tweet 38 | if (record[POST_SEL.QuoteTweet]) { 39 | mapper.mapPostWorker(set, record[POST_SEL.QuoteTweet], record); 40 | } 41 | // reply tweet 42 | if (record[POST_SEL.ReplyToTweet]) { 43 | mapper.mapPostWorker(set, record[POST_SEL.ReplyToTweet], record); 44 | } 45 | } 46 | 47 | return set; 48 | }, 49 | 50 | mapPostWorker: function(set, record, parentRecord) { 51 | const mapper = POSTS_IMPORT_MAPPER; // just to abbreviate 52 | // the select columns of SYNC.getPosts 53 | mapper.attachPostAttr(POST_SEL.PostTime, APPSCHEMA.SocialPostTime, set, record, parentRecord); 54 | mapper.attachPostAttr(POST_SEL.AuthorHandle, APPSCHEMA.SocialPostAuthorHandle, set, record, parentRecord); 55 | mapper.attachAuthorName(set, record, parentRecord); 56 | mapper.attachPostAttr(POST_SEL.PostText, APPSCHEMA.SocialPostText, set, record, parentRecord); 57 | mapper.attachPostAttr(POST_SEL.ReplyToUrlKey, APPSCHEMA.SocialPostReplyToUrlKey, set, record, parentRecord); 58 | mapper.attachPostAttr(POST_SEL.ReposterHandle, APPSCHEMA.SocialPostReposter, set, record, parentRecord); 59 | mapper.attachReposterName(set, record, parentRecord); 60 | mapper.attachPostAttr(POST_SEL.QuoteOfUrlKey, APPSCHEMA.SocialPostQuoteOf, set, record, parentRecord); 61 | mapper.attachPostAttr(POST_SEL.ThreadUrlKey, APPSCHEMA.SocialPostThreadUrlKey, set, record, parentRecord); 62 | mapper.attachPostAttr(POST_SEL.EmbedsVideo, APPSCHEMA.SocialPostEmbedsVideo, set, record, parentRecord); 63 | mapper.attachPostAttr(POST_SEL.CardText, APPSCHEMA.SocialPostCardText, set, record, parentRecord); 64 | mapper.attachPostAttr(POST_SEL.ReplyCount, APPSCHEMA.SocialPostReplyCount, set, record, parentRecord); 65 | mapper.attachPostAttr(POST_SEL.LikeCount, APPSCHEMA.SocialPostLikeCount, set, record, parentRecord); 66 | mapper.attachPostAttr(POST_SEL.ReshareCount, APPSCHEMA.SocialPostReshareCount, set, record, parentRecord); 67 | mapper.attachPostAttr(POST_SEL.CardShortUrl, APPSCHEMA.SocialPostCardShortUrl, set, record, parentRecord); 68 | mapper.attachPostAttr(POST_SEL.CardFullUrl, APPSCHEMA.SocialPostCardFullUrl, set, record, parentRecord); 69 | // the more interesting mappings 70 | mapper.attachSearchBlob(set, record, parentRecord); 71 | mapper.attachCardSearchBlob(set, record, parentRecord); 72 | }, 73 | 74 | attachSearchBlob: function(set, record, parentRecord) { 75 | const urlKey = record[POST_SEL.PostUrlKey]; 76 | const authorHandle = record[POST_SEL.AuthorHandle]; 77 | const authorName = record[POST_SEL.AuthorName]; 78 | const postText = record[POST_SEL.PostText]; 79 | let qtAuthorHandle; 80 | let qtAuthorName; 81 | let qtPostText; 82 | 83 | let qt = record[POST_SEL.QuoteTweet]; 84 | if (qt) { 85 | qtAuthorHandle = qt[POST_SEL.AuthorHandle]; 86 | qtAuthorName = qt[POST_SEL.AuthorName]; 87 | qtPostText = qt[POST_SEL.PostText]; 88 | } 89 | 90 | const searchBlob = TWEET_SAVE_MAPPER.buildSearchBlob(urlKey, authorHandle, authorName, postText, qtAuthorHandle, qtAuthorName, qtPostText); 91 | 92 | const mapped = { 93 | s: record[POST_SEL.PostUrlKey], 94 | o: searchBlob, 95 | g: POSTS_IMPORT_MAPPER.getGraph(record, parentRecord), 96 | t: POSTS_IMPORT_MAPPER.getTimestamp(record) 97 | }; 98 | 99 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialPostSearchBlob.Name).sogs.push(mapped); 100 | }, 101 | 102 | attachCardSearchBlob: function(set, record, parentRecord) { 103 | const urlKey = record[POST_SEL.PostUrlKey]; 104 | 105 | const cardText = record[POST_SEL.CardText]; 106 | if (!STR.hasLen(cardText)) { 107 | return; 108 | } 109 | 110 | const cardFullUrl = record[POST_SEL.CardFullUrl]; 111 | 112 | const searchBlob = TCARD_SAVE_MAPPER.buildCardSearchBlob(urlKey, cardText, cardFullUrl); 113 | 114 | const mapped = { 115 | s: record[POST_SEL.PostUrlKey], 116 | o: searchBlob, 117 | g: POSTS_IMPORT_MAPPER.getGraph(record, parentRecord), 118 | t: POSTS_IMPORT_MAPPER.getTimestamp(record) 119 | }; 120 | 121 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialPostCardSearchBlob.Name).sogs.push(mapped); 122 | }, 123 | 124 | attachAuthorName: function(set, record, parentRecord) { 125 | const sHandle = record[POST_SEL.AuthorHandle]; 126 | const oValue = record[POST_SEL.AuthorName]; 127 | if (!sHandle || !oValue) { return; } 128 | 129 | const mapped = { 130 | s: sHandle, 131 | o: oValue, 132 | g: POSTS_IMPORT_MAPPER.getGraph(record, parentRecord), 133 | t: POSTS_IMPORT_MAPPER.getTimestamp(record) 134 | }; 135 | 136 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileDisplayName.Name).sogs.push(mapped); 137 | }, 138 | 139 | attachReposterName: function(set, record, parentRecord) { 140 | const sHandle = record[POST_SEL.ReposterHandle]; 141 | const oValue = record[POST_SEL.ReposterName]; 142 | if (!sHandle || !oValue) { return; } 143 | 144 | const mapped = { 145 | s: sHandle, 146 | o: oValue, 147 | g: POSTS_IMPORT_MAPPER.getGraph(record, parentRecord), 148 | t: POSTS_IMPORT_MAPPER.getTimestamp(record) 149 | }; 150 | 151 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileDisplayName.Name).sogs.push(mapped); 152 | }, 153 | 154 | // where PostUrlKey is the subject 155 | attachPostAttr: function(column, entDefn, set, record, parentRecord) { 156 | const mapped = POSTS_IMPORT_MAPPER.buildPostSog(record, column, parentRecord); 157 | if (!mapped) { return; } 158 | APPSCHEMA.SAVING.getSubset(set, entDefn.Name).sogs.push(mapped); 159 | }, 160 | 161 | buildPostSog: function(record, column, parentRecord) { 162 | const oValue = record[column]; 163 | if (!oValue) { return null; } 164 | return { 165 | s: record[POST_SEL.PostUrlKey], 166 | o: oValue, 167 | g: POSTS_IMPORT_MAPPER.getGraph(record, parentRecord), 168 | t: POSTS_IMPORT_MAPPER.getTimestamp(record) 169 | }; 170 | }, 171 | 172 | // we aren't bothering with a separate timestamp per component entity 173 | getTimestamp: function(record) { 174 | return record[SCHEMA_CONSTANTS.COLUMNS.Timestamp]; 175 | }, 176 | 177 | // we aren't bothering with a separate graph per component entity 178 | getGraph: function(record, parentRecord) { 179 | let graph = record[SCHEMA_CONSTANTS.COLUMNS.NamedGraph]; 180 | if (!STR.hasLen(graph) && parentRecord) { 181 | graph = parentRecord[SCHEMA_CONSTANTS.COLUMNS.NamedGraph]; 182 | } 183 | return graph; 184 | } 185 | }; -------------------------------------------------------------------------------- /src/lib/worker/syncimport/posttopicratingsimportmapper.js: -------------------------------------------------------------------------------- 1 | var POST_TOPIC_RATINGS_IMPORT_MAPPER = { 2 | mapSavableSet: function(data) { 3 | const entTopicRating = APPSCHEMA.SocialPostSubtopicRating; 4 | const entDefns = [entTopicRating]; 5 | 6 | // passing true for onlyIfNewer because this is a sync context 7 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, true); 8 | const records = data.data; // data node holds the records 9 | 10 | // RDF - subject, object, graph, timestamp 11 | const ratings = records.map(function(x) { 12 | let topic = x[SYNC_COL.RATED_POST.Topic]; 13 | let subtopic = x[SYNC_COL.RATED_POST.Subtopic]; 14 | let score = x[SYNC_COL.RATED_POST.Rating]; 15 | if (STR.hasLen(topic) && STR.hasLen(subtopic)) { 16 | let concatSubtopic = TOPICS.concatTopicFullName(topic, subtopic); 17 | let withRating = STR.concatSubtopicRatingTag(concatSubtopic, score); 18 | 19 | return { 20 | s: x[SYNC_COL.RATED_POST.PostUrlKey], 21 | o: withRating, 22 | g: x[SCHEMA_CONSTANTS.COLUMNS.NamedGraph], 23 | t: x[SCHEMA_CONSTANTS.COLUMNS.Timestamp] 24 | }; 25 | } 26 | else { 27 | return null; 28 | } 29 | }).filter(function(r) { return r != null; }); 30 | 31 | APPSCHEMA.SAVING.getSubset(set, entTopicRating.Name).sogs = ratings; 32 | 33 | return set; 34 | } 35 | }; -------------------------------------------------------------------------------- /src/lib/worker/syncimport/profilefavoritesimportmapper.js: -------------------------------------------------------------------------------- 1 | var PROFILE_FAVORITES_IMPORT_MAPPER = { 2 | mapSavableSet: function(data) { 3 | const listMemberEntDefn = APPSCHEMA.SocialListMember; 4 | const entDefns = [listMemberEntDefn]; 5 | 6 | // passing true for onlyIfNewer because this is a sync context 7 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, true); 8 | const records = data.data; // data node holds the records 9 | 10 | // RDF - subject, object, graph, timestamp 11 | const listMembers = records.map(function(x) { 12 | return { 13 | s: LIST_FAVORITES, 14 | o: x[SYNC_COL.FAVORITES.Handle], 15 | g: x[SCHEMA_CONSTANTS.COLUMNS.NamedGraph], 16 | t: x[SCHEMA_CONSTANTS.COLUMNS.Timestamp] 17 | }; 18 | }); 19 | 20 | APPSCHEMA.SAVING.getSubset(set, listMemberEntDefn.Name).sogs = listMembers; 21 | 22 | return set; 23 | } 24 | }; -------------------------------------------------------------------------------- /src/lib/worker/syncimport/profileimgsimportmapper.js: -------------------------------------------------------------------------------- 1 | var PROFILE_IMGS_IMPORT_MAPPER = { 2 | mapSavableSet: function(data) { 3 | const entCdnUrl = APPSCHEMA.SocialProfileImgSourceUrl; 4 | const entBinary = APPSCHEMA.SocialProfileImgBinary; 5 | const entDefns = [entCdnUrl, entBinary]; 6 | 7 | // passing true for onlyIfNewer because this is a sync context 8 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, true); 9 | const records = data.data; // data node holds the records 10 | 11 | // RDF - subject, object, graph, timestamp 12 | const cdnUrls = records 13 | .filter(function(r) { 14 | return !STR.isTruthy(r[SYNC_COL.PROFILE_IMGS.IsB64]); 15 | }) 16 | .map(function(x) { 17 | return PROFILE_IMGS_IMPORT_MAPPER.mapRecord(x); 18 | }); 19 | 20 | const binaryUrls = records 21 | .filter(function(r) { 22 | return STR.isTruthy(r[SYNC_COL.PROFILE_IMGS.IsB64]); 23 | }) 24 | .map(function(x) { 25 | return PROFILE_IMGS_IMPORT_MAPPER.mapRecord(x); 26 | }); 27 | 28 | APPSCHEMA.SAVING.getSubset(set, entCdnUrl.Name).sogs = cdnUrls; 29 | APPSCHEMA.SAVING.getSubset(set, entBinary.Name).sogs = binaryUrls; 30 | 31 | return set; 32 | }, 33 | 34 | mapRecord: function(x) { 35 | return { 36 | s: x[SYNC_COL.PROFILE_IMGS.Handle], 37 | o: x[SYNC_COL.PROFILE_IMGS.Img], 38 | g: x[SCHEMA_CONSTANTS.COLUMNS.NamedGraph], 39 | t: x[SCHEMA_CONSTANTS.COLUMNS.Timestamp] 40 | }; 41 | } 42 | }; -------------------------------------------------------------------------------- /src/lib/worker/syncimport/profilesimportmapper.js: -------------------------------------------------------------------------------- 1 | var PROFILES_IMPORT_MAPPER = { 2 | mapSavableSet: function(data) { 3 | const entDisplay = APPSCHEMA.SocialProfileDisplayName; 4 | const entDescription = APPSCHEMA.SocialProfileDescription; 5 | const entDefns = [entDisplay, entDescription]; 6 | 7 | // passing true for onlyIfNewer because this is a sync context 8 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, true); 9 | const records = data.data; // data node holds the records 10 | 11 | // RDF - subject, object, graph, timestamp 12 | const displays = records.map(function(x) { 13 | return { 14 | s: x[SYNC_COL.PROFILES.Handle], 15 | o: x[SYNC_COL.PROFILES.Display], 16 | g: x[SCHEMA_CONSTANTS.COLUMNS.NamedGraph], 17 | t: x[SCHEMA_CONSTANTS.COLUMNS.Timestamp] 18 | }; 19 | }); 20 | 21 | const descriptions = records 22 | .filter(function(x) { 23 | return STR.hasLen(x[SYNC_COL.PROFILES.Detail]); 24 | }) 25 | .map(function(x) { 26 | return { 27 | s: x[SYNC_COL.PROFILES.Handle], 28 | o: x[SYNC_COL.PROFILES.Detail], 29 | g: x[SCHEMA_CONSTANTS.COLUMNS.NamedGraph], 30 | t: x[SYNC_COL.PROFILES.DetailTimestamp] 31 | }; 32 | }); 33 | 34 | APPSCHEMA.SAVING.getSubset(set, entDisplay.Name).sogs = displays; 35 | APPSCHEMA.SAVING.getSubset(set, entDescription.Name).sogs = descriptions; 36 | 37 | return set; 38 | } 39 | }; -------------------------------------------------------------------------------- /src/lib/worker/twitterconnsavemapper.js: -------------------------------------------------------------------------------- 1 | // saving followers or connections 2 | 3 | /* 4 | source is 5 | 6 | TWITTER Person: handle, displayName, description, pageType, owner, imgCdnUrl, img64Url, accounts 7 | where 8 | - handle and owner include @ symbol if the source site convention does 9 | - img64Url is attached via background.js 10 | - each account is { emails, urls, mdons } per STR.extractAccounts 11 | */ 12 | 13 | var TWITTER_CONN_SAVE_MAPPER = { 14 | 15 | mapSavableSet: function(records, pageType, graph) { 16 | const set = TWITTER_PROFILE_SAVE_MAPPER.mapSavableSet(records, PAGETYPE.TWITTER.PROFILE, graph); 17 | const connEntDefn = PAGETYPE.getRootEntDefn(pageType); 18 | 19 | const connections = records.map(function(x) { 20 | return {s: x.owner, o: x.handle, g: graph}; 21 | }); 22 | 23 | set.subsets.unshift(APPSCHEMA.SAVING.newSavableSubset(connEntDefn)); // pushes to front of array 24 | APPSCHEMA.SAVING.getSubset(set, connEntDefn.Name).sogs = connections; 25 | 26 | return set; 27 | } 28 | }; -------------------------------------------------------------------------------- /src/lib/worker/twitterprofilesavemapper.js: -------------------------------------------------------------------------------- 1 | // saving profiles (not tied to connection) 2 | 3 | /* 4 | source is 5 | 6 | TWITTER Person: handle, displayName, description, pageType, owner, imgCdnUrl, img64Url, accounts 7 | where 8 | - handle and owner include @ symbol if the source site convention does 9 | - img64Url is attached via background.js 10 | - each account is { emails, urls, mdons } per STR.extractAccounts 11 | */ 12 | 13 | var TWITTER_PROFILE_SAVE_MAPPER = { 14 | 15 | // the entity definitions relevant for saving followers/connections 16 | getEntDefns: function(withFollowCounts) { 17 | 18 | var ents = [ 19 | APPSCHEMA.SocialProfileDisplayName, 20 | APPSCHEMA.SocialProfileDescription, 21 | APPSCHEMA.SocialProfileImgSourceUrl, 22 | APPSCHEMA.SocialProfileImgBinary, 23 | APPSCHEMA.SocialProfileLinkMastodonAccount, 24 | APPSCHEMA.SocialProfileLinkExternalUrl, 25 | APPSCHEMA.SocialProfileLinkEmailAddress 26 | ]; 27 | 28 | if (withFollowCounts) { 29 | ents.push(APPSCHEMA.SocialFollowerCount); 30 | ents.push(APPSCHEMA.SocialFollowingCount); 31 | } 32 | 33 | return ents; 34 | }, 35 | 36 | // pageType is part of the "interface" but not used for this one 37 | mapSavableSet: function(records, pageType, graph) { 38 | // now map the records into each subset based on connection-specific mapping logic 39 | // subject | object | graph 40 | 41 | const displayNames = records.map(function(x) { 42 | return {s: x.handle, o: x.displayName, g: graph}; 43 | }); 44 | 45 | const descriptions = records.map(function(x) { 46 | return {s: x.handle, o: x.description, g: graph}; 47 | }); 48 | 49 | const imgCdns = records.map(function(x) { 50 | return {s: x.handle, o: x.imgCdnUrl, g: graph}; 51 | }); 52 | 53 | const img64s = records.filter(function(x) { return x.img64Url && x.img64Url.length > 0; }).map(function(x) { 54 | return {s: x.handle, o: x.img64Url, g: graph}; 55 | }); 56 | 57 | const followersCounts = records.filter(function(x) { return x.followersCount && parseInt(x.followersCount) > 0; }).map(function(x) { 58 | return {s: x.handle, o: x.followersCount, g: graph}; 59 | }); 60 | 61 | const followingCounts = records.filter(function(x) { return x.followingCount && parseInt(x.followingCount) > 0; }).map(function(x) { 62 | return {s: x.handle, o: x.followingCount, g: graph}; 63 | }); 64 | 65 | const mdons = []; 66 | const urls = []; 67 | const emails = []; 68 | for (let i = 0; i < records.length; i++) { 69 | let record = records[i]; 70 | if (record.accounts && record.accounts.mdons) { 71 | record.accounts.mdons.forEach(x => mdons.push({s: record.handle, o: x, g: graph})); 72 | } 73 | if (record.accounts && record.accounts.urls) { 74 | record.accounts.urls.forEach(x => urls.push({s: record.handle, o: x, g: graph})); 75 | } 76 | if (record.accounts && record.accounts.emails) { 77 | record.accounts.emails.forEach(x => emails.push({s: record.handle, o: x, g: graph})); 78 | } 79 | } 80 | 81 | const withFollowCounts = followersCounts.length + followingCounts.length > 0; 82 | const entDefns = TWITTER_PROFILE_SAVE_MAPPER.getEntDefns(withFollowCounts); 83 | // passing false for onlyIfNewer because this is a save context (definitely newer than whatever is in the db) 84 | // as opposed to a sync context (where we could be syncing in data that is older than our db) 85 | const set = APPSCHEMA.SAVING.newSavableSet(entDefns, false); 86 | 87 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileDisplayName.Name).sogs = displayNames; 88 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileDescription.Name).sogs = descriptions; 89 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileImgSourceUrl.Name).sogs = imgCdns; 90 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileImgBinary.Name).sogs = img64s; 91 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileLinkMastodonAccount.Name).sogs = mdons; 92 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileLinkExternalUrl.Name).sogs = urls; 93 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialProfileLinkEmailAddress.Name).sogs = emails; 94 | 95 | if (withFollowCounts) { 96 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialFollowerCount.Name).sogs = followersCounts; 97 | APPSCHEMA.SAVING.getSubset(set, APPSCHEMA.SocialFollowingCount.Name).sogs = followingCounts; 98 | } 99 | 100 | return set; 101 | } 102 | }; -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Whosum Social Assistant/Positive Sum Networks", 4 | "version": "1.1.1", 5 | "description": "Privately preserve your social experiences. Optionally share to foster Positive Sum Networks.", 6 | "icons": { 7 | "16": "images/icon-16.png", 8 | "32": "images/icon-32.png", 9 | "48": "images/icon-48.png", 10 | "128": "images/icon-128.png" 11 | }, 12 | "author": "whosumsupport@positivesum.net", 13 | "action": { 14 | "default_popup": "popup.html", 15 | "default_icon": { 16 | "16": "images/icon-16.png", 17 | "32": "images/icon-32.png", 18 | "48": "images/icon-48.png", 19 | "128": "images/icon-128.png" 20 | } 21 | }, 22 | "content_scripts": [ 23 | { 24 | "js": [ 25 | "lib/shared/constants.js", 26 | "lib/shared/datatypes.js", 27 | "lib/shared/pagetypes.js", 28 | "lib/shared/appgraphs.js", 29 | "lib/shared/appschema.js", 30 | "lib/shared/settingslib.js", 31 | "lib/shared/es6lib.js", 32 | "lib/shared/strlib.js", 33 | "lib/shared/cryptolib.js", 34 | "lib/shared/emojilib.js", 35 | "lib/shared/urlparsing.js", 36 | "lib/content/recordinglib.js", 37 | "lib/content/twitter/twitterparsing.js", 38 | "lib/content/twitter/twitterfollowparsing.js", 39 | "lib/content/twitter/twitterfollowrecorder.js", 40 | "lib/content/twitter/twittertweetsrecorder.js", 41 | "lib/content/twitter/twittertweetparsing.js", 42 | "lib/content/nitter/nittertweetsrecorder.js", 43 | "lib/content/nitter/nitterparsing.js", 44 | "lib/content/nitter/nittertweetparsing.js", 45 | "lib/content/embvideo/squiddy.js", 46 | "lib/content/recorderfactory.js", 47 | "content.js" 48 | ], 49 | "all_frames": true, 50 | "matches": [ 51 | "https://*.twitter.com/*", 52 | "https://*.x.com/*", 53 | "https://*.nitter.net/*", 54 | "https://*.nitter.cz/*", 55 | "https://*.squidlr.com/*" 56 | ] 57 | } 58 | ], 59 | "background": { 60 | "service_worker": "background.js" 61 | }, 62 | "permissions": [ 63 | "activeTab", 64 | "storage", 65 | "unlimitedStorage" 66 | ], 67 | "cross_origin_embedder_policy": { 68 | "value": "require-corp" 69 | }, 70 | "cross_origin_opener_policy": { 71 | "value": "same-origin" 72 | }, 73 | "content_security_policy": { 74 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Whosum Social Assistant 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 | +Σ Whosum 17 |
18 |
19 | Locally Cached Social Database 20 |
21 |
22 |
23 |

Please see below to finish installing

24 |

Click the puzzle piece icon on the browser menu and pin the extension to get started

25 |
26 |
    27 |
  1. Click the puzzle piece icon
  2. 28 |
  3. Pin the Whosum Social Assistant
  4. 29 |
  5. Once pinned, click our icon to get started
  6. 30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/whatsnew105.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Whosum Social Assistant 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 | +Σ Whosum 17 |
18 |
19 | Locally Cached Social Database 20 |
21 |
22 |
23 |

New features for Mastodon users!

24 |

Explore the new Mastodon follow features

25 |
26 |
    27 |
  1. Use the new Mastodon tab to download and follow connections
  2. 28 |
  3. Then follow Mastodon accounts from either tab
  4. 29 |
30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/whatsnew108.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Whosum Social Assistant 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 | +Σ Whosum 17 |
18 |
19 | Locally Cached Social Database 20 |
21 |
22 |
23 |

Whosum now records tweets and videos!

24 |
25 |
    26 |
  1. Record tweets, threads, and videos!
  2. 27 |
  3. Categorize and rate posts for safe-keeping
  4. 28 |
  5. Backup your data to a personal cloud account using the Backups tab
  6. 29 |
30 |

Explore the new tweet-recording features

31 |
32 | 33 | 34 | --------------------------------------------------------------------------------