├── LICENSE ├── README.md ├── v2 ├── chrome │ ├── _locales │ ├── background.js │ ├── data │ └── manifest.json └── firefox │ ├── _locales │ ├── de │ │ └── messages.json │ ├── en │ │ └── messages.json │ ├── es │ │ └── messages.json │ ├── fr │ │ └── messages.json │ ├── it │ │ └── messages.json │ ├── ja │ │ └── messages.json │ ├── nl │ │ └── messages.json │ ├── pl │ │ └── messages.json │ ├── pt_BR │ │ └── messages.json │ ├── pt_PT │ │ └── messages.json │ ├── ru │ │ └── messages.json │ └── zh_CN │ │ └── messages.json │ ├── background.js │ ├── data │ ├── assets │ │ └── bell.wav │ ├── commander │ │ ├── components │ │ │ ├── directory-view.js │ │ │ ├── directory-view │ │ │ │ ├── list-view.js │ │ │ │ └── path-view.js │ │ │ ├── prompt-view.js │ │ │ └── tools-view.js │ │ ├── engine.js │ │ ├── images │ │ │ ├── directory-readonly.svg │ │ │ ├── directory.svg │ │ │ ├── error.svg │ │ │ └── page.svg │ │ ├── index.css │ │ ├── index.html │ │ └── index.js │ └── icons │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 19.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 38.png │ │ ├── 48.png │ │ ├── 512.png │ │ ├── 64.png │ │ ├── dark │ │ └── 128.png │ │ ├── light │ │ └── 128.png │ │ └── svgs │ │ ├── dark.svg │ │ ├── default.svg │ │ └── light.svg │ └── manifest.json └── v3 ├── _locales ├── de │ └── messages.json ├── en │ └── messages.json ├── es │ └── messages.json ├── fr │ └── messages.json ├── it │ └── messages.json ├── ja │ └── messages.json ├── nl │ └── messages.json ├── pl │ └── messages.json ├── pt_BR │ └── messages.json ├── pt_PT │ └── messages.json ├── ru │ └── messages.json └── zh_CN │ └── messages.json ├── data ├── assets │ └── bell.wav ├── commander │ ├── commands │ │ ├── default.json │ │ └── vim.json │ ├── components │ │ ├── directory-view.js │ │ ├── directory-view │ │ │ ├── list-view.js │ │ │ ├── path-view-test │ │ │ │ ├── index.css │ │ │ │ ├── index.html │ │ │ │ └── index.js │ │ │ └── path-view.js │ │ ├── notify-view.js │ │ ├── prompt-view.js │ │ └── tools-view.js │ ├── engine.js │ ├── images │ │ ├── directory-readonly.svg │ │ ├── directory.svg │ │ ├── drop-after.svg │ │ ├── drop-inside.svg │ │ ├── error.svg │ │ └── page.svg │ ├── index.css │ ├── index.html │ └── index.js └── icons │ ├── 128.png │ ├── 16.png │ ├── 19.png │ ├── 256.png │ ├── 32.png │ ├── 38.png │ ├── 48.png │ ├── 512.png │ ├── 64.png │ ├── dark │ └── 128.png │ ├── light │ └── 128.png │ └── svgs │ ├── dark.svg │ ├── default.svg │ └── light.svg ├── manifest.json └── worker.js /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bookmarks Commander 2 | A dual-pane Norton Commander liked bookmarks manager that supports sorting, dark theme, search, and duplicate detection 3 | 4 | ### Preview 5 | 6 | [![IMAGE ALT TEXT HERE](https://img.youtube.com/vi/qoqJK3hEFMs/0.jpg)](https://www.youtube.com/watch?v=qoqJK3hEFMs) 7 | 8 | ### Web Components 9 | 10 | List View: https://webextension.org/custom-component/list-view/index.html 11 | Path View: https://webextension.org/custom-component/path-view/index.html 12 | 13 | ### Download Links 14 | * Homepage: https://add0n.com/bookmarks-commander.html 15 | * Usage Review: https://webextension.org/blog/2022/04/17/bookmarks-commander-extension.html 16 | * Chrome: https://chrome.google.com/webstore/detail/bookmarks-commander/knfpajocfeohpaipkfpdbfhgibajfmcf 17 | * Edge: https://microsoftedge.microsoft.com/addons/detail/kenlddohpphjdhgfejegaaplbfmcpcin 18 | * Firefox: https://addons.mozilla.org/firefox/addon/bookmarks-commander/ 19 | -------------------------------------------------------------------------------- /v2/chrome/_locales: -------------------------------------------------------------------------------- 1 | ../firefox/_locales -------------------------------------------------------------------------------- /v2/chrome/background.js: -------------------------------------------------------------------------------- 1 | ../firefox/background.js -------------------------------------------------------------------------------- /v2/chrome/data: -------------------------------------------------------------------------------- 1 | ../firefox/data -------------------------------------------------------------------------------- /v2/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "version": "0.2.8", 4 | "name": "Bookmarks Commander", 5 | "description": "__MSG_description__", 6 | "default_locale": "en", 7 | "offline_enabled": true, 8 | "permissions": [ 9 | "bookmarks", 10 | "contextMenus", 11 | "notifications", 12 | "storage", 13 | "chrome://favicon/*" 14 | ], 15 | "homepage_url": "https://add0n.com/bookmarks-commander.html", 16 | "background": { 17 | "persistent": false, 18 | "scripts": [ 19 | "background.js" 20 | ] 21 | }, 22 | "icons": { 23 | "16": "data/icons/16.png", 24 | "19": "data/icons/19.png", 25 | "32": "data/icons/32.png", 26 | "38": "data/icons/38.png", 27 | "48": "data/icons/48.png", 28 | "64": "data/icons/64.png", 29 | "128": "data/icons/128.png", 30 | "256": "data/icons/256.png", 31 | "512": "data/icons/512.png" 32 | }, 33 | "browser_action": {}, 34 | "incognito": "split" 35 | } 36 | -------------------------------------------------------------------------------- /v2/firefox/_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Ein Lesezeichen-Manager mit zwei Fenstern, der Sortierung, dunkles Thema, Suche und Erkennung von Duplikaten unterstützt" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "A dual-pane Norton Commander liked bookmarks manager that supports sorting, dark theme, search, and duplicate detection" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Un gestor de marcadores de doble panel que admite la clasificación, el tema oscuro, la búsqueda y la detección de duplicados" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/fr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Un gestionnaire de signets à double volet qui prend en charge le tri, les thèmes sombres, la recherche et la détection des doublons" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/it/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Un gestore di segnalibri a doppio pannello che supporta l'ordinamento, il tema scuro, la ricerca e il rilevamento dei duplicati" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "デュアルペインのノートンコマンダーは、ソート、ダークテーマ、検索、重複検出をサポートしています。" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/nl/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Een bladwijzerbeheerder met twee deelvensters die sorteren, een donker thema, zoeken en duplicaatdetectie ondersteunt" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/pl/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Dwupanelowy menedżer zakładek Norton Commander z obsługą sortowania, ciemnego motywu, wyszukiwania i wykrywania duplikatów" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/pt_BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Um gerenciador de marcadores de dois painéis que suporta classificação, tema escuro, busca e detecção de duplicatas." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/pt_PT/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Um gestor de marcadores de dois painéis que suporta a classificação, tema escuro, pesquisa e detecção de duplicados" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Двухпанельный менеджер закладок, поддерживающий сортировку, темную тему, поиск и обнаружение дубликатов." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "一个双窗格的诺顿指挥官喜欢的书签管理器,支持排序,暗主题,搜索和重复检测。" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/background.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | chrome.browserAction.onClicked.addListener(() => { 4 | chrome.storage.local.get({ 5 | 'mode': 'tab' 6 | }, async prefs => { 7 | // try to find an open instance 8 | try { 9 | await new Promise((resolve, reject) => { 10 | chrome.runtime.sendMessage({ 11 | method: 'instance' 12 | }, r => r ? resolve() : reject(chrome.runtime.lastError)); 13 | }); 14 | } 15 | catch (e) { 16 | if (prefs.mode === 'tab') { 17 | chrome.tabs.create({ 18 | url: 'data/commander/index.html' 19 | }, tab => chrome.storage.local.set({ 20 | tab: tab.id 21 | })); 22 | } 23 | else if (prefs.mode === 'window') { 24 | chrome.windows.getCurrent(win => { 25 | chrome.storage.local.get({ 26 | 'window.width': 750, 27 | 'window.height': 600, 28 | 'window.left': win.left + Math.round((win.width - 700) / 2), 29 | 'window.top': win.top + Math.round((win.height - 500) / 2) 30 | }, prefs => { 31 | chrome.windows.create({ 32 | url: '/data/commander/index.html?mode=window', 33 | width: Math.max(400, prefs['window.width']), 34 | height: Math.max(300, prefs['window.height']), 35 | left: prefs['window.left'], 36 | top: prefs['window.top'], 37 | type: 'popup' 38 | }); 39 | }); 40 | }); 41 | } 42 | } 43 | }); 44 | }); 45 | 46 | const icon = mode => chrome.browserAction.setIcon({ 47 | path: { 48 | '16': 'data/icons/' + mode + '/128.png' 49 | } 50 | }); 51 | 52 | { 53 | const startup = () => chrome.storage.local.get({ 54 | 'mode': 'tab', 55 | 'popup.width': 800, 56 | 'popup.height': 600, 57 | 'custom-icon': '' 58 | }, prefs => { 59 | if (prefs['custom-icon']) { 60 | icon(prefs['custom-icon']); 61 | } 62 | chrome.contextMenus.create({ 63 | id: 'mode-tab', 64 | title: 'Open in Tab', 65 | contexts: ['browser_action'], 66 | type: 'radio', 67 | checked: prefs.mode === 'tab' 68 | }); 69 | chrome.contextMenus.create({ 70 | id: 'mode-window', 71 | title: 'Open in Window', 72 | contexts: ['browser_action'], 73 | type: 'radio', 74 | checked: prefs.mode === 'window' 75 | }); 76 | chrome.contextMenus.create({ 77 | id: 'mode-popup', 78 | title: 'Open in Popup', 79 | contexts: ['browser_action'], 80 | type: 'radio', 81 | checked: prefs.mode === 'popup' 82 | }); 83 | if (prefs.mode === 'popup') { 84 | chrome.browserAction.setPopup({ 85 | popup: `data/commander/index.html?mode=popup&width=${prefs['popup.width']}&height=${prefs['popup.height']}` 86 | }); 87 | } 88 | }); 89 | chrome.runtime.onInstalled.addListener(startup); 90 | chrome.runtime.onStartup.addListener(startup); 91 | } 92 | chrome.contextMenus.onClicked.addListener(info => { 93 | if (info.menuItemId.startsWith('mode-')) { 94 | chrome.storage.local.set({ 95 | mode: info.menuItemId.replace('mode-', '') 96 | }); 97 | } 98 | }); 99 | 100 | chrome.storage.onChanged.addListener(ps => { 101 | if (ps.mode) { 102 | chrome.storage.local.get({ 103 | 'popup.width': 800, 104 | 'popup.height': 600 105 | }, prefs => { 106 | chrome.browserAction.setPopup({ 107 | popup: ps.mode.newValue === 'popup' ? 108 | `data/commander/index.html?mode=popup&width=${prefs['popup.width']}&height=${prefs['popup.height']}` : 109 | '' 110 | }); 111 | }); 112 | } 113 | if (ps['custom-icon']) { 114 | icon(ps['custom-icon'].newValue); 115 | } 116 | }); 117 | 118 | chrome.runtime.onMessage.addListener((request, sender) => { 119 | if (request.method === 'save-size') { 120 | chrome.storage.local.set(request.prefs); 121 | } 122 | else if (request.method === 'activate') { 123 | chrome.windows.update(sender.tab.windowId, { 124 | focused: true 125 | }); 126 | chrome.tabs.update(sender.tab.id, { 127 | active: true 128 | }); 129 | } 130 | }); 131 | 132 | /* FAQs & Feedback */ 133 | { 134 | const {management, runtime: {onInstalled, setUninstallURL, getManifest}, storage, tabs} = chrome; 135 | if (navigator.webdriver !== true) { 136 | const page = getManifest().homepage_url; 137 | const {name, version} = getManifest(); 138 | onInstalled.addListener(({reason, previousVersion}) => { 139 | management.getSelf(({installType}) => installType === 'normal' && storage.local.get({ 140 | 'faqs': true, 141 | 'last-update': 0 142 | }, prefs => { 143 | if (reason === 'install' || (prefs.faqs && reason === 'update')) { 144 | const doUpdate = (Date.now() - prefs['last-update']) / 1000 / 60 / 60 / 24 > 45; 145 | if (doUpdate && previousVersion !== version) { 146 | tabs.query({active: true, currentWindow: true}, tbs => tabs.create({ 147 | url: page + '?version=' + version + (previousVersion ? '&p=' + previousVersion : '') + '&type=' + reason, 148 | active: reason === 'install', 149 | ...(tbs && tbs.length && {index: tbs[0].index + 1}) 150 | })); 151 | storage.local.set({'last-update': Date.now()}); 152 | } 153 | } 154 | })); 155 | }); 156 | setUninstallURL(page + '?rd=feedback&name=' + encodeURIComponent(name) + '&version=' + version); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /v2/firefox/data/assets/bell.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/assets/bell.wav -------------------------------------------------------------------------------- /v2/firefox/data/commander/components/directory-view.js: -------------------------------------------------------------------------------- 1 | /* global engine */ 2 | class DirectoryView extends HTMLElement { 3 | constructor() { 4 | super(); 5 | 6 | this.history = []; 7 | const shadow = this.attachShadow({ 8 | mode: 'open' 9 | }); 10 | shadow.innerHTML = ` 11 | 27 | 28 | - 29 | 30 | 31 | `; 32 | this.pathView = shadow.querySelector('path-view'); 33 | this.listView = shadow.querySelector('list-view'); 34 | this.CountElement = shadow.getElementById('count'); 35 | 36 | // events 37 | const onsubmit = e => this.emit('directory-view:submit', e.detail); 38 | this.pathView.addEventListener('submit', onsubmit); 39 | this.listView.addEventListener('submit', onsubmit); 40 | this.listView.addEventListener('selection-changed', () => this.emit('directory-view:selection-changed')); 41 | this.listView.addEventListener('drop-request', e => this.emit('directory-view:drop-request', e.detail)); 42 | this.listView.addEventListener('command', e => this.emit('directory-view:command', e.detail)); 43 | // focus the list-view element 44 | this.addEventListener('click', () => { 45 | this.listView.focus(); 46 | }); 47 | } 48 | emit(name, detail) { 49 | return this.dispatchEvent(new CustomEvent(name, { 50 | bubbles: true, 51 | detail 52 | })); 53 | } 54 | async buildPathView(id, arr) { 55 | // store path only if it is needed 56 | if (!arr) { 57 | arr = await engine.bookmarks.hierarchy(id); 58 | this.emit('directory-view:path', { 59 | id, 60 | arr 61 | }); 62 | } 63 | this.arr = arr; 64 | this.pathView.build(arr); 65 | } 66 | // if update, then selected elements are persistent 67 | async buildListView(id, update = false, selectedIDs = []) { 68 | const method = update ? 'update' : 'build'; 69 | try { 70 | // add openerId to empty "duplicates" queries 71 | if (id.query && id.query === 'duplicates') { 72 | let openerId = this.id(); 73 | if (/Firefox/.test(navigator.userAgent)) { 74 | if (typeof openerId !== 'string' || openerId.trim() === '') { 75 | openerId = engine.bookmarks.rootID; 76 | } 77 | } 78 | else if (isNaN(openerId)) { // Chrome 79 | openerId = engine.bookmarks.rootID; 80 | } 81 | id.query += ':' + openerId; 82 | } 83 | const nodes = await engine.bookmarks.children(id); 84 | this.count = this.CountElement.textContent = nodes.length; 85 | if (this.isSearch(id)) { 86 | const length = this.history.length; 87 | nodes.unshift({ 88 | title: '[..]', 89 | id: length ? this.history[length - 1] : '', 90 | openerId: id, 91 | index: -1, 92 | readonly: true 93 | }); 94 | } 95 | else if (this.isRoot(id) === false) { 96 | const parent = await engine.bookmarks.parent(id); 97 | nodes.unshift({ 98 | title: '[..]', 99 | id: parent.parentId, 100 | openerId: id, 101 | index: -1, 102 | readonly: true 103 | }); 104 | } 105 | const origin = this.isSearch(id) ? 'search' : ( 106 | this.isRoot(id) ? 'root' : 'other' 107 | ); 108 | 109 | if (method === 'build') { 110 | this.listView.build(nodes, undefined, selectedIDs, {origin}); 111 | } 112 | else { 113 | this.listView.update(nodes); 114 | } 115 | this.listView.mode({ 116 | path: this.isSearch(id) 117 | }); 118 | 119 | this.history.push(id); 120 | } 121 | catch (e) { 122 | this.listView.build(undefined, e, undefined, {origin}); 123 | console.warn(e); 124 | window.setTimeout(() => this.build(''), 2000); 125 | } 126 | } 127 | async build(id, arr, selectedIDs = []) { 128 | this.emit('directory-view:update-requested'); 129 | 130 | id = id || engine.bookmarks.rootID; 131 | this.buildListView(id, false, selectedIDs); 132 | this.buildPathView(id, arr).then(() => { 133 | this.emit('directory-view:content-updated'); 134 | }); 135 | this._id = id; 136 | } 137 | style({ 138 | name = 200, 139 | added = 90, 140 | modified = 90 141 | }) { 142 | this.listView.style.setProperty('--name-width', name + 'px'); 143 | this.listView.style.setProperty('--added-width', added + 'px'); 144 | this.listView.style.setProperty('--modified-width', modified + 'px'); 145 | } 146 | update(id) { 147 | this.buildListView(id, true); 148 | } 149 | entries(...args) { 150 | return this.listView.entries(...args); 151 | } 152 | id() { 153 | return this._id; 154 | } 155 | list() { 156 | return this.arr; 157 | } 158 | isRoot(id) { 159 | return engine.bookmarks.isRoot(id || this.id()); 160 | } 161 | isSearch(id) { 162 | return engine.bookmarks.isSearch(id || this.id()); 163 | } 164 | navigate(direction = 'forward') { 165 | if (direction === 'first' || direction === 'last') { 166 | this.listView[direction](); 167 | } 168 | else { 169 | this.listView[direction === 'forward' ? 'next' : 'previous'](); 170 | } 171 | } 172 | owner(name) { 173 | this.setAttribute('owner', name); 174 | this.listView.setAttribute('owner', name); 175 | this.pathView.setAttribute('owner', name); 176 | } 177 | static get observedAttributes() { 178 | return ['path']; 179 | } 180 | async attributeChangedCallback(name, oldValue, newValue) { 181 | if (name === 'path') { 182 | this.build(newValue); 183 | } 184 | } 185 | } 186 | window.customElements.define('directory-view', DirectoryView); 187 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/components/directory-view/list-view.js: -------------------------------------------------------------------------------- 1 | /* global engine */ 2 | 3 | class ListView extends HTMLElement { 4 | constructor() { 5 | super(); 6 | const shadow = this.attachShadow({ 7 | mode: 'open' 8 | }); 9 | shadow.innerHTML = ` 10 | 121 | 122 | 132 | 148 | 149 |
150 |
151 |
152 |
Name
153 |
Path
154 |
Link
155 |
Added
156 |
Modified
157 |
158 |
159 | `; 160 | 161 | this.template = shadow.querySelector('template'); 162 | this.content = shadow.getElementById('content'); 163 | 164 | this.content.addEventListener('focus', () => this.classList.add('active')); 165 | this.content.addEventListener('blur', () => { 166 | const active = this.shadowRoot.activeElement; 167 | // if document is not focused, keep the active view 168 | if (active === null) { 169 | this.classList.remove('active'); 170 | } 171 | }); 172 | 173 | // context menu 174 | this.content.addEventListener('contextmenu', e => { 175 | const {target} = e; 176 | if (target.classList.contains('entry') && target.dataset.index !== '-1') { 177 | e.preventDefault(); 178 | this.emit('selection-changed'); 179 | // is this entry selected? 180 | if (target.dataset.selected === 'false') { 181 | this.select(target); 182 | } 183 | 184 | const m = this.shadowRoot.getElementById('menu'); 185 | const directory = target.dataset.type === 'DIRECTORY'; 186 | m.querySelector('[data-id="open-in-new-tab"]').classList[directory ? 'add' : 'remove']('disabled'); 187 | m.querySelector('[data-id="open-in-new-window"]').classList[directory ? 'add' : 'remove']('disabled'); 188 | m.querySelector('[data-id="copy-link"]').classList[directory ? 'add' : 'remove']('disabled'); 189 | m.querySelector('[data-id="import-tree"]').classList[ 190 | this?.extra?.origin === 'search' ? 'add' : 'remove' 191 | ]('disabled'); 192 | m.querySelector('[data-id="open-folder"]').classList[ 193 | this?.extra?.origin === 'other' && directory === false ? 'add' : 'remove' 194 | ]('disabled'); 195 | 196 | m.style.left = (e.clientX - 10) + 'px'; 197 | m.style.top = (e.clientY - 10) + 'px'; 198 | m.classList.remove('hidden'); 199 | m.focus(); 200 | } 201 | }); 202 | this.shadowRoot.getElementById('menu').onblur = e => e.target.classList.add('hidden'); 203 | 204 | this.config = { 205 | remote: false 206 | }; 207 | 208 | shadow.addEventListener('click', e => { 209 | const {target} = e; 210 | if (target.classList.contains('entry') && target.classList.contains('hr') === false) { 211 | // single-click => toggle selection 212 | if (e.detail === 1 || e.detail === 0) { 213 | if (e.ctrlKey === false && e.metaKey === false && e.shiftKey === false) { 214 | this.items().forEach(e => e.dataset.selected = false); 215 | } 216 | // multiple select 217 | if (e.shiftKey) { 218 | const e = this.content.querySelector('.entry[data-last-selected=true]'); 219 | const es = [...this.content.querySelectorAll('.entry')]; 220 | if (e) { 221 | const i = es.indexOf(e); 222 | const j = es.indexOf(target); 223 | 224 | for (let k = Math.min(i, j); k < Math.max(i, j); k += 1) { 225 | es[k].dataset.selected = true; 226 | } 227 | } 228 | } 229 | // select / deselect on meta 230 | if (e.ctrlKey || e.metaKey) { 231 | target.dataset.selected = target.dataset.selected !== 'true'; 232 | } 233 | else { 234 | target.dataset.selected = true; 235 | } 236 | for (const e of [...this.content.querySelectorAll('.entry[data-last-selected=true]')]) { 237 | e.dataset.lastSelected = false; 238 | } 239 | target.dataset.lastSelected = true; 240 | 241 | // scroll (only when e.isTrusted === false) 242 | if (e.isTrusted === false) { 243 | this.scroll(target); 244 | } 245 | this.emit('selection-changed'); 246 | } 247 | // double-click => submit selection 248 | else { 249 | const entries = []; 250 | if (target.dataset.selected === 'true') { 251 | entries.push(...this.entries(true)); 252 | } 253 | else { 254 | const entry = Object.assign({}, target.node, target.dataset); 255 | if (entry.id.startsWith('{')) { 256 | entry.id = JSON.parse(entry.id); 257 | } 258 | entries.push(entry); 259 | } 260 | this.emit('submit', { 261 | shiftKey: e.shiftKey, 262 | ctrlKey: e.ctrlKey, 263 | metaKey: e.metaKey, 264 | entries 265 | }); 266 | } 267 | } 268 | else if (target.dataset.id === 'open-in-new-tab') { 269 | shadow.dispatchEvent(new KeyboardEvent('keydown', { 270 | code: 'Enter', 271 | metaKey: true 272 | })); 273 | } 274 | else if (target.dataset.id === 'open-in-new-window') { 275 | shadow.dispatchEvent(new KeyboardEvent('keydown', { 276 | code: 'Enter', 277 | shiftKey: true 278 | })); 279 | } 280 | else if (target.dataset.id === 'open-folder') { 281 | this.emit('command', { 282 | command: 'open-folder' 283 | }); 284 | } 285 | else if (target.dataset.id === 'open-folder-other-pane') { 286 | this.emit('command', { 287 | command: 'mirror', 288 | altKey: true 289 | }); 290 | } 291 | else if ( 292 | ['copy-link', 'copy-id', 'copy-title', 'copy-details'].indexOf(target.dataset.id) !== -1 || 293 | ['import-tree', 'export-tree'].indexOf(target.dataset.id) !== -1 || 294 | ['trash'].indexOf(target.dataset.id) !== -1 295 | ) { 296 | if (target.dataset.id === 'trash' && confirm('Are you sure?') === false) { 297 | return; 298 | } 299 | this.emit('command', { 300 | command: target.dataset.id, 301 | shiftKey: e.shiftKey 302 | }); 303 | } 304 | }); 305 | // to prevent conflict with command access 306 | shadow.addEventListener('keyup', e => { 307 | if (e.code.startsWith('Key') || e.code.startsWith('Digit')) { 308 | const d = this.content.querySelector(`.entry[data-selected=true] ~ .entry[data-key="${e.key}"]`); 309 | if (d) { 310 | d.click(); 311 | } 312 | else { 313 | const d = this.content.querySelector(`.entry[data-key="${e.key}"]`); 314 | if (d) { 315 | d.click(); 316 | } 317 | } 318 | } 319 | else if ( 320 | e.code === 'Backspace' && 321 | e.shiftKey === false && e.altKey === false && e.metaKey === false && e.ctrlKey === false 322 | ) { 323 | const d = this.content.querySelector('.entry[data-index="-1"]'); 324 | if (d) { 325 | this.dbclick(d); 326 | } 327 | else { 328 | engine.notify('beep'); 329 | } 330 | } 331 | }); 332 | shadow.addEventListener('keydown', e => { 333 | const meta = e.metaKey || e.ctrlKey || e.shiftKey; 334 | if (e.code === 'Enter') { 335 | const entries = this.entries(); 336 | if (entries.length) { 337 | this.emit('submit', { 338 | shiftKey: e.shiftKey, 339 | ctrlKey: e.ctrlKey, 340 | metaKey: e.metaKey, 341 | entries 342 | }); 343 | } 344 | } 345 | // select all 346 | else if (e.code === 'KeyA' && meta) { 347 | const [e, ...es] = [...this.content.querySelectorAll('.entry[data-readonly=false]')]; 348 | this.select(e); 349 | for (const e of es) { 350 | this.select(e, true); 351 | } 352 | } 353 | }); 354 | // keyboard navigation 355 | shadow.addEventListener('keydown', e => { 356 | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { 357 | e.preventDefault(); 358 | const meta = e.metaKey || e.ctrlKey || e.shiftKey; 359 | const reverse = (e.metaKey && e.shiftKey) || (e.ctrlKey && e.shiftKey); 360 | this[e.key === 'ArrowUp' ? 'previous' : 'next'](meta, reverse); 361 | } 362 | }); 363 | // drag and drop 364 | shadow.addEventListener('drop', e => { 365 | e.preventDefault(); 366 | const j = e.dataTransfer.getData('application/bc.bookmark'); 367 | try { 368 | this.emit('drop-request', { 369 | ...JSON.parse(j), 370 | destination: e.target.getRootNode().host.getAttribute('owner') 371 | }); 372 | } 373 | catch (e) {} 374 | }); 375 | shadow.addEventListener('dragover', e => e.preventDefault()); 376 | shadow.addEventListener('dragstart', e => { 377 | const ids = []; 378 | const types = []; 379 | const selected = []; 380 | if (e.target.dataset.selected === 'true') { 381 | const es = [...this.content.querySelectorAll('.entry[data-selected=true]')]; 382 | ids.push(...es.map(e => e.dataset.id)); 383 | types.push(...es.map(e => e.dataset.type)); 384 | selected.push(...es.map(e => e.dataset.selected)); 385 | } 386 | else { 387 | ids.push(e.target.dataset.id); 388 | types.push(e.target.dataset.type); 389 | selected.push(e.target.dataset.selected); 390 | } 391 | 392 | e.dataTransfer.setData('application/bc.bookmark', JSON.stringify({ 393 | ids, 394 | types, 395 | selected, 396 | source: e.target.getRootNode().host.getAttribute('owner') 397 | })); 398 | e.dataTransfer.setData('text/uri-list', e.target.querySelector('[data-id="href"]').textContent); 399 | e.dataTransfer.setData('text/plain', e.target.querySelector('[data-id="name"]').textContent); 400 | }); 401 | } 402 | query(q) { 403 | return this.content.querySelector(q); 404 | } 405 | select(e, metaKey = false) { 406 | const event = document.createEvent('MouseEvent'); 407 | event.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, metaKey, false, false, metaKey, 0, null); 408 | e.dispatchEvent(event); 409 | } 410 | first(metaKey = false) { 411 | const e = this.content.querySelector('.entry[data-index]'); 412 | if (e) { 413 | this.select(e, metaKey); 414 | } 415 | } 416 | last(metaKey = false) { 417 | const e = this.content.querySelector('.entry[data-index]:last-child'); 418 | if (e) { 419 | this.select(e, metaKey); 420 | } 421 | } 422 | previous(metaKey = false, reverse = false) { 423 | if (reverse) { 424 | const es = this.content.querySelectorAll('.entry[data-selected=true]'); 425 | if (es.length > 1) { 426 | es[0].dataset.selected = false; 427 | } 428 | } 429 | else { 430 | const e = this.content.querySelector('.entry:not(.hr) + .entry[data-selected=true]'); 431 | if (e) { 432 | this.select(e.previousElementSibling, metaKey); 433 | } 434 | } 435 | } 436 | next(metaKey = false, reverse = false) { 437 | if (reverse) { 438 | const es = this.content.querySelectorAll('.entry[data-selected=true]'); 439 | if (es.length > 1) { 440 | es[es.length - 1].dataset.selected = false; 441 | } 442 | } 443 | else { 444 | const e = [...this.content.querySelectorAll('.entry[data-selected=true] + .entry')].pop(); 445 | if (e) { 446 | this.select(e, metaKey); 447 | } 448 | } 449 | } 450 | items(selected = true) { 451 | if (selected) { 452 | return [...this.content.querySelectorAll('.entry[data-selected=true]')]; 453 | } 454 | return [...this.content.querySelectorAll('.entry[data-index]:not([data-index="-1"])')]; 455 | } 456 | entries(selected = true) { 457 | return this.items(selected).map(target => { 458 | const o = Object.assign({}, target.node, target.dataset); 459 | // id is from search 460 | if (target.dataset.id.startsWith('{')) { 461 | o.id = JSON.parse(target.dataset.id); 462 | } 463 | return o; 464 | }); 465 | } 466 | emit(name, detail) { 467 | return this.dispatchEvent(new CustomEvent(name, { 468 | bubbles: true, 469 | detail 470 | })); 471 | } 472 | dbclick(e) { 473 | return e.dispatchEvent(new CustomEvent('click', { 474 | detail: 2, 475 | bubbles: true 476 | })); 477 | } 478 | favicon(href) { 479 | if (typeof InstallTrigger !== 'undefined') { 480 | if (this.config.remote) { 481 | return 'http://www.google.com/s2/favicons?domain_url=' + href; 482 | } 483 | else { 484 | return '/data/commander/images/page.svg'; 485 | } 486 | } 487 | return 'chrome://favicon/' + href; 488 | } 489 | date(ms) { 490 | if (ms) { 491 | return (new Date(ms)).toLocaleDateString(); 492 | } 493 | return ''; 494 | } 495 | clean() { 496 | [...this.content.querySelectorAll('.entry:not(.hr)')].forEach(e => e.remove()); 497 | } 498 | // ids of selected elements 499 | build(nodes, err, ids = [], extra) { // extra = {origin: ['root', 'search', 'extra']} 500 | this.clean(); 501 | this.extra = extra; 502 | 503 | // remove unknown ids 504 | ids = ids.filter(id => nodes.some(n => n.id === id)); 505 | 506 | const f = document.createDocumentFragment(); 507 | if (err) { 508 | const clone = document.importNode(this.template.content, true); 509 | clone.querySelector('[data-id="name"]').textContent = err.message; 510 | clone.querySelector('div').dataset.type = 'ERROR'; 511 | f.appendChild(clone); 512 | } 513 | else { 514 | for (const node of nodes) { 515 | const clone = document.importNode(this.template.content, true); 516 | clone.querySelector('[data-id="name"]').textContent = node.title; 517 | clone.querySelector('[data-id="href"]').textContent = node.url; 518 | clone.querySelector('[data-id="path"]').textContent = node.relativePath; 519 | clone.querySelector('[data-id="added"]').textContent = this.date(node.dateAdded); 520 | clone.querySelector('[data-id="modified"]').textContent = this.date(node.dateGroupModified); 521 | const type = node.url ? 'FILE' : 'DIRECTORY'; 522 | const div = clone.querySelector('div'); 523 | Object.assign(div.dataset, { 524 | key: node.title ? node.title[0].toLowerCase() : '', 525 | type, 526 | index: node.index, 527 | id: typeof node.id === 'string' ? node.id : JSON.stringify(node.id), 528 | readonly: node.readonly || false 529 | }); 530 | div.title = `${node.title} 531 | ${node.url} 532 | ${node.relativePath}`; 533 | 534 | if (node.readonly !== true) { 535 | div.setAttribute('draggable', 'true'); 536 | } 537 | div.node = node; 538 | div.dataset.selected = ids.length ? ids.indexOf(node.id) !== -1 : node === nodes[0]; 539 | if (type === 'FILE') { 540 | clone.querySelector('[data-id="icon"]').style['background-image'] = `url(${this.favicon(node.url)})`; 541 | } 542 | f.appendChild(clone); 543 | } 544 | } 545 | this.content.appendChild(f); 546 | // scroll the first selected index into the view 547 | if (ids.length) { 548 | const e = this.content.querySelector(`[data-id="${ids[0]}"`); 549 | if (e) { 550 | this.scroll(e); 551 | } 552 | } 553 | } 554 | mode(o) { 555 | this.content.dataset.path = Boolean(o.path); 556 | } 557 | // refresh the list while keeping selections 558 | update(nodes, err) { 559 | const ids = [...this.content.querySelectorAll('[data-selected="true"]')] 560 | .map(e => e.dataset.id) 561 | // make sure ids are still present 562 | .filter(id => nodes.some(n => n.id === id)); 563 | return this.build(nodes, err, ids); 564 | } 565 | // content is the only focusable element 566 | focus() { 567 | this.content.focus(); 568 | } 569 | scroll(target) { 570 | const hr = this.content.querySelector('.hr').getBoundingClientRect(); 571 | const bounding = target.getBoundingClientRect(); 572 | // do we need scroll from top 573 | if (bounding.top < hr.top + hr.height) { 574 | target.scrollIntoView({ 575 | block: 'start' 576 | }); 577 | this.content.scrollTop -= bounding.height; 578 | } 579 | if (bounding.bottom > hr.top + this.content.clientHeight) { 580 | target.scrollIntoView({ 581 | block: 'end' 582 | }); 583 | } 584 | } 585 | connectedCallback() { 586 | const hr = this.content.querySelector('div.entry.hr'); 587 | const entries = [...hr.querySelectorAll('div')]; 588 | entries.forEach((entry, index) => { 589 | const drag = entry.querySelector('i'); 590 | if (!drag) { 591 | return; 592 | } 593 | drag.onmousedown = () => { 594 | const resize = e => { 595 | const widths = entries.map(e => e.getBoundingClientRect().width); 596 | const total = widths.reduce((p, c) => c + p, 0); 597 | 598 | widths[index] -= e.movementX; 599 | if (widths[index] < 32) { 600 | return; 601 | } 602 | for (let j = index - 1; j >= 0; j -= 1) { 603 | if (widths[j] !== 0) { 604 | widths[j] += e.movementX; 605 | if (widths[j] < 32) { 606 | return; 607 | } 608 | break; 609 | } 610 | } 611 | this.shadowRoot.getElementById('styles').textContent = ` 612 | #content[data-path=${this.content.dataset.path}] div.entry { 613 | grid-template-columns: ${widths.filter(w => w).map(w => (w / total * 100) + '%').join(' ')}; 614 | } 615 | `; 616 | }; 617 | document.addEventListener('mousemove', resize); 618 | document.onmouseup = () => { 619 | document.removeEventListener('mousemove', resize); 620 | }; 621 | }; 622 | }); 623 | } 624 | } 625 | window.customElements.define('list-view', ListView); 626 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/components/directory-view/path-view.js: -------------------------------------------------------------------------------- 1 | class PathView extends HTMLElement { 2 | constructor() { 3 | super(); 4 | const shadow = this.attachShadow({ 5 | mode: 'open' 6 | }); 7 | shadow.innerHTML = ` 8 | 47 |
48 |
49 | 50 |
51 | `; 52 | this.content = shadow.getElementById('content'); 53 | this.content.addEventListener('click', e => { 54 | const {target} = e; 55 | if (target.id && target !== this.content) { 56 | const id = target.id.startsWith('{') ? JSON.parse(target.id) : target.id; 57 | this.dispatchEvent(new CustomEvent('submit', { 58 | detail: { 59 | entries: [{ 60 | id, 61 | type: 'DIRECTORY' 62 | }] 63 | } 64 | })); 65 | } 66 | }); 67 | } 68 | build(map) { 69 | this.content.textContent = ''; 70 | const f = document.createDocumentFragment(); 71 | map.forEach(({title, id}, i) => { 72 | const label = document.createElement('label'); 73 | const span = document.createElement('span'); 74 | const input = document.createElement('input'); 75 | input.type = 'radio'; 76 | input.name = 'group'; 77 | input.id = typeof id === 'string' ? id : JSON.stringify(id); 78 | input.checked = i === map.length - 1; 79 | f.appendChild(input); 80 | label.title = span.textContent = title || ''; 81 | label.appendChild(span); 82 | f.appendChild(label); 83 | label.setAttribute('for', input.id); 84 | }); 85 | this.content.appendChild(f); 86 | this.content.scrollLeft = this.content.scrollWidth; 87 | } 88 | } 89 | window.customElements.define('path-view', PathView); 90 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/components/prompt-view.js: -------------------------------------------------------------------------------- 1 | class PromptView extends HTMLElement { 2 | constructor() { 3 | super(); 4 | const shadow = this.attachShadow({ 5 | mode: 'open' 6 | }); 7 | shadow.innerHTML = ` 8 | 52 |
53 | This is a prompt message 54 | 55 |
56 | 57 | 58 |
59 |
60 | `; 61 | this.events = {}; 62 | } 63 | connectedCallback() { 64 | const input = this.shadowRoot.querySelector('input[type=text]'); 65 | const next = value => { 66 | this.classList.add('hidden'); 67 | for (const c of this.events.blur || []) { 68 | c(); 69 | } 70 | this.resolve(value); 71 | }; 72 | 73 | this.shadowRoot.addEventListener('submit', e => { 74 | e.preventDefault(); 75 | next(input.value); 76 | }); 77 | this.addEventListener('keyup', e => { 78 | if (e.code === 'Escape') { 79 | next(''); 80 | } 81 | }); 82 | this.shadowRoot.querySelector('input[type=button]').addEventListener('click', () => { 83 | next(''); 84 | }); 85 | this.addEventListener('click', () => { 86 | input.focus(); 87 | }); 88 | 89 | this.addEventListener('keypress', e => e.stopPropagation()); 90 | this.addEventListener('keyup', e => e.stopPropagation()); 91 | this.addEventListener('keydown', e => e.stopPropagation()); 92 | } 93 | ask(message, value = '') { 94 | const input = this.shadowRoot.querySelector('input[type=text]'); 95 | const span = this.shadowRoot.querySelector('span'); 96 | span.textContent = message; 97 | 98 | this.classList.remove('hidden'); 99 | input.value = value; 100 | 101 | window.setTimeout(() => { 102 | input.focus(); 103 | input.select(); 104 | }); 105 | 106 | return new Promise(resolve => this.resolve = resolve); 107 | } 108 | on(method, callback) { 109 | this.events[method] = this.events[method] || []; 110 | this.events[method].push(callback); 111 | } 112 | } 113 | window.customElements.define('prompt-view', PromptView); 114 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/components/tools-view.js: -------------------------------------------------------------------------------- 1 | /* global engine */ 2 | class ToolsView extends HTMLElement { 3 | constructor() { 4 | super(); 5 | const shadow = this.attachShadow({ 6 | mode: 'open' 7 | }); 8 | this.shadow = shadow; 9 | shadow.innerHTML = ` 10 | 85 | 86 |
87 | Copy 88 | Title (X) 89 | Link (C) 90 | ID 91 | Edit 92 | Title 93 | Link 94 | JSON 95 | Import 96 | Export (Y) 97 | New 98 | Bookmark 99 | Directory 100 | Move 101 | Left (←) 102 | Right (→) 103 | Tools 104 | CMD (S) 105 | Root 106 | Mirror 107 | Sync 108 | Delete 109 | Search (F) 110 | Sort (J) 111 |
112 | `; 113 | this.shadow.addEventListener('click', e => { 114 | const command = e.target.dataset.command; 115 | if (command === 'commands') { 116 | this.command(new KeyboardEvent('keydown', { 117 | code: 'KeyS', 118 | ctrlKey: true 119 | })); 120 | } 121 | else if (command) { 122 | this.emit('tools-view:command', { 123 | command, 124 | shiftKey: e.shiftKey 125 | }); 126 | } 127 | }); 128 | } 129 | emit(name, detail) { 130 | return this.dispatchEvent(new CustomEvent(name, { 131 | bubbles: true, 132 | detail 133 | })); 134 | } 135 | validate(name) { 136 | if (name === 'open-folder') { 137 | name = 'mirror'; 138 | } 139 | const d = this.shadow.querySelector(`[data-command="${name}"]`); 140 | if (d) { 141 | if (d.dataset.enabled === 'false') { 142 | return 0; 143 | } 144 | else { 145 | return 1; 146 | } 147 | } 148 | // commands without buttons 149 | else if (['first', 'last'].some(s => s === name)) { 150 | return 1; 151 | } 152 | return -1; 153 | } 154 | command(e, callback = () => {}) { 155 | const meta = e.ctrlKey || e.metaKey; 156 | let command = ''; 157 | if (e.key === 'Home') { 158 | command = 'first'; 159 | } 160 | else if (e.key === 'End') { 161 | command = 'last'; 162 | } 163 | else if (e.code === 'KeyC' && meta) { 164 | command = 'copy-link'; 165 | } 166 | else if (e.code === 'KeyP' && meta) { 167 | command = 'import-tree'; 168 | } 169 | else if (e.code === 'KeyY' && meta) { 170 | command = 'export-tree'; 171 | } 172 | else if (e.code === 'KeyX' && meta) { 173 | command = 'copy-title'; 174 | } 175 | else if (e.code === 'KeyI' && meta) { 176 | command = 'copy-id'; 177 | } 178 | else if (e.code === 'KeyE' && meta) { 179 | command = 'edit-title'; 180 | } 181 | else if (e.code === 'KeyL' && meta) { 182 | command = 'edit-link'; 183 | } 184 | else if (e.code === 'KeyB' && meta) { 185 | command = 'new-file'; 186 | } 187 | else if (e.code === 'KeyD' && meta) { 188 | command = 'new-directory'; 189 | } 190 | else if (e.code === 'ArrowLeft' && meta) { 191 | command = 'move-left'; 192 | } 193 | else if (e.code === 'ArrowRight' && meta) { 194 | command = 'move-right'; 195 | } 196 | else if ((e.code === 'Delete' || e.code === 'Backspace') && meta) { 197 | command = 'trash'; 198 | } 199 | else if (e.code === 'KeyO' && meta) { 200 | command = 'root'; 201 | } 202 | else if (e.code === 'KeyM' && e.altKey && e.shiftKey) { 203 | command = 'open-folder'; 204 | } 205 | else if (e.code === 'KeyM' && e.altKey) { 206 | command = 'mirror'; 207 | } 208 | else if (e.code === 'KeyM' && meta) { 209 | command = 'mirror'; 210 | } 211 | else if (e.code === 'KeyF' && meta) { 212 | command = 'search'; 213 | } 214 | else if (e.code === 'KeyJ' && (meta || e.altKey)) { 215 | command = 'sort'; 216 | } 217 | else if (e.code === 'KeyS' && meta && e.shiftKey) { 218 | command = 'sync'; 219 | } 220 | // command box 221 | if (e.code === 'KeyS' && meta && e.shiftKey === false) { 222 | engine.user.ask(`Enter a Command: 223 | 224 | icon=[default|light|dark] 225 | theme=[default|dark|light] 226 | font-size=[number]px 227 | font-family=[font-name] 228 | views=[1|2] 229 | column-widths=[name]px, [added]px, [modified]px`).then(command => { 230 | if (command.startsWith('icon=')) { 231 | const path = command.replace('icon=', '') || 'default'; 232 | chrome.storage.local.set({ 233 | 'custom-icon': path === 'default' ? '' : path 234 | }); 235 | } 236 | else if (command.startsWith('theme=')) { 237 | const path = command.replace('theme=', '') || 'default'; 238 | chrome.storage.local.set({ 239 | 'theme': path === 'default' ? '' : path 240 | }); 241 | } 242 | else if (command.startsWith('font-size=')) { 243 | const px = /font-size=(\d+)px/.exec(command); 244 | chrome.storage.local.set({ 245 | 'font-size': px && px.length ? px[1] : '' 246 | }); 247 | } 248 | else if (command.startsWith('font-family=')) { 249 | chrome.storage.local.set({ 250 | 'font-family': command.replace('font-family=', '') 251 | }); 252 | } 253 | else if (command.startsWith('views=')) { 254 | const views = Math.min(2, Math.max(1, Number(command.replace('views=', '')))); 255 | chrome.storage.local.set({ 256 | views 257 | }); 258 | } 259 | else if (command.startsWith('column-widths=')) { 260 | const widths = [...command.replace('column-widths=', '').split(/\s*,\s*/).map(s => parseInt(s))].slice(0, 3); 261 | widths[0] = widths[0] ? Math.min(1000, Math.max(32, widths[0])) : 200; 262 | widths[1] = widths[1] ? Math.min(1000, Math.max(32, widths[1])) : 90; 263 | widths[2] = widths[2] ? Math.min(1000, Math.max(32, widths[2])) : 90; 264 | 265 | chrome.storage.local.set({ 266 | widths: { 267 | name: widths[0], 268 | added: widths[1], 269 | modified: widths[2] 270 | } 271 | }); 272 | } 273 | }); 274 | e.preventDefault(); 275 | } 276 | if (command) { 277 | const code = this.validate(command); 278 | if (code === 0 || code === 1) { 279 | e.preventDefault(); 280 | e.stopPropagation(); // to prevent other modules from running 281 | } 282 | if (code === 1) { 283 | callback(command, e); 284 | } 285 | if (code === 0) { 286 | engine.notify('beep'); 287 | } 288 | } 289 | } 290 | state(command, enabled) { 291 | const e = this.shadow.querySelector(`[data-command="${command}"]`); 292 | if (e) { 293 | e.dataset.enabled = enabled; 294 | } 295 | } 296 | } 297 | window.customElements.define('tools-view', ToolsView); 298 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/engine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bookmarks = { 4 | rootID: typeof InstallTrigger !== 'undefined' ? 'root________' : '0', 5 | isRoot(id) { 6 | return id === '' || id === bookmarks.rootID; 7 | }, 8 | isSearch(id) { 9 | return Boolean(id.query); 10 | }, 11 | parent(id) { 12 | return new Promise((resolve, reject) => { 13 | chrome.bookmarks.get(id, arr => { 14 | const lastError = chrome.runtime.lastError; 15 | if (lastError) { 16 | reject(lastError); 17 | } 18 | else { 19 | resolve(arr[0]); 20 | } 21 | }); 22 | }); 23 | }, 24 | async hierarchy(id) { 25 | const cache = []; 26 | if (bookmarks.isSearch(id)) { 27 | let title = 'Search: ' + id.query; 28 | if (id.query.startsWith('duplicates')) { 29 | const openerId = id.query.replace('duplicates:', '') || bookmarks.rootID; 30 | title = `Duplicates for "${openerId}"`; 31 | } 32 | cache.push({ 33 | title, 34 | id 35 | }); 36 | } 37 | else { 38 | while (this.isRoot(id) === false) { 39 | const node = await bookmarks.parent(id); 40 | id = node.parentId; 41 | cache.unshift(node); 42 | } 43 | cache.unshift({ 44 | title: '/', 45 | id: bookmarks.rootID 46 | }); 47 | } 48 | 49 | return cache; 50 | }, 51 | children(id) { 52 | // duplicate finder 53 | if (id.query && id.query.startsWith('duplicates')) { 54 | let openerId = id.query.replace('duplicates:', '') || bookmarks.rootID; 55 | if (/Firefox/.test(navigator.userAgent)) { 56 | if (typeof openerId !== 'string' || openerId.trim() === '') { 57 | openerId = bookmarks.rootID; 58 | } 59 | } 60 | else if (isNaN(openerId)) { // Chrome 61 | openerId = bookmarks.rootID; 62 | } 63 | return new Promise(resolve => chrome.bookmarks.getSubTree(openerId, children => { 64 | const links = {}; 65 | const swipe = (root, path = '.') => { 66 | for (const node of root.children) { 67 | if ('children' in node) { 68 | swipe(node, path + '/' + (node.title || '')); 69 | } 70 | else if (node.url) { 71 | links[node.url] = links[node.url] || []; 72 | node.relativePath = path.replace('.//', '/'); 73 | links[node.url].push(node); 74 | } 75 | } 76 | }; 77 | swipe({ 78 | children 79 | }); 80 | return resolve(Object.values(links).filter(nodes => nodes.length > 1).flat()); 81 | })); 82 | } 83 | else if (id.query) { 84 | return new Promise(resolve => chrome.bookmarks.search({ 85 | query: id.query 86 | }, async nodes => { 87 | for (const node of nodes) { 88 | const arr = await bookmarks.hierarchy(node.id); 89 | arr.shift(); 90 | arr.pop(); 91 | node.relativePath = ['', ...arr, ''].map(n => n.title).join('/'); 92 | } 93 | resolve(nodes); 94 | })); 95 | } 96 | return new Promise((resolve, reject) => { 97 | chrome.bookmarks.getChildren(id, nodes => { 98 | const lastError = chrome.runtime.lastError; 99 | if (lastError) { 100 | reject(lastError); 101 | } 102 | else { 103 | // You cannot use this API to add or remove entries in the root folder. 104 | if (id === '' || id === bookmarks.rootID) { 105 | nodes.forEach(n => n.readonly = true); 106 | } 107 | resolve(nodes); 108 | } 109 | }); 110 | }); 111 | }, 112 | tree(id) { 113 | return new Promise((resolve, reject) => { 114 | chrome.bookmarks.getSubTree(id, nodes => { 115 | const lastError = chrome.runtime.lastError; 116 | if (lastError) { 117 | reject(lastError); 118 | } 119 | else { 120 | resolve(nodes); 121 | } 122 | }); 123 | }); 124 | }, 125 | update(id, o) { 126 | return new Promise((resolve, reject) => chrome.bookmarks.update(id, o, nodes => { 127 | const lastError = chrome.runtime.lastError; 128 | if (lastError) { 129 | reject(lastError); 130 | } 131 | else { 132 | resolve(nodes); 133 | } 134 | })); 135 | }, 136 | move(id, o) { 137 | return new Promise((resolve, reject) => chrome.bookmarks.move(id, o, node => { 138 | const lastError = chrome.runtime.lastError; 139 | if (lastError) { 140 | reject(lastError); 141 | } 142 | else { 143 | resolve(node); 144 | } 145 | })); 146 | }, 147 | create(o) { 148 | return new Promise((resolve, reject) => chrome.bookmarks.create(o, node => { 149 | const lastError = chrome.runtime.lastError; 150 | if (lastError) { 151 | reject(lastError); 152 | } 153 | else { 154 | resolve(node); 155 | } 156 | })); 157 | }, 158 | remove(id, recursive = false) { 159 | return new Promise((resolve, reject) => chrome.bookmarks[recursive ? 'removeTree' : 'remove'](id, () => { 160 | const lastError = chrome.runtime.lastError; 161 | if (lastError) { 162 | reject(lastError); 163 | } 164 | else { 165 | resolve(); 166 | } 167 | })); 168 | } 169 | }; 170 | 171 | const tabs = { 172 | create(o) { 173 | return new Promise(resolve => chrome.tabs.create(o, resolve)); 174 | }, 175 | update(id, o) { 176 | return new Promise(resolve => chrome.tabs.update(id, o, resolve)); 177 | }, 178 | active() { 179 | return new Promise((resolve, reject) => chrome.tabs.query({ 180 | active: true, 181 | windowType: 'normal' 182 | }, tabs => tabs.length ? resolve(tabs[0]) : reject(Error('no active tab')))); 183 | } 184 | }; 185 | 186 | const windows = { 187 | create(o) { 188 | return new Promise(resolve => chrome.windows.create(o, resolve)); 189 | } 190 | }; 191 | 192 | const storage = { 193 | get(o) { 194 | return new Promise(resolve => chrome.storage.local.get(o, resolve)); 195 | }, 196 | set(o) { 197 | return new Promise(resolve => chrome.storage.local.set(o, resolve)); 198 | }, 199 | changed(callback) { 200 | chrome.storage.onChanged.addListener(callback); 201 | } 202 | }; 203 | 204 | const ue = document.querySelector('prompt-view'); 205 | const user = { 206 | ask(msg, value) { 207 | return ue.ask(msg, value); 208 | }, 209 | on(name, callback) { 210 | ue.on(name, callback); 211 | } 212 | }; 213 | 214 | window.engine = { 215 | bookmarks, 216 | tabs, 217 | windows, 218 | storage, 219 | user, 220 | notify(e) { 221 | if (e === 'beep') { 222 | return (new Audio('/data/assets/bell.wav')).play(); 223 | } 224 | chrome.notifications.create({ 225 | type: 'basic', 226 | iconUrl: '/data/icons/48.png', 227 | title: chrome.runtime.getManifest().name, 228 | message: e.message || e 229 | }); 230 | }, 231 | clipboard: { 232 | copy(str) { 233 | return navigator.clipboard.writeText(str).catch(() => new Promise(resolve => { 234 | document.oncopy = e => { 235 | e.clipboardData.setData('text/plain', str); 236 | e.preventDefault(); 237 | resolve(); 238 | }; 239 | document.execCommand('Copy', false, null); 240 | })); 241 | }, 242 | read() { 243 | return navigator.clipboard.readText(); 244 | } 245 | }, 246 | download(content, name, type) { 247 | const a = document.createElement('a'); 248 | const b = new Blob([content], { 249 | type 250 | }); 251 | a.href = URL.createObjectURL(b); 252 | a.download = name; 253 | a.click(); 254 | setTimeout(() => URL.revokeObjectURL(a.href), 1000); 255 | } 256 | }; 257 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/images/directory-readonly.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/images/directory.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/images/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/images/page.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color: #3e3e3e; 3 | --bg: #eee; 4 | --bg-light: #dadada; 5 | --bg-active: #fff; 6 | --bg-header: #f5f5f5; 7 | --bg-even-row: #f5f5f5; 8 | --bg-selected-row: #c0e7ff; 9 | --bg-path: #dadada; 10 | --bg-path-active: #fff; 11 | --bg-blur: rgba(0, 0, 0, 0.6); 12 | --disabled-color: #a0a0a0; 13 | --disabled-shadow: #fcffff; 14 | --border: #cacaca; 15 | --border-alt: #e8e3e9; 16 | --selection: #8a8c8d; 17 | } 18 | :root.dark { 19 | --color: #9c9c9c; 20 | --bg: #18191b; 21 | --bg-light: #35363a; 22 | --bg-even-row: rgba(255, 255, 255, 0.05); 23 | --bg-selected-row: #0f488e; 24 | --bg-header: #35363a; 25 | --bg-active: #000; 26 | --bg-path: #202124; 27 | --bg-path-active: #000; 28 | --bg-blur: rgba(255, 255, 255, 0.05); 29 | --disabled-color: #5b5b5b; 30 | --disabled-shadow: #393b42; 31 | --border: #4f5052; 32 | --border-alt: #4f5052; 33 | --selection: #000; 34 | } 35 | 36 | body { 37 | font-size: 13px; 38 | font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; 39 | height: 100vh; 40 | margin: 0; 41 | display: flex; 42 | flex-direction: column; 43 | background-color: var(--bg, #eee); 44 | color: var(--color, #3e3e3e); 45 | } 46 | #directories { 47 | overflow: hidden; 48 | display: grid; 49 | grid-template-columns: 1fr 1fr; 50 | flex: 1; 51 | margin: 2px; 52 | } 53 | #directories label { 54 | display: flex; 55 | overflow: hidden; 56 | } 57 | #directories input[type=radio] { 58 | display: none; 59 | } 60 | directory-view { 61 | flex: 1; 62 | overflow: hidden; 63 | } 64 | label:not(:first-child) directory-view { 65 | margin-left: 2px; 66 | } 67 | @media screen and (max-width: 600px) { 68 | #directories { 69 | grid-template-columns: 1fr; 70 | } 71 | } 72 | 73 | prompt-view { 74 | position: fixed; 75 | top: 0; 76 | left: 0; 77 | width: 100%; 78 | height: 100%; 79 | background-color: var(--bg-blur, rgba(0, 0, 0, 0.6)); 80 | } 81 | 82 | #toast { 83 | padding: 10px; 84 | position: fixed; 85 | top: 10px; 86 | right: 10px; 87 | width: 300px; 88 | background-color: var(--bg, rgba(0, 0, 0, 0.6)); 89 | border: solid 1px var(--border); 90 | } 91 | #toast:empty { 92 | display: none; 93 | } 94 | 95 | .hidden { 96 | display: none; 97 | } 98 | 99 | body[data-views="1"] #directories { 100 | grid-template-columns: 1fr; 101 | } 102 | body[data-views="1"] label[data-id="right"] { 103 | display: none !important; 104 | } 105 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Bookmarks Commander 8 | 9 | 10 | 11 |
12 | 16 | 20 |
21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /v2/firefox/data/commander/index.js: -------------------------------------------------------------------------------- 1 | /* global engine */ 2 | 'use strict'; 3 | 4 | const args = new URLSearchParams(location.search); 5 | if (args.has('width')) { 6 | document.documentElement.style.width = args.get('width') + 'px'; 7 | } 8 | if (args.has('height')) { 9 | document.documentElement.style.height = args.get('height') + 'px'; 10 | } 11 | 12 | const title = { 13 | 'directory-view-1': '...', 14 | 'directory-view-2': '...' 15 | }; 16 | 17 | const toast = msg => { 18 | clearTimeout(toast.id); 19 | toast.id = setTimeout(() => { 20 | document.getElementById('toast').textContent = ''; 21 | }, 2000); 22 | document.getElementById('toast').textContent = msg; 23 | }; 24 | 25 | /* events */ 26 | // persist last visited paths and update title 27 | document.addEventListener('directory-view:path', e => { 28 | const {detail} = e; 29 | const id = e.target.getAttribute('id'); 30 | title[id] = detail.arr[detail.arr.length - 1].title; 31 | 32 | document.title = '[L] ' + title['directory-view-1'] + ' [R] ' + title['directory-view-2']; 33 | engine.storage.set({ 34 | [id]: detail.id 35 | }); 36 | }); 37 | /* history */ 38 | document.addEventListener('directory-view:path', () => { 39 | const state = { 40 | 'directory-view-1': views.left.id(), 41 | 'directory-view-1-ids': views.left.entries().map(o => o.id), 42 | 'directory-view-2': views.right.id(), 43 | 'directory-view-2-ids': views.right.entries().map(o => o.id), 44 | 'active': views.active() === views.left ? 'left' : 'right' 45 | }; 46 | // do not push state if history.state is equal to the current state => state is set by history.state 47 | if (history.state) { 48 | if ( 49 | JSON.stringify(history.state['directory-view-1']) === JSON.stringify(state['directory-view-1']) && 50 | JSON.stringify(history.state['directory-view-2']) === JSON.stringify(state['directory-view-2']) 51 | ) { 52 | return; 53 | } 54 | } 55 | clearTimeout(history.id); 56 | history.id = setTimeout(() => { 57 | history[history.state ? 'pushState' : 'replaceState'](state, document.title); 58 | }, 100); 59 | }); 60 | window.addEventListener('popstate', e => { 61 | if (history.state && history.state.active) { 62 | start(e.state); 63 | } 64 | }); 65 | history.busy = false; 66 | 67 | // user-action 68 | document.addEventListener('directory-view:submit', e => { 69 | const {detail} = e; 70 | detail.entries.forEach(o => { 71 | if (o.type === 'DIRECTORY' && detail.entries.length === 1) { 72 | const {id, openerId} = detail.entries[0]; 73 | // prevent looping 74 | if (openerId && openerId.id) { 75 | if (openerId.id.id) { 76 | e.target.build(openerId.id, undefined, [openerId.id.id]); 77 | } 78 | else { 79 | e.target.build(openerId.id, undefined, []); 80 | } 81 | } 82 | else if (openerId) { 83 | e.target.build(id, undefined, [openerId]); 84 | } 85 | else { 86 | e.target.build(id); 87 | } 88 | } 89 | else if (o.type === 'FILE') { 90 | if (detail.metaKey || detail.ctrlKey) { 91 | engine.tabs.create({ 92 | url: o.url, 93 | active: false 94 | }); 95 | } 96 | else if (detail.shiftKey) { 97 | engine.windows.create({ 98 | url: o.url 99 | }); 100 | } 101 | else { 102 | if (args.get('mode') === 'window') { 103 | engine.tabs.active().then(tab => engine.tabs.update(tab.id, { 104 | url: o.url 105 | })).catch(() => engine.tabs.create({ 106 | url: o.url 107 | })).finally(() => window.close()); 108 | } 109 | else { 110 | engine.tabs.update(undefined, { 111 | url: o.url 112 | }); 113 | } 114 | } 115 | } 116 | }); 117 | }); 118 | document.addEventListener('directory-view:drop-request', async e => { 119 | const {ids, types, selected, source, destination} = e.detail; 120 | // cannot move to the root directory 121 | if (views[destination].isRoot()) { 122 | toast('Cannot move to the root directory'); 123 | return; 124 | } 125 | // cannot move to a search directory 126 | if (views[destination].isSearch()) { 127 | toast('Cannot move to a search view'); 128 | return; 129 | } 130 | // cannot move a directory to a child directory 131 | if (types.some(type => type === 'DIRECTORY')) { 132 | const d = views[destination].list(); 133 | // if any selected directory is in the path of destination directory, prevent moving 134 | if (ids.some(id => d.some(de => de.id === id))) { 135 | toast('Cannot move to a child directory'); 136 | return; 137 | } 138 | } 139 | 140 | const s = source === 'right' ? views.right : views.left; 141 | const d = destination === 'right' ? views.right : views.left; 142 | if (selected.some(s => s === 'true')) { 143 | s.navigate('previous'); 144 | } 145 | for (const id of ids) { 146 | await engine.bookmarks.move(id, { 147 | parentId: d.id(), 148 | index: Number(d.entries()[0].index) + 1 149 | }).catch(engine.notify); 150 | } 151 | // update both views 152 | views.update(); 153 | }); 154 | document.addEventListener('directory-view:selection-changed', e => { 155 | const input = e.target.parentElement.querySelector('input[type=radio]'); 156 | if (input) { 157 | input.click(); 158 | } 159 | views.changed(); 160 | 161 | engine.storage.set({ 162 | [e.target.getAttribute('id') + '-ids']: e.target.entries().map(o => o.id) 163 | }); 164 | }); 165 | document.addEventListener('directory-view:content-updated', () => { 166 | views.changed(); 167 | }); 168 | { 169 | const commit = e => { 170 | command(e.detail.command, { 171 | shiftKey: e.detail.shiftKey, 172 | altKey: e.detail.altKey, 173 | metaKey: e.detail.metaKey, 174 | ctrlKey: e.detail.ctrlKey 175 | }); 176 | views.active().click(); 177 | }; 178 | document.addEventListener('directory-view:command', commit); 179 | document.addEventListener('tools-view:command', commit); 180 | } 181 | 182 | engine.user.on('blur', () => views.active().click()); 183 | 184 | /* views */ 185 | const views = { 186 | 'parent': document.getElementById('directories'), 187 | 'left': document.getElementById('directory-view-1'), 188 | 'right': document.getElementById('directory-view-2'), 189 | active(reverse = false) { 190 | let e = document.querySelector('input:checked + directory-view'); 191 | if (reverse) { 192 | e = document.querySelector('input:not(:checked) + directory-view'); 193 | } 194 | return e || views.left; 195 | }, 196 | changed() { 197 | const active = views.active(); 198 | 199 | const direction = active === views.left ? 'LEFT' : 'RIGHT'; 200 | engine.storage.set({ 201 | active: active === views.left ? 'left' : 'right' 202 | }); 203 | const entries = active.entries(); 204 | 205 | const readonly = entries.some(o => o.readonly === 'true'); 206 | const directory = entries.some(o => o.type === 'DIRECTORY'); 207 | const file = entries.some(o => o.type === 'FILE'); 208 | 209 | // move-left or move-right 210 | if (readonly) { 211 | toolsView.state('move-left', false); 212 | toolsView.state('move-right', false); 213 | } 214 | else { 215 | /* move-left or move-right*/ 216 | for (const moveTo of ['left', 'right']) { 217 | let move = direction !== moveTo.toUpperCase(); 218 | // cannot move to the root directory 219 | if (move && views[moveTo].isRoot()) { 220 | move = false; 221 | } 222 | // cannot move to a search directory 223 | if (move && views[moveTo].isSearch()) { 224 | move = false; 225 | } 226 | // cannot move a directory to a child directory 227 | if (move && directory) { 228 | const d = views[moveTo].list(); 229 | // if any selected directory is in the path of destination directory, prevent moving 230 | if (entries.some(e => d.some(de => de.id === e.id))) { 231 | move = false; 232 | } 233 | } 234 | toolsView.state('move-' + moveTo, move); 235 | } 236 | } 237 | // delete 238 | toolsView.state('trash', readonly === false); 239 | // sort 240 | toolsView.state('sort', active.isRoot() === false && active.count > 1 && active.isSearch() === false); 241 | // copy-link 242 | toolsView.state('copy-link', file); 243 | // edit-link 244 | toolsView.state('edit-link', readonly === false && file && entries.length === 1); 245 | // edit-title 246 | toolsView.state('edit-title', readonly === false && entries.length === 1); 247 | // new-file 248 | toolsView.state('new-file', active.isRoot() || active.isSearch() ? false : true); 249 | // new-directory (test; create directory on [..]) 250 | toolsView.state('new-directory', active.isRoot() || active.isSearch() ? false : true); 251 | // import-tree; 252 | // allow on root; does not allow on back button; does not allow on search 253 | toolsView.state('import-tree', entries.length === 1 && active.isSearch() === false && (readonly === false || active.isRoot())); 254 | // sync 255 | if ( 256 | views.left.isRoot() || views.left.isSearch() || 257 | views.right.isRoot() || views.right.isSearch() || 258 | views.left.id() === views.right.id() 259 | ) { 260 | toolsView.state('sync', false); 261 | } 262 | else { 263 | toolsView.state('sync', true); 264 | } 265 | }, 266 | update() { 267 | views.left.update(views.left.id()); 268 | views.right.update(views.right.id()); 269 | } 270 | }; 271 | views.left.owner('left'); 272 | views.right.owner('right'); 273 | const toolsView = document.getElementById('tools-view'); 274 | 275 | /* restore, and active a pane after DOM content is loaded */ 276 | const start = state => { 277 | document.getElementById('directory-view-1').build( 278 | state['directory-view-1'], 279 | undefined, 280 | state['directory-view-1-ids'] || [] 281 | ); 282 | document.getElementById('directory-view-2').build( 283 | state['directory-view-2'], 284 | undefined, 285 | state['directory-view-2-ids'] || [] 286 | ); 287 | 288 | views[state.active].click(); 289 | }; 290 | document.addEventListener('DOMContentLoaded', () => engine.storage.get({ 291 | 'directory-view-1': '', 292 | 'directory-view-1-ids': [], 293 | 'directory-view-2': '', 294 | 'directory-view-2-ids': [], 295 | 'active': 'left' 296 | }).then(start)); 297 | 298 | /* on command */ 299 | const command = async (command, e) => { 300 | const view = views.active(); 301 | if (view) { 302 | const entries = view.entries(); 303 | // copy-details 304 | if (command === 'copy-details') { 305 | engine.clipboard.copy(entries.map(o => [o.title, o.url, o.id].filter(a => a).join('\n')).join('\n\n')); 306 | } 307 | // copy-title 308 | else if (command === 'copy-title') { 309 | engine.clipboard.copy(entries.map(o => o.title).join('\n')); 310 | } 311 | // copy-id 312 | else if (command === 'copy-id') { 313 | engine.clipboard.copy(entries.map(o => o.id).join('\n')); 314 | } 315 | // copy-link 316 | else if (command === 'copy-link') { 317 | engine.clipboard.copy(entries.map(o => o.url).filter(a => a).join('\n')); 318 | } 319 | // import-tree 320 | else if (command === 'import-tree') { 321 | engine.clipboard.read().then(JSON.parse).then(async nodes => { 322 | if (Array.isArray(nodes) === false) { 323 | throw Error('This is not a valid JSON array'); 324 | } 325 | const entry = entries[0]; 326 | const step = async (node, parentId) => { 327 | const o = { 328 | parentId 329 | }; 330 | if (node.index) { 331 | o.index = Number(node.index) + 1; 332 | } 333 | o.title = node.title; 334 | if (!o.title) { 335 | throw Error('Bookmark needs title'); 336 | } 337 | if (node.type === 'FILE') { 338 | o.url = node.url; 339 | if (!o.url) { 340 | throw Error('Bookmark needs URL'); 341 | } 342 | } 343 | const n = await engine.bookmarks.create(o); 344 | if (node.type === 'DIRECTORY') { 345 | for (const e of node.children) { 346 | await step(e, n.id); 347 | } 348 | } 349 | }; 350 | let msg = 'Insert the bookmark tree after this node?'; 351 | if (entry.type === 'DIRECTORY') { 352 | msg = 'Insert the bookmark tree inside this node?'; 353 | } 354 | if (window.confirm(msg)) { 355 | for (const node of nodes) { 356 | if (entry.type === 'FILE') { 357 | node.index = entry.index; 358 | } 359 | await step(node, entry.type === 'FILE' ? entry.parentId : entry.id).then(() => views.update()); 360 | } 361 | } 362 | }).catch(engine.notify); 363 | } 364 | // export-tree 365 | else if (command === 'export-tree') { 366 | const items = []; 367 | for (const entry of entries) { 368 | await engine.bookmarks.tree(entry.id).then(nodes => { 369 | const step = (parent, node) => { 370 | parent.title = node.title; 371 | parent.type = node.url ? 'FILE' : 'DIRECTORY'; 372 | if (parent.type === 'FILE') { 373 | parent.url = node.url; 374 | } 375 | else { 376 | parent.children = []; 377 | for (const n of (node.children || [])) { 378 | const p = {}; 379 | parent.children.push(p); 380 | step(p, n); 381 | } 382 | } 383 | }; 384 | const root = {}; 385 | step(root, nodes[0]); 386 | items.push(root); 387 | }); 388 | if (e.shiftKey) { 389 | engine.download(JSON.stringify(items, undefined, ' '), 'tree.json', 'application/json'); 390 | } 391 | else { 392 | engine.clipboard.copy(JSON.stringify(items, undefined, ' ')); 393 | } 394 | } 395 | } 396 | // edit-title 397 | else if (command === 'edit-title' || command === 'edit-link') { 398 | const entry = entries[0]; 399 | let o = {}; 400 | if (command === 'edit-title') { 401 | const title = await engine.user.ask('Edit Title', entry.title); 402 | o = {title}; 403 | if (title === entry.title || title === '') { 404 | return; 405 | } 406 | } 407 | else { 408 | const url = await engine.user.ask('Edit Link', entry.url); 409 | o = {url}; 410 | if (url === entry.url || url === '') { 411 | return; 412 | } 413 | } 414 | engine.bookmarks.update(entry.id, o).catch(engine.notify).finally(() => { 415 | // we need to update both views 416 | views.update(); 417 | }); 418 | } 419 | else if (command === 'move-left' || command === 'move-right') { 420 | const s = command === 'move-left' ? views.right : views.left; 421 | const d = command === 'move-right' ? views.right : views.left; 422 | const toBeMoved = s.entries(); 423 | s.navigate('previous'); 424 | for (const entry of toBeMoved) { 425 | await engine.bookmarks.move(entry.id, { 426 | parentId: d.id(), 427 | index: Number(d.entries()[0].index) + 1 428 | }).catch(engine.notify); 429 | } 430 | // update both views 431 | views.update(); 432 | } 433 | else if (command === 'root') { 434 | if (e.shiftKey) { 435 | engine.storage.set({ 436 | [view.getAttribute('id')]: '' 437 | }).then(() => { 438 | view.build(engine.bookmarks.rootID); 439 | }); 440 | } 441 | else { 442 | engine.storage.set({ 443 | 'directory-view-1': '', 444 | 'directory-view-2': '' 445 | }).then(() => location.reload()); 446 | } 447 | } 448 | else if (command === 'new-file' || command === 'new-directory') { 449 | const entry = entries[0]; 450 | const o = { 451 | parentId: view.id(), 452 | index: Number(entry.index) + 1 453 | }; 454 | if (command === 'new-file') { 455 | const title = ((await engine.user.ask('Title of New Bookmark', entry.title)) || '').trim(); 456 | if (!title) { 457 | return; 458 | } 459 | o.title = title; 460 | const url = ((await engine.user.ask('URL of New Bookmark', entry.url || 'https://www.example.com')) || '').trim(); 461 | if (!url) { 462 | return; 463 | } 464 | o.url = url; 465 | } 466 | else { 467 | const title = ((await engine.user.ask('Title of New Directory', entry.title)) || '').trim(); 468 | if (title) { 469 | o.title = title; 470 | } 471 | else { 472 | return; 473 | } 474 | } 475 | engine.bookmarks.create(o).then(() => { 476 | // update both views 477 | views.update(); 478 | }, engine.notify); 479 | } 480 | else if (command === 'trash') { 481 | view.navigate('previous'); 482 | for (const entry of entries) { 483 | await engine.bookmarks.remove(entry.id).catch(e => { 484 | if (entry.type === 'DIRECTORY') { 485 | if (window.confirm(`"${entry.title}" directory is not empty. Remove anyway?`)) { 486 | engine.bookmarks.remove(entry.id, true).catch(engine.notify); 487 | } 488 | } 489 | else { 490 | engine.notify(e); 491 | } 492 | }); 493 | } 494 | views.update(); 495 | } 496 | else if (command === 'mirror') { 497 | const next = (...args) => { 498 | views[view === views.left ? 'right' : 'left'].build(...args); 499 | }; 500 | if (e.shiftKey) { 501 | const dir = entries.filter(o => o.type === 'DIRECTORY').shift(); 502 | if (dir) { 503 | return next(dir.id); 504 | } 505 | } 506 | // on search pane, entries[0].id browse the directory while view.id() browse the search 507 | next(e.altKey ? entries[0].parentId : view.id(), undefined, entries.map(o => o.id)); 508 | } 509 | else if (command === 'sync') { 510 | const el = views.left.entries(false).filter(o => o.type === 'FILE'); 511 | const er = views.right.entries(false).filter(o => o.type === 'FILE'); 512 | 513 | const combined = [...el, ...er].map(o => ({ 514 | title: o.title, 515 | url: o.url 516 | })).map(o => JSON.stringify(o)).filter((s, i, l) => s && l.indexOf(s) === i).map(JSON.parse); 517 | // sync panes 518 | for (const [view, list] of [[views.left, el], [views.right, er]]) { 519 | const selected = []; 520 | for (const o of combined) { 521 | if (list.some(e => e.title === o.title && e.url === o.url) === false) { 522 | const b = { 523 | ...o, 524 | parentId: view.id() 525 | }; 526 | const node = await engine.bookmarks.create(b); 527 | selected.push(node.id); 528 | } 529 | } 530 | if (selected.length) { 531 | view.build(view.id(), undefined, selected); 532 | } 533 | } 534 | } 535 | else if (command === 'open-folder') { 536 | const next = (...args) => { 537 | views[view === views.left ? 'left' : 'right'].build(...args); 538 | }; 539 | next(entries[0].parentId, undefined, entries.map(o => o.id)); 540 | } 541 | else if (command === 'search') { 542 | const id = view.id(); 543 | let value = ''; 544 | if (e.shiftKey) { 545 | value = 'duplicates'; 546 | } 547 | else if (id.query) { 548 | value = id.query; 549 | } 550 | engine.user.ask( 551 | 'Search For:\n\nUse "duplicates" keyword to find duplicated bookmarks in the current tree)', 552 | value 553 | ).then(query => { 554 | if (query) { 555 | view.build({ 556 | id, 557 | query 558 | }); 559 | } 560 | }); 561 | } 562 | else if (command === 'sort') { 563 | const entries = view.entries(false); 564 | // sort based on 565 | const rules = (e.altKey ? await engine.user.ask( 566 | 'Sort By (link, name, date):', 567 | 'name, link' 568 | ) : 'name').split(/\s*,\s*/).filter(a => a === 'link' || a === 'name' || a === 'date'); 569 | if (rules.length === 0) { 570 | return; 571 | } 572 | 573 | const sort = list => { 574 | return list.sort((a, b) => { 575 | const compare = method => { 576 | if (method === 'name') { 577 | return ('' + a.title).localeCompare(b.title + ''); 578 | } 579 | else if (method === 'link') { 580 | return ('' + a.url).localeCompare(b.url + ''); 581 | } 582 | else if (method === 'date') { 583 | return a.dateAdded - b.dateAdded; 584 | } 585 | return 0; 586 | }; 587 | let w = 0; 588 | for (const rule of rules) { 589 | w = compare(rule); 590 | if (w !== 0) { 591 | break; 592 | } 593 | } 594 | if (e.shiftKey) { 595 | return -1 * w; 596 | } 597 | return w; 598 | }); 599 | }; 600 | const directories = sort(entries.filter(e => e.readonly === 'false' && e.type === 'DIRECTORY')); 601 | const files = sort(entries.filter(e => e.readonly === 'false' && e.type === 'FILE')); 602 | 603 | let index = 0; 604 | for (const directory of directories) { 605 | await engine.bookmarks.move(directory.id, { 606 | parentId: view.id(), 607 | index 608 | }).then(() => index += 1).catch(engine.notify); 609 | } 610 | for (const file of files) { 611 | await engine.bookmarks.move(file.id, { 612 | parentId: view.id(), 613 | index 614 | }).then(() => index += 1).catch(engine.notify); 615 | } 616 | // update both views 617 | views.update(); 618 | } 619 | else if (command === 'first' || command === 'last') { 620 | view.navigate(command); 621 | } 622 | } 623 | }; 624 | 625 | /* keyboard */ 626 | document.addEventListener('keydown', e => { 627 | // toggle between views by Tab 628 | if (e.key === 'Tab') { 629 | const view = views.active(true); 630 | e.preventDefault(); 631 | if (view) { 632 | return view.click(); 633 | } 634 | } 635 | // move to the left view with arrow key 636 | else if (e.code === 'ArrowLeft' && e.shiftKey === false && e.ctrlKey === false && e.metaKey === false) { 637 | views.left.click(); 638 | } 639 | // move to the left view with arrow key 640 | else if (e.code === 'ArrowRight' && e.shiftKey === false && e.ctrlKey === false && e.metaKey === false) { 641 | views.right.click(); 642 | } 643 | // toggle between views by Ctrl + Digit 644 | else if (e.code === 'Digit1' && (e.metaKey || e.ctrlKey)) { 645 | views.left.click(); 646 | e.preventDefault(); 647 | } 648 | else if (e.code === 'Digit2' && (e.metaKey || e.ctrlKey)) { 649 | views.right.click(); 650 | e.preventDefault(); 651 | } 652 | toolsView.command(e, command); 653 | }); 654 | // on active view change 655 | views.parent.addEventListener('change', () => { 656 | views.changed(); 657 | }); 658 | 659 | // select view on tools empty space 660 | toolsView.addEventListener('click', () => views.active().click()); 661 | 662 | // remember last state 663 | if (args.get('mode') === 'window') { 664 | const resize = () => { 665 | engine.storage.set({ 666 | 'window.left': window.screenX, 667 | 'window.top': window.screenY, 668 | 'window.width': window.outerWidth, 669 | 'window.height': window.outerHeight 670 | }); 671 | }; 672 | window.addEventListener('resize', resize); 673 | window.addEventListener('beforeunload', resize); 674 | } 675 | 676 | // styling 677 | const styling = () => engine.storage.get({ 678 | 'font-size': 13, 679 | 'font-family': 'Arial, "Helvetica Neue", Helvetica, sans-serif', 680 | 'user-styles': '', 681 | 'theme': 'default', 682 | 'views': 2, 683 | 'widths': { 684 | name: 100, 685 | added: 90, 686 | modified: 90 687 | } 688 | }).then(prefs => { 689 | if (prefs.theme === 'dark') { 690 | document.documentElement.classList.add('dark'); 691 | } 692 | else if (prefs.theme === 'light') { 693 | document.documentElement.classList.remove('dark'); 694 | } 695 | else { 696 | if (matchMedia('(prefers-color-scheme: dark)').matches) { 697 | document.documentElement.classList.add('dark'); 698 | } 699 | else { 700 | document.documentElement.classList.remove('dark'); 701 | } 702 | } 703 | 704 | document.body.dataset.views = prefs.views; 705 | document.getElementById('user-styles').textContent = ` 706 | body { 707 | font-size: ${prefs['font-size']}px; 708 | font-family: ${prefs['font-family']}; 709 | } 710 | ${prefs['user-styles']} 711 | `; 712 | views.left.style(prefs.widths); 713 | views.right.style(prefs.widths); 714 | }); 715 | styling(); 716 | engine.storage.changed(ps => { 717 | if (ps['font-size'] || ps['font-family'] || ps['user-styles'] || ps['views'] || ps['widths'] || ps['theme']) { 718 | styling(); 719 | } 720 | }); 721 | 722 | // messaging 723 | chrome.runtime.onMessage.addListener((request, sender, response) => { 724 | if (request.method === 'instance') { 725 | response(true); 726 | chrome.runtime.sendMessage({method: 'activate'}); 727 | } 728 | }); 729 | -------------------------------------------------------------------------------- /v2/firefox/data/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/128.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/16.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/19.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/256.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/32.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/38.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/48.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/512.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/64.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/dark/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/dark/128.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/light/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v2/firefox/data/icons/light/128.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/svgs/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 50 | 51 | -------------------------------------------------------------------------------- /v2/firefox/data/icons/svgs/default.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 50 | 51 | -------------------------------------------------------------------------------- /v2/firefox/data/icons/svgs/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 50 | 51 | -------------------------------------------------------------------------------- /v2/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "version": "0.2.8", 4 | "name": "Bookmarks Commander", 5 | "description": "__MSG_description__", 6 | "default_locale": "en", 7 | "permissions": [ 8 | "bookmarks", 9 | "contextMenus", 10 | "notifications", 11 | "storage", 12 | "clipboardRead", 13 | "clipboardWrite" 14 | ], 15 | "homepage_url": "https://add0n.com/bookmarks-commander.html", 16 | "background": { 17 | "scripts": [ 18 | "background.js" 19 | ] 20 | }, 21 | "icons": { 22 | "16": "data/icons/16.png", 23 | "19": "data/icons/19.png", 24 | "32": "data/icons/32.png", 25 | "38": "data/icons/38.png", 26 | "48": "data/icons/48.png", 27 | "64": "data/icons/64.png", 28 | "128": "data/icons/128.png", 29 | "256": "data/icons/256.png", 30 | "512": "data/icons/512.png" 31 | }, 32 | "browser_action": { 33 | "default_icon": "data/icons/svgs/default.svg", 34 | "theme_icons": [{ 35 | "light": "data/icons/svgs/light.svg", 36 | "dark": "data/icons/svgs/dark.svg", 37 | "size": 19 38 | }] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /v3/_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Ein Lesezeichen-Manager mit zwei Fenstern, der Sortierung, dunkles Thema, Suche und Erkennung von Duplikaten unterstützt" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "A dual-pane Norton Commander liked bookmarks manager that supports sorting, dark theme, search, and duplicate detection" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Un gestor de marcadores de doble panel que admite la clasificación, el tema oscuro, la búsqueda y la detección de duplicados" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/fr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Un gestionnaire de signets à double volet qui prend en charge le tri, les thèmes sombres, la recherche et la détection des doublons" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/it/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Un gestore di segnalibri a doppio pannello che supporta l'ordinamento, il tema scuro, la ricerca e il rilevamento dei duplicati" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "デュアルペインのノートンコマンダーは、ソート、ダークテーマ、検索、重複検出をサポートしています。" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/nl/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Een bladwijzerbeheerder met twee deelvensters die sorteren, een donker thema, zoeken en duplicaatdetectie ondersteunt" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/pl/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Dwupanelowy menedżer zakładek Norton Commander z obsługą sortowania, ciemnego motywu, wyszukiwania i wykrywania duplikatów" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/pt_BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Um gerenciador de marcadores de dois painéis que suporta classificação, tema escuro, busca e detecção de duplicatas." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/pt_PT/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Um gestor de marcadores de dois painéis que suporta a classificação, tema escuro, pesquisa e detecção de duplicados" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Двухпанельный менеджер закладок, поддерживающий сортировку, темную тему, поиск и обнаружение дубликатов." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "一个双窗格的诺顿指挥官喜欢的书签管理器,支持排序,暗主题,搜索和重复检测。" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/data/assets/bell.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/assets/bell.wav -------------------------------------------------------------------------------- /v3/data/commander/commands/default.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "name": "first", 3 | "shortcuts": [{ 4 | "keys": ["Home"], 5 | "description": "Move selection to top" 6 | }] 7 | }, { 8 | "name": "last", 9 | "shortcuts": [{ 10 | "keys": ["End"], 11 | "description": "Move selection to bottom" 12 | }] 13 | }, { 14 | "name": "copy-title", 15 | "shortcuts": [{ 16 | "keys": ["Ctrl + KeyX", "Command + KeyX"], 17 | "description": "Copy bookmark's title to the clipboard" 18 | }] 19 | }, { 20 | "name": "copy-link", 21 | "shortcuts": [{ 22 | "keys": ["Ctrl + KeyC", "Command + KeyC"], 23 | "description": "Copy bookmark's link to the clipboard" 24 | }] 25 | }, { 26 | "name": "copy-id", 27 | "shortcuts": [{ 28 | "keys": ["Ctrl + KeyI", "Command + KeyI"], 29 | "description": "Copy bookmark's internal ID to the clipboard" 30 | }] 31 | }, { 32 | "name": "duplicate", 33 | "shortcuts": [{ 34 | "keys": ["Ctrl + KeyU", "Command + KeyU"], 35 | "description": "Duplicate bookmark to the other pane" 36 | }] 37 | }, { 38 | "name": "edit-title", 39 | "shortcuts": [{ 40 | "keys": ["Ctrl + KeyE", "Command + KeyE"], 41 | "description": "Change bookmark's title" 42 | }] 43 | }, { 44 | "name": "edit-link", 45 | "shortcuts": [{ 46 | "keys": ["Ctrl + KeyL", "Command + KeyL"], 47 | "description": "Change bookmark's link" 48 | }] 49 | }, { 50 | "name": "import-tree", 51 | "shortcuts": [{ 52 | "keys": ["Ctrl + KeyP", "Command + KeyP"], 53 | "description": "Paste the current bookmark tree inside a directory or next to the current bookmark" 54 | }] 55 | }, { 56 | "name": "export-tree", 57 | "shortcuts": [{ 58 | "keys": ["Ctrl + KeyY", "Command + KeyY"], 59 | "description": "Copy the selected bookmarks to the clipboard" 60 | }, { 61 | "keys": ["Ctrl + Shift + KeyY", "Command + Shift + KeyY"], 62 | "description": ["Export selected bookmarks to a JSON file"] 63 | }] 64 | }, { 65 | "name": "new-file", 66 | "shortcuts": [{ 67 | "keys": ["Ctrl + KeyB", "Command + KeyB"], 68 | "description": "Create a new bookmark" 69 | }] 70 | }, { 71 | "name": "new-directory", 72 | "shortcuts": [{ 73 | "keys": ["Ctrl + KeyD", "Command + KeyD"], 74 | "description": "Create a new empty directory" 75 | }] 76 | }, { 77 | "name": "move-left", 78 | "shortcuts": [{ 79 | "keys": ["Ctrl + ArrowLeft", "Command + ArrowLeft"], 80 | "description": "Move selected bookmarks to the left pane" 81 | }] 82 | }, { 83 | "name": "move-right", 84 | "shortcuts": [{ 85 | "keys": ["Ctrl + ArrowRight", "Command + ArrowRight"], 86 | "description": "Move the selected bookmarks to the right pane" 87 | }] 88 | }, { 89 | "name": "move-top", 90 | "shortcuts": [{ 91 | "keys": ["Alt + Home"], 92 | "description": "Move to the top of the list" 93 | }] 94 | }, { 95 | "name": "move-up", 96 | "shortcuts": [{ 97 | "keys": ["Alt + ArrowUp"], 98 | "description": "Move one-level up" 99 | }] 100 | }, { 101 | "name": "move-down", 102 | "shortcuts": [{ 103 | "keys": ["Alt + ArrowDown"], 104 | "description": "Move one-level down" 105 | }] 106 | }, { 107 | "name": "move-bottom", 108 | "shortcuts": [{ 109 | "keys": ["Alt + End"], 110 | "description": "Move to the end of the list" 111 | }] 112 | }, { 113 | "name": "commands", 114 | "shortcuts": [{ 115 | "keys": ["Ctrl + KeyS", "Command + KeyS"], 116 | "description": "Open commands box" 117 | }] 118 | }, { 119 | "name": "root", 120 | "shortcuts": [{ 121 | "keys": ["Ctrl + KeyO", "Command + KeyO"], 122 | "description": "Reset both panes" 123 | }, { 124 | "keys": ["Ctrl + Shift + KeyO", "Command + Shift + KeyO"], 125 | "description": "Reset only the active pane" 126 | }] 127 | }, { 128 | "name": "mirror", 129 | "shortcuts": [{ 130 | "keys": ["Alt + Shift + KeyM"], 131 | "description": "Open path folder", 132 | "name": "open-folder" 133 | }, { 134 | "keys": ["Ctrl + KeyM", "Command + KeyM"], 135 | "description": "Mirror the inactive pane" 136 | }, { 137 | "keys": ["Ctrl + Shift + KeyM", "Command + Shift + KeyM"], 138 | "description": "Navigate inactive pane into the first selected Dir" 139 | }, { 140 | "keys": ["Alt + KeyM"], 141 | "description": "Open path folder in opposite pane" 142 | }] 143 | }, { 144 | "name": "sync", 145 | "shortcuts": [{ 146 | "keys": ["Ctrl + Shift + KeyS", "Command + Shift + KeyS"], 147 | "description": "Sync bookmarks (not directories) of two panes" 148 | }] 149 | }, { 150 | "name": "trash", 151 | "shortcuts": [{ 152 | "keys": ["Ctrl + Delete", "Ctrl + Backspace", "Command + Delete", "Command + Backspace"], 153 | "description": "Delete the active bookmarks and directories" 154 | }] 155 | }, { 156 | "name": "sort", 157 | "shortcuts": [{ 158 | "keys": ["Ctrl + KeyJ", "Command + KeyJ"], 159 | "description": "Sort A-Z" 160 | }, { 161 | "keys": ["Ctrl + Shift + KeyJ", "Command + Shift + KeyJ"], 162 | "description": "Sort Z-A" 163 | }, { 164 | "keys": ["Alt + KeyJ"], 165 | "description": "Custom Sorting (A-Z)" 166 | }, { 167 | "keys": ["Alt + Shift + KeyJ"], 168 | "description": "Custom Sorting (Z-A)" 169 | }] 170 | }, { 171 | "name": "shortcuts", 172 | "shortcuts": [{ 173 | "keys": ["Ctrl + KeyH", "Command + KeyH"], 174 | "description": "View shortcuts" 175 | }] 176 | }, { 177 | "name": "search", 178 | "shortcuts": [{ 179 | "keys": ["Ctrl + KeyF", "Command + KeyF"], 180 | "description": "Search inside the active directory" 181 | }, { 182 | "keys": ["Ctrl + Shift + KeyF", "Command + Shift + KeyF"], 183 | "description": "Search for duplicates inside the active directory" 184 | }, { 185 | "keys": ["Escape"], 186 | "description": "Focus active pane" 187 | }] 188 | }] 189 | -------------------------------------------------------------------------------- /v3/data/commander/commands/vim.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "name": "select-previous", 3 | "shortcuts": [{ 4 | "keys": ["KeyJ"], 5 | "description": "Select previous" 6 | }] 7 | }, { 8 | "name": "select-next", 9 | "shortcuts": [{ 10 | "keys": ["KeyK"], 11 | "description": "Select next" 12 | }] 13 | }, { 14 | "name": "open-in-new-tab", 15 | "shortcuts": [{ 16 | "keys": ["KeyO", "Shift + KeyO", "Ctrl + KeyO", "Command + KeyO"], 17 | "description": "Open in new tab" 18 | }] 19 | }] 20 | -------------------------------------------------------------------------------- /v3/data/commander/components/directory-view.js: -------------------------------------------------------------------------------- 1 | /* global engine */ 2 | class DirectoryView extends HTMLElement { 3 | constructor() { 4 | super(); 5 | 6 | this.history = []; 7 | const shadow = this.attachShadow({ 8 | mode: 'open' 9 | }); 10 | shadow.innerHTML = ` 11 | 28 | 29 | - 30 | 31 | 32 | `; 33 | this.listView = shadow.querySelector('list-view'); 34 | this.CountElement = shadow.getElementById('count'); 35 | 36 | // events 37 | const onsubmit = e => this.emit('directory-view:submit', e.detail); 38 | this.listView.addEventListener('submit', onsubmit); 39 | this.listView.addEventListener('selection-changed', () => this.emit('directory-view:selection-changed')); 40 | this.listView.addEventListener('drop-request', e => this.emit('directory-view:drop-request', e.detail)); 41 | this.listView.addEventListener('command', e => this.emit('directory-view:command', e.detail)); 42 | 43 | this.pathView = shadow.querySelector('path-view'); 44 | this.pathView.addEventListener('change', e => onsubmit({ 45 | detail: { 46 | entries: [{ 47 | id: e.target.value.id, 48 | type: 'DIRECTORY' 49 | }] 50 | } 51 | })); 52 | // focus the list-view element 53 | this.addEventListener('click', () => { 54 | this.listView.focus(); 55 | }); 56 | } 57 | emit(name, detail) { 58 | return this.dispatchEvent(new CustomEvent(name, { 59 | bubbles: true, 60 | detail 61 | })); 62 | } 63 | async buildPathView(id, arr) { 64 | // store path only if it is needed 65 | if (!arr) { 66 | arr = await engine.bookmarks.hierarchy(id); 67 | this.emit('directory-view:path', { 68 | id, 69 | arr 70 | }); 71 | } 72 | this.arr = arr; 73 | this.pathView.build(arr); 74 | } 75 | // if update, then selected elements are persistent 76 | async buildListView(id, update = false, selectedIDs = []) { 77 | const method = update ? 'update' : 'build'; 78 | try { 79 | // add openerId to empty "duplicates" queries 80 | if (id.query && id.query === 'duplicates') { 81 | let openerId = this.id(); 82 | if (/Firefox/.test(navigator.userAgent)) { 83 | if (typeof openerId !== 'string' || openerId.trim() === '') { 84 | openerId = engine.bookmarks.rootID; 85 | } 86 | } 87 | else if (isNaN(openerId)) { // Chrome 88 | openerId = engine.bookmarks.rootID; 89 | } 90 | id.query += ':' + openerId; 91 | } 92 | const nodes = await engine.bookmarks.children(id); 93 | this.count = this.CountElement.textContent = nodes.length; 94 | if (this.isSearch(id)) { 95 | const length = this.history.length; 96 | nodes.unshift({ 97 | title: '[..]', 98 | id: length ? this.history[length - 1] : '', 99 | openerId: id, 100 | index: -1, 101 | readonly: true 102 | }); 103 | } 104 | else if (this.isRoot(id) === false) { 105 | const parent = await engine.bookmarks.parent(id); 106 | nodes.unshift({ 107 | title: '[..]', 108 | id: parent.parentId, 109 | openerId: id, 110 | index: -1, 111 | readonly: true 112 | }); 113 | } 114 | const origin = this.isSearch(id) ? 'search' : ( 115 | this.isRoot(id) ? 'root' : 'other' 116 | ); 117 | 118 | if (method === 'build') { 119 | this.listView.build(nodes, undefined, selectedIDs, {origin}); 120 | } 121 | else { 122 | this.listView.update(nodes); 123 | } 124 | this.listView.mode({ 125 | path: this.isSearch(id) 126 | }); 127 | 128 | this.history.push(id); 129 | } 130 | catch (e) { 131 | this.listView.build(undefined, e, undefined, {origin}); 132 | console.warn(e); 133 | window.setTimeout(() => this.build(''), 2000); 134 | } 135 | } 136 | build(id, arr, selectedIDs = []) { 137 | this.emit('directory-view:update-requested'); 138 | 139 | id = id || engine.bookmarks.rootID; 140 | Promise.all([ 141 | this.buildListView(id, false, selectedIDs), 142 | this.buildPathView(id, arr) 143 | ]).then(() => { 144 | this.emit('directory-view:content-updated'); 145 | }); 146 | this._id = id; 147 | } 148 | style({ 149 | name = 200, 150 | added = 90, 151 | modified = 90 152 | }) { 153 | this.listView.style.setProperty('--name-width', name + 'px'); 154 | this.listView.style.setProperty('--added-width', added + 'px'); 155 | this.listView.style.setProperty('--modified-width', modified + 'px'); 156 | } 157 | update(id) { 158 | this.buildListView(id, true).then(() => { 159 | this.emit('directory-view:content-updated'); 160 | }); 161 | } 162 | entries(...args) { 163 | return this.listView.entries(...args); 164 | } 165 | id() { 166 | return this._id; 167 | } 168 | list() { 169 | return this.arr; 170 | } 171 | isRoot(id) { 172 | return engine.bookmarks.isRoot(id || this.id()); 173 | } 174 | isSearch(id) { 175 | return engine.bookmarks.isSearch(id || this.id()); 176 | } 177 | navigate(direction = 'forward') { 178 | if (direction === 'first' || direction === 'last') { 179 | this.listView[direction](); 180 | } 181 | else { 182 | this.listView[direction === 'forward' ? 'next' : 'previous'](); 183 | } 184 | } 185 | simulate(e) { 186 | this.listView.simulate(e); 187 | } 188 | state(command, enabled) { 189 | this.listView.state(command, enabled); 190 | } 191 | owner(name) { 192 | this.setAttribute('owner', name); 193 | this.listView.setAttribute('owner', name); 194 | this.pathView.setAttribute('owner', name); 195 | } 196 | static get observedAttributes() { 197 | return ['path']; 198 | } 199 | attributeChangedCallback(name, oldValue, newValue) { 200 | if (name === 'path') { 201 | this.build(newValue); 202 | } 203 | } 204 | } 205 | window.customElements.define('directory-view', DirectoryView); 206 | -------------------------------------------------------------------------------- /v3/data/commander/components/directory-view/path-view-test/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/commander/components/directory-view/path-view-test/index.css -------------------------------------------------------------------------------- /v3/data/commander/components/directory-view/path-view-test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

10 | one 11 | 12 |

13 |

14 | two 15 | 16 |

17 |

18 | three 19 | 20 | !!! 21 | 22 |

23 |

24 | four 25 | 26 | !!! 27 | 28 |

29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /v3/data/commander/components/directory-view/path-view-test/index.js: -------------------------------------------------------------------------------- 1 | { 2 | const view = document.getElementById('one'); 3 | view.build([ 4 | {title: 'first'}, 5 | {title: 'two'}, 6 | {title: 'three'}, 7 | {title: '1 / This is a Long Text', checked: true}, 8 | {title: '2 / This is a Long Text', checked: true}, 9 | {title: '3 / This is a Long Text'}, 10 | {title: '4 / This is a Long Text'}, 11 | {title: '5 / This is a Long Text'}, 12 | {title: '6 / This is a Long Text'}, 13 | {title: '7 / This is a Long Text'}, 14 | {title: 'This is a Long Text'}, 15 | {title: 'last'} 16 | ]); 17 | } 18 | { 19 | const view = document.getElementById('two'); 20 | view.build(); 21 | } 22 | { 23 | const view = document.getElementById('three'); 24 | view.build([ 25 | {title: 'item █'}, 26 | {title: 'item ⧉'}, 27 | {title: 'item ╬'}, 28 | {title: 'item ⧅'} 29 | ]); 30 | } 31 | { 32 | const view = document.getElementById('four'); 33 | view.build([ 34 | {title: 'one', id: { 35 | a: 1, 36 | b: 2 37 | }}, 38 | {title: 'two', id: 2}, 39 | {title: 'three', id: 3} 40 | ]); 41 | const update = () => document.getElementById('selected').textContent = JSON.stringify(view.value); 42 | view.addEventListener('change', update); 43 | update(); 44 | } 45 | -------------------------------------------------------------------------------- /v3/data/commander/components/directory-view/path-view.js: -------------------------------------------------------------------------------- 1 | class PathView extends HTMLElement { 2 | constructor() { 3 | super(); 4 | const shadow = this.attachShadow({ 5 | mode: 'open' 6 | }); 7 | shadow.innerHTML = ` 8 | 64 |
65 |
66 | 67 |
68 | `; 69 | this.content = shadow.getElementById('content'); 70 | this.entries = new Map(); 71 | } 72 | connectedCallback() { 73 | this.content.addEventListener('change', e => { 74 | if (this.entries.has(e.target)) { 75 | this.dispatchEvent(new Event('change')); 76 | } 77 | }); 78 | } 79 | build(map = [{title: 'empty'}]) { 80 | this.content.textContent = ''; 81 | const f = document.createDocumentFragment(); 82 | 83 | map.forEach(o => { 84 | const {title, id = 'pve-' + Math.random(), checked = false} = o; 85 | const label = document.createElement('label'); 86 | const span = document.createElement('span'); 87 | const input = document.createElement('input'); 88 | input.type = 'radio'; 89 | input.name = 'group'; 90 | input.id = typeof id === 'string' ? id : JSON.stringify(id); 91 | input.checked = checked; 92 | f.appendChild(input); 93 | label.title = span.textContent = title || ''; 94 | label.appendChild(span); 95 | f.appendChild(label); 96 | label.setAttribute('for', input.id); 97 | 98 | this.entries.set(input, o); 99 | }); 100 | 101 | // check 102 | if (!f.querySelector('input:checked')) { 103 | f.querySelector('input:last-of-type').checked = true; 104 | } 105 | 106 | this.content.appendChild(f); 107 | 108 | this.content.scrollLeft = this.content.scrollWidth; 109 | } 110 | get value() { 111 | const e = this.content.querySelector('input:checked'); 112 | console.log(e); 113 | return this.entries.get(e); 114 | } 115 | } 116 | window.customElements.define('path-view', PathView); 117 | -------------------------------------------------------------------------------- /v3/data/commander/components/notify-view.js: -------------------------------------------------------------------------------- 1 | class NotifyView extends HTMLElement { 2 | constructor() { 3 | super(); 4 | const shadow = this.attachShadow({ 5 | mode: 'open' 6 | }); 7 | shadow.innerHTML = ` 8 | 24 |
25 | 26 |
27 | `; 28 | } 29 | notify(message, timeout = 5000) { 30 | this.timeout = setTimeout(() => { 31 | this.classList.add('hidden'); 32 | }, timeout); 33 | this.shadowRoot.querySelector('span').textContent = message; 34 | this.classList.remove('hidden'); 35 | } 36 | } 37 | window.customElements.define('notify-view', NotifyView); 38 | -------------------------------------------------------------------------------- /v3/data/commander/components/prompt-view.js: -------------------------------------------------------------------------------- 1 | class PromptView extends HTMLElement { 2 | constructor() { 3 | super(); 4 | const shadow = this.attachShadow({ 5 | mode: 'open' 6 | }); 7 | shadow.innerHTML = ` 8 | 56 | 57 |
58 | This is a prompt message 59 | 60 | 61 |
62 | 63 | 64 |
65 |
66 |
67 | `; 68 | this.events = {}; 69 | } 70 | connectedCallback() { 71 | this.shadowRoot.querySelector('input[type=button]').addEventListener('click', () => { 72 | this.shadowRoot.querySelector('dialog').close(); 73 | }); 74 | this.addEventListener('keypress', e => e.stopPropagation()); 75 | this.addEventListener('keyup', e => e.stopPropagation()); 76 | this.addEventListener('keydown', e => e.stopPropagation()); 77 | } 78 | ask(message, value = '', history = []) { 79 | const dialog = this.shadowRoot.querySelector('dialog'); 80 | const form = this.shadowRoot.querySelector('form'); 81 | const input = this.shadowRoot.querySelector('input[type=text]'); 82 | const span = this.shadowRoot.querySelector('span'); 83 | const list = this.shadowRoot.getElementById('list'); 84 | span.textContent = message; 85 | 86 | input.value = value; 87 | list.textContent = ''; 88 | 89 | for (const s of history) { 90 | const option = document.createElement('option'); 91 | option.value = s; 92 | option.textContent = s; 93 | list.appendChild(option); 94 | } 95 | 96 | return new Promise(resolve => { 97 | const next = value => { 98 | dialog.close(); 99 | for (const c of this.events.blur || []) { 100 | c(); 101 | } 102 | setTimeout(() => resolve(value), 100); 103 | }; 104 | form.onsubmit = e => { 105 | e.preventDefault(); 106 | next(input.value); 107 | }; 108 | dialog.onclose = () => next(''); 109 | dialog.showModal(); 110 | 111 | window.setTimeout(() => { 112 | input.focus(); 113 | input.select(); 114 | }); 115 | }); 116 | } 117 | on(method, callback) { 118 | this.events[method] = this.events[method] || []; 119 | this.events[method].push(callback); 120 | } 121 | } 122 | window.customElements.define('prompt-view', PromptView); 123 | -------------------------------------------------------------------------------- /v3/data/commander/components/tools-view.js: -------------------------------------------------------------------------------- 1 | /* global engine */ 2 | class ToolsView extends HTMLElement { 3 | constructor() { 4 | super(); 5 | const shadow = this.attachShadow({ 6 | mode: 'open' 7 | }); 8 | this.shadow = shadow; 9 | shadow.innerHTML = ` 10 | 109 | 110 |
111 | Copy 112 | Title (X) 113 | Link (C) 114 | ID 115 | Dup 116 | Edit 117 | Title 118 | Link 119 | JSON 120 | Import 121 | Export (Y) 122 | New 123 | Bookmark 124 | Directory 125 | Focus 126 | Left (←) 127 | Right (→) 128 | Move 129 | Top 130 | Up 131 | Down 132 | Last 133 | Tools 134 | CMD (S) 135 | Root 136 | Mirror 137 | Sync 138 | Delete 139 | Sort (J) 140 | Help 141 |
142 | 143 | `; 144 | this.commands = []; 145 | 146 | this.shadow.getElementById('search').addEventListener('keyup', e => { 147 | if (e.key === 'Enter') { 148 | this.emit('tools-view:command', { 149 | command: 'search', 150 | query: e.target.value 151 | }); 152 | } 153 | }); 154 | this.shadow.getElementById('search').addEventListener('keydown', e => { 155 | if (e.key === 'Escape') { 156 | this.emit('tools-view:blur'); 157 | } 158 | }); 159 | this.shadow.addEventListener('click', e => { 160 | const command = e.target?.dataset?.command; 161 | if (command === 'commands') { 162 | this.command(new KeyboardEvent('keydown', { 163 | code: 'KeyS', 164 | ctrlKey: true 165 | })); 166 | } 167 | else if (command) { 168 | this.emit('tools-view:command', { 169 | command, 170 | shiftKey: e.shiftKey 171 | }); 172 | } 173 | // allow the search box to get focused 174 | else if (e.target.id === 'search') { 175 | e.stopPropagation(); 176 | } 177 | }); 178 | } 179 | emit(name, detail = {}) { 180 | return this.dispatchEvent(new CustomEvent(name, { 181 | bubbles: true, 182 | detail 183 | })); 184 | } 185 | validate(name) { 186 | if (name === 'ignore') { 187 | return -1; 188 | } 189 | // since the command does not exist, map it to sometime close to pass validation 190 | if (name === 'open-in-new-tab') { 191 | name = 'copy-link'; 192 | } 193 | else if (name === 'open-folder') { 194 | name = 'mirror'; 195 | } 196 | else if (name === 'select-previous' || name === 'select-next') { 197 | return 1; 198 | } 199 | const d = this.shadow.querySelector(`[data-command="${name}"]`); 200 | if (d) { 201 | if (d.dataset.enabled === 'false') { 202 | return 0; 203 | } 204 | else { 205 | return 1; 206 | } 207 | } 208 | // commands without buttons 209 | else if (['first', 'last'].some(s => s === name)) { 210 | return 1; 211 | } 212 | return -1; 213 | } 214 | command(e, callback = () => {}) { 215 | const parts = []; 216 | if (e.altKey) { 217 | parts.push('Alt'); 218 | } 219 | if (e.ctrlKey) { 220 | parts.push('Ctrl'); 221 | } 222 | else if (e.metaKey) { 223 | parts.push('Command'); 224 | } 225 | if (e.shiftKey) { 226 | parts.push('Shift'); 227 | } 228 | parts.push(e.code); 229 | const cmd = parts.join(' + '); 230 | let command = (() => { 231 | for (const {name, shortcuts} of this.commands) { 232 | for (const o of shortcuts) { 233 | for (const key of o.keys) { 234 | if (cmd === key) { 235 | return o.name || name; 236 | } 237 | } 238 | } 239 | } 240 | return ''; 241 | })(); 242 | 243 | const meta = e.ctrlKey || e.metaKey; 244 | 245 | if (e.code === 'KeyF' && meta) { 246 | command = 'ignore'; 247 | const o = this.shadow.getElementById('search'); 248 | 249 | if (e.shiftKey) { 250 | o.value = 'duplicates'; 251 | } 252 | o.focus(); 253 | o.select(); 254 | } 255 | // command box 256 | if (e.code === 'KeyS' && meta && e.shiftKey === false) { 257 | engine.user.ask(`Enter a Command: 258 | 259 | icon=[default|light|dark] 260 | theme=[default|dark|light] 261 | font-size=[number]px 262 | font-family=[font-name] 263 | views=[1|2] 264 | column-widths=[name]px, [added]px, [modified]px 265 | ask-before-delete=[true|false] 266 | ask-before-directory-delete=[true|false] 267 | commands-mapping=[default|vim] (vim mapping is not ready)`, '', [ 268 | 'icon=default', 269 | 'icon=light', 270 | 'icon=dark', 271 | 'theme=default', 272 | 'theme=dark', 273 | 'theme=light', 274 | 'font-family=', 275 | 'views=1', 276 | 'views=2', 277 | 'column-widths=', 278 | 'ask-before-delete=true', 279 | 'ask-before-delete=false', 280 | 'ask-before-directory-delete=true', 281 | 'ask-before-directory-delete=false', 282 | 'commands-mapping=default', 283 | 'commands-mapping=vim' 284 | ]).then(command => { 285 | if (command.startsWith('icon=')) { 286 | const path = command.replace('icon=', '') || 'default'; 287 | engine.storage.set({ 288 | 'custom-icon': path === 'default' ? '' : path 289 | }); 290 | } 291 | else if (command.startsWith('theme=')) { 292 | const path = command.replace('theme=', '') || 'default'; 293 | engine.storage.set({ 294 | 'theme': path === 'default' ? '' : path 295 | }); 296 | } 297 | else if (command.startsWith('font-size=')) { 298 | const px = /font-size=(\d+)px/.exec(command); 299 | engine.storage.set({ 300 | 'font-size': px && px.length ? px[1] : '' 301 | }); 302 | } 303 | else if (command.startsWith('font-family=')) { 304 | engine.storage.set({ 305 | 'font-family': command.replace('font-family=', '') 306 | }); 307 | } 308 | else if (command.startsWith('commands-mapping=')) { 309 | engine.storage.set({ 310 | 'commands-mapping': command.replace('commands-mapping=', '') 311 | }).then(() => location.reload()); 312 | } 313 | else if (command.startsWith('ask-before-delete=')) { 314 | engine.storage.set({ 315 | 'ask-before-delete': command.replace('ask-before-delete=', '') === 'false' ? false : true 316 | }); 317 | } 318 | else if (command.startsWith('ask-before-directory-delete=')) { 319 | engine.storage.set({ 320 | 'ask-before-directory-delete': command.replace('ask-before-directory-delete=', '') === 'false' ? false : true 321 | }); 322 | } 323 | else if (command.startsWith('views=')) { 324 | const views = Math.min(2, Math.max(1, Number(command.replace('views=', '')))); 325 | engine.storage.set({ 326 | views 327 | }); 328 | } 329 | else if (command.startsWith('column-widths=')) { 330 | const widths = [...command.replace('column-widths=', '').split(/\s*,\s*/).map(s => parseInt(s))].slice(0, 3); 331 | widths[0] = widths[0] ? Math.min(1000, Math.max(32, widths[0])) : 200; 332 | widths[1] = widths[1] ? Math.min(1000, Math.max(32, widths[1])) : 90; 333 | widths[2] = widths[2] ? Math.min(1000, Math.max(32, widths[2])) : 90; 334 | 335 | engine.storage.set({ 336 | widths: { 337 | name: widths[0], 338 | added: widths[1], 339 | modified: widths[2] 340 | } 341 | }); 342 | } 343 | }); 344 | e.preventDefault(); 345 | } 346 | if (command) { 347 | const code = this.validate(command); 348 | if (code === 0 || code === 1 || code === -1) { 349 | e.preventDefault(); 350 | e.stopPropagation(); // to prevent other modules from running 351 | } 352 | if (code === 1) { 353 | callback(command, e); 354 | } 355 | if (code === 0) { 356 | engine.notify('beep'); 357 | } 358 | } 359 | } 360 | state(command, enabled) { 361 | const e = this.shadow.querySelector(`[data-command="${command}"]`); 362 | if (e) { 363 | e.dataset.enabled = enabled; 364 | } 365 | } 366 | load(es) { 367 | this.commands = es; 368 | 369 | for (const {name, shortcuts} of es) { 370 | const e = this.shadow.querySelector(`[data-command=${name}]`) || this.shadow.getElementById(name); 371 | if (e) { 372 | e.title = shortcuts.map(({keys, description}) => { 373 | return keys.join(' or ') + ' ➝ ' + description; 374 | }).join('\n\n'); 375 | } 376 | } 377 | } 378 | } 379 | window.customElements.define('tools-view', ToolsView); 380 | -------------------------------------------------------------------------------- /v3/data/commander/engine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bookmarks = { 4 | rootID: typeof InstallTrigger !== 'undefined' ? 'root________' : '0', 5 | isRoot(id) { 6 | return id === '' || id === bookmarks.rootID; 7 | }, 8 | isSearch(id) { 9 | return Boolean(id.query); 10 | }, 11 | parent(id) { 12 | return new Promise((resolve, reject) => { 13 | chrome.bookmarks.get(id, arr => { 14 | const lastError = chrome.runtime.lastError; 15 | if (lastError) { 16 | reject(lastError); 17 | } 18 | else { 19 | resolve(arr[0]); 20 | } 21 | }); 22 | }); 23 | }, 24 | async hierarchy(id) { 25 | const cache = []; 26 | if (bookmarks.isSearch(id)) { 27 | let title = 'Search: ' + id.query; 28 | if (id.query.startsWith('duplicates')) { 29 | const openerId = id.query.replace('duplicates:', '') || bookmarks.rootID; 30 | title = `Duplicates for "${openerId}"`; 31 | } 32 | cache.push({ 33 | title, 34 | id 35 | }); 36 | } 37 | else { 38 | while (this.isRoot(id) === false) { 39 | const node = await bookmarks.parent(id); 40 | id = node.parentId; 41 | cache.unshift(node); 42 | } 43 | cache.unshift({ 44 | title: '/', 45 | id: bookmarks.rootID 46 | }); 47 | } 48 | 49 | return cache; 50 | }, 51 | children(id) { 52 | // duplicate finder 53 | if (id.query && id.query.startsWith('duplicates')) { 54 | let openerId = id.query.replace('duplicates:', '') || bookmarks.rootID; 55 | if (/Firefox/.test(navigator.userAgent)) { 56 | if (typeof openerId !== 'string' || openerId.trim() === '') { 57 | openerId = bookmarks.rootID; 58 | } 59 | } 60 | else if (isNaN(openerId)) { // Chrome 61 | openerId = bookmarks.rootID; 62 | } 63 | return new Promise(resolve => chrome.bookmarks.getSubTree(openerId, children => { 64 | const links = {}; 65 | const swipe = (root, path = '.') => { 66 | for (const node of root.children) { 67 | if ('children' in node) { 68 | swipe(node, path + '/' + (node.title || '')); 69 | } 70 | else if (node.url) { 71 | links[node.url] = links[node.url] || []; 72 | node.relativePath = path.replace('.//', '/'); 73 | links[node.url].push(node); 74 | } 75 | } 76 | }; 77 | swipe({ 78 | children 79 | }); 80 | return resolve(Object.values(links).filter(nodes => nodes.length > 1).flat()); 81 | })); 82 | } 83 | else if (id.query) { 84 | return new Promise(resolve => chrome.bookmarks.search({ 85 | query: id.query 86 | }, async nodes => { 87 | for (const node of nodes) { 88 | const arr = await bookmarks.hierarchy(node.id); 89 | arr.shift(); 90 | arr.pop(); 91 | node.relativePath = ['', ...arr, ''].map(n => n.title).join('/'); 92 | } 93 | resolve(nodes); 94 | })); 95 | } 96 | return new Promise((resolve, reject) => { 97 | chrome.bookmarks.getChildren(id, nodes => { 98 | const lastError = chrome.runtime.lastError; 99 | if (lastError) { 100 | reject(lastError); 101 | } 102 | else { 103 | // You cannot use this API to add or remove entries in the root folder. 104 | if (id === '' || id === bookmarks.rootID) { 105 | nodes.forEach(n => n.readonly = true); 106 | } 107 | resolve(nodes); 108 | } 109 | }); 110 | }); 111 | }, 112 | tree(id) { 113 | return new Promise((resolve, reject) => { 114 | chrome.bookmarks.getSubTree(id, nodes => { 115 | const lastError = chrome.runtime.lastError; 116 | if (lastError) { 117 | reject(lastError); 118 | } 119 | else { 120 | resolve(nodes); 121 | } 122 | }); 123 | }); 124 | }, 125 | update(id, o) { 126 | return new Promise((resolve, reject) => chrome.bookmarks.update(id, o, nodes => { 127 | const lastError = chrome.runtime.lastError; 128 | if (lastError) { 129 | reject(lastError); 130 | } 131 | else { 132 | resolve(nodes); 133 | } 134 | })); 135 | }, 136 | move(id, o) { 137 | return new Promise((resolve, reject) => chrome.bookmarks.move(id, o, node => { 138 | const lastError = chrome.runtime.lastError; 139 | if (lastError) { 140 | reject(lastError); 141 | } 142 | else { 143 | resolve(node); 144 | } 145 | })); 146 | }, 147 | create(o) { 148 | return new Promise((resolve, reject) => chrome.bookmarks.create(o, node => { 149 | const lastError = chrome.runtime.lastError; 150 | if (lastError) { 151 | reject(lastError); 152 | } 153 | else { 154 | resolve(node); 155 | } 156 | })); 157 | }, 158 | remove(id, recursive = false) { 159 | return new Promise((resolve, reject) => chrome.bookmarks[recursive ? 'removeTree' : 'remove'](id, () => { 160 | const lastError = chrome.runtime.lastError; 161 | if (lastError) { 162 | reject(lastError); 163 | } 164 | else { 165 | resolve(); 166 | } 167 | })); 168 | } 169 | }; 170 | 171 | const tabs = { 172 | create(o) { 173 | return new Promise(resolve => chrome.tabs.create(o, resolve)); 174 | }, 175 | update(id, o) { 176 | return new Promise(resolve => chrome.tabs.update(id, o, resolve)); 177 | }, 178 | active() { 179 | return new Promise((resolve, reject) => chrome.tabs.query({ 180 | active: true, 181 | windowType: 'normal' 182 | }, tabs => tabs.length ? resolve(tabs[0]) : reject(Error('no active tab')))); 183 | } 184 | }; 185 | 186 | const windows = { 187 | create(o) { 188 | return new Promise(resolve => chrome.windows.create(o, resolve)); 189 | } 190 | }; 191 | 192 | const storage = { 193 | get(o) { 194 | return new Promise(resolve => chrome.storage.local.get(o, resolve)); 195 | }, 196 | set(o) { 197 | return new Promise(resolve => chrome.storage.local.set(o, resolve)); 198 | }, 199 | changed(callback) { 200 | chrome.storage.onChanged.addListener(callback); 201 | } 202 | }; 203 | 204 | const ue = document.querySelector('prompt-view'); 205 | const user = { 206 | ask(msg, value, history = []) { 207 | return ue.ask(msg, value, history); 208 | }, 209 | on(name, callback) { 210 | ue.on(name, callback); 211 | } 212 | }; 213 | 214 | window.engine = { 215 | bookmarks, 216 | tabs, 217 | windows, 218 | storage, 219 | user, 220 | notify(e) { 221 | if (e === 'beep') { 222 | return (new Audio('/data/assets/bell.wav')).play(); 223 | } 224 | console.warn(e); 225 | document.querySelector('notify-view').notify(e.message || e); 226 | }, 227 | clipboard: { 228 | copy(str) { 229 | return navigator.clipboard.writeText(str).catch(() => new Promise(resolve => { 230 | document.oncopy = e => { 231 | e.clipboardData.setData('text/plain', str); 232 | e.preventDefault(); 233 | resolve(); 234 | }; 235 | document.execCommand('Copy', false, null); 236 | })); 237 | }, 238 | read() { 239 | return navigator.clipboard.readText(); 240 | } 241 | }, 242 | download(content, name, type) { 243 | const a = document.createElement('a'); 244 | const b = new Blob([content], { 245 | type 246 | }); 247 | a.href = URL.createObjectURL(b); 248 | a.download = name; 249 | a.click(); 250 | setTimeout(() => URL.revokeObjectURL(a.href), 1000); 251 | } 252 | }; 253 | -------------------------------------------------------------------------------- /v3/data/commander/images/directory-readonly.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /v3/data/commander/images/directory.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v3/data/commander/images/drop-after.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /v3/data/commander/images/drop-inside.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /v3/data/commander/images/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /v3/data/commander/images/page.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v3/data/commander/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color: #3e3e3e; 3 | --bg: #eee; 4 | --bg-light: #dadada; 5 | --bg-active: #fff; 6 | --bg-header: #f5f5f5; 7 | --bg-even-row: #f5f5f5; 8 | --bg-selected-row: #c0e7ff; 9 | --bg-indicator: #000; 10 | --bg-path: #dadada; 11 | --bg-path-active: #fff; 12 | --bg-blur: rgba(0, 0, 0, 0.6); 13 | --disabled-color: #a0a0a0; 14 | --disabled-shadow: #fcffff; 15 | --border: #cacaca; 16 | --border-alt: #e8e3e9; 17 | --selection: #8a8c8d; 18 | --bg-command: rgba(0, 0, 0, 0.15); 19 | } 20 | :root.dark { 21 | --color: #9c9c9c; 22 | --bg: #18191b; 23 | --bg-light: #35363a; 24 | --bg-even-row: rgba(255, 255, 255, 0.05); 25 | --bg-selected-row: #0f488e; 26 | --bg-indicator: #9c9c9c; 27 | --bg-header: #35363a; 28 | --bg-active: #000; 29 | --bg-path: #202124; 30 | --bg-path-active: #000; 31 | --bg-blur: rgba(255, 255, 255, 0.05); 32 | --disabled-color: #5b5b5b; 33 | --disabled-shadow: #393b42; 34 | --border: #4f5052; 35 | --border-alt: #4f5052; 36 | --selection: #000; 37 | --bg-command: rgba(255, 255, 255, 0.25); 38 | } 39 | 40 | body { 41 | font-size: 13px; 42 | font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; 43 | height: 100vh; 44 | margin: 0; 45 | display: flex; 46 | flex-direction: column; 47 | background-color: var(--bg, #eee); 48 | color: var(--color, #3e3e3e); 49 | } 50 | #directories { 51 | overflow: hidden; 52 | display: grid; 53 | grid-template-columns: 1fr 1fr; 54 | flex: 1; 55 | margin: 2px; 56 | } 57 | #directories label { 58 | display: flex; 59 | overflow: hidden; 60 | } 61 | #directories input[type=radio] { 62 | display: none; 63 | } 64 | directory-view { 65 | flex: 1; 66 | overflow: hidden; 67 | } 68 | label:not(:first-child) directory-view { 69 | margin-left: 2px; 70 | } 71 | @media screen and (max-width: 600px) { 72 | #directories { 73 | grid-template-columns: 1fr; 74 | } 75 | } 76 | 77 | prompt-view { 78 | background-color: var(--bg-blur, rgba(0, 0, 0, 0.6)); 79 | } 80 | 81 | #toast { 82 | padding: 10px; 83 | position: fixed; 84 | top: 10px; 85 | right: 10px; 86 | width: 300px; 87 | background-color: var(--bg, rgba(0, 0, 0, 0.6)); 88 | border: solid 1px var(--border); 89 | } 90 | #toast:empty { 91 | display: none; 92 | } 93 | 94 | .hidden { 95 | display: none; 96 | } 97 | 98 | body[data-views="1"] #directories { 99 | grid-template-columns: 1fr; 100 | } 101 | body[data-views="1"] label[data-id="right"] { 102 | display: none !important; 103 | } 104 | -------------------------------------------------------------------------------- /v3/data/commander/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Bookmarks Commander 8 | 9 | 10 | 11 |
12 | 16 | 20 |
21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /v3/data/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/128.png -------------------------------------------------------------------------------- /v3/data/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/16.png -------------------------------------------------------------------------------- /v3/data/icons/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/19.png -------------------------------------------------------------------------------- /v3/data/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/256.png -------------------------------------------------------------------------------- /v3/data/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/32.png -------------------------------------------------------------------------------- /v3/data/icons/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/38.png -------------------------------------------------------------------------------- /v3/data/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/48.png -------------------------------------------------------------------------------- /v3/data/icons/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/512.png -------------------------------------------------------------------------------- /v3/data/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/64.png -------------------------------------------------------------------------------- /v3/data/icons/dark/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/dark/128.png -------------------------------------------------------------------------------- /v3/data/icons/light/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian-girko/bookmarks-commander/08c858d0eb0727cc274ccc222c5695ec3d71271f/v3/data/icons/light/128.png -------------------------------------------------------------------------------- /v3/data/icons/svgs/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 50 | 51 | -------------------------------------------------------------------------------- /v3/data/icons/svgs/default.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 50 | 51 | -------------------------------------------------------------------------------- /v3/data/icons/svgs/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 50 | 51 | -------------------------------------------------------------------------------- /v3/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "version": "0.5.0", 4 | "name": "Bookmarks Commander", 5 | "description": "__MSG_description__", 6 | "default_locale": "en", 7 | "offline_enabled": true, 8 | "permissions": [ 9 | "bookmarks", 10 | "contextMenus", 11 | "favicon", 12 | "storage" 13 | ], 14 | "homepage_url": "https://add0n.com/bookmarks-commander.html", 15 | "background": { 16 | "service_worker": "worker.js" 17 | }, 18 | "icons": { 19 | "16": "/data/icons/16.png", 20 | "19": "/data/icons/19.png", 21 | "32": "/data/icons/32.png", 22 | "38": "/data/icons/38.png", 23 | "48": "/data/icons/48.png", 24 | "64": "/data/icons/64.png", 25 | "128": "/data/icons/128.png", 26 | "256": "/data/icons/256.png", 27 | "512": "/data/icons/512.png" 28 | }, 29 | "action": {}, 30 | "incognito": "split" 31 | } 32 | -------------------------------------------------------------------------------- /v3/worker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | chrome.action.onClicked.addListener(() => { 4 | chrome.storage.local.get({ 5 | 'mode': 'tab' 6 | }, async prefs => { 7 | // try to find an open instance 8 | try { 9 | await new Promise((resolve, reject) => { 10 | chrome.runtime.sendMessage({ 11 | method: 'instance' 12 | }, r => r ? resolve() : reject(chrome.runtime.lastError)); 13 | }); 14 | } 15 | catch (e) { 16 | if (prefs.mode === 'tab') { 17 | chrome.tabs.create({ 18 | url: '/data/commander/index.html' 19 | }, tab => chrome.storage.local.set({ 20 | tab: tab.id 21 | })); 22 | } 23 | else if (prefs.mode === 'window') { 24 | chrome.windows.getCurrent(win => { 25 | chrome.storage.local.get({ 26 | 'window.width': 750, 27 | 'window.height': 600, 28 | 'window.left': win.left + Math.round((win.width - 700) / 2), 29 | 'window.top': win.top + Math.round((win.height - 500) / 2) 30 | }, prefs => { 31 | chrome.windows.create({ 32 | url: '/data/commander/index.html?mode=window', 33 | width: Math.max(400, prefs['window.width']), 34 | height: Math.max(300, prefs['window.height']), 35 | left: prefs['window.left'], 36 | top: prefs['window.top'], 37 | type: 'popup' 38 | }); 39 | }); 40 | }); 41 | } 42 | } 43 | }); 44 | }); 45 | 46 | const icon = mode => chrome.action.setIcon({ 47 | path: { 48 | '16': '/data/icons/' + mode + '/128.png' 49 | } 50 | }); 51 | 52 | { 53 | const startup = () => chrome.storage.local.get({ 54 | 'mode': 'tab', 55 | 'popup.width': 800, 56 | 'popup.height': 600, 57 | 'custom-icon': '' 58 | }, prefs => { 59 | if (prefs['custom-icon']) { 60 | icon(prefs['custom-icon']); 61 | } 62 | chrome.contextMenus.create({ 63 | id: 'mode-tab', 64 | title: 'Open in Tab', 65 | contexts: ['browser_action'], 66 | type: 'radio', 67 | checked: prefs.mode === 'tab' 68 | }); 69 | chrome.contextMenus.create({ 70 | id: 'mode-window', 71 | title: 'Open in Window', 72 | contexts: ['browser_action'], 73 | type: 'radio', 74 | checked: prefs.mode === 'window' 75 | }); 76 | chrome.contextMenus.create({ 77 | id: 'mode-popup', 78 | title: 'Open in Popup', 79 | contexts: ['browser_action'], 80 | type: 'radio', 81 | checked: prefs.mode === 'popup' 82 | }); 83 | chrome.contextMenus.create({ 84 | id: 'restart', 85 | title: 'Restart Commander', 86 | contexts: ['browser_action'] 87 | }); 88 | if (prefs.mode === 'popup') { 89 | chrome.action.setPopup({ 90 | popup: `data/commander/index.html?mode=popup&width=${prefs['popup.width']}&height=${prefs['popup.height']}` 91 | }); 92 | } 93 | }); 94 | chrome.runtime.onInstalled.addListener(startup); 95 | chrome.runtime.onStartup.addListener(startup); 96 | } 97 | chrome.contextMenus.onClicked.addListener(info => { 98 | if (info.menuItemId === 'restart') { 99 | chrome.runtime.reload(); 100 | } 101 | else if (info.menuItemId.startsWith('mode-')) { 102 | chrome.storage.local.set({ 103 | mode: info.menuItemId.replace('mode-', '') 104 | }); 105 | } 106 | }); 107 | 108 | chrome.storage.onChanged.addListener(ps => { 109 | if (ps.mode) { 110 | chrome.storage.local.get({ 111 | 'popup.width': 800, 112 | 'popup.height': 600 113 | }, prefs => { 114 | chrome.action.setPopup({ 115 | popup: ps.mode.newValue === 'popup' ? 116 | `data/commander/index.html?mode=popup&width=${prefs['popup.width']}&height=${prefs['popup.height']}` : 117 | '' 118 | }); 119 | }); 120 | } 121 | if (ps['custom-icon']) { 122 | icon(ps['custom-icon'].newValue); 123 | } 124 | }); 125 | 126 | chrome.runtime.onMessage.addListener((request, sender) => { 127 | if (request.method === 'save-size') { 128 | chrome.storage.local.set(request.prefs); 129 | } 130 | else if (request.method === 'activate') { 131 | chrome.windows.update(sender.tab.windowId, { 132 | focused: true 133 | }); 134 | chrome.tabs.update(sender.tab.id, { 135 | active: true 136 | }); 137 | } 138 | }); 139 | 140 | /* FAQs & Feedback */ 141 | { 142 | const {management, runtime: {onInstalled, setUninstallURL, getManifest}, storage, tabs} = chrome; 143 | if (navigator.webdriver !== true) { 144 | const page = getManifest().homepage_url; 145 | const {name, version} = getManifest(); 146 | onInstalled.addListener(({reason, previousVersion}) => { 147 | management.getSelf(({installType}) => installType === 'normal' && storage.local.get({ 148 | 'faqs': true, 149 | 'last-update': 0 150 | }, prefs => { 151 | if (reason === 'install' || (prefs.faqs && reason === 'update')) { 152 | const doUpdate = (Date.now() - prefs['last-update']) / 1000 / 60 / 60 / 24 > 45; 153 | if (doUpdate && previousVersion !== version) { 154 | tabs.query({active: true, lastFocusedWindow: true}, tbs => tabs.create({ 155 | url: page + '?version=' + version + (previousVersion ? '&p=' + previousVersion : '') + '&type=' + reason, 156 | active: reason === 'install', 157 | ...(tbs && tbs.length && {index: tbs[0].index + 1}) 158 | })); 159 | storage.local.set({'last-update': Date.now()}); 160 | } 161 | } 162 | })); 163 | }); 164 | setUninstallURL(page + '?rd=feedback&name=' + encodeURIComponent(name) + '&version=' + version); 165 | } 166 | } 167 | --------------------------------------------------------------------------------